Auth & Security

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:

MethodEndpointPurpose
GET/ServiceProviderConfigDiscovery (RFC 7643 §5)
GET/ResourceTypes[/{name}]Discovery
GET/Schemas[/{id}]Discovery
POST/UsersCreate a user
GET/Users[/{id}]List / read
PUT/Users/{id}Replace
PATCH/Users/{id}Partial update (incl. active=false)
DELETE/Users/{id}Remove
POST/GroupsCreate 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:

PATCH /Users/{id} active=false no yes Okta: deactivate user SetUserActive(false) member_id linked? audit scim.user.deprovisioned (allow) MemberDisabler.DisableMember tenant_api_key.status = revoked audit scim.user.deprovisioned (allow)

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:

  1. 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.
  2. Otherwise, a custom scim:<slug> role. The display name is slugified and registered as scim:<slug> with the viewer scope bundle as its starting point; an admin can re-bundle it later. The scim: prefix is non-negotiable — without it, a group named "admin" would try to redefine the built-in admin role, 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 of crypto/rand entropy, 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 sha256 of the full token is persisted; there is no plaintext column in the scim_bearer_token schema 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.
Mint a SCIM bearer token
# 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_key credentials SCIM deprovisioning revokes.
  • Authorization — the roles a mapped group grants.