rspace-online/src/encryptid/migrations/encrypt-pii.ts

164 lines
5.8 KiB
TypeScript

#!/usr/bin/env bun
/**
* Backfill migration: encrypt PII fields at rest
*
* Usage: docker exec encryptid bun run src/encryptid/migrations/encrypt-pii.ts
*
* Idempotent — only processes rows where _enc IS NULL.
* Batched for notifications (500/batch), unbatched for smaller tables.
*/
import postgres from 'postgres';
import { encryptField, hashForLookup } from '../server-crypto';
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
console.error('DATABASE_URL required');
process.exit(1);
}
const sql = postgres(DATABASE_URL, { max: 5 });
async function backfillUsers() {
const rows = await sql`
SELECT id, email, profile_email, bio, avatar_url, wallet_address, up_address
FROM users WHERE email_enc IS NULL AND (email IS NOT NULL OR bio IS NOT NULL OR avatar_url IS NOT NULL OR wallet_address IS NOT NULL OR up_address IS NOT NULL)
`;
console.log(`[users] ${rows.length} rows to backfill`);
for (const row of rows) {
const [emailEnc, emailHash, profileEmailEnc, bioEnc, avatarUrlEnc, walletEnc, upEnc, upHash] = await Promise.all([
encryptField(row.email),
row.email ? hashForLookup(row.email) : null,
encryptField(row.profile_email),
encryptField(row.bio),
encryptField(row.avatar_url),
encryptField(row.wallet_address),
encryptField(row.up_address),
row.up_address ? hashForLookup(row.up_address) : null,
]);
await sql`
UPDATE users SET
email_enc = ${emailEnc}, email_hash = ${emailHash},
profile_email_enc = ${profileEmailEnc},
bio_enc = ${bioEnc}, avatar_url_enc = ${avatarUrlEnc},
wallet_address_enc = ${walletEnc},
up_address_enc = ${upEnc}, up_address_hash = ${upHash}
WHERE id = ${row.id}
`;
}
console.log(`[users] done`);
}
async function backfillGuardians() {
const rows = await sql`
SELECT id, name, email FROM guardians WHERE name_enc IS NULL
`;
console.log(`[guardians] ${rows.length} rows to backfill`);
for (const row of rows) {
const [nameEnc, emailEnc] = await Promise.all([
encryptField(row.name),
encryptField(row.email),
]);
await sql`UPDATE guardians SET name_enc = ${nameEnc}, email_enc = ${emailEnc} WHERE id = ${row.id}`;
}
console.log(`[guardians] done`);
}
async function backfillIdentityInvites() {
const rows = await sql`
SELECT id, email, message FROM identity_invites WHERE email_enc IS NULL
`;
console.log(`[identity_invites] ${rows.length} rows to backfill`);
for (const row of rows) {
const [emailEnc, emailHash, messageEnc] = await Promise.all([
encryptField(row.email),
hashForLookup(row.email),
encryptField(row.message),
]);
await sql`UPDATE identity_invites SET email_enc = ${emailEnc}, email_hash = ${emailHash}, message_enc = ${messageEnc} WHERE id = ${row.id}`;
}
console.log(`[identity_invites] done`);
}
async function backfillSpaceInvites() {
const rows = await sql`
SELECT id, email FROM space_invites WHERE email_enc IS NULL AND email IS NOT NULL
`;
console.log(`[space_invites] ${rows.length} rows to backfill`);
for (const row of rows) {
const emailEnc = await encryptField(row.email);
await sql`UPDATE space_invites SET email_enc = ${emailEnc} WHERE id = ${row.id}`;
}
console.log(`[space_invites] done`);
}
async function backfillNotifications() {
const BATCH = 500;
let offset = 0;
let total = 0;
while (true) {
const rows = await sql`
SELECT id, title, body, actor_username FROM notifications WHERE title_enc IS NULL
ORDER BY created_at LIMIT ${BATCH} OFFSET ${offset}
`;
if (rows.length === 0) break;
total += rows.length;
console.log(`[notifications] processing batch at offset ${offset} (${rows.length} rows)`);
await Promise.all(rows.map(async (row) => {
const [titleEnc, bodyEnc, actorEnc] = await Promise.all([
encryptField(row.title),
encryptField(row.body),
encryptField(row.actor_username),
]);
await sql`UPDATE notifications SET title_enc = ${titleEnc}, body_enc = ${bodyEnc}, actor_username_enc = ${actorEnc} WHERE id = ${row.id}`;
}));
offset += BATCH;
}
console.log(`[notifications] ${total} rows done`);
}
async function backfillFundClaims() {
const rows = await sql`
SELECT id, email, wallet_address FROM fund_claims WHERE email_enc IS NULL AND (email IS NOT NULL OR wallet_address IS NOT NULL)
`;
console.log(`[fund_claims] ${rows.length} rows to backfill`);
for (const row of rows) {
const [emailEnc, emailHmac, walletEnc] = await Promise.all([
encryptField(row.email),
row.email ? hashForLookup(row.email) : null,
encryptField(row.wallet_address),
]);
await sql`UPDATE fund_claims SET email_enc = ${emailEnc}, email_hmac = ${emailHmac}, wallet_address_enc = ${walletEnc} WHERE id = ${row.id}`;
}
console.log(`[fund_claims] done`);
}
async function main() {
console.log('=== PII Encryption Backfill ===');
console.log(`Time: ${new Date().toISOString()}`);
await backfillUsers();
await backfillGuardians();
await backfillIdentityInvites();
await backfillSpaceInvites();
await backfillNotifications();
await backfillFundClaims();
// Verification counts
const [usersLeft] = await sql`SELECT COUNT(*)::int as c FROM users WHERE email IS NOT NULL AND email_enc IS NULL`;
const [guardiansLeft] = await sql`SELECT COUNT(*)::int as c FROM guardians WHERE name_enc IS NULL`;
const [notifLeft] = await sql`SELECT COUNT(*)::int as c FROM notifications WHERE title_enc IS NULL`;
console.log('\n=== Verification ===');
console.log(`Users without email_enc: ${usersLeft.c}`);
console.log(`Guardians without name_enc: ${guardiansLeft.c}`);
console.log(`Notifications without title_enc: ${notifLeft.c}`);
await sql.end();
console.log('\nDone.');
}
main().catch(err => {
console.error('Migration failed:', err);
process.exit(1);
});