Compliance & DSGVO
Four features that implement DSGVO Art. 15 (Auskunft), Art. 17
(Löschung mit Grace), Art. 18 (Restriction), and Art. 20 (Portabilität)
for any multi-tenant Kumiko app. Domain entities join the pipeline through
two hooks, export and delete. The framework provides the forget cron,
export ZIP, magic-link delivery, and auth middleware guard.
compliance-profiles
Section titled “compliance-profiles”Status: ✅ Stable
What: Region-specific defaults for grace periods, supervisory
authorities, notification languages, audit-log retention, sub-processor
contracts. Tenant-Admin picks one profile during onboarding; every
consumer feature reads compliance.forTenant(tenantId) and gets
profile-aware behaviour automatically.
Profiles shipped:
| Profile | Region | Authority | Languages | Tenant-Destroy-Grace |
|---|---|---|---|---|
eu-dsgvo | DACH (default) | BlnBDI Berlin | de, en | 30 days |
swiss-dsg (extends eu-dsgvo) | CH | EDÖB Bern | de, fr, it, en | 30 days |
de-hr-dsgvo-hgb (extends eu-dsgvo) | DE-HR | Landes-Datenschutzbehörde | de | 60 days (HR-Override) |
minimal-no-region | , | (migration edge-case) | , | , |
Per-tenant overrides via override.userRights.gracePeriod={ days: N }
keep the rest of the profile inherited.
Example:
import { createComplianceProfilesFeature } from "@cosmicdrift/kumiko-bundled-features/compliance-profiles";
await runDevApp({ features: [createComplianceProfilesFeature(), myFeature],});
// Tenant-Admin sets profile via the dispatcherr.queryHandler({ name: "settings", handler: async (_q, ctx) => { const profile = await ctx.queryAs(ctx.user, "compliance-profiles:query:for-tenant", {}); return { gracePeriodDays: profile.userRights.gracePeriod.days }; },});data-retention
Section titled “data-retention”Status: ✅ Stable
What: Retention policy resolver per Entity in 3 layers (Entity-
Default → Tenant-Preset → Tenant-Override). Cleanup-Cron deletes or
anonymizes rows after keepFor expires; supports blockDelete for
legal-hold periods (HGB, Steuer-Aufbewahrungspflicht).
How it works: Add retention: { keepFor: "90d", strategy: "hardDelete", reference: "createdAt" }
to createEntity({...}). The resolver picks the most specific policy;
cleanup-runner iterates due rows in batches.
Strategies:
hardDelete, row goneanonymize, the per-fieldanonymizefunctions run, row stays (multi-user-refs and business data intact). Requiresanonymize: () => ...on the PII fields, a policy without any anonymize-annotated field is reported as skipped (missing_anonymize_fields), and the boot validator warns about it.blockDelete, legal hold: rows stay untouched whilekeepForruns (HR/Steuer-Aufbewahrungspflicht,de-hr-dsgvo-hgbsets 10y for invoices). A user-forget during the hold anonymizes instead of deleting. After the hold expires the anonymize functions run : the row is never deleted, only its person link goes.
Example:
const invoiceEntity = createEntity({ fields: { amount: createNumberField({ required: true }), // person link: overwritten after the 10y hold expires (and on // user-forget during the hold); the row itself is never deleted. customerName: createTextField({ anonymize: () => "[ANONYMIZED]" }), }, retention: { keepFor: "10y", strategy: "blockDelete", reference: "createdAt" },});user-data-rights
Section titled “user-data-rights”Status: ✅ Stable
What: Full DSGVO pipeline, request-export, request-deletion + cancel, restrict-account + lift, my-audit-log, list-download-attempts (DPO operator-query). Plus the async export-job worker (ZIP streaming + storage upload + signed magic-link with SHA-256 hash + edge-rate-limit
- 90d brute-force-detection-audit).
How it works: Domain features hook into EXT_USER_DATA with two
hooks per entity:
defineFeature("notes", (r) => { r.requires("user-data-rights"); r.useExtension(EXT_USER_DATA, "note", { export: async (ctx) => ({ entity: "note", rows: await ctx.db.select().from(notesTable) .where(and(eq(notesTable.tenantId, ctx.tenantId), eq(notesTable.authorId, ctx.userId))), }), delete: async (ctx, strategy) => { const where = and(eq(notesTable.tenantId, ctx.tenantId), eq(notesTable.authorId, ctx.userId)); if (strategy === "anonymize") { await ctx.db.update(notesTable).set({ authorId: null }).where(where); } else { await ctx.db.delete(notesTable).where(where); } }, });});That’s it. Forget-Cron iterates all EXT_USER_DATA providers,
calls the strategy from retention.policyFor, anonymizes the user
row. Export-Job builds the ZIP, sends a Magic-Link.
Endpoints shipped:
| Article | Handler / Cron | Who |
|---|---|---|
| Art. 15 | user-data-rights:query:my-audit-log | User (account-wide event history) |
| Art. 15 + 20 | user-data-rights:write:request-export | User (async ZIP + magic-link) |
| Art. 15 + 20 | GET /user-export/by-token | anonymous (magic-link, multi-use within TTL) |
| Art. 15 + 20 | GET /user-export/by-job/:jobId | User (session-auth, cross-tenant-same-user) |
| Art. 17 | user-data-rights:write:request-deletion + cancel-deletion | User |
| Art. 17 | run-forget-cleanup | Cron (after grace) |
| Art. 18 | user-data-rights:write:restrict-account + lift-restriction | Admin / SystemAdmin |
| Operator | user-data-rights:query:list-download-attempts | Admin / DPO |
user-data-rights-defaults
Section titled “user-data-rights-defaults”Status: ✅ Stable
What: Default EXT_USER_DATA hooks for every bundled entity that
holds per-user data:
user, anonymizes the row with a sentinel email (deleted-<id>@anonymized.invalid), replacesdisplayName, nullspasswordHash, blocks loginfileRef, deletes file rows + storage binaries ondelete, keeps row + nullsinsertedByIdonanonymizeuser-session(ip, userAgent), hard-deleteapi-token, hard-delete (which also revokes the token)in-app-message, hard-deletetenant-invitation, invitee rows forgotten/pseudonymized, inviter link severednotification-preference+ user-scopedconfig-value, purged via the executor forget verb
Hooks for optional source features (sessions, personal-access-tokens, channel-in-app, tenant, delivery, config) no-op when the feature isn’t mounted, mounting the defaults never forces you to mount the sources.
Why it’s optional: App-authors with custom user-anonymize policies (e.g. soft-delete with retention) can leave this feature out and register their own hooks. 95% of apps just mount it.
Example:
await runDevApp({ features: [ createComplianceProfilesFeature(), createDataRetentionFeature(), createUserDataRightsFeature(), createUserDataRightsDefaultsFeature(), // covers the bundled entities above notesFeature, // your domain ], auth: { jwtSecret: process.env["JWT_SECRET"]! },});See also
Section titled “See also”- Recipe:
samples/recipes/user-data-rights/, minimal Notes-domain withEXT_USER_DATA-Hooks, 3 tests - Sample-App:
samples/apps/user-data-rights-demo/, runnable dev-server with todos-domain - Compliance-Operator-Guide:
packages/bundled-features/src/user-data-rights/COMPLIANCE.md, Verarbeitungsverzeichnis material + AVV-TOM list + Datenschutzerklärung snippets - Cross-Data-Matrix Test:
packages/bundled-features/src/user-data-rights/__tests__/cross-data-matrix.integration.ts, pins the 3-provider integration (user + fileRef + custom-domain)
What this framework does NOT cover
Section titled “What this framework does NOT cover”App-author responsibility (legal, not technical):
- Selecting + AVV with concrete storage/email providers
- Data-breach reporting to supervisory authority
- DSFA (Datenschutz-Folgenabschätzung), the framework provides TOM inputs, the assessment is yours
- Cookie-consent layer (frontend / separate feature)
- Tenant-lifecycle destroy (Sprint 5, separate feature)
Known technical limitation:
- Event payloads written before crypto-shredding keep their plaintext.
Kumiko is event-sourced; forget rewrites the read side and appends
anonymized events. Fields annotated
pii/userOwned/tenantOwnedare encrypted under a per-subject key when a KMS adapter is configured (runProdApp({ kms })), forget erases that key, which makes the values unreadable in rows and events (crypto-shredding, see the concept page and the crypto-shredding sample). Plaintext payloads appended before the KMS was active keep their original bytes; the backfill for those and coverage for customctx.appendEventpayloads are tracked in kumiko-framework#799.