SCIM 2.0 — user & group provisioning (Phase 14)
TASK-0132. An identity system (Okta, Microsoft Entra, …) provisions users and groups into Factory over SCIM 2.0. Factory is the SCIM target: the IdP calls our endpoints with an org bearer token. Conventions (error model, auth, IDs) are defined in authentication.md. This is a protocol surface — the only routes served outside
/api/v1(with the SAML/OIDC routes from TASK-0131), so it is excluded from the TASK-0127 OpenAPI contract by an explicit allowlist (see versioning.md).
Two surfaces
-
Protocol routes —
…/scim/v2/*, bearer-token auth, content typeapplication/scim+json. Served by the API outside/api/v1.Route Methods /scim/v2/UsersPOST(create),GET(list)/scim/v2/Users/:idGET,PATCH,DELETE/scim/v2/GroupsPOST(create),GET(list)/scim/v2/Groups/:idGET,PATCH,DELETE -
Admin routes —
/api/v1, Bearer, org owner (the org-admin role gate lands with TASK-0133 and tightens these):Route Method Purpose GET /api/v1/orgs/:orgId/scimGET Current config (masked — the raw token never returns) POST /api/v1/orgs/:orgId/scimPOST Enable: generate the scim_…token (reveal-once in this response)PATCH /api/v1/orgs/:orgId/scimPATCH rotate/disable/ updateuserEmailDomain/defaultRole/autoJoinOrgWorkspacesPOST /api/v1/orgs/:orgId/scim/testPOST Test-connection with a token pasted from the browser
Auth (protocol)
Authorization: Bearer scim_… on /scim/v2/* only. The raw token is
generated server-side on enable/rotate, shown once in that response, and
stored only as its SHA-256 (scimConfig.bearerTokenHash). A Redis hash index
(scim:index → orgId, plus scim:index:org:<orgId> → token hashes) makes
lookup O(1) with no per-request DB scan; it is rebuilt on demand and kept
in sync on enable/rotate/disable. A bad/unknown token → 401 invalidCredentials.
The token acts as the org: every audit row is actorType: "INTEGRATION",
actorId: <orgId>.
Users
Create — POST /scim/v2/Users. Body per the SCIM User schema.
userName maps to an email:
- if
userNamecontains@→ it is the email (lowercased as-is); - else
emails[0].valuewins when present; - else
<userName>@<scimConfig.userEmailDomain>— if that domain is unset →400 invalidValue.
Creates a User (email-verified; status ACTIVE unless active:false;
display name from name) + an OrganizationMember (role from
scimConfig.defaultRole, capped at MEMBER — SCIM can never grant a
higher role) + auto-join of the org's workspaces (autoJoinOrgWorkspaces).
If the payload carries a password it is bcrypt-hashed immediately (so the
user can log in with it) and is never logged or audited. Response 201
with the SCIM representation + Location + ETag. Duplicate email →
409 duplicate.
Read — GET /scim/v2/Users/:id (the SCIM id is the User id) and
GET /scim/v2/Users (list + filters + pagination). Cross-org access returns
404 (existence hiding, not 403).
Patch — PATCH /scim/v2/Users/:id with SCIM Operations on
active, name, displayName, emails, userName. replace active:false
= deactivate.
Delete — DELETE /scim/v2/Users/:id = deactivate (SCIM's DELETE
deprovisions; we disable, we never hard-delete). The User row survives with
status: SUSPENDED; all memberships are kept. The response meta and the
audit row (scim.user.deactivated { via: "delete" | "patch" }) make the
semantics explicit.
Deactivation (recorded decision)
Deactivating a user (via replace active:false or DELETE) sets
User.status → SUSPENDED, which the AuthGuard enforces at both layers:
- a fresh login is refused —
403{ reason: "account_suspended" }; - an in-flight session (a previously-issued access token) is rejected on
its next API call —
403{ reason: "account_suspended" }(the guard re-checks status per request; auditauth.suspended_blocked).
The account and its workspace/org memberships are preserved, so re-activating
(replace active:true) restores access.
Groups
A Group maps to a Team in the org's primary workspace (the first
non-personal, non-deleted workspace). displayName → team name (de-duplicated
with a numeric suffix); members[].value is our User id or an email, resolved
to a TeamMember (unresolved values are skipped + audited). PATCH adds
removes members and renames. DELETE removes the team — members keep
their workspace membership (only the team link is dropped; audited
scim.group.deleted).
Filters & pagination
?filter= accepts a SCIM filter-grammar subset (parser in
scim/filters.ts, unit-tested):
- attributes:
userName(→ email),active,id,displayName; - operators:
eq,pr(present); - combinators:
and,or(one level, depth-capped); - anything else (e.g.
neq) →400 invalidFilter(empty filter →400).
Pagination: startIndex (1-based) + count (≤ 100, default 50). The list
response is a SCIM ListResponse:
{ "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
"totalResults": 7, "startIndex": 1, "itemsPerPage": 50,
"Resources": [ … ] }
ETag
Each resource carries a weak ETag W/"scim-<16hex>" computed from the
canonical representation + updatedAt (computed, not stored). PATCH/DELETE
accept If-Match; a mismatch → 412 (Precondition Failed) before any
mutation. * and comma-lists are honored.
Error envelope
Every non-2xx returns application/scim+json in the RFC 7644 shape
(status is the HTTP status as a string):
{ "schemas": ["urn:ietf:params:scim:api:errors:2.0"],
"status": "409", "detail": "A User with userName 'jane@acme.test' already exists.",
"scimType": "duplicate" }
| status | scimType |
when |
|---|---|---|
400 |
invalidFilter / invalidValue |
unsupported filter / bad field (e.g. @-less userName with no userEmailDomain) |
401 |
invalidCredentials |
missing/bad/disabled bearer token |
403 |
forbidden |
(reserved) |
404 |
— | resource not found / cross-org |
409 |
duplicate |
email already exists |
412 |
preconditionFailed |
If-Match mismatch |
Audit
scim.user.created, scim.user.updated, scim.user.deactivated,
scim.group.created, scim.group.updated, scim.group.deleted — all
actorType: "INTEGRATION", actorId: <orgId>, with an operation summary in
metadata (never the full payload, never a password). Admin actions are
audited too: scim.token.created (enable/rotate), scim.token.disabled,
scim.config.updated.
Admin flow (SCIM card)
The Integrations tab SCIM card (apps/web/src/components/admin/ScimCard.tsx):
- Enable → generates the
scim_…token, shown once with a copy button + a "shown only once / stored as a hash" warning (like API keys). - Shows the endpoint URL (
<SSO_ORIGIN|WEB_ORIGIN>/scim/v2) + a curl example for the IdP admin. - Test connection →
POST …/scim/testwith a token pasted from the browser (the server never stores it); green/red result panel. - Rotate → new reveal-once token; the old token stops working immediately.
- Disable → confirm dialog; the token is invalidated immediately
(Redis index removed +
scimConfigcleared) — a subsequent protocol call →401. Provisioned users keep their accounts.
Wire examples
Create user (IdP → us):
POST /scim/v2/Users
Authorization: Bearer scim_…
Content-Type: application/scim+json
{ "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"userName": "jane.doe", "name": { "givenName": "Jane", "familyName": "Doe" },
"emails": [{ "value": "jane@acme.test", "primary": true }],
"password": "initial-rotated-by-idp", "active": true }
→ 201 Location: /scim/v2/Users/usr_01J9…
ETag: W/"scim-9f2c1ab4d0e35c67"
{ "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], "id": "usr_01J9…",
"userName": "jane@acme.test", "active": true,
"meta": { "resourceType": "User", "created": "…", "location": "…",
"version": "W/\"scim-9f2c1ab4d0e35c67\"" } }
Deactivate (replace active, with ETag):
PATCH /scim/v2/Users/usr_01J9…
If-Match: W/"scim-9f2c1ab4d0e35c67"
Content-Type: application/scim+json
{ "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [{ "op": "replace", "path": "active", "value": false }] }
→ 200 (User.status → SUSPENDED; login + in-flight sessions blocked; membership kept)
List with filter + pagination:
GET /scim/v2/Users?filter=active%20eq%20false&startIndex=1&count=50
→ 200 { "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
"totalResults": 7, "startIndex": 1, "itemsPerPage": 50, "Resources": [ … ] }