SCIM

iya-sts is a SCIM 2.0 service provider at /scim/v2 (RFC 7642, RFC 7643, RFC 7644). It is the one protocol family here whose purpose is to write. What it writes is the embedded LDAP directory, entry for entry, and SCIM keeps no store of its own. Every trust realm has its own endpoint (/realm/{id}/scim/v2), and it provisions into that realm’s directory.

POST /scim/v2/Users {"userName": "dave"}
    -> uid=dave,ou=users,dc=example,dc=com     the directory entry itself
    -> ldapsearch -b ou=users '(uid=dave)'     finds it
    -> /admin/users?user=dave                  shows it
    -> /admin/vc's selection sweeps it         so a credential has something to assert
    -> an access token for dave                carries its attributes

That is the whole design: the interesting property of a SCIM endpoint is that what it writes is what everything else then reads.

Features

Resources and operations

  • Users and Groups: create, read, list, replace (PUT), modify (PATCH) and delete. PATCH is RFC 7644 section 3.5.2 in full, including value-filter paths such as emails[type eq "work"].value.
  • Query: filter (section 3.4.2.2), sortBy, sortOrder, startIndex, count, attributes and excludedAttributes. A filter this server cannot evaluate is refused 400 invalidFilter, never answered with an empty list.
  • Search by POST: /Users/.search, /Groups/.search, and /.search at the root, which answers one filter from Users and Groups together. The body must carry the SearchRequest schema URN.
  • Bulk: POST /scim/v2/Bulk (section 3.7). Each operation carries its own status, so a bulk request in which one operation failed is still a 200.
  • /Me: an alias for the authenticated subject (section 3.11), with GET, PUT, PATCH and DELETE. An anonymous caller, or any POST, gets 501. A credential that names somebody with no entry, such as a client_credentials token, gets 404.
  • Discovery: /ServiceProviderConfig, /ResourceTypes and /Schemas. The User resource type declares the enterprise extension as a schemaExtension. /Schemas lists only what the directory stores (since #206): nickName, locale, timezone, ims, photos, entitlements, roles, x509Certificates, name.middleName, name.honorificSuffix, costCenter, manager.displayName, and the display and primary of emails, phoneNumbers and addresses are not offered, because nothing here could keep them. emails.type is work only, phoneNumbers.type work or mobile, addresses.type work; another type is refused 400 invalidValue.
  • References: a Group member, a group a user is in, and a user’s manager each carry a $ref, the absolute URL of that resource. A member has no display.
  • Filters compare a caseExact: false attribute (userName, every email value, most of the schema) without regard to case, and refuse gt, ge, lt and le on a boolean or binary attribute with 400 invalidFilter.
  • An unknown path under /scim/v2 is answered 404 in the SCIM Error schema, not as an HTML page.

Filtering, sorting, PATCH and bulk are advertised as supported. ETag and changePassword are advertised as unsupported. The ServiceProviderConfig is built from the same values the endpoints enforce, so its maxResults, bulk.maxOperations and bulk.maxPayloadSize are the limits actually applied.

The id is the entry’s entryUUID

A resource’s id is its directory entry’s entryUUID (RFC 4530). It is kept through a rename, and a person deleted and created again gets a new one. It is the value a person’s tokens carry in sub, as urn:uuid:<entryUUID>.

GET /scim/v2/Users/5503c620-f13d-42e9-a841-fbfbe4cf8899

Group members[].value, a User’s groups[].value and the enterprise manager.value are ids on the wire. The directory stores the DNs they name. A DN presented as an id still resolves, for a client that stored one earlier.

Provisioning is the directory

A User create goes through the same function as the console’s New user form and POST /admin-api/users/create. The name rules, the one-entry-per-person check and the refusals are the same at every door:

  • userName is unique. A second user with a taken name gets 409 uniqueness, including a name that another entry answers to under a different naming attribute.
  • The userName invalid is refused 400 invalidValue, the same reserved value every other protocol here refuses.
  • A full directory (ldap.maxEntries) is a 500.
  • A name carrying RFC 4514’s reserved characters is refused. One rule decides that for SCIM, the console and /admin-api, users and groups alike, so the doors cannot drift apart on which names are usable in a DN.

Because a create goes through the directory’s own door, it also gets the directory’s fold: a new name lands on the entry that is already this person’s under a different naming attribute (a client certificate’s cn=rcbj,ou=users, say) rather than creating a second object for one person. The create writes one user.create audit row naming SCIM as the protocol; SCIM itself records only updates and deletes, so one act is never counted twice.

A created group gets an entry under ou=groups with a groupOfNames object class. A group list includes everything the directory counts as a group: entries under ou=groups and entries carrying a group object class anywhere. On a read, member, uniqueMember and memberUid are all resolved as members. A write puts values in member and clears the other two. A member that names nothing is accepted and logged, because this directory does no referential integrity.

Being provisioned is not authenticating. A person created over SCIM has an entry with origin: scim and no row on /admin/users until they sign in somewhere.

A service deployed in several regions

When this service runs as several regional cells, each person is homed in one of them and their data stays there. SCIM follows the person:

  • a request about one person (/Users/{id}) is answered by the region they are homed in, whichever region your client reached;
  • a new person is homed in the region named by urn:ietf:params:scim:schemas:extension:iya-sts:2.0:User:homeCell (a region id; write-only and never returned), or in the realm’s default region. A region the realm may not home people in is refused. A later write cannot move a person: that is an administrator’s act;
  • a list or a search returns the people of the region that answered;
  • a Group write whose members are homed in more than one region, and a BulkRequest whose operations are, are refused whole (400 invalidValue). Send one request per region’s people.

A service deployed as one region ignores homeCell.

Attribute mapping

Each SCIM member is stored in one LDAP attribute. For example, userName is uid, name.familyName is sn, emails is mail, phoneNumbers is telephoneNumber (type work) and mobile (type mobile), userType is employeeType, the address members are street, l, st, postalCode, c and postalAddress, and the enterprise extension maps to employeeNumber, departmentNumber, o, ou and manager. The full table, with type, parent and extension for each row, is published live at GET /scim and on /admin/scim.

A PUT replaces only what the mapping covers. Every attribute outside the mapping is left alone: schacDateOfBirth, authnMethod, mfaAuthenticated and the x509* attributes, for example. A client removes a mapped value by omitting it. entryDN, createTimestamp and modifyTimestamp are never written back. meta.created and meta.lastModified come from the timestamps. (The DN is synthesised from where the entry is stored, RFC 5020, and the timestamps belong to the entry rather than to whoever wrote it, so writing them back would store a copy of what is meant to be computed.)

active: false disables the account

active maps to pwdAccountLockedTime, the administrative lock from the LDAP password policy draft. active: false is the same act as Disable on /admin/users. Every door then refuses the person: a password anywhere, any sign-in, a session they already hold, a Kerberos AS-REQ, every token grant and refresh, and the management API. Everything they hold is ended. Sessions end with CAEP session-revoked and back-channel Logout Tokens, and RISC reports account-disabled. active: true enables them again, and nothing they held comes back.

A resource that does not mention active leaves the lock as it was, so a client that never sends the member cannot re-enable an account an administrator disabled. On the way out active is always present.

Authentication: all six schemes of RFC 7644 section 2

RFC 7644 section 2 defines no SCIM credential of its own: “The SCIM protocol is based upon HTTP and does not itself define a SCIM-specific scheme for authentication and authorization.” It names six ways — TLS client authentication, HOBA, bearer tokens, proof-of-possession tokens, cookies, and HTTP Basic, which it discourages — and states two normative sentences, both honoured here: a provider SHALL indicate its supported schemes in WWW-Authenticate, and MUST be able to map the authenticated client to an access control policy (the policy is below).

Every endpoint needs a credential, except the three discovery documents while scim.authDiscovery is off. A request with no credential gets 401 with one WWW-Authenticate header per offered scheme. The ServiceProviderConfig’s authenticationSchemes is built from the same table, so a scheme turned off disappears from both.

Scheme type published What it takes
OAuth 2.0 Bearer (RFC 6750) oauthbearertoken An access token from this service with scim:read or scim:write, verified for signature, revocation and audience
DPoP (RFC 9449) oauth2 The same token, key-bound, with a proof. An RFC 8705 certificate-bound token is honoured too
HTTP Basic (RFC 7617) httpbasic A username and password
HTTP Digest (RFC 7616) httpdigest SHA-256 or SHA-512-256, with the -sess variants; MD5 only where scim.digestMd5 turns it on, in development mode
HOBA (RFC 7486) hoba An RSA/SHA-256 signature over the server’s challenge, by a key registered at POST /.well-known/hoba/register
Session cookie httpcookie The browser sign-on session from /authn/login, consulted only when there is no Authorization header
TLS client certificate tlsclientauth A certificate that verified against the client truststore on the main port (needs global.https)

The ServiceProviderConfig publishes all seven rows, and three of them have no canonical type: RFC 7643 section 5 gives authenticationSchemes.type five values (oauth, oauth2, oauthbearertoken, httpbasic, httpdigest), and there is none for a client certificate, a cookie or HOBA. Those three are published with a type of their own (tlsclientauth, httpcookie, hoba) rather than left out — a ServiceProviderConfig listing four of the seven ways in would be the most misleading document this service publishes, and it is the first thing a SCIM client reads.

The access policy. Only the OAuth schemes carry scopes. scim:read reads and scim:write writes, and neither implies the other. A wrong scope is 403 with error="insufficient_scope". Every other scheme may both read and write. To exercise a client’s scope handling, turn the other five off. After the scope check, the XACML access gate decides. It permits by default, and its refusal says that the credential was accepted.

A credential that was presented and failed is always a refusal, even on the discovery endpoints.

Digest and HOBA really verify the exchange in both modes. A Digest nonce expires after scim.digestNonceSeconds and is then refused with stale=true, which a conforming client retries silently. A replayed nonce count is refused without stale, because the credential was valid and has been seen before. A HOBA challenge may be reused until it expires, so a replay is a repeated (key id, challenge, nonce) triple. On a cluster a nonce count or a HOBA triple is spent once across every node.

HOBA’s signature is RSA with SHA-256 over RFC 7486 section 5’s length-prefixed blob. The key is registered at POST /.well-known/hoba/register — in development unauthenticated, for the reason POST /tls/trust is: it is how a caller gets a credential — and it lands on the person’s own directory entry as hobaPublicKey, so an ldapsearch and /admin/users show it. A HOBA credential names its key by id alone, which is why a key id already registered to another account is refused in every mode. In development the shared Digest password defaults to password!, the same value as krb5.userPassword, so there is one fact to remember rather than two.

An accepted SCIM credential starts a session keyed on the scheme and the principal, never on the credential. Basic, Digest and HOBA present a credential on every request and are recorded as authentications, so their caller appears on /admin/users under protocol SCIM. A token, a cookie or a certificate continues an authentication already recorded elsewhere.

Things you can make fail

Do this Get this
create a user named invalid 400 invalidValue
create a duplicate userName 409 uniqueness
ask for an id that names nothing 404
send a filter the server cannot evaluate 400 invalidFilter — “no results” and “I could not read your filter” are different answers, and a client can only act on the second
send nothing 401 with every offered challenge
use a token with the wrong scope 403 insufficient_scope
use a token this service did not issue, or a revoked one 401 — a scope on a token nobody verified is a permission its holder wrote for themselves
use Basic with the password invalid 401
use Digest with a wrong password, a stale nonce or a repeated nc 401, three ways
use a HOBA signature that does not verify, or a repeated triple 401
GET /Me with no credential, or any POST /Me 501 — the alias is unavailable (an anonymous caller has no subject, and a POST would create one that already exists); a credential naming nobody here gets 404
POST to .search without the SearchRequest URN 400 invalidSyntax
send a Bulk request over scim.bulkMaxOperations 413 payloadTooLarge
send If-Match (other than *) on a PUT, PATCH or DELETE 412 — there are no entity-tags, so no version can match; nothing is changed
order a boolean in a filter (active gt true) 400 invalidFilter
send an email of type home 400 invalidValue
ask for a path under /scim/v2 that is no endpoint 404, as a SCIM Error

Not implemented

  • ETag versioning. A version built over a one-second timestamp would be a concurrency control a client trusts and that is wrong. Responses carry no ETag header at all — not even the weak one a web framework would add by default — so they do not contradict the ServiceProviderConfig, and an If-Match on a write is refused 412 rather than ignored.
  • The RFC 7643 members listed under Discovery above that the directory has no place for, and primary on a multi-valued member.
  • changePassword. SCIM carries no password here.
  • A scheme that RFC 7644 section 2 does not name, such as an API key header.

Development and product mode

A credential is required in both modes. What differs is how much of it is checked.

  Product Development
OAuth tokens Verified; the scope decides Verified; the scope decides
Basic The password is verified against the person’s hashed userPassword (off the request thread). A person who holds or must hold a second factor is refused their own password with the same 401 a wrong one gets, and uses an app password scoped to scim Any username, any password except invalid
Digest Not offered, and refused (STS-SCIM-0056). A salted scrypt hash cannot answer an RFC 7616 exchange Any username with the one shared password, scim.digestPassword
HOBA registration Only the signed-in owner of an existing account may register a key for it (STS-SCIM-0069). A registration never creates an account Anybody may register any key for any name
The shared Digest password in a 401 Never printed Printed, as a test aid

In every mode a HOBA key id already registered to another account is refused (409), and the SCIM scopes are tied to the client: they are issued only to a client whose oauthAllowedScope declares them, and a token is honoured only while its client still does — withdrawing the declaration cuts off tokens already issued (STS-SCIM-0079). Declare them on the application’s page, or with POST /admin-api/applications/add ({"application": "<id>", "attribute": "oauthAllowedScope", "value": "scim:write"}). Verifying a Basic password costs about 70 ms of CPU per request in product mode, so a bulk provisioning client should use a scim:write access token. See what is not checked.

In development mode the credential is a turnstile, not a lock. Any password but invalid passes Basic, any username passes Digest with the shared password, and anybody can register a HOBA key for any name. Nothing decides that a caller should be allowed to delete an account — only that they said who they were first. Do not put a development-mode SCIM endpoint on a public address on the strength of it.

Configuration

Setting Environment variable Default Runtime? What it does
scim.enabled SCIM_ENABLED true yes Turns the family on; off leaves the routes registered and answering 501.
scim.maxResults SCIM_MAX_RESULTS 200 yes The largest page a list or search returns, published as filter.maxResults; a larger count is clamped.
scim.bulkMaxOperations SCIM_BULK_MAX_OPERATIONS 100 yes How many operations one Bulk request may carry, published as bulk.maxOperations.
scim.bulkMaxPayloadSize SCIM_BULK_MAX_PAYLOAD_SIZE 1048576 yes The largest Bulk body in bytes, published as bulk.maxPayloadSize and checked against that number.
scim.authDiscovery SCIM_AUTH_DISCOVERY false yes Whether the three discovery documents also need a credential. Off is what RFC 7643 section 5 asks (authenticationSchemes SHOULD be readable without prior authentication); on departs from it.
scim.inventOnCreate SCIM_INVENT_ON_CREATE true yes Development mode only: fill a provisioned person in with the invented persona and the /admin/vc attributes the client did not send. Off, a create writes exactly what was sent.
scim.authRealm SCIM_AUTH_REALM SCIM yes The protection space in every challenge; Digest and HOBA credentials are computed over it.
scim.scopeRead SCIM_SCOPE_READ scim:read yes The OAuth scope needed to read, published in scopes_supported.
scim.scopeWrite SCIM_SCOPE_WRITE scim:write yes The OAuth scope needed to create, replace, patch, delete or bulk; it does not imply the read scope.
scim.authBearer SCIM_AUTH_BEARER true yes Offer OAuth 2.0 access tokens, as Bearer or DPoP.
scim.authBasic SCIM_AUTH_BASIC true yes Offer HTTP Basic.
scim.authDigest SCIM_AUTH_DIGEST true yes Offer HTTP Digest (never offered in product mode).
scim.digestPassword SCIM_DIGEST_PASSWORD password! yes The password every username shares for Digest in development.
scim.digestNonceSeconds SCIM_DIGEST_NONCE_SECONDS 300 yes How long a Digest nonce is usable before it is refused with stale=true.
scim.digestMd5 SCIM_DIGEST_MD5 false yes Whether Digest offers and accepts MD5 beside SHA-256 and SHA-512-256. Warning: MD5 is collision-broken and RFC 7616 keeps it for backward compatibility only; turn it on only to exercise a client that speaks nothing else. Development mode only — refused on write in product (STS-CORE-0103) and ignored if stored (STS-CORE-0106); product offers no Digest at all.
scim.maxDigestNonces SCIM_MAX_DIGEST_NONCES 2000 yes How many issued Digest nonces are remembered; a forgotten one is refused stale=true.
scim.authHoba SCIM_AUTH_HOBA true yes Offer HOBA, and turn /.well-known/hoba/register on or off.
scim.hobaMaxAgeSeconds SCIM_HOBA_MAX_AGE_SECONDS 600 yes The max-age of a HOBA challenge, published and enforced.
scim.maxHobaChallenges SCIM_MAX_HOBA_CHALLENGES 2000 yes How many issued HOBA challenges are remembered.
scim.maxHobaSeen SCIM_MAX_HOBA_SEEN 5000 yes How many accepted HOBA triples are remembered for replay detection; evicting one also forgets its challenge.
scim.authCookie SCIM_AUTH_COOKIE true yes Accept the browser sign-on session cookie.
scim.authClientCert SCIM_AUTH_CLIENT_CERT true yes Accept a TLS client certificate that verified against the client truststore.

ldap.maxEntries is the cap a SCIM create runs out of, because SCIM has no store of its own.

This table is a copy of the rows in the service’s settings table. The live source is Protocols → SCIM (/admin/scim), where every setting is drawn, and GET /admin-api/config. POST /admin-api/config/set changes one, and every setting can be set per trust realm. See Configuration for how a value is resolved.

Design decisions

  • No second store. A SCIM server with its own map beside the directory would teach a provisioning client nothing. The useful property of a SCIM endpoint is that what it writes is what everything else then reads.
  • One create function for every door. SCIM, the console and /admin-api share the directory’s own create, so “creating a user” has one meaning and cannot fold a person into two entries.
  • A credential is required, and in development it is a turnstile rather than a lock. These endpoints create and delete accounts. Requiring a credential makes a client’s 401, 403, challenge and scope handling testable, which an open endpoint cannot do. No setting turns the requirement off.
  • All six schemes of RFC 7644 section 2 and no others. An API key header is what many real integrations use, but it is in no specification and would interoperate with nothing.
  • The read and write scopes do not imply each other, so that a read-only provisioning credential exists for a client to handle.
  • Digest and HOBA really verify. A server that accepted any Digest response or any signature would not be performing the exchange, and the client code that computes it would never run.
  • Digest is not offered in product mode. It needs the password or an unsalted hash of it on the server, and storing that per person would be a password equivalent with no work factor.
  • The discovery endpoints are open by default. The ServiceProviderConfig is where a client learns which schemes exist, so demanding a credential to fetch it makes the client know the answer before it asks.
  • A PUT replaces only the mapping’s window. Read strictly, a PUT would delete attributes SCIM never knew about and cannot restore.
  • active omitted leaves the lock alone. RFC 7643 gives active no default, and a client that never sends it must not undo an administrator’s Disable by omission.
  • A dangling group member is accepted. Refusing it would make a state the directory can reach impossible to reproduce through SCIM.
  • The id is entryUUID, not the DN. RFC 7643 section 3.1’s id must never be reassigned, and a rename reassigns a DN.
  • ETag and changePassword are advertised as unsupported rather than half-implemented.
  • The published limits are read per request. The ServiceProviderConfig is rebuilt from the current settings each time it is fetched, so a runtime change to scim.maxResults is both enforced and advertised at once, never enforced while the document still shows the old number.

The SCIM library, and two defects routed around

The schema, filter and PATCH-path handling come from scimmy (MIT, no runtime dependencies). It brings the three things that are hard about SCIM and boring to get right: the RFC 7643 schema definitions with their attribute characteristics and coercion, the section 3.4.2.2 filter grammar, and the section 3.5.2 PATCH path grammar — where hand-rolled servers are usually subtly wrong: emails[type eq "work"].value is a path, and treating it as a property name makes a provisioning client’s updates land somewhere else.

Two things it does not do look as though they are handled:

  • Reading a resource does not apply the filter it parsed. A handler that ignored the filter would return everybody for every query, which looks like a working server until somebody filters. This service applies it; sorting and pagination are the library’s.
  • Its filter matcher throws on a nested attribute the resource lacks. A filter naming any sub-attribute (emails.value co "@example.com", name.familyName sw "Sm") would blow up on the first person with no email — in a directory the ordinary case — surfacing as 400 invalidValue: Cannot convert undefined or null to object. This service pads every multi-valued and complex member to at least an empty array or object before matching, and takes the padding off again before the resource goes on the wire.

In the running service

  • Protocols → SCIM (/admin/scim): the six schemes and which are on, the endpoints, what SCIM here will not do, the negatives you can provoke, the attribute mapping, counters of what has been done, and every scim.* setting.
  • Monitoring → SCIM metrics (/admin/scim/monitor): calls, successes and failures, latency and bytes per operation, counts per resource type and per scheme (including schemes at zero), one row per authenticated principal, and the last fifty requests. A caller the gate refused is counted as refused, not as a client. There is no reset. One Bulk request carrying five creates counts as one bulk and five creates, because each of the five really is performed — the column is not meant to add up. /admin/scim draws every operation and resource type including those at zero, because “does this server do PATCH” is the question somebody arrives with.
  • The scim.* settings are drawn on /admin/scim and saved through POST /admin-api/config/set; there is no SCIM-specific write operation beside GET /admin-api/scim.
  • GET /scim: a description of the surface, the mapping table and the schemes, with ?format=json. It is not a SCIM endpoint.
  • The management API: GET /admin-api/scim and GET /admin-api/scim/monitor.
  • The provisioned people and groups: Users (/admin/users), Groups (/admin/groups) and /admin/ldap/directory.

GET /admin/sts-metadata lists every SCIM endpoint. Every SCIM failure is recorded under an STS-SCIM-NNNN code on the audit row and never sent to a client. See error codes.