forked from wangziqi/gongxue-base
250 lines
9.8 KiB
JavaScript
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,
|
|
}));
|
|
}
|