Skip to content

SCIM provisioning

SCIM 2.0 (RFC 7643/7644) is how identity providers provision users and groups across domains. Entra plays SCIM two ways, and the emulator emulates both — in two phases:

  • Phase 1 — SCIM service provider (server). The emulator exposes /scim/v2/Users and /Groups over its directory, so any SCIM client can CRUD it. Also the foundation the client phase reuses (the SCIM↔store mapping).
  • Phase 2 — SCIM provisioning client. The emulator pushes its directory out to a SCIM endpoint you configure, replicating Entra’s real outbound provisioning — so you can test a SCIM receiver you’re building against “what Entra actually sends,” with no cloud tenant.

Shared foundation: the SCIM ↔ store mapping

Section titled “Shared foundation: the SCIM ↔ store mapping”

Both phases translate between the store’s User/Group and SCIM core resources (urn:ietf:params:scim:schemas:core:2.0:{User,Group}, plus the enterprise:2.0:User extension):

SCIM attributestore field
idUser.ID / Group.ID (lowercase GUID)
userNameUser.UserPrincipalName
name.givenName / name.familyNameGivenName / Surname
displayNameDisplayName
emails[primary].valueMail
activeAccountEnabled
password (write-only)scrypt-hashed into PasswordHash
members[].valuegroup membership (group_members)
meta.{created,location,resourceType}derived

Correlation is by userName (Entra’s default matching attribute), so no schema change is needed for phase 1. externalId persistence is a later add (one nullable column) once the client phase needs it.

A new internal/scim surface, registered like /graph (Host-routed scim. subdomain + /scim/v2 on the compat origin).

Endpoints (RFC 7644):

Method + pathPurpose
GET /scim/v2/ServiceProviderConfigadvertised capabilities (patch, filter, bulk=false)
GET /scim/v2/ResourceTypes, /Schemasdiscovery
GET /scim/v2/Users?filter=userName eq "x"&startIndex=1&count=100list (filter + pagination)
POST /scim/v2/Userscreate → 201
GET/PUT/PATCH/DELETE /scim/v2/Users/{id}read / replace / PatchOp / delete
…/Groups (same shape)groups + members PatchOp
  • Auth: bearer secret token (Entra’s SCIM model — a static token, not a JWT). Configurable; a seeded dev default. 401 on mismatch.
  • PatchOp: the common operations Entra emits — replace active (soft deprovision), attribute replaces, and add/remove on members.
  • Wire format: ListResponse / Error message schemas, application/scim+json.

Backed entirely by the existing store repos (CreateUser, ListUsers, AddGroupMember, …), so there’s one directory whether you reach it via Graph, the admin API, or SCIM.

Phase 2 — provisioning client (emulate Entra outbound)

Section titled “Phase 2 — provisioning client (emulate Entra outbound)”

Built (internal/scim provisioner + admin API + portal view), including member-correlated group provisioning and true incremental sync (an updated_at watermark; only users changed since the last cycle are pushed).

A controllable provisioning engine (admin-triggered, not a real 40-min timer) that pushes the directory to a configured target using Entra’s request sequence:

  • Configure: POST /admin/api/scim/target { endpoint, token, scope }.
  • Sync: POST /admin/api/scim/sync { mode: "initial" | "incremental" }.
    • Initial: per in-scope user/group → GET /Users?filter=userName eq "…" (existence probe), then POST or PATCH — the exact shape Entra uses.
    • Incremental: replay changes since a watermark.
    • Deprovision: disabled/removed → PATCH {active:false}, then DELETE.
  • Provisioning log: GET /admin/api/scim/log — every SCIM request/response, mirroring the audit trail.
  • Fidelity: enterprise:2.0:User extension, externalId correlation, soft-delete semantics, quarantine/retry on target errors.

Portal: a “Provisioning” view — configure the target, run a cycle, watch the SCIM request log stream (reuses the Audit view pattern).

  • Phase 1: integration tests drive the SCIM endpoints against the seeded directory (create → list-by-filter → patch active → delete), asserting SCIM wire shapes and bearer auth.
  • Phase 2: an e2e suite stands up a mock SCIM target (a tiny Go server), points the emulator at it, triggers a sync, and asserts the emulator emitted Entra-shaped requests (correlation filter, PatchOp, active:false on disable) — CI-verifiable like the SDK matrix.

The inbound service provider is driven in CI by scim2-tester, an RFC 7643/7644 compliance checker written by neither us nor Microsoft (e2e/scim, job scim-e2e).

Microsoft ships no SCIM client that can witness this: their SCIM client is the Entra provisioning service, a cloud service; their SCIM Validator is hosted and so cannot reach a localhost emulator; and AzureAD/SCIMReferenceCode is a server. An independent third party is therefore the strongest witness available here, and a stronger one than our own client driving our own server.

Outbound: witnessed by Microsoft’s own reference server

Section titled “Outbound: witnessed by Microsoft’s own reference server”

The provisioning client is witnessed differently, because the direction is reversed: the emulator is the SCIM client, so the witness has to be a real service provider on the other end. Microsoft publishes one — AzureAD/SCIMReferenceCode — which makes e2e/scim-outbound (job scim-outbound-e2e) the only Microsoft-authored witness in the SCIM cluster.

It asserts the full Entra sequence end to end: filter-probe, create, externalId correlation, and active:false deprovision through an incremental sync, verified by reading the reference server’s own directory back.

Three things about it are deliberate and worth not “tidying”:

  • It targets netcoreapp3.1, which is out of support. It is built with the .NET 8 SDK and run with DOTNET_ROLL_FORWARD=LatestMajor, so no EOL runtime is installed and not one line of it is patched. Its System.IdentityModel.Tokens.Jwt 5.6.0 carries a known moderate advisory (GHSA-59j7-ghrg-fj52); it is a test fixture, never shipped, and pinned.
  • It runs Debug + ASPNETCORE_ENVIRONMENT=Development, the sample’s documented local mode. A Release build strips the JWT bypass and requires a real OIDC Authority, which no offline test can supply.
  • It is cloned at a pinned commit, never master, so the witness cannot change under us.

Building it is genuinely awkward and fails quietly in two different ways; the suite documents both at the call site. The sharpest is that on macOS the path must be resolve()d, because /var is a symlink to /private/var and MSBuild otherwise canonicalises the sample’s ProjectReference to a different path than the one it was handed, and the build dies with CS0234 across every namespace.

Five checks fail by design, pinned by name in e2e/scim/suite.py so a new failure breaks the build while these stay documented. Each is a place the directory model — which mirrors Entra’s — cannot represent what unconstrained SCIM allows:

checkwhy it cannot pass
emails.type / emails.primary round-tripthe directory keeps a single mail, as Entra’s user does, not a typed multi-valued SCIM collection
members.display round-tripmembership display is derived from the member’s displayName, not stored per edge, so a caller cannot set it independently
remove of activeaccountEnabled is non-nullable in the directory, exactly as in Entra, so removal resets it to the default rather than unsetting it

These are boundaries of the emulated model, not gaps in the wire protocol. If one starts passing, the suite fails as stale rather than silently over- reporting: a pin that no longer describes reality is worse than no pin.

The full SCIM filter grammar (only the eq correlation filters Entra uses), bulk operations, ETags/versioning, and /Me. Provisioning runs on demand (via the admin API or portal), not a background scheduler — deterministic for tests, consistent with clock control.