@cosmicdrift/kumiko-bundled-features 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/package.json +11 -5
  3. package/src/auth-email-password/auth-user-row.ts +6 -0
  4. package/src/auth-email-password/constants.ts +11 -0
  5. package/src/auth-email-password/handlers/login.write.ts +31 -1
  6. package/src/auth-email-password/i18n.ts +4 -0
  7. package/src/compliance-profiles/README.md +88 -0
  8. package/src/compliance-profiles/__tests__/compliance-profiles.integration.ts +308 -0
  9. package/src/compliance-profiles/__tests__/seeding.integration.ts +93 -0
  10. package/src/compliance-profiles/feature.ts +51 -0
  11. package/src/compliance-profiles/handlers/for-tenant.query.ts +63 -0
  12. package/src/compliance-profiles/handlers/list-profiles.query.ts +44 -0
  13. package/src/compliance-profiles/handlers/needs-profile.query.ts +56 -0
  14. package/src/compliance-profiles/handlers/set-profile.write.ts +146 -0
  15. package/src/compliance-profiles/handlers/sub-processors.query.ts +43 -0
  16. package/src/compliance-profiles/index.ts +6 -0
  17. package/src/compliance-profiles/resolve-for-tenant.ts +61 -0
  18. package/src/compliance-profiles/schema/profile-selection.ts +52 -0
  19. package/src/compliance-profiles/seeding.ts +96 -0
  20. package/src/data-retention/__tests__/data-retention.integration.ts +49 -0
  21. package/src/data-retention/__tests__/keep-for.test.ts +77 -0
  22. package/src/data-retention/__tests__/override-schema.test.ts +96 -0
  23. package/src/data-retention/__tests__/policy-for.integration.ts +172 -0
  24. package/src/data-retention/__tests__/resolver.test.ts +201 -0
  25. package/src/data-retention/_internal/parse-override.ts +33 -0
  26. package/src/data-retention/feature.ts +57 -0
  27. package/src/data-retention/handlers/policy-for.query.ts +57 -0
  28. package/src/data-retention/index.ts +18 -0
  29. package/src/data-retention/keep-for.ts +75 -0
  30. package/src/data-retention/override-schema.ts +37 -0
  31. package/src/data-retention/presets.ts +72 -0
  32. package/src/data-retention/resolve-for-tenant.ts +50 -0
  33. package/src/data-retention/resolver.ts +107 -0
  34. package/src/data-retention/schema/tenant-retention-override.ts +47 -0
  35. package/src/file-foundation/feature.ts +43 -3
  36. package/src/file-foundation/index.ts +1 -0
  37. package/src/file-provider-inmemory/feature.ts +6 -3
  38. package/src/file-provider-s3/feature.ts +8 -10
  39. package/src/files/README.md +50 -0
  40. package/src/files/__tests__/files.integration.ts +157 -0
  41. package/src/files/feature.ts +34 -0
  42. package/src/files/index.ts +1 -0
  43. package/src/files/schema/file-ref.ts +58 -0
  44. package/src/files-provider-s3/s3-provider.ts +89 -0
  45. package/src/secrets/__tests__/require-secrets-context.test.ts +81 -0
  46. package/src/secrets/feature.ts +10 -6
  47. package/src/sessions/constants.ts +4 -0
  48. package/src/sessions/feature.ts +3 -0
  49. package/src/sessions/handlers/revoke-all-for-user.write.ts +42 -0
  50. package/src/tier-engine/__tests__/auto-default-tier.integration.ts +118 -0
  51. package/src/tier-engine/feature.ts +16 -6
  52. package/src/user/__tests__/user-status.test.ts +39 -0
  53. package/src/user/index.ts +11 -1
  54. package/src/user/schema/user.ts +76 -0
  55. package/src/user-data-rights/COMPLIANCE.md +182 -0
  56. package/src/user-data-rights/README.md +109 -0
  57. package/src/user-data-rights/__tests__/audit-log.integration.ts +199 -0
  58. package/src/user-data-rights/__tests__/cross-data-matrix.integration.ts +349 -0
  59. package/src/user-data-rights/__tests__/download.integration.ts +565 -0
  60. package/src/user-data-rights/__tests__/export-job-idempotency.integration.ts +244 -0
  61. package/src/user-data-rights/__tests__/export-job-schema.test.ts +163 -0
  62. package/src/user-data-rights/__tests__/policy-to-strategy.test.ts +30 -0
  63. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.ts +370 -0
  64. package/src/user-data-rights/__tests__/request-deletion-callback.integration.ts +179 -0
  65. package/src/user-data-rights/__tests__/request-export.integration.ts +269 -0
  66. package/src/user-data-rights/__tests__/restriction-flow.integration.ts +309 -0
  67. package/src/user-data-rights/__tests__/run-export-jobs.integration.ts +1124 -0
  68. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.ts +703 -0
  69. package/src/user-data-rights/__tests__/run-user-export.integration.ts +291 -0
  70. package/src/user-data-rights/__tests__/token-helpers.test.ts +63 -0
  71. package/src/user-data-rights/__tests__/user-data-rights.integration.ts +57 -0
  72. package/src/user-data-rights/__tests__/zip-path.test.ts +119 -0
  73. package/src/user-data-rights/audit-download.ts +125 -0
  74. package/src/user-data-rights/feature.ts +309 -0
  75. package/src/user-data-rights/handlers/cancel-deletion.write.ts +84 -0
  76. package/src/user-data-rights/handlers/download-by-job.query.ts +209 -0
  77. package/src/user-data-rights/handlers/download-by-token.query.ts +257 -0
  78. package/src/user-data-rights/handlers/export-status.query.ts +76 -0
  79. package/src/user-data-rights/handlers/lift-restriction.write.ts +68 -0
  80. package/src/user-data-rights/handlers/list-download-attempts.query.ts +53 -0
  81. package/src/user-data-rights/handlers/my-audit-log.query.ts +63 -0
  82. package/src/user-data-rights/handlers/request-deletion.write.ts +123 -0
  83. package/src/user-data-rights/handlers/request-export.write.ts +155 -0
  84. package/src/user-data-rights/handlers/restrict-account.write.ts +81 -0
  85. package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +61 -0
  86. package/src/user-data-rights/i18n.ts +37 -0
  87. package/src/user-data-rights/index.ts +19 -0
  88. package/src/user-data-rights/run-export-jobs.ts +878 -0
  89. package/src/user-data-rights/run-forget-cleanup.ts +334 -0
  90. package/src/user-data-rights/run-user-export.ts +211 -0
  91. package/src/user-data-rights/schema/download-attempt.ts +37 -0
  92. package/src/user-data-rights/schema/download-token.ts +111 -0
  93. package/src/user-data-rights/schema/export-job.ts +166 -0
  94. package/src/user-data-rights/token-helpers.ts +67 -0
  95. package/src/user-data-rights/zip-path.ts +94 -0
  96. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.ts +337 -0
  97. package/src/user-data-rights-defaults/feature.ts +40 -0
  98. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +109 -0
  99. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +91 -0
  100. package/src/user-data-rights-defaults/index.ts +6 -0
