@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.
- package/package.json +7 -6
- package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +283 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +5 -1
- package/src/auth-email-password/handlers/invite-accept.write.ts +5 -2
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +5 -1
- package/src/auth-email-password/handlers/token-request-handler.ts +4 -2
- package/src/channel-email/__tests__/pii-guard.test.ts +56 -0
- package/src/channel-email/email-channel.ts +2 -1
- package/src/channel-email/index.ts +1 -0
- package/src/channel-email/pii-guard.ts +36 -0
- package/src/config/table.ts +1 -1
- package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +148 -0
- package/src/crypto-shredding/constants.ts +5 -0
- package/src/crypto-shredding/feature.ts +18 -0
- package/src/crypto-shredding/handlers/forget-subject.write.ts +91 -0
- package/src/crypto-shredding/index.ts +11 -0
- package/src/delivery/tables.ts +1 -1
- package/src/mail-foundation/feature.ts +5 -2
- package/src/personal-access-tokens/__tests__/feature.test.ts +24 -0
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +39 -1
- package/src/personal-access-tokens/feature.ts +11 -1
- package/src/personal-access-tokens/handlers/create.write.ts +21 -15
- package/src/personal-access-tokens/handlers/list.query.ts +12 -9
- package/src/personal-access-tokens/schema/api-token.ts +1 -1
- package/src/sessions/__tests__/sessions.integration.test.ts +49 -0
- package/src/sessions/feature.ts +2 -0
- package/src/sessions/handlers/list.query.ts +12 -9
- package/src/sessions/handlers/mine.query.ts +11 -8
- package/src/sessions/schema/user-session.ts +4 -2
- package/src/sessions/session-callbacks.ts +19 -10
- package/src/shared/decrypt-stored-pii.ts +19 -0
- package/src/shared/encrypt-for-direct-write.ts +23 -0
- package/src/shared/index.ts +2 -0
- package/src/tenant/handlers/invitations.query.ts +10 -2
- package/src/tenant/invitation-table.ts +3 -2
- package/src/user/schema/user.ts +5 -2
- package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +143 -0
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +79 -0
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +73 -0
- package/src/user-data-rights/handlers/deletion-grace-period.ts +4 -1
- package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +3 -1
- package/src/user-data-rights/run-export-jobs.ts +5 -1
- package/src/user-data-rights/run-forget-cleanup.ts +51 -3
- package/src/user-data-rights/run-user-export.ts +39 -12
- package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +6 -1
- 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
|
-
|
|
61
|
+
userOwned: { ownerField: "userId" },
|
|
60
62
|
}),
|
|
61
63
|
userAgent: createTextField({
|
|
62
64
|
maxLength: 512,
|
|
63
65
|
access: { write: access.privileged },
|
|
64
|
-
|
|
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(
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
+
}
|
package/src/shared/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
},
|
package/src/user/schema/user.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|