Skip to content

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.

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:

ProfileRegionAuthorityLanguagesTenant-Destroy-Grace
eu-dsgvoDACH (default)BlnBDI Berlinde, en30 days
swiss-dsg (extends eu-dsgvo)CHEDÖB Bernde, fr, it, en30 days
de-hr-dsgvo-hgb (extends eu-dsgvo)DE-HRLandes-Datenschutzbehördede60 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 dispatcher
r.queryHandler({
name: "settings",
handler: async (_q, ctx) => {
const profile = await ctx.dispatch.queryAs(ctx.user, "compliance-profiles:query:for-tenant", {});
return { gracePeriodDays: profile.userRights.gracePeriod.days };
},
});

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.

Tenant preset: When compliance-profiles is mounted, the tenant’s profile picks the preset: eu-dsgvo uses dsgvo-basic, de-hr-dsgvo-hgb uses dsgvo-hgb, swiss-dsg uses swiss-dsg, and minimal-no-region uses default, which triggers no automatic action. A tenant that has not chosen a profile counts as minimal-no-region. Without compliance-profiles, only entity defaults and tenant overrides apply.

The retention cron, the user-data-rights forget and the data-retention:query:policy-for query resolve the policy the same way: override, then preset, then entity default. On a dsgvo-hgb tenant, forgetting a user therefore keeps invoice, booking and contract rows under blockDelete and only anonymizes them, and it anonymizes order rows instead of deleting them. policy-for reports source: "preset" for these entities.

Strategies:

  • hardDelete, row gone
  • anonymize, the per-field anonymize functions run, row stays (multi-user-refs and business data intact). Requires anonymize: () => ... 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 while keepFor runs (HR/Steuer-Aufbewahrungspflicht, de-hr-dsgvo-hgb sets 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.
customerId: createTextField({ personal: "ref" }),
customerName: createTextField({
personal: { of: "customerId" },
find: "none",
anonymize: () => "[ANONYMIZED]",
}),
},
retention: { keepFor: "10y", strategy: "blockDelete", reference: "createdAt" },
});

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. For every tenant the user belongs to, the forget cron resolves each hooked entity’s retention policy with resolveRetentionPolicyForTenant (override, then preset, then entity default). An anonymize or blockDelete policy calls the hook with the anonymize strategy. Any other policy, or none, calls it with delete. Afterwards the cron anonymizes the user row. Export-Job builds the ZIP, sends a Magic-Link.

Endpoints shipped:

ArticleHandler / CronWho
Art. 15user-data-rights:query:my-audit-logUser (account-wide event history)
Art. 15 + 20user-data-rights:write:request-exportUser (async ZIP + magic-link)
Art. 15 + 20GET /user-export/by-tokenanonymous (magic-link, multi-use within TTL)
Art. 15 + 20GET /user-export/by-job/:jobIdUser (session-auth, cross-tenant-same-user)
Art. 17user-data-rights:write:request-deletion + cancel-deletionUser
Art. 17run-forget-cleanupCron (after grace)
Art. 18user-data-rights:write:restrict-account + lift-restrictionAdmin / SystemAdmin
Operatoruser-data-rights:query:list-download-attemptsAdmin / DPO
Request account deletion: a login-free card with an email field and a Request confirmation link button

A user who can no longer sign in can still request erasure. The request-deletion-by-email handler sends a confirmation link to the account’s address, and confirm-deletion-by-token starts the deletion once the link is opened. On the client, makePublicDeletionGate serves both steps on login-free routes; the sample mounts the request screen at /account/delete.

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), replaces displayName, nulls passwordHash, blocks login
  • fileRef, deletes file rows + storage binaries on delete, keeps row + nulls insertedById on anonymize
  • user-session (ip, userAgent), hard-delete
  • api-token, hard-delete (which also revokes the token)
  • in-app-message, hard-delete
  • tenant-invitation, invitee rows forgotten/pseudonymized, inviter link severed
  • notification-preference + user-scoped config-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"]! },
});

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 / tenantOwned are 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 custom ctx.events.append payloads are tracked in kumiko-framework#799.