import { Inject, Injectable } from "@nestjs/common"; import { Prisma } from "@prisma/client"; import { PrismaService } from "../../common/prisma.service.js"; import { AuditService } from "../../common/audit.service.js"; import { ApiError } from "../../common/errors.js"; import { assertCan, assertRole } from "../../lib/authorize.js"; import { aes256gcmDecrypt, aes256gcmEncrypt } from "../../lib/crypto.js"; import { loadEnv } from "../../config/env.js"; /** * Integrations: connection settings per workspace (SSO / SCIM / custom). * * This is STORE-ONLY for now — SSO/SCIM enforcement logic arrives in * Phase 14 (identity module). Secrets inside `config` (clientSecret, * scimBearerToken, …) are AES-GCM-encrypted at rest and masked on read. */ const SECRET_FIELDS = ["clientSecret", "scimBearerToken", "apiKey", "token", "password"]; const SSO_KEYS = ["issuer", "clientId", "clientSecret", "scopes", "jwksUrl"]; const SCIM_KEYS = ["baseUrl", "scimBearerToken"]; const KINDS = ["sso", "scim", "custom"] as const; export type IntegrationKind = (typeof KINDS)[number]; @Injectable() export class IntegrationsService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(AuditService) private readonly audit: AuditService ) {} private masterKey(): string { const key = loadEnv().MASTER_ENCRYPTION_KEY; if (!key) { throw new ApiError("INTERNAL_ERROR", 500, "MASTER_ENCRYPTION_KEY is not configured — integration secrets cannot be stored."); } return key; } private encryptConfig(kind: IntegrationKind, config: Record): Record { const out: Record = { ...config }; const known = kind === "sso" ? SSO_KEYS : kind === "scim" ? SCIM_KEYS : SECRET_FIELDS; for (const field of known) { const v = out[field]; if (typeof v === "string" && v.length > 0) out[field] = aes256gcmEncrypt(v, this.masterKey()); } return out; } private maskConfig(kind: IntegrationKind, config: Record): Record { const out: Record = { ...config }; const known = kind === "sso" ? SSO_KEYS : kind === "scim" ? SCIM_KEYS : SECRET_FIELDS; for (const field of known) { if (typeof out[field] === "string" && out[field].length > 0) out[field] = "••••encrypted••••"; } return out; } async list(workspaceId: string, userId: string) { await assertCan(this.prisma, { workspaceId, userId }, "EDIT"); const rows = await this.prisma.integration.findMany({ where: { workspaceId }, orderBy: { createdAt: "asc" } }); return { data: rows.map((r) => ({ id: r.id, name: r.name, kind: r.provider, status: r.status, config: this.maskConfig(r.provider as IntegrationKind, r.config as Record), createdById: r.createdById, createdAt: r.createdAt, updatedAt: r.updatedAt })) }; } async create( workspaceId: string, userId: string, input: { name: string; kind: IntegrationKind; config: Record }, req: any ) { await assertRole(this.prisma, { workspaceId, userId }, ["OWNER", "ADMIN"]); if (!KINDS.includes(input.kind)) { throw ApiError.validation("kind must be sso | scim | custom.", [{ field: "kind", issue: "invalid kind" }]); } const dup = await this.prisma.integration.findFirst({ where: { workspaceId, name: input.name, status: { not: "revoked" } } }); if (dup) { throw new ApiError("CONFLICT", 409, "An active integration with this name exists.", [ { field: "name", issue: "already_exists" } ]); } const row = await this.prisma.integration.create({ data: { workspaceId, name: input.name, provider: input.kind, config: this.encryptConfig(input.kind, input.config) as Prisma.InputJsonValue, status: "active", createdById: userId } }); await this.audit.audit(req, req?.auth, { action: "integration.created", resourceType: "integration", resourceId: row.id, workspaceId, metadata: { name: input.name, kind: input.kind } }); return { id: row.id, name: row.name, kind: row.provider, status: row.status, createdAt: row.createdAt }; } async update( workspaceId: string, integrationId: string, userId: string, patch: { name?: string; status?: string; config?: Record }, req: any ) { await assertRole(this.prisma, { workspaceId, userId }, ["OWNER", "ADMIN"]); const row = await this.prisma.integration.findFirst({ where: { id: integrationId, workspaceId } }); if (!row) throw ApiError.notFound("Integration not found."); const data: Record = {}; if (patch.name !== undefined) data.name = patch.name; if (patch.status !== undefined) data.status = patch.status; if (patch.config) { // merge: keep existing encrypted values for fields not re-supplied const current = row.config as Record; const merged: Record = { ...current }; const kind = row.provider as IntegrationKind; const known = kind === "sso" ? SSO_KEYS : kind === "scim" ? SCIM_KEYS : SECRET_FIELDS; for (const [k, v] of Object.entries(patch.config)) { if (known.includes(k)) { if (typeof v === "string" && v.length > 0 && v !== "••••encrypted••••") { merged[k] = aes256gcmEncrypt(v, this.masterKey()); } // masked/empty values leave the stored secret untouched } else { merged[k] = v; } } data.config = merged; } const updated = await this.prisma.integration.update({ where: { id: integrationId }, data }); await this.audit.audit(req, req?.auth, { action: "integration.updated", resourceType: "integration", resourceId: integrationId, workspaceId, metadata: { fields: Object.keys(patch) } }); return { id: updated.id, name: updated.name, kind: updated.provider, status: updated.status, config: this.maskConfig(updated.provider as IntegrationKind, updated.config as Record) }; } async remove(workspaceId: string, integrationId: string, userId: string, req: any) { await assertRole(this.prisma, { workspaceId, userId }, ["OWNER", "ADMIN"]); const row = await this.prisma.integration.findFirst({ where: { id: integrationId, workspaceId } }); if (!row) throw ApiError.notFound("Integration not found."); await this.prisma.integration.update({ where: { id: integrationId }, data: { status: "revoked" } }); await this.audit.audit(req, req?.auth, { action: "integration.revoked", resourceType: "integration", resourceId: integrationId, workspaceId }); return { revoked: true }; } /** Decrypted config (internal use — Phase 14 identity flows). */ async decryptedConfig(workspaceId: string, integrationId: string): Promise | null> { const row = await this.prisma.integration.findFirst({ where: { id: integrationId, workspaceId } }); if (!row) return null; const out: Record = { ...(row.config as Record) }; const kind = row.provider as IntegrationKind; const known = kind === "sso" ? SSO_KEYS : kind === "scim" ? SCIM_KEYS : SECRET_FIELDS; for (const field of known) { const v = out[field]; if (typeof v === "string" && v.length > 20) { try { out[field] = aes256gcmDecrypt(v, this.masterKey()); } catch { // leave as-is } } } return out; } }