@cosmicdrift/kumiko-bundled-features 0.110.0 → 0.111.0

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 (38) hide show
  1. package/package.json +6 -6
  2. package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.test.ts +3 -3
  3. package/src/auth-email-password/__tests__/account-lockout.integration.test.ts +3 -3
  4. package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +9 -10
  5. package/src/auth-email-password/__tests__/auth.integration.test.ts +5 -5
  6. package/src/auth-email-password/__tests__/email-verification.integration.test.ts +7 -4
  7. package/src/auth-email-password/__tests__/identity-v3-login.integration.test.ts +3 -3
  8. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +3 -4
  9. package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.test.ts +3 -3
  10. package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +3 -3
  11. package/src/config/__tests__/config.integration.test.ts +7 -8
  12. package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +179 -0
  13. package/src/config/__tests__/inherited-redaction.integration.test.ts +4 -3
  14. package/src/config/feature.ts +14 -8
  15. package/src/config/handlers/reencrypt.job.ts +197 -0
  16. package/src/config/handlers/set.write.ts +2 -2
  17. package/src/config/index.ts +4 -3
  18. package/src/config/resolver.ts +34 -10
  19. package/src/file-foundation/__tests__/file-foundation.integration.test.ts +4 -3
  20. package/src/jobs/__tests__/job-system-user.integration.test.ts +4 -8
  21. package/src/mail-foundation/__tests__/mail-foundation.integration.test.ts +4 -3
  22. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +3 -4
  23. package/src/readiness/__tests__/readiness.integration.test.ts +4 -3
  24. package/src/secrets/handlers/rotate.job.ts +77 -98
  25. package/src/secrets/secrets-context.ts +7 -38
  26. package/src/secrets/table.ts +6 -7
  27. package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +6 -4
  28. package/src/sessions/__tests__/sessions.integration.test.ts +3 -3
  29. package/src/shared/chunked-entity-migration.ts +89 -0
  30. package/src/shared/index.ts +7 -0
  31. package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +6 -5
  32. package/src/tenant/__tests__/tenant.integration.test.ts +8 -4
  33. package/src/tier-engine/__tests__/tier-engine.integration.test.ts +7 -4
  34. package/src/user-data-rights/__tests__/download.integration.test.ts +7 -4
  35. package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +152 -0
  36. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +3 -3
  37. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +3 -3
  38. package/src/user-data-rights/run-user-export.ts +42 -4
