241 lines
12 KiB
JavaScript
241 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Release-only RLS gate. Run explicitly with:
|
|
* node scripts/verify-rls-release-readiness.mjs
|
|
*
|
|
* CI and deployment both run this gate. A non-zero exit blocks release until
|
|
* reviewed RLS DDL exists in prisma/migrations for every model whose manifest
|
|
* enforcement requires tenant RLS.
|
|
*/
|
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import { TENANT_SCOPED_IMPLICIT_TABLES, TENANT_SCOPE_MANIFEST } from "../prisma/tenant-scope-manifest.mjs";
|
|
import { modelRequiresTenantRls, parsePrismaSchemaDirectory, validateTenantScopeManifest } from "./lib/prisma-tenant-scope.mjs";
|
|
|
|
const CANONICAL_TENANT_CONTEXT = "app.tenant_id";
|
|
|
|
function stripSqlComments(sql) {
|
|
return sql.replace(/\/\*[\s\S]*?\*\//g, "").replace(/--.*$/gm, "");
|
|
}
|
|
|
|
function listFiles(directory, predicate) {
|
|
if (!existsSync(directory)) return [];
|
|
return readdirSync(directory, { withFileTypes: true })
|
|
.filter((entry) => entry.isFile() && predicate(entry.name))
|
|
.map((entry) => path.join(directory, entry.name))
|
|
.sort();
|
|
}
|
|
|
|
function listMigrationFiles(migrationsDirectory) {
|
|
if (!existsSync(migrationsDirectory)) return [];
|
|
return readdirSync(migrationsDirectory, { withFileTypes: true })
|
|
.filter((entry) => entry.isDirectory())
|
|
.map((entry) => path.join(migrationsDirectory, entry.name, "migration.sql"))
|
|
.filter(existsSync)
|
|
.sort();
|
|
}
|
|
|
|
function addCapability(map, table, capability, source) {
|
|
const current = map.get(table) ?? {
|
|
enable: false,
|
|
force: false,
|
|
policy: false,
|
|
policyUsing: false,
|
|
policyCheck: false,
|
|
grant: false,
|
|
sources: new Set(),
|
|
};
|
|
current[capability] = true;
|
|
current.policy = current.policyUsing && current.policyCheck;
|
|
current.sources.add(source);
|
|
map.set(table, current);
|
|
}
|
|
|
|
function parseSqlCapabilities(files) {
|
|
const capabilities = new Map();
|
|
for (const file of files) {
|
|
const sql = stripSqlComments(readFileSync(file, "utf8"));
|
|
const source = path.basename(path.dirname(file)) === "rls" ? path.basename(file) : path.basename(path.dirname(file));
|
|
|
|
for (const match of sql.matchAll(/ALTER\s+TABLE\s+(?:ONLY\s+)?(?:public\.)?"([^"]+)"\s+ENABLE\s+ROW\s+LEVEL\s+SECURITY/gi)) {
|
|
addCapability(capabilities, match[1], "enable", source);
|
|
}
|
|
for (const match of sql.matchAll(/ALTER\s+TABLE\s+(?:ONLY\s+)?(?:public\.)?"([^"]+)"\s+FORCE\s+ROW\s+LEVEL\s+SECURITY/gi)) {
|
|
addCapability(capabilities, match[1], "force", source);
|
|
}
|
|
for (const match of sql.matchAll(/CREATE\s+POLICY\s+[\s\S]*?\s+ON\s+(?:public\.)?"([^"]+)"([\s\S]*?);/gi)) {
|
|
const statement = match[0];
|
|
if (!statement.includes(CANONICAL_TENANT_CONTEXT)) continue;
|
|
if (/\bUSING\s*\(/i.test(statement)) addCapability(capabilities, match[1], "policyUsing", source);
|
|
if (/\bWITH\s+CHECK\s*\(/i.test(statement)) addCapability(capabilities, match[1], "policyCheck", source);
|
|
}
|
|
// Immutable tables intentionally grant the app only SELECT/INSERT while
|
|
// the worker receives UPDATE/DELETE. Any explicit DML grant to a reviewed
|
|
// runtime role proves that RLS is not made functionally unreachable.
|
|
const directGrant =
|
|
/GRANT\s+(?:(?:SELECT|INSERT|UPDATE|DELETE)\s*(?:,\s*)?)+\s+ON\s+((?:"[^"]+"\s*(?:,\s*)?)+)\s+TO\s+(?:hivegrid_app|hivegrid_worker)(?:\s*,\s*(?:hivegrid_app|hivegrid_worker))*/gi;
|
|
for (const match of sql.matchAll(directGrant)) {
|
|
for (const table of match[1].matchAll(/"([^"]+)"/g)) addCapability(capabilities, table[1], "grant", source);
|
|
}
|
|
|
|
const dynamicTables = [...sql.matchAll(/FOREACH\s+tbl\s+IN\s+ARRAY\s+ARRAY\[([\s\S]*?)\]/gi)].flatMap((arrayMatch) =>
|
|
[...arrayMatch[1].matchAll(/'([A-Za-z0-9_]+)'/g)].map((tableMatch) => tableMatch[1])
|
|
);
|
|
if (dynamicTables.length === 0) continue;
|
|
|
|
const hasDynamicEnable = /ALTER\s+TABLE\s+%I\s+ENABLE\s+ROW\s+LEVEL\s+SECURITY/i.test(sql);
|
|
const hasDynamicForce = /ALTER\s+TABLE\s+%I\s+FORCE\s+ROW\s+LEVEL\s+SECURITY/i.test(sql);
|
|
const hasDynamicPolicy = /CREATE\s+POLICY\s+%I\s+ON\s+%I/i.test(sql) && sql.includes(CANONICAL_TENANT_CONTEXT) && /WITH\s+CHECK/i.test(sql);
|
|
const hasDynamicGrant = /GRANT\s+(?:(?:SELECT|INSERT|UPDATE|DELETE)\s*(?:,\s*)?)+\s+ON\s+%I\s+TO\s+(?:hivegrid_app|hivegrid_worker)/i.test(sql);
|
|
|
|
for (const table of dynamicTables) {
|
|
if (hasDynamicEnable) addCapability(capabilities, table, "enable", source);
|
|
if (hasDynamicForce) addCapability(capabilities, table, "force", source);
|
|
if (hasDynamicPolicy) {
|
|
addCapability(capabilities, table, "policyUsing", source);
|
|
addCapability(capabilities, table, "policyCheck", source);
|
|
}
|
|
if (hasDynamicGrant) addCapability(capabilities, table, "grant", source);
|
|
}
|
|
}
|
|
return capabilities;
|
|
}
|
|
|
|
function groupModels(models) {
|
|
const grouped = new Map();
|
|
for (const model of models) {
|
|
const entries = grouped.get(model.schema) ?? [];
|
|
entries.push(model.model);
|
|
grouped.set(model.schema, entries);
|
|
}
|
|
return [...grouped.entries()].map(([schema, names]) => `${schema}: ${names.join(", ")}`);
|
|
}
|
|
|
|
function missingCapability(models, capabilities, capability) {
|
|
return models.filter((model) => capabilities.get(model.table)?.[capability] !== true);
|
|
}
|
|
|
|
export function inspectRlsReleaseReadiness(rootDirectory = process.cwd(), options = {}) {
|
|
const schemaDirectory = path.join(rootDirectory, "prisma", "schema");
|
|
const rlsDirectory = path.join(rootDirectory, "prisma", "rls");
|
|
const migrationsDirectory = path.join(rootDirectory, "prisma", "migrations");
|
|
const manifest = options.manifest ?? TENANT_SCOPE_MANIFEST;
|
|
const implicitTables = options.implicitTables ?? (options.manifest ? [] : TENANT_SCOPED_IMPLICIT_TABLES);
|
|
const allModels = parsePrismaSchemaDirectory(schemaDirectory);
|
|
const scopeValidation = validateTenantScopeManifest(allModels, manifest);
|
|
const models = allModels
|
|
.filter((model) => manifest[model.model] && modelRequiresTenantRls(manifest[model.model]))
|
|
.map((model) => ({ ...model, ...manifest[model.model] }))
|
|
.concat(implicitTables);
|
|
const artifactFiles = listFiles(rlsDirectory, (name) => name.endsWith("-isolation.sql"));
|
|
const migrationFiles = listMigrationFiles(migrationsDirectory);
|
|
const artifacts = parseSqlCapabilities(artifactFiles);
|
|
const migrations = parseSqlCapabilities(migrationFiles);
|
|
|
|
const missingArtifacts = models.filter((model) => !artifacts.has(model.table));
|
|
const artifactWithoutEnable = missingCapability(
|
|
models.filter((model) => artifacts.has(model.table)),
|
|
artifacts,
|
|
"enable"
|
|
);
|
|
const artifactWithoutForce = missingCapability(
|
|
models.filter((model) => artifacts.has(model.table)),
|
|
artifacts,
|
|
"force"
|
|
);
|
|
const artifactWithoutPolicy = missingCapability(
|
|
models.filter((model) => artifacts.has(model.table)),
|
|
artifacts,
|
|
"policy"
|
|
);
|
|
const artifactWithoutGrant = missingCapability(
|
|
models.filter((model) => artifacts.has(model.table)),
|
|
artifacts,
|
|
"grant"
|
|
);
|
|
const migrationWithoutEnable = missingCapability(models, migrations, "enable");
|
|
const migrationWithoutForce = missingCapability(models, migrations, "force");
|
|
const migrationWithoutPolicy = missingCapability(models, migrations, "policy");
|
|
|
|
const failures = [];
|
|
if (!scopeValidation.ok) failures.push(`tenant scope manifest has ${scopeValidation.errors.length} errors`);
|
|
if (missingArtifacts.length > 0) failures.push(`RLS artifact coverage missing for ${missingArtifacts.length} tenant models`);
|
|
if (artifactWithoutEnable.length > 0) failures.push(`${artifactWithoutEnable.length} artifact-covered models lack ENABLE ROW LEVEL SECURITY`);
|
|
if (artifactWithoutForce.length > 0) failures.push(`${artifactWithoutForce.length} artifact-covered models lack FORCE ROW LEVEL SECURITY`);
|
|
if (artifactWithoutPolicy.length > 0) failures.push(`${artifactWithoutPolicy.length} artifact-covered models lack a canonical tenant policy`);
|
|
if (artifactWithoutGrant.length > 0) failures.push(`${artifactWithoutGrant.length} artifact-covered models lack restricted-role DML grants`);
|
|
if (migrationWithoutEnable.length > 0) failures.push(`${migrationWithoutEnable.length} tenant models lack active ENABLE RLS in prisma/migrations`);
|
|
if (migrationWithoutForce.length > 0) failures.push(`${migrationWithoutForce.length} tenant models lack active FORCE RLS in prisma/migrations`);
|
|
if (migrationWithoutPolicy.length > 0) failures.push(`${migrationWithoutPolicy.length} tenant models lack an active policy in prisma/migrations`);
|
|
|
|
return {
|
|
ok: failures.length === 0,
|
|
allModels,
|
|
scopeValidation,
|
|
models,
|
|
artifactFileCount: artifactFiles.length,
|
|
migrationFileCount: migrationFiles.length,
|
|
missingArtifacts,
|
|
artifactWithoutEnable,
|
|
artifactWithoutForce,
|
|
artifactWithoutPolicy,
|
|
artifactWithoutGrant,
|
|
migrationWithoutEnable,
|
|
migrationWithoutForce,
|
|
migrationWithoutPolicy,
|
|
failures,
|
|
};
|
|
}
|
|
|
|
export function formatRlsReleaseReadiness(result) {
|
|
const artifactCovered = result.models.length - result.missingArtifacts.length;
|
|
const migrationEnabled = result.models.length - result.migrationWithoutEnable.length;
|
|
const migrationForced = result.models.length - result.migrationWithoutForce.length;
|
|
const migrationPolicies = result.models.length - result.migrationWithoutPolicy.length;
|
|
const lines = [
|
|
`${result.ok ? "PASS" : "FAIL"} RLS release readiness`,
|
|
`Tenant scope manifest: ${result.scopeValidation.classifiedCount}/${result.scopeValidation.modelCount} Prisma models classified`,
|
|
`RLS-enforced models: ${result.models.length}`,
|
|
`Rollout artifacts: ${result.artifactFileCount} files, ${artifactCovered}/${result.models.length} models covered`,
|
|
`Deploy migrations: ${result.migrationFileCount} files, ENABLE ${migrationEnabled}/${result.models.length}, FORCE ${migrationForced}/${result.models.length}, POLICY ${migrationPolicies}/${result.models.length}`,
|
|
];
|
|
|
|
for (const failure of result.failures) lines.push(`FAIL ${failure}`);
|
|
if (result.scopeValidation.errors.length > 0) {
|
|
lines.push("Tenant scope manifest errors:", ...result.scopeValidation.errors.map((entry) => `- ${entry}`));
|
|
}
|
|
if (result.missingArtifacts.length > 0) {
|
|
lines.push("Missing artifact coverage:", ...groupModels(result.missingArtifacts).map((entry) => `- ${entry}`));
|
|
}
|
|
if (result.artifactWithoutEnable.length > 0) {
|
|
lines.push("Artifacts without ENABLE:", ...groupModels(result.artifactWithoutEnable).map((entry) => `- ${entry}`));
|
|
}
|
|
if (result.artifactWithoutForce.length > 0) {
|
|
lines.push("Artifacts without FORCE:", ...groupModels(result.artifactWithoutForce).map((entry) => `- ${entry}`));
|
|
}
|
|
if (result.artifactWithoutPolicy.length > 0) {
|
|
lines.push("Artifacts without canonical policy:", ...groupModels(result.artifactWithoutPolicy).map((entry) => `- ${entry}`));
|
|
}
|
|
if (result.artifactWithoutGrant.length > 0) {
|
|
lines.push("Artifacts without restricted-role grants:", ...groupModels(result.artifactWithoutGrant).map((entry) => `- ${entry}`));
|
|
}
|
|
if (!result.ok) {
|
|
lines.push(
|
|
"Release blocker: artifact SQL is not deployment. Fold reviewed policies into a forward migration on main, provision a NOBYPASSRLS runtime role outside repository SQL, and run live cross-tenant read/write tests before release."
|
|
);
|
|
}
|
|
return `${lines.join("\n")}\n`;
|
|
}
|
|
|
|
export function runRlsReleaseReadiness(rootDirectory = process.cwd()) {
|
|
const result = inspectRlsReleaseReadiness(rootDirectory);
|
|
(result.ok ? process.stdout : process.stderr).write(formatRlsReleaseReadiness(result));
|
|
return result.ok ? 0 : 1;
|
|
}
|
|
|
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
process.exitCode = runRlsReleaseReadiness();
|
|
}
|