213 lines
8.5 KiB
JavaScript
213 lines
8.5 KiB
JavaScript
import { readFileSync, readdirSync } from "node:fs";
|
|
import path from "node:path";
|
|
|
|
export const TENANT_SCOPE_KINDS = Object.freeze([
|
|
"tenant-required",
|
|
"tenant-or-global",
|
|
"derived-child",
|
|
"tenant-root",
|
|
"global-catalog",
|
|
"global-shared",
|
|
"platform-admin",
|
|
]);
|
|
|
|
export const TENANT_RLS_ENFORCEMENTS = Object.freeze(["tenant-policy", "mixed-policy", "join-policy", "root-policy"]);
|
|
|
|
const VALID_SCOPES = new Set(TENANT_SCOPE_KINDS);
|
|
const VALID_ENFORCEMENTS = new Set([...TENANT_RLS_ENFORCEMENTS, "none", "platform-role"]);
|
|
|
|
function listSchemaFiles(schemaDirectory) {
|
|
return readdirSync(schemaDirectory, { withFileTypes: true })
|
|
.filter((entry) => entry.isFile() && entry.name.endsWith(".prisma"))
|
|
.map((entry) => path.join(schemaDirectory, entry.name))
|
|
.sort();
|
|
}
|
|
|
|
function parseOwningRelations(body) {
|
|
const relations = [];
|
|
const relationField = /^\s*(\w+)\s+([A-Za-z_]\w*)(\?)?\s+@relation\(([\s\S]*?)\)/gm;
|
|
for (const relation of body.matchAll(relationField)) {
|
|
const fields = /\bfields\s*:\s*\[([^\]]+)\]/m.exec(relation[4])?.[1];
|
|
const references = /\breferences\s*:\s*\[([^\]]+)\]/m.exec(relation[4])?.[1];
|
|
if (!fields || !references) continue;
|
|
relations.push({
|
|
field: relation[1],
|
|
target: relation[2],
|
|
nullable: relation[3] === "?",
|
|
fields: fields.split(",").map((field) => field.trim()),
|
|
references: references.split(",").map((field) => field.trim()),
|
|
});
|
|
}
|
|
return relations.sort((left, right) => left.field.localeCompare(right.field));
|
|
}
|
|
|
|
function parseFieldNames(body) {
|
|
const fields = [];
|
|
for (const line of body.split("\n")) {
|
|
const field = /^\s*(\w+)\s+[A-Za-z_]\w*(?:\[\])?\??(?:\s|$)/u.exec(line)?.[1];
|
|
if (field) fields.push(field);
|
|
}
|
|
return [...new Set(fields)].sort();
|
|
}
|
|
|
|
function parseNullableFieldNames(body) {
|
|
const fields = [];
|
|
for (const line of body.split("\n")) {
|
|
const field = /^\s*(\w+)\s+[A-Za-z_]\w*(?:\[\])?\?(?:\s|$)/u.exec(line)?.[1];
|
|
if (field) fields.push(field);
|
|
}
|
|
return [...new Set(fields)].sort();
|
|
}
|
|
|
|
/** Parse the model and owning-FK surface needed by the static tenant gates. */
|
|
export function parsePrismaSchemaDirectory(schemaDirectory) {
|
|
const models = [];
|
|
for (const file of listSchemaFiles(schemaDirectory)) {
|
|
const source = readFileSync(file, "utf8");
|
|
for (const match of source.matchAll(/model\s+(\w+)\s*\{([\s\S]*?)\n\}/g)) {
|
|
const body = match[2];
|
|
const tenantField = /^\s*tenantId\s+String(\?)?/m.exec(body);
|
|
const owningRelations = parseOwningRelations(body);
|
|
models.push({
|
|
model: match[1],
|
|
table: /@@map\(\s*"([^"]+)"\s*\)/m.exec(body)?.[1] ?? match[1],
|
|
schema: path.basename(file),
|
|
tenantField: tenantField ? { nullable: tenantField[1] === "?" } : null,
|
|
fieldNames: parseFieldNames(body),
|
|
nullableFieldNames: parseNullableFieldNames(body),
|
|
owningRelations,
|
|
owningRelationTargets: [...new Set(owningRelations.map((relation) => relation.target))].sort(),
|
|
});
|
|
}
|
|
}
|
|
return models.sort((left, right) => left.schema.localeCompare(right.schema) || left.model.localeCompare(right.model));
|
|
}
|
|
|
|
/**
|
|
* Discover models whose rows inherit a tenant boundary through an owning FK.
|
|
* Tenant roots and direct tenant models seed the graph; traversal then follows
|
|
* child -> parent relations until no new descendants are found.
|
|
*/
|
|
export function discoverIndirectTenantModels(models, manifest) {
|
|
const modelNames = new Set(models.map((model) => model.model));
|
|
const tenantBoundary = new Set(
|
|
models
|
|
.filter((model) => {
|
|
const scope = manifest[model.model]?.scope;
|
|
return scope === "tenant-required" || scope === "tenant-or-global" || scope === "tenant-root";
|
|
})
|
|
.map((model) => model.model)
|
|
);
|
|
const indirect = new Set();
|
|
|
|
let changed = true;
|
|
while (changed) {
|
|
changed = false;
|
|
for (const model of models) {
|
|
if (model.tenantField || tenantBoundary.has(model.model) || indirect.has(model.model)) continue;
|
|
if (model.owningRelationTargets.some((target) => modelNames.has(target) && (tenantBoundary.has(target) || indirect.has(target)))) {
|
|
indirect.add(model.model);
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return indirect;
|
|
}
|
|
|
|
export function modelRequiresTenantRls(entry) {
|
|
return TENANT_RLS_ENFORCEMENTS.includes(entry.enforcement);
|
|
}
|
|
|
|
/** Validate schema coverage, direct-field semantics, and indirect FK drift. */
|
|
export function validateTenantScopeManifest(models, manifest) {
|
|
const errors = [];
|
|
const byName = new Map(models.map((model) => [model.model, model]));
|
|
const schemaNames = new Set(byName.keys());
|
|
const manifestNames = new Set(Object.keys(manifest));
|
|
|
|
for (const model of models) {
|
|
if (!manifestNames.has(model.model)) errors.push(`${model.schema}: ${model.model} is missing from the tenant scope manifest`);
|
|
}
|
|
for (const model of manifestNames) {
|
|
if (!schemaNames.has(model)) errors.push(`tenant scope manifest contains unknown model ${model}`);
|
|
}
|
|
|
|
for (const model of models) {
|
|
const entry = manifest[model.model];
|
|
if (!entry) continue;
|
|
if (!VALID_SCOPES.has(entry.scope)) errors.push(`${model.model} has invalid tenant scope ${String(entry.scope)}`);
|
|
if (!VALID_ENFORCEMENTS.has(entry.enforcement)) {
|
|
errors.push(`${model.model} has invalid tenant enforcement ${String(entry.enforcement)}`);
|
|
}
|
|
|
|
if (entry.scope === "tenant-required" && model.tenantField?.nullable !== false) {
|
|
errors.push(`${model.schema}: ${model.model} is tenant-required but lacks a required tenantId String field`);
|
|
}
|
|
if (entry.scope === "tenant-or-global" && model.tenantField?.nullable !== true) {
|
|
errors.push(`${model.schema}: ${model.model} is tenant-or-global but lacks a nullable tenantId String? field`);
|
|
}
|
|
if (entry.scope === "derived-child" && model.tenantField) {
|
|
errors.push(`${model.schema}: ${model.model} is derived-child but declares its own tenantId field`);
|
|
}
|
|
if ((entry.scope === "global-catalog" || entry.scope === "global-shared" || entry.scope === "tenant-root") && model.tenantField) {
|
|
errors.push(`${model.schema}: ${model.model} is ${entry.scope} but declares a tenantId field`);
|
|
}
|
|
|
|
const expectedEnforcement = {
|
|
"tenant-required": "tenant-policy",
|
|
"tenant-or-global": "mixed-policy",
|
|
"derived-child": "join-policy",
|
|
"tenant-root": "root-policy",
|
|
"global-catalog": "none",
|
|
"global-shared": "none",
|
|
"platform-admin": "platform-role",
|
|
}[entry.scope];
|
|
if (entry.enforcement !== expectedEnforcement && !entry.reason) {
|
|
errors.push(`${model.model} overrides ${entry.scope} enforcement without an explicit reason`);
|
|
}
|
|
if (entry.tenantParentBoundary === true && !entry.reason) {
|
|
errors.push(`${model.model} declares a tenant-parent boundary exception without an explicit reason`);
|
|
}
|
|
}
|
|
|
|
const tenantRoots = models.filter((model) => manifest[model.model]?.scope === "tenant-root").map((model) => model.model);
|
|
if ((byName.has("Tenant") || tenantRoots.length > 0) && (tenantRoots.length !== 1 || tenantRoots[0] !== "Tenant")) {
|
|
errors.push(`tenant-root classification must contain exactly Tenant, found: ${tenantRoots.join(", ") || "none"}`);
|
|
}
|
|
|
|
const indirectModels = discoverIndirectTenantModels(models, manifest);
|
|
for (const modelName of indirectModels) {
|
|
const entry = manifest[modelName];
|
|
if (!entry) continue;
|
|
const classifiedBoundary = entry.scope === "derived-child" || entry.scope === "platform-admin";
|
|
if (!classifiedBoundary && entry.tenantParentBoundary !== true) {
|
|
errors.push(`${byName.get(modelName)?.schema}: ${modelName} has an owning FK path to tenant data but is classified ${entry.scope}`);
|
|
}
|
|
}
|
|
for (const model of models) {
|
|
const entry = manifest[model.model];
|
|
if (entry?.scope === "derived-child" && !indirectModels.has(model.model)) {
|
|
errors.push(`${model.schema}: ${model.model} is derived-child but no owning FK path reaches a tenant boundary`);
|
|
}
|
|
if (entry?.tenantParentBoundary === true && !indirectModels.has(model.model)) {
|
|
errors.push(`${model.schema}: ${model.model} declares an unnecessary tenant-parent boundary exception`);
|
|
}
|
|
}
|
|
|
|
const scopeCounts = Object.fromEntries(TENANT_SCOPE_KINDS.map((scope) => [scope, 0]));
|
|
for (const model of models) {
|
|
const scope = manifest[model.model]?.scope;
|
|
if (scopeCounts[scope] !== undefined) scopeCounts[scope] += 1;
|
|
}
|
|
|
|
return {
|
|
ok: errors.length === 0,
|
|
errors,
|
|
indirectModels,
|
|
scopeCounts,
|
|
classifiedCount: models.filter((model) => manifest[model.model]).length,
|
|
modelCount: models.length,
|
|
};
|
|
}
|