@cosmicdrift/kumiko-bundled-features 0.119.0 → 0.121.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 +6 -6
- 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/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/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/index.ts +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.121.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -96,11 +96,11 @@
|
|
|
96
96
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
97
97
|
},
|
|
98
98
|
"dependencies": {
|
|
99
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
100
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
101
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
102
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
103
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
99
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.121.0",
|
|
100
|
+
"@cosmicdrift/kumiko-framework": "0.121.0",
|
|
101
|
+
"@cosmicdrift/kumiko-headless": "0.121.0",
|
|
102
|
+
"@cosmicdrift/kumiko-renderer": "0.121.0",
|
|
103
|
+
"@cosmicdrift/kumiko-renderer-web": "0.121.0",
|
|
104
104
|
"@mollie/api-client": "^4.5.0",
|
|
105
105
|
"@node-rs/argon2": "^2.0.2",
|
|
106
106
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -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
|
});
|
|
@@ -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,
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { selectMany, type WhereObject } 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 { jobRunsTable } from "../job-run-table";
|
|
5
6
|
|
|
7
|
+
async function decryptRunPayload<T extends Record<string, unknown>>(row: T): Promise<T> {
|
|
8
|
+
if (typeof row["payload"] !== "string") return row;
|
|
9
|
+
return { ...row, payload: await decryptStoredPii(row["payload"], "job-runs") };
|
|
10
|
+
}
|
|
11
|
+
|
|
6
12
|
export const listQuery = defineQueryHandler({
|
|
7
13
|
name: "list",
|
|
8
14
|
schema: z.object({
|
|
@@ -19,6 +25,7 @@ export const listQuery = defineQueryHandler({
|
|
|
19
25
|
orderBy: { col: "id", direction: "desc" },
|
|
20
26
|
limit: query.payload.limit ?? 50,
|
|
21
27
|
});
|
|
22
|
-
|
|
28
|
+
// payload is stored encrypted under the triggering user's DEK (#799).
|
|
29
|
+
return { rows: await Promise.all(rows.map(decryptRunPayload)) };
|
|
23
30
|
},
|
|
24
31
|
});
|
package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { configValueEntity, createConfigFeature } from "../../config";
|
|
|
25
25
|
import { createDataRetentionFeature } from "../../data-retention";
|
|
26
26
|
import { createDeliveryFeature, notificationPreferenceEntity } from "../../delivery";
|
|
27
27
|
import { createFilesFeature } from "../../files";
|
|
28
|
+
import { createJobsFeature, jobRunLogsTable, jobRunsTable } from "../../jobs";
|
|
28
29
|
import {
|
|
29
30
|
apiTokenEntity,
|
|
30
31
|
createPersonalAccessTokensFeature,
|
|
@@ -41,8 +42,10 @@ import {
|
|
|
41
42
|
apiTokenExportHook,
|
|
42
43
|
configValueDeleteHook,
|
|
43
44
|
configValueExportHook,
|
|
45
|
+
deliveryAttemptExportHook,
|
|
44
46
|
inAppMessageDeleteHook,
|
|
45
47
|
inAppMessageExportHook,
|
|
48
|
+
jobRunExportHook,
|
|
46
49
|
notificationPreferenceDeleteHook,
|
|
47
50
|
notificationPreferenceExportHook,
|
|
48
51
|
tenantInvitationDeleteHook,
|
|
@@ -71,6 +74,7 @@ beforeAll(async () => {
|
|
|
71
74
|
createPersonalAccessTokensFeature({ scopes: PAT_SCOPES }),
|
|
72
75
|
createDeliveryFeature(),
|
|
73
76
|
createChannelInAppFeature(),
|
|
77
|
+
createJobsFeature(),
|
|
74
78
|
createUserDataRightsFeature(),
|
|
75
79
|
createUserDataRightsDefaultsFeature(),
|
|
76
80
|
],
|
|
@@ -81,7 +85,7 @@ beforeAll(async () => {
|
|
|
81
85
|
await unsafeCreateEntityTable(full.db, tenantInvitationEntity);
|
|
82
86
|
await unsafeCreateEntityTable(full.db, notificationPreferenceEntity);
|
|
83
87
|
await unsafeCreateEntityTable(full.db, configValueEntity);
|
|
84
|
-
await unsafePushTables(full.db, { inAppMessagesTable });
|
|
88
|
+
await unsafePushTables(full.db, { inAppMessagesTable, jobRunsTable, jobRunLogsTable });
|
|
85
89
|
|
|
86
90
|
minimal = await setupTestStack({
|
|
87
91
|
features: [
|
|
@@ -364,6 +368,60 @@ describe("config-value userData-hooks", () => {
|
|
|
364
368
|
});
|
|
365
369
|
});
|
|
366
370
|
|
|
371
|
+
describe("delivery-attempt userData-hooks (#799)", () => {
|
|
372
|
+
async function seedAttempt(id: string, recipientId: string, tenantId: string): Promise<void> {
|
|
373
|
+
await asRawClient(full.db).unsafe(
|
|
374
|
+
`INSERT INTO read_delivery_attempts
|
|
375
|
+
(id, tenant_id, notification_type, channel, recipient_id, recipient_address, status, priority)
|
|
376
|
+
VALUES ($1, $2, 'app:notify:ping', 'email', $3, $4, 'sent', 'normal')`,
|
|
377
|
+
[id, tenantId, recipientId, `user-${recipientId}@example.com`],
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
test("export returns only the user's attempts in the export tenant", async () => {
|
|
382
|
+
await seedAttempt(uuid(60), "da-user-1", TENANT_A);
|
|
383
|
+
await seedAttempt(uuid(61), "da-user-1", TENANT_B);
|
|
384
|
+
await seedAttempt(uuid(62), "da-user-2", TENANT_A);
|
|
385
|
+
|
|
386
|
+
const result = await deliveryAttemptExportHook(ctx("da-user-1"));
|
|
387
|
+
expect(result?.entity).toBe("delivery-attempt");
|
|
388
|
+
expect(result?.rows).toHaveLength(1);
|
|
389
|
+
expect(result?.rows[0]?.["recipientAddress"]).toBe("user-da-user-1@example.com");
|
|
390
|
+
expect(result?.rows[0]?.["channel"]).toBe("email");
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("export returns null for a user without attempts", async () => {
|
|
394
|
+
expect(await deliveryAttemptExportHook(ctx("da-nobody"))).toBeNull();
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
describe("job-run userData-hooks (#799)", () => {
|
|
399
|
+
async function seedRun(id: string, triggeredById: string | null): Promise<void> {
|
|
400
|
+
await asRawClient(full.db).unsafe(
|
|
401
|
+
`INSERT INTO read_job_runs
|
|
402
|
+
(id, tenant_id, job_name, bull_job_id, status, payload, attempt, started_at, triggered_by_id)
|
|
403
|
+
VALUES ($1::uuid, $2, 'app:job:export', $3, 'completed', '{"scope":"mine"}', 1, now(), $4)`,
|
|
404
|
+
[id, TENANT_A, `bull-${id}`, triggeredById],
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
test("export filters by triggeredById only (system-tenant rows)", async () => {
|
|
409
|
+
await seedRun(uuid(70), "jr-user-1");
|
|
410
|
+
await seedRun(uuid(71), "jr-user-2");
|
|
411
|
+
await seedRun(uuid(72), null);
|
|
412
|
+
|
|
413
|
+
const result = await jobRunExportHook(ctx("jr-user-1"));
|
|
414
|
+
expect(result?.entity).toBe("job-run");
|
|
415
|
+
expect(result?.rows).toHaveLength(1);
|
|
416
|
+
expect(result?.rows[0]?.["jobName"]).toBe("app:job:export");
|
|
417
|
+
expect(result?.rows[0]?.["payload"]).toBe('{"scope":"mine"}');
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("export returns null for a user without runs", async () => {
|
|
421
|
+
expect(await jobRunExportHook(ctx("jr-nobody"))).toBeNull();
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
367
425
|
describe("Presence-Gating: Source-Feature nicht gemountet → null/no-op statt Crash", () => {
|
|
368
426
|
test("alle gegateten Hooks no-open auf dem Minimal-Stack", async () => {
|
|
369
427
|
const minCtx = {
|
|
@@ -382,6 +440,8 @@ describe("Presence-Gating: Source-Feature nicht gemountet → null/no-op statt C
|
|
|
382
440
|
expect(await tenantInvitationExportHook(minCtx)).toBeNull();
|
|
383
441
|
expect(await notificationPreferenceExportHook(minCtx)).toBeNull();
|
|
384
442
|
expect(await configValueExportHook(minCtx)).toBeNull();
|
|
443
|
+
expect(await deliveryAttemptExportHook(minCtx)).toBeNull();
|
|
444
|
+
expect(await jobRunExportHook(minCtx)).toBeNull();
|
|
385
445
|
|
|
386
446
|
await expect(apiTokenDeleteHook(minCtx, "delete")).resolves.toBeUndefined();
|
|
387
447
|
await expect(inAppMessageDeleteHook(minCtx, "delete")).resolves.toBeUndefined();
|
|
@@ -5,11 +5,16 @@ import {
|
|
|
5
5
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
6
|
import { apiTokenDeleteHook, apiTokenExportHook } from "./hooks/api-token.userdata-hook";
|
|
7
7
|
import { configValueDeleteHook, configValueExportHook } from "./hooks/config-value.userdata-hook";
|
|
8
|
+
import {
|
|
9
|
+
deliveryAttemptDeleteHook,
|
|
10
|
+
deliveryAttemptExportHook,
|
|
11
|
+
} from "./hooks/delivery-attempt.userdata-hook";
|
|
8
12
|
import { fileRefDeleteHook, fileRefExportHook } from "./hooks/file-ref.userdata-hook";
|
|
9
13
|
import {
|
|
10
14
|
inAppMessageDeleteHook,
|
|
11
15
|
inAppMessageExportHook,
|
|
12
16
|
} from "./hooks/in-app-message.userdata-hook";
|
|
17
|
+
import { jobRunDeleteHook, jobRunExportHook } from "./hooks/job-run.userdata-hook";
|
|
13
18
|
import {
|
|
14
19
|
notificationPreferenceDeleteHook,
|
|
15
20
|
notificationPreferenceExportHook,
|
|
@@ -46,7 +51,7 @@ import { userSessionDeleteHook, userSessionExportHook } from "./hooks/user-sessi
|
|
|
46
51
|
export function createUserDataRightsDefaultsFeature(): FeatureDefinition {
|
|
47
52
|
return defineFeature("user-data-rights-defaults", (r) => {
|
|
48
53
|
r.describe(
|
|
49
|
-
"Registers ready-made `EXT_USER_DATA` export and delete hooks for the bundled entities that hold per-user data: `user` (delete strategy sets email to `deleted-<id>@anonymized.invalid`, nulls `passwordHash`, sets status to `Deleted`; anonymize strategy sets email to `anonymized-<id>@anonymized.invalid` without touching `passwordHash`), `fileRef` (delete removes both the DB row and the storage binary), plus — gated on the source feature being mounted — `user-session` (ip/userAgent, hard-delete), `api-token` (hard-delete = revoke), `in-app-message` (hard-delete), `tenant-invitation` (invitee email forgotten/pseudonymized, inviter link severed), `notification-preference` and user-scoped `config-value` (purged via the forget verb). Mount this alongside `user-data-rights` for standard GDPR compliance; omit it only if your app needs custom anonymization logic for these entities.",
|
|
54
|
+
"Registers ready-made `EXT_USER_DATA` export and delete hooks for the bundled entities that hold per-user data: `user` (delete strategy sets email to `deleted-<id>@anonymized.invalid`, nulls `passwordHash`, sets status to `Deleted`; anonymize strategy sets email to `anonymized-<id>@anonymized.invalid` without touching `passwordHash`), `fileRef` (delete removes both the DB row and the storage binary), plus — gated on the source feature being mounted — `user-session` (ip/userAgent, hard-delete), `api-token` (hard-delete = revoke), `in-app-message` (hard-delete), `tenant-invitation` (invitee email forgotten/pseudonymized, inviter link severed), `notification-preference` and user-scoped `config-value` (purged via the forget verb), plus export-only hooks for the events-only aggregates `delivery-attempt` (recipientAddress) and `job-run` (payload) whose erasure runs via crypto-shredding. Mount this alongside `user-data-rights` for standard GDPR compliance; omit it only if your app needs custom anonymization logic for these entities.",
|
|
50
55
|
);
|
|
51
56
|
r.uiHints({
|
|
52
57
|
displayLabel: "User Data Rights · Default Hooks",
|
|
@@ -65,6 +70,7 @@ export function createUserDataRightsDefaultsFeature(): FeatureDefinition {
|
|
|
65
70
|
"tenant",
|
|
66
71
|
"delivery",
|
|
67
72
|
"config",
|
|
73
|
+
"jobs",
|
|
68
74
|
);
|
|
69
75
|
|
|
70
76
|
r.useExtension(EXT_USER_DATA, "user", {
|
|
@@ -106,5 +112,19 @@ export function createUserDataRightsDefaultsFeature(): FeatureDefinition {
|
|
|
106
112
|
export: configValueExportHook,
|
|
107
113
|
delete: configValueDeleteHook,
|
|
108
114
|
});
|
|
115
|
+
|
|
116
|
+
// Events-only aggregates (#799): export reads the projected rows, the
|
|
117
|
+
// delete hooks are deliberate no-ops — erasure happens via crypto-
|
|
118
|
+
// shredding (forget erases the user's DEK; recipientAddress / job
|
|
119
|
+
// payload become unreadable in events AND rows).
|
|
120
|
+
r.useExtension(EXT_USER_DATA, "delivery-attempt", {
|
|
121
|
+
export: deliveryAttemptExportHook,
|
|
122
|
+
delete: deliveryAttemptDeleteHook,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
r.useExtension(EXT_USER_DATA, "job-run", {
|
|
126
|
+
export: jobRunExportHook,
|
|
127
|
+
delete: jobRunDeleteHook,
|
|
128
|
+
});
|
|
109
129
|
});
|
|
110
130
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { deliveryAttemptsTable } from "../../delivery";
|
|
4
|
+
import { featureMounted } from "./feature-mounted";
|
|
5
|
+
|
|
6
|
+
// userData-Hooks for delivery's attempt log (deferred from #797, closed by
|
|
7
|
+
// #799). deliveryAttempt is an events-only aggregate — the export reads the
|
|
8
|
+
// projected rows; recipientAddress may be ciphertext (kumiko-pii:), which
|
|
9
|
+
// the export runner's central decrypt sweep resolves to plaintext (or
|
|
10
|
+
// [[erased]] after a forget).
|
|
11
|
+
|
|
12
|
+
export const deliveryAttemptExportHook: UserDataExportHook = async (ctx) => {
|
|
13
|
+
if (!featureMounted(ctx, "delivery")) return null;
|
|
14
|
+
const rows = await selectMany<Record<string, unknown>>(ctx.db, deliveryAttemptsTable, {
|
|
15
|
+
tenantId: ctx.tenantId,
|
|
16
|
+
recipientId: ctx.userId,
|
|
17
|
+
});
|
|
18
|
+
if (rows.length === 0) return null;
|
|
19
|
+
return {
|
|
20
|
+
entity: "delivery-attempt",
|
|
21
|
+
rows: rows.map((r) => ({
|
|
22
|
+
notificationType: r["notificationType"],
|
|
23
|
+
channel: r["channel"],
|
|
24
|
+
status: r["status"],
|
|
25
|
+
recipientAddress: r["recipientAddress"],
|
|
26
|
+
priority: r["priority"],
|
|
27
|
+
createdAt: r["createdAt"],
|
|
28
|
+
})),
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const deliveryAttemptDeleteHook: UserDataDeleteHook = async () => {
|
|
33
|
+
// Deliberate no-op: erasure runs via crypto-shredding — the forget pipeline
|
|
34
|
+
// erases the recipient's DEK, which makes recipientAddress unreadable in
|
|
35
|
+
// BOTH the append-only events and the projected rows (#799). A read-side
|
|
36
|
+
// UPDATE here would be wiped on the next projection rebuild anyway.
|
|
37
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { jobRunsTable } from "../../jobs";
|
|
4
|
+
import { featureMounted } from "./feature-mounted";
|
|
5
|
+
|
|
6
|
+
// userData-Hooks for the jobs run log (deferred from #797, closed by #799).
|
|
7
|
+
// Job runs live on the SYSTEM tenant regardless of who triggered them, so
|
|
8
|
+
// the export filters by triggeredById only — Art. 15 covers everything the
|
|
9
|
+
// user caused, across tenants. payload may be ciphertext (kumiko-pii:); the
|
|
10
|
+
// export runner's central decrypt sweep resolves it.
|
|
11
|
+
|
|
12
|
+
export const jobRunExportHook: UserDataExportHook = async (ctx) => {
|
|
13
|
+
if (!featureMounted(ctx, "jobs")) return null;
|
|
14
|
+
const rows = await selectMany<Record<string, unknown>>(ctx.db, jobRunsTable, {
|
|
15
|
+
triggeredById: ctx.userId,
|
|
16
|
+
});
|
|
17
|
+
if (rows.length === 0) return null;
|
|
18
|
+
return {
|
|
19
|
+
entity: "job-run",
|
|
20
|
+
rows: rows.map((r) => ({
|
|
21
|
+
jobName: r["jobName"],
|
|
22
|
+
status: r["status"],
|
|
23
|
+
payload: r["payload"],
|
|
24
|
+
startedAt: r["startedAt"],
|
|
25
|
+
finishedAt: r["finishedAt"],
|
|
26
|
+
})),
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const jobRunDeleteHook: UserDataDeleteHook = async () => {
|
|
31
|
+
// Deliberate no-op: erasure runs via crypto-shredding — the forget pipeline
|
|
32
|
+
// erases the triggering user's DEK, which makes the payload unreadable in
|
|
33
|
+
// BOTH the append-only events and the projected rows (#799). triggeredById
|
|
34
|
+
// itself is a pseudonymous fk (plaintext by design, like config.userId).
|
|
35
|
+
};
|
|
@@ -13,10 +13,8 @@ import { featureMounted } from "./feature-mounted";
|
|
|
13
13
|
// A preference without its user is meaningless, so both strategies purge via
|
|
14
14
|
// the forget verb.
|
|
15
15
|
//
|
|
16
|
-
// The delivery ATTEMPTS log
|
|
17
|
-
//
|
|
18
|
-
// append-only event store — per-user redaction there needs the event-store
|
|
19
|
-
// redaction epic; a read-side UPDATE would be wiped on rebuild.
|
|
16
|
+
// The delivery ATTEMPTS log is covered separately: export via
|
|
17
|
+
// delivery-attempt.userdata-hook.ts, erasure via crypto-shredding (#799).
|
|
20
18
|
|
|
21
19
|
const crud = createEventStoreExecutor(notificationPreferencesTable, notificationPreferenceEntity, {
|
|
22
20
|
entityName: "notification-preference",
|
|
@@ -4,6 +4,10 @@ export {
|
|
|
4
4
|
configValueDeleteHook,
|
|
5
5
|
configValueExportHook,
|
|
6
6
|
} from "./hooks/config-value.userdata-hook";
|
|
7
|
+
export {
|
|
8
|
+
deliveryAttemptDeleteHook,
|
|
9
|
+
deliveryAttemptExportHook,
|
|
10
|
+
} from "./hooks/delivery-attempt.userdata-hook";
|
|
7
11
|
export {
|
|
8
12
|
fileRefDeleteHook,
|
|
9
13
|
fileRefExportHook,
|
|
@@ -12,6 +16,7 @@ export {
|
|
|
12
16
|
inAppMessageDeleteHook,
|
|
13
17
|
inAppMessageExportHook,
|
|
14
18
|
} from "./hooks/in-app-message.userdata-hook";
|
|
19
|
+
export { jobRunDeleteHook, jobRunExportHook } from "./hooks/job-run.userdata-hook";
|
|
15
20
|
export {
|
|
16
21
|
notificationPreferenceDeleteHook,
|
|
17
22
|
notificationPreferenceExportHook,
|