ops: harden production data migration
This commit is contained in:
@@ -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));
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user