diff --git a/README.md b/README.md index 1ebef80e..04329873 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ The interactive setup handles everything: deployment mode, domain & HTTPS (autom - **Audit logging** — every tool call logged with input, output, duration, status - **Roles & access control** — tool-level whitelisting per custom role, per-user MCP API keys - **[Single sign-on](docs/sso.md)** — Microsoft Entra ID, Google, Okta, Auth0 and generic OIDC; AnythingMCP roles synced from your directory groups on every sign-in, so joiners and leavers are handled where they already are (self-hosted only) +- **[SCIM provisioning](docs/scim-entra-setup.md)** — Entra ID creates, updates and deactivates accounts on its own. Disable someone in the directory and their workspace access and MCP API keys die with it, without waiting for a sign-in (self-hosted only) - **Environment variables** — per-connector `{{VAR}}` interpolation, hidden from the AI - **Docker ready** — `docker compose up` and you're running diff --git a/docs/scim-entra-setup.md b/docs/scim-entra-setup.md new file mode 100644 index 00000000..e4045195 --- /dev/null +++ b/docs/scim-entra-setup.md @@ -0,0 +1,248 @@ +# SCIM provisioning with Microsoft Entra ID — step by step + +Click-by-click setup for **automatic user and group provisioning** from Entra ID +into AnythingMCP. For what provisioning *does* — what is synced, how leavers are +handled, how roles behave between sign-ins — read +[§6 of the SSO guide](sso.md#6-provisioning-scim) first; this page is the +mechanics. + +> **Self-hosted only.** Every SCIM route answers **404** on AnythingMCP Cloud. +> See [deployment.md](deployment.md) to run your own instance. + +**Time:** about 15 minutes. **Reversible:** yes, at every step. + +--- + +## Before you start + +| You need | Notes | +|---|---| +| SSO already working | Provisioning attaches to an existing Entra provider — set it up with [§1–§3 of the SSO guide](sso.md) first | +| **Microsoft Entra ID P1** (or P2) | Automatic provisioning is a paid feature on Microsoft's side | +| **Application Administrator** (or Global Administrator) in Entra | Needed to create the app and its provisioning configuration | +| A **public HTTPS URL** for AnythingMCP | Microsoft calls you from the internet. `localhost` will not do — for a trial run, a tunnel (ngrok, Cloudflare Tunnel) is enough | +| **Admin** in the AnythingMCP workspace | To mint the token | + +> **Two applications, not one.** The app you registered for sign-in came from +> *App registrations*. Entra does **not** offer automatic provisioning on those: +> its Provisioning page says *"automatic provisioning … is not supported"* and +> leaves **Get started** greyed out. Provisioning needs a second, **non-gallery** +> application, created from *Enterprise applications → New application*. The two +> share nothing but the directory, so groups have to be assigned to **both**. + +--- + +## Step 1 — Mint the token in AnythingMCP + +**Settings → Single sign-on → your Entra provider → Provisioning (SCIM) → +Enable provisioning.** + +You get two values: + +| Value | Looks like | +|---|---| +| **Tenant URL** | `https:///api/scim/v2` | +| **Secret token** | `scim_…`, 48 characters | + +**The token is shown once.** Copy both now — *Rotate token* is the only way to +get another one, and rotating breaks the Entra side until you paste the new one. + +> Copying the token from a terminal? `cat` prints it without a trailing +> newline, so zsh appends a `%` to the display. That `%` is **not** part of the +> token — pasting it produces `401 Authentication required` and Entra reports +> `CredentialValidationUnavailable`. Use `pbcopy < file` or select carefully. + +--- + +## Step 2 — Create the provisioning application in Entra + +**Entra admin center → Enterprise applications → New application → Create your +own application.** + +1. Name it something recognisable — `AnythingMCP provisioning`. +2. Choose **Integrate any other application you don't find in the gallery + (Non-gallery)**. +3. **Create.** + +--- + +## Step 3 — Assign the users and groups + +**Your new app → Users and groups → Add user/group.** + +Assign the same groups you assigned to the sign-in app. Only assigned objects +are provisioned, and **assignment does not carry over between the two apps** — +sign-in reads the assignment of the first app, provisioning the assignment of +the second. + +| | | +|---|---| +| Assign **groups**, not individual users, where you can | The group is what carries the role mapping | +| Only **Security** and **Microsoft 365** groups can be assigned | Distribution lists cannot | +| Nested groups are **not** expanded | Members of a child group are not provisioned | + +--- + +## Step 4 — Create the provisioning configuration + +**Your app → Provisioning → Overview → Connect your application.** + +Fill in the *New provisioning configuration* blade: + +| Field | Value | +|---|---| +| Select authentication method | **Bearer authentication** | +| Tenant URL | the URL from step 1, ending in `/api/scim/v2` | +| Secret token | the token from step 1 | + +Then **Test connection** → **Create**. + +> **Test connection alone saves nothing.** Newer tenants show a separate +> *Connectivity* blade where you can enter and save the same two fields; doing +> that stores the credentials but does **not** create the provisioning job — +> *Attribute mapping*, *Scoping filters* and *Start provisioning* all stay +> greyed out and *Provisioning Mode* keeps reading *Manual*. The configuration +> only exists after **Create** on this blade. + +A successful Create takes you to the configuration overview, showing a **Job +ID** and *Current cycle status: Initial sync paused*. That is the expected +state — nothing has been provisioned yet. + +**What Test connection does on the wire:** a single +`GET /api/scim/v2/Users?filter=userName eq ""`. AnythingMCP +answers `200` with an empty `ListResponse`, which is what Entra wants to see. + +--- + +## Step 5 — Fix the `externalId` mapping ⚠️ + +**Your app → Provisioning → Attribute mapping → Provision Microsoft Entra ID +Users.** + +| Target attribute | Must come from | Entra's default | +|---|---|---| +| `userName` | `userPrincipalName` | ✅ already right | +| `active` | `Switch([IsSoftDeleted]…)` | ✅ already right — **do not unmap it**, it is what deprovisions leavers | +| **`externalId`** | **`objectId`** | ❌ ships as `mailNickname` — **change it** | + +Click the `externalId` row, set **Source attribute** to `objectId`, **Ok**, +**Save**. + +> **Why this one matters more than the rest.** `externalId` is the identity +> anchor. An SSO sign-in stores the user's `oid`; if SCIM sends `mailNickname` +> instead, the same human arrives as two different people — one account created +> by provisioning, another by sign-in, each with their own roles. On an account +> that already exists AnythingMCP keeps its own anchor and applies the rest of +> the update rather than refusing it, recording `externalIdIgnored` on the +> `SCIM_USER_UPDATED` audit event so you can find the misconfiguration. Fix the +> mapping before you let a full cycle run. + +Everything else can stay as it is. AnythingMCP stores the display name and the +email and ignores the rest (addresses, phone numbers, department) — harmless. + +--- + +## Step 6 — Scope + +**Provisioning → Settings → Scope: *Sync only assigned users and groups*.** + +This is the default and it is the one you want: it mirrors the rule the groups +claim already follows. *Sync all users and groups* would push your entire +directory. + +--- + +## Step 7 — Test one person before letting it loose + +**Provisioning → Provision on demand.** Search for a user, **Provision**. + +Then check the result **in AnythingMCP**, not only in Entra: + +- **Settings → Users** — the person is listed +- **Audit Log** — a `SCIM_USER_PROVISIONED` (new account) or + `SCIM_USER_UPDATED` (existing one adopted) event +- **Settings → Single sign-on → Provisioning (SCIM)** — *Users provisioned* + goes up, *Last request from Entra* is recent + +> **Do not trust the four green ticks.** Entra's *Provision on demand* view has +> been observed reporting **Success** on all four steps while the target +> answered an HTTP error and changed nothing. The audit log is the source of +> truth. + +To test a group the same way, select the group instead of a user; Entra lets +you pick up to five members per run. + +--- + +## Step 8 — Start provisioning + +**Provisioning → Overview → Start provisioning.** + +> **The first cycle creates every assigned user.** Check who is in scope before +> you press it. Later cycles run about every 40 minutes and only carry changes. + +--- + +## Verifying it end to end + +A healthy provision-on-demand of one user in one group looks like this on the +wire — useful when you need to prove where a problem is: + +``` +GET /api/scim/v2/Users?filter=userName eq "…" -> 200 +GET /api/scim/v2/Groups?excludedAttributes=members&filter=… -> 200 +GET /api/scim/v2/Users/ -> 200 +PATCH /api/scim/v2/Users/ -> 200 +GET /api/scim/v2/Groups/?excludedAttributes=members -> 200 +PATCH /api/scim/v2/Groups/ -> 200 +``` + +and this in the AnythingMCP audit log: + +``` +SCIM_USER_UPDATED +SCIM_GROUP_MEMBERSHIP_CHANGED { added: 1 } +ROLE_SYNC_APPLIED { trigger: 'scim', matched: 1 } +ROLE_SYNC_BATCH_COMPLETED { applied: 1, unchanged: 0 } +``` + +`ROLE_SYNC_APPLIED` appears only when the sync actually changed something; a +re-run over an unchanged directory reports `applied: 0, unchanged: N` and +writes no per-user event. + +--- + +## Day-two operations + +| Task | Where | +|---|---| +| Give a provisioned group its MCP roles | **Role mappings** — SCIM groups appear automatically with a `from SCIM` badge | +| Remove a group | Unassign it in Entra, not in AnythingMCP | +| Re-evaluate everyone after editing mappings | **Resync roles now** in the provisioning panel | +| Rotate the token | **Rotate token**, then paste the new one in Entra — the old one dies immediately | +| Stop provisioning | **Disable** in AnythingMCP (SCIM routes then answer `401`), or *Pause provisioning* in Entra. Accounts, roles and mappings are left untouched | + +--- + +## Troubleshooting + +| Symptom | Cause and fix | +|---|---| +| **Get started** greyed out, *"automatic provisioning … is not supported"* | The app came from *App registrations*. Create the second, non-gallery app (step 2) | +| Test connection fails, `CredentialValidationUnavailable` + `Unauthorized` | Wrong or mistyped token — check for a stray `%` or whitespace. A rotation invalidates the old token instantly | +| Test connection fails, timeout or DNS error | The Tenant URL is not reachable from the internet, or does not end in `/api/scim/v2` | +| Test connection passes, but nothing else lights up | You saved on the *Connectivity* blade instead of pressing **Create** in *New provisioning configuration* (step 4) | +| A person has two accounts | `externalId` is mapped to `mailNickname`. Fix step 5, then delete the duplicate | +| `externalIdIgnored` on a `SCIM_USER_UPDATED` event | Same cause. The update went through anyway; fix the mapping before more accounts appear | +| Provisioned, but the user gets no tools | No mapped group matched, so the fallback applied. Give the `from SCIM` row its MCP roles, and check role sync is on and reading **groups** | +| A group change never arrives | The group is not assigned to the **provisioning** app (assignment does not carry over), or the cycle has not run — use *Provision on demand* | +| `409` when Entra creates a user | An unlinked local account already uses that email, or the change would deactivate the last admin. Both are reported in Entra's provisioning log | +| A leaver still has access | `active` is unmapped in step 5, or their key was issued outside AnythingMCP. Look for `SCIM_USER_DEPROVISIONED` in the audit log | +| Every SCIM route returns 404 | You are on AnythingMCP Cloud. Provisioning is self-hosted only | + +--- + +## See also + +- [Single sign-on](sso.md) — providers, role mappings, enforcement, recovery codes +- [Deployment](deployment.md) — running your own instance diff --git a/docs/sso.md b/docs/sso.md index b5d1491b..ae163a7a 100644 --- a/docs/sso.md +++ b/docs/sso.md @@ -19,6 +19,7 @@ password, and keep their AnythingMCP roles in step with your directory groups. | **Sign-in** | Members authenticate at your IdP; no AnythingMCP password | | **Account linking** | An existing password account can attach an IdP identity | | **Role sync** | Directory groups (or app roles) grant AnythingMCP roles at every sign-in | +| **Provisioning (SCIM)** | Entra creates, updates and **deactivates** accounts with no sign-in required (Entra ID only) | | **Require SSO** | Turn off password sign-in for the whole workspace | | **Recovery codes** | Single-use break-glass credentials for when the IdP is unreachable | @@ -184,6 +185,121 @@ whole previous set. --- +## 6. Provisioning (SCIM) + +**Settings → Single sign-on → Provisioning (SCIM) → Enable provisioning.** +Entra ID only, self-hosted only. + +Role sync (§3) runs at **sign-in**. That is enough to widen or narrow what +someone may do, but it never runs for a person who has stopped signing in — and +an MCP API key keeps working without a sign-in. SCIM is the missing push +channel: Entra tells AnythingMCP about the change when it happens, with no +sign-in required. + +Enabling mints a **bearer token, shown once**. Copy it together with the Tenant +URL before leaving the page; rotating is the only way to get another one. + +### Set it up in Entra + +The click-by-click walkthrough — including the two traps that cost the most +time — lives in **[SCIM provisioning with Microsoft Entra ID, step by +step](scim-entra-setup.md)**. In outline: + +1. Enable provisioning here and copy the **Tenant URL** and **Secret token**. +2. Create a **second, non-gallery application** in Entra + (*Enterprise applications → New application → Create your own application*). +3. Assign your groups to it — assignment does not carry over from the sign-in + app. +4. **Provisioning → Overview → Connect your application**: bearer + authentication, the two values from step 1, **Test connection**, **Create**. +5. **Attribute mapping**: change `externalId` from `mailNickname` to + **`objectId`**. +6. **Provision on demand** one person to check it, then **Start provisioning**. + +> **It has to be a second application.** The app you registered in §1 came from +> *App registrations*; Entra does not offer automatic provisioning on those, and +> its Provisioning page shows "automatic provisioning … is not supported" with +> **Get started** greyed out. + +> **`externalId` is the one mapping you must change.** Entra ships it as +> `mailNickname`; the identity anchor an SSO sign-in stores is the `oid`. Leave +> the default and the same person arrives twice — once from provisioning, once +> from sign-in. On an account that already exists AnythingMCP keeps its own +> anchor and applies the rest of the update rather than refusing it, recording +> `externalIdIgnored` on the audit event. + +> **The first cycle creates every assigned user**, then Entra polls about every +> 40 minutes. And Entra's *Provision on demand* has been seen reporting every +> step as *Success* while the target answered an error — check +> `SCIM_USER_PROVISIONED` / `SCIM_USER_UPDATED` in the audit log instead. + +> Group **object IDs are tenant-wide**, so groups pushed by SCIM land on the +> same mappings as the ones the sign-in token carries. Existing mappings keep +> the roles you gave them. + +### What is synced, and what is not + +| Synced | Not synced | +|---|---| +| Account creation for assigned users | Passwords — provisioned accounts sign in through SSO | +| Display name and email | Photos, phone numbers, manager, department | +| `active` → deactivate / reactivate | Workspace *ownership* or billing | +| Group membership → MCP roles, live | Nested group members (Entra does not expand them) | + +Provisioned people appear under **Settings → Users**, and their groups appear +in **Role mappings** with a `from SCIM` badge. Those rows are filled in for you: +assign MCP roles to them as usual, but remove them by unassigning the group in +Entra rather than in AnythingMCP. + +### Deprovisioning + +When Entra reports a user as `active: false` — unassigned from the app, disabled +or deleted in the directory — AnythingMCP, in one step and without a sign-in: + +- deactivates the workspace membership, +- **deactivates every MCP API key** the user holds, +- invalidates their existing sessions and dashboard tokens. + +A `DELETE` (Entra sends one when a user is purged, roughly 30 days later) does +the same thing. The account row is **kept**: hard-deleting it would dissolve the +audit trail and tool-invocation attribution at exactly the moment an +investigation would want them, and a user restored in Entra would come back as a +second account. Re-enabling in Entra restores the membership; the old API keys +stay dead and must be reissued. + +The last admin of a workspace is protected here as it is everywhere else: the +deactivation is refused, the attempt is audited as +`LAST_ADMIN_PROTECTION_TRIGGERED`, and Entra receives a `409` so the failure +appears in its provisioning log. + +### Roles between sign-ins + +With SCIM on and role sync reading **groups**, group membership arriving over +SCIM is what decides roles — the `groups` claim in a sign-in token is ignored +for users SCIM has described. That is deliberate: SCIM holds the whole +membership set, whereas a token can silently drop it (see *Tokens that carry too +many 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. + +A group change reaches the user's tools within seconds of Entra pushing it — +`tools/list` widens or narrows with no sign-in. **Resync roles now** in the +panel re-evaluates every SCIM-known member against the current mappings; use it +after editing mappings if you do not want to wait for the next directory change. + +> **If role sync is off, or set to *app roles*, SCIM still provisions accounts +> but changes no roles.** The panel says so, and the Role mappings tab warns +> when the source is app roles. + +### Turning it off + +**Disable** discards the token and stops Entra reaching the endpoint; the SCIM +routes then answer `401`. Accounts, memberships, roles and group mappings are +left exactly as they are. **Rotate token** invalidates the old token +immediately — Entra provisioning fails until the new one is pasted in. + +--- + ## Turning SSO off | Goal | How | @@ -234,6 +350,16 @@ holds any MCP role, they see exactly what their roles grant. `SSO_LOGIN_FAILED`, `ROLE_SYNC_APPLIED`, `ROLE_SYNC_SKIPPED`, `LAST_ADMIN_PROTECTION_TRIGGERED`, `RECOVERY_CODES_GENERATED`, `RECOVERY_CODE_USED`. +- Provisioning adds `SCIM_ENABLED`, `SCIM_DISABLED`, `SCIM_TOKEN_ROTATED`, + `SCIM_AUTH_FAILED`, `SCIM_USER_PROVISIONED`, `SCIM_USER_UPDATED`, + `SCIM_USER_DEPROVISIONED`, `SCIM_USER_REACTIVATED`, `SCIM_GROUP_CREATED`, + `SCIM_GROUP_DELETED` and `SCIM_GROUP_MEMBERSHIP_CHANGED`. The SCIM bearer + token is stored as a hash and never appears in an audit entry, including the + one recording its own rotation. +- `ROLE_SYNC_APPLIED` is written only when a sync actually **changes** + something. A re-run that finds the directory where it left it writes nothing + and is counted as *unchanged* in the `ROLE_SYNC_BATCH_COMPLETED` summary, so + the entries that remain are the ones that moved someone's access. - Group object ids are **not** written to the audit trail. Sign-in events record the *count* of groups presented, which answers "did the directory send anything at all?" without persisting directory structure. @@ -248,3 +374,11 @@ holds any MCP role, they see exactly what their roles grant. | `ROLE_SYNC_SKIPPED` with `reason: overage` | The user is in too many groups; switch the claim to *Groups assigned to the application* | | Cannot enable **Require single sign-on** | Complete one dashboard sign-in through the provider, and generate recovery codes | | Every SSO route returns 404 | You are on AnythingMCP Cloud. SSO is self-hosted only | +| Entra's **Get started** for provisioning is greyed out | The app came from *App registrations*. Provisioning needs a second, non-gallery app — see §6 | +| **Test connection** fails | The Tenant URL must end in `/api/scim/v2` and be reachable from Microsoft's network; the token must be the current one. A rotation invalidates the old token immediately | +| Provisioned, but the user has no tools | No mapped group matched, so the fallback applied. Assign MCP roles to the `from SCIM` row under *Role mappings*, or check role sync is on and reading **groups** | +| SCIM created a second account for someone who already signs in with SSO | `externalId` is mapped to `mailNickname`. Point it at `objectId`, then delete the duplicate | +| `externalIdIgnored` on a `SCIM_USER_UPDATED` event | Same cause. The update was applied anyway, but fix the mapping before more accounts are created | +| A group change in Entra does not reach AnythingMCP | The group is not assigned to the **provisioning** app (assignment does not carry over from the sign-in app), or the cycle has not run yet — use *Provision on demand* | +| A leaver still has access | Their key was issued outside AnythingMCP's knowledge, or `active` is not mapped in Entra's attribute mappings. Check for `SCIM_USER_DEPROVISIONED` in the audit log | +| `409` when Entra creates a user | An unlinked local account already uses that email, or the change would deactivate the last admin. Both are reported in Entra's provisioning log | diff --git a/package.json b/package.json index 2d763a33..a7efa1bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "anythingmcp", - "version": "0.4.4", + "version": "0.5.0", "description": "Self-hosted MCP gateway for REST, SOAP/WSDL, GraphQL and SQL — turn any API into MCP tools for Claude, ChatGPT, Gemini, Copilot and Cursor. 30+ pre-built adapters, on-prem audit log, OAuth2/RBAC. Open source (AGPL-3.0).", "private": true, "license": "AGPL-3.0-only", diff --git a/packages/backend/package.json b/packages/backend/package.json index a49a65c2..b1c53992 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,6 +1,6 @@ { "name": "@anythingmcp/backend", - "version": "0.4.4", + "version": "0.5.0", "description": "AnythingMCP — NestJS Backend + Dynamic MCP Server", "private": true, "license": "AGPL-3.0-only", diff --git a/packages/backend/prisma/migrations/20260909090000_add_scim_provisioning/migration.sql b/packages/backend/prisma/migrations/20260909090000_add_scim_provisioning/migration.sql new file mode 100644 index 00000000..50b4ff47 --- /dev/null +++ b/packages/backend/prisma/migrations/20260909090000_add_scim_provisioning/migration.sql @@ -0,0 +1,14 @@ +-- SCIM 2.0 inbound provisioning (Entra ID → /api/scim/v2). + +-- AlterTable: identity_providers +ALTER TABLE "identity_providers" + ADD COLUMN "scim_enabled" BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN "scim_token_hash" TEXT, + ADD COLUMN "scim_token_issued_at" TIMESTAMP(3), + ADD COLUMN "scim_last_request_at" TIMESTAMP(3); + +-- One token per provider; the guard looks it up by this index on every request. +CREATE UNIQUE INDEX "identity_providers_scim_token_hash_key" ON "identity_providers"("scim_token_hash"); + +-- AlterTable: user_identities +ALTER TABLE "user_identities" ADD COLUMN "scim_managed_at" TIMESTAMP(3); diff --git a/packages/backend/prisma/migrations/20260909100000_add_scim_groups/migration.sql b/packages/backend/prisma/migrations/20260909100000_add_scim_groups/migration.sql new file mode 100644 index 00000000..65b039b3 --- /dev/null +++ b/packages/backend/prisma/migrations/20260909100000_add_scim_groups/migration.sql @@ -0,0 +1,28 @@ +-- SCIM group state: groups pushed by the directory and their members. +-- Separate from identity_provider_role_mappings on purpose — see schema.prisma. + +CREATE TABLE "identity_provider_groups" ( + "id" TEXT NOT NULL, + "provider_id" TEXT NOT NULL, + "external_id" TEXT, + "display_name" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "identity_provider_groups_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "identity_provider_groups_provider_id_external_id_key" ON "identity_provider_groups"("provider_id", "external_id"); +CREATE INDEX "identity_provider_groups_provider_id_idx" ON "identity_provider_groups"("provider_id"); +ALTER TABLE "identity_provider_groups" ADD CONSTRAINT "identity_provider_groups_provider_id_fkey" + FOREIGN KEY ("provider_id") REFERENCES "identity_providers"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE TABLE "identity_provider_group_members" ( + "group_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "identity_provider_group_members_pkey" PRIMARY KEY ("group_id", "user_id") +); +CREATE INDEX "identity_provider_group_members_user_id_idx" ON "identity_provider_group_members"("user_id"); +ALTER TABLE "identity_provider_group_members" ADD CONSTRAINT "identity_provider_group_members_group_id_fkey" + FOREIGN KEY ("group_id") REFERENCES "identity_provider_groups"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "identity_provider_group_members" ADD CONSTRAINT "identity_provider_group_members_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/backend/prisma/schema.prisma b/packages/backend/prisma/schema.prisma index a61acfd7..e0e38282 100644 --- a/packages/backend/prisma/schema.prisma +++ b/packages/backend/prisma/schema.prisma @@ -166,6 +166,7 @@ model User { mcpApiKeys McpApiKey[] emailVerificationTokens EmailVerificationToken[] recoveryCodes RecoveryCode[] + scimGroupMemberships IdentityProviderGroupMember[] @@index([organizationId]) @@map("users") @@ -782,6 +783,20 @@ model IdentityProvider { /// through this provider has succeeded. lastSuccessfulLoginAt DateTime? @map("last_successful_login_at") + // ── SCIM 2.0 inbound provisioning ────────────────────────────────────── + /// Entra pushes users and groups to /api/scim/v2 with a long-lived bearer. + scimEnabled Boolean @default(false) @map("scim_enabled") + /// sha256 hex of the bearer token. The plaintext is shown once at issue and + /// never stored. This is the schema's first sha256 credential and differs + /// from the bcrypt used for passwords on purpose: the token is 256 random + /// bits, so stretching adds nothing, while verification has to be a single + /// indexed lookup on every request of an Entra provisioning burst. + scimTokenHash String? @unique @map("scim_token_hash") + scimTokenIssuedAt DateTime? @map("scim_token_issued_at") + /// Bumped at most once a minute by the SCIM guard — "is Entra still talking + /// to us". Entra has no end-of-cycle call, so this is the only honest signal. + scimLastRequestAt DateTime? @map("scim_last_request_at") + /// Per-type settings, validated by a Zod schema keyed on `type` — the same /// shape-in-Json approach as connector engines. ENTRA: { tenantId }. /// OKTA: { authorizationServerId? }. AUTH0: { rolesClaimNamespace }. @@ -794,6 +809,7 @@ model IdentityProvider { roleMappings IdentityProviderRoleMapping[] identities UserIdentity[] loginAttempts SsoLoginAttempt[] + scimGroups IdentityProviderGroup[] @@unique([organizationId, name]) @@index([organizationId]) @@ -826,6 +842,48 @@ model IdentityProviderRoleMapping { @@map("identity_provider_role_mappings") } +/// A directory group pushed over SCIM, with its members. +/// +/// Deliberately NOT a row of `IdentityProviderRoleMapping`. A mapping that +/// matches a user and carries no roles is a real "matched, grant nothing" +/// outcome, which `RoleSyncService` turns into 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 hand every SCIM user in any group +/// full tool access. Groups live here; mappings join on `(providerId, +/// externalId)` and are created only when an admin assigns roles. +model IdentityProviderGroup { + id String @id @default(cuid()) + providerId String @map("provider_id") + provider IdentityProvider @relation(fields: [providerId], references: [id], onDelete: Cascade) + + /// Entra group OBJECT ID (SCIM `externalId`). Nullable defensively: a group + /// without one can never match a mapping and is ignored by role sync. + externalId String? @map("external_id") + displayName String @map("display_name") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + members IdentityProviderGroupMember[] + + @@unique([providerId, externalId]) + @@index([providerId]) + @@map("identity_provider_groups") +} + +model IdentityProviderGroupMember { + groupId String @map("group_id") + group IdentityProviderGroup @relation(fields: [groupId], references: [id], onDelete: Cascade) + userId String @map("user_id") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) @map("created_at") + + @@id([groupId, userId]) + @@index([userId]) + @@map("identity_provider_group_members") +} + /// Links a local user to an identity at an external provider. /// /// SECURITY: the key is (provider, externalSubject) — NEVER the email. Entra's @@ -840,8 +898,16 @@ model UserIdentity { providerId String @map("provider_id") provider IdentityProvider @relation(fields: [providerId], references: [id], onDelete: Cascade) - /// The provider's immutable subject. Entra: the `oid` claim. + /// The provider's immutable subject. Entra: the `oid` claim. SCIM sends the + /// same object id as `externalId`, so a SCIM-created identity and a later + /// SSO sign-in converge on this one row. externalSubject String @map("external_subject") + /// Set when SCIM has described this user — on create, or on the first + /// PUT/PATCH that touches an identity SSO created. Distinguishes "SCIM says + /// this user is in no group" (authoritative) from "SCIM has never mentioned + /// them" (fall back to the token's groups claim). Without it a token that + /// arrives without a groups claim would wipe SCIM-derived roles. + scimManagedAt DateTime? @map("scim_managed_at") /// Entra: the `tid` claim. Stored so a tenant change becomes detectable. externalTid String? @map("external_tid") diff --git a/packages/backend/src/audit/security-event.service.ts b/packages/backend/src/audit/security-event.service.ts index f88a3e62..1eb149ea 100644 --- a/packages/backend/src/audit/security-event.service.ts +++ b/packages/backend/src/audit/security-event.service.ts @@ -17,6 +17,9 @@ export const SecurityEvents = { SSO_ENFORCEMENT_CHANGED: 'SSO_ENFORCEMENT_CHANGED', RECOVERY_CODES_GENERATED: 'RECOVERY_CODES_GENERATED', RECOVERY_CODE_USED: 'RECOVERY_CODE_USED', + SCIM_ENABLED: 'SCIM_ENABLED', + SCIM_DISABLED: 'SCIM_DISABLED', + SCIM_TOKEN_ROTATED: 'SCIM_TOKEN_ROTATED', // ── Auth plane: who got in, and who failed to ─────────────────────────── SSO_LOGIN_SUCCESS: 'SSO_LOGIN_SUCCESS', @@ -26,11 +29,24 @@ export const SecurityEvents = { JIT_PROVISIONED: 'JIT_PROVISIONED', /** A bearer token was refused — e.g. an MCP-issued token sent to the dashboard API. */ TOKEN_REJECTED: 'TOKEN_REJECTED', + /** A request to /api/scim/v2 carried no valid bearer. */ + SCIM_AUTH_FAILED: 'SCIM_AUTH_FAILED', + + // ── Provisioning plane: what the directory pushed ─────────────────────── + SCIM_USER_PROVISIONED: 'SCIM_USER_PROVISIONED', + SCIM_USER_UPDATED: 'SCIM_USER_UPDATED', + SCIM_USER_DEPROVISIONED: 'SCIM_USER_DEPROVISIONED', + SCIM_USER_REACTIVATED: 'SCIM_USER_REACTIVATED', + SCIM_GROUP_CREATED: 'SCIM_GROUP_CREATED', + SCIM_GROUP_DELETED: 'SCIM_GROUP_DELETED', + SCIM_GROUP_MEMBERSHIP_CHANGED: 'SCIM_GROUP_MEMBERSHIP_CHANGED', // ── Authorization plane: what they were allowed to do ─────────────────── ROLE_CHANGED: 'ROLE_CHANGED', LAST_ADMIN_PROTECTION_TRIGGERED: 'LAST_ADMIN_PROTECTION_TRIGGERED', MEMBERSHIP_REMOVED_BY_SYNC: 'MEMBERSHIP_REMOVED_BY_SYNC', + /** One summary row per batch re-sync (mapping edit, SCIM enable, group delete). */ + ROLE_SYNC_BATCH_COMPLETED: 'ROLE_SYNC_BATCH_COMPLETED', /** An admin or a directory push removed this member's access to one workspace. */ USER_DEACTIVATED: 'USER_DEACTIVATED', USER_REACTIVATED: 'USER_REACTIVATED', diff --git a/packages/backend/src/identity-providers/identity-providers.controller.spec.ts b/packages/backend/src/identity-providers/identity-providers.controller.spec.ts index bb2ceed8..f5be6534 100644 --- a/packages/backend/src/identity-providers/identity-providers.controller.spec.ts +++ b/packages/backend/src/identity-providers/identity-providers.controller.spec.ts @@ -1,4 +1,6 @@ +import { BadRequestException } from '@nestjs/common'; import { IdentityProvidersController } from './identity-providers.controller'; +import { IdentityProviderError } from './identity-providers.service'; import { SecurityEventService } from '../audit/security-event.service'; import { PrismaService } from '../common/prisma.service'; @@ -53,6 +55,7 @@ describe('IdentityProvidersController audit trail', () => { service, new SecurityEventService(prisma as unknown as PrismaService), { hasUnused: jest.fn(async () => true) } as any, + { resyncProvider: jest.fn(async () => ({ total: 0 })) } as any, ); }); @@ -96,4 +99,38 @@ describe('IdentityProvidersController audit trail', () => { expect(JSON.stringify(created)).not.toContain('SUPER-SECRET'); }); + + describe('SCIM provisioning', () => { + const status = { enabled: true, supported: true, issuedAt: new Date('2026-09-09'), lastRequestAt: null, tenantUrl: 'https://x/api/scim/v2', userCount: 0, unlinkedMemberCount: 0 }; + + it('returns the bearer token ONCE on first enable and never persists it in the audit row', async () => { + service.setScimEnabled = jest.fn().mockResolvedValue({ status, bearerToken: 'scim_' + 'x'.repeat(43) }); + const out = await controller.setScim(req, 'idp-1', { enabled: true } as any); + expect(out.bearerToken).toMatch(/^scim_/); + const row = created.find((r: any) => r.event === 'SCIM_ENABLED'); + expect(row.metadata).toEqual({ providerId: 'idp-1', issued: true }); + expect(JSON.stringify(created)).not.toContain('scim_x'); + expect(JSON.stringify(created)).not.toContain('[REDACTED]'); + }); + + it('does not return a token when SCIM is merely re-enabled', async () => { + service.setScimEnabled = jest.fn().mockResolvedValue({ status, bearerToken: undefined }); + const out = await controller.setScim(req, 'idp-1', { enabled: true } as any); + expect(out.bearerToken).toBeUndefined(); + }); + + it('rotation audits the issue time only', async () => { + service.rotateScimToken = jest.fn().mockResolvedValue({ status, bearerToken: 'scim_' + 'y'.repeat(43) }); + const out = await controller.rotateScim(req, 'idp-1'); + expect(out.bearerToken).toMatch(/^scim_/); + const row = created.find((r: any) => r.event === 'SCIM_TOKEN_ROTATED'); + expect(row.metadata).toEqual({ providerId: 'idp-1', issuedAt: status.issuedAt.toISOString() }); + expect(JSON.stringify(created)).not.toContain('[REDACTED]'); + }); + + it('maps an unsupported provider type to a 400', async () => { + service.setScimEnabled = jest.fn().mockRejectedValue(new IdentityProviderError('SCIM provisioning is only supported for ENTRA providers')); + await expect(controller.setScim(req, 'idp-1', { enabled: true } as any)).rejects.toThrow(BadRequestException); + }); + }); }); diff --git a/packages/backend/src/identity-providers/identity-providers.controller.ts b/packages/backend/src/identity-providers/identity-providers.controller.ts index 82c8352d..e98d16ea 100644 --- a/packages/backend/src/identity-providers/identity-providers.controller.ts +++ b/packages/backend/src/identity-providers/identity-providers.controller.ts @@ -43,6 +43,7 @@ import { SecurityEvents, } from '../audit/security-event.service'; import { RecoveryCodesService } from '../auth/recovery-codes.service'; +import { RoleSyncService } from './role-sync.service'; // Derived from the Prisma enum rather than hand-kept: a new provider type is // then accepted automatically and cannot drift out of sync with the database. @@ -174,6 +175,12 @@ class EnforceSsoDto { enforce: boolean; } +class ScimSettingsDto { + @ApiProperty({ description: 'Turn SCIM provisioning on or off. Enabling for the first time returns the bearer token ONCE.' }) + @IsBoolean() + enabled: boolean; +} + class ReplaceRoleMappingsDto { @ApiProperty({ type: [RoleMappingDto] }) @IsArray() @@ -201,6 +208,7 @@ export class IdentityProvidersController { private readonly service: IdentityProvidersService, private readonly securityEvents: SecurityEventService, private readonly recoveryCodes: RecoveryCodesService, + private readonly roleSync: RoleSyncService, ) {} @Get() @@ -349,6 +357,11 @@ export class IdentityProvidersController { before: before.map(summariseMapping), after: (after ?? []).map(summariseMapping), }); + // With SCIM the memberships are known, so there is no reason to wait for + // each user's next login. Fire-and-forget: an admin's PUT must not block + // on N users behind a proxy; the batch reports itself in the audit trail + // and the panel's "Resync now" awaits when someone wants to watch. + void this.roleSync.resyncProvider(id, this.syncCtx(req)); return after; } @@ -376,6 +389,72 @@ export class IdentityProvidersController { return updated; } + @Post(':id/resync-roles') + @ApiOperation({ summary: 'Re-derive every SCIM-managed member\'s roles from stored group membership (ADMIN)' }) + async resyncRoles(@Req() req: any, @Param('id') id: string) { + const provider = await this.service.findByIdForOrg(id, req.user.organizationId); + if (!provider) throw new NotFoundException('Identity provider not found'); + return this.roleSync.resyncProvider(id, this.syncCtx(req)); + } + + private syncCtx(req: any) { + return { actorUserId: req.user.sub as string, ip: req.ip as string, userAgent: req.headers?.['user-agent'] as string }; + } + + @Get(':id/scim') + @ApiOperation({ summary: 'SCIM provisioning status for this provider (ADMIN)' }) + async scimStatus(@Req() req: any, @Param('id') id: string) { + const status = await this.service.getScimStatus(id, req.user.organizationId, this.publicBaseUrl(req)); + if (!status) throw new NotFoundException('Identity provider not found'); + return status; + } + + @Put(':id/scim') + @ApiOperation({ summary: 'Enable or disable SCIM provisioning (ADMIN). First enable returns the bearer token once.' }) + async setScim(@Req() req: any, @Param('id') id: string, @Body() dto: ScimSettingsDto) { + const result = await this.run(() => + this.service.setScimEnabled(id, req.user.organizationId, dto.enabled, this.publicBaseUrl(req)), + ); + if (!result) throw new NotFoundException('Identity provider not found'); + await this.audit(req, dto.enabled ? SecurityEvents.SCIM_ENABLED : SecurityEvents.SCIM_DISABLED, id, { + // `issued`, never `token*`: the redactor blanks any key naming a token. + issued: Boolean(result.bearerToken), + }); + if (dto.enabled) void this.roleSync.resyncProvider(id, this.syncCtx(req)); + return { ...result.status, ...(result.bearerToken ? { bearerToken: result.bearerToken } : {}) }; + } + + @Post(':id/scim/rotate') + @ApiOperation({ summary: 'Rotate the SCIM bearer token (ADMIN). The old token stops working immediately.' }) + async rotateScim(@Req() req: any, @Param('id') id: string) { + const result = await this.run(() => + this.service.rotateScimToken(id, req.user.organizationId, this.publicBaseUrl(req)), + ); + if (!result) throw new NotFoundException('Identity provider not found'); + await this.audit(req, SecurityEvents.SCIM_TOKEN_ROTATED, id, { + issuedAt: result.status.issuedAt ? new Date(result.status.issuedAt).toISOString() : null, + }); + return { ...result.status, bearerToken: result.bearerToken }; + } + + @Delete(':id/scim') + @ApiOperation({ summary: 'Disable SCIM provisioning and discard the token (ADMIN)' }) + async removeScim(@Req() req: any, @Param('id') id: string) { + const ok = await this.service.disableScim(id, req.user.organizationId); + if (!ok) throw new NotFoundException('Identity provider not found'); + await this.audit(req, SecurityEvents.SCIM_DISABLED, id, { issued: false, removed: true }); + return { message: 'SCIM provisioning disabled' }; + } + + /** Same precedence as the SSO redirect URI; the admin pastes this into Entra. */ + private publicBaseUrl(req: any): string { + const configured = process.env.FRONTEND_URL || process.env.SERVER_URL; + if (configured) return configured.replace(/\/$/, ''); + const proto = String(req.headers?.['x-forwarded-proto'] ?? req.protocol ?? 'https').split(',')[0]; + const host = String(req.headers?.['x-forwarded-host'] ?? req.headers?.host ?? '').split(',')[0]; + return `${proto}://${host}`; + } + @Post(':id/test') @ApiOperation({ summary: diff --git a/packages/backend/src/identity-providers/identity-providers.module.ts b/packages/backend/src/identity-providers/identity-providers.module.ts index 3149b4c1..080a20f5 100644 --- a/packages/backend/src/identity-providers/identity-providers.module.ts +++ b/packages/backend/src/identity-providers/identity-providers.module.ts @@ -4,13 +4,20 @@ import { IdentityProvidersController } from './identity-providers.controller'; import { SsoController } from './sso.controller'; import { SsoService } from './sso.service'; import { RoleSyncService } from './role-sync.service'; +import { ScimController } from './scim/scim.controller'; +import { ScimAuthGuard } from './scim/scim-auth.guard'; +import { ScimUsersService } from './scim/scim-users.service'; +import { ScimGroupsService } from './scim/scim-groups.service'; +import { UsersModule } from '../users/users.module'; // `DeploymentService` and `PrismaService` come from the @Global() PrismaModule, // and `SecurityEventService` from the @Global() AuditModule — providing any of // them here would shadow the shared instance for no reason. @Module({ - controllers: [IdentityProvidersController, SsoController], - providers: [IdentityProvidersService, SsoService, RoleSyncService], + // UsersModule exports UserLifecycleService, which SCIM `active: false` calls. + imports: [UsersModule], + controllers: [IdentityProvidersController, SsoController, ScimController], + providers: [IdentityProvidersService, SsoService, RoleSyncService, ScimAuthGuard, ScimUsersService, ScimGroupsService], exports: [IdentityProvidersService, SsoService, RoleSyncService], }) export class IdentityProvidersModule {} diff --git a/packages/backend/src/identity-providers/identity-providers.service.ts b/packages/backend/src/identity-providers/identity-providers.service.ts index 2a9d6106..3087b525 100644 --- a/packages/backend/src/identity-providers/identity-providers.service.ts +++ b/packages/backend/src/identity-providers/identity-providers.service.ts @@ -1,5 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; -import { randomBytes } from 'crypto'; +import { createHash, randomBytes } from 'crypto'; import { PrismaService } from '../common/prisma.service'; import type { IdentityProviderType, @@ -60,6 +60,9 @@ const PUBLIC_SELECT = { roleSyncSource: true, roleSyncFallback: true, roleSyncDefaultRoleIds: true, + scimEnabled: true, + scimTokenIssuedAt: true, + scimLastRequestAt: true, enforceSso: true, lastSuccessfulLoginAt: true, config: true, @@ -447,17 +450,46 @@ export class IdentityProvidersService { select: { id: true }, }); if (!provider) return null; - return this.prisma.identityProviderRoleMapping.findMany({ - where: { providerId }, - select: { - id: true, - externalId: true, - label: true, - userRole: true, - mcpRoleIds: true, - }, - orderBy: [{ label: 'asc' }, { externalId: 'asc' }], + const [mappings, groups] = await Promise.all([ + this.prisma.identityProviderRoleMapping.findMany({ + where: { providerId }, + select: { id: true, externalId: true, label: true, userRole: true, mcpRoleIds: true }, + }), + this.prisma.identityProviderGroup.findMany({ + where: { providerId, externalId: { not: null } }, + select: { externalId: true, displayName: true, _count: { select: { members: true } } }, + }), + ]); + + // Groups the directory pushed appear as rows even before an admin has + // assigned roles, so nobody has to paste object ids by hand. A row with + // no roles is display-only until saved with some; see replaceRoleMappings. + const byExternal = new Map(groups.map((g) => [g.externalId!, g])); + const rows = mappings.map((m) => { + const g = byExternal.get(m.externalId); + return { + ...m, + scimManaged: Boolean(g), + scimDisplayName: g?.displayName ?? null, + scimMemberCount: g?._count.members ?? null, + }; }); + const mapped = new Set(mappings.map((m) => m.externalId)); + for (const g of groups) { + if (mapped.has(g.externalId!)) continue; + rows.push({ + id: null as unknown as string, + externalId: g.externalId!, + label: g.displayName, + userRole: null, + mcpRoleIds: [], + scimManaged: true, + scimDisplayName: g.displayName, + scimMemberCount: g._count.members, + }); + } + rows.sort((a, b) => (a.label ?? '').localeCompare(b.label ?? '') || a.externalId.localeCompare(b.externalId)); + return rows; } /** @@ -516,17 +548,24 @@ export class IdentityProvidersService { } } + // Rows that grant nothing are NOT persisted. A mapping that matches a user + // and carries no roles is a "matched, grant nothing" outcome; the sync + // now treats that as a fallback, but keeping such rows out of the table + // means there is nothing for a future change to get wrong. SCIM-pushed + // groups still appear in the list (see listRoleMappings) whether or not a + // row exists for them. + const effective = mappings.filter((m) => (m.mcpRoleIds?.length ?? 0) > 0 || m.userRole); await this.prisma.$transaction(async (tx) => { - await tx.identityProviderRoleMapping.deleteMany({ where: { providerId } }); - if (mappings.length > 0) { - await tx.identityProviderRoleMapping.createMany({ - data: mappings.map((m) => ({ - providerId, - externalId: m.externalId.trim(), - label: m.label?.trim() || null, - userRole: m.userRole ?? null, - mcpRoleIds: m.mcpRoleIds ?? [], - })), + await tx.identityProviderRoleMapping.deleteMany({ + where: { providerId, externalId: { notIn: effective.map((m) => m.externalId.trim()) } }, + }); + for (const m of effective) { + const externalId = m.externalId.trim(); + const data = { label: m.label?.trim() || null, userRole: m.userRole ?? null, mcpRoleIds: m.mcpRoleIds ?? [] }; + await tx.identityProviderRoleMapping.upsert({ + where: { providerId_externalId: { providerId, externalId } }, + create: { providerId, externalId, ...data }, + update: data, }); } }); @@ -594,4 +633,103 @@ export class IdentityProvidersService { select: PUBLIC_SELECT, }); } + + // ── SCIM provisioning ───────────────────────────────────────────────────── + + /** Providers whose SCIM client we have tested against. */ + static readonly SCIM_CAPABLE_TYPES: readonly IdentityProviderType[] = ['ENTRA']; + + async getScimStatus(id: string, organizationId: string, baseUrl: string) { + const p = await this.prisma.identityProvider.findFirst({ + where: { id, organizationId }, + select: { id: true, type: true, scimEnabled: true, scimTokenIssuedAt: true, scimLastRequestAt: true }, + }); + if (!p) return null; + const [userCount, groupCount, unlinkedMemberCount] = await Promise.all([ + this.prisma.userIdentity.count({ where: { providerId: id, scimManagedAt: { not: null } } }), + this.prisma.identityProviderGroup.count({ where: { providerId: id } }), + // Members this SCIM client will 409 on: they exist locally but have no + // identity at this provider yet. Shown so the admin knows who to link. + this.prisma.organizationMember.count({ + where: { organizationId, user: { identities: { none: { providerId: id } } } }, + }), + ]); + return { + enabled: p.scimEnabled, + supported: IdentityProvidersService.SCIM_CAPABLE_TYPES.includes(p.type), + issuedAt: p.scimTokenIssuedAt, + lastRequestAt: p.scimLastRequestAt, + tenantUrl: `${baseUrl.replace(/\/$/, '')}/api/scim/v2`, + userCount, + groupCount, + unlinkedMemberCount, + }; + } + + /** + * Turns SCIM on or off. Enabling on a provider without a token mints one and + * returns the PLAINTEXT — the only time it is ever visible. Disabling keeps + * the hash so a later re-enable does not force Entra to be reconfigured. + */ + async setScimEnabled(id: string, organizationId: string, enabled: boolean, baseUrl: string) { + const p = await this.prisma.identityProvider.findFirst({ + where: { id, organizationId }, + select: { id: true, type: true, scimTokenHash: true }, + }); + if (!p) return null; + if (enabled && !IdentityProvidersService.SCIM_CAPABLE_TYPES.includes(p.type)) { + throw new IdentityProviderError(`SCIM provisioning is only supported for ${IdentityProvidersService.SCIM_CAPABLE_TYPES.join(', ')} providers`); + } + let bearerToken: string | undefined; + const data: Record = { scimEnabled: enabled }; + if (enabled && !p.scimTokenHash) { + bearerToken = mintScimToken(); + data.scimTokenHash = scimDigest(bearerToken); + data.scimTokenIssuedAt = new Date(); + } + await this.prisma.identityProvider.update({ where: { id }, data }); + const status = await this.getScimStatus(id, organizationId, baseUrl); + return { status: status!, bearerToken }; + } + + /** Mints a new token; the old one stops working with this write. */ + async rotateScimToken(id: string, organizationId: string, baseUrl: string) { + const p = await this.prisma.identityProvider.findFirst({ + where: { id, organizationId }, + select: { id: true, type: true }, + }); + if (!p) return null; + if (!IdentityProvidersService.SCIM_CAPABLE_TYPES.includes(p.type)) { + throw new IdentityProviderError('SCIM provisioning is not supported for this provider type'); + } + const bearerToken = mintScimToken(); + await this.prisma.identityProvider.update({ + where: { id }, + data: { scimEnabled: true, scimTokenHash: scimDigest(bearerToken), scimTokenIssuedAt: new Date() }, + }); + const status = await this.getScimStatus(id, organizationId, baseUrl); + return { status: status!, bearerToken }; + } + + /** Off, and the credential gone: a leaked token from before is worthless. */ + async disableScim(id: string, organizationId: string): Promise { + const r = await this.prisma.identityProvider.updateMany({ + where: { id, organizationId }, + data: { scimEnabled: false, scimTokenHash: null, scimTokenIssuedAt: null }, + }); + if (r.count === 0) return false; + // Stored memberships would otherwise keep outranking the token's claims + // at sign-in for a channel that no longer receives updates. + await this.prisma.identityProviderGroup.deleteMany({ where: { providerId: id } }); + return true; + } +} + +/** `scim_` + 256 random bits. The prefix lets secret scanners recognise it. */ +function mintScimToken(): string { + return `scim_${randomBytes(32).toString('base64url')}`; +} + +function scimDigest(token: string): string { + return createHash('sha256').update(token, 'utf8').digest('hex'); } diff --git a/packages/backend/src/identity-providers/role-sync.service.spec.ts b/packages/backend/src/identity-providers/role-sync.service.spec.ts index c04c570b..d85b8aa7 100644 --- a/packages/backend/src/identity-providers/role-sync.service.spec.ts +++ b/packages/backend/src/identity-providers/role-sync.service.spec.ts @@ -11,6 +11,7 @@ function makeProvider(over: Partial = {}) { roleSyncSource: 'GROUPS', roleSyncFallback: 'DENY_ALL', roleSyncDefaultRoleIds: [], + scimEnabled: false, ...over, } as any; } @@ -30,14 +31,21 @@ describe('RoleSyncService', () => { upsert: jest.fn(async () => ({ id: 'role_deny' })), }, userRoleAssignment: { + // What the user already holds from a previous sync. Empty by default, + // so every test below writes a genuine change; the no-op tests set it. + findMany: jest.fn(async () => [] as { roleId: string }[]), deleteMany: jest.fn(async () => ({ count: 0 })), createMany: jest.fn(async () => ({ count: 0 })), }, organizationMember: { - findUnique: jest.fn(async () => ({ role: 'VIEWER' })), + findUnique: jest.fn(async () => ({ role: 'VIEWER', deactivatedAt: null })), update: jest.fn(async () => ({})), count: jest.fn(async () => 2), }, + user: { findUnique: jest.fn(async () => ({ organizationId: ORG })), update: jest.fn(async () => ({})) }, + userIdentity: { findUnique: jest.fn(async () => null), findMany: jest.fn(async () => []) }, + identityProviderGroupMember: { findMany: jest.fn(async () => []) }, + identityProvider: { findUnique: jest.fn(async () => null) }, $transaction: jest.fn((fn: any) => fn(prisma)), }; securityEvents = { log: jest.fn() }; @@ -88,6 +96,57 @@ describe('RoleSyncService', () => { expect(out.grantedRoleIds).not.toContain('r9'); }); + // A resync that finds the directory exactly where it left it must write + // nothing and say so — otherwise re-running a batch over a large provider + // reports every user as changed and buries the real changes in audit noise. + it('a directory state that has not moved is neither written nor audited', async () => { + prisma.identityProviderRoleMapping.findMany.mockResolvedValue([ + { externalId: 'g1', userRole: null, mcpRoleIds: ['r1', 'r2'] }, + ]); + prisma.userRoleAssignment.findMany.mockResolvedValue([{ roleId: 'r2' }, { roleId: 'r1' }]); + + const out = await service.syncOnLogin(makeProvider(), USER, { groups: ['g1'] }); + + expect(out).toMatchObject({ applied: false, reason: 'matched', matched: 1 }); + expect([...out.grantedRoleIds].sort()).toEqual(['r1', 'r2']); + expect(prisma.userRoleAssignment.deleteMany).not.toHaveBeenCalled(); + expect(prisma.userRoleAssignment.createMany).not.toHaveBeenCalled(); + expect(securityEvents.log).not.toHaveBeenCalledWith( + expect.objectContaining({ event: 'ROLE_SYNC_APPLIED' }), + ); + }); + + it('a fallback the user already sits on is not re-applied', async () => { + prisma.identityProviderRoleMapping.findMany.mockResolvedValue([]); + prisma.userRoleAssignment.findMany.mockResolvedValue([{ roleId: 'role_deny' }]); + + const out = await service.syncOnLogin(makeProvider(), USER, { groups: ['unmapped'] }); + + expect(out).toMatchObject({ applied: false, reason: 'fallback_deny_all' }); + expect(prisma.userRoleAssignment.deleteMany).not.toHaveBeenCalled(); + expect(securityEvents.log).not.toHaveBeenCalledWith( + expect.objectContaining({ event: 'ROLE_SYNC_APPLIED' }), + ); + }); + + // The counter-case: a refused demotion changes nothing in the database, but + // the directory did ask for one and an auditor has to be able to see it. + it('audits a refused demotion even though no role row moved', async () => { + prisma.identityProviderRoleMapping.findMany.mockResolvedValue([ + { externalId: 'g1', userRole: 'VIEWER', mcpRoleIds: ['r1'] }, + ]); + prisma.userRoleAssignment.findMany.mockResolvedValue([{ roleId: 'r1' }]); + prisma.organizationMember.findUnique.mockResolvedValue({ role: 'ADMIN', deactivatedAt: null }); + prisma.organizationMember.count.mockResolvedValue(1); + + const out = await service.syncOnLogin(makeProvider(), USER, { groups: ['g1'] }); + + expect(out).toMatchObject({ applied: true, lastAdminProtected: true }); + expect(securityEvents.log).toHaveBeenCalledWith( + expect.objectContaining({ event: 'ROLE_SYNC_APPLIED' }), + ); + }); + it('never matches on a group display name', async () => { prisma.identityProviderRoleMapping.findMany.mockResolvedValue([ { externalId: 'GB_Fuehrungskreis', userRole: 'ADMIN', mcpRoleIds: ['r1'] }, @@ -235,4 +294,128 @@ describe('RoleSyncService', () => { expect.objectContaining({ event: 'ROLE_SYNC_FAILED' }), ); }); + + // ── SCIM as the source of truth ─────────────────────────────────────────── + + describe('with SCIM enabled', () => { + const scim = () => makeProvider({ scimEnabled: true }); + const managed = () => prisma.userIdentity.findUnique.mockResolvedValue({ scimManagedAt: new Date() }); + const stored = (ids: string[]) => + prisma.identityProviderGroupMember.findMany.mockResolvedValue(ids.map((externalId) => ({ group: { externalId } }))); + + it('at sign-in, uses the stored memberships and ignores the token entirely', async () => { + managed(); stored(['g1']); + prisma.identityProviderRoleMapping.findMany.mockResolvedValue([ + { externalId: 'g1', userRole: null, mcpRoleIds: ['r1'] }, + { externalId: 'g-token-only', userRole: null, mcpRoleIds: ['r9'] }, + ]); + const out = await service.syncOnLogin(scim(), USER, { groups: ['g-token-only'] }); + expect(out).toMatchObject({ reason: 'matched', membershipSource: 'scim', trigger: 'login' }); + expect(out.grantedRoleIds).toEqual(['r1']); + expect(out.grantedRoleIds).not.toContain('r9'); + }); + + // The whole point: a token WITHOUT a groups claim must not wipe what SCIM + // granted. Before SCIM, this exact case produced DENY_ALL. + it('a token without a groups claim cannot wipe SCIM-derived roles', async () => { + managed(); stored(['g1']); + prisma.identityProviderRoleMapping.findMany.mockResolvedValue([{ externalId: 'g1', userRole: null, mcpRoleIds: ['r1'] }]); + const out = await service.syncOnLogin(scim(), USER, {}); + expect(out).toMatchObject({ reason: 'matched', membershipSource: 'scim' }); + }); + + it('an overage pointer is irrelevant when SCIM describes the user', async () => { + managed(); stored(['g1']); + prisma.identityProviderRoleMapping.findMany.mockResolvedValue([{ externalId: 'g1', userRole: null, mcpRoleIds: ['r1'] }]); + const out = await service.syncOnLogin(scim(), USER, { _claim_names: { groups: 'src1' } }); + expect(out.reason).toBe('matched'); + expect(securityEvents.log).not.toHaveBeenCalledWith(expect.objectContaining({ event: 'ROLE_SYNC_SKIPPED' })); + }); + + it('falls back to the claims for a user SCIM has never described', async () => { + prisma.userIdentity.findUnique.mockResolvedValue({ scimManagedAt: null }); + prisma.identityProviderRoleMapping.findMany.mockResolvedValue([{ externalId: 'g-claim', userRole: null, mcpRoleIds: ['r1'] }]); + const out = await service.syncOnLogin(scim(), USER, { groups: ['g-claim'] }); + expect(out).toMatchObject({ reason: 'matched', membershipSource: 'claims' }); + }); + + // [] from SCIM is authoritative: "in no group" applies the fallback. + it('SCIM saying "no groups" applies the fallback even if the token disagrees', async () => { + managed(); stored([]); + prisma.identityProviderRoleMapping.findMany.mockResolvedValue([{ externalId: 'g-claim', userRole: null, mcpRoleIds: ['r1'] }]); + const out = await service.syncOnLogin(scim(), USER, { groups: ['g-claim'] }); + expect(out).toMatchObject({ reason: 'fallback_deny_all', membershipSource: 'scim' }); + }); + + it('syncFromScim reports source_not_groups under APP_ROLES and no_directory_state when unmanaged', async () => { + expect(await service.syncFromScim(makeProvider({ scimEnabled: true, roleSyncSource: 'APP_ROLES' }), USER)).toMatchObject({ reason: 'source_not_groups' }); + prisma.userIdentity.findUnique.mockResolvedValue(null); + expect(await service.syncFromScim(scim(), USER)).toMatchObject({ reason: 'no_directory_state' }); + expect(prisma.userRoleAssignment.deleteMany).not.toHaveBeenCalled(); + }); + + it('writes the same source as the sign-in path, so the last writer wins', async () => { + managed(); stored(['g1']); + prisma.identityProviderRoleMapping.findMany.mockResolvedValue([{ externalId: 'g1', userRole: null, mcpRoleIds: ['r1'] }]); + await service.syncFromScim(scim(), USER); + expect(prisma.userRoleAssignment.deleteMany).toHaveBeenCalledWith({ + where: expect.objectContaining({ source: SYNC_SOURCE }), + }); + expect(prisma.userRoleAssignment.createMany).toHaveBeenCalledWith( + expect.objectContaining({ data: [expect.objectContaining({ source: SYNC_SOURCE })] }), + ); + }); + }); + + // A mapping that matches and grants nothing used to write an EMPTY synced + // set — leaving the user with no role, which getAllowedToolIds reads as + // unrestricted. It must land on the fallback like a miss does. + it('a match that grants nothing is a fallback, not full access', async () => { + prisma.identityProviderRoleMapping.findMany.mockResolvedValue([{ externalId: 'g1', userRole: null, mcpRoleIds: [] }]); + const out = await service.syncOnLogin(makeProvider(), USER, { groups: ['g1'] }); + expect(out.reason).toBe('fallback_deny_all'); + expect(prisma.role.upsert).toHaveBeenCalled(); + }); + + it('a match that grants only a workspace role is still a match', async () => { + prisma.identityProviderRoleMapping.findMany.mockResolvedValue([{ externalId: 'g1', userRole: 'EDITOR', mcpRoleIds: [] }]); + const out = await service.syncOnLogin(makeProvider(), USER, { groups: ['g1'] }); + expect(out.reason).toBe('matched'); + expect(prisma.organizationMember.update).toHaveBeenCalledWith(expect.objectContaining({ data: { role: 'EDITOR' } })); + }); + + it('leaves a deactivated membership alone', async () => { + prisma.organizationMember.findUnique.mockResolvedValue({ role: 'VIEWER', deactivatedAt: new Date() }); + prisma.identityProviderRoleMapping.findMany.mockResolvedValue([{ externalId: 'g1', userRole: 'ADMIN', mcpRoleIds: ['r1'] }]); + await service.syncOnLogin(makeProvider(), USER, { groups: ['g1'] }); + expect(prisma.organizationMember.update).not.toHaveBeenCalled(); + }); + + describe('resync', () => { + it('counts outcomes, never throws, and writes one batch summary', async () => { + const provider = makeProvider({ scimEnabled: true }); + prisma.userIdentity.findUnique.mockResolvedValue({ scimManagedAt: new Date() }); + prisma.identityProviderGroupMember.findMany.mockResolvedValue([]); + prisma.identityProviderRoleMapping.findMany + .mockResolvedValueOnce([]) // u1 → fallback (applied) + .mockRejectedValueOnce(new Error('boom')); // u2 → failed + const summary = await service.resyncUsers(provider, ['u1', 'u2', 'u1'], { actorUserId: 'admin' }); + expect(summary).toMatchObject({ total: 3, applied: 1, failed: 0, unchanged: 1 }); + expect(securityEvents.log).toHaveBeenCalledWith(expect.objectContaining({ event: 'ROLE_SYNC_BATCH_COMPLETED', actorType: 'USER' })); + }); + + it('resyncProvider is a no-op unless SCIM, role sync and GROUPS are all on', async () => { + prisma.identityProvider.findUnique.mockResolvedValue(makeProvider({ scimEnabled: false })); + expect(await service.resyncProvider('idp_1')).toMatchObject({ total: 0 }); + expect(prisma.userIdentity.findMany).not.toHaveBeenCalled(); + }); + + it('a second resync during a run joins the first', async () => { + prisma.identityProvider.findUnique.mockImplementation(() => new Promise((r) => setTimeout(() => r(makeProvider({ scimEnabled: true })), 20))); + // `async` wraps the shared job in a fresh promise per call, so identity + // is checked on the work, not the wrapper: one provider load, one run. + await Promise.all([service.resyncProvider('idp_1'), service.resyncProvider('idp_1')]); + expect(prisma.identityProvider.findUnique).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/packages/backend/src/identity-providers/role-sync.service.ts b/packages/backend/src/identity-providers/role-sync.service.ts index 382af4d8..5ac4a45b 100644 --- a/packages/backend/src/identity-providers/role-sync.service.ts +++ b/packages/backend/src/identity-providers/role-sync.service.ts @@ -10,6 +10,14 @@ import type { RoleSyncFallback, RoleSyncSource, UserRole } from '../generated/pr * `source` written on every assignment this service owns. It is part of the * unique key on `user_roles`, which is what keeps a sync from ever deleting a * grant an admin made by hand — and vice versa. + * + * ONE source for both the sign-in path and the SCIM path, deliberately. Both + * project the same directory; `writeAssignments` rewrites the whole synced set + * on every run, so the last writer wins and that is correct. Two sources would + * be two independent projections merged by union in `getAllowedToolIds`: the + * more permissive one would always win, and neither writer could ever revoke + * what the other granted — a SCIM removal would leave the last login's grant + * in place, which is precisely the leaver problem SCIM exists to close. */ export const SYNC_SOURCE = 'entra'; @@ -30,8 +38,25 @@ export interface RoleSyncProvider { roleSyncSource: RoleSyncSource; roleSyncFallback: RoleSyncFallback; roleSyncDefaultRoleIds: string[]; + /** When true, stored SCIM memberships outrank the token's groups claim. */ + scimEnabled: boolean; } +/** The provider projection every caller of this service should select. */ +export const ROLE_SYNC_PROVIDER_SELECT = { + id: true, + organizationId: true, + roleSyncEnabled: true, + roleSyncSource: true, + roleSyncFallback: true, + roleSyncDefaultRoleIds: true, + scimEnabled: true, +} as const; + +export type RoleSyncTrigger = 'login' | 'scim' | 'resync'; +/** Which statement of the user's groups decided the outcome. */ +export type MembershipSource = 'claims' | 'scim' | 'none'; + export type RoleSyncReason = | 'disabled' | 'overage' @@ -39,13 +64,19 @@ export type RoleSyncReason = | 'matched' | 'fallback_deny_all' | 'fallback_keep_existing' - | 'fallback_default_role'; + | 'fallback_default_role' + /** A SCIM trigger while the provider reads APP_ROLES — groups mean nothing. */ + | 'source_not_groups' + /** A SCIM trigger for a user SCIM has never described. */ + | 'no_directory_state'; export interface RoleSyncOutcome { /** False means nothing was written. */ applied: boolean; reason: RoleSyncReason; - /** How many ids the token presented. */ + trigger: RoleSyncTrigger; + membershipSource: MembershipSource; + /** How many ids were presented. */ presented: number; /** How many mappings those ids hit. */ matched: number; @@ -56,9 +87,47 @@ export interface RoleSyncOutcome { lastAdminProtected?: boolean; } +export interface ResyncSummary { + providerId: string; + trigger: RoleSyncTrigger; + total: number; + applied: number; + unchanged: number; + failed: number; + lastAdminProtected: number; + durationMs: number; +} + +type SyncCtx = { ip?: string | null; userAgent?: string | null; actorUserId?: string | null }; + +/** Order- and duplicate-insensitive comparison of two role-id lists. */ +const sameSet = (a: string[], b: string[]): boolean => { + const left = new Set(a); + const right = new Set(b); + return left.size === right.size && [...left].every((id) => right.has(id)); +}; + +const NOTHING = (trigger: RoleSyncTrigger, reason: RoleSyncReason, membershipSource: MembershipSource = 'none'): RoleSyncOutcome => ({ + applied: false, + reason, + trigger, + membershipSource, + presented: 0, + matched: 0, + grantedRoleIds: [], +}); + /** * Projects an external directory's groups (or application roles) onto - * AnythingMCP roles, on every sign-in. + * AnythingMCP roles. + * + * Two entry points, one core. `syncOnLogin` reads the token's claims; + * `syncFromScim` reads the memberships the directory pushed over SCIM. When + * SCIM is enabled the stored memberships win even at sign-in: they are fresher + * for removals (a token's groups 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. * * Three properties this service is built around: * @@ -77,12 +146,16 @@ export interface RoleSyncOutcome { @Injectable() export class RoleSyncService { private readonly logger = new Logger(RoleSyncService.name); + /** Single-flight per provider: a second resync during a run joins it. */ + private readonly inflight = new Map>(); constructor( private readonly prisma: PrismaService, private readonly securityEvents: SecurityEventService, ) {} + // ── Entry points ────────────────────────────────────────────────────────── + /** * Runs a sync for one sign-in. Never throws: a directory that returns * something unexpected must not turn a valid authentication into a failed @@ -93,61 +166,164 @@ export class RoleSyncService { provider: RoleSyncProvider, userId: string, claims: Record, - ctx: { ip?: string | null; userAgent?: string | null } = {}, + ctx: SyncCtx = {}, ): Promise { - try { - return await this.run(provider, userId, claims, ctx); - } catch (e: any) { - this.logger.error( - `Role sync failed for provider ${provider.id}: ${e?.message}`, + return this.guarded(provider, userId, ctx, 'login', async () => { + if (!provider.roleSyncEnabled) return NOTHING('login', 'disabled'); + + if (provider.roleSyncSource === 'GROUPS' && provider.scimEnabled) { + const stored = await this.scimPresentedIds(provider.id, userId); + // null: SCIM has never described this user — the claims are all we + // have. [] or more: SCIM is authoritative, the claim is not consulted. + if (stored !== null) { + return this.syncFromDirectoryState(provider, userId, stored, ctx, 'login', 'scim'); + } + } + + // Entra replaces the claim with a Graph URL past ~150 groups (~200 for + // SAML). The list we would read is then simply absent, and every mapping + // would miss. Acting on that would silently strip the roles of the most + // heavily-grouped users in the directory — so we do nothing at all and + // say so loudly. Resolving the overage needs Graph `GroupMember.Read.All`, + // which this product deliberately does not ask for. (SCIM has no such + // cap: Entra pushes membership per group, not a bounded list per user.) + if (this.hasOverage(provider.roleSyncSource, claims)) { + this.logger.warn( + `Role sync skipped: token from provider ${provider.id} signalled a groups overage`, + ); + await this.securityEvents.log({ + event: SecurityEvents.ROLE_SYNC_SKIPPED, + actorType: 'SYSTEM', + organizationId: provider.organizationId, + targetUserId: userId, + metadata: { providerId: provider.id, reason: 'overage' }, + ip: ctx.ip, + userAgent: ctx.userAgent, + }); + return NOTHING('login', 'overage', 'claims'); + } + + return this.syncFromDirectoryState( + provider, + userId, + this.extractIds(provider.roleSyncSource, claims), + ctx, + 'login', + 'claims', ); - await this.securityEvents.log({ - event: SecurityEvents.ROLE_SYNC_FAILED, - actorType: 'SYSTEM', - organizationId: provider.organizationId, - targetUserId: userId, - metadata: { providerId: provider.id, error: String(e?.message ?? e) }, - ip: ctx.ip, - userAgent: ctx.userAgent, - }); - return { applied: false, reason: 'disabled', presented: 0, matched: 0, grantedRoleIds: [] }; - } + }); } - private async run( + /** + * Re-derives one user's roles from the group memberships SCIM has stored. + * Called after every membership change Entra pushes, and by `resyncUsers`. + * Never throws. + */ + async syncFromScim( provider: RoleSyncProvider, userId: string, - claims: Record, - ctx: { ip?: string | null; userAgent?: string | null }, + ctx: SyncCtx = {}, + trigger: 'scim' | 'resync' = 'scim', ): Promise { - if (!provider.roleSyncEnabled) { - return { applied: false, reason: 'disabled', presented: 0, matched: 0, grantedRoleIds: [] }; - } + return this.guarded(provider, userId, ctx, trigger, async () => { + if (!provider.roleSyncEnabled) return NOTHING(trigger, 'disabled'); + // SCIM groups only ever describe groups. Under APP_ROLES the roles come + // from the token's `roles` claim at sign-in, exactly as before. + if (provider.roleSyncSource !== 'GROUPS') return NOTHING(trigger, 'source_not_groups'); + const stored = await this.scimPresentedIds(provider.id, userId); + if (stored === null) return NOTHING(trigger, 'no_directory_state'); + return this.syncFromDirectoryState(provider, userId, stored, ctx, trigger, 'scim'); + }); + } - // Entra replaces the claim with a Graph URL past ~150 groups (~200 for - // SAML). The list we would read is then simply absent, and every mapping - // would miss. Acting on that would silently strip the roles of the most - // heavily-grouped users in the directory — so we do nothing at all and say - // so loudly. Resolving the overage needs Graph `GroupMember.Read.All`, - // which this product deliberately does not ask for. - if (this.hasOverage(provider.roleSyncSource, claims)) { - this.logger.warn( - `Role sync skipped: token from provider ${provider.id} signalled a groups overage`, + /** + * Re-syncs every SCIM-managed user of a provider. Used after a mapping edit + * (with SCIM the memberships are known, so there is no reason to wait for + * each user's next login) and after role sync is switched on. + */ + async resyncProvider(providerId: string, ctx: SyncCtx = {}, opts: { concurrency?: number } = {}): Promise { + const running = this.inflight.get(providerId); + if (running) return running; + + const job = (async () => { + const started = Date.now(); + const provider = await this.prisma.identityProvider.findUnique({ + where: { id: providerId }, + select: ROLE_SYNC_PROVIDER_SELECT, + }); + const empty: ResyncSummary = { + providerId, trigger: 'resync', total: 0, applied: 0, unchanged: 0, failed: 0, lastAdminProtected: 0, durationMs: 0, + }; + if (!provider || !provider.roleSyncEnabled || provider.roleSyncSource !== 'GROUPS' || !provider.scimEnabled) { + return empty; + } + const identities = await this.prisma.userIdentity.findMany({ + where: { providerId, scimManagedAt: { not: null } }, + select: { userId: true }, + }); + const summary = await this.resyncUsers(provider, identities.map((i) => i.userId), ctx, 'resync', opts); + return { ...summary, durationMs: Date.now() - started }; + })().finally(() => this.inflight.delete(providerId)); + + this.inflight.set(providerId, job); + return job; + } + + /** Re-syncs an explicit set of users — e.g. the former members of a deleted group. */ + async resyncUsers( + provider: RoleSyncProvider, + userIds: string[], + ctx: SyncCtx = {}, + trigger: RoleSyncTrigger = 'resync', + opts: { concurrency?: number } = {}, + ): Promise { + const started = Date.now(); + const concurrency = Math.max(1, opts.concurrency ?? 4); + const summary: ResyncSummary = { + providerId: provider.id, trigger, total: userIds.length, applied: 0, unchanged: 0, failed: 0, lastAdminProtected: 0, durationMs: 0, + }; + const unique = [...new Set(userIds)]; + for (let i = 0; i < unique.length; i += concurrency) { + const results = await Promise.allSettled( + unique.slice(i, i + concurrency).map((u) => this.syncFromScim(provider, u, ctx, trigger === 'login' ? 'scim' : trigger)), ); + for (const r of results) { + if (r.status !== 'fulfilled') { summary.failed++; continue; } + if (r.value.applied) summary.applied++; else summary.unchanged++; + if (r.value.lastAdminProtected) summary.lastAdminProtected++; + } + } + summary.durationMs = Date.now() - started; + + if (unique.length > 0) { await this.securityEvents.log({ - event: SecurityEvents.ROLE_SYNC_SKIPPED, - actorType: 'SYSTEM', + event: SecurityEvents.ROLE_SYNC_BATCH_COMPLETED, + actorType: ctx.actorUserId ? 'USER' : 'SYSTEM', + actorUserId: ctx.actorUserId ?? null, organizationId: provider.organizationId, - targetUserId: userId, - metadata: { providerId: provider.id, reason: 'overage' }, + metadata: { ...summary }, ip: ctx.ip, userAgent: ctx.userAgent, }); - return { applied: false, reason: 'overage', presented: 0, matched: 0, grantedRoleIds: [] }; } + return summary; + } - const presented = this.extractIds(provider.roleSyncSource, claims); + // ── The core ────────────────────────────────────────────────────────────── + /** + * `presentedIds` is the directory's complete statement of the user's groups + * (or app roles). Matches them against the provider's mappings and writes + * the result. + */ + private async syncFromDirectoryState( + provider: RoleSyncProvider, + userId: string, + presentedIds: string[], + ctx: SyncCtx, + trigger: RoleSyncTrigger, + membershipSource: MembershipSource, + ): Promise { const mappings = await this.prisma.identityProviderRoleMapping.findMany({ where: { providerId: provider.id }, select: { externalId: true, userRole: true, mcpRoleIds: true }, @@ -155,52 +331,60 @@ export class RoleSyncService { // Set membership rather than a nested loop: a Führungskreis member can // easily present a hundred groups against a few dozen mappings. - const presentedSet = new Set(presented); + const presentedSet = new Set(presentedIds); const matches = mappings.filter((m) => presentedSet.has(m.externalId)); - if (matches.length === 0) { - return this.applyFallback(provider, userId, presented.length, ctx); - } - - const desiredMcpRoleIds = [ - ...new Set(matches.flatMap((m) => m.mcpRoleIds)), - ]; + const desiredMcpRoleIds = [...new Set(matches.flatMap((m) => m.mcpRoleIds))]; // Being in more groups can only ever widen access, so the org role is the // most privileged of the matches — not the last one read. const desiredOrgRole = this.highestOrgRole( matches.map((m) => m.userRole).filter((r): r is UserRole => r != null), ); - const granted = await this.writeAssignments( - provider, - userId, - desiredMcpRoleIds, - ); + // A match that grants nothing is treated as no match. Writing an empty + // synced set would leave the user with no role at all — which + // `getAllowedToolIds` reads as UNRESTRICTED. A mapping row an admin has + // not finished (or a SCIM group nobody has assigned roles to yet) must + // land the user on the fallback, not on full access. + if (matches.length === 0 || (desiredMcpRoleIds.length === 0 && desiredOrgRole === null)) { + return this.applyFallback(provider, userId, presentedIds.length, ctx, trigger, membershipSource); + } + + const { granted, changed } = await this.writeAssignments(provider, userId, desiredMcpRoleIds); const org = await this.writeOrgRole(provider, userId, desiredOrgRole, ctx); - await this.securityEvents.log({ - event: SecurityEvents.ROLE_SYNC_APPLIED, - actorType: 'SYSTEM', - organizationId: provider.organizationId, - targetUserId: userId, - metadata: { - providerId: provider.id, - source: provider.roleSyncSource, - presented: presented.length, - matched: matches.length, - grantedRoleIds: granted, - orgRoleBefore: org.before, - orgRoleAfter: org.after, - lastAdminProtected: org.lastAdminProtected, - }, - ip: ctx.ip, - userAgent: ctx.userAgent, - }); + // A refused demotion is not a no-op: the directory asked for a change and + // was denied, which is precisely what an auditor needs to see. + const applied = changed || org.before !== org.after || Boolean(org.lastAdminProtected); + if (applied) { + await this.securityEvents.log({ + event: SecurityEvents.ROLE_SYNC_APPLIED, + actorType: 'SYSTEM', + organizationId: provider.organizationId, + targetUserId: userId, + metadata: { + providerId: provider.id, + source: provider.roleSyncSource, + trigger, + membershipSource, + presented: presentedIds.length, + matched: matches.length, + grantedRoleIds: granted, + orgRoleBefore: org.before, + orgRoleAfter: org.after, + lastAdminProtected: org.lastAdminProtected, + }, + ip: ctx.ip, + userAgent: ctx.userAgent, + }); + } return { - applied: true, + applied, reason: 'matched', - presented: presented.length, + trigger, + membershipSource, + presented: presentedIds.length, matched: matches.length, grantedRoleIds: granted, orgRoleBefore: org.before, @@ -209,6 +393,58 @@ export class RoleSyncService { }; } + /** The never-throws wrapper shared by every entry point. */ + private async guarded( + provider: RoleSyncProvider, + userId: string, + ctx: SyncCtx, + trigger: RoleSyncTrigger, + fn: () => Promise, + ): Promise { + try { + return await fn(); + } catch (e: any) { + this.logger.error(`Role sync failed for provider ${provider.id}: ${e?.message}`); + await this.securityEvents.log({ + event: SecurityEvents.ROLE_SYNC_FAILED, + actorType: 'SYSTEM', + organizationId: provider.organizationId, + targetUserId: userId, + metadata: { providerId: provider.id, trigger, error: String(e?.message ?? e) }, + ip: ctx.ip, + userAgent: ctx.userAgent, + }); + return NOTHING(trigger, 'disabled'); + } + } + + // ── Directory state ─────────────────────────────────────────────────────── + + /** + * The group object ids SCIM has placed this user in. + * + * null → SCIM has never described this user at this provider. The + * caller must fall back to the token's claims. + * [] → SCIM manages this user and they are in no group. Authoritative. + * [...] → the group object ids. + * + * The null/[] distinction rests on `user_identities.scim_managed_at`, set + * by the SCIM layer on create and on first touch. Without it a user in no + * group would be indistinguishable from one SCIM has never seen. + */ + private async scimPresentedIds(providerId: string, userId: string): Promise { + const identity = await this.prisma.userIdentity.findUnique({ + where: { userId_providerId: { userId, providerId } }, + select: { scimManagedAt: true }, + }); + if (!identity?.scimManagedAt) return null; + const rows = await this.prisma.identityProviderGroupMember.findMany({ + where: { userId, group: { providerId, externalId: { not: null } } }, + select: { group: { select: { externalId: true } } }, + }); + return rows.map((r) => r.group.externalId).filter((id): id is string => Boolean(id)); + } + // ── Claim reading ───────────────────────────────────────────────────────── private hasOverage(source: RoleSyncSource, claims: Record) { @@ -223,10 +459,7 @@ export class RoleSyncService { * rather than coerced, because a directory that suddenly sends a different * shape is a reason to grant nothing, not to guess. */ - private extractIds( - source: RoleSyncSource, - claims: Record, - ): string[] { + private extractIds(source: RoleSyncSource, claims: Record): string[] { const raw = source === 'APP_ROLES' ? claims.roles : claims.groups; if (!Array.isArray(raw)) return []; return raw.filter((v): v is string => typeof v === 'string' && v.length > 0); @@ -256,7 +489,7 @@ export class RoleSyncService { provider: RoleSyncProvider, userId: string, roleIds: string[], - ): Promise { + ): Promise<{ granted: string[]; changed: boolean }> { const valid = roleIds.length === 0 ? [] @@ -276,6 +509,21 @@ export class RoleSyncService { ); } + // A directory that has not moved must not look like one that has. Compare + // the synced set we already hold against the one we are about to write: + // equal sets mean no rows change, so the write is skipped and the caller + // reports `applied: false`. Without this every re-run of a batch resync + // would emit one ROLE_SYNC_APPLIED per user and report them all as + // changed — noise that buries the syncs that did move someone's access. + const existing = ( + await this.prisma.userRoleAssignment.findMany({ + where: { userId, source: SYNC_SOURCE, organizationId: provider.organizationId }, + select: { roleId: true }, + }) + ).map((a) => a.roleId); + const changed = !sameSet(existing, valid); + if (!changed) return { granted: valid, changed }; + await this.prisma.$transaction(async (tx) => { await tx.userRoleAssignment.deleteMany({ where: { @@ -303,18 +551,19 @@ export class RoleSyncService { // where a sync produces N, and it is dropped in the contract migration; a // release rolled back to before role sync existed should see no synced // roles, which is exactly what leaving it alone produces. - return valid; + return { granted: valid, changed }; } /** * Moves the user's organization role, refusing any change that would leave - * the workspace with no administrator. + * the workspace with no ACTIVE administrator. A deactivated membership is + * left alone entirely: its role is inert and comes back on reactivation. */ private async writeOrgRole( provider: RoleSyncProvider, userId: string, desired: UserRole | null, - ctx: { ip?: string | null; userAgent?: string | null }, + ctx: SyncCtx, ): Promise<{ before?: UserRole; after?: UserRole; lastAdminProtected?: boolean }> { if (desired === null) return {}; @@ -322,9 +571,9 @@ export class RoleSyncService { where: { userId_organizationId: { userId, organizationId: provider.organizationId }, }, - select: { role: true }, + select: { role: true, deactivatedAt: true }, }); - if (!membership) return {}; + if (!membership || membership.deactivatedAt) return {}; if (membership.role === desired) return { before: membership.role, after: desired }; // A directory edit must not be able to lock every human out of a @@ -332,7 +581,7 @@ export class RoleSyncService { // guarded — promotion is always allowed. if (membership.role === 'ADMIN' && desired !== 'ADMIN') { const admins = await this.prisma.organizationMember.count({ - where: { organizationId: provider.organizationId, role: 'ADMIN' }, + where: { organizationId: provider.organizationId, role: 'ADMIN', deactivatedAt: null }, }); if (admins <= 1) { this.logger.warn( @@ -351,11 +600,18 @@ export class RoleSyncService { } } - await this.prisma.organizationMember.update({ - where: { - userId_organizationId: { userId, organizationId: provider.organizationId }, - }, - data: { role: desired }, + await this.prisma.$transaction(async (tx) => { + await tx.organizationMember.update({ + where: { + userId_organizationId: { userId, organizationId: provider.organizationId }, + }, + data: { role: desired }, + }); + // Keep the active-org cache honest, as the admin path does. + const user = await tx.user.findUnique({ where: { id: userId }, select: { organizationId: true } }); + if (user?.organizationId === provider.organizationId) { + await tx.user.update({ where: { id: userId }, data: { role: desired } }); + } }); await this.securityEvents.log({ event: SecurityEvents.ROLE_CHANGED, @@ -375,43 +631,25 @@ export class RoleSyncService { provider: RoleSyncProvider, userId: string, presented: number, - ctx: { ip?: string | null; userAgent?: string | null }, + ctx: SyncCtx, + trigger: RoleSyncTrigger, + membershipSource: MembershipSource, ): Promise { + const base = { trigger, membershipSource, presented, matched: 0 }; + if (provider.roleSyncFallback === 'KEEP_EXISTING') { - return { - applied: false, - reason: 'fallback_keep_existing', - presented, - matched: 0, - grantedRoleIds: [], - }; + return { ...base, applied: false, reason: 'fallback_keep_existing', grantedRoleIds: [] }; } if (provider.roleSyncFallback === 'DEFAULT_ROLE') { - const granted = await this.writeAssignments( - provider, - userId, - provider.roleSyncDefaultRoleIds, - ); - await this.audit(provider, userId, 'fallback_default_role', presented, granted, ctx); - return { - applied: true, - reason: 'fallback_default_role', - presented, - matched: 0, - grantedRoleIds: granted, - }; + const { granted, changed } = await this.writeAssignments(provider, userId, provider.roleSyncDefaultRoleIds); + if (changed) await this.audit(provider, userId, 'fallback_default_role', base, granted, ctx); + return { ...base, applied: changed, reason: 'fallback_default_role', grantedRoleIds: granted }; } - const granted = await this.applyDenyAll(provider, userId); - await this.audit(provider, userId, 'fallback_deny_all', presented, granted, ctx); - return { - applied: true, - reason: 'fallback_deny_all', - presented, - matched: 0, - grantedRoleIds: granted, - }; + const { granted, changed } = await this.applyDenyAll(provider, userId); + if (changed) await this.audit(provider, userId, 'fallback_deny_all', base, granted, ctx); + return { ...base, applied: changed, reason: 'fallback_deny_all', grantedRoleIds: granted }; } /** @@ -430,7 +668,7 @@ export class RoleSyncService { private async applyDenyAll( provider: RoleSyncProvider, userId: string, - ): Promise { + ): Promise<{ granted: string[]; changed: boolean }> { const role = await this.prisma.role.upsert({ where: { organizationId_name: { @@ -454,23 +692,16 @@ export class RoleSyncService { provider: RoleSyncProvider, userId: string, reason: RoleSyncReason, - presented: number, + base: { trigger: RoleSyncTrigger; membershipSource: MembershipSource; presented: number; matched: number }, grantedRoleIds: string[], - ctx: { ip?: string | null; userAgent?: string | null }, + ctx: SyncCtx, ) { await this.securityEvents.log({ event: SecurityEvents.ROLE_SYNC_APPLIED, actorType: 'SYSTEM', organizationId: provider.organizationId, targetUserId: userId, - metadata: { - providerId: provider.id, - source: provider.roleSyncSource, - reason, - presented, - matched: 0, - grantedRoleIds, - }, + metadata: { providerId: provider.id, source: provider.roleSyncSource, reason, ...base, grantedRoleIds }, ip: ctx.ip, userAgent: ctx.userAgent, }); diff --git a/packages/backend/src/identity-providers/scim/scim-auth.guard.spec.ts b/packages/backend/src/identity-providers/scim/scim-auth.guard.spec.ts new file mode 100644 index 00000000..970c5c18 --- /dev/null +++ b/packages/backend/src/identity-providers/scim/scim-auth.guard.spec.ts @@ -0,0 +1,100 @@ +import { createHash } from 'crypto'; +import { ScimAuthGuard } from './scim-auth.guard'; +import { ScimError } from './scim.errors'; +import { SecurityEventService } from '../../audit/security-event.service'; +import { PrismaService } from '../../common/prisma.service'; + +const TOKEN = 'scim_' + 'a'.repeat(43); +const HASH = createHash('sha256').update(TOKEN).digest('hex'); + +describe('ScimAuthGuard', () => { + let prisma: any; + let events: any[]; + let guard: ScimAuthGuard; + + const row = (over: Record = {}) => ({ + id: 'idp-1', + organizationId: 'org-1', + type: 'ENTRA', + isActive: true, + scimEnabled: true, + scimTokenHash: HASH, + scimLastRequestAt: null, + jitDefaultRole: 'VIEWER', + roleSyncEnabled: true, + roleSyncSource: 'GROUPS', + roleSyncFallback: 'DENY_ALL', + roleSyncDefaultRoleIds: [], + ...over, + }); + + const ctxFor = (authorization?: string) => { + const req: any = { headers: { authorization, 'user-agent': 'jest' }, ip: '127.0.0.1' }; + return { ctx: { switchToHttp: () => ({ getRequest: () => req }) } as any, req }; + }; + + beforeEach(() => { + events = []; + prisma = { + identityProvider: { + findUnique: jest.fn(async () => row()), + update: jest.fn(async () => ({})), + }, + securityEvent: { create: jest.fn(async (a: any) => { events.push(a.data); return a.data; }) }, + }; + guard = new ScimAuthGuard(prisma, new SecurityEventService(prisma as unknown as PrismaService)); + }); + + // Scanners hit unauthenticated endpoints constantly; none of them may cost a + // database round trip or an audit row. + it('refuses a missing or malformed header without touching the database', async () => { + for (const h of [undefined, 'Basic abc', 'Bearer', 'Bearer short']) { + await expect(guard.canActivate(ctxFor(h).ctx)).rejects.toBeInstanceOf(ScimError); + } + expect(prisma.identityProvider.findUnique).not.toHaveBeenCalled(); + expect(events).toHaveLength(0); + }); + + it('looks the token up by its sha256 digest', async () => { + await guard.canActivate(ctxFor(`Bearer ${TOKEN}`).ctx); + expect(prisma.identityProvider.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ where: { scimTokenHash: HASH } }), + ); + }); + + it('audits and refuses an unknown token', async () => { + prisma.identityProvider.findUnique.mockResolvedValue(null); + await expect(guard.canActivate(ctxFor(`Bearer ${TOKEN}`).ctx)).rejects.toBeInstanceOf(ScimError); + expect(events).toEqual([expect.objectContaining({ event: 'SCIM_AUTH_FAILED', metadata: { providerId: null, reason: 'unknown_credential' } })]); + }); + + it.each([ + ['scim_disabled', { scimEnabled: false }], + ['provider_inactive', { isActive: false }], + ])('refuses with reason %s', async (reason, over) => { + prisma.identityProvider.findUnique.mockResolvedValue(row(over)); + await expect(guard.canActivate(ctxFor(`Bearer ${TOKEN}`).ctx)).rejects.toBeInstanceOf(ScimError); + expect(events[0].metadata).toEqual({ providerId: 'idp-1', reason }); + }); + + it('pins the provider on the request without the hash', async () => { + const { ctx, req } = ctxFor(`Bearer ${TOKEN}`); + expect(await guard.canActivate(ctx)).toBe(true); + expect(req.scimProvider).toMatchObject({ id: 'idp-1', organizationId: 'org-1', roleSyncSource: 'GROUPS' }); + expect(req.scimProvider.scimTokenHash).toBeUndefined(); + expect(events).toHaveLength(0); + }); + + it('bumps last-seen at most once a minute, and never fails the request on it', async () => { + await guard.canActivate(ctxFor(`Bearer ${TOKEN}`).ctx); + expect(prisma.identityProvider.update).toHaveBeenCalledTimes(1); + + prisma.identityProvider.findUnique.mockResolvedValue(row({ scimLastRequestAt: new Date(Date.now() - 10_000) })); + await guard.canActivate(ctxFor(`Bearer ${TOKEN}`).ctx); + expect(prisma.identityProvider.update).toHaveBeenCalledTimes(1); + + prisma.identityProvider.findUnique.mockResolvedValue(row({ scimLastRequestAt: new Date(Date.now() - 120_000) })); + prisma.identityProvider.update.mockRejectedValue(new Error('db down')); + expect(await guard.canActivate(ctxFor(`Bearer ${TOKEN}`).ctx)).toBe(true); + }); +}); diff --git a/packages/backend/src/identity-providers/scim/scim-auth.guard.ts b/packages/backend/src/identity-providers/scim/scim-auth.guard.ts new file mode 100644 index 00000000..218cca59 --- /dev/null +++ b/packages/backend/src/identity-providers/scim/scim-auth.guard.ts @@ -0,0 +1,113 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { createHash, timingSafeEqual } from 'crypto'; +import { PrismaService } from '../../common/prisma.service'; +import { + SecurityEventService, + SecurityEvents, +} from '../../audit/security-event.service'; +import { ScimError } from './scim.errors'; +import type { RoleSyncProvider } from '../role-sync.service'; +import type { IdentityProviderType, UserRole } from '../../generated/prisma/client'; + +/** What the SCIM services get to know about the caller. Never the hash. */ +export interface ScimProvider extends RoleSyncProvider { + type: IdentityProviderType; + jitDefaultRole: UserRole; + scimLastRequestAt: Date | null; +} + +export const SCIM_PROVIDER_SELECT = { + id: true, + organizationId: true, + type: true, + isActive: true, + scimEnabled: true, + scimTokenHash: true, + scimLastRequestAt: true, + jitDefaultRole: true, + roleSyncEnabled: true, + roleSyncSource: true, + roleSyncFallback: true, + roleSyncDefaultRoleIds: true, +} as const; + +export function scimTokenHash(token: string): string { + return createHash('sha256').update(token, 'utf8').digest('hex'); +} + +/** Write amplification guard for the "last seen" column during Entra bursts. */ +const LAST_SEEN_INTERVAL_MS = 60_000; + +/** + * Authenticates a SCIM request by its bearer token and pins the request to + * ONE identity provider — and therefore one organization. + * + * The token is compared by sha256 digest, via a single indexed lookup: an + * Entra initial cycle sends hundreds of requests in minutes, and bcrypt at + * cost 12 on each would be both slow and pointless for a 256-bit random + * secret. `timingSafeEqual` on the digests is belt and braces on top of the + * index — a B-tree comparison could at most leak bits of the hash, which + * preimage resistance makes worthless. + */ +@Injectable() +export class ScimAuthGuard implements CanActivate { + constructor( + private readonly prisma: PrismaService, + private readonly securityEvents: SecurityEventService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const req = context.switchToHttp().getRequest(); + const header: string | undefined = req.headers?.authorization; + const m = typeof header === 'string' ? header.match(/^Bearer\s+(\S+)$/i) : null; + const token = m?.[1]; + + // Malformed or absent: refuse without touching the database or the audit + // trail. Scanners hit unauthenticated endpoints constantly, and each one + // must not become a row. + if (!token || token.length < 16 || token.length > 512) { + throw new ScimError(401, 'Authentication required'); + } + + const digest = scimTokenHash(token); + const row = await this.prisma.identityProvider.findUnique({ + where: { scimTokenHash: digest }, + select: SCIM_PROVIDER_SELECT, + }); + + const reason = !row + ? 'unknown_credential' + : !row.scimEnabled + ? 'scim_disabled' + : !row.isActive + ? 'provider_inactive' + : !row.scimTokenHash || + !timingSafeEqual(Buffer.from(digest), Buffer.from(row.scimTokenHash)) + ? 'unknown_credential' + : null; + + if (reason || !row) { + await this.securityEvents.log({ + event: SecurityEvents.SCIM_AUTH_FAILED, + actorType: 'ANONYMOUS', + organizationId: row?.organizationId ?? null, + metadata: { providerId: row?.id ?? null, reason: reason ?? 'unknown_credential' }, + ip: req.ip, + userAgent: req.headers?.['user-agent'], + }); + throw new ScimError(401, 'Authentication required'); + } + + const { scimTokenHash: _hash, ...provider } = row; + req.scimProvider = provider as ScimProvider; + + const last = row.scimLastRequestAt?.getTime() ?? 0; + if (Date.now() - last > LAST_SEEN_INTERVAL_MS) { + // Fire-and-forget: a failed bump must never fail the request. + this.prisma.identityProvider + .update({ where: { id: row.id }, data: { scimLastRequestAt: new Date() } }) + .catch(() => undefined); + } + return true; + } +} diff --git a/packages/backend/src/identity-providers/scim/scim-groups.service.spec.ts b/packages/backend/src/identity-providers/scim/scim-groups.service.spec.ts new file mode 100644 index 00000000..84e502e6 --- /dev/null +++ b/packages/backend/src/identity-providers/scim/scim-groups.service.spec.ts @@ -0,0 +1,127 @@ +import { ScimGroupsService } from './scim-groups.service'; +import { SecurityEventService } from '../../audit/security-event.service'; +import { PrismaService } from '../../common/prisma.service'; + +const ORG = 'org-1'; +const provider = { id: 'idp-1', organizationId: ORG, roleSyncEnabled: true, roleSyncSource: 'GROUPS', roleSyncFallback: 'DENY_ALL', roleSyncDefaultRoleIds: [], scimEnabled: true } as any; +const ctx = { baseUrl: 'https://x/api/scim/v2' }; +const group = (members: string[] = [], over: Record = {}) => ({ + id: 'g-1', providerId: 'idp-1', externalId: 'oid-g1', displayName: 'GB_Test', + createdAt: new Date(), updatedAt: new Date(), + members: members.map((userId) => ({ userId, user: { email: `${userId}@x` } })), + ...over, +}); + +describe('ScimGroupsService', () => { + let prisma: any; + let events: any[]; + let roleSync: any; + let service: ScimGroupsService; + + beforeEach(() => { + events = []; + prisma = { + identityProviderGroup: { + findUnique: jest.fn(async () => null), + findFirst: jest.fn(async () => group()), + findMany: jest.fn(async () => []), + count: jest.fn(async () => 0), + create: jest.fn(async (a: any) => group(a.data.members?.create?.map((m: any) => m.userId) ?? [])), + update: jest.fn(async () => ({})), + delete: jest.fn(async () => ({})), + }, + identityProviderGroupMember: { createMany: jest.fn(async () => ({})), deleteMany: jest.fn(async () => ({})) }, + identityProviderRoleMapping: { updateMany: jest.fn(async () => ({ count: 1 })) }, + userIdentity: { findMany: jest.fn(async ({ where }: any) => where.userId.in.filter((u: string) => u !== 'stranger').map((userId: string) => ({ userId }))) }, + securityEvent: { create: jest.fn(async (a: any) => { events.push(a.data); return a.data; }) }, + $transaction: jest.fn((fn: any) => fn(prisma)), + }; + roleSync = { resyncUsers: jest.fn(async () => ({})) }; + service = new ScimGroupsService(prisma, new SecurityEventService(prisma as unknown as PrismaService), roleSync); + }); + + const patch = (ops: unknown[]) => ({ schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'], Operations: ops }); + + it('creates a group, copies the name onto an existing mapping, and resyncs the members', async () => { + const out = await service.create(provider, { displayName: 'GB_Test', externalId: 'oid-g1', members: [{ value: 'u1' }, { value: 'stranger' }] }, ctx); + expect(prisma.identityProviderGroup.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ externalId: 'oid-g1', displayName: 'GB_Test', members: { create: [{ userId: 'u1' }] } }), + })); + expect(prisma.identityProviderRoleMapping.updateMany).toHaveBeenCalledWith({ where: { providerId: 'idp-1', externalId: 'oid-g1' }, data: { label: 'GB_Test' } }); + expect(roleSync.resyncUsers).toHaveBeenCalledWith(provider, ['u1'], expect.anything(), 'scim'); + // Out-of-scope ids are skipped and reported, never fatal. + expect(events[0]).toMatchObject({ event: 'SCIM_GROUP_CREATED', metadata: expect.objectContaining({ skippedMemberIds: 'stranger' }) }); + expect(out).toMatchObject({ id: 'g-1', externalId: 'oid-g1', displayName: 'GB_Test' }); + }); + + it('409s a duplicate externalId', async () => { + prisma.identityProviderGroup.findUnique.mockResolvedValue({ id: 'g-1' }); + await expect(service.create(provider, { displayName: 'x', externalId: 'oid-g1' }, ctx)).rejects.toMatchObject({ status: 409 }); + }); + + it('PATCH Add members (Entra shape) writes memberships and resyncs only the added users', async () => { + prisma.identityProviderGroup.findFirst.mockResolvedValue(group(['u1'])); + await service.patch(provider, 'g-1', patch([{ op: 'Add', path: 'members', value: [{ $ref: null, value: 'u2' }, { value: 'u1' }] }]), ctx); + expect(prisma.identityProviderGroupMember.createMany).toHaveBeenCalledWith({ data: [{ groupId: 'g-1', userId: 'u2' }], skipDuplicates: true }); + expect(roleSync.resyncUsers).toHaveBeenCalledWith(provider, ['u2'], expect.anything(), 'scim'); + }); + + it('PATCH Remove in both forms removes memberships and resyncs', async () => { + prisma.identityProviderGroup.findFirst.mockResolvedValue(group(['u1', 'u2'])); + await service.patch(provider, 'g-1', patch([ + { op: 'Remove', path: 'members[value eq "u1"]' }, + { op: 'Remove', path: 'members', value: [{ value: 'u2' }] }, + ]), ctx); + expect(prisma.identityProviderGroupMember.deleteMany).toHaveBeenCalledWith({ where: { groupId: 'g-1', userId: { in: ['u1', 'u2'] } } }); + expect(roleSync.resyncUsers).toHaveBeenCalledWith(provider, ['u1', 'u2'], expect.anything(), 'scim'); + }); + + it('PATCH replace displayName renames and copies the label', async () => { + await service.patch(provider, 'g-1', patch([{ op: 'Replace', path: 'displayName', value: 'GB_Renamed' }]), ctx); + expect(prisma.identityProviderGroup.update).toHaveBeenCalledWith({ where: { id: 'g-1' }, data: { displayName: 'GB_Renamed' } }); + expect(prisma.identityProviderRoleMapping.updateMany).toHaveBeenCalledWith({ where: { providerId: 'idp-1', externalId: 'oid-g1' }, data: { label: 'GB_Renamed' } }); + expect(roleSync.resyncUsers).not.toHaveBeenCalled(); + }); + + it('PUT replaces the member set with a diff', async () => { + prisma.identityProviderGroup.findFirst.mockResolvedValue(group(['u1', 'u2'])); + await service.replace(provider, 'g-1', { displayName: 'GB_Test', members: [{ value: 'u2' }, { value: 'u3' }] }, ctx); + expect(prisma.identityProviderGroupMember.deleteMany).toHaveBeenCalledWith({ where: { groupId: 'g-1', userId: { in: ['u1'] } } }); + expect(prisma.identityProviderGroupMember.createMany).toHaveBeenCalledWith({ data: [{ groupId: 'g-1', userId: 'u3' }], skipDuplicates: true }); + expect(roleSync.resyncUsers).toHaveBeenCalledWith(provider, ['u1', 'u3'], expect.anything(), 'scim'); + }); + + it('DELETE removes the group and resyncs every former member', async () => { + prisma.identityProviderGroup.findFirst.mockResolvedValue(group(['u1', 'u2'])); + await service.remove(provider, 'g-1', ctx); + expect(prisma.identityProviderGroup.delete).toHaveBeenCalledWith({ where: { id: 'g-1' } }); + expect(roleSync.resyncUsers).toHaveBeenCalledWith(provider, ['u1', 'u2'], expect.anything(), 'scim'); + expect(events.map((e) => e.event)).toContain('SCIM_GROUP_DELETED'); + }); + + // Entra repeats a delete it did not see acknowledged. The retry has to read + // as success, or the provisioning log fills with failures for groups that + // are already gone. + it('DELETE of a group that is already gone succeeds quietly', async () => { + prisma.identityProviderGroup.findFirst.mockResolvedValue(null); + await expect(service.remove(provider, 'g-1', ctx)).resolves.toBeUndefined(); + expect(prisma.identityProviderGroup.delete).not.toHaveBeenCalled(); + expect(roleSync.resyncUsers).not.toHaveBeenCalled(); + expect(events.map((e) => e.event)).not.toContain('SCIM_GROUP_DELETED'); + }); + + it('lists by case-insensitive displayName and honours excludedAttributes=members', async () => { + prisma.identityProviderGroup.findMany.mockResolvedValue([group(['u1'])]); + prisma.identityProviderGroup.count.mockResolvedValue(1); + const out = await service.list(provider, { attr: 'displayName', value: 'gb_test' }, { startIndex: 1, count: 100 }, new Set(['members']), ctx); + expect(prisma.identityProviderGroup.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: { providerId: 'idp-1', displayName: { equals: 'gb_test', mode: 'insensitive' } }, + })); + expect(out.Resources[0]).not.toHaveProperty('members'); + }); + + it('404s a group of another provider', async () => { + prisma.identityProviderGroup.findFirst.mockResolvedValue(null); + await expect(service.get(provider, 'other', new Set(), ctx)).rejects.toMatchObject({ status: 404 }); + }); +}); diff --git a/packages/backend/src/identity-providers/scim/scim-groups.service.ts b/packages/backend/src/identity-providers/scim/scim-groups.service.ts new file mode 100644 index 00000000..51b21bda --- /dev/null +++ b/packages/backend/src/identity-providers/scim/scim-groups.service.ts @@ -0,0 +1,340 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../../common/prisma.service'; +import { + SecurityEventService, + SecurityEvents, +} from '../../audit/security-event.service'; +import { RoleSyncService } from '../role-sync.service'; +import { ScimError } from './scim.errors'; +import { isRecord, memberIdFromPath, parsePatch, PatchOp, ScimFilter } from './scim.parser'; +import { SCIM_GROUP_SCHEMA, SCIM_LIST_SCHEMA } from './scim.schemas'; +import type { ScimProvider } from './scim-auth.guard'; +import type { ScimCtx } from './scim-users.service'; + +const GROUP_INCLUDE = { + members: { select: { userId: true, user: { select: { email: true } } } }, +} as const; + +type GroupRow = { + id: string; + providerId: string; + externalId: string | null; + displayName: string; + createdAt: Date; + updatedAt: Date; + members: { userId: string; user: { email: string } }[]; +}; + +/** + * SCIM Groups for one identity provider. + * + * Entra pushes a group when it is assigned to the application, and then + * every membership delta. What we keep is the group's object id, its display + * name and who is in it; what we do with it is re-run the role sync for every + * affected user, so a group change reaches their MCP tools on the next request + * — no sign-in involved. + */ +@Injectable() +export class ScimGroupsService { + private readonly logger = new Logger(ScimGroupsService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly securityEvents: SecurityEventService, + private readonly roleSync: RoleSyncService, + ) {} + + // ── Read ────────────────────────────────────────────────────────────────── + + async list( + provider: ScimProvider, + filter: ScimFilter | null, + page: { startIndex: number; count: number }, + excluded: Set, + ctx: ScimCtx, + ) { + const where = { providerId: provider.id, ...this.whereFor(filter) }; + const [total, rows] = await Promise.all([ + this.prisma.identityProviderGroup.count({ where }), + this.prisma.identityProviderGroup.findMany({ + where, + include: GROUP_INCLUDE, + orderBy: { createdAt: 'asc' }, + skip: page.startIndex - 1, + take: page.count, + }), + ]); + return { + schemas: [SCIM_LIST_SCHEMA], + totalResults: total, + startIndex: page.startIndex, + itemsPerPage: rows.length, + Resources: rows.map((r) => this.toScim(r as GroupRow, ctx, !excluded.has('members'))), + }; + } + + async get(provider: ScimProvider, id: string, excluded: Set, ctx: ScimCtx) { + return this.toScim(await this.find(provider, id), ctx, !excluded.has('members')); + } + + // ── Write ───────────────────────────────────────────────────────────────── + + async create(provider: ScimProvider, body: unknown, ctx: ScimCtx) { + const parsed = this.readGroup(body); + if (parsed.externalId) { + const dup = await this.prisma.identityProviderGroup.findUnique({ + where: { providerId_externalId: { providerId: provider.id, externalId: parsed.externalId } }, + select: { id: true }, + }); + if (dup) throw new ScimError(409, 'Group already provisioned', 'uniqueness'); + } + + const members = await this.validMembers(provider, parsed.memberIds); + const group = await this.prisma.identityProviderGroup.create({ + data: { + providerId: provider.id, + externalId: parsed.externalId ?? null, + displayName: parsed.displayName, + members: { create: members.valid.map((userId) => ({ userId })) }, + }, + include: GROUP_INCLUDE, + }); + + // The mapping row, if an admin already created one by object id, gets the + // real name. Nothing else about it changes — its roles are the admin's. + await this.copyLabel(provider, group.externalId, group.displayName); + + await this.securityEvents.log({ + event: SecurityEvents.SCIM_GROUP_CREATED, + actorType: 'SYSTEM', + organizationId: provider.organizationId, + metadata: { + providerId: provider.id, + groupId: group.id, + externalId: group.externalId, + displayName: group.displayName, + members: members.valid.length, + skippedMemberIds: members.skipped.join(','), + }, + ip: ctx.ip, + userAgent: ctx.userAgent, + }); + + await this.resync(provider, members.valid, ctx); + return this.toScim(group as GroupRow, ctx, true); + } + + async replace(provider: ScimProvider, id: string, body: unknown, ctx: ScimCtx) { + const group = await this.find(provider, id); + const parsed = this.readGroup(body); + if (parsed.externalId && group.externalId && parsed.externalId !== group.externalId) { + throw new ScimError(400, 'externalId is immutable', 'mutability'); + } + const members = await this.validMembers(provider, parsed.memberIds); + const before = new Set(group.members.map((m) => m.userId)); + const after = new Set(members.valid); + + await this.prisma.$transaction(async (tx) => { + await tx.identityProviderGroup.update({ + where: { id }, + data: { displayName: parsed.displayName, ...(group.externalId ? {} : { externalId: parsed.externalId ?? null }) }, + }); + const removed = [...before].filter((u) => !after.has(u)); + const added = [...after].filter((u) => !before.has(u)); + if (removed.length) await tx.identityProviderGroupMember.deleteMany({ where: { groupId: id, userId: { in: removed } } }); + if (added.length) await tx.identityProviderGroupMember.createMany({ data: added.map((userId) => ({ groupId: id, userId })), skipDuplicates: true }); + }); + await this.copyLabel(provider, group.externalId, parsed.displayName); + + const affected = [...new Set([...before, ...after])].filter((u) => before.has(u) !== after.has(u)); + await this.auditMembership(provider, group, affected.filter((u) => after.has(u)).length, affected.filter((u) => !after.has(u)).length, members.skipped, ctx); + await this.resync(provider, affected, ctx); + return this.get(provider, id, new Set(), ctx); + } + + async patch(provider: ScimProvider, id: string, body: unknown, ctx: ScimCtx) { + const group = await this.find(provider, id); + const ops = parsePatch(body); + const toAdd = new Set(); + const toRemove = new Set(); + let displayName: string | undefined; + + for (const op of ops) { + const path = (op.path ?? '').toLowerCase(); + if (path === 'displayname' && typeof op.value === 'string' && op.value.trim()) { + displayName = op.value.trim(); + continue; + } + const single = memberIdFromPath(op.path); + if (single) { + if (op.op === 'remove') toRemove.add(single); + continue; + } + if (path === 'members') { + for (const v of this.memberValues(op)) (op.op === 'remove' ? toRemove : toAdd).add(v); + } + // Anything else is an attribute we have no column for. Ignored. + } + // `remove` with no value on `members` empties the group. + if (ops.some((o) => o.op === 'remove' && (o.path ?? '').toLowerCase() === 'members' && o.value === undefined)) { + for (const m of group.members) toRemove.add(m.userId); + } + + const members = await this.validMembers(provider, [...toAdd]); + const existing = new Set(group.members.map((m) => m.userId)); + const added = members.valid.filter((u) => !existing.has(u)); + const removed = [...toRemove].filter((u) => existing.has(u)); + + await this.prisma.$transaction(async (tx) => { + if (displayName) await tx.identityProviderGroup.update({ where: { id }, data: { displayName } }); + if (removed.length) await tx.identityProviderGroupMember.deleteMany({ where: { groupId: id, userId: { in: removed } } }); + if (added.length) await tx.identityProviderGroupMember.createMany({ data: added.map((userId) => ({ groupId: id, userId })), skipDuplicates: true }); + }); + if (displayName) await this.copyLabel(provider, group.externalId, displayName); + + if (added.length || removed.length || members.skipped.length) { + await this.auditMembership(provider, group, added.length, removed.length, members.skipped, ctx); + } + await this.resync(provider, [...added, ...removed], ctx); + return this.get(provider, id, new Set(), ctx); + } + + /** + * A deleted group takes its memberships with it; every former member is + * re-synced. + * + * Deleting a group that is already gone is success, not an error: Entra + * retries a delete it did not see acknowledged, and the second attempt must + * not surface in the provisioning log as a failure. This matches the user + * path, where DELETE is idempotent because the identity row survives. + */ + async remove(provider: ScimProvider, id: string, ctx: ScimCtx): Promise { + const group = await this.findOrNull(provider, id); + if (!group) return; + const former = group.members.map((m) => m.userId); + await this.prisma.identityProviderGroup.delete({ where: { id } }); + await this.securityEvents.log({ + event: SecurityEvents.SCIM_GROUP_DELETED, + actorType: 'SYSTEM', + organizationId: provider.organizationId, + metadata: { providerId: provider.id, groupId: id, externalId: group.externalId, displayName: group.displayName, members: former.length }, + ip: ctx.ip, + userAgent: ctx.userAgent, + }); + await this.resync(provider, former, ctx); + } + + // ── Internals ───────────────────────────────────────────────────────────── + + private async find(provider: ScimProvider, id: string): Promise { + const row = await this.findOrNull(provider, id); + if (!row) throw new ScimError(404, 'Group not found', 'noTarget'); + return row; + } + + /** A group of another provider is indistinguishable from nonexistent. */ + private async findOrNull(provider: ScimProvider, id: string): Promise { + const row = await this.prisma.identityProviderGroup.findFirst({ + where: { id, providerId: provider.id }, + include: GROUP_INCLUDE, + }); + return (row as GroupRow | null) ?? null; + } + + private whereFor(filter: ScimFilter | null) { + if (!filter) return {}; + switch (filter.attr) { + case 'displayName': + return { displayName: { equals: filter.value, mode: 'insensitive' as const } }; + case 'externalId': + return { externalId: filter.value }; + case 'id': + return { id: filter.value }; + default: + throw new ScimError(400, `Unsupported filter attribute: ${filter.attr}`, 'invalidFilter'); + } + } + + private readGroup(body: unknown): { displayName: string; externalId?: string; memberIds: string[] } { + if (!isRecord(body)) throw new ScimError(400, 'Request body must be an object', 'invalidSyntax'); + const displayName = typeof body.displayName === 'string' ? body.displayName.trim() : ''; + if (!displayName) throw new ScimError(400, 'displayName is required', 'invalidValue'); + const externalId = typeof body.externalId === 'string' && body.externalId.trim() ? body.externalId.trim() : undefined; + const memberIds = Array.isArray(body.members) + ? [...new Set(body.members.filter(isRecord).map((m) => m.value).filter((v): v is string => typeof v === 'string' && v.length > 0))] + : []; + return { displayName, externalId, memberIds }; + } + + private memberValues(op: PatchOp): string[] { + const v = op.value; + const list = Array.isArray(v) ? v : isRecord(v) ? [v] : []; + return [...new Set(list.filter(isRecord).map((m) => m.value).filter((x): x is string => typeof x === 'string' && x.length > 0))]; + } + + /** + * Members must be users with an identity at THIS provider. Ids that are not + * are skipped and reported, not fatal: failing the whole PATCH over one + * out-of-scope id would block role sync for every legitimate member, and + * Entra re-sends group updates after the missing user is provisioned. + */ + private async validMembers(provider: ScimProvider, ids: string[]): Promise<{ valid: string[]; skipped: string[] }> { + if (ids.length === 0) return { valid: [], skipped: [] }; + const known = await this.prisma.userIdentity.findMany({ + where: { providerId: provider.id, userId: { in: ids } }, + select: { userId: true }, + }); + const ok = new Set(known.map((k) => k.userId)); + return { valid: ids.filter((i) => ok.has(i)), skipped: ids.filter((i) => !ok.has(i)) }; + } + + private async copyLabel(provider: ScimProvider, externalId: string | null, displayName: string) { + if (!externalId) return; + await this.prisma.identityProviderRoleMapping.updateMany({ + where: { providerId: provider.id, externalId }, + data: { label: displayName }, + }); + } + + private async resync(provider: ScimProvider, userIds: string[], ctx: ScimCtx) { + if (userIds.length === 0) return; + await this.roleSync.resyncUsers(provider, userIds, { ip: ctx.ip, userAgent: ctx.userAgent }, 'scim'); + } + + private async auditMembership(provider: ScimProvider, group: GroupRow, added: number, removed: number, skipped: string[], ctx: ScimCtx) { + await this.securityEvents.log({ + event: SecurityEvents.SCIM_GROUP_MEMBERSHIP_CHANGED, + actorType: 'SYSTEM', + organizationId: provider.organizationId, + metadata: { + providerId: provider.id, + groupId: group.id, + externalId: group.externalId, + added, + removed, + // Joined, not an array: keeps the row under the redactor's depth bound. + skippedMemberIds: skipped.join(','), + }, + ip: ctx.ip, + userAgent: ctx.userAgent, + }); + } + + private toScim(row: GroupRow, ctx: ScimCtx, includeMembers: boolean) { + return { + schemas: [SCIM_GROUP_SCHEMA], + id: row.id, + ...(row.externalId ? { externalId: row.externalId } : {}), + displayName: row.displayName, + ...(includeMembers + ? { members: row.members.map((m) => ({ value: m.userId, display: m.user.email })) } + : {}), + meta: { + resourceType: 'Group', + created: row.createdAt.toISOString(), + lastModified: row.updatedAt.toISOString(), + location: `${ctx.baseUrl}/Groups/${row.id}`, + }, + }; + } +} diff --git a/packages/backend/src/identity-providers/scim/scim-users.service.spec.ts b/packages/backend/src/identity-providers/scim/scim-users.service.spec.ts new file mode 100644 index 00000000..a50a354a --- /dev/null +++ b/packages/backend/src/identity-providers/scim/scim-users.service.spec.ts @@ -0,0 +1,297 @@ +import { ScimUsersService } from './scim-users.service'; +import { ScimError } from './scim.errors'; +import { SecurityEventService } from '../../audit/security-event.service'; +import { PrismaService } from '../../common/prisma.service'; + +const ORG = 'org-1'; +const provider = { + id: 'idp-1', + organizationId: ORG, + type: 'ENTRA', + isActive: true, + scimEnabled: true, + scimLastRequestAt: null, + jitDefaultRole: 'VIEWER', + roleSyncEnabled: true, + roleSyncSource: 'GROUPS', + roleSyncFallback: 'DENY_ALL', + roleSyncDefaultRoleIds: [], +} as any; +const ctx = { baseUrl: 'https://mcp.example/api/scim/v2', ip: '1.2.3.4', userAgent: 'entra' }; + +const identity = (over: Record = {}, user: Record = {}) => ({ + id: 'ui-1', + userId: 'u1', + externalSubject: 'oid-1', + scimManagedAt: new Date(), + createdAt: new Date('2026-01-01'), + user: { + id: 'u1', + email: 'anna@x.com', + name: 'Anna', + passwordHash: null, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-02'), + memberships: [{ organizationId: ORG, deactivatedAt: null }], + ...user, + }, + ...over, +}); + +describe('ScimUsersService', () => { + let prisma: any; + let events: any[]; + let lifecycle: any; + let roleSync: any; + let service: ScimUsersService; + + beforeEach(() => { + events = []; + prisma = { + userIdentity: { + findUnique: jest.fn(async () => null), + findMany: jest.fn(async () => []), + count: jest.fn(async () => 0), + create: jest.fn(async () => ({})), + update: jest.fn(async () => ({})), + }, + user: { + findUnique: jest.fn(async () => null), + create: jest.fn(async () => ({ id: 'u-new' })), + update: jest.fn(async () => ({})), + delete: jest.fn(), + }, + organizationMember: { create: jest.fn(async () => ({})) }, + securityEvent: { create: jest.fn(async (a: any) => { events.push(a.data); return a.data; }) }, + $transaction: jest.fn((fn: any) => fn(prisma)), + }; + lifecycle = { + deactivateInOrganization: jest.fn(async () => ({ status: 'deactivated', keysDeactivated: 1 })), + reactivateInOrganization: jest.fn(async () => ({ status: 'reactivated', role: 'VIEWER' })), + }; + roleSync = { syncFromScim: jest.fn(async () => ({ applied: true, reason: 'fallback_deny_all' })) }; + service = new ScimUsersService(prisma, new SecurityEventService(prisma as unknown as PrismaService), lifecycle, roleSync); + }); + + const eventNames = () => events.map((e) => e.event); + + describe('create', () => { + const body = { + schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], + externalId: 'oid-new', + userName: 'Anna.Rossi@X.com', + active: true, + name: { givenName: 'Anna', familyName: 'Rossi' }, + emails: [{ primary: true, type: 'work', value: 'Anna.Rossi@x.com' }], + title: 'Engineer', + 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User': { department: 'R&D' }, + }; + + it('provisions user + membership + SCIM-managed identity with no password, then applies the fallback', async () => { + // After create, `get` re-reads the identity. + prisma.userIdentity.findUnique + .mockResolvedValueOnce(null) // existing-identity check + .mockResolvedValueOnce(identity({ userId: 'u-new', externalSubject: 'oid-new' }, { id: 'u-new', email: 'anna.rossi@x.com', name: 'Anna Rossi' })); + const out = await service.create(provider, body, ctx); + + expect(prisma.user.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ email: 'anna.rossi@x.com', name: 'Anna Rossi', passwordHash: null, emailVerified: true, role: 'VIEWER', organizationId: ORG }), + })); + expect(prisma.organizationMember.create).toHaveBeenCalledWith({ data: { userId: 'u-new', organizationId: ORG, role: 'VIEWER' } }); + expect(prisma.userIdentity.create).toHaveBeenCalledWith({ + data: { userId: 'u-new', providerId: 'idp-1', externalSubject: 'oid-new', scimManagedAt: expect.any(Date) }, + }); + // A member with no role is UNRESTRICTED; the fallback must apply now. + expect(roleSync.syncFromScim).toHaveBeenCalledWith(provider, 'u-new', expect.anything()); + expect(lifecycle.deactivateInOrganization).not.toHaveBeenCalled(); + expect(eventNames()).toEqual(['SCIM_USER_PROVISIONED']); + expect(JSON.stringify(events)).not.toContain('[REDACTED]'); + expect(out).toMatchObject({ id: 'u-new', externalId: 'oid-new', userName: 'anna.rossi@x.com', active: true }); + expect(out.meta.location).toBe('https://mcp.example/api/scim/v2/Users/u-new'); + }); + + it('requires externalId', async () => { + await expect(service.create(provider, { ...body, externalId: undefined }, ctx)).rejects.toMatchObject({ status: 400 }); + expect(prisma.user.create).not.toHaveBeenCalled(); + }); + + it('answers 409 uniqueness when the identity already exists', async () => { + prisma.userIdentity.findUnique.mockResolvedValue({ id: 'ui-x' }); + await expect(service.create(provider, body, ctx)).rejects.toMatchObject({ status: 409 }); + expect(prisma.user.create).not.toHaveBeenCalled(); + }); + + // The same anti-takeover rule as JIT: a directory never claims a local account. + it('refuses to adopt an existing unlinked local account', async () => { + prisma.user.findUnique.mockResolvedValue({ id: 'local' }); + const err = await service.create(provider, body, ctx).catch((e) => e); + expect(err).toBeInstanceOf(ScimError); + expect(err.status).toBe(409); + expect(JSON.stringify(err.getResponse())).toContain('not linked to this identity provider'); + expect(prisma.user.create).not.toHaveBeenCalled(); + }); + + it('deactivates immediately when created with active:false', async () => { + prisma.userIdentity.findUnique + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(identity({ userId: 'u-new' }, { id: 'u-new', memberships: [{ organizationId: ORG, deactivatedAt: new Date() }] })); + const out = await service.create(provider, { ...body, active: 'False' }, ctx); + expect(lifecycle.deactivateInOrganization).toHaveBeenCalledWith('u-new', ORG, expect.objectContaining({ reason: 'scim', providerId: 'idp-1' })); + expect(out.active).toBe(false); + }); + }); + + describe('patch', () => { + const patch = (ops: unknown[]) => ({ schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'], Operations: ops }); + + it('active:"False" deprovisions through the lifecycle primitive, once', async () => { + prisma.userIdentity.findUnique + .mockResolvedValueOnce(identity()) + .mockResolvedValueOnce(identity({}, { memberships: [{ organizationId: ORG, deactivatedAt: new Date() }] })); + const out = await service.patch(provider, 'u1', patch([{ op: 'Replace', path: 'active', value: 'False' }]), ctx); + expect(lifecycle.deactivateInOrganization).toHaveBeenCalledWith('u1', ORG, expect.objectContaining({ reason: 'scim' })); + expect(eventNames()).toEqual(['SCIM_USER_DEPROVISIONED']); + expect(out.active).toBe(false); + + // Already inactive → no second lifecycle call. + lifecycle.deactivateInOrganization.mockClear(); + prisma.userIdentity.findUnique.mockResolvedValue(identity({}, { memberships: [{ organizationId: ORG, deactivatedAt: new Date() }] })); + await service.patch(provider, 'u1', patch([{ op: 'replace', path: 'active', value: false }]), ctx); + expect(lifecycle.deactivateInOrganization).not.toHaveBeenCalled(); + }); + + it('active:true reactivates and re-runs the role sync', async () => { + prisma.userIdentity.findUnique + .mockResolvedValueOnce(identity({}, { memberships: [{ organizationId: ORG, deactivatedAt: new Date() }] })) + .mockResolvedValueOnce(identity()); + await service.patch(provider, 'u1', patch([{ op: 'replace', value: { active: true } }]), ctx); + expect(lifecycle.reactivateInOrganization).toHaveBeenCalled(); + expect(roleSync.syncFromScim).toHaveBeenCalledWith(provider, 'u1', expect.anything()); + expect(eventNames()).toEqual(['SCIM_USER_REACTIVATED']); + }); + + // The membership is the workspace's one way back in; Entra is told loudly. + it('surfaces the last-admin outcome as a 409 after revoking sessions and keys', async () => { + lifecycle.deactivateInOrganization.mockResolvedValue({ status: 'last_admin_retained', keysDeactivated: 2 }); + prisma.userIdentity.findUnique.mockResolvedValue(identity()); + await expect(service.patch(provider, 'u1', patch([{ op: 'replace', path: 'active', value: false }]), ctx)).rejects.toMatchObject({ status: 409 }); + expect(events[0]).toMatchObject({ event: 'SCIM_USER_DEPROVISIONED', metadata: expect.objectContaining({ outcome: 'last_admin_retained' }) }); + }); + + it('updates the name from partial name ops and ignores unmapped attributes', async () => { + prisma.userIdentity.findUnique.mockResolvedValue(identity()); + await service.patch(provider, 'u1', patch([ + { op: 'Replace', path: 'name.familyName', value: 'Bianchi' }, + { op: 'Replace', path: 'title', value: 'CTO' }, + { op: 'Add', path: 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department', value: 'Ops' }, + ]), ctx); + expect(prisma.user.update).toHaveBeenCalledWith({ where: { id: 'u1' }, data: { name: 'Bianchi' } }); + expect(eventNames()).toEqual(['SCIM_USER_UPDATED']); + }); + + // Entra's stock mapping for a non-gallery app sends `mailNickname` as + // externalId, and it packs every attribute into a single PatchOp. Refusing + // the operation used to 400 the whole request, throwing away the name, the + // department and `active` — while Entra's provision-on-demand view still + // showed four green ticks. Keep our anchor, apply the rest, say so. + it('ignores a mismatched externalId instead of failing the whole patch', async () => { + prisma.userIdentity.findUnique.mockResolvedValue(identity()); + await service.patch(provider, 'u1', patch([ + { op: 'Replace', path: 'externalId', value: 'mmr' }, + { op: 'Replace', path: 'displayName', value: 'Morelli Matteo' }, + ]), ctx); + + expect(prisma.user.update).toHaveBeenCalledWith({ where: { id: 'u1' }, data: { name: 'Morelli Matteo' } }); + expect(prisma.userIdentity.update).not.toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ externalSubject: 'mmr' }) }), + ); + expect(events[events.length - 1]).toMatchObject({ + event: 'SCIM_USER_UPDATED', + metadata: expect.objectContaining({ externalIdIgnored: 'mmr' }), + }); + }); + + it('does not flag an externalId that matches the anchor', async () => { + prisma.userIdentity.findUnique.mockResolvedValue(identity()); + await service.patch(provider, 'u1', patch([ + { op: 'Replace', path: 'externalId', value: 'oid-1' }, + { op: 'Replace', path: 'displayName', value: 'A' }, + ]), ctx); + expect(events[events.length - 1].metadata).not.toHaveProperty('externalIdIgnored'); + }); + + it('marks an SSO-created identity as SCIM-managed on first touch', async () => { + prisma.userIdentity.findUnique.mockResolvedValue(identity({ scimManagedAt: null })); + await service.patch(provider, 'u1', patch([{ op: 'replace', path: 'displayName', value: 'A' }]), ctx); + expect(prisma.userIdentity.update).toHaveBeenCalledWith({ where: { id: 'ui-1' }, data: { scimManagedAt: expect.any(Date) } }); + }); + + describe('email rule', () => { + const emailOp = patch([{ op: 'Replace', path: 'emails[type eq "work"].value', value: 'New@x.com' }]); + + it('applies to a provider-owned, single-org account with an unclaimed address', async () => { + prisma.userIdentity.findUnique.mockResolvedValue(identity()); + await service.patch(provider, 'u1', emailOp, ctx); + expect(prisma.user.update).toHaveBeenCalledWith({ where: { id: 'u1' }, data: { email: 'new@x.com' } }); + }); + + it.each([ + ['has_password', { passwordHash: 'x' }, null], + ['multi_org', { memberships: [{ organizationId: ORG, deactivatedAt: null }, { organizationId: 'org-2', deactivatedAt: null }] }, null], + ['conflict', {}, { id: 'someone' }], + ])('is skipped (%s) but the request still succeeds', async (reason, userOver, owner) => { + prisma.userIdentity.findUnique.mockResolvedValue(identity({}, userOver as any)); + prisma.user.findUnique.mockResolvedValue(owner); + await service.patch(provider, 'u1', emailOp, ctx); + expect(prisma.user.update).not.toHaveBeenCalled(); + expect(events[0].metadata.emailChangeSkipped).toBe(reason); + }); + + it('a deactivation in the same request is never blocked by an email conflict', async () => { + prisma.userIdentity.findUnique.mockResolvedValue(identity({}, { passwordHash: 'x' })); + await service.patch(provider, 'u1', patch([ + { op: 'replace', path: 'userName', value: 'new@x.com' }, + { op: 'replace', path: 'active', value: false }, + ]), ctx); + expect(lifecycle.deactivateInOrganization).toHaveBeenCalled(); + }); + }); + }); + + describe('remove (DELETE)', () => { + it('deprovisions and keeps the row', async () => { + prisma.userIdentity.findUnique.mockResolvedValue(identity()); + await service.remove(provider, 'u1', ctx); + expect(lifecycle.deactivateInOrganization).toHaveBeenCalled(); + expect(prisma.user.delete).not.toHaveBeenCalled(); + }); + + it('is idempotent on an already inactive user', async () => { + prisma.userIdentity.findUnique.mockResolvedValue(identity({}, { memberships: [{ organizationId: ORG, deactivatedAt: new Date() }] })); + await service.remove(provider, 'u1', ctx); + expect(lifecycle.deactivateInOrganization).not.toHaveBeenCalled(); + }); + }); + + describe('scope', () => { + it('404s a user with no identity at this provider', async () => { + prisma.userIdentity.findUnique.mockResolvedValue(null); + await expect(service.get(provider, 'other', ctx)).rejects.toMatchObject({ status: 404 }); + expect(prisma.userIdentity.findUnique).toHaveBeenCalledWith(expect.objectContaining({ + where: { userId_providerId: { userId: 'other', providerId: 'idp-1' } }, + })); + }); + + it('filters by lower-cased email within the provider', async () => { + await service.list(provider, { attr: 'userName', value: 'Anna@X.com' }, { startIndex: 1, count: 100 }, ctx); + expect(prisma.userIdentity.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: { providerId: 'idp-1', user: { email: 'anna@x.com' } }, + })); + }); + + it('returns an empty ListResponse for a miss (Entra Test Connection)', async () => { + const out = await service.list(provider, { attr: 'userName', value: 'nobody' }, { startIndex: 1, count: 100 }, ctx); + expect(out).toEqual(expect.objectContaining({ totalResults: 0, itemsPerPage: 0, Resources: [] })); + }); + }); +}); diff --git a/packages/backend/src/identity-providers/scim/scim-users.service.ts b/packages/backend/src/identity-providers/scim/scim-users.service.ts new file mode 100644 index 00000000..d0d1be30 --- /dev/null +++ b/packages/backend/src/identity-providers/scim/scim-users.service.ts @@ -0,0 +1,468 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../../common/prisma.service'; +import { + SecurityEventService, + SecurityEvents, +} from '../../audit/security-event.service'; +import { UserLifecycleService } from '../../users/user-lifecycle.service'; +import { RoleSyncService } from '../role-sync.service'; +import { ScimError } from './scim.errors'; +import { + coerceActive, + displayNameOf, + memberIdFromPath, + parsePatch, + pickEmail, + readUser, + ParsedUser, + PatchOp, + ScimFilter, +} from './scim.parser'; +import { SCIM_LIST_SCHEMA, SCIM_USER_SCHEMA } from './scim.schemas'; +import type { ScimProvider } from './scim-auth.guard'; + +export interface ScimCtx { + baseUrl: string; + ip?: string | null; + userAgent?: string | null; +} + +/** The identity row plus everything needed to render a SCIM User. */ +const IDENTITY_INCLUDE = { + user: { + select: { + id: true, + email: true, + name: true, + passwordHash: true, + createdAt: true, + updatedAt: true, + memberships: { select: { organizationId: true, deactivatedAt: true } }, + }, + }, +} as const; + +type IdentityRow = { + id: string; + userId: string; + externalSubject: string; + scimManagedAt: Date | null; + createdAt: Date; + user: { + id: string; + email: string; + name: string | null; + passwordHash: string | null; + createdAt: Date; + updatedAt: Date; + memberships: { organizationId: string; deactivatedAt: Date | null }[]; + }; +}; + +interface UserChanges { + active?: boolean; + email?: string; + name?: string | null; + externalId?: string; +} + +/** + * SCIM Users for one identity provider. + * + * Scope is the set of `user_identities` rows for that provider: a user with + * no identity here does not exist as far as this SCIM client is concerned, + * whatever their email says. That is the same rule SSO sign-in applies + * (`providerId_externalSubject`, never email), and it is what keeps a + * directory from reaching accounts it does not own. + */ +@Injectable() +export class ScimUsersService { + private readonly logger = new Logger(ScimUsersService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly securityEvents: SecurityEventService, + private readonly lifecycle: UserLifecycleService, + private readonly roleSync: RoleSyncService, + ) {} + + // ── Read ────────────────────────────────────────────────────────────────── + + async list( + provider: ScimProvider, + filter: ScimFilter | null, + page: { startIndex: number; count: number }, + ctx: ScimCtx, + ) { + const where = { providerId: provider.id, ...this.whereFor(filter) }; + const [total, rows] = await Promise.all([ + this.prisma.userIdentity.count({ where }), + this.prisma.userIdentity.findMany({ + where, + include: IDENTITY_INCLUDE, + orderBy: { createdAt: 'asc' }, + skip: page.startIndex - 1, + take: page.count, + }), + ]); + return { + schemas: [SCIM_LIST_SCHEMA], + totalResults: total, + startIndex: page.startIndex, + itemsPerPage: rows.length, + Resources: rows.map((r) => this.toScim(provider, r as IdentityRow, ctx)), + }; + } + + async get(provider: ScimProvider, userId: string, ctx: ScimCtx) { + const row = await this.find(provider, userId); + return this.toScim(provider, row, ctx); + } + + // ── Create ──────────────────────────────────────────────────────────────── + + async create(provider: ScimProvider, body: unknown, ctx: ScimCtx) { + const parsed = readUser(body); + + // The identity key, and the one attribute the admin MUST re-map by hand: + // a non-gallery Entra app ships `externalId ← mailNickname`, which is + // neither unique nor immutable. Mapped to `objectId` it matches the `oid` + // an SSO sign-in stores, so the same person keeps one account whichever + // path reaches us first. + if (!parsed.externalId) { + throw new ScimError(400, 'externalId is required (map it to objectId in Entra)', 'invalidValue'); + } + + const existing = await this.prisma.userIdentity.findUnique({ + where: { + providerId_externalSubject: { providerId: provider.id, externalSubject: parsed.externalId }, + }, + select: { id: true }, + }); + if (existing) { + throw new ScimError(409, 'User already provisioned', 'uniqueness'); + } + + const email = parsed.primaryEmail ?? parsed.userName.toLowerCase(); + + // The same anti-takeover rule as JIT provisioning: a local account that + // owns this address is never claimed by a directory. Binding this + // provider's object id to it would let whoever controls the tenant sign + // in as that person everywhere they are a member. + const collision = await this.prisma.user.findUnique({ + where: { email }, + select: { id: true }, + }); + if (collision) { + throw new ScimError( + 409, + 'An account with this email already exists and is not linked to this identity provider. The user can link it by signing in with Microsoft from Settings → Connected accounts, or an administrator can remove the local account.', + 'uniqueness', + ); + } + + const now = new Date(); + const created = await this.prisma.$transaction(async (tx) => { + const user = await tx.user.create({ + data: { + email, + name: displayNameOf(parsed), + passwordHash: null, + emailVerified: true, + role: provider.jitDefaultRole, + organizationId: provider.organizationId, + }, + select: { id: true }, + }); + await tx.organizationMember.create({ + data: { userId: user.id, organizationId: provider.organizationId, role: provider.jitDefaultRole }, + }); + await tx.userIdentity.create({ + data: { + userId: user.id, + providerId: provider.id, + externalSubject: parsed.externalId!, + scimManagedAt: now, + }, + }); + return user; + }); + + await this.securityEvents.log({ + event: SecurityEvents.SCIM_USER_PROVISIONED, + actorType: 'SYSTEM', + organizationId: provider.organizationId, + targetUserId: created.id, + metadata: { providerId: provider.id, oid: parsed.externalId, role: provider.jitDefaultRole }, + ip: ctx.ip, + userAgent: ctx.userAgent, + }); + + if (!parsed.active) { + await this.lifecycle.deactivateInOrganization(created.id, provider.organizationId, this.lifecycleCtx(provider, ctx)); + } + + // A brand-new member with no role is UNRESTRICTED under getAllowedToolIds. + // The identity is SCIM-managed and in no group yet, so this applies the + // provider's fallback (DENY_ALL by default) from the first request, not + // from the first login. + await this.roleSync.syncFromScim(provider, created.id, { ip: ctx.ip, userAgent: ctx.userAgent }); + + return this.get(provider, created.id, ctx); + } + + // ── Update ──────────────────────────────────────────────────────────────── + + async replace(provider: ScimProvider, userId: string, body: unknown, ctx: ScimCtx) { + const row = await this.find(provider, userId); + const parsed = readUser(body); + const changes: UserChanges = { + // PUT with `active` absent must not silently reactivate. + ...(body && typeof (body as any).active !== 'undefined' ? { active: parsed.active } : {}), + email: parsed.primaryEmail ?? parsed.userName.toLowerCase(), + name: displayNameOf(parsed), + externalId: parsed.externalId, + }; + await this.apply(provider, row, changes, ctx); + return this.get(provider, userId, ctx); + } + + async patch(provider: ScimProvider, userId: string, body: unknown, ctx: ScimCtx) { + const row = await this.find(provider, userId); + const changes = this.changesFromPatch(row, parsePatch(body)); + await this.apply(provider, row, changes, ctx); + return this.get(provider, userId, ctx); + } + + /** + * DELETE is deprovisioning, not erasure. Entra sends it when a user is purged + * or when soft-delete is turned off; either way the outcome wanted is "no + * access", which deactivation already guarantees. Hard-deleting 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 Entra would come back as a second account. + */ + async remove(provider: ScimProvider, userId: string, ctx: ScimCtx): Promise { + const row = await this.find(provider, userId); + await this.apply(provider, row, { active: false }, ctx); + } + + // ── Internals ───────────────────────────────────────────────────────────── + + private async find(provider: ScimProvider, userId: string): Promise { + const row = await this.prisma.userIdentity.findUnique({ + where: { userId_providerId: { userId, providerId: provider.id } }, + include: IDENTITY_INCLUDE, + }); + // A user from another organization, or one without an identity here, is + // indistinguishable from nonexistent — 404 either way. + if (!row) throw new ScimError(404, 'User not found', 'noTarget'); + return row as IdentityRow; + } + + private whereFor(filter: ScimFilter | null) { + if (!filter) return {}; + switch (filter.attr) { + case 'userName': + case 'emails.value': + return { user: { email: filter.value.toLowerCase() } }; + case 'externalId': + return { externalSubject: filter.value }; + case 'id': + return { userId: filter.value }; + default: + throw new ScimError(400, `Unsupported filter attribute: ${filter.attr}`, 'invalidFilter'); + } + } + + private changesFromPatch(row: IdentityRow, ops: PatchOp[]): UserChanges { + const c: UserChanges = {}; + let given: string | undefined; + let family: string | undefined; + let display: string | undefined; + let formatted: string | undefined; + let touchedName = false; + + for (const op of ops) { + const path = (op.path ?? '').replace(/^urn:ietf:params:scim:schemas:core:2\.0:User:/i, ''); + const lower = path.toLowerCase(); + if (memberIdFromPath(op.path)) continue; // group membership lives on /Groups + + if (lower === 'active') { + c.active = op.op === 'remove' ? false : coerceActive(op.value); + } else if (lower === 'username') { + if (typeof op.value === 'string' && op.value.trim()) c.email = op.value.trim().toLowerCase(); + } else if (lower === 'emails') { + const e = pickEmail(op.value); + if (e) c.email = e; + } else if (/^emails\[.*\]\.value$/i.test(path)) { + if (typeof op.value === 'string' && op.value.trim()) c.email = op.value.trim().toLowerCase(); + } else if (lower === 'externalid') { + if (typeof op.value === 'string') c.externalId = op.value; + } else if (lower === 'displayname') { + display = typeof op.value === 'string' ? op.value : undefined; touchedName = true; + } else if (lower === 'name.givenname') { + given = typeof op.value === 'string' ? op.value : undefined; touchedName = true; + } else if (lower === 'name.familyname') { + family = typeof op.value === 'string' ? op.value : undefined; touchedName = true; + } else if (lower === 'name.formatted') { + formatted = typeof op.value === 'string' ? op.value : undefined; touchedName = true; + } + // Anything else (title, department, enterprise extension, …) is an + // attribute the admin mapped that we have no column for. Ignored, never + // 400: Entra pushes whatever is mapped. + } + + if (touchedName) { + // A PATCH may carry only one part of the name; keep the rest. + const current = row.user.name ?? ''; + c.name = displayNameOf({ + displayName: display, + formattedName: formatted, + givenName: given, + familyName: family, + }) ?? (display === undefined && formatted === undefined && !given && !family ? current : null); + } + return c; + } + + private async apply(provider: ScimProvider, row: IdentityRow, changes: UserChanges, ctx: ScimCtx) { + const orgId = provider.organizationId; + const membership = row.user.memberships.find((m) => m.organizationId === orgId); + const isActive = Boolean(membership) && membership!.deactivatedAt === null; + const metadata: Record = { providerId: provider.id, oid: row.externalSubject }; + + // A directory that reports a different externalId is misconfigured, not + // malicious: Entra's stock mapping for a non-gallery app sends + // `mailNickname`, so the value rarely matches the objectId we anchored the + // identity to at sign-in. Refusing the request would be worse than useless + // — Entra packs the whole user into ONE PatchOp, so a 400 here throws away + // the name, the department and, critically, `active`, and its + // provision-on-demand view still reports the step as a success. Keep our + // anchor, apply everything else, and record the mismatch so an admin can + // find it. + if (changes.externalId !== undefined && changes.externalId !== row.externalSubject) { + metadata.externalIdIgnored = changes.externalId; + this.logger.warn( + `SCIM sent externalId "${changes.externalId}" for a user anchored to "${row.externalSubject}" ` + + `(provider ${provider.id}). Map externalId to objectId in Entra; the value was ignored.`, + ); + } + + // Mark the identity as SCIM-managed on first touch, so the role sync can + // tell "SCIM says no groups" from "SCIM never mentioned this user". + if (!row.scimManagedAt) { + await this.prisma.userIdentity.update({ + where: { id: row.id }, + data: { scimManagedAt: new Date() }, + }); + } + + const data: { name?: string | null; email?: string } = {}; + if (changes.name !== undefined && changes.name !== row.user.name) data.name = changes.name; + + if (changes.email && changes.email !== row.user.email) { + // `users.email` is global and is where password-reset mail goes. Only + // rewrite it for an account this provider fully owns: no password, no + // other workspace, and the address unclaimed. Otherwise keep the old + // address and carry on — a deactivation in the same request must never + // be blocked by an email conflict. + const skipped = row.user.passwordHash + ? 'has_password' + : row.user.memberships.some((m) => m.organizationId !== orgId) + ? 'multi_org' + : (await this.prisma.user.findUnique({ where: { email: changes.email }, select: { id: true } })) + ? 'conflict' + : null; + if (skipped) metadata.emailChangeSkipped = skipped; + else data.email = changes.email; + } + + if (Object.keys(data).length > 0) { + await this.prisma.user.update({ where: { id: row.userId }, data }); + metadata.updated = Object.keys(data); + } + + if (changes.active === false && isActive) { + const result = await this.lifecycle.deactivateInOrganization(row.userId, orgId, this.lifecycleCtx(provider, ctx)); + await this.securityEvents.log({ + event: SecurityEvents.SCIM_USER_DEPROVISIONED, + actorType: 'SYSTEM', + organizationId: orgId, + targetUserId: row.userId, + metadata: { ...metadata, outcome: result.status }, + ip: ctx.ip, + userAgent: ctx.userAgent, + }); + if (result.status === 'last_admin_retained') { + // Sessions and keys are already revoked. Tell Entra so the failure is + // visible in its provisioning log instead of silently succeeding. + throw new ScimError( + 409, + 'This user is the only administrator of the workspace. Their sessions and MCP keys were revoked, but the membership was kept so the workspace stays recoverable. Promote another administrator and retry.', + 'mutability', + ); + } + return; + } + + if (changes.active === true && membership && !isActive) { + await this.lifecycle.reactivateInOrganization(row.userId, orgId, this.lifecycleCtx(provider, ctx)); + await this.securityEvents.log({ + event: SecurityEvents.SCIM_USER_REACTIVATED, + actorType: 'SYSTEM', + organizationId: orgId, + targetUserId: row.userId, + metadata, + ip: ctx.ip, + userAgent: ctx.userAgent, + }); + await this.roleSync.syncFromScim(provider, row.userId, { ip: ctx.ip, userAgent: ctx.userAgent }); + return; + } + + if (metadata.updated || metadata.emailChangeSkipped) { + await this.securityEvents.log({ + event: SecurityEvents.SCIM_USER_UPDATED, + actorType: 'SYSTEM', + organizationId: orgId, + targetUserId: row.userId, + metadata, + ip: ctx.ip, + userAgent: ctx.userAgent, + }); + } + } + + private lifecycleCtx(provider: ScimProvider, ctx: ScimCtx) { + return { + reason: 'scim' as const, + actor: { type: 'SYSTEM' as const }, + providerId: provider.id, + ip: ctx.ip, + userAgent: ctx.userAgent, + }; + } + + private toScim(provider: ScimProvider, row: IdentityRow, ctx: ScimCtx) { + const membership = row.user.memberships.find((m) => m.organizationId === provider.organizationId); + const active = Boolean(membership) && membership!.deactivatedAt === null; + return { + schemas: [SCIM_USER_SCHEMA], + id: row.userId, + externalId: row.externalSubject, + userName: row.user.email, + active, + ...(row.user.name ? { displayName: row.user.name, name: { formatted: row.user.name } } : {}), + emails: [{ value: row.user.email, type: 'work', primary: true }], + meta: { + resourceType: 'User', + created: row.user.createdAt.toISOString(), + lastModified: row.user.updatedAt.toISOString(), + location: `${ctx.baseUrl}/Users/${row.userId}`, + }, + }; + } +} + +export type { ParsedUser }; diff --git a/packages/backend/src/identity-providers/scim/scim.controller.ts b/packages/backend/src/identity-providers/scim/scim.controller.ts new file mode 100644 index 00000000..18f8b8f8 --- /dev/null +++ b/packages/backend/src/identity-providers/scim/scim.controller.ts @@ -0,0 +1,210 @@ +import { + Body, + Controller, + Delete, + Get, + Header, + HttpCode, + HttpStatus, + Param, + Patch, + Post, + Put, + Query, + Req, + UseFilters, + UseGuards, +} from '@nestjs/common'; +import { ApiExcludeController } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import { ConfigService } from '@nestjs/config'; +import { Request } from 'express'; +import { SelfHostedOnlyGuard } from '../../common/self-hosted-only.guard'; +import { ScimAuthGuard, ScimProvider } from './scim-auth.guard'; +import { SCIM_CONTENT_TYPE, ScimError, ScimExceptionFilter } from './scim.errors'; +import { parseExcluded, parseFilter, parsePagination } from './scim.parser'; +import { resourceTypes, schemas, serviceProviderConfig } from './scim.schemas'; +import { ScimCtx, ScimUsersService } from './scim-users.service'; +import { ScimGroupsService } from './scim-groups.service'; + +/** + * SCIM 2.0 endpoint for Microsoft Entra ID outbound provisioning. + * + * Mounted under /api so the frontend's existing rewrite and the login + * redirect middleware both leave it alone — a bare /scim would be 302'd to + * /login by proxy.ts and Entra would receive an HTML page. + * + * NO DTO CLASSES HERE, deliberately. The global ValidationPipe runs with + * `forbidNonWhitelisted`; bodies are typed `unknown` so the pipe never looks + * at them, and the parser reads the fields it understands. Adding a DTO to + * any route re-enables whitelisting and 400s every real Entra payload. + * + * No `@Res()` either: handlers return plain objects and Nest serialises them + * (Express keeps the Content-Type set by `@Header`, so the SCIM media type + * survives). Writing `res.json(body)` from a helper looked to CodeQL like a + * reflected-XSS sink it could not tie to a route; a typed return value is + * not one. + */ +const ScimJson = () => Header('Content-Type', SCIM_CONTENT_TYPE); + +@ApiExcludeController() +@UseGuards(SelfHostedOnlyGuard, ScimAuthGuard) +@UseFilters(ScimExceptionFilter) +// Entra's initial cycle sends hundreds of requests within minutes; the global +// 100/min bucket would 429 it. Overrides `default` only — see app.module.ts. +@Throttle({ default: { limit: 1000, ttl: 60_000 } }) +@Controller('api/scim/v2') +export class ScimController { + constructor( + private readonly users: ScimUsersService, + private readonly groups: ScimGroupsService, + private readonly config: ConfigService, + ) {} + + // ── Discovery ───────────────────────────────────────────────────────────── + + @Get('ServiceProviderConfig') + @ScimJson() + serviceProviderConfig(@Req() req: Request) { + return serviceProviderConfig(this.baseUrl(req)); + } + + @Get('ResourceTypes') + @ScimJson() + resourceTypes(@Req() req: Request) { + return this.list(resourceTypes(this.baseUrl(req))); + } + + @Get('ResourceTypes/:name') + @ScimJson() + resourceType(@Req() req: Request, @Param('name') name: string) { + const rt = resourceTypes(this.baseUrl(req)).find((r) => r.id.toLowerCase() === name.toLowerCase()); + if (!rt) throw new ScimError(404, 'Resource type not found', 'noTarget'); + return rt; + } + + @Get('Schemas') + @ScimJson() + schemas(@Req() req: Request) { + return this.list(schemas(this.baseUrl(req))); + } + + @Get('Schemas/:uri') + @ScimJson() + schema(@Req() req: Request, @Param('uri') uri: string) { + const s = schemas(this.baseUrl(req)).find((x) => x.id === uri); + if (!s) throw new ScimError(404, 'Schema not found', 'noTarget'); + return s; + } + + // ── Users ───────────────────────────────────────────────────────────────── + + @Get('Users') + @ScimJson() + listUsers(@Req() req: Request, @Query() q: Record) { + return this.users.list(this.provider(req), parseFilter(q.filter), parsePagination(q), this.ctx(req)); + } + + @Get('Users/:id') + @ScimJson() + getUser(@Req() req: Request, @Param('id') id: string) { + return this.users.get(this.provider(req), id, this.ctx(req)); + } + + @Post('Users') + @HttpCode(HttpStatus.CREATED) + @ScimJson() + createUser(@Req() req: Request, @Body() body: unknown) { + return this.users.create(this.provider(req), body, this.ctx(req)); + } + + @Put('Users/:id') + @ScimJson() + replaceUser(@Req() req: Request, @Param('id') id: string, @Body() body: unknown) { + return this.users.replace(this.provider(req), id, body, this.ctx(req)); + } + + @Patch('Users/:id') + @ScimJson() + patchUser(@Req() req: Request, @Param('id') id: string, @Body() body: unknown) { + return this.users.patch(this.provider(req), id, body, this.ctx(req)); + } + + @Delete('Users/:id') + @HttpCode(HttpStatus.NO_CONTENT) + async deleteUser(@Req() req: Request, @Param('id') id: string): Promise { + await this.users.remove(this.provider(req), id, this.ctx(req)); + } + + // ── Groups ──────────────────────────────────────────────────────────────── + + @Get('Groups') + @ScimJson() + listGroups(@Req() req: Request, @Query() q: Record) { + return this.groups.list(this.provider(req), parseFilter(q.filter), parsePagination(q), parseExcluded(q), this.ctx(req)); + } + + @Get('Groups/:id') + @ScimJson() + getGroup(@Req() req: Request, @Param('id') id: string, @Query() q: Record) { + return this.groups.get(this.provider(req), id, parseExcluded(q), this.ctx(req)); + } + + @Post('Groups') + @HttpCode(HttpStatus.CREATED) + @ScimJson() + createGroup(@Req() req: Request, @Body() body: unknown) { + return this.groups.create(this.provider(req), body, this.ctx(req)); + } + + @Put('Groups/:id') + @ScimJson() + replaceGroup(@Req() req: Request, @Param('id') id: string, @Body() body: unknown) { + return this.groups.replace(this.provider(req), id, body, this.ctx(req)); + } + + @Patch('Groups/:id') + @ScimJson() + patchGroup(@Req() req: Request, @Param('id') id: string, @Body() body: unknown) { + return this.groups.patch(this.provider(req), id, body, this.ctx(req)); + } + + @Delete('Groups/:id') + @HttpCode(HttpStatus.NO_CONTENT) + async deleteGroup(@Req() req: Request, @Param('id') id: string): Promise { + await this.groups.remove(this.provider(req), id, this.ctx(req)); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private provider(req: Request): ScimProvider { + return (req as any).scimProvider; + } + + private ctx(req: Request): ScimCtx { + return { baseUrl: this.baseUrl(req), ip: req.ip, userAgent: req.headers['user-agent'] }; + } + + /** + * Where this endpoint is reachable, for `meta.location`. Configured URL + * first; a header-derived value is acceptable as a fallback here because it + * only decorates responses — nothing is redirected to it. + */ + private baseUrl(req: Request): string { + const configured = this.config.get('FRONTEND_URL') || this.config.get('SERVER_URL'); + if (configured) return `${configured.replace(/\/$/, '')}/api/scim/v2`; + const proto = (req.headers['x-forwarded-proto'] as string | undefined)?.split(',')[0] || req.protocol; + const host = (req.headers['x-forwarded-host'] as string | undefined)?.split(',')[0] || req.headers.host; + return `${proto}://${host}/api/scim/v2`; + } + + private list(resources: unknown[]) { + return { + schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'], + totalResults: resources.length, + startIndex: 1, + itemsPerPage: resources.length, + Resources: resources, + }; + } +} diff --git a/packages/backend/src/identity-providers/scim/scim.errors.spec.ts b/packages/backend/src/identity-providers/scim/scim.errors.spec.ts new file mode 100644 index 00000000..ffb0027e --- /dev/null +++ b/packages/backend/src/identity-providers/scim/scim.errors.spec.ts @@ -0,0 +1,64 @@ +import { HttpException, NotFoundException } from '@nestjs/common'; +import { ScimError, ScimExceptionFilter, SCIM_ERROR_SCHEMA } from './scim.errors'; + +/** + * Entra parses the SCIM error document, not Nest's default body. A 409 it + * cannot read as `uniqueness` becomes a quarantined user instead of a + * GET-then-PATCH retry, so the shape is load-bearing. + */ +describe('ScimExceptionFilter', () => { + let res: any; + let filter: ScimExceptionFilter; + const host = () => ({ switchToHttp: () => ({ getResponse: () => res }) }) as any; + + beforeEach(() => { + res = { + headers: {} as Record, + statusCode: 0, + body: undefined as unknown, + setHeader(k: string, v: string) { this.headers[k] = v; }, + status(c: number) { this.statusCode = c; return this; }, + json(b: unknown) { this.body = b; return this; }, + }; + filter = new ScimExceptionFilter(); + }); + + it('emits a ScimError as-is, with the SCIM content type', () => { + filter.catch(new ScimError(409, 'dup', 'uniqueness'), host()); + expect(res.statusCode).toBe(409); + expect(res.body).toEqual({ schemas: [SCIM_ERROR_SCHEMA], status: '409', scimType: 'uniqueness', detail: 'dup' }); + expect(res.headers['Content-Type']).toContain('application/scim+json'); + }); + + it('adds WWW-Authenticate on 401', () => { + filter.catch(new ScimError(401, 'nope'), host()); + expect(res.headers['WWW-Authenticate']).toBe('Bearer realm="scim"'); + }); + + it('maps a Prisma unique violation to 409 uniqueness', () => { + filter.catch({ code: 'P2002', message: 'Unique constraint failed on users.email' }, host()); + expect(res.statusCode).toBe(409); + expect(res.body).toMatchObject({ scimType: 'uniqueness' }); + // Never the Prisma text: it names tables and columns. + expect(JSON.stringify(res.body)).not.toContain('users.email'); + }); + + it('wraps other HttpExceptions (e.g. the self-hosted-only 404) in the SCIM shape', () => { + filter.catch(new NotFoundException('Not found'), host()); + expect(res.statusCode).toBe(404); + expect(res.body).toEqual({ schemas: [SCIM_ERROR_SCHEMA], status: '404', detail: 'Not found' }); + }); + + it('never leaks an internal error message', () => { + filter.catch(new Error('connect ECONNREFUSED 10.0.0.5:5432'), host()); + expect(res.statusCode).toBe(500); + expect(JSON.stringify(res.body)).not.toContain('ECONNREFUSED'); + }); + + it('ScimError is an HttpException carrying the SCIM body', () => { + const e = new ScimError(400, 'bad', 'invalidValue'); + expect(e).toBeInstanceOf(HttpException); + expect(e.getStatus()).toBe(400); + expect(e.getResponse()).toMatchObject({ schemas: [SCIM_ERROR_SCHEMA], scimType: 'invalidValue' }); + }); +}); diff --git a/packages/backend/src/identity-providers/scim/scim.errors.ts b/packages/backend/src/identity-providers/scim/scim.errors.ts new file mode 100644 index 00000000..db812449 --- /dev/null +++ b/packages/backend/src/identity-providers/scim/scim.errors.ts @@ -0,0 +1,103 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpException, + HttpStatus, + Logger, +} from '@nestjs/common'; +import { Response } from 'express'; + +export const SCIM_ERROR_SCHEMA = 'urn:ietf:params:scim:api:messages:2.0:Error'; +export const SCIM_CONTENT_TYPE = 'application/scim+json; charset=utf-8'; + +/** RFC 7644 §3.12 `scimType` values we use. */ +export type ScimType = + | 'invalidFilter' + | 'invalidSyntax' + | 'invalidValue' + | 'invalidPath' + | 'uniqueness' + | 'mutability' + | 'noTarget' + | 'tooMany'; + +/** + * A SCIM error. Extends HttpException with the SCIM body already in place, so + * even a path that escapes the filter answers in the shape Entra expects. + */ +export class ScimError extends HttpException { + constructor(status: number, detail: string, scimType?: ScimType) { + super( + { + schemas: [SCIM_ERROR_SCHEMA], + status: String(status), + ...(scimType ? { scimType } : {}), + detail, + }, + status, + ); + } +} + +/** + * Turns every failure on the SCIM controller into a SCIM error document. + * + * Nest's default JSON error body (`{ message, error, statusCode }`) is not + * what Entra parses; a 409 it cannot read as `uniqueness` becomes a + * quarantined user instead of a GET-then-PATCH retry. + */ +@Catch() +export class ScimExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger('ScimExceptionFilter'); + + catch(exception: unknown, host: ArgumentsHost) { + const res = host.switchToHttp().getResponse(); + res.setHeader('Content-Type', SCIM_CONTENT_TYPE); + + if (exception instanceof ScimError) { + const status = exception.getStatus(); + if (status === HttpStatus.UNAUTHORIZED) { + res.setHeader('WWW-Authenticate', 'Bearer realm="scim"'); + } + return res.status(status).json(exception.getResponse()); + } + + // Prisma unique violation — a concurrent create for the same identity or + // email. `uniqueness` is the scimType Entra recognises as "already there". + if (isPrismaError(exception) && exception.code === 'P2002') { + return res.status(HttpStatus.CONFLICT).json({ + schemas: [SCIM_ERROR_SCHEMA], + status: '409', + scimType: 'uniqueness', + detail: 'A resource with this identifier already exists.', + }); + } + + if (exception instanceof HttpException) { + const status = exception.getStatus(); + if (status === HttpStatus.UNAUTHORIZED) { + res.setHeader('WWW-Authenticate', 'Bearer realm="scim"'); + } + return res.status(status).json({ + schemas: [SCIM_ERROR_SCHEMA], + status: String(status), + detail: exception.message, + }); + } + + // Never leak an internal message to the directory. + this.logger.error( + `Unhandled SCIM error: ${exception instanceof Error ? exception.stack ?? exception.message : String(exception)}`, + ); + return res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ + schemas: [SCIM_ERROR_SCHEMA], + status: '500', + detail: 'Internal error.', + }); + } +} + +function isPrismaError(e: unknown): e is { code: string } { + return typeof e === 'object' && e !== null && typeof (e as any).code === 'string'; +} diff --git a/packages/backend/src/identity-providers/scim/scim.parser.spec.ts b/packages/backend/src/identity-providers/scim/scim.parser.spec.ts new file mode 100644 index 00000000..55d81605 --- /dev/null +++ b/packages/backend/src/identity-providers/scim/scim.parser.spec.ts @@ -0,0 +1,137 @@ +import { + coerceActive, + displayNameOf, + memberIdFromPath, + parseExcluded, + parseFilter, + parsePagination, + parsePatch, + readUser, +} from './scim.parser'; +import { ScimError } from './scim.errors'; + +/** + * Every shape here was taken from Microsoft's SCIM tutorial or observed from + * a real Entra tenant. A parser that handles only the RFC's canonical forms + * fails the first provisioning cycle. + */ +describe('ScimParser', () => { + describe('parseFilter', () => { + it('reads the equality filters Entra uses', () => { + expect(parseFilter('userName eq "a@b.c"')).toEqual({ attr: 'userName', value: 'a@b.c' }); + expect(parseFilter('externalId eq "0f3d"')).toEqual({ attr: 'externalId', value: '0f3d' }); + expect(parseFilter('displayName eq "GB Test"')).toEqual({ attr: 'displayName', value: 'GB Test' }); + expect(parseFilter('emails[type eq "work"].value eq "a@b.c"')).toEqual({ attr: 'emails.value', value: 'a@b.c' }); + }); + + it('is case-insensitive on the attribute and the operator, and unescapes quotes', () => { + expect(parseFilter(' UserName EQ "Team \\"A\\"" ')).toEqual({ attr: 'userName', value: 'Team "A"' }); + }); + + it('returns null when absent', () => { + expect(parseFilter(undefined)).toBeNull(); + expect(parseFilter('')).toBeNull(); + }); + + // An ignored filter would return the whole list and Entra would take the + // first entry as the match — so anything unsupported must be refused. + it('refuses other operators and unknown attributes', () => { + expect(() => parseFilter('userName co "a"')).toThrow(ScimError); + expect(() => parseFilter('title eq "x"')).toThrow(ScimError); + expect(() => parseFilter('userName eq "a" and active eq true')).toThrow(ScimError); + }); + }); + + describe('parsePagination / parseExcluded', () => { + it('defaults and clamps', () => { + expect(parsePagination({})).toEqual({ startIndex: 1, count: 100 }); + expect(parsePagination({ startIndex: '0', count: '5000' })).toEqual({ startIndex: 1, count: 200 }); + expect(parsePagination({ startIndex: 'x', count: '-1' })).toEqual({ startIndex: 1, count: 1 }); + }); + it('reads excludedAttributes', () => { + expect(parseExcluded({ excludedAttributes: 'members, groups' })).toEqual(new Set(['members', 'groups'])); + }); + }); + + describe('parsePatch', () => { + const wrap = (ops: unknown[]) => ({ schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'], Operations: ops }); + + it('lower-cases the capitalised ops Entra emits', () => { + const ops = parsePatch(wrap([ + { op: 'Replace', path: 'active', value: false }, + { op: 'Add', path: 'members', value: [{ value: 'u1' }] }, + { op: 'Remove', path: 'members[value eq "u1"]' }, + ])); + expect(ops.map((o) => o.op)).toEqual(['replace', 'add', 'remove']); + }); + + it('expands a path-less replace with an object value, including nested name and the enterprise URN', () => { + const ops = parsePatch(wrap([{ + op: 'replace', + value: { + active: 'True', + displayName: 'Anna', + name: { givenName: 'Anna', familyName: 'Rossi' }, + 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User': { department: 'R&D' }, + }, + }])); + expect(ops).toEqual([ + { op: 'replace', path: 'active', value: 'True' }, + { op: 'replace', path: 'displayName', value: 'Anna' }, + { op: 'replace', path: 'name.givenName', value: 'Anna' }, + { op: 'replace', path: 'name.familyName', value: 'Rossi' }, + { op: 'replace', path: 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department', value: 'R&D' }, + ]); + }); + + it('tolerates a missing schemas array but rejects a wrong one', () => { + expect(parsePatch({ Operations: [{ op: 'replace', path: 'active', value: true }] })).toHaveLength(1); + expect(() => parsePatch({ schemas: ['urn:x'], Operations: [{ op: 'replace', path: 'active', value: true }] })).toThrow(ScimError); + }); + + it('rejects empty operations and unknown ops', () => { + expect(() => parsePatch(wrap([]))).toThrow(ScimError); + expect(() => parsePatch(wrap([{ op: 'move', path: 'x' }]))).toThrow(ScimError); + expect(() => parsePatch(wrap([{ op: 'replace', value: 'not-an-object' }]))).toThrow(ScimError); + }); + }); + + describe('coerceActive', () => { + it('accepts booleans and the string forms', () => { + expect(coerceActive(true)).toBe(true); + expect(coerceActive('False')).toBe(false); + expect(coerceActive('true')).toBe(true); + expect(() => coerceActive('maybe')).toThrow(ScimError); + expect(() => coerceActive(1)).toThrow(ScimError); + }); + }); + + describe('readUser', () => { + it('picks the email by primary, then work, then first — lower-cased', () => { + const base = { userName: 'U@X.com' }; + expect(readUser({ ...base, emails: [{ value: 'A@x', type: 'home' }, { value: 'B@x', type: 'work' }] }).primaryEmail).toBe('b@x'); + expect(readUser({ ...base, emails: [{ value: 'A@x', type: 'home' }, { value: 'C@x', primary: true }] }).primaryEmail).toBe('c@x'); + expect(readUser({ ...base, emails: [{ value: 'A@x' }] }).primaryEmail).toBe('a@x'); + expect(readUser({ ...base }).primaryEmail).toBeUndefined(); + }); + + it('requires userName and defaults active to true', () => { + expect(() => readUser({ active: true })).toThrow(ScimError); + expect(readUser({ userName: 'u' }).active).toBe(true); + expect(readUser({ userName: 'u', active: 'False' }).active).toBe(false); + }); + }); + + it('builds a display name from whatever was sent', () => { + expect(displayNameOf({ displayName: 'Anna R' })).toBe('Anna R'); + expect(displayNameOf({ formattedName: 'Anna Rossi' })).toBe('Anna Rossi'); + expect(displayNameOf({ givenName: 'Anna', familyName: 'Rossi' })).toBe('Anna Rossi'); + expect(displayNameOf({})).toBeNull(); + }); + + it('extracts a member id from a filtered path', () => { + expect(memberIdFromPath('members[value eq "abc"]')).toBe('abc'); + expect(memberIdFromPath('members')).toBeNull(); + expect(memberIdFromPath(undefined)).toBeNull(); + }); +}); diff --git a/packages/backend/src/identity-providers/scim/scim.parser.ts b/packages/backend/src/identity-providers/scim/scim.parser.ts new file mode 100644 index 00000000..13dd34ee --- /dev/null +++ b/packages/backend/src/identity-providers/scim/scim.parser.ts @@ -0,0 +1,187 @@ +import { ScimError } from './scim.errors'; +import { SCIM_ENTERPRISE_USER_SCHEMA, SCIM_PATCH_SCHEMA } from './scim.schemas'; + +/** + * Tolerant readers for what Entra actually sends. + * + * Pure functions, no DI, no DTO classes: 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. + */ + +export type ScimFilter = { + attr: 'userName' | 'externalId' | 'id' | 'displayName' | 'emails.value'; + value: string; +}; + +const FILTER_ATTRS: Record = { + username: 'userName', + externalid: 'externalId', + id: 'id', + displayname: 'displayName', + 'emails.value': 'emails.value', + 'emails[type eq "work"].value': 'emails.value', +}; + +/** + * Only ` eq ""` is supported — the sole form Entra uses to look a + * resource up. Anything else is `invalidFilter` rather than silently ignored, + * because an ignored filter would return the whole list and Entra would treat + * the first entry as the match. + */ +export function parseFilter(raw: string | undefined): ScimFilter | null { + if (raw === undefined || raw === null || raw.trim() === '') return null; + const text = raw.trim(); + // Bounded and scanned with `lastIndexOf`, not matched with a regex: a + // pattern like `([A-Za-z =]+?)\s+eq` backtracks polynomially on a string of + // spaces, and this input arrives on an unauthenticated-until-proven route. + if (text.length > 512) throw new ScimError(400, 'Filter too long', 'invalidFilter'); + const idx = text.toLowerCase().lastIndexOf(' eq '); + if (idx <= 0) throw new ScimError(400, `Unsupported filter: ${text}`, 'invalidFilter'); + const attrRaw = text.slice(0, idx).trim(); + const valueRaw = text.slice(idx + 4).trim(); + if (valueRaw.length < 2 || !valueRaw.startsWith('"') || !valueRaw.endsWith('"')) { + throw new ScimError(400, `Unsupported filter: ${text}`, 'invalidFilter'); + } + const inner = valueRaw.slice(1, -1); + // An unescaped quote inside the value means this was not a single + // `attr eq "value"` expression (e.g. `a eq "x" and b eq "y"`). + if (/(^|[^\\])"/.test(inner)) throw new ScimError(400, `Unsupported filter: ${text}`, 'invalidFilter'); + const attr = FILTER_ATTRS[attrRaw.toLowerCase()]; + if (!attr) throw new ScimError(400, `Unsupported filter attribute: ${attrRaw}`, 'invalidFilter'); + return { attr, value: inner.replace(/\\"/g, '"') }; +} + +export function parsePagination(q: Record) { + const startIndex = Math.max(1, Number.parseInt(q.startIndex ?? '1', 10) || 1); + const count = Math.min(200, Math.max(1, Number.parseInt(q.count ?? '100', 10) || 100)); + return { startIndex, count }; +} + +export function parseExcluded(q: Record): Set { + return new Set( + (q.excludedAttributes ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + ); +} + +export type PatchOp = { op: 'add' | 'replace' | 'remove'; path?: string; value?: unknown }; + +/** + * Normalises a PatchOp document: + * - `op` is matched case-insensitively (Entra sends `Add`/`Replace`/`Remove`); + * - a path-less add/replace whose value is an object is expanded to one op per + * attribute, with nested objects flattened to dotted paths and the + * enterprise URN kept as a prefix. + */ +export function parsePatch(body: unknown): PatchOp[] { + if (!isRecord(body)) throw new ScimError(400, 'Request body must be an object', 'invalidSyntax'); + if (Array.isArray(body.schemas) && !body.schemas.includes(SCIM_PATCH_SCHEMA)) { + throw new ScimError(400, `schemas must include ${SCIM_PATCH_SCHEMA}`, 'invalidSyntax'); + } + const ops = body.Operations; + if (!Array.isArray(ops) || ops.length === 0) { + throw new ScimError(400, 'Operations must be a non-empty array', 'invalidSyntax'); + } + + const out: PatchOp[] = []; + for (const raw of ops) { + if (!isRecord(raw)) throw new ScimError(400, 'Each operation must be an object', 'invalidSyntax'); + const op = String(raw.op ?? '').trim().toLowerCase(); + if (op !== 'add' && op !== 'replace' && op !== 'remove') { + throw new ScimError(400, `Unsupported op: ${String(raw.op)}`, 'invalidValue'); + } + const path = typeof raw.path === 'string' && raw.path.trim() ? raw.path.trim() : undefined; + + if (!path && op !== 'remove' && isRecord(raw.value)) { + for (const [k, v] of Object.entries(raw.value)) { + if (k === SCIM_ENTERPRISE_USER_SCHEMA && isRecord(v)) { + for (const [ek, ev] of Object.entries(v)) out.push({ op, path: `${k}:${ek}`, value: ev }); + } else if (isRecord(v) && !Array.isArray(v)) { + for (const [nk, nv] of Object.entries(v)) out.push({ op, path: `${k}.${nk}`, value: nv }); + } else { + out.push({ op, path: k, value: v }); + } + } + continue; + } + if (!path && op !== 'remove') { + throw new ScimError(400, 'A path-less add/replace needs an object value', 'invalidValue'); + } + out.push({ op, path, value: raw.value }); + } + return out; +} + +/** `true`/`false`, or the strings Entra sometimes sends (`"True"`, `"False"`). */ +export function coerceActive(v: unknown): boolean { + if (typeof v === 'boolean') return v; + if (typeof v === 'string') { + const s = v.trim().toLowerCase(); + if (s === 'true') return true; + if (s === 'false') return false; + } + throw new ScimError(400, `active must be a boolean, got ${JSON.stringify(v)}`, 'invalidValue'); +} + +export interface ParsedUser { + userName: string; + externalId?: string; + active: boolean; + displayName?: string; + givenName?: string; + familyName?: string; + formattedName?: string; + /** Lowercased. primary > type=work > first. */ + primaryEmail?: string; +} + +export function readUser(body: unknown): ParsedUser { + if (!isRecord(body)) throw new ScimError(400, 'Request body must be an object', 'invalidSyntax'); + const userName = typeof body.userName === 'string' ? body.userName.trim() : ''; + if (!userName) throw new ScimError(400, 'userName is required', 'invalidValue'); + const name = isRecord(body.name) ? body.name : {}; + return { + userName, + externalId: optString(body.externalId), + active: body.active === undefined ? true : coerceActive(body.active), + displayName: optString(body.displayName), + givenName: optString(name.givenName), + familyName: optString(name.familyName), + formattedName: optString(name.formatted), + primaryEmail: pickEmail(body.emails), + }; +} + +export function pickEmail(emails: unknown): string | undefined { + if (!Array.isArray(emails)) return undefined; + const entries = emails.filter(isRecord).filter((e) => typeof e.value === 'string' && e.value.trim()); + const chosen = + entries.find((e) => e.primary === true || e.primary === 'true') ?? + entries.find((e) => String(e.type ?? '').toLowerCase() === 'work') ?? + entries[0]; + return chosen ? String(chosen.value).trim().toLowerCase() : undefined; +} + +/** A display name for the `users.name` column, from whatever Entra sent. */ +export function displayNameOf(u: Pick): string | null { + const joined = [u.givenName, u.familyName].filter(Boolean).join(' ').trim(); + return u.displayName || u.formattedName || joined || null; +} + +/** `members[value eq ""]` → ``. */ +export function memberIdFromPath(path: string | undefined): string | null { + const m = path?.match(/^members\[value\s+eq\s+"([^"]+)"\]$/i); + return m ? m[1] : null; +} + +export function isRecord(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +function optString(v: unknown): string | undefined { + return typeof v === 'string' && v.trim() ? v.trim() : undefined; +} diff --git a/packages/backend/src/identity-providers/scim/scim.schemas.ts b/packages/backend/src/identity-providers/scim/scim.schemas.ts new file mode 100644 index 00000000..db208508 --- /dev/null +++ b/packages/backend/src/identity-providers/scim/scim.schemas.ts @@ -0,0 +1,133 @@ +/** + * Static SCIM 2.0 discovery documents (RFC 7643 §5–§7). + * + * Kept minimal on purpose: they describe only the attributes this server + * honours. Entra reads /Schemas when the provisioning configuration is saved + * and surfaces whatever it finds as mappable target attributes — advertising + * `title` or `department` here would invite mappings we silently drop. + */ + +export const SCIM_USER_SCHEMA = 'urn:ietf:params:scim:schemas:core:2.0:User'; +export const SCIM_GROUP_SCHEMA = 'urn:ietf:params:scim:schemas:core:2.0:Group'; +export const SCIM_ENTERPRISE_USER_SCHEMA = + 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User'; +export const SCIM_LIST_SCHEMA = 'urn:ietf:params:scim:api:messages:2.0:ListResponse'; +export const SCIM_PATCH_SCHEMA = 'urn:ietf:params:scim:api:messages:2.0:PatchOp'; + +export function serviceProviderConfig(baseUrl: string) { + return { + schemas: ['urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig'], + documentationUri: 'https://github.com/HelpCode-ai/anythingmcp/blob/main/docs/sso.md', + patch: { supported: true }, + bulk: { supported: false, maxOperations: 0, maxPayloadSize: 0 }, + filter: { supported: true, maxResults: 200 }, + changePassword: { supported: false }, + sort: { supported: false }, + etag: { supported: false }, + authenticationSchemes: [ + { + type: 'oauthbearertoken', + name: 'OAuth Bearer Token', + description: + 'Long-lived bearer token issued from Settings → Single sign-on → Provisioning (SCIM).', + specUri: 'https://www.rfc-editor.org/info/rfc6750', + primary: true, + }, + ], + meta: { + resourceType: 'ServiceProviderConfig', + location: `${baseUrl}/ServiceProviderConfig`, + }, + }; +} + +export function resourceTypes(baseUrl: string) { + return [ + { + schemas: ['urn:ietf:params:scim:schemas:core:2.0:ResourceType'], + id: 'User', + name: 'User', + endpoint: '/Users', + description: 'A member of the workspace', + schema: SCIM_USER_SCHEMA, + schemaExtensions: [{ schema: SCIM_ENTERPRISE_USER_SCHEMA, required: false }], + meta: { resourceType: 'ResourceType', location: `${baseUrl}/ResourceTypes/User` }, + }, + { + schemas: ['urn:ietf:params:scim:schemas:core:2.0:ResourceType'], + id: 'Group', + name: 'Group', + endpoint: '/Groups', + description: 'A directory group whose membership is mapped to roles', + schema: SCIM_GROUP_SCHEMA, + meta: { resourceType: 'ResourceType', location: `${baseUrl}/ResourceTypes/Group` }, + }, + ]; +} + +const attr = ( + name: string, + type: string, + extra: Record = {}, +) => ({ + name, + type, + multiValued: false, + required: false, + caseExact: false, + mutability: 'readWrite', + returned: 'default', + uniqueness: 'none', + ...extra, +}); + +export function schemas(baseUrl: string) { + return [ + { + id: SCIM_USER_SCHEMA, + name: 'User', + description: 'User Account', + attributes: [ + attr('userName', 'string', { required: true, uniqueness: 'server' }), + attr('externalId', 'string', { required: true, mutability: 'immutable', caseExact: true }), + attr('active', 'boolean'), + attr('displayName', 'string'), + { + ...attr('name', 'complex'), + subAttributes: [ + attr('formatted', 'string'), + attr('givenName', 'string'), + attr('familyName', 'string'), + ], + }, + { + ...attr('emails', 'complex', { multiValued: true }), + subAttributes: [ + attr('value', 'string'), + attr('type', 'string'), + attr('primary', 'boolean'), + ], + }, + { + ...attr('groups', 'complex', { multiValued: true, mutability: 'readOnly' }), + subAttributes: [attr('value', 'string'), attr('display', 'string')], + }, + ], + meta: { resourceType: 'Schema', location: `${baseUrl}/Schemas/${SCIM_USER_SCHEMA}` }, + }, + { + id: SCIM_GROUP_SCHEMA, + name: 'Group', + description: 'Group', + attributes: [ + attr('displayName', 'string', { required: true }), + attr('externalId', 'string', { mutability: 'immutable', caseExact: true }), + { + ...attr('members', 'complex', { multiValued: true }), + subAttributes: [attr('value', 'string'), attr('display', 'string')], + }, + ], + meta: { resourceType: 'Schema', location: `${baseUrl}/Schemas/${SCIM_GROUP_SCHEMA}` }, + }, + ]; +} diff --git a/packages/backend/src/identity-providers/sso.service.ts b/packages/backend/src/identity-providers/sso.service.ts index e554562c..879763c5 100644 --- a/packages/backend/src/identity-providers/sso.service.ts +++ b/packages/backend/src/identity-providers/sso.service.ts @@ -3,7 +3,7 @@ import { ConfigService } from '@nestjs/config'; import { randomBytes } from 'crypto'; import * as client from 'openid-client'; import { PrismaService } from '../common/prisma.service'; -import { RoleSyncService } from './role-sync.service'; +import { ROLE_SYNC_PROVIDER_SELECT, RoleSyncService } from './role-sync.service'; import { DeploymentService } from '../common/deployment.service'; import { AuthService } from '../auth/auth.service'; import { assertSafeOutboundUrl } from '../common/ssrf.util'; @@ -324,18 +324,13 @@ export class SsoService { include: { provider: { select: { - id: true, + ...ROLE_SYNC_PROVIDER_SELECT, type: true, issuer: true, clientId: true, - organizationId: true, jitProvisioning: true, jitDefaultRole: true, config: true, - roleSyncEnabled: true, - roleSyncSource: true, - roleSyncFallback: true, - roleSyncDefaultRoleIds: true, }, }, }, diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts index e4f6aef5..e62c623b 100644 --- a/packages/backend/src/main.ts +++ b/packages/backend/src/main.ts @@ -46,7 +46,11 @@ async function bootstrap() { expressApp.set('trust proxy', true); // Increase body size limit for large API spec imports (Postman, OpenAPI, etc.) - app.use(json({ limit: '10mb' })); + // `application/scim+json` is what Entra ID sends to the SCIM endpoint. + // body-parser's default `type` matches only application/json, so without + // this every SCIM POST/PATCH would arrive as an empty body and fail in ways + // that look nothing like a content-type problem. + app.use(json({ limit: '10mb', type: ['application/json', 'application/scim+json'] })); app.use(urlencoded({ extended: true, limit: '10mb' })); const configService = app.get(ConfigService); diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 27db23c0..157b7987 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@anythingmcp/frontend", - "version": "0.4.4", + "version": "0.5.0", "description": "AnythingMCP — Next.js Admin UI", "private": true, "license": "AGPL-3.0-only", diff --git a/packages/frontend/src/app/settings/identity-providers/page.tsx b/packages/frontend/src/app/settings/identity-providers/page.tsx index e0e648a9..4bffdc87 100644 --- a/packages/frontend/src/app/settings/identity-providers/page.tsx +++ b/packages/frontend/src/app/settings/identity-providers/page.tsx @@ -15,6 +15,7 @@ import { Badge } from '@/components/ui/badge'; import { useToast } from '@/components/toast'; import { RoleMappingsPanel } from './role-mappings'; import { RecoveryCodesCard } from './recovery-codes'; +import { ScimPanel } from './scim-panel'; /** * Per-type configuration fields. @@ -132,6 +133,7 @@ export default function IdentityProvidersPage() { const [testing, setTesting] = useState(null); const [copiedId, setCopiedId] = useState(null); const [mappingsFor, setMappingsFor] = useState(null); + const [scimFor, setScimFor] = useState(null); const [enforcing, setEnforcing] = useState(null); const [showForm, setShowForm] = useState(false); @@ -578,6 +580,7 @@ export default function IdentityProvidersPage() { {PROVIDER_TYPES[p.type]?.label ?? p.type} {!p.isActive && Inactive} {p.enforceSso && SSO required} + {p.scimEnabled && SCIM} {expiringSoon(p) && Secret expiring}

{p.issuer}

@@ -636,6 +639,15 @@ export default function IdentityProvidersPage() { > {mappingsFor === p.id ? 'Hide role mappings' : 'Role mappings'} + {p.type === 'ENTRA' && ( + + )}