@@ -0,0 +1,118 @@
1
+ // auto-default-tier hook regression test — beweist dass beim
2
+ // `tenant:write:create` der postSave-Hook von createTierEngineFeature
3
+ // fired und automatisch ein tier-assignment-row mit `defaultTier`
4
+ // schreibt.
5
+ //
6
+ // **Warum dieser Test existiert (2026-05-10):**
7
+ // Der auto-default-tier-Hook wurde in Sprint 8a Phase 2 hinzugefügt aber
8
+ // nie direkt getestet. Bei Sprint 8c (Studio-Mount mit auto-default-
9
+ // compliance-companion-hook) flog der Bug auf: `ctx.db as DbConnection`
10
+ // war ein Type-Lie — TenantDb exposed select/insert/update/delete, NICHT
11
+ // execute(). Der event-store-append (event-store.ts:102) ruft
12
+ // `db.execute(sql\`SELECT pg_notify(...)\`)` → TypeError. Fix:
13
+ // `ctx.db.raw as DbConnection` (Pattern aus signup-confirm.write.ts:107).
14
+ //
15
+ // Pin-Verträge:
16
+ // 1. tenant:write:create fired postSave-Hook mit isNew=true
17
+ // 2. Hook erstellt tier-assignment-row im NEUEN tenant (nicht im caller-
18
+ // tenant — Memory `feedback_event_store_tenant_consistency`)
19
+ // 3. Idempotency: tenant-update fired keinen weiteren row
20
+
21
+ import { composeFeatures } from "@cosmicdrift/kumiko-dev-server/compose-features";
22
+ import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
23
+ import {
24
+ createTestUser,
25
+ setupTestStack,
26
+ type TestStack,
27
+ unsafePushTables,
28
+ } from "@cosmicdrift/kumiko-framework/stack";
29
+ import { eq } from "drizzle-orm";
30
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
31
+ import { configValuesTable } from "../../config";
32
+ import { TenantHandlers, tenantMembershipsTable, tenantTable } from "../../tenant";
33
+ import { userTable } from "../../user";
34
+ import type { TierMap } from "../compose-app";
35
+ import { tierAssignmentEntity } from "../entity";
36
+ import { createTierEngineFeature } from "../feature";
37
+
38
+ const TEST_TIER_MAP: TierMap<{ readonly maxItems: number }> = {
39
+ free: { features: [], caps: { maxItems: 1 } },
40
+ };
41
+
42
+ const tierAssignmentTable = buildDrizzleTable("tier-assignment", tierAssignmentEntity);
43
+
44
+ const features = composeFeatures(
45
+ [createTierEngineFeature({ defaultTier: "free", tierMap: TEST_TIER_MAP })],
46
+ { includeBundled: true },
47
+ );
48
+
49
+ let stack: TestStack;
50
+ const PLATFORM_TENANT = "00000000-0000-4000-8000-000000000001";
51
+ const sysadmin = createTestUser({
52
+ id: "platform-sysadmin",
53
+ tenantId: PLATFORM_TENANT,
54
+ roles: ["SystemAdmin"],
55
+ });
56
+
57
+ beforeAll(async () => {
58
+ stack = await setupTestStack({ features });
59
+ await unsafePushTables(stack.db, {
60
+ config_values: configValuesTable,
61
+ users: userTable,
62
+ tenants: tenantTable,
63
+ tenant_memberships: tenantMembershipsTable,
64
+ tier_assignments: tierAssignmentTable,
65
+ });
66
+ });
67
+
68
+ afterAll(async () => stack?.cleanup());
69
+
70
+ describe("auto-default-tier postSave hook on tenant-create", () => {
71
+ test("sysadmin creates a new tenant → free tier-assignment-row angelegt", async () => {
72
+ const data = (await stack.http.writeOk<Record<string, unknown>>(
73
+ TenantHandlers.create,
74
+ { key: "test-tenant-1", name: "Test Tenant One" },
75
+ sysadmin,
76
+ ))!;
77
+ const newTenantId = data["id"] as string;
78
+ expect(typeof newTenantId).toBe("string");
79
+
80
+ const rows = await stack.db
81
+ .select()
82
+ .from(tierAssignmentTable)
83
+ .where(eq(tierAssignmentTable["tenantId"], newTenantId));
84
+ expect(rows.length).toBe(1);
85
+ expect((rows[0] as Record<string, unknown>)["tier"]).toBe("free");
86
+ });
87
+
88
+ test("idempotency: tenant-update fired keinen weiteren tier-assignment-row", async () => {
89
+ const created = (await stack.http.writeOk<Record<string, unknown>>(
90
+ TenantHandlers.create,
91
+ { key: "test-tenant-2", name: "Test Tenant Two" },
92
+ sysadmin,
93
+ ))!;
94
+ const tenantId = created["id"] as string;
95
+
96
+ const existing = (await stack.db
97
+ .select()
98
+ .from(tenantTable)
99
+ .where(eq(tenantTable["id"], tenantId))) as Array<{ id: string; version: number }>;
100
+ const currentVersion = existing[0]!.version;
101
+
102
+ await stack.http.writeOk(
103
+ TenantHandlers.update,
104
+ {
105
+ id: tenantId,
106
+ version: currentVersion,
107
+ changes: { name: "Test Tenant Two — Renamed" },
108
+ },
109
+ sysadmin,
110
+ );
111
+
112
+ const rows = await stack.db
113
+ .select()
114
+ .from(tierAssignmentTable)
115
+ .where(eq(tierAssignmentTable["tenantId"], tenantId));
116
+ expect(rows.length).toBe(1);
117
+ });
118
+ });
@@ -253,12 +253,22 @@ export function createTierEngineFeature<
253
253
  if (!ctx.db) return;
