@cosmicdrift/kumiko-framework 0.254.0 → 0.255.1
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 +3 -3
- package/src/bun-db/__tests__/run-in-savepoint-if-supported.integration.test.ts +47 -0
- package/src/bun-db/query.ts +16 -10
- package/src/crypto/__tests__/event-pii.test.ts +144 -11
- package/src/crypto/__tests__/subject-id-schema.test.ts +60 -0
- package/src/crypto/__tests__/subject-key-record.test.ts +10 -0
- package/src/crypto/__tests__/subject-resolver.test.ts +89 -0
- package/src/crypto/event-pii.ts +16 -9
- package/src/crypto/index.ts +3 -0
- package/src/crypto/kms-adapter.ts +25 -1
- package/src/crypto/subject-resolver.ts +62 -1
- package/src/db/queries/backfill-pii.ts +10 -6
- package/src/engine/__tests__/projection-detail-tabs.test.ts +33 -2
- package/src/engine/boot-validator/__tests__/record-owned.test.ts +4 -5
- package/src/engine/boot-validator/query-output-columns.ts +4 -2
- package/src/engine/boot-validator/record-owned.ts +3 -4
- package/src/engine/boot-validator/screens.ts +7 -4
- package/src/engine/feature-config-events-jobs.ts +18 -5
- package/src/engine/index.ts +2 -0
- package/src/engine/types/index.ts +3 -0
- package/src/engine/types/screen.ts +1 -0
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +133 -7
- package/src/event-store/event-store.ts +5 -1
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +79 -0
- package/src/pipeline/dispatch-write.ts +28 -10
- package/src/ui-types/index.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.255.1",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"./package.json": "./package.json"
|
|
199
199
|
},
|
|
200
200
|
"dependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-types": "0.255.1",
|
|
202
202
|
"bullmq": "^5.76.7",
|
|
203
203
|
"bun-types": "^1.3.13",
|
|
204
204
|
"hono": "^4.13.1",
|
|
@@ -214,7 +214,7 @@
|
|
|
214
214
|
"zod": "^4.4.3"
|
|
215
215
|
},
|
|
216
216
|
"devDependencies": {
|
|
217
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
217
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.255.1",
|
|
218
218
|
"bun-types": "^1.3.13",
|
|
219
219
|
"pino-pretty": "^13.1.3"
|
|
220
220
|
},
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// dead-tx-in-afterCommit-hooks: runInSavepointIfSupported must fail
|
|
2
|
+
// loudly on a transaction handle that has already committed, instead of
|
|
3
|
+
// silently re-running `fn` outside the savepoint's isolation. Uses a real
|
|
4
|
+
// Postgres connection — a mock handle can't produce the driver's actual
|
|
5
|
+
// PG 25P01 ("no active sql transaction") on a post-commit savepoint call.
|
|
6
|
+
|
|
7
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
8
|
+
import { InternalError } from "../../errors";
|
|
9
|
+
import { asRawClient, runInSavepointIfSupported } from "../query";
|
|
10
|
+
import { closeDb, getDb } from "./_helpers";
|
|
11
|
+
|
|
12
|
+
afterAll(async () => {
|
|
13
|
+
await closeDb();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("runInSavepointIfSupported — already-committed transaction", () => {
|
|
17
|
+
test("throws InternalError naming afterCommit + dbOutsideTransaction, never runs fn", async () => {
|
|
18
|
+
const db = await getDb();
|
|
19
|
+
const raw = asRawClient(db);
|
|
20
|
+
|
|
21
|
+
let committedTx: unknown;
|
|
22
|
+
await raw.begin(async (tx) => {
|
|
23
|
+
committedTx = tx;
|
|
24
|
+
});
|
|
25
|
+
// `committedTx` is now a driver handle whose transaction has committed —
|
|
26
|
+
// it still exposes `.savepoint()`, calling it fails with PG 25P01.
|
|
27
|
+
|
|
28
|
+
let fnCalls = 0;
|
|
29
|
+
const fn = async () => {
|
|
30
|
+
fnCalls++;
|
|
31
|
+
return "should-not-run";
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
let caught: unknown;
|
|
35
|
+
try {
|
|
36
|
+
await runInSavepointIfSupported(committedTx, fn);
|
|
37
|
+
} catch (e) {
|
|
38
|
+
caught = e;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
expect(caught).toBeInstanceOf(InternalError);
|
|
42
|
+
const message = (caught as Error).message;
|
|
43
|
+
expect(message).toContain("afterCommit");
|
|
44
|
+
expect(message).toContain("dbOutsideTransaction");
|
|
45
|
+
expect(fnCalls).toBe(0);
|
|
46
|
+
});
|
|
47
|
+
});
|
package/src/bun-db/query.ts
CHANGED
|
@@ -34,6 +34,7 @@ import type { EntityTableMeta } from "../db/entity-table-meta";
|
|
|
34
34
|
import { extractPgError } from "../db/pg-error";
|
|
35
35
|
import { type NotExecutorOnly, toSnakeCase } from "../db/table-builder";
|
|
36
36
|
import { camelCase as envCamelCase } from "../env";
|
|
37
|
+
import { InternalError } from "../errors";
|
|
37
38
|
import { parseJsonSafe } from "../utils/safe-json";
|
|
38
39
|
|
|
39
40
|
// Idempotent snake_case → camelCase. `env.camelCase` always lowercases first
|
|
@@ -145,15 +146,11 @@ export async function runInSavepoint<T>(tx: unknown, fn: (sp: unknown) => Promis
|
|
|
145
146
|
// and doesn't need one, since each statement there is its own auto-committed
|
|
146
147
|
// unit and a failed statement can't poison anything downstream.
|
|
147
148
|
//
|
|
148
|
-
// `.savepoint` being present isn't proof the transaction is still open:
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
// active sql transaction") because there's no BEGIN left to nest into. That
|
|
154
|
-
// SAVEPOINT command is the first thing the driver sends, before `fn` runs,
|
|
155
|
-
// so catching 25P01 and retrying directly is safe — nothing from `fn` has
|
|
156
|
-
// executed yet.
|
|
149
|
+
// `.savepoint` being present isn't proof the transaction is still open: a
|
|
150
|
+
// committed TransactionSql-shaped db still exposes `.savepoint`, and calling
|
|
151
|
+
// it fails with PG 25P01 ("no active sql transaction") since there's no
|
|
152
|
+
// BEGIN left to nest into. That is now a programming error, not something
|
|
153
|
+
// to paper over by re-running `fn` without the savepoint's isolation.
|
|
157
154
|
export async function runInSavepointIfSupported<T>(
|
|
158
155
|
db: unknown,
|
|
159
156
|
fn: (sp: DbRunner) => Promise<T>,
|
|
@@ -167,7 +164,16 @@ export async function runInSavepointIfSupported<T>(
|
|
|
167
164
|
try {
|
|
168
165
|
return await raw.savepoint((sp) => fn(asRunner(sp)));
|
|
169
166
|
} catch (e) {
|
|
170
|
-
if (extractPgError(e)?.code === "25P01")
|
|
167
|
+
if (extractPgError(e)?.code === "25P01") {
|
|
168
|
+
throw new InternalError({
|
|
169
|
+
message:
|
|
170
|
+
"runInSavepointIfSupported: savepoint on a transaction that has already committed — " +
|
|
171
|
+
"this is almost always database work in an afterCommit hook. afterCommit is the default " +
|
|
172
|
+
"phase of r.hook/postSave/postDelete. Use HookPhases.inTransaction for writes that must " +
|
|
173
|
+
"roll back with the trigger, or ctx.dbOutsideTransaction for work that must survive a rollback.",
|
|
174
|
+
cause: e instanceof Error ? e : undefined,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
171
177
|
throw e;
|
|
172
178
|
}
|
|
173
179
|
}
|
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
// are testable without a database.
|
|
5
5
|
|
|
6
6
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
7
|
+
import { normalizeEventPiiSubject } from "@cosmicdrift/kumiko-types/handlers";
|
|
7
8
|
import { z } from "zod";
|
|
8
9
|
import { createRegistry, defineFeature } from "../../engine";
|
|
10
|
+
import type { TenantId } from "../../engine/types/identifiers";
|
|
9
11
|
import {
|
|
10
12
|
configuredEventPiiCatalog,
|
|
11
13
|
configureEventPiiCatalog,
|
|
@@ -20,6 +22,7 @@ import {
|
|
|
20
22
|
PII_ERASED_SENTINEL,
|
|
21
23
|
resetPiiSubjectKmsForTests,
|
|
22
24
|
} from "../pii-field-encryption";
|
|
25
|
+
import type { EventSubjectEnvelope } from "../subject-resolver";
|
|
23
26
|
|
|
24
27
|
const attemptSchema = z.object({
|
|
25
28
|
recipientId: z.string().nullable(),
|
|
@@ -29,6 +32,12 @@ const attemptSchema = z.object({
|
|
|
29
32
|
|
|
30
33
|
const EVENT_TYPE = "mailer:event:attempt";
|
|
31
34
|
|
|
35
|
+
const ENVELOPE: EventSubjectEnvelope = {
|
|
36
|
+
tenantId: "6b2f4a0e-1c9d-4f3a-9d2e-0000000000e1" as TenantId,
|
|
37
|
+
aggregateType: "mailer-attempt",
|
|
38
|
+
aggregateId: "6b2f4a0e-1c9d-4f3a-9d2e-0000000000e2",
|
|
39
|
+
};
|
|
40
|
+
|
|
32
41
|
function catalogWithAttempt(): void {
|
|
33
42
|
configureEventPiiCatalog(
|
|
34
43
|
new Map([[EVENT_TYPE, { recipientAddress: { subjectField: "recipientId" } }]]),
|
|
@@ -40,6 +49,19 @@ afterEach(() => {
|
|
|
40
49
|
resetPiiSubjectKmsForTests();
|
|
41
50
|
});
|
|
42
51
|
|
|
52
|
+
describe("normalizeEventPiiSubject", () => {
|
|
53
|
+
test("canonical personal.of and legacy subjectField normalize to the same form", () => {
|
|
54
|
+
expect(normalizeEventPiiSubject({ personal: { of: "recipientId" } })).toEqual({
|
|
55
|
+
kind: "user",
|
|
56
|
+
ownerField: "recipientId",
|
|
57
|
+
});
|
|
58
|
+
expect(normalizeEventPiiSubject({ subjectField: "recipientId" })).toEqual({
|
|
59
|
+
kind: "user",
|
|
60
|
+
ownerField: "recipientId",
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
43
65
|
describe("defineEvent piiFields validation", () => {
|
|
44
66
|
test("valid piiFields land on the EventDef and in the registry catalog", () => {
|
|
45
67
|
const feature = defineFeature("mailer", (r) => {
|
|
@@ -73,14 +95,46 @@ describe("defineEvent piiFields validation", () => {
|
|
|
73
95
|
).toThrow(/piiFields references "ownerId"/);
|
|
74
96
|
});
|
|
75
97
|
|
|
76
|
-
test("field cannot be its own subjectField", () => {
|
|
98
|
+
test("field cannot be its own subjectField (legacy form)", () => {
|
|
77
99
|
expect(() =>
|
|
78
100
|
defineFeature("mailer", (r) => {
|
|
79
101
|
r.defineEvent("attempt", attemptSchema, {
|
|
80
102
|
piiFields: { recipientAddress: { subjectField: "recipientAddress" } },
|
|
81
103
|
});
|
|
82
104
|
}),
|
|
83
|
-
).toThrow(/cannot use itself as
|
|
105
|
+
).toThrow(/cannot use itself as the owner field/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("valid canonical personal.of piiFields land on the EventDef and in the registry catalog", () => {
|
|
109
|
+
const feature = defineFeature("mailer", (r) => {
|
|
110
|
+
r.defineEvent("attempt", attemptSchema, {
|
|
111
|
+
piiFields: { recipientAddress: { personal: { of: "recipientId" } } },
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
createRegistry([feature]);
|
|
115
|
+
expect(configuredEventPiiCatalog().get(EVENT_TYPE)).toEqual({
|
|
116
|
+
recipientAddress: { personal: { of: "recipientId" } },
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("unknown owner field throws at definition time (canonical form)", () => {
|
|
121
|
+
expect(() =>
|
|
122
|
+
defineFeature("mailer", (r) => {
|
|
123
|
+
r.defineEvent("attempt", attemptSchema, {
|
|
124
|
+
piiFields: { recipientAddress: { personal: { of: "ownerId" } } },
|
|
125
|
+
});
|
|
126
|
+
}),
|
|
127
|
+
).toThrow(/piiFields references "ownerId"/);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("field cannot be its own owner field (canonical form)", () => {
|
|
131
|
+
expect(() =>
|
|
132
|
+
defineFeature("mailer", (r) => {
|
|
133
|
+
r.defineEvent("attempt", attemptSchema, {
|
|
134
|
+
piiFields: { recipientAddress: { personal: { of: "recipientAddress" } } },
|
|
135
|
+
});
|
|
136
|
+
}),
|
|
137
|
+
).toThrow(/cannot use itself as the owner field/);
|
|
84
138
|
});
|
|
85
139
|
|
|
86
140
|
test("omitting the options argument throws an explicit PII stance error (fw#2558)", () => {
|
|
@@ -112,6 +166,46 @@ describe("defineEvent piiFields validation", () => {
|
|
|
112
166
|
createRegistry([feature]);
|
|
113
167
|
expect(configuredEventPiiCatalog().has(EVENT_TYPE)).toBe(false);
|
|
114
168
|
});
|
|
169
|
+
|
|
170
|
+
test('personal: "tenant" needs only the pii field itself on the schema, no owner field', () => {
|
|
171
|
+
const feature = defineFeature("mailer", (r) => {
|
|
172
|
+
r.defineEvent("attempt", attemptSchema, {
|
|
173
|
+
piiFields: { recipientAddress: { personal: "tenant" } },
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
createRegistry([feature]);
|
|
177
|
+
expect(configuredEventPiiCatalog().get(EVENT_TYPE)).toEqual({
|
|
178
|
+
recipientAddress: { personal: "tenant" },
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('personal: "self" needs only the pii field itself on the schema, no owner field', () => {
|
|
183
|
+
const feature = defineFeature("mailer", (r) => {
|
|
184
|
+
r.defineEvent("attempt", attemptSchema, {
|
|
185
|
+
piiFields: { recipientAddress: { personal: "self" } },
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
createRegistry([feature]);
|
|
189
|
+
expect(configuredEventPiiCatalog().get(EVENT_TYPE)).toEqual({
|
|
190
|
+
recipientAddress: { personal: "self" },
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('personal: "tenant" on a field not in the schema throws', () => {
|
|
195
|
+
expect(() =>
|
|
196
|
+
defineFeature("mailer", (r) => {
|
|
197
|
+
r.defineEvent("attempt", attemptSchema, { piiFields: { nope: { personal: "tenant" } } });
|
|
198
|
+
}),
|
|
199
|
+
).toThrow(/piiFields references "nope"/);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('personal: "self" on a field not in the schema throws', () => {
|
|
203
|
+
expect(() =>
|
|
204
|
+
defineFeature("mailer", (r) => {
|
|
205
|
+
r.defineEvent("attempt", attemptSchema, { piiFields: { nope: { personal: "self" } } });
|
|
206
|
+
}),
|
|
207
|
+
).toThrow(/piiFields references "nope"/);
|
|
208
|
+
});
|
|
115
209
|
});
|
|
116
210
|
|
|
117
211
|
describe("encryptEventPayloadPii", () => {
|
|
@@ -119,12 +213,12 @@ describe("encryptEventPayloadPii", () => {
|
|
|
119
213
|
|
|
120
214
|
test("uncatalogued event type returns the payload untouched (same reference)", async () => {
|
|
121
215
|
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
122
|
-
expect(await encryptEventPayloadPii("other:event:x", payload)).toBe(payload);
|
|
216
|
+
expect(await encryptEventPayloadPii("other:event:x", payload, ENVELOPE)).toBe(payload);
|
|
123
217
|
});
|
|
124
218
|
|
|
125
219
|
test("no KMS configured → plaintext passthrough (rollout mode)", async () => {
|
|
126
220
|
catalogWithAttempt();
|
|
127
|
-
expect(await encryptEventPayloadPii(EVENT_TYPE, payload)).toBe(payload);
|
|
221
|
+
expect(await encryptEventPayloadPii(EVENT_TYPE, payload, ENVELOPE)).toBe(payload);
|
|
128
222
|
});
|
|
129
223
|
|
|
130
224
|
test("encrypts under the subject's DEK; subject fk stays plaintext", async () => {
|
|
@@ -132,7 +226,7 @@ describe("encryptEventPayloadPii", () => {
|
|
|
132
226
|
const kms = new InMemoryKmsAdapter();
|
|
133
227
|
configurePiiSubjectKms(kms);
|
|
134
228
|
|
|
135
|
-
const out = await encryptEventPayloadPii(EVENT_TYPE, payload);
|
|
229
|
+
const out = await encryptEventPayloadPii(EVENT_TYPE, payload, ENVELOPE);
|
|
136
230
|
expect(isPiiCiphertext(out["recipientAddress"])).toBe(true);
|
|
137
231
|
expect(String(out["recipientAddress"])).toContain("user:u-1");
|
|
138
232
|
expect(out["recipientId"]).toBe("u-1");
|
|
@@ -144,6 +238,23 @@ describe("encryptEventPayloadPii", () => {
|
|
|
144
238
|
expect(back["recipientAddress"]).toBe("u1@example.com");
|
|
145
239
|
});
|
|
146
240
|
|
|
241
|
+
test("legacy subjectField and canonical personal.of encrypt under the same subject key", async () => {
|
|
242
|
+
const LEGACY_TYPE = "mailer:event:legacy-attempt";
|
|
243
|
+
const CANONICAL_TYPE = "mailer:event:canonical-attempt";
|
|
244
|
+
configureEventPiiCatalog(
|
|
245
|
+
new Map([
|
|
246
|
+
[LEGACY_TYPE, { recipientAddress: { subjectField: "recipientId" } }],
|
|
247
|
+
[CANONICAL_TYPE, { recipientAddress: { personal: { of: "recipientId" } } }],
|
|
248
|
+
]),
|
|
249
|
+
);
|
|
250
|
+
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
251
|
+
|
|
252
|
+
const legacy = await encryptEventPayloadPii(LEGACY_TYPE, payload, ENVELOPE);
|
|
253
|
+
const canonical = await encryptEventPayloadPii(CANONICAL_TYPE, payload, ENVELOPE);
|
|
254
|
+
expect(String(legacy["recipientAddress"])).toContain("user:u-1");
|
|
255
|
+
expect(String(canonical["recipientAddress"])).toContain("user:u-1");
|
|
256
|
+
});
|
|
257
|
+
|
|
147
258
|
test("null subject field → value stays plaintext (no user key to shred)", async () => {
|
|
148
259
|
catalogWithAttempt();
|
|
149
260
|
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
@@ -152,25 +263,25 @@ describe("encryptEventPayloadPii", () => {
|
|
|
152
263
|
recipientAddress: "ops@example.com",
|
|
153
264
|
status: "sent",
|
|
154
265
|
};
|
|
155
|
-
expect(await encryptEventPayloadPii(EVENT_TYPE, systemPayload)).toBe(systemPayload);
|
|
266
|
+
expect(await encryptEventPayloadPii(EVENT_TYPE, systemPayload, ENVELOPE)).toBe(systemPayload);
|
|
156
267
|
});
|
|
157
268
|
|
|
158
269
|
test("null pii value passes through", async () => {
|
|
159
270
|
catalogWithAttempt();
|
|
160
271
|
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
161
272
|
const skipped = { recipientId: "u-1", recipientAddress: null, status: "skipped" };
|
|
162
|
-
expect(await encryptEventPayloadPii(EVENT_TYPE, skipped)).toBe(skipped);
|
|
273
|
+
expect(await encryptEventPayloadPii(EVENT_TYPE, skipped, ENVELOPE)).toBe(skipped);
|
|
163
274
|
});
|
|
164
275
|
|
|
165
276
|
test("idempotent: ciphertext and erased sentinel stay as-is", async () => {
|
|
166
277
|
catalogWithAttempt();
|
|
167
278
|
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
168
|
-
const once = await encryptEventPayloadPii(EVENT_TYPE, payload);
|
|
169
|
-
const twice = await encryptEventPayloadPii(EVENT_TYPE, once);
|
|
279
|
+
const once = await encryptEventPayloadPii(EVENT_TYPE, payload, ENVELOPE);
|
|
280
|
+
const twice = await encryptEventPayloadPii(EVENT_TYPE, once, ENVELOPE);
|
|
170
281
|
expect(twice["recipientAddress"]).toBe(once["recipientAddress"]);
|
|
171
282
|
|
|
172
283
|
const erased = { ...payload, recipientAddress: PII_ERASED_SENTINEL };
|
|
173
|
-
const out = await encryptEventPayloadPii(EVENT_TYPE, erased);
|
|
284
|
+
const out = await encryptEventPayloadPii(EVENT_TYPE, erased, ENVELOPE);
|
|
174
285
|
expect(out["recipientAddress"]).toBe(PII_ERASED_SENTINEL);
|
|
175
286
|
});
|
|
176
287
|
|
|
@@ -178,6 +289,28 @@ describe("encryptEventPayloadPii", () => {
|
|
|
178
289
|
catalogWithAttempt();
|
|
179
290
|
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
180
291
|
const broken = { recipientId: "u-1", recipientAddress: 42, status: "sent" };
|
|
181
|
-
expect(encryptEventPayloadPii(EVENT_TYPE, broken)).rejects.toThrow(
|
|
292
|
+
expect(encryptEventPayloadPii(EVENT_TYPE, broken, ENVELOPE)).rejects.toThrow(
|
|
293
|
+
/must be a string/,
|
|
294
|
+
);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test('personal: "tenant" encrypts under the envelope tenantId, not a payload field', async () => {
|
|
298
|
+
configureEventPiiCatalog(new Map([[EVENT_TYPE, { recipientAddress: { personal: "tenant" } }]]));
|
|
299
|
+
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
300
|
+
|
|
301
|
+
const out = await encryptEventPayloadPii(EVENT_TYPE, payload, ENVELOPE);
|
|
302
|
+
expect(isPiiCiphertext(out["recipientAddress"])).toBe(true);
|
|
303
|
+
expect(String(out["recipientAddress"])).toContain(`tenant:${ENVELOPE.tenantId}`);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test('personal: "self" encrypts under the envelope aggregateType:aggregateId', async () => {
|
|
307
|
+
configureEventPiiCatalog(new Map([[EVENT_TYPE, { recipientAddress: { personal: "self" } }]]));
|
|
308
|
+
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
309
|
+
|
|
310
|
+
const out = await encryptEventPayloadPii(EVENT_TYPE, payload, ENVELOPE);
|
|
311
|
+
expect(isPiiCiphertext(out["recipientAddress"])).toBe(true);
|
|
312
|
+
expect(String(out["recipientAddress"])).toContain(
|
|
313
|
+
`record:${ENVELOPE.aggregateType}:${ENVELOPE.aggregateId}`,
|
|
314
|
+
);
|
|
182
315
|
});
|
|
183
316
|
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// subjectIdSchema is `satisfies z.ZodType<SubjectId>` at the definition site
|
|
2
|
+
// (kms-adapter.ts) — a future 4th SubjectId variant or a renamed field fails
|
|
3
|
+
// to compile there. This file pins the runtime side of that binding: exactly
|
|
4
|
+
// the three SubjectId shapes parse, everything else is refused.
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import { subjectIdSchema } from "../kms-adapter";
|
|
8
|
+
|
|
9
|
+
const UUID_A = "6b2f4a0e-1c9d-4f3a-9d2e-00000000000a";
|
|
10
|
+
|
|
11
|
+
describe("subjectIdSchema", () => {
|
|
12
|
+
test("accepts a user subject", () => {
|
|
13
|
+
expect(subjectIdSchema.safeParse({ kind: "user", userId: UUID_A }).success).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("accepts a tenant subject", () => {
|
|
17
|
+
expect(subjectIdSchema.safeParse({ kind: "tenant", tenantId: UUID_A }).success).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("accepts a record subject", () => {
|
|
21
|
+
expect(
|
|
22
|
+
subjectIdSchema.safeParse({ kind: "record", entity: "mail-account", id: UUID_A }).success,
|
|
23
|
+
).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("mints tenantId as a branded TenantId, not a plain string type", () => {
|
|
27
|
+
const result = subjectIdSchema.parse({ kind: "tenant", tenantId: UUID_A });
|
|
28
|
+
expect(result).toEqual({ kind: "tenant", tenantId: UUID_A });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("rejects a non-UUID userId", () => {
|
|
32
|
+
expect(subjectIdSchema.safeParse({ kind: "user", userId: "not-a-uuid" }).success).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("rejects a non-UUID tenantId", () => {
|
|
36
|
+
expect(subjectIdSchema.safeParse({ kind: "tenant", tenantId: "not-a-uuid" }).success).toBe(
|
|
37
|
+
false,
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("rejects a non-UUID record id", () => {
|
|
42
|
+
expect(
|
|
43
|
+
subjectIdSchema.safeParse({ kind: "record", entity: "mail-account", id: "not-a-uuid" })
|
|
44
|
+
.success,
|
|
45
|
+
).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("rejects a record entity that violates RECORD_ENTITY_PATTERN", () => {
|
|
49
|
+
expect(
|
|
50
|
+
subjectIdSchema.safeParse({ kind: "record", entity: "_internal", id: UUID_A }).success,
|
|
51
|
+
).toBe(false);
|
|
52
|
+
expect(
|
|
53
|
+
subjectIdSchema.safeParse({ kind: "record", entity: "v2.digest", id: UUID_A }).success,
|
|
54
|
+
).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("rejects an unknown subject kind", () => {
|
|
58
|
+
expect(subjectIdSchema.safeParse({ kind: "bogus", id: UUID_A }).success).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -56,6 +56,16 @@ describe("record subject key round-trip", () => {
|
|
|
56
56
|
expect(() => subjectKeyForRecord("bad:name", UUID_A)).toThrow();
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
+
test("subjectKeyForRecord rejects entity names that subjectIdSchema (forget-subject) could never shred (fw#2801)", () => {
|
|
60
|
+
expect(() => subjectKeyForRecord("v2.digest", UUID_A)).toThrow();
|
|
61
|
+
expect(() => subjectKeyForRecord("_internal", UUID_A)).toThrow();
|
|
62
|
+
expect(() => subjectKeyForRecord("3rd", UUID_A)).toThrow();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("subjectKeyForRecord accepts a kebab-case registry entity name", () => {
|
|
66
|
+
expect(subjectKeyForRecord("mail-account", UUID_A)).toBe(`record:mail-account:${UUID_A}`);
|
|
67
|
+
});
|
|
68
|
+
|
|
59
69
|
test("ciphertext round-trip for a record-owned field", async () => {
|
|
60
70
|
const kms = new InMemoryKmsAdapter();
|
|
61
71
|
const fields = collectPiiSubjectFields(recordEntity);
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { createEntity, createTextField } from "../../engine/factories";
|
|
3
|
+
import type { TenantId } from "../../engine/types/identifiers";
|
|
3
4
|
import {
|
|
4
5
|
collectPiiSubjectFields,
|
|
6
|
+
resolveEventSubject,
|
|
5
7
|
resolveSubjectForField,
|
|
6
8
|
SubjectResolutionError,
|
|
7
9
|
} from "../subject-resolver";
|
|
@@ -138,3 +140,90 @@ describe("collectPiiSubjectFields", () => {
|
|
|
138
140
|
expect(collectPiiSubjectFields(brandingEntity)).toEqual(["brandColor"]);
|
|
139
141
|
});
|
|
140
142
|
});
|
|
143
|
+
|
|
144
|
+
const EVENT_TENANT = "6b2f4a0e-1c9d-4f3a-9d2e-0000000000e1" as TenantId;
|
|
145
|
+
|
|
146
|
+
describe("resolveEventSubject (fw#2801)", () => {
|
|
147
|
+
test("personal: { of } → user subject from the named payload field", () => {
|
|
148
|
+
const subject = resolveEventSubject(
|
|
149
|
+
"note",
|
|
150
|
+
{ personal: { of: "authorId" } },
|
|
151
|
+
{ authorId: UUID_A },
|
|
152
|
+
{ tenantId: EVENT_TENANT, aggregateType: "note", aggregateId: UUID_B },
|
|
153
|
+
);
|
|
154
|
+
expect(subject).toEqual({ kind: "user", userId: UUID_A });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("personal: { of } with a missing owner value → null, no throw (system-triggered event)", () => {
|
|
158
|
+
const subject = resolveEventSubject(
|
|
159
|
+
"note",
|
|
160
|
+
{ personal: { of: "authorId" } },
|
|
161
|
+
{ authorId: null },
|
|
162
|
+
{ tenantId: EVENT_TENANT, aggregateType: "note", aggregateId: UUID_B },
|
|
163
|
+
);
|
|
164
|
+
expect(subject).toBeNull();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('personal: "tenant" → subject from the envelope tenantId', () => {
|
|
168
|
+
const subject = resolveEventSubject(
|
|
169
|
+
"note",
|
|
170
|
+
{ personal: "tenant" },
|
|
171
|
+
{},
|
|
172
|
+
{ tenantId: EVENT_TENANT, aggregateType: "note", aggregateId: UUID_B },
|
|
173
|
+
);
|
|
174
|
+
expect(subject).toEqual({ kind: "tenant", tenantId: EVENT_TENANT });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('personal: "self" → record subject from the envelope aggregateType/aggregateId', () => {
|
|
178
|
+
const subject = resolveEventSubject(
|
|
179
|
+
"note",
|
|
180
|
+
{ personal: "self" },
|
|
181
|
+
{},
|
|
182
|
+
{ tenantId: EVENT_TENANT, aggregateType: "note", aggregateId: UUID_B },
|
|
183
|
+
);
|
|
184
|
+
expect(subject).toEqual({ kind: "record", entity: "note", id: UUID_B });
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('personal: "tenant" with an empty envelope tenantId throws and names the pii field, not "tenant"', () => {
|
|
188
|
+
expect(() =>
|
|
189
|
+
resolveEventSubject(
|
|
190
|
+
"note",
|
|
191
|
+
{ personal: "tenant" },
|
|
192
|
+
{},
|
|
193
|
+
{ tenantId: "" as TenantId, aggregateType: "note", aggregateId: UUID_B },
|
|
194
|
+
),
|
|
195
|
+
).toThrow(/field "note"/);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('personal: "self" with an empty envelope aggregateId throws and names the pii field, not "self"', () => {
|
|
199
|
+
expect(() =>
|
|
200
|
+
resolveEventSubject(
|
|
201
|
+
"note",
|
|
202
|
+
{ personal: "self" },
|
|
203
|
+
{},
|
|
204
|
+
{ tenantId: EVENT_TENANT, aggregateType: "note", aggregateId: "" },
|
|
205
|
+
),
|
|
206
|
+
).toThrow(/field "note"/);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('personal: "self" with an aggregateType that violates the record-entity pattern throws — mint must never outrun shred (fw#2801)', () => {
|
|
210
|
+
expect(() =>
|
|
211
|
+
resolveEventSubject(
|
|
212
|
+
"note",
|
|
213
|
+
{ personal: "self" },
|
|
214
|
+
{},
|
|
215
|
+
{ tenantId: EVENT_TENANT, aggregateType: "v2.digest", aggregateId: UUID_B },
|
|
216
|
+
),
|
|
217
|
+
).toThrow(/field "note".*v2\.digest/s);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("legacy subjectField normalizes to the same user subject as personal: { of }", () => {
|
|
221
|
+
const subject = resolveEventSubject(
|
|
222
|
+
"note",
|
|
223
|
+
{ subjectField: "authorId" },
|
|
224
|
+
{ authorId: UUID_A },
|
|
225
|
+
{ tenantId: EVENT_TENANT, aggregateType: "note", aggregateId: UUID_B },
|
|
226
|
+
);
|
|
227
|
+
expect(subject).toEqual({ kind: "user", userId: UUID_A });
|
|
228
|
+
});
|
|
229
|
+
});
|
package/src/crypto/event-pii.ts
CHANGED
|
@@ -12,9 +12,10 @@
|
|
|
12
12
|
// silent no-op (forgetting the option) is now either an explicit "none" or a
|
|
13
13
|
// missing subject KMS at encrypt-time, both visible states, not a gap.
|
|
14
14
|
|
|
15
|
+
import type { EventPiiFields } from "@cosmicdrift/kumiko-types/handlers";
|
|
15
16
|
import { requestContext } from "../api/request-context";
|
|
16
|
-
import type { EventPiiFields } from "../engine/types/handlers";
|
|
17
17
|
import { configuredPiiSubjectKms, encryptPiiValueForSubject } from "./pii-field-encryption";
|
|
18
|
+
import { type EventSubjectEnvelope, resolveEventSubject } from "./subject-resolver";
|
|
18
19
|
|
|
19
20
|
export type EventPiiCatalog = ReadonlyMap<string, EventPiiFields>;
|
|
20
21
|
|
|
@@ -35,14 +36,20 @@ export function resetEventPiiCatalogForTests(): void {
|
|
|
35
36
|
catalog = new Map();
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
// Encrypts catalogued payload fields under the
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
39
|
+
// Encrypts catalogued payload fields under the declared subject's DEK
|
|
40
|
+
// (user/tenant/self — resolveEventSubject, fw#2801). No-op when the event
|
|
41
|
+
// type is uncatalogued or no subject KMS is configured (plaintext rollout
|
|
42
|
+
// mode — the hard boot gate governs whether that is acceptable). A
|
|
43
|
+
// user-subject field with no resolvable owner (system cron runs,
|
|
44
|
+
// recipient-less skip attempts) leaves the value plaintext: there is no
|
|
45
|
+
// user key to shred. This is the ONLY live-write encrypt path — the backfill
|
|
46
|
+
// catalog branch (`backfill-pii.ts`) resolves through the same
|
|
47
|
+
// resolveEventSubject so a field never ends up encrypted under different
|
|
48
|
+
// subjects depending on which path wrote it.
|
|
43
49
|
export async function encryptEventPayloadPii(
|
|
44
50
|
eventType: string,
|
|
45
51
|
payload: Record<string, unknown>,
|
|
52
|
+
envelope: EventSubjectEnvelope,
|
|
46
53
|
): Promise<Record<string, unknown>> {
|
|
47
54
|
const piiFields = catalog.get(eventType);
|
|
48
55
|
if (!piiFields) return payload;
|
|
@@ -58,11 +65,11 @@ export async function encryptEventPayloadPii(
|
|
|
58
65
|
`Event "${eventType}" piiFields."${field}" must be a string payload field, got ${typeof value}`,
|
|
59
66
|
);
|
|
60
67
|
}
|
|
61
|
-
const
|
|
62
|
-
if (
|
|
68
|
+
const subject = resolveEventSubject(field, spec, payload, envelope);
|
|
69
|
+
if (subject === null) continue;
|
|
63
70
|
const encrypted = await encryptPiiValueForSubject(
|
|
64
71
|
kms,
|
|
65
|
-
|
|
72
|
+
subject,
|
|
66
73
|
value,
|
|
67
74
|
{ requestId: requestContext.get()?.requestId ?? "append-event" },
|
|
68
75
|
field,
|
package/src/crypto/index.ts
CHANGED
|
@@ -24,12 +24,15 @@ export {
|
|
|
24
24
|
type KmsContext,
|
|
25
25
|
type KmsHealth,
|
|
26
26
|
type LocalKeyKmsAdapter,
|
|
27
|
+
RECORD_ENTITY_PATTERN,
|
|
27
28
|
type RemoteCryptoKmsAdapter,
|
|
28
29
|
type SubjectDek,
|
|
29
30
|
type SubjectId,
|
|
30
31
|
type SubjectKey,
|
|
31
32
|
subjectIdFromKey,
|
|
33
|
+
subjectIdSchema,
|
|
32
34
|
subjectIdToKey,
|
|
35
|
+
subjectKeyForRecord,
|
|
33
36
|
subjectKeyForTenant,
|
|
34
37
|
subjectKeyForUser,
|
|
35
38
|
} from "./kms-adapter";
|
|
@@ -1,10 +1,34 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
RECORD_ENTITY_PATTERN,
|
|
3
|
+
type SubjectId,
|
|
4
|
+
subjectIdToKey,
|
|
5
|
+
} from "@cosmicdrift/kumiko-types/kms-adapter-types";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import type { TenantId } from "../engine/types/identifiers";
|
|
2
8
|
|
|
3
9
|
export * from "@cosmicdrift/kumiko-types/kms-adapter-types";
|
|
4
10
|
|
|
5
11
|
// The KMS error classes live here and not in kumiko-types (#1629): callers
|
|
6
12
|
// branch on them with `instanceof`, which needs a single copy of the class.
|
|
7
13
|
|
|
14
|
+
// `satisfies z.ZodType<SubjectId>` binds this to the TS type at compile time
|
|
15
|
+
// (fw#2801) — mint (subjectKeyForRecord) and shred (forget-subject) can no
|
|
16
|
+
// longer drift apart on which subjects are valid, the way #2809 happened.
|
|
17
|
+
export const subjectIdSchema = z.discriminatedUnion("kind", [
|
|
18
|
+
z.object({ kind: z.literal("user"), userId: z.uuid() }),
|
|
19
|
+
z.object({
|
|
20
|
+
kind: z.literal("tenant"),
|
|
21
|
+
// z.uuid() only proves string shape; TenantId is a nominal brand on top
|
|
22
|
+
// of that, minted here right after validation (@cast-boundary).
|
|
23
|
+
tenantId: z.uuid().transform((value) => value as TenantId),
|
|
24
|
+
}),
|
|
25
|
+
z.object({
|
|
26
|
+
kind: z.literal("record"),
|
|
27
|
+
entity: z.string().regex(RECORD_ENTITY_PATTERN),
|
|
28
|
+
id: z.uuid(),
|
|
29
|
+
}),
|
|
30
|
+
]) satisfies z.ZodType<SubjectId>;
|
|
31
|
+
|
|
8
32
|
export class KeyErasedError extends Error {
|
|
9
33
|
constructor(public readonly subject: SubjectId) {
|
|
10
34
|
super(`Subject key erased: ${subjectIdToKey(subject)}`);
|