Skip to content

feat(scim): SCIM 2.0 provisioning from Microsoft Entra ID - #536

Merged
keysersoft merged 10 commits into
mainfrom
keysersoft/scim-groups
Sep 9, 2026
Merged

feat(scim): SCIM 2.0 provisioning from Microsoft Entra ID#536
keysersoft merged 10 commits into
mainfrom
keysersoft/scim-groups

Conversation

@keysersoft

@keysersoft keysersoft commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

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 .env keeps working indefinitely. This PR adds the push
channel — 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

  • Users — create, update, deactivate, reactivate, delete. DELETE is
    deprovisioning, not erasure: the row survives so the audit trail and
    tool-invocation attribution survive with it.
  • Groups — create, membership add/remove, delete. A provisioned group shows
    up under Role mappings with a from SCIM badge, pre-filled and ready for
    its MCP roles.
  • Live role sync — a group change reaches tools/list in seconds, with no
    sign-in. Once SCIM describes a user, the token's groups claim is ignored:
    SCIM holds the whole membership set, a token can silently drop it.
  • Deprovisioning that means itactive: false deactivates the
    membership, kills every MCP API key, and invalidates sessions and
    dashboard tokens, in one step.
  • Dashboard — enable, token shown once, rotate, disable, live counters,
    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 to mailNickname, not
    objectId. We answered 400 mutability, and since Entra packs the whole
    user into one PatchOp, that single rejection discarded the name, the
    department and active — deprovisioning quietly did nothing, while
    Entra's Provision on demand showed four green ticks. The value is now
    ignored and recorded as externalIdIgnored instead of being fatal.
  • Role-sync countersapplied was hardcoded true, so every resync
    reported all members as changed and wrote one ROLE_SYNC_APPLIED per user
    even when nothing moved. Now it compares before writing.
  • Group DELETE — was not idempotent; an Entra retry got a 404 for a group
    that was already gone.

Behaviour changes

  • ROLE_SYNC_APPLIED is written only when a sync changes something. Re-runs
    over an unchanged directory report applied: 0, unchanged: N in the
    ROLE_SYNC_BATCH_COMPLETED summary and write no per-user event.
  • A repeated DELETE /Groups/:id answers 204 instead of 404.
  • A SCIM PATCH carrying a mismatched externalId is applied instead of
    rejected.

Migrations

Additive only: 20260909090000_add_scim_provisioning,
20260909100000_add_scim_groups.

Docs

  • docs/sso.md §6 — what provisioning does, deprovisioning semantics, how
    roles behave between sign-ins, how to turn it off.
  • docs/scim-entra-setup.md — new, click-by-click. It exists because two
    things 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 externalId must be re-mapped to objectId.

Verification

4024 tests (3969 pass, 55 skip). Beyond that, provisioning was run
end to end against a live Entra tenant over a public tunnel:

Test connection filtered GET /Users200, empty ListResponse
Provision on demand, user PATCH /Users/:id200; the existing SSO identity was adoptedscim_managed_at set, external_subject untouched, no duplicate account
Provision on demand, group GET /Groups?excludedAttributes=members → membership PATCHSCIM_GROUP_MEMBERSHIP_CHANGED
Role sync on that change applied: 0, unchanged: 1 — the counter fix, on the real path
Throttle X-Ratelimit-Limit: 1000 on the SCIM controller, so an initial cycle is not rate-limited

Entra quirks exercised for real, not just in tests: application/scim+json,
capitalised op, filtered paths (addresses[type eq "work"].streetAddress),
excludedAttributes=members, and the userName 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.

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.
Comment thread packages/backend/src/identity-providers/scim/scim.controller.ts Fixed
Comment thread packages/backend/src/identity-providers/scim/scim.parser.ts Fixed
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
keysersoft force-pushed the keysersoft/scim-groups branch 2 times, most recently from 8a01df2 to ff7c425 Compare September 9, 2026 09:16
@keysersoft
keysersoft force-pushed the keysersoft/scim-groups branch from ff7c425 to 5686c0f Compare September 9, 2026 09:16
Comment thread packages/backend/src/identity-providers/scim/scim.controller.ts Fixed
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
keysersoft force-pushed the keysersoft/scim-groups branch from 5686c0f to 474983d Compare September 9, 2026 09:23
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
keysersoft force-pushed the keysersoft/scim-groups branch from 474983d to 7f9c076 Compare September 9, 2026 09:23
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.
@keysersoft keysersoft changed the title feat(scim): group provisioning, and one role sync for sign-in and SCIM feat(scim): SCIM 2.0 provisioning from Microsoft Entra ID Sep 9, 2026
@keysersoft
keysersoft merged commit 695b018 into main Sep 9, 2026
11 checks passed
@keysersoft
keysersoft deleted the keysersoft/scim-groups branch September 9, 2026 12:01
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 9, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants