Files
accounted/scripts/backfill-encrypt-personnummer.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

107 lines
4.0 KiB
TypeScript

/**
* One-off backfill: encrypt any employees.personnummer still stored as
* plaintext (12 digits) into aes-256-gcm ciphertext, matching the format
* written by encryptPersonnummer().
*
* WHY: the v1 REST create route used to store personnummer unencrypted, which
* 500'd every decrypt-on-read path with ERR_CRYPTO_INVALID_AUTH_TAG
* ("Invalid authentication tag length: 6"). The code fix stops new plaintext;
* this repairs the rows already in the DB and closes the GDPR at-rest gap.
* Run AFTER the code fix is deployed.
*
* Idempotent: only touches rows whose personnummer matches /^\d{12}$/, and each
* update is guarded on the exact plaintext value so a re-run or a concurrent
* write can never double-encrypt. Safe to re-run.
*
* Usage:
* npx tsx scripts/backfill-encrypt-personnummer.ts # dry run (read-only)
* npx tsx scripts/backfill-encrypt-personnummer.ts --confirm # performs the writes
*
* Reads NEXT_PUBLIC_SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY +
* PERSONNUMMER_ENCRYPTION_KEY from .env.local. Treat .env.local as pointing at
* PRODUCTION: the dry run is read-only; --confirm mutates PII.
*/
import { createClient } from '@supabase/supabase-js'
import { config as dotenv } from 'dotenv'
import { resolve } from 'node:path'
import { encryptPersonnummer } from '@/lib/salary/personnummer'
dotenv({ path: resolve(process.cwd(), '.env.local') })
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL
const SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!SUPABASE_URL || !SERVICE_KEY) {
console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local')
process.exit(1)
}
// Refuse to run without the real key: encrypting with the dev fallback key
// would make the values unreadable in production.
if (!process.env.PERSONNUMMER_ENCRYPTION_KEY) {
console.error(
'Missing PERSONNUMMER_ENCRYPTION_KEY. Refusing to run so rows are not encrypted with the dev fallback key.',
)
process.exit(1)
}
const CONFIRM = process.argv.includes('--confirm')
const PLAINTEXT = /^\d{12}$/
const sb = createClient(SUPABASE_URL, SERVICE_KEY, { auth: { persistSession: false } })
async function main() {
const host = new URL(SUPABASE_URL!).host
console.log(`Target: ${host} mode: ${CONFIRM ? 'WRITE (--confirm)' : 'DRY RUN (read-only)'}`)
const { data, error } = await sb
.from('employees')
.select('id, company_id, personnummer, personnummer_last4')
if (error) throw new Error(`select employees: ${error.message}`)
const rows = data ?? []
const plaintextRows = rows.filter((r) => PLAINTEXT.test(String(r.personnummer ?? '')))
const byCompany = new Map<string, number>()
for (const r of plaintextRows) {
byCompany.set(r.company_id, (byCompany.get(r.company_id) ?? 0) + 1)
}
console.log(
`Scanned ${rows.length} employees; ${plaintextRows.length} plaintext across ${byCompany.size} companies.`,
)
for (const [companyId, n] of byCompany) console.log(` company ${companyId}: ${n} row(s)`)
if (plaintextRows.length === 0) {
console.log('Nothing to backfill.')
return
}
if (!CONFIRM) {
console.log('\nDRY RUN: no writes performed. Re-run with --confirm to encrypt these rows.')
return
}
let updated = 0
for (const r of plaintextRows) {
const plaintext = String(r.personnummer)
const last4 = plaintext.slice(-4)
const patch: Record<string, string> = { personnummer: encryptPersonnummer(plaintext) }
if (r.personnummer_last4 !== last4) patch.personnummer_last4 = last4
// Guard on the still-plaintext value: makes the update idempotent and safe
// against a concurrent write (never double-encrypts).
const { error: upErr } = await sb
.from('employees')
.update(patch)
.eq('id', r.id)
.eq('personnummer', plaintext)
if (upErr) throw new Error(`update ${r.id}: ${upErr.message}`)
updated++
}
console.log(`\nEncrypted ${updated} row(s). Re-run without --confirm to verify 0 remain.`)
}
main().catch((e) => {
console.error(e)
process.exit(1)
})