Skip to content

v0.5.0 — SCIM provisioning from Microsoft Entra ID

Latest

Choose a tag to compare

@keysersoft keysersoft released this 09 Sep 12:01
· 1 commit to main since this release
695b018

New: SCIM 2.0 provisioning from Microsoft Entra ID

Role sync has always run at sign-in. That is enough to widen or narrow what someone may do, and useless for taking access away: a person who has stopped signing in never triggers it, and the MCP API key sitting in their .env keeps working indefinitely. The gap was not in enforcement — getAllowedToolIds is evaluated on every request — it was that nothing told AnythingMCP the directory had changed.

This release adds that channel. Entra pushes joiners, movers and leavers over SCIM 2.0, and the change lands without a sign-in.

Self-hosted only. Every SCIM route answers 404 on AnythingMCP Cloud, for the same reason SSO does: a shared deployment would let any tenant point a workspace at an arbitrary directory and provision accounts from it.

Users

Create, update, deactivate, reactivate and delete. DELETE is deprovisioning, not erasure — the account row survives, because hard-deleting it would dissolve the audit trail, tool-invocation attribution and the admin-count checks at exactly the moment an investigation would want them, and a user restored in the directory would come back as a second account.

Deactivation is one step and it is thorough:

  • the workspace membership is deactivated,
  • every MCP API key the user holds is deactivated,
  • existing sessions and dashboard tokens are invalidated.

The last admin of a workspace is protected here as everywhere else: the attempt is refused, audited as LAST_ADMIN_PROTECTION_TRIGGERED, and answered with a 409 so it appears in Entra's own provisioning log.

Groups, and roles that move on their own

Provisioned groups appear under Role mappings with a from SCIM badge, pre-filled with the real display name and ready for their MCP roles. A membership change reaches tools/list within seconds of Entra pushing it — no sign-in, no cache to wait out.

Once SCIM describes a user, the groups claim in their sign-in token is ignored. That is deliberate: SCIM holds the whole membership set, whereas a token can silently drop it past ~150 groups, and letting the token win would let an absent claim revoke access SCIM had just granted. A user SCIM has never described still falls back to the claim.

In the dashboard

Settings → Single sign-on → Provisioning (SCIM). Enable, copy the Tenant URL and the token (shown once), rotate, disable. Live counters for users provisioned and groups synced, when Entra last called, and a Resync roles now button to re-evaluate every SCIM-known member after editing mappings.

Fixes

Three bugs, all found by running provisioning against a live Entra tenant rather than against our own assumptions. Each is a fact about Entra that no unit test could have told us.

  • externalId no longer breaks every update. A non-gallery Entra application ships externalId mapped to mailNickname, not objectId. We answered 400 mutability to any value that did not match our anchor — and since Entra packs the entire user into a single PatchOp, that one rejection discarded the display name, the department and, critically, active. Deprovisioning, the whole point of the feature, silently did nothing, while Entra's Provision on demand view reported four green ticks. The mismatched value is now ignored and recorded as externalIdIgnored on the audit event instead of being fatal.
  • Role sync no longer claims to have changed something when it did not. applied was hardcoded true on the matched path, 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.
  • A repeated group DELETE is no longer an error. Entra retries a delete it did not see acknowledged; the retry got a 404 for a group that was already gone and surfaced as a failure.

Behaviour changes

  • ROLE_SYNC_APPLIED is written only when a sync actually changes something. A re-run over an unchanged directory reports applied: 0, unchanged: N in the ROLE_SYNC_BATCH_COMPLETED summary and writes no per-user event. A refused last-admin demotion still counts as applied and is still audited — the directory asked for a change and was denied, which is what an auditor needs to see.
  • A repeated DELETE /api/scim/v2/Groups/:id answers 204 instead of 404.
  • A SCIM PATCH carrying a mismatched externalId is applied instead of rejected.

Upgrade notes

Two additive migrations, 20260909090000_add_scim_provisioning and 20260909100000_add_scim_groups. Nothing is dropped or rewritten, and provisioning is inert until an admin enables it per provider.

If you already run SSO with role sync, nothing changes until you turn SCIM on. When you do, read docs/scim-entra-setup.md first — two things on the Entra side are not guessable:

  • provisioning needs a second, non-gallery application. The app you registered for sign-in came from App registrations, and Entra shows "automatic provisioning … is not supported" with Get started greyed out on those;
  • externalId must be re-mapped to objectId. Left at its mailNickname default, the same person arrives twice — once from provisioning, once from sign-in.

Verification

4024 tests (3969 pass, 55 skip), plus an end-to-end run against a live Entra tenant over a public tunnel:

Step Result
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, so an initial cycle is not rate-limited

Entra's quirks were exercised for real, not only in tests: application/scim+json, capitalised op, active as the string "False", path-less replace with an object value, filtered paths such as addresses[type eq "work"].streetAddress, excludedAttributes=members, and the userName eq "<guid>" connection probe.

One gap, stated plainly: the full deprovisioning loop through Entra — disable in the directory, watch the API key return 401 — was not run, because it needs a throwaway directory account. The behaviour itself is verified at the API level.

Documentation

  • docs/scim-entra-setup.md — new: click-by-click Entra setup, what a healthy run looks like on the wire, and a troubleshooting table built from the things that actually went wrong.
  • docs/sso.md §6 — what is synced and what is not, the exact deprovisioning semantics, how roles behave between sign-ins, and how to turn it all off.

Full changelog: v0.4.4...v0.5.0