Files
HiveGrid-Wireframes/hivegrid-cloud/docs/features/dsgvo-bulk-ops.md
T

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 = 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.