@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
|
@@ -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";
|
|
@@ -38,31 +40,50 @@ const brandingEntity = createEntity({
|
|
|
38
40
|
|
|
39
41
|
const UUID_A = "6b2f4a0e-1c9d-4f3a-9d2e-00000000000a";
|
|
40
42
|
const UUID_B = "6b2f4a0e-1c9d-4f3a-9d2e-00000000000b";
|
|
43
|
+
const ENTITY_NAME = "resolver-entity";
|
|
41
44
|
|
|
42
45
|
describe("resolveSubjectForField", () => {
|
|
43
46
|
test("pii: true → the entity row itself is the user subject", () => {
|
|
44
|
-
const subject = resolveSubjectForField(
|
|
47
|
+
const subject = resolveSubjectForField(
|
|
48
|
+
userLikeEntity,
|
|
49
|
+
"email",
|
|
50
|
+
{ id: UUID_A },
|
|
51
|
+
{
|
|
52
|
+
entityName: ENTITY_NAME,
|
|
53
|
+
},
|
|
54
|
+
);
|
|
45
55
|
expect(subject).toEqual({ kind: "user", userId: UUID_A });
|
|
46
56
|
});
|
|
47
57
|
|
|
48
58
|
test("pii self-subject stringifies serial ids", () => {
|
|
49
|
-
const subject = resolveSubjectForField(
|
|
59
|
+
const subject = resolveSubjectForField(
|
|
60
|
+
userLikeEntity,
|
|
61
|
+
"email",
|
|
62
|
+
{ id: 42 },
|
|
63
|
+
{
|
|
64
|
+
entityName: ENTITY_NAME,
|
|
65
|
+
},
|
|
66
|
+
);
|
|
50
67
|
expect(subject).toEqual({ kind: "user", userId: "42" });
|
|
51
68
|
});
|
|
52
69
|
|
|
53
70
|
test("userOwned → subject comes from the owner reference field", () => {
|
|
54
|
-
const subject = resolveSubjectForField(
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
71
|
+
const subject = resolveSubjectForField(
|
|
72
|
+
commentEntity,
|
|
73
|
+
"body",
|
|
74
|
+
{ id: UUID_A, authorId: UUID_B },
|
|
75
|
+
{ entityName: ENTITY_NAME },
|
|
76
|
+
);
|
|
58
77
|
expect(subject).toEqual({ kind: "user", userId: UUID_B });
|
|
59
78
|
});
|
|
60
79
|
|
|
61
80
|
test("tenantOwned → subject from the row's tenantId column", () => {
|
|
62
|
-
const subject = resolveSubjectForField(
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
81
|
+
const subject = resolveSubjectForField(
|
|
82
|
+
brandingEntity,
|
|
83
|
+
"brandColor",
|
|
84
|
+
{ id: UUID_A, tenantId: UUID_B },
|
|
85
|
+
{ entityName: ENTITY_NAME },
|
|
86
|
+
);
|
|
66
87
|
expect(subject).toEqual({ kind: "tenant", tenantId: UUID_B });
|
|
67
88
|
});
|
|
68
89
|
|
|
@@ -71,37 +92,44 @@ describe("resolveSubjectForField", () => {
|
|
|
71
92
|
brandingEntity,
|
|
72
93
|
"brandColor",
|
|
73
94
|
{ id: UUID_A },
|
|
74
|
-
{ tenantId: UUID_B },
|
|
95
|
+
{ tenantId: UUID_B, entityName: ENTITY_NAME },
|
|
75
96
|
);
|
|
76
97
|
expect(subject).toEqual({ kind: "tenant", tenantId: UUID_B });
|
|
77
98
|
});
|
|
78
99
|
|
|
79
100
|
test("unannotated field → null (stays plaintext)", () => {
|
|
80
|
-
expect(
|
|
101
|
+
expect(
|
|
102
|
+
resolveSubjectForField(userLikeEntity, "role", { id: UUID_A }, { entityName: ENTITY_NAME }),
|
|
103
|
+
).toBeNull();
|
|
81
104
|
});
|
|
82
105
|
|
|
83
106
|
test("empty owner reference throws instead of silently falling back to plaintext", () => {
|
|
84
|
-
expect(() =>
|
|
85
|
-
|
|
86
|
-
);
|
|
107
|
+
expect(() =>
|
|
108
|
+
resolveSubjectForField(commentEntity, "body", { id: UUID_A }, { entityName: ENTITY_NAME }),
|
|
109
|
+
).toThrow(SubjectResolutionError);
|
|
87
110
|
});
|
|
88
111
|
|
|
89
112
|
test("tenantOwned without any tenant scope throws", () => {
|
|
90
|
-
expect(() =>
|
|
91
|
-
|
|
92
|
-
|
|
113
|
+
expect(() =>
|
|
114
|
+
resolveSubjectForField(
|
|
115
|
+
brandingEntity,
|
|
116
|
+
"brandColor",
|
|
117
|
+
{ id: UUID_A },
|
|
118
|
+
{ entityName: ENTITY_NAME },
|
|
119
|
+
),
|
|
120
|
+
).toThrow(SubjectResolutionError);
|
|
93
121
|
});
|
|
94
122
|
|
|
95
123
|
test("pii row without id throws", () => {
|
|
96
|
-
expect(() =>
|
|
97
|
-
|
|
98
|
-
);
|
|
124
|
+
expect(() =>
|
|
125
|
+
resolveSubjectForField(userLikeEntity, "email", {}, { entityName: ENTITY_NAME }),
|
|
126
|
+
).toThrow(SubjectResolutionError);
|
|
99
127
|
});
|
|
100
128
|
|
|
101
129
|
test("unknown field name throws", () => {
|
|
102
|
-
expect(() =>
|
|
103
|
-
|
|
104
|
-
);
|
|
130
|
+
expect(() =>
|
|
131
|
+
resolveSubjectForField(userLikeEntity, "nope", { id: UUID_A }, { entityName: ENTITY_NAME }),
|
|
132
|
+
).toThrow(SubjectResolutionError);
|
|
105
133
|
});
|
|
106
134
|
});
|
|
107
135
|
|
|
@@ -112,3 +140,90 @@ describe("collectPiiSubjectFields", () => {
|
|
|
112
140
|
expect(collectPiiSubjectFields(brandingEntity)).toEqual(["brandColor"]);
|
|
113
141
|
});
|
|
114
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,14 @@ 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,
|
|
32
33
|
subjectIdToKey,
|
|
34
|
+
subjectKeyForRecord,
|
|
33
35
|
subjectKeyForTenant,
|
|
34
36
|
subjectKeyForUser,
|
|
35
37
|
} from "./kms-adapter";
|
|
@@ -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;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { type EventPiiSubject, normalizeEventPiiSubject } from "@cosmicdrift/kumiko-types/handlers";
|
|
1
2
|
import type { EntityDefinition } from "../engine/types/fields";
|
|
2
3
|
import type { TenantId } from "../engine/types/identifiers";
|
|
3
4
|
import { isSelfPiiField } from "./is-self-pii-field";
|
|
4
|
-
import type
|
|
5
|
+
import { RECORD_ENTITY_PATTERN, type SubjectId } from "./kms-adapter";
|
|
5
6
|
|
|
6
7
|
// Thrown when a field IS pii-annotated but the row can't name its subject —
|
|
7
8
|
// that must surface as an error, not fall back to plaintext.
|
|
@@ -21,7 +22,7 @@ export interface ResolveSubjectOptions {
|
|
|
21
22
|
readonly tenantId?: TenantId;
|
|
22
23
|
// Canonical source is the registry entity name (executor `entityName` /
|
|
23
24
|
// event `aggregate_type`) — required to resolve recordOwned fields.
|
|
24
|
-
readonly entityName
|
|
25
|
+
readonly entityName: string;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
function nonEmptyString(value: unknown): string | null {
|
|
@@ -101,7 +102,7 @@ export function resolveSubjectForField(
|
|
|
101
102
|
entity: EntityDefinition,
|
|
102
103
|
fieldName: string,
|
|
103
104
|
row: Record<string, unknown>,
|
|
104
|
-
opts: ResolveSubjectOptions
|
|
105
|
+
opts: ResolveSubjectOptions,
|
|
105
106
|
): SubjectId | null {
|
|
106
107
|
const field = entity.fields[fieldName];
|
|
107
108
|
if (!field) throw new SubjectResolutionError(fieldName, "field is not defined on the entity");
|
|
@@ -125,6 +126,66 @@ export function resolveSubjectForField(
|
|
|
125
126
|
return null;
|
|
126
127
|
}
|
|
127
128
|
|
|
129
|
+
// Envelope facts an event's own aggregate stream always carries — unlike a
|
|
130
|
+
// row's tenantId/id columns, these are structural, so a missing/empty value
|
|
131
|
+
// here is a caller bug, not a normal "no subject to shred" case.
|
|
132
|
+
export interface EventSubjectEnvelope {
|
|
133
|
+
readonly tenantId: TenantId;
|
|
134
|
+
readonly aggregateType: string;
|
|
135
|
+
readonly aggregateId: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Same resolver for the live-append path (encryptEventPayloadPii) and the
|
|
139
|
+
// backfill catalog path (backfillEventPiiEncryption) — the reason the two
|
|
140
|
+
// can never encrypt the same field under different subjects. "user" returns
|
|
141
|
+
// null on a missing owner (no key to shred for system-triggered events);
|
|
142
|
+
// "tenant"/"self" throw instead, since their envelope facts are structural.
|
|
143
|
+
export function resolveEventSubject(
|
|
144
|
+
fieldName: string,
|
|
145
|
+
spec: EventPiiSubject,
|
|
146
|
+
payload: Record<string, unknown>,
|
|
147
|
+
envelope: EventSubjectEnvelope,
|
|
148
|
+
): SubjectId | null {
|
|
149
|
+
const normalized = normalizeEventPiiSubject(spec);
|
|
150
|
+
|
|
151
|
+
if (normalized.kind === "user") {
|
|
152
|
+
const userId = nonEmptyString(payload[normalized.ownerField]);
|
|
153
|
+
return userId === null ? null : { kind: "user", userId };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (normalized.kind === "tenant") {
|
|
157
|
+
if (nonEmptyString(envelope.tenantId) === null) {
|
|
158
|
+
throw new SubjectResolutionError(fieldName, "event envelope tenantId is empty");
|
|
159
|
+
}
|
|
160
|
+
return { kind: "tenant", tenantId: envelope.tenantId };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (normalized.kind === "self") {
|
|
164
|
+
if (
|
|
165
|
+
nonEmptyString(envelope.aggregateType) === null ||
|
|
166
|
+
nonEmptyString(envelope.aggregateId) === null
|
|
167
|
+
) {
|
|
168
|
+
throw new SubjectResolutionError(
|
|
169
|
+
fieldName,
|
|
170
|
+
"event envelope aggregateType/aggregateId is empty",
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
// aggregate_type is a free-form text column (events-schema.ts) — reject
|
|
174
|
+
// here rather than mint a record key that subjectIdSchema (forget-
|
|
175
|
+
// subject.write.ts) would later refuse to shred.
|
|
176
|
+
if (!RECORD_ENTITY_PATTERN.test(envelope.aggregateType)) {
|
|
177
|
+
throw new SubjectResolutionError(
|
|
178
|
+
fieldName,
|
|
179
|
+
`event aggregateType "${envelope.aggregateType}" does not match ${RECORD_ENTITY_PATTERN} — not a shreddable record subject`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
return { kind: "record", entity: envelope.aggregateType, id: envelope.aggregateId };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const exhaustiveCheck: never = normalized;
|
|
186
|
+
throw new Error(`Unhandled normalized event PII subject: ${JSON.stringify(exhaustiveCheck)}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
128
189
|
function hasSubjectAnnotation(field: EntityDefinition["fields"][string]): boolean {
|
|
129
190
|
return (
|
|
130
191
|
("userOwned" in field && field.userOwned !== undefined) ||
|
|
@@ -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,
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
// same collectPiiSubjectFields/resolveSubjectForField the live write
|
|
12
12
|
// path uses
|
|
13
13
|
// - custom events from the event-PII catalog (r.defineEvent piiFields) —
|
|
14
|
-
// user
|
|
14
|
+
// user/tenant/self subjects, via the same resolveEventSubject the live
|
|
15
|
+
// append path uses (fw#2801)
|
|
15
16
|
//
|
|
16
17
|
// Already-forgotten subjects must NOT get a fresh key minted for their old
|
|
17
18
|
// plaintext — three erased-detection layers write [[erased]] instead:
|
|
@@ -59,6 +60,7 @@ import {
|
|
|
59
60
|
} from "../../crypto/pii-field-encryption";
|
|
60
61
|
import {
|
|
61
62
|
collectPiiSubjectFields,
|
|
63
|
+
resolveEventSubject,
|
|
62
64
|
resolveSubjectForField,
|
|
63
65
|
SubjectResolutionError,
|
|
64
66
|
} from "../../crypto/subject-resolver";
|
|
@@ -284,11 +286,13 @@ export async function backfillEventPiiEncryption(
|
|
|
284
286
|
const catalogFields = eventCatalog.get(row.type);
|
|
285
287
|
if (catalogFields) {
|
|
286
288
|
for (const [field, spec] of Object.entries(catalogFields)) {
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
289
|
+
const subject = resolveEventSubject(field, spec, payload, {
|
|
290
|
+
tenantId: row.tenant_id as TenantId, // @cast-boundary db-read — tenant_id column is the branded TenantId
|
|
291
|
+
aggregateType: row.aggregate_type,
|
|
292
|
+
aggregateId: row.aggregate_id,
|
|
293
|
+
});
|
|
294
|
+
if (subject === null) continue;
|
|
295
|
+
const outcome = await encryptField(payload, field, subject);
|
|
292
296
|
bump(outcome);
|
|
293
297
|
}
|
|
294
298
|
} else {
|
|
@@ -132,6 +132,37 @@ describe("validateBoot — projectionDetail tabs (fw record-layout)", () => {
|
|
|
132
132
|
expect(() => validateBoot([feature])).toThrow(/metric "balance" has no entry in fieldLabels/);
|
|
133
133
|
});
|
|
134
134
|
|
|
135
|
+
test("object-form metric with its own label needs no fieldLabels entry", () => {
|
|
136
|
+
const feature = defineFeature("app", (r) => {
|
|
137
|
+
r.queryHandler("rent:detail", z.object({}), async () => ({ description: "x" }), {
|
|
138
|
+
access: { openToAll: true },
|
|
139
|
+
});
|
|
140
|
+
r.screen({
|
|
141
|
+
id: "rent-detail",
|
|
142
|
+
type: "projectionDetail",
|
|
143
|
+
query: "app:query:rent:detail",
|
|
144
|
+
layout: { sections: [{ title: "s", fields: ["description"] }] },
|
|
145
|
+
metrics: [{ field: "balance", label: "rent.balance" }],
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("object-form metric with neither its own label nor a fieldLabels entry throws", () => {
|
|
152
|
+
const feature = defineFeature("app", (r) => {
|
|
153
|
+
r.screen({
|
|
154
|
+
id: "rent-detail",
|
|
155
|
+
type: "projectionDetail",
|
|
156
|
+
query: "app:query:rent:detail",
|
|
157
|
+
layout: { sections: [{ title: "s", fields: ["description"] }] },
|
|
158
|
+
metrics: [{ field: "balance" }],
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
162
|
+
/metric "balance" has no entry in fieldLabels and no own "label"/,
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
135
166
|
test("valid tabs + header + metrics declaration boots cleanly", () => {
|
|
136
167
|
const feature = defineFeature("app", (r) => {
|
|
137
168
|
r.queryHandler("rent:detail", z.object({}), async () => ({ description: "x" }), {
|
|
@@ -142,8 +173,8 @@ describe("validateBoot — projectionDetail tabs (fw record-layout)", () => {
|
|
|
142
173
|
type: "projectionDetail",
|
|
143
174
|
query: "app:query:rent:detail",
|
|
144
175
|
header: { title: "name", subtitle: "address", status: "state" },
|
|
145
|
-
metrics: ["balance", "overdueDays"],
|
|
146
|
-
fieldLabels: { balance: "rent.balance"
|
|
176
|
+
metrics: ["balance", { field: "overdueDays", label: "rent.overdueDays" }],
|
|
177
|
+
fieldLabels: { balance: "rent.balance" },
|
|
147
178
|
layout: {
|
|
148
179
|
mode: "tabs",
|
|
149
180
|
sections: [
|
|
@@ -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);
|
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
QueryHandlerDef,
|
|
13
13
|
ScreenDefinition,
|
|
14
14
|
} from "../types";
|
|
15
|
+
import { metricField } from "../types";
|
|
15
16
|
import { buildQueryHandlerMap } from "./projection-list-screens";
|
|
16
17
|
import { getZodObjectShape, getZodRowShape } from "./zod-shape";
|
|
17
18
|
|
|
@@ -130,11 +131,12 @@ function checkProjectionDetailOutputFields(
|
|
|
130
131
|
if (screen.header.status !== undefined) checkHeaderField("status", screen.header.status);
|
|
131
132
|
}
|
|
132
133
|
for (const metric of screen.metrics ?? []) {
|
|
134
|
+
const field = metricField(metric);
|
|
133
135
|
checkFieldExists(
|
|
134
136
|
recordShape,
|
|
135
|
-
|
|
137
|
+
field,
|
|
136
138
|
() =>
|
|
137
|
-
`${prefix} metrics references field "${
|
|
139
|
+
`${prefix} metrics references field "${field}" which is not present in query "${screen.query}"'s outputSchema.`,
|
|
138
140
|
);
|
|
139
141
|
}
|
|
140
142
|
}
|
|
@@ -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
|
+
}
|