Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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);
24 changes: 23 additions & 1 deletion packages/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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 }.
Expand Down Expand Up @@ -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")

Expand Down
11 changes: 11 additions & 0 deletions packages/backend/src/audit/security-event.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown> = { 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<boolean> {
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');
}
Loading
Loading