SCIM 2.0 Inbound Provisioning — Design
Date: 2026-07-04 · Status: Approved (brainstorm; user deferred SCIM specifics to me)
Module: CORE platform (no license gate). Queue #2 (after LDAP/AD ✅; then observability #3, backup system #4). Disabled by default.
Goal
An external IdP (Okta / Entra / OneLogin) provisions Obscura accounts and pushes group
membership over the standard SCIM 2.0 protocol (RFC 7643/7644). Users are created, updated,
and deprovisioned automatically as employees join/change/leave; group membership maps to
Obscura roles. Authentication remains via OIDC SSO — SCIM only provisions.
Decisions (from brainstorm)
- Scope: Users + Groups→roles. Full
/Usersand/Groups, with group membership
driving Obscura role grants (reusing the LDAP group→role machinery). - Deprovision: disable, never hard-delete.
active=falseAND SCIMDELETEboth set
users.disabled=true, revoke sessions, strip directory-granted roles; the account and
its authored content/audit survive.active=truerestores. - Token: admin-minted in the UI. Shown once, stored as a SHA-256 hash, rotatable;
one active token; presence of a token = SCIM enabled. - Login pairing: SCIM users (
provider='scim', no password) authenticate via OIDC;
the OIDC path links to the existing SCIM account rather than duplicating.
The SCIM surface (internal/scim context, /scim/v2/)
Authenticated by the minted bearer token (constant-time hash compare), NOT user
sessions. All responses use SCIM media type application/scim+json and RFC error envelopes.
- Discovery (IdPs probe these on connect):
GET /scim/v2/ServiceProviderConfig— advertise patch=true, filter=true, bulk=false,
sort=false, changePassword=false, etag=false, and bearer auth.
GET /scim/v2/ResourceTypes,GET /scim/v2/Schemas— static User + Group schema JSON. - Users:
GET /Users(withfilter=userName eq "x"andexternalId eq "x"— IdPs
query before create to dedupe — plusstartIndex/count),POST /Users,
GET/PUT/PATCH/DELETE /Users/{id}. - Groups:
GET /Groups(+filter=displayName eq),POST,GET/PUT/PATCH/DELETE /Groups/{id}; membership add/remove via PATCHOperations.
Minimal filter parser: supports <attr> eq "<value>" for the attributes above only;
anything else → 400 invalidFilter. No bulk, no sort, no cursor beyond startIndex/count.
Users ↔ Obscura accounts
- Create (
POST /Users): mapuserName(oremails[primary].value) →email;
displayName/name.formatted→display_name;active→!disabled; store
externalIdinuser_identities(idp='scim', subject=externalId)for IdP correlation.
Provision asprovider='scim', no password_hash. DuplicateuserName/email →409. - Update/PATCH: apply changed attributes;
PATCHsupportsreplace/add/removeon
the mapped attributes +active. - Deprovision (
active=falseorDELETE):SetUserDisabled(true)→ block login +
revoke live sessions (reuse the existing LogoutAll/disable path) + strip
scim_role_grants(and leave manual bindings untouched). Idempotent.active=true(or a
later re-POSTmatched by externalId/email) re-enables. - Login integration: the OIDC JIT path (
ProvisionFromOIDC/UpsertUserByIdentity)
must MATCH an existingprovider='scim'user by email (or externalId) and issue a
session without creating a second row. The already-shipped guard that keeps
provider != 'local'users off the local-password path is extended from=='ldap'to
!= 'local'soscimusers also cannot local-login (their empty hash must never hit
the demopassword==emailconvention).
Groups → roles (parallels LDAP, provision-time trigger)
scim_groups(id uuid PK, external_id text, display_name text, updated_at)+
scim_group_members(group_id, user_id, PK(group_id,user_id))— the pushed membership.scim_group_roles(scim_group_id → role_id)— admin-managed mapping (Admin → Directory).scim_role_grants(user_id, role_id, scim_group_id, granted_at, PK(user_id,role_id))—
the grant ledger, identical shape toldap_role_grants.- Reconcile a user's SCIM roles whenever the IdP changes membership (PATCH Group
members, or a User create/update carryinggroups) OR a mapping changes: grant missing
mapped roles (via the rbac binder + ledger row), revoke ledger rows whose group no longer
maps/contains the user,RefreshEffectivePermsonce on change. Manual bindings never
touched. Reconciliation errors are logged, non-fatal to the SCIM operation. - LDAP and SCIM keep separate group→role tables and grant ledgers (different sources,
different triggers) but both feed the same rbac binder; a role can be granted by both
without conflict (each ledger tracks its own).
Token management
scim_tokens(id uuid PK, token_hash text, created_at, created_by, last_used_at, revoked_at). Admin-only endpoints (reuse therbac.adminperm from the LDAP tab):
POST /admin/scim/token→ generates a 32-byte random token, returns it ONCE in the
response, stores onlysha256(token), revokes any prior active token;GET /admin/scim/status→ {enabled (an active token exists), base_url, last_used_at,
provisioned_user_count};DELETE /admin/scim/token→ revoke (disables SCIM).- SCIM request auth middleware: parse
Authorization: Bearer <t>,sha256it, look up a
non-revoked row (constant-time compare), bumplast_used_at; miss → SCIM401.
Admin UI
Extend the existing Admin → Directory (LDAP) tab into Admin → Directory with an
added SCIM card: the base URL to paste into the IdP, a Generate/Rotate/Revoke token
control (token shown once in a copyable field), status (enabled, last used, #provisioned),
and the SCIM group→role mapping table (same UX as the LDAP mapping table). en/id.
Operator guide
docs/SCIM.md — what to configure in Okta/Entra: the SCIM base URL
(https://<host>/scim/v2), the bearer token, "unique identifier field = userName",
supported attributes/mappings, push-groups setup, and a troubleshooting table (401 token
vs 404 vs 409 duplicate vs filter-unsupported). Upload the rendered md per CLAUDE.md.
Error handling
RFC 7644 §3.12 error envelopes ({schemas:["urn:...:Error"], status, scimType, detail}).
409 uniqueness on duplicate userName; 404 {...}; 401 on bad/absent/revoked token; 400
invalidValue/invalidFilter. SCIM operations never touch local/oidc/ldap users' provider
or password. A SCIM failure never affects an unrelated user.
Testing (repo discipline: never go test on the live DB)
Build/vet + tsc/vite; scripted-curl e2e simulating an IdP against the deployed stack:
mint token → discovery probe (ServiceProviderConfig) → POST /Users → GET
/Users?filter=userName eq dedup → PATCH active=false → assert disabled=true +
session revoked → POST /Groups + PATCH add member → map group→role → assert role granted
(ledger + binding) → remove mapping → assert revoked → manual binding survives → DELETE
/Users/{id} → disabled → bearer-token matrix (valid/invalid/revoked → 401). Cleanup: purge
provisioned users + scim_* rows + revoke token. Demo intact, 5 modules, LDAP+SCIM disabled
by default.
Out of scope (YAGNI)
Bulk (/Bulk), /Me, ETags/versioning, sort, cursor pagination, SCIM-carried passwords,
enterprise-extension beyond storing department/manager if present, multiple active tokens,
SCIM as an auth method (it is provisioning only).