@cosmicdrift/kumiko-framework 0.173.1 → 0.174.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/api/__tests__/api.test.ts +11 -2
- 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 +7 -3
- package/src/crypto/__tests__/kms-wiring.test.ts +6 -0
- package/src/crypto/ciphertext-pattern.ts +19 -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 +45 -0
- package/src/db/__tests__/migrate-runner.test.ts +16 -0
- package/src/db/blind-index-cleanup.ts +15 -24
- package/src/db/eagerload.ts +75 -9
- package/src/db/entity-table-meta.ts +6 -1
- package/src/db/event-store-executor-write.ts +38 -3
- package/src/db/migrate-runner.ts +5 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +19 -0
- package/src/engine/__tests__/boot-validator.test.ts +51 -1
- package/src/engine/__tests__/ownership.test.ts +23 -0
- package/src/engine/__tests__/schema-builder.test.ts +76 -6
- package/src/engine/boot-validator/access-roles.ts +63 -21
- package/src/engine/boot-validator/entity-handler.ts +4 -0
- package/src/engine/boot-validator/index.ts +17 -2
- package/src/engine/boot-validator/pii-retention.ts +17 -10
- package/src/engine/boot-validator/screens.ts +10 -2
- package/src/engine/boot-validator.ts +1 -0
- package/src/engine/create-app.ts +4 -2
- package/src/engine/field-access.ts +17 -3
- package/src/engine/index.ts +2 -0
- package/src/engine/ownership.ts +19 -0
- package/src/engine/schema-builder.ts +46 -22
- package/src/entrypoint/index.ts +2 -5
- package/src/jobs/__tests__/scheduler-id.test.ts +13 -1
- package/src/jobs/job-runner.ts +7 -1
- package/src/pipeline/dispatch-shared.ts +11 -3
- package/src/pipeline/dispatch-stream.ts +3 -6
- package/src/pipeline/system-hooks.ts +20 -7
- package/src/schema-cli.ts +7 -5
- package/src/search/__tests__/reindex-entity.integration.test.ts +97 -1
- package/src/search/purge-subject.ts +2 -9
- package/src/search/reindex-entity.ts +15 -2
- package/src/secrets/derive-purpose-secret.ts +6 -16
- package/src/testing/__tests__/e2e-generator.test.ts +7 -0
- package/src/testing/__tests__/wait-for.test.ts +2 -2
- package/src/testing/e2e-generator.ts +5 -0
- package/src/testing/shared-entities.ts +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.174.1",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -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.1",
|
|
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.1",
|
|
202
202
|
"bun-types": "^1.3.13",
|
|
203
203
|
"pino-pretty": "^13.1.3"
|
|
204
204
|
},
|
|
@@ -564,9 +564,18 @@ describe("POST /api/stream pre-pull race", () => {
|
|
|
564
564
|
// in-flight .next(), so cleanup only runs once the pending pull settles.
|
|
565
565
|
// Abort before heartbeatMs so the route hits the 499 branch; sleep then
|
|
566
566
|
// completes and the queued return drains the generator's finally.
|
|
567
|
+
//
|
|
568
|
+
// Abort is triggered off an entry signal, not a fixed sleep margin against
|
|
569
|
+
// the heartbeat timer — a stalled event loop could otherwise let the
|
|
570
|
+
// heartbeat fire first and flip the route onto the 200-SSE branch.
|
|
567
571
|
let cleanedUp = false;
|
|
572
|
+
let entered: () => void;
|
|
573
|
+
const atEntry = new Promise<void>((resolve) => {
|
|
574
|
+
entered = resolve;
|
|
575
|
+
});
|
|
568
576
|
const dispatcher = stubDispatcher(async function* () {
|
|
569
577
|
try {
|
|
578
|
+
entered();
|
|
570
579
|
await Bun.sleep(80);
|
|
571
580
|
yield { i: 0 };
|
|
572
581
|
} finally {
|
|
@@ -583,8 +592,8 @@ describe("POST /api/stream pre-pull race", () => {
|
|
|
583
592
|
signal: ac.signal,
|
|
584
593
|
}),
|
|
585
594
|
);
|
|
586
|
-
// Abort
|
|
587
|
-
await
|
|
595
|
+
// Abort as soon as the generator has been entered — no timing window left.
|
|
596
|
+
await atEntry;
|
|
588
597
|
ac.abort();
|
|
589
598
|
const res = await pending;
|
|
590
599
|
expect(res.status).toBe(499);
|
|
@@ -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,9 +19,13 @@ 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 {
|
|
@@ -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", () => {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Shared SQL helpers for locating a subject's PII ciphertext by its inline
|
|
2
|
+
// subject-key prefix (kumiko-pii:v<version>:<subjectKey>:...). Used by both
|
|
3
|
+
// the blind-index sweep (db/blind-index-cleanup.ts) and the search-index
|
|
4
|
+
// purge (search/purge-subject.ts) — the two sweeps must stay in lockstep
|
|
5
|
+
// across ciphertext format versions.
|
|
6
|
+
|
|
7
|
+
export function quoteIdent(name: string): string {
|
|
8
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function escapeLikePattern(value: string): string {
|
|
12
|
+
return value.replace(/[\\%_]/g, (m) => `\\${m}`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// "v%" matches any format version (v1 no-AAD, v2 AAD-bound, #1263) — the
|
|
16
|
+
// subject key placement is stable across versions.
|
|
17
|
+
export function subjectCiphertextLikePattern(subjectKey: string): string {
|
|
18
|
+
return `kumiko-pii:v%:${escapeLikePattern(subjectKey)}:%`;
|
|
19
|
+
}
|
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,51 @@ 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
|
+
});
|
|
275
|
+
|
|
276
|
+
// Review-fix (kumiko-framework#1685): a preSave hook that echoes `id`/
|
|
277
|
+
// `version` back in its return value must not have those leak into the
|
|
278
|
+
// persisted row — the framework-minted aggregateId stays authoritative.
|
|
279
|
+
test("create: preSave hook returning `id`/`version` does not override the minted aggregateId", async () => {
|
|
280
|
+
const result = await crud.create({ authorId: nonAdmin.id, note: "mine" }, nonAdmin, tdb, {
|
|
281
|
+
preSave: async (changes) => ({ ...changes, id: "hook-injected-id", version: 999 }),
|
|
282
|
+
});
|
|
283
|
+
expect(result.isSuccess).toBe(true);
|
|
284
|
+
if (!result.isSuccess) return;
|
|
285
|
+
expect(result.data.id).not.toBe("hook-injected-id");
|
|
286
|
+
|
|
287
|
+
const row = await asRawClient(testDb.db).unsafe(
|
|
288
|
+
`SELECT id FROM read_es_write_owned_field WHERE id = $1`,
|
|
289
|
+
[result.data.id],
|
|
290
|
+
);
|
|
291
|
+
expect(row.length).toBe(1);
|
|
292
|
+
});
|
|
248
293
|
});
|
|
249
294
|
|
|
250
295
|
// =============================================================================
|
|
@@ -99,4 +99,20 @@ describe("splitSqlStatements", () => {
|
|
|
99
99
|
'CREATE TABLE "a" ("id" uuid);',
|
|
100
100
|
]);
|
|
101
101
|
});
|
|
102
|
+
|
|
103
|
+
test("throws fail-loud on a dollar-quoted body instead of splitting it in half", () => {
|
|
104
|
+
expect(() => splitSqlStatements("DO $$ BEGIN PERFORM 1; END $$;")).toThrow(
|
|
105
|
+
/unsupported dollar-quoted body/,
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("throws fail-loud on a tagged dollar-quoted body ($tag$...$tag$)", () => {
|
|
110
|
+
expect(() => splitSqlStatements("DO $tag$ BEGIN PERFORM 1; END $tag$;")).toThrow(
|
|
111
|
+
/unsupported dollar-quoted body/,
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("a bare $ not opening a dollar-tag does not false-positive (digit after $ is not a tag)", () => {
|
|
116
|
+
expect(splitSqlStatements("SELECT $1;")).toEqual(["SELECT $1;"]);
|
|
117
|
+
});
|
|
102
118
|
});
|
|
@@ -1,46 +1,37 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Immediate blind-index nulling after a subject erase (#818).
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// bidx
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// (kumiko-pii:v1:<subjectKey>:...),
|
|
8
|
-
//
|
|
3
|
+
// After kms.eraseKey the ciphertext is unreadable, but the deterministic
|
|
4
|
+
// bidx column would stay matchable until the next write/rebuild — a
|
|
5
|
+
// linkage window ("does any row hold value X"). This sweep closes it right
|
|
6
|
+
// away: the ciphertext names its subject inline
|
|
7
|
+
// (kumiko-pii:v1:<subjectKey>:...), so a LIKE-prefix match finds exactly
|
|
8
|
+
// the erased subject's rows — one UPDATE per lookupable field.
|
|
9
9
|
//
|
|
10
|
-
// Rows
|
|
11
|
-
//
|
|
12
|
-
//
|
|
10
|
+
// Rows the forget run deletes/anonymizes via the executor anyway get their
|
|
11
|
+
// bidx recomputed automatically there; this sweep covers the rows left
|
|
12
|
+
// behind (foreign entities with userOwned fields).
|
|
13
13
|
|
|
14
14
|
import { collectLookupableFields } from "../crypto/blind-index";
|
|
15
|
+
import { quoteIdent, subjectCiphertextLikePattern } from "../crypto/ciphertext-pattern";
|
|
15
16
|
import type { FeatureDefinition } from "../engine/types";
|
|
16
17
|
import { toSnakeCase } from "../utils/case";
|
|
17
18
|
import type { DbRunner } from "./connection";
|
|
18
19
|
import { resolveTableName } from "./entity-table-meta";
|
|
19
20
|
import { executeRawQuery } from "./queries/raw-sql";
|
|
20
21
|
|
|
21
|
-
function quoteIdent(name: string): string {
|
|
22
|
-
return `"${name.replace(/"/g, '""')}"`;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function escapeLikePattern(value: string): string {
|
|
26
|
-
return value.replace(/[\\%_]/g, (m) => `\\${m}`);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
22
|
export async function nullBlindIndexesForSubject(
|
|
30
23
|
db: DbRunner,
|
|
31
24
|
features: ReadonlyMap<string, FeatureDefinition>,
|
|
32
25
|
subjectKey: string,
|
|
33
26
|
): Promise<void> {
|
|
34
|
-
|
|
35
|
-
// subject key placement is stable across versions.
|
|
36
|
-
const likePattern = `kumiko-pii:v%:${escapeLikePattern(subjectKey)}:%`;
|
|
27
|
+
const likePattern = subjectCiphertextLikePattern(subjectKey);
|
|
37
28
|
for (const feature of features.values()) {
|
|
38
29
|
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
39
30
|
const lookupable = collectLookupableFields(entity);
|
|
40
31
|
if (lookupable.length === 0) continue;
|
|
41
|
-
//
|
|
42
|
-
// (buildEntityTable
|
|
43
|
-
//
|
|
32
|
+
// No featureName prefix — the dispatcher builds entity tables without
|
|
33
|
+
// one (buildEntityTable with no featureName option), the sweep has to
|
|
34
|
+
// hit the same names.
|
|
44
35
|
const tableName = resolveTableName(entityName, entity, undefined);
|
|
45
36
|
for (const fieldName of lookupable) {
|
|
46
37
|
const snake = toSnakeCase(fieldName);
|
package/src/db/eagerload.ts
CHANGED
|
@@ -95,25 +95,86 @@ 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,
|
|
121
|
+
piiFields: readonly string[],
|
|
122
|
+
encryptedFields: ReadonlySet<string>,
|
|
123
|
+
kms: ReturnType<typeof configuredPiiSubjectKms>,
|
|
101
124
|
): Promise<Record<string, unknown>> {
|
|
125
|
+
if (hasOwnershipScopedRead(refEntity)) {
|
|
126
|
+
if (piiFields.length === 0 && encryptedFields.size === 0) return row;
|
|
127
|
+
const out = { ...row };
|
|
128
|
+
for (const field of piiFields) delete out[field];
|
|
129
|
+
for (const field of encryptedFields) delete out[field];
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
102
133
|
let out = row;
|
|
103
|
-
const piiFields = collectPiiSubjectFields(refEntity);
|
|
104
|
-
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
|
+
//
|
|
151
|
+
// piiFields/encryptedFields/kms are constant per refEntity (fw#1671) — the
|
|
152
|
+
// caller computes them once and passes them in instead of recomputing per row.
|
|
153
|
+
async function buildRefLookupMap(
|
|
154
|
+
rawRefRows: ReadonlyArray<Record<string, unknown>>,
|
|
155
|
+
refEntity: EntityDefinition,
|
|
156
|
+
refEntityName: string,
|
|
157
|
+
piiFields: readonly string[],
|
|
158
|
+
encryptedFields: ReadonlySet<string>,
|
|
159
|
+
kms: ReturnType<typeof configuredPiiSubjectKms>,
|
|
160
|
+
): Promise<Map<string, Record<string, unknown>>> {
|
|
161
|
+
const map = new Map<string, Record<string, unknown>>();
|
|
162
|
+
for (const r of rawRefRows) {
|
|
163
|
+
let decrypted: Record<string, unknown>;
|
|
164
|
+
try {
|
|
165
|
+
decrypted = await decryptReferencedRow(r, refEntity, piiFields, encryptedFields, kms);
|
|
166
|
+
} catch (e) {
|
|
167
|
+
console.warn(
|
|
168
|
+
`[eagerload] failed to decrypt referenced row entity=${refEntityName} id=${String(r["id"])}: ${e instanceof Error ? e.message : String(e)}`,
|
|
169
|
+
);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const id = decrypted["id"];
|
|
173
|
+
if (typeof id === "string") map.set(id, decrypted);
|
|
174
|
+
}
|
|
175
|
+
return map;
|
|
176
|
+
}
|
|
177
|
+
|
|
117
178
|
/** Eagerload für eine Liste von Rows. Mutiert nicht — gibt eine
|
|
118
179
|
* flache Kopie der Rows mit hinzugefügtem `_refs`-Property zurück. */
|
|
119
180
|
export async function enrichWithReferences(
|
|
@@ -159,12 +220,17 @@ export async function enrichWithReferences(
|
|
|
159
220
|
const rawRefRows = (await selectMany(db, refTable, { id: idArray })) as Array<
|
|
160
221
|
Record<string, unknown>
|
|
161
222
|
>;
|
|
162
|
-
const
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
223
|
+
const piiFields = collectPiiSubjectFields(refEntity);
|
|
224
|
+
const encryptedFields = collectEncryptedFieldNames(refEntity);
|
|
225
|
+
const kms = configuredPiiSubjectKms();
|
|
226
|
+
const map = await buildRefLookupMap(
|
|
227
|
+
rawRefRows,
|
|
228
|
+
refEntity,
|
|
229
|
+
rf.refEntityName,
|
|
230
|
+
piiFields,
|
|
231
|
+
encryptedFields,
|
|
232
|
+
kms,
|
|
233
|
+
);
|
|
168
234
|
return { fieldName: rf.fieldName, multiple: rf.multiple, map };
|
|
169
235
|
}),
|
|
170
236
|
);
|