think
16px
820px

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)

  1. Scope: Users + Groups→roles. Full /Users and /Groups, with group membership
    driving Obscura role grants (reusing the LDAP group→role machinery).
  2. Deprovision: disable, never hard-delete. active=false AND SCIM DELETE both set
    users.disabled=true, revoke sessions, strip directory-granted roles; the account and
    its authored content/audit survive. active=true restores.
  3. Token: admin-minted in the UI. Shown once, stored as a SHA-256 hash, rotatable;
    one active token; presence of a token = SCIM enabled.
  4. 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 (with filter=userName eq "x" and externalId eq "x" — IdPs
    query before create to dedupe — plus startIndex/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 PATCH Operations.

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): map userName (or emails[primary].value) → email;
    displayName/name.formatteddisplay_name; active!disabled; store
    externalId in user_identities(idp='scim', subject=externalId) for IdP correlation.
    Provision as provider='scim', no password_hash. Duplicate userName/email → 409.
  • Update/PATCH: apply changed attributes; PATCH supports replace/add/remove on
    the mapped attributes + active.
  • Deprovision (active=false or DELETE): 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-POST matched by externalId/email) re-enables.
  • Login integration: the OIDC JIT path (ProvisionFromOIDC/UpsertUserByIdentity)
    must MATCH an existing provider='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'
    so scim users also cannot local-login (their empty hash must never hit
    the demo password==email convention).

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 to ldap_role_grants.
  • Reconcile a user's SCIM roles whenever the IdP changes membership (PATCH Group
    members, or a User create/update carrying groups) 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, RefreshEffectivePerms once 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 the rbac.admin perm from the LDAP tab):
    POST /admin/scim/token → generates a 32-byte random token, returns it ONCE in the
    response, stores only sha256(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>, sha256 it, look up a
    non-revoked row (constant-time compare), bump last_used_at; miss → SCIM 401.

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 /UsersGET /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).