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
) {
@@ -2,10 +2,13 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { types as pgTypes } from "pg";
import {
SafeProductionMigrationError,
assertActiveAdminRoles,
assertActiveAdminUsers,
assertTargetAdminState,
assertTargetEmpty,
copiedDeferredForeignKeys,
normalizeAuthUser,
@@ -70,7 +73,7 @@ test("configuration requires an explicit Owner and ciphertext decision", () => {
const base = {
SUPABASE_SOURCE_DATABASE_URL: "postgresql://source.invalid/jyotisha",
PRODUCTION_TARGET_DATABASE_URL: "postgresql://target.invalid/jyotisha",
PRODUCTION_OWNER_USER_ID: "018f4e6d-7a11-7000-8000-000000000001",
PRODUCTION_OWNER_USER_ID: "b8907d0c-6ed0-4270-b866-7e83bb4a1b26",
PRODUCTION_OWNER_EMAIL: "luna@copse.life",
};
@@ -78,6 +81,22 @@ test("configuration requires an explicit Owner and ciphertext decision", () => {
readConfiguration({ ...base, PRODUCTION_CIPHERTEXT_MODE: "exclude" }).ciphertextMode,
"exclude",
);
assert.throws(
() => readConfiguration({
...base,
PRODUCTION_OWNER_USER_ID: "018f4e6d-7a11-7000-8000-000000000001",
PRODUCTION_CIPHERTEXT_MODE: "exclude",
}),
/approved production Owner/,
);
assert.throws(
() => readConfiguration({
...base,
PRODUCTION_OWNER_EMAIL: "other@example.com",
PRODUCTION_CIPHERTEXT_MODE: "exclude",
}),
/approved production Owner/,
);
assert.throws(
() => readConfiguration({ ...base, PRODUCTION_CIPHERTEXT_MODE: "preserve" }),
/confirmed production encryption keys/,
@@ -92,6 +111,12 @@ test("configuration requires an explicit Owner and ciphertext decision", () => {
);
});
test("PostgreSQL timestamp parsers preserve microseconds", () => {
const value = "2026-08-09 00:00:00.123456+00";
assert.equal(pgTypes.getTypeParser(1184)(value), value);
assert.equal(pgTypes.getTypeParser(1114)(value), value);
});
test("Supabase identity transform preserves UUID and ban state without credentials", () => {
const source = {
id: "018F4E6D-7A11-7000-8000-000000000001",
@@ -112,6 +137,8 @@ test("Supabase identity transform preserves UUID and ban state without credentia
assert.equal(user.banned, true);
assert.deepEqual(user.ban_expires, source.banned_until);
assert.equal(user.two_factor_enabled, false);
assert.deepEqual(user.created_at, source.created_at);
assert.deepEqual(user.updated_at, source.updated_at);
assert.doesNotMatch(JSON.stringify(user), /must-not-migrate|password|refresh_token|mfa_secret/);
});
@@ -148,6 +175,35 @@ test("legacy production without admin tables promotes only the designated Owner"
);
});
test("source and target reject a second active Owner", async () => {
const sourceTables = new Map([
["admin_users", table([{ name: "user_id" }])],
["admin_user_roles", table([{ name: "admin_user_id" }, { name: "role_id" }])],
["admin_roles", table([{ name: "id" }, { name: "code" }])],
]);
let query = 0;
const source = {
async query() {
query += 1;
return { rows: [{ count: query === 1 ? "0" : "1" }] };
},
};
await assert.rejects(
() => assertActiveAdminRoles(source, sourceTables, "018f4e6d-7a11-7000-8000-000000000001"),
/more than one active Owner/,
);
const target = {
async query() {
return { rows: [{ owner_count: 1, total_owner_count: 2, admins_without_roles: 0, unusable_identity_admins: 0 }] };
},
};
await assert.rejects(
() => assertTargetAdminState(target, "018f4e6d-7a11-7000-8000-000000000001"),
/administrator reconciliation failed/,
);
});
test("active administrators become usable identity admins", () => {
const ownerId = "018f4e6d-7a11-7000-8000-000000000001";
const adminId = "018f4e6d-7a11-7000-8000-000000000002";
@@ -293,6 +349,11 @@ test("transfer plan uses non-nullable dependencies and rejects unsafe cycles", (
test("target preflight rejects existing business rows but permits migration seeds", async () => {
const counts = new Map([
["identity.users", 0],
["identity.accounts", 0],
["identity.sessions", 0],
["identity.verifications", 0],
["identity.otp_rate_limits", 0],
["identity.two_factors", 0],
["auth.users", 0],
["public.admin_roles", 6],
["public.profiles", 1],
@@ -311,6 +372,9 @@ test("target preflight rejects existing business rows but permits migration seed
await assert.rejects(() => assertTargetEmpty(client, targetTables), /not empty/);
counts.set("public.profiles", 0);
counts.set("identity.verifications", 1);
await assert.rejects(() => assertTargetEmpty(client, targetTables), /identity database is not empty/);
counts.set("identity.verifications", 0);
await assert.doesNotReject(() => assertTargetEmpty(client, targetTables));
});