User Data Rights — DSGVO, Apple/Google Compliance
Loeschung, Export und Transparenz sind rechtliche Pflichten. Jede App muss das koennen — deshalb liefert das Framework es als Core-Feature, damit Feature-Devs sich nicht damit beschaeftigen muessen.
Rechtliche Anforderungen
| Recht | Quelle | Was muss die App koennen |
|---|---|---|
| Right to be Forgotten | DSGVO Art. 17 | User kann seine Loeschung anfordern, alle personenbezogenen Daten werden geloescht/anonymisiert |
| Data Portability | DSGVO Art. 20 | User bekommt Export seiner Daten in maschinenlesbarem Format |
| Right of Access | DSGVO Art. 15 | User sieht welche Daten ueber ihn gespeichert sind |
| Transparenz | DSGVO Art. 12-14 | User sieht wann/wie seine Daten verarbeitet wurden (Audit Log) |
| Apple Account Deletion | App Store Guideline 5.1.1(v) | In-App Loeschungs-Funktion, Grace Period zulaessig |
| Google Account Deletion | Play Store Policy | In-App Loeschungs-Funktion, 30 Tage Grace Period |
Architektur-Prinzip
Jedes Feature das User-Daten speichert haengt sich an eine zentrale Extension. Das Framework orchestriert Export und Loeschung — jedes Feature liefert nur seinen Teil.
// Framework-Core: Registrar-Extensionr.extendsRegistrar("userData", { hooks: { export: (userId, ctx) => Promise<UserDataExport>, delete: (userId, strategy, ctx) => Promise<void>, },});
// Jedes Feature nutzt sie:r.useExtension("userData", "task", { export: async (userId, ctx) => { ... }, delete: async (userId, strategy, ctx) => { ... },});Boot-Regel: Jedes Feature mit Feldern die auf User referenzieren (user.id, createdBy, assigneeId, etc.) muss die userData-Extension nutzen. Fehlt sie → Boot-Warning.
Core-Feature: user-data-rights
Das Framework liefert user-data-rights als Core-Feature. Andere Features haengen sich nur ein.
Endpoints
POST /api/user/data-export → startet Export-Job, Response: { jobId }POST /api/user/delete-account → markiert User zur Loeschung (Grace Period)POST /api/user/cancel-deletion → zieht Loeschung zurueck (innerhalb Grace Period)GET /api/user/audit-log → eigenes Audit-LogGET /api/user/data-summary → welche Daten speichert die App ueber michHandler im Feature (QNs)
user-data-rights:write:request-export → Export anfordernuser-data-rights:write:request-deletion → Loeschung anfordernuser-data-rights:write:cancel-deletion → zurueckziehenuser-data-rights:query:my-audit-log → Audit-Log lesenuser-data-rights:query:my-data-summary → Uebersicht
user-data-rights:event:export-ready → Export fertig (Download-Link)user-data-rights:event:deletion-executed → Nach Grace Period geloeschtFlow 1: Data Export
User klickt "Meine Daten anfordern" → POST /api/user/data-export → Handler startet Job "export-user-data" → Job ruft alle userData:export Hooks auf → Sammelt Daten pro Feature in JSON-Dateien → ZIP-Archiv wird via Files-Feature gespeichert → Signed Download-URL (gueltig 7 Tage) → Event: user-data-rights:event:export-ready → Notification: Email an User mit Download-LinkExport-Struktur
export-{userId}-{timestamp}.zip├── profile.json ← vom user-Feature├── tasks.json ← vom tasks-Feature├── orders.json ← vom orders-Feature├── audit-log.json ← vom audit-Feature├── files/ ← vom files-Feature (eigentliche Dateien)│ ├── invoice-2024-001.pdf│ └── avatar.jpg└── _manifest.json ← Framework-generiert: was ist drin, welche VersionJedes Feature entscheidet selbst welche Daten es exportiert. Framework prueft beim Boot dass alle User-Referenz-Features die Extension implementiert haben.
Flow 2: Account Deletion
User klickt "Konto loeschen" → POST /api/user/delete-account → User wird markiert: deletionRequested=true, gracePeriodEnd=now+30d → Grace Period laeuft (User kann zurueckziehen) → Event: user-data-rights:event:deletion-requested (sofort) → Notification: "Konto wird in 30 Tagen geloescht" an User
Taeglicher Job checkt abgelaufene Grace Periods: → Fuer jeden User mit gracePeriodEnd < now(): → Ruft alle userData:delete Hooks auf → Strategy laut Preset: "delete" oder "anonymize" → User-Entity selbst: geloescht oder anonymisiert → Event: user-data-rights:event:deletion-executedDelete vs. Anonymize
Jedes Feature entscheidet pro Datensatz was Sinn ergibt:
r.useExtension("userData", "task", { delete: async (userId, strategy, ctx) => { if (strategy === "delete") { // Alle Tasks des Users hart loeschen await ctx.db.delete(tasks).where(eq(tasks.createdBy, userId)); } else { // Anonymize: Task bleibt (ggf. anderen zugeordnet), User-Referenz weg await ctx.db .update(tasks) .set({ createdBy: null, createdByName: "[Geloeschter Nutzer]" }) .where(eq(tasks.createdBy, userId)); } },});Faustregel:
- Persoenliche Daten (Profil, Einstellungen, private Notizen) → delete
- Geteilte Daten (Tasks, Kommentare in geteilten Projekten) → anonymize (Erhalt fuer andere User)
- Rechnungen/Vertraege → anonymize + Aufbewahrungspflicht (AO/HGB)
Was UEBERLEBT die Loeschung
- Audit-Log Eintraege — rechtliche Nachvollziehbarkeit (gekuerzt, nur User-ID, keine Inhalte)
- Rechnungen/Buchhaltung — Aufbewahrungspflicht (10 Jahre)
- Anonymisierte aggregierte Daten — OK ohne Personenbezug
Flow 3: Audit Log pro User
User kann jederzeit sein eigenes Audit-Log einsehen — nicht nur bei Loeschung/Export:
// Core-Feature registriert Query + Screenr.queryHandler("my-audit-log", z.object({ limit: z.number().optional(), cursor: z.string().optional(),}), async (query, ctx) => { return ctx.db .select() .from(auditLog) .where(eq(auditLog.userId, ctx.user.id)) .orderBy(desc(auditLog.createdAt)) .limit(query.payload.limit ?? 50);});
r.screen({ id: "my-audit", type: "custom", renderer: { react: MyAuditLogScreen },});Transparenz ist Teil der DSGVO (Art. 15) — ohne muss der User Daten per Brief anfordern. Mit ist’s eine Sidebar-Seite.
Presets: default vs. dsgvo
Cleanup-Preset bestimmt Grace-Period-Laengen und Delete/Anonymize-Strategie:
r.definePreset("cleanup", { default: { deletionGracePeriod: { days: 30 }, userDataStrategy: "anonymize", // wo moeglich, Daten anonymisieren auditLogRetention: { months: 24 }, dataExportRetention: { days: 7 }, }, dsgvo: { deletionGracePeriod: { days: 30 }, userDataStrategy: "delete", // hart loeschen wo moeglich auditLogRetention: { months: 3 }, // minimal noetige Speicherung dataExportRetention: { days: 7 }, },});Tenant-Admin waehlt im Config zwischen default, dsgvo oder custom Overrides.
Notifications: alles event-driven
Das Notification-System (Delivery-Feature) hoert auf Events via r.notification({ trigger }). Damit bekommt man automatisch Benachrichtigungen fuer alle Data-Rights-Events ohne manuelles Verdrahten.
Event-driven (sofort)
// User loescht sein Konto → Admin bekommt sofort Nachrichtr.notification("deletionRequested", { trigger: { on: "user-data-rights:write:request-deletion" }, recipient: async (result, ctx) => getAllAdminIds(ctx), data: (result) => ({ title: "Loeschungs-Anfrage", body: `User ${result.data.userId} hat Kontoloeschung angefordert`, }),});
// Export fertig → User bekommt Download-Linkr.notification("dataExportReady", { trigger: { on: "user-data-rights:event:export-ready" }, recipient: (result) => result.data.userId, data: (result) => ({ title: "Deine Daten sind bereit", body: "Download-Link gueltig fuer 7 Tage", downloadUrl: result.data.url, }),});
// Nach Grace Period geloescht → User-Info per Email (falls noch erreichbar)r.notification("deletionExecuted", { trigger: { on: "user-data-rights:event:deletion-executed" }, recipient: (result) => result.data.userId, data: (result) => ({ title: "Konto geloescht", body: "Dein Konto wurde wie angefordert geloescht.", }),});Aggregate/Digest (zeitbasiert)
Fuer taegliche Zusammenfassungen gibt’s kein Event — das ist ein Job. Die Benachrichtigung selbst bleibt deklarativ via Notification:
// Admin-Digest: wer wird in 7 Tagen geloescht?r.job("upcoming-deletions-digest", { trigger: { cron: "0 9 * * *" },}, async (ctx) => { const soon = await ctx.db .select() .from(users) .where(and( eq(users.deletionRequested, true), between(users.gracePeriodEnd, now(), addDays(now(), 7)), ));
if (soon.length === 0) return;
const admins = await getAllAdminIds(ctx); await ctx.notify.send("admin-notifications:notify:upcoming-deletions", { to: admins, data: { users: soon, count: soon.length }, });});
// Notification selbst (manual triggered, keine trigger.on)r.notification("upcomingDeletions", { manual: true, data: (ctx) => ({ title: `${ctx.count} User werden bald geloescht`, body: ctx.users.map(u => u.email).join(", "), }),});
// Daily Digest: wer hat gestern Export angefordert?r.job("daily-export-requests-digest", { trigger: { cron: "0 8 * * *" },}, async (ctx) => { const yesterday = await ctx.db .select() .from(exportRequests) .where(gte(exportRequests.createdAt, subDays(now(), 1)));
if (yesterday.length === 0) return;
await ctx.notify.send("admin-notifications:notify:export-digest", { to: await getAllAdminIds(ctx), data: { count: yesterday.length, requests: yesterday }, });});Was der Dev machen MUSS
Jedes Feature mit User-Referenzen:
defineFeature("tasks", (r) => { r.entity("task", createEntity({ fields: { title: createTextField(), createdBy: createNumberField(), // → User-Referenz! }, }));
// PFLICHT: Extension nutzen, sonst Boot-Warning r.useExtension("userData", "task", { export: async (userId, ctx) => { return ctx.db.select().from(tasks).where(eq(tasks.createdBy, userId)); }, delete: async (userId, strategy, ctx) => { if (strategy === "delete") { await ctx.db.delete(tasks).where(eq(tasks.createdBy, userId)); } else { await ctx.db.update(tasks).set({ createdBy: null }).where(eq(tasks.createdBy, userId)); } }, });});Das ist alles. Drei Zeilen Boilerplate pro Feature — der Rest passiert automatisch.
Was der Dev NICHT machen muss
- Eigene Loesch-Logik pro Feature bauen
- Export-Format definieren
- Grace-Period-Logik implementieren
- Benachrichtigungen verdrahten
- Apple/Google Compliance selbst pruefen
- Audit-Log-Infrastruktur bauen
- Loesch-Reihenfolge bei Fremdschluessel-Abhaengigkeiten orchestrieren
Boot-Validation
| Check | Fehler/Warning |
|---|---|
Feature hat User-Referenz-Feld, nutzt aber userData Extension nicht | Warning: “feature X referenziert user.id aber hat keinen userData Hook” |
userData.export Hook wirft bei Boot-Test | Error: “Export-Hook von X schlaegt fehl” |
userData.delete Hook ohne Strategy-Parameter | Error: “Delete-Hook muss strategy akzeptieren” |
| Preset-Config verweist auf ungueltigen Preset | Error |
UI-Integration
Das Core-Feature user-data-rights bringt eigene UI-Sections mit die in der Profil-Seite erscheinen:
// Screen Extension haengt sich automatisch an User-Profile Screen:r.screenExtension({ target: { screen: "user-profile:screen:profile" }, position: "bottom", component: { react: DataRightsSection, },});Dev muss nichts machen — sobald user-data-rights als Feature dabei ist, sieht der User:
- “Meine Daten anfordern” Button
- “Konto loeschen” Button
- “Mein Audit-Log” Link
Checkliste fuer neue Features
Wenn ein neues Feature User-Daten speichert:
-
r.useExtension("userData", "entity", { export, delete })registriert - Export liefert JSON mit allen User-bezogenen Daten
- Delete unterstuetzt
"delete"und"anonymize"Strategy - Tests fuer Export (Snapshot der JSON-Struktur)
- Tests fuer Delete (User weg, Daten weg/anonymisiert)
Das ist der komplette Aufwand. Keine Copy-Paste-Arbeit, kein “vergessen wir das dieses Mal bitte nicht” — Boot-Validation zwingt’s.
Umsetzungs-Plan
Das user-data-rights Core-Feature existiert noch nicht. Sobald ein Sprint es baut, ziehen mehrere Features ihre bislang aufgeschobenen Extensions mit — im Besonderen Files 1.11.
Phase 1 — Extension-Provider + Export-Flow
| # | Schritt | Status | Notes |
|---|---|---|---|
| 1.1 | r.extendsRegistrar("userData", { hooks: { export, delete } }) im neuen @kumiko/bundled-features/user-data-rights | ❌ | Extension-Shell; sammelt Hooks, ohne Endpoint ist sie still |
| 1.2 | POST /api/user/data-export + Job export-user-data (ruft alle userData:export Hooks, ZIP via files) | ❌ | Braucht files-Feature als defineFeature (siehe 1.5) |
| 1.3 | GET /api/user/data-summary (aggregiert Hook-Counts ohne Daten zu materialisieren) | ❌ | |
| 1.4 | POST /api/user/delete-account + POST /api/user/cancel-deletion + Grace-Period-Job | ❌ | Strategy laut Preset (delete vs. anonymize) |
| 1.5 | files-Refactor zu defineFeature | ❌ | Blocker für 1.2 und Files-1.11. Routes aus buildServer raus, fileRefsTable als r.entity("fileRef", …), storageProvider bleibt App-weit (ServerOption, kein Extension) |
| 1.6 | Files 1.11: r.useExtension("userData", "fileRef", { export, delete }) | ❌ | Export: Metadata + Signed-URLs pro File. Delete: strategy delete → storage-binary + FileRef-Row weg + ctx.archiveStream auf Aggregate; strategy anonymize → insertedById = null, binary bleibt |
| 1.7 | User-Feature: r.useExtension("userData", "user", …) | ❌ | Profil-Felder, Login-Credentials |
| 1.8 | Weitere User-Referenz-Features migrieren (Tenant-Membership, Delivery, Jobs-Audit, …) | ❌ | Boot-Warning führt sie schrittweise ein |
Phase 2 — Transparenz + Notifications
| # | Schritt | Status |
|---|---|---|
| 2.1 | GET /api/user/audit-log (eigenes Audit aus events-Tabelle gefiltert) | ❌ |
| 2.2 | Event-driven Notifications (deletionRequested, dataExportReady, deletionExecuted) | ❌ |
| 2.3 | Admin-Digests (upcoming-deletions-digest, daily-export-requests-digest) | ❌ |
Phase 3 — UI-Integration (mit UI-Build-Plan M2)
| # | Schritt | Status |
|---|---|---|
| 3.1 | r.screenExtension für Profil-Seite (Data-Rights-Section) | ❌ |
| 3.2 | ”Meine Daten anfordern” + “Konto löschen” + “Mein Audit-Log” Screens | ❌ |
Integrations-Checkliste (Sprint-Start)
- Vor Sprint-Start: welche Delete-Strategie für FileRef-Events? Entscheidung zwischen
ctx.archiveStream(Marten-Gold-Standard) vs. physisches Event-Delete. Bisherige Neigung: archivieren, damit der Audit-Pfad konsistent mit anderen Aggregates bleibt. -
files-Refactor nicht vorziehen — er landet zusammen mit dem Extension-Usage, sonst verdoppelt sich die Test-Stack-Arbeit. -
filesStorageTrackingFeaturebekommtrequires("files")sobald files als Feature läuft.