Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/scripts/lib/prisma-tenant-scope.mjs
T

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,
};
}