1
16import { Inject, Injectable } from "@nestjs/common";
17import type { Request } from "express";
18import type { ScimOrgRef } from "./users.service.js";
19import { between } from "../../lib/positions.js";
20import { PrismaService } from "../../common/prisma.service.js";
21import { computeEtag } from "./etag.js";
22import { ScimError } from "./scim-errors.js";
23import { SCIM_GROUP_SCHEMA, ScimGroupCreate, type ScimOp } from "./schemas.js";
24
25const GROUP_SELECT = {
26 id: true,
27 name: true,
28 slug: true,
29 description: true,
30 workspaceId: true,
31 createdAt: true,
32 updatedAt: true
33} as const;
34
35@Injectable()
36export class ScimGroupsService {
37 constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
38
39
41 private async primaryWorkspace(org: ScimOrgRef): Promise<{ id: string; slug: string }> {
42 const ws = await this.prisma.workspace.findFirst({
43 where: { organizationId: org.id, isPersonal: false, deletedAt: null },
44 orderBy: { createdAt: "asc" },
45 select: { id: true, slug: true }
46 });
47 if (!ws) {
48 throw ScimError.invalidValue(
49 "The org has no (non-personal) workspace to host SCIM groups; create a workspace first."
50 );
51 }
52 return ws;
53 }
54
55 private async scopedTeam(org: ScimOrgRef, id: string) {
56 const team = await this.prisma.team.findUnique({ where: { id: id }, select: GROUP_SELECT });
57 if (!team) throw ScimError.notFound("Group not found.");
58 const ws = await this.prisma.workspace.findUnique({
59 where: { id: team.workspaceId },
60 select: { id: true, organizationId: true }
61 });
62
63 if (!ws || ws.organizationId !== org.id) throw ScimError.notFound("Group not found.");
64 return team;
65 }
66
67
69 private async resolveMember(org: ScimOrgRef, value: string): Promise<string | null> {
70 if (value.includes("@")) {
71 const byEmail = await this.prisma.user.findUnique({
72 where: { email: value.trim().toLowerCase() },
73 select: { id: true }
74 });
75 if (!byEmail) return null;
76 value = byEmail.id;
77 }
78 const member = await this.prisma.organizationMember.findUnique({
79 where: { organizationId_userId: { organizationId: org.id, userId: value } }
80 });
81 return member ? value : null;
82 }
83
84 private groupRepr(team: {
85 id: string;
86 name: string;
87 description: string | null;
88 workspaceId: string;
89 createdAt: Date;
90 updatedAt: Date;
91 memberCount?: number;
92 }): Record<string, unknown> {
93 return {
94 schemas: [SCIM_GROUP_SCHEMA],
95 id: team.id,
96 displayName: team.name,
97 description: team.description ?? undefined,
98 meta: {
99 resourceType: "Group",
100 created: team.createdAt.toISOString(),
101 location: `/scim/v2/Groups/${team.id}`,
102 version: `W/"scim-0"`
103 }
104 };
105 }
106
107 private audit(
108 req: Request,
109 org: ScimOrgRef,
110 action: string,
111 resourceId: string,
112 metadata: Record<string, unknown>
113 ): void {
114 void this.prisma.auditLog
115 .create({
116 data: {
117 actorType: "INTEGRATION",
118 actorId: org.id,
119 workspaceId: null,
120 resourceType: "group",
121 resourceId,
122 action,
123 ipAddress: req.ip ?? null,
124 userAgent: (req.headers["user-agent"] as string | undefined) ?? null,
125 metadata: { orgId: org.id, ...metadata } as object
126 }
127 })
128 .catch(() => {
129
130 });
131 }
132
133 async create(req: Request, org: ScimOrgRef, body: ScimGroupCreate) {
134 const ws = await this.primaryWorkspace(org);
135
136
137
138 let name = body.displayName.trim().slice(0, 128);
139 let slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "group";
140 for (let i = 2; ; i += 1) {
141 const clash = await this.prisma.team.findUnique({
142 where: { workspaceId_slug: { workspaceId: ws.id, slug } }
143 });
144 if (!clash) break;
145 const suffix = `-${i}`;
146 name = `${body.displayName.slice(0, 128 - suffix.length).trim()}${suffix}`;
147 slug = `${slug.slice(0, Math.max(1, 40 - suffix.length))}${suffix}`;
148 if (i > 50) throw ScimError.duplicate(`Too many groups named '${body.displayName}'.`);
149 }
150
151 const last = await this.prisma.team.findFirst({
152 where: { workspaceId: ws.id },
153 orderBy: { position: "desc" },
154 select: { position: true }
155 });
156 const team = await this.prisma.team.create({
157 data: {
158 workspaceId: ws.id,
159 name,
160 slug,
161 description: body.description ?? null,
162 position: between(last?.position)
163 },
164 select: GROUP_SELECT
165 });
166
167 const { added, skipped } = await this.syncMembers(org, team.id, body.members ?? [], []);
168 const teamFull = await this.prisma.team.findUnique({ where: { id: team.id }, select: GROUP_SELECT });
169 const rep = this.groupRepr(teamFull!);
170 const etag = computeEtag(rep, teamFull!.updatedAt);
171 (rep.meta as { version: string }).version = etag;
172
173 this.audit(req, org, "scim.group.created", team.id, {
174 teamId: team.id,
175 workspaceId: ws.id,
176 displayName: name,
177 membersAdded: added.length,
178 membersUnresolved: skipped
179 });
180
181 return { rep, etag, location: `/scim/v2/Groups/${team.id}`, added, skipped };
182 }
183
184 async get(org: ScimOrgRef, id: string) {
185 const team = await this.scopedTeam(org, id);
186 const memberCount = await this.prisma.teamMember.count({ where: { teamId: id } });
187 const rep = this.groupRepr({ ...team, memberCount });
188 const etag = computeEtag(rep, team.updatedAt);
189 (rep.meta as { version: string }).version = etag;
190 return { rep, etag, location: `/scim/v2/Groups/${id}`, memberCount };
191 }
192
193 async list(org: ScimOrgRef, query: { filter?: string; startIndex?: string; count?: string }) {
194 const teams = await this.prisma.team.findMany({
195 where: { workspace: { organizationId: org.id } },
196 select: GROUP_SELECT,
197 orderBy: { createdAt: "asc" },
198 skip: Math.max((parseInt(query.startIndex ?? "1", 10) || 1) - 1, 0),
199 take: Math.min(Math.max(parseInt(query.count ?? "50", 10) || 50, 1), 100)
200 });
201 const total = await this.prisma.team.count({ where: { workspace: { organizationId: org.id } } });
202 const resources = teams.map((t) => {
203 const rep = this.groupRepr(t);
204 (rep.meta as { version: string }).version = computeEtag(rep, t.updatedAt);
205 return rep;
206 });
207 return { totalResults: total, startIndex: Math.max((parseInt(query.startIndex ?? "1", 10) || 1), 1), itemsPerPage: resources.length, resources };
208 }
209
210 async patch(req: Request, org: ScimOrgRef, id: string, ops: ScimOp[]) {
211 await this.scopedTeam(org, id);
212 const changes: string[] = [];
213 let renameTo: string | null = null;
214
215 const memberAdds: string[] = [];
216 const memberRemoves: string[] = [];
217
218 for (const op of ops) {
219 const { op: verb, path, value } = op;
220 if (path === "displayName" || path === undefined) {
221 if (path === "displayName" && typeof value === "string") {
222 renameTo = value.trim().slice(0, 128) || null;
223 changes.push("displayName");
224 } else if (
225 path === undefined &&
226 typeof value === "object" &&
227 value !== null &&
228 !Array.isArray(value) &&
229 "displayName" in value
230 ) {
231 const dn = (value as { displayName?: string }).displayName;
232 if (typeof dn === "string") {
233 renameTo = dn.trim().slice(0, 128) || null;
234 changes.push("displayName");
235 }
236 }
237 } else if (path === "members") {
238
239
240 const items = Array.isArray(value) ? value : [value];
241 for (const m of items as Array<{ value?: string }>) {
242 if (!m?.value) continue;
243 if (verb === "add") memberAdds.push(m.value);
244 else if (verb === "remove") memberRemoves.push(m.value);
245 }
246 } else {
247 throw ScimError.invalidValue(`Unsupported group patch path "${path ?? "(body)"}" for op "${verb}".`);
248 }
249 }
250
251 const update: Record<string, unknown> = {};
252 if (renameTo) {
253 update.name = renameTo;
254 const ws = await this.primaryWorkspace(org);
255 let slug = renameTo.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "group";
256 for (let i = 2; ; i += 1) {
257 const clash = await this.prisma.team.findUnique({
258 where: { workspaceId_slug: { workspaceId: ws.id, slug } }
259 });
260 if (!clash || clash.id === id) break;
261 const suffix = `-${i}`;
262 update.name = `${renameTo.slice(0, 128 - suffix.length).trim()}${suffix}`;
263 slug = `${slug.slice(0, Math.max(1, 40 - suffix.length))}${suffix}`;
264 }
265 update.slug = slug;
266 }
267 if (Object.keys(update).length) {
268 await this.prisma.team.update({ where: { id }, data: update });
269 }
270
271 const { added, skipped } = await this.syncMembers(
272 org,
273 id,
274 memberAdds.map((v) => ({ value: v })),
275 memberRemoves.map((v) => ({ value: v }))
276 );
277 if (added.length) changes.push(`members+${added.length}`);
278 if (memberRemoves.length) changes.push(`members-${memberRemoves.length}`);
279
280 const teamFull = await this.prisma.team.findUniqueOrThrow({ where: { id }, select: GROUP_SELECT });
281 const memberCount = await this.prisma.teamMember.count({ where: { teamId: id } });
282 const rep = this.groupRepr({ ...teamFull, memberCount });
283 const etag = computeEtag(rep, teamFull.updatedAt);
284 (rep.meta as { version: string }).version = etag;
285
286 this.audit(req, org, "scim.group.updated", id, {
287 teamId: id,
288 changes,
289 membersAdded: added,
290 membersUnresolved: skipped
291 });
292
293 return { rep, etag, location: `/scim/v2/Groups/${id}` };
294 }
295
296
299 private async syncMembers(
300 org: ScimOrgRef,
301 teamId: string,
302 adds: Array<{ value: string }>,
303 removes: Array<{ value: string }>
304 ): Promise<{ added: string[]; skipped: string[] }> {
305 const added: string[] = [];
306 const skipped: string[] = [];
307
308 for (const { value } of adds) {
309 const userId = await this.resolveMember(org, value);
310 if (!userId) {
311 skipped.push(value);
312 continue;
313 }
314 const wsMember = await this.prisma.workspaceMember.findUnique({
315 where: { workspaceId_userId: { workspaceId: (await this.prisma.team.findUniqueOrThrow({ where: { id: teamId }, select: { workspaceId: true } })).workspaceId, userId } }
316 });
317 if (!wsMember) {
318
319
320
321 const wm = await this.prisma.workspaceMember.create({
322 data: {
323 workspaceId: (await this.prisma.team.findUniqueOrThrow({ where: { id: teamId }, select: { workspaceId: true } })).workspaceId,
324 userId,
325 role: "MEMBER" as never,
326 joinedAt: new Date()
327 }
328 });
329 await this.prisma.teamMember.create({
330 data: { teamId, userId, workspaceMemberId: wm.id, role: "MEMBER" as never }
331 });
332 added.push(value);
333 continue;
334 }
335 const exists = await this.prisma.teamMember.findUnique({ where: { teamId_userId: { teamId, userId } } });
336 if (exists) continue;
337 await this.prisma.teamMember.create({
338 data: { teamId, userId, workspaceMemberId: wsMember.id, role: "MEMBER" as never }
339 });
340 added.push(value);
341 }
342
343 for (const { value } of removes) {
344 const userId = await this.resolveMember(org, value);
345 if (!userId) {
346 skipped.push(value);
347 continue;
348 }
349
350
351 await this.prisma.teamMember.deleteMany({ where: { teamId, userId } });
352 }
353
354 return { added, skipped };
355 }
356
357 async delete(req: Request, org: ScimOrgRef, id: string) {
358 const team = await this.scopedTeam(org, id);
359 const memberCount = await this.prisma.teamMember.count({ where: { teamId: id } });
360
361 await this.prisma.team.delete({ where: { id } });
362 this.audit(req, org, "scim.group.deleted", id, {
363 teamId: id,
364 workspaceId: team.workspaceId,
365 displayName: team.name,
366 membersRemoved: memberCount
367 });
368 return { rep: { schemas: [SCIM_GROUP_SCHEMA], id, meta: { resourceType: "Group", note: "Group deleted; members keep their workspace membership." } } };
369 }
370}
371