Inbound SCIM provisioning server
Inbound SCIM provisioning server
The Directory module plus the SCIM controllers in src/Api/Http/Controllers/Scim/
make the platform a SCIM 2.0 server: a customer's identity provider pushes users
and groups in over HTTP, and the platform provisions local accounts, org
membership and — on deactivation or delete — session revocation from those pushes.
This is the opposite direction from
outbound SCIM provisioning, where the platform is the
SCIM client pushing to a downstream app's endpoint. Both share one vocabulary
source, Cbox\Id\Scim\ScimSchema (URNs, ListResponse, PatchOp, Error, meta).
Endpoint surface
Everything is registered under /scim/v2 by Api\ApiServiceProvider, inside the
environment-resolved IdP surface, behind
ScimContentType → throttle:120,1 → AuthenticateScim:
| Method | Path | Purpose |
|---|---|---|
GET |
/scim/v2/ServiceProviderConfig |
What the server supports (RFC 7644 §4). |
GET |
/scim/v2/ResourceTypes |
User and Group resource types. |
GET |
/scim/v2/Schemas |
Core User, Enterprise User extension, Group. |
GET |
/scim/v2/Users |
List, with filter, startIndex, count. |
POST |
/scim/v2/Users |
Create (provision). |
GET |
/scim/v2/Users/{id} |
Read one. |
PUT |
/scim/v2/Users/{id} |
Full replace. |
PATCH |
/scim/v2/Users/{id} |
Partial update (path and pathless forms). |
DELETE |
/scim/v2/Users/{id} |
Deprovision. |
GET |
/scim/v2/Groups |
List, with filter, startIndex, count, attributes. |
POST |
/scim/v2/Groups |
Create. |
GET |
/scim/v2/Groups/{id} |
Read one (members included by default). |
PUT |
/scim/v2/Groups/{id} |
Full replace, including membership. |
PATCH |
/scim/v2/Groups/{id} |
Rename and membership add/remove/replace. |
DELETE |
/scim/v2/Groups/{id} |
Delete. |
There is no other SCIM route. The discovery endpoints are authenticated like the
rest — an unauthenticated GET /scim/v2/ServiceProviderConfig is a 401.
Authentication and scoping
Each directory is registered per organization and gets exactly one bearer token:
use Cbox\Id\Directory\Contracts\Directories;
$registered = app(Directories::class)->register($organization->id, 'Corporate IdP');
$registered->token; // "scim_<64 hex chars>" — shown once, never retrievable
$registered->directory->id; // the directory the token authenticates
- The token is generated as
'scim_'.bin2hex(random_bytes(32))and stored only ashash('sha256', $token)indirectories.bearer_token_hash. The plaintext is returned once and is not recoverable; the package exposes no rotation call, so replacing a token means registering a directory again. AuthenticateScimreadsAuthorization: Bearer …, looks the SHA-256 hash up, and requiresstatus = active. A miss returns a SCIMErrorwith401andWWW-Authenticate: Bearer realm="SCIM".- The resolved
Directoryis stashed on the request; every controller reads it and scopes every query todirectory_id. A token therefore addresses exactly one directory — never another directory in the same organization.
Environment isolation
Directory, DirectoryUser and DirectoryGroup are all BelongsToEnvironment, so
the token lookup itself is environment-scoped (see
Environments). Presenting environment A's token on environment B's
host does not resolve a directory at all — it is a 401, not a cross-tenant read.
Resource ids are equally scoped: a GET/PATCH/PUT/DELETE of another
environment's user id is a 404, and its rows are invisible to filter queries.
Media type
ScimContentType runs on the outside of the stack and stamps
Content-Type: application/scim+json on every non-empty response body, success or
failure. A 204 No Content (a successful DELETE) keeps its empty body and no
content type. Requests are parsed as JSON; the server does not require the request
itself to carry the SCIM media type.
Users
List, filter, paginate
curl -sS 'https://id.example.com/scim/v2/Users?filter=userName%20eq%20%22sam%22' \
-H 'Authorization: Bearer scim_…'
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
"totalResults": 1,
"startIndex": 1,
"itemsPerPage": 1,
"Resources": [
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"id": "01J…",
"externalId": "okta|1",
"userName": "sam",
"active": true,
"displayName": "Sam Ito",
"name": {"formatted": "Sam Ito", "givenName": "Sam", "familyName": "Ito"},
"emails": [{"value": "[email protected]", "primary": true}],
"meta": {
"resourceType": "User",
"created": "2026-07-25T09:12:44Z",
"lastModified": "2026-07-25T09:12:44Z",
"location": "https://id.example.com/scim/v2/Users/01J…"
}
}
]
}
startIndexis 1-based and defaults to 1;countdefaults to and is capped at 200 (DatabaseDirectoryUsers::MAX_PAGE). Results are ordered byid.meta.created/meta.lastModifiedare emitted as UTC (…Z) so a connector can run a delta sync offmeta.lastModified gt "<watermark>"instead of a full sweep.meta.locationis an absolute URI. Every single-resource response also carriesContent-Locationwith the same value, and a201carriesLocationas well (RFC 7644 §3.1, §3.3).
Create
curl -sS -X POST 'https://id.example.com/scim/v2/Users' \
-H 'Authorization: Bearer scim_…' \
-H 'Content-Type: application/scim+json' \
-d '{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"userName": "[email protected]",
"externalId": "okta|1",
"name": {"givenName": "Dana", "familyName": "Rivera"},
"emails": [{"value": "[email protected]", "primary": true}],
"active": true
}'
Answers 201 with the created resource. Notes that follow from the mapper
(Api\Support\ScimMapper) and DatabaseDirectorySync:
userNameis required; an absent or empty one is400 invalidValue.externalIdis the provisioning key. When the body omits it,userNameis used instead. APOSTwhoseexternalIdalready exists in the directory updates that row (updateOrCreate) and still answers201.emailsis multi-valued on the wire but the platform keeps one address: the entry marked"primary": true, else the first with a value. It is returned as[{value, primary: true}].displayNamefalls back toname.formatted, then togivenName + familyName, then touserName. The name parts are persisted, so a later single-part PATCH merges instead of erasing the other part.activedefaults totruewhen absent ornull.- Provisioning links a local subject, adds organization membership, and emits
directory.user.provisioned.
Enterprise User extension
urn:ietf:params:scim:schemas:extension:enterprise:2.0:User is accepted on create,
PATCH (both the URN-qualified path urn:…:User:department and the pathless nested
object) and returned when non-empty, with the URN appended to schemas. The stored
set is exactly employeeNumber, costCenter, organization, division,
department, manager; any other key under the URN is dropped on create and is
400 invalidPath when patched by an explicit path.
Read, replace, patch
PUT is a full replace and re-provisions from the body:
userNameis required (400 invalidValuewithout it).- The URL is the identity. A body
externalIdnaming a different resource is400 mutability; an omittedexternalIdis pinned to the URL-located row rather than re-keying the write.
PATCH accepts both shapes IdPs send — an explicit path, and the pathless
"partial resource in value" form:
curl -sS -X PATCH 'https://id.example.com/scim/v2/Users/01J…' \
-H 'Authorization: Bearer scim_…' \
-H 'Content-Type: application/scim+json' \
-d '{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{"op": "replace", "path": "active", "value": false},
{"op": "replace", "path": "phoneNumbers[type eq \"mobile\"].value", "value": "+45 12 34 56 78"}
]
}'
| Aspect | Behaviour |
|---|---|
Operations key |
Matched case-insensitively (operations, OPERATIONS); must be a non-empty array of objects. |
op |
Only add, remove, replace (case-insensitive). add and replace both set the value. |
| Addressable paths | active, userName, displayName, name (whole object), name.formatted, name.givenName, name.familyName, emails, and the enterprise attributes. |
| Value-filter paths | Stripped: emails[type eq "work"].value, emails[type EQ 'work'].value, emails[primary eq true].value all resolve to emails. |
| Tolerated paths | Schema-defined attributes the platform does not store are accepted and ignored: phoneNumbers, addresses, photos, ims, roles, groups, entitlements, x509Certificates, title, userType, nickName, profileUrl, preferredLanguage, locale, timezone, name.middleName, name.honorificPrefix, name.honorificSuffix. A deactivation push that also carries them still deactivates. |
| Unknown paths | Anything else is 400 invalidPath, and no part of the request is applied. |
remove |
Clears displayName, name.formatted, name.givenName, name.familyName, emails. A remove with no path is 400 noTarget. userName, externalId and active are not clearable — deactivation is replace active:false. |
| Name recomposition | Patching name.givenName/name.familyName without an explicit displayName recomposes the display name from the merged parts. |
active is parsed strictly by Cbox\Id\Scim\Support\ScimBoolean: the JSON literals
true/false, and the strings "true"/"false" in any case and trimmed (Entra
sends "False"). Anything else — "fasle", "no", "1", 0, 1 — is
400 invalidValue, because coercing a typo here is a deprovision.
Delete
curl -sS -X DELETE 'https://id.example.com/scim/v2/Users/01J…' -H 'Authorization: Bearer scim_…'
# 204 No Content
DELETE deprovisions: the directory row is marked inactive, the local subject is
deactivated, organization membership is removed, and every session that subject holds
is revoked immediately. The directory_users row is not physically removed, so a
subsequent GET /scim/v2/Users/{id} still resolves and reports "active": false.
DELETE of an id this directory does not have is 404 (RFC 7644 §3.6) — it used
to answer 204, which told the IdP a deprovision had succeeded for an id the server
never held.
Groups
curl -sS -X POST 'https://id.example.com/scim/v2/Groups' \
-H 'Authorization: Bearer scim_…' \
-H 'Content-Type: application/scim+json' \
-d '{"displayName": "Engineering", "externalId": "grp|1",
"members": [{"value": "01J…alice"}, {"value": "01J…bob"}]}'
displayNameis required onPOSTandPUT(400 invalidValueotherwise).members[].valueis the platform'sDirectoryUserid (the SCIMid), not theexternalId. Ids that are not users of this directory are silently dropped rather than erroring.PUTreplaces membership with exactly the supplied set.PATCHsupports:add/replaceonpath: "members";removewithmembers[value eq "<id>"](one member) or baremembers(all); rename via{"op":"replace","path":"displayName","value":"…"}or the pathless{"op":"replace","value":{"displayName":"…"}}. A pathlessreplacecarryingmembersreplaces membership; one that carries nomembersleaves membership untouched.pathandvaluekeys are matched case-insensitively.- Group
PATCHis transactional: a later invalid operation rolls back the earlier ones, so a group is never left half-edited. - Every membership change emits
directory.group.membership_changed, whichAccessControl\Listeners\ReconcileGroupRolesOnDomainEventconsumes to reconcile group→role assignments. DELETEdetaches members and removes the group row; a subsequent read is404.
members is omitted from listings
GET /Groups omits members entirely unless the client asks for it. Omitted, not
emitted empty — "members": [] would assert the group has no members, which is a
different fact. The listing does not even query the membership pivot.
Ask for it with the RFC 7644 §3.9 attributes parameter:
curl -sS 'https://id.example.com/scim/v2/Groups?attributes=members' -H 'Authorization: Bearer scim_…'
# fully-qualified names work too:
curl -sS 'https://id.example.com/scim/v2/Groups?attributes=urn:ietf:params:scim:schemas:core:2.0:Group:members' …
Reading a single group returns members by default; suppress them with
?excludedAttributes=members. /Schemas declares Group.members with
"returned": "request" to match. This is the only attribute selection implemented:
no other attribute can be included or excluded, on either resource.
Filtering
/Users filters are parsed by Directory\Support\ScimUserFilter over a closed set of
attributes and a type system (ScimFilterAttribute, ScimValueType).
| Attribute | Compared as | Column |
|---|---|---|
userName |
case-insensitive text | user_name_lower |
emails, emails.value |
case-insensitive text | email_lower |
externalId |
case-sensitive text (client-assigned, opaque) | external_id |
active |
boolean | active |
meta.lastModified |
timestamp | updated_at |
meta.created |
timestamp | created_at |
Attribute names are matched case-insensitively (RFC 7643 §2.1). Any attribute not in
the table — nickName, title, … — is 400 invalidFilter.
| Operator | Allowed on | Notes |
|---|---|---|
eq, ne |
every attribute | |
co, sw, ew |
text attributes only | Translated to LIKE; % and _ in the value are escaped, so they match literally. |
gt, ge, lt, le |
timestamp attributes only | The delta-sync watermark comparison. |
pr |
every attribute | IS NOT NULL. |
Clauses combine with a single top-level and or or. Grouping parentheses,
not, nested/value-path filters, and mixing and with or in one expression are all
refused — the parser does not guess precedence.
Filter values are type-checked
A literal that cannot be a value of the attribute's type refuses the whole filter
with 400 invalidFilter, rather than being coerced and answering with a confidently
wrong result set:
| Filter | Result |
|---|---|
active eq true / active eq "false" |
Parsed. |
active eq "fasle", active eq "0", active eq "" |
400 invalidFilter |
active co "tru" |
400 invalidFilter (substring match on a boolean) |
userName gt "a" |
400 invalidFilter (ordering on text is not implemented) |
meta.lastModified gt "2026-07-10T00:00:00Z" |
Parsed. |
meta.lastModified gt "yesterday", … gt "" |
400 invalidFilter |
Timestamp literals must be xsd:dateTime as RFC 7643 §2.3.5 defines it, and are rebased
onto config('app.timezone') — the frame the created_at/updated_at columns are
stored in — while meta is emitted in UTC, so a watermark round-trips correctly on a
non-UTC application timezone.
/Groups filtering is far narrower by design: exactly one clause, exactly
displayName eq "…" or externalId eq "…", value compared as sent. Anything else is
400 invalidFilter.
Case-insensitive identity on every driver
userName and the primary email are stored in dedicated folded columns
(user_name_lower, email_lower, added by
2026_07_25_000100_add_normalized_scim_columns_to_directory_users, maintained by a
saving hook on DirectoryUser and indexed with directory_id). Equality no longer
depends on the database collation, which had made these case-sensitive on PostgreSQL
and case-insensitive on MySQL:
userName eq "[email protected]"matches a user stored as[email protected]on every driver;- and a create whose
userNamediffers from an existing one only in case now collides:409 uniqueness, not a second account for one person.
Discovery
GET /scim/v2/ServiceProviderConfig reports the truth, including the gaps:
| Capability | Advertised |
|---|---|
patch.supported |
true |
filter.supported / filter.maxResults |
true / 200 |
bulk.supported |
false |
changePassword.supported |
false |
sort.supported |
false |
etag.supported |
false |
authenticationSchemes[0].type |
oauthbearertoken |
GET /scim/v2/Schemas returns three schemas — core User, Enterprise User, Group. The
User schema declares userName (required, uniqueness: server), externalId,
name (with formatted, givenName, familyName, plus middleName,
honorificPrefix, honorificSuffix marked returned: never because they are
accepted and discarded), displayName, emails (with value, display, type,
primary; display and type are returned: never) and active. Declaring name
and emails matters in practice: a schema import that lists only scalars leaves an
admin unable to map email or first/last name at all.
GET /scim/v2/ResourceTypes returns User (with the Enterprise extension declared
as optional) and Group.
Error semantics
Every failure is an RFC 7644 §3.12 Error envelope
(urn:ietf:params:scim:api:messages:2.0:Error) with status and, where the RFC
defines one, scimType:
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"status": "400",
"scimType": "invalidSyntax",
"detail": "A PATCH request must carry a non-empty \"Operations\" array (RFC 7644 §3.5.2)."
}
| Condition | Status | scimType |
|---|---|---|
| Missing, unknown or inactive bearer token | 401 |
— (plus WWW-Authenticate: Bearer realm="SCIM") |
Unknown user/group id on GET, PUT, PATCH, DELETE (including another environment's id) |
404 |
— |
POST/PUT /Users without userName |
400 |
invalidValue |
POST/PUT /Groups without displayName |
400 |
invalidValue |
active present but not a SCIM boolean (create, replace, or patch) |
400 |
invalidValue |
Operations absent, empty, not an array, or containing a non-object |
400 |
invalidSyntax |
op missing or not add/remove/replace (User and Group) |
400 |
invalidSyntax |
PATCH path the server cannot interpret (User and Group) |
400 |
invalidPath |
remove with no path |
400 |
noTarget |
PUT body externalId naming a different resource |
400 |
mutability |
Unparsable or unsupported filter (/Users or /Groups) |
400 |
invalidFilter |
userName already taken in this directory, including a case variant |
409 |
uniqueness |
| Email already belongs to a platform account | 409 |
uniqueness |
| Rate limit exceeded | 429 |
— |
Three consequences worth stating plainly, because they are all deliberate reversals of a silent-success behaviour:
- A PATCH the server could not read is a
400, not a200. A body with a malformed or absentOperationsmember used to be degraded to "no operations" and answered200with the unchanged resource, so an IdP recorded a deactivation that never happened and never retried it. A merely lower-casedoperationsis legal SCIM and is still accepted. - A non-boolean
activeis a400. It used to be coerced ("fasle"→false) and answered200— a deprovision caused by a typo. DELETEof an unknown id is a404. It used to be204.
Rate limiting
The SCIM group is throttled at 120 requests per minute (throttle:120,1). Because
ScimContentType sits outside the throttle, a 429 is re-framed into the SCIM
Error envelope with status: "429" and detail: "Too Many Attempts.", served as
application/scim+json, with the original Retry-After header carried across. A
plain Laravel {"message":"Too Many Attempts."} in application/json is unparsable
to a SCIM client, which reads it as a fatal connector fault instead of "back off".
Honest scope
What this server does not implement:
- No
/Bulk(advertised asbulk.supported: false,maxOperations: 0). - No
/Meendpoint, and no.search(POST /.search) query endpoint — filtering is query-string only. - No sorting.
sortBy/sortOrderare ignored; results are always ordered byid. Advertised assort.supported: false. - No ETags / optimistic concurrency.
meta.versionis not emitted andIf-Match/If-None-Matchare not honoured. Advertised asetag.supported: false. - No
changePassword; passwords are not part of the mapped profile. - Filter gaps: no grouping parentheses, no
not, no nested or complex value-path filters (emails[type eq "work"] pr), no mixingandwithor, no ordering comparisons on text, and only the seven/Usersattributes listed above./Groupssupports a singledisplayName/externalIdequality clause only. - Attribute selection is limited to
Group.members.attributes/excludedAttributesare not honoured for any other attribute or for/Users. DELETE /Users/{id}is a soft deprovision — the row remains and reads back asactive: false.- Some accepted attributes are discarded, by design, and
/Schemassays so withreturned: "never":name.middleName,name.honorificPrefix,name.honorificSuffix, and per-addressemails[].display/emails[].type. The wider tolerated set (phoneNumbers,addresses,title,userType, …) is accepted and ignored rather than refused, so a deprovision push carrying them still applies. - Group membership does not accept
externalIdreferences —members[].valuemust be the SCIMidof a user in the same directory; unknown ids are ignored, not reported. - No group
displayNameuniqueness is enforced.
Related
- Outbound SCIM provisioning — the mirror direction: the platform as SCIM client, pushing to a downstream app.
- Custom SCIM attribute mapping — per-connection attribute mapping for that outbound direction.
- Environments & the isolation model — the boundary a directory token can never cross.
- Organization access — where the groups this endpoint syncs turn into roles.