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
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user