144 lines
4.9 KiB
JavaScript
144 lines
4.9 KiB
JavaScript
#!/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);
|
|
});
|
|
}
|