Build GDPR compliance system: data export, account deletion, consent
- Data export (Art. 15/20): GET /api/gdpr/export serves a full JSON export of the user's data (credentials/tokens excluded, exclusions declared) - Right to erasure (Art. 17): self-service deletion with 30-day grace period (Settings -> Privacy & Data), cancellable; daily /api/cron/gdpr drain cancels Stripe billing, purges Spaces files, cascade-deletes the account, anonymizes consent rows, and writes audit evidence - Migration 0011: account_deletion_requests (partial unique index = one pending per user) + FK-less consent_log (survives erasure) - Consent: terms/privacy acceptance logged at signup (email + Google); cookie banner with analytics opt-out (umami.disabled), choices logged server-side for signed-in users via POST /api/gdpr/consent - Admin deleteUser upgraded to the same full purge (was leaving Spaces files and Stripe subscriptions orphaned) - /gdpr legal page now points at the self-service tools - scripts/verify-gdpr.ts: end-to-end verification vs live dev DB (22/22) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
0d11018019
commit
5a555c715e
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Exercises the REAL GDPR data-export and account-deletion paths against the
|
||||
* live (dev) DB to prove the system works end-to-end: seeds a throwaway user
|
||||
* with portfolio data + a stored file, exports, then runs the deletion drain
|
||||
* and asserts everything is gone (and the compliance evidence remains).
|
||||
* Run: npx tsx scripts/verify-gdpr.ts
|
||||
*/
|
||||
import { config } from "dotenv"
|
||||
config({ path: ".env.local" })
|
||||
process.env.DATABASE_SSL = process.env.DATABASE_SSL ?? "disable"
|
||||
|
||||
const TEST_USER_ID = "gdpr-verify-user"
|
||||
const TEST_EMAIL = "gdpr-verify@example.com"
|
||||
|
||||
let failures = 0
|
||||
function check(label: string, ok: boolean, detail?: string) {
|
||||
console.log(` ${ok ? "✓" : "✗"} ${label}${!ok && detail ? ` — ${detail}` : ""}`)
|
||||
if (!ok) failures++
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { db, pool } = await import("../lib/db")
|
||||
const s = await import("../lib/db/schema")
|
||||
const { eq, and } = await import("drizzle-orm")
|
||||
const storage = await import("../lib/storage")
|
||||
const { buildUserDataExport } = await import("../lib/gdpr/export")
|
||||
const { processDueDeletions } = await import("../lib/gdpr/delete")
|
||||
|
||||
// ── cleanup any prior run ──────────────────────────────────────────────────
|
||||
await db.delete(s.user).where(eq(s.user.id, TEST_USER_ID))
|
||||
await db.delete(s.consent_log).where(eq(s.consent_log.user_id, TEST_USER_ID))
|
||||
await db.delete(s.account_deletion_requests).where(eq(s.account_deletion_requests.user_id, TEST_USER_ID))
|
||||
await db.delete(s.admin_audit_log).where(eq(s.admin_audit_log.target_user_id, TEST_USER_ID))
|
||||
|
||||
// ── seed ───────────────────────────────────────────────────────────────────
|
||||
console.log("── Seeding throwaway user + portfolio ───")
|
||||
await db.insert(s.user).values({ id: TEST_USER_ID, name: "GDPR Verify", email: TEST_EMAIL })
|
||||
await db.insert(s.profiles).values({ id: TEST_USER_ID, email: TEST_EMAIL, full_name: "GDPR Verify" })
|
||||
const [prop] = await db
|
||||
.insert(s.properties)
|
||||
.values({ user_id: TEST_USER_ID, name: "Test House", address_line1: "1 Test St", city: "Testville" })
|
||||
.returning()
|
||||
const [tenant] = await db
|
||||
.insert(s.tenants)
|
||||
.values({ user_id: TEST_USER_ID, property_id: prop.id, first_name: "Tina", last_name: "Tenant", email: "tina@example.com" })
|
||||
.returning()
|
||||
await db.insert(s.rent_payments).values({
|
||||
user_id: TEST_USER_ID, tenant_id: tenant.id, property_id: prop.id, amount: 1200, due_date: "2026-07-01",
|
||||
})
|
||||
await db.insert(s.api_keys).values({
|
||||
user_id: TEST_USER_ID, name: "test key", key_hash: `hash-${Date.now()}`, key_prefix: "pmn_test",
|
||||
})
|
||||
await db.insert(s.consent_log).values([
|
||||
{ user_id: TEST_USER_ID, email: TEST_EMAIL, kind: "terms", granted: true, source: "signup" },
|
||||
{ user_id: TEST_USER_ID, email: TEST_EMAIL, kind: "privacy", granted: true, source: "signup" },
|
||||
])
|
||||
const { key: fileKey } = await storage.saveBuffer(Buffer.from("gdpr verify payload"), {
|
||||
userId: TEST_USER_ID, scope: "verify", ext: "txt",
|
||||
})
|
||||
console.log(` seeded property=${prop.id.slice(0, 8)} tenant=${tenant.id.slice(0, 8)} file=${fileKey}`)
|
||||
|
||||
// ── export ─────────────────────────────────────────────────────────────────
|
||||
console.log("── Data export (Articles 15/20) ─────────")
|
||||
const exp = await buildUserDataExport(TEST_USER_ID)
|
||||
check("subject user present", exp.data_subject.user?.email === TEST_EMAIL)
|
||||
check("profile present", exp.data_subject.profile?.id === TEST_USER_ID)
|
||||
check("property exported", exp.portfolio.properties.length === 1)
|
||||
check("tenant exported", exp.portfolio.tenants.length === 1)
|
||||
check("payment exported", exp.portfolio.rent_payments.length === 1)
|
||||
check("consent history exported", exp.privacy.consent_log.length === 2)
|
||||
check("api key exported WITHOUT hash",
|
||||
exp.automation.api_keys.length === 1 && !("key_hash" in exp.automation.api_keys[0]))
|
||||
const serialized = JSON.stringify(exp)
|
||||
check("no key_hash anywhere in export", !serialized.includes("key_hash"))
|
||||
check("no password field anywhere in export", !serialized.includes('"password"'))
|
||||
|
||||
// ── deletion drain ─────────────────────────────────────────────────────────
|
||||
console.log("── Deletion (Article 17) ────────────────")
|
||||
await db.insert(s.account_deletion_requests).values({
|
||||
user_id: TEST_USER_ID,
|
||||
email: null, // null → no completion email attempt from the drain
|
||||
scheduled_for: new Date(Date.now() - 60_000).toISOString(), // due 1 min ago
|
||||
})
|
||||
const { processed, deleted } = await processDueDeletions(50)
|
||||
check("drain picked up the request", processed >= 1)
|
||||
check("drain deleted the account", deleted >= 1)
|
||||
|
||||
const userAfter = await db.query.user.findFirst({ where: eq(s.user.id, TEST_USER_ID) })
|
||||
check("user row deleted", !userAfter)
|
||||
const profileAfter = await db.query.profiles.findFirst({ where: eq(s.profiles.id, TEST_USER_ID) })
|
||||
check("profile cascaded", !profileAfter)
|
||||
const propsAfter = await db.query.properties.findMany({ where: eq(s.properties.user_id, TEST_USER_ID) })
|
||||
check("properties cascaded", propsAfter.length === 0)
|
||||
const tenantsAfter = await db.query.tenants.findMany({ where: eq(s.tenants.user_id, TEST_USER_ID) })
|
||||
check("tenants cascaded", tenantsAfter.length === 0)
|
||||
const keysAfter = await db.query.api_keys.findMany({ where: eq(s.api_keys.user_id, TEST_USER_ID) })
|
||||
check("api keys cascaded", keysAfter.length === 0)
|
||||
|
||||
const request = await db.query.account_deletion_requests.findFirst({
|
||||
where: eq(s.account_deletion_requests.user_id, TEST_USER_ID),
|
||||
})
|
||||
check("request marked completed", request?.status === "completed")
|
||||
check("request email nulled", request?.email === null)
|
||||
|
||||
const consents = await db.query.consent_log.findMany({ where: eq(s.consent_log.user_id, TEST_USER_ID) })
|
||||
check("consent facts retained", consents.length === 2)
|
||||
check("consent PII anonymized", consents.every((c) => c.email === null && c.ip_address === null))
|
||||
|
||||
const audit = await db.query.admin_audit_log.findFirst({
|
||||
where: and(eq(s.admin_audit_log.target_user_id, TEST_USER_ID), eq(s.admin_audit_log.action, "gdpr_delete_account")),
|
||||
})
|
||||
check("audit evidence written (system action, admin_id null)", !!audit && audit.admin_id === null)
|
||||
|
||||
const bytesAfter = await storage.getUserStorageBytes(TEST_USER_ID)
|
||||
check("stored files purged", bytesAfter === 0, `${bytesAfter} bytes remain`)
|
||||
|
||||
// ── cleanup compliance rows from the test run ──────────────────────────────
|
||||
await db.delete(s.consent_log).where(eq(s.consent_log.user_id, TEST_USER_ID))
|
||||
await db.delete(s.account_deletion_requests).where(eq(s.account_deletion_requests.user_id, TEST_USER_ID))
|
||||
await db.delete(s.admin_audit_log).where(eq(s.admin_audit_log.target_user_id, TEST_USER_ID))
|
||||
|
||||
console.log(failures === 0 ? "\nALL CHECKS PASSED ✓" : `\n${failures} CHECK(S) FAILED ✗`)
|
||||
await pool.end()
|
||||
process.exit(failures === 0 ? 0 : 1)
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
Reference in New Issue
Block a user