Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/scripts/verify-rls-release-readiness.mjs
T

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();
}