254
254
 
255
255
  // ctx.db ist im inTransaction-phase eine TenantDb (tenant-scoped
256
- // proxy auf die echte TX). Für event-store-reads (cross-tenant
257
- // stream-lookup via aggregate-id) brauchen wir die rohe TX —
258
- // TenantDb wrapped die echte DbConnection, der select-call
259
- // funktioniert structural identisch. Cast als DbConnection ist
260
- // boundary-cast für event-store-API, kein narrowing-escape.
261
- const rawDb = ctx.db as DbConnection;
256
+ // proxy auf die echte TX). Für event-store-Pfade brauchen wir
257
+ // die rohe DbConnection TenantDb exposes nur select/insert/
258
+ // update/delete, NICHT execute (event-store-append.ts:102 ruft
259
+ // db.execute(sql`SELECT pg_notify(...)`) TypeError sonst).
260
+ // Pattern matched signup-confirm.write.ts:107 (.raw), nicht
261
+ // `as DbConnection` das ist Type-Lie der erst beim ersten
262
+ // .execute()-Call crashed.
263
+ //
264
+ // AppContext.db ist union (DbConnection | TenantDb). Im
265
+ // inTransaction-phase garantiert TenantDb — der dispatcher
266
+ // wrapped vorher (siehe pipeline/dispatcher.ts createTenantDb-
267
+ // Aufruf). TypeGuard via `"raw" in ...` ist robuster als
268
+ // `as TenantDb` gegen future refactor.
269
+ // skip: defensive — sollte im inTransaction nie greifen.
270
+ if (!("raw" in ctx.db)) return;
271
+ const rawDb = ctx.db.raw as DbConnection;
262
272
 
263
273
  // Idempotency: stream-existence-check vor create. Pattern aus
264
274
  // seedTenant.ts. Bei re-replay (rebuild) nicht versionsbumpen.
@@ -0,0 +1,39 @@
1
+ // Drift-Guard fuer USER_STATUS (S2.D2.5 N1).
2
+ //
3
+ // Plus: USER_STATUS_OPTIONS wird nicht direkt exportiert (private im
4
+ // schema/user.ts), wird aber via createSelectField in der Entity
5
+ // referenziert. Wenn USER_STATUS-Object erweitert wird ohne dass das
6
+ // Tuple synchron mitwaechst, faengt der Test es ab — entity.fields.status
7
+ // liefert die options-Liste.
8
+
9
+ import { describe, expect, test } from "vitest";
10
+ import { USER_STATUS, userEntity } from "../schema/user";
11
+
12
+ describe("USER_STATUS — Drift-Guard (S2.D2.5 N1)", () => {
13
+ test("Snapshot-Vergleich: USER_STATUS-Object und entity.fields.status.options synchron", () => {
14
+ const objectValues = Object.values(USER_STATUS).sort();
15
+ // entity.fields.status ist createSelectField — options ist die Tuple
16
+ const statusField = userEntity.fields["status"] as { options: readonly string[] };
17
+ const optionValues = [...statusField.options].sort();
18
+
19
+ expect(optionValues).toEqual(objectValues);
20
+ });
21
+
22
+ test("USER_STATUS-Snapshot — explizit zu updaten bei Aenderungen", () => {
23
+ expect(USER_STATUS).toMatchInlineSnapshot(`
24
+ {
25
+ "Active": "active",
26
+ "Deleted": "deleted",
27
+ "DeletionRequested": "deletionRequested",
28
+ "Restricted": "restricted",
29
+ }
30
+ `);
31
+ });
32
+
33
+ test("USER_STATUS-Werte sind camelCase (Convention fuer status-Strings)", () => {
34
+ // Erwartung: alle Werte starten mit lowercase und enthalten kein Leerzeichen
35
+ for (const value of Object.values(USER_STATUS)) {
36
+ expect(value).toMatch(/^[a-z][a-zA-Z]*$/);
37
+ }
38
+ });
39
+ });
package/src/user/index.ts CHANGED
@@ -1,4 +1,14 @@
1
1
  export { UserCommandSchemas } from "./command-schemas";
2
2
  export { USER_FEATURE, UserErrors, UserHandlers, UserQueries } from "./constants";
3
3
  export { createUserFeature } from "./feature";
4
- export { userEntity, userTable } from "./schema/user";
4
+ export type { UserStatus } from "./schema/user";
5
+ export {
6
+ USER_ANONYMIZED_DISPLAY_NAME,
7
+ USER_ANONYMIZED_EMAIL_DOMAIN,
8
+ USER_ANONYMIZED_EMAIL_PREFIX,
9
+ USER_DELETED_DISPLAY_NAME,
10
+ USER_DELETED_EMAIL_PREFIX,
11
+ USER_STATUS,
12
+ userEntity,
13
+ userTable,
14
+ } from "./schema/user";
@@ -3,9 +3,54 @@ import {
3
3
  access,
4
4
  createBooleanField,
5
5
  createEntity,
6
+ createSelectField,
6
7
  createTextField,
8
+ createTimestampField,
7
9
  } from "@cosmicdrift/kumiko-framework/engine";
8
10
 
