Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

SCIM v2 Support

This page is a protocol-level reference for whoever configures the SCIM side of an Identity Provider connector (Okta, Entra ID, Workday, …) against Keystone, and a compatibility matrix against RFC 7644 for anyone evaluating whether Keystone’s SCIM implementation fits their IdP’s requirements. For how to register a realm and grant it access, see the Administrator Guide. For full design rationale, see ADR 0024.

Keystone implements a deliberately restricted subset of RFC 7644, not full compliance — the restrictions exist to bound worst-case query/PATCH complexity per request (the same posture applied elsewhere in this codebase to claim mapping and rate limiting). Most enterprise IdPs, including Okta and Entra ID, tolerate a narrower filter grammar and bulk.supported: false without issue; check the matrix below against your specific connector before assuming a feature works.

Base URL & Authentication

https://<host>/SCIM/v2/{domain_id}/...
Authorization: Bearer kscim_...

Every request is scoped to one domain and authenticates with a bearer API key (see API-Key Authentication) — never a Fernet token. The key must belong to an active, registered realm for that (domain_id, provider_id) coordinate, or every request gets 403 regardless of role (see Realm Activation Gate).

Content Negotiation

Request bodies (POST/PUT/PATCH carrying a payload) must declare Content-Type: application/scim+json or, for connectors that only speak plain JSON, application/json — either is accepted. Anything else, or a missing header on a request that does carry a body, is rejected with 415 Unsupported Media Type. Every response carries Content-Type: application/scim+json, including error responses.

Discovery

GET /SCIM/v2/{domain_id}/ServiceProviderConfig
GET /SCIM/v2/{domain_id}/Schemas
GET /SCIM/v2/{domain_id}/ResourceTypes

Unauthenticated within the SCIM sub-router (bearer auth is still accepted, just not required) — most connectors probe these before presenting credentials. ServiceProviderConfig honestly advertises what’s not supported rather than claiming full compliance:

{
  "patch": { "supported": true },
  "bulk": { "supported": false, "maxOperations": 0, "maxPayloadSize": 0 },
  "filter": { "supported": true, "maxResults": 200 },
  "changePassword": { "supported": false },
  "sort": { "supported": false },
  "etag": { "supported": true },
  "authenticationSchemes": [{ "type": "oauthbearertoken", "primary": true }]
}

Resource Endpoints

POST   /SCIM/v2/{domain_id}/Users            GET   .../Users        GET .../Users/{id}
PUT    /SCIM/v2/{domain_id}/Users/{id}        PATCH .../Users/{id}   DELETE .../Users/{id}

POST   /SCIM/v2/{domain_id}/Groups            GET   .../Groups       GET .../Groups/{id}
PUT    /SCIM/v2/{domain_id}/Groups/{id}       PATCH .../Groups/{id}  DELETE .../Groups/{id}

An HTTP method not mapped for a given path (e.g. POST .../Users/{id}, or any method on /ServiceProviderConfig other than GET) returns 405 Method Not Allowed.

Ownership fencing

A resource is only visible to the realm that created it. GET/PUT/PATCH/ DELETE against an id owned by a different realm — or a same-ID resource that doesn’t exist at all — both return an identical 404, by design: this prevents realm-boundary probing via response-shape differences.

POST — required fields, schemas validation

The request body’s schemas array must contain the resource’s core schema URI (urn:ietf:params:scim:schemas:core:2.0:User / ...:Group) — a missing or mismatched schemas array is rejected with 400 invalidValue. For Users, externalId is additionally mandatory (400 if empty/absent) and drives deterministic id derivation so a later federated JIT login for the same IdP sub claim converges onto the same account rather than creating a duplicate.

Response meta

Every response carries meta.location (an absolute URL), and 201 Created responses on POST additionally carry an HTTP Location header matching it. RFC 7644 doesn’t mandate the header outside 201; the body field is present on every response regardless.

Attribute Mapping (User)

SCIM attributeKeystone field
iddeterministic (domain_id + externalId)
externalIdrealm-scoped ownership index
userNamename
activeenabled
name.givenName / name.familyNameextension attributes
emails[primary eq true].valueextension attribute
displayNameextension attribute

Attribute Mapping (Group)

SCIM attributeKeystone field
idserver-assigned
externalIdrealm-scoped ownership index
displayNamename
membersresolved membership, capped at 1000 entries, must reference Users owned by the same realm

A members entry referencing a user owned by a different realm, or a manually-created user with no SCIM ownership record at all, is rejected with 400 invalidValue on both PUT and PATCH add.

DELETE semantics

Neither Users nor Groups are hard-deleted by DELETE. A User is disabled and its sessions revoked; a Group has its role assignments immediately stripped (closing live authorization) while its membership snapshot is retained for a forensic window. Both become invisible to subsequent GET/PUT/PATCH/List (404) immediately. See Deprovisioning & retention for the retention window and operator purge-now path — this deviates from a strict reading of RFC 7644 §3.6, which doesn’t distinguish soft- from hard-delete.

Filtering

filter := term (LOGICAL_OP term)*      # "and"/"or" MUST NOT be mixed in one filter string
term    := ATTR OP value
OP      := eq | ne | co | sw | pr

