1
19import { Inject, Injectable } from "@nestjs/common";
20import bcrypt from "bcryptjs";
21import type { Request } from "express";
22import { PrismaService } from "../../common/prisma.service.js";
23import type { User } from "@prisma/client";
24import { computeEtag } from "./etag.js";
25import { ScimError } from "./scim-errors.js";
26
27
29export interface ScimOrgRef {
30 id: string;
31 scimConfig: unknown;
32}
33import { filterToWhere, parseScimFilter } from "./filters.js";
34import {
35 SCIM_USER_SCHEMA,
36 ScimPatch,
37 ScimUserCreate,
38 type ScimOp
39} from "./schemas.js";
40
41const BCRYPT_COST = 12;
42const COUNT_CAP = 100;
43const COUNT_DEFAULT = 50;
44
45type ScimConfig = {
46 bearerTokenHash?: string;
47 tokenHint?: string;
48 userEmailDomain?: string;
49 defaultRole?: "GUEST" | "MEMBER";
50 autoJoinOrgWorkspaces?: boolean;
51 enabledAt?: string;
52};
53
54const USER_SELECT = {
55 id: true,
56 email: true,
57 name: true,
58 displayName: true,
59 status: true,
60 avatarUrl: true,
61 createdAt: true,
62 updatedAt: true
63} as const;
64
65type ScimUserView = Pick<User, "id" | "email" | "name" | "displayName" | "status" | "createdAt" | "updatedAt">;
66
67
71export function userToScim(user: ScimUserView): Record<string, unknown> {
72 const dn = (user.displayName ?? user.name).trim();
73 const parts = dn ? dn.split(/\s+/) : [];
74 return {
75 schemas: [SCIM_USER_SCHEMA],
76 id: user.id,
77 userName: user.email,
78 name: dn
79 ? { formatted: dn, givenName: parts[0], familyName: parts.length > 1 ? parts.slice(1).join(" ") : undefined }
80 : undefined,
81 displayName: user.displayName ?? user.name,
82 active: user.status !== "SUSPENDED",
83 emails: [{ value: user.email, type: "work", primary: true }],
84 meta: { resourceType: "User", version: `W/"scim-0"` }
85 };
86}
87
88@Injectable()
89export class ScimUsersService {
90 constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
91
92 private cfg(org: ScimOrgRef): ScimConfig {
93 return (org.scimConfig as ScimConfig | null) ?? {};
94 }
95
96
98 toEmail(body: ScimUserCreate, org: ScimOrgRef): string {
99 const first = body.emails?.[0]?.value;
100 const raw = first ?? body.userName;
101 if (raw.includes("@")) return raw.trim().toLowerCase();
102 const domain = this.cfg(org).userEmailDomain;
103 if (!domain) {
104 throw ScimError.invalidValue(
105 `userName "${body.userName}" has no "@" and the org has no scimConfig.userEmailDomain to map it.`
106 );
107 }
108 return `${body.userName.trim().toLowerCase()}@${domain.toLowerCase()}`;
109 }
110
111 private audit(
112 req: Request,
113 org: ScimOrgRef,
114 action: string,
115 resourceType: string,
116 resourceId: string,
117 metadata: Record<string, unknown>
118 ): void {
119 void this.prisma.auditLog
120 .create({
121 data: {
122 actorType: "INTEGRATION",
123 actorId: org.id,
124 workspaceId: null,
125 resourceType,
126 resourceId,
127 action,
128 ipAddress: req.ip ?? null,
129 userAgent: (req.headers["user-agent"] as string | undefined) ?? null,
130 metadata: { orgId: org.id, ...metadata } as object
131 }
132 })
133 .catch(() => {
134
135 });
136 }
137
138 async create(req: Request, org: ScimOrgRef, body: ScimUserCreate) {
139 const email = this.toEmail(body, org);
140
141
142
143 const existing = await this.prisma.user.findUnique({ where: { email }, select: { id: true } });
144 if (existing) {
145 throw ScimError.duplicate(`A User with userName '${email}' already exists.`);
146 }
147
148 const displayName =
149 body.displayName ??
150 (body.name
151 ? [body.name.givenName, body.name.familyName].filter(Boolean).join(" ").trim() || undefined
152 : undefined);
153 const name = displayName?.trim() || email.split("@")[0];
154 const passwordHash = body.password ? await bcrypt.hash(body.password, BCRYPT_COST) : null;
155 const status = body.active === false ? "SUSPENDED" : "ACTIVE";
156
157 const user = await this.prisma.user.create({
158 data: {
159 email,
160 name,
161 displayName: displayName?.trim() ?? null,
162 passwordHash,
163 emailVerifiedAt: new Date(),
164 emailVerification: "VERIFIED",
165 status: status as never,
166
167
168 },
169 select: USER_SELECT
170 });
171
172
173 const role = this.cfg(org).defaultRole === "GUEST" ? "GUEST" : "MEMBER";
174 await this.prisma.organizationMember.create({
175 data: { organizationId: org.id, userId: user.id, role: role as never }
176 });
177
178
179 if (this.cfg(org).autoJoinOrgWorkspaces !== false) {
180 const orgWorkspaces = await this.prisma.workspace.findMany({
181 where: { organizationId: org.id, isPersonal: false, deletedAt: null },
182 select: { id: true }
183 });
184 if (orgWorkspaces.length) {
185 await this.prisma.workspaceMember.createMany({
186 data: orgWorkspaces.map((w) => ({
187 workspaceId: w.id,
188 userId: user.id,
189 role: "MEMBER" as never,
190 joinedAt: new Date()
191 })),
192 skipDuplicates: true
193 });
194 }
195 }
196
197 const rep = userToScim(user);
198 const etag = computeEtag(rep, user.updatedAt);
199 rep.meta = {
200 resourceType: "User",
201 created: user.createdAt.toISOString(),
202 location: `/scim/v2/Users/${user.id}`,
203 version: etag
204 };
205
206 this.audit(req, org, "scim.user.created", "user", user.id, {
207 userId: user.id,
208 email,
209 source: "scim",
210 status,
211 role,
212 hadPassword: Boolean(body.password)
213 });
214
215 return { rep, etag, location: `/scim/v2/Users/${user.id}` };
216 }
217
218 private async scopedUser(org: ScimOrgRef, id: string) {
219 const user = await this.prisma.user.findUnique({ where: { id }, select: { id: true, email: true } });
220 if (!user) throw ScimError.notFound("User not found.");
221 const member = await this.prisma.organizationMember.findUnique({
222 where: { organizationId_userId: { organizationId: org.id, userId: id } }
223 });
224
225 if (!member) throw ScimError.notFound("User not found.");
226 return this.prisma.user.findUniqueOrThrow({ where: { id }, select: USER_SELECT });
227 }
228
229 async get(org: ScimOrgRef, id: string) {
230 const user = await this.scopedUser(org, id);
231 const rep = userToScim(user);
232 const etag = computeEtag(rep, user.updatedAt);
233 rep.meta = {
234 resourceType: "User",
235 created: user.createdAt.toISOString(),
236 location: `/scim/v2/Users/${user.id}`,
237 version: etag
238 };
239 return { rep, etag, location: `/scim/v2/Users/${user.id}` };
240 }
241
242
244 async list(org: ScimOrgRef, query: { filter?: string; startIndex?: string; count?: string }) {
245
246 const memberIds = await this.prisma.organizationMember.findMany({
247 where: { organizationId: org.id },
248 select: { userId: true }
249 });
250 if (memberIds.length === 0) {
251 return { totalResults: 0, startIndex: 1, itemsPerPage: 0, resources: [], etag: null as string | null };
252 }
253 const base = { id: { in: memberIds.map((m) => m.userId) } } as Record<string, unknown>;
254 let where = base;
255 if (query.filter !== undefined) {
256 if (!query.filter.trim()) throw ScimError.invalidFilter("Empty filter.");
257 where = { AND: [base, filterToWhere(parseScimFilter(query.filter))] };
258 }
259
260 const count = Math.min(Math.max(parseInt(query.count ?? String(COUNT_DEFAULT), 10) || COUNT_DEFAULT, 1), COUNT_CAP);
261 const startIndex = Math.max(parseInt(query.startIndex ?? "1", 10) || 1, 1);
262 const skip = startIndex - 1;
263
264 const [total, users] = await this.prisma.$transaction([
265 this.prisma.user.count({ where }),
266 this.prisma.user.findMany({
267 where,
268 select: USER_SELECT,
269 orderBy: { createdAt: "asc" },
270 skip,
271 take: count
272 })
273 ]);
274
275 const resources = users.map((u) => {
276 const rep = userToScim(u);
277 const et = computeEtag(rep, u.updatedAt);
278 rep.meta = {
279 resourceType: "User",
280 created: u.createdAt.toISOString(),
281 location: `/scim/v2/Users/${u.id}`,
282 version: et
283 };
284 return rep;
285 });
286
287 return {
288 totalResults: total,
289 startIndex,
290 itemsPerPage: count,
291 resources,
292 etag: resources.length ? computeEtag(resources[resources.length - 1], users[users.length - 1].updatedAt) : null
293 };
294 }
295
296
298 async patch(req: Request, org: ScimOrgRef, id: string, body: ScimPatch) {
299 const user = await this.scopedUser(org, id);
300 const ops: ScimOp[] = body.Operations ?? body.operations!;
301 const update: Record<string, unknown> = {};
302 const changes: string[] = [];
303
304 for (const op of ops) {
305 const { op: verb, path, value } = op;
306 if (path === "active" || (path === undefined && verb === "replace" && typeof value === "boolean")) {
307 const active = typeof value === "boolean" ? value : value === "false";
308 update.status = active ? "ACTIVE" : "SUSPENDED";
309 changes.push(`active=${active}`);
310 } else if (path === "name" && value && typeof value === "object" && !Array.isArray(value)) {
311 const n = value as { givenName?: string; familyName?: string; formatted?: string };
312 const full = n.formatted ?? [n.givenName, n.familyName].filter(Boolean).join(" ").trim();
313 if (full) {
314 update.displayName = full.slice(0, 256);
315 update.name = full.split(/\s+/)[0] || user.name;
316 changes.push("name");
317 }
318 } else if (path === "displayName" && typeof value === "string") {
319 update.displayName = value.trim().slice(0, 256);
320 changes.push("displayName");
321 } else if (path === "emails" && Array.isArray(value) && value.length) {
322 const email = (value[0] as { value?: string }).value;
323 if (email) {
324 update.email = email.trim().toLowerCase();
325 changes.push("emails");
326 }
327 } else if (path === "userName" && typeof value === "string") {
328 const email = value.includes("@") ? value : this.toEmail({ userName: value } as ScimUserCreate, org);
329 update.email = email.trim().toLowerCase();
330 changes.push("userName");
331 } else {
332 throw ScimError.invalidValue(`Unsupported patch path "${path ?? "(body)"}" for op "${verb}".`);
333 }
334 }
335
336 if (changes.length) {
337
338 if (typeof update.email === "string") {
339 const clash = await this.prisma.user.findUnique({ where: { email: update.email as string }, select: { id: true } });
340 if (clash && clash.id !== user.id) {
341 throw ScimError.duplicate(`A User with email '${update.email}' already exists.`);
342 }
343 }
344 await this.prisma.user.update({ where: { id: user.id }, data: update as never });
345 }
346
347 const fresh = await this.prisma.user.findUniqueOrThrow({ where: { id: user.id }, select: USER_SELECT });
348 const rep = userToScim(fresh);
349 const etag = computeEtag(rep, fresh.updatedAt);
350 rep.meta = {
351 resourceType: "User",
352 created: fresh.createdAt.toISOString(),
353 location: `/scim/v2/Users/${fresh.id}`,
354 version: etag
355 };
356
357 const deactivated = fresh.status === "SUSPENDED" && user.status !== "SUSPENDED";
358 this.audit(req, org, deactivated ? "scim.user.deactivated" : "scim.user.updated", "user", user.id, {
359 userId: user.id,
360 changes,
361 ...(deactivated ? { via: "patch" } : {})
362 });
363
364 return { rep, etag, location: `/scim/v2/Users/${user.id}` };
365 }
366
367
368 async delete(req: Request, org: ScimOrgRef, id: string) {
369 const user = await this.scopedUser(org, id);
370 if (user.status !== "SUSPENDED") {
371 await this.prisma.user.update({ where: { id: user.id }, data: { status: "SUSPENDED" } });
372 this.audit(req, org, "scim.user.deactivated", "user", user.id, {
373 userId: user.id,
374 via: "delete"
375 });
376 }
377 return { rep: this.emptyRepr(user), etag: computeEtag(this.emptyRepr(user), user.updatedAt) };
378 }
379
380 private emptyRepr(user: { id: string }): Record<string, unknown> {
381 return {
382 schemas: [SCIM_USER_SCHEMA],
383 id: user.id,
384 active: false,
385 meta: { resourceType: "User", note: "DELETED=deactivated; the User row is retained (status SUSPENDED)." }
386 };
387 }
388}
389