import { PrismaClient } from "@prisma/client"; import { PrismaPg } from "@prisma/adapter-pg"; /** * Central database access for the ISMS tool. * * Multi-tenant rule (see docs/SPEC.md §2): no query on tenant-scoped models * without a tenant context. Application code MUST use `dbForTenant(tenantId)` * for all tenant-scoped models — the raw client is only for global data * (permissions, control catalogs) and platform administration. * * Postgres Row Level Security is added on top as a second line of defense * (prisma/rls.sql, applied via migration). */ const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; export const prisma = globalForPrisma.prisma ?? new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }), }); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; /** Models that carry a tenantId column and must never be queried without one. */ const TENANT_MODELS = new Set([ "User", "Role", "AuditLog", "Task", "TaskComment", "ManagedRegister", "RegisterRow", "TenantSettings", "TenantModule", "Asset", "AssetRelation", "Process", "ProcessAsset", "BiaEntry", "Risk", "RiskAsset", "Measure", "RiskMeasure", "SupplierProfile", "ITServiceProfile", "SupplierAssessment", "Contract", "Nda", "SupplierEvidence", "ServiceControlResponsibility", "Subcontractor", "ManagementDecision", "MaturityAssessment", "PolicyDocument", "PolicyRequirement", "PolicyVariable", "PolicyBaselineParam", "PolicyEvidence", "CryptoEntry", "ClassificationClass", "HandlingAspect", "HandlingRule", "RiskMatrixClass", "RiskEwLevel", "RiskDamageDimension", "HandbookTopic", ]); /** * Returns a Prisma client that transparently enforces the tenant scope: * reads are filtered by tenantId, creates get the tenantId injected. */ export function dbForTenant(tenantId: string) { if (!tenantId) throw new Error("dbForTenant: tenantId is required"); return prisma.$extends({ query: { $allModels: { async $allOperations({ model, operation, args, query }) { if (!TENANT_MODELS.has(model)) return query(args); const a = args as Record; if ( operation === "findMany" || operation === "findFirst" || operation === "findFirstOrThrow" || operation === "count" || operation === "aggregate" || operation === "groupBy" || operation === "updateMany" || operation === "deleteMany" ) { a.where = { AND: [{ tenantId }, (a.where as object) ?? {}] }; } else if (operation === "create") { a.data = { ...(a.data as object), tenantId }; } else if (operation === "createMany") { const data = a.data as Record[]; a.data = data.map((d) => ({ ...d, tenantId })); } else if (operation === "findUnique" || operation === "findUniqueOrThrow") { // Unique lookups cannot be widened with AND — verify ownership on the result. const result = await query(args); if ( result && typeof result === "object" && "tenantId" in result && (result as { tenantId: string }).tenantId !== tenantId ) { throw new Error( `Tenant isolation violation: ${model} belongs to another tenant` ); } return result; } else if ( operation === "update" || operation === "delete" || operation === "upsert" ) { // Mutations on unique keys: verify ownership BEFORE mutating. const delegate = ( prisma as unknown as Record< string, { findUnique: (a: unknown) => Promise<{ tenantId?: string } | null> } > )[model.charAt(0).toLowerCase() + model.slice(1)]; const existing = await delegate.findUnique({ where: a.where }); if (existing && existing.tenantId !== tenantId) { throw new Error( `Tenant isolation violation: ${model} belongs to another tenant` ); } if (operation === "upsert") { a.create = { ...(a.create as object), tenantId }; } } return query(args); }, }, }, }); } export type TenantDb = ReturnType;