11
+ /**
12
+ * User-Lifecycle-Status (S2.U1). Single source of truth — Auth-Middleware
13
+ * (S2.U6), Forget-Job (S2.U5) und Restriction-Handler nutzen diese
14
+ * Constants statt Magic-Strings (Memory feedback_role_naming_drift —
15
+ * gleiches Pattern wie ROLES.SystemAdmin).
16
+ */
17
+ export const USER_STATUS = {
18
+ Active: "active",
19
+ Restricted: "restricted",
20
+ DeletionRequested: "deletionRequested",
21
+ Deleted: "deleted",
22
+ } as const;
23
+
24
+ export type UserStatus = (typeof USER_STATUS)[keyof typeof USER_STATUS];
25
+
26
+ /**
27
+ * Anonymize-Display-Strings fuer userDeleteHook (S2.H1). Constants statt
28
+ * Magic-Strings damit i18n-Mapping moeglich + drift-fest. Default-DE,
29
+ * App-Author kann via i18n-System uebersetzen wenn gewuenscht.
30
+ */
31
+ export const USER_DELETED_DISPLAY_NAME = "[Geloescht]";
32
+ export const USER_ANONYMIZED_DISPLAY_NAME = "[Anonymisiert]";
33
+
34
+ /**
35
+ * Email-Pseudonyme nach Forget. `<prefix>-<userId>@anonymized.invalid`
36
+ * — der userId-Suffix ist als Pseudo-Audit-Marker fuer Operator
37
+ * (Tracing-fall) erlaubt; user-id selbst ist UUID, kein PII.
38
+ * `.invalid`-TLD ist RFC2606-reserviert — niemals deliverbare Email.
39
+ */
40
+ export const USER_DELETED_EMAIL_PREFIX = "deleted";
41
+ export const USER_ANONYMIZED_EMAIL_PREFIX = "anonymized";
42
+ export const USER_ANONYMIZED_EMAIL_DOMAIN = "anonymized.invalid";
43
+
44
+ // Tuple form fuer createSelectField (erfordert non-empty readonly tuple).
45
+ // Object.values(USER_STATUS) waere string[] — statisches Tuple ist
46
+ // type-sicher.
47
+ const USER_STATUS_OPTIONS = [
48
+ USER_STATUS.Active,
49
+ USER_STATUS.Restricted,
50
+ USER_STATUS.DeletionRequested,
51
+ USER_STATUS.Deleted,
52
+ ] as const;
53
+
9
54
  // User entity — tenant-agnostic. A single user can belong to multiple tenants
10
55
  // via tenantMemberships. No tenantId column on this table.
11
56
  export const userEntity = createEntity({
@@ -63,6 +108,37 @@ export const userEntity = createEntity({
63
108
  default: "[]",
64
109
  access: { write: access.privileged },
65
110
  }),
111
+
112
+ // S2.U1: User-Lifecycle-Status für user-data-rights (Sprint 2).
113
+ // - "active": Normaler State, alle Operationen erlaubt
114
+ // - "restricted": Art. 18 Restriction — Auth-Middleware blockiert
115
+ // Schreib-Endpoints, Read bleibt erlaubt damit
116
+ // User das Banner sieht + lift-restriction klicken kann
117
+ // - "deletionRequested": delete-account aufgerufen, gracePeriodEnd
118
+ // gesetzt, User kann via cancel-deletion zurueck
119
+ // auf "active". Auth-Middleware blockiert wie
120
+ // "restricted".
121
+ // - "deleted": Forget executed nach Grace, Row anonymisiert via
122
+ // softDelete. Auth-Middleware blockt Login.
123
+ //
124
+ // Schreibrecht privileged: nur die request-deletion / restrict / lift /
125
+ // execute-forget-Handler (alle SYSTEM-context) duerfen status flippen.
126
+ status: createSelectField({
127
+ required: true,
128
+ default: USER_STATUS.Active,
129
+ options: USER_STATUS_OPTIONS,
130
+ access: { write: access.privileged },
131
+ }),
132
+
133
+ // Wann darf der pending-Forget tatsaechlich ausgefuehrt werden?
134
+ // Cron-Job in user-data-rights checkt taeglich gracePeriodEnd < now()
135
+ // und triggert dann die EXT_USER_DATA-Hooks. NULL solange kein
136
+ // Forget pending — wird beim delete-account-Call gesetzt
137
+ // (= now() + Compliance-Profile.userRights.gracePeriod), beim
138
+ // cancel-deletion zurueckgesetzt.
139
+ gracePeriodEnd: createTimestampField({
140
+ access: { write: access.privileged },
141
+ }),
66
142
  },
67
143
  });
68
144
 
