1
20import { Inject, Injectable } from "@nestjs/common";
21import type { Request } from "express";
22import { Prisma } from "@prisma/client";
23import { randomToken, sha256hex } from "../../lib/crypto.js";
24import { ApiError } from "../../common/errors.js";
25import { PrismaService } from "../../common/prisma.service.js";
26import { requireUser } from "../../common/types.js";
27import { loadEnv } from "../../config/env.js";
28import { ScimAuthService } from "./scim-auth.service.js";
29import { ScimConfigSchema, type ScimAdminUpdate, type ScimEnable } from "./schemas.js";
30import { assertOrgAdmin } from "../org/guards.js";
31
32export interface ScimPublicConfig {
33 enabled: boolean;
34 tokenHint?: string;
35 userEmailDomain?: string | null;
36 defaultRole?: "GUEST" | "MEMBER";
37 autoJoinOrgWorkspaces?: boolean;
38 enabledAt?: string;
39 endpointUrl?: string;
40 curlExample?: string;
41}
42
43@Injectable()
44export class ScimAdminService {
45 constructor(
46 @Inject(PrismaService) private readonly prisma: PrismaService,
47 @Inject(ScimAuthService) private readonly auth: ScimAuthService
48 ) {}
49
50
52 private base(): string {
53 const env = loadEnv();
54 return (env.SSO_ORIGIN || env.WEB_ORIGIN).replace(/\/$/, "");
55 }
56
57
59 private async requireOrgOwner(orgId: string, userId: string) {
60 await assertOrgAdmin(this.prisma, orgId, userId);
61 const org = await this.prisma.organization.findUnique({ where: { id: orgId } });
62 if (!org) throw ApiError.notFound("Organization not found.");
63 return org;
64 }
65
66 private userId(req: Request): string {
67 const auth = (req as Request & { auth?: { userId?: string } }).auth;
68 if (!auth?.userId) throw ApiError.unauthorized();
69 return auth.userId;
70 }
71
72 private masked(org: { scimConfig: unknown }): ScimPublicConfig {
73 const cfg = (org.scimConfig as Record<string, unknown> | null) ?? null;
74 if (!cfg) return { enabled: false };
75 const parsed = ScimConfigSchema.parse(cfg);
76 const endpointUrl = `${this.base()}/scim/v2`;
77 return {
78 enabled: true,
79 tokenHint: parsed.tokenHint,
80 userEmailDomain: parsed.userEmailDomain ?? null,
81 defaultRole: parsed.defaultRole,
82 autoJoinOrgWorkspaces: parsed.autoJoinOrgWorkspaces,
83 enabledAt: parsed.enabledAt,
84 endpointUrl,
85 curlExample: `curl ${endpointUrl}/Users -H "Authorization: Bearer scim_…"`
86 };
87 }
88
89 async get(orgId: string, req: Request): Promise<ScimPublicConfig> {
90 const org = await this.requireOrgOwner(orgId, requireUser(req));
91 return this.masked(org);
92 }
93
94
96 async enable(orgId: string, input: ScimEnable, req: Request): Promise<ScimPublicConfig & { token: string }> {
97 const org = await this.requireOrgOwner(orgId, requireUser(req));
98 if (org.scimConfig != null) {
99 throw ApiError.conflict("SCIM is already enabled for this org; rotate or disable first.", [
100 { reason: "already_enabled" }
101 ]);
102 }
103 const rawToken = `scim_${randomToken(32)}`;
104 const hash = sha256hex(rawToken);
105 const config = ScimConfigSchema.parse({
106 bearerTokenHash: hash,
107 tokenHint: rawToken.slice(-4),
108 userEmailDomain: input.userEmailDomain,
109 defaultRole: input.defaultRole,
110 autoJoinOrgWorkspaces: input.autoJoinOrgWorkspaces,
111 enabledAt: new Date().toISOString()
112 });
113 await this.prisma.organization.update({ where: { id: orgId }, data: { scimConfig: config as object } });
114 await this.auth.upsertIndex(hash, orgId);
115
116 await this.audit(req, orgId, "scim.token.created", { hint: config.tokenHint, domain: input.userEmailDomain ?? null });
117
118 return { ...(await this.get(orgId, req)), token: rawToken };
119 }
120
121 async update(orgId: string, input: ScimAdminUpdate, req: Request): Promise<ScimPublicConfig & { token?: string }> {
122 const org = await this.requireOrgOwner(orgId, requireUser(req));
123 const current = (org.scimConfig as Record<string, unknown> | null) ?? null;
124 const result: { token?: string } = {};
125
126 if (input.disable === true) {
127 if (!current) throw ApiError.notFound("SCIM is not enabled for this org.");
128 const oldHash = current.bearerTokenHash as string;
129 await this.prisma.organization.update({ where: { id: orgId }, data: { scimConfig: Prisma.JsonNull as object } });
130 await this.auth.removeIndex(oldHash, orgId);
131 await this.audit(req, orgId, "scim.token.disabled", { hint: (current.tokenHint as string) ?? null });
132 return { enabled: false };
133 }
134
135 if (!current) throw ApiError.notFound("SCIM is not enabled for this org; enable it first.");
136
137 if (input.rotate === true) {
138 const rawToken = `scim_${randomToken(32)}`;
139 const hash = sha256hex(rawToken);
140 await this.prisma.organization.update({
141 where: { id: orgId },
142 data: {
143 scimConfig: {
144 ...current,
145 bearerTokenHash: hash,
146 tokenHint: rawToken.slice(-4),
147 enabledAt: new Date().toISOString()
148 } as object
149 }
150 });
151
152 await this.auth.upsertIndex(hash, orgId);
153 result.token = rawToken;
154 await this.audit(req, orgId, "scim.token.created", { hint: rawToken.slice(-4), rotated: true });
155 } else {
156 const next = {
157 ...current,
158 userEmailDomain: input.userEmailDomain === undefined ? current.userEmailDomain : input.userEmailDomain,
159 defaultRole: input.defaultRole ?? current.defaultRole,
160 autoJoinOrgWorkspaces: input.autoJoinOrgWorkspaces ?? current.autoJoinOrgWorkspaces
161 };
162 await this.prisma.organization.update({ where: { id: orgId }, data: { scimConfig: next as object } });
163 await this.audit(req, orgId, "scim.config.updated", {
164 changed: Object.keys(input).filter((k) => k !== "rotate")
165 });
166 }
167
168 return { ...(await this.get(orgId, req)), ...result };
169 }
170
171
174 async test(orgId: string, token: string, req: Request): Promise<{ ok: boolean; detail: string }> {
175 await this.requireOrgOwner(orgId, requireUser(req));
176 const resolved = await this.auth.resolveOrg(token);
177 if (resolved?.orgId === orgId) {
178 return { ok: true, detail: "Token valid — resolves to this org." };
179 }
180 return { ok: false, detail: resolved ? "Token valid, but for a DIFFERENT org." : "Unknown or disabled token." };
181 }
182
183 private audit(req: Request, orgId: string, action: string, metadata: Record<string, unknown>): void {
184 void this.prisma.auditLog
185 .create({
186 data: {
187 actorType: "USER",
188 actorId: requireUser(req),
189 resourceType: "org",
190 resourceId: orgId,
191 action,
192 ipAddress: req.ip ?? null,
193 userAgent: (req.headers["user-agent"] as string | undefined) ?? null,
194 metadata: { orgId, ...metadata } as object
195 }
196 })
197 .catch(() => {
198
199 });
200 }
201}
202