fix(sso): preserve admin-assigned roles across SSO sign-in (#1862)

* fix(sso): preserve admin-assigned roles across SSO sign-in

An SSO/OIDC/LDAP user's role was overwritten by the IdP-derived role on
every sign-in, so a role an admin assigned in Settings > Users reverted to
the provider default on the next login. Gate role re-sync behind an opt-in
sso_role_sync setting (default off), so manual role edits persist unless the
operator explicitly enables IdP-authoritative sync. Email continues to sync
unconditionally.

Adds human-session-only GET/PUT /api/sso/config/role-sync endpoints with a
hub-side API-token rejection in the remote proxy, a frontend toggle, a
regenerated SSO settings screenshot, and matching docs.

Closes #1851

* fix(sso): satisfy CodeQL on role-sync log and test token hashing

Route three inline API-token creation blocks through the shared
createTestApiToken helper so the sha256 hashing lives in one place, and
log the role-sync toggle as a word instead of a raw boolean. No behavior
change; resolves the CodeQL js/insecure-hashing and log-injection alerts.

* fix(sso): harden role-sync gate, name the toggle, fix screenshot

Addresses pre-merge review findings on the SSO role-sync feature:
- Make the hub-side SSO config authz guard case-insensitive to match
  Express routing semantics, closing a case-variant API-token bypass.
- Give the IdP role-sync switch an accessible name.
- Capture the SSO settings screenshot at desktop size with the scroll
  area expanded so the role-sync control is fully visible.
This commit is contained in:
Anso
2026-08-28 13:26:22 +00:00
committed by GitHub
parent cc4a6571c7
commit 7cd42699d1
16 changed files with 852 additions and 51 deletions
+3
View File
@@ -133,6 +133,9 @@ describe('API token blocked endpoints', () => {
{ method: 'put', path: '/api/sso/config/ldap', body: { enabled: true } },
{ method: 'delete', path: '/api/sso/config/ldap' },
{ method: 'post', path: '/api/sso/config/ldap/test' },
// SSO role sync configuration
{ method: 'get', path: '/api/sso/config/role-sync' },
{ method: 'put', path: '/api/sso/config/role-sync', body: { enabled: true } },
// Node management
{ method: 'post', path: '/api/nodes', body: { name: 'test', type: 'local' } },
{ method: 'put', path: '/api/nodes/1', body: { name: 'updated' } },
@@ -0,0 +1,156 @@
/**
* Hub → remote proxy coverage for SSO configuration API-token rejection.
* The remote proxy replaces incoming credentials with a node-to-node JWT,
* so destination-side rejectApiTokenScope cannot identify the original API
* token. This test proves that the hub-side guard in remoteNodeProxy blocks
* full-admin API tokens targeting /api/sso/config on remote nodes.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import http from 'http';
import request from 'supertest';
import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET } from './helpers/setupTestDb';
import jwt from 'jsonwebtoken';
import { createTestApiToken } from './helpers/apiTokenTestHelper';
import { PROXY_ROLE_HEADER } from '../services/license-headers';
let tmpDir: string;
let app: import('express').Express;
let adminToken: string;
let fullAdminApiToken: string;
let remoteNodeId: number;
interface CapturedHop {
method: string;
url: string;
roleHeader: string | undefined;
}
const capturedHops: CapturedHop[] = [];
function captureHop(req: http.IncomingMessage, into: CapturedHop[]): void {
into.push({
method: req.method ?? '',
url: req.url ?? '',
roleHeader: req.headers[PROXY_ROLE_HEADER] as string | undefined,
});
}
function createRemoteServer(): http.Server {
return http.createServer((req, res) => {
// Always serve /api/meta so the proxy can discover capabilities
if (req.url?.startsWith('/api/meta')) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ version: '0.97.1', capabilities: ['cross-node-rbac'] }));
return;
}
captureHop(req, capturedHops);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true }));
});
}
async function listen(server: http.Server): Promise<number> {
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
return (server.address() as import('net').AddressInfo).port;
}
let remoteServer: http.Server;
let remotePort: number;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
const { DatabaseService } = await import('../services/DatabaseService');
const db = DatabaseService.getInstance();
adminToken = jwt.sign({ username: 'testadmin', role: 'admin' }, TEST_JWT_SECRET, { expiresIn: '1h' });
// Create a full-admin API token via the shared test helper
const admin = db.getUserByUsername('testadmin');
fullAdminApiToken = createTestApiToken({
db: DatabaseService,
scope: 'full-admin',
userId: admin!.id,
name: `sso-config-proxy-test-${Date.now()}`,
});
// Set up the remote server
remoteServer = createRemoteServer();
remotePort = await listen(remoteServer);
remoteNodeId = db.addNode({
name: 'sso-config-remote',
type: 'remote',
mode: 'proxy',
compose_dir: '/tmp',
is_default: false,
api_url: `http://127.0.0.1:${remotePort}`,
api_token: 'remote-sso-token',
});
});
afterAll(async () => {
await new Promise<void>((resolve) => remoteServer.close(() => resolve()));
cleanupTestDb(tmpDir);
});
beforeEach(() => {
capturedHops.length = 0;
});
describe('Hub-side API-token rejection for remote SSO config', () => {
const blockedRequests: Array<{ method: 'get' | 'put'; path: string; body?: Record<string, unknown> }> = [
{ method: 'get', path: '/api/sso/config/role-sync' },
{ method: 'get', path: '/api/sso/config' },
{ method: 'put', path: '/api/sso/config/role-sync', body: { enabled: true } },
{ method: 'get', path: '/api/sso/config/ldap' },
{ method: 'put', path: '/api/sso/config/ldap', body: { enabled: true } },
// Case variants: Express routes case-insensitively, so the hub guard must
// reject them too (regression for a case-sensitive guard bypass).
{ method: 'get', path: '/api/SSO/config/role-sync' },
{ method: 'put', path: '/api/SSO/config/role-sync', body: { enabled: true } },
{ method: 'get', path: '/api/Sso/Config/Role-Sync' },
{ method: 'put', path: '/api/Sso/Config/Role-Sync', body: { enabled: true } },
];
for (const { method, path, body } of blockedRequests) {
it(`returns 403 SCOPE_DENIED for ${method.toUpperCase()} ${path}, no upstream hop`, async () => {
let req = request(app)[method](path)
.set('Authorization', `Bearer ${fullAdminApiToken}`)
.set('x-node-id', String(remoteNodeId));
if (body) req = req.send(body);
const res = await req;
expect(res.status).toBe(403);
expect(res.body.code).toBe('SCOPE_DENIED');
expect(capturedHops).toHaveLength(0);
});
}
it('Browser admin session reaches upstream for GET /api/sso/config/role-sync', async () => {
const res = await request(app)
.get('/api/sso/config/role-sync')
.set('Authorization', `Bearer ${adminToken}`)
.set('x-node-id', String(remoteNodeId));
expect(res.status).toBe(200);
const hop = capturedHops.find((h) => h.url?.includes('/sso/config/role-sync'));
expect(hop).toBeDefined();
expect(hop!.method).toBe('GET');
expect(hop!.roleHeader).toBe('admin');
});
it('Browser admin session reaches upstream for PUT /api/sso/config/role-sync', async () => {
const res = await request(app)
.put('/api/sso/config/role-sync')
.set('Authorization', `Bearer ${adminToken}`)
.set('x-node-id', String(remoteNodeId))
.send({ enabled: true });
expect(res.status).toBe(200);
const hop = capturedHops.find((h) => h.url?.includes('/sso/config/role-sync'));
expect(hop).toBeDefined();
expect(hop!.method).toBe('PUT');
expect(hop!.roleHeader).toBe('admin');
});
});
+286 -39
View File
@@ -1,10 +1,11 @@
import { describe, it, expect, beforeAll, afterAll, vi, type MockInstance } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi, type MockInstance } from 'vitest';
import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET } from './helpers/setupTestDb';
import supertest from 'supertest';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import type { Express } from 'express';
import { generateApiToken } from '../utils/apiTokenFormat';
import { createTestApiToken } from './helpers/apiTokenTestHelper';
let tmpDir: string;
let app: Express;
@@ -368,56 +369,138 @@ describe('Database migration - SSO columns', () => {
});
describe('SSO Role Sync on Re-Login', () => {
afterAll(() => {
afterEach(async () => {
// Ensure sso_role_sync is reset to default between tests so the
// enabled-case does not leak into subsequent suites.
const { DatabaseService } = await import('../services/DatabaseService');
DatabaseService.getInstance().updateGlobalSetting('sso_role_sync', '0');
vi.restoreAllMocks();
});
it('provisionUser promotes user when IdP role changes to admin', async () => {
const { SSOService } = await import('../services/SSOService');
const sso = SSOService.getInstance();
// Create a viewer
const user1 = sso.provisionUser({
authProvider: 'oidc_okta',
providerId: 'okta-role-sync-test',
preferredUsername: 'rolesync_user',
email: 'rolesync@example.com',
role: 'viewer',
});
expect(user1.role).toBe('viewer');
// Re-login with admin role from IdP
const user2 = sso.provisionUser({
authProvider: 'oidc_okta',
providerId: 'okta-role-sync-test',
preferredUsername: 'rolesync_user',
email: 'rolesync@example.com',
role: 'admin',
});
expect(user2.id).toBe(user1.id);
expect(user2.role).toBe('admin');
});
it('provisionUser demotes user when IdP role changes to viewer', async () => {
it('provisionUser preserves existing role when sso_role_sync setting is missing', async () => {
const { SSOService } = await import('../services/SSOService');
const { DatabaseService } = await import('../services/DatabaseService');
const sso = SSOService.getInstance();
const db = DatabaseService.getInstance();
// Look up user from previous test (should be admin now)
const existing = db.getUserByProviderIdentity('oidc_okta', 'okta-role-sync-test');
expect(existing).toBeDefined();
expect(existing!.role).toBe('admin');
// Spy: return settings without sso_role_sync to simulate a missing key
const realSettings = { ...db.getGlobalSettings() };
delete realSettings['sso_role_sync'];
vi.spyOn(db, 'getGlobalSettings').mockReturnValue(Object.freeze(realSettings));
// Re-login with viewer role (e.g., removed from admin group)
const user = sso.provisionUser({
// Create a viewer
const user1 = sso.provisionUser({
authProvider: 'oidc_okta',
providerId: 'okta-role-sync-test',
preferredUsername: 'rolesync_user',
email: 'rolesync@example.com',
providerId: 'okta-role-sync-missing',
preferredUsername: 'rolesync_missing',
email: 'rolesync_missing@example.com',
role: 'viewer',
});
expect(user.role).toBe('viewer');
expect(user1.role).toBe('viewer');
// Re-login with admin role from IdP - should NOT overwrite when sync is off
const user2 = sso.provisionUser({
authProvider: 'oidc_okta',
providerId: 'okta-role-sync-missing',
preferredUsername: 'rolesync_missing',
email: 'rolesync_missing_new@example.com',
role: 'admin',
});
expect(user2.id).toBe(user1.id);
expect(user2.role).toBe('viewer'); // preserved, not promoted
expect(user2.email).toBe('rolesync_missing_new@example.com'); // email still syncs
});
it('provisionUser preserves existing role when sso_role_sync is off', async () => {
const { SSOService } = await import('../services/SSOService');
const { DatabaseService } = await import('../services/DatabaseService');
const sso = SSOService.getInstance();
const db = DatabaseService.getInstance();
// Explicitly set to '0'
db.updateGlobalSetting('sso_role_sync', '0');
// Create a viewer
const user1 = sso.provisionUser({
authProvider: 'oidc_okta',
providerId: 'okta-role-sync-off',
preferredUsername: 'rolesync_off',
email: 'rolesync_off@example.com',
role: 'viewer',
});
expect(user1.role).toBe('viewer');
// Re-login with admin role - should NOT overwrite when sync is off
const user2 = sso.provisionUser({
authProvider: 'oidc_okta',
providerId: 'okta-role-sync-off',
preferredUsername: 'rolesync_off',
email: 'rolesync_off_new@example.com',
role: 'admin',
});
expect(user2.id).toBe(user1.id);
expect(user2.role).toBe('viewer'); // preserved
expect(user2.email).toBe('rolesync_off_new@example.com'); // email still syncs
});
it('provisionUser applies IdP role when sso_role_sync is enabled', async () => {
const { SSOService } = await import('../services/SSOService');
const { DatabaseService } = await import('../services/DatabaseService');
const sso = SSOService.getInstance();
const db = DatabaseService.getInstance();
// Enable sync
db.updateGlobalSetting('sso_role_sync', '1');
// Create a viewer
const user1 = sso.provisionUser({
authProvider: 'oidc_okta',
providerId: 'okta-role-sync-on',
preferredUsername: 'rolesync_on',
email: 'rolesync_on@example.com',
role: 'viewer',
});
expect(user1.role).toBe('viewer');
// Re-login with admin role - SHOULD overwrite when sync is enabled
const user2 = sso.provisionUser({
authProvider: 'oidc_okta',
providerId: 'okta-role-sync-on',
preferredUsername: 'rolesync_on',
email: 'rolesync_on_new@example.com',
role: 'admin',
});
expect(user2.id).toBe(user1.id);
expect(user2.role).toBe('admin'); // promoted
expect(user2.email).toBe('rolesync_on_new@example.com'); // email still syncs
});
it('provisionUser applies IdP role demotion when sso_role_sync is enabled', async () => {
const { SSOService } = await import('../services/SSOService');
const { DatabaseService } = await import('../services/DatabaseService');
const sso = SSOService.getInstance();
const db = DatabaseService.getInstance();
db.updateGlobalSetting('sso_role_sync', '1');
// Create an admin
const user1 = sso.provisionUser({
authProvider: 'oidc_okta',
providerId: 'okta-role-sync-demote',
preferredUsername: 'rolesync_demote',
email: 'rolesync_demote@example.com',
role: 'admin',
});
expect(user1.role).toBe('admin');
// Re-login with viewer role (removed from admin group) - SHOULD demote
const user = sso.provisionUser({
authProvider: 'oidc_okta',
providerId: 'okta-role-sync-demote',
preferredUsername: 'rolesync_demote',
email: 'rolesync_demote@example.com',
role: 'viewer',
});
expect(user.role).toBe('viewer'); // demoted
});
});
@@ -633,6 +716,170 @@ describe('SSO Config - API Token Denied', () => {
});
});
describe('SSO Role Sync Config Endpoints', () => {
let viewerToken: string;
beforeAll(async () => {
const { DatabaseService } = await import('../services/DatabaseService');
const db = DatabaseService.getInstance();
if (!db.getUserByUsername('sso_test_viewer')) {
db.addUser({ username: 'sso_test_viewer', password_hash: '$2b$10$fake', role: 'viewer' });
}
viewerToken = jwt.sign({ username: 'sso_test_viewer', role: 'viewer' }, TEST_JWT_SECRET, { expiresIn: '1h' });
});
beforeEach(async () => {
// Ensure order independence: reset to default before each test
const { DatabaseService } = await import('../services/DatabaseService');
DatabaseService.getInstance().updateGlobalSetting('sso_role_sync', '0');
});
afterEach(() => {
// Restore any spies after each test
vi.restoreAllMocks();
});
it('Administrator GET returns { enabled: false } initially', async () => {
const res = await supertest(app)
.get('/api/sso/config/role-sync')
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(200);
expect(res.body).toEqual({ enabled: false });
});
it('Administrator PUT persists and returns { success: true }', async () => {
const res = await supertest(app)
.put('/api/sso/config/role-sync')
.set('Authorization', `Bearer ${adminToken}`)
.send({ enabled: true });
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true });
});
it('Subsequent GET returns { enabled: true } after PUT', async () => {
// PUT first
await supertest(app)
.put('/api/sso/config/role-sync')
.set('Authorization', `Bearer ${adminToken}`)
.send({ enabled: true });
// Then GET
const res = await supertest(app)
.get('/api/sso/config/role-sync')
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(200);
expect(res.body).toEqual({ enabled: true });
});
it('PUT with missing body returns 400', async () => {
const res = await supertest(app)
.put('/api/sso/config/role-sync')
.set('Authorization', `Bearer ${adminToken}`)
.send({});
expect(res.status).toBe(400);
expect(res.body.error).toBe('enabled must be a boolean');
});
it('PUT with non-boolean enabled returns 400', async () => {
const res = await supertest(app)
.put('/api/sso/config/role-sync')
.set('Authorization', `Bearer ${adminToken}`)
.send({ enabled: 'yes' });
expect(res.status).toBe(400);
expect(res.body.error).toBe('enabled must be a boolean');
});
it('PUT with null enabled returns 400', async () => {
const res = await supertest(app)
.put('/api/sso/config/role-sync')
.set('Authorization', `Bearer ${adminToken}`)
.send({ enabled: null });
expect(res.status).toBe(400);
expect(res.body.error).toBe('enabled must be a boolean');
});
it('Unauthenticated GET returns 401', async () => {
const res = await supertest(app).get('/api/sso/config/role-sync');
expect(res.status).toBe(401);
});
it('Unauthenticated PUT returns 401', async () => {
const res = await supertest(app)
.put('/api/sso/config/role-sync')
.send({ enabled: true });
expect(res.status).toBe(401);
});
it('Viewer GET returns 403 ADMIN_REQUIRED', async () => {
const res = await supertest(app)
.get('/api/sso/config/role-sync')
.set('Authorization', `Bearer ${viewerToken}`);
expect(res.status).toBe(403);
expect(res.body.code).toBe('ADMIN_REQUIRED');
});
it('Viewer PUT returns 403 ADMIN_REQUIRED', async () => {
const res = await supertest(app)
.put('/api/sso/config/role-sync')
.set('Authorization', `Bearer ${viewerToken}`)
.send({ enabled: true });
expect(res.status).toBe(403);
expect(res.body.code).toBe('ADMIN_REQUIRED');
});
it('Direct API token GET returns 403 SCOPE_DENIED', async () => {
const { DatabaseService } = await import('../services/DatabaseService');
const db = DatabaseService.getInstance();
const admin = db.getUserByUsername('testadmin');
const rawToken = createTestApiToken({
db: DatabaseService,
scope: 'full-admin',
userId: admin!.id,
name: `role-sync-test-${Date.now()}`,
});
const res = await supertest(app)
.get('/api/sso/config/role-sync')
.set('Authorization', `Bearer ${rawToken}`);
expect(res.status).toBe(403);
expect(res.body.code).toBe('SCOPE_DENIED');
});
it('Direct API token PUT returns 403 SCOPE_DENIED', async () => {
const { DatabaseService } = await import('../services/DatabaseService');
const db = DatabaseService.getInstance();
const admin = db.getUserByUsername('testadmin');
const rawToken = createTestApiToken({
db: DatabaseService,
scope: 'full-admin',
userId: admin!.id,
name: `role-sync-put-test-${Date.now()}`,
});
const res = await supertest(app)
.put('/api/sso/config/role-sync')
.set('Authorization', `Bearer ${rawToken}`)
.send({ enabled: true });
expect(res.status).toBe(403);
expect(res.body.code).toBe('SCOPE_DENIED');
});
it('Database failure returns controlled 500 response on PUT', async () => {
const { DatabaseService } = await import('../services/DatabaseService');
const db = DatabaseService.getInstance();
// Spy on updateGlobalSetting (only called in the route handler, not auth
// middleware) to simulate a database write failure.
vi.spyOn(db, 'updateGlobalSetting').mockImplementation(() => {
throw new Error('DB write failure');
});
const res = await supertest(app)
.put('/api/sso/config/role-sync')
.set('Authorization', `Bearer ${adminToken}`)
.send({ enabled: true });
expect(res.status).toBe(500);
expect(res.body.error).toBe('Failed to update role-sync setting');
});
});
describe('SSO Test Connection - Custom OIDC', () => {
it('POST /api/sso/config/oidc_custom/test returns failure when not configured', async () => {
const res = await supertest(app)
+13
View File
@@ -50,6 +50,7 @@ import {
} from '../middleware/permissions';
import type { PermissionAction } from '../middleware/permissions';
import { SETTING_WRITE_PERMISSIONS } from '../routes/settings';
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
/**
* Per-request hop timing for the critical hydration GETs, kept off the Request
@@ -378,6 +379,18 @@ export function createRemoteProxyMiddleware(): RequestHandler {
return;
}
// SSO configuration routes are human-session-only. The destination-side
// rejectApiTokenScope cannot detect the original API token because this
// proxy replaces incoming credentials with a node-to-node JWT. Reject
// API-token-authenticated requests here, covering the /sso/config collection
// and all descendant paths (req.path is post-/api strip). Express matches
// routes case-insensitively, so this guard must too (the i flag).
if (/^\/sso\/config(?:\/|$)/i.test(req.path)) {
if (rejectApiTokenScope(req, res, 'API tokens cannot access SSO configuration.')) {
return;
}
}
const node = NodeRegistry.getInstance().getNode(req.nodeId);
if (!node || node.type !== 'remote') {
next();
+25
View File
@@ -41,6 +41,31 @@ ssoConfigRouter.get('/', (req: Request, res: Response): void => {
}
});
ssoConfigRouter.get('/role-sync', (req: Request, res: Response): void => {
if (rejectApiTokenScope(req, res, SSO_SCOPE_MESSAGE)) return;
if (!requireAdmin(req, res)) return;
const enabled = DatabaseService.getInstance().getGlobalSettings()['sso_role_sync'] === '1';
res.json({ enabled });
});
ssoConfigRouter.put('/role-sync', (req: Request, res: Response): void => {
if (rejectApiTokenScope(req, res, SSO_SCOPE_MESSAGE)) return;
if (!requireAdmin(req, res)) return;
const enabled = req.body?.enabled;
if (typeof enabled !== 'boolean') {
res.status(400).json({ error: 'enabled must be a boolean' });
return;
}
try {
DatabaseService.getInstance().updateGlobalSetting('sso_role_sync', enabled ? '1' : '0');
console.log(`[SSO] role-sync updated: ${enabled ? 'enabled' : 'disabled'}`);
res.json({ success: true });
} catch (error) {
console.error('[SSO] Failed to update role-sync setting:', error);
res.status(500).json({ error: 'Failed to update role-sync setting' });
}
});
ssoConfigRouter.get('/:provider', (req: Request, res: Response): void => {
if (rejectApiTokenScope(req, res, SSO_SCOPE_MESSAGE)) return;
if (!requireAdmin(req, res)) return;
+4 -1
View File
@@ -2111,7 +2111,10 @@ export class DatabaseService {
// behave); admins who want a strict absolute session ceiling can turn
// it off in Settings > Users.
stmt.run('session_sliding_refresh', '1');
stmt.run('gitops_schema_version', '1');
stmt.run('gitops_schema_version', '1');
// SSO role sync defaults off: admin-set roles persist across SSO sign-ins;
// operators who want IdP group membership to drive roles opt in via Settings > SSO.
stmt.run('sso_role_sync', '0');
// Seed the default local node if none exists
const nodeCount = (this.db.prepare('SELECT COUNT(*) as count FROM nodes').get() as any)?.count || 0;
+7 -2
View File
@@ -601,8 +601,13 @@ export class SSOService {
updates.email = params.email;
}
// Sync role from identity provider on every login
if (params.role !== existing.role) {
// Preserve the stored role by default; IdP role changes are opt-in
// (sso_role_sync). By default the role assigned at first provisioning is
// preserved so an admin's manual edit in Settings → Users survives
// subsequent sign-ins (issue #1851). When sso_role_sync is '1', the
// provider-derived role is applied on each login. Email, in contrast, is
// synced whenever it changed, regardless of sso_role_sync.
if (db.getGlobalSettings()['sso_role_sync'] === '1' && params.role !== existing.role) {
updates.role = params.role;
}
+2 -2
View File
@@ -196,7 +196,7 @@ Two SSO-specific behaviors to keep in mind:
- **Password fields are hidden when editing an SSO user.** The form shows `Password is managed by the identity provider (<provider>)` in place of the password inputs. SSO users always authenticate through their IdP.
- **Optional MFA enforcement.** Each SSO provider config exposes a `Require MFA` toggle. Off (default), SSO users are not required to enroll in TOTP. On, every SSO-provisioned user must enroll TOTP after their first successful sign-in before they can use the rest of the console.
The role assigned at provisioning is the role configured on the SSO provider (or, for LDAP, derived from group membership). After provisioning, an admin can adjust the role and add scoped permissions just like any local account.
The role assigned at provisioning is the role configured on the SSO provider (or, for LDAP, derived from group membership). After provisioning, an admin can adjust the role and add scoped permissions just like any local account. The manual role persists across later sign-ins by default; to have the identity provider reapply a role from directory membership on each login instead, enable **IdP role synchronization** in **Settings · SSO**.
To configure a provider, see [SSO Authentication](/features/sso). The tier split for provider configuration (Custom OIDC and preset providers at Community, LDAP at Admiral) is enforced separately from the rest of the user-management surface.
@@ -246,6 +246,6 @@ Entries include the acting user, IP address, HTTP method and path, response stat
The 15-minute window expires on the clock, but the failure counter only resets on a successful sign-in. If the user retries with another wrong code after the window expires, the counter is still at five and the lockout re-engages immediately. Reset the user's 2FA from the row action to clear both the enrollment and the failure counter, then ask them to sign in with their password and re-enroll TOTP from their account settings.
</Accordion>
<Accordion title="An SSO user has the wrong role assigned at provisioning">
The role assigned at first sign-in comes from the SSO provider configuration (group mapping for LDAP, claim mapping for OIDC). The user record already exists, so edit the role from **Settings · Users** for an immediate fix, and update the provider config under **Settings · SSO** to prevent the same drift on the next provisioning.
The role assigned at first sign-in comes from the SSO provider configuration (group mapping for LDAP, claim mapping for OIDC). The user record already exists, so edit the role from **Settings · Users** for an immediate fix; that role persists on later sign-ins. To have the identity provider overwrite locally set roles from directory membership on each login, enable **IdP role synchronization** in **Settings · SSO**.
</Accordion>
</AccordionGroup>
+2 -2
View File
@@ -44,7 +44,7 @@ When a user signs in via SSO for the first time, Sencho creates a local account:
- **Role** is assigned from [role mapping](#role-mapping); defaults to Viewer if no mapping matches.
- **Password** is set to an unusable placeholder. SSO users cannot sign in with the password form.
On every subsequent sign-in, the existing account is reused and the user's **email** and **role** are synced from the identity provider. Adding someone to your admin group promotes them to Admin on their next sign-in; removing them demotes them to the default role.
On every subsequent sign-in, the existing account is reused and the user's **email** is synced from the identity provider. The **role** is assigned at first login and then preserved, so a role you set manually in **Settings · Users** survives later sign-ins. To instead let the identity provider's group mapping drive the role on every login, enable **IdP role synchronization** in **Settings · SSO**. With that on, adding someone to your admin group promotes them to Admin on their next sign-in and removing them demotes them to the default role.
## Role mapping
@@ -102,7 +102,7 @@ docker compose exec sencho node dist/cli/enableLocalLogin.js
No restart is required; the next login attempt honors the restored mode. The same command is listed under **Settings → Operations → Recovery** and in [Emergency command-line recovery](/operations/emergency-cli).
<Frame>
<img src="/images/sso/sso-settings.png" alt="SSO settings panel listing the five identity providers as collapsible cards with enable / disable toggles" />
<img src="/images/sso/sso-settings.png" alt="SSO settings panel listing the five identity providers as collapsible cards with enable / disable toggles, followed by the IdP role synchronization toggle" />
</Frame>
Click a card to expand it. The footer of every expanded form has the same actions:
+1 -1
View File
@@ -176,7 +176,7 @@ By default, all SSO users are assigned the **Viewer** role. To grant Admin to sp
- SSO_OIDC_ADMIN_CLAIM_VALUE=sencho-admins
```
This tells Sencho to check the `groups` claim in the OIDC ID token. If it contains `sencho-admins`, the user gets Admin. Roles are synced on every login, so removing a user from the admin group will demote them on their next sign-in.
This tells Sencho to check the `groups` claim in the OIDC ID token. If it contains `sencho-admins`, the user gets Admin. Group mapping determines the initial role on first login; local role changes persist by default. Recurring promotion or demotion from directory membership requires enabling **IdP role synchronization** in **Settings · SSO**.
<Note>
Some providers (e.g., Okta, Zitadel) require custom scopes to include group claims in the ID token. You can configure additional scopes in the **Scopes** field in Settings → Access → SSO, or via environment variable. The default is `openid email profile`.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 150 KiB

+2 -2
View File
@@ -112,10 +112,10 @@ Sencho supports five identity providers split across tiers by delivery model:
All OIDC flows use PKCE (Proof Key for Code Exchange) and a cryptographic state parameter to prevent authorization code interception and cross-site request forgery. SSO credentials (client secrets and LDAP bind passwords) are encrypted at rest with AES-256-GCM.
When a user signs in via SSO for the first time, Sencho automatically provisions a local account with the role mapped from your identity provider's claims. SSO users cannot fall back to password authentication; their access is governed entirely by the identity provider.
When a user signs in via SSO for the first time, Sencho automatically provisions a local account with the role mapped from your identity provider's claims. That role is preserved on later sign-ins, so an administrator's manual role edit in **Settings · Users** remains authoritative by default. To have the identity provider's group mapping reapply on every login instead, enable **IdP role synchronization** in **Settings · SSO**. SSO users cannot fall back to password authentication; their access is governed by the identity provider's authentication and Sencho's role assignments.
<Frame>
<img src="/images/sso/sso-settings.png" alt="SSO settings showing all five identity provider cards" />
<img src="/images/sso/sso-settings.png" alt="SSO settings panel listing the five identity providers as collapsible cards, with the IdP role synchronization toggle" />
</Frame>
For configuration details, see [SSO & LDAP Authentication](/features/sso) or the [SSO Quickstart](/getting-started/sso-quickstart).
+1 -1
View File
@@ -91,7 +91,7 @@ Check from two places, since either alone only shows one side of provisioning.
<img src="/images/tutorials/set-up-sso/users-list-provisioned.png" alt="The Users table under Settings, Access, Users, with a row for the newly auto-provisioned SSO user showing role Viewer and today's date under Created." />
</Frame>
On every later sign-in, this same account is reused. Its email and role are re-synced from the identity provider each time, so promoting or removing someone from your directory takes effect on their next login.
On every later sign-in, this same account is reused. Its email is re-synced from the identity provider. Its role stays whatever is set on the account, so a manual role edit in **Settings · Users** persists. To have the identity provider reapply the role on each login instead, enable **IdP role synchronization** in **Settings · SSO**; then promoting or removing someone from your directory takes effect on their next login.
## If something goes wrong
+31
View File
@@ -52,6 +52,37 @@ test('resources', async ({ page }) => {
await page.screenshot({ path: path.join(DOCS_IMAGES, 'resources.png'), fullPage: true });
});
test('sso settings', async ({ page }) => {
await page.setViewportSize({ width: 1920, height: 1080 });
await loginAs(page);
await page.getByRole('button', { name: /profile/i }).click();
await page.getByRole('button', { name: 'Settings', exact: true }).click();
await page.getByText('SSO', { exact: true }).first().click();
// The role-sync switch confirms the SSO panel (admin-only) has loaded; it
// only renders once GET /sso/config/role-sync resolves, so wait for the
// switch itself, not just the adjacent label text.
const roleSyncSwitch = page.getByRole('switch', { name: 'IdP role synchronization' });
await expect(roleSyncSwitch).toBeVisible();
await roleSyncSwitch.scrollIntoViewIfNeeded();
// The SSO panel lives in a fixed-height Radix scroll area, so a plain
// fullPage capture clips content below the fold. Expand the viewport (and
// its overflow-hidden root) so the whole panel, including the role-sync
// control, is captured.
await roleSyncSwitch.evaluate((el) => {
const viewport = el.closest<HTMLElement>('[data-radix-scroll-area-viewport]');
if (!viewport) return;
viewport.style.height = 'auto';
viewport.style.overflow = 'visible';
const root = viewport.parentElement;
if (root) {
root.style.height = 'auto';
root.style.overflow = 'visible';
}
});
await page.waitForTimeout(300);
await page.screenshot({ path: path.join(DOCS_IMAGES, 'sso', 'sso-settings.png'), fullPage: true });
});
function emptyCounts() {
return {
add: 0, modify: 0, delete: 0, rename: 0, unchanged: 0,
+81
View File
@@ -422,6 +422,85 @@ function ProviderCardWithGate(props: {
type AuthMode = 'local_and_sso' | 'sso_only';
function RoleSyncToggle() {
// null = loading/unknown/unconfirmed; never present "off" as a confirmed
// state during loading or error.
const [enabled, setEnabled] = useState<boolean | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const res = await apiFetch('/sso/config/role-sync');
if (!res.ok) {
if (!cancelled) {
const data = await res.json().catch(() => null);
toast.error(data?.error || data?.message || 'Failed to load role sync setting');
}
return;
}
const data = await res.json() as { enabled: boolean };
if (!cancelled) setEnabled(data.enabled);
} catch (error: unknown) {
if (!cancelled) {
setEnabled(null);
toast.error((error as Error)?.message || 'Failed to load role sync setting');
}
}
})();
return () => { cancelled = true; };
}, []);
const handleToggle = async (next: boolean) => {
if (saving) return;
setSaving(true);
try {
const res = await apiFetch('/sso/config/role-sync', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: next }),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
toast.error(data?.error || data?.message || 'Failed to update role sync');
return;
}
setEnabled(next);
toast.success(next ? 'IdP role synchronization enabled' : 'IdP role synchronization disabled');
} catch (error: unknown) {
toast.error((error as Error)?.message || 'Failed to update role sync');
} finally {
setSaving(false);
}
};
return (
<div className="flex items-center gap-3 rounded-md border border-card-border bg-card/40 p-4">
<div className="flex flex-col flex-1 min-w-0">
<Label className="font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle">
IdP role synchronization
</Label>
<p className="text-xs text-muted-foreground">
When enabled, SSO sign-in syncs the provider&apos;s role group mapping over any role an admin assigns in Settings → Users. Disable to keep manual role edits persistent across logins.
</p>
</div>
{enabled === null ? (
<div className="flex items-center justify-center w-[60px]">
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
</div>
) : (
<TogglePill
checked={enabled}
onChange={handleToggle}
disabled={saving}
aria-label="IdP role synchronization"
/>
)}
</div>
);
}
const AUTH_MODE_OPTIONS: Array<{ value: AuthMode; label: string }> = [
{ value: 'local_and_sso', label: 'Local and SSO' },
{ value: 'sso_only', label: 'SSO only' },
@@ -647,6 +726,8 @@ export function SSOSection() {
))}
</div>
<RoleSyncToggle />
<div className="text-xs text-muted-foreground space-y-1">
<p>SSO users are automatically provisioned on first login and assigned a role based on your identity provider's group membership.</p>
<p>For OIDC providers, set the OAuth callback URL to: <code className="bg-muted px-1 rounded">{'https://<your-sencho-url>/api/auth/sso/oidc/<provider>/callback'}</code></p>
@@ -46,7 +46,7 @@ import { toast } from '@/components/ui/toast-store';
import { SSOSection } from '../SSOSection';
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
const mockedToast = toast as unknown as { error: ReturnType<typeof vi.fn> };
const mockedToast = toast as unknown as { success: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn>; };
function res(ok: boolean, body: unknown): { ok: boolean; json: () => Promise<unknown> } {
return { ok, json: () => Promise.resolve(body) };
@@ -151,3 +151,240 @@ describe('SSOSection error surfacing', () => {
expect(onSwitches[0]).toHaveTextContent('ON');
});
});
describe('SSOSection role sync toggle', () => {
// Helper: mock all the base SSO section loads with an empty provider list
function mockBaseSsoLoad(extraMock?: (path: string) => unknown) {
mockedFetch.mockImplementation((path: string) => {
if (path === '/sso/config') return Promise.resolve(res(true, []));
if (path === '/sso/auth-mode') return Promise.resolve(res(true, { authenticationMode: 'local_and_sso', localLoginEnabled: true }));
if (extraMock) {
const result = extraMock(path);
if (result !== undefined) return Promise.resolve(result) as unknown;
return Promise.resolve(res(true, {}));
}
return Promise.resolve(res(true, {}));
});
}
// The role-sync TogglePill carries aria-label="IdP role synchronization" so
// it is discoverable by role and setting name; queryByRole returns null when
// the switch is absent (the unknown/loading state).
function getRoleSyncSwitch(): Element | null {
return screen.queryByRole('switch', { name: 'IdP role synchronization' });
}
it('default-off load: toggle shows OFF and is a confirmed state', async () => {
mockBaseSsoLoad((path: string) => {
if (path === '/sso/config/role-sync') return res(true, { enabled: false });
return undefined;
});
render(<SSOSection />);
await waitFor(() => {
expect(mockedFetch).toHaveBeenCalledWith('/sso/config/role-sync');
});
await waitFor(() => {
const roleSyncToggle = getRoleSyncSwitch();
expect(roleSyncToggle).not.toBeNull();
expect(roleSyncToggle).toHaveTextContent('OFF');
expect(roleSyncToggle?.hasAttribute('disabled')).toBeFalsy();
});
});
it('enabled load: toggle shows ON', async () => {
mockBaseSsoLoad((path: string) => {
if (path === '/sso/config/role-sync') return res(true, { enabled: true });
return undefined;
});
render(<SSOSection />);
await waitFor(() => {
const onToggle = getRoleSyncSwitch();
expect(onToggle).not.toBeNull();
expect(onToggle).toHaveTextContent('ON');
});
});
it('load failure: toasts error, toggle not presented as a confirmed OFF', async () => {
mockBaseSsoLoad((path: string) => {
if (path === '/sso/config/role-sync') return Promise.reject(new Error('Network timeout'));
return undefined;
});
render(<SSOSection />);
await waitFor(() => {
expect(mockedToast.error).toHaveBeenCalledWith('Network timeout');
});
// On load failure the role-sync control stays in its unknown (null) state,
// so no role-sync switch is rendered; "off" must not appear as confirmed.
expect(getRoleSyncSwitch()).toBeNull();
});
it('load HTTP error: toasts backend message, toggle not presented as confirmed', async () => {
mockBaseSsoLoad((path: string) => {
if (path === '/sso/config/role-sync') return res(false, { error: 'Role sync unavailable' });
return undefined;
});
render(<SSOSection />);
await waitFor(() => {
expect(mockedToast.error).toHaveBeenCalledWith('Role sync unavailable');
});
expect(getRoleSyncSwitch()).toBeNull();
});
it('save success: PUT sends { enabled: true }, toast.success shown', async () => {
const user = userEvent.setup();
let putBody: unknown;
mockBaseSsoLoad((path: string) => {
if (path === '/sso/config/role-sync') return res(true, { enabled: false });
return undefined;
});
mockedFetch.mockImplementation((path: string, opts?: { method?: string; body?: string }) => {
if (path === '/sso/config') return Promise.resolve(res(true, []));
if (path === '/sso/auth-mode') return Promise.resolve(res(true, { authenticationMode: 'local_and_sso', localLoginEnabled: true }));
if (path === '/sso/config/role-sync' && (!opts?.method || opts?.method === 'GET')) return Promise.resolve(res(true, { enabled: false }));
if (path === '/sso/config/role-sync' && opts?.method === 'PUT') {
putBody = opts.body;
return Promise.resolve(res(true, { success: true }));
}
return Promise.resolve(res(true, {}));
});
render(<SSOSection />);
await waitFor(() => {
expect(mockedFetch).toHaveBeenCalledWith('/sso/config/role-sync');
});
// Wait for the toggle to load, then click it
await waitFor(() => {
expect(getRoleSyncSwitch()).not.toBeNull();
});
await user.click(getRoleSyncSwitch()!);
await waitFor(() => {
expect(mockedToast.success).toHaveBeenCalledWith('IdP role synchronization enabled');
});
// Verify exact payload
expect(JSON.parse(putBody as string)).toEqual({ enabled: true });
});
it('save failure: reverts to last confirmed value, toast.error shown, control re-enabled', async () => {
const user = userEvent.setup();
mockBaseSsoLoad((path: string) => {
if (path === '/sso/config/role-sync') return res(true, { enabled: false });
return undefined;
});
mockedFetch.mockImplementation((path: string, opts?: { method?: string }) => {
if (path === '/sso/config') return Promise.resolve(res(true, []));
if (path === '/sso/auth-mode') return Promise.resolve(res(true, { authenticationMode: 'local_and_sso', localLoginEnabled: true }));
if (path === '/sso/config/role-sync' && (!opts?.method || opts?.method === 'GET')) return Promise.resolve(res(true, { enabled: false }));
if (path === '/sso/config/role-sync' && opts?.method === 'PUT') return Promise.reject(new Error('Save failed'));
return Promise.resolve(res(true, {}));
});
render(<SSOSection />);
await waitFor(() => {
expect(getRoleSyncSwitch()).not.toBeNull();
});
await user.click(getRoleSyncSwitch()!);
await waitFor(() => {
expect(mockedToast.error).toHaveBeenCalledWith('Save failed');
});
// Should still show OFF (last confirmed value), not ON, and be re-enabled
await waitFor(() => {
const roleSyncSwitch = getRoleSyncSwitch();
expect(roleSyncSwitch).not.toBeNull();
expect(roleSyncSwitch).toHaveTextContent('OFF');
expect(roleSyncSwitch?.hasAttribute('disabled')).toBeFalsy();
});
});
it('save HTTP error: toasts backend message, reverts to last confirmed value', async () => {
const user = userEvent.setup();
mockBaseSsoLoad((path: string) => {
if (path === '/sso/config/role-sync') return res(true, { enabled: false });
return undefined;
});
mockedFetch.mockImplementation((path: string, opts?: { method?: string; body?: string }) => {
if (path === '/sso/config') return Promise.resolve(res(true, []));
if (path === '/sso/auth-mode') return Promise.resolve(res(true, { authenticationMode: 'local_and_sso', localLoginEnabled: true }));
if (path === '/sso/config/role-sync' && (!opts?.method || opts?.method === 'GET')) return Promise.resolve(res(true, { enabled: false }));
if (path === '/sso/config/role-sync' && opts?.method === 'PUT') return Promise.resolve(res(false, { error: 'Save rejected' }));
return Promise.resolve(res(true, {}));
});
render(<SSOSection />);
await waitFor(() => {
expect(getRoleSyncSwitch()).not.toBeNull();
});
await user.click(getRoleSyncSwitch()!);
await waitFor(() => {
expect(mockedToast.error).toHaveBeenCalledWith('Save rejected');
});
await waitFor(() => {
const roleSyncSwitch = getRoleSyncSwitch();
expect(roleSyncSwitch).not.toBeNull();
expect(roleSyncSwitch).toHaveTextContent('OFF');
expect(roleSyncSwitch?.hasAttribute('disabled')).toBeFalsy();
});
});
it('active-instance targeting: role-sync apiFetch calls do not pass localOnly', async () => {
mockBaseSsoLoad((path: string) => {
if (path === '/sso/config/role-sync') return res(true, { enabled: false });
return undefined;
});
render(<SSOSection />);
await waitFor(() => {
// Find the role-sync GET call
const calls = mockedFetch.mock.calls;
const roleSyncCalls = calls.filter(([path]) => path === '/sso/config/role-sync');
expect(roleSyncCalls.length).toBeGreaterThan(0);
// Assert no localOnly: true in any role-sync call (opts may be undefined
// on the mount GET, which is fine: the point is it never targets the hub)
for (const [, opts] of roleSyncCalls) {
const rest = opts as { localOnly?: boolean } | undefined;
expect(rest?.localOnly).not.toBe(true);
}
});
});
it('exact payload: PUT sends only { enabled: boolean }', async () => {
const user = userEvent.setup();
let putBody: string | undefined;
mockBaseSsoLoad((path: string) => {
if (path === '/sso/config/role-sync') return res(true, { enabled: true });
return undefined;
});
mockedFetch.mockImplementation((path: string, opts?: { method?: string; body?: string }) => {
if (path === '/sso/config') return Promise.resolve(res(true, []));
if (path === '/sso/auth-mode') return Promise.resolve(res(true, { authenticationMode: 'local_and_sso', localLoginEnabled: true }));
if (path === '/sso/config/role-sync' && (!opts?.method || opts?.method === 'GET')) return Promise.resolve(res(true, { enabled: true }));
if (path === '/sso/config/role-sync' && opts?.method === 'PUT') {
putBody = opts.body;
return Promise.resolve(res(true, { success: true }));
}
return Promise.resolve(res(true, {}));
});
render(<SSOSection />);
await waitFor(() => {
expect(mockedFetch).toHaveBeenCalledWith('/sso/config/role-sync');
});
// Wait for the ON toggle to load, then click to turn off
await waitFor(() => {
expect(getRoleSyncSwitch()).not.toBeNull();
});
await user.click(getRoleSyncSwitch()!);
await waitFor(() => {
expect(putBody).toBeDefined();
expect(JSON.parse(putBody as string)).toEqual({ enabled: false });
});
});
});