@@ -0,0 +1,152 @@
1
+ // Art. 20 + encrypted entity fields: export hooks read raw rows past the
2
+ // executor's decrypt layer, so runUserExport must decrypt encrypted fields
3
+ // centrally — plaintext with a configured cipher, an explicit marker
4
+ // (never the raw ciphertext) without one.
5
+
6
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
7
+ import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
8
+ import {
9
+ buildEntityTable,
10
+ configureEntityFieldEncryption,
11
+ resetEntityFieldEncryptionCacheForTests,
12
+ } from "@cosmicdrift/kumiko-framework/db";
13
+ import {
14
+ createEntity,
15
+ createRegistry,
16
+ createTextField,
17
+ defineFeature,
18
+ EXT_USER_DATA,
19
+ type UserDataExportHook,
20
+ } from "@cosmicdrift/kumiko-framework/engine";
21
+ import { setupTestStack, type TestStack } from "@cosmicdrift/kumiko-framework/stack";
22
+ import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
23
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
24
+ import { createComplianceProfilesFeature } from "../../compliance-profiles";
25
+ import { createDataRetentionFeature } from "../../data-retention";
26
+ import { createSessionsFeature } from "../../sessions";
27
+ import { createUserFeature } from "../../user";
28
+ import { createUserDataRightsFeature } from "../feature";
29
+ import { runUserExport } from "../run-user-export";
30
+
31
+ const TENANT = "00000000-0000-4000-8000-00000000000a";
32
+ const USER_ID = "cccccccc-cccc-4ccc-8ccc-000000000001";
33
+ const PLAINTEXT_NOTE = "my private vault note";
34
+
35
+ const cipher = createTestEnvelopeCipher();
36
+
37
+ const vaultNoteEntity = createEntity({
38
+ table: "read_vault_notes",
39
+ fields: {
40
+ ownerId: createTextField({ required: true }),
41
+ note: createTextField({ encrypted: true }),
42
+ },
43
+ });
44
+
45
+ const vaultNotesTableRef = buildEntityTable("vault-note", vaultNoteEntity);
46
+
47
+ // Export hook mirrors real hooks (folders-user-data pattern): raw select,
48
+ // no decrypt of its own.
49
+ const vaultExportHook: UserDataExportHook = async (ctx) => {
50
+ const rows = await selectMany<Record<string, unknown>>(ctx.db, vaultNotesTableRef, {
51
+ ownerId: ctx.userId,
52
+ });
53
+ if (rows.length === 0) return null;
54
+ return { entity: "vault-note", rows };
55
+ };
56
+
57
+ const vaultFeature = defineFeature("vault", (r) => {
58
+ const entity = r.entity("vault-note", vaultNoteEntity);
59
+ r.useExtension(EXT_USER_DATA, entity, {
60
+ export: vaultExportHook,
61
+ delete: async () => undefined,
62
+ });
63
+ });
64
+
65
+ let stack: TestStack;
66
+ let registry: ReturnType<typeof createRegistry>;
67
+
68
+ beforeAll(async () => {
69
+ stack = await setupTestStack({ features: [] });
70
+ // user-data-rights hosts EXT_USER_DATA — without it the vault feature's
71
+ // useExtension reference fails registry creation. Its hard requires
72
+ // (user/data-retention/compliance-profiles/sessions) come along.
73
+ registry = createRegistry([
74
+ createUserFeature(),
75
+ createDataRetentionFeature(),
76
+ createComplianceProfilesFeature(),
77
+ createSessionsFeature(),
78
+ createUserDataRightsFeature(),
79
+ vaultFeature,
80
+ ]);
81
+
82
+ await asRawClient(stack.db).unsafe(`
83
+ CREATE TABLE IF NOT EXISTS read_tenant_memberships (
84
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
85
+ tenant_id UUID NOT NULL,
86
+ user_id TEXT NOT NULL,
87
+ version INTEGER NOT NULL DEFAULT 0,
88
+ inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
89
+ modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
90
+ inserted_by_id TEXT,
91
+ modified_by_id TEXT,
92
+ is_deleted BOOLEAN NOT NULL DEFAULT false,
93
+ deleted_at TIMESTAMPTZ,
94
+ deleted_by_id TEXT,
95
+ roles TEXT NOT NULL DEFAULT '[]',
96
+ UNIQUE(user_id, tenant_id)
97
+ )
98
+ `);
99
+ await asRawClient(stack.db).unsafe(
100
+ `INSERT INTO read_tenant_memberships (tenant_id, user_id) VALUES ('${TENANT}', '${USER_ID}')
101
+ ON CONFLICT DO NOTHING`,
102
+ );
103
+
104
+ await asRawClient(stack.db).unsafe(`
105
+ CREATE TABLE IF NOT EXISTS read_vault_notes (
106
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
107
+ tenant_id UUID NOT NULL,
108
+ version INTEGER NOT NULL DEFAULT 0,
109
+ inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
110
+ modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
111
+ inserted_by_id TEXT,
112
+ modified_by_id TEXT,
113
+ owner_id TEXT NOT NULL,
114
+ note TEXT
115
+ )
116
+ `);
117
+ const storedNote = await cipher.encrypt(PLAINTEXT_NOTE);
118
+ await asRawClient(stack.db).unsafe(
119
+ `INSERT INTO read_vault_notes (tenant_id, owner_id, note) VALUES ('${TENANT}', '${USER_ID}', '${storedNote.replaceAll("'", "''")}')`,
120
+ );
121
+ });
122
+
123
+ afterEach(() => {
124
+ resetEntityFieldEncryptionCacheForTests();
125
+ });
126
+
127
+ afterAll(async () => {
128
+ await stack.cleanup();
129
+ });
130
+
131
+ async function exportedNote(): Promise<unknown> {
132
+ const bundle = await runUserExport({
133
+ db: stack.db,
134
+ registry,
135
+ userId: USER_ID,
136
+ now: getTemporal().Now.instant(),
137
+ });
138
+ const snippet = bundle.tenants.flatMap((t) => t.entities).find((e) => e.entity === "vault-note");
139
+ return snippet?.rows[0]?.["note"];
140
+ }
141
+
142
+ describe("user export with encrypted entity fields", () => {
143
+ test("decrypts encrypted fields when the cipher is configured", async () => {
144
+ configureEntityFieldEncryption(cipher);
145
+ expect(await exportedNote()).toBe(PLAINTEXT_NOTE);
146
+ });
147
+
148
+ test("exports an explicit marker — never ciphertext — without a cipher", async () => {
149
+ const value = await exportedNote();
150
+ expect(value).toBe("[encrypted:unavailable]");
151
+ });
152
+ });
@@ -14,7 +14,6 @@
14
14
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
15
15
  import { randomBytes } from "node:crypto";
