Zum Inhalt springen

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

RechtQuelleWas muss die App koennen
Right to be ForgottenDSGVO Art. 17User kann seine Loeschung anfordern, alle personenbezogenen Daten werden geloescht/anonymisiert
Data PortabilityDSGVO Art. 20User bekommt Export seiner Daten in maschinenlesbarem Format
Right of AccessDSGVO Art. 15User sieht welche Daten ueber ihn gespeichert sind
TransparenzDSGVO Art. 12-14User sieht wann/wie seine Daten verarbeitet wurden (Audit Log)
Apple Account DeletionApp Store Guideline 5.1.1(v)In-App Loeschungs-Funktion, Grace Period zulaessig
Google Account DeletionPlay Store PolicyIn-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-Extension
r.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-Log
GET /api/user/data-summary → welche Daten speichert die App ueber mich

Handler im Feature (QNs)

user-data-rights:write:request-export → Export anfordern
user-data-rights:write:request-deletion → Loeschung anfordern
user-data-rights:write:cancel-deletion → zurueckziehen
user-data-rights:query:my-audit-log → Audit-Log lesen
user-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 geloescht

Flow 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-Link

Export-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 Version

Jedes 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-executed

Delete 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 + Screen
r.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 Nachricht
r.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-Link
r.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

CheckFehler/Warning
Feature hat User-Referenz-Feld, nutzt aber userData Extension nichtWarning: “feature X referenziert user.id aber hat keinen userData Hook”
userData.export Hook wirft bei Boot-TestError: “Export-Hook von X schlaegt fehl”
userData.delete Hook ohne Strategy-ParameterError: “Delete-Hook muss strategy akzeptieren”
Preset-Config verweist auf ungueltigen PresetError

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

#SchrittStatusNotes
1.1r.extendsRegistrar("userData", { hooks: { export, delete } }) im neuen @kumiko/bundled-features/user-data-rightsExtension-Shell; sammelt Hooks, ohne Endpoint ist sie still
1.2POST /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.3GET /api/user/data-summary (aggregiert Hook-Counts ohne Daten zu materialisieren)
1.4POST /api/user/delete-account + POST /api/user/cancel-deletion + Grace-Period-JobStrategy laut Preset (delete vs. anonymize)
1.5files-Refactor zu defineFeatureBlocker 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.6Files 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 anonymizeinsertedById = null, binary bleibt
1.7User-Feature: r.useExtension("userData", "user", …)Profil-Felder, Login-Credentials
1.8Weitere User-Referenz-Features migrieren (Tenant-Membership, Delivery, Jobs-Audit, …)Boot-Warning führt sie schrittweise ein

Phase 2 — Transparenz + Notifications

#SchrittStatus
2.1GET /api/user/audit-log (eigenes Audit aus events-Tabelle gefiltert)
2.2Event-driven Notifications (deletionRequested, dataExportReady, deletionExecuted)
2.3Admin-Digests (upcoming-deletions-digest, daily-export-requests-digest)

Phase 3 — UI-Integration (mit UI-Build-Plan M2)

#SchrittStatus
3.1r.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.
  • filesStorageTrackingFeature bekommt requires("files") sobald files als Feature läuft.