@cosmicdrift/kumiko-framework 0.173.0 → 0.174.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/__tests__/schema-cli.integration.test.ts +18 -0
- package/src/api/__tests__/request-id-middleware.test.ts +73 -0
- package/src/api/request-id-middleware.ts +13 -2
- package/src/api/sse-broker.ts +9 -6
- package/src/crypto/__tests__/kms-wiring.test.ts +6 -0
- package/src/crypto/kms-wiring.ts +5 -0
- package/src/db/__tests__/eagerload.integration.test.ts +119 -1
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +27 -0
- package/src/db/__tests__/migrate-runner.test.ts +4 -0
- package/src/db/eagerload.ts +58 -8
- package/src/db/event-store-executor-write.ts +86 -14
- package/src/engine/__tests__/boot-validator.test.ts +87 -1
- package/src/engine/__tests__/entity-presave-wiring.integration.test.ts +53 -1
- package/src/engine/__tests__/schema-builder.test.ts +29 -0
- package/src/engine/__tests__/store-table.test.ts +15 -0
- package/src/engine/boot-validator/screens.ts +71 -27
- package/src/engine/field-access.ts +17 -3
- package/src/engine/schema-builder.ts +4 -1
- package/src/entrypoint/__tests__/split-deploy.integration.test.ts +28 -0
- package/src/jobs/__tests__/scheduler-id.test.ts +1 -1
- package/src/pipeline/dispatch-shared.ts +11 -3
- package/src/pipeline/dispatch-stream.ts +3 -6
- package/src/pipeline/system-hooks.ts +4 -4
- package/src/search/__tests__/reindex-entity.integration.test.ts +97 -1
- package/src/search/reindex-entity.ts +15 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.174.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>",
|
|
@@ -182,7 +182,7 @@
|
|
|
182
182
|
"./package.json": "./package.json"
|
|
183
183
|
},
|
|
184
184
|
"dependencies": {
|
|
185
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
185
|
+
"@cosmicdrift/kumiko-types": "0.174.0",
|
|
186
186
|
"bullmq": "^5.76.7",
|
|
187
187
|
"bun-types": "^1.3.13",
|
|
188
188
|
"hono": "^4.12.27",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"zod": "^4.4.3"
|
|
199
199
|
},
|
|
200
200
|
"devDependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.174.0",
|
|
202
202
|
"bun-types": "^1.3.13",
|
|
203
203
|
"pino-pretty": "^13.1.3"
|
|
204
204
|
},
|
|
@@ -200,6 +200,24 @@ CREATE TABLE IF NOT EXISTS "read_widgets_v2" ("id" uuid PRIMARY KEY);
|
|
|
200
200
|
expect(cap.err.join("\n")).toContain("read_widgets");
|
|
201
201
|
});
|
|
202
202
|
|
|
203
|
+
test("migration file missing a column its snapshot entry claims → column-drift hint, not unexpected-table", async () => {
|
|
204
|
+
writeSchemaFile(appCwd, "read_widgets", "note");
|
|
205
|
+
await runSchemaCli(["generate", "init"], appCwd, captureOut().out);
|
|
206
|
+
writeFileSync(
|
|
207
|
+
join(appCwd, "kumiko/migrations/0001_init.sql"),
|
|
208
|
+
`-- oops: hand-edited to drop the "note" column the snapshot still claims
|
|
209
|
+
CREATE TABLE IF NOT EXISTS "read_widgets" ("id" uuid PRIMARY KEY);
|
|
210
|
+
`,
|
|
211
|
+
);
|
|
212
|
+
const cap = captureOut();
|
|
213
|
+
const code = await runSchemaCli(["validate"], appCwd, cap.out);
|
|
214
|
+
expect(code).toBe(1);
|
|
215
|
+
const err = cap.err.join("\n");
|
|
216
|
+
expect(err).toContain("missing columns: note");
|
|
217
|
+
expect(err).toContain("Fix (missing-table/column-drift)");
|
|
218
|
+
expect(err).not.toContain("Fix (unexpected-table)");
|
|
219
|
+
});
|
|
220
|
+
|
|
203
221
|
test("migration creates a table with no snapshot entry → unexpected-table hint points at r.storeTable, not hand-fix", async () => {
|
|
204
222
|
writeSchemaFile(appCwd, "read_widgets");
|
|
205
223
|
await runSchemaCli(["generate", "init"], appCwd, captureOut().out);
|
|
@@ -121,3 +121,76 @@ describe("requestIdMiddleware — ip + userAgent capture (603/2)", () => {
|
|
|
121
121
|
expect(captured).toBeUndefined();
|
|
122
122
|
});
|
|
123
123
|
});
|
|
124
|
+
|
|
125
|
+
describe("requestIdMiddleware — client-supplied id sanitization (input-validation)", () => {
|
|
126
|
+
test("oversized X-Request-ID is rejected, a fresh id is generated instead", async () => {
|
|
127
|
+
let captured: string | undefined;
|
|
128
|
+
|
|
129
|
+
const app = new Hono();
|
|
130
|
+
app.use("/probe", requestIdMiddleware());
|
|
131
|
+
app.get("/probe", (c) => {
|
|
132
|
+
captured = requestContext.get()?.requestId;
|
|
133
|
+
return c.text("ok");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const junk = "x".repeat(5000);
|
|
137
|
+
const res = await app.request(
|
|
138
|
+
new Request("http://test.local/probe", {
|
|
139
|
+
method: "GET",
|
|
140
|
+
headers: { "X-Request-ID": junk },
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
expect(res.status).toBe(200);
|
|
145
|
+
expect(captured).toBeDefined();
|
|
146
|
+
expect(captured).not.toBe(junk);
|
|
147
|
+
expect(captured?.length).toBeLessThan(128);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("disallowed-character X-Correlation-ID falls back to the (sanitized) requestId", async () => {
|
|
151
|
+
let captured: { requestId: string | undefined; correlationId: string | undefined } = {
|
|
152
|
+
requestId: undefined,
|
|
153
|
+
correlationId: undefined,
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const app = new Hono();
|
|
157
|
+
app.use("/probe", requestIdMiddleware());
|
|
158
|
+
app.get("/probe", (c) => {
|
|
159
|
+
const ctx = requestContext.get();
|
|
160
|
+
captured = { requestId: ctx?.requestId, correlationId: ctx?.correlationId };
|
|
161
|
+
return c.text("ok");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const res = await app.request(
|
|
165
|
+
new Request("http://test.local/probe", {
|
|
166
|
+
method: "GET",
|
|
167
|
+
headers: { "X-Request-ID": "req-1", "X-Correlation-ID": "corr injected; drop table" },
|
|
168
|
+
}),
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
expect(res.status).toBe(200);
|
|
172
|
+
expect(captured.requestId).toBe("req-1");
|
|
173
|
+
expect(captured.correlationId).toBe("req-1");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("a well-formed client id is preserved", async () => {
|
|
177
|
+
let captured: string | undefined;
|
|
178
|
+
|
|
179
|
+
const app = new Hono();
|
|
180
|
+
app.use("/probe", requestIdMiddleware());
|
|
181
|
+
app.get("/probe", (c) => {
|
|
182
|
+
captured = requestContext.get()?.requestId;
|
|
183
|
+
return c.text("ok");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const res = await app.request(
|
|
187
|
+
new Request("http://test.local/probe", {
|
|
188
|
+
method: "GET",
|
|
189
|
+
headers: { "X-Request-ID": "trace-abc123.def" },
|
|
190
|
+
}),
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
expect(res.status).toBe(200);
|
|
194
|
+
expect(captured).toBe("trace-abc123.def");
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -4,6 +4,16 @@ import { requestContext } from "./request-context";
|
|
|
4
4
|
const REQUEST_ID_HEADER = "X-Request-ID";
|
|
5
5
|
const CORRELATION_ID_HEADER = "X-Correlation-ID";
|
|
6
6
|
|
|
7
|
+
// requestId/correlationId flow unvalidated into append-only event-store
|
|
8
|
+
// metadata (e.g. sessions:revoke-all-for-user) — cap shape at the trust
|
|
9
|
+
// boundary so a client can't smuggle an oversized/control-char payload into
|
|
10
|
+
// permanent, replayed, DSGVO-exported storage via a client-set header.
|
|
11
|
+
const SAFE_ID_RE = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
12
|
+
|
|
13
|
+
function sanitizeClientId(value: string | undefined): string | undefined {
|
|
14
|
+
return value !== undefined && SAFE_ID_RE.test(value) ? value : undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
7
17
|
/**
|
|
8
18
|
* Assigns a requestId + correlationId to every request and wraps execution
|
|
9
19
|
* in AsyncLocalStorage. Runs BEFORE auth — both ids are available even for
|
|
@@ -15,8 +25,9 @@ const CORRELATION_ID_HEADER = "X-Correlation-ID";
|
|
|
15
25
|
*/
|
|
16
26
|
export function requestIdMiddleware() {
|
|
17
27
|
return async (c: Context, next: Next) => {
|
|
18
|
-
const requestId =
|
|
19
|
-
|
|
28
|
+
const requestId =
|
|
29
|
+
sanitizeClientId(c.req.header(REQUEST_ID_HEADER)) ?? requestContext.generateId();
|
|
30
|
+
const correlationId = sanitizeClientId(c.req.header(CORRELATION_ID_HEADER)) ?? requestId;
|
|
20
31
|
c.header(REQUEST_ID_HEADER, requestId);
|
|
21
32
|
c.header(CORRELATION_ID_HEADER, correlationId);
|
|
22
33
|
c.set("requestId", requestId);
|
package/src/api/sse-broker.ts
CHANGED
|
@@ -19,15 +19,18 @@ export type SseBroker = {
|
|
|
19
19
|
getClientCount(channel: string): number;
|
|
20
20
|
getTotalClientCount(): number;
|
|
21
21
|
// Separate from addClient so it doesn't count towards getClientCount.
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
// Required (fw#1601): an app-injected SseBroker (e.g. a Redis-backed
|
|
23
|
+
// multi-replica broker) that skips these silently turns the mid-stream
|
|
24
|
+
// access-teardown security control (#1561) into a no-op — a revoked
|
|
25
|
+
// session keeps receiving live SSE data with no error or log. A no-op
|
|
26
|
+
// stub is one line for a broker that genuinely doesn't need it.
|
|
27
|
+
subscribeAccessInvalidation(userId: string, onInvalidate: () => void): () => void;
|
|
28
|
+
publishAccessInvalidation(userId: string): void;
|
|
25
29
|
};
|
|
26
30
|
|
|
27
31
|
export function createSseBroker(): SseBroker {
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
// (security control is single-node). Upgrade: Redis pub/sub on userAccessChannel.
|
|
32
|
+
// Cross-replica fanout lives one level up: the SSE + access-invalidation
|
|
33
|
+
// consumers (system-hooks.ts) run delivery: "per-instance" (#1718).
|
|
31
34
|
const channels = new Map<string, Map<string, SseClient>>();
|
|
32
35
|
const accessInvalidationListeners = new Map<string, Set<() => void>>();
|
|
33
36
|
|
|
@@ -83,6 +83,12 @@ describe("buildPgKmsOptions", () => {
|
|
|
83
83
|
/PLATFORM_KEK_PREVIOUS_VERSION must be set/,
|
|
84
84
|
);
|
|
85
85
|
});
|
|
86
|
+
|
|
87
|
+
test("rejects PLATFORM_KEK_PREVIOUS_VERSION without PLATFORM_KEK_PREVIOUS — half-set rotation must not silently drop the version", () => {
|
|
88
|
+
expect(() => buildPgKmsOptions({ ...rotationEnv, PLATFORM_KEK_PREVIOUS_VERSION: "1" })).toThrow(
|
|
89
|
+
/PLATFORM_KEK_PREVIOUS must be set/,
|
|
90
|
+
);
|
|
91
|
+
});
|
|
86
92
|
});
|
|
87
93
|
|
|
88
94
|
describe("resolveKmsWiring", () => {
|
package/src/crypto/kms-wiring.ts
CHANGED
|
@@ -83,6 +83,11 @@ export function buildPgKmsOptions(env: PgKmsRotationEnv): PgKmsAdapterOptions {
|
|
|
83
83
|
? parseKekVersion(env.PLATFORM_KEK_VERSION, "PLATFORM_KEK_VERSION")
|
|
84
84
|
: 1;
|
|
85
85
|
if (!env.PLATFORM_KEK_PREVIOUS) {
|
|
86
|
+
if (env.PLATFORM_KEK_PREVIOUS_VERSION) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
"PLATFORM_KEK_PREVIOUS must be set when PLATFORM_KEK_PREVIOUS_VERSION is set.",
|
|
89
|
+
);
|
|
90
|
+
}
|
|
86
91
|
return {
|
|
87
92
|
databaseUrl: env.SUBJECT_KEYS_DATABASE_URL,
|
|
88
93
|
platformKek: env.PLATFORM_KEK,
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
InMemoryKmsAdapter,
|
|
10
10
|
isPiiCiphertext,
|
|
11
11
|
} from "../../crypto";
|
|
12
|
-
import { createEntity, createTextField } from "../../engine";
|
|
12
|
+
import { createEntity, createTextField, from } from "../../engine";
|
|
13
13
|
import type { EntityDefinition } from "../../engine/types";
|
|
14
14
|
import { setupTestStack, type TestStack, testTenantId, unsafeCreateEntityTable } from "../../stack";
|
|
15
15
|
import { createTestEnvelopeCipher, resetPiiSubjectKmsForTests, seedRows } from "../../testing";
|
|
@@ -58,13 +58,44 @@ const leadEntity = createEntity({
|
|
|
58
58
|
fields: {
|
|
59
59
|
title: createTextField({ required: true }),
|
|
60
60
|
contact: { type: "reference", entity: "contact" },
|
|
61
|
+
ownedContact: { type: "reference", entity: "ownedContact" },
|
|
62
|
+
unrestrictedContact: { type: "reference", entity: "unrestrictedContact" },
|
|
61
63
|
},
|
|
62
64
|
});
|
|
63
65
|
const contactTable = buildEntityTable("contact", contactEntity);
|
|
64
66
|
|
|
67
|
+
// fw#1671: ownership-scoped ref entity — eagerload has no SessionUser to
|
|
68
|
+
// evaluate access.read against, so PII/encrypted fields must be stripped
|
|
69
|
+
// entirely rather than decrypted (fail closed, not a same-tenant PII leak).
|
|
70
|
+
const ownedContactEntity = createEntity({
|
|
71
|
+
table: "el_owned_contacts",
|
|
72
|
+
fields: {
|
|
73
|
+
name: createTextField({ required: true }),
|
|
74
|
+
email: createTextField({ required: true, tenantOwned: true }),
|
|
75
|
+
iban: createTextField({ required: true, encrypted: true }),
|
|
76
|
+
},
|
|
77
|
+
access: { read: { admin: from("user:id", "ownerId") } },
|
|
78
|
+
});
|
|
79
|
+
const ownedContactTable = buildEntityTable("ownedContact", ownedContactEntity);
|
|
80
|
+
|
|
81
|
+
// access.read present but every role is "all" (unrestricted) — must NOT
|
|
82
|
+
// trigger the strip path, or #1667's decrypt would silently regress for
|
|
83
|
+
// every entity that merely declares an access.read map without narrowing it.
|
|
84
|
+
const unrestrictedContactEntity = createEntity({
|
|
85
|
+
table: "el_unrestricted_contacts",
|
|
86
|
+
fields: {
|
|
87
|
+
name: createTextField({ required: true }),
|
|
88
|
+
email: createTextField({ required: true, tenantOwned: true }),
|
|
89
|
+
},
|
|
90
|
+
access: { read: { admin: "all", member: "all" } },
|
|
91
|
+
});
|
|
92
|
+
const unrestrictedContactTable = buildEntityTable("unrestrictedContact", unrestrictedContactEntity);
|
|
93
|
+
|
|
65
94
|
const resolve = (name: string): EntityDefinition | undefined => {
|
|
66
95
|
if (name === "author") return authorEntity;
|
|
67
96
|
if (name === "contact") return contactEntity;
|
|
97
|
+
if (name === "ownedContact") return ownedContactEntity;
|
|
98
|
+
if (name === "unrestrictedContact") return unrestrictedContactEntity;
|
|
68
99
|
return undefined;
|
|
69
100
|
};
|
|
70
101
|
|
|
@@ -83,6 +114,9 @@ const many = (r: EagerloadedRow | undefined, f: string) =>
|
|
|
83
114
|
r?._refs?.[f] as ReadonlyArray<Record<string, unknown>> | undefined;
|
|
84
115
|
|
|
85
116
|
const CX = "44444444-4444-4444-8444-444444444444";
|
|
117
|
+
const OWNED = "66666666-6666-4666-8666-666666666666";
|
|
118
|
+
const UNRESTRICTED = "88888888-8888-4888-8888-888888888888";
|
|
119
|
+
const BROKEN = "77777777-7777-4777-8777-777777777777";
|
|
86
120
|
const TEST_KEY = Buffer.from("a]bJm#kP9xQ2@wN!vL$hR5yT8eU0iO3f").toString("base64");
|
|
87
121
|
const cipher = createTestEnvelopeCipher(TEST_KEY);
|
|
88
122
|
const kms = new InMemoryKmsAdapter();
|
|
@@ -94,6 +128,8 @@ beforeAll(async () => {
|
|
|
94
128
|
stack = await setupTestStack({ features: [] });
|
|
95
129
|
await unsafeCreateEntityTable(stack.db, authorEntity);
|
|
96
130
|
await unsafeCreateEntityTable(stack.db, contactEntity);
|
|
131
|
+
await unsafeCreateEntityTable(stack.db, ownedContactEntity);
|
|
132
|
+
await unsafeCreateEntityTable(stack.db, unrestrictedContactEntity);
|
|
97
133
|
dbA = createTenantDb(stack.db, tenantA, "tenant");
|
|
98
134
|
|
|
99
135
|
await seedRows(stack.db, authorTable, [
|
|
@@ -120,6 +156,44 @@ beforeAll(async () => {
|
|
|
120
156
|
cipher,
|
|
121
157
|
);
|
|
122
158
|
await seedRows(stack.db, contactTable, [encrypted]);
|
|
159
|
+
|
|
160
|
+
const plainOwned = {
|
|
161
|
+
id: OWNED,
|
|
162
|
+
tenantId: tenantA,
|
|
163
|
+
name: "Owned Grace",
|
|
164
|
+
email: "owned-grace@acme.test",
|
|
165
|
+
iban: "DE99999",
|
|
166
|
+
};
|
|
167
|
+
const ownedPiiEncrypted = await encryptPiiFieldValues(
|
|
168
|
+
plainOwned,
|
|
169
|
+
ownedContactEntity,
|
|
170
|
+
["email"],
|
|
171
|
+
kms,
|
|
172
|
+
{ requestId: "test" },
|
|
173
|
+
);
|
|
174
|
+
const ownedEncrypted = await encryptEntityFieldValues(
|
|
175
|
+
ownedPiiEncrypted,
|
|
176
|
+
collectEncryptedFieldNames(ownedContactEntity),
|
|
177
|
+
cipher,
|
|
178
|
+
);
|
|
179
|
+
await seedRows(stack.db, ownedContactTable, [ownedEncrypted]);
|
|
180
|
+
|
|
181
|
+
// Legacy/backfilled row: iban never went through encryptEntityFieldValues,
|
|
182
|
+
// so decryptEntityFieldValues throws on it (fw#1671 error-propagation) —
|
|
183
|
+
// a single row like this must not 500 the whole eagerload lookup.
|
|
184
|
+
await seedRows(stack.db, contactTable, [
|
|
185
|
+
{
|
|
186
|
+
id: BROKEN,
|
|
187
|
+
tenantId: tenantA,
|
|
188
|
+
name: "Broken",
|
|
189
|
+
email: "broken@acme.test",
|
|
190
|
+
iban: "not-an-envelope",
|
|
191
|
+
},
|
|
192
|
+
]);
|
|
193
|
+
|
|
194
|
+
await seedRows(stack.db, unrestrictedContactTable, [
|
|
195
|
+
{ id: UNRESTRICTED, tenantId: tenantA, name: "Unrestricted", email: "unrestricted@acme.test" },
|
|
196
|
+
]);
|
|
123
197
|
});
|
|
124
198
|
|
|
125
199
|
afterAll(async () => {
|
|
@@ -237,4 +311,48 @@ describe("enrichWithReferences", () => {
|
|
|
237
311
|
expect(contact?.["iban"]).toBe("DE12345");
|
|
238
312
|
expect(isPiiCiphertext(contact?.["email"])).toBe(false);
|
|
239
313
|
});
|
|
314
|
+
|
|
315
|
+
test('fw#1671: access.read map where every rule is "all" (unrestricted) does NOT trigger the strip path', async () => {
|
|
316
|
+
const [row] = (await enrichWithReferences(
|
|
317
|
+
[{ id: "l1", unrestrictedContact: UNRESTRICTED }],
|
|
318
|
+
leadEntity,
|
|
319
|
+
resolve,
|
|
320
|
+
dbA,
|
|
321
|
+
)) as EagerloadedRow[];
|
|
322
|
+
const unrestricted = single(row, "unrestrictedContact");
|
|
323
|
+
|
|
324
|
+
expect(unrestricted).toBeDefined();
|
|
325
|
+
expect(unrestricted?.["name"]).toBe("Unrestricted");
|
|
326
|
+
expect(unrestricted?.["email"]).toBe("unrestricted@acme.test");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("fw#1671: ownership-scoped ref entity — PII/encrypted fields are stripped, not decrypted or leaked as ciphertext", async () => {
|
|
330
|
+
const [row] = (await enrichWithReferences(
|
|
331
|
+
[{ id: "l1", ownedContact: OWNED }],
|
|
332
|
+
leadEntity,
|
|
333
|
+
resolve,
|
|
334
|
+
dbA,
|
|
335
|
+
)) as EagerloadedRow[];
|
|
336
|
+
const owned = single(row, "ownedContact");
|
|
337
|
+
|
|
338
|
+
expect(owned).toBeDefined();
|
|
339
|
+
expect(owned?.["name"]).toBe("Owned Grace");
|
|
340
|
+
expect(owned?.["email"]).toBeUndefined();
|
|
341
|
+
expect(owned?.["iban"]).toBeUndefined();
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("fw#1671: a row with a broken envelope is dropped, a sibling row's good ref still resolves in the same batch", async () => {
|
|
345
|
+
const [goodRow, brokenRow] = (await enrichWithReferences(
|
|
346
|
+
[
|
|
347
|
+
{ id: "l1", contact: CX },
|
|
348
|
+
{ id: "l2", contact: BROKEN },
|
|
349
|
+
],
|
|
350
|
+
leadEntity,
|
|
351
|
+
resolve,
|
|
352
|
+
dbA,
|
|
353
|
+
)) as EagerloadedRow[];
|
|
354
|
+
|
|
355
|
+
expect(single(goodRow, "contact")?.["email"]).toBe("grace@acme.test");
|
|
356
|
+
expect(single(brokenRow, "contact")).toBeUndefined();
|
|
357
|
+
});
|
|
240
358
|
});
|
|
@@ -245,6 +245,33 @@ describe("event-store-executor write-verbs — field-level ownership_denied", ()
|
|
|
245
245
|
if (result.isSuccess) return;
|
|
246
246
|
expect((result.error.details as { reason?: string }).reason).toBe("ownership_denied");
|
|
247
247
|
});
|
|
248
|
+
|
|
249
|
+
// fw#1685: a preSave hook that derives a field the user never submitted
|
|
250
|
+
// must not have that field field-ownership-checked against the user —
|
|
251
|
+
// only what the user actually wrote in `payload.changes` is checked.
|
|
252
|
+
test("create: preSave-derived `note` (not submitted by the user) does not trigger ownership_denied", async () => {
|
|
253
|
+
const result = await crud.create({ authorId: TestUsers.driver.id }, nonAdmin, tdb, {
|
|
254
|
+
preSave: async (changes) => ({ ...changes, note: "hook-derived" }),
|
|
255
|
+
});
|
|
256
|
+
expect(result.isSuccess).toBe(true);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("update: preSave-derived `note` (not submitted by the user) does not trigger ownership_denied", async () => {
|
|
260
|
+
const created = await crud.create(
|
|
261
|
+
{ authorId: TestUsers.driver.id, note: "original" },
|
|
262
|
+
admin,
|
|
263
|
+
tdb,
|
|
264
|
+
);
|
|
265
|
+
if (!created.isSuccess) throw new Error("setup failed");
|
|
266
|
+
|
|
267
|
+
const result = await crud.update(
|
|
268
|
+
{ id: created.data.id, version: 1, changes: {} },
|
|
269
|
+
nonAdmin,
|
|
270
|
+
tdb,
|
|
271
|
+
{ preSave: async (changes) => ({ ...changes, note: "hook-derived" }) },
|
|
272
|
+
);
|
|
273
|
+
expect(result.isSuccess).toBe(true);
|
|
274
|
+
});
|
|
248
275
|
});
|
|
249
276
|
|
|
250
277
|
// =============================================================================
|
|
@@ -38,6 +38,10 @@ describe("splitSqlStatements", () => {
|
|
|
38
38
|
expect(splitSqlStatements("SELECT a/*x*/AS b;")).toEqual(["SELECT a AS b;"]);
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
+
test("nested block comment also leaves a space at outer depth (#1599)", () => {
|
|
42
|
+
expect(splitSqlStatements("SELECT a/*x/*y*/z*/AS b;")).toEqual(["SELECT a AS b;"]);
|
|
43
|
+
});
|
|
44
|
+
|
|
41
45
|
test("nested block comments close only at matching depth (Postgres)", () => {
|
|
42
46
|
expect(splitSqlStatements("/* a /* b */ c */ SELECT 1;")).toEqual(["SELECT 1;"]);
|
|
43
47
|
});
|
package/src/db/eagerload.ts
CHANGED
|
@@ -95,25 +95,80 @@ export function collectReferenceFields(entity: EntityDefinition): readonly Refer
|
|
|
95
95
|
// event-store-executor-context's decryptForRead ordering: PII is the outer
|
|
96
96
|
// layer, peel it before the envelope-encrypted fields, or the envelope
|
|
97
97
|
// cipher chokes on a still-PII-wrapped string.
|
|
98
|
+
//
|
|
99
|
+
// Ownership guard (fw#1671): the ref lookup below is tenant-scoped only, not
|
|
100
|
+
// ownership-scoped — this function has no SessionUser to evaluate
|
|
101
|
+
// refEntity.access.read against. If the ref entity declares row-level
|
|
102
|
+
// ownership at all, decrypting here would hand a same-tenant User A the
|
|
103
|
+
// plaintext PII of a User B row they merely reference (e.g. a freely-settable
|
|
104
|
+
// reference UUID), even though refEntity.access.read says "own" — the caller
|
|
105
|
+
// never gets to run that ownership check. Fail closed: strip PII/encrypted
|
|
106
|
+
// fields entirely instead of decrypting (or leaking ciphertext) when that
|
|
107
|
+
// guarantee can't be evaluated here.
|
|
108
|
+
function hasOwnershipScopedRead(refEntity: EntityDefinition): boolean {
|
|
109
|
+
const readMap = refEntity.access?.read;
|
|
110
|
+
if (readMap === undefined) return false;
|
|
111
|
+
// "all" means that role sees every row unrestricted — a map where every
|
|
112
|
+
// rule is "all" carries no ownership restriction at all, so stripping
|
|
113
|
+
// here would just silently drop PII/encrypted fields #1667 wants
|
|
114
|
+
// decrypted, for no security benefit.
|
|
115
|
+
return Object.values(readMap).some((rule) => rule !== "all");
|
|
116
|
+
}
|
|
117
|
+
|
|
98
118
|
async function decryptReferencedRow(
|
|
99
119
|
row: Record<string, unknown>,
|
|
100
120
|
refEntity: EntityDefinition,
|
|
101
121
|
): Promise<Record<string, unknown>> {
|
|
102
|
-
let out = row;
|
|
103
122
|
const piiFields = collectPiiSubjectFields(refEntity);
|
|
123
|
+
const encryptedFields = collectEncryptedFieldNames(refEntity);
|
|
124
|
+
if (hasOwnershipScopedRead(refEntity)) {
|
|
125
|
+
if (piiFields.length === 0 && encryptedFields.size === 0) return row;
|
|
126
|
+
const out = { ...row };
|
|
127
|
+
for (const field of piiFields) delete out[field];
|
|
128
|
+
for (const field of encryptedFields) delete out[field];
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let out = row;
|
|
104
133
|
const kms = configuredPiiSubjectKms();
|
|
105
134
|
if (piiFields.length > 0 && kms) {
|
|
106
135
|
out = await decryptPiiFieldValues(out, piiFields, kms, {
|
|
107
136
|
requestId: requestContext.get()?.requestId ?? "eagerload",
|
|
108
137
|
});
|
|
109
138
|
}
|
|
110
|
-
const encryptedFields = collectEncryptedFieldNames(refEntity);
|
|
111
139
|
if (encryptedFields.size > 0) {
|
|
112
140
|
out = await decryptEntityFieldValues(out, encryptedFields, resolveEntityFieldEncryption());
|
|
113
141
|
}
|
|
114
142
|
return out;
|
|
115
143
|
}
|
|
116
144
|
|
|
145
|
+
// Per-row, not Promise.all: a single legacy/backfilled row without a valid
|
|
146
|
+
// envelope (decryptEntityFieldValues throws hard on malformed ciphertext)
|
|
147
|
+
// must not 500 the whole list request — the main rows the caller asked for
|
|
148
|
+
// are unrelated to this one broken reference. Drop just that row from the
|
|
149
|
+
// map; the renderer falls back to the raw UUID.
|
|
150
|
+
async function buildRefLookupMap(
|
|
151
|
+
rawRefRows: ReadonlyArray<Record<string, unknown>>,
|
|
152
|
+
refEntity: EntityDefinition,
|
|
153
|
+
refEntityName: string,
|
|
154
|
+
): Promise<Map<string, Record<string, unknown>>> {
|
|
155
|
+
const map = new Map<string, Record<string, unknown>>();
|
|
156
|
+
for (const r of rawRefRows) {
|
|
157
|
+
let decrypted: Record<string, unknown>;
|
|
158
|
+
try {
|
|
159
|
+
decrypted = await decryptReferencedRow(r, refEntity);
|
|
160
|
+
} catch (e) {
|
|
161
|
+
console.warn(
|
|
162
|
+
`[eagerload] failed to decrypt referenced row entity=${refEntityName} id=${String(r["id"])}: ${e instanceof Error ? e.message : String(e)}`,
|
|
163
|
+
);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const id = decrypted["id"];
|
|
167
|
+
if (typeof id === "string") map.set(id, decrypted);
|
|
168
|
+
}
|
|
169
|
+
return map;
|
|
170
|
+
}
|
|
171
|
+
|
|
117
172
|
/** Eagerload für eine Liste von Rows. Mutiert nicht — gibt eine
|
|
118
173
|
* flache Kopie der Rows mit hinzugefügtem `_refs`-Property zurück. */
|
|
119
174
|
export async function enrichWithReferences(
|
|
@@ -159,12 +214,7 @@ export async function enrichWithReferences(
|
|
|
159
214
|
const rawRefRows = (await selectMany(db, refTable, { id: idArray })) as Array<
|
|
160
215
|
Record<string, unknown>
|
|
161
216
|
>;
|
|
162
|
-
const
|
|
163
|
-
const map = new Map<string, Record<string, unknown>>();
|
|
164
|
-
for (const r of refRows) {
|
|
165
|
-
const id = r["id"];
|
|
166
|
-
if (typeof id === "string") map.set(id, r);
|
|
167
|
-
}
|
|
217
|
+
const map = await buildRefLookupMap(rawRefRows, refEntity, rf.refEntityName);
|
|
168
218
|
return { fieldName: rf.fieldName, multiple: rf.multiple, map };
|
|
169
219
|
}),
|
|
170
220
|
);
|
|
@@ -48,6 +48,40 @@ function isUnchangedValue(a: unknown, b: unknown): boolean {
|
|
|
48
48
|
return JSON.stringify(a) === JSON.stringify(b);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
type PreSaveFn = (
|
|
52
|
+
changes: Record<string, unknown>,
|
|
53
|
+
previous: Record<string, unknown>,
|
|
54
|
+
isNew: boolean,
|
|
55
|
+
) => Promise<Record<string, unknown>>;
|
|
56
|
+
|
|
57
|
+
// preSave runs before ownership checks: authorization must evaluate the row
|
|
58
|
+
// as it will actually be persisted, including hook-derived fields
|
|
59
|
+
// (kumiko-framework#1672). A throwing hook is an app-author bug (bad
|
|
60
|
+
// business rule, not a framework fault) — map it to a clean writeFailure
|
|
61
|
+
// instead of letting it propagate as an internal_error 500.
|
|
62
|
+
async function runPreSave(
|
|
63
|
+
preSave: PreSaveFn | undefined,
|
|
64
|
+
changes: Record<string, unknown>,
|
|
65
|
+
previous: Record<string, unknown>,
|
|
66
|
+
isNew: boolean,
|
|
67
|
+
entityName: string,
|
|
68
|
+
action: "create" | "update",
|
|
69
|
+
): Promise<{ readonly data: DbRow } | { readonly failure: ReturnType<typeof writeFailure> }> {
|
|
70
|
+
if (!preSave) return { data: changes as DbRow };
|
|
71
|
+
try {
|
|
72
|
+
return { data: (await preSave(changes, previous, isNew)) as DbRow };
|
|
73
|
+
} catch (e) {
|
|
74
|
+
return {
|
|
75
|
+
failure: writeFailure(
|
|
76
|
+
new UnprocessableError("presave_hook_failed", {
|
|
77
|
+
i18nKey: "errors.presaveHookFailed",
|
|
78
|
+
details: { entityName, action, message: e instanceof Error ? e.message : String(e) },
|
|
79
|
+
}),
|
|
80
|
+
),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
51
85
|
export function createWriteVerbs(
|
|
52
86
|
ctx: ExecutorContext,
|
|
53
87
|
): Pick<EventStoreExecutor, "create" | "update" | "delete" | "forget" | "restore"> {
|
|
@@ -74,12 +108,16 @@ export function createWriteVerbs(
|
|
|
74
108
|
const explicitId = typeof payload["id"] === "string" ? (payload["id"] as string) : undefined; // @cast-boundary engine-payload
|
|
75
109
|
const aggregateId = explicitId ?? generateId();
|
|
76
110
|
const { id: _id, ...payloadWithoutId } = payload;
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
111
|
+
const preSaveResult = await runPreSave(
|
|
112
|
+
options?.preSave,
|
|
113
|
+
applyDefaults(payloadWithoutId),
|
|
114
|
+
{},
|
|
115
|
+
true,
|
|
116
|
+
entityName,
|
|
117
|
+
"create",
|
|
118
|
+
);
|
|
119
|
+
if ("failure" in preSaveResult) return preSaveResult.failure;
|
|
120
|
+
const data = preSaveResult.data;
|
|
83
121
|
|
|
84
122
|
// H.2 — entity-level write-ownership on create. No oldRow exists, so
|
|
85
123
|
// only the new row is checked. No Straddle concern for creates.
|
|
@@ -95,7 +133,20 @@ export function createWriteVerbs(
|
|
|
95
133
|
// Field-level write-ownership on create — mirror of entity-level but
|
|
96
134
|
// per declared field. Role-level was already checked by the
|
|
97
135
|
// dispatcher; here we enforce ownership-rules against the new row.
|
|
98
|
-
|
|
136
|
+
//
|
|
137
|
+
// Which fields get checked is scoped to the pre-hook payload (fw#1685)
|
|
138
|
+
// — a hook-derived field the user never submitted must not be
|
|
139
|
+
// field-ownership-checked against the user. The rule for a checked
|
|
140
|
+
// field is still evaluated against the full post-hook row (`data`) —
|
|
141
|
+
// an ownership rule can reference a column only a hook populates
|
|
142
|
+
// (kumiko-framework#1672).
|
|
143
|
+
const fieldDeniedCreate = checkWriteFieldOwnership(
|
|
144
|
+
entity,
|
|
145
|
+
applyDefaults(payloadWithoutId),
|
|
146
|
+
user,
|
|
147
|
+
undefined,
|
|
148
|
+
data,
|
|
149
|
+
);
|
|
99
150
|
if (fieldDeniedCreate) {
|
|
100
151
|
return writeFailure(
|
|
101
152
|
new UnprocessableError("ownership_denied", {
|
|
@@ -226,12 +277,16 @@ export function createWriteVerbs(
|
|
|
226
277
|
const previous = await loadById(payload.id, db);
|
|
227
278
|
if (!previous) return writeFailure(new NotFoundError(entityName, payload.id));
|
|
228
279
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
280
|
+
const preSaveResult = await runPreSave(
|
|
281
|
+
updateOptions?.preSave,
|
|
282
|
+
payload.changes,
|
|
283
|
+
previous,
|
|
284
|
+
false,
|
|
285
|
+
entityName,
|
|
286
|
+
"update",
|
|
287
|
+
);
|
|
288
|
+
if ("failure" in preSaveResult) return preSaveResult.failure;
|
|
289
|
+
const changes = preSaveResult.data;
|
|
235
290
|
|
|
236
291
|
// H.2 — entity-level write-ownership on update. Load old row (already
|
|
237
292
|
// done above), build post-change row via shallow merge. Straddle-safe
|
|
@@ -259,7 +314,24 @@ export function createWriteVerbs(
|
|
|
259
314
|
// `previous`, we can run the ownership rules per field against both
|
|
260
315
|
// sides and reject individual fields the user isn't entitled to
|
|
261
316
|
// touch on this specific row.
|
|
262
|
-
|
|
317
|
+
//
|
|
318
|
+
// Which fields get checked is scoped to `payload.changes` (the user's
|
|
319
|
+
// actual submission), NOT `changes` (post-preSave-hook data, fw#1685)
|
|
320
|
+
// — a hook-derived field the user never submitted (e.g. a system hook
|
|
321
|
+
// setting `assignedTo`) has no business being field-ownership-checked
|
|
322
|
+
// against the *user*, and doing so rejects writes the user is fully
|
|
323
|
+
// entitled to make. The dispatcher's role-gate (checkWriteFieldAccess)
|
|
324
|
+
// already runs on this same pre-hook payload for consistency. The rule
|
|
325
|
+
// for a checked field is still evaluated against the full post-hook
|
|
326
|
+
// row (`changes` merged onto `previous`) — an ownership rule can
|
|
327
|
+
// reference a column only a hook populates (kumiko-framework#1672).
|
|
328
|
+
const fieldDeniedUpdate = checkWriteFieldOwnership(
|
|
329
|
+
entity,
|
|
330
|
+
payload.changes,
|
|
331
|
+
user,
|
|
332
|
+
previous,
|
|
333
|
+
changes,
|
|
334
|
+
);
|
|
263
335
|
if (fieldDeniedUpdate) {
|
|
264
336
|
return writeFailure(
|
|
265
337
|
new UnprocessableError("ownership_denied", {
|