16
16
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
17
- import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
18
17
  import {
19
18
  createRegistry,
20
19
  type Registry,
@@ -40,6 +39,7 @@ import {
40
39
  } from "@cosmicdrift/kumiko-framework/stack";
41
40
  import {
42
41
  createLateBoundHolder,
42
+ createTestEnvelopeCipher,
43
43
  resetTestTables,
44
44
  updateRows,
45
45
  } from "@cosmicdrift/kumiko-framework/testing";
@@ -97,8 +97,8 @@ function buildFeatures() {
97
97
  }
98
98
 
99
99
  beforeAll(async () => {
100
- const encryption = createEncryptionProvider(encryptionKey);
101
- const resolver = createConfigResolver({ encryption });
100
+ const encryption = createTestEnvelopeCipher(encryptionKey);
101
+ const resolver = createConfigResolver({ cipher: encryption });
102
102
  const bound = sessionCallbacksFromLateBound(callbacks);
103
103
 
104
104
  stack = await setupTestStack({
@@ -13,7 +13,6 @@
13
13
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
14
14
  import { randomBytes } from "node:crypto";
15
15
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
16
- import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
17
16
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
18
17
  import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
19
18
  import {
@@ -26,6 +25,7 @@ import {
26
25
  } from "@cosmicdrift/kumiko-framework/stack";
27
26
  import {
28
27
  createLateBoundHolder,
28
+ createTestEnvelopeCipher,
29
29
  resetTestTables,
30
30
  updateRows,
31
31
  } from "@cosmicdrift/kumiko-framework/testing";
@@ -65,8 +65,8 @@ const ALICE_EMAIL = "alice.restrict@example.com";
65
65
  const ALICE_PW = "alice-pw-long-enough";
66
66
 
67
67
  beforeAll(async () => {
68
- const encryption = createEncryptionProvider(encryptionKey);
69
- const resolver = createConfigResolver({ encryption });
68
+ const encryption = createTestEnvelopeCipher(encryptionKey);
69
+ const resolver = createConfigResolver({ cipher: encryption });
70
70
  const bound = sessionCallbacksFromLateBound(callbacks);
71
71
 
72
72
  stack = await setupTestStack({
@@ -22,6 +22,11 @@
22
22
 
23
23
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
24
24
  import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
25
+ import {
26
+ collectEncryptedFieldNames,
27
+ configuredEntityFieldEncryption,
28
+ decryptEntityFieldValues,
29
+ } from "@cosmicdrift/kumiko-framework/db";
25
30
  import {
26
31
  EXT_USER_DATA,
27
32
  type Registry,
@@ -137,8 +142,9 @@ export async function runUserExport(args: RunUserExportArgs): Promise<UserExport
137
142
  for (const tenantId of tenantList) {
138
143
  const entities: UserDataExportSnippet[] = [];
139
144
  for (const entry of hookEntries) {
140
- const snippet = await entry.exportHook({ db, tenantId, userId });
141
- if (snippet === null) continue;
145
+ const rawSnippet = await entry.exportHook({ db, tenantId, userId });
146
+ if (rawSnippet === null) continue;
147
+ const snippet = await decryptSnippetFields(registry, entry.entityName, rawSnippet);
142
148
  entities.push(snippet);
143
149
  if (snippet.fileRefs) {
144
150
  for (const ref of snippet.fileRefs) {
@@ -169,12 +175,13 @@ export async function runUserExport(args: RunUserExportArgs): Promise<UserExport
169
175
  if (tenantList.length === 0 && hookEntries.length > 0) {
170
176
  const orphanEntities: UserDataExportSnippet[] = [];
171
177
  for (const entry of hookEntries) {
172
- const snippet = await entry.exportHook({
178
+ const rawSnippet = await entry.exportHook({
173
179
  db,
174
180
  tenantId: SYSTEM_TENANT_ID_FOR_ORPHANS,
175
181
  userId,
176
182
  });
177
- if (snippet === null) continue;
183
+ if (rawSnippet === null) continue;
184
+ const snippet = await decryptSnippetFields(registry, entry.entityName, rawSnippet);
178
185
  orphanEntities.push(snippet);
179
186
  if (snippet.fileRefs) {
180
187
  for (const ref of snippet.fileRefs) {
@@ -203,6 +210,37 @@ export async function runUserExport(args: RunUserExportArgs): Promise<UserExport
203
210
  };
204
211
  }
205
212
 
213
+ // Art. 20 verlangt die DATEN, nicht deren Ciphertext: Export-Hooks lesen
214
+ // raw rows an der Executor-Decrypt-Schicht vorbei, darum entschluesselt
215
+ // dieser zentrale Pass encrypted entity fields nach jedem Hook. Ohne
216
+ // konfigurierten Cipher wird ein expliziter Marker exportiert statt den
217
+ // base64-Blob als "Wert" auszuliefern (leak-by-confusion).
218
+ const ENCRYPTED_UNAVAILABLE = "[encrypted:unavailable]";
219
+
220
+ async function decryptSnippetFields(
221
+ registry: Registry,
222
+ hookEntityName: string,
223
+ snippet: UserDataExportSnippet,
224
+ ): Promise<UserDataExportSnippet> {
225
+ const entity = registry.getEntity(snippet.entity) ?? registry.getEntity(hookEntityName);
226
+ if (!entity) return snippet;
227
+ const encryptedFields = collectEncryptedFieldNames(entity);
228
+ if (encryptedFields.size === 0) return snippet;
229
+
230
+ const cipher = configuredEntityFieldEncryption();
231
+ const rows = await Promise.all(
232
+ snippet.rows.map(async (row) => {
233
+ if (cipher) return decryptEntityFieldValues(row, encryptedFields, cipher);
234
+ const out = { ...row };
235
+ for (const name of encryptedFields) {
236
+ if (typeof out[name] === "string") out[name] = ENCRYPTED_UNAVAILABLE;
237
+ }
238
+ return out;
239
+ }),
240
+ );
241
+ return { ...snippet, rows };
242
+ }
243
+
206
244
  // Pseudo-Tenant fuer User ohne aktive Memberships. Identisch zum
207
245
  // Pattern in run-forget-cleanup.ts — RFC4122-Null-UUID. Tenant-scoped
208
246
  // Hooks finden hier nichts (no-op).