feat(scim): SCIM 2.0 provisioning from Microsoft Entra ID - #536
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-groups
branch
2 times, most recently
from
September 9, 2026 09:16
8a01df2 to
ff7c425
Compare
keysersoft
force-pushed
the
keysersoft/scim-groups
branch
from
September 9, 2026 09:16
ff7c425 to
5686c0f
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.
keysersoft
force-pushed
the
keysersoft/scim-groups
branch
from
September 9, 2026 09:23
5686c0f to
474983d
Compare
Closes the second half of the gap: a group membership change in Entra now reaches a member's MCP tools within the provisioning cycle — or immediately with "Provision on demand" — with no sign-in involved. Verified locally: a group pushed with the user as a member widened tools/list from 0 to 2, a removal took them back to the fallback, and an admin editing the mapping widened them again, all against a live MCP key and without a single login. Groups live in their own tables, deliberately NOT as mapping rows. A mapping that matches a user and carries no roles was a "matched, grant nothing" outcome, which wrote an EMPTY synced set — and a user holding no role at all is unrestricted. Auto-creating an empty mapping for every pushed group would therefore have handed every SCIM user in any group full tool access. The sync now treats an empty union as a fallback (closing that pre-existing hazard for hand-made rows too), the editor no longer persists rows that grant nothing, and pushed groups still appear in the mappings list — with their real names and member counts — so nobody pastes object ids by hand. The role sync has one core and two entry points. When SCIM is enabled, the stored memberships outrank the token's groups claim even at sign-in: they are fresher for removals (a claim is minted at sign-in and lives as long as the session), and a token that arrives WITHOUT a groups claim — an MCP surface token, a claim that stopped after an app-registration edit — must not be read as "member of nothing" and wipe what SCIM granted. Both paths write the same `source`: two sources would be two projections merged by union, and neither writer could ever revoke what the other granted — the leaver problem again, one layer down. `user_identities.scim_managed_at` is what tells "SCIM says no groups" from "SCIM has never mentioned this user"; without it the second case would fall through to DENY_ALL. A mapping edit or a SCIM enable now re-syncs every SCIM-managed member in the background (single-flight per provider, one audit summary per batch), and the panel has a "Resync roles now" for when someone wants to watch. Under APP_ROLES SCIM groups are stored but never change roles; the UI says so. Group PATCHes handle both member forms Entra uses; out-of-scope member ids are skipped and reported rather than failing the whole request, since Entra re-sends group updates once the missing user is provisioned. A deleted group re-syncs its former members. A renamed one updates the mapping's label. The org-role writer now skips deactivated memberships and counts only active admins for last-admin protection.
keysersoft
force-pushed
the
keysersoft/scim-groups
branch
from
September 9, 2026 09:23
474983d to
7f9c076
Compare
Entra retries a delete it did not see acknowledged, and the retry landed on find() and got a 404 — surfacing in the provisioning log as a failure for a group that was already gone. The user path is idempotent because the identity row survives a delete; groups now match it by returning quietly when the row is absent, without a second SCIM_GROUP_DELETED event.
writeAssignments hardcoded applied:true, so the "unchanged" branch was unreachable: every resync reported all N members as changed and wrote one ROLE_SYNC_APPLIED per user, even when the directory had not moved. On a provider of any size a single mapping edit buried the syncs that mattered under hundreds of identical audit rows. It now compares the synced role set it already holds against the one it is about to write, skips the transaction when they match, and reports applied:false. A refused last-admin demotion still counts as applied and is still audited — the directory asked for a change and was denied, which is exactly what an auditor needs to see.
A non-gallery Entra application ships externalId <- mailNickname, not objectId. We answered 400 mutability to any value that did not match the anchor, and Entra packs the whole user into a single PatchOp — so one unsupported operation threw away the display name, the department and, critically, active. Deprovisioning, the reason the feature exists, silently did nothing while Entra's provision-on-demand view showed four green ticks. The mismatched value is now ignored rather than fatal: we keep the oid the identity was anchored to at sign-in, apply everything else, log a warning and record externalIdIgnored on the audit event so the misconfiguration is findable. The mapping still has to be corrected in Entra, or SCIM and sign-in create two accounts for the same person; the create path says so in its error. Found by running provisioning against a real Entra tenant.
…ntra sso.md gains the missing section 6: what provisioning syncs and what it does not, the exact deprovisioning semantics, why the groups claim is ignored once SCIM describes a user, and how to turn it off. The audit event list and the troubleshooting table grew to match. The click-by-click walkthrough moves to its own page, scim-entra-setup.md, because the Entra side has two traps that cost real time and neither is guessable: provisioning needs a second, non-gallery application (the app you registered for sign-in shows "not supported" with Get started greyed out), and externalId ships mapped to mailNickname instead of objectId. It also records that saving credentials on the Connectivity blade does not create the configuration, and that Provision on demand reports Success even when the target answered an error — so the audit log, not the portal, is the check. The in-app setup steps were wrong in the same two places and are corrected.
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.
SCIM 2.0 provisioning from Microsoft Entra ID
Role sync runs at sign-in. That is fine for widening access and useless for
taking it away: a person who has stopped signing in never triggers it, and the
MCP API key in their
.envkeeps working indefinitely. This PR adds the pushchannel — Entra tells AnythingMCP when someone joins, changes group, or leaves,
and the change lands without a sign-in.
Self-hosted only; every route answers 404 on Cloud.
What ships
DELETEisdeprovisioning, not erasure: the row survives so the audit trail and
tool-invocation attribution survive with it.
up under Role mappings with a
from SCIMbadge, pre-filled and ready forits MCP roles.
tools/listin seconds, with nosign-in. Once SCIM describes a user, the token's
groupsclaim is ignored:SCIM holds the whole membership set, a token can silently drop it.
active: falsedeactivates themembership, kills every MCP API key, and invalidates sessions and
dashboard tokens, in one step.
Resync roles now, and setup steps that match what the Entra portal actually
shows in 2026.
Fixes found by running it against a real Entra tenant
Three bugs that unit tests could not have caught, because each is a fact about
Entra rather than about us:
externalId— a non-gallery app ships it mapped tomailNickname, notobjectId. We answered400 mutability, and since Entra packs the wholeuser into one
PatchOp, that single rejection discarded the name, thedepartment and
active— deprovisioning quietly did nothing, whileEntra's Provision on demand showed four green ticks. The value is now
ignored and recorded as
externalIdIgnoredinstead of being fatal.appliedwas hardcoded true, so every resyncreported all members as changed and wrote one
ROLE_SYNC_APPLIEDper usereven when nothing moved. Now it compares before writing.
DELETE— was not idempotent; an Entra retry got a 404 for a groupthat was already gone.
Behaviour changes
ROLE_SYNC_APPLIEDis written only when a sync changes something. Re-runsover an unchanged directory report
applied: 0, unchanged: Nin theROLE_SYNC_BATCH_COMPLETEDsummary and write no per-user event.DELETE /Groups/:idanswers204instead of404.PATCHcarrying a mismatchedexternalIdis applied instead ofrejected.
Migrations
Additive only:
20260909090000_add_scim_provisioning,20260909100000_add_scim_groups.Docs
docs/sso.md§6 — what provisioning does, deprovisioning semantics, howroles behave between sign-ins, how to turn it off.
docs/scim-entra-setup.md— new, click-by-click. It exists because twothings on the Entra side are not guessable and cost real time: provisioning
needs a second, non-gallery application (the app you registered for
sign-in shows "automatic provisioning … is not supported" with Get
started greyed out), and
externalIdmust be re-mapped toobjectId.Verification
4024tests (3969pass,55skip). Beyond that, provisioning was runend to end against a live Entra tenant over a public tunnel:
GET /Users→200, emptyListResponsePATCH /Users/:id→200; the existing SSO identity was adopted —scim_managed_atset,external_subjectuntouched, no duplicate accountGET /Groups?excludedAttributes=members→ membershipPATCH→SCIM_GROUP_MEMBERSHIP_CHANGEDapplied: 0, unchanged: 1— the counter fix, on the real pathX-Ratelimit-Limit: 1000on the SCIM controller, so an initial cycle is not rate-limitedEntra quirks exercised for real, not just in tests:
application/scim+json,capitalised
op, filtered paths (addresses[type eq "work"].streetAddress),excludedAttributes=members, and theuserName eq "<guid>"connection probe.Not covered: the full deprovisioning loop through Entra (disable in the
directory → key returns 401). It needs a throwaway directory account; the
behaviour itself is verified at the API level.