@cosmicdrift/kumiko-bundled-features 0.116.1 → 0.119.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 (46) hide show
  1. package/package.json +7 -6
  2. package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +283 -0
  3. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +5 -1
  4. package/src/auth-email-password/handlers/invite-accept.write.ts +5 -2
  5. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +5 -1
  6. package/src/auth-email-password/handlers/token-request-handler.ts +4 -2
  7. package/src/channel-email/__tests__/pii-guard.test.ts +56 -0
  8. package/src/channel-email/email-channel.ts +2 -1
  9. package/src/channel-email/index.ts +1 -0
  10. package/src/channel-email/pii-guard.ts +36 -0
  11. package/src/config/table.ts +1 -1
  12. package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +148 -0
  13. package/src/crypto-shredding/constants.ts +5 -0
  14. package/src/crypto-shredding/feature.ts +18 -0
  15. package/src/crypto-shredding/handlers/forget-subject.write.ts +91 -0
  16. package/src/crypto-shredding/index.ts +11 -0
  17. package/src/delivery/tables.ts +1 -1
  18. package/src/mail-foundation/feature.ts +5 -2
  19. package/src/personal-access-tokens/__tests__/feature.test.ts +24 -0
  20. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +39 -1
  21. package/src/personal-access-tokens/feature.ts +11 -1
  22. package/src/personal-access-tokens/handlers/create.write.ts +21 -15
  23. package/src/personal-access-tokens/handlers/list.query.ts +12 -9
  24. package/src/personal-access-tokens/schema/api-token.ts +1 -1
  25. package/src/sessions/__tests__/sessions.integration.test.ts +49 -0
  26. package/src/sessions/feature.ts +2 -0
  27. package/src/sessions/handlers/list.query.ts +12 -9
  28. package/src/sessions/handlers/mine.query.ts +11 -8
  29. package/src/sessions/schema/user-session.ts +4 -2
  30. package/src/sessions/session-callbacks.ts +19 -10
  31. package/src/shared/decrypt-stored-pii.ts +19 -0
  32. package/src/shared/encrypt-for-direct-write.ts +23 -0
  33. package/src/shared/index.ts +2 -0
  34. package/src/tenant/handlers/invitations.query.ts +10 -2
  35. package/src/tenant/invitation-table.ts +3 -2
  36. package/src/user/schema/user.ts +5 -2
  37. package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +143 -0
  38. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +79 -0
  39. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +73 -0
  40. package/src/user-data-rights/handlers/deletion-grace-period.ts +4 -1
  41. package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +3 -1
  42. package/src/user-data-rights/run-export-jobs.ts +5 -1
  43. package/src/user-data-rights/run-forget-cleanup.ts +51 -3
  44. package/src/user-data-rights/run-user-export.ts +39 -12
  45. package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +6 -1
  46. package/src/user-profile/handlers/change-email.write.ts +3 -1
@@ -53,15 +53,17 @@ export const userSessionEntity = createEntity({
53
53
  revokedAt: createTimestampField({
54
54
  access: { write: access.privileged },
55
55
  }),
56
+ // Subject is the session's user, not the session row — a user-forget
57
+ // must shred these, so the key hangs off userId (sid-self would orphan).
56
58
  ip: createTextField({
57
59
  maxLength: 64,
58
60
  access: { write: access.privileged },
59
- pii: true,
61
+ userOwned: { ownerField: "userId" },
60
62
  }),
61
63
  userAgent: createTextField({
62
64
  maxLength: 512,
63
65
  access: { write: access.privileged },
64
- pii: true,
66
+ userOwned: { ownerField: "userId" },
65
67
  }),
66
68
  },
67
69
  });
@@ -10,9 +10,10 @@ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
10
10
  import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
11
11
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
12
12
  import { Temporal } from "temporal-polyfill";
13
+ import { encryptForDirectWrite } from "../shared";
13
14
  import { USER_STATUS, type UserStatus, userTable } from "../user";
14
15
  import { DEFAULT_SESSION_EXPIRY_MS } from "./constants";
15
- import { userSessionTable } from "./schema/user-session";
16
+ import { userSessionEntity, userSessionTable } from "./schema/user-session";
16
17
 
17
18
  // Locked accounts whose live sessions must be refused. deletionRequested is
18
19
  // intentionally absent — it's a reversible grace period and the user needs
@@ -55,15 +56,23 @@ export function createSessionCallbacks(opts: SessionCallbacksOptions): SessionCa
55
56
  const sid = generateId();
