@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
|
@@ -247,7 +247,7 @@ export function deriveEntityTableMeta(
|
|
|
247
247
|
const tableName = resolveTableName(entityName, entity, options?.featureName);
|
|
248
248
|
const source = options?.source ?? "managed";
|
|
249
249
|
if (source === "unmanaged") {
|
|
250
|
-
assertUnmanagedTableName(tableName, "deriveEntityTableMeta");
|
|
250
|
+
assertUnmanagedTableName(tableName, "deriveEntityTableMeta/buildEntityTableMeta");
|
|
251
251
|
}
|
|
252
252
|
const idType = entity.idType ?? "uuid";
|
|
253
253
|
|
|
@@ -424,6 +424,11 @@ function columnsByNameMeta(meta: EntityTableMeta): Map<string, ColumnMeta> {
|
|
|
424
424
|
/**
|
|
425
425
|
* Hand-built EntityTableMeta for direct-write stores (no entity base columns).
|
|
426
426
|
* Prefer a `store_*` table name; `read_` is reserved for managed projections (#1220).
|
|
427
|
+
*
|
|
428
|
+
* Escape hatch, not a shortcut: no audit trail, no automatic tenant_id index,
|
|
429
|
+
* no softDelete — the app author owns tenant-scoping and retention for this
|
|
430
|
+
* table. Justify WHY in the call site; reviewers should scrutinize every new
|
|
431
|
+
* unmanaged table.
|
|
427
432
|
*/
|
|
428
433
|
export function defineUnmanagedTable(input: UnmanagedTableInput): EntityTableMeta {
|
|
429
434
|
assertUnmanagedTableName(input.tableName, "defineUnmanagedTable");
|
|
@@ -69,7 +69,12 @@ async function runPreSave(
|
|
|
69
69
|
): Promise<{ readonly data: DbRow } | { readonly failure: ReturnType<typeof writeFailure> }> {
|
|
70
70
|
if (!preSave) return { data: changes as DbRow };
|
|
71
71
|
try {
|
|
72
|
-
|
|
72
|
+
const hookResult = await preSave(changes, previous, isNew);
|
|
73
|
+
// A hook that echoes `id`/`version` back (e.g. `{ ...changes, id: x }`)
|
|
74
|
+
// must not leak them into the persisted row — aggregateId already comes
|
|
75
|
+
// from generateId()/the loaded row, not from hook output (fw#1685).
|
|
76
|
+
const { id: _hookId, version: _hookVersion, ...safe } = hookResult as Record<string, unknown>;
|
|
77
|
+
return { data: safe as DbRow };
|
|
73
78
|
} catch (e) {
|
|
74
79
|
return {
|
|
75
80
|
failure: writeFailure(
|
|
@@ -133,7 +138,20 @@ export function createWriteVerbs(
|
|
|
133
138
|
// Field-level write-ownership on create — mirror of entity-level but
|
|
134
139
|
// per declared field. Role-level was already checked by the
|
|
135
140
|
// dispatcher; here we enforce ownership-rules against the new row.
|
|
136
|
-
|
|
141
|
+
//
|
|
142
|
+
// Which fields get checked is scoped to the pre-hook payload (fw#1685)
|
|
143
|
+
// — a hook-derived field the user never submitted must not be
|
|
144
|
+
// field-ownership-checked against the user. The rule for a checked
|
|
145
|
+
// field is still evaluated against the full post-hook row (`data`) —
|
|
146
|
+
// an ownership rule can reference a column only a hook populates
|
|
147
|
+
// (kumiko-framework#1672).
|
|
148
|
+
const fieldDeniedCreate = checkWriteFieldOwnership(
|
|
149
|
+
entity,
|
|
150
|
+
applyDefaults(payloadWithoutId),
|
|
151
|
+
user,
|
|
152
|
+
undefined,
|
|
153
|
+
data,
|
|
154
|
+
);
|
|
137
155
|
if (fieldDeniedCreate) {
|
|
138
156
|
return writeFailure(
|
|
139
157
|
new UnprocessableError("ownership_denied", {
|
|
@@ -301,7 +319,24 @@ export function createWriteVerbs(
|
|
|
301
319
|
// `previous`, we can run the ownership rules per field against both
|
|
302
320
|
// sides and reject individual fields the user isn't entitled to
|
|
303
321
|
// touch on this specific row.
|
|
304
|
-
|
|
322
|
+
//
|
|
323
|
+
// Which fields get checked is scoped to `payload.changes` (the user's
|
|
324
|
+
// actual submission), NOT `changes` (post-preSave-hook data, fw#1685)
|
|
325
|
+
// — a hook-derived field the user never submitted (e.g. a system hook
|
|
326
|
+
// setting `assignedTo`) has no business being field-ownership-checked
|
|
327
|
+
// against the *user*, and doing so rejects writes the user is fully
|
|
328
|
+
// entitled to make. The dispatcher's role-gate (checkWriteFieldAccess)
|
|
329
|
+
// already runs on this same pre-hook payload for consistency. The rule
|
|
330
|
+
// for a checked field is still evaluated against the full post-hook
|
|
331
|
+
// row (`changes` merged onto `previous`) — an ownership rule can
|
|
332
|
+
// reference a column only a hook populates (kumiko-framework#1672).
|
|
333
|
+
const fieldDeniedUpdate = checkWriteFieldOwnership(
|
|
334
|
+
entity,
|
|
335
|
+
payload.changes,
|
|
336
|
+
user,
|
|
337
|
+
previous,
|
|
338
|
+
changes,
|
|
339
|
+
);
|
|
305
340
|
if (fieldDeniedUpdate) {
|
|
306
341
|
return writeFailure(
|
|
307
342
|
new UnprocessableError("ownership_denied", {
|
package/src/db/migrate-runner.ts
CHANGED
|
@@ -160,6 +160,11 @@ export function splitSqlStatements(sqlText: string): readonly string[] {
|
|
|
160
160
|
current += ch;
|
|
161
161
|
continue;
|
|
162
162
|
}
|
|
163
|
+
if (ch === "$" && /^\$([A-Za-z_]\w*)?\$/.test(sqlText.slice(i))) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
"splitSqlStatements: unsupported dollar-quoted body — migration SQL is malformed, refusing to split",
|
|
166
|
+
);
|
|
167
|
+
}
|
|
163
168
|
if (ch === ";") {
|
|
164
169
|
statements.push(current);
|
|
165
170
|
current = "";
|
|
@@ -626,6 +626,25 @@ describe("validateBoot — retention", () => {
|
|
|
626
626
|
expect(matchingWarn).toBeUndefined();
|
|
627
627
|
});
|
|
628
628
|
|
|
629
|
+
test("blockDelete with only a subjectRef-only field and no anonymize warns (#1645)", () => {
|
|
630
|
+
const feature = defineFeature("test", (r) => {
|
|
631
|
+
r.entity(
|
|
632
|
+
"lease",
|
|
633
|
+
createEntity({
|
|
634
|
+
fields: {
|
|
635
|
+
authorId: createTextField({ subjectRef: true }),
|
|
636
|
+
},
|
|
637
|
+
retention: { keepFor: "10y", strategy: "blockDelete" },
|
|
638
|
+
}),
|
|
639
|
+
);
|
|
640
|
+
});
|
|
641
|
+
validateBoot([feature]);
|
|
642
|
+
const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
|
|
643
|
+
String(args[0]).includes('strategy="blockDelete" but no field has an anonymize-function'),
|
|
644
|
+
);
|
|
645
|
+
expect(matchingWarn).toBeDefined();
|
|
646
|
+
});
|
|
647
|
+
|
|
629
648
|
test('retention.keepFor with invalid format "30days" warns', () => {
|
|
630
649
|
const feature = defineFeature("test", (r) => {
|
|
631
650
|
r.entity(
|
|
@@ -600,7 +600,7 @@ describe("boot-validator", () => {
|
|
|
600
600
|
});
|
|
601
601
|
}),
|
|
602
602
|
];
|
|
603
|
-
|
|
603
|
+
validateBootRaw(withBootValidatorFixture(features), { warnOnUniqueAccessRoles: true });
|
|
604
604
|
// Not toHaveBeenCalledTimes(1): this file's tests share the process-global
|
|
605
605
|
// console.warn and run with the default concurrency (bunfig.toml) — other
|
|
606
606
|
// concurrently-running tests' own "role used by one handler" warnings can
|
|
@@ -615,6 +615,27 @@ describe("boot-validator", () => {
|
|
|
615
615
|
}
|
|
616
616
|
});
|
|
617
617
|
|
|
618
|
+
test("does NOT warn on unique access roles by default — opt-in only (#1711)", () => {
|
|
619
|
+
const warnSpy = spyOn(console, "warn");
|
|
620
|
+
try {
|
|
621
|
+
const features = [
|
|
622
|
+
defineFeature("b", (r) => {
|
|
623
|
+
r.queryHandler("list", z.object({}), async () => [], {
|
|
624
|
+
access: { roles: ["OnlyThereRole"] },
|
|
625
|
+
});
|
|
626
|
+
}),
|
|
627
|
+
];
|
|
628
|
+
validateBoot(features);
|
|
629
|
+
expect(
|
|
630
|
+
warnSpy.mock.calls.some((call) =>
|
|
631
|
+
(call[0] as string | undefined)?.includes("OnlyThereRole"),
|
|
632
|
+
),
|
|
633
|
+
).toBe(false);
|
|
634
|
+
} finally {
|
|
635
|
+
warnSpy.mockRestore();
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
|
|
618
639
|
test("throws when a stream handler has no access rule", () => {
|
|
619
640
|
const features = [
|
|
620
641
|
defineFeature("a", (r) => {
|
|
@@ -2886,6 +2907,35 @@ describe("boot-validator — config key backing × scope", () => {
|
|
|
2886
2907
|
);
|
|
2887
2908
|
});
|
|
2888
2909
|
|
|
2910
|
+
test("navigate with params targeting a cross-entity entityList screen → no throw (list screens read URL search params for filter-prefill, fw#1708)", () => {
|
|
2911
|
+
const feature = defineFeature("housing", (r) => {
|
|
2912
|
+
r.entity("unit", createEntity({ fields: { id: createTextField() } }));
|
|
2913
|
+
r.entity("contract", createEntity({ fields: { unitId: createTextField() } }));
|
|
2914
|
+
r.screen({
|
|
2915
|
+
id: "unit-list",
|
|
2916
|
+
type: "entityList",
|
|
2917
|
+
entity: "unit",
|
|
2918
|
+
columns: ["id"],
|
|
2919
|
+
rowActions: [
|
|
2920
|
+
{
|
|
2921
|
+
kind: "navigate",
|
|
2922
|
+
id: "view-contracts",
|
|
2923
|
+
label: "actions.viewContracts",
|
|
2924
|
+
screen: "contract-list",
|
|
2925
|
+
params: { map: { "housing:contract-list.f.unitId": "id" } },
|
|
2926
|
+
},
|
|
2927
|
+
],
|
|
2928
|
+
});
|
|
2929
|
+
r.screen({
|
|
2930
|
+
id: "contract-list",
|
|
2931
|
+
type: "entityList",
|
|
2932
|
+
entity: "contract",
|
|
2933
|
+
columns: ["unitId"],
|
|
2934
|
+
});
|
|
2935
|
+
});
|
|
2936
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
2937
|
+
});
|
|
2938
|
+
|
|
2889
2939
|
test("navigate with params targeting a custom screen → no throw (author owns the component, may read searchParams itself)", () => {
|
|
2890
2940
|
const feature = defineFeature("shop", (r) => {
|
|
2891
2941
|
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
@@ -195,6 +195,29 @@ describe("userCanReadFieldRow() — multi-role OR", () => {
|
|
|
195
195
|
// row with mismatched teamId — TeamMember would fail, Admin passes
|
|
196
196
|
expect(userCanReadFieldRow(user, accessMap, { teamId: "ops" })).toBe(true);
|
|
197
197
|
});
|
|
198
|
+
|
|
199
|
+
// fw#1700: matchesRule() throws on a where-rule (SQL-layer only, can't
|
|
200
|
+
// evaluate in-memory). Field-level access is boot-validator-rejected for
|
|
201
|
+
// where-rules, but this function is also reachable from hand-rolled
|
|
202
|
+
// entity-level reads — a role backed by a where-rule must fail closed
|
|
203
|
+
// (deny, no throw) instead of crashing the caller with an uncaught 500.
|
|
204
|
+
test("role backed by a where-rule → fails closed (deny), does not throw", () => {
|
|
205
|
+
const whereMap: OwnershipMap = {
|
|
206
|
+
Support: { kind: "where", where: () => ({ sqlText: "1=1", params: [] }) },
|
|
207
|
+
};
|
|
208
|
+
const user = mkUser({ roles: ["Support"] });
|
|
209
|
+
expect(() => userCanReadFieldRow(user, whereMap, { teamId: "ops" })).not.toThrow();
|
|
210
|
+
expect(userCanReadFieldRow(user, whereMap, { teamId: "ops" })).toBe(false);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("where-rule role does not block a later 'all' role in the same access map", () => {
|
|
214
|
+
const mixedMap: OwnershipMap = {
|
|
215
|
+
Support: { kind: "where", where: () => ({ sqlText: "1=1", params: [] }) },
|
|
216
|
+
Admin: "all",
|
|
217
|
+
};
|
|
218
|
+
const user = mkUser({ roles: ["Support", "Admin"] });
|
|
219
|
+
expect(userCanReadFieldRow(user, mixedMap, { teamId: "ops" })).toBe(true);
|
|
220
|
+
});
|
|
198
221
|
});
|
|
199
222
|
|
|
200
223
|
// --- userCanWriteFieldRow() — STRADDLE PREVENTION ---
|
|
@@ -131,6 +131,18 @@ describe("buildInsertSchema", () => {
|
|
|
131
131
|
valid: { age: 5 },
|
|
132
132
|
invalid: { age: 11 },
|
|
133
133
|
},
|
|
134
|
+
{
|
|
135
|
+
name: "integer field rejects a value outside Postgres int4 range (must 400, not crash the DB write)",
|
|
136
|
+
fields: { attempt: createNumberField({ integer: true }) },
|
|
137
|
+
valid: { attempt: 2147483647 },
|
|
138
|
+
invalid: { attempt: 2147483648 },
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: "integer field with explicit max narrower than int4 still enforces the explicit bound",
|
|
142
|
+
fields: { displayOrder: createNumberField({ integer: true, max: 100 }) },
|
|
143
|
+
valid: { displayOrder: 100 },
|
|
144
|
+
invalid: { displayOrder: 101 },
|
|
145
|
+
},
|
|
134
146
|
{
|
|
135
147
|
name: "date field",
|
|
136
148
|
fields: { born: createDateField() },
|
|
@@ -438,6 +450,26 @@ describe("buildInsertSchema", () => {
|
|
|
438
450
|
}
|
|
439
451
|
});
|
|
440
452
|
|
|
453
|
+
// Review-fix (kumiko-framework#1712): an optional select WITHOUT a default
|
|
454
|
+
// normalizes an untouched <select> to null (see the "unset (null)" test
|
|
455
|
+
// above). A client that reuses that null against a since-defaulted field
|
|
456
|
+
// must fall back to the default too, not get rejected as an invalid enum
|
|
457
|
+
// value the way a bare `null` previously was.
|
|
458
|
+
test("optional select with default accepts null and falls back to the default", () => {
|
|
459
|
+
const entity = createEntity({
|
|
460
|
+
table: "Test",
|
|
461
|
+
fields: {
|
|
462
|
+
locale: createSelectField({ options: ["de", "en", "fr"] as const, default: "de" }),
|
|
463
|
+
},
|
|
464
|
+
});
|
|
465
|
+
const schema = buildInsertSchema(entity);
|
|
466
|
+
const result = schema.safeParse({ locale: null });
|
|
467
|
+
expect(result.success).toBe(true);
|
|
468
|
+
if (result.success) {
|
|
469
|
+
expect(result.data["locale"]).toBe("de");
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
|
|
441
473
|
test("optional select with default still validates a real value", () => {
|
|
442
474
|
const entity = createEntity({
|
|
443
475
|
table: "Test",
|
|
@@ -537,11 +569,13 @@ describe("buildUpdateSchema", () => {
|
|
|
537
569
|
}
|
|
538
570
|
});
|
|
539
571
|
|
|
540
|
-
//
|
|
541
|
-
//
|
|
542
|
-
//
|
|
543
|
-
//
|
|
544
|
-
|
|
572
|
+
// fw#1703: buildUpdateSchema never applies defaults for an OMITTED field —
|
|
573
|
+
// omitting a field must leave it untouched. But an explicit `""` from an
|
|
574
|
+
// untouched <select> is a submission, not an omission, and "a field with a
|
|
575
|
+
// default is never unset" (same invariant the insert path documents at
|
|
576
|
+
// #1702) — so "" must map to the field's default on update too, not clobber
|
|
577
|
+
// an existing value to null.
|
|
578
|
+
test("optional select with default on update: empty string falls back to the default", () => {
|
|
545
579
|
const entity = createEntity({
|
|
546
580
|
table: "Test",
|
|
547
581
|
fields: {
|
|
@@ -553,7 +587,43 @@ describe("buildUpdateSchema", () => {
|
|
|
553
587
|
const result = schema.safeParse({ locale: "" });
|
|
554
588
|
expect(result.success).toBe(true);
|
|
555
589
|
if (result.success) {
|
|
556
|
-
expect(result.data["locale"]).
|
|
590
|
+
expect(result.data["locale"]).toBe("de");
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
test("optional select with default on update: omitting the field leaves it untouched", () => {
|
|
595
|
+
const entity = createEntity({
|
|
596
|
+
table: "Test",
|
|
597
|
+
fields: {
|
|
598
|
+
locale: createSelectField({ options: ["de", "en"] as const, default: "de" }),
|
|
599
|
+
},
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
const schema = buildUpdateSchema(entity);
|
|
603
|
+
const result = schema.safeParse({});
|
|
604
|
+
expect(result.success).toBe(true);
|
|
605
|
+
if (result.success) {
|
|
606
|
+
expect(Object.hasOwn(result.data, "locale")).toBe(false);
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
test("required select with default on update: empty string falls back to the default instead of a required-error", () => {
|
|
611
|
+
const entity = createEntity({
|
|
612
|
+
table: "Test",
|
|
613
|
+
fields: {
|
|
614
|
+
locale: createSelectField({
|
|
615
|
+
options: ["de", "en"] as const,
|
|
616
|
+
default: "de",
|
|
617
|
+
required: true,
|
|
618
|
+
}),
|
|
619
|
+
},
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
const schema = buildUpdateSchema(entity);
|
|
623
|
+
const result = schema.safeParse({ locale: "" });
|
|
624
|
+
expect(result.success).toBe(true);
|
|
625
|
+
if (result.success) {
|
|
626
|
+
expect(result.data["locale"]).toBe("de");
|
|
557
627
|
}
|
|
558
628
|
});
|
|
559
629
|
});
|
|
@@ -1,32 +1,74 @@
|
|
|
1
|
+
import { normalizeAccessEntry } from "../ownership";
|
|
1
2
|
import type { FeatureDefinition } from "../types";
|
|
2
3
|
|
|
3
4
|
const BUILTIN_ROLES = new Set(["all", "system"]);
|
|
4
5
|
|
|
6
|
+
function addRole(roleHandlers: Map<string, Set<string>>, role: string, identifier: string): void {
|
|
7
|
+
let handlers = roleHandlers.get(role);
|
|
8
|
+
if (!handlers) {
|
|
9
|
+
handlers = new Set();
|
|
10
|
+
roleHandlers.set(role, handlers);
|
|
11
|
+
}
|
|
12
|
+
handlers.add(identifier);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function addHandlerRoles(roleHandlers: Map<string, Set<string>>, f: FeatureDefinition): void {
|
|
16
|
+
const handlerGroups = [
|
|
17
|
+
{ type: "write", defs: f.writeHandlers },
|
|
18
|
+
{ type: "query", defs: f.queryHandlers },
|
|
19
|
+
{ type: "stream", defs: f.streamHandlers },
|
|
20
|
+
] as const;
|
|
21
|
+
|
|
22
|
+
for (const { type, defs } of handlerGroups) {
|
|
23
|
+
for (const [handlerName, def] of Object.entries(defs)) {
|
|
24
|
+
if (!def.access || !("roles" in def.access)) continue;
|
|
25
|
+
const identifier = `${f.name}:${type}:${handlerName}`;
|
|
26
|
+
for (const role of def.access.roles) {
|
|
27
|
+
addRole(roleHandlers, role, identifier);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function addConfigKeyRoles(roleHandlers: Map<string, Set<string>>, f: FeatureDefinition): void {
|
|
34
|
+
for (const [key, keyDef] of Object.entries(f.configKeys ?? {})) {
|
|
35
|
+
const identifier = `${f.name}:config:${key}`;
|
|
36
|
+
for (const role of [...keyDef.access.read, ...keyDef.access.write]) {
|
|
37
|
+
addRole(roleHandlers, role, identifier);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function addEntityFieldRoles(roleHandlers: Map<string, Set<string>>, f: FeatureDefinition): void {
|
|
43
|
+
for (const [entityName, entity] of Object.entries(f.entities ?? {})) {
|
|
44
|
+
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
45
|
+
const identifier = `${f.name}:entity:${entityName}.${fieldName}`;
|
|
46
|
+
const readRoles = Object.keys(normalizeAccessEntry(field.access?.read) ?? {});
|
|
47
|
+
const writeRoles = Object.keys(normalizeAccessEntry(field.access?.write) ?? {});
|
|
48
|
+
for (const role of [...readRoles, ...writeRoles]) {
|
|
49
|
+
addRole(roleHandlers, role, identifier);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// A single "exactly one handler" heuristic over-counts by construction:
|
|
56
|
+
// legitimate fine-grained roles (a role scoped to one admin endpoint on
|
|
57
|
+
// purpose) are the normal case, not a typo — and until every access
|
|
58
|
+
// surface is scanned, a role can look unique here while it's really used
|
|
59
|
+
// elsewhere (configKeys / entity+field access), a false positive in the
|
|
60
|
+
// other direction. Both is why this stays opt-in (#1711) rather than a
|
|
61
|
+
// default-on prod warning.
|
|
5
62
|
export function warnOnUniqueAccessRoles(features: readonly FeatureDefinition[]): void {
|
|
6
|
-
// role → set of distinct
|
|
63
|
+
// role → set of distinct identifiers using it, across every access
|
|
64
|
+
// surface — write/query/stream handlers, config-key access, and
|
|
65
|
+
// entity/field-level access rules.
|
|
7
66
|
const roleHandlers = new Map<string, Set<string>>();
|
|
8
67
|
|
|
9
68
|
for (const f of features) {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
{ type: "stream", defs: f.streamHandlers },
|
|
14
|
-
] as const;
|
|
15
|
-
|
|
16
|
-
for (const { type, defs } of handlerGroups) {
|
|
17
|
-
for (const [handlerName, def] of Object.entries(defs)) {
|
|
18
|
-
if (!def.access || !("roles" in def.access)) continue;
|
|
19
|
-
const identifier = `${f.name}:${type}:${handlerName}`;
|
|
20
|
-
for (const role of def.access.roles) {
|
|
21
|
-
let handlers = roleHandlers.get(role);
|
|
22
|
-
if (!handlers) {
|
|
23
|
-
handlers = new Set();
|
|
24
|
-
roleHandlers.set(role, handlers);
|
|
25
|
-
}
|
|
26
|
-
handlers.add(identifier);
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
}
|
|
69
|
+
addHandlerRoles(roleHandlers, f);
|
|
70
|
+
addConfigKeyRoles(roleHandlers, f);
|
|
71
|
+
addEntityFieldRoles(roleHandlers, f);
|
|
30
72
|
}
|
|
31
73
|
|
|
32
74
|
for (const [role, handlers] of roleHandlers) {
|
|
@@ -61,9 +61,12 @@ export const PII_USER_OWNED_NAME_HINTS: ReadonlySet<string> = new Set([
|
|
|
61
61
|
export const PII_USER_REFERENCE_NAME_HINTS: ReadonlySet<string> = new Set([
|
|
62
62
|
"authorid",
|
|
63
63
|
"assigneeid",
|
|
64
|
+
"assigneeuserid",
|
|
64
65
|
"ownerid",
|
|
66
|
+
"createdby",
|
|
65
67
|
"createdbyid",
|
|
66
68
|
"createdbyuserid",
|
|
69
|
+
"updatedby",
|
|
67
70
|
"updatedbyid",
|
|
68
71
|
"updatedbyuserid",
|
|
69
72
|
"invitedby",
|
|
@@ -72,6 +75,7 @@ export const PII_USER_REFERENCE_NAME_HINTS: ReadonlySet<string> = new Set([
|
|
|
72
75
|
"uploadedby",
|
|
73
76
|
"assignedto",
|
|
74
77
|
"reportedby",
|
|
78
|
+
"memberid",
|
|
75
79
|
]);
|
|
76
80
|
|
|
77
81
|
// --- Extension preSave wiring validation ---
|
|
@@ -61,11 +61,24 @@ export { validateAppCustomScreenWriteQns } from "./custom-screen-write-qns";
|
|
|
61
61
|
// dieselbe Extraktionslogik.
|
|
62
62
|
export { collectWriteHandlerQns } from "./nav";
|
|
63
63
|
|
|
64
|
+
export type ValidateBootOptions = {
|
|
65
|
+
/** Warn when an access role is used by exactly one handler/config-key/
|
|
66
|
+
* field across the whole boot scan — often a typo, but also the normal
|
|
67
|
+
* shape of a legitimate fine-grained role (one role scoped to one admin
|
|
68
|
+
* endpoint on purpose). Opt-in (default false, #1711): a default-on
|
|
69
|
+
* prod warning that nobody can silence per-role isn't worth the noise
|
|
70
|
+
* it generates on every boot. */
|
|
71
|
+
readonly warnOnUniqueAccessRoles?: boolean;
|
|
72
|
+
};
|
|
73
|
+
|
|
64
74
|
/**
|
|
65
75
|
* Validates all feature configurations at boot time.
|
|
66
76
|
* Throws on the first error found — fail fast.
|
|
67
77
|
*/
|
|
68
|
-
export function validateBoot(
|
|
78
|
+
export function validateBoot(
|
|
79
|
+
features: readonly FeatureDefinition[],
|
|
80
|
+
options?: ValidateBootOptions,
|
|
81
|
+
): void {
|
|
69
82
|
const featureMap = new Map<string, FeatureDefinition>();
|
|
70
83
|
for (const f of features) {
|
|
71
84
|
featureMap.set(f.name, f);
|
|
@@ -213,5 +226,7 @@ export function validateBoot(features: readonly FeatureDefinition[]): void {
|
|
|
213
226
|
|
|
214
227
|
validateConfigReads(features, allConfigKeys);
|
|
215
228
|
warnOnToggleableDependencies(features, featureMap);
|
|
216
|
-
warnOnUniqueAccessRoles
|
|
229
|
+
if (options?.warnOnUniqueAccessRoles === true) {
|
|
230
|
+
warnOnUniqueAccessRoles(features);
|
|
231
|
+
}
|
|
217
232
|
}
|
|
@@ -21,6 +21,13 @@ const FRAMEWORK_TIMESTAMP_FIELDS: ReadonlySet<string> = new Set([
|
|
|
21
21
|
// werden statt erst beim ersten Cleanup-Run.
|
|
22
22
|
const KEEP_FOR_PATTERN = /^\d+[hdwmy]$/;
|
|
23
23
|
|
|
24
|
+
// A field carries a subject binding — pii/userOwned/tenantOwned mark
|
|
25
|
+
// annotated content, subjectRef marks a bare FK into `user` with no
|
|
26
|
+
// annotated content of its own but the same Art.17 obligations (#1645).
|
|
27
|
+
function hasSubjectAnnotation(annot: PiiAnnotations): boolean {
|
|
28
|
+
return Boolean(annot.pii || annot.userOwned || annot.tenantOwned || annot.subjectRef);
|
|
29
|
+
}
|
|
30
|
+
|
|
24
31
|
// --- PII / Subject-Key Annotations + Retention validation ---
|
|
25
32
|
//
|
|
26
33
|
// Drei Klassen von Checks:
|
|
@@ -133,11 +140,12 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
|
|
|
133
140
|
);
|
|
134
141
|
}
|
|
135
142
|
|
|
136
|
-
//
|
|
137
|
-
// sortable +
|
|
138
|
-
// #1610
|
|
139
|
-
//
|
|
140
|
-
// sensitive + searchable
|
|
143
|
+
// Sorting reads the projection column — that stays ciphertext, so
|
|
144
|
+
// sortable + subject annotation stays a boot-fail. searchable has
|
|
145
|
+
// been allowed since #1610: the search consumer decrypts into the
|
|
146
|
+
// derived index and forget purges those docs (see
|
|
147
|
+
// createSearchEventConsumer). sensitive + searchable stays forbidden
|
|
148
|
+
// (nobody-may-read-back).
|
|
141
149
|
{
|
|
142
150
|
const flags = field as {
|
|
143
151
|
readonly searchable?: boolean;
|
|
@@ -215,7 +223,7 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
|
|
|
215
223
|
} else if (PII_USER_REFERENCE_NAME_HINTS.has(lower) && !annot.subjectRef) {
|
|
216
224
|
// biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
|
|
217
225
|
console.warn(
|
|
218
|
-
`[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a user-reference-typical name but no { subjectRef: true } annotation — a foreign key into \`user\` carries Art.17 obligations even with no annotated content on the entity. Mark it { subjectRef: true },
|
|
226
|
+
`[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a user-reference-typical name but no { subjectRef: true } annotation — a foreign key into \`user\` carries Art.17 obligations even with no annotated content on the entity. Mark it { subjectRef: true } AND register r.useExtension(EXT_USER_DATA, "${entityName}", …) — without the hook the V3 boot guard throws. Or { userOwned: { ownerField: "${fieldName}" } } on the field it owns. If business data, set { allowPlaintext: "..." } to silence.`,
|
|
219
227
|
);
|
|
220
228
|
}
|
|
221
229
|
}
|
|
@@ -245,10 +253,9 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
|
|
|
245
253
|
if (retention.strategy === "blockDelete") {
|
|
246
254
|
// blockDelete on an entity with no subject field is the correct
|
|
247
255
|
// "never auto-delete" choice; User-Forget never reaches those rows (#1622).
|
|
248
|
-
const hasSubjectField = Object.values(fieldsByName).some(
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
});
|
|
256
|
+
const hasSubjectField = Object.values(fieldsByName).some(
|
|
257
|
+
(f) => hasSubjectAnnotation(f as PiiAnnotations), // @cast-boundary schema-walk
|
|
258
|
+
);
|
|
252
259
|
const hasAnonymize = Object.values(fieldsByName).some((f) => {
|
|
253
260
|
const a = f as PiiAnnotations; // @cast-boundary schema-walk
|
|
254
261
|
return Boolean(a.anonymize);
|
|
@@ -36,8 +36,16 @@ function validateRowActionNavigateParams(
|
|
|
36
36
|
): void {
|
|
37
37
|
// skip: not a navigate-with-params action — nothing to validate here.
|
|
38
38
|
if (action.kind !== "navigate" || action.params === undefined) return;
|
|
39
|
-
//
|
|
40
|
-
|
|
39
|
+
// entityList/projectionList targets also read URL search params (Tier
|
|
40
|
+
// 2.7c filter-prefill, see use-list-url-state.ts: `<screenId>.q/.sort/
|
|
41
|
+
// .dir/.page/.f.<field>`), not just actionForm/entityEdit-create.
|
|
42
|
+
const exemptTargetType =
|
|
43
|
+
target === undefined ||
|
|
44
|
+
target.screen.type === "custom" ||
|
|
45
|
+
target.screen.type === "entityList" ||
|
|
46
|
+
target.screen.type === "projectionList";
|
|
47
|
+
// skip: unresolvable/custom/list target already reported (or exempt) elsewhere.
|
|
48
|
+
if (exemptTargetType) return;
|
|
41
49
|
|
|
42
50
|
const isEntityEditUpdate =
|
|
43
51
|
target.screen.type === "entityEdit" &&
|
package/src/engine/create-app.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { validateBoot } from "./boot-validator";
|
|
1
|
+
import { type ValidateBootOptions, validateBoot } from "./boot-validator";
|
|
2
2
|
import { createRegistry } from "./registry";
|
|
3
3
|
import type { FeatureDefinition, Registry } from "./types";
|
|
4
4
|
import { DEFAULT_CURRENCIES } from "./types";
|
|
@@ -8,6 +8,8 @@ export type AppConfig = {
|
|
|
8
8
|
features: readonly FeatureDefinition[];
|
|
9
9
|
softDelete?: boolean; // Global default for all entities (default: true)
|
|
10
10
|
currencies?: readonly string[]; // Extends DEFAULT_CURRENCIES
|
|
11
|
+
/** Opt-in boot-validator warnings — see ValidateBootOptions. */
|
|
12
|
+
validateBootOptions?: ValidateBootOptions;
|
|
11
13
|
};
|
|
12
14
|
|
|
13
15
|
export type App = {
|
|
@@ -98,7 +100,7 @@ export function createApp(config: AppConfig): App {
|
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
// Run boot-time validation before creating registry
|
|
101
|
-
validateBoot(config.features);
|
|
103
|
+
validateBoot(config.features, config.validateBootOptions);
|
|
102
104
|
|
|
103
105
|
return {
|
|
104
106
|
registry: createRegistry(config.features),
|
|
@@ -98,15 +98,29 @@ export function checkWriteFieldRoles(
|
|
|
98
98
|
// row. For creates, pass oldRow = undefined; the check degenerates to a
|
|
99
99
|
// newRow-only evaluation.
|
|
100
100
|
//
|
|
101
|
+
// `submittedChanges` and `rowContext` are deliberately separate (fw#1685):
|
|
102
|
+
// - `submittedChanges` drives WHICH fields get checked — only what the user
|
|
103
|
+
// actually wrote in the request. A preSave-hook-derived field the user
|
|
104
|
+
// never touched (e.g. a system hook setting `assignedTo`) must not be
|
|
105
|
+
// field-ownership-checked against the user at all.
|
|
106
|
+
// - `rowContext` drives what a checked field's rule is evaluated AGAINST —
|
|
107
|
+
// this needs the full post-hook row, because an ownership rule can
|
|
108
|
+
// reference a DIFFERENT column that only a hook populates (kumiko-
|
|
109
|
+
// framework#1672: a hook derives `authorId`, a user-submitted `secretNote`
|
|
110
|
+
// field's rule is `from("user:id", "authorId")` — the check needs the
|
|
111
|
+
// hook-derived `authorId` in scope even though the user only wrote
|
|
112
|
+
// `secretNote`). Defaults to `submittedChanges` when omitted.
|
|
113
|
+
//
|
|
101
114
|
// Returns the denied field name for the caller to wrap into an
|
|
102
115
|
// `ownership_denied` error with scope: "field", or null if all fields pass.
|
|
103
116
|
export function checkWriteFieldOwnership(
|
|
104
117
|
entity: EntityDefinition,
|
|
105
|
-
|
|
118
|
+
submittedChanges: Readonly<Record<string, unknown>>,
|
|
106
119
|
user: SessionUser,
|
|
107
120
|
oldRow?: Readonly<Record<string, unknown>>,
|
|
121
|
+
rowContext: Readonly<Record<string, unknown>> = submittedChanges,
|
|
108
122
|
): string | null {
|
|
109
|
-
for (const key of Object.keys(
|
|
123
|
+
for (const key of Object.keys(submittedChanges)) {
|
|
110
124
|
const field = entity.fields[key];
|
|
111
125
|
if (!field) continue;
|
|
112
126
|
|
|
@@ -120,7 +134,7 @@ export function checkWriteFieldOwnership(
|
|
|
120
134
|
const hasOwnershipRule = Object.values(accessMap).some((r) => r !== "all");
|
|
121
135
|
if (!hasOwnershipRule) continue;
|
|
122
136
|
|
|
123
|
-
const newRow: Record<string, unknown> = { ...(oldRow ?? {}), ...
|
|
137
|
+
const newRow: Record<string, unknown> = { ...(oldRow ?? {}), ...rowContext };
|
|
124
138
|
const effectiveOld = oldRow ?? newRow; // create: compare against newRow
|
|
125
139
|
|
|
126
140
|
if (!userCanWriteFieldRow(user, accessMap, effectiveOld, newRow)) {
|
package/src/engine/index.ts
CHANGED
|
@@ -205,6 +205,8 @@ export type { OwnershipClause, OwnershipMap, OwnershipRef, OwnershipRule } from
|
|
|
205
205
|
export {
|
|
206
206
|
buildOwnershipClause,
|
|
207
207
|
from,
|
|
208
|
+
normalizeAccessEntry,
|
|
209
|
+
userCanCreateFieldRow,
|
|
208
210
|
userCanReadFieldRow,
|
|
209
211
|
userCanWriteFieldRow,
|
|
210
212
|
} from "./ownership";
|