@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 +3 -3
- package/src/__tests__/ownership-where-write-path.integration.test.ts +143 -0
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +42 -1
- package/src/db/event-store-executor-write.ts +13 -2
- package/src/engine/__tests__/boot-validator.test.ts +52 -0
- package/src/engine/__tests__/ownership.test.ts +36 -4
- package/src/engine/__tests__/where-rule-lint.test.ts +79 -0
- package/src/engine/boot-validator/__tests__/ownership.test.ts +87 -0
- package/src/engine/boot-validator/ownership.ts +119 -3
- package/src/engine/ownership.ts +23 -9
- package/src/engine/where-rule-lint.ts +196 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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.
|
|
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 {
|
|
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
|
|
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 (
|
|
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).
|
|
201
|
-
//
|
|
202
|
-
//
|
|
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
|
|
2
|
-
import type {
|
|
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")
|
|
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>,
|
package/src/engine/ownership.ts
CHANGED
|
@@ -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
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
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;
|
|
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
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
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
|
+
}
|