ops: harden production data migration
Staging Backend Quality Gate / validate (push) Successful in 10m5s
Staging Backend Quality Gate / publish (push) Successful in 9m18s

This commit is contained in:
Jesse_Chen
2026-08-10 04:19:24 +08:00
parent 18459f03d7
commit 7a24a8d387
2 changed files with 112 additions and 10 deletions
@@ -1,13 +1,28 @@
import { createHash } from "node:crypto";
import { dirname, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { Pool } from "pg";
import { Pool, types as pgTypes } from "pg";
import { runMigrations } from "./db-migrate.mjs";
import { normalizeSupabaseUsers } from "./import-supabase-auth-users.mjs";
export class SafeProductionMigrationError extends Error {}
const PRODUCTION_OWNER_USER_ID = "b8907d0c-6ed0-4270-b866-7e83bb4a1b26";
const PRODUCTION_OWNER_EMAIL = "luna@copse.life";
const IDENTITY_STATE_TABLES = [
"users",
"accounts",
"sessions",
"verifications",
"otp_rate_limits",
"two_factors",
];
// Preserve PostgreSQL microseconds. JavaScript Date truncates timestamps to milliseconds.
pgTypes.setTypeParser(1114, (value) => value);
pgTypes.setTypeParser(1184, (value) => value);
const ALLOWED_TARGET_ROWS = new Set([
"public.admin_permissions",
"public.admin_role_permissions",
@@ -65,10 +80,16 @@ export function readConfiguration(env) {
if (!ownerUserId || !uuidPattern.test(ownerUserId)) {
throw new SafeProductionMigrationError("PRODUCTION_OWNER_USER_ID must be a UUID");
}
if (ownerUserId !== PRODUCTION_OWNER_USER_ID) {
throw new SafeProductionMigrationError("PRODUCTION_OWNER_USER_ID does not match the approved production Owner");
}
const ownerEmail = env.PRODUCTION_OWNER_EMAIL?.trim().toLowerCase();
if (!ownerEmail || !ownerEmail.includes("@")) {
throw new SafeProductionMigrationError("PRODUCTION_OWNER_EMAIL must be an email address");
}
if (ownerEmail !== PRODUCTION_OWNER_EMAIL) {
throw new SafeProductionMigrationError("PRODUCTION_OWNER_EMAIL does not match the approved production Owner");
}
const ciphertextMode = env.PRODUCTION_CIPHERTEXT_MODE?.trim();
if (!new Set(["preserve", "exclude"]).has(ciphertextMode)) {
throw new SafeProductionMigrationError(
@@ -352,7 +373,7 @@ function bannedState(value, now = new Date()) {
throw new SafeProductionMigrationError("source contains an invalid banned_until value");
}
return date > now
? { banned: true, banExpires: date }
? { banned: true, banExpires: value }
: { banned: false, banExpires: null };
}
@@ -370,14 +391,14 @@ export function normalizeAuthUsers(rows, now = new Date(), activeAdminUserIds =
name: user.name,
email: user.email,
email_verified: user.emailVerified,
email_verified_at: user.emailVerifiedAt,
email_verified_at: user.emailVerifiedAt === null ? null : rows[index].email_confirmed_at,
image: user.image,
role: activeAdminUserIds.has(user.id) ? "admin" : "user",
banned,
ban_reason: banned ? "migrated blocked-user state" : null,
ban_expires: banExpires,
created_at: user.createdAt,
updated_at: user.updatedAt,
created_at: rows[index].created_at,
updated_at: rows[index].updated_at,
two_factor_enabled: false,
};
});
@@ -438,8 +459,10 @@ export function assertActiveAdminUsers(users, activeAdminUserIds, ownerUserId, o
}
export async function assertTargetEmpty(target, targetTables) {
if (await countRows(target, "identity", "users")) {
throw new SafeProductionMigrationError("target identity database is not empty");
for (const table of IDENTITY_STATE_TABLES) {
if (await countRows(target, "identity", table)) {
throw new SafeProductionMigrationError("target identity database is not empty");
}
}
if (await countRows(target, "auth", "users")) {
throw new SafeProductionMigrationError("target auth compatibility table is not empty");
@@ -461,7 +484,7 @@ async function assertNoUnmappedSourceTables(source, sourceTables, targetTables)
}
}
async function assertActiveAdminRoles(source, sourceTables, ownerUserId) {
export async function assertActiveAdminRoles(source, sourceTables, ownerUserId) {
if (!sourceTables.has("admin_users")) return;
if (!sourceTables.has("admin_user_roles") || !sourceTables.has("admin_roles")) {
const result = await source.query(
@@ -491,6 +514,19 @@ async function assertActiveAdminRoles(source, sourceTables, ownerUserId) {
if (Number(result.rows[0].count) > 0) {
throw new SafeProductionMigrationError("an active source administrator has no canonical target role");
}
const otherOwners = await source.query(
`
select count(*)::bigint as count
from public.admin_users au
join public.admin_user_roles aur on aur.admin_user_id = au.user_id
join public.admin_roles ar on ar.id = aur.role_id
where au.revoked_at is null and ar.code = 'owner' and au.user_id <> $1
`,
[ownerUserId],
);
if (Number(otherOwners.rows[0].count) > 0) {
throw new SafeProductionMigrationError("source contains more than one active Owner");
}
}
function remapForeignKeys(row, table, maps) {
@@ -719,11 +755,12 @@ async function forceOwner(target, ownerUserId) {
);
}
async function assertTargetAdminState(target, ownerUserId) {
export async function assertTargetAdminState(target, ownerUserId) {
const result = await target.query(
`
select
count(*) filter (where au.user_id = $1 and au.revoked_at is null and ar.code = 'owner')::int as owner_count,
count(*) filter (where au.revoked_at is null and ar.code = 'owner')::int as total_owner_count,
(
select count(*)::int from public.admin_users active
where active.revoked_at is null and not exists (
@@ -745,6 +782,7 @@ async function assertTargetAdminState(target, ownerUserId) {
);
if (
result.rows[0].owner_count !== 1 ||
result.rows[0].total_owner_count !== 1 ||
result.rows[0].admins_without_roles !== 0 ||
result.rows[0].unusable_identity_admins !== 0
) {