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,attributesandexcludedAttributes. A filter this server cannot evaluate is refused400 invalidFilter, never answered with an empty list. - Search by POST:
/Users/.search,/Groups/.search, and/.searchat 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, gets501. A credential that names somebody with no entry, such as aclient_credentialstoken, gets404.- Discovery:
/ServiceProviderConfig,/ResourceTypesand/Schemas. The User resource type declares the enterprise extension as aschemaExtension./Schemaslists only what the directory stores (since #206):nickName,locale,timezone,ims,photos,entitlements,roles,x509Certificates,name.middleName,name.honorificSuffix,costCenter,manager.displayName, and thedisplayandprimaryofemails,phoneNumbersandaddressesare not offered, because nothing here could keep them.emails.typeisworkonly,phoneNumbers.typeworkormobile,addresses.typework; another type is refused400 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 nodisplay. - Filters compare a
caseExact: falseattribute (userName, every emailvalue, most of the schema) without regard to case, and refusegt,ge,ltandleon a boolean or binary attribute with400 invalidFilter. - An unknown path under
/scim/v2is answered404in 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:
userNameis unique. A second user with a taken name gets409 uniqueness, including a name that another entry answers to under a different naming attribute.- The
userNameinvalidis refused400 invalidValue, the same reserved value every other protocol here refuses. - A full directory (
ldap.maxEntries) is a500. - 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-Matchon a write is refused412rather than ignored. - The RFC 7643 members listed under Discovery above that the directory has
no place for, and
primaryon 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-apishare 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.
activeomitted leaves the lock alone. RFC 7643 givesactiveno 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
idisentryUUID, not the DN. RFC 7643 section 3.1’s id must never be reassigned, and a rename reassigns a DN. - ETag and
changePasswordare 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.maxResultsis 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 as400 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 everyscim.*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 onebulkand fivecreates, because each of the five really is performed — the column is not meant to add up./admin/scimdraws 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/scimand saved throughPOST /admin-api/config/set; there is no SCIM-specific write operation besideGET /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/scimandGET /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.
Related
- LDAP: the directory SCIM writes into
- OAuth 2.0 and OpenID Connect: where a
scim:*token comes from - OAuth security: DPoP and certificate-bound tokens
- TLS and mutual TLS: the client truststore
- Authentication: the sign-on session the cookie scheme uses
- Shared Signals and CAEP events: what a disable sends
- XACML: the access gate after the scope check
- Trust realms
- What is not checked
- Configuration