SCIM 2.0 inbound provisioning#
Requirements: §29 (PROV-001..PROV-009, PROV-018), USR-017, TEN-015. Code:
internal/provisioning (connectors, users, groups, filter, discovery),
internal/httpapi/scim.go. Tests: TestSCIMProvisioning
(tests/e2e/scim_test.go), TestParseFilter and TestParseFilterRejects
(internal/provisioning/filter_test.go). OpenAPI tag Provisioning, security
scheme scimConnectorToken, trust class provisioning.
An external directory (an HR system or another identity provider) creates, updates and deprovisions an organization's members and groups through SCIM 2.0 (RFC 7643, RFC 7644). Each provisioning connector is bound to one organization, the resource types it may manage and a role ceiling. A connector:
- sees and changes only the users and groups it provisioned itself, within its
organization. Its
externalIdvalues are its own namespace: two connectors may use the sameexternalIdfor different accounts; - never changes the realm-wide account state.
activeis the account's membership state in the connector's organization, and deprovisioning removes only that membership; - grants roles only through the mappings and the ceiling an organization administrator approved.
This API manages only inbound SCIM connectors (scim_inbound). Outbound
provisioning (pushing accounts to another system,
outbound-provisioning.md) and LDAP/Active
Directory connectors with directory synchronization
(directory.md) are separate connector kinds managed at
/directory-connectors; they are not listed here, and creating one here is
refused.
Connectors#
Paths are relative to /api/v1/realms/{realm}/organizations/{org}. Every route
needs org.integrations.write in the organization (the built-in organization
owner role and the realm realm-admin role have it; the built-in
organization admin role does not). POST, PUT and token rotation also need
an MFA session that is not recovery-restricted; the realm policy
mfa.org_admin_mfa_optional does not waive it.
| Method and path | Effect |
|---|---|
GET /provisioning-connectors | List the organization's connectors |
POST /provisioning-connectors | Create a connector; returns its token once |
GET /provisioning-connectors/{id} | One connector with its evidence; ETag: W/"v<N>" |
PUT /provisioning-connectors/{id} | Replace the definition and set state; If-Match required (428 without it, 412 when stale, also when a concurrent update wins between the check and the write) |
POST /provisioning-connectors/{id}/token | Issue a new token; the previous one stops working at once |
POST /api/v1/realms/main/organizations/org_.../provisioning-connectors
{
"name": "Okta",
"allowed_resource_types": ["User", "Group"],
"role_ceiling_ids": ["rol_member", "rol_engineering"],
"group_role_mappings": { "Engineering": ["rol_engineering"] },
"default_role_id": "rol_member",
"config": {}
}| Member | Rules |
|---|---|
name | 1 to 100 characters |
kind | scim_inbound (the default and the only accepted value). Any other kind, including scim_outbound, is 422 on kind: outbound and directory connectors are managed at /directory-connectors |
allowed_resource_types | User and/or Group (default both). Requests for another type get 403 |
role_ceiling_ids | At most 50 organization-boundary roles of this organization (or realm-wide organization roles). Each must be within the delegation ceiling of the administrator who creates or updates the connector (403 grant_ceiling_exceeded otherwise), so a connector never holds more authority than its creator |
group_role_mappings | At most 200 entries "<external group>": ["<role id>", ...]; keys of 1 to 256 characters; every role must be in role_ceiling_ids (422 otherwise) |
default_role_id | Optional; must be in role_ceiling_ids. Assigned with each new membership the connector creates |
config | Free-form object, stored and returned |
state | PUT only: active or disabled (omitted keeps the current state) |
PUT replaces the whole definition (send every member you want to keep) and
re-validates the ceiling against the administrator making the change.
POST answers 201 with Cache-Control: no-store and:
{ "connector": { "id": "con_...", "...": "..." },
"token": "pa_scim_...",
"scim_base_url": "https://id.example.com/api/v1/realms/main/scim/v2" }The token is shown once (only its digest is stored). Configure the base URL and
the token as the bearer credential in the directory. scim_base_url is built
from server.public_origin.
A connector also reports operational evidence (PROV-018):
| Member | Meaning |
|---|---|
last_sync_at | Time of the last successful SCIM request |
last_error | The detail of the last failed request (at most 200 characters) |
stats | Counters of successful requests per operation, <method>_<resource type> (post_user, patch_group, delete_user, ...; get_discovery for ServiceProviderConfig, ResourceTypes and Schemas), and errors for failed requests |
Requests refused before they reach the connector's logic (invalid token, resource type not allowed, rate limit, unavailable limiter) are not counted.
A connector stops authenticating when it is disabled, when its organization is
not active, and when the organization's deletion starts (the deletion
disables all its connectors;
data-inventory.md).
SCIM endpoint#
Base URL: /api/v1/realms/{realm}/scim/v2, authenticated with
Authorization: Bearer pa_scim_....
| Path | Methods |
|---|---|
/ServiceProviderConfig, /ResourceTypes, /Schemas | GET |
/Users, /Groups | GET (list), POST |
/Users/{id}, /Groups/{id} | GET, PUT, PATCH, DELETE |
- Request bodies must be
application/scim+jsonorapplication/json; duplicate JSON members are refused (400,scimTypeinvalidSyntax). The deployment'slimits.max_body_bytes(256 KiB by default) applies, as on every route: a larger body is refused with an ordinary413problem response (the SCIM handler's own cap of 1 MiB matters only if that limit is raised). Responses areapplication/scim+jsonwithCache-Control: no-store. - Each connector may send 1200 requests per minute; above that the answer is
429withRetry-After. The limit fails closed: when the limiter (a PostgreSQL counter) cannot be read, the answer is503("temporarily unavailable"). - Errors are SCIM error documents (RFC 7644 §3.12):
schemas,status,detailand, where it applies,scimType(invalidSyntax,invalidFilter,invalidValue,invalidPath,noTarget,mutability,uniqueness,tooMany). A missing or invalid token gets401withWWW-Authenticate: Bearer realm="scim". Validation failures that the administration API reports as422are400 invalidValuehere, and every409(including a separation-of-duties conflict or the last-owner rule) carriesscimTypeuniqueness. idis ProudAuth's ID (usr_...,grp_...).meta.versionand theETagheader areW/"<n>", derived from the resource's content (users:userName,displayName,name,active,emails,externalId; groups:displayName,externalIdand members) and the connector's write counter, so a change made through the administration API to any of these makes a SCIM write with the old tag stale. Roles granted to a group do not change its tag.PUT,PATCHandDELETEhonorIf-Match(412when stale; omitted or*matches anything). Each of them locks the resource before comparing the tag: for a user the account row and its membership in the connector's organization, for a group the group row, which are the rows the administration API locks or changes. A concurrent change cannot slip in between (PROV-008, TEST-050). Group writes and local deletion lock the group before its connector mapping and members, so mixed deletion/update requests use the same order.- Conflicts with administrators. Whichever write takes the row first
wins the race, and the other one sees its result: an administrator's
change that commits while a SCIM write is waiting makes that write fail
with
412when it sentIf-Matchfrom before the change, and withoutIf-Matchthe SCIM change is applied on top of it (aPATCHchanges only the attributes it names; aPUTreplaces every stored attribute, by design). An administrator'sPATCH /users/{user}that arrives while a SCIM write holds the account waits and is then applied on top of it.If-Matchis optional forPATCH /users/{user}too: without it thePATCHchanges only the fields it names on the current account, and with theETagofGET /users/{user}it fails with412when anything, including a SCIM write, changed the account since. SendIf-Matchwhen a change must not be applied to an account someone else just changed. Replica consistency evidence includes repeated concurrency-32 User races between conditional connector and administrator writes through three independent pools. Group race evidence adds conditional renames, membership replacement versus administrator additions in both causal orders, and controlled concurrent deletion, preserving group-derived authority and exact transition evidence. This covers atomic conflicts, not full directory-sync capacity.GETof one resource honorsIf-None-Match(304). - Maintenance mode
restrictedrefuses writes with the ordinary503problem response (incidents.md).
Capabilities#
ServiceProviderConfig advertises exactly what is implemented (PROV-009):
patch, filter (maxResults 200) and etag are supported; bulk, sort
and changePassword are not. ResourceTypes lists only the types the
connector may manage. Schemas lists the stored attributes; other attributes
(phoneNumbers, title, the enterprise extension, ...) are accepted and
ignored.
Lists:
filter(RFC 7644 §3.4.2.2):eq,ne,co,sw,ew,gt,ge,lt,le,pr;and,or,notand parentheses; value paths such asemails[type eq "work"].value. Attribute names are case-insensitive and may be qualified with the core schema URN, for Users and Groups alike (urn:ietf:params:scim:schemas:core:2.0:Group:displayName); string comparisons are case-insensitive except foridandexternalId. Ordering operators accept only strings and numbers, andco,sw,ewonly strings. A filter has at most 1024 UTF-8 bytes and 64 nodes; anything else is400 invalidFilter.attributesandexcludedAttributesselect top-level attributes (id,schemasandmetaare always returned).- Paging with
startIndex(from 1) andcount(at most 200). - A filter that is a single
eqcomparison with a string value is answered from indexes, whatever the number of resources:idandexternalId(users and groups),userName(users; matches the username or the email address) anddisplayName(groups). Any other list, including an unfiltered one andeqcombined withand,orornot, examines at most 20,000 resources of the connector: a connector that manages more users (or groups) gets400 tooManyon such a list, never a truncated result. Paging works on the examined set, so each page request examines it again.
User scans read mappings, realm-qualified users/identifiers and
organization-qualified memberships in batches, rather than four database
round trips per candidate. Every match contributes to totalResults,
while only the requested page is retained. Per-item content and ETags
remain identical to single-resource reads. TestSCIMListsBoundDatabaseRoundTrips
counts real SQL requests at two cardinalities (PROV-002, PROV-008, NFR-004,
NFR-006); TestUserBatchReadsKeepRealmAndIdentifierOrder checks the
internal batch's realm boundary and identifier ordering (DOM-002, DOM-007).
Group scans read connector/organization/realm-qualified metadata in one
batch, then stream member rows in the database's group/user order. They
retain one group's raw member collection at a time and only the requested
result page, including for member-dependent filters whose output excludes
members. Data and content-derived ETags match individual reads.
TestSCIMGroupListsBoundDatabaseRoundTrips counts three database round trips
at 8 and 64 managed Groups, checks empty/member-bearing groups, projection,
ETags and foreign/missing mappings (PROV-002, PROV-008, NFR-004, NFR-006).
TestSCIMScanBoundaryPreservesExactAndIndexedResults checks Users and Groups
at 20,000/20,001 resources, including concurrency-32 refusals and indexed
successes: a compound filter remains a bounded scan even when logically
equivalent to a single indexed equality. These are correctness/query-work
checks, not workload capacity measurements.
Lists fail if any database read of a candidate, its identifiers, membership
or group members fails: they return a SCIM error instead of successful
partial totals/pages. A database failure is also not a missing-user 404.
Genuinely deleted resources or candidates removed concurrently stay
invisible. TestSCIMReadFailuresDoNotBecomePartialLists exercises these
boundaries with locked tables and PostgreSQL statement timeouts (PROV-002,
NFR-006, QA-008); restoring reads restores complete lists.
Users#
Stored attributes: userName, name.givenName, name.familyName,
displayName, emails (the primary one, or the first), active and
externalId.
POST /Users (userName required) looks for an existing account by email
(from emails, or userName when it is an email address):
- No account: a new
activeaccount is created (created_viascim:<connector id>). The email is verified only when its domain is a domain the organization verified; otherwise the account gets theverify_emailrequired action. AuserNamethat is not an email address becomes the username when it passes the username rules; one already used by another account is a409 uniquenessconflict. - Existing account: it is adopted only when all of these hold: the email
is verified on that account, its domain is one the organization verified
(a subdomain counts only when the domain was verified with
include_subdomains), the account is not protected (deployment administrators, break-glass accounts), it holds no realm-scoped role, and this connector does not manage it yet. Otherwise the request fails with409 uniqueness; it never takes the account over. An account that a directory connector sources (directory.md) can be adopted under the same rule; the connector then manages only its membership, because identity attributes change only on accounts the connector created.
In both cases the account gets a membership in the connector's organization
with source scim (active, or suspended when the document says
"active": false) and, for a new membership, the connector's
default_role_id (assignment source scim). An existing membership keeps its
roles. Audit: scim.user.provisioned (adopted, active) and, for a new
membership, organization.membership.added (source scim).
An initially inactive new membership with a default role is created,
granted that approved role and suspended in one transaction; no active
membership is visible outside it. General role assignment still requires
an active membership. Reactivation preserves that one default grant
(TestSCIMInitiallyInactiveUserWithDefaultRole, PROV-004, PROV-005, QA-008).
PUT and PATCH /Users/{id}:
activesets the membership in the connector's organization toactiveorsuspended(organization.membership.suspended/.reactivated); suspension also revokes the account's grants and support grants in that organization. The realm-wide account state never changes.- Identity attributes (
userName,name,displayName,emails) change only on accounts the connector owns: created by this connector and with no membership in another organization. Otherwise the request fails with400 mutability, and onlyactivecan change. A new email of an owned account replaces the old one and is verified when its domain is verified for the organization. - Protected accounts and holders of realm-scoped roles cannot be changed
(
403). PATCHtakes 1 to 100 operations (add,replace,remove, any case). Paths:active,userName,displayName,externalId,name,name.givenName,name.familyName,emails(includingemails[...]value paths); an operation withoutpathtakes an object value. Both{"op": "Replace", "path": "active", "value": "False"}(Azure AD style) and{"op": "replace", "value": {"active": false}}(Okta style) are accepted.activecannot be removed.PUTwithoutexternalIdkeeps the stored one.- Audit:
scim.user.updated(identity_changed,active).
DELETE /Users/{id} removes the account's membership in the connector's
organization (reason scim_deprovisioned, organization.membership.removed)
and the connector's mapping, and records scim.user.deprovisioned. The
account, its credentials and its other memberships are not touched; afterwards
the connector no longer finds the account (404). The organization's last
active owner cannot be suspended or removed (409).
Groups#
Groups are ordinary groups of the connector's organization (source scim).
displayNameis required (at most 200 characters) and unique in the organization, case-insensitively (409 uniqueness).- Members must be users this connector provisioned that still have a
membership in the organization; any other member is
400 invalidValue. Adding a member runs the separation-of-duties check of the group's roles (ADR 0013). - Roles come only from the connector's
group_role_mappings: a mapping whose key equals the group'sdisplayNameorexternalId(case-insensitively) grants its roles, limited torole_ceiling_ids, as organization-scoped group assignments with sourcescim. EachPOST,PUTandPATCHof the group re-applies the mapping and removesscimassignments it no longer names; roles that administrators granted to the group are left alone. A change to the connector's mappings takes effect for a group at its next write by the connector. A connector cannot add members to a group whose roles exceed itsrole_ceiling_ids, whoever granted them and including roles inherited from parent groups (403, "the group grants roles beyond this connector's ceiling"). The check runs whenever a request lists members to add, so aPUTor areplaceofmemberson such a group fails as a whole even when it names only existing members; usePATCHremovethere. PATCHsupportsdisplayName(add,replace),members(add,replace,remove;removewithout a value removes every member),members[value eq "<id>"]removal and operations withoutpathcarryingdisplayNameand/ormembers. Other paths are400 invalidPath.DELETE /Groups/{id}deletes the group and every role assignment granted to it, including those an administrator granted, in one transaction: each assignment is recorded asrole.unassigned(reasonGROUP_DELETED, metadatarole_id,subject_type: "group",subject_id), then the group asgroup.deleted; noauthorization.role.unassignedwebhook event is published. The members keep their organization memberships. A group with child groups cannot be deleted (409 uniqueness, "delete or move child groups first"). Deleting a group through the administration API (DELETE /organizations/{org}/groups/{group}) records the same events. Both paths remove inbound/directory Group links atomically, allowing the connector to provision that external ID again. A create also repairs a legacy link whose local group was already deleted; a link to a live group still returns409. Outbound links remain until downstream cleanup succeeds.excludedAttributes=membersleaves members out of a list.- Audit:
group.created,group.deleted,group.member.addedandgroup.member.removed(sourcescim),role.assignedandrole.unassigned.
Audit and evidence#
| Event | Category | When |
|---|---|---|
provisioning.connector.created, .updated, .token_rotated | configuration | Connector management (actor: the administrator) |
scim.user.provisioned, scim.user.updated, scim.user.deprovisioned | account | User requests (actor type connector, subject the account) |
organization.membership.added, .suspended, .reactivated, .removed | security | Membership changes made by a connector |
group.created, group.deleted, group.member.added, group.member.removed, role.assigned, role.unassigned | security, authorization | Group and role changes made by a connector (actor type connector) |
Find a connector's actions with
GET /audit-events?actor_id=con_... or type_prefix=scim.
(monitoring.md).
Stored data: provisioning_connectors (token digest only) and
connector_external_ids (data-inventory.md).
A compromised token is handled in
incidents.md.
Upgrade note#
Migration 0042_scim_membership_source.sql adds scim to the allowed
memberships.source values (expand-only). Older binaries in a mixed-version
window have no SCIM routes; create connectors after the rollout.
Since migration 0065 (directory integration), this API accepts and lists
only scim_inbound connectors. A scim_outbound connector created here
earlier (stored, never used) is no longer visible here; it now appears at
/directory-connectors as an outbound connector without a base URL or
credential, and while it is active its deliveries fail and dead-letter.
Disable such connectors before the upgrade (PUT here with
"state": "disabled"); afterwards they can only be changed through
/directory-connectors, where PUT needs a complete, valid outbound
configuration (outbound-provisioning.md).
Not covered#
- No interoperability test against a real directory (Okta, Microsoft Entra ID,
...);
TestSCIMProvisioninguses Okta- and Azure AD-style requests (interoperability.md). - Group capacity and real-directory load remain unmeasured. Local SCIM User lifecycle and extended-mix results and User-filter results are published with single-laptop/budget limits; the Group races above are correctness tests.
- Bulk operations, sorting and password changes. Outbound provisioning is a separate feature (outbound-provisioning.md).