SCIM 2.0 provisioning
Automated user and group lifecycle from your IdP — the /scim/v2 router, the deprovision-revokes-key wire, group-to-role mapping, and bearer-token custody.
SCIM (System for Cross-domain Identity Management) lets your identity provider push user and group lifecycle into Ductor automatically: when Okta creates, updates, or deactivates a user, Ductor's provisioning surface reflects that change without a human in the loop. The load-bearing guarantee is deprovision-revokes-key — an Okta deactivation revokes the linked Ductor API key. This page documents the wire surface, the role bridge, and token custody.
The wire surface
The SCIM server is a hand-rolled chi router mounted under /scim/v2. Every
response — success and error — carries Content-Type: application/scim+json. The full RFC 7643/7644 surface is implemented:
discovery, plus Users and Groups CRUD with PATCH.
Everything is mounted under /scim/v2:
| Method | Endpoint | Purpose |
|---|---|---|
| GET | /ServiceProviderConfig | Discovery (RFC 7643 §5) |
| GET | /ResourceTypes[/{name}] | Discovery |
| GET | /Schemas[/{id}] | Discovery |
| POST | /Users | Create a user |
| GET | /Users[/{id}] | List / read |
| PUT | /Users/{id} | Replace |
| PATCH | /Users/{id} | Partial update (incl. active=false) |
| DELETE | /Users/{id} | Remove |
| POST | /Groups | Create a group |
| GET | /Groups[/{id}] | List / read |
| PUT | /Groups/{id} | Replace |
| PATCH | /Groups/{id} | Partial update (membership) |
| DELETE | /Groups/{id} | Remove |
The bearer middleware sits at the router root, so every route — including
discovery — is authenticated. Discovery is left behind auth deliberately:
meta.location values in responses leak the tenant URL pattern, so exposing
them unauthenticated would be an information leak.
Deprovision revokes the key
This is the reason SCIM exists here. When your IdP deactivates a user — a
PATCH with active=false, or a DELETE — Ductor calls
MemberDisabler.DisableMember, which flips the linked tenant_api_key row to
revoked. The provisioned SCIM user and the Ductor credential are two ends of
one wire:
DELETE mirrors this: it disables the linked member first, then hard-deletes
the SCIM user row — the IdP's "remove this identity entirely."
A failed deprovision still leaves an audit trail
If DisableMember fails, the service emits a
scim.user.deprovisioned.failed audit event with Decision=deny before
bubbling the error up. The tamper-evident hash chain must record the attempt
regardless of outcome — a deprovision that errored out silently would be
exactly the gap an attacker or a misconfiguration could hide in. On the
UpdateUser path the SCIM user row is also rolled back to its prior state so
a half-applied deactivation cannot linger.
Group display name → RBAC role
A SCIM Group's displayName maps to a Ductor RBAC role. The resolution order:
- Built-in match first (case-insensitive). If the display name matches a
built-in role —
viewer,operator,admin,service— that built-in is used verbatim, with no new role registered. - Otherwise, a custom
scim:<slug>role. The display name is slugified and registered asscim:<slug>with the viewer scope bundle as its starting point; an admin can re-bundle it later. Thescim:prefix is non-negotiable — without it, a group named "admin" would try to redefine the built-inadminrole, which the RBAC layer forbids.
A user's effective roles are the deduplicated union across their current group membership. Removing a user from a group drops that group's role unless another group they still belong to carries the same role — so membership changes converge to the correct role set rather than accumulating stale grants. See Authorization → Roles for what those roles grant.
Bearer tokens: minted once, only the hash persists
SCIM clients authenticate with a dedicated bearer token, separate from Ductor API keys and SAML sessions:
- Shape.
dct_scim_<base64url(rand32)>— 32 bytes ofcrypto/randentropy, well above the 128-bit floor for bearer tokens. The prefix is part of the token and is included in the hash, so operators can spot the token type at a glance. - Storage. Only the
sha256of the full token is persisted; there is no plaintext column in thescim_bearer_tokenschema at all. The plaintext is returned exactly once at mint time and can never be retrieved again. - Verification. The middleware hashes the presented bearer, looks up the active row by hash, and — defense in depth even though the lookup is by hash — constant-time-compares the stored hash. The plaintext is never logged on any code path.
# Prints the plaintext ONCE — capture it into your IdP's SCIM config now.
ductor scim-token mint --tenant acme --name "okta-prod"Cross-tenant access returns 404, not 403
Every SCIM operation is scoped to the tenant the presenting bearer was minted for — the tenant comes from the token row, never from a URL segment or header, so a request can never claim a tenant other than its own. When a request references a resource in another tenant (or one that does not exist), the service returns a structured not-found that transport renders as 404, not 403.
404 is the deliberate answer to an existence oracle
A 403 on a cross-tenant read would confirm that the resource exists
somewhere — an existence oracle across the tenant boundary. Returning 404
makes "you may not see this" and "this does not exist" indistinguishable, so
an IdP integration (or a stolen bearer) cannot enumerate another tenant's
users or groups. The same guard applies to group-membership mutations: a
cross-tenant group is a 404, never a silent no-op on a foreign resource.
Every failure looks identical on the wire
Like the SAML SP, the SCIM bearer middleware defeats an auth oracle: every
rejection branch — missing header, malformed header, unknown hash, revoked
token, empty tenant, hash mismatch — writes a single canonical 401 with a
byte-identical body and WWW-Authenticate header. The distinguishing reason
lives only in slog. The only variation a caller sees on a handler error is
the RFC 7644 scimType→status mapping for well-formed requests (e.g.
uniqueness→409, invalidValue→400).
Where to go next
- SAML 2.0 SSO — the sign-in half of enterprise identity; SCIM handles lifecycle, SAML handles authentication.
- API keys — the
tenant_api_keycredentials SCIM deprovisioning revokes. - Authorization — the roles a mapped group grants.
SAML 2.0 SSO
Ductor's receive-only SAML Service Provider — the per-tenant /saml router, replay-before-mint ACS pipeline, attribute-to-role derivation, and its oracle-defense posture.
Authorization
RBAC roles and scopes, the method policy registry, deny-by-default evaluation, the tenant boundary, and audit logging.