diff --git a/api-v3/src/database/migrations/1788652800000-reset-production-operational-data.ts b/api-v3/src/database/migrations/1788652800000-reset-production-operational-data.ts index a57c6a2..0af4e47 100644 --- a/api-v3/src/database/migrations/1788652800000-reset-production-operational-data.ts +++ b/api-v3/src/database/migrations/1788652800000-reset-production-operational-data.ts @@ -26,11 +26,7 @@ export class ResetProductionOperationalData1788652800000 implements MigrationInt const adminId = adminRows[0].id; - // Product configuration that must survive a clean operational start. - const preservedTables = new Set([ - 'typeorm_migrations', - 'users', - 'user_roles', + const structuralTables = [ 'roles', 'permissions', 'role_permissions', @@ -41,6 +37,34 @@ export class ResetProductionOperationalData1788652800000 implements MigrationInt 'finding_catalog_items', 'finding_catalog_item_asset_types', 'finding_catalog_asset_type_profiles', + ]; + + // Snapshot structural row counts so TRUNCATE ... CASCADE can never silently + // remove product configuration while still leaving operational tables empty. + const structuralCounts = new Map(); + for (const table of structuralTables) { + const safeTable = `"${table.replace(/"/g, '""')}"`; + const rows: Array<{ total: string }> = await queryRunner.query( + `SELECT count(*)::text AS total FROM ${safeTable}`, + ); + structuralCounts.set(table, rows[0]?.total ?? '0'); + } + + const adminRolesBefore: Array<{ total: string }> = await queryRunner.query( + `SELECT count(*)::text AS total FROM user_roles WHERE user_id = $1`, + [adminId], + ); + const adminRoleCount = adminRolesBefore[0]?.total ?? '0'; + if (adminRoleCount === '0') { + throw new Error('Production reset aborted: admin has no assigned role'); + } + + // Product configuration that must survive a clean operational start. + const preservedTables = new Set([ + 'typeorm_migrations', + 'users', + 'user_roles', + ...structuralTables, ]); const tableRows: Array<{ table_name: string }> = await queryRunner.query(` @@ -68,14 +92,17 @@ export class ResetProductionOperationalData1788652800000 implements MigrationInt // A reset must invalidate every prior login token, including admin's. // auth_sessions is operational and was truncated above; admin simply logs in again. - await queryRunner.query(` + await queryRunner.query( + ` UPDATE users SET failed_login_attempts = 0, locked_until = NULL, last_login_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = $1 - `, [adminId]); + `, + [adminId], + ); const finalUsers: Array<{ total: string; admins: string }> = await queryRunner.query(` SELECT @@ -88,6 +115,37 @@ export class ResetProductionOperationalData1788652800000 implements MigrationInt throw new Error('Production reset verification failed: users table is not admin-only'); } + const finalAdminRoles: Array<{ total: string; foreign_users: string }> = await queryRunner.query( + ` + SELECT + count(*) FILTER (WHERE user_id = $1)::text AS total, + count(*) FILTER (WHERE user_id <> $1)::text AS foreign_users + FROM user_roles + `, + [adminId], + ); + + if ( + finalAdminRoles[0]?.total !== adminRoleCount || + finalAdminRoles[0]?.foreign_users !== '0' + ) { + throw new Error('Production reset verification failed: admin role assignments changed'); + } + + // Assert every structural table kept exactly the same number of rows. + for (const table of structuralTables) { + const safeTable = `"${table.replace(/"/g, '""')}"`; + const rows: Array<{ total: string }> = await queryRunner.query( + `SELECT count(*)::text AS total FROM ${safeTable}`, + ); + const before = structuralCounts.get(table) ?? '0'; + if (rows[0]?.total !== before) { + throw new Error( + `Production reset verification failed: structural table ${table} changed (${before} -> ${rows[0]?.total ?? 'unknown'})`, + ); + } + } + // Assert that every operational table is empty. This makes the migration // fail atomically if a table was repopulated during the reset transaction. for (const table of operationalTables) { @@ -103,7 +161,7 @@ export class ResetProductionOperationalData1788652800000 implements MigrationInt // Keep a concise server-side record in the migration log for deploy diagnostics. // eslint-disable-next-line no-console console.log( - `[production-reset] kept admin=${adminRows[0].username} (${adminId}); cleared ${operationalTables.length} operational tables`, + `[production-reset] kept admin=${adminRows[0].username} (${adminId}); preserved ${structuralTables.length} structural tables; cleared ${operationalTables.length} operational tables`, ); }