feat(scim): SCIM 2.0 user provisioning from Entra ID (self-hosted only) - #535
Merged
Merged
Conversation
Role sync ran only at SSO sign-in, so a user disabled or deleted in Entra kept every MCP API key they held, indefinitely. This adds the push channel the directory needs: a SCIM 2.0 endpoint at /api/scim/v2 that Entra's outbound provisioning creates, updates and — the part that matters — deactivates users through, with no sign-in involved. Groups follow in the next change. `active: false` calls the same UserLifecycleService primitive as the admin's Deactivate, so the directory and the admin can never disagree about what "deactivated" means. DELETE is deprovisioning, not erasure: Entra sends it when a user is purged or when soft-delete is off, and the outcome wanted is "no access", which deactivation already guarantees. Hard-deleting would dissolve the audit trail at exactly the moment an investigation would want it. Three decisions worth knowing: - Identities key on `externalId` (the Entra object id), never on email — the same value the OIDC `oid` claim carries, so a SCIM-created identity and a later SSO sign-in converge on one row. An address already owned by an unlinked local account is refused with 409: binding a tenant's object id to it would let whoever controls the tenant sign in as that person everywhere. - The bearer token is stored as a sha256 digest with a unique index — the schema's first sha256 credential. bcrypt exists to stretch low-entropy passwords; a 256-bit random token gains nothing from it, while an Entra initial cycle sends hundreds of requests in minutes and each must be one indexed lookup. Plaintext (the MCP-key precedent) would turn a database dump into a credential that can deactivate every user. - The controller declares no DTOs. The global ValidationPipe runs with `forbidNonWhitelisted`, and Entra's payloads carry `schemas`, `meta`, the enterprise extension URN and whatever else an admin mapped; a class-based DTO would 400 real traffic on the first unexpected key. A tolerant parser reads the fields we honour and ignores the rest. Entra quirks handled explicitly: `Content-Type: application/scim+json` (the body parser accepted only application/json, so every SCIM POST would have arrived empty); capitalised `Add`/`Replace`/`Remove`; `active` as the string "False"; path-less replace with an object value; `emails[type eq "work"].value`; Test Connection as a filtered GET expecting an empty ListResponse; 409 `uniqueness` on duplicates so Entra falls back to GET-then-PATCH instead of quarantining; initial-cycle bursts above the global 100/min throttle. A brand-new member holding no MCP role is UNRESTRICTED, so the role sync runs with nothing presented right after creation and the provider's fallback (DENY_ALL by default) applies from the first request, not from the first login. Verified end to end on the local stack, with Entra-shaped requests: create → identity marked SCIM-managed, VIEWER membership, "No access (SSO)" grant; duplicate → 409; unlinked local email → 409; PATCH active "False" → MCP key refused, sessions revoked, membership deactivated, all audited; GET still returns the user with active:false; path-less replace reactivates and renames while the old key stays revoked; DELETE → 204 with the row kept; rotate → old token 401, new token 200; disable → 401. Through the UI: enable shows the tenant URL and the token once, then status only.
CodeQL on the SCIM endpoint: the filter regex backtracked polynomially on a string of spaces (input that arrives before authentication is proven), and JSON responses went out through send(JSON.stringify()) rather than the JSON encoder. Filters are now scanned with lastIndexOf and bounded to 512 chars; responses use res.json with the SCIM media type kept.
keysersoft
force-pushed
the
keysersoft/scim-users
branch
from
September 9, 2026 09:16
a8a800e to
35dfa6c
Compare
Handlers return plain objects with the SCIM media type set through @Header instead of writing through an injected Express response. Same wire format; CodeQL could not tie the res.json() helper to a route and reported it as a reflected-XSS sink, while a typed return value is not one.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Second of the SCIM series (after #534). Adds the push channel from the directory: a SCIM 2.0 endpoint at
/api/scim/v2that Entra's outbound provisioning creates, updates and deactivates users through — with no sign-in involved. Groups follow in the next PR.Why
Role sync ran only at SSO sign-in. A user disabled or deleted in Entra therefore kept every MCP API key they held, indefinitely. Verified empirically before this series started.
What it does
active: false(andDELETE) call the sameUserLifecycleServiceprimitive as the admin's Deactivate from feat(users): deactivate members, and make the membership the authority #534: sessions revoked, org-scoped MCP keys deactivated, membership deactivated, audited. The directory and the admin cannot disagree about what "deactivated" means.DELETEis deprovisioning, not erasure — Entra sends it on purge or with soft-delete off; the row is kept so the audit trail and a later re-enable survive.externalId(Entra object id) — the same value as the OIDCoid, so SCIM and SSO converge on one identity row. An email already owned by an unlinked local account → 409, never adopted (nOAuth anti-takeover, same rule as JIT).DENY_ALLby default) applies from the first request.Design points
scim_+ 256 random bits, stored as a sha256 digest with a unique index — the schema's first sha256 credential, and deliberately not bcrypt: verification must be one indexed lookup per request of an Entra burst, and a 256-bit random secret gains nothing from stretching. Shown once.ValidationPiperuns withforbidNonWhitelisted; Entra payloads carryschemas,meta, the enterprise URN and whatever an admin mapped. A tolerant parser reads what we honour and ignores the rest.application/scim+jsonis now accepted — without it every SCIM POST/PATCH arrived as{}./apiso the frontend rewrite and the login-redirect middleware leave it alone; a bare/scimwould have 302'd Entra to the login page.SelfHostedOnlyGuardfirst, so cloud answers 404 before a credential is examined). Cloud gets the additive migration and nothing else.Entra quirks handled
Capitalised
Add/Replace/Remove·activeas the string"False"· path-less replace with an object value (nestedname, enterprise URN) ·emails[type eq "work"].value· Test Connection = filtered GET → 200 empty ListResponse · 409uniquenesson duplicates (so Entra retries with GET-then-PATCH instead of quarantining) · initial-cycle bursts (1000/min on this controller) ·scimLastRequestAtbumped at most once a minute.Verified
Local stack with Entra-shaped requests:
Through the UI: Enable shows the tenant URL and the token once, then status only; Rotate/Disable behind
confirm(). Unit specs for guard, parser, exception filter, users service and admin routes (148 tests in the module).Not yet done: the real-Entra run (Provisioning → Provision on demand) needs a public tunnel, which the corporate network blocks — planned with the Groups PR.