Skip to main content

SCIM 2.0

The SCIM 2.0 endpoints let an Identity Provider — Okta, Azure AD, OneLogin — provision and de-provision Adjudon users without anyone clicking through the dashboard. The conformance target is RFC 7643 (core schema) and RFC 7644 (protocol). The Adjudon implementation runs at /scim/v2/* (deliberately outside the /api/v1/* namespace because that is what the IdP setup wizards expect), authenticates via a per-org bearer token, and maps SCIM Users to Adjudon User + OrgMember records and SCIM Groups to Adjudon role buckets. Tested against API version v1. Plan-gated by scim (Enterprise+) — lower tiers receive 403 UPGRADE_REQUIRED on every CRUD endpoint. Discovery endpoints are public per SCIM 2.0 § 4.

Mount, auth, and conformance posture

Endpoints sit at /scim/v2/<Resource>. The IdP sets one HTTP header:

Authorization: Bearer scim_<copy-once-token>

The bearer token is generated once via the dashboard's SSO settings, hashed (sha256) at rest, and compared with constant-time on every request — the plaintext token is never retrievable after the first reveal. Per-token and per-IP rate limits stack (600 req/15min on each); SCIM payloads accept both application/json and application/scim+json.

Adjudon's ServiceProviderConfig advertises filter.supported: true with a 200-result ceiling on a single response. Patch is supported on Users (add/replace/remove); Groups support patch only on members (the platform-defined role buckets cannot be created or deleted by an IdP).

The 17 endpoints

Discovery (public, no auth)
GET /scim/v2/ServiceProviderConfig
GET /scim/v2/ResourceTypes
GET /scim/v2/ResourceTypes/:id
GET /scim/v2/Schemas
GET /scim/v2/Schemas/:id

Users (bearer + plan-gate)
GET /scim/v2/Users list (filter + 1-based pagination)
GET /scim/v2/Users/:id
POST /scim/v2/Users provision
PUT /scim/v2/Users/:id full replace
PATCH /scim/v2/Users/:id partial update
DELETE /scim/v2/Users/:id deprovision (soft delete)

Groups (bearer + plan-gate)
GET /scim/v2/Groups
GET /scim/v2/Groups/:id
PATCH /scim/v2/Groups/:id members add/remove only
POST /scim/v2/Groups → 405 Method Not Allowed
PUT /scim/v2/Groups/:id → 405 Method Not Allowed
DELETE /scim/v2/Groups/:id → 405 Method Not Allowed

Discovery is public so the IdP setup wizards can introspect during configuration. Every other endpoint requires scim_* bearer auth.

The User resource

A SCIM User maps to one Adjudon User document. The mapping is deliberately small — SCIM is for identity propagation, not business-logic synchronisation:

SCIM fieldAdjudon fieldNotes
userNameUser.emailPrimary identifier; case-folded to lower
displayNameUser.nameFalls back to givenName + familyName, then userName.split('@')[0]
activeUser.isActivefalse triggers a soft-deprovision
externalIdUser.ssoIdThe IdP's stable user identifier
emails[].value(same as userName)First entry is canonical
name.givenName, name.familyName(composes displayName)Optional
Provision — POST /scim/v2/Users
curl -X POST https://api.adjudon.com/scim/v2/Users \
-H "Authorization: Bearer $ADJUDON_SCIM_TOKEN" \
-H "Content-Type: application/scim+json" \
-d '{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"userName": "[email protected]",
"displayName": "Alice Schmidt",
"active": true,
"externalId": "okta-00u1f2c4...",
"emails": [{ "value": "[email protected]", "primary": true }]
}'

The provisioning flow runs JIT role-mapping — the user's role is computed from the IdP's group membership against the org's roleMapping regex rules (configured in SSO settings). A user provisioned without a matching mapping rule lands at member by default.

A successful POST /Users creates one User document and one OrgMember document atomically and writes a scim.user.created entry to the Operations Audit Log. The audit entry stamps the bearer-token's tokenId, the IdP's externalId, and the JIT role-mapping decision — an auditor reading the log can reconstruct who provisioned whom, against which IdP, with which inferred role, without reading the SCIM request body itself.

Update flows: PUT vs PATCH

Both PUT and PATCH are supported. They are not interchangeable.

PUT /Users/:id is a full replace — the request body is the new representation of the User. Fields the IdP omits are treated as cleared, not preserved. This matches the SCIM spec's strict reading of PUT semantics; an IdP that means "update one field" must send PATCH instead, or PUT must include every field the User currently has. Adjudon does not silently merge a PUT body with prior state.

PATCH /Users/:id accepts the SCIM patch envelope:

{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{ "op": "replace", "path": "active", "value": false },
{ "op": "replace", "path": "displayName", "value": "Alice Schmidt-Berger" }
]
}

The supported operations are add, replace, and remove. Paths target individual scalar fields (userName, active, displayName) or the bulk replace of several attributes via a no-path operation whose value is the partial User object. Unknown paths return 400 invalidValue with a SCIM-shaped envelope; the patch is applied transactionally — either every operation succeeds or none of them do.

The deprovisioning idiom across IdPs is PATCH /Users/:id with {"op":"replace","path":"active","value":false}. This is equivalent to DELETE /Users/:id and lands the same audit-log entry (scim.user.deactivated); IdPs that send DELETE explicitly get the same behaviour.

Listing and filtering

GET /Users supports SCIM-protocol filter expressions and 1-based pagination:

curl "https://api.adjudon.com/scim/v2/Users?filter=userName+eq+%22alice%40example.com%22&startIndex=1&count=100" \
-H "Authorization: Bearer $ADJUDON_SCIM_TOKEN"
Query paramDefaultDescription
filterSCIM-protocol filter (userName eq "...", active eq true, displayName co "Alice")
startIndex11-based offset
count100Max 200 per response

The filter parser is hand-rolled (no scim2-parse-filter npm dependency) and supports the operators eq, ne, co, sw, ew, pr, plus and / or chaining and () grouping. Filters that exceed the parser's grammar return 400 invalidFilter with the SCIM-shaped error envelope.

Per-org token lifecycle

Each org provisions exactly one SCIM token at a time. Generating a new token revokes the previous one immediately — the old hash is overwritten on the Organization.ssoConfig document. An IdP that started syncing under the old token receives 401 on its next request and the operator must paste the new value into the IdP's SCIM connector. The sequence is intentional: SCIM tokens are write-equivalent to a global admin credential and the audit posture cannot tolerate two parallel tokens of unclear ownership.

Bearer tokens carry the prefix scim_ followed by 48 hex characters; the prefix lets log scrubbers match the format without leaking the secret. Tokens never expire by time alone; revocation is the only termination path.

The Group resource

Adjudon Groups are platform-defined role buckets, not customer- defined. The IdP cannot create, replace, or delete a Group; the allowed operation is PATCH on members to add or remove a user to/from a bucket. The current bucket set is owner, admin, member, reviewer. A PATCH /Groups/admin/members adding a user is the same as setting that user's OrgMember.role = 'admin'. A POST /Groups, PUT /Groups/:id, or DELETE /Groups/:id returns 405 Method Not Allowed with a SCIM-shaped error.

This is a deliberate divergence from the SCIM spec letter: the spec allows arbitrary group creation. Adjudon's role model is fixed because the regulator-readable audit trail needs stable role names that never drift across customers. Treat Groups as read-and-PATCH-only.

Errors

Every error returns the SCIM-shaped envelope:

{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"status": "409",
"scimType": "uniqueness",
"detail": "A user with this userName already exists"
}

Common codes:

HTTPscimTypeTrigger
400invalidValueMissing userName, malformed payload
400invalidFilterFilter the parser cannot resolve
401Bearer token missing or invalid (constant-time compare fails)
403Plan does not include scim (Enterprise+ required)
404User or Group not found inside the org scope
405Group create / replace / delete attempted
409uniquenessuserName collision on POST
429Per-token or per-IP rate limit exceeded (600/15min each)

Common gotchas

  • Plan-gated. SCIM is Enterprise-only; Sandbox, Scale, and Governance receive 403 UPGRADE_REQUIRED on every Users / Groups endpoint. Discovery endpoints are public regardless of plan — an IdP introspection request never leaks plan tier.
  • Bearer token is shown once. The plaintext is delivered once on the dashboard's SSO → SCIM tab; thereafter Adjudon stores only the sha256 hash. Lose the token and you generate a new one; there is no recovery path.
  • /scim/v2/* is outside /api/v1/* deliberately. IdP setup wizards (Okta, Azure AD, OneLogin) hard-code the SCIM path convention; mounting under /api/v1/scim would break their out-of-the-box configurations.
  • Soft-delete on DELETE. DELETE /Users/:id flips isActive to false and writes a SCIM-attributed entry into the Operations Audit Log. It does not hard-delete the User row — an audit reviewer can still resolve historic decisions made by the deprovisioned user.
  • Groups are read + PATCH only. This diverges from the SCIM spec's create/replace/delete shape and is documented at the endpoint level. The 405 carries a SCIM-shaped error explaining why.
  • Idempotency. The Idempotency-Key middleware is wired only on POST /traces; SCIM mutations do not auto-receive replay protection. The userName unique index defends against duplicate POSTs (returns 409 uniqueness); duplicate PATCHes are already idempotent at the protocol level.

What this resource is and is not

  • Not a session-management surface. SCIM provisions and deprovisions identities; it does not log users in or invalidate their JWTs. A SCIM-deactivated user still has a valid Adjudon JWT until the JWT itself expires or the Auth API revokes the session. For immediate cut-off, deprovision via SCIM and revoke the user's active sessions through the Auth API.
  • Not a directory mirror. Adjudon does not pull from the IdP on a schedule. The model is push-only: the IdP tells Adjudon when something changed, full stop. Periodic full-tenant syncs from Okta or Azure AD are tolerated by the rate limit but not required for correctness.
  • Not arbitrary-attribute storage. SCIM extension schemas beyond the core User and the role-bucket Group are not supported today. Custom attributes the IdP sends are silently ignored; this is documented at the Schemas discovery endpoint, which lists exactly the schemas Adjudon implements.

See also

  • Authentication — the JWT and API-key surfaces SCIM-provisioned users authenticate against
  • Auth API — the OIDC and SAML endpoints that complement SCIM for full SSO
  • Audit Log API — where every scim.user.* and scim.group.* event lands
  • Plans & Features — the scim feature gate that unlocks this resource
  • Error Codes — the broader error taxonomy this resource maps onto