56
57
  const now = Temporal.Now.instant();
57
58
  const expiresAt = now.add({ milliseconds: ttlMs });
58
- await insertOne(db, userSessionTable, {
59
- id: sid,
60
- tenantId: user.tenantId,
61
- userId: user.id,
62
- createdAt: now,
63
- expiresAt,
64
- ip: meta.ip,
65
- userAgent: meta.userAgent,
66
- });
59
+ await insertOne(
60
+ db,
61
+ userSessionTable,
62
+ await encryptForDirectWrite(
63
+ userSessionEntity,
64
+ {
65
+ id: sid,
66
+ tenantId: user.tenantId,
67
+ userId: user.id,
68
+ createdAt: now,
69
+ expiresAt,
70
+ ip: meta.ip,
71
+ userAgent: meta.userAgent,
72
+ },
73
+ "sessions:create",
74
+ ),
75
+ );
67
76
  return sid;
68
77
  },
69
78
 
@@ -0,0 +1,19 @@
1
+ import { requestContext } from "@cosmicdrift/kumiko-framework/api";
2
+ import {
3
+ configuredPiiSubjectKms,
4
+ decryptPiiFieldValues,
5
+ } from "@cosmicdrift/kumiko-framework/crypto";
6
+
7
+ // Raw reads (fetchOne/selectMany) return the stored column value — with an
8
+ // active KMS that is ciphertext (self-describing: it names its own subject,
9
+ // so no entity/field context is needed). An erased subject yields the
10
+ // sentinel, which matches no real value.
11
+ export async function decryptStoredPii(value: string, fallbackRequestId: string): Promise<string> {
12
+ const kms = configuredPiiSubjectKms();
13
+ if (!kms) return value;
14
+ const out = await decryptPiiFieldValues({ value }, ["value"], kms, {
15
+ requestId: requestContext.get()?.requestId ?? fallbackRequestId,
16
+ });
17
+ const decrypted = out["value"];
18
+ return typeof decrypted === "string" ? decrypted : value;
19
+ }
@@ -0,0 +1,23 @@
1
+ import { requestContext } from "@cosmicdrift/kumiko-framework/api";
2
+ import {
3
+ collectPiiSubjectFields,
4
+ configuredPiiSubjectKms,
5
+ encryptPiiFieldValues,
6
+ } from "@cosmicdrift/kumiko-framework/crypto";
7
+ import type { EntityDefinition } from "@cosmicdrift/kumiko-framework/engine";
8
+
9
+ // Unmanaged direct-write stores (r.unmanagedTable) skip the executor, so its
10
+ // PII encryption never runs — every insert of subject-annotated fields must
11
+ // go through this instead, and the feature declares { piiEncryptedOnWrite:
12
+ // true } at the registration site (enforced by the registry, #820).
13
+ export async function encryptForDirectWrite(
14
+ entity: EntityDefinition,
15
+ row: Record<string, unknown>,
16
+ fallbackRequestId: string,
17
+ ): Promise<Record<string, unknown>> {
18
+ const kms = configuredPiiSubjectKms();
19
+ if (!kms) return row;
20
+ return encryptPiiFieldValues(row, entity, collectPiiSubjectFields(entity), kms, {
21
+ requestId: requestContext.get()?.requestId ?? fallbackRequestId,
22
+ });
23
+ }
@@ -5,3 +5,5 @@ export {
5
5
  type MigrationRowOutcome,
6
6
  runChunkedMigration,
7
7
  } from "./chunked-entity-migration";
8
+ export { decryptStoredPii } from "./decrypt-stored-pii";
9
+ export { encryptForDirectWrite } from "./encrypt-for-direct-write";
@@ -1,6 +1,7 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { z } from "zod";
4
+ import { decryptStoredPii } from "../../shared";
4
5
  import { INVITATION_STATUS, tenantInvitationsTable } from "../invitation-table";
5
6
 
6
7
  // Pending-Invitations-Liste für den aktuellen Tenant. Admin-only.
