/** * TASK-0132 — SCIM Groups service (Group ↔ Team mapping). * * A SCIM Group is a `Team` in the org's PRIMARY workspace (the first * non-personal, non-deleted org workspace — deterministic: oldest by * createdAt). `displayName` = team name (unique per workspace via slug; * a name collision is resolved with a numeric suffix so the IdP's create * never 409s on rename-lag). Members: `value` is our SCIM user id (the * User id) or an email — resolved to a User (400 invalidValue if unknown * / not a member). `remove` operations drop the `TeamMember`. * * DELETE group → delete the team (members KEEP their workspace * membership — only the TeamMember rows go, via the Team cascade). * Audited `scim.group.created/updated/deleted`, actorType INTEGRATION. */ import { Inject, Injectable } from "@nestjs/common"; import type { Request } from "express"; import type { ScimOrgRef } from "./users.service.js"; import { between } from "../../lib/positions.js"; import { PrismaService } from "../../common/prisma.service.js"; import { computeEtag } from "./etag.js"; import { ScimError } from "./scim-errors.js"; import { SCIM_GROUP_SCHEMA, ScimGroupCreate, type ScimOp } from "./schemas.js"; const GROUP_SELECT = { id: true, name: true, slug: true, description: true, workspaceId: true, createdAt: true, updatedAt: true } as const; @Injectable() export class ScimGroupsService { constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} /** The org's primary workspace (first non-personal, non-deleted, * oldest). 404 if the org has no workspace (nothing to map into). */ private async primaryWorkspace(org: ScimOrgRef): Promise<{ id: string; slug: string }> { const ws = await this.prisma.workspace.findFirst({ where: { organizationId: org.id, isPersonal: false, deletedAt: null }, orderBy: { createdAt: "asc" }, select: { id: true, slug: true } }); if (!ws) { throw ScimError.invalidValue( "The org has no (non-personal) workspace to host SCIM groups; create a workspace first." ); } return ws; } private async scopedTeam(org: ScimOrgRef, id: string) { const team = await this.prisma.team.findUnique({ where: { id: id }, select: GROUP_SELECT }); if (!team) throw ScimError.notFound("Group not found."); const ws = await this.prisma.workspace.findUnique({ where: { id: team.workspaceId }, select: { id: true, organizationId: true } }); // Cross-org → 404 (existence-hiding). if (!ws || ws.organizationId !== org.id) throw ScimError.notFound("Group not found."); return team; } /** Resolve a member `value` (User id or email) to a User id, or null * when unknown / not an org member (dropped with a warning in audit). */ private async resolveMember(org: ScimOrgRef, value: string): Promise { if (value.includes("@")) { const byEmail = await this.prisma.user.findUnique({ where: { email: value.trim().toLowerCase() }, select: { id: true } }); if (!byEmail) return null; value = byEmail.id; } const member = await this.prisma.organizationMember.findUnique({ where: { organizationId_userId: { organizationId: org.id, userId: value } } }); return member ? value : null; } private groupRepr(team: { id: string; name: string; description: string | null; workspaceId: string; createdAt: Date; updatedAt: Date; memberCount?: number; }): Record { return { schemas: [SCIM_GROUP_SCHEMA], id: team.id, displayName: team.name, description: team.description ?? undefined, meta: { resourceType: "Group", created: team.createdAt.toISOString(), location: `/scim/v2/Groups/${team.id}`, version: `W/"scim-0"` } }; } private audit( req: Request, org: ScimOrgRef, action: string, resourceId: string, metadata: Record ): void { void this.prisma.auditLog .create({ data: { actorType: "INTEGRATION", actorId: org.id, workspaceId: null, resourceType: "group", 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: ScimGroupCreate) { const ws = await this.primaryWorkspace(org); // Unique (name, slug) per workspace — a name collision (e.g. an IdP // rename lag) gets a numeric suffix so the create succeeds. let name = body.displayName.trim().slice(0, 128); let slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "group"; for (let i = 2; ; i += 1) { const clash = await this.prisma.team.findUnique({ where: { workspaceId_slug: { workspaceId: ws.id, slug } } }); if (!clash) break; const suffix = `-${i}`; name = `${body.displayName.slice(0, 128 - suffix.length).trim()}${suffix}`; slug = `${slug.slice(0, Math.max(1, 40 - suffix.length))}${suffix}`; if (i > 50) throw ScimError.duplicate(`Too many groups named '${body.displayName}'.`); } const last = await this.prisma.team.findFirst({ where: { workspaceId: ws.id }, orderBy: { position: "desc" }, select: { position: true } }); const team = await this.prisma.team.create({ data: { workspaceId: ws.id, name, slug, description: body.description ?? null, position: between(last?.position) }, select: GROUP_SELECT }); const { added, skipped } = await this.syncMembers(org, team.id, body.members ?? [], []); const teamFull = await this.prisma.team.findUnique({ where: { id: team.id }, select: GROUP_SELECT }); const rep = this.groupRepr(teamFull!); const etag = computeEtag(rep, teamFull!.updatedAt); (rep.meta as { version: string }).version = etag; this.audit(req, org, "scim.group.created", team.id, { teamId: team.id, workspaceId: ws.id, displayName: name, membersAdded: added.length, membersUnresolved: skipped }); return { rep, etag, location: `/scim/v2/Groups/${team.id}`, added, skipped }; } async get(org: ScimOrgRef, id: string) { const team = await this.scopedTeam(org, id); const memberCount = await this.prisma.teamMember.count({ where: { teamId: id } }); const rep = this.groupRepr({ ...team, memberCount }); const etag = computeEtag(rep, team.updatedAt); (rep.meta as { version: string }).version = etag; return { rep, etag, location: `/scim/v2/Groups/${id}`, memberCount }; } async list(org: ScimOrgRef, query: { filter?: string; startIndex?: string; count?: string }) { const teams = await this.prisma.team.findMany({ where: { workspace: { organizationId: org.id } }, select: GROUP_SELECT, orderBy: { createdAt: "asc" }, skip: Math.max((parseInt(query.startIndex ?? "1", 10) || 1) - 1, 0), take: Math.min(Math.max(parseInt(query.count ?? "50", 10) || 50, 1), 100) }); const total = await this.prisma.team.count({ where: { workspace: { organizationId: org.id } } }); const resources = teams.map((t) => { const rep = this.groupRepr(t); (rep.meta as { version: string }).version = computeEtag(rep, t.updatedAt); return rep; }); return { totalResults: total, startIndex: Math.max((parseInt(query.startIndex ?? "1", 10) || 1), 1), itemsPerPage: resources.length, resources }; } async patch(req: Request, org: ScimOrgRef, id: string, ops: ScimOp[]) { await this.scopedTeam(org, id); // 404 / cross-org check const changes: string[] = []; let renameTo: string | null = null; const memberAdds: string[] = []; const memberRemoves: string[] = []; for (const op of ops) { const { op: verb, path, value } = op; if (path === "displayName" || path === undefined) { if (path === "displayName" && typeof value === "string") { renameTo = value.trim().slice(0, 128) || null; changes.push("displayName"); } else if ( path === undefined && typeof value === "object" && value !== null && !Array.isArray(value) && "displayName" in value ) { const dn = (value as { displayName?: string }).displayName; if (typeof dn === "string") { renameTo = dn.trim().slice(0, 128) || null; changes.push("displayName"); } } } else if (path === "members") { // `value` may be a single member object OR an array of them (RFC // 7644 allows both; IdPs differ). const items = Array.isArray(value) ? value : [value]; for (const m of items as Array<{ value?: string }>) { if (!m?.value) continue; if (verb === "add") memberAdds.push(m.value); else if (verb === "remove") memberRemoves.push(m.value); } } else { throw ScimError.invalidValue(`Unsupported group patch path "${path ?? "(body)"}" for op "${verb}".`); } } const update: Record = {}; if (renameTo) { update.name = renameTo; const ws = await this.primaryWorkspace(org); let slug = renameTo.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "group"; for (let i = 2; ; i += 1) { const clash = await this.prisma.team.findUnique({ where: { workspaceId_slug: { workspaceId: ws.id, slug } } }); if (!clash || clash.id === id) break; const suffix = `-${i}`; update.name = `${renameTo.slice(0, 128 - suffix.length).trim()}${suffix}`; slug = `${slug.slice(0, Math.max(1, 40 - suffix.length))}${suffix}`; } update.slug = slug; } if (Object.keys(update).length) { await this.prisma.team.update({ where: { id }, data: update }); } const { added, skipped } = await this.syncMembers( org, id, memberAdds.map((v) => ({ value: v })), memberRemoves.map((v) => ({ value: v })) ); if (added.length) changes.push(`members+${added.length}`); if (memberRemoves.length) changes.push(`members-${memberRemoves.length}`); const teamFull = await this.prisma.team.findUniqueOrThrow({ where: { id }, select: GROUP_SELECT }); const memberCount = await this.prisma.teamMember.count({ where: { teamId: id } }); const rep = this.groupRepr({ ...teamFull, memberCount }); const etag = computeEtag(rep, teamFull.updatedAt); (rep.meta as { version: string }).version = etag; this.audit(req, org, "scim.group.updated", id, { teamId: id, changes, membersAdded: added, membersUnresolved: skipped }); return { rep, etag, location: `/scim/v2/Groups/${id}` }; } /** Add/remove TeamMembers. `value` = User id or email; unknown or * non-member values are SKIPPED (collected for the audit `skipped`), * never a 400 — an IdP sync should not die on one stale member. */ private async syncMembers( org: ScimOrgRef, teamId: string, adds: Array<{ value: string }>, removes: Array<{ value: string }> ): Promise<{ added: string[]; skipped: string[] }> { const added: string[] = []; const skipped: string[] = []; for (const { value } of adds) { const userId = await this.resolveMember(org, value); if (!userId) { skipped.push(value); continue; } const wsMember = await this.prisma.workspaceMember.findUnique({ where: { workspaceId_userId: { workspaceId: (await this.prisma.team.findUniqueOrThrow({ where: { id: teamId }, select: { workspaceId: true } })).workspaceId, userId } } }); if (!wsMember) { // The user is an org member but not in the primary workspace yet — // add the workspace membership first (SCIM groups imply workspace // access to the team's workspace). const wm = await this.prisma.workspaceMember.create({ data: { workspaceId: (await this.prisma.team.findUniqueOrThrow({ where: { id: teamId }, select: { workspaceId: true } })).workspaceId, userId, role: "MEMBER" as never, joinedAt: new Date() } }); await this.prisma.teamMember.create({ data: { teamId, userId, workspaceMemberId: wm.id, role: "MEMBER" as never } }); added.push(value); continue; } const exists = await this.prisma.teamMember.findUnique({ where: { teamId_userId: { teamId, userId } } }); if (exists) continue; await this.prisma.teamMember.create({ data: { teamId, userId, workspaceMemberId: wsMember.id, role: "MEMBER" as never } }); added.push(value); } for (const { value } of removes) { const userId = await this.resolveMember(org, value); if (!userId) { skipped.push(value); continue; } // Delete the TeamMember only — the WorkspaceMember stays (the // spec: members keep their workspace membership). await this.prisma.teamMember.deleteMany({ where: { teamId, userId } }); } return { added, skipped }; } async delete(req: Request, org: ScimOrgRef, id: string) { const team = await this.scopedTeam(org, id); const memberCount = await this.prisma.teamMember.count({ where: { teamId: id } }); // Delete the team — TeamMember cascades (workspace membership kept). await this.prisma.team.delete({ where: { id } }); this.audit(req, org, "scim.group.deleted", id, { teamId: id, workspaceId: team.workspaceId, displayName: team.name, membersRemoved: memberCount }); return { rep: { schemas: [SCIM_GROUP_SCHEMA], id, meta: { resourceType: "Group", note: "Group deleted; members keep their workspace membership." } } }; } }