diff --git a/backend/src/__tests__/api-tokens.test.ts b/backend/src/__tests__/api-tokens.test.ts index 568e545a..57b4ce26 100644 --- a/backend/src/__tests__/api-tokens.test.ts +++ b/backend/src/__tests__/api-tokens.test.ts @@ -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' } }, diff --git a/backend/src/__tests__/proxy-sso-config-authz.test.ts b/backend/src/__tests__/proxy-sso-config-authz.test.ts new file mode 100644 index 00000000..7b78a099 --- /dev/null +++ b/backend/src/__tests__/proxy-sso-config-authz.test.ts @@ -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 { + await new Promise((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((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 }> = [ + { 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'); + }); +}); diff --git a/backend/src/__tests__/sso.test.ts b/backend/src/__tests__/sso.test.ts index 8ddbedc4..3087c5bf 100644 --- a/backend/src/__tests__/sso.test.ts +++ b/backend/src/__tests__/sso.test.ts @@ -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) diff --git a/backend/src/proxy/remoteNodeProxy.ts b/backend/src/proxy/remoteNodeProxy.ts index 0d2589db..4f14cfba 100644 --- a/backend/src/proxy/remoteNodeProxy.ts +++ b/backend/src/proxy/remoteNodeProxy.ts @@ -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(); diff --git a/backend/src/routes/ssoConfig.ts b/backend/src/routes/ssoConfig.ts index d0c13f1f..585b7f7c 100644 --- a/backend/src/routes/ssoConfig.ts +++ b/backend/src/routes/ssoConfig.ts @@ -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; diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index eac641dd..1d52284d 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -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; diff --git a/backend/src/services/SSOService.ts b/backend/src/services/SSOService.ts index 34e56b93..1c053153 100644 --- a/backend/src/services/SSOService.ts +++ b/backend/src/services/SSOService.ts @@ -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; } diff --git a/docs/features/rbac.mdx b/docs/features/rbac.mdx index 573a0d8b..588ed274 100644 --- a/docs/features/rbac.mdx +++ b/docs/features/rbac.mdx @@ -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 ()` 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. - 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**. diff --git a/docs/features/sso.mdx b/docs/features/sso.mdx index ee50c3a3..f569bac3 100644 --- a/docs/features/sso.mdx +++ b/docs/features/sso.mdx @@ -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). - SSO settings panel listing the five identity providers as collapsible cards with enable / disable toggles + SSO settings panel listing the five identity providers as collapsible cards with enable / disable toggles, followed by the IdP role synchronization toggle Click a card to expand it. The footer of every expanded form has the same actions: diff --git a/docs/getting-started/sso-quickstart.mdx b/docs/getting-started/sso-quickstart.mdx index 36fdca11..8c9d652c 100644 --- a/docs/getting-started/sso-quickstart.mdx +++ b/docs/getting-started/sso-quickstart.mdx @@ -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**. 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`. diff --git a/docs/images/sso/sso-settings.png b/docs/images/sso/sso-settings.png index 3670f653..ece9d499 100644 Binary files a/docs/images/sso/sso-settings.png and b/docs/images/sso/sso-settings.png differ diff --git a/docs/reference/security.mdx b/docs/reference/security.mdx index d0713267..d8694fbb 100644 --- a/docs/reference/security.mdx +++ b/docs/reference/security.mdx @@ -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. - SSO settings showing all five identity provider cards + SSO settings panel listing the five identity providers as collapsible cards, with the IdP role synchronization toggle For configuration details, see [SSO & LDAP Authentication](/features/sso) or the [SSO Quickstart](/getting-started/sso-quickstart). diff --git a/docs/tutorials/set-up-sso.mdx b/docs/tutorials/set-up-sso.mdx index 665b4dd1..a8d2cbdb 100644 --- a/docs/tutorials/set-up-sso.mdx +++ b/docs/tutorials/set-up-sso.mdx @@ -91,7 +91,7 @@ Check from two places, since either alone only shows one side of provisioning. 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. -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 diff --git a/e2e/screenshots.spec.ts b/e2e/screenshots.spec.ts index 0ce17bac..be50606a 100644 --- a/e2e/screenshots.spec.ts +++ b/e2e/screenshots.spec.ts @@ -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('[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, diff --git a/frontend/src/components/SSOSection.tsx b/frontend/src/components/SSOSection.tsx index 925c586b..b8522b97 100644 --- a/frontend/src/components/SSOSection.tsx +++ b/frontend/src/components/SSOSection.tsx @@ -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(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 ( +
+
+ +

+ When enabled, SSO sign-in syncs the provider's role group mapping over any role an admin assigns in Settings → Users. Disable to keep manual role edits persistent across logins. +

+
+ {enabled === null ? ( +
+ +
+ ) : ( + + )} +
+ ); +} + 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() { ))} + +

SSO users are automatically provisioned on first login and assigned a role based on your identity provider's group membership.

For OIDC providers, set the OAuth callback URL to: {'https:///api/auth/sso/oidc//callback'}

diff --git a/frontend/src/components/__tests__/SSOSection.test.tsx b/frontend/src/components/__tests__/SSOSection.test.tsx index 8c9f5eef..73a2db33 100644 --- a/frontend/src/components/__tests__/SSOSection.test.tsx +++ b/frontend/src/components/__tests__/SSOSection.test.tsx @@ -46,7 +46,7 @@ import { toast } from '@/components/ui/toast-store'; import { SSOSection } from '../SSOSection'; const mockedFetch = apiFetch as unknown as ReturnType; -const mockedToast = toast as unknown as { error: ReturnType }; +const mockedToast = toast as unknown as { success: ReturnType; error: ReturnType; }; function res(ok: boolean, body: unknown): { ok: boolean; json: () => Promise } { 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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 }); + }); + }); +});