7.5 KiB
DSGVO Bulk-Ops (Self-Service Export, Deletion, Pseudonymisation)
Phase: 10 — Compliance Polish
Status: active
Detail-MD: docs/improvement-plan/10-compliance-polish/dsgvo-bulk-ops.md
This document describes the runtime architecture of the DSGVO bulk-ops pipeline that delivers the user-facing rights under GDPR Art. 15, 17 and 20.
Schema
The User model carries six DSGVO flags (prisma/schema/accounts.prisma):
| Column | Purpose |
|---|---|
pendingDeletion |
True while the 30-day grace period is active; login is blocked. |
scheduledDeletionAt |
Deadline for the grace-period cron to trigger the cascade. |
deletionRequestedAt |
Timestamp the user (or admin) first hit the delete button. |
pseudonymized |
True after the cascade has rewritten PII to deterministic pseudonyms. |
pseudonymizedAt |
Timestamp of the pseudonymisation event. |
deletionRequestId |
Idempotency token shared with the worker pipeline and the AuditEvent metadata. |
The composite index (pendingDeletion, scheduledDeletionAt) keeps the
grace-period cron query O(log n).
User-FK Map
packages/db/src/dsgvo/user-fk-map.ts enumerates every model that
holds a foreign key (or logical reference) to User.id, paired with an
explicit cascade strategy:
| Strategy | Meaning |
|---|---|
hard-delete |
Row is deleted when the user is deleted. Used for join tables, ephemeral data, RBAC bindings. |
pseudonymize |
FK column is rewritten to the deterministic pseudonym hash. Used for audit-bound rows. |
retain-tombstone |
FK column is set to NULL (schema-level SetNull). Row outlives the user without a reference. |
AuditEvent.actorId is always pseudonymize, never hard-delete,
even when the user opted for deletion. SOC2-CC6.1 requires the audit
trail to survive a user-deletion event.
A regression test reads prisma/schema/*.prisma and asserts the map
is complete; future migrations that add a new User-FK without
updating the map fail CI before the cascade silently leaks PII.
Pseudonymisation
packages/db/src/dsgvo/pseudonymize.ts produces a deterministic
identity set derived from SHA-256(userId | pepper):
pseudonymize("u1") === pseudonymize("u1") // true
// → { pseudoId, pseudoEmail: "deleted-user-<hash>@pseudonymized.invalid", ... }
Determinism keeps grouped reports coherent without exposing PII. No external secret is mixed in by default — re-identification from the pseudonym alone is intentionally impossible (Art. 4(5) DSGVO).
anonymizeIp(ip) strips the last IPv4 octet (/24) or the IPv6
interface identifier (/48) for AuditEvent.actorIp rewrites.
Worker Pipeline
Three worker jobs (apps/worker/src/jobs/dsgvo-*.ts) execute the
heavy lifting:
1. dsgvo-export.ts
Streams a JSON bundle of every USER_FK_MAP table that opts into export
into the S3 multipart sink (@toolctrl/exports#createS3MultipartUploadSink).
Bundle layout:
{
"bundleVersion": "1.0",
"exportedAt": "2026-05-17T18:21:00Z",
"requestId": "uuid",
"user": { /* canonical User row */ },
"sections": {
"Log.userId": [ /* rows */ ],
"TenantUser.userId": [ /* rows */ ],
"...": []
}
}
After completion a presigned URL is minted (7-day validity), emailed
to the user and audited as EXPORT_PERSONAL_DATA with
regulatoryScope=["gdpr"], dataSensitivity="pii".
2. dsgvo-deletion.ts
Iterates USER_FK_MAP and applies the entry's declared strategy:
hard-delete→deleteMany({ where: { <column>: userId } }).pseudonymize→updateMany(..., { <column>: pseudonym.pseudoId }).retain-tombstone→updateMany(..., { <column>: null }).
The canonical User row is then either hard-deleted (mode=delete)
or rewritten via buildUserPseudonymizationUpdate (mode=pseudonymize).
Audited as DELETE_USER / PSEUDONYMIZE_USER.
3. dsgvo-verify.ts
Runs ~1h after the cascade. Queries every hard-delete entry in
USER_FK_MAP for stragglers. Clean run → outcome=success AuditEvent.
Stragglers → outcome=failure AuditEvent + escalation hook.
UI
| Route | Audience |
|---|---|
/app/<tenant>/settings/privacy |
Self-service (user) |
/admin/accounts/users/<id>/privacy |
Platform admin |
Both routes share the four intents (export, delete, pseudonymize
[admin-only], reactivate). Every intent writes an AuditEvent with
regulatoryScope=["gdpr"], dataSensitivity="pii" and either
actorType="user" or actorType="admin".
Admin actions additionally require a reason text (10+ chars) which
is captured in AuditEvent.metadata.reason for compliance evidence.
Login Guard
AuthService.loginFromRequest rejects logins for:
pendingDeletion = trueusers with HTTP 423 Locked.pseudonymized = trueusers with HTTP 410 Gone.
Both surface i18n message keys (auth.errors.accountPendingDeletion,
auth.errors.accountPseudonymized) which the UI can render as a
"reactivate?" screen.
Environment Variables
| Variable | Default | Purpose |
|---|---|---|
DSGVO_GRACE_PERIOD_DAYS |
30 |
Days between deletion request and cascade execution. |
DSGVO_EXPORT_BUCKET |
(operator-defined) | S3 bucket for personal-data export bundles. |
DSGVO_EXPORT_SIGNED_URL_VALIDITY_HOURS |
168 (7 days) |
Presigned URL TTL for export downloads. |
Retention Semantics
- Hard-deleted rows are gone. No backup-restore happens automatically.
- Pseudonymised rows survive indefinitely with no recoverable PII — re-identification is only possible via the encrypted PseudonymizationMap (out of scope for this initial implementation; reserved for a future task).
- AuditEvents always survive with
actorIdreplaced by the pseudonym hash. The hash-chain (packages/db/src/audit-hash.ts) stays intact.
Escalation Procedures
When the verifier worker reports stragglers, the operator should:
- Inspect the failure AuditEvent (
action=VERIFY_USER_DELETION,outcome=failure) to find the offending models. - Manually delete via
pnpm dsgvo:reveal(TBD) or Prisma Studio. - Re-run
dsgvo-verifyand confirmoutcome=success. - If the cascade itself failed, restore from backup before the deletion request and retry.
See also
docs/improvement-plan/10-compliance-polish/dsgvo-bulk-ops.md— full task spec and acceptance criteria.docs/features/compliance-audit.md— companion #67 audit-export.docs/compliance/gdpr.md— policy-level overview.