1import { Inject, Injectable } from "@nestjs/common";
2import { Prisma } from "@prisma/client";
3import { PrismaService } from "../../common/prisma.service.js";
4import { AuditService } from "../../common/audit.service.js";
5import { ApiError } from "../../common/errors.js";
6import { assertCan, assertRole } from "../../lib/authorize.js";
7import { aes256gcmDecrypt, aes256gcmEncrypt } from "../../lib/crypto.js";
8import { loadEnv } from "../../config/env.js";
9
10
17
18const SECRET_FIELDS = ["clientSecret", "scimBearerToken", "apiKey", "token", "password"];
19const SSO_KEYS = ["issuer", "clientId", "clientSecret", "scopes", "jwksUrl"];
20const SCIM_KEYS = ["baseUrl", "scimBearerToken"];
21
22const KINDS = ["sso", "scim", "custom"] as const;
23export type IntegrationKind = (typeof KINDS)[number];
24
25@Injectable()
26export class IntegrationsService {
27 constructor(
28 @Inject(PrismaService) private readonly prisma: PrismaService,
29 @Inject(AuditService) private readonly audit: AuditService
30 ) {}
31
32 private masterKey(): string {
33 const key = loadEnv().MASTER_ENCRYPTION_KEY;
34 if (!key) {
35 throw new ApiError("INTERNAL_ERROR", 500, "MASTER_ENCRYPTION_KEY is not configured — integration secrets cannot be stored.");
36 }
37 return key;
38 }
39
40 private encryptConfig(kind: IntegrationKind, config: Record<string, unknown>): Record<string, unknown> {
41 const out: Record<string, unknown> = { ...config };
42 const known = kind === "sso" ? SSO_KEYS : kind === "scim" ? SCIM_KEYS : SECRET_FIELDS;
43 for (const field of known) {
44 const v = out[field];
45 if (typeof v === "string" && v.length > 0) out[field] = aes256gcmEncrypt(v, this.masterKey());
46 }
47 return out;
48 }
49
50 private maskConfig(kind: IntegrationKind, config: Record<string, unknown>): Record<string, unknown> {
51 const out: Record<string, unknown> = { ...config };
52 const known = kind === "sso" ? SSO_KEYS : kind === "scim" ? SCIM_KEYS : SECRET_FIELDS;
53 for (const field of known) {
54 if (typeof out[field] === "string" && out[field].length > 0) out[field] = "••••encrypted••••";
55 }
56 return out;
57 }
58
59 async list(workspaceId: string, userId: string) {
60 await assertCan(this.prisma, { workspaceId, userId }, "EDIT");
61 const rows = await this.prisma.integration.findMany({
62 where: { workspaceId },
63 orderBy: { createdAt: "asc" }
64 });
65 return {
66 data: rows.map((r) => ({
67 id: r.id,
68 name: r.name,
69 kind: r.provider,
70 status: r.status,
71 config: this.maskConfig(r.provider as IntegrationKind, r.config as Record<string, unknown>),
72 createdById: r.createdById,
73 createdAt: r.createdAt,
74 updatedAt: r.updatedAt
75 }))
76 };
77 }
78
79 async create(
80 workspaceId: string,
81 userId: string,
82 input: { name: string; kind: IntegrationKind; config: Record<string, unknown> },
83 req: any
84 ) {
85 await assertRole(this.prisma, { workspaceId, userId }, ["OWNER", "ADMIN"]);
86 if (!KINDS.includes(input.kind)) {
87 throw ApiError.validation("kind must be sso | scim | custom.", [{ field: "kind", issue: "invalid kind" }]);
88 }
89 const dup = await this.prisma.integration.findFirst({
90 where: { workspaceId, name: input.name, status: { not: "revoked" } }
91 });
92 if (dup) {
93 throw new ApiError("CONFLICT", 409, "An active integration with this name exists.", [
94 { field: "name", issue: "already_exists" }
95 ]);
96 }
97 const row = await this.prisma.integration.create({
98 data: {
99 workspaceId,
100 name: input.name,
101 provider: input.kind,
102 config: this.encryptConfig(input.kind, input.config) as Prisma.InputJsonValue,
103 status: "active",
104 createdById: userId
105 }
106 });
107 await this.audit.audit(req, req?.auth, {
108 action: "integration.created",
109 resourceType: "integration",
110 resourceId: row.id,
111 workspaceId,
112 metadata: { name: input.name, kind: input.kind }
113 });
114 return { id: row.id, name: row.name, kind: row.provider, status: row.status, createdAt: row.createdAt };
115 }
116
117 async update(
118 workspaceId: string,
119 integrationId: string,
120 userId: string,
121 patch: { name?: string; status?: string; config?: Record<string, unknown> },
122 req: any
123 ) {
124 await assertRole(this.prisma, { workspaceId, userId }, ["OWNER", "ADMIN"]);
125 const row = await this.prisma.integration.findFirst({ where: { id: integrationId, workspaceId } });
126 if (!row) throw ApiError.notFound("Integration not found.");
127 const data: Record<string, unknown> = {};
128 if (patch.name !== undefined) data.name = patch.name;
129 if (patch.status !== undefined) data.status = patch.status;
130 if (patch.config) {
131
132 const current = row.config as Record<string, unknown>;
133 const merged: Record<string, unknown> = { ...current };
134 const kind = row.provider as IntegrationKind;
135 const known = kind === "sso" ? SSO_KEYS : kind === "scim" ? SCIM_KEYS : SECRET_FIELDS;
136 for (const [k, v] of Object.entries(patch.config)) {
137 if (known.includes(k)) {
138 if (typeof v === "string" && v.length > 0 && v !== "••••encrypted••••") {
139 merged[k] = aes256gcmEncrypt(v, this.masterKey());
140 }
141
142 } else {
143 merged[k] = v;
144 }
145 }
146 data.config = merged;
147 }
148 const updated = await this.prisma.integration.update({ where: { id: integrationId }, data });
149 await this.audit.audit(req, req?.auth, {
150 action: "integration.updated",
151 resourceType: "integration",
152 resourceId: integrationId,
153 workspaceId,
154 metadata: { fields: Object.keys(patch) }
155 });
156 return {
157 id: updated.id,
158 name: updated.name,
159 kind: updated.provider,
160 status: updated.status,
161 config: this.maskConfig(updated.provider as IntegrationKind, updated.config as Record<string, unknown>)
162 };
163 }
164
165 async remove(workspaceId: string, integrationId: string, userId: string, req: any) {
166 await assertRole(this.prisma, { workspaceId, userId }, ["OWNER", "ADMIN"]);
167 const row = await this.prisma.integration.findFirst({ where: { id: integrationId, workspaceId } });
168 if (!row) throw ApiError.notFound("Integration not found.");
169 await this.prisma.integration.update({ where: { id: integrationId }, data: { status: "revoked" } });
170 await this.audit.audit(req, req?.auth, {
171 action: "integration.revoked",
172 resourceType: "integration",
173 resourceId: integrationId,
174 workspaceId
175 });
176 return { revoked: true };
177 }
178
179
180 async decryptedConfig(workspaceId: string, integrationId: string): Promise<Record<string, unknown> | null> {
181 const row = await this.prisma.integration.findFirst({ where: { id: integrationId, workspaceId } });
182 if (!row) return null;
183 const out: Record<string, unknown> = { ...(row.config as Record<string, unknown>) };
184 const kind = row.provider as IntegrationKind;
185 const known = kind === "sso" ? SSO_KEYS : kind === "scim" ? SCIM_KEYS : SECRET_FIELDS;
186 for (const field of known) {
187 const v = out[field];
188 if (typeof v === "string" && v.length > 20) {
189 try {
190 out[field] = aes256gcmDecrypt(v, this.masterKey());
191 } catch {
192
193 }
194 }
195 }
196 return out;
197 }
198}
199