# SCIM 2.0 provisioning (/docs/auth/scim-provisioning)



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-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 [#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:

```mermaid
flowchart TD
    A["Okta: deactivate user"] -->|"PATCH /Users/{id} active=false"| B["SetUserActive(false)"]
    B --> C{"member_id linked?"}
    C -->|"no"| D["audit scim.user.deprovisioned (allow)"]
    C -->|"yes"| E["MemberDisabler.DisableMember"]
    E --> F["tenant_api_key.status = revoked"]
    F --> G["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."

<Callout title="A failed deprovision still leaves an audit trail" type="warn">
  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.
</Callout>

## Group display name → RBAC role [#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](/docs/auth/authorization#roles-bundles-of-scopes)
for what those roles grant.

## Bearer tokens: minted once, only the hash persists [#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.

```bash title="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 [#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.

<Callout title="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.
</Callout>

## Every failure looks identical on the wire [#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 [#where-to-go-next]

* [SAML 2.0 SSO](/docs/auth/sso-saml) — the sign-in half of enterprise identity;
  SCIM handles lifecycle, SAML handles authentication.
* [API keys](/docs/auth/api-keys) — the `tenant_api_key` credentials SCIM
  deprovisioning revokes.
* [Authorization](/docs/auth/authorization) — the roles a mapped group grants.
