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 field | Adjudon field | Notes |
|---|---|---|
userName | User.email | Primary identifier; case-folded to lower |
displayName | User.name | Falls back to givenName + familyName, then userName.split('@')[0] |
active | User.isActive | false triggers a soft-deprovision |
externalId | User.ssoId | The IdP's stable user identifier |
emails[].value | (same as userName) | First entry is canonical |
name.givenName, name.familyName | (composes displayName) | Optional |
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 param | Default | Description |
|---|---|---|
filter | — | SCIM-protocol filter (userName eq "...", active eq true, displayName co "Alice") |
startIndex | 1 | 1-based offset |
count | 100 | Max 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:
| HTTP | scimType | Trigger |
|---|---|---|
400 | invalidValue | Missing userName, malformed payload |
400 | invalidFilter | Filter the parser cannot resolve |
401 | — | Bearer token missing or invalid (constant-time compare fails) |
403 | — | Plan does not include scim (Enterprise+ required) |
404 | — | User or Group not found inside the org scope |
405 | — | Group create / replace / delete attempted |
409 | uniqueness | userName collision on POST |
429 | — | Per-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_REQUIREDon 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/scimwould break their out-of-the-box configurations.- Soft-delete on DELETE.
DELETE /Users/:idflipsisActivetofalseand 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-Keymiddleware is wired only onPOST /traces; SCIM mutations do not auto-receive replay protection. TheuserNameunique index defends against duplicate POSTs (returns409 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
Schemasdiscovery 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.*andscim.group.*event lands - Plans & Features —
the
scimfeature gate that unlocks this resource - Error Codes — the broader error taxonomy this resource maps onto