@cosmicdrift/kumiko-framework 0.245.0 → 0.247.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 +11 -7
- package/src/db/apply-entity-event.ts +8 -9
- package/src/db/event-store-executor-write.ts +12 -7
- package/src/engine/__tests__/boot-validator.test.ts +78 -1
- package/src/engine/__tests__/entity-restore-tenant-isolation.integration.test.ts +143 -0
- package/src/engine/boot-validator/__tests__/ownership.test.ts +1 -1
- package/src/engine/boot-validator/index.ts +3 -8
- package/src/engine/boot-validator/ownership.ts +60 -22
- package/src/engine/boot-validator/screens.ts +17 -0
- package/src/stack/__tests__/ownership-boot-guard.integration.test.ts +108 -0
- package/src/stack/test-stack.ts +9 -0
- package/src/ui-types/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.247.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.247.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.247.0",
|
|
218
218
|
"bun-types": "^1.3.13",
|
|
219
219
|
"pino-pretty": "^13.1.3"
|
|
220
220
|
},
|
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
// (userCanCreateFieldRow / userCanWriteFieldRow); there is no SQL layer on the
|
|
5
5
|
// write path to run a where-rule against, and on create there is not even a
|
|
6
6
|
// row yet. Boot validation rejects the shape outright
|
|
7
|
-
// (boot-validator/ownership.ts)
|
|
8
|
-
//
|
|
9
|
-
//
|
|
7
|
+
// (boot-validator/ownership.ts) and setupTestStack now runs that guard too, so
|
|
8
|
+
// the rule is installed after the stack is up — that is the only way left to
|
|
9
|
+
// reach the runtime path behind the guard and pin it to a fail-closed deny
|
|
10
|
+
// instead of a 500.
|
|
10
11
|
|
|
11
12
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
12
13
|
import { asRawClient } from "../db/query";
|
|
@@ -17,7 +18,7 @@ import {
|
|
|
17
18
|
defineEntityWriteHandler,
|
|
18
19
|
defineFeature,
|
|
19
20
|
} from "../engine";
|
|
20
|
-
import type { SessionUser, WhereRule } from "../engine/types";
|
|
21
|
+
import type { OwnershipRule, SessionUser, WhereRule } from "../engine/types";
|
|
21
22
|
import {
|
|
22
23
|
createTestUser,
|
|
23
24
|
setupTestStack,
|
|
@@ -36,6 +37,10 @@ const ownerWhereRule: WhereRule = {
|
|
|
36
37
|
}),
|
|
37
38
|
};
|
|
38
39
|
|
|
40
|
+
// Admin keeps a plain rule so the suite can seed rows over real HTTP; Member
|
|
41
|
+
// is the shape under test and is swapped in after boot (see beforeAll).
|
|
42
|
+
const memoWriteAccess: Record<string, OwnershipRule> = { Admin: "all", Member: "all" };
|
|
43
|
+
|
|
39
44
|
const memoEntity = createEntity({
|
|
40
45
|
table: "fw2626_memos",
|
|
41
46
|
softDelete: true,
|
|
@@ -45,9 +50,7 @@ const memoEntity = createEntity({
|
|
|
45
50
|
},
|
|
46
51
|
access: {
|
|
47
52
|
read: { Admin: "all", Member: "all" },
|
|
48
|
-
|
|
49
|
-
// Member is the shape under test.
|
|
50
|
-
write: { Admin: "all", Member: ownerWhereRule },
|
|
53
|
+
write: memoWriteAccess,
|
|
51
54
|
},
|
|
52
55
|
});
|
|
53
56
|
|
|
@@ -81,6 +84,7 @@ let seeded: { id: string; version: number };
|
|
|
81
84
|
beforeAll(async () => {
|
|
82
85
|
stack = await setupTestStack({ features: [memosFeature] });
|
|
83
86
|
await unsafeCreateEntityTable(stack.db, memoEntity, "memo");
|
|
87
|
+
memoWriteAccess["Member"] = ownerWhereRule;
|
|
84
88
|
});
|
|
85
89
|
|
|
86
90
|
afterAll(async () => {
|
|
@@ -14,15 +14,14 @@
|
|
|
14
14
|
// (DEK erased → Wert unlesbar, bidx NULL). Load-bearing Test:
|
|
15
15
|
// db/__tests__/implicit-projection-equivalence.integration.test.ts.
|
|
16
16
|
//
|
|
17
|
-
// Tenant
|
|
18
|
-
//
|
|
19
|
-
// 1.
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// 2.
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// Sicherheitslücke.
|
|
17
|
+
// Tenant isolation: applyEntityEvent expects a raw DbRunner (tx or pool), NOT
|
|
18
|
+
// a TenantDb wrapper. Two sources keep it safe:
|
|
19
|
+
// 1. The live path reads the target row through the tenant-scoped TenantDb
|
|
20
|
+
// before writing — loadById() for update/delete/forget, db.fetchOne() for
|
|
21
|
+
// restore — so the aggregateId is already tenant-validated on arrival.
|
|
22
|
+
// 2. create sets tenantId explicitly from event.tenantId, never derived from
|
|
23
|
+
// the TenantDb wrapper default.
|
|
24
|
+
// The missing TenantDb wrapper in this function is therefore not a hole.
|
|
26
25
|
//
|
|
27
26
|
// Auto-Verben:
|
|
28
27
|
// <entity>.created → INSERT
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
entityEventName,
|
|
28
28
|
tryMapUniqueViolation,
|
|
29
29
|
} from "./event-store-executor-context";
|
|
30
|
-
import { runInSavepointIfSupported
|
|
30
|
+
import { runInSavepointIfSupported } from "./query";
|
|
31
31
|
|
|
32
32
|
// Art. 17 erasure runs as the framework operator, not as a row owner; a
|
|
33
33
|
// per-role ownership map can never cover it, and a silent deny means the
|
|
@@ -714,7 +714,12 @@ export function createWriteVerbs(
|
|
|
714
714
|
);
|
|
715
715
|
}
|
|
716
716
|
|
|
717
|
-
|
|
717
|
+
// Tenant boundary: db.fetchOne applies TenantDb's tenant predicate,
|
|
718
|
+
// selectMany(db.raw, ...) did not — any caller could un-delete a foreign
|
|
719
|
+
// tenant's row by id. "system"-mode dbs (r.systemScope() / crossTenant
|
|
720
|
+
// handlers) still read unfiltered. No isDeleted filter here: restore
|
|
721
|
+
// targets exactly the soft-deleted row.
|
|
722
|
+
const row = await db.fetchOne(table, { id: payload.id });
|
|
718
723
|
if (!row) return writeFailure(new NotFoundError(entityName, payload.id));
|
|
719
724
|
const data = row as DbRow;
|
|
720
725
|
if (!data["isDeleted"]) {
|
|
@@ -783,9 +788,9 @@ export function createWriteVerbs(
|
|
|
783
788
|
throw e;
|
|
784
789
|
}
|
|
785
790
|
|
|
786
|
-
// Live==Rebuild via applyEntityEvent. Restore
|
|
787
|
-
//
|
|
788
|
-
//
|
|
791
|
+
// Live==Rebuild via applyEntityEvent. Restore only writes isDeleted=false
|
|
792
|
+
// plus the version bump, so there is no sensitive-field drift and no
|
|
793
|
+
// payload override is needed.
|
|
789
794
|
const restoreResult = await runInSavepointIfSupported(db.raw, async (sp) =>
|
|
790
795
|
applyEntityEvent(event, table, entity, sp),
|
|
791
796
|
);
|
|
@@ -798,11 +803,11 @@ export function createWriteVerbs(
|
|
|
798
803
|
await entityCache.del(user.tenantId, entityName, payload.id);
|
|
799
804
|
}
|
|
800
805
|
|
|
801
|
-
// Read-
|
|
806
|
+
// Read-side auto-convert for compound types, same as update/list.
|
|
802
807
|
// decryptForRead matches create/update/list/detail: the caller-facing
|
|
803
808
|
// row and `previous` snapshot must be plaintext for `encrypted` fields,
|
|
804
809
|
// same as every other executor method — `data`/`restored` are raw rows
|
|
805
|
-
// (
|
|
810
|
+
// (db.fetchOne / applyEntityEvent), never decrypted before this point.
|
|
806
811
|
const restoredHydrated = await decryptForRead(
|
|
807
812
|
rehydrateCompoundTypes(restored as DbRow, entity) as DbRow,
|
|
808
813
|
);
|
|
@@ -1266,7 +1266,7 @@ describe("boot-validator", () => {
|
|
|
1266
1266
|
// Apps running only on openToAll / system handlers have no corpus
|
|
1267
1267
|
// of known roles beyond "all"/"system" — validator must not flag
|
|
1268
1268
|
// their OwnershipMap roles as unknown. This is the regression test
|
|
1269
|
-
// for the
|
|
1269
|
+
// for the canValidateRoles gate.
|
|
1270
1270
|
const features = [
|
|
1271
1271
|
defineFeature("orders", (r) => {
|
|
1272
1272
|
r.entity(
|
|
@@ -3851,6 +3851,83 @@ describe("boot-validator", () => {
|
|
|
3851
3851
|
/rowAction "add-note" drawer-target "ghost-form" does not resolve to a registered screen/,
|
|
3852
3852
|
);
|
|
3853
3853
|
});
|
|
3854
|
+
|
|
3855
|
+
test("projectionDetail relatedList defaultSort.field not a listed column → throw", () => {
|
|
3856
|
+
const feature = defineFeature("shop", (r) => {
|
|
3857
|
+
r.screen({
|
|
3858
|
+
id: "order-detail",
|
|
3859
|
+
type: "projectionDetail",
|
|
3860
|
+
query: "shop:query:order-detail",
|
|
3861
|
+
layout: {
|
|
3862
|
+
sections: [
|
|
3863
|
+
{
|
|
3864
|
+
kind: "relatedList",
|
|
3865
|
+
title: "Positions",
|
|
3866
|
+
query: "shop:query:order-positions",
|
|
3867
|
+
columns: [{ field: "name", sortable: true }],
|
|
3868
|
+
defaultSort: { field: "price", dir: "asc" },
|
|
3869
|
+
},
|
|
3870
|
+
],
|
|
3871
|
+
},
|
|
3872
|
+
});
|
|
3873
|
+
});
|
|
3874
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
3875
|
+
/defaultSort\.field "price" is not a listed column/,
|
|
3876
|
+
);
|
|
3877
|
+
});
|
|
3878
|
+
|
|
3879
|
+
test("projectionDetail relatedList defaultSort.field not sortable → throw", () => {
|
|
3880
|
+
const feature = defineFeature("shop", (r) => {
|
|
3881
|
+
r.screen({
|
|
3882
|
+
id: "order-detail",
|
|
3883
|
+
type: "projectionDetail",
|
|
3884
|
+
query: "shop:query:order-detail",
|
|
3885
|
+
layout: {
|
|
3886
|
+
sections: [
|
|
3887
|
+
{
|
|
3888
|
+
kind: "relatedList",
|
|
3889
|
+
title: "Positions",
|
|
3890
|
+
query: "shop:query:order-positions",
|
|
3891
|
+
columns: [{ field: "name" }],
|
|
3892
|
+
defaultSort: { field: "name", dir: "asc" },
|
|
3893
|
+
},
|
|
3894
|
+
],
|
|
3895
|
+
},
|
|
3896
|
+
});
|
|
3897
|
+
});
|
|
3898
|
+
expect(() => validateBoot([feature])).toThrow(/defaultSort\.field "name" is not sortable/);
|
|
3899
|
+
});
|
|
3900
|
+
|
|
3901
|
+
test("projectionDetail relatedList defaultSort.field sortable → kein Throw", () => {
|
|
3902
|
+
const feature = defineFeature("shop", (r) => {
|
|
3903
|
+
r.queryHandler("order-detail", z.object({}), async () => ({ id: "o1" }), {
|
|
3904
|
+
access: { openToAll: true },
|
|
3905
|
+
});
|
|
3906
|
+
r.queryHandler(
|
|
3907
|
+
"order-positions",
|
|
3908
|
+
z.object({}),
|
|
3909
|
+
async () => ({ rows: [], nextCursor: null }),
|
|
3910
|
+
{ access: { openToAll: true } },
|
|
3911
|
+
);
|
|
3912
|
+
r.screen({
|
|
3913
|
+
id: "order-detail",
|
|
3914
|
+
type: "projectionDetail",
|
|
3915
|
+
query: "shop:query:order-detail",
|
|
3916
|
+
layout: {
|
|
3917
|
+
sections: [
|
|
3918
|
+
{
|
|
3919
|
+
kind: "relatedList",
|
|
3920
|
+
title: "Positions",
|
|
3921
|
+
query: "shop:query:order-positions",
|
|
3922
|
+
columns: [{ field: "name", sortable: true }],
|
|
3923
|
+
defaultSort: { field: "name", dir: "asc" },
|
|
3924
|
+
},
|
|
3925
|
+
],
|
|
3926
|
+
},
|
|
3927
|
+
});
|
|
3928
|
+
});
|
|
3929
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
3930
|
+
});
|
|
3854
3931
|
});
|
|
3855
3932
|
|
|
3856
3933
|
// --- Screen short-id collision across features ---
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// restore() used to load its target row with selectMany(db.raw, ...) — a raw,
|
|
2
|
+
// unfiltered read. Any caller holding the write role could therefore un-delete
|
|
3
|
+
// a soft-deleted row belonging to another tenant and got the decrypted row back
|
|
4
|
+
// in the response. delete() never had that hole (it goes through the
|
|
5
|
+
// tenant-scoped loadById). These tests pin both halves: the tenant-scoped
|
|
6
|
+
// handler must not reach across, and the crossTenant handler must still be able
|
|
7
|
+
// to.
|
|
8
|
+
//
|
|
9
|
+
// Two stacks on the SAME Postgres database: one registers the restore handler
|
|
10
|
+
// without `crossTenant`, the other with it — a clean A/B on the one flag.
|
|
11
|
+
|
|
12
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
13
|
+
import { selectMany } from "../../db/query";
|
|
14
|
+
import { buildEntityTable } from "../../db/table-builder";
|
|
15
|
+
import {
|
|
16
|
+
setupTestStack,
|
|
17
|
+
type TestStack,
|
|
18
|
+
TestUsers,
|
|
19
|
+
testTenantId,
|
|
20
|
+
unsafeCreateEntityTable,
|
|
21
|
+
} from "../../stack";
|
|
22
|
+
import {
|
|
23
|
+
createEntity,
|
|
24
|
+
createTextField,
|
|
25
|
+
defineEntityCreateHandler,
|
|
26
|
+
defineEntityDeleteHandler,
|
|
27
|
+
defineEntityRestoreHandler,
|
|
28
|
+
defineFeature,
|
|
29
|
+
from,
|
|
30
|
+
} from "../index";
|
|
31
|
+
|
|
32
|
+
const thingEntity = createEntity({
|
|
33
|
+
table: "ctrestore_things",
|
|
34
|
+
softDelete: true,
|
|
35
|
+
fields: {
|
|
36
|
+
label: createTextField({ required: true }),
|
|
37
|
+
},
|
|
38
|
+
access: { write: { Admin: "all", SystemAdmin: from("user:tenantId", "tenantId") } },
|
|
39
|
+
});
|
|
40
|
+
const thingTable = buildEntityTable("thing", thingEntity);
|
|
41
|
+
|
|
42
|
+
function buildFeature(crossTenant: boolean) {
|
|
43
|
+
return defineFeature("ctrestore", (r) => {
|
|
44
|
+
r.entity("thing", thingEntity);
|
|
45
|
+
r.writeHandler(
|
|
46
|
+
defineEntityCreateHandler("thing", thingEntity, {
|
|
47
|
+
access: { roles: ["Admin", "SystemAdmin"] },
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
r.writeHandler(
|
|
51
|
+
defineEntityDeleteHandler("thing", thingEntity, {
|
|
52
|
+
access: { roles: ["Admin", "SystemAdmin"] },
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
r.writeHandler(
|
|
56
|
+
defineEntityRestoreHandler("thing", thingEntity, {
|
|
57
|
+
access: { roles: ["Admin", "SystemAdmin"] },
|
|
58
|
+
...(crossTenant && { crossTenant: true }),
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const dbName = `kumiko_test_ctrestore_${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`;
|
|
65
|
+
|
|
66
|
+
let tenantScopedStack: TestStack;
|
|
67
|
+
let crossTenantStack: TestStack;
|
|
68
|
+
|
|
69
|
+
beforeAll(async () => {
|
|
70
|
+
tenantScopedStack = await setupTestStack({ features: [buildFeature(false)], dbName });
|
|
71
|
+
await unsafeCreateEntityTable(tenantScopedStack.db, thingEntity, "thing");
|
|
72
|
+
crossTenantStack = await setupTestStack({
|
|
73
|
+
features: [buildFeature(true)],
|
|
74
|
+
dbName,
|
|
75
|
+
persistentDb: true,
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
afterAll(async () => {
|
|
80
|
+
await crossTenantStack.cleanup();
|
|
81
|
+
await tenantScopedStack.cleanup();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
async function createDeletedThing(label: string): Promise<string> {
|
|
85
|
+
const created = await tenantScopedStack.http.writeOk<{ id: string }>(
|
|
86
|
+
"ctrestore:write:thing:create",
|
|
87
|
+
{ label },
|
|
88
|
+
TestUsers.otherTenant,
|
|
89
|
+
);
|
|
90
|
+
await tenantScopedStack.http.writeOk(
|
|
91
|
+
"ctrestore:write:thing:delete",
|
|
92
|
+
{ id: created.id },
|
|
93
|
+
TestUsers.otherTenant,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const rows = await selectMany(tenantScopedStack.db, thingTable, { id: created.id });
|
|
97
|
+
expect(rows[0]?.["isDeleted"]).toBe(true);
|
|
98
|
+
|
|
99
|
+
return created.id;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
describe("entity restore: tenant isolation", () => {
|
|
103
|
+
test("tenant-scoped restore cannot un-delete a foreign tenant's row", async () => {
|
|
104
|
+
const id = await createDeletedThing("foreign-deleted");
|
|
105
|
+
|
|
106
|
+
const err = await tenantScopedStack.http.writeErr(
|
|
107
|
+
"ctrestore:write:thing:restore",
|
|
108
|
+
{ id },
|
|
109
|
+
TestUsers.admin,
|
|
110
|
+
);
|
|
111
|
+
expect(err.code).toBe("not_found");
|
|
112
|
+
|
|
113
|
+
const rows = await selectMany(tenantScopedStack.db, thingTable, { id });
|
|
114
|
+
expect(rows[0]?.["isDeleted"]).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("own-tenant restore still works", async () => {
|
|
118
|
+
const id = await createDeletedThing("own-restore");
|
|
119
|
+
|
|
120
|
+
await tenantScopedStack.http.writeOk(
|
|
121
|
+
"ctrestore:write:thing:restore",
|
|
122
|
+
{ id },
|
|
123
|
+
TestUsers.otherTenant,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const rows = await selectMany(tenantScopedStack.db, thingTable, { id });
|
|
127
|
+
expect(rows[0]?.["isDeleted"]).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("crossTenant restore still reaches a foreign tenant's row", async () => {
|
|
131
|
+
const id = await createDeletedThing("crosstenant-restore");
|
|
132
|
+
|
|
133
|
+
await crossTenantStack.http.writeOk(
|
|
134
|
+
"ctrestore:write:thing:restore",
|
|
135
|
+
{ id },
|
|
136
|
+
TestUsers.systemAdmin,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const rows = await selectMany(tenantScopedStack.db, thingTable, { id });
|
|
140
|
+
expect(rows[0]?.["isDeleted"]).toBe(false);
|
|
141
|
+
expect(rows[0]?.["tenantId"]).toBe(testTenantId(2));
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -11,7 +11,7 @@ import type { OwnershipMap, WhereRule } from "../../types/ownership";
|
|
|
11
11
|
import { validateOwnershipRules } from "../ownership";
|
|
12
12
|
|
|
13
13
|
const NO_CLAIMS: ReadonlyMap<string, ClaimKeyDefinition> = new Map();
|
|
14
|
-
// Empty corpus keeps
|
|
14
|
+
// Empty corpus keeps canValidateRoles() false, so role-existence checks
|
|
15
15
|
// stay out of the way of the where-rule assertions below.
|
|
16
16
|
const NO_ROLES: ReadonlySet<string> = new Set();
|
|
17
17
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { validateEntityFieldEncryptionAvailable } from "../../db/entity-field-encryption";
|
|
2
2
|
import { QnTypes, qualifyEntityName } from "../qualified-name";
|
|
3
|
-
import type {
|
|
3
|
+
import type { FeatureDefinition } from "../types";
|
|
4
4
|
import { warnOnUniqueAccessRoles } from "./access-roles";
|
|
5
5
|
import { validateActionWiring, validateFieldWiring } from "./action-wiring";
|
|
6
6
|
import { validateApiExposureMatching, validateExtensionUsages } from "./api-ext";
|
|
@@ -44,7 +44,7 @@ import {
|
|
|
44
44
|
validateNavs,
|
|
45
45
|
warnOnNavAccessInversion,
|
|
46
46
|
} from "./nav";
|
|
47
|
-
import { validateOwnershipRules } from "./ownership";
|
|
47
|
+
import { collectClaimKeys, validateOwnershipRules } from "./ownership";
|
|
48
48
|
import { validatePiiAndRetention } from "./pii-retention";
|
|
49
49
|
import { validateProjectionListScreens } from "./projection-list-screens";
|
|
50
50
|
import { validateQueryOutputColumns } from "./query-output-columns";
|
|
@@ -119,12 +119,7 @@ export function validateBoot(
|
|
|
119
119
|
// `from("claim:<feature>:<key>")` strings against this map. Qualified name
|
|
120
120
|
// is how the resolver / readClaim / ownership system all reference claims,
|
|
121
121
|
// so we key on the qualifiedName here too.
|
|
122
|
-
const allClaimKeys =
|
|
123
|
-
for (const f of features) {
|
|
124
|
-
for (const def of Object.values(f.claimKeys)) {
|
|
125
|
-
allClaimKeys.set(def.qualifiedName, def);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
122
|
+
const allClaimKeys = collectClaimKeys(features);
|
|
128
123
|
|
|
129
124
|
// Cross-feature role set — derived from handler-access rules + framework
|
|
130
125
|
// built-ins ("all", "system"). We don't have a dedicated role-registry
|
|
@@ -44,10 +44,40 @@ export type WhereRuleProbe = {
|
|
|
44
44
|
// SQL, so it is the only path on which a where-rule is admissible.
|
|
45
45
|
export type AccessPath = "read" | "write";
|
|
46
46
|
|
|
47
|
+
// Qualified-name → definition map of every claim key in the mounted feature
|
|
48
|
+
// set. `from("claim:<feature>:<key>")` rules resolve against exactly this.
|
|
49
|
+
export function collectClaimKeys(
|
|
50
|
+
features: readonly FeatureDefinition[],
|
|
51
|
+
): Map<string, ClaimKeyDefinition> {
|
|
52
|
+
const claimKeys = new Map<string, ClaimKeyDefinition>();
|
|
53
|
+
for (const f of features) {
|
|
54
|
+
for (const def of Object.values(f.claimKeys)) {
|
|
55
|
+
claimKeys.set(def.qualifiedName, def);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return claimKeys;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Subset-safe ownership validation for callers that mount a deliberate slice
|
|
62
|
+
// of the app rather than all of it (setupTestStack). Both cross-feature
|
|
63
|
+
// corpora go in as undefined — a three-feature stack genuinely does not know
|
|
64
|
+
// which roles and claims the whole app declares, and answering that question
|
|
65
|
+
// from a slice produces false "unknown role"/"unknown claim" errors. The
|
|
66
|
+
// subset-invariant checks (#2639 where-lint, #2626 where-on-write, unknown
|
|
67
|
+
// column) run in full: those hold or fail per feature, whatever else is
|
|
68
|
+
// mounted.
|
|
69
|
+
export function validateOwnershipBoot(features: readonly FeatureDefinition[]): void {
|
|
70
|
+
for (const feature of features) {
|
|
71
|
+
validateOwnershipRules(feature, undefined, undefined);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// undefined corpus = "this caller cannot answer that question", not "skip the
|
|
76
|
+
// check" — see validateOwnershipBoot. validateBoot passes the real corpora.
|
|
47
77
|
export function validateOwnershipRules(
|
|
48
78
|
feature: FeatureDefinition,
|
|
49
|
-
allClaimKeys: ReadonlyMap<string, ClaimKeyDefinition
|
|
50
|
-
knownRoles: ReadonlySet<string
|
|
79
|
+
allClaimKeys: ReadonlyMap<string, ClaimKeyDefinition> | undefined,
|
|
80
|
+
knownRoles: ReadonlySet<string> | undefined,
|
|
51
81
|
): void {
|
|
52
82
|
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
53
83
|
const columnNames = new Set<string>(Object.keys(entity.fields));
|
|
@@ -126,8 +156,8 @@ export function validateOwnershipRules(
|
|
|
126
156
|
export function checkFieldAccess(args: {
|
|
127
157
|
readonly access: OwnershipMap | readonly string[] | undefined;
|
|
128
158
|
readonly columnNames: ReadonlySet<string>;
|
|
129
|
-
readonly allClaimKeys: ReadonlyMap<string, ClaimKeyDefinition
|
|
130
|
-
readonly knownRoles: ReadonlySet<string
|
|
159
|
+
readonly allClaimKeys: ReadonlyMap<string, ClaimKeyDefinition> | undefined;
|
|
160
|
+
readonly knownRoles: ReadonlySet<string> | undefined;
|
|
131
161
|
readonly scope: string;
|
|
132
162
|
readonly featureName: string;
|
|
133
163
|
readonly probe?: WhereRuleProbe;
|
|
@@ -162,12 +192,12 @@ export function checkFieldAccess(args: {
|
|
|
162
192
|
|
|
163
193
|
export function checkLegacyRoleList(
|
|
164
194
|
roles: readonly string[],
|
|
165
|
-
knownRoles: ReadonlySet<string
|
|
195
|
+
knownRoles: ReadonlySet<string> | undefined,
|
|
166
196
|
scope: string,
|
|
167
197
|
featureName: string,
|
|
168
198
|
): void {
|
|
169
|
-
// skip:
|
|
170
|
-
if (!
|
|
199
|
+
// skip: corpus cannot answer "is this role real?" — see canValidateRoles
|
|
200
|
+
if (!canValidateRoles(knownRoles)) return;
|
|
171
201
|
for (const roleName of roles) {
|
|
172
202
|
if (!knownRoles.has(roleName)) {
|
|
173
203
|
throw new Error(buildUnknownRoleMessage(roleName, knownRoles, scope, featureName));
|
|
@@ -175,13 +205,15 @@ export function checkLegacyRoleList(
|
|
|
175
205
|
}
|
|
176
206
|
}
|
|
177
207
|
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
208
|
+
// A role corpus can only answer "is this role real?" when it actually holds
|
|
209
|
+
// app-defined roles. undefined means the caller could not derive one at all
|
|
210
|
+
// (a test stack mounts a deliberate subset of the app), and a corpus of
|
|
211
|
+
// nothing but "all"/"system" belongs to an app whose handlers never declare
|
|
212
|
+
// a role — in both cases every app-defined role would flag as unknown.
|
|
213
|
+
export function canValidateRoles(
|
|
214
|
+
knownRoles: ReadonlySet<string> | undefined,
|
|
215
|
+
): knownRoles is ReadonlySet<string> {
|
|
216
|
+
if (!knownRoles) return false;
|
|
185
217
|
for (const r of knownRoles) {
|
|
186
218
|
if (r !== "all" && r !== "system") return true;
|
|
187
219
|
}
|
|
@@ -191,8 +223,8 @@ export function shouldValidateRoles(knownRoles: ReadonlySet<string>): boolean {
|
|
|
191
223
|
export function checkOwnershipMap(args: {
|
|
192
224
|
readonly map: OwnershipMap;
|
|
193
225
|
readonly columnNames: ReadonlySet<string>;
|
|
194
|
-
readonly allClaimKeys: ReadonlyMap<string, ClaimKeyDefinition
|
|
195
|
-
readonly knownRoles: ReadonlySet<string
|
|
226
|
+
readonly allClaimKeys: ReadonlyMap<string, ClaimKeyDefinition> | undefined;
|
|
227
|
+
readonly knownRoles: ReadonlySet<string> | undefined;
|
|
196
228
|
readonly scope: string;
|
|
197
229
|
readonly featureName: string;
|
|
198
230
|
readonly probe?: WhereRuleProbe;
|
|
@@ -201,9 +233,8 @@ export function checkOwnershipMap(args: {
|
|
|
201
233
|
for (const [roleName, rawRule] of Object.entries(args.map)) {
|
|
202
234
|
// Role-existence check — typos like `{"Admi": "all"}` where no handler
|
|
203
235
|
// or other map mentions "Admi" would otherwise silently grant nothing.
|
|
204
|
-
//
|
|
205
|
-
|
|
206
|
-
if (shouldValidateRoles(args.knownRoles) && !args.knownRoles.has(roleName)) {
|
|
236
|
+
// Cross-feature, so it needs a corpus that can answer the question.
|
|
237
|
+
if (canValidateRoles(args.knownRoles) && !args.knownRoles.has(roleName)) {
|
|
207
238
|
throw new Error(
|
|
208
239
|
buildUnknownRoleMessage(roleName, args.knownRoles, args.scope, args.featureName),
|
|
209
240
|
);
|
|
@@ -216,12 +247,14 @@ export function checkOwnershipMap(args: {
|
|
|
216
247
|
if (args.path === "write") {
|
|
217
248
|
throw new Error(buildWhereOnWritePathMessage(roleName, args.scope, args.featureName));
|
|
218
249
|
}
|
|
219
|
-
probeWhereRule(rule, roleName, args.probe, args.scope);
|
|
250
|
+
probeWhereRule(rule, roleName, args.probe, args.scope, args.featureName);
|
|
220
251
|
continue; // escape hatch — feature author owns the SQL
|
|
221
252
|
}
|
|
222
253
|
|
|
223
254
|
// FromRule — validate ref + column.
|
|
224
|
-
|
|
255
|
+
// Claim-existence is cross-feature: only the whole-app corpus can tell a
|
|
256
|
+
// typo from a claim declared in a feature this caller did not mount.
|
|
257
|
+
if (rule.refKind === "claim" && args.allClaimKeys) {
|
|
225
258
|
// refPath is the qualified claim name ("feature:shortName").
|
|
226
259
|
const claim = args.allClaimKeys.get(rule.refPath);
|
|
227
260
|
if (!claim) {
|
|
@@ -258,6 +291,7 @@ export function probeWhereRule(
|
|
|
258
291
|
roleName: string,
|
|
259
292
|
probe: WhereRuleProbe | undefined,
|
|
260
293
|
scope: string,
|
|
294
|
+
featureName: string,
|
|
261
295
|
): void {
|
|
262
296
|
// skip: no probe means the entity's column set could not be derived above;
|
|
263
297
|
// without it there is nothing to check the rule's SQL against, so the
|
|
@@ -277,7 +311,11 @@ export function probeWhereRule(
|
|
|
277
311
|
// skip: a rule producing no SQL fragment for the probe user has nothing to
|
|
278
312
|
// lint here; ruleToFragment still validates it against a real request.
|
|
279
313
|
if (typeof fragment?.sqlText !== "string") return;
|
|
280
|
-
assertQualifiedWhereFragment(
|
|
314
|
+
assertQualifiedWhereFragment(
|
|
315
|
+
fragment.sqlText,
|
|
316
|
+
probe.columns,
|
|
317
|
+
`${scope} (role "${roleName}", feature: "${featureName}")`,
|
|
318
|
+
);
|
|
281
319
|
}
|
|
282
320
|
|
|
283
321
|
export function buildWhereOnWritePathMessage(
|
|
@@ -846,6 +846,23 @@ export function validateScreens(
|
|
|
846
846
|
);
|
|
847
847
|
}
|
|
848
848
|
}
|
|
849
|
+
if (section.defaultSort !== undefined) {
|
|
850
|
+
const sortField = section.defaultSort.field;
|
|
851
|
+
const col = section.columns.find((c) => normalizeListColumn(c).field === sortField);
|
|
852
|
+
if (col === undefined) {
|
|
853
|
+
throw new Error(
|
|
854
|
+
`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
|
|
855
|
+
`(relatedList) defaultSort.field "${sortField}" is not a listed column.`,
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
if (normalizeListColumn(col).sortable !== true) {
|
|
859
|
+
throw new Error(
|
|
860
|
+
`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
|
|
861
|
+
`(relatedList) defaultSort.field "${sortField}" is not sortable. Set sortable: true on ` +
|
|
862
|
+
`the column or pick another field.`,
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
849
866
|
continue;
|
|
850
867
|
}
|
|
851
868
|
if (isWriteFormEditSection(section)) {
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// setupTestStack runs the ownership boot-guards (fw#2639 unqualified
|
|
2
|
+
// where-fragments, fw#2626 where-rules on the write path). Before this, only
|
|
3
|
+
// the prod boot ran validateBoot, so a feature with a broken access map came
|
|
4
|
+
// through every test suite green and failed on the first real deploy.
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import {
|
|
8
|
+
createEntity,
|
|
9
|
+
createTextField,
|
|
10
|
+
defineEntityQueryHandler,
|
|
11
|
+
defineFeature,
|
|
12
|
+
from,
|
|
13
|
+
} from "../../engine";
|
|
14
|
+
import type { WhereRule } from "../../engine/types";
|
|
15
|
+
import { setupTestStack } from "../test-stack";
|
|
16
|
+
|
|
17
|
+
// Bare `owner_id` inside the subquery binds to fwbootguard_shares, not to the
|
|
18
|
+
// outer memo table — the predicate collapses into a tautology and grants every
|
|
19
|
+
// row. This is the exact fail-open shape fw#2639 lints for.
|
|
20
|
+
const unqualifiedReadRule: WhereRule = {
|
|
21
|
+
kind: "where",
|
|
22
|
+
where: (user, ctx) => ({
|
|
23
|
+
sqlText: `${ctx.tableName}.id IN (SELECT memo_id FROM fwbootguard_shares WHERE owner_id = $${ctx.paramStart})`,
|
|
24
|
+
params: [user.id],
|
|
25
|
+
}),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const qualifiedWriteRule: WhereRule = {
|
|
29
|
+
kind: "where",
|
|
30
|
+
where: (user, ctx) => ({
|
|
31
|
+
sqlText: `${ctx.tableName}.owner_id = $${ctx.paramStart}`,
|
|
32
|
+
params: [user.id],
|
|
33
|
+
}),
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function memoEntity(access: Parameters<typeof createEntity>[0]["access"]) {
|
|
37
|
+
return createEntity({
|
|
38
|
+
table: "fwbootguard_memos",
|
|
39
|
+
fields: {
|
|
40
|
+
ownerId: createTextField({ required: true }),
|
|
41
|
+
title: createTextField({ required: true }),
|
|
42
|
+
},
|
|
43
|
+
...(access ? { access } : {}),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function featureWith(access: Parameters<typeof createEntity>[0]["access"]) {
|
|
48
|
+
const entity = memoEntity(access);
|
|
49
|
+
return defineFeature("fwbootguard", (r) => {
|
|
50
|
+
r.entity("memo", entity);
|
|
51
|
+
r.queryHandler(
|
|
52
|
+
defineEntityQueryHandler("memo:detail", entity, { access: { roles: ["Member"] } }),
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("setupTestStack — ownership boot-guards", () => {
|
|
58
|
+
test("rejects an unqualified where-fragment on access.read (fw#2639)", async () => {
|
|
59
|
+
const promise = setupTestStack({
|
|
60
|
+
features: [featureWith({ read: { Member: unqualifiedReadRule } })],
|
|
61
|
+
});
|
|
62
|
+
await expect(promise).rejects.toThrow(
|
|
63
|
+
/references column "owner_id" unqualified inside a subquery/,
|
|
64
|
+
);
|
|
65
|
+
await expect(promise).rejects.toThrow(/entity "memo"\.access\.read/);
|
|
66
|
+
await expect(promise).rejects.toThrow(/role "Member", feature: "fwbootguard"/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("rejects a where-rule on access.write (fw#2626)", async () => {
|
|
70
|
+
const promise = setupTestStack({
|
|
71
|
+
features: [featureWith({ read: { Member: "all" }, write: { Member: qualifiedWriteRule } })],
|
|
72
|
+
});
|
|
73
|
+
await expect(promise).rejects.toThrow(/entity "memo"\.access\.write/);
|
|
74
|
+
await expect(promise).rejects.toThrow(/feature: "fwbootguard"/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("a qualified read-rule still boots", async () => {
|
|
78
|
+
const stack = await setupTestStack({
|
|
79
|
+
features: [featureWith({ read: { Member: qualifiedWriteRule } })],
|
|
80
|
+
});
|
|
81
|
+
expect(stack.registry.getEntity("memo")?.table).toBe("fwbootguard_memos");
|
|
82
|
+
await stack.cleanup();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// The role and claim corpora are whole-app facts. A stack that mounts a
|
|
86
|
+
// deliberate subset cannot answer them, so those two checks stay out of the
|
|
87
|
+
// test-stack path while the subset-invariant where-lint keeps firing on the
|
|
88
|
+
// very same map. Without this test, the next "fix" to collectKnownRoles
|
|
89
|
+
// quietly reinstates the false positives it removed.
|
|
90
|
+
test("a subset stack skips the cross-feature checks but still lints where-fragments", async () => {
|
|
91
|
+
const undeclaredRefs = {
|
|
92
|
+
Auditor: "all",
|
|
93
|
+
Reviewer: from("claim:fwbootguardelsewhere:teamId", "ownerId"),
|
|
94
|
+
} as const;
|
|
95
|
+
|
|
96
|
+
const stack = await setupTestStack({
|
|
97
|
+
features: [featureWith({ read: { ...undeclaredRefs, Member: "all" } })],
|
|
98
|
+
});
|
|
99
|
+
expect(stack.registry.getEntity("memo")?.table).toBe("fwbootguard_memos");
|
|
100
|
+
await stack.cleanup();
|
|
101
|
+
|
|
102
|
+
await expect(
|
|
103
|
+
setupTestStack({
|
|
104
|
+
features: [featureWith({ read: { ...undeclaredRefs, Member: unqualifiedReadRule } })],
|
|
105
|
+
}),
|
|
106
|
+
).rejects.toThrow(/references column "owner_id" unqualified inside a subquery/);
|
|
107
|
+
});
|
|
108
|
+
});
|
package/src/stack/test-stack.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { buildServer } from "../api/server";
|
|
|
5
5
|
import { createSseBroker, type SseBroker } from "../api/sse-broker";
|
|
6
6
|
import type { PgClient } from "../db/connection";
|
|
7
7
|
import { extractTableInfo } from "../db/query";
|
|
8
|
+
import { validateOwnershipBoot } from "../engine/boot-validator/ownership";
|
|
8
9
|
import { createRegistry } from "../engine/registry";
|
|
9
10
|
import type { AppContext, FeatureDefinition, JobRunIn, Registry, TenantId } from "../engine/types";
|
|
10
11
|
import { createArchivedStreamsTable, createEventsTable } from "../event-store";
|
|
@@ -180,6 +181,14 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
|
|
|
180
181
|
const { ensureTemporalPolyfill } = await import("../time/polyfill");
|
|
181
182
|
await ensureTemporalPolyfill();
|
|
182
183
|
|
|
184
|
+
// Ownership boot-guards (fw#2639 unqualified where-fragments, fw#2626
|
|
185
|
+
// where-rules on the write path) otherwise run only in the prod boot via
|
|
186
|
+
// validateBoot, leaving every test stack blind to a broken access map.
|
|
187
|
+
// Runs after the polyfill (buildEntityTable needs it to derive the probe
|
|
188
|
+
// columns) and before the ephemeral DB/Redis exist, so a throw can't leak
|
|
189
|
+
// a database that nothing will clean up.
|
|
190
|
+
validateOwnershipBoot(options.features);
|
|
191
|
+
|
|
183
192
|
// Forward db-name/persistent-flag through to createTestDb. The
|
|
184
193
|
// defaults (undefined dbName, persistent:false) keep the legacy
|
|
185
194
|
// test contract: fresh kumiko_test_<random> DB per setup, dropped
|