No nested/parenthesized expressions, no complex-attribute filters (emails[type eq "work"]). A filter string over 512 bytes or 8 terms is rejected. Violations return 400 invalidFilter.

User attributeAllowed operators
userNameeq, ne, co, sw, pr
externalIdeq, ne, pr
ideq, pr
activeeq, pr
Group attributeAllowed operators
displayNameeq, ne, co, sw, pr
externalIdeq, ne, pr
ideq, pr

Pagination

startIndex (1-based, default 1), count (default and max 200). No cursor/continuation token — a bounded scan over the realm’s own resource set, excluding deprovisioned entries.

PATCH

Operations: [{op, path, value}], add/replace/remove only, restricted to these top-level scalar paths:

ResourcePatchable paths
Useractive, userName, displayName, externalId, name.givenName, name.familyName
GroupdisplayName, externalId, members (add/remove only — no replace)

id and meta are real SCIM attributes but always immutable — a PATCH naming either returns 400 mutability (distinct from a path that isn’t a recognized attribute at all, which returns 400 invalidPath). Any other unrecognized path, an array-index path, or a complex filter expression (emails[type eq "work"].value) also returns 400 invalidPath. PUT performs a full declarative replace, including a full membership resync for Groups.

ETags & Concurrency

GET/PUT/PATCH/POST responses carry a weak ETag: W/"<version>". Send If-Match: W/"<version>" on PUT/PATCH to get an atomic compare-and-swap; a stale version returns 412 Precondition Failed (no response body). This closes the lost-update race for concurrent push-group syncs from a single IdP without needing a distributed lock.

/Bulk and /Me

Both return 501 Not Implemented with a SCIM-shaped error body rather than a generic 404 — RFC 7644 clients commonly probe these before falling back. Neither is planned for this subset (see Explicitly out of scope below).

Errors

Standard envelope:

{
  "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
  "status": "409",
  "scimType": "uniqueness",
  "detail": "userName already exists within this domain"
}
ConditionHTTP statusscimType
Realm not registered / disabled403(no body)
Resource not owned by caller’s realm404(no body)
userName/displayName/externalId collision409uniqueness
Missing/wrong request schemas, cross-realm membership reference, invalid PATCH value400invalidValue
Disallowed filter attribute/operator/mixed chain/oversized400invalidFilter
Disallowed or unrecognized PATCH path400invalidPath
PATCH targeting a real but immutable path (id, meta)400mutability
Malformed JSON request body400invalidSyntax
Unsupported/missing Content-Type on a bodied request415(no body)
Unmapped HTTP method on a mapped path405(no body)
If-Match version mismatch412(no body)
/Bulk, /Me501(no body)

noTarget, tooMany, invalidVers, and sensitive are defined by RFC 7644 §3.12 but never emitted — see the compatibility matrix below for why.

Explicitly out of scope

/Bulk, sortBy/sortOrder, arbitrary filter/PATCH path expressions (emails[type eq "work"]), and multi-valued complex-attribute addressing. Extending any of these requires a ratifying ADR 0024 revision given their DoS/complexity surface.


RFC 7644 Compatibility Matrix

RFC 7644 §FeatureStatusNotes
§3.1HTTP methods, Content-Type, Location headerSupportedAccepts application/json in addition to application/scim+json
§3.2Discovery (ServiceProviderConfig/Schemas/ResourceTypes)SupportedHonestly advertises unsupported features rather than over-claiming
§3.3POST (create)Supportedschemas and (User) externalId are mandatory
§3.4.2FilteringPartialRestricted attribute/operator allowlist, homogeneous and/or only, no nesting — see Filtering
§3.4.2.2Alternative search (POST .search)Not supported
§3.4.3PaginationSupportedOffset/count only, no continuation token
§3.4.4SortingNot supportedsort.supported: false in discovery
§3.4.5attributes/excludedAttributes query paramsNot supportedAlways returns the full mapped resource
§3.5.1PUT (replace)SupportedFull declarative replace, incl. Group membership resync
§3.5.2PATCH (modify)PartialScalar top-level path allowlist only — see PATCH
§3.6DELETEPartialSoft-delete/tombstone semantics, not hard delete — see DELETE semantics
§3.7Bulk operationsNot supportedExplicit 501, not a bare 404
§3.11/MeNot supportedExplicit 501 — no “current resource” concept for an API-key-authenticated client
§3.12Error response formatPartialEnvelope always present; only 6 of 10 defined scimTypes are emitted — see Errors
§3.14ETag / conditional requestsSupportedWeak ETags, atomic compare-and-swap on If-Match
§4 (core schema)User resource attributesPartialCore identity + name/email/displayName only — see Attribute Mapping
§4 (core schema)Group resource attributesPartialdisplayName + members only — see Attribute Mapping
§4 (core schema)Extension schemas (Enterprise User, etc.)Not supported
§7 (multi-tenancy)Tenant isolationSupported (different mechanism)Domain + realm scoping instead of a tenant attribute — see Ownership fencing
§8 (security)Bearer token authSupportedDomain-scoped API keys (ADR 0021), not OAuth2