# 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): ```ts pseudonymize("u1") === pseudonymize("u1") // true // → { pseudoId, pseudoEmail: "deleted-user-@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: ```jsonc { "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: { : userId } })`. - `pseudonymize` → `updateMany(..., { : pseudonym.pseudoId })`. - `retain-tombstone` → `updateMany(..., { : 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//settings/privacy` | Self-service (user) | | `/admin/accounts/users//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 = true` users with HTTP 423 Locked. - `pseudonymized = true` users 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 `actorId` replaced 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: 1. Inspect the failure AuditEvent (`action=VERIFY_USER_DELETION`, `outcome=failure`) to find the offending models. 2. Manually delete via `pnpm dsgvo:reveal` (TBD) or Prisma Studio. 3. Re-run `dsgvo-verify` and confirm `outcome=success`. 4. 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.