Files
gongxue-base/scripts/lib/tenant-foreign-key-audit.js
2026-07-12 19:26:57 +08:00

250 lines
9.8 KiB
JavaScript

import crypto from 'node:crypto';
export const TENANT_FOREIGN_KEY_AUDIT_KIND = 'tenant-foreign-key-audit';
export const EXPECTED_TENANT_FOREIGN_KEY_RELATION_COUNT = 189;
export const EXPECTED_TENANT_FOREIGN_KEY_SCHEMA_SHA256 = '884a5a59c101299551c27bde83f82b9738074a8729da9284775f75537f615868';
const TENANT_FOREIGN_KEY_EXCEPTIONS = new Map([
[
'platform_audit_alerts.platform_audit_alerts_rule_id_fkey',
{
mode: 'global-or-same-tenant-parent',
reason: 'Platform audit rules may be global (tenant_id is null) or scoped to the alert tenant.',
childColumn: 'rule_id',
parentTable: 'platform_audit_alert_rules',
parentColumn: 'id',
},
],
[
'tenant_question_bank_adoptions.tenant_question_bank_adoptions_source_question_bank_id_fkey',
{
mode: 'platform-source-or-same-tenant-parent',
reason: 'A tenant adoption may reference a platform-owned public question bank.',
childColumn: 'source_question_bank_id',
parentTable: 'question_banks',
parentColumn: 'id',
},
],
[
'tenant_content_notifications.tenant_content_notifications_source_question_bank_id_fkey',
{
mode: 'platform-source-or-same-tenant-parent',
reason: 'A tenant notification may identify the platform-owned public question bank that triggered it.',
childColumn: 'source_question_bank_id',
parentTable: 'question_banks',
parentColumn: 'id',
},
],
]);
const RELATION_QUERY = `
with tenant_tables as (
select cls.oid, cls.relname
from pg_class cls
join pg_namespace ns on ns.oid = cls.relnamespace
where ns.nspname = 'public'
and cls.relkind in ('r', 'p')
and exists (
select 1
from pg_attribute attribute
where attribute.attrelid = cls.oid
and attribute.attname = 'tenant_id'
and not attribute.attisdropped
)
)
select child.relname as "childTable",
constraint_row.conname as "constraintName",
parent.relname as "parentTable",
array(
select attribute.attname
from unnest(constraint_row.conkey) with ordinality key_column(attnum, ordinal)
join pg_attribute attribute
on attribute.attrelid = constraint_row.conrelid
and attribute.attnum = key_column.attnum
order by key_column.ordinal
) as "childColumns",
array(
select attribute.attname
from unnest(constraint_row.confkey) with ordinality key_column(attnum, ordinal)
join pg_attribute attribute
on attribute.attrelid = constraint_row.confrelid
and attribute.attnum = key_column.attnum
order by key_column.ordinal
) as "parentColumns",
constraint_row.convalidated as validated,
constraint_row.confupdtype as "updateAction",
constraint_row.confdeltype as "deleteAction"
from pg_constraint constraint_row
join tenant_tables child on child.oid = constraint_row.conrelid
join tenant_tables parent on parent.oid = constraint_row.confrelid
where constraint_row.contype = 'f'
and not exists (
select 1
from unnest(constraint_row.conkey) key_column(attnum)
join pg_attribute attribute
on attribute.attrelid = constraint_row.conrelid
and attribute.attnum = key_column.attnum
where attribute.attname = 'tenant_id'
)
order by child.relname, constraint_row.conname
`;
function textArray(value) {
if (Array.isArray(value)) return value.map(item => String(item));
if (typeof value !== 'string' || value.length < 2) return [];
return value.slice(1, -1).split(',').filter(Boolean).map(item => item.replace(/^"|"$/g, ''));
}
export function normalizeTenantForeignKeyRelation(row) {
return {
childTable: String(row.childTable || row.child_table || ''),
constraintName: String(row.constraintName || row.constraint_name || ''),
childColumns: textArray(row.childColumns || row.child_columns),
parentTable: String(row.parentTable || row.parent_table || ''),
parentColumns: textArray(row.parentColumns || row.parent_columns),
validated: row.validated === true,
updateAction: String(row.updateAction || row.update_action || ''),
deleteAction: String(row.deleteAction || row.delete_action || ''),
};
}
export function tenantForeignKeyRelationKey(relation) {
return `${relation.childTable}.${relation.constraintName}`;
}
export function tenantForeignKeyRelationCanonical(relation) {
return [
relation.childTable,
relation.constraintName,
relation.childColumns.join(','),
relation.parentTable,
relation.parentColumns.join(','),
relation.validated ? 'validated' : 'not-valid',
`update:${relation.updateAction}`,
`delete:${relation.deleteAction}`,
].join('|');
}
export function tenantForeignKeySchemaSha256(relations) {
const canonical = relations
.map(normalizeTenantForeignKeyRelation)
.sort((left, right) => tenantForeignKeyRelationKey(left).localeCompare(tenantForeignKeyRelationKey(right)))
.map(tenantForeignKeyRelationCanonical)
.join('\n');
return crypto.createHash('sha256').update(canonical).digest('hex');
}
function exceptionFor(relation) {
const key = tenantForeignKeyRelationKey(relation);
const exception = TENANT_FOREIGN_KEY_EXCEPTIONS.get(key);
if (!exception) return null;
if (
relation.childColumns.length !== 1
|| relation.parentColumns.length !== 1
|| relation.childColumns[0] !== exception.childColumn
|| relation.parentTable !== exception.parentTable
|| relation.parentColumns[0] !== exception.parentColumn
) {
throw new Error(`Tenant foreign key exception definition no longer matches ${key}`);
}
return exception;
}
export function summarizeTenantForeignKeySchema(inputRelations) {
const relations = inputRelations.map(normalizeTenantForeignKeyRelation);
const keys = new Set(relations.map(tenantForeignKeyRelationKey));
const missingExceptions = [...TENANT_FOREIGN_KEY_EXCEPTIONS.keys()].filter(key => !keys.has(key));
const exceptionRelations = relations.filter(relation => exceptionFor(relation));
const unvalidatedRelations = relations
.filter(relation => !relation.validated)
.map(tenantForeignKeyRelationKey);
const schemaSha256 = tenantForeignKeySchemaSha256(relations);
const schemaMatches = relations.length === EXPECTED_TENANT_FOREIGN_KEY_RELATION_COUNT
&& schemaSha256 === EXPECTED_TENANT_FOREIGN_KEY_SCHEMA_SHA256
&& missingExceptions.length === 0
&& unvalidatedRelations.length === 0;
return {
relationCount: relations.length,
expectedRelationCount: EXPECTED_TENANT_FOREIGN_KEY_RELATION_COUNT,
schemaSha256,
expectedSchemaSha256: EXPECTED_TENANT_FOREIGN_KEY_SCHEMA_SHA256,
schemaMatches,
exceptionCount: exceptionRelations.length,
expectedExceptionCount: TENANT_FOREIGN_KEY_EXCEPTIONS.size,
missingExceptions,
unvalidatedRelations,
};
}
export async function loadTenantForeignKeyRelations(queryable) {
const result = await queryable.query(RELATION_QUERY);
return result.rows.map(normalizeTenantForeignKeyRelation);
}
function quoteIdentifier(value) {
return `"${String(value).replaceAll('"', '""')}"`;
}
function quoteLiteral(value) {
return `'${String(value).replaceAll("'", "''")}'`;
}
function violationPredicate(relation) {
const exception = exceptionFor(relation);
if (!exception) return 'child.tenant_id is distinct from parent.tenant_id';
if (exception.mode === 'global-or-same-tenant-parent') {
return 'parent.tenant_id is not null and child.tenant_id is distinct from parent.tenant_id';
}
if (exception.mode === 'platform-source-or-same-tenant-parent') {
return "child.tenant_id is distinct from parent.tenant_id and parent.source_scope is distinct from 'platform'";
}
throw new Error(`Unsupported tenant foreign key exception mode: ${exception.mode}`);
}
export function buildTenantForeignKeyViolationQuery(inputRelations) {
const relations = inputRelations.map(normalizeTenantForeignKeyRelation);
if (relations.length === 0) {
return `select null::text as "relationKey", null::text as "childTenantId", null::text as "parentTenantId" where false`;
}
return relations.map(relation => {
if (relation.childColumns.length !== relation.parentColumns.length || relation.childColumns.length === 0) {
throw new Error(`Invalid tenant foreign key shape: ${tenantForeignKeyRelationKey(relation)}`);
}
const join = relation.childColumns.map((childColumn, index) => (
`parent.${quoteIdentifier(relation.parentColumns[index])} = child.${quoteIdentifier(childColumn)}`
)).join(' and ');
return `(
select ${quoteLiteral(tenantForeignKeyRelationKey(relation))}::text as "relationKey",
child.tenant_id::text as "childTenantId",
parent.tenant_id::text as "parentTenantId"
from public.${quoteIdentifier(relation.childTable)} child
join public.${quoteIdentifier(relation.parentTable)} parent on ${join}
where ${violationPredicate(relation)}
limit 1
)`;
}).join('\nunion all\n');
}
export async function auditTenantForeignKeyData(client, inputRelations, statementTimeoutMs = 120_000) {
const relations = inputRelations.map(normalizeTenantForeignKeyRelation);
const timeout = Math.max(1_000, Math.min(900_000, Number(statementTimeoutMs) || 120_000));
await client.query('begin read only');
try {
await client.query(`set local statement_timeout = '${timeout}ms'`);
await client.query(`set local lock_timeout = '5s'`);
const result = await client.query(buildTenantForeignKeyViolationQuery(relations));
await client.query('commit');
return result.rows;
} catch (error) {
await client.query('rollback').catch(() => undefined);
throw error;
}
}
export function tenantForeignKeyExceptions() {
return [...TENANT_FOREIGN_KEY_EXCEPTIONS.entries()].map(([relationKey, definition]) => ({
relationKey,
...definition,
}));
}