/** * TASK-0132 — SCIM admin service (org-owner gated now; TASK-0133 swaps to * the shared `requireOrgAdmin` guard — the route surface is stable). * * GET /api/v1/orgs/:orgId/scim — config (masked: no raw token) * POST /api/v1/orgs/:orgId/scim — enable (generates scim_… token, * reveal-once) * PATCH /api/v1/orgs/:orgId/scim — update / rotate / disable * POST /api/v1/orgs/:orgId/scim/test — test-connection (browser pastes * the token back — the server * never stores the raw) * * The raw token is shown ONCE (the enable/rotate response) — only the * SHA-256 hash is stored (`Organization.scimConfig.bearerTokenHash`) and * the Redis hash index (`scim:index`) is what the protocol guard consults. * Disable = immediate invalidation (the index entry is removed AND the * config cleared — the guard re-checks the DB row, so a disabled org is * dead even if Redis still had the hash). */ import { Inject, Injectable } from "@nestjs/common"; import type { Request } from "express"; import { Prisma } from "@prisma/client"; import { randomToken, sha256hex } from "../../lib/crypto.js"; import { ApiError } from "../../common/errors.js"; import { PrismaService } from "../../common/prisma.service.js"; import { requireUser } from "../../common/types.js"; import { loadEnv } from "../../config/env.js"; import { ScimAuthService } from "./scim-auth.service.js"; import { ScimConfigSchema, type ScimAdminUpdate, type ScimEnable } from "./schemas.js"; import { assertOrgAdmin } from "../org/guards.js"; export interface ScimPublicConfig { enabled: boolean; tokenHint?: string; userEmailDomain?: string | null; defaultRole?: "GUEST" | "MEMBER"; autoJoinOrgWorkspaces?: boolean; enabledAt?: string; endpointUrl?: string; curlExample?: string; } @Injectable() export class ScimAdminService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(ScimAuthService) private readonly auth: ScimAuthService ) {} /** The protocol base the IdP configures (SSO_ORIGIN || WEB_ORIGIN — the * origin that SERVES `/scim/v2`, i.e. the API). */ private base(): string { const env = loadEnv(); return (env.SSO_ORIGIN || env.WEB_ORIGIN).replace(/\/$/, ""); } /** Org-ADMIN gate (TASK-0133): interim owner check → shared * `requireOrgAdmin` (`403 {reason:"requires_org_admin"}`). Name kept. */ private async requireOrgOwner(orgId: string, userId: string) { await assertOrgAdmin(this.prisma, orgId, userId); const org = await this.prisma.organization.findUnique({ where: { id: orgId } }); if (!org) throw ApiError.notFound("Organization not found."); return org; } private userId(req: Request): string { const auth = (req as Request & { auth?: { userId?: string } }).auth; if (!auth?.userId) throw ApiError.unauthorized(); return auth.userId; } private masked(org: { scimConfig: unknown }): ScimPublicConfig { const cfg = (org.scimConfig as Record | null) ?? null; if (!cfg) return { enabled: false }; const parsed = ScimConfigSchema.parse(cfg); const endpointUrl = `${this.base()}/scim/v2`; return { enabled: true, tokenHint: parsed.tokenHint, userEmailDomain: parsed.userEmailDomain ?? null, defaultRole: parsed.defaultRole, autoJoinOrgWorkspaces: parsed.autoJoinOrgWorkspaces, enabledAt: parsed.enabledAt, endpointUrl, curlExample: `curl ${endpointUrl}/Users -H "Authorization: Bearer scim_…"` }; } async get(orgId: string, req: Request): Promise { const org = await this.requireOrgOwner(orgId, requireUser(req)); return this.masked(org); } /** Enable SCIM: mint `scim_`, store only the hash, index it in * Redis, return the raw token ONCE. */ async enable(orgId: string, input: ScimEnable, req: Request): Promise { const org = await this.requireOrgOwner(orgId, requireUser(req)); if (org.scimConfig != null) { throw ApiError.conflict("SCIM is already enabled for this org; rotate or disable first.", [ { reason: "already_enabled" } ]); } const rawToken = `scim_${randomToken(32)}`; const hash = sha256hex(rawToken); const config = ScimConfigSchema.parse({ bearerTokenHash: hash, tokenHint: rawToken.slice(-4), userEmailDomain: input.userEmailDomain, defaultRole: input.defaultRole, autoJoinOrgWorkspaces: input.autoJoinOrgWorkspaces, enabledAt: new Date().toISOString() }); await this.prisma.organization.update({ where: { id: orgId }, data: { scimConfig: config as object } }); await this.auth.upsertIndex(hash, orgId); await this.audit(req, orgId, "scim.token.created", { hint: config.tokenHint, domain: input.userEmailDomain ?? null }); return { ...(await this.get(orgId, req)), token: rawToken }; } async update(orgId: string, input: ScimAdminUpdate, req: Request): Promise { const org = await this.requireOrgOwner(orgId, requireUser(req)); const current = (org.scimConfig as Record | null) ?? null; const result: { token?: string } = {}; if (input.disable === true) { if (!current) throw ApiError.notFound("SCIM is not enabled for this org."); const oldHash = current.bearerTokenHash as string; await this.prisma.organization.update({ where: { id: orgId }, data: { scimConfig: Prisma.JsonNull as object } }); await this.auth.removeIndex(oldHash, orgId); await this.audit(req, orgId, "scim.token.disabled", { hint: (current.tokenHint as string) ?? null }); return { enabled: false }; } if (!current) throw ApiError.notFound("SCIM is not enabled for this org; enable it first."); if (input.rotate === true) { const rawToken = `scim_${randomToken(32)}`; const hash = sha256hex(rawToken); await this.prisma.organization.update({ where: { id: orgId }, data: { scimConfig: { ...current, bearerTokenHash: hash, tokenHint: rawToken.slice(-4), enabledAt: new Date().toISOString() } as object } }); // upsertIndex clears the org's OLD hash entry first (rotation). await this.auth.upsertIndex(hash, orgId); result.token = rawToken; await this.audit(req, orgId, "scim.token.created", { hint: rawToken.slice(-4), rotated: true }); } else { const next = { ...current, userEmailDomain: input.userEmailDomain === undefined ? current.userEmailDomain : input.userEmailDomain, defaultRole: input.defaultRole ?? current.defaultRole, autoJoinOrgWorkspaces: input.autoJoinOrgWorkspaces ?? current.autoJoinOrgWorkspaces }; await this.prisma.organization.update({ where: { id: orgId }, data: { scimConfig: next as object } }); await this.audit(req, orgId, "scim.config.updated", { changed: Object.keys(input).filter((k) => k !== "rotate") }); } return { ...(await this.get(orgId, req)), ...result }; } /** Test-connection: the browser pastes the raw token back (the server * never stores it). 200 = the token resolves to THIS org; 401 otherwise * (the UI shows a red panel). */ async test(orgId: string, token: string, req: Request): Promise<{ ok: boolean; detail: string }> { await this.requireOrgOwner(orgId, requireUser(req)); const resolved = await this.auth.resolveOrg(token); if (resolved?.orgId === orgId) { return { ok: true, detail: "Token valid — resolves to this org." }; } return { ok: false, detail: resolved ? "Token valid, but for a DIFFERENT org." : "Unknown or disabled token." }; } private audit(req: Request, orgId: string, action: string, metadata: Record): void { void this.prisma.auditLog .create({ data: { actorType: "USER", actorId: requireUser(req), resourceType: "org", resourceId: orgId, action, ipAddress: req.ip ?? null, userAgent: (req.headers["user-agent"] as string | undefined) ?? null, metadata: { orgId, ...metadata } as object } }) .catch(() => { /* audit must never break admin ops */ }); } }