Status: P2 backlog item 1 ("SCIM and external group reconciliation") of
docs/ENTERPRISE_ARCHITECTURE_ROADMAP.md. Implements the core of SCIM 2.0
(RFC 7643/7644) Users provisioning — enough for a real institutional IdP
(Okta, Azure AD, OneLogin, ...) to automatically create, deactivate, and
reactivate accounts as people join and leave the organization's directory.
Groups are explicitly out of scope for this slice — see below.
A SCIM "create user" call creates an Invitation, not a new user record.
This was a deliberate product decision, not an implementation shortcut —
asked directly rather than guessed at, given the real security stakes of an
identity-binding mechanism. The alternative (making externalSubject
optional on User and auto-binding a real OIDC login to a pre-provisioned
record by matching email) is how real Okta/Azure AD SCIM+OIDC integrations
usually work, but it needs a schema/store migration and new login-time
resolution logic with genuine account-takeover-adjacent risk if the
matching logic has a bug. Reusing the existing, already-tested Invitation
mechanism was chosen as the smaller, safer path.
The consequence: a SCIM resource's id is the Invitation's id while
the invitation is pending. Once the invitee actually logs in and accepts
(via the normal POST /organizations/:id/invitations/:id/accept flow), the
resource becomes a real User/Membership with a different id. A
persistent SCIM client's own reconciliation loop — GET .../Users?filter=userName eq "...", which is what every real IdP actually
polls with for idempotent sync, not a remembered id from months ago —
always converges on the truth. A client that specifically cached the old
pending-invitation id would see it 404 after acceptance. This is disclosed
here and in routes/scim.ts's own doc comment, not silently glossed over.
SCIM endpoints (/scim/v2/organizations/:organizationId/...) are
authenticated with a static bearer token, never OIDC — SCIM
provisioning happens before a real login, so there is no sub claim yet
to verify against. An org admin holding scim:manageTokens creates a token
via POST /organizations/:id/scim-tokens (OIDC-authenticated, like every
other admin route) — the plaintext secret is shown exactly once in that
response and never retrievable again (GET .../scim-tokens returns
metadata only). Paste it into the IdP's SCIM connector configuration as its
bearer token.
POST /scim/v2/organizations/:id/Users returns a non-standard
modelforgeInviteToken field alongside the standard SCIM User
representation — the plaintext acceptance token the invitee needs to call
POST .../invitations/:id/accept. SCIM itself has no concept of "a secret
the provisioning target needs delivered out of band," and a SCIM
connector has no mechanism to relay a response field to the actual human
being provisioned (it just syncs directory state). The organization is
responsible for actually getting this to the invitee — most IdPs' SCIM
connectors surface the raw response in their own provisioning/request logs
for exactly this kind of operational need. This is the same operational
reality as an admin-console-created invitation (also delivered out of
band, by design — this codebase has no built-in email sender for either
path), just automated instead of a human copying a token out of a browser.
GET/POST /scim/v2/organizations/:id/Users— list (withfilter=userName eq "value", pagination viastartIndex/count) and create.GET/PUT/PATCH/DELETE /scim/v2/organizations/:id/Users/:id.GET /scim/v2/organizations/:id/ServiceProviderConfigand/ResourceTypes— lightweight discovery endpoints several IdP setup wizards fetch before the first real Users call.- PATCH supports both real-world shapes for deactivation: Azure AD's
path-based
{op:"replace", path:"active", value:false}and Okta's value-object{op:"replace", value:{active:false}}. Any other operation in the payload is a documented no-op (RFC 7644 §3.5.2 treats an unrecognized attribute as valid, not an error). - DELETE never hard-deletes — matching this codebase's standing
convention elsewhere (MasterVault's soft-delete-only design,
tenant-backup's non-destructive restore). It has the same effect as
PATCH/PUT with
active=false: revoke a still-pending invitation, or suspend an existing membership. The user record itself is never removed. - SCIM uniqueness semantics: creating a
userNamethat already resolves to an existing user or pending invitation is409withscimType: "uniqueness", not a silent duplicate. - Every SCIM-driven mutation is audited (
scimToken.create/.revoke, plus the normalinvitation.create/user.update/etc. entries the underlying operations already produce), attributed toscim:<tokenId>as the actor.
- Groups. SCIM group-membership push would need a way to apply a grant to a still-pending (Identity-less) invitee, which does not exist. Not attempted.
- Full SCIM filter grammar (RFC 7644 §3.4.2.2's and/or/not, other
operators, complex attribute paths). Only
userName eq "value"is supported — the one expression every real institutional IdP actually sends for idempotent sync. An unsupported filter is rejected with400, not silently ignored (which could return an unfiltered list a client might mistake for "no matches"). - Bulk operations (
ServiceProviderConfigcorrectly reportsbulk.supported: false). - PUT-based rename of a still-pending invitation. There is no
PrincipalStore.updateInvitationmethod; PUT only ever acts onactive. Renaming an already-accepted user via PUT does work (the normalupdateUserpath). - SCIM's
PATCH .../Groups,emails[type=...]multi-value semantics, and custom schema extensions. Not modeled.
- In the admin console (or via
POST /organizations/:id/scim-tokensdirectly), create a SCIM token withscim:manageTokens. - Configure the institution's IdP SCIM connector with base URL
https://<host>/scim/v2/organizations/<organizationId>and the token as its bearer credential. - Assign people to the relevant directory group in the IdP; its SCIM
connector will call
POST .../Users. - Retrieve
modelforgeInviteTokenfrom the IdP's own SCIM request log and deliver it to the invitee through whatever channel the institution already uses. - Deprovisioning (removing someone from the directory group, or
disabling their IdP account) triggers
PATCH/DELETEautomatically — no manual action needed on this side.