@cosmicdrift/kumiko-bundled-features 0.118.0 → 0.120.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/__tests__/delivery-pii-kms.integration.test.ts +171 -0
- package/src/delivery/feature.ts +5 -1
- package/src/delivery/handlers/log.query.ts +14 -1
- package/src/delivery/tables.ts +1 -1
- package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +120 -0
- package/src/jobs/feature.ts +6 -1
- package/src/jobs/handlers/detail.query.ts +6 -0
- package/src/jobs/handlers/list.query.ts +8 -1
- package/src/mail-foundation/feature.ts +5 -2
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +39 -1
- package/src/personal-access-tokens/feature.ts +2 -0
- 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/__tests__/bundled-entities.userdata-hooks.integration.test.ts +61 -1
- package/src/user-data-rights-defaults/feature.ts +21 -1
- package/src/user-data-rights-defaults/hooks/delivery-attempt.userdata-hook.ts +37 -0
- package/src/user-data-rights-defaults/hooks/job-run.userdata-hook.ts +35 -0
- package/src/user-data-rights-defaults/hooks/notification-preference.userdata-hook.ts +2 -4
- package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +6 -1
- package/src/user-data-rights-defaults/index.ts +5 -0
- package/src/user-profile/handlers/change-email.write.ts +3 -1
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// crypto-shredding forget-subject — end-to-end over real HTTP dispatch:
|
|
2
|
+
//
|
|
3
|
+
// - DPO erases a user subject → DEK gone (getKey throws KeyErased),
|
|
4
|
+
// subject-forgotten audit event appended
|
|
5
|
+
// - tenant subjects shred the same way
|
|
6
|
+
// - repeat forget is a no-op erase but still audited
|
|
7
|
+
// - no KMS configured → 500 with actionable message
|
|
8
|
+
// - Member role → 403 (DPO/SystemAdmin only)
|
|
9
|
+
|
|
10
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
11
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
12
|
+
import {
|
|
13
|
+
configurePiiSubjectKms,
|
|
14
|
+
InMemoryKmsAdapter,
|
|
15
|
+
resetPiiSubjectKmsForTests,
|
|
16
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
17
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
18
|
+
import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
19
|
+
import { setupTestStack, type TestStack, testTenantId } from "@cosmicdrift/kumiko-framework/stack";
|
|
20
|
+
import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
21
|
+
import { SUBJECT_FORGOTTEN_EVENT_NAME } from "../constants";
|
|
22
|
+
import { createCryptoShreddingFeature } from "../feature";
|
|
23
|
+
|
|
24
|
+
const FORGET = "crypto-shredding:write:forget-subject";
|
|
25
|
+
|
|
26
|
+
let stack: TestStack;
|
|
27
|
+
let kms: InMemoryKmsAdapter;
|
|
28
|
+
|
|
29
|
+
const TENANT: TenantId = testTenantId(1);
|
|
30
|
+
const TARGET_USER_ID = "aaaaaaaa-aaaa-4aaa-8aaa-000000000001";
|
|
31
|
+
const TARGET_TENANT_ID = "aaaaaaaa-aaaa-4aaa-8aaa-0000000000a1";
|
|
32
|
+
const REASON = "authority request #42 (Art. 17)";
|
|
33
|
+
|
|
34
|
+
const dpoUser = {
|
|
35
|
+
id: "aaaaaaaa-aaaa-4aaa-8aaa-0000000000d1",
|
|
36
|
+
tenantId: TENANT,
|
|
37
|
+
roles: ["DataProtectionOfficer"],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const memberUser = {
|
|
41
|
+
id: "aaaaaaaa-aaaa-4aaa-8aaa-0000000000e1",
|
|
42
|
+
tenantId: TENANT,
|
|
43
|
+
roles: ["Member"],
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
beforeAll(async () => {
|
|
47
|
+
stack = await setupTestStack({ features: [createCryptoShreddingFeature()] });
|
|
48
|
+
await createEventsTable(stack.db);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterAll(async () => {
|
|
52
|
+
await stack.cleanup();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
beforeEach(async () => {
|
|
56
|
+
await resetTestTables(stack.db, [eventsTable]);
|
|
57
|
+
kms = new InMemoryKmsAdapter();
|
|
58
|
+
configurePiiSubjectKms(kms);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
resetPiiSubjectKmsForTests();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
async function forgottenEvents(): Promise<Array<{ payload: Record<string, unknown> }>> {
|
|
66
|
+
return (await selectMany(stack.db, eventsTable, {
|
|
67
|
+
type: SUBJECT_FORGOTTEN_EVENT_NAME,
|
|
68
|
+
})) as Array<{ payload: Record<string, unknown> }>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe("crypto-shredding :: forget-subject", () => {
|
|
72
|
+
test("DPO forgets a user subject → key erased + audit event", async () => {
|
|
73
|
+
const subject = { kind: "user", userId: TARGET_USER_ID } as const;
|
|
74
|
+
await kms.createKey(subject);
|
|
75
|
+
|
|
76
|
+
const result = await stack.http.writeOk<{ subjectKey: string }>(
|
|
77
|
+
FORGET,
|
|
78
|
+
{ subject, reason: REASON },
|
|
79
|
+
dpoUser,
|
|
80
|
+
);
|
|
81
|
+
expect(result.subjectKey).toBe(`user:${TARGET_USER_ID}`);
|
|
82
|
+
|
|
83
|
+
await expect(kms.getKey(subject)).rejects.toThrow("Subject key erased");
|
|
84
|
+
|
|
85
|
+
const events = await forgottenEvents();
|
|
86
|
+
expect(events).toHaveLength(1);
|
|
87
|
+
expect(events[0]?.payload).toMatchObject({
|
|
88
|
+
subjectKey: `user:${TARGET_USER_ID}`,
|
|
89
|
+
reason: REASON,
|
|
90
|
+
forgottenBy: dpoUser.id,
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("tenant subject shreds the same way", async () => {
|
|
95
|
+
const subject = { kind: "tenant", tenantId: TARGET_TENANT_ID } as const;
|
|
96
|
+
await kms.createKey({ kind: "tenant", tenantId: TARGET_TENANT_ID as TenantId });
|
|
97
|
+
|
|
98
|
+
const result = await stack.http.writeOk<{ subjectKey: string }>(
|
|
99
|
+
FORGET,
|
|
100
|
+
{ subject, reason: REASON },
|
|
101
|
+
dpoUser,
|
|
102
|
+
);
|
|
103
|
+
expect(result.subjectKey).toBe(`tenant:${TARGET_TENANT_ID}`);
|
|
104
|
+
|
|
105
|
+
await expect(
|
|
106
|
+
kms.getKey({ kind: "tenant", tenantId: TARGET_TENANT_ID as TenantId }),
|
|
107
|
+
).rejects.toThrow("Subject key erased");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("repeat forget: erase is a no-op but each attempt is audited", async () => {
|
|
111
|
+
const subject = { kind: "user", userId: TARGET_USER_ID } as const;
|
|
112
|
+
await kms.createKey(subject);
|
|
113
|
+
|
|
114
|
+
await stack.http.writeOk(FORGET, { subject, reason: REASON }, dpoUser);
|
|
115
|
+
await stack.http.writeOk(FORGET, { subject, reason: `${REASON} (repeat)` }, dpoUser);
|
|
116
|
+
|
|
117
|
+
expect(await forgottenEvents()).toHaveLength(2);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("no KMS configured → 500 with boot hint", async () => {
|
|
121
|
+
resetPiiSubjectKmsForTests();
|
|
122
|
+
|
|
123
|
+
const err = await stack.http.writeErr(
|
|
124
|
+
FORGET,
|
|
125
|
+
{ subject: { kind: "user", userId: TARGET_USER_ID }, reason: REASON },
|
|
126
|
+
dpoUser,
|
|
127
|
+
);
|
|
128
|
+
expect(err.httpStatus).toBe(500);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("Member role → 403", async () => {
|
|
132
|
+
const err = await stack.http.writeErr(
|
|
133
|
+
FORGET,
|
|
134
|
+
{ subject: { kind: "user", userId: TARGET_USER_ID }, reason: REASON },
|
|
135
|
+
memberUser,
|
|
136
|
+
);
|
|
137
|
+
expect(err.httpStatus).toBe(403);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("reason shorter than 10 chars → schema reject", async () => {
|
|
141
|
+
const err = await stack.http.writeErr(
|
|
142
|
+
FORGET,
|
|
143
|
+
{ subject: { kind: "user", userId: TARGET_USER_ID }, reason: "short" },
|
|
144
|
+
dpoUser,
|
|
145
|
+
);
|
|
146
|
+
expect(err.httpStatus).toBe(400);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { CRYPTO_SHREDDING_FEATURE_NAME } from "./constants";
|
|
3
|
+
import { forgetSubjectWrite, subjectForgottenSchema } from "./handlers/forget-subject.write";
|
|
4
|
+
|
|
5
|
+
export function createCryptoShreddingFeature(): FeatureDefinition {
|
|
6
|
+
return defineFeature(CRYPTO_SHREDDING_FEATURE_NAME, (r) => {
|
|
7
|
+
r.describe(
|
|
8
|
+
"Operator-level crypto-shredding trigger. `forget-subject` erases a user or tenant subject key in the configured KMS adapter, making every PII field encrypted under it permanently unreadable (reads render the `[[erased]]` sentinel), and appends a `subject-forgotten` audit event. Requires a KMS adapter (`runProdApp({ kms })`). The automated Art.-17 deletion pipeline in `user-data-rights` erases keys itself; this command covers manual forgets (authority requests, operator recovery, tenant destroy).",
|
|
9
|
+
);
|
|
10
|
+
r.uiHints({
|
|
11
|
+
displayLabel: "Crypto-Shredding",
|
|
12
|
+
category: "compliance",
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
r.defineEvent("subject-forgotten", subjectForgottenSchema);
|
|
16
|
+
r.writeHandler(forgetSubjectWrite);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { requestContext } from "@cosmicdrift/kumiko-framework/api";
|
|
2
|
+
import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
|
|
3
|
+
import {
|
|
4
|
+
configuredPiiSubjectKms,
|
|
5
|
+
type SubjectId,
|
|
6
|
+
subjectIdToKey,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
8
|
+
import { nullBlindIndexesForSubject } from "@cosmicdrift/kumiko-framework/db";
|
|
9
|
+
import { defineWriteHandler, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
10
|
+
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { CRYPTO_SHREDDING_AGGREGATE_TYPE, SUBJECT_FORGOTTEN_EVENT_NAME } from "../constants";
|
|
13
|
+
|
|
14
|
+
export const subjectIdSchema = z.discriminatedUnion("kind", [
|
|
15
|
+
z.object({ kind: z.literal("user"), userId: z.uuid() }),
|
|
16
|
+
z.object({ kind: z.literal("tenant"), tenantId: z.uuid() }),
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
export const forgetSubjectSchema = z.object({
|
|
20
|
+
subject: subjectIdSchema,
|
|
21
|
+
reason: z.string().min(10),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export const subjectForgottenSchema = z.object({
|
|
25
|
+
subjectKey: z.string().min(1),
|
|
26
|
+
reason: z.string().min(10),
|
|
27
|
+
forgottenBy: z.string().min(1),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// Manual crypto-shred for a DPO / platform operator: erases the subject's
|
|
31
|
+
// DEK immediately (all its PII ciphertext becomes unreadable, reads render
|
|
32
|
+
// "[[erased]]") and appends the audit event. Forget is final — the adapter
|
|
33
|
+
// keeps a tombstone, so the subject can never get a new key.
|
|
34
|
+
//
|
|
35
|
+
// The automated Art.-17 path (user-data-rights forget-cleanup) calls
|
|
36
|
+
// kms.eraseKey directly inside its per-user sub-tx; this command is the
|
|
37
|
+
// standalone trigger for cases outside that pipeline (authority requests,
|
|
38
|
+
// tenant-destroy in Sprint 5, operator recovery).
|
|
39
|
+
export const forgetSubjectWrite = defineWriteHandler({
|
|
40
|
+
name: "forget-subject",
|
|
41
|
+
schema: forgetSubjectSchema,
|
|
42
|
+
access: { roles: [ROLES.DataProtectionOfficer, ROLES.SystemAdmin] },
|
|
43
|
+
handler: async (event, ctx) => {
|
|
44
|
+
const kms = configuredPiiSubjectKms();
|
|
45
|
+
if (!kms) {
|
|
46
|
+
return writeFailure(
|
|
47
|
+
new InternalError({
|
|
48
|
+
message:
|
|
49
|
+
"[crypto-shredding] forget-subject called but no KMS adapter is configured — " +
|
|
50
|
+
"pass runProdApp({ kms }) / configurePiiSubjectKms(adapter) at boot.",
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const raw = event.payload.subject;
|
|
56
|
+
const subject: SubjectId =
|
|
57
|
+
raw.kind === "user"
|
|
58
|
+
? { kind: "user", userId: raw.userId }
|
|
59
|
+
: { kind: "tenant", tenantId: raw.tenantId as TenantId }; // @cast-boundary uuid-validated command payload → branded id
|
|
60
|
+
const subjectKey = subjectIdToKey(subject);
|
|
61
|
+
|
|
62
|
+
// Erase BEFORE the audit append: if the append throws, the key is gone
|
|
63
|
+
// but no event exists — a retry is a no-op erase plus the event. The
|
|
64
|
+
// reverse order could leave an audit trail claiming a shred that never
|
|
65
|
+
// happened.
|
|
66
|
+
await kms.eraseKey(subject, {
|
|
67
|
+
requestId: requestContext.get()?.requestId ?? "crypto-shredding:forget-subject",
|
|
68
|
+
userId: event.user.id,
|
|
69
|
+
eraseReason: event.payload.reason,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Blind-Index-Sweep (#818): bidx-Spalten des erased Subjects sofort
|
|
73
|
+
// nullen — sonst bliebe der deterministische HMAC bis zum nächsten
|
|
74
|
+
// Rebuild equality-matchbar. Bewusst ctx.db.raw: der Ciphertext-Prefix
|
|
75
|
+
// adressiert das Subject tenant-übergreifend.
|
|
76
|
+
await nullBlindIndexesForSubject(ctx.db.raw, ctx.registry.features, subjectKey);
|
|
77
|
+
|
|
78
|
+
await ctx.unsafeAppendEvent({
|
|
79
|
+
aggregateId: raw.kind === "user" ? raw.userId : raw.tenantId,
|
|
80
|
+
aggregateType: CRYPTO_SHREDDING_AGGREGATE_TYPE,
|
|
81
|
+
type: SUBJECT_FORGOTTEN_EVENT_NAME,
|
|
82
|
+
payload: {
|
|
83
|
+
subjectKey,
|
|
84
|
+
reason: event.payload.reason,
|
|
85
|
+
forgottenBy: event.user.id,
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return { isSuccess: true as const, data: { subjectKey } };
|
|
90
|
+
},
|
|
91
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export {
|
|
2
|
+
CRYPTO_SHREDDING_AGGREGATE_TYPE,
|
|
3
|
+
CRYPTO_SHREDDING_FEATURE_NAME,
|
|
4
|
+
SUBJECT_FORGOTTEN_EVENT_NAME,
|
|
5
|
+
} from "./constants";
|
|
6
|
+
export { createCryptoShreddingFeature } from "./feature";
|
|
7
|
+
export {
|
|
8
|
+
forgetSubjectSchema,
|
|
9
|
+
subjectForgottenSchema,
|
|
10
|
+
subjectIdSchema,
|
|
11
|
+
} from "./handlers/forget-subject.write";
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Delivery-attempt log under KMS (#799): recipientAddress is written via
|
|
2
|
+
// LOW-LEVEL append() in attempt-log.ts — the event-PII catalog must cover
|
|
3
|
+
// it. Stored event + projected row carry ciphertext under the recipient's
|
|
4
|
+
// DEK, the outgoing mail still reaches the PLAINTEXT address, the admin
|
|
5
|
+
// log.query decrypts for display, and erasing the recipient's key flips
|
|
6
|
+
// the log entry to [[erased]] without touching the append-only stream.
|
|
7
|
+
|
|
8
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
10
|
+
import {
|
|
11
|
+
configurePiiSubjectKms,
|
|
12
|
+
InMemoryKmsAdapter,
|
|
13
|
+
isPiiCiphertext,
|
|
14
|
+
PII_ERASED_SENTINEL,
|
|
15
|
+
resetPiiSubjectKmsForTests,
|
|
16
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
17
|
+
import {
|
|
18
|
+
defineFeature,
|
|
19
|
+
defineWriteHandler,
|
|
20
|
+
type NotifyFn,
|
|
21
|
+
qn,
|
|
22
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
23
|
+
import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
24
|
+
import {
|
|
25
|
+
createTestUser,
|
|
26
|
+
setupTestStack,
|
|
27
|
+
type TestStack,
|
|
28
|
+
TestUsers,
|
|
29
|
+
unsafePushTables,
|
|
30
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
31
|
+
import { z } from "zod";
|
|
32
|
+
import { createChannelEmailFeature } from "../../channel-email/feature";
|
|
33
|
+
import { createInMemoryTransport } from "../../channel-email/types";
|
|
34
|
+
import { createConfigFeature } from "../../config/feature";
|
|
35
|
+
import { configValuesTable } from "../../config/table";
|
|
36
|
+
import { createRendererFoundationFeature } from "../../renderer-foundation/feature";
|
|
37
|
+
import { createRendererSimpleFeature } from "../../renderer-simple/feature";
|
|
38
|
+
import { simpleRenderer } from "../../renderer-simple/simple-renderer";
|
|
39
|
+
import { createTemplateResolverFeature } from "../../template-resolver/feature";
|
|
40
|
+
import { createTenantFeature } from "../../tenant/feature";
|
|
41
|
+
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
42
|
+
import { DELIVERY_ATTEMPT_EVENT } from "../constants";
|
|
43
|
+
import { createDeliveryFeature } from "../feature";
|
|
44
|
+
import { deliveryAttemptsTable, notificationPreferencesTable } from "../tables";
|
|
45
|
+
import { createDeliveryTestContext } from "../testing";
|
|
46
|
+
|
|
47
|
+
const emailTransport = createInMemoryTransport();
|
|
48
|
+
const testEmail = (userId: string | number) => `user-${userId}@test.com`;
|
|
49
|
+
|
|
50
|
+
const admin = TestUsers.admin;
|
|
51
|
+
const recipient = createTestUser({ id: 7, roles: ["User"] });
|
|
52
|
+
|
|
53
|
+
const appFeature = defineFeature("app", (r) => {
|
|
54
|
+
r.requires("delivery");
|
|
55
|
+
|
|
56
|
+
r.writeHandler(
|
|
57
|
+
defineWriteHandler({
|
|
58
|
+
name: "ping",
|
|
59
|
+
schema: z.object({ toUserId: z.string() }),
|
|
60
|
+
handler: async (event, ctx) => {
|
|
61
|
+
const notify = ctx.notify as NotifyFn;
|
|
62
|
+
await notify(qn("app", "notify", "pinged"), {
|
|
63
|
+
to: event.payload.toUserId,
|
|
64
|
+
data: { title: "Ping", body: "Du wurdest angepingt" },
|
|
65
|
+
});
|
|
66
|
+
return { isSuccess: true, data: { sent: true } };
|
|
67
|
+
},
|
|
68
|
+
access: { openToAll: true },
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
let stack: TestStack;
|
|
74
|
+
let kms: InMemoryKmsAdapter;
|
|
75
|
+
|
|
76
|
+
beforeAll(async () => {
|
|
77
|
+
stack = await setupTestStack({
|
|
78
|
+
features: [
|
|
79
|
+
createConfigFeature(),
|
|
80
|
+
createTenantFeature(),
|
|
81
|
+
createTemplateResolverFeature(),
|
|
82
|
+
createRendererFoundationFeature(),
|
|
83
|
+
createDeliveryFeature(),
|
|
84
|
+
createRendererSimpleFeature(),
|
|
85
|
+
createChannelEmailFeature({
|
|
86
|
+
transport: emailTransport,
|
|
87
|
+
renderer: simpleRenderer,
|
|
88
|
+
resolveEmail: async (userId) => testEmail(userId),
|
|
89
|
+
}),
|
|
90
|
+
appFeature,
|
|
91
|
+
],
|
|
92
|
+
extraContext: (deps) => createDeliveryTestContext(deps),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await unsafePushTables(stack.db, {
|
|
96
|
+
configValuesTable,
|
|
97
|
+
tenantMembershipsTable,
|
|
98
|
+
notificationPreferencesTable,
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
afterAll(async () => {
|
|
103
|
+
await stack.cleanup();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ONE kms for the whole file: rows persist across tests, so a fresh adapter
|
|
107
|
+
// per test would orphan earlier ciphertext (decrypt fails loud on KeyNotFound).
|
|
108
|
+
beforeEach(() => {
|
|
109
|
+
kms ??= new InMemoryKmsAdapter();
|
|
110
|
+
configurePiiSubjectKms(kms);
|
|
111
|
+
emailTransport.sent.length = 0;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
afterAll(() => {
|
|
115
|
+
resetPiiSubjectKmsForTests();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
async function pingRecipient(): Promise<void> {
|
|
119
|
+
await stack.http.writeOk(qn("app", "write", "ping"), { toUserId: recipient.id }, admin);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
describe("delivery attempt log under KMS", () => {
|
|
123
|
+
test("mail goes to the plaintext address; event + row store ciphertext", async () => {
|
|
124
|
+
await pingRecipient();
|
|
125
|
+
|
|
126
|
+
expect(emailTransport.sent.length).toBe(1);
|
|
127
|
+
expect(emailTransport.sent[0]?.to).toBe(testEmail(recipient.id));
|
|
128
|
+
|
|
129
|
+
const events = await selectMany(stack.db, eventsTable, { type: DELIVERY_ATTEMPT_EVENT });
|
|
130
|
+
const sent = events
|
|
131
|
+
.map((e) => e.payload as Record<string, unknown>)
|
|
132
|
+
.find((p) => p["channel"] === "email" && p["status"] === "sent");
|
|
133
|
+
expect(sent).toBeDefined();
|
|
134
|
+
expect(isPiiCiphertext(sent?.["recipientAddress"])).toBe(true);
|
|
135
|
+
expect(String(sent?.["recipientAddress"])).toContain(`user:${recipient.id}`);
|
|
136
|
+
expect(sent?.["recipientId"]).toBe(recipient.id);
|
|
137
|
+
|
|
138
|
+
const rows = await selectMany(stack.db, deliveryAttemptsTable, {
|
|
139
|
+
recipientId: recipient.id,
|
|
140
|
+
channel: "email",
|
|
141
|
+
});
|
|
142
|
+
expect(rows.length).toBeGreaterThan(0);
|
|
143
|
+
expect(isPiiCiphertext(rows[0]?.["recipientAddress"])).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("admin log.query decrypts for display; after key-erase it shows [[erased]]", async () => {
|
|
147
|
+
await pingRecipient();
|
|
148
|
+
|
|
149
|
+
const before = await stack.http.queryOk<{ rows: Record<string, unknown>[] }>(
|
|
150
|
+
"delivery:query:log",
|
|
151
|
+
{ limit: 100 },
|
|
152
|
+
admin,
|
|
153
|
+
);
|
|
154
|
+
const entry = before.rows.find(
|
|
155
|
+
(r) => r["recipientId"] === recipient.id && r["channel"] === "email",
|
|
156
|
+
);
|
|
157
|
+
expect(entry?.["recipientAddress"]).toBe(testEmail(recipient.id));
|
|
158
|
+
|
|
159
|
+
await kms.eraseKey({ kind: "user", userId: recipient.id });
|
|
160
|
+
|
|
161
|
+
const after = await stack.http.queryOk<{ rows: Record<string, unknown>[] }>(
|
|
162
|
+
"delivery:query:log",
|
|
163
|
+
{ limit: 100 },
|
|
164
|
+
admin,
|
|
165
|
+
);
|
|
166
|
+
const erased = after.rows.find(
|
|
167
|
+
(r) => r["recipientId"] === recipient.id && r["channel"] === "email",
|
|
168
|
+
);
|
|
169
|
+
expect(erased?.["recipientAddress"]).toBe(PII_ERASED_SENTINEL);
|
|
170
|
+
});
|
|
171
|
+
});
|
package/src/delivery/feature.ts
CHANGED
|
@@ -40,7 +40,11 @@ export function createDeliveryFeature(): FeatureDefinition {
|
|
|
40
40
|
// attempt is a fresh stream, no CRUD lifecycle. Framework's
|
|
41
41
|
// boot-validator accepts the projection below because at least one
|
|
42
42
|
// apply-key is a registered domain-event (DELIVERY_ATTEMPT_EVENT).
|
|
43
|
-
|
|
43
|
+
// recipientAddress is the real PII (email address); recipientId stays
|
|
44
|
+
// plaintext — pseudonymous fk, same line as config.userId (#821).
|
|
45
|
+
r.defineEvent("attempt", deliveryAttemptSchema, {
|
|
46
|
+
piiFields: { recipientAddress: { subjectField: "recipientId" } },
|
|
47
|
+
});
|
|
44
48
|
|
|
45
49
|
// Inline projection that materialises every delivery attempt into
|
|
46
50
|
// deliveryAttemptsTable. Runs in the SAME transaction as the low-level
|
|
@@ -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 { deliveryAttemptsTable } from "../tables";
|
|
5
6
|
|
|
6
7
|
export const logQuery = defineQueryHandler({
|
|
@@ -14,6 +15,18 @@ export const logQuery = defineQueryHandler({
|
|
|
14
15
|
orderBy: { col: "createdAt", direction: "desc" },
|
|
15
16
|
limit: query.payload.limit,
|
|
16
17
|
});
|
|
17
|
-
|
|
18
|
+
// recipientAddress is stored encrypted under the recipient's DEK (#799)
|
|
19
|
+
// — decrypt for the admin log view; forgotten subjects show [[erased]].
|
|
20
|
+
return {
|
|
21
|
+
rows: await Promise.all(
|
|
22
|
+
rows.map(async (row) => ({
|
|
23
|
+
...row,
|
|
24
|
+
recipientAddress:
|
|
25
|
+
typeof row["recipientAddress"] === "string"
|
|
26
|
+
? await decryptStoredPii(row["recipientAddress"], "delivery-log")
|
|
27
|
+
: row["recipientAddress"],
|
|
28
|
+
})),
|
|
29
|
+
),
|
|
30
|
+
};
|
|
18
31
|
},
|
|
19
32
|
});
|
package/src/delivery/tables.ts
CHANGED
|
@@ -76,7 +76,7 @@ export const deliveryAttemptsTableMeta: EntityTableMeta = defineUnmanagedTable({
|
|
|
76
76
|
export const notificationPreferenceEntity = createEntity({
|
|
77
77
|
table: "read_notification_preferences",
|
|
78
78
|
fields: {
|
|
79
|
-
userId: createTextField({ required: true,
|
|
79
|
+
userId: createTextField({ required: true, allowPlaintext: "pseudonymous-fk" }),
|
|
80
80
|
notificationType: createTextField({ required: true }), // qualified name or "*"
|
|
81
81
|
channel: createTextField({ required: true }), // "inApp", "email", "push", or "*"
|
|
82
82
|
enabled: createBooleanField({ default: true }),
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Event-PII on the jobs run-logger (#799): runStarted.payload can carry
|
|
2
|
+
// arbitrary user data and is written via LOW-LEVEL append() (not
|
|
3
|
+
// ctx.appendEvent) — exactly the path the event-PII catalog must cover.
|
|
4
|
+
// With a KMS active the stored event AND the projected read-row carry
|
|
5
|
+
// ciphertext under the triggering user's DEK; erasing that key makes the
|
|
6
|
+
// payload unreadable ([[erased]]) without touching the append-only stream.
|
|
7
|
+
|
|
8
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
10
|
+
import {
|
|
11
|
+
configurePiiSubjectKms,
|
|
12
|
+
decryptPiiFieldValues,
|
|
13
|
+
InMemoryKmsAdapter,
|
|
14
|
+
isPiiCiphertext,
|
|
15
|
+
PII_ERASED_SENTINEL,
|
|
16
|
+
resetPiiSubjectKmsForTests,
|
|
17
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
18
|
+
import { createRegistry } from "@cosmicdrift/kumiko-framework/engine";
|
|
19
|
+
import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
20
|
+
import {
|
|
21
|
+
createTestDb,
|
|
22
|
+
createTestRedis,
|
|
23
|
+
type TestDb,
|
|
24
|
+
type TestRedis,
|
|
25
|
+
unsafePushTables,
|
|
26
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
27
|
+
import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
28
|
+
import { createJobsFeature } from "../feature";
|
|
29
|
+
import { createJobRunLogger, JOB_RUN_STARTED_EVENT } from "../job-run-logger";
|
|
30
|
+
import { jobRunLogsTable, jobRunsTable } from "../job-run-table";
|
|
31
|
+
|
|
32
|
+
let testDb: TestDb;
|
|
33
|
+
let testRedis: TestRedis;
|
|
34
|
+
let logger: ReturnType<typeof createJobRunLogger>;
|
|
35
|
+
let kms: InMemoryKmsAdapter;
|
|
36
|
+
|
|
37
|
+
const USER_ID = "u-pii-9";
|
|
38
|
+
const SECRET_PAYLOAD = JSON.stringify({ iban: "DE89370400440532013000" });
|
|
39
|
+
|
|
40
|
+
beforeAll(async () => {
|
|
41
|
+
testDb = await createTestDb();
|
|
42
|
+
testRedis = await createTestRedis();
|
|
43
|
+
// createRegistry publishes the event-PII catalog as a module singleton —
|
|
44
|
+
// the logger's low-level append() picks it up without further wiring.
|
|
45
|
+
const registry = createRegistry([createJobsFeature()]);
|
|
46
|
+
await unsafePushTables(testDb.db, { jobRunsTable, jobRunLogsTable });
|
|
47
|
+
await createEventsTable(testDb.db);
|
|
48
|
+
logger = createJobRunLogger({ db: testDb.db, registry });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterAll(async () => {
|
|
52
|
+
await testDb.cleanup();
|
|
53
|
+
await testRedis.cleanup();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
beforeEach(async () => {
|
|
57
|
+
await resetTestTables(testDb.db, [eventsTable, jobRunsTable, jobRunLogsTable]);
|
|
58
|
+
kms = new InMemoryKmsAdapter();
|
|
59
|
+
configurePiiSubjectKms(kms);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
resetPiiSubjectKmsForTests();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("jobs run-started payload under KMS", () => {
|
|
67
|
+
test("stored event carries ciphertext payload, plaintext triggeredById", async () => {
|
|
68
|
+
await logger.onJobStart?.("app:job:export", "bull-1", {
|
|
69
|
+
triggeredById: USER_ID,
|
|
70
|
+
payload: SECRET_PAYLOAD,
|
|
71
|
+
attempt: 1,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const events = await selectMany(testDb.db, eventsTable, { type: JOB_RUN_STARTED_EVENT });
|
|
75
|
+
expect(events.length).toBe(1);
|
|
76
|
+
const payload = events[0]?.payload as Record<string, unknown>;
|
|
77
|
+
expect(isPiiCiphertext(payload["payload"])).toBe(true);
|
|
78
|
+
expect(String(payload["payload"])).toContain(`user:${USER_ID}`);
|
|
79
|
+
expect(payload["triggeredById"]).toBe(USER_ID);
|
|
80
|
+
|
|
81
|
+
const back = await decryptPiiFieldValues(payload, ["payload"], kms, { requestId: "t" });
|
|
82
|
+
expect(back["payload"]).toBe(SECRET_PAYLOAD);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("projected read-row carries the same ciphertext; erase → [[erased]]", async () => {
|
|
86
|
+
await logger.onJobStart?.("app:job:export", "bull-2", {
|
|
87
|
+
triggeredById: USER_ID,
|
|
88
|
+
payload: SECRET_PAYLOAD,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const row = await fetchOne(testDb.db, jobRunsTable, { bullJobId: "bull-2" });
|
|
92
|
+
expect(isPiiCiphertext(row?.["payload"])).toBe(true);
|
|
93
|
+
|
|
94
|
+
await kms.eraseKey({ kind: "user", userId: USER_ID });
|
|
95
|
+
const after = await decryptPiiFieldValues({ payload: row?.["payload"] }, ["payload"], kms, {
|
|
96
|
+
requestId: "t",
|
|
97
|
+
});
|
|
98
|
+
expect(after["payload"]).toBe(PII_ERASED_SENTINEL);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("system run (no triggeredById) stays plaintext — no subject to shred", async () => {
|
|
102
|
+
await logger.onJobStart?.("app:job:cron-sweep", "bull-3", {
|
|
103
|
+
payload: JSON.stringify({ scope: "all" }),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const row = await fetchOne(testDb.db, jobRunsTable, { bullJobId: "bull-3" });
|
|
107
|
+
expect(row?.["payload"]).toBe(JSON.stringify({ scope: "all" }));
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("without a KMS the payload stays plaintext (rollout mode)", async () => {
|
|
111
|
+
resetPiiSubjectKmsForTests();
|
|
112
|
+
await logger.onJobStart?.("app:job:export", "bull-4", {
|
|
113
|
+
triggeredById: USER_ID,
|
|
114
|
+
payload: SECRET_PAYLOAD,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const row = await fetchOne(testDb.db, jobRunsTable, { bullJobId: "bull-4" });
|
|
118
|
+
expect(row?.["payload"]).toBe(SECRET_PAYLOAD);
|
|
119
|
+
});
|
|
120
|
+
});
|
package/src/jobs/feature.ts
CHANGED
|
@@ -44,7 +44,12 @@ export function createJobsFeature(): FeatureDefinition {
|
|
|
44
44
|
// (no executor, no CRUD). The boot-validator accepts the two
|
|
45
45
|
// projections below because every apply-key is a registered
|
|
46
46
|
// domain-event.
|
|
47
|
-
|
|
47
|
+
// payload can carry arbitrary user data; triggeredById stays plaintext
|
|
48
|
+
// (pseudonymous fk). System runs (triggeredById null) stay plaintext —
|
|
49
|
+
// no user subject to shred.
|
|
50
|
+
r.defineEvent("run-started", runStartedSchema, {
|
|
51
|
+
piiFields: { payload: { subjectField: "triggeredById" } },
|
|
52
|
+
});
|
|
48
53
|
r.defineEvent("run-completed", runCompletedSchema);
|
|
49
54
|
r.defineEvent("run-failed", runFailedSchema);
|
|
50
55
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { fetchOne, 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 { jobRunLogsTable, jobRunsTable } from "../job-run-table";
|
|
5
6
|
|
|
6
7
|
export const detailQuery = defineQueryHandler({
|
|
@@ -18,6 +19,11 @@ export const detailQuery = defineQueryHandler({
|
|
|
18
19
|
|
|
19
20
|
if (!row) return null;
|
|
20
21
|
|
|
22
|
+
// payload is stored encrypted under the triggering user's DEK (#799).
|
|
23
|
+
if (typeof row["payload"] === "string") {
|
|
24
|
+
row["payload"] = await decryptStoredPii(row["payload"], "job-run-detail");
|
|
25
|
+
}
|
|
26
|
+
|
|
21
27
|
const logs = await selectMany(
|
|
22
28
|
db,
|
|
23
29
|
jobRunLogsTable,
|