diff --git a/backend/src/__tests__/sso.test.ts b/backend/src/__tests__/sso.test.ts index 3087c5bf..3094197d 100644 --- a/backend/src/__tests__/sso.test.ts +++ b/backend/src/__tests__/sso.test.ts @@ -502,6 +502,46 @@ describe('SSO Role Sync on Re-Login', () => { }); expect(user.role).toBe('viewer'); // demoted }); + + it.each([ + ['deployer', 'viewer', 'admin'], + ['node-admin', 'admin', 'viewer'], + ['auditor', 'viewer', 'admin'], + ])('preserves granular %s role when sso_role_sync is enabled (%s -> %s IdP)', async (granularRole, firstIdp, secondIdp) => { + 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'); + const providerId = `okta-granular-${granularRole}`; + + // First provisioning applies the IdP-derived first role + const first = sso.provisionUser({ + authProvider: 'oidc_okta', + providerId, + preferredUsername: `granular_${granularRole}`, + email: `granular_${granularRole}@example.com`, + role: firstIdp as 'admin' | 'viewer', + }); + expect(first.role).toBe(firstIdp); + + // Admin assigns a granular role + db.updateUser(first.id, { role: granularRole }); + const afterSeed = db.getUser(first.id); + expect(afterSeed!.role).toBe(granularRole); + + // Re-login with opposite coarse IdP role; granular must survive + const second = sso.provisionUser({ + authProvider: 'oidc_okta', + providerId, + preferredUsername: `granular_${granularRole}`, + email: `granular_${granularRole}_new@example.com`, + role: secondIdp as 'admin' | 'viewer', + }); + expect(second.id).toBe(first.id); + expect(second.role).toBe(granularRole); // preserved, not overwritten + expect(second.email).toBe(`granular_${granularRole}_new@example.com`); // email syncs independently + }); }); describe('LDAP Filter Escaping', () => { diff --git a/backend/src/services/SSOService.ts b/backend/src/services/SSOService.ts index 1c053153..b40b9a37 100644 --- a/backend/src/services/SSOService.ts +++ b/backend/src/services/SSOService.ts @@ -601,13 +601,16 @@ export class SSOService { updates.email = params.email; } - // 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) { + // Role sync: the IdP can only express admin or viewer. When + // sso_role_sync is '1', sync only moves a user between those + // two roles. Granular roles (deployer, node-admin, auditor) + // are never overwritten because the IdP cannot express them; + // they remain manually authoritative. Email syncs independently. + if ( + db.getGlobalSettings()['sso_role_sync'] === '1' && + params.role !== existing.role && + (existing.role === 'admin' || existing.role === 'viewer') + ) { updates.role = params.role; } diff --git a/docs/features/rbac.mdx b/docs/features/rbac.mdx index 588ed274..5d686432 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 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**. +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. When IdP role synchronization is enabled, the stored role is updated between Admin and Viewer on each sign-in; granular roles (deployer, node-admin, auditor) remain manually authoritative and are never overwritten by an IdP-driven change. 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. diff --git a/docs/features/sso.mdx b/docs/features/sso.mdx index f569bac3..a775ffb9 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** 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. +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. The IdP can only express Admin or Viewer, so synchronization applies only while the stored role is one of those two; granular roles (deployer, node-admin, auditor) remain manually authoritative and are never overwritten by an IdP-driven change. ## Role mapping diff --git a/docs/getting-started/sso-quickstart.mdx b/docs/getting-started/sso-quickstart.mdx index 8c9d652c..dbb5f3d9 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. 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**. +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**. With that on, only Admin and Viewer are synchronized; granular roles (deployer, node-admin, auditor) remain manually authoritative. 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/frontend/src/components/SSOSection.tsx b/frontend/src/components/SSOSection.tsx index 4a326dad..ba716c1f 100644 --- a/frontend/src/components/SSOSection.tsx +++ b/frontend/src/components/SSOSection.tsx @@ -484,7 +484,7 @@ function RoleSyncToggle() { IdP role synchronization

- 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. + When enabled, SSO sign-in syncs the provider's role group mapping over any role an admin assigns in Settings → Users. The IdP can only express Admin or Viewer, so synchronization only applies while the stored role is one of those two; granular roles (deployer, node-admin, auditor) remain manually authoritative. Disable to keep manual role edits persistent across logins.

{enabled === null ? ( diff --git a/frontend/src/components/__tests__/SSOSection.test.tsx b/frontend/src/components/__tests__/SSOSection.test.tsx index c7d9b0e6..ad785ba6 100644 --- a/frontend/src/components/__tests__/SSOSection.test.tsx +++ b/frontend/src/components/__tests__/SSOSection.test.tsx @@ -381,4 +381,16 @@ describe('SSOSection role sync toggle', () => { expect(JSON.parse(putBody as string)).toEqual({ enabled: false }); }); }); + + it('helper copy explains the narrowed sync behavior (only admin/viewer, granular preserved)', async () => { + mockBaseSsoLoad(); + render(); + await waitFor(() => { + expect(getRoleSyncSwitch()).not.toBeNull(); + }); + const label = screen.getByText(/IdP role synchronization/); + expect(label).toBeInTheDocument(); + // Confirm the revised explanation is present: granular roles stay manual + expect(screen.getByText(/granular roles \(deployer, node-admin, auditor\) remain manually authoritative/i)).toBeInTheDocument(); + }); });