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