@cosmicdrift/kumiko-framework 0.252.1 → 0.254.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/crypto/__tests__/blind-index.test.ts +2 -0
- package/src/crypto/__tests__/pii-field-encryption.test.ts +29 -8
- package/src/crypto/__tests__/subject-key-record.test.ts +3 -3
- package/src/crypto/__tests__/subject-resolver.test.ts +50 -24
- package/src/crypto/pii-field-encryption.ts +3 -3
- package/src/crypto/subject-resolver.ts +2 -2
- package/src/db/__tests__/eagerload.integration.test.ts +9 -3
- package/src/db/queries/backfill-pii.ts +8 -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/record-owned.ts +22 -0
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +116 -1
- package/src/event-store/__tests__/perf.integration.test.ts +8 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.254.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.254.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.254.0",
|
|
218
218
|
"bun-types": "^1.3.13",
|
|
219
219
|
"pino-pretty": "^13.1.3"
|
|
220
220
|
},
|
|
@@ -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"]);
|
|
@@ -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.
|
|
@@ -76,9 +76,9 @@ describe("record subject key round-trip", () => {
|
|
|
76
76
|
|
|
77
77
|
describe("resolveSubjectForField: recordOwned fail-closed", () => {
|
|
78
78
|
test("recordOwned field without opts.entityName throws", () => {
|
|
79
|
-
expect(() =>
|
|
80
|
-
|
|
81
|
-
);
|
|
79
|
+
expect(() =>
|
|
80
|
+
resolveSubjectForField(recordEntity, "body", { id: UUID_A }, { entityName: "" }),
|
|
81
|
+
).toThrow(SubjectResolutionError);
|
|
82
82
|
});
|
|
83
83
|
|
|
84
84
|
test("recordOwned field with row missing id throws", () => {
|
|
@@ -38,31 +38,50 @@ const brandingEntity = createEntity({
|
|
|
38
38
|
|
|
39
39
|
const UUID_A = "6b2f4a0e-1c9d-4f3a-9d2e-00000000000a";
|
|
40
40
|
const UUID_B = "6b2f4a0e-1c9d-4f3a-9d2e-00000000000b";
|
|
41
|
+
const ENTITY_NAME = "resolver-entity";
|
|
41
42
|
|
|
42
43
|
describe("resolveSubjectForField", () => {
|
|
43
44
|
test("pii: true → the entity row itself is the user subject", () => {
|
|
44
|
-
const subject = resolveSubjectForField(
|
|
45
|
+
const subject = resolveSubjectForField(
|
|
46
|
+
userLikeEntity,
|
|
47
|
+
"email",
|
|
48
|
+
{ id: UUID_A },
|
|
49
|
+
{
|
|
50
|
+
entityName: ENTITY_NAME,
|
|
51
|
+
},
|
|
52
|
+
);
|
|
45
53
|
expect(subject).toEqual({ kind: "user", userId: UUID_A });
|
|
46
54
|
});
|
|
47
55
|
|
|
48
56
|
test("pii self-subject stringifies serial ids", () => {
|
|
49
|
-
const subject = resolveSubjectForField(
|
|
57
|
+
const subject = resolveSubjectForField(
|
|
58
|
+
userLikeEntity,
|
|
59
|
+
"email",
|
|
60
|
+
{ id: 42 },
|
|
61
|
+
{
|
|
62
|
+
entityName: ENTITY_NAME,
|
|
63
|
+
},
|
|
64
|
+
);
|
|
50
65
|
expect(subject).toEqual({ kind: "user", userId: "42" });
|
|
51
66
|
});
|
|
52
67
|
|
|
53
68
|
test("userOwned → subject comes from the owner reference field", () => {
|
|
54
|
-
const subject = resolveSubjectForField(
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
69
|
+
const subject = resolveSubjectForField(
|
|
70
|
+
commentEntity,
|
|
71
|
+
"body",
|
|
72
|
+
{ id: UUID_A, authorId: UUID_B },
|
|
73
|
+
{ entityName: ENTITY_NAME },
|
|
74
|
+
);
|
|
58
75
|
expect(subject).toEqual({ kind: "user", userId: UUID_B });
|
|
59
76
|
});
|
|
60
77
|
|
|
61
78
|
test("tenantOwned → subject from the row's tenantId column", () => {
|
|
62
|
-
const subject = resolveSubjectForField(
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
79
|
+
const subject = resolveSubjectForField(
|
|
80
|
+
brandingEntity,
|
|
81
|
+
"brandColor",
|
|
82
|
+
{ id: UUID_A, tenantId: UUID_B },
|
|
83
|
+
{ entityName: ENTITY_NAME },
|
|
84
|
+
);
|
|
66
85
|
expect(subject).toEqual({ kind: "tenant", tenantId: UUID_B });
|
|
67
86
|
});
|
|
68
87
|
|
|
@@ -71,37 +90,44 @@ describe("resolveSubjectForField", () => {
|
|
|
71
90
|
brandingEntity,
|
|
72
91
|
"brandColor",
|
|
73
92
|
{ id: UUID_A },
|
|
74
|
-
{ tenantId: UUID_B },
|
|
93
|
+
{ tenantId: UUID_B, entityName: ENTITY_NAME },
|
|
75
94
|
);
|
|
76
95
|
expect(subject).toEqual({ kind: "tenant", tenantId: UUID_B });
|
|
77
96
|
});
|
|
78
97
|
|
|
79
98
|
test("unannotated field → null (stays plaintext)", () => {
|
|
80
|
-
expect(
|
|
99
|
+
expect(
|
|
100
|
+
resolveSubjectForField(userLikeEntity, "role", { id: UUID_A }, { entityName: ENTITY_NAME }),
|
|
101
|
+
).toBeNull();
|
|
81
102
|
});
|
|
82
103
|
|
|
83
104
|
test("empty owner reference throws instead of silently falling back to plaintext", () => {
|
|
84
|
-
expect(() =>
|
|
85
|
-
|
|
86
|
-
);
|
|
105
|
+
expect(() =>
|
|
106
|
+
resolveSubjectForField(commentEntity, "body", { id: UUID_A }, { entityName: ENTITY_NAME }),
|
|
107
|
+
).toThrow(SubjectResolutionError);
|
|
87
108
|
});
|
|
88
109
|
|
|
89
110
|
test("tenantOwned without any tenant scope throws", () => {
|
|
90
|
-
expect(() =>
|
|
91
|
-
|
|
92
|
-
|
|
111
|
+
expect(() =>
|
|
112
|
+
resolveSubjectForField(
|
|
113
|
+
brandingEntity,
|
|
114
|
+
"brandColor",
|
|
115
|
+
{ id: UUID_A },
|
|
116
|
+
{ entityName: ENTITY_NAME },
|
|
117
|
+
),
|
|
118
|
+
).toThrow(SubjectResolutionError);
|
|
93
119
|
});
|
|
94
120
|
|
|
95
121
|
test("pii row without id throws", () => {
|
|
96
|
-
expect(() =>
|
|
97
|
-
|
|
98
|
-
);
|
|
122
|
+
expect(() =>
|
|
123
|
+
resolveSubjectForField(userLikeEntity, "email", {}, { entityName: ENTITY_NAME }),
|
|
124
|
+
).toThrow(SubjectResolutionError);
|
|
99
125
|
});
|
|
100
126
|
|
|
101
127
|
test("unknown field name throws", () => {
|
|
102
|
-
expect(() =>
|
|
103
|
-
|
|
104
|
-
);
|
|
128
|
+
expect(() =>
|
|
129
|
+
resolveSubjectForField(userLikeEntity, "nope", { id: UUID_A }, { entityName: ENTITY_NAME }),
|
|
130
|
+
).toThrow(SubjectResolutionError);
|
|
105
131
|
});
|
|
106
132
|
});
|
|
107
133
|
|
|
@@ -160,7 +160,7 @@ export interface EncryptPiiOptions {
|
|
|
160
160
|
// carry a pii field without its ownerField — the merged row still has it).
|
|
161
161
|
readonly subjectSource?: Record<string, unknown>;
|
|
162
162
|
// Canonical registry entity name — required to resolve recordOwned fields.
|
|
163
|
-
readonly entityName
|
|
163
|
+
readonly entityName: string;
|
|
164
164
|
}
|
|
165
165
|
|
|
166
166
|
export async function encryptPiiFieldValues(
|
|
@@ -169,7 +169,7 @@ export async function encryptPiiFieldValues(
|
|
|
169
169
|
piiFields: readonly string[],
|
|
170
170
|
kms: LocalKeyKmsAdapter,
|
|
171
171
|
kmsCtx: KmsContext,
|
|
172
|
-
opts: EncryptPiiOptions
|
|
172
|
+
opts: EncryptPiiOptions,
|
|
173
173
|
): Promise<Record<string, unknown>> {
|
|
174
174
|
if (piiFields.length === 0) return row;
|
|
175
175
|
const only = opts.onlyKeys ? new Set(opts.onlyKeys) : null;
|
|
@@ -187,8 +187,8 @@ export async function encryptPiiFieldValues(
|
|
|
187
187
|
throw new Error(`PII field "${name}" must be a string, got ${typeof value}`);
|
|
188
188
|
}
|
|
189
189
|
const subject = resolveSubjectForField(entity, name, subjectSource, {
|
|
190
|
+
entityName: opts.entityName,
|
|
190
191
|
...(opts.tenantId !== undefined && { tenantId: opts.tenantId }),
|
|
191
|
-
...(opts.entityName !== undefined && { entityName: opts.entityName }),
|
|
192
192
|
});
|
|
193
193
|
// skip: collectPiiSubjectFields only yields annotated fields — null is unreachable, kept as a type guard
|
|
194
194
|
if (subject === null) continue;
|
|
@@ -21,7 +21,7 @@ export interface ResolveSubjectOptions {
|
|
|
21
21
|
readonly tenantId?: TenantId;
|
|
22
22
|
// Canonical source is the registry entity name (executor `entityName` /
|
|
23
23
|
// event `aggregate_type`) — required to resolve recordOwned fields.
|
|
24
|
-
readonly entityName
|
|
24
|
+
readonly entityName: string;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
function nonEmptyString(value: unknown): string | null {
|
|
@@ -101,7 +101,7 @@ export function resolveSubjectForField(
|
|
|
101
101
|
entity: EntityDefinition,
|
|
102
102
|
fieldName: string,
|
|
103
103
|
row: Record<string, unknown>,
|
|
104
|
-
opts: ResolveSubjectOptions
|
|
104
|
+
opts: ResolveSubjectOptions,
|
|
105
105
|
): SubjectId | null {
|
|
106
106
|
const field = entity.fields[fieldName];
|
|
107
107
|
if (!field) throw new SubjectResolutionError(fieldName, "field is not defined on the entity");
|
|
@@ -147,9 +147,14 @@ beforeAll(async () => {
|
|
|
147
147
|
email: "grace@acme.test",
|
|
148
148
|
iban: "DE12345",
|
|
149
149
|
};
|
|
150
|
-
const piiEncrypted = await encryptPiiFieldValues(
|
|
151
|
-
|
|
152
|
-
|
|
150
|
+
const piiEncrypted = await encryptPiiFieldValues(
|
|
151
|
+
plainContact,
|
|
152
|
+
contactEntity,
|
|
153
|
+
["email"],
|
|
154
|
+
kms,
|
|
155
|
+
{ requestId: "test" },
|
|
156
|
+
{ entityName: "contact" },
|
|
157
|
+
);
|
|
153
158
|
const encrypted = await encryptEntityFieldValues(
|
|
154
159
|
piiEncrypted,
|
|
155
160
|
collectEncryptedFieldNames(contactEntity),
|
|
@@ -170,6 +175,7 @@ beforeAll(async () => {
|
|
|
170
175
|
["email"],
|
|
171
176
|
kms,
|
|
172
177
|
{ requestId: "test" },
|
|
178
|
+
{ entityName: "owned-contact" },
|
|
173
179
|
);
|
|
174
180
|
const ownedEncrypted = await encryptEntityFieldValues(
|
|
175
181
|
ownedPiiEncrypted,
|
|
@@ -6,8 +6,12 @@
|
|
|
6
6
|
// them in place, per field, under the owning subject's DEK:
|
|
7
7
|
//
|
|
8
8
|
// - entity lifecycle events (<entity>.created/updated/deleted/forgotten/
|
|
9
|
-
// restored) for every entity with PII subject annotations
|
|
10
|
-
//
|
|
9
|
+
// restored) for every entity with PII subject annotations — generic over
|
|
10
|
+
// subject kind (user/tenant/record, fw#2790), since it goes through the
|
|
11
|
+
// same collectPiiSubjectFields/resolveSubjectForField the live write
|
|
12
|
+
// path uses
|
|
13
|
+
// - custom events from the event-PII catalog (r.defineEvent piiFields) —
|
|
14
|
+
// user-subject only, see the note at its call site (fw#2801)
|
|
11
15
|
//
|
|
12
16
|
// Already-forgotten subjects must NOT get a fresh key minted for their old
|
|
13
17
|
// plaintext — three erased-detection layers write [[erased]] instead:
|
|
@@ -282,6 +286,8 @@ export async function backfillEventPiiEncryption(
|
|
|
282
286
|
for (const [field, spec] of Object.entries(catalogFields)) {
|
|
283
287
|
const subjectId = payload[spec.subjectField];
|
|
284
288
|
if (typeof subjectId !== "string" || subjectId.length === 0) continue;
|
|
289
|
+
// Catalog entries only ever resolve a user subject — a tenant/record
|
|
290
|
+
// subject in a custom event is not backfillable today (fw#2801).
|
|
285
291
|
const outcome = await encryptField(payload, field, { kind: "user", userId: subjectId });
|
|
286
292
|
bump(outcome);
|
|
287
293
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// fw#2809 — a recordOwned field is encrypted under `record:<entity>:<id>`
|
|
2
|
+
// (kms-adapter-types.ts) and forgetSubject's subjectIdSchema validates that
|
|
3
|
+
// id as a UUID (crypto-shredding/handlers/forget-subject.write.ts). An
|
|
4
|
+
// idType: "serial" entity would encrypt such a field but could never satisfy
|
|
5
|
+
// a forget-subject request for it — the boot guard catches that combination
|
|
6
|
+
// before it ships.
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import { defineFeature } from "../../define-feature";
|
|
10
|
+
import { createEntity, createTextField } from "../../factories";
|
|
11
|
+
import type { FeatureDefinition } from "../../types";
|
|
12
|
+
import { validateRecordOwnedSubjects } from "../record-owned";
|
|
13
|
+
|
|
14
|
+
function featureWith(
|
|
15
|
+
idType: "serial" | "uuid" | undefined,
|
|
16
|
+
withRecordOwnedField: boolean,
|
|
17
|
+
): FeatureDefinition {
|
|
18
|
+
return defineFeature("notes", (r) => {
|
|
19
|
+
r.entity(
|
|
20
|
+
"note",
|
|
21
|
+
createEntity({
|
|
22
|
+
table: "fw2809_guard_notes",
|
|
23
|
+
idType,
|
|
24
|
+
fields: {
|
|
25
|
+
body: withRecordOwnedField
|
|
26
|
+
? createTextField({ personal: { of: "id" }, find: "none" })
|
|
27
|
+
: createTextField(),
|
|
28
|
+
},
|
|
29
|
+
}),
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("validateRecordOwnedSubjects", () => {
|
|
35
|
+
test("idType: 'serial' + recordOwned field fails the boot, naming entity and field", () => {
|
|
36
|
+
const feature = featureWith("serial", true);
|
|
37
|
+
expect(() => validateRecordOwnedSubjects(feature)).toThrow(/Feature notes/);
|
|
38
|
+
expect(() => validateRecordOwnedSubjects(feature)).toThrow(/"note"/);
|
|
39
|
+
expect(() => validateRecordOwnedSubjects(feature)).toThrow(/"body"/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("idType: 'uuid' + recordOwned field boots fine", () => {
|
|
43
|
+
const feature = featureWith("uuid", true);
|
|
44
|
+
expect(() => validateRecordOwnedSubjects(feature)).not.toThrow();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("idType omitted (default uuid) + recordOwned field boots fine", () => {
|
|
48
|
+
const feature = featureWith(undefined, true);
|
|
49
|
+
expect(() => validateRecordOwnedSubjects(feature)).not.toThrow();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("idType: 'serial' without any recordOwned field boots fine", () => {
|
|
53
|
+
const feature = featureWith("serial", false);
|
|
54
|
+
expect(() => validateRecordOwnedSubjects(feature)).not.toThrow();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -52,6 +52,7 @@ import {
|
|
|
52
52
|
} from "./projection-list-screens";
|
|
53
53
|
import { validateQueryOutputColumns } from "./query-output-columns";
|
|
54
54
|
import { validateQueryRefs } from "./query-refs";
|
|
55
|
+
import { validateRecordOwnedSubjects } from "./record-owned";
|
|
55
56
|
import {
|
|
56
57
|
collectScreenQns,
|
|
57
58
|
collectScreensByShortId,
|
|
@@ -174,6 +175,7 @@ export function validateBoot(
|
|
|
174
175
|
if (validateEncryptedFields(feature)) hasEncryptedFields = true;
|
|
175
176
|
if (validateFileFields(feature)) hasFileFields = true;
|
|
176
177
|
validatePiiAndRetention(feature);
|
|
178
|
+
validateRecordOwnedSubjects(feature);
|
|
177
179
|
validateApiExposureMatching(feature, allExposedApis, featureMap);
|
|
178
180
|
validateEmbeddedFields(feature, featureMap);
|
|
179
181
|
validateMultiSelectFields(feature);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { FeatureDefinition } from "../types";
|
|
2
|
+
import type { ResolvedPiiFlags } from "../types/fields";
|
|
3
|
+
|
|
4
|
+
// A recordOwned field is encrypted under `record:<entity>:<id>` (kms-adapter-types.ts)
|
|
5
|
+
// and forgetSubject's subjectIdSchema requires that id to be a UUID
|
|
6
|
+
// (crypto-shredding/handlers/forget-subject.write.ts) — an entity with
|
|
7
|
+
// idType: "serial" would encrypt the field but could never satisfy a
|
|
8
|
+
// forget-subject request for it.
|
|
9
|
+
export function validateRecordOwnedSubjects(feature: FeatureDefinition): void {
|
|
10
|
+
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
11
|
+
if (entity.idType !== "serial") continue;
|
|
12
|
+
|
|
13
|
+
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
14
|
+
const annot = field as ResolvedPiiFlags; // @cast-boundary schema-walk
|
|
15
|
+
if (annot.recordOwned !== true) continue;
|
|
16
|
+
|
|
17
|
+
throw new Error(
|
|
18
|
+
`[Feature ${feature.name}] entity "${entityName}" field "${fieldName}" is recordOwned but the entity declares idType: "serial" — the record subject key is "record:${entityName}:<id>" and forgetSubject validates that id as a UUID, so this field would be encrypted but could never be shredded. Set idType: "uuid" on entity "${entityName}", or give field "${fieldName}" a different personal stance (personal: "self" / "tenant" / { of: "<field>" }).`,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
isPiiCiphertext,
|
|
21
21
|
KeyNotFoundError,
|
|
22
22
|
PgKmsAdapter,
|
|
23
|
+
PII_CIPHERTEXT_PREFIX,
|
|
23
24
|
PII_ERASED_SENTINEL,
|
|
24
25
|
} from "../../crypto";
|
|
25
26
|
import { applyEntityEvent } from "../../db/apply-entity-event";
|
|
@@ -72,6 +73,20 @@ const notesFeature = defineFeature("notes", (r) => {
|
|
|
72
73
|
r.entity("note", noteEntity);
|
|
73
74
|
});
|
|
74
75
|
|
|
76
|
+
// personal: { of: "id" } → recordOwned (kumiko-framework#2786): the row
|
|
77
|
+
// itself is the subject, not a user/tenant reachable via an owner field.
|
|
78
|
+
// fw#2790 — this fixture proves the pre-existing generic backfill already
|
|
79
|
+
// covers the record subject kind for lifecycle events, no new code needed.
|
|
80
|
+
const surveyEntity = createEntity({
|
|
81
|
+
fields: {
|
|
82
|
+
answer: createTextField({ personal: { of: "id" }, find: "none" }),
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
const surveyTable = buildEntityTable("survey", surveyEntity);
|
|
86
|
+
const surveyFeature = defineFeature("survey", (r) => {
|
|
87
|
+
r.entity("survey", surveyEntity);
|
|
88
|
+
});
|
|
89
|
+
|
|
75
90
|
let testDb: TestDb;
|
|
76
91
|
let registry: Registry;
|
|
77
92
|
let kms: InMemoryKmsAdapter;
|
|
@@ -96,10 +111,11 @@ async function appendPlain(
|
|
|
96
111
|
|
|
97
112
|
beforeAll(async () => {
|
|
98
113
|
testDb = await createTestDb();
|
|
99
|
-
registry = createRegistry([crmFeature, mailerFeature, notesFeature]);
|
|
114
|
+
registry = createRegistry([crmFeature, mailerFeature, notesFeature, surveyFeature]);
|
|
100
115
|
await createEventsTable(testDb.db);
|
|
101
116
|
await unsafeCreateEntityTable(testDb.db, contactEntity, "contact");
|
|
102
117
|
await unsafeCreateEntityTable(testDb.db, noteEntity, "note");
|
|
118
|
+
await unsafeCreateEntityTable(testDb.db, surveyEntity, "survey");
|
|
103
119
|
});
|
|
104
120
|
|
|
105
121
|
afterAll(async () => {
|
|
@@ -111,6 +127,7 @@ beforeEach(async () => {
|
|
|
111
127
|
await raw.unsafe(`TRUNCATE "kumiko_events" RESTART IDENTITY`);
|
|
112
128
|
await raw.unsafe(`TRUNCATE "${contactTable.tableName}"`);
|
|
113
129
|
await raw.unsafe(`TRUNCATE "${noteTable.tableName}"`);
|
|
130
|
+
await raw.unsafe(`TRUNCATE "${surveyTable.tableName}"`);
|
|
114
131
|
// Plaintext era: NO KMS while the legacy events are appended.
|
|
115
132
|
resetPiiSubjectKmsForTests();
|
|
116
133
|
resetBlindIndexKeyForTests();
|
|
@@ -590,6 +607,104 @@ describe("backfillEventPiiEncryption: owner-resolution chain (fw#2266)", () => {
|
|
|
590
607
|
});
|
|
591
608
|
});
|
|
592
609
|
|
|
610
|
+
// fw#2790 — "Altbestand: Freitext-Klartext in kumiko_events". #2596 added
|
|
611
|
+
// the recordOwned ("record") subject kind for row-scoped free text with no
|
|
612
|
+
// user/tenant owner. Nothing in this file's resolution path (subject-
|
|
613
|
+
// resolver.ts / pii-field-encryption.ts) special-cases subject kind, so the
|
|
614
|
+
// expectation is that this backfill already covers it for lifecycle events
|
|
615
|
+
// with zero new code — these tests pin that down rather than assume it.
|
|
616
|
+
describe("backfillEventPiiEncryption: record subject (kumiko-framework#2786)", () => {
|
|
617
|
+
test("encrypts a lifecycle payload under record:<entity>:<id>, not user:<id>", async () => {
|
|
618
|
+
const s1 = generateId();
|
|
619
|
+
await appendPlain(s1, "survey", "survey.created", { id: s1, answer: "plaintext answer" });
|
|
620
|
+
|
|
621
|
+
armKms();
|
|
622
|
+
const result = await backfillEventPiiEncryption(testDb.db, registry);
|
|
623
|
+
|
|
624
|
+
expect(result.failures).toEqual([]);
|
|
625
|
+
expect(result.updatedEvents).toBe(1);
|
|
626
|
+
expect(result.encryptedFields).toBe(1);
|
|
627
|
+
// Neither owner-resolution fallback is needed: the subject is the row's
|
|
628
|
+
// own id, always present on the event — stage 2/3 stay unused.
|
|
629
|
+
expect(result.ownerFromProjection).toBe(0);
|
|
630
|
+
expect(result.erasedUnresolvable).toBe(0);
|
|
631
|
+
|
|
632
|
+
const events = await loadAggregate(testDb.db, s1, TENANT);
|
|
633
|
+
const created = events[0]?.payload as Record<string, unknown>;
|
|
634
|
+
expect(isPiiCiphertext(created["answer"])).toBe(true);
|
|
635
|
+
// Exact prefix, not just "contains": the entity segment must be the
|
|
636
|
+
// registry name forget-subject resolves ("survey"), not the table name
|
|
637
|
+
// or a divergent aggregate_type — a wrong segment here would make the
|
|
638
|
+
// ciphertext unreachable by record-forget (fw#2790).
|
|
639
|
+
expect(String(created["answer"])).toStartWith(`${PII_CIPHERTEXT_PREFIX}record:survey:${s1}:`);
|
|
640
|
+
expect(String(created["answer"])).not.toContain(`user:${s1}`);
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
test("encrypts changes+previous on an update the same way", async () => {
|
|
644
|
+
const s2 = generateId();
|
|
645
|
+
await appendPlain(s2, "survey", "survey.created", { id: s2, answer: "first" });
|
|
646
|
+
await appendPlain(
|
|
647
|
+
s2,
|
|
648
|
+
"survey",
|
|
649
|
+
"survey.updated",
|
|
650
|
+
{ changes: { answer: "second" }, previous: { id: s2, answer: "first" } },
|
|
651
|
+
1,
|
|
652
|
+
);
|
|
653
|
+
|
|
654
|
+
armKms();
|
|
655
|
+
const result = await backfillEventPiiEncryption(testDb.db, registry);
|
|
656
|
+
expect(result.failures).toEqual([]);
|
|
657
|
+
expect(result.updatedEvents).toBe(2);
|
|
658
|
+
|
|
659
|
+
const events = await loadAggregate(testDb.db, s2, TENANT);
|
|
660
|
+
const updated = events[1]?.payload as {
|
|
661
|
+
changes: Record<string, unknown>;
|
|
662
|
+
previous: Record<string, unknown>;
|
|
663
|
+
};
|
|
664
|
+
expect(String(updated.changes["answer"])).toStartWith(
|
|
665
|
+
`${PII_CIPHERTEXT_PREFIX}record:survey:${s2}:`,
|
|
666
|
+
);
|
|
667
|
+
expect(String(updated.previous["answer"])).toStartWith(
|
|
668
|
+
`${PII_CIPHERTEXT_PREFIX}record:survey:${s2}:`,
|
|
669
|
+
);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
test("a pre-KMS-forgotten row gets [[erased]], not a fresh record key", async () => {
|
|
673
|
+
const s3 = generateId();
|
|
674
|
+
await appendPlain(s3, "survey", "survey.created", { id: s3, answer: "secret" });
|
|
675
|
+
await appendPlain(
|
|
676
|
+
s3,
|
|
677
|
+
"survey",
|
|
678
|
+
"survey.forgotten",
|
|
679
|
+
{ previous: { id: s3, answer: "secret" } },
|
|
680
|
+
1,
|
|
681
|
+
);
|
|
682
|
+
|
|
683
|
+
armKms();
|
|
684
|
+
const result = await backfillEventPiiEncryption(testDb.db, registry);
|
|
685
|
+
|
|
686
|
+
expect(result.failures).toEqual([]);
|
|
687
|
+
expect(result.erasedFields).toBe(2);
|
|
688
|
+
const events = await loadAggregate(testDb.db, s3, TENANT);
|
|
689
|
+
const created = events[0]?.payload as Record<string, unknown>;
|
|
690
|
+
expect(created["answer"]).toBe(PII_ERASED_SENTINEL);
|
|
691
|
+
const forgotten = events[1]?.payload as { previous: Record<string, unknown> };
|
|
692
|
+
expect(forgotten.previous["answer"]).toBe(PII_ERASED_SENTINEL);
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
test("idempotent: a second run touches nothing", async () => {
|
|
696
|
+
const s4 = generateId();
|
|
697
|
+
await appendPlain(s4, "survey", "survey.created", { id: s4, answer: "once" });
|
|
698
|
+
|
|
699
|
+
armKms();
|
|
700
|
+
const first = await backfillEventPiiEncryption(testDb.db, registry);
|
|
701
|
+
expect(first.updatedEvents).toBe(1);
|
|
702
|
+
const second = await backfillEventPiiEncryption(testDb.db, registry);
|
|
703
|
+
expect(second.updatedEvents).toBe(0);
|
|
704
|
+
expect(second.failures).toEqual([]);
|
|
705
|
+
});
|
|
706
|
+
});
|
|
707
|
+
|
|
593
708
|
describe("backfillEventPiiEncryption: raw jsonb column type (fw#2253)", () => {
|
|
594
709
|
// The UPDATE used to wrap outcome.payload in JSON.stringify before handing
|
|
595
710
|
// it to the ::jsonb cast — Bun.SQL already serializes objects, so the
|
|
@@ -13,7 +13,10 @@
|
|
|
13
13
|
// are the ceiling.
|
|
14
14
|
//
|
|
15
15
|
// Runs isolated in the `event-store-perf` CI job (test:integration:perf:eventstore,
|
|
16
|
-
// #1940) — gate on p95 for typical latency; p99 keeps a separate tail budget for
|
|
16
|
+
// #1940) — gate on p95 for typical latency; p99 keeps a separate tail budget for
|
|
17
|
+
// residual scheduling noise. CI's Postgres container runs with fsync/synchronous_commit/
|
|
18
|
+
// full_page_writes off (#2798) — it exists only to be measured and thrown away, so these
|
|
19
|
+
// numbers aren't comparable to a default-config local container.
|
|
17
20
|
|
|
18
21
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
19
22
|
import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
|
|
@@ -98,12 +101,14 @@ describe("event-store performance — Gate A", () => {
|
|
|
98
101
|
const p50 = percentile(samples, 0.5);
|
|
99
102
|
const p95 = percentile(samples, 0.95);
|
|
100
103
|
const p99 = percentile(samples, 0.99);
|
|
104
|
+
const over30 = samples.filter((s) => s >= 30).length;
|
|
105
|
+
const top5 = samples.slice(-5).map((s) => s.toFixed(1));
|
|
101
106
|
console.log(
|
|
102
|
-
` Write-latency: p50=${p50.toFixed(2)}ms, p95=${p95.toFixed(2)}ms, p99=${p99.toFixed(2)}ms (n=200)`,
|
|
107
|
+
` Write-latency: p50=${p50.toFixed(2)}ms, p95=${p95.toFixed(2)}ms, p99=${p99.toFixed(2)}ms (n=200) over30=${over30} top5=${top5}`,
|
|
103
108
|
);
|
|
104
109
|
|
|
105
110
|
expect(p95).toBeLessThan(30);
|
|
106
|
-
// Tail budget: absorbs
|
|
111
|
+
// Tail budget: absorbs residual runner scheduling noise while still catching order-of-magnitude regressions.
|
|
107
112
|
expect(p99).toBeLessThan(250);
|
|
108
113
|
});
|
|
109
114
|
|