@@ -17,10 +18,17 @@ export const invitationsQuery = defineQueryHandler({
17
18
  schema: z.object({}),
18
19
  access: { roles: ["Admin", "SystemAdmin"] },
19
20
  handler: async (query, ctx) => {
20
- const rows = await selectMany(ctx.db, tenantInvitationsTable, {
21
+ const rows = await selectMany<Record<string, unknown>>(ctx.db, tenantInvitationsTable, {
21
22
  tenantId: query.user.tenantId,
22
23
  status: INVITATION_STATUS.pending,
23
24
  });
24
- return rows ?? [];
25
+ return Promise.all(
26
+ (rows ?? []).map(async (row) => {
27
+ const email = row["email"];
28
+ return typeof email === "string"
29
+ ? { ...row, email: await decryptStoredPii(email, "tenant:invitations") }
30
+ : row;
31
+ }),
32
+ );
25
33
  },
26
34
  });
@@ -58,7 +58,7 @@ export const tenantInvitationEntity = createEntity({
58
58
  fields: {
59
59
  // Eingeladene Email — case-insensitive normalisiert beim Insert.
60
60
  // PII bis zur Annahme (danach hat der User selbst seine email in users).
61
- email: createTextField({ required: true, maxLength: 320, pii: true }),
61
+ email: createTextField({ required: true, maxLength: 320, pii: true, lookupable: true }),
62
62
  // Membership-Rolle die dem User nach Accept gegeben wird. Default
63
63
  // im handler ist "Admin" (Co-Admin-Pattern für kleine Teams).
64
64
  role: createTextField({ required: true, maxLength: 50 }),
@@ -70,7 +70,8 @@ export const tenantInvitationEntity = createEntity({
70
70
  default: "pending",
71
71
  }),
72
72
  // userId des einladenden Admins (für Audit-Trail "wer hat eingeladen").
73
- invitedBy: createTextField({ required: true, pii: true }),
73
+ // Self-referencing ownerField: the field's own value IS the owner userId.
74
+ invitedBy: createTextField({ required: true, userOwned: { ownerField: "invitedBy" } }),
74
75
  // UI-Anzeige — Wahrheit liegt in Redis-TTL.
75
76
  expiresAt: createTimestampField({ required: true }),
76
77
  },
@@ -67,6 +67,7 @@ export const userEntity = createEntity({
67
67
  format: "email",
68
68
  maxLength: 320,
69
69
  pii: true,
70
+ lookupable: true,
70
71
  access: { write: access.privileged },
71
72
  }),
72
73
 
@@ -79,8 +80,10 @@ export const userEntity = createEntity({
79
80
  }),
80
81
 
81
82
  // Profile — user-editable. Display-name is real-name in most apps,
82
- // so treat as PII for DSGVO export/forget pipelines.
83
- displayName: createTextField({ required: true, maxLength: 100, searchable: true, pii: true }),
83
+ // so treat as PII for DSGVO export/forget pipelines. NOT searchable:
84
+ // substring search on an encrypted field would require plaintext copies
85
+ // in the search index (boot-validator rejects the combination, #818).
86
+ displayName: createTextField({ required: true, maxLength: 100, pii: true }),
84
87
  locale: createTextField({ maxLength: 10, default: "de" }),
85
88
 
86
89
  // Which tenant should this user land in on next login. Set by the login
@@ -0,0 +1,143 @@
1
+ // Anonymer Deletion-Flow mit aktivem PII-KMS + Blind-Index (#818 PR 2):
2
+ // die User-Row liegt verschlüsselt in der DB. request-deletion-by-email muss
3
+ // den User über die email_bidx-Spalte finden und die Verify-Mail an den
4
+ // KLARTEXT schicken (vorher: Ciphertext-Adresse → Mail unzustellbar).
5
+
6
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
7
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
8
+ import {
9
+ configureBlindIndexKey,
10
+ configurePiiSubjectKms,
11
+ InMemoryKmsAdapter,
12
+ isPiiCiphertext,
13
+ resetBlindIndexKeyForTests,
14
+ resetPiiSubjectKmsForTests,
15
+ } from "@cosmicdrift/kumiko-framework/crypto";
16
+ import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
17
+ import {
18
+ setupTestStack,
19
+ type TestStack,
20
+ testTenantId,
21
+ unsafeCreateEntityTable,
22
+ } from "@cosmicdrift/kumiko-framework/stack";
23
+ import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
24
+ import {
25
+ createComplianceProfilesFeature,
26
+ tenantComplianceProfileEntity,
27
+ tenantComplianceProfileTable,
28
+ } from "../../compliance-profiles";
29
+ import { createDataRetentionFeature } from "../../data-retention";
30
+ import { createSessionsFeature } from "../../sessions";
31
+ import { USER_STATUS, userEntity, userTable } from "../../user";
32
+ import { createUserFeature } from "../../user/feature";
33
+ import { seedUser } from "../../user/seeding";
34
+ import { createUserDataRightsFeature } from "../feature";
35
+ import type { SendDeletionVerificationEmailFn } from "../handlers/request-deletion-by-email.write";
36
+
37
+ const REQUEST_BY_EMAIL = "user-data-rights:write:request-deletion-by-email";
38
+ const CONFIRM_BY_TOKEN = "user-data-rights:write:confirm-deletion-by-token";
39
+ const DELETION_SECRET = "test-deletion-secret-0123456789abcdef";
40
+ const VERIFY_URL = "https://app.example.test/delete-account/confirm";
41
+ const ALICE_EMAIL = "alice.kms-deletion@example.com";
42
+ const BIDX_KEY = Buffer.alloc(32, 7).toString("base64");
43
+
44
+ const tenantA = testTenantId(1);
45
+
46
+ type VerifyArgs = Parameters<SendDeletionVerificationEmailFn>[0];
47
+ const verifyCalls: VerifyArgs[] = [];
48
+ const sendDeletionVerificationEmail: SendDeletionVerificationEmailFn = async (args) => {
49
+ verifyCalls.push(args);
50
+ };
51
+
52
+ let stack: TestStack;
53
+ let aliceId: string;
54
+
55
+ beforeAll(async () => {
56
+ stack = await setupTestStack({
57
+ features: [
58
+ createUserFeature(),
59
+ createDataRetentionFeature(),
60
+ createComplianceProfilesFeature(),
61
+ createSessionsFeature(),
62
+ createUserDataRightsFeature({
63
+ deletionTokenSecret: DELETION_SECRET,
64
+ deletionVerifyUrl: VERIFY_URL,
65
+ sendDeletionVerificationEmail,
66
+ }),
67
+ ],
68
+ anonymousAccess: { defaultTenantId: tenantA },
69
+ });
70
+ await unsafeCreateEntityTable(stack.db, userEntity);
71
+ await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
72
+ await createEventsTable(stack.db);
73
+ });
74
+
75
+ afterAll(async () => {
76
+ await stack.cleanup();
77
+ });
78
+
79
+ beforeEach(async () => {
80
+ verifyCalls.length = 0;
81
+ await resetTestTables(stack.db, [userTable, tenantComplianceProfileTable, eventsTable]);
82
+
83
+ // KMS + bidx-Key VOR dem Seed — seedUser läuft über den Executor, die
84
+ // Row soll den verschlüsselten Prod-Zustand abbilden.
85
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
86
+ configureBlindIndexKey(BIDX_KEY);
87
+ ({ id: aliceId } = await seedUser(stack.db, {
88
+ email: ALICE_EMAIL,
89
+ displayName: "Alice",
90
+ passwordHash: "hashed",
91
+ emailVerified: true,
92
+ }));
93
+ });
94
+
95
+ afterEach(() => {
96
+ resetPiiSubjectKmsForTests();
97
+ resetBlindIndexKeyForTests();
98
+ });
99
+
100
+ function tokenFromLastVerifyCall(): string {
101
+ const url = new URL(verifyCalls[0]?.verifyUrl ?? "");
102
+ return url.searchParams.get("token") ?? "";
103
+ }
104
+
105
+ describe("anonymous deletion flow with active KMS", () => {
106
+ test("request-by-email finds the encrypted row via bidx and mails the PLAINTEXT address", async () => {
107
+ const rows = await asRawClient(stack.db).unsafe<Record<string, unknown>>(
108
+ `SELECT email, email_bidx FROM "${userTable.tableName}" WHERE id = $1`,
109
+ [aliceId],
110
+ );
111
+ expect(isPiiCiphertext(rows[0]?.["email"])).toBe(true);
112
+ expect(String(rows[0]?.["email_bidx"])).toStartWith("kumiko-bidx:v1:");
113
+
114
+ const res = await stack.http.raw("POST", "/api/write", {
115
+ type: REQUEST_BY_EMAIL,
116
+ payload: { email: ALICE_EMAIL },
117
+ });
118
+ expect(res.status).toBe(200);
119
+
120
+ expect(verifyCalls).toHaveLength(1);
121
+ expect(verifyCalls[0]?.email).toBe(ALICE_EMAIL);
122
+ expect(verifyCalls[0]?.verifyUrl).toContain("token=");
123
+ });
124
+
125
+ test("confirm-by-token flips to DeletionRequested; response carries no ciphertext", async () => {
126
+ await stack.http.raw("POST", "/api/write", {
127
+ type: REQUEST_BY_EMAIL,
128
+ payload: { email: ALICE_EMAIL },
129
+ });
130
+ const token = tokenFromLastVerifyCall();
131
+ expect(token).not.toBe("");
132
+
133
+ const res = await stack.http.raw("POST", "/api/write", {
134
+ type: CONFIRM_BY_TOKEN,
135
+ payload: { token },
136
+ });
137
+ expect(res.status).toBe(200);
138
+ const bodyText = await res.text();
139
+ expect(bodyText).not.toContain("kumiko-pii:");
140
+ const body = JSON.parse(bodyText) as { data?: { status?: string } };
141
+ expect(body.data?.status).toBe(USER_STATUS.DeletionRequested);
142
+ });
143
+ });
@@ -17,6 +17,7 @@
17
17
 
18
18
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
19
19
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
20
+ import { InMemoryKmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
20
21
  import type { JobContext } from "@cosmicdrift/kumiko-framework/engine";
21
22
  import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
22
23
  import {
@@ -747,3 +748,81 @@ describe("runForgetCleanup :: per-User-Sub-Tx-Isolation (advisor-pinned Architek
747
748
  }
748
749
  });
749
750
  });
751
+
752
+ // eraseKey never fails in the in-memory adapter, so the rollback test
753
+ // overrides it to simulate a KMS outage.
754
+ class FailingEraseKms extends InMemoryKmsAdapter {
755
+ override async eraseKey(): Promise<void> {
756
+ throw new Error("kms unavailable");
757
+ }
758
+ }
759
+
760
+ describe("runForgetCleanup :: crypto-shredding (subject key erase)", () => {
761
+ test("successful forget erases the user's subject key", async () => {
762
+ await seedUser(ALICE_ID, {
763
+ status: USER_STATUS.DeletionRequested,
764
+ gracePeriodEnd: instantFromOffsetMs(-60 * 1000),
765
+ });
766
+ await seedMembership(ALICE_ID, TENANT_A);
767
+
768
+ const kms = new InMemoryKmsAdapter();
769
+ await kms.createKey({ kind: "user", userId: ALICE_ID });
770
+
771
+ const result = await runForgetCleanup({
772
+ db: stack.db,
773
+ registry: stack.registry,
774
+ now: NOW(),
775
+ kms,
776
+ });
777
+
778
+ expect(result.processedUserIds).toEqual([ALICE_ID]);
779
+ expect(result.errors).toHaveLength(0);
780
+ await expect(kms.getKey({ kind: "user", userId: ALICE_ID })).rejects.toThrow(
781
+ "Subject key erased",
782
+ );
783
+ });
784
+
785
+ test("user without a subject key: erase is a no-op, forget still completes", async () => {
786
+ await seedUser(ALICE_ID, {
787
+ status: USER_STATUS.DeletionRequested,
788
+ gracePeriodEnd: instantFromOffsetMs(-60 * 1000),
789
+ });
790
+ await seedMembership(ALICE_ID, TENANT_A);
791
+
792
+ const result = await runForgetCleanup({
793
+ db: stack.db,
794
+ registry: stack.registry,
795
+ now: NOW(),
796
+ kms: new InMemoryKmsAdapter(),
797
+ });
798
+
799
+ expect(result.processedUserIds).toEqual([ALICE_ID]);
800
+ expect(result.errors).toHaveLength(0);
801
+ });
802
+
803
+ test("eraseKey failure rolls back the user's sub-tx (stays DeletionRequested, PII intact)", async () => {
804
+ await seedUser(ALICE_ID, {
805
+ status: USER_STATUS.DeletionRequested,
806
+ gracePeriodEnd: instantFromOffsetMs(-60 * 1000),
807
+ email: "alice.erase-fail@example.com",
808
+ });
809
+ await seedMembership(ALICE_ID, TENANT_A);
810
+
811
+ const result = await runForgetCleanup({
812
+ db: stack.db,
813
+ registry: stack.registry,
814
+ now: NOW(),
815
+ kms: new FailingEraseKms(),
816
+ });
817
+
818
+ expect(result.processedUserIds).toHaveLength(0);
819
+ expect(result.errors.length).toBeGreaterThanOrEqual(1);
820
+ expect(result.errors[0]?.message).toContain("kms unavailable");
821
+
822
+ // Sub-tx rollback: the anonymize hooks ran but must be undone — a user
823
+ // whose key survives must keep retrying, not sit half-forgotten.
824
+ const alice = await fetchUser(ALICE_ID);
825
+ expect(alice?.status).toBe(USER_STATUS.DeletionRequested);
826
+ expect(alice?.email).toBe("alice.erase-fail@example.com");
827
+ });
828
+ });
@@ -16,6 +16,12 @@
16
16
 
17
17
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
18
18
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
19
+ import {
20
+ configurePiiSubjectKms,
21
+ encryptPiiFieldValues,
22
+ InMemoryKmsAdapter,
23
+ resetPiiSubjectKmsForTests,
24
+ } from "@cosmicdrift/kumiko-framework/crypto";
19
25
  import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
20
26
  import {
21
27
  setupTestStack,
@@ -288,3 +294,70 @@ describe("runUserExport :: Empty-State", () => {
288
294
  expect(allUserSnippets).toEqual([]);
289
295
  });
290
296
  });
297
+
298
+ describe("runUserExport :: PII-Subject-Ciphertexte (#820)", () => {
299
+ test("Art. 20: bundle carries plaintext, never kumiko-pii: blobs", async () => {
300
+ const kms = new InMemoryKmsAdapter();
301
+ configurePiiSubjectKms(kms);
302
+ try {
303
+ await seedUser(ALICE_ID, { email: "alice.kms@example.com", displayName: "Alice KMS" });
304
+ await seedMembership(ALICE_ID, TENANT_A);
305
+ // Read-Pfad-Test: die Spalten tragen echten Subject-Ciphertext, wie
306
+ // sie ein Executor-Write hinterlassen haette (Write-Pfad: PR 1/2).
307
+ const encrypted = await encryptPiiFieldValues(
308
+ { id: ALICE_ID, email: "alice.kms@example.com", displayName: "Alice KMS" },
309
+ userEntity,
310
+ ["email", "displayName"],
311
+ kms,
312
+ { requestId: "test" },
313
+ );
314
+ await asRawClient(stack.db).unsafe(
315
+ `UPDATE read_users SET email = $1, display_name = $2 WHERE id = $3`,
316
+ [String(encrypted["email"]), String(encrypted["displayName"]), ALICE_ID],
317
+ );
318
+
319
+ const bundle = await runUserExport({
320
+ db: stack.db,
321
+ registry: stack.registry,
322
+ userId: ALICE_ID,
323
+ now: NOW(),
324
+ });
325
+
326
+ const json = JSON.stringify(bundle);
327
+ expect(json).not.toContain("kumiko-pii:");
328
+ expect(json).toContain("alice.kms@example.com");
329
+ expect(json).toContain("Alice KMS");
330
+ } finally {
331
+ resetPiiSubjectKmsForTests();
332
+ }
333
+ });
334
+
335
+ test("ciphertext without a configured KMS exports an explicit marker, not the blob", async () => {
336
+ const kms = new InMemoryKmsAdapter();
337
+ configurePiiSubjectKms(kms);
338
+ await seedUser(ALICE_ID, { email: "alice.nokms@example.com" });
339
+ await seedMembership(ALICE_ID, TENANT_A);
340
+ const encrypted = await encryptPiiFieldValues(
341
+ { id: ALICE_ID, email: "alice.nokms@example.com" },
342
+ userEntity,
343
+ ["email"],
344
+ kms,
345
+ { requestId: "test" },
346
+ );
347
+ await asRawClient(stack.db).unsafe(`UPDATE read_users SET email = $1 WHERE id = $2`, [
348
+ String(encrypted["email"]),
349
+ ALICE_ID,
350
+ ]);
351
+ resetPiiSubjectKmsForTests();
352
+
353
+ const bundle = await runUserExport({
354
+ db: stack.db,
355
+ registry: stack.registry,
356
+ userId: ALICE_ID,
357
+ now: NOW(),
358
+ });
359
+ const json = JSON.stringify(bundle);
360
+ expect(json).not.toContain("kumiko-pii:");
361
+ expect(json).toContain("[encrypted:unavailable]");
362
+ });
363
+ });
@@ -3,6 +3,7 @@ import { addDurationSpec, type DurationSpec } from "@cosmicdrift/kumiko-framewor
3
3
  import { createSystemUser, type HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
5
5
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
6
+ import { decryptStoredPii } from "../../shared";
6
7
  import { USER_STATUS, userTable } from "../../user";
7
8
  import { updateUserLifecycle } from "../lib/update-user-lifecycle";
8
9
 
@@ -73,7 +74,9 @@ export async function startDeletionGracePeriod(
73
74
  return {
74
75
  ok: true,
75
76
  gracePeriodEnd,
76
- userEmail: userRow["email"] ?? "",
77
+ userEmail: userRow["email"]
78
+ ? await decryptStoredPii(userRow["email"], "user-data-rights:grace-period")
79
+ : "",
77
80
  userLocale: userRow["locale"] ?? null,
78
81
  };
79
82
  }
@@ -95,7 +95,9 @@ export function createRequestDeletionByEmailHandler(opts: RequestDeletionByEmail
95
95
  if (opts.sendDeletionVerificationEmail) {
96
96
  try {
97
97
  await opts.sendDeletionVerificationEmail({
98
- email: userRow["email"],
98
+ // Row value is ciphertext with an active KMS; the lookup above
99
+ // only matches byte-exact, so the payload IS the stored plaintext.
100
+ email: event.payload.email,
99
101
  verifyUrl,
100
102
  expiresAt: expiresAt.toString(),
101
103
  });
@@ -46,6 +46,7 @@
46
46
 
47
47
  import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
48
48
  import { addDurationSpec } from "@cosmicdrift/kumiko-framework/compliance";
49
+ import { PII_ERASED_SENTINEL } from "@cosmicdrift/kumiko-framework/crypto";
49
50
  import type { DbConnection, DbRunner } from "@cosmicdrift/kumiko-framework/db";
50
51
  import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
51
52
  import type { Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
@@ -57,6 +58,7 @@ import {
57
58
  } from "@cosmicdrift/kumiko-framework/files";
58
59
  import type { getTemporal } from "@cosmicdrift/kumiko-framework/time";
59
60
  import { resolveProfileForTenant } from "../compliance-profiles";
61
+ import { decryptStoredPii } from "../shared";
60
62
  import { userTable } from "../user";
61
63
  import { selectExportJobsForStorageCleanup } from "./db/queries/export-jobs";
62
64
  import { runUserExport, type UserExportBundle } from "./run-user-export";
@@ -767,7 +769,9 @@ async function lookupUserContact(
767
769
  locale: string | null;
768
770
  } | null;
769
771
  if (!row?.email) return null;
770
- return { email: row.email, locale: row.locale ?? null };
772
+ const email = await decryptStoredPii(row.email, "user-data-rights:export-ready");
773
+ if (email === PII_ERASED_SENTINEL) return null;
774
+ return { email, locale: row.locale ?? null };
771
775
  }
772
776
 
773
777
  // Atom 5 — Email-Notification beim done-flip. Best-effort:
@@ -33,7 +33,12 @@
33
33
  // retried automatisch).
34
34
 
35
35
  import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
36
- import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
36
+ import {
37
+ configuredPiiSubjectKms,
38
+ type KmsAdapter,
39
+ subjectIdToKey,
40
+ } from "@cosmicdrift/kumiko-framework/crypto";
41
+ import { type DbRunner, nullBlindIndexesForSubject } from "@cosmicdrift/kumiko-framework/db";
37
42
  import {
38
43
  EXT_USER_DATA,
39
44
  EXT_USER_DATA_ORDER,
@@ -46,6 +51,7 @@ import {
46
51
  } from "@cosmicdrift/kumiko-framework/engine";
47
52
  import type { getTemporal } from "@cosmicdrift/kumiko-framework/time";
48
53
  import { resolveRetentionPolicyForTenant } from "../data-retention";
54
+ import { decryptStoredPii } from "../shared";
49
55
  import { tenantMembershipsTable } from "../tenant";
50
56
  import { USER_STATUS, userTable } from "../user";
51
57
  import { selectUsersDueForForgetCleanup } from "./db/queries/forget-cleanup";
@@ -101,6 +107,15 @@ export interface RunForgetCleanupArgs {
101
107
  tenantId: TenantId,
102
108
  ) => Promise<UserDataStorageProvider | undefined>;
103
109
 
110
+ /**
111
+ * KMS for crypto-shredding: erases the per-user subject key after the
112
+ * delete-hooks, inside the per-user sub-tx, right before the status flip.
113
+ * Defaults to the boot-configured adapter (configurePiiSubjectKms);
114
+ * explicit arg is the test seam. Omitted + none configured → plaintext
115
+ * deployment, nothing to shred.
116
+ */
117
+ readonly kms?: KmsAdapter;
118
+
104
119
  /**
105
120
  * App-level tenant-occupancy model (resolved from the `tenantModel` config by
106
121
  * the cron/handler). The pipeline refines it PER TENANT with a sole-member
@@ -144,6 +159,7 @@ export async function runForgetCleanup(
144
159
  args: RunForgetCleanupArgs,
145
160
  ): Promise<RunForgetCleanupResult> {
146
161
  const { db, registry, now, sendDeletionExecutedEmail, buildStorageProvider } = args;
162
+ const kms = args.kms ?? configuredPiiSubjectKms();
147
163
  const appTenantModel: TenantUserModel = args.tenantModel ?? "multi-user";
148
164
 
149
165
  // Step 1: Find users with expired grace period.
@@ -188,6 +204,7 @@ export async function runForgetCleanup(
188
204
  hookEntries,
189
205
  buildStorageProvider,
190
206
  appTenantModel,
207
+ kms,
191
208
  });
192
209
  hookCallsAttempted += userResult.hookCallsAttempted;
193
210
  errors.push(...userResult.errors);
@@ -248,8 +265,9 @@ async function processUser(args: {
248
265
  hookEntries: readonly HookEntry[];
249
266
  buildStorageProvider?: (tenantId: TenantId) => Promise<UserDataStorageProvider | undefined>;
250
267
  appTenantModel: TenantUserModel;
268
+ kms?: KmsAdapter;
251
269
  }): Promise<ProcessUserResult> {
252
- const { db, registry, userId, hookEntries, buildStorageProvider, appTenantModel } = args;
270
+ const { db, registry, userId, hookEntries, buildStorageProvider, appTenantModel, kms } = args;
253
271
  const errors: ForgetCleanupError[] = [];
254
272
  let hookCallsAttempted = 0;
255
273
 
@@ -262,7 +280,9 @@ async function processUser(args: {
262
280
  id: userId,
263
281
  });
264
282
  const userEmailBeforeDelete =
265
- userPreTx?.email && userPreTx.email.length > 0 ? userPreTx.email : null;
283
+ userPreTx?.email && userPreTx.email.length > 0
284
+ ? await decryptStoredPii(userPreTx.email, "user-data-rights:forget-cleanup")
285
+ : null;
266
286
  const userLocaleBeforeDelete = userPreTx?.locale ?? null;
267
287
 
268
288
  // Memberships fuer diesen User holen — alle Tenants in denen er Mitglied ist.
@@ -328,6 +348,34 @@ async function processUser(args: {
328
348
  }
329
349
  }
330
350
 
351
+ // Crypto-shredding: erase the user's subject key AFTER the hooks and
352
+ // BEFORE the status flip. The KMS is not a tx participant, so ordering
353
+ // is what makes this crash-safe: eraseKey-throw → tx rollback → user
354
+ // stays DeletionRequested → next run retries; flip-throw after a
355
+ // successful erase → retry re-runs the idempotent hooks and eraseKey
356
+ // (contractually a no-op on an erased/unknown subject). Erasing after
357
+ // the flip instead would risk a permanent silent miss: user already
358
+ // Deleted, no run ever picks him up again, key stays live.
359
+ if (kms) {
360
+ await kms.eraseKey(
361
+ { kind: "user", userId },
362
+ {
363
+ requestId: "user-data-rights:run-forget-cleanup",
364
+ userId,
365
+ eraseReason: "user-data-rights:forget",
366
+ },
367
+ );
368
+ // Blind-Index-Sweep (#818): bidx-Spalten des erased Subjects sofort
369
+ // nullen — sonst bliebe der deterministische HMAC bis zum nächsten
370
+ // Rebuild equality-matchbar (Linkage-Fenster). In der Sub-Tx, damit
371
+ // ein Rollback auch den Sweep zurücknimmt.
372
+ await nullBlindIndexesForSubject(
373
+ tx,
374
+ registry.features,
375
+ subjectIdToKey({ kind: "user", userId }),
376
+ );
377
+ }
378
+
331
379
  // Status-Flip in derselben Sub-Tx. Falls einer der Hooks oben
332
380
  // geworfen hat, kommen wir hier nicht an — die Tx rollback'd
333
381
  // alles, der User bleibt im DeletionRequested-Status, naechster