@cosmicdrift/kumiko-framework 0.253.0 → 0.255.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 +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__/blind-index.test.ts +2 -0
- package/src/crypto/__tests__/event-pii.test.ts +144 -11
- package/src/crypto/__tests__/pii-field-encryption.test.ts +29 -8
- package/src/crypto/__tests__/subject-key-record.test.ts +13 -3
- package/src/crypto/__tests__/subject-resolver.test.ts +139 -24
- package/src/crypto/event-pii.ts +16 -9
- package/src/crypto/index.ts +2 -0
- package/src/crypto/pii-field-encryption.ts +3 -3
- package/src/crypto/subject-resolver.ts +64 -3
- package/src/db/__tests__/eagerload.integration.test.ts +9 -3
- 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 +56 -0
- package/src/engine/boot-validator/index.ts +2 -0
- package/src/engine/boot-validator/query-output-columns.ts +4 -2
- package/src/engine/boot-validator/record-owned.ts +22 -0
- 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.0",
|
|
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.0",
|
|
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.0",
|
|
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
|
}
|
|
@@ -101,6 +101,7 @@ describe("computeBlindIndexValues", () => {
|
|
|
101
101
|
["email"],
|
|
102
102
|
kms,
|
|
103
103
|
{ requestId: "test" },
|
|
104
|
+
{ entityName: "bidx-user" },
|
|
104
105
|
);
|
|
105
106
|
const out = await computeBlindIndexValues({ email: stored["email"] }, ["email"]);
|
|
106
107
|
expect(out["emailBidx"]).toBe(computeBlindIndex(TEST_KEY, "marc@example.com"));
|
|
@@ -116,6 +117,7 @@ describe("computeBlindIndexValues", () => {
|
|
|
116
117
|
["email"],
|
|
117
118
|
kms,
|
|
118
119
|
{ requestId: "test" },
|
|
120
|
+
{ entityName: "bidx-user" },
|
|
119
121
|
);
|
|
120
122
|
await kms.eraseKey({ kind: "user", userId: UUID_A });
|
|
121
123
|
const out = await computeBlindIndexValues({ email: stored["email"] }, ["email"]);
|
|
@@ -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
|
});
|
|
@@ -23,6 +23,7 @@ import { collectPiiSubjectFields } from "../subject-resolver";
|
|
|
23
23
|
const UUID_A = "6b2f4a0e-1c9d-4f3a-9d2e-00000000000a";
|
|
24
24
|
const UUID_B = "6b2f4a0e-1c9d-4f3a-9d2e-00000000000b";
|
|
25
25
|
const KMS_CTX: KmsContext = { requestId: "test" };
|
|
26
|
+
const ENTITY_NAME = "pii-entity";
|
|
26
27
|
|
|
27
28
|
const userLikeEntity = createEntity({
|
|
28
29
|
fields: {
|
|
@@ -67,7 +68,9 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
|
|
|
67
68
|
const fields = collectPiiSubjectFields(userLikeEntity);
|
|
68
69
|
const row = { id: UUID_A, email: "marc@example.com", role: "admin" };
|
|
69
70
|
|
|
70
|
-
const stored = await encryptPiiFieldValues(row, userLikeEntity, fields, kms, KMS_CTX
|
|
71
|
+
const stored = await encryptPiiFieldValues(row, userLikeEntity, fields, kms, KMS_CTX, {
|
|
72
|
+
entityName: ENTITY_NAME,
|
|
73
|
+
});
|
|
71
74
|
expect(isPiiCiphertext(stored["email"])).toBe(true);
|
|
72
75
|
expect(String(stored["email"])).toStartWith(`${PII_CIPHERTEXT_PREFIX}user:${UUID_A}:`);
|
|
73
76
|
expect(stored["role"]).toBe("admin");
|
|
@@ -88,6 +91,7 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
|
|
|
88
91
|
["email"],
|
|
89
92
|
kms,
|
|
90
93
|
KMS_CTX,
|
|
94
|
+
{ entityName: ENTITY_NAME },
|
|
91
95
|
);
|
|
92
96
|
const dek = await kms.getKey({ kind: "user", userId: UUID_A });
|
|
93
97
|
expect(dek.length).toBe(32);
|
|
@@ -101,6 +105,7 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
|
|
|
101
105
|
["body"],
|
|
102
106
|
kms,
|
|
103
107
|
KMS_CTX,
|
|
108
|
+
{ entityName: ENTITY_NAME },
|
|
104
109
|
);
|
|
105
110
|
expect(String(stored["body"])).toStartWith(`${PII_CIPHERTEXT_PREFIX}user:${UUID_B}:`);
|
|
106
111
|
});
|
|
@@ -113,7 +118,7 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
|
|
|
113
118
|
["brandColor"],
|
|
114
119
|
kms,
|
|
115
120
|
KMS_CTX,
|
|
116
|
-
{ tenantId: UUID_B },
|
|
121
|
+
{ tenantId: UUID_B, entityName: ENTITY_NAME },
|
|
117
122
|
);
|
|
118
123
|
expect(String(stored["brandColor"])).toStartWith(`${PII_CIPHERTEXT_PREFIX}tenant:${UUID_B}:`);
|
|
119
124
|
});
|
|
@@ -124,6 +129,7 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
|
|
|
124
129
|
const stored = await encryptPiiFieldValues(changes, commentEntity, ["body"], kms, KMS_CTX, {
|
|
125
130
|
onlyKeys: ["body"],
|
|
126
131
|
subjectSource: { id: UUID_A, body: "edited", authorId: UUID_B },
|
|
132
|
+
entityName: ENTITY_NAME,
|
|
127
133
|
});
|
|
128
134
|
expect(String(stored["body"])).toStartWith(`${PII_CIPHERTEXT_PREFIX}user:${UUID_B}:`);
|
|
129
135
|
});
|
|
@@ -137,13 +143,16 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
|
|
|
137
143
|
fields,
|
|
138
144
|
kms,
|
|
139
145
|
KMS_CTX,
|
|
146
|
+
{ entityName: ENTITY_NAME },
|
|
140
147
|
);
|
|
141
148
|
await kms.eraseKey({ kind: "user", userId: UUID_A });
|
|
142
149
|
|
|
143
150
|
const read = await decryptPiiFieldValues(stored, fields, kms, KMS_CTX);
|
|
144
151
|
expect(read["email"]).toBe(PII_ERASED_SENTINEL);
|
|
145
152
|
|
|
146
|
-
const reStored = await encryptPiiFieldValues(read, userLikeEntity, fields, kms, KMS_CTX
|
|
153
|
+
const reStored = await encryptPiiFieldValues(read, userLikeEntity, fields, kms, KMS_CTX, {
|
|
154
|
+
entityName: ENTITY_NAME,
|
|
155
|
+
});
|
|
147
156
|
expect(reStored["email"]).toBe(PII_ERASED_SENTINEL);
|
|
148
157
|
});
|
|
149
158
|
|
|
@@ -158,6 +167,7 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
|
|
|
158
167
|
["email"],
|
|
159
168
|
kms,
|
|
160
169
|
KMS_CTX,
|
|
170
|
+
{ entityName: ENTITY_NAME },
|
|
161
171
|
),
|
|
162
172
|
).rejects.toThrow(/erased/);
|
|
163
173
|
});
|
|
@@ -181,8 +191,11 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
|
|
|
181
191
|
["email"],
|
|
182
192
|
kms,
|
|
183
193
|
KMS_CTX,
|
|
194
|
+
{ entityName: ENTITY_NAME },
|
|
184
195
|
);
|
|
185
|
-
const twice = await encryptPiiFieldValues(once, userLikeEntity, ["email"], kms, KMS_CTX
|
|
196
|
+
const twice = await encryptPiiFieldValues(once, userLikeEntity, ["email"], kms, KMS_CTX, {
|
|
197
|
+
entityName: ENTITY_NAME,
|
|
198
|
+
});
|
|
186
199
|
expect(twice["email"]).toBe(once["email"]);
|
|
187
200
|
});
|
|
188
201
|
|
|
@@ -194,6 +207,7 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
|
|
|
194
207
|
["email"],
|
|
195
208
|
kms,
|
|
196
209
|
KMS_CTX,
|
|
210
|
+
{ entityName: ENTITY_NAME },
|
|
197
211
|
);
|
|
198
212
|
expect(stored["email"]).toBeNull();
|
|
199
213
|
|
|
@@ -203,7 +217,7 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
|
|
|
203
217
|
["email"],
|
|
204
218
|
kms,
|
|
205
219
|
KMS_CTX,
|
|
206
|
-
{ onlyKeys: ["role"] },
|
|
220
|
+
{ onlyKeys: ["role"], entityName: ENTITY_NAME },
|
|
207
221
|
);
|
|
208
222
|
expect(skipped["email"]).toBe("a@b.c");
|
|
209
223
|
});
|
|
@@ -211,7 +225,9 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
|
|
|
211
225
|
test("non-string pii value throws", async () => {
|
|
212
226
|
const kms = new InMemoryKmsAdapter();
|
|
213
227
|
await expect(
|
|
214
|
-
encryptPiiFieldValues({ id: UUID_A, email: 42 }, userLikeEntity, ["email"], kms, KMS_CTX
|
|
228
|
+
encryptPiiFieldValues({ id: UUID_A, email: 42 }, userLikeEntity, ["email"], kms, KMS_CTX, {
|
|
229
|
+
entityName: ENTITY_NAME,
|
|
230
|
+
}),
|
|
215
231
|
).rejects.toThrow(/must be a string/);
|
|
216
232
|
});
|
|
217
233
|
|
|
@@ -223,6 +239,7 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
|
|
|
223
239
|
["email"],
|
|
224
240
|
kmsA,
|
|
225
241
|
KMS_CTX,
|
|
242
|
+
{ entityName: ENTITY_NAME },
|
|
226
243
|
);
|
|
227
244
|
const kmsB = new InMemoryKmsAdapter();
|
|
228
245
|
await expect(decryptPiiFieldValues(stored, ["email"], kmsB, KMS_CTX)).rejects.toBeInstanceOf(
|
|
@@ -248,7 +265,9 @@ describe("tenant-owned field encryption (kumiko-platform#457)", () => {
|
|
|
248
265
|
expect(fields).toEqual(["iban"]);
|
|
249
266
|
|
|
250
267
|
const row = { id: UUID_A, tenantId: UUID_B, iban: "DE89370400440532013000" };
|
|
251
|
-
const stored = await encryptPiiFieldValues(row, brandingWithAccess, fields, kms, KMS_CTX
|
|
268
|
+
const stored = await encryptPiiFieldValues(row, brandingWithAccess, fields, kms, KMS_CTX, {
|
|
269
|
+
entityName: ENTITY_NAME,
|
|
270
|
+
});
|
|
252
271
|
expect(isPiiCiphertext(stored["iban"])).toBe(true);
|
|
253
272
|
expect(String(stored["iban"])).toStartWith(`${PII_CIPHERTEXT_PREFIX}tenant:${UUID_B}:`);
|
|
254
273
|
|
|
@@ -266,7 +285,9 @@ describe("tenant-owned field encryption (kumiko-platform#457)", () => {
|
|
|
266
285
|
});
|
|
267
286
|
const fields = collectPiiSubjectFields(brandingWithAccess);
|
|
268
287
|
const row = { id: UUID_A, tenantId: UUID_B, iban: "DE89370400440532013000" };
|
|
269
|
-
const stored = await encryptPiiFieldValues(row, brandingWithAccess, fields, kms, KMS_CTX
|
|
288
|
+
const stored = await encryptPiiFieldValues(row, brandingWithAccess, fields, kms, KMS_CTX, {
|
|
289
|
+
entityName: ENTITY_NAME,
|
|
290
|
+
});
|
|
270
291
|
|
|
271
292
|
// Erasure is subject-keyed, not field-flag-keyed — kms.eraseKey doesn't
|
|
272
293
|
// know or care about the field's `personal` annotation, just the subject.
|
|
@@ -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);
|
|
@@ -76,9 +86,9 @@ describe("record subject key round-trip", () => {
|
|
|
76
86
|
|
|
77
87
|
describe("resolveSubjectForField: recordOwned fail-closed", () => {
|
|
78
88
|
test("recordOwned field without opts.entityName throws", () => {
|
|
79
|
-
expect(() =>
|
|
80
|
-
|
|
81
|
-
);
|
|
89
|
+
expect(() =>
|
|
90
|
+
resolveSubjectForField(recordEntity, "body", { id: UUID_A }, { entityName: "" }),
|
|
91
|
+
).toThrow(SubjectResolutionError);
|
|
82
92
|
});
|
|
83
93
|
|
|
84
94
|
test("recordOwned field with row missing id throws", () => {
|