@cosmicdrift/kumiko-framework 0.243.4 → 0.245.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.243.4",
3
+ "version": "0.245.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -198,7 +198,7 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-types": "0.243.4",
201
+ "@cosmicdrift/kumiko-types": "0.245.0",
202
202
  "bullmq": "^5.76.7",
203
203
  "bun-types": "^1.3.13",
204
204
  "hono": "^4.13.1",
@@ -214,7 +214,7 @@
214
214
  "zod": "^4.4.3"
215
215
  },
216
216
  "devDependencies": {
217
- "@cosmicdrift/kumiko-dispatcher-live": "0.243.4",
217
+ "@cosmicdrift/kumiko-dispatcher-live": "0.245.0",
218
218
  "bun-types": "^1.3.13",
219
219
  "pino-pretty": "^13.1.3"
220
220
  },
@@ -0,0 +1,143 @@
1
+ // fw#2626 — `{ kind: "where" }` ownership rules on the WRITE path.
2
+ //
3
+ // `entity.access.write` is only ever evaluated in memory
4
+ // (userCanCreateFieldRow / userCanWriteFieldRow); there is no SQL layer on the
5
+ // write path to run a where-rule against, and on create there is not even a
6
+ // row yet. Boot validation rejects the shape outright
7
+ // (boot-validator/ownership.ts), but setupTestStack wires the registry without
8
+ // validateBoot, so this suite still reaches the runtime path the boot guard
9
+ // protects and pins it to a fail-closed deny instead of a 500.
10
+
11
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
12
+ import { asRawClient } from "../db/query";
13
+ import {
14
+ createEntity,
15
+ createTextField,
16
+ defineEntityQueryHandler,
17
+ defineEntityWriteHandler,
18
+ defineFeature,
19
+ } from "../engine";
20
+ import type { SessionUser, WhereRule } from "../engine/types";
21
+ import {
22
+ createTestUser,
23
+ setupTestStack,
24
+ type TestStack,
25
+ TestUsers,
26
+ testTenantId,
27
+ unsafeCreateEntityTable,
28
+ } from "../stack";
29
+ import { expectErrorIncludes } from "../testing";
30
+
31
+ const ownerWhereRule: WhereRule = {
32
+ kind: "where",
33
+ where: (user, ctx) => ({
34
+ sqlText: `${ctx.tableName}.owner_id = $${ctx.paramStart}`,
35
+ params: [user.id],
36
+ }),
37
+ };
38
+
39
+ const memoEntity = createEntity({
40
+ table: "fw2626_memos",
41
+ softDelete: true,
42
+ fields: {
43
+ ownerId: createTextField({ required: true }),
44
+ title: createTextField({ required: true }),
45
+ },
46
+ access: {
47
+ read: { Admin: "all", Member: "all" },
48
+ // Admin keeps a plain rule so the suite can seed rows over real HTTP;
49
+ // Member is the shape under test.
50
+ write: { Admin: "all", Member: ownerWhereRule },
51
+ },
52
+ });
53
+
54
+ const memosFeature = defineFeature("fw2626memos", (r) => {
55
+ r.entity("memo", memoEntity);
56
+ for (const verb of ["create", "update", "delete"] as const) {
57
+ r.writeHandler(
58
+ defineEntityWriteHandler(`memo:${verb}`, memoEntity, {
59
+ access: { roles: ["Admin", "Member"] },
60
+ }),
61
+ );
62
+ }
63
+ r.queryHandler(
64
+ defineEntityQueryHandler("memo:detail", memoEntity, {
65
+ access: { roles: ["Admin", "Member"] },
66
+ }),
67
+ );
68
+ });
69
+
70
+ const tenant = testTenantId(1);
71
+ const admin: SessionUser = { ...TestUsers.admin, tenantId: tenant };
72
+ const member = createTestUser({
73
+ id: "22222222-0000-4000-8000-000000002626",
74
+ tenantId: tenant,
75
+ roles: ["Member"],
76
+ });
77
+
78
+ let stack: TestStack;
79
+ let seeded: { id: string; version: number };
80
+
81
+ beforeAll(async () => {
82
+ stack = await setupTestStack({ features: [memosFeature] });
83
+ await unsafeCreateEntityTable(stack.db, memoEntity, "memo");
84
+ });
85
+
86
+ afterAll(async () => {
87
+ await stack.cleanup();
88
+ });
89
+
90
+ beforeEach(async () => {
91
+ await asRawClient(stack.db).unsafe("DELETE FROM fw2626_memos");
92
+ const row = await stack.http.writeOk<{ id: string; data: { version: number } }>(
93
+ "fw2626memos:write:memo:create",
94
+ { ownerId: member.id, title: "seed" },
95
+ admin,
96
+ );
97
+ seeded = { id: row.id, version: row.data.version };
98
+ });
99
+
100
+ describe("write-path where-rule — create", () => {
101
+ test("create under a where-rule is denied, not a 500", async () => {
102
+ const err = await stack.http.writeErr(
103
+ "fw2626memos:write:memo:create",
104
+ { ownerId: member.id, title: "mine" },
105
+ member,
106
+ );
107
+ expectErrorIncludes(err, "ownership_denied");
108
+ expect(err.httpStatus).toBe(422);
109
+ });
110
+ });
111
+
112
+ describe("write-path where-rule — update/delete", () => {
113
+ test("update under a where-rule stays denied", async () => {
114
+ const err = await stack.http.writeErr(
115
+ "fw2626memos:write:memo:update",
116
+ { id: seeded.id, version: seeded.version, changes: { title: "edited" } },
117
+ member,
118
+ );
119
+ expectErrorIncludes(err, "ownership_denied");
120
+ expect(err.httpStatus).toBe(422);
121
+ });
122
+
123
+ test("delete under a where-rule stays denied", async () => {
124
+ const err = await stack.http.writeErr(
125
+ "fw2626memos:write:memo:delete",
126
+ { id: seeded.id },
127
+ member,
128
+ );
129
+ expectErrorIncludes(err, "ownership_denied");
130
+ expect(err.httpStatus).toBe(422);
131
+ });
132
+ });
133
+
134
+ describe("write-path where-rule — sibling roles unaffected", () => {
135
+ test("Admin's plain rule still writes through the same map", async () => {
136
+ const res = await stack.http.writeOk<{ data: { title: string } }>(
137
+ "fw2626memos:write:memo:update",
138
+ { id: seeded.id, version: seeded.version, changes: { title: "by-admin" } },
139
+ admin,
140
+ );
141
+ expect(res.data.title).toBe("by-admin");
142
+ });
143
+ });
@@ -8,9 +8,16 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:tes
8
8
  import { asRawClient, transaction } from "../../db/query";
