/** * TASK-0132 — SCIM Users service (the IdP-facing provisioning surface). * * SCIM User ↔ `User` / `OrganizationMember` / `WorkspaceMember` mapping: * - `userName` → email (`@`-less → `@`; * `emails[0].value` wins when present) * - create → User (no password unless pushed, then bcrypt; email verified) * + OrganizationMember (role from `defaultRole`, CAPPED at MEMBER) * + auto-join the org's non-personal workspaces (the SSO JIT policy) * - `replace active:false` / DELETE → `User.status SUSPENDED` (NEVER * hard-delete — the SCIM "deprovision" maps to disable; memberships * are kept). Login + in-flight sessions are blocked by the Phase-1 * status check + the TASK-0132 guard fix. * * The actor is the ORG (actorType INTEGRATION, actorId = orgId) — the * bearer token acts as the org. Every audit row carries the operation * summary; the pushed password is NEVER audited or logged. */ import { Inject, Injectable } from "@nestjs/common"; import bcrypt from "bcryptjs"; import type { Request } from "express"; import { PrismaService } from "../../common/prisma.service.js"; import type { User } from "@prisma/client"; import { computeEtag } from "./etag.js"; import { ScimError } from "./scim-errors.js"; /** The org the SCIM bearer token resolved to (set by `ScimBearerGuard`). * The services need only the id + the stored config. */ export interface ScimOrgRef { id: string; scimConfig: unknown; } import { filterToWhere, parseScimFilter } from "./filters.js"; import { SCIM_USER_SCHEMA, ScimPatch, ScimUserCreate, type ScimOp } from "./schemas.js"; const BCRYPT_COST = 12; const COUNT_CAP = 100; const COUNT_DEFAULT = 50; type ScimConfig = { bearerTokenHash?: string; tokenHint?: string; userEmailDomain?: string; defaultRole?: "GUEST" | "MEMBER"; autoJoinOrgWorkspaces?: boolean; enabledAt?: string; }; const USER_SELECT = { id: true, email: true, name: true, displayName: true, status: true, avatarUrl: true, createdAt: true, updatedAt: true } as const; type ScimUserView = Pick; /** The SCIM User representation (what goes out the wire). The `password` * is deliberately absent — it is never returned. `meta.version` is a * placeholder the caller replaces with the computed ETag (the ETag needs * the FINAL representation, so it can't be inlined here). */ export function userToScim(user: ScimUserView): Record { const dn = (user.displayName ?? user.name).trim(); const parts = dn ? dn.split(/\s+/) : []; return { schemas: [SCIM_USER_SCHEMA], id: user.id, userName: user.email, name: dn ? { formatted: dn, givenName: parts[0], familyName: parts.length > 1 ? parts.slice(1).join(" ") : undefined } : undefined, displayName: user.displayName ?? user.name, active: user.status !== "SUSPENDED", emails: [{ value: user.email, type: "work", primary: true }], meta: { resourceType: "User", version: `W/"scim-0"` } }; } @Injectable() export class ScimUsersService { constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} private cfg(org: ScimOrgRef): ScimConfig { return (org.scimConfig as ScimConfig | null) ?? {}; } /** `@`-less userName → `@`; else verbatim. * `emails[0].value` (lowercased) always wins when present. */ toEmail(body: ScimUserCreate, org: ScimOrgRef): string { const first = body.emails?.[0]?.value; const raw = first ?? body.userName; if (raw.includes("@")) return raw.trim().toLowerCase(); const domain = this.cfg(org).userEmailDomain; if (!domain) { throw ScimError.invalidValue( `userName "${body.userName}" has no "@" and the org has no scimConfig.userEmailDomain to map it.` ); } return `${body.userName.trim().toLowerCase()}@${domain.toLowerCase()}`; } private audit( req: Request, org: ScimOrgRef, action: string, resourceType: string, resourceId: string, metadata: Record ): void { void this.prisma.auditLog .create({ data: { actorType: "INTEGRATION", actorId: org.id, workspaceId: null, resourceType, resourceId, action, ipAddress: req.ip ?? null, userAgent: (req.headers["user-agent"] as string | undefined) ?? null, metadata: { orgId: org.id, ...metadata } as object } }) .catch(() => { /* audit must never break provisioning */ }); } async create(req: Request, org: ScimOrgRef, body: ScimUserCreate) { const email = this.toEmail(body, org); // Duplicate → 409 (existence is OK to reveal to the IdP — it scoped its // own org with the token). const existing = await this.prisma.user.findUnique({ where: { email }, select: { id: true } }); if (existing) { throw ScimError.duplicate(`A User with userName '${email}' already exists.`); } const displayName = body.displayName ?? (body.name ? [body.name.givenName, body.name.familyName].filter(Boolean).join(" ").trim() || undefined : undefined); const name = displayName?.trim() || email.split("@")[0]; const passwordHash = body.password ? await bcrypt.hash(body.password, BCRYPT_COST) : null; const status = body.active === false ? "SUSPENDED" : "ACTIVE"; const user = await this.prisma.user.create({ data: { email, name, displayName: displayName?.trim() ?? null, passwordHash, emailVerifiedAt: new Date(), emailVerification: "VERIFIED", status: status as never, // SCIM-provisioned users are org members — set the org on creation // so the auto-join + membership are one coherent unit. }, select: USER_SELECT }); // Org membership (role CAPPED at MEMBER). const role = this.cfg(org).defaultRole === "GUEST" ? "GUEST" : "MEMBER"; await this.prisma.organizationMember.create({ data: { organizationId: org.id, userId: user.id, role: role as never } }); // Auto-join the org's non-personal workspaces (the SSO JIT policy). if (this.cfg(org).autoJoinOrgWorkspaces !== false) { const orgWorkspaces = await this.prisma.workspace.findMany({ where: { organizationId: org.id, isPersonal: false, deletedAt: null }, select: { id: true } }); if (orgWorkspaces.length) { await this.prisma.workspaceMember.createMany({ data: orgWorkspaces.map((w) => ({ workspaceId: w.id, userId: user.id, role: "MEMBER" as never, joinedAt: new Date() })), skipDuplicates: true }); } } const rep = userToScim(user); const etag = computeEtag(rep, user.updatedAt); rep.meta = { resourceType: "User", created: user.createdAt.toISOString(), location: `/scim/v2/Users/${user.id}`, version: etag }; this.audit(req, org, "scim.user.created", "user", user.id, { userId: user.id, email, source: "scim", status, role, hadPassword: Boolean(body.password) // boolean only — NEVER the password }); return { rep, etag, location: `/scim/v2/Users/${user.id}` }; } private async scopedUser(org: ScimOrgRef, id: string) { const user = await this.prisma.user.findUnique({ where: { id }, select: { id: true, email: true } }); if (!user) throw ScimError.notFound("User not found."); const member = await this.prisma.organizationMember.findUnique({ where: { organizationId_userId: { organizationId: org.id, userId: id } } }); // Cross-org (or not a member) → 404, not a leak (existence-hiding). if (!member) throw ScimError.notFound("User not found."); return this.prisma.user.findUniqueOrThrow({ where: { id }, select: USER_SELECT }); } async get(org: ScimOrgRef, id: string) { const user = await this.scopedUser(org, id); const rep = userToScim(user); const etag = computeEtag(rep, user.updatedAt); rep.meta = { resourceType: "User", created: user.createdAt.toISOString(), location: `/scim/v2/Users/${user.id}`, version: etag }; return { rep, etag, location: `/scim/v2/Users/${user.id}` }; } /** List with `?filter` + `startIndex`/`count`. Returns the SCIM * ListResponse fields + the ETag of the last item (for If-Match). */ async list(org: ScimOrgRef, query: { filter?: string; startIndex?: string; count?: string }) { // Build the Prisma where from the SCIM filter (only org members). const memberIds = await this.prisma.organizationMember.findMany({ where: { organizationId: org.id }, select: { userId: true } }); if (memberIds.length === 0) { return { totalResults: 0, startIndex: 1, itemsPerPage: 0, resources: [], etag: null as string | null }; } const base = { id: { in: memberIds.map((m) => m.userId) } } as Record; let where = base; if (query.filter !== undefined) { if (!query.filter.trim()) throw ScimError.invalidFilter("Empty filter."); where = { AND: [base, filterToWhere(parseScimFilter(query.filter))] }; } const count = Math.min(Math.max(parseInt(query.count ?? String(COUNT_DEFAULT), 10) || COUNT_DEFAULT, 1), COUNT_CAP); const startIndex = Math.max(parseInt(query.startIndex ?? "1", 10) || 1, 1); const skip = startIndex - 1; const [total, users] = await this.prisma.$transaction([ this.prisma.user.count({ where }), this.prisma.user.findMany({ where, select: USER_SELECT, orderBy: { createdAt: "asc" }, skip, take: count }) ]); const resources = users.map((u) => { const rep = userToScim(u); const et = computeEtag(rep, u.updatedAt); rep.meta = { resourceType: "User", created: u.createdAt.toISOString(), location: `/scim/v2/Users/${u.id}`, version: et }; return rep; }); return { totalResults: total, startIndex, itemsPerPage: count, resources, etag: resources.length ? computeEtag(resources[resources.length - 1], users[users.length - 1].updatedAt) : null }; } /** Apply SCIM patch operations. `replace active:false` = deactivate * (SUSPENDED). ETag precondition is checked by the controller. */ async patch(req: Request, org: ScimOrgRef, id: string, body: ScimPatch) { const user = await this.scopedUser(org, id); const ops: ScimOp[] = body.Operations ?? body.operations!; const update: Record = {}; const changes: string[] = []; for (const op of ops) { const { op: verb, path, value } = op; if (path === "active" || (path === undefined && verb === "replace" && typeof value === "boolean")) { const active = typeof value === "boolean" ? value : value === "false"; update.status = active ? "ACTIVE" : "SUSPENDED"; changes.push(`active=${active}`); } else if (path === "name" && value && typeof value === "object" && !Array.isArray(value)) { const n = value as { givenName?: string; familyName?: string; formatted?: string }; const full = n.formatted ?? [n.givenName, n.familyName].filter(Boolean).join(" ").trim(); if (full) { update.displayName = full.slice(0, 256); update.name = full.split(/\s+/)[0] || user.name; changes.push("name"); } } else if (path === "displayName" && typeof value === "string") { update.displayName = value.trim().slice(0, 256); changes.push("displayName"); } else if (path === "emails" && Array.isArray(value) && value.length) { const email = (value[0] as { value?: string }).value; if (email) { update.email = email.trim().toLowerCase(); changes.push("emails"); } } else if (path === "userName" && typeof value === "string") { const email = value.includes("@") ? value : this.toEmail({ userName: value } as ScimUserCreate, org); update.email = email.trim().toLowerCase(); changes.push("userName"); } else { throw ScimError.invalidValue(`Unsupported patch path "${path ?? "(body)"}" for op "${verb}".`); } } if (changes.length) { // email is unique — guard against clobbering another user. if (typeof update.email === "string") { const clash = await this.prisma.user.findUnique({ where: { email: update.email as string }, select: { id: true } }); if (clash && clash.id !== user.id) { throw ScimError.duplicate(`A User with email '${update.email}' already exists.`); } } await this.prisma.user.update({ where: { id: user.id }, data: update as never }); } const fresh = await this.prisma.user.findUniqueOrThrow({ where: { id: user.id }, select: USER_SELECT }); const rep = userToScim(fresh); const etag = computeEtag(rep, fresh.updatedAt); rep.meta = { resourceType: "User", created: fresh.createdAt.toISOString(), location: `/scim/v2/Users/${fresh.id}`, version: etag }; const deactivated = fresh.status === "SUSPENDED" && user.status !== "SUSPENDED"; this.audit(req, org, deactivated ? "scim.user.deactivated" : "scim.user.updated", "user", user.id, { userId: user.id, changes, ...(deactivated ? { via: "patch" } : {}) }); return { rep, etag, location: `/scim/v2/Users/${user.id}` }; } /** DELETE = deactivate (SUSPENDED), never hard-delete (SCIM deprovision). */ async delete(req: Request, org: ScimOrgRef, id: string) { const user = await this.scopedUser(org, id); if (user.status !== "SUSPENDED") { await this.prisma.user.update({ where: { id: user.id }, data: { status: "SUSPENDED" } }); this.audit(req, org, "scim.user.deactivated", "user", user.id, { userId: user.id, via: "delete" }); } return { rep: this.emptyRepr(user), etag: computeEtag(this.emptyRepr(user), user.updatedAt) }; } private emptyRepr(user: { id: string }): Record { return { schemas: [SCIM_USER_SCHEMA], id: user.id, active: false, meta: { resourceType: "User", note: "DELETED=deactivated; the User row is retained (status SUSPENDED)." } }; } }