diff --git a/apps/docs/content/docs/platform/enterprise/scim.mdx b/apps/docs/content/docs/platform/enterprise/scim/index.mdx similarity index 98% rename from apps/docs/content/docs/platform/enterprise/scim.mdx rename to apps/docs/content/docs/platform/enterprise/scim/index.mdx index fbab49fadcf..f6bba22897b 100644 --- a/apps/docs/content/docs/platform/enterprise/scim.mdx +++ b/apps/docs/content/docs/platform/enterprise/scim/index.mdx @@ -66,12 +66,14 @@ Two tokens can be active at a time, so you can rotate without downtime: issue th +For a step-by-step walkthrough, see [Okta provisioning](/platform/enterprise/scim/okta). + If you use OIDC for sign-in, create a separate provisioning integration: Okta cannot add SCIM to a custom OIDC app. In the Okta Integration Network catalog, add **SCIM 2.0 Test App (Header Auth)** for a private integration. See [Okta's setup guide](https://developer.okta.com/docs/guides/scim-provisioning-integration-connect/main/). In that app, open **Provisioning → Integration → Configure API Integration**, enable API integration, and enter: -- **SCIM 2.0 Base Url**: `https:///api/scim/v2` -- **API Token**: your Sim token +- **Base URL**: `https:///api/scim/v2` +- **API Token**: `Bearer ` (include `Bearer` and a space) Select **Test API Credentials**, then save. Under **Provisioning → To App**, enable Create Users, Update User Attributes, and Deactivate Users. Assign a test user first, then use **Push Groups** for groups you want to map in Sim. Keep assignment groups separate from groups you push, as required by Okta. diff --git a/apps/docs/content/docs/platform/enterprise/scim/meta.json b/apps/docs/content/docs/platform/enterprise/scim/meta.json new file mode 100644 index 00000000000..d392d2f1c8d --- /dev/null +++ b/apps/docs/content/docs/platform/enterprise/scim/meta.json @@ -0,0 +1,5 @@ +{ + "title": "Directory provisioning", + "pages": ["index", "okta"], + "defaultOpen": false +} diff --git a/apps/docs/content/docs/platform/enterprise/scim/okta.mdx b/apps/docs/content/docs/platform/enterprise/scim/okta.mdx new file mode 100644 index 00000000000..1bbba4218cb --- /dev/null +++ b/apps/docs/content/docs/platform/enterprise/scim/okta.mdx @@ -0,0 +1,108 @@ +--- +title: Okta provisioning +description: Connect a private Okta SCIM integration to Sim and verify user and group provisioning +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' + +Use an Okta SCIM integration to create, update, and deactivate Sim members. This guide covers provisioning; configure [single sign-on](/platform/enterprise/sso) separately for authentication. + +## Before you start + +- An Enterprise organization in Sim, with an owner or administrator who can manage provisioning. +- Each user email domain [verified in Sim](/platform/enterprise/verified-domains). +- An Okta administrator account with access to application provisioning. +- A Sim deployment reachable by Okta over HTTPS. For self-hosted deployments, use its public URL and apply the [SCIM rollout requirements](/platform/enterprise/scim#deployment-and-upgrades). + +Start with a test user and a dedicated group before assigning your organization. Provisioned members use a seat; they receive workspace access only through a mapping or an existing grant. + +## Connect Okta to Sim + + + + +### Enable provisioning in Sim + +Open **Settings → Organization → Single sign-on → Provisioning** and turn on **Enable directory provisioning**. Copy the **Base URL** from **Connection**. Under **Tokens**, choose an expiry and select **Issue token**. Copy the token before closing its dialog; Sim shows it once. + + + +### Add the Okta integration + +In the Okta Admin Console, open **Applications and Resources → Applications → Browse App Catalog**. Search for **SCIM 2.0 Test App (Header Auth)** and select **Add Integration**. Give the app a recognizable name, such as **Sim provisioning**. + +For a provisioning-only app, hide its application icon from users and turn off automatic sign-in from the landing page. On **Sign-On Options**, select **Secure Web Authentication**, use your Sim HTTPS login URL, and set **Application username format** to **Email**. Complete the app setup. These template settings do not configure Sim SSO. + + + Okta cannot add SCIM to a custom OIDC app. Keep this provisioning app alongside your OIDC sign-in app. For an existing custom SAML or SWA app, use [Okta's custom-app SCIM instructions](https://help.okta.com/en-us/Content/Topics/apps/apps_app_integration_wizard_scim.htm); those connection fields differ from the catalog template below. + + + + +### Configure API integration + +In the new Okta app, open **Provisioning → Integration → Configure API Integration** and select **Enable API integration**. + +| Field | Value | +| --- | --- | +| Base URL | The URL copied from Sim, ending in `/api/scim/v2` | +| API Token | `Bearer ` — include `Bearer` and a space before the token | + +Select **Test API Credentials** and save after the test succeeds. A successful connection test checks connectivity and authentication; continue with a test assignment to verify provisioning. + +The Header Auth template sends this field as the complete `Authorization` header. Entering only the token produces **A bearer token is required**. + +![Okta API integration with a successful credential test; the test deployment URL is redacted](/static/enterprise/okta/api-integration.png) + + + +### Enable provisioning actions + +Under **Provisioning → To App**, select **Edit**, enable **Create Users**, **Update User Attributes**, and **Deactivate Users**, then save. Leave **Sync Password** disabled: Sim does not synchronize passwords through SCIM. + +Use an email address from a verified Sim domain for the application username and primary email. Review the attribute mappings if your Okta usernames differ from users' email addresses. + +![Okta provisioning actions with Create Users, Update User Attributes, and Deactivate Users enabled, and Sync Password disabled](/static/enterprise/okta/provisioning-actions.png) + + + + +This uses Okta's [private integration template](https://developer.okta.com/docs/guides/scim-provisioning-integration-connect/main/). It does not make the integration a published or certified Okta Integration Network application. + +## Verify a user lifecycle + +Assign one test user to the app from **Assignments**. In Sim, confirm that the account appears in the organization and that **Provisioning → Activity** shows successful requests. Update the user's **Display name** in Okta and confirm it in Sim. Sim uses the mapped `displayName`, falling back to the structured name when it is omitted. + +Unassign the test user and check that Sim marks them as deactivated. Reassign the same user and confirm that the existing member becomes active again. If the user belongs to a pushed group, remove them from that group and confirm the downstream removal **before** unassigning the app; see [Okta's offboarding order](https://help.okta.com/en-us/Content/Topics/users-groups-profiles/app-assignments-group-push.htm). + +Okta deactivates users over SCIM; it does not send a SCIM DELETE. Sim suspends access while retaining the user's organization membership, ownership, and seat. See [deactivation behavior](/platform/enterprise/scim#what-it-does) before using unassignment for offboarding. + +## Push groups and map access + +Use separate groups for app assignment and Group Push. Okta does not support using the same group for both purposes. Assign the users to the app first. Under **Push Groups → Find groups by name**, select the group, leave **Push group memberships immediately** enabled, and save with **Create Group** selected for a new downstream group. + +![Okta Group Push selecting an engineering group and creating its downstream group in Sim](/static/enterprise/okta/group-push.png) + +Once the group appears under **Single sign-on → Provisioning → Group mappings** in Sim, map it to a workspace, permission group, or the organization admin role. Workspace access requires a workspace mapping. If name matching is enabled, Sim can automatically map matching permission groups. + +Use a workspace mapping for the first test. Confirm that the member gains access, then remove them from the pushed group while keeping their app assignment. Confirm that the directory grant is withdrawn. Add them back and verify access is restored without creating another account. + +With **Lock managed membership** enabled, manage access for provisioned members through directory groups; manual invitations and grants are blocked. Read [how access is withdrawn](/platform/enterprise/scim#how-access-is-withdrawn) before mapping groups that overlap with existing manual access. + +## Rotate the token + +Issue a replacement token in Sim while the current token remains active. In Okta, edit **Provisioning → Integration** and replace **API Token** with `Bearer `. Test the credentials, save, and confirm a successful provisioning request before revoking the old token in Sim. Sim allows two active tokens so rotation can overlap. + +## Troubleshooting + +| Symptom | Check | +| --- | --- | +| Credential test fails | Include `Bearer ` before the token. Use the complete HTTPS base URL and a current Sim token, and confirm provisioning is enabled and reachable. | +| User creation fails | Confirm the email domain is verified, a seat is available, and the account does not belong to another Sim organization. | +| User exists but has no workspace access | Push the directory group and add a Sim workspace mapping. Assignment alone creates organization membership. | +| Name or membership changes do not arrive | Check the app assignment, provisioning actions, attribute mappings, and Group Push status in Okta. | +| A pushed group is missing members | Confirm those users are active in Okta and successfully assigned to the app. After activation, repush the group; see [Okta's Group Push troubleshooting](https://help.okta.com/en-us/Content/Topics/users-groups-profiles/usgp-group-push-troubleshoot.htm). | +| A deactivated member still uses a seat | Deactivation suspends access. Removing organization membership is a separate offboarding action. | + +Use **View Logs** in the Okta app and **Provisioning → Activity** in Sim to inspect failures. Requests with an invalid or revoked token appear only in Okta because Sim cannot associate them with a connection. diff --git a/apps/docs/content/docs/platform/enterprise/sso.mdx b/apps/docs/content/docs/platform/enterprise/sso.mdx index 8921a0ad2a2..d3c21dde6c0 100644 --- a/apps/docs/content/docs/platform/enterprise/sso.mdx +++ b/apps/docs/content/docs/platform/enterprise/sso.mdx @@ -105,6 +105,8 @@ For a saved connection, open **Sign-in** and select **Edit**. The Provider ID re SCIM settings save immediately in the **Provisioning** tab. Its **Disable just-in-time provisioning** rule overrides Automatic first-sign-in membership while the connection is active and entitled. Existing members can still sign in. See [directory provisioning](/platform/enterprise/scim#provisioning-and-sso-together). +For Okta, follow the [provisioning walkthrough](/platform/enterprise/scim/okta) to connect a separate SCIM app and verify user and group synchronization. + ## Provider Guides diff --git a/apps/docs/public/static/enterprise/okta/api-integration.png b/apps/docs/public/static/enterprise/okta/api-integration.png new file mode 100644 index 00000000000..1014e2d9d2a Binary files /dev/null and b/apps/docs/public/static/enterprise/okta/api-integration.png differ diff --git a/apps/docs/public/static/enterprise/okta/group-push.png b/apps/docs/public/static/enterprise/okta/group-push.png new file mode 100644 index 00000000000..bd158cf684a Binary files /dev/null and b/apps/docs/public/static/enterprise/okta/group-push.png differ diff --git a/apps/docs/public/static/enterprise/okta/provisioning-actions.png b/apps/docs/public/static/enterprise/okta/provisioning-actions.png new file mode 100644 index 00000000000..bb681e881f9 Binary files /dev/null and b/apps/docs/public/static/enterprise/okta/provisioning-actions.png differ diff --git a/apps/sim/ee/scim/TESTING.md b/apps/sim/ee/scim/TESTING.md index d27c54fa25d..1d39f142c6a 100644 --- a/apps/sim/ee/scim/TESTING.md +++ b/apps/sim/ee/scim/TESTING.md @@ -59,6 +59,9 @@ timeout. filters, account email drift, and filters reflecting manual suspension. - Entra-shaped complex name and extension PATCH, case-insensitive core-qualified password fields, atomic failure, and partial complex attribute selection. +- Okta display-name updates with an echoed stale formatted name, actual account + name drift repair, stable retries, optional display-name removal, and group + member labels falling back to the formatted name. - Group membership idempotency and membership-only replacement timestamps. - Workspace, organization-role, and permission-group mappings, reconciliation, drift repair, withdrawal, and preservation of pre-existing manual workspace access. @@ -101,6 +104,22 @@ which verifies that SCIM's `disableJit` setting blocks fresh SSO membership, preserves existing membership, and permits JIT when disabled. These checks run the admission operation and Enterprise entitlement reads through PostgreSQL. +## Live Okta verification + +The private **SCIM 2.0 Test App (Header Auth)** has also been exercised through +the Okta Admin Console against an isolated test organization over HTTPS. The +walkthrough and redacted screenshots are in the +[Okta provisioning guide](../../../docs/content/docs/platform/enterprise/scim/okta.mdx). +The run verified credential testing, user assignment and creation, profile +updates, deactivation and reactivation of the same account, group push, workspace +grant and withdrawal, group rename, readdition, repeated push, reconciliation, +token rotation, and downstream group deletion. Resulting names, memberships, +and grants were checked in PostgreSQL as well as the admin interfaces. + +This used synthetic users and a seeded verified test domain. It did not exercise +real DNS ownership verification, an end-user SSO login, or a Microsoft Entra +tenant. It is a provider interoperability check, not OIN certification. + ## Remaining provider verification Before claiming a provider integration has been validated, use an actual Okta diff --git a/apps/sim/ee/scim/lib/application/users/provision-user.test.ts b/apps/sim/ee/scim/lib/application/users/provision-user.test.ts index a905cacaca7..a3d340c4db5 100644 --- a/apps/sim/ee/scim/lib/application/users/provision-user.test.ts +++ b/apps/sim/ee/scim/lib/application/users/provision-user.test.ts @@ -123,6 +123,7 @@ function attributes(overrides: Partial = {}): ScimUserAttrib externalId: 'ext-1', active: true, displayName: 'Ada Lovelace', + displayNameSource: 'provider', name: { formatted: 'Ada Lovelace', givenName: 'Ada', familyName: 'Lovelace' }, emails: [{ value: 'ada@acme.test', type: 'work', primary: true }], ...overrides, @@ -256,6 +257,39 @@ describe('provisionScimUser', () => { }) }) + it.each(['create', 'link'] as const)( + 'uses the explicit display name when the identity action is %s', + async (action) => { + stageConnection() + if (action === 'link') { + mocks.resolveIdentity.mockResolvedValue({ + action: 'link', + userId: 'u-old', + via: 'tombstone', + }) + } + const stored = attributes({ displayName: 'Countess Lovelace' }) + stageReadBack(action === 'link' ? 'u-old' : 'u-new', stored, null) + await run(stored) + if (action === 'create') { + expect(mocks.createUser).toHaveBeenCalledWith({ + body: { + email: 'ada@acme.test', + name: 'Countess Lovelace', + data: { emailVerified: false }, + }, + }) + } else { + expect(mocks.syncIdentity).toHaveBeenCalledWith(db, { + userId: 'u-old', + email: 'ada@acme.test', + name: 'Countess Lovelace', + }) + } + expect(mocks.insertScimUser.mock.calls[0][1].attributes.name.formatted).toBe('Ada Lovelace') + } + ) + it('runs the post-commit effects against the subscription admission validated', async () => { stageConnection() await run(attributes()) diff --git a/apps/sim/ee/scim/lib/application/users/provision-user.ts b/apps/sim/ee/scim/lib/application/users/provision-user.ts index 9f37c414269..08523eee734 100644 --- a/apps/sim/ee/scim/lib/application/users/provision-user.ts +++ b/apps/sim/ee/scim/lib/application/users/provision-user.ts @@ -32,7 +32,7 @@ import { resolveProvisionedIdentity, } from '@/ee/scim/lib/identity/resolve-user' import { reconcileUserProjection } from '@/ee/scim/lib/projection/reconcile-user' -import { primaryEmail } from '@/ee/scim/lib/protocol/canonical' +import { accountName, primaryEmail } from '@/ee/scim/lib/protocol/canonical' import { ScimError, uniqueness } from '@/ee/scim/lib/protocol/errors' import { toUserResource } from '@/ee/scim/lib/protocol/resources' import { @@ -128,7 +128,7 @@ export const provisionScimUser = defineAuthorizedScimUseCase({ const created = await auth.api.createUser({ body: { email, - name: attributes.name.formatted, + name: accountName(attributes), data: { emailVerified: false }, }, }) @@ -182,7 +182,7 @@ export const provisionScimUser = defineAuthorizedScimUseCase({ emailChanged = await syncAccountIdentityTx(tx, { userId, email, - name: attributes.name.formatted, + name: accountName(attributes), }) if (emailChanged && attributes.active) { await revokeUserSessionsTx(tx, { userId, organizationId: context.organizationId }) diff --git a/apps/sim/ee/scim/lib/application/users/update-user.test.ts b/apps/sim/ee/scim/lib/application/users/update-user.test.ts index c8b846e2223..28ad4af630e 100644 --- a/apps/sim/ee/scim/lib/application/users/update-user.test.ts +++ b/apps/sim/ee/scim/lib/application/users/update-user.test.ts @@ -82,6 +82,7 @@ function attributes(overrides: Partial = {}): ScimUserAttrib externalId: 'ext-1', active: true, displayName: 'Ada Lovelace', + displayNameSource: 'provider', name: { formatted: 'Ada Lovelace', givenName: 'Ada', familyName: 'Lovelace' }, emails: [{ value: 'ada@acme.test', type: 'work', primary: true }], ...overrides, @@ -89,7 +90,12 @@ function attributes(overrides: Partial = {}): ScimUserAttrib } function stage( - record: { attributes?: Partial; email?: string; active?: boolean } = {} + record: { + attributes?: Partial + email?: string + name?: string + active?: boolean + } = {} ) { queueTableRows(scimConnection, [ { id: 'conn-1', organizationId: 'org-1', status: 'active', settings: {} }, @@ -103,6 +109,10 @@ function stage( active: record.active ?? stored.active, attributes: stored, email: record.email ?? 'ada@acme.test', + name: + record.name ?? + (stored.displayNameSource === 'provider' ? stored.displayName : undefined) ?? + stored.name.formatted, createdAt: new Date('2026-01-01T00:00:00.000Z'), updatedAt: new Date('2026-01-01T00:00:00.000Z'), userSuspendedAt: null, @@ -145,11 +155,125 @@ describe('user updates', () => { const result = await run(replaceScimUser, { scimUserId: 'su-1', attributes: attributes() }) expect(result.outcome).toBeNull() expect(mocks.updateScimUser).not.toHaveBeenCalled() + expect(mocks.syncIdentity).not.toHaveBeenCalled() expect(mocks.reconcile).not.toHaveBeenCalled() expect(mocks.recordAudit).not.toHaveBeenCalled() expect(result.resource.id).toBe('su-1') }) + it('projects an Okta display-name update despite an echoed stale formatted name', async () => { + stage() + const next = attributes({ + displayName: 'Augusta King', + name: { formatted: 'Ada Lovelace', givenName: 'Augusta', familyName: 'King' }, + }) + await run(replaceScimUser, { scimUserId: 'su-1', attributes: next }) + expect(mocks.syncIdentity).toHaveBeenCalledWith(db, { userId: 'u-1', name: 'Augusta King' }) + expect(mocks.updateScimUser).toHaveBeenCalledWith(db, { + scimUserId: 'su-1', + attributes: next, + active: true, + }) + expect(mocks.assertDomainOwned).not.toHaveBeenCalled() + expect(mocks.revokeSessions).not.toHaveBeenCalled() + expect(mocks.suspend).not.toHaveBeenCalled() + expect(mocks.unsuspend).not.toHaveBeenCalled() + expect(mocks.invalidate).not.toHaveBeenCalled() + }) + + it.each(['patch', 'replace'] as const)( + 'repairs account-name drift on an identical %s only once', + async (method) => { + const useCase = method === 'patch' ? patchScimUser : replaceScimUser + const input = + method === 'patch' + ? { + scimUserId: 'su-1', + operations: [{ op: 'replace', path: 'displayName', value: 'Ada Lovelace' }], + } + : { scimUserId: 'su-1', attributes: attributes() } + stage({ name: 'Old account name' }) + const repaired = await run(useCase, input) + expect(repaired.outcome).not.toBeNull() + expect(mocks.syncIdentity).toHaveBeenCalledWith(db, { userId: 'u-1', name: 'Ada Lovelace' }) + expect(mocks.revokeSessions).not.toHaveBeenCalled() + expect(mocks.invalidate).not.toHaveBeenCalled() + + vi.clearAllMocks() + stage() + const repeated = await run(useCase, input) + expect(repeated.outcome).toBeNull() + expect(mocks.syncIdentity).not.toHaveBeenCalled() + expect(mocks.updateScimUser).not.toHaveBeenCalled() + expect(mocks.reconcile).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + } + ) + + it.each(['add', 'replace'] as const)('projects a display-name-only PATCH %s', async (op) => { + stage() + await run(patchScimUser, { + scimUserId: 'su-1', + operations: [{ op, path: 'displayName', value: 'Countess Lovelace' }], + }) + expect(mocks.syncIdentity).toHaveBeenCalledWith(db, { + userId: 'u-1', + name: 'Countess Lovelace', + }) + }) + + it('updates the account from name parts when no explicit display name exists', async () => { + stage({ attributes: { displayName: undefined } }) + await run(patchScimUser, { + scimUserId: 'su-1', + operations: [{ op: 'replace', path: 'name.givenName', value: 'Augusta' }], + }) + expect(mocks.syncIdentity).toHaveBeenCalledWith(db, { userId: 'u-1', name: 'Augusta Lovelace' }) + expect(mocks.updateScimUser.mock.calls[0][1].attributes.displayName).toBeUndefined() + }) + + it('keeps an explicit display name when only a name part changes', async () => { + stage() + await run(patchScimUser, { + scimUserId: 'su-1', + operations: [{ op: 'replace', path: 'name.givenName', value: 'Augusta' }], + }) + expect(mocks.syncIdentity).not.toHaveBeenCalled() + expect(mocks.updateScimUser.mock.calls[0][1].attributes.name.formatted).toBe('Augusta Lovelace') + }) + + it('preserves name-part updates for legacy records with synthesized display names', async () => { + stage({ attributes: { displayNameSource: undefined } }) + await run(patchScimUser, { + scimUserId: 'su-1', + operations: [{ op: 'replace', path: 'name.givenName', value: 'Augusta' }], + }) + expect(mocks.syncIdentity).toHaveBeenCalledWith(db, { userId: 'u-1', name: 'Augusta Lovelace' }) + }) + + it('adopts an explicit display name when a provider replaces a legacy profile', async () => { + stage({ attributes: { displayName: 'Countess Lovelace', displayNameSource: undefined } }) + await run(replaceScimUser, { + scimUserId: 'su-1', + attributes: attributes({ displayName: 'Countess Lovelace' }), + }) + expect(mocks.syncIdentity).toHaveBeenCalledWith(db, { + userId: 'u-1', + name: 'Countess Lovelace', + }) + expect(mocks.updateScimUser.mock.calls[0][1].attributes.displayNameSource).toBe('provider') + }) + + it('restores the formatted fallback when a display name is removed', async () => { + stage({ attributes: { displayName: 'Countess Lovelace' } }) + await run(patchScimUser, { + scimUserId: 'su-1', + operations: [{ op: 'remove', path: 'displayName' }], + }) + expect(mocks.syncIdentity).toHaveBeenCalledWith(db, { userId: 'u-1', name: 'Ada Lovelace' }) + expect(mocks.updateScimUser.mock.calls[0][1].attributes.displayName).toBeUndefined() + }) + it('deactivates by suspending, never by removing, and keeps the projection', async () => { stage() const result = await run(patchScimUser, { diff --git a/apps/sim/ee/scim/lib/application/users/update-user.ts b/apps/sim/ee/scim/lib/application/users/update-user.ts index d2e6984075e..0b69ff1f617 100644 --- a/apps/sim/ee/scim/lib/application/users/update-user.ts +++ b/apps/sim/ee/scim/lib/application/users/update-user.ts @@ -20,7 +20,7 @@ import { scimOperations } from '@/ee/scim/lib/application/operations' import { syncAccountIdentityTx } from '@/ee/scim/lib/identity/account-identity' import { assertDomainOwned } from '@/ee/scim/lib/identity/resolve-user' import { reconcileUserProjection } from '@/ee/scim/lib/projection/reconcile-user' -import { primaryEmail } from '@/ee/scim/lib/protocol/canonical' +import { accountName, primaryEmail } from '@/ee/scim/lib/protocol/canonical' import { notFound, ScimError } from '@/ee/scim/lib/protocol/errors' import { toUserResource } from '@/ee/scim/lib/protocol/resources' import { applyUserPatch, userAttributesEqual } from '@/ee/scim/lib/protocol/user-patch' @@ -55,6 +55,7 @@ async function applyUserUpdate( next: ScimUserAttributes ): Promise { const nextEmail = primaryEmail(next) + const nextName = accountName(next) const emailChanged = accountEmailDiverged(current, next) const deactivated = current.active && !next.active const reactivated = !current.active && next.active @@ -73,7 +74,7 @@ async function applyUserUpdate( await syncAccountIdentityTx(tx, { userId: current.userId, email: nextEmail, - name: next.name.formatted, + name: nextName, }) /** @@ -87,8 +88,8 @@ async function applyUserUpdate( organizationId: context.organizationId, }) } - } else if (next.name.formatted !== current.attributes.name.formatted) { - await syncAccountIdentityTx(tx, { userId: current.userId, name: next.name.formatted }) + } else if (nextName !== current.name) { + await syncAccountIdentityTx(tx, { userId: current.userId, name: nextName }) } if (deactivated) { @@ -161,6 +162,10 @@ function accountEmailDiverged(current: ScimUserRecord, next: ScimUserAttributes) return normalizeEmail(primaryEmail(next)) !== normalizeEmail(current.email) } +function accountIdentityDiverged(current: ScimUserRecord, next: ScimUserAttributes): boolean { + return accountEmailDiverged(current, next) || accountName(next) !== current.name +} + /** Rendered inside the write transaction, so a concurrent delete cannot make a committed update unreadable. */ async function renderUpdated( tx: DbOrTx, @@ -245,7 +250,7 @@ export const replaceScimUser = defineAuthorizedScimUseCase({ * organization produces 2,000 spurious audit rows per sync. */ const outcome = - userAttributesEqual(current.attributes, next) && !accountEmailDiverged(current, next) + userAttributesEqual(current.attributes, next) && !accountIdentityDiverged(current, next) ? null : await applyUserUpdate(tx, context, current, next) return { @@ -285,7 +290,7 @@ export const patchScimUser = defineAuthorizedScimUseCase({ * nothing. */ const outcome = - changed || accountEmailDiverged(current, next) + changed || accountIdentityDiverged(current, next) ? await applyUserUpdate(tx, context, current, next) : null return { diff --git a/apps/sim/ee/scim/lib/protocol/canonical.test.ts b/apps/sim/ee/scim/lib/protocol/canonical.test.ts index f68375f8289..9383501b8bf 100644 --- a/apps/sim/ee/scim/lib/protocol/canonical.test.ts +++ b/apps/sim/ee/scim/lib/protocol/canonical.test.ts @@ -3,7 +3,12 @@ */ import { describe, expect, it } from 'vitest' import { scimGroupWriteSchema, scimUserWriteSchema } from '@/lib/api/contracts/scim' -import { primaryEmail, toCanonicalGroup, toCanonicalUser } from '@/ee/scim/lib/protocol/canonical' +import { + accountName, + primaryEmail, + toCanonicalGroup, + toCanonicalUser, +} from '@/ee/scim/lib/protocol/canonical' import { SCIM_ENTERPRISE_USER_SCHEMA, SCIM_GROUP_SCHEMA, @@ -52,13 +57,30 @@ describe('toCanonicalUser', () => { expect(scimType).toBe('invalidValue') }) - it('builds a display name from the parts when none is supplied', () => { + it('uses name parts for the account without inventing a provider display name', () => { const user = parseUser({ userName: 'ada@acme.test', name: { givenName: 'Ada', familyName: 'Lovelace' }, }) expect(user.name.formatted).toBe('Ada Lovelace') - expect(user.displayName).toBe('Ada Lovelace') + expect(user).not.toHaveProperty('displayName') + expect(accountName(user)).toBe('Ada Lovelace') + }) + + it('prefers an explicit display name while retaining the independent formatted name', () => { + const user = parseUser({ + userName: 'ada@acme.test', + displayName: 'Countess Lovelace', + name: { formatted: 'Ada Lovelace', givenName: 'Augusta', familyName: 'King' }, + }) + expect(accountName(user)).toBe('Countess Lovelace') + expect(user.name.formatted).toBe('Ada Lovelace') + }) + + it('falls back to the account email when no name is supplied', () => { + const user = parseUser({ userName: 'ada@acme.test' }) + expect(accountName(user)).toBe('ada@acme.test') + expect(user).not.toHaveProperty('displayName') }) it('keeps a provider extension’s attributes under its URN', () => { diff --git a/apps/sim/ee/scim/lib/protocol/canonical.ts b/apps/sim/ee/scim/lib/protocol/canonical.ts index a38f86a1f28..de34a74ba90 100644 --- a/apps/sim/ee/scim/lib/protocol/canonical.ts +++ b/apps/sim/ee/scim/lib/protocol/canonical.ts @@ -100,6 +100,7 @@ export function toCanonicalUser(body: ScimUserWriteParsed): ScimUserAttributes { const { emails, primary } = normalizeEmails(body.emails, userName) const name = formatName(body.name, body.displayName, primary) + const displayName = trimmed(body.displayName) const enterprise = body[SCIM_ENTERPRISE_USER_SCHEMA] const extra = collectExtra(body) @@ -107,7 +108,7 @@ export function toCanonicalUser(body: ScimUserWriteParsed): ScimUserAttributes { userName, ...(trimmed(body.externalId) ? { externalId: trimmed(body.externalId) } : {}), active: body.active ?? true, - displayName: trimmed(body.displayName) ?? name.formatted, + ...(displayName ? { displayName, displayNameSource: 'provider' as const } : {}), name, emails, ...(isRecord(enterprise) ? { enterprise: normalizeEnterprise(enterprise) } : {}), @@ -159,6 +160,13 @@ export function primaryEmail(attributes: ScimUserAttributes): string { return (attributes.emails.find((entry) => entry.primary) ?? attributes.emails[0]).value } +/** Projects the directory's preferred display name onto the Sim account. */ +export function accountName(attributes: ScimUserAttributes): string { + return attributes.displayNameSource === 'provider' + ? (attributes.displayName ?? attributes.name.formatted) + : attributes.name.formatted +} + export interface CanonicalScimGroup { displayName: string externalId?: string diff --git a/apps/sim/ee/scim/lib/protocol/resources.test.ts b/apps/sim/ee/scim/lib/protocol/resources.test.ts index 31e1e2014d6..754b44b4f88 100644 --- a/apps/sim/ee/scim/lib/protocol/resources.test.ts +++ b/apps/sim/ee/scim/lib/protocol/resources.test.ts @@ -1,6 +1,7 @@ /** * @vitest-environment node */ +import type { ScimUserAttributes } from '@sim/db/schema' import { describe, expect, it } from 'vitest' import { scimGroupResourceSchema, scimUserResourceSchema } from '@/lib/api/contracts/scim' import { SCIM_MAX_PAGE_SIZE } from '@/ee/scim/lib/protocol/constants' @@ -26,9 +27,10 @@ function userRow() { userName: 'ada@acme.test', active: true, displayName: 'Ada Lovelace', + displayNameSource: 'provider', name: { formatted: 'Ada Lovelace', givenName: 'Ada', familyName: 'Lovelace' }, emails: [{ value: 'ada@acme.test', type: 'work', primary: true }], - }, + } satisfies ScimUserAttributes, createdAt: new Date('2026-01-01T00:00:00.000Z'), updatedAt: new Date('2026-02-01T00:00:00.000Z'), email: 'ada@acme.test', @@ -59,6 +61,14 @@ describe('resolvePage', () => { }) describe('toUserResource', () => { + it('omits an absent display name so provider read-modify-write does not make a fallback explicit', () => { + const row = userRow() + const { displayName: _displayName, ...attributes } = row.attributes + const resource = toUserResource({ ...row, attributes }, BASE_URL) + expect(resource).not.toHaveProperty('displayName') + expect(resource.name.formatted).toBe('Ada Lovelace') + expect(scimUserResourceSchema.safeParse(resource).success).toBe(true) + }) it('renders the resource a provider expects', () => { const resource = toUserResource(userRow(), BASE_URL) expect(resource).toMatchObject({ @@ -75,6 +85,7 @@ describe('toUserResource', () => { expect(resource.groups).toEqual([ { value: 'g1', display: 'Engineering', $ref: `${BASE_URL}/Groups/g1` }, ]) + expect(resource).not.toHaveProperty('displayNameSource') }) it('declares a provider extension it stored and returns its attributes', () => { diff --git a/apps/sim/ee/scim/lib/protocol/resources.ts b/apps/sim/ee/scim/lib/protocol/resources.ts index db57fb45fdc..739df0bdbee 100644 --- a/apps/sim/ee/scim/lib/protocol/resources.ts +++ b/apps/sim/ee/scim/lib/protocol/resources.ts @@ -27,7 +27,7 @@ export interface ScimUserResource { externalId?: string userName: string active: boolean - displayName: string + displayName?: string name: { formatted: string; givenName?: string; familyName?: string } emails: Array<{ value: string; type?: string; primary: boolean }> groups: Array<{ value: string; display: string; $ref: string }> @@ -113,7 +113,7 @@ export function toUserResource(row: UserResourceRow, baseUrl: string): ScimUserR ...(row.externalId ? { externalId: row.externalId } : {}), userName: row.userName, active: row.active, - displayName: stored.displayName, + ...(stored.displayName !== undefined ? { displayName: stored.displayName } : {}), name: stored.name, emails, groups: row.groups.map((group) => ({ diff --git a/apps/sim/ee/scim/lib/protocol/user-patch.test.ts b/apps/sim/ee/scim/lib/protocol/user-patch.test.ts index 7e10da6c0da..715dbf4451c 100644 --- a/apps/sim/ee/scim/lib/protocol/user-patch.test.ts +++ b/apps/sim/ee/scim/lib/protocol/user-patch.test.ts @@ -18,6 +18,7 @@ function baseUser(overrides: Partial = {}): ScimUserAttribut externalId: '00u1', active: true, displayName: 'Ada Lovelace', + displayNameSource: 'provider', name: { formatted: 'Ada Lovelace', givenName: 'Ada', familyName: 'Lovelace' }, emails: [{ value: 'ada@acme.test', type: 'work', primary: true }], ...overrides, @@ -31,6 +32,18 @@ function parseOperations(operations: unknown[]) { } describe('applyUserPatch', () => { + it('removes a display name without synthesizing it again on a later name-part patch', () => { + const removed = applyUserPatch(baseUser(), [{ op: 'remove', path: 'displayName' }]) + expect(removed.next.displayName).toBeUndefined() + expect(removed.next.displayNameSource).toBeUndefined() + const repeated = applyUserPatch(removed.next, [{ op: 'remove', path: 'displayName' }]) + expect(repeated.changed).toBe(false) + const renamed = applyUserPatch(repeated.next, [ + { op: 'replace', path: 'name.givenName', value: 'Augusta' }, + ]) + expect(renamed.next.name.formatted).toBe('Augusta Lovelace') + expect(renamed.next.displayName).toBeUndefined() + }) it('deactivates from Okta’s path-less replace', () => { const { next, changed } = applyUserPatch( baseUser(), diff --git a/apps/sim/ee/scim/lib/protocol/user-patch.ts b/apps/sim/ee/scim/lib/protocol/user-patch.ts index 073995f69b0..5c0ee34443f 100644 --- a/apps/sim/ee/scim/lib/protocol/user-patch.ts +++ b/apps/sim/ee/scim/lib/protocol/user-patch.ts @@ -45,12 +45,11 @@ function requireBoolean(value: unknown, attribute: string): boolean { return normalized } -/** Recomputes `formatted` and `displayName` after a name part changes. */ -function refreshDerivedNames(user: ScimUserAttributes, fallback: string): void { +/** Recomputes the formatted name without overwriting an explicit display name. */ +function refreshFormattedName(user: ScimUserAttributes, fallback: string): void { const joined = [user.name.givenName, user.name.familyName].filter(Boolean).join(' ') if (joined) user.name.formatted = joined else if (!user.name.formatted) user.name.formatted = fallback - if (!user.displayName) user.displayName = user.name.formatted } function setPrimaryEmailValue(user: ScimUserAttributes, value: string): void { @@ -169,7 +168,13 @@ function applyOperation( return case 'displayname': - user.displayName = op === 'remove' ? user.name.formatted : requireString(value, 'displayName') + if (op === 'remove') { + user.displayName = undefined + user.displayNameSource = undefined + } else { + user.displayName = requireString(value, 'displayName') + user.displayNameSource = 'provider' + } return case 'name.formatted': @@ -180,13 +185,13 @@ function applyOperation( case 'name.givenname': if (op === 'remove') user.name.givenName = undefined else user.name.givenName = requireString(value, 'name.givenName') - refreshDerivedNames(user, user.userName) + refreshFormattedName(user, user.userName) return case 'name.familyname': if (op === 'remove') user.name.familyName = undefined else user.name.familyName = requireString(value, 'name.familyName') - refreshDerivedNames(user, user.userName) + refreshFormattedName(user, user.userName) return case 'emails': diff --git a/apps/sim/ee/scim/lib/repository/groups.ts b/apps/sim/ee/scim/lib/repository/groups.ts index a9bdc1aae84..47be71aa94b 100644 --- a/apps/sim/ee/scim/lib/repository/groups.ts +++ b/apps/sim/ee/scim/lib/repository/groups.ts @@ -10,8 +10,8 @@ const logger = createLogger('ScimGroupRepository') /** Reads and writes of the provisioned Group table, always anchored to a connection. */ -/** What a member is called in a Group response: the same display name the User resource shows. */ -const memberDisplayName = sql`coalesce(${scimUser.attributes} ->> 'displayName', ${scimUser.userName})` +/** A group member's preferred display name, falling back to the formatted name or username. */ +const memberDisplayName = sql`coalesce(${scimUser.attributes} ->> 'displayName', ${scimUser.attributes} #>> '{name,formatted}', ${scimUser.userName})` export interface ScimGroupRecord { id: string diff --git a/apps/sim/ee/scim/lib/repository/users.ts b/apps/sim/ee/scim/lib/repository/users.ts index fc44fcc4e78..c6a7a14a563 100644 --- a/apps/sim/ee/scim/lib/repository/users.ts +++ b/apps/sim/ee/scim/lib/repository/users.ts @@ -74,6 +74,7 @@ const USER_SELECTION = { createdAt: scimUser.createdAt, updatedAt: scimUser.updatedAt, email: user.email, + name: user.name, userSuspendedAt: user.suspendedAt, } as const @@ -87,6 +88,7 @@ export interface ScimUserRecord { createdAt: Date updatedAt: Date email: string + name: string userSuspendedAt: Date | null } diff --git a/apps/sim/lib/copilot/generated/docs-manifest.ts b/apps/sim/lib/copilot/generated/docs-manifest.ts index e2f9e8f6189..1eadb35cd90 100644 --- a/apps/sim/lib/copilot/generated/docs-manifest.ts +++ b/apps/sim/lib/copilot/generated/docs-manifest.ts @@ -376,6 +376,7 @@ export const DOCS_MANIFEST: readonly string[] = [ 'platform/enterprise/data-retention.mdx', 'platform/enterprise/forks.mdx', 'platform/enterprise/scim.mdx', + 'platform/enterprise/scim/okta.mdx', 'platform/enterprise/self-hosted.mdx', 'platform/enterprise/session-policies.mdx', 'platform/enterprise/sso.mdx', diff --git a/apps/sim/scripts/test-scim-e2e.ts b/apps/sim/scripts/test-scim-e2e.ts index 89c24292395..4ec8e28d00b 100644 --- a/apps/sim/scripts/test-scim-e2e.ts +++ b/apps/sim/scripts/test-scim-e2e.ts @@ -488,21 +488,55 @@ async function run() { ) await check( - 'User PUT replaces a profile and repeated identical writes keep resource versions stable', + 'User PUT projects display names and repairs account drift without repeated writes', async () => { const profile = { schemas: [USER_SCHEMA], userName: bobEmail, externalId: 'bob-directory-id', active: true, - name: { givenName: 'Robert', familyName: 'Example' }, + name: { formatted: 'Bob Example', givenName: 'Robert', familyName: 'Example' }, displayName: 'Robert Example', } const first = (await scim(token, `/Users/${bobId}`, { method: 'PUT', body: profile })).body + const [account] = await sql`select u.id, u.name, u.updated_at, u.email_verified + from "user" u join scim_user su on su.user_id = u.id where su.id = ${bobId}` + assert.equal(account.name, 'Robert Example') + assert.equal(record(first.name).formatted, 'Bob Example') const second = (await scim(token, `/Users/${bobId}`, { method: 'PUT', body: profile })).body assert.equal(record(first.name).givenName, 'Robert') assert.equal(record(first.meta).version, record(second.meta).version) assert.equal(record(first.meta).lastModified, record(second.meta).lastModified) + assert.equal( + (await sql`select updated_at from "user" where id = ${account.id}`)[0].updated_at.getTime(), + account.updated_at.getTime() + ) + + await sql`update "user" set name = 'Old account name' where id = ${account.id}` + const repaired = (await scim(token, `/Users/${bobId}`, { method: 'PUT', body: profile })).body + const [restored] = + await sql`select id, name, updated_at, email_verified from "user" where id = ${account.id}` + assert.equal(restored.name, 'Robert Example') + assert.equal(restored.email_verified, account.email_verified) + assert.equal(repaired.id, bobId) + const repeated = (await scim(token, `/Users/${bobId}`, { method: 'PUT', body: profile })).body + assert.equal(record(repaired.meta).version, record(repeated.meta).version) + assert.equal( + (await sql`select updated_at from "user" where id = ${account.id}`)[0].updated_at.getTime(), + restored.updated_at.getTime() + ) + + await patch(token, `/Users/${bobId}`, [{ op: 'remove', path: 'displayName' }]) + const fallback = ( + await patch(token, `/Users/${bobId}`, [ + { op: 'replace', path: 'name.givenName', value: 'Bobby' }, + ]) + ).body + assert(!('displayName' in fallback)) + assert.equal( + (await sql`select name from "user" where id = ${account.id}`)[0].name, + 'Bobby Example' + ) } ) @@ -543,6 +577,20 @@ async function run() { }) ).body assert.notEqual(record(initial.meta).lastModified, record(replaced.meta).lastModified) + assert.equal( + (replaced.members as unknown[]).map(record).find((member) => member.value === bobId) + ?.display, + 'Bobby Example' + ) + const listedGroup = resources((await scim(token, '/Groups')).body).find( + (group) => group.id === groupId + ) + assert(listedGroup) + assert.equal( + (listedGroup.members as unknown[]).map(record).find((member) => member.value === bobId) + ?.display, + 'Bobby Example' + ) const stable = ( await scim(token, `/Groups/${groupId}`, { method: 'PUT', diff --git a/packages/db/schema.ts b/packages/db/schema.ts index cf8a6b45052..0efc7773708 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -6055,7 +6055,9 @@ export interface ScimUserAttributes { userName: string externalId?: string active: boolean - displayName: string + displayName?: string + /** Older records synthesized displayName; unmarked records retain formatted-name account projection. */ + displayNameSource?: 'provider' name: { formatted: string givenName?: string