feat(scim): SCIM 2.0 directory provisioning with group-to-access projection - #7567
Conversation
Sim becomes an authorization server, so other software can sign a person in and act as them with their consent. `sim login` uses it by default, replacing a permanent personal API key with a short-lived, scoped, revocable token. Built on `@better-auth/oauth-provider@1.6.27`, pinned to match the Better Auth already in the tree — no upgrade required. Tokens are opaque rather than JWTs, so revoking an app in settings or running `sim logout` takes effect on the very next request. Access tokens last an hour; refresh tokens rotate on every use and expire after thirty days. A new principal kind, `oauth_access_token`, is admitted exactly where `personal_api_key` is and nowhere else; `check:principal-kind-parity` fails the build if the two ever drift apart. Write scope is derived from the HTTP method at v2 admission rather than from an operation's `minimumRole`, because several POST routes only read — a role-derived rule let a read-only token write. The consent card refuses to render for a request Sim did not issue: an unsigned query, a repeated parameter, or a client the server declines to name. Clients are operator-created rows. Dynamic registration is off, and every one of the plugin's client CRUD endpoints is closed at the source. Off with `OAUTH_PROVIDER_ENABLED=false`, which the CLI detects and falls back to the existing pairing-code handoff.
# Conflicts: # packages/db/migrations/meta/0321_snapshot.json # packages/db/migrations/meta/_journal.json
…ection
Adds a SCIM 2.0 service provider so an organization's identity provider
(Okta, Microsoft Entra ID, OneLogin, JumpCloud) can create, update,
deactivate, and remove members, and map pushed groups onto permission
groups, workspace access, and the organization admin role.
Protocol surface (`/api/scim/v2`)
- Users and Groups: list with `eq`/`and` filters and paging, get, create,
replace, patch, delete; discovery documents for ServiceProviderConfig,
ResourceTypes, and Schemas.
- A dedicated `defineScimRoute` builder: bearer authentication to a new
`scim_connection` principal, per-connection rate limit, RFC 7644 error
envelope, `application/scim+json`, 415 on wrong media type.
- Tolerances for what providers actually send: Entra's capitalized ops and
string booleans, one-element arrays, path-less dotted-key replaces, and
filtered email paths that create their target; Okta's path-less
`replace {active:false}` and filtered member removal.
Identity and safety
- Never links by unverified email: resolution is tombstone by externalId,
then verified-domain email within this organization, then create.
- Every create and email change is refused outside the organization's
verified domains, closing the SCIM account-takeover shape.
- Deactivation is a new reversible `user.suspendedAt` state, enforced at
session creation and both API-key auth paths. It is deliberately not
`banned`, which archives owned workspaces and cannot be undone.
- The organization owner cannot be deprovisioned; seats are validated with
the same policy as SSO admission.
Projection
- Every grant SCIM makes is recorded, so withdrawing group access touches
only what the directory granted and never a manual grant.
- Permission groups gain `membershipMode: 'explicit'` so a directory-managed
group governs nobody when empty instead of widening to everyone.
- An hourly reconcile sweep re-applies mappings idempotently.
Shared primitives extracted from routes so UI and SCIM share one
implementation: per-user session revocation (with security-version bump),
personal API-key revocation, suspend/unsuspend, member role change,
workspace access grant/revoke, permission-group add/remove member. The
member role route now uses the role-change primitive under the org lock.
Admin surface under `/api/organizations/[id]/scim`: connection settings,
credential issue/revoke (two active for rotation, digest-only storage),
group mappings, activity log, on-demand reconcile.
Migration 0323 is expand-only: nine new tables plus nullable/defaulted
columns on `user` and `permission_group`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVf3fLVj7iWzED7L2wQvhG
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryAdds enterprise SCIM 2.0 directory provisioning with secure user lifecycle management and group-to-access projection.
Confidence Score: 5/5The PR appears safe to merge, with no outstanding previous findings or newly established defects. Every previous thread is resolved, the current membership-mode semantics disprove the access-broadening security lead, and the changes since the previous review preserve behavior while consistently relocating SCIM server modules and their references.
|
| Filename | Overview |
|---|---|
| apps/sim/ee/scim/lib/application/users/provision-user.ts | Provisions directory users through verified identity resolution, organization membership creation, and access reconciliation. |
| apps/sim/ee/scim/lib/application/users/deprovision-user.ts | Implements transactional directory deprovisioning through the shared organization-removal lifecycle. |
| apps/sim/ee/scim/lib/projection/reconcile-user.ts | Reconciles group-derived access while preserving grant provenance and manual-access semantics. |
| apps/sim/ee/scim/lib/route.ts | Defines the authenticated SCIM protocol boundary and standardized request and error handling. |
| apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/route.ts | Applies membership-mode-aware protection when removing permission-group members. |
| packages/db/migrations/0323_scim_provisioning.sql | Adds the expand-only schema required for SCIM connections, identities, groups, mappings, credentials, logs, tombstones, and projection provenance. |
| scripts/check-api-validation-contracts.ts | Updates declarative-route validation to recognize the relocated SCIM route builder. |
Reviews (8): Last reviewed commit: "refactor(scim): keep the server code at ..." | Re-trigger Greptile
There was a problem hiding this comment.
All reported issues were addressed across 98 files
Not reviewed (too large): apps/sim/lib/execution/sandbox/bundles/docx.cjs (~38 lines), apps/sim/lib/execution/sandbox/bundles/pptxgenjs.cjs (~34 lines) - if these are generated or fixture files, add them to ignored paths to exclude them from future reviews.
Review and enterprise-norms audit of the provisioning surface: - Deactivation keeps workspace grants and API keys; it only blocks sign-in, refuses personal keys at auth, and stops scheduled/webhook execution via isAccountBlocked. Reactivation restores access exactly as it was - Organization/user advisory locks are taken before permission-group locks in projection and user update; concurrent PATCHes lock the SCIM row - Downgrades of a projected workspace level are applied, not just raises - No-op PUT/PATCH/replace writes nothing and records no audit - Unique violations map to 409 uniqueness; domain refusals to invalidValue - scimManagedUserPredicate is anchored to the organization - autoMapPermissionGroupsByName adopts an existing same-name group - Rate limit raised for Entra's initial-cycle burst; discovery is IP-limited - Dead exports and duplicate scope checks removed Adds the Settings → SSO → Directory provisioning section: enable toggle, base URL, connection settings, credential issue/revoke (token shown once), group mappings, recent activity, and reconcile now. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JVf3fLVj7iWzED7L2wQvhG
Findings from a full code review of the SCIM surface and a regression review of the shared primitives it extracted: - attributes/excludedAttributes projection no longer fails response validation (Entra excludes groups on every page); projected attributes are optional in the response contract - defaultWorkspaceGrants are checked against the organization, closing a cross-tenant workspace grant - User PUT/PATCH serialize on the organization/user advisory locks instead of a scim_user row lock that inverted lock order against projection - PATCH add emails keeps the existing primary; canonical nested name and enterprise objects are accepted in a path-less replace - A group mapped to the org admin role skips the owner instead of failing the sync; a withdrawal that cannot hand ownership on is retried next pass - Relinking a recreated identity syncs the account's email and name - disableJit is enforced at SSO admission rather than flipped once on the provider; reconcile refuses a disabled connection clearly - Conflicts that are not duplicates no longer carry scimType uniqueness - Workspace revocation hands the workspace to its billed account first, matching the members route - Batch invitations return 403 for a managed member; role change maps a lock timeout to 409; managed-membership locking is inert with the flag off - Settings routes' all-members conflict rule ignores explicit-mode groups - Admin wrapper projects audit like the directory wrapper; admin use cases split into connection, credentials, mappings - Pure grant resolution/diff extracted; group list N+1 removed; no-op group writes skipped; roster shows directory deactivation - Tests: grants, authentication, identity resolution, lifecycle primitives, projection-vs-contract, email add, canonical nesting (139 SCIM tests) - Dead code removed; unrelated sandbox bundle churn reverted Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Filters accept unquoted booleans for active (RFC 7644) - Group PATCH resolves order-sensitive membership deltas by last operation and refuses a non-string externalId - Full writes accept case-insensitive attribute names (Entra's `username`) - Credential issue counts and inserts under the connection row lock - Projection records provenance only for access the directory actually granted unless the directory is the source of truth; skips departed members and workspaces moved to another organization - Reconcile job verifies its lease per batch and reads settings after taking it - Group writes serialize on the organization lock; the member role route runs its managed-membership check under the same locks - Seat reconciliation targets the subscription admission validated against - Docs: FAQ import, self-hosted flags; docs manifest regenerated - Chart 1.10.0 for the new cron job; test fixtures carry column defaults Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ed audit mock Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
@cubic-dev-ai review this PR |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
…ead surface, docs - Organization removal no longer deletes personal API keys except when the directory deprovisions; a member removing themselves keeps their own session. Every other caller keeps the session revocation - The reconcile interval is shorter than the cron period, so the once-an-hour guarantee actually holds - Removed: defaultWorkspaceGrants (no client, no docs), the unused tombstone and delete helpers, parameters no caller varied, the duplicate discovery list helper; the routes barrel exports the SCIM builders; group cap raised to 5,000 - Docs: per-resource filters and limits, authentication failures absent from Activity, name matching and the permanent explicit-membership switch, default group not a target, credential expiry choices, SSO and both flags as prerequisites, what disabling the connection does Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DGXcwsHzYGp6pUDWRtJKpz
|
@cubic-dev-ai review this PR |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
…er enterprise features Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DGXcwsHzYGp6pUDWRtJKpz
|
@cubic-dev-ai review this PR |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
Brings PR #7488 (OAuth provider) under the SCIM branch so #7567 can target it. - SCIM migration renumbered to 0324_scim_provisioning after the OAuth branch's 0323; regenerated from the merged schema (identical apart from the number). - Forbidden detail codes keep both INSUFFICIENT_SCOPE and SCIM_MANAGED_MEMBERSHIP; OpenAPI docs regenerated. - SCIM ships with the enterprise plan on the hosted product: the outer gate is now isScimDeploymentEnabled() (hosted, or SCIM_ENABLED / ENTERPRISE_ENABLED self-hosted) and the plan check stays per organization. The env variable remains the self-hosted switch and is bundled under ENTERPRISE_ENABLED. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DGXcwsHzYGp6pUDWRtJKpz
#7488 landed on staging as a squash, so the branch now carries staging itself. Same resolutions as the OAuth merge: both forbidden detail codes, OpenAPI regenerated, chart 1.10.0, journal keeps 0323_oauth_provider then 0324_scim_provisioning. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DGXcwsHzYGp6pUDWRtJKpz
Three read-only auditors (IdP compatibility and docs, correctness and regressions, code economy and tests) reviewed the merged branch. Every verified finding is addressed here. Sync-breaking and regressions: - Accept provider extension URNs in `schemas` (Okta custom attributes, Entra custom extensions) and echo them on the resource; the check now lives in the write contracts. - A lapsed enterprise plan no longer keeps refusing manual membership changes, invitations, or SSO JIT on a directory's behalf. - Leaving a workspace no longer signs the leaver out (session spared). - OAuth access and refresh tokens are refused for a directory-suspended account, like sessions and personal keys. - Workspace-role changes for a directory-managed member are refused under membership locking, as the docs already said. - A permission group moved back to governing everyone no longer fails a sync with a 500; the grant is left on record and logged. - The explicit-membership flip after auto-mapping now happens after the projection, keeping the permission-group lock a leaf. - Request-log prune runs before each pass, so a failing pass still bounds the log; contract-validation failures now carry scimType and detail in the activity log; `application/scim+json; charset=utf-8`. Economy: shared helpers replace local copies (postgres error code, SHA-256 base64url, bearer parsing, email syntax, batch reconcile loop); unreachable defensive checks behind the contracts removed; half-applied base-URL injection removed from the route builder; casts, restating comments, and unused exports dropped. Docs: withdrawal semantics under locking stated precisely; SCIM env vars added to the self-hosted, SSO, and environment-variable references; group name uniqueness noted. Tests: extension URN acceptance and echo, managed-membership guard and its entitlement bypass, directory-only SSO admission, OAuth suspension, permissions-route guard, settle step ordering, and the weak assertions the audit flagged strengthened. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DGXcwsHzYGp6pUDWRtJKpz
…ip; say "token" Provisioning use case (20), reconcile job (12), and the permission-group add/remove primitives (13) now have tests that assert call arguments, result fields, audit lists, and thrown status + scimType. User-facing wording is "token", as every provider labels it (Okta API Token, Entra Secret Token, OneLogin SCIM Bearer Token, JumpCloud Token Key), and to keep clear of Sim's own connected-account credentials. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DGXcwsHzYGp6pUDWRtJKpz
Summary
Adds SCIM 2.0 directory provisioning so an enterprise's identity provider (Okta, Microsoft Entra ID, OneLogin, JumpCloud) can create, update, deactivate, and remove Sim members, and map pushed groups onto permission groups, workspace access, and the organization admin role. Pairs with the existing SAML/OIDC SSO.
Ships with the Enterprise plan on the hosted product, the same way SSO does: nothing to switch on, and
isScimEntitledForOrganizationchecks the plan per organization. Self-hosted deployments get it withENTERPRISE_ENABLED=trueor just this feature withSCIM_ENABLED/NEXT_PUBLIC_SCIM_ENABLED(thescimentry in the enterprise feature set). Built on #7488 (OAuth provider), now on staging; the SCIM migration is numbered after its0323.What ships
Protocol surface
/api/scim/v2/{Users,Groups,ServiceProviderConfig,ResourceTypes,Schemas}eq/andfilters andstartIndex/countpaging.defineScimRoutebuilder: bearer auth to a newscim_connectionprincipal, per-connection rate limit, RFC 7644 error envelope,application/scim+json, 415 on wrong media type.Replace/"False", one-element arrays, path-less dotted-key replaces, filtered email paths that create their target; Okta's path-lessreplace {active:false}andmembers[value eq]removal. PATCH/Users→ 200 + resource; PATCH/Groups→ 204.Identity and safety
externalId→ verified-domain email within this org → create. Every create and email change is refused outside the org's verified domains.user.suspendedAtstate. It blocks session creation, refuses the user's personal API keys at both auth paths, and stops their scheduled/webhook/inbox runs viaisAccountBlocked. Everything they own and every grant they hold is left untouched, so reactivation restores access exactly as it was. Deliberately notbanned, which archives owned workspaces and has no undo.Projection
scim_projection_grant), so withdrawing group access touches only what the directory granted, never a manual grant.permission_group.membershipMode = 'explicit'(new, opt-in) so a directory-managed group governs nobody when empty instead of widening to everyone in its workspaces.Extracted shared primitives (UI and SCIM share one implementation): per-user session revocation with security-version bump, personal API-key revocation, suspend/unsuspend, member role change, workspace access grant/revoke, permission-group add/remove member. The member-role PUT route now goes through the role-change primitive under the org lock (closes a concurrent-promotion race).
Admin surface
/api/organizations/[id]/scim/**: settings, token issue/revoke (two active for rotation, SHA-256 digest only, secret shown once), group mappings, activity log, reconcile.Settings UI Settings → SSO → Directory provisioning (
ee/scim/): enable toggle, base URL, the three connection settings, token list with issue (optional expiry; shown once in a modal) and revoke, per-group mappings to permission groups / workspaces at a level / org admin, recent activity with failure detail, Reconcile now. Rendered on the hosted product and on self-hosted deployments with thescimfeature on. The organization roster marks a directory-deactivated member.Docs:
platform/enterprise/scim.mdxwith Okta/Entra/OneLogin/JumpCloud setup; corrected the two SSO doc statements that said deprovisioning was unavailable.Migration
0324_scim_provisioning(numbered after #7488's0323_oauth_provider) is expand-only: 9 new tables plus nullable/defaulted columns onuser(suspended_at,suspension_source) andpermission_group(membership_mode).check:migrationspasses.Backwards compatibility
membership_mode='inherit',suspended_at=NULL).PrincipalKindexcludesscim_connection, so no workspace operation can name it (compile-time).Verification
bun run check:audits— all 45 passbunx turbo run type-check— all 26 workspacesbun run build— passesuniqueness, org-unanchored managed-membership predicate, unimplementedautoMapPermissionGroupsByName, discovery endpoints unthrottled, rate limit below Entra's initial-cycle burst, and dead exports removed.scim-reconcilecron added in simstudioai/infra#330.defaultWorkspaceGrantsorg-checked (cross-tenant fix); row-lock inversion removed from User PUT/PATCH; PATCHadd emailskeeps the primary; canonical nestedname/enterprise accepted; owner skipped by org-role mappings; withdrawals that cannot hand ownership on are retried; relink syncs identity;disableJitenforced at SSO admission; conflicts not labelleduniqueness; workspace revoke transfers ownership first; batch invitations 403 for managed members; lock timeout → 409 on role change; managed-membership lock inert with flag off; settings all-members rule mode-aware; admin use cases split with shared audit projection; pure grant diff extracted; group list N+1 removed. 139 SCIM tests plus lifecycle primitive tests.Independent audit pass (after the review rounds)
Three auditors — plan adherence and abstractions, correctness and concurrency, tests and standards conformance — reviewed the whole tree rather than the latest diff. Their findings drove one consolidated pass:
removeUserFromOrganizationnow revokes sessions and personal API keys and retires the directory row into its tombstone in the same transaction, for the settings UI and SCIM alike. Deprovision is a single path; the defensive re-checks the review rounds had accumulated are gone.scim_projection_grant.origin(directory|adopted) records every satisfied mapping; adopted access is left alone on withdrawal unless the directory is the source of truth.lib/permission-groups/application/group-membership.ts; the settings routes and the workspace-member route use the shared primitives; direct workspace grants honor managed-membership lock.extralike create/replace (Entra default mappings); unknown group members dropped with a warning; legacy Entra User marker tolerated; the directory re-asserts a drifted account address.ssoProviderIdand the unused credentialscopesparameter removed; mappingsourcecolumn; indexes on the mapping and tombstone FKs.Certification pass
A final certifying reviewer traced every path end to end on the audited tree and produced a caller-by-caller regression table. Two items it found are fixed here: organization removal had started deleting personal API keys for every caller (now opt-in, used by directory deprovisioning only, and a member removing themselves keeps their own session), and the reconcile interval equalled the cron period so the sweep ran every other tick (now 50 minutes). The rest of its list — unused default-grants surface, dead helpers, unvaried parameters, a duplicated list helper, docs claims — is applied. The server code moved to
apps/sim/ee/scim/libalongside the other enterprise features, following theee/<feature>/liblayout that #7574 applies to every enterprise feature.Form factor against Okta, Entra, WorkOS, Vercel, Notion, Slack, and GitHub Enterprise: base URL plus bearer token, two rotatable credentials, group → access mappings, suspend-on-deactivate with everything kept, remove-on-delete with the account kept, RFC 7644 discovery, and the Okta/Entra payload tolerances all match or exceed the norm. Deliberate deviations, each documented: a verified domain is required for every provisioned address (account-takeover boundary), a deactivated member keeps their seat, and an email change signs the member out.
Certification pass, round two (after merging staging)
Three independent read-only auditors (IdP compatibility and docs, correctness and regressions against every shared file touched, code economy and tests) re-read the branch at its staging-merged head. Every verified finding is fixed in
fix(scim): certification audit round:schemas(Okta custom attributes, Entra custom extensions) were refused with 400; now accepted, kept under the resource, and echoed inschemas. The check moved into the write contracts.isAccountBlocked.scimTypeanddetailin the activity log.Not done: no live Okta or Entra tenant was exercised end to end. Verification is the audits, the tests, the review rounds, and the provider-behaviour tolerances checked against the providers' documentation.
Decisions recorded
Suspension on
userrather thanmember; new principal kind rather than reusing API-key principal; own PATCH applier rather thanscim-patch(which mishandles Entra's capitalizedRemoveand string booleans); own filter scanner rather than a dependency (closed grammar); managed-membership lock on by default; a suspended member keeps their seat (freeing it is a product decision, not taken here); admin reconcile and mapping changes project synchronously in the request (an async run with progress is a follow-up for very large tenants); bearer-token auth only (Entra gallery listing would additionally need OAuth client-credentials; custom Entra apps work as shipped).🤖 Generated with Claude Code
https://claude.ai/code/session_01DGXcwsHzYGp6pUDWRtJKpz