9
9
  import { createEntity, createTextField } from "../../engine";
10
10
  import { from } from "../../engine/ownership";
11
+ import { createSystemUser } from "../../engine/system-user";
11
12
  import { createEventsTable } from "../../event-store";
12
13
  import type { EntityCache } from "../../pipeline/entity-cache";
13
- import { createTestDb, type TestDb, TestUsers, unsafeCreateEntityTable } from "../../stack";
14
+ import {
15
+ createTestDb,
16
+ createTestUser,
17
+ type TestDb,
18
+ TestUsers,
19
+ unsafeCreateEntityTable,
20
+ } from "../../stack";
14
21
  import { createEventStoreExecutor } from "../event-store-executor";
15
22
  import { buildEntityTable } from "../table-builder";
16
23
  import { createTenantDb, type TenantDb } from "../tenant-db";
@@ -118,6 +125,40 @@ describe("event-store-executor write-verbs — entity-level ownership_denied", (
118
125
  expect(events).toHaveLength(1);
119
126
  });
120
127
 
128
+ test("forget: framework system user bypasses ownership (fw#2639 — GDPR erasure runs as SYSTEM)", async () => {
129
+ const created = await crud.create({ email: "erase-me@test.de" }, admin, tdb);
130
+ if (!created.isSuccess) throw new Error("setup failed");
131
+
132
+ const systemUser = createSystemUser(admin.tenantId);
133
+ const result = await crud.forget({ id: created.data.id }, systemUser, tdb);
134
+ expect(result.isSuccess).toBe(true);
135
+
136
+ const rows = (await asRawClient(testDb.db).unsafe(
137
+ `SELECT id FROM read_es_write_restricted WHERE email = 'erase-me@test.de'`,
138
+ )) as unknown[];
139
+ expect(rows).toHaveLength(0);
140
+
141
+ const events = (await asRawClient(testDb.db).unsafe(
142
+ `SELECT type FROM kumiko_events WHERE type = 'esWriteRestricted.forgotten' AND aggregate_id = $1`,
143
+ [String(created.data.id)],
144
+ )) as unknown[];
145
+ expect(events).toHaveLength(1);
146
+ });
147
+
148
+ test('forget: a role named "system" that isn\'t the framework SYSTEM_USER_ID is still denied', async () => {
149
+ // Proves the bypass is keyed on the id, not just the role name — a
150
+ // tenant-defined "system" role must not get an accidental Art.17
151
+ // shortcut around access.write.
152
+ const created = await crud.create({ email: "not-system@test.de" }, admin, tdb);
153
+ if (!created.isSuccess) throw new Error("setup failed");
154
+
155
+ const impostor = createTestUser({ id: 42, roles: ["system"] });
156
+ const result = await crud.forget({ id: created.data.id }, impostor, tdb);
157
+ expect(result.isSuccess).toBe(false);
158
+ if (result.isSuccess) return;
159
+ expect((result.error.details as { reason?: string }).reason).toBe("ownership_denied");
160
+ });
161
+
121
162
  test("restore: role without a write-rule → ownership_denied", async () => {
122
163
  const created = await crud.create({ email: "torestore@test.de" }, admin, tdb);
123
164
  if (!created.isSuccess) throw new Error("setup failed");
@@ -1,6 +1,7 @@
1
1
  import { checkWriteFieldOwnership } from "../engine/field-access";
2
2
  import { userCanCreateFieldRow, userCanWriteFieldRow } from "../engine/ownership";
3
- import type { EntityId } from "../engine/types";
3
+ import { SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/system-user";
4
+ import type { EntityId, SessionUser } from "../engine/types";
4
5
  import {
5
6
  VersionConflictError as FrameworkVersionConflict,
6
7
  IdempotentReplayError,
@@ -28,6 +29,13 @@ import {
28
29
  } from "./event-store-executor-context";
29
30
  import { runInSavepointIfSupported, selectMany } from "./query";
30
31
 
32
+ // Art. 17 erasure runs as the framework operator, not as a row owner; a
33
+ // per-role ownership map can never cover it, and a silent deny means the
34
+ // erasure never happened.
35
+ function isFrameworkSystemUser(user: SessionUser): boolean {
36
+ return user.id === SYSTEM_USER_ID && user.roles.includes(SYSTEM_ROLE);
37
+ }
38
+
31
39
  // The five write verbs (create/update/delete/forget/restore) of the event-
32
40
  // store-executor. Split out of event-store-executor.ts (#1005, Welle 2) —
33
41
  // behavior-preserving relocation, not a redesign: every closure below is
@@ -616,7 +624,10 @@ export function createWriteVerbs(
616
624
  if (!raw) return writeFailure(new NotFoundError(entityName, payload.id));
617
625
  const existing = await decryptForRead(rehydrateCompoundTypes(raw as DbRow, entity) as DbRow);
618
626
 
619
- if (!userCanWriteFieldRow(user, entity.access?.write, existing, existing)) {
627
+ if (
628
+ !isFrameworkSystemUser(user) &&
629
+ !userCanWriteFieldRow(user, entity.access?.write, existing, existing)
630
+ ) {
620
631
  return writeFailure(
621
632
  new UnprocessableError("ownership_denied", {
622
633
  i18nKey: "errors.ownershipDenied",
@@ -1099,6 +1099,58 @@ describe("boot-validator", () => {
1099
1099
  expect(() => validateBoot(features)).not.toThrow();
1100
1100
  });
1101
1101
 
1102
+ test("fw#2639: a where-rule with an unqualified column inside a subquery fails boot", () => {
1103
+ const features = [
1104
+ defineFeature("orders", (r) => {
1105
+ r.entity(
1106
+ "order",
1107
+ createEntity({
1108
+ table: "orders",
1109
+ fields: { entityId: createTextField({ required: true }) },
1110
+ access: {
1111
+ read: {
1112
+ Auditor: {
1113
+ kind: "where",
1114
+ where: (_user, ctx) => ({
1115
+ sqlText: `EXISTS (SELECT 1 FROM teams t WHERE t.entity_id = entity_id AND t.team_id = $${ctx.paramStart})`,
1116
+ params: ["team-a"],
1117
+ }),
1118
+ },
1119
+ },
1120
+ },
1121
+ }),
1122
+ );
1123
+ }),
1124
+ ];
1125
+ expect(() => validateBoot(features)).toThrow(/unqualified/i);
1126
+ });
1127
+
1128
+ test("fw#2639: the same where-rule, correctly qualified, passes boot", () => {
1129
+ const features = [
1130
+ defineFeature("orders", (r) => {
1131
+ r.entity(
1132
+ "order",
1133
+ createEntity({
1134
+ table: "orders",
1135
+ fields: { entityId: createTextField({ required: true }) },
1136
+ access: {
1137
+ read: {
1138
+ Auditor: {
1139
+ kind: "where",
1140
+ where: (_user, ctx) => ({
1141
+ sqlText: `EXISTS (SELECT 1 FROM teams t WHERE t.entity_id = ${ctx.tableName}.entity_id AND t.team_id = $${ctx.paramStart})`,
1142
+ params: ["team-a"],
1143
+ }),
1144
+ },
1145
+ },
1146
+ },
1147
+ }),
1148
+ );
1149
+ }),
1150
+ ];
1151
+ expect(() => validateBoot(features)).not.toThrow();
1152
+ });
1153
+
1102
1154
  test("framework columns (id, tenantId, version, ...) are acceptable targets", () => {
1103
1155
  const features = [
1104
1156
  defineFeature("teams", (r) => {
@@ -197,10 +197,9 @@ describe("userCanReadFieldRow() — multi-role OR", () => {
197
197
  });
198
198
 
199
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.
200
+ // evaluate in-memory). A read map may legitimately carry one — it belongs
201
+ // on the SQL path (buildOwnershipClause) — so an in-memory read check must
202
+ // fail closed (deny, no throw) instead of crashing the caller with a 500.
204
203
  test("role backed by a where-rule → fails closed (deny), does not throw", () => {
205
204
  const whereMap: OwnershipMap = {
206
205
  Support: { kind: "where", where: () => ({ sqlText: "1=1", params: [] }) },
@@ -283,6 +282,18 @@ describe("userCanWriteFieldRow() — Straddle-attack prevention", () => {
283
282
  const user = mkUser();
284
283
  expect(userCanWriteFieldRow(user, undefined, {}, {})).toBe(true);
285
284
  });
285
+
286
+ // fw#2626: the write path never reaches SQL, so a where-rule there can only
287
+ // deny. Boot validation rejects the shape; this is the runtime backstop for
288
+ // maps assembled outside validateBoot.
289
+ test("role backed by a where-rule → fails closed (deny), does not throw", () => {
290
+ const user = mkUser({ roles: ["Support"] });
291
+ const whereMap: OwnershipMap = {
292
+ Support: { kind: "where", where: () => ({ sqlText: "1=1", params: [] }) },
293
+ };
294
+ expect(() => userCanWriteFieldRow(user, whereMap, { a: 1 }, { a: 2 })).not.toThrow();
295
+ expect(userCanWriteFieldRow(user, whereMap, { a: 1 }, { a: 2 })).toBe(false);
296
+ });
286
297
  });
287
298
 
288
299
  // --- userCanCreateFieldRow() ---
@@ -440,4 +451,25 @@ describe("userCanCreateFieldRow() — create case (no old row)", () => {
440
451
  const user = mkUser({ roles: ["Admin"] });
441
452
  expect(userCanCreateFieldRow(user, accessMap, { teamId: "ops" })).toBe(true);
442
453
  });
454
+
455
+ // fw#2626: create used to hand a where-rule to matchesRule(), which throws —
456
+ // every create against such an entity ended as a 500. It now denies, exactly
457
+ // like userCanWriteFieldRow does for update/delete.
458
+ test("role backed by a where-rule → fails closed (deny), does not throw", () => {
459
+ const user = mkUser({ roles: ["Support"] });
460
+ const whereMap: OwnershipMap = {
461
+ Support: { kind: "where", where: () => ({ sqlText: "1=1", params: [] }) },
462
+ };
463
+ expect(() => userCanCreateFieldRow(user, whereMap, { teamId: "ops" })).not.toThrow();
464
+ expect(userCanCreateFieldRow(user, whereMap, { teamId: "ops" })).toBe(false);
465
+ });
466
+
467
+ test("where-rule role does not block a later 'all' role in the same access map", () => {
468
+ const user = mkUser({ roles: ["Support", "Admin"] });
469
+ const mixedMap: OwnershipMap = {
470
+ Support: { kind: "where", where: () => ({ sqlText: "1=1", params: [] }) },
471
+ Admin: "all",
472
+ };
473
+ expect(userCanCreateFieldRow(user, mixedMap, { teamId: "ops" })).toBe(true);
474
+ });
443
475
  });
@@ -0,0 +1,79 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { assertQualifiedWhereFragment, tableColumnSqlNames } from "../where-rule-lint";
3
+
4
+ const COLUMNS = new Set(["entity_id", "team_id", "owner_id"]);
5
+
6
+ describe("assertQualifiedWhereFragment — passes (not a tautology)", () => {
7
+ test.each([
8
+ ["literal true", "1=1"],
9
+ ["unqualified column, no subquery", "owner_id = $1"],
10
+ ["unknown identifier compared to a literal", "custom_expr_42 = 1"],
11
+ [
12
+ "correctly qualified subquery",
13
+ "EXISTS (SELECT 1 FROM teams t WHERE t.entity_id = read_notes.entity_id AND t.team_id = $1)",
14
+ ],
15
+ [
16
+ "column name only inside a string literal",
17
+ "EXISTS (SELECT 1 FROM teams t WHERE t.label = 'entity_id')",
18
+ ],
19
+ [
20
+ "column name only inside a line comment",
21
+ "EXISTS (SELECT 1 FROM teams t WHERE t.x = $1) -- entity_id",
22
+ ],
23
+ ])("%s", (_label, sqlText) => {
24
+ expect(() => assertQualifiedWhereFragment(sqlText, COLUMNS, "scope")).not.toThrow();
25
+ });
26
+ });
27
+
28
+ describe("assertQualifiedWhereFragment — throws (fail-closed)", () => {
29
+ test("unqualified column shadowed inside a subquery (the real bug)", () => {
30
+ expect(() =>
31
+ assertQualifiedWhereFragment(
32
+ "EXISTS (SELECT 1 FROM teams t WHERE t.entity_id = entity_id AND t.team_id = $1)",
33
+ COLUMNS,
34
+ "scope",
35
+ ),
36
+ ).toThrow(/unqualified/i);
37
+ });
38
+
39
+ test("double-quoted unqualified column inside a subquery", () => {
40
+ expect(() =>
41
+ assertQualifiedWhereFragment(
42
+ 'EXISTS (SELECT 1 FROM teams t WHERE t.entity_id = "entity_id")',
43
+ COLUMNS,
44
+ "scope",
45
+ ),
46
+ ).toThrow(/unqualified/i);
47
+ });
48
+
49
+ test("bare self-comparison", () => {
50
+ expect(() => assertQualifiedWhereFragment("team_id = team_id", COLUMNS, "scope")).toThrow(
51
+ /tautology/i,
52
+ );
53
+ });
54
+
55
+ test("qualified self-comparison", () => {
56
+ expect(() =>
57
+ assertQualifiedWhereFragment("t.entity_id = t.entity_id", COLUMNS, "scope"),
58
+ ).toThrow(/tautology/i);
59
+ });
60
+
61
+ test("$N placeholders never count as identifiers", () => {
62
+ // Regression guard: if the identifier regex ever matched "$1", this
63
+ // would misfire as a self-comparison between two placeholders.
64
+ expect(() => assertQualifiedWhereFragment("$1 = $1", COLUMNS, "scope")).not.toThrow();
65
+ });
66
+ });
67
+
68
+ describe("tableColumnSqlNames", () => {
69
+ test("plain-object table maps field keys to snake_case SQL names", () => {
70
+ const names = tableColumnSqlNames({ teamId: { name: "team_id" } });
71
+ expect(names.has("team_id")).toBe(true);
72
+ });
73
+
74
+ test("non-object table yields an empty set", () => {
75
+ expect(tableColumnSqlNames(null).size).toBe(0);
76
+ expect(tableColumnSqlNames(undefined).size).toBe(0);
77
+ expect(tableColumnSqlNames("not a table").size).toBe(0);
78
+ });
79
+ });
@@ -0,0 +1,87 @@
1
+ // fw#2626 — boot guard: `{ kind: "where" }` is admissible on access.read
2
+ // (buildOwnershipClause runs it as SQL) and rejected on access.write, where
3
+ // rules are evaluated in memory against the concrete row and a create has no
4
+ // row at all. Without the guard such a map boots and only ever denies.
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import { defineFeature } from "../../define-feature";
8
+ import { createEntity, createTextField } from "../../factories";
9
+ import type { ClaimKeyDefinition, FeatureDefinition } from "../../types";
10
+ import type { OwnershipMap, WhereRule } from "../../types/ownership";
11
+ import { validateOwnershipRules } from "../ownership";
12
+
13
+ const NO_CLAIMS: ReadonlyMap<string, ClaimKeyDefinition> = new Map();
14
+ // Empty corpus keeps shouldValidateRoles() false, so role-existence checks
15
+ // stay out of the way of the where-rule assertions below.
16
+ const NO_ROLES: ReadonlySet<string> = new Set();
17
+
18
+ const ownerWhere: WhereRule = {
19
+ kind: "where",
20
+ where: (user, ctx) => ({
21
+ sqlText: `${ctx.tableName}.owner_id = $${ctx.paramStart}`,
22
+ params: [user.id],
23
+ }),
24
+ };
25
+
26
+ type AccessMaps = { readonly read?: OwnershipMap; readonly write?: OwnershipMap };
27
+
28
+ function featureWith(entityAccess: AccessMaps, fieldAccess?: AccessMaps): FeatureDefinition {
29
+ return defineFeature("memos", (r) => {
30
+ r.entity(
31
+ "memo",
32
+ createEntity({
33
+ table: "fw2626_guard_memos",
34
+ fields: {
35
+ ownerId: createTextField({ required: true }),
36
+ title: createTextField({ access: fieldAccess }),
37
+ },
38
+ access: entityAccess,
39
+ }),
40
+ );
41
+ });
42
+ }
43
+
44
+ function validate(feature: FeatureDefinition): void {
45
+ validateOwnershipRules(feature, NO_CLAIMS, NO_ROLES);
46
+ }
47
+
48
+ describe("validateOwnershipRules — where-rules on access.write", () => {
49
+ test("entity.access.write with a where-rule fails the boot", () => {
50
+ const feature = featureWith({ write: { Member: ownerWhere } });
51
+ expect(() => validate(feature)).toThrow(/access\.write/);
52
+ expect(() => validate(feature)).toThrow(/where/);
53
+ });
54
+
55
+ test("the error names the role, the feature and the from()-alternative", () => {
56
+ const feature = featureWith({ write: { Member: ownerWhere } });
57
+ let message = "";
58
+ try {
59
+ validate(feature);
60
+ } catch (e) {
61
+ message = e instanceof Error ? e.message : String(e);
62
+ }
63
+ expect(message).toContain('"Member"');
64
+ expect(message).toContain('"memos"');
65
+ expect(message).toContain("from(");
66
+ });
67
+
68
+ test("field.access.write with a where-rule fails the boot", () => {
69
+ const feature = featureWith({}, { write: { Member: ownerWhere } });
70
+ expect(() => validate(feature)).toThrow(/memo\.title\.access\.write/);
71
+ });
72
+
73
+ test("a where-rule on access.write is rejected even next to an 'all' role", () => {
74
+ const feature = featureWith({ write: { Admin: "all", Member: ownerWhere } });
75
+ expect(() => validate(feature)).toThrow(/access\.write/);
76
+ });
77
+ });
78
+
79
+ describe("validateOwnershipRules — where-rules on access.read stay supported", () => {
80
+ test("entity.access.read with a where-rule boots", () => {
81
+ expect(() => validate(featureWith({ read: { Member: ownerWhere } }))).not.toThrow();
82
+ });
83
+
84
+ test("field.access.read with a where-rule boots", () => {
85
+ expect(() => validate(featureWith({}, { read: { Member: ownerWhere } }))).not.toThrow();
86
+ });
87
+ });
@@ -1,5 +1,9 @@
1
- import type { OwnershipMap, OwnershipRule } from "../ownership";
2
- import type { ClaimKeyDefinition, FeatureDefinition } from "../types";
1
+ import { buildEntityTable } from "../../db/table-builder";
2
+ import type { OwnershipMap, OwnershipRule, SqlFragment, WhereRule } from "../ownership";
3
+ import { SYSTEM_USER_ID } from "../system-user";
4
+ import type { ClaimKeyDefinition, FeatureDefinition, SessionUser } from "../types";
5
+ import { SYSTEM_TENANT_ID } from "../types/identifiers";
6
+ import { assertQualifiedWhereFragment, tableColumnSqlNames } from "../where-rule-lint";
3
7
 
4
8
  // --- Ownership rule validation (H.2) ---
5
9
  //
@@ -7,6 +11,38 @@ import type { ClaimKeyDefinition, FeatureDefinition } from "../types";
7
11
  // FromRule against the cross-feature claim registry, and confirms the
8
12
  // referenced column exists on the entity. Catches typos, renames, and
9
13
  // cross-feature-claim-removal scenarios at boot instead of at request time.
14
+ //
15
+ // fw#2639: also probes every `{ kind: "where" }` rule against the where-rule
16
+ // lint (see ../where-rule-lint.ts) so a fail-open unqualified-column bug
17
+ // fails the boot instead of waiting for a request from the right role.
18
+ //
19
+ // fw#2626: `access.write` maps reject `{ kind: "where" }` outright. A
20
+ // where-rule is raw SQL and the write path never reaches SQL — it evaluates
21
+ // rules in memory against the concrete row (userCanCreateFieldRow /
22
+ // userCanWriteFieldRow), and a create has no row to run a predicate against
23
+ // at all. Such a rule can therefore only ever deny, so the boot fails instead
24
+ // of shipping an access map that silently locks the role out.
25
+
26
+ // A rule that reads real claims off the user can't be evaluated at boot —
27
+ // PROBE_USER carries none — so probing is best-effort: rules that throw on
28
+ // the probe user fall through to the runtime backstop in
29
+ // ownership.ts#ruleToFragment instead.
30
+ const PROBE_USER: SessionUser = {
31
+ id: SYSTEM_USER_ID,
32
+ tenantId: SYSTEM_TENANT_ID,
33
+ roles: [],
34
+ claims: {},
35
+ };
36
+
37
+ export type WhereRuleProbe = {
38
+ readonly table: unknown;
39
+ readonly tableName: string;
40
+ readonly columns: ReadonlySet<string>;
41
+ };
42
+
43
+ // Which half of an access map is being validated. Only "read" ever reaches
44
+ // SQL, so it is the only path on which a where-rule is admissible.
45
+ export type AccessPath = "read" | "write";
10
46
 
11
47
  export function validateOwnershipRules(
12
48
  feature: FeatureDefinition,
@@ -20,6 +56,19 @@ export function validateOwnershipRules(
20
56
  const frameworkColumns = ["id", "tenantId", "version", "insertedAt", "modifiedAt"];
21
57
  for (const col of frameworkColumns) columnNames.add(col);
22
58
 
59
+ let probe: WhereRuleProbe | undefined;
60
+ try {
61
+ const table = buildEntityTable(entityName, entity);
62
+ const columns = tableColumnSqlNames(table);
63
+ if (columns.size > 0) {
64
+ probe = { table, tableName: entity.table ?? entityName, columns };
65
+ }
66
+ } catch {
67
+ // skip: table cannot be built outside the real boot sequence here —
68
+ // the runtime backstop in ruleToFragment still covers this entity.
69
+ probe = undefined;
70
+ }
71
+
23
72
  // Entity-level access
24
73
  if (entity.access?.read) {
25
74
  checkOwnershipMap({
@@ -29,6 +78,8 @@ export function validateOwnershipRules(
29
78
  knownRoles,
30
79
  scope: `entity "${entityName}".access.read`,
31
80
  featureName: feature.name,
81
+ probe,
82
+ path: "read",
32
83
  });
33
84
  }
34
85
  if (entity.access?.write) {
@@ -39,6 +90,8 @@ export function validateOwnershipRules(
39
90
  knownRoles,
40
91
  scope: `entity "${entityName}".access.write`,
41
92
  featureName: feature.name,
93
+ probe,
94
+ path: "write",
42
95
  });
43
96
  }
44
97
 
@@ -53,6 +106,8 @@ export function validateOwnershipRules(
53
106
  knownRoles,
54
107
  scope: `${entityName}.${fieldName}.access.read`,
55
108
  featureName: feature.name,
109
+ probe,
110
+ path: "read",
56
111
  });
57
112
  checkFieldAccess({
58
113
  access: field.access?.write,
@@ -61,6 +116,8 @@ export function validateOwnershipRules(
61
116
  knownRoles,
62
117
  scope: `${entityName}.${fieldName}.access.write`,
63
118
  featureName: feature.name,
119
+ probe,
120
+ path: "write",
64
121
  });
65
122
  }
66
123
  }
@@ -73,6 +130,8 @@ export function checkFieldAccess(args: {
73
130
  readonly knownRoles: ReadonlySet<string>;
74
131
  readonly scope: string;
75
132
  readonly featureName: string;
133
+ readonly probe?: WhereRuleProbe;
134
+ readonly path: AccessPath;
76
135
  }): void {
77
136
  // skip: no access rules on this field, nothing to validate
78
137
  if (!args.access) return;
@@ -96,6 +155,8 @@ export function checkFieldAccess(args: {
96
155
  knownRoles: args.knownRoles,
97
156
  scope: args.scope,
98
157
  featureName: args.featureName,
158
+ probe: args.probe,
159
+ path: args.path,
99
160
  });
100
161
  }
101
162
 
@@ -134,6 +195,8 @@ export function checkOwnershipMap(args: {
134
195
  readonly knownRoles: ReadonlySet<string>;
135
196
  readonly scope: string;
136
197
  readonly featureName: string;
198
+ readonly probe?: WhereRuleProbe;
199
+ readonly path: AccessPath;
137
200
  }): void {
138
201
  for (const [roleName, rawRule] of Object.entries(args.map)) {
139
202
  // Role-existence check — typos like `{"Admi": "all"}` where no handler
@@ -149,7 +212,13 @@ export function checkOwnershipMap(args: {
149
212
  // @cast-boundary schema-walk — extracted from feature-config inspection
150
213
  const rule = rawRule as OwnershipRule;
151
214
  if (rule === "all") continue;
152
- if (rule.kind === "where") continue; // escape hatch — feature author owns the SQL
215
+ if (rule.kind === "where") {
216
+ if (args.path === "write") {
217
+ throw new Error(buildWhereOnWritePathMessage(roleName, args.scope, args.featureName));
218
+ }
219
+ probeWhereRule(rule, roleName, args.probe, args.scope);
220
+ continue; // escape hatch — feature author owns the SQL
221
+ }
153
222
 
154
223
  // FromRule — validate ref + column.
155
224
  if (rule.refKind === "claim") {
@@ -181,6 +250,53 @@ export function checkOwnershipMap(args: {
181
250
  }
182
251
  }
183
252
 
253
+ // Best-effort: run the where-rule lint against the SQL the rule produces
254
+ // for PROBE_USER. A rule that needs real claims throws on that call and is
255
+ // silently skipped — ruleToFragment's runtime backstop is what catches it.
256
+ export function probeWhereRule(
257
+ rule: WhereRule,
258
+ roleName: string,
259
+ probe: WhereRuleProbe | undefined,
260
+ scope: string,
261
+ ): void {
262
+ // skip: no probe means the entity's column set could not be derived above;
263
+ // without it there is nothing to check the rule's SQL against, so the
264
+ // runtime backstop in ruleToFragment is the only line of defense left.
265
+ if (!probe) return;
266
+ let fragment: SqlFragment;
267
+ try {
268
+ fragment = rule.where(PROBE_USER, {
269
+ table: probe.table,
270
+ tableName: probe.tableName,
271
+ paramStart: 1,
272
+ });
273
+ } catch {
274
+ // skip: a rule that needs real claims cannot be probed at boot; ruleToFragment is the backstop
275
+ return;
276
+ }
277
+ // skip: a rule producing no SQL fragment for the probe user has nothing to
278
+ // lint here; ruleToFragment still validates it against a real request.
279
+ if (typeof fragment?.sqlText !== "string") return;
280
+ assertQualifiedWhereFragment(fragment.sqlText, probe.columns, `${scope} (role "${roleName}")`);
281
+ }
282
+
283
+ export function buildWhereOnWritePathMessage(
284
+ roleName: string,
285
+ scope: string,
286
+ featureName: string,
287
+ ): string {
288
+ return (
289
+ `[Kumiko Ownership] ${scope} uses a \`{ kind: "where" }\` rule for role ` +
290
+ `"${roleName}" (feature: "${featureName}"). where-rules are raw SQL and only ` +
291
+ `the read path runs SQL (buildOwnershipClause); write access is decided in ` +
292
+ `memory against the concrete row, and a create has no row yet — so the rule ` +
293
+ `could only ever deny this role. Use a from()-rule (e.g. ` +
294
+ `from("user:id", "ownerId") or from("claim:<feature>:<key>")) on access.write, ` +
295
+ `or enforce the condition in the write-handler's preSave hook. ` +
296
+ `where-rules stay supported on access.read.`
297
+ );
298
+ }
299
+
184
300
  export function buildUnknownRoleMessage(
185
301
  roleName: string,
186
302
  knownRoles: ReadonlySet<string>,
@@ -23,6 +23,7 @@ import {
23
23
  } from "@cosmicdrift/kumiko-types/schema-table-types";
24
24
  import { toSnakeCase } from "../db/table-builder";
25
25
  import type { SessionUser } from "./types";
26
+ import { assertQualifiedWhereFragment, tableColumnSqlNames } from "./where-rule-lint";
26
27
 
27
28
  // Types live in engine/types/ownership.ts (no runtime dependency); re-exported
28
29
  // here for backwards compatibility with existing importers of this file.
@@ -113,12 +114,13 @@ export function matchesRule(
113
114
  ): boolean {
114
115
  if (rule === "all") return true;
115
116
  if (rule.kind === "where") {
116
- // `where` rules produce Drizzle SQL for the DB-side filter. They don't
117
- // have a straightforward in-memory evaluator — the feature author owns
118
- // the semantics. Field-level filters can't use `{ where }` rules; the
119
- // boot-validator rejects them with a clear error at registration time.
117
+ // `where` rules produce SQL for the DB-side filter and have no in-memory
118
+ // evaluator — the feature author owns the semantics. Only the read path
119
+ // reaches SQL (buildOwnershipClause); on write maps the boot-validator
120
+ // rejects them outright (fw#2626), and the in-memory write helpers below
121
+ // fail closed rather than calling in here.
120
122
  throw new Error(
121
- "where-rules can only be evaluated at the SQL layer; boot-validator should reject them on field-level access.",
123
+ "where-rules can only be evaluated at the SQL layer (buildOwnershipClause); they are rejected on access.write at boot.",
122
124
  );
123
125
  }
124
126
 
@@ -158,10 +160,10 @@ export function userCanReadFieldRow(
158
160
  for (const role of user.roles) {
159
161
  const rule = accessMap[role];
160
162
  if (!rule) continue;
161
- // where-rules are entity-level SQL predicates (buildOwnershipClause);
162
- // matchesRule can't evaluate them in-memory and throws. Field-level
163
- // access is boot-validator-rejected for where-rules, but this function
164
- // is also reachable from hand-rolled entity-level reads.
163
+ // where-rules are SQL predicates (buildOwnershipClause); matchesRule
164
+ // can't evaluate them in-memory and throws. Read maps may legitimately
165
+ // carry one — it just belongs on the SQL path, so an in-memory read
166
+ // check denies rather than crashing.
165
167
  // skip: where-rules are SQL-layer only — fail closed instead of throwing.
166
168
  if (rule !== "all" && rule.kind === "where") continue;
167
169
  if (matchesRule(rule, user, row)) return true;
@@ -225,6 +227,10 @@ export function userCanCreateFieldRow(
225
227
  const rule = accessMap[role];
226
228
  if (!rule) continue;
227
229
  if (rule === "all") return true;
230
+ // skip: where-rules are SQL-layer only — fail closed instead of throwing.
231
+ // Mirrors userCanWriteFieldRow; a create has no row to run SQL against
232
+ // at all, so the boot-validator rejects the shape on access.write.
233
+ if (rule.kind === "where") continue;
228
234
  if (matchesRule(rule, user, newRow)) return true;
229
235
  }
230
236
  return false;
@@ -349,6 +355,14 @@ function ruleToFragment(
349
355
  tableName: tableNameOf(table),
350
356
  paramStart,
351
357
  });
358
+ // Runtime backstop for the boot-time probe (boot-validator/ownership.ts):
359
+ // an unqualified column in a correlated subquery silently turns into a
360
+ // fail-open tautology (fw#2639) — throw instead of shipping it.
361
+ assertQualifiedWhereFragment(
362
+ frag.sqlText,
363
+ tableColumnSqlNames(table),
364
+ `ownership where-rule on "${tableNameOf(table)}"`,
365
+ );
352
366
  return { kind: "sql", sqlText: frag.sqlText, params: frag.params };
353
367
  }
354
368
  // FromRule
@@ -0,0 +1,196 @@
1
+ // Static lint for `WhereRule` SQL fragments (fw#2639).
2
+ //
3
+ // A `where`-rule hands the framework raw SQL. If the author writes an
4
+ // unqualified column name inside a correlated subquery, Postgres binds it
5
+ // to the innermost table instead of the outer one — the intended predicate
6
+ // silently collapses into a tautology (`t.x = t.x`) and the rule grants
7
+ // every row instead of none. This module is a text-level lint (no SQL
8
+ // parser) that catches the two shapes that always mean "this rule is
9
+ // broken": a bare column reference inside a subquery that shadows an outer
10
+ // column, and a literal self-comparison.
11
+
12
+ import { KUMIKO_COLUMNS_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
13
+ import { toSnakeCase } from "../db/table-builder";
14
+
15
+ const IDENT_SRC = `"(?:[^"]|"")+"|[A-Za-z_][A-Za-z0-9_$]*`;
16
+ const QUALIFIED_SRC = `(?:${IDENT_SRC})(?:\\.(?:${IDENT_SRC}))?`;
17
+
18
+ const TOKEN_RE = new RegExp(IDENT_SRC, "g");
19
+ const SELF_COMPARISON_RE = new RegExp(
20
+ `(${QUALIFIED_SRC})\\s*(?<![!<>=])=(?!=)\\s*(${QUALIFIED_SRC})`,
21
+ "g",
22
+ );
23
+ const QUALIFIED_PARSE_RE = new RegExp(`^(${IDENT_SRC})(?:\\.(${IDENT_SRC}))?$`);
24
+
25
+ // Resolve the SQL column names a table exposes, so the lint can tell a
26
+ // real column reference apart from a table alias / function name / SQL
27
+ // keyword. Returns an empty set when nothing is derivable (fail-open on
28
+ // the lint itself would be worse than skipping it).
29
+ export function tableColumnSqlNames(table: unknown): ReadonlySet<string> {
30
+ if (table === null || typeof table !== "object") return new Set();
31
+
32
+ const cols = (table as Record<symbol, unknown>)[KUMIKO_COLUMNS_SYMBOL];
33
+ if (cols && typeof cols === "object") {
34
+ const names = new Set<string>();
35
+ for (const col of Object.values(cols as Record<string, unknown>)) {
36
+ if (col && typeof col === "object") {
37
+ const nameVal = (col as Record<string, unknown>)["name"];
38
+ if (typeof nameVal === "string") names.add(nameVal.toLowerCase());
39
+ }
40
+ }
41
+ return names;
42
+ }
43
+
44
+ // Plain-object fallback (tests): no Drizzle column-symbol map, so every
45
+ // object-valued key is treated as a field and mapped to its SQL name the
46
+ // same way columnSqlName() in ownership.ts does for hand-rolled tables.
47
+ const names = new Set<string>();
48
+ for (const [key, value] of Object.entries(table as Record<string, unknown>)) {
49
+ if (value && typeof value === "object") {
50
+ names.add(toSnakeCase(key).toLowerCase());
51
+ }
52
+ }
53
+ return names;
54
+ }
55
+
56
+ function skipLineComment(sql: string, i: number): number {
57
+ let j = i;
58
+ while (j < sql.length && sql[j] !== "\n") j++;
59
+ return j;
60
+ }
61
+
62
+ function skipBlockComment(sql: string, i: number): number {
63
+ let j = i + 2;
64
+ while (j < sql.length && !(sql[j] === "*" && sql[j + 1] === "/")) j++;
65
+ return j + 2;
66
+ }
67
+
68
+ function skipQuotedLiteral(sql: string, i: number): number {
69
+ let j = i + 1;
70
+ while (j < sql.length) {
71
+ if (sql[j] === "'" && sql[j + 1] === "'") {
72
+ j += 2;
73
+ continue;
74
+ }
75
+ if (sql[j] === "'") {
76
+ j++;
77
+ break;
78
+ }
79
+ j++;
80
+ }
81
+ return j;
82
+ }
83
+
84
+ function stripCommentsAndStrings(sqlText: string): string {
85
+ let result = "";
86
+ let i = 0;
87
+ const len = sqlText.length;
88
+ while (i < len) {
89
+ const ch = sqlText[i];
90
+ if (ch === "-" && sqlText[i + 1] === "-") {
91
+ i = skipLineComment(sqlText, i);
92
+ result += " ";
93
+ continue;
94
+ }
95
+ if (ch === "/" && sqlText[i + 1] === "*") {
96
+ i = skipBlockComment(sqlText, i);
97
+ result += " ";
98
+ continue;
99
+ }
100
+ if (ch === "'") {
101
+ i = skipQuotedLiteral(sqlText, i);
102
+ result += " ";
103
+ continue;
104
+ }
105
+ result += ch;
106
+ i++;
107
+ }
108
+ return result;
109
+ }
110
+
111
+ function normalizeIdent(raw: string): string {
112
+ if (raw.length >= 2 && raw.startsWith('"') && raw.endsWith('"')) {
113
+ return raw.slice(1, -1).replace(/""/g, '"').toLowerCase();
114
+ }
115
+ return raw.toLowerCase();
116
+ }
117
+
118
+ function normalizeQualifiedRef(raw: string): string {
119
+ const parsed = QUALIFIED_PARSE_RE.exec(raw);
120
+ if (!parsed) return normalizeIdent(raw);
121
+ const first = normalizeIdent(parsed[1] ?? "");
122
+ const second = parsed[2];
123
+ return second === undefined ? first : `${first}.${normalizeIdent(second)}`;
124
+ }
125
+
126
+ // Rule 1: `X = Y` where X and Y are textually the same (optionally
127
+ // once-qualified) identifier is always a tautology, subquery or not.
128
+ function findSelfComparison(cleaned: string): string | null {
129
+ SELF_COMPARISON_RE.lastIndex = 0;
130
+ let match: RegExpExecArray | null = SELF_COMPARISON_RE.exec(cleaned);
131
+ while (match !== null) {
132
+ const left = match[1] ?? "";
133
+ const right = match[2] ?? "";
134
+ if (normalizeQualifiedRef(left) === normalizeQualifiedRef(right)) {
135
+ return left.trim();
136
+ }
137
+ match = SELF_COMPARISON_RE.exec(cleaned);
138
+ }
139
+ return null;
140
+ }
141
+
142
+ // Rule 2: inside a subquery, a bare identifier that happens to name a
143
+ // column on the OUTER table is the fail-open shape — Postgres resolves it
144
+ // against the innermost table first. A qualifier (`t.x`) or a table/alias
145
+ // name immediately followed by `.` is not a column reference and is
146
+ // ignored.
147
+ function findUnqualifiedColumnReference(
148
+ cleaned: string,
149
+ columnSqlNames: ReadonlySet<string>,
150
+ ): string | null {
151
+ TOKEN_RE.lastIndex = 0;
152
+ let match: RegExpExecArray | null = TOKEN_RE.exec(cleaned);
153
+ while (match !== null) {
154
+ const start = match.index;
155
+ const end = start + match[0].length;
156
+ const precededByDot = start > 0 && cleaned[start - 1] === ".";
157
+ const followedByDot = cleaned[end] === ".";
158
+ if (!precededByDot && !followedByDot) {
159
+ const normalized = normalizeIdent(match[0]);
160
+ if (columnSqlNames.has(normalized)) return normalized;
161
+ }
162
+ match = TOKEN_RE.exec(cleaned);
163
+ }
164
+ return null;
165
+ }
166
+
167
+ // Throws (fail-closed) instead of shipping a where-rule that would silently
168
+ // fail-open at request time. Called both as a runtime backstop
169
+ // (ownership.ts#ruleToFragment) and as a boot-time probe
170
+ // (boot-validator/ownership.ts).
171
+ export function assertQualifiedWhereFragment(
172
+ sqlText: string,
173
+ columnSqlNames: ReadonlySet<string>,
174
+ scope: string,
175
+ ): void {
176
+ const cleaned = stripCommentsAndStrings(sqlText);
177
+
178
+ const selfComparison = findSelfComparison(cleaned);
179
+ if (selfComparison !== null) {
180
+ throw new Error(
181
+ `[Kumiko Ownership] ${scope}: where-rule SQL compares "${selfComparison}" with itself — this is a tautology that matches every row. Remove the redundant comparison or compare against a real bound value / a different, properly qualified column.`,
182
+ );
183
+ }
184
+
185
+ // skip: without a subquery there is no inner table to shadow — a bare
186
+ // column reference cannot bind against anything but the outer table, so
187
+ // the fail-open shape this lint targets is impossible here.
188
+ if (!/\bSELECT\b/i.test(cleaned)) return;
189
+
190
+ const unqualified = findUnqualifiedColumnReference(cleaned, columnSqlNames);
191
+ if (unqualified !== null) {
192
+ throw new Error(
193
+ `[Kumiko Ownership] ${scope}: where-rule SQL references column "${unqualified}" unqualified inside a subquery. Postgres binds an unqualified name to the innermost table first, which silently turns the ownership predicate into a tautology (fail-open). Qualify it with the outer table, e.g. \`\${ctx.tableName}.${unqualified}\`.`,
194
+ );
195
+ }
196
+ }