#!/usr/bin/env node /* eslint-disable no-console */ import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, ".."); export const schemaPath = path.join(repoRoot, "prisma/schema/accounts.prisma"); export const migrationPath = path.join( repoRoot, "prisma/migrations/20260508140000_impersonation_audit/migration.sql", ); export function extractModel(schemaText, modelName) { const modelMatch = schemaText.match(new RegExp(`model\\s+${modelName}\\s+\\{([\\s\\S]*?)\\n\\}`)); return modelMatch?.[1] ?? null; } function assertMatch(failures, id, text, pattern, message) { if (!pattern.test(text)) { failures.push({ id, message }); } } function normalizeSql(sqlText) { return sqlText.replace(/\s+/g, " ").trim(); } export function verifyAuditCascadeGuard({ schemaText, migrationSql }) { const failures = []; const model = extractModel(schemaText, "ImpersonationAudit"); if (!model) { failures.push({ id: "schema.model.missing", message: "Prisma schema is missing model ImpersonationAudit." }); } else { assertMatch( failures, "schema.actorUserId.nullable", model, /^\s*actorUserId\s+String\?/m, "ImpersonationAudit.actorUserId must be nullable (String?).", ); assertMatch( failures, "schema.targetUserId.nullable", model, /^\s*targetUserId\s+String\?/m, "ImpersonationAudit.targetUserId must be nullable (String?).", ); assertMatch( failures, "schema.actor.optional-set-null", model, /^\s*actor\s+User\?\s+@relation\([^\n]*fields:\s*\[actorUserId\][^\n]*onDelete:\s*SetNull[^\n]*\)/m, "ImpersonationAudit.actor relation must be optional and use onDelete: SetNull.", ); assertMatch( failures, "schema.target.optional-set-null", model, /^\s*target\s+User\?\s+@relation\([^\n]*fields:\s*\[targetUserId\][^\n]*onDelete:\s*SetNull[^\n]*\)/m, "ImpersonationAudit.target relation must be optional and use onDelete: SetNull.", ); assertMatch( failures, "schema.tenant.optional-set-null", model, /^\s*tenant\s+Tenant\?\s+@relation\([^\n]*fields:\s*\[tenantId\][^\n]*onDelete:\s*SetNull[^\n]*\)/m, "ImpersonationAudit.tenant relation must be optional and use onDelete: SetNull.", ); const cascadeRelations = model .split(/\r?\n/) .map((line) => line.trim()) .filter((line) => /@relation\(/.test(line) && /onDelete:\s*Cascade/.test(line)); for (const relation of cascadeRelations) { failures.push({ id: "schema.relation.no-cascade", message: `ImpersonationAudit relation must not use onDelete: Cascade: ${relation}`, }); } } const normalizedSql = normalizeSql(migrationSql); assertMatch( failures, "migration.actorUserId.nullable", normalizedSql, /"actorUserId"\s+TEXT\s*(?:,|$)/, 'Migration column "actorUserId" must be nullable TEXT, not TEXT NOT NULL.', ); assertMatch( failures, "migration.targetUserId.nullable", normalizedSql, /"targetUserId"\s+TEXT\s*(?:,|$)/, 'Migration column "targetUserId" must be nullable TEXT, not TEXT NOT NULL.', ); const expectedForeignKeys = [ ["migration.actorUserId.set-null", /CONSTRAINT\s+"ImpersonationAudit_actorUserId_fkey"\s+FOREIGN KEY\s+\("actorUserId"\)\s+REFERENCES\s+"User"\("id"\)\s+ON DELETE SET NULL/i], ["migration.targetUserId.set-null", /CONSTRAINT\s+"ImpersonationAudit_targetUserId_fkey"\s+FOREIGN KEY\s+\("targetUserId"\)\s+REFERENCES\s+"User"\("id"\)\s+ON DELETE SET NULL/i], ["migration.tenantId.set-null", /CONSTRAINT\s+"ImpersonationAudit_tenantId_fkey"\s+FOREIGN KEY\s+\("tenantId"\)\s+REFERENCES\s+"Tenant"\("id"\)\s+ON DELETE SET NULL/i], ]; for (const [id, pattern] of expectedForeignKeys) { assertMatch(failures, id, normalizedSql, pattern, `${id} foreign key must use ON DELETE SET NULL.`); } return { ok: failures.length === 0, failures }; } export function formatAuditCascadeGuardResult(result) { if (result.ok) { return "PASS impersonation audit cascade guard: schema and migration retain audit rows with nullable SetNull relations."; } return [ "FAIL impersonation audit cascade guard", ...result.failures.map((failure) => `- ${failure.id}: ${failure.message}`), ].join("\n"); } async function main() { const [schemaText, migrationSql] = await Promise.all([readFile(schemaPath, "utf8"), readFile(migrationPath, "utf8")]); const result = verifyAuditCascadeGuard({ schemaText, migrationSql }); const output = formatAuditCascadeGuardResult(result); if (result.ok) { console.log(output); return; } console.error(output); process.exit(1); } if (process.argv[1] === fileURLToPath(import.meta.url)) { main().catch((error) => { console.error(error); process.exit(1); }); }