From dfbada8e5ec4e67c1e72522b094ccd52cf4a4360 Mon Sep 17 00:00:00 2001 From: Matteo Date: Wed, 9 Sep 2026 10:58:20 +0200 Subject: [PATCH 01/10] feat(scim): SCIM 2.0 user provisioning from Entra ID (self-hosted only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Role sync ran only at SSO sign-in, so a user disabled or deleted in Entra kept every MCP API key they held, indefinitely. This adds the push channel the directory needs: a SCIM 2.0 endpoint at /api/scim/v2 that Entra's outbound provisioning creates, updates and — the part that matters — deactivates users through, with no sign-in involved. Groups follow in the next change. `active: false` calls the same UserLifecycleService primitive as the admin's Deactivate, so the directory and the admin can never disagree about what "deactivated" means. DELETE is deprovisioning, not erasure: Entra sends it when a user is purged or when soft-delete is off, and the outcome wanted is "no access", which deactivation already guarantees. Hard-deleting would dissolve the audit trail at exactly the moment an investigation would want it. Three decisions worth knowing: - Identities key on `externalId` (the Entra object id), never on email — the same value the OIDC `oid` claim carries, so a SCIM-created identity and a later SSO sign-in converge on one row. An address already owned by an unlinked local account is refused with 409: binding a tenant's object id to it would let whoever controls the tenant sign in as that person everywhere. - The bearer token is stored as a sha256 digest with a unique index — the schema's first sha256 credential. bcrypt exists to stretch low-entropy passwords; a 256-bit random token gains nothing from it, while an Entra initial cycle sends hundreds of requests in minutes and each must be one indexed lookup. Plaintext (the MCP-key precedent) would turn a database dump into a credential that can deactivate every user. - The controller declares no DTOs. The global ValidationPipe runs with `forbidNonWhitelisted`, and Entra's payloads carry `schemas`, `meta`, the enterprise extension URN and whatever else an admin mapped; a class-based DTO would 400 real traffic on the first unexpected key. A tolerant parser reads the fields we honour and ignores the rest. Entra quirks handled explicitly: `Content-Type: application/scim+json` (the body parser accepted only application/json, so every SCIM POST would have arrived empty); capitalised `Add`/`Replace`/`Remove`; `active` as the string "False"; path-less replace with an object value; `emails[type eq "work"].value`; Test Connection as a filtered GET expecting an empty ListResponse; 409 `uniqueness` on duplicates so Entra falls back to GET-then-PATCH instead of quarantining; initial-cycle bursts above the global 100/min throttle. A brand-new member holding no MCP role is UNRESTRICTED, so the role sync runs with nothing presented right after creation and the provider's fallback (DENY_ALL by default) applies from the first request, not from the first login. Verified end to end on the local stack, with Entra-shaped requests: create → identity marked SCIM-managed, VIEWER membership, "No access (SSO)" grant; duplicate → 409; unlinked local email → 409; PATCH active "False" → MCP key refused, sessions revoked, membership deactivated, all audited; GET still returns the user with active:false; path-less replace reactivates and renames while the old key stays revoked; DELETE → 204 with the row kept; rotate → old token 401, new token 200; disable → 401. Through the UI: enable shows the tenant URL and the token once, then status only. --- .../migration.sql | 14 + packages/backend/prisma/schema.prisma | 24 +- .../src/audit/security-event.service.ts | 11 + .../identity-providers.controller.spec.ts | 36 ++ .../identity-providers.controller.ts | 59 +++ .../identity-providers.module.ts | 10 +- .../identity-providers.service.ts | 98 +++- .../scim/scim-auth.guard.spec.ts | 100 ++++ .../scim/scim-auth.guard.ts | 113 +++++ .../scim/scim-users.service.spec.ts | 271 +++++++++++ .../scim/scim-users.service.ts | 451 ++++++++++++++++++ .../scim/scim.controller.ts | 158 ++++++ .../scim/scim.errors.spec.ts | 64 +++ .../identity-providers/scim/scim.errors.ts | 103 ++++ .../scim/scim.parser.spec.ts | 137 ++++++ .../identity-providers/scim/scim.parser.ts | 173 +++++++ .../identity-providers/scim/scim.schemas.ts | 133 ++++++ packages/backend/src/main.ts | 6 +- .../app/settings/identity-providers/page.tsx | 15 + .../identity-providers/scim-panel.tsx | 233 +++++++++ packages/frontend/src/lib/api.ts | 32 ++ 21 files changed, 2236 insertions(+), 5 deletions(-) create mode 100644 packages/backend/prisma/migrations/20260909090000_add_scim_provisioning/migration.sql create mode 100644 packages/backend/src/identity-providers/scim/scim-auth.guard.spec.ts create mode 100644 packages/backend/src/identity-providers/scim/scim-auth.guard.ts create mode 100644 packages/backend/src/identity-providers/scim/scim-users.service.spec.ts create mode 100644 packages/backend/src/identity-providers/scim/scim-users.service.ts create mode 100644 packages/backend/src/identity-providers/scim/scim.controller.ts create mode 100644 packages/backend/src/identity-providers/scim/scim.errors.spec.ts create mode 100644 packages/backend/src/identity-providers/scim/scim.errors.ts create mode 100644 packages/backend/src/identity-providers/scim/scim.parser.spec.ts create mode 100644 packages/backend/src/identity-providers/scim/scim.parser.ts create mode 100644 packages/backend/src/identity-providers/scim/scim.schemas.ts create mode 100644 packages/frontend/src/app/settings/identity-providers/scim-panel.tsx 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/schema.prisma b/packages/backend/prisma/schema.prisma index a61acfd7..caee69c3 100644 --- a/packages/backend/prisma/schema.prisma +++ b/packages/backend/prisma/schema.prisma @@ -782,6 +782,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 }. @@ -840,8 +854,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..eafaeda1 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,6 +29,14 @@ 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', // ── Authorization plane: what they were allowed to do ─────────────────── ROLE_CHANGED: 'ROLE_CHANGED', 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..075a07f4 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'; @@ -96,4 +98,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..5c6e7a25 100644 --- a/packages/backend/src/identity-providers/identity-providers.controller.ts +++ b/packages/backend/src/identity-providers/identity-providers.controller.ts @@ -174,6 +174,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() @@ -376,6 +382,59 @@ export class IdentityProvidersController { return updated; } + @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), + }); + 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..690edb84 100644 --- a/packages/backend/src/identity-providers/identity-providers.module.ts +++ b/packages/backend/src/identity-providers/identity-providers.module.ts @@ -4,13 +4,19 @@ 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 { 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], 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..73a6986a 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, @@ -594,4 +597,97 @@ 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, unlinkedMemberCount] = await Promise.all([ + this.prisma.userIdentity.count({ where: { providerId: id, scimManagedAt: { not: null } } }), + // 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, + 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 }, + }); + return r.count > 0; + } +} + +/** `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/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-users.service.spec.ts b/packages/backend/src/identity-providers/scim/scim-users.service.spec.ts new file mode 100644 index 00000000..f82e8538 --- /dev/null +++ b/packages/backend/src/identity-providers/scim/scim-users.service.spec.ts @@ -0,0 +1,271 @@ +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 = { syncOnLogin: 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.syncOnLogin).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.syncOnLogin).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']); + }); + + it('refuses an externalId change', async () => { + prisma.userIdentity.findUnique.mockResolvedValue(identity()); + await expect(service.patch(provider, 'u1', patch([{ op: 'replace', path: 'externalId', value: 'other' }]), ctx)).rejects.toMatchObject({ status: 400 }); + }); + + 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..52d4214e --- /dev/null +++ b/packages/backend/src/identity-providers/scim/scim-users.service.ts @@ -0,0 +1,451 @@ +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. Entra maps objectId → externalId by default; without + // it there is nothing immutable to anchor the account to. + 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. + // Run the sync with nothing presented so the provider's fallback (DENY_ALL + // by default) applies from the first request, not from the first login. + await this.roleSync.syncOnLogin(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 }; + + if (changes.externalId !== undefined && changes.externalId !== row.externalSubject) { + throw new ScimError(400, 'externalId is immutable', 'mutability'); + } + + // 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.syncOnLogin(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..3d927e2a --- /dev/null +++ b/packages/backend/src/identity-providers/scim/scim.controller.ts @@ -0,0 +1,158 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Patch, + Post, + Put, + Query, + Req, + Res, + UseFilters, + UseGuards, +} from '@nestjs/common'; +import { ApiExcludeController } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import { ConfigService } from '@nestjs/config'; +import { Request, Response } 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 { parseFilter, parsePagination } from './scim.parser'; +import { resourceTypes, schemas, serviceProviderConfig } from './scim.schemas'; +import { ScimCtx, ScimUsersService } from './scim-users.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. + */ +@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 config: ConfigService, + ) {} + + // ── Discovery ───────────────────────────────────────────────────────────── + + @Get('ServiceProviderConfig') + serviceProviderConfig(@Req() req: Request, @Res() res: Response) { + return this.send(res, serviceProviderConfig(this.baseUrl(req))); + } + + @Get('ResourceTypes') + resourceTypes(@Req() req: Request, @Res() res: Response) { + return this.send(res, this.list(resourceTypes(this.baseUrl(req)))); + } + + @Get('ResourceTypes/:name') + resourceType(@Req() req: Request, @Res() res: Response, @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 this.send(res, rt); + } + + @Get('Schemas') + schemas(@Req() req: Request, @Res() res: Response) { + return this.send(res, this.list(schemas(this.baseUrl(req)))); + } + + @Get('Schemas/:uri') + schema(@Req() req: Request, @Res() res: Response, @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 this.send(res, s); + } + + // ── Users ───────────────────────────────────────────────────────────────── + + @Get('Users') + async listUsers(@Req() req: Request, @Res() res: Response, @Query() q: Record) { + const ctx = this.ctx(req); + return this.send(res, await this.users.list(this.provider(req), parseFilter(q.filter), parsePagination(q), ctx)); + } + + @Get('Users/:id') + async getUser(@Req() req: Request, @Res() res: Response, @Param('id') id: string) { + return this.send(res, await this.users.get(this.provider(req), id, this.ctx(req))); + } + + @Post('Users') + @HttpCode(HttpStatus.CREATED) + async createUser(@Req() req: Request, @Res() res: Response, @Body() body: unknown) { + return this.send(res, await this.users.create(this.provider(req), body, this.ctx(req)), HttpStatus.CREATED); + } + + @Put('Users/:id') + async replaceUser(@Req() req: Request, @Res() res: Response, @Param('id') id: string, @Body() body: unknown) { + return this.send(res, await this.users.replace(this.provider(req), id, body, this.ctx(req))); + } + + @Patch('Users/:id') + async patchUser(@Req() req: Request, @Res() res: Response, @Param('id') id: string, @Body() body: unknown) { + return this.send(res, await this.users.patch(this.provider(req), id, body, this.ctx(req))); + } + + @Delete('Users/:id') + @HttpCode(HttpStatus.NO_CONTENT) + async deleteUser(@Req() req: Request, @Res() res: Response, @Param('id') id: string) { + await this.users.remove(this.provider(req), id, this.ctx(req)); + res.status(HttpStatus.NO_CONTENT).end(); + } + + // ── 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, + }; + } + + private send(res: Response, body: unknown, status = HttpStatus.OK) { + res.setHeader('Content-Type', SCIM_CONTENT_TYPE); + return res.status(status).send(JSON.stringify(body)); + } +} 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..dfa868d9 --- /dev/null +++ b/packages/backend/src/identity-providers/scim/scim.parser.ts @@ -0,0 +1,173 @@ +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 m = raw.trim().match(/^([A-Za-z.\[\]" =]+?)\s+eq\s+"((?:[^"\\]|\\.)*)"$/i); + if (!m) throw new ScimError(400, `Unsupported filter: ${raw}`, 'invalidFilter'); + const attr = FILTER_ATTRS[m[1].trim().toLowerCase()]; + if (!attr) throw new ScimError(400, `Unsupported filter attribute: ${m[1]}`, 'invalidFilter'); + return { attr, value: m[2].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/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/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' && ( + + )}