From 7a24a8d38713505ca7ba1df35255b1e0b88c7987 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Mon, 10 Aug 2026 04:19:24 +0800 Subject: [PATCH] ops: harden production data migration --- .../scripts/migrate-supabase-production.mjs | 56 +++++++++++++--- .../tests/production-data-migration.test.ts | 66 ++++++++++++++++++- 2 files changed, 112 insertions(+), 10 deletions(-) diff --git a/frontend/scripts/migrate-supabase-production.mjs b/frontend/scripts/migrate-supabase-production.mjs index be4d9ed0..4586cb17 100644 --- a/frontend/scripts/migrate-supabase-production.mjs +++ b/frontend/scripts/migrate-supabase-production.mjs @@ -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 ) { diff --git a/frontend/tests/production-data-migration.test.ts b/frontend/tests/production-data-migration.test.ts index b78c6e6f..8f554030 100644 --- a/frontend/tests/production-data-migration.test.ts +++ b/frontend/tests/production-data-migration.test.ts @@ -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)); });