173 lines
7.5 KiB
Markdown
173 lines
7.5 KiB
Markdown
# 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-<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:
|
|
|
|
```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: { <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 = 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.
|