Skip to content

feat(scim): SCIM 2.0 user provisioning from Entra ID (self-hosted only) - #535

Merged
keysersoft merged 4 commits into
mainfrom
keysersoft/scim-users
Sep 9, 2026
Merged

keysersoft merged 4 commits into
mainfrom
keysersoft/scim-users

Conversation

@keysersoft

Copy link
Copy Markdown
Contributor

Second of the SCIM series (after #534). Adds the push channel from the directory: a SCIM 2.0 endpoint at /api/scim/v2 that 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 (and DELETE) call the same UserLifecycleService primitive 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.
  • DELETE is 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.
  • Last admin: sessions and keys are revoked, the membership is kept, and Entra gets a 409 so the failure is visible in its provisioning log.
  • Identities key on externalId (Entra object id) — the same value as the OIDC oid, 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).
  • A freshly created member holds no MCP role and would be unrestricted; the role sync runs immediately with nothing presented so the fallback (DENY_ALL by default) applies from the first request.

Design points

  • Token: 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.
  • No DTOs on the SCIM controller. The global ValidationPipe runs with forbidNonWhitelisted; Entra payloads carry schemas, meta, the enterprise URN and whatever an admin mapped. A tolerant parser reads what we honour and ignores the rest.
  • Body parser: application/scim+json is now accepted — without it every SCIM POST/PATCH arrived as {}.
  • Mounted under /api so the frontend rewrite and the login-redirect middleware leave it alone; a bare /scim would have 302'd Entra to the login page.
  • Self-hosted only (SelfHostedOnlyGuard first, so cloud answers 404 before a credential is examined). Cloud gets the additive migration and nothing else.

Entra quirks handled

Capitalised Add/Replace/Remove · active as the string "False" · path-less replace with an object value (nested name, enterprise URN) · emails[type eq "work"].value · Test Connection = filtered GET → 200 empty ListResponse · 409 uniqueness on duplicates (so Entra retries with GET-then-PATCH instead of quarantining) · initial-cycle bursts (1000/min on this controller) · scimLastRequestAt bumped at most once a minute.

Verified

Local stack with Entra-shaped requests:

POST /Users            → 201, identity scim_managed, VIEWER membership, "No access (SSO)" grant
duplicate POST         → 409 uniqueness
unlinked local email   → 409 (actionable detail)
PATCH active "False"   → key REFUSED, deactivated_at set, key inactive, sessions revoked
                         audit: SCIM_USER_PROVISIONED, ROLE_SYNC_APPLIED, USER_DEACTIVATED,
                                SESSIONS_REVOKED, API_KEY_DEACTIVATED, SCIM_USER_DEPROVISIONED
GET after deactivate   → active:false (soft-delete semantics)
path-less replace      → reactivated + renamed; old key stays revoked
DELETE                 → 204, row kept
rotate                 → old token 401, new 200
disable                → 401 for everyone

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.

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-users.service.ts Dismissed
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.
Comment thread packages/backend/src/identity-providers/scim/scim.controller.ts Fixed
@keysersoft
keysersoft force-pushed the keysersoft/scim-users branch from a8a800e to 35dfa6c Compare September 9, 2026 09:16
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 merged commit a48b0b9 into main Sep 9, 2026
11 checks passed
@keysersoft
keysersoft deleted the keysersoft/scim-users 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