164 lines
5.8 KiB
TypeScript
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);
|
|
});
|