@@ -0,0 +1,182 @@
1
+ # DSGVO Compliance — Operator-Guide
2
+
3
+ Dieses Dokument bündelt die **technischen Fakten** zur DSGVO-Pipeline
4
+ (Art. 15/17/18/20) als Grundlage für:
5
+
6
+ - **Verarbeitungsverzeichnis** (Art. 30) — was wird wo wie lange gespeichert
7
+ - **Datenschutzerklärung** — welche Endpoints decken welchen Artikel ab
8
+ - **AVV mit Sub-Processors** — welche TOM sind eingebaut
9
+ - **Operator-Runbook** — was triggert wann, wer kann was sehen
10
+
11
+ > Juristische Texte (AVV-Wortlaut, Datenschutzerklärung-Bausteine,
12
+ > Verarbeitungsverzeichnis-Strukturierung) gehören zu Marc + Anwalt —
13
+ > dieses Dokument liefert nur die technischen Fakten dafür.
14
+
15
+ ---
16
+
17
+ ## 1. Endpoint → Artikel-Mapping
18
+
19
+ | Artikel | Endpoint / Runner | Wer ruft auf | Was passiert |
20
+ |---------|-------------------|--------------|--------------|
21
+ | **Art. 15 (Auskunft, light)** | `user-data-rights:query:my-audit-log` | User | Liest eigene Framework-Events aus `kumiko_events` (account-weit über alle Memberships). Filterbar nach `eventType`, `aggregateType`, `from`/`to`. Domain-Entities ohne `ctx.appendEvent` erscheinen NICHT — die kommen im Export-Bundle (Art. 20). |
22
+ | **Art. 15 + 20 (Auskunft + Portabilität)** | `user-data-rights:write:request-export` | User | Async Job → ZIP mit user-Profil + fileRefs + alle EXT_USER_DATA-Provider-Daten (cross-tenant) + signed Magic-Link per Email. Idempotent: nur 1 active Job pro User (`ACTIVE_JOB_CONSTRAINT`). |
23
+ | **Art. 15 + 20** | `GET /user-export/by-token?token=…` | Anonym (Magic-Link-Pfad) | Token-Hash-Lookup → 302-Redirect auf signed Storage-URL. Multi-use innerhalb TTL. Audit: `lastUsedAt`, `lastUsedFromIp`, `lastUsedUserAgent`. |
24
+ | **Art. 15 + 20** | `GET /user-export/by-job/:jobId` | User (Session-Auth) | UI-Klick-Pfad. Cross-tenant-same-user: User in Tenant B kann Job aus Tenant A laden wenn er der Owner ist. |
25
+ | **Art. 17 (Löschung)** | `user-data-rights:write:request-deletion` | User | Soft-Delete: `status=DeletionRequested` + `gracePeriodEnd = now + profile.gracePeriod`. Cron `run-forget-cleanup` führt nach Grace die Anonymisierung durch. |
26
+ | **Art. 17** | `user-data-rights:write:cancel-deletion` | User | Während Grace: status zurück auf `Active`. |
27
+ | **Art. 17** | `run-forget-cleanup` (Cron) | System | Findet User mit `status=DeletionRequested AND gracePeriodEnd < now`. Pro User Sub-TX über alle EXT_USER_DATA-Provider mit Strategy aus `retention.policyFor`. user-Hook anonymisiert (PII raus, Sentinel-Email). |
28
+ | **Art. 18 (Restriction)** | `user-data-rights:write:restrict-account` | Admin / SystemAdmin | Status-Flip → Auth-Middleware-Guard blockt Logins. `sessions.revokeAllForUser` killt aktive Sessions. |
29
+ | **Art. 18** | `user-data-rights:write:lift-restriction` | Admin / SystemAdmin | Restriction aufheben. |
30
+ | **Operator (DPO)** | `user-data-rights:query:list-download-attempts` | Admin / SystemAdmin | Brute-Force-Detection: zeigt invalid Download-Versuche (notFound / expired / failed / signedUrlNotSupported) gefiltert nach Result, IP, Zeitraum. |
31
+
32
+ ---
33
+
34
+ ## 2. Speicherorte (Verarbeitungsverzeichnis Art. 30)
35
+
36
+ | Tabelle | Inhalt | Personenbezug | Retention | Zweck |
37
+ |---------|--------|---------------|-----------|-------|
38
+ | `read_users` | User-Profil (email, displayName, passwordHash, locale, status, gracePeriodEnd, roles) | direkt | per Domain (typ. blockDelete bei Aufbewahrungspflicht, sonst hardDelete via Forget-Pipeline) | Authentifizierung, Nutzer-Profil |
39
+ | `read_tenant_memberships` | User↔Tenant-Verknüpfung + Rollen | direkt (via userId) | per Tenant-Lifecycle | Mehrmandant-Zuordnung |
40
+ | `kumiko_events` | Event-Store (alle write-Events) | direkt (`createdBy = userId`) | per Domain-Policy via `data-retention` | Audit-Trail (Art. 15-Selbstauskunft Quelle), Event-Sourcing |
41
+ | `read_export_jobs` | Async Export-Status (queued/running/done/failed), userId, requestedAt, doneAt, storageKey | direkt (userId) | per `compliance-profiles` Profil-Default | Idempotenz + Status-Polling |
42
+ | `read_export_download_tokens` | Magic-Link-Hash (SHA-256), TTL, useCount, lastUsedAt, lastUsedFromIp, lastUsedUserAgent | indirekt (Token→Job→User) | `compliance-profiles.userRights.exportDownloadTtl` (default 7d) | Magic-Link-Auth + Multi-Use-Audit |
43
+ | `read_download_attempts` | Invalid Download-Versuche: result, via, tokenHash, ip, userAgent, attemptedAt | indirekt (IP) | **90d hardDelete** (Entity-Default, Disk-Bomb-Schutz) | DPO-Brute-Force-Detection |
44
+ | `read_tenant_compliance_profiles` | Per-Tenant Profile-Wahl + Override | nein | unbounded (Konfiguration) | Region-/Branchen-Defaults |
45
+ | `read_tenant_retention_overrides` | Per-Tenant Retention-Override pro Entity | nein | unbounded (Konfiguration) | Aufbewahrungspflicht-Edge-Cases |
46
+ | Storage-Provider (Local / S3) | Export-ZIPs + File-Binaries | indirekt (Inhalt) | Local: per `exportDownloadTtl` Cleanup. S3: lifecycle-Policy (App-Author-Verantwortung) | Daten-Export-Auslieferung |
47
+
48
+ ---
49
+
50
+ ## 3. Compliance-Profile
51
+
52
+ | Profil | gracePeriod | exportDownloadTtl | Stale-After | Sub-Processors |
53
+ |--------|-------------|-------------------|-------------|----------------|
54
+ | `eu-dsgvo` | 30d | 7d | 30d | per Tenant konfigurierbar |
55
+ | `swiss-dsg` | 30d | 7d | 30d | + EDÖB-Meldepfad |
56
+ | `de-hr-dsgvo-hgb` | 30d | 7d | 30d | + HR-Aufbewahrung 10y für HR-Entities (anonymize statt delete) |
57
+ | `minimal-no-region` | 30d | 7d | 30d | Migration-Edge-Case ohne Region |
58
+
59
+ Tenant kann via `compliance-profiles:write:set-profile` + Override (`override.userRights.gracePeriod={ days: N }` etc.) eigene Werte setzen.
60
+
61
+ ---
62
+
63
+ ## 4. Technische und Organisatorische Maßnahmen (TOM)
64
+
65
+ Eingebaute Schutzmaßnahmen — können 1:1 in den AVV-Anhang "Technische
66
+ und Organisatorische Maßnahmen" übernommen werden:
67
+
68
+ ### Vertraulichkeit / Zugriffskontrolle
69
+
70
+ - **Magic-Link-Token-Hashing**: Plain-Token landet NIE in DB / Event-Store. SHA-256-Hash via `crypto.subtle.digest` (Web-Crypto-API). Plain-Token kommt nur ephemeral via Email-Callback an die App-Author-Implementation.
71
+ - **Download-Token Multi-Use within TTL**: kein consume-on-use (Pattern Google Takeout) — User kann ZIP mehrfach laden, aber TTL gilt absolut.
72
+ - **Audit-Felder am Token**: useCount, lastUsedAt, lastUsedFromIp, lastUsedUserAgent. Operator sieht ob Token mehrfach verwendet wurde, von welcher IP.
73
+ - **Account-weite Auskunft via `ctx.db.raw`**: `my-audit-log` umgeht TenantDb-Auto-Filter explizit (Account-weite Sicht für Art. 15 ist Pflicht); Sicherung über hard-coded `WHERE createdBy = ctx.user.id` (kein userId-Parameter, kein Cross-User-Snooping möglich).
74
+ - **Cross-User-Schutz im Download-Pfad**: `download-by-job` checkt `jobRow.userId === session.user.id` — User kann nur eigene Jobs laden.
75
+ - **Restriction killt Sessions**: `restrict-account` triggert `sessions.revokeAllForUser` — restricted User kann existierende Tabs nicht weiternutzen.
76
+
77
+ ### Integrität
78
+
79
+ - **Event-Sourcing first-class**: alle DSGVO-Schreib-Operationen (request-deletion, restrict, lift, request-export) sind Events im `kumiko_events`-Store mit `version_conflict`-Schutz.
80
+ - **Forget-Strategy aus `data-retention`**: Cleanup-Runner konsultiert `retention.policyFor` pro Entity — `blockDelete` für gesetzliche Aufbewahrungspflicht (HR/HGB), `anonymize` als Alternative zu `hardDelete`.
81
+ - **Strategy-respect-Pattern in Default-Hooks**: user-Hook anonymisiert mit Sentinel-Pattern `deleted-<id>@anonymized.invalid` — Unique-Constraint + FK-Refs bleiben intakt.
82
+
83
+ ### Verfügbarkeit / Belastbarkeit
84
+
85
+ - **Best-Effort-Audit beim Download**: Audit-INSERT in `read_download_attempts` ist `try/catch` swallowed — Audit-Failure killt nicht den User-facing 4xx.
86
+ - **Idempotenz im Export-Job**: `ACTIVE_JOB_CONSTRAINT` (UNIQUE-Index auf `(userId, status='active')`) verhindert Doppel-Jobs. Worker-Crash → Job bleibt `running`, Recovery-Pfad via Job-Run-Tracking aus `jobs`-Feature.
87
+ - **Per-User Sub-TX im Forget-Runner**: Ein User-Hook-Throw rollback'd nur diesen User; andere User im Batch laufen weiter.
88
+ - **Best-Effort-Notification-Callbacks**: send-Throw für Job A killt nicht Batch B/C (Memory: Atom 5.fix3).
89
+
90
+ ### Brute-Force-Schutz
91
+
92
+ - **Edge-Rate-Limit auf Download-Endpoint**: `rateLimit: { per: "ip", limit: 30, windowSeconds: 60 }` für `download-by-token`.
93
+ - **Download-Attempt-Audit** mit 90d hardDelete: invalid Versuche werden persistiert für DPO-Detection, Tabelle ist begrenzt → kein Disk-Bomb durch Brute-Force.
94
+ - **Token-Hash-Suchraum**: 32-Byte-Random = 256 Bit, Brute-Force über Edge-Limit praktisch nicht möglich.
95
+
96
+ ### Zweckbindung / Datenminimierung
97
+
98
+ - **Export-Bundle Default-PII-Filter**: user-Hook entfernt `passwordHash`, `roles`, `status` aus dem Bundle (App-Author kann das per Custom-Hook überschreiben).
99
+ - **fileRefs separat**: Export-Bundle enthält Datei-Metadaten (id, fileName, mimeType, size); Binaries werden via signed-URL separat ins ZIP gepackt — kein Inline-Base64-Memory-Druck.
100
+
101
+ ### Auftragskontrolle (gegenüber Sub-Processors)
102
+
103
+ - **Storage-Provider als Plugin** (`file-foundation` + `file-provider-{s3,inmemory}`): App-Author wählt Provider; AVV mit S3-Hoster (Hetzner / AWS) ist vom Provider abhängig.
104
+ - **Email-Transport als Plugin** (`mail-foundation` + `mail-transport-{smtp,inmemory}`): SMTP / SES / Resend per Plugin austauschbar.
105
+ - **`compliance-profiles:query:sub-processors`**: Per-Tenant Liste der aktiven Sub-Processors, abrufbar für AVV-Anhang.
106
+
107
+ ---
108
+
109
+ ## 5. Cron-Jobs / Operationale Trigger
110
+
111
+ | Job | Trigger | Was passiert | Operator-Sichtbarkeit |
112
+ |-----|---------|--------------|------------------------|
113
+ | `run-forget-cleanup` | Cron (per App-Author konfigurierbar, typisch täglich) | Findet User mit abgelaufener Grace, ruft `EXT_USER_DATA.delete` pro Provider, anonymisiert User, sendet `sendDeletionExecutedEmail`-Callback | `read_job_runs` (success/fail), Hook-Errors als `errors[]` im Result |
114
+ | `run-export-jobs` | Cron + Event-getriggert | Findet pending Export-Jobs, ruft `runUserExport` → ZIP-Bau → Storage-Upload → Magic-Link-Token → `sendExportReadyEmail` | `read_job_runs`, `read_export_jobs.status` |
115
+ | `data-retention-cleanup` | Cron (in `data-retention`) | Cleant abgelaufene Rows pro Entity per `retention.keepFor` | `read_job_runs` |
116
+ | Token-Cleanup (implizit) | Per `compliance-profiles.userRights.exportDownloadTtl` | Worker-Job entfernt expired Magic-Link-Tokens + Storage-Binaries | `read_export_download_tokens` |
117
+ | Download-Attempt-Cleanup | Per Entity-Default 90d | Cleant `read_download_attempts` | — |
118
+
119
+ App-Author registriert die Cron-Trigger im run-config; `jobs`-Feature
120
+ persistiert Run-Tracking + Retry-Pfad.
121
+
122
+ ---
123
+
124
+ ## 6. Doku-Snippets für Marc
125
+
126
+ ### Datenschutzerklärung — Betroffenenrechte
127
+
128
+ > **Recht auf Auskunft (Art. 15 DSGVO):** Sie können jederzeit eine
129
+ > Kopie aller bei uns über Sie gespeicherten Daten anfordern. Über die
130
+ > Funktion "Daten exportieren" in den Account-Einstellungen erhalten
131
+ > Sie ein vollständiges JSON-/ZIP-Bundle Ihrer Profildaten, hochgeladenen
132
+ > Dateien und [App-Author: Domain-Daten ergänzen] per signed Magic-Link
133
+ > auf Ihre hinterlegte E-Mail-Adresse. Der Link ist 7 Tage gültig.
134
+
135
+ > **Recht auf Löschung (Art. 17 DSGVO):** Über "Account löschen" können
136
+ > Sie Ihren Account jederzeit zur Löschung vormerken. Es gilt eine
137
+ > Karenzzeit von 30 Tagen, in der Sie den Antrag widerrufen können
138
+ > (Funktion "Löschung widerrufen"). Nach Ablauf werden Ihre Daten
139
+ > automatisch gelöscht oder anonymisiert. Daten mit gesetzlicher
140
+ > Aufbewahrungspflicht (z. B. Rechnungen nach §147 AO) bleiben bis zum
141
+ > Fristablauf gesperrt erhalten und werden danach automatisch gelöscht.
142
+
143
+ > **Recht auf Einschränkung (Art. 18 DSGVO):** Auf begründeten Antrag
144
+ > kann Ihr Account temporär gesperrt werden. Während der Sperre können
145
+ > Sie sich nicht mehr einloggen, Ihre Daten werden aber nicht gelöscht
146
+ > oder verändert.
147
+
148
+ ### AVV-TOM-Anhang
149
+
150
+ Für den AVV-Anhang "Technische und Organisatorische Maßnahmen" siehe
151
+ **Abschnitt 4** dieses Dokuments — komplette Liste mit Verweisen auf
152
+ die Code-Implementierung.
153
+
154
+ ### Verarbeitungsverzeichnis
155
+
156
+ Spalte "Speicherorte / Empfänger" → siehe **Abschnitt 2** (Tabellen-
157
+ Übersicht). Spalte "Löschfristen" → siehe **Abschnitt 3** + Spalte
158
+ "Retention" in Abschnitt 2.
159
+
160
+ ---
161
+
162
+ ## 7. Was NICHT in diesem Framework abgedeckt ist
163
+
164
+ Bewusst ausserhalb — App-Author-Verantwortung:
165
+
166
+ - **Auswahl + AVV mit konkretem Storage-Provider** (Hetzner / AWS / etc.)
167
+ - **Auswahl + AVV mit konkretem Email-Provider** (SMTP-Host / SES / Resend)
168
+ - **Datenpannen-Meldung** an Aufsichtsbehörde (organisatorisch, nicht technisch)
169
+ - **DSFA** (Datenschutz-Folgenabschätzung) — Framework liefert die TOM-Inputs, App-Author macht die Bewertung
170
+ - **Cookie-Consent-Layer** (das ist Frontend-Sache + separate consent-Feature, nicht Teil von user-data-rights)
171
+ - **Tenant-Lifecycle-Destroy** (Account-Löschung des Tenants selbst, nicht der User darin) — kommt als separates `tenant-lifecycle`-Feature in Sprint 5
172
+
173
+ ---
174
+
175
+ ## Referenzen
176
+
177
+ - Code: `packages/bundled-features/src/user-data-rights/`
178
+ - Default-Hooks: `packages/bundled-features/src/user-data-rights-defaults/`
179
+ - Compliance-Profile: `packages/bundled-features/src/compliance-profiles/`
180
+ - Retention-Engine: `packages/bundled-features/src/data-retention/`
181
+ - Sample-App: `samples/apps/user-data-rights-demo/`
182
+ - Tests: `packages/bundled-features/src/user-data-rights/__tests__/` (188 Tests)
@@ -0,0 +1,109 @@
1
+ # user-data-rights
2
+
3
+ DSGVO Art. 15 (Auskunft) + Art. 17 (Löschung) + Art. 18 (Restriction) +
4
+ Art. 20 (Portabilität) als Core-Feature.
5
+
6
+ **Status:** S2 abgeschlossen — alle Endpoints, Hooks, Default-Provider,
7
+ Cron-Pipeline, Tests + Sample wired.
8
+
9
+ ## Pattern
10
+
11
+ Statt jedes Feature seine eigene Forget-/Export-Logik schreibt, hängt
12
+ es sich via `r.useExtension(EXT_USER_DATA, "<entity>", { export, delete })`
13
+ an. user-data-rights orchestriert Export- und Forget-Pipeline:
14
+
15
+ ```ts
16
+ defineFeature("tasks", (r) => {
17
+ r.requires("user-data-rights");
18
+ r.useExtension(EXT_USER_DATA, "task", {
19
+ export: async (ctx) => ({
20
+ entity: "task",
21
+ rows: await ctx.db.select().from(tasksTable)
22
+ .where(eq(tasksTable.authorId, ctx.userId)),
23
+ }),
24
+ delete: async (ctx, strategy) => {
25
+ if (strategy === "anonymize") {
26
+ await ctx.db.update(tasksTable).set({ authorId: null })
27
+ .where(eq(tasksTable.authorId, ctx.userId));
28
+ } else {
29
+ await ctx.db.delete(tasksTable)
30
+ .where(eq(tasksTable.authorId, ctx.userId));
31
+ }
32
+ },
33
+ });
34
+ });
35
+ ```
36
+
37
+ Hook-Signaturen in `framework/src/engine/extensions/user-data.ts`:
38
+
39
+ - `UserDataExportHook(ctx) => Promise<UserDataExportSnippet | null>`
40
+ - `UserDataDeleteHook(ctx, strategy) => Promise<void>`
41
+ - `UserDataDeleteStrategy = "delete" | "anonymize"`
42
+
43
+ ## Endpoints
44
+
45
+ | Article | Handler | Zweck |
46
+ |---------|---------|-------|
47
+ | Art. 15 | `user-data-rights:query:my-audit-log` | User sieht eigene Framework-Events (account-weit über alle Memberships). Domain-Entities ohne `ctx.appendEvent` erscheinen NICHT — nur im Export-Bundle. |
48
+ | Art. 15+20 | `user-data-rights:write:request-export` | Async Job → ZIP mit user-Profil + fileRefs + alle EXT_USER_DATA-Provider-Daten + signed Magic-Link |
49
+ | Art. 17 | `user-data-rights:write:request-deletion` | Soft-Delete mit Grace-Period, anschließend Cron anonymisiert User + cleant Domain-Entities |
50
+ | Art. 17 | `user-data-rights:write:cancel-deletion` | User widerruft seinen Forget-Request während der Grace |
51
+ | Art. 18 | `user-data-rights:write:restrict-account` | Auth-Middleware blockt Logins bis Lift |
52
+ | Art. 18 | `user-data-rights:write:lift-restriction` | Admin/SystemAdmin hebt Restriction auf |
53
+ | Operator | `user-data-rights:query:list-download-attempts` | DPO-Sicht auf invalid Download-Versuche (Brute-Force-Detection, Admin/SystemAdmin only) |
54
+
55
+ Plus 2 anonyme HTTP-Routes für Export-Download (Magic-Link-Pfad +
56
+ session-auth-Pfad), siehe `handlers/download-by-{token,job}.query.ts`.
57
+
58
+ ## Cross-Feature-API
59
+
60
+ **Exposes:**
61
+ - `userDataRights.runExport` — über die public Runner-Exports
62
+ (`runUserExport`, `runForgetCleanup`)
63
+ - `userDataRights.runForget`
64
+
65
+ **Uses:**
66
+ - `compliance.forTenant` (Grace-Period aus Profile)
67
+ - `retention.policyFor` (blockDelete-Konsultation, anonymize statt delete)
68
+ - `sessions.revokeAllForUser` (Restriction killt aktive Sessions)
69
+
70
+ ## Default-Hooks (`user-data-rights-defaults`)
71
+
72
+ Optional-mountbares Sub-Feature liefert Default-Hooks für
73
+ Core-Entities `user` (anonymize: email→`deleted-<id>@anonymized.invalid`,
74
+ displayName→`(deleted)`, passwordHash=null) und `fileRef` (delete: row +
75
+ storage-binary; anonymize: insertedById=null). App-Author kann es
76
+ weglassen wenn er Custom-Hooks registrieren will.
77
+
78
+ ## Audit-Trail
79
+
80
+ | Tabelle | Zweck | Retention |
81
+ |---------|-------|-----------|
82
+ | `kumiko_events` | Framework-Event-Store, Quelle für `my-audit-log` | per Domain-Policy |
83
+ | `read_export_jobs` | Async Export-Status (queued / done / failed) | per `compliance-profiles` |
84
+ | `read_export_download_tokens` | Magic-Link-Hash + TTL + lastUsed-Audit | per `compliance-profiles` (default `exportDownloadTtl`) |
85
+ | `read_download_attempts` | Invalid-Download-Versuche für DPO-Brute-Force-Detection | **90d hardDelete** (Entity-Default — schützt vor Disk-Bomb bei aktiven Angriffen) |
86
+
87
+ ## Tests
88
+
89
+ 18 Testdateien, 188 Tests, alle grün:
90
+
91
+ | Datei | Pinst |
92
+ |-------|-------|
93
+ | `audit-log.integration.ts` | Cross-User-Isolation, Account-weite Sicht, eventType-Filter, Admin-only operator-query, download-attempt 90d-retention |
94
+ | `cross-data-matrix.integration.ts` | 3-Provider-Pipeline (user + fileRef + custom-domain), Cross-Tenant Forget mit user-anonymize, Other-User-Isolation |
95
+ | `download.integration.ts` | HTTP-e2e via `r.httpRoute`: Magic-Link, multi-use, expired, failed-job, storage-cleared, cross-tenant-same-user, malicious-filename |
96
+ | `request-export.integration.ts` | Idempotency, active-job-constraint, cross-tenant-anyMember-userId-pattern |
97
+ | `request-deletion-callback.integration.ts` + `request-cancel-deletion.integration.ts` | Grace + Cancel-Pfad + Email-Callback best-effort |
98
+ | `restriction-flow.integration.ts` | Status-Flip + Auth-Middleware-Block + Lift |
99
+ | `run-{export-jobs,forget-cleanup,user-export}.integration.ts` | Worker-Logic + Idempotency + Email-Callbacks |
100
+ | `policy-to-strategy.test.ts` | Retention.strategy → UserDataDeleteStrategy mapping |
101
+ | `user-data-rights.integration.ts` | Boot-Smoke + Feature-Meta |
102
+ | `token-helpers.test.ts` + `zip-path.test.ts` | Token-Hashing + Path-Traversal-Schutz |
103
+ | `export-job-{idempotency,schema}.test.ts` | Active-job-uniqueness + Schema-Constraints |
104
+
105
+ ## Sample
106
+
107
+ `samples/apps/user-data-rights-demo` — runnable Demo mit todos-Domain,
108
+ EXT_USER_DATA-Hook für strategy-aware delete (anonymize → authorId=null,
109
+ delete → DROP), 3 living-doc Integration-Tests.