rastack 0.0.51 → 0.0.52
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/CHANGELOG.md +2 -0
- package/auth/context.ts +73 -0
- package/auth/core.ts +293 -0
- package/auth/index.ts +23 -0
- package/components/auto-form/AutoForm.tsx +32 -12
- package/components/auto-form/use-auto-form.ts +75 -6
- package/components/data-table/DataTable.tsx +72 -45
- package/components/data-table/use-data-table.ts +57 -3
- package/components/index.ts +2 -0
- package/db.ts +9 -0
- package/dist/admin.js +13 -13
- package/dist/compile/analyze.js +7 -2
- package/dist/compile/entities.js +163 -16
- package/dist/compile/model.d.ts +7 -3
- package/dist/compile/openapi.js +13 -3
- package/dist/compile/program.d.ts +2 -1
- package/dist/compile/program.js +7 -2
- package/dist/define/auth.d.ts +27 -0
- package/dist/define/auth.js +42 -0
- package/dist/define/db.d.ts +24 -1
- package/dist/define/db.js +1 -1
- package/dist/define/index.d.ts +19 -2
- package/dist/define/manifest.js +10 -1
- package/dist/define/markers.d.ts +80 -0
- package/dist/define/markers.js +37 -0
- package/dist/define/types.d.ts +10 -0
- package/dist/define/types.js +26 -0
- package/dist/wasm/rastack_wasm.js +1 -1
- package/dist/wasm/rastack_wasm_bg.wasm +0 -0
- package/package.json +1 -1
- package/provider/index.ts +1 -5
- package/provider/provider.tsx +42 -11
- package/provider/types.ts +14 -2
- package/provider/warehouse.ts +4 -3
- package/provider/wasm.ts +3 -1
- package/runtime.ts +1 -0
- package/src/compile/analyze.ts +15 -3
- package/src/compile/entities.ts +193 -19
- package/src/compile/model.ts +8 -9
- package/src/compile/openapi.ts +11 -3
- package/src/compile/program.ts +10 -3
- package/src/define/auth.ts +28 -0
- package/src/define/db.ts +26 -4
- package/src/define/index.ts +20 -2
- package/src/define/manifest.ts +10 -1
- package/src/define/markers.ts +83 -0
- package/src/define/types.ts +11 -0
- package/test/auth.spec.ts +207 -0
- package/test/compile.spec.ts +18 -7
- package/test/components.spec.ts +116 -0
- package/test/entities.spec.ts +123 -6
- package/types.ts +7 -2
- package/wasm/rastack_wasm.js +1 -1
- package/wasm/rastack_wasm_bg.wasm +0 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entity-level authorization markers — auth as *TypeScript types*.
|
|
3
|
+
*
|
|
4
|
+
* Class and interface entities have no options bag, so their access policy is
|
|
5
|
+
* expressed the same way their columns are: in the type system. Attach a
|
|
6
|
+
* marker with `implements` (classes) or `extends` (interfaces) and the
|
|
7
|
+
* compiler reads the policy straight off the heritage clause:
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* import { AdminGroups, IsAuthenticated, Owner, Group } from "rastack/auth";
|
|
11
|
+
*
|
|
12
|
+
* // Per-user rows: RLS on `owner`, staff sees everything.
|
|
13
|
+
* export class Itinerary implements IsAuthenticated, AdminGroups<"staff"> {
|
|
14
|
+
* owner!: string & Owner;
|
|
15
|
+
* notes!: string;
|
|
16
|
+
* }
|
|
17
|
+
*
|
|
18
|
+
* // Group-owned dataset: rows belong to a team and are *physically stored*
|
|
19
|
+
* // under that team's own storage prefix (groups/{team}/…).
|
|
20
|
+
* export class Report implements IsAuthenticated, AdminGroups<"staff"> {
|
|
21
|
+
* team!: string & Group;
|
|
22
|
+
* title!: string;
|
|
23
|
+
* }
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* The markers are phantom types: every property is optional and never exists
|
|
27
|
+
* at runtime, so `implements` needs no members and instances stay plain data.
|
|
28
|
+
* Each marker family uses its own phantom key, so markers combine freely —
|
|
29
|
+
* while two *permission* markers on one entity conflict in the type checker,
|
|
30
|
+
* which is exactly right.
|
|
31
|
+
*
|
|
32
|
+
* JSDoc equivalents exist for every marker (`@rastackPermission`,
|
|
33
|
+
* `@rastackAdminGroups staff, ops`, `@rastackScope owner|group|shared`, and
|
|
34
|
+
* field-level `@owner` / `@group`), mirroring the column-constraint tags.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** The permission gate carried in the type system. */
|
|
38
|
+
export interface PermissionOf<P extends string> {
|
|
39
|
+
readonly __rastackPermission?: P;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Every path requires a verified identity (this is also the default). */
|
|
43
|
+
export interface IsAuthenticated extends PermissionOf<"authenticated"> {}
|
|
44
|
+
|
|
45
|
+
/** Anonymous callers may read (list/retrieve); writes require identity. */
|
|
46
|
+
export interface IsAuthenticatedOrReadOnly extends PermissionOf<"authenticatedOrReadOnly"> {}
|
|
47
|
+
|
|
48
|
+
/** No gate — every caller may read and write. Opt-in, never the default. */
|
|
49
|
+
export interface IsPublic extends PermissionOf<"public"> {}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Token groups (`cognito:groups`) that bypass row-level security on this
|
|
53
|
+
* entity — the "is admin" role, driven from scopes: `implements
|
|
54
|
+
* AdminGroups<"staff" | "ops">`. Members see and act on every row.
|
|
55
|
+
*/
|
|
56
|
+
export interface AdminGroups<G extends string> {
|
|
57
|
+
readonly __rastackAdminGroups?: G;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Storage scope carried in the type system. */
|
|
61
|
+
export interface ScopeOf<S extends string> {
|
|
62
|
+
readonly __rastackScope?: S;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Physically partition the entity's table per identity
|
|
67
|
+
* (`tenants/{sub}/…`) — the prefix object-storage IAM can fence per user.
|
|
68
|
+
* Combine with an `Owner` field brand.
|
|
69
|
+
*/
|
|
70
|
+
export interface OwnerScoped extends ScopeOf<"owner"> {}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Physically partition the entity's table per group (`groups/{name}/…`).
|
|
74
|
+
* This is the default whenever a `Group` field brand is present — declare it
|
|
75
|
+
* only for emphasis or with a JSDoc-configured group field.
|
|
76
|
+
*/
|
|
77
|
+
export interface GroupScoped extends ScopeOf<"group"> {}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Keep rows in one shared table (row-level filters still apply). Use to opt a
|
|
81
|
+
* `Group`-branded entity out of the default per-group physical partitioning.
|
|
82
|
+
*/
|
|
83
|
+
export interface SharedStorage extends ScopeOf<"shared"> {}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `rastack/types` compatibility surface — one module aliasing the two
|
|
3
|
+
* split authoring subpaths: `rastack/db` (the `DbConfig` column brands) and
|
|
4
|
+
* `rastack/auth` (the entity-level auth markers + `Owner`/`Group` brands).
|
|
5
|
+
* Prefer those; everything stays re-exported here so existing imports keep
|
|
6
|
+
* working. The compiler maps all three specifiers (see `compile/program.ts`);
|
|
7
|
+
* the published package re-exports the same modules from `tools/types.ts`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export * from "./db";
|
|
11
|
+
export * from "./markers";
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the `rastack/auth` core — the client-side mirror of the access
|
|
3
|
+
* rules `rastack-api-core` enforces (see `rust/rastack-api-core/tests/access.rs`
|
|
4
|
+
* for the server side of the same matrix). The semantics here must track the
|
|
5
|
+
* Rust `check_permission` / `enforced_owner` exactly: `can()` answers "would
|
|
6
|
+
* the server accept this request?", so the components can limit the paths they
|
|
7
|
+
* expose instead of surfacing 401s.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
anonymousAuth,
|
|
12
|
+
authFromClaims,
|
|
13
|
+
bypassesRls,
|
|
14
|
+
can,
|
|
15
|
+
canActOnRow,
|
|
16
|
+
isAdmin,
|
|
17
|
+
ownsRow,
|
|
18
|
+
policyFromManifest,
|
|
19
|
+
policyFromOpenApi,
|
|
20
|
+
trustedAuth,
|
|
21
|
+
userAuth,
|
|
22
|
+
type ResourcePolicy,
|
|
23
|
+
} from "../auth/core";
|
|
24
|
+
|
|
25
|
+
const rls: ResourcePolicy = {
|
|
26
|
+
permission: "authenticated",
|
|
27
|
+
access: { ownerField: "owner", scope: "owner", adminGroups: ["staff"] },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
describe("rastack/auth — the permission gate (can)", () => {
|
|
31
|
+
it("defaults to authenticated — secure by default, like the server", () => {
|
|
32
|
+
expect(can(anonymousAuth(), {}, "list")).toBe(false);
|
|
33
|
+
expect(can(anonymousAuth(), undefined, "create")).toBe(false);
|
|
34
|
+
expect(can(userAuth("u1"), {}, "list")).toBe(true);
|
|
35
|
+
expect(can(userAuth("u1"), undefined, "delete")).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("public admits everyone, for every action", () => {
|
|
39
|
+
const policy: ResourcePolicy = { permission: "public" };
|
|
40
|
+
for (const action of [
|
|
41
|
+
"list",
|
|
42
|
+
"retrieve",
|
|
43
|
+
"create",
|
|
44
|
+
"update",
|
|
45
|
+
"delete",
|
|
46
|
+
] as const) {
|
|
47
|
+
expect(can(anonymousAuth(), policy, action)).toBe(true);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("authenticatedOrReadOnly admits anonymous reads but not writes", () => {
|
|
52
|
+
const policy: ResourcePolicy = { permission: "authenticatedOrReadOnly" };
|
|
53
|
+
expect(can(anonymousAuth(), policy, "list")).toBe(true);
|
|
54
|
+
expect(can(anonymousAuth(), policy, "retrieve")).toBe(true);
|
|
55
|
+
expect(can(anonymousAuth(), policy, "create")).toBe(false);
|
|
56
|
+
expect(can(anonymousAuth(), policy, "update")).toBe(false);
|
|
57
|
+
expect(can(anonymousAuth(), policy, "delete")).toBe(false);
|
|
58
|
+
expect(can(userAuth("u1"), policy, "delete")).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("row-secured resources need a subject (the server answers 401 without one)", () => {
|
|
62
|
+
// Authenticated but subject-less — e.g. a machine token without `sub`.
|
|
63
|
+
const noSub = { authenticated: true, groups: [] };
|
|
64
|
+
expect(can(noSub, rls, "list")).toBe(false);
|
|
65
|
+
expect(can(userAuth("u1"), rls, "list")).toBe(true);
|
|
66
|
+
// A configured admin group bypasses row-level security.
|
|
67
|
+
expect(can({ ...noSub, groups: ["staff"] }, rls, "list")).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("trusted contexts (dev without auth) pass everything", () => {
|
|
71
|
+
expect(can(trustedAuth(), rls, "delete")).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("a missing identity gates nothing — the server stays the enforcer", () => {
|
|
75
|
+
expect(can(undefined, rls, "delete")).toBe(true);
|
|
76
|
+
expect(can(null, { permission: "authenticated" }, "create")).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("rastack/auth — row-level security helpers", () => {
|
|
81
|
+
const mine = { id: 1, owner: "u1" };
|
|
82
|
+
const theirs = { id: 2, owner: "u2" };
|
|
83
|
+
|
|
84
|
+
it("owners own their rows; others don't", () => {
|
|
85
|
+
expect(ownsRow(userAuth("u1"), rls, mine)).toBe(true);
|
|
86
|
+
expect(ownsRow(userAuth("u1"), rls, theirs)).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("admin groups and trusted contexts bypass the owner filter", () => {
|
|
90
|
+
expect(bypassesRls(userAuth("u1", ["staff"]), rls)).toBe(true);
|
|
91
|
+
expect(ownsRow(userAuth("u1", ["staff"]), rls, theirs)).toBe(true);
|
|
92
|
+
expect(ownsRow(trustedAuth(), rls, theirs)).toBe(true);
|
|
93
|
+
expect(bypassesRls(userAuth("u1", ["other"]), rls)).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("unsecured resources are owned by everyone", () => {
|
|
97
|
+
expect(ownsRow(userAuth("u1"), {}, theirs)).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("canActOnRow combines the gate and ownership", () => {
|
|
101
|
+
expect(canActOnRow(userAuth("u1"), rls, "update", mine)).toBe(true);
|
|
102
|
+
expect(canActOnRow(userAuth("u1"), rls, "update", theirs)).toBe(false);
|
|
103
|
+
expect(canActOnRow(anonymousAuth(), rls, "update", mine)).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("rastack/auth — group-owned datasets", () => {
|
|
108
|
+
// A group dataset: rows belong to a team, physically partitioned per group.
|
|
109
|
+
const grouped: ResourcePolicy = {
|
|
110
|
+
permission: "authenticated",
|
|
111
|
+
access: { groupField: "team", scope: "group", adminGroups: ["staff"] },
|
|
112
|
+
};
|
|
113
|
+
const blueRow = { id: 1, team: "blue" };
|
|
114
|
+
|
|
115
|
+
it("members read; identities in no group can read (empty) but never write", () => {
|
|
116
|
+
const dana = userAuth("dana", ["blue"]);
|
|
117
|
+
const loner = userAuth("lonnie", []);
|
|
118
|
+
expect(can(dana, grouped, "list")).toBe(true);
|
|
119
|
+
expect(can(dana, grouped, "create")).toBe(true);
|
|
120
|
+
// Reads outside any group just come back empty — but a write can never
|
|
121
|
+
// succeed, so the UI shouldn't offer it.
|
|
122
|
+
expect(can(loner, grouped, "list")).toBe(true);
|
|
123
|
+
expect(can(loner, grouped, "create")).toBe(false);
|
|
124
|
+
expect(can(loner, grouped, "delete")).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("row membership mirrors the server's group guard", () => {
|
|
128
|
+
expect(ownsRow(userAuth("dana", ["blue"]), grouped, blueRow)).toBe(true);
|
|
129
|
+
expect(ownsRow(userAuth("erin", ["green"]), grouped, blueRow)).toBe(false);
|
|
130
|
+
expect(
|
|
131
|
+
canActOnRow(userAuth("erin", ["green"]), grouped, "update", blueRow),
|
|
132
|
+
).toBe(false);
|
|
133
|
+
// adminGroups members act across groups.
|
|
134
|
+
expect(ownsRow(userAuth("sam", ["staff"]), grouped, blueRow)).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("owner + group guards both apply when a policy carries both", () => {
|
|
138
|
+
const both: ResourcePolicy = {
|
|
139
|
+
access: { ownerField: "owner", groupField: "team" },
|
|
140
|
+
};
|
|
141
|
+
const row = { id: 1, owner: "dana", team: "blue" };
|
|
142
|
+
expect(ownsRow(userAuth("dana", ["blue"]), both, row)).toBe(true);
|
|
143
|
+
expect(ownsRow(userAuth("dana", ["green"]), both, row)).toBe(false);
|
|
144
|
+
expect(ownsRow(userAuth("erin", ["blue"]), both, row)).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("isAdmin is the scope-driven admin role", () => {
|
|
148
|
+
expect(isAdmin(userAuth("sam", ["staff"]), grouped)).toBe(true);
|
|
149
|
+
expect(isAdmin(userAuth("dana", ["blue"]), grouped)).toBe(false);
|
|
150
|
+
expect(isAdmin(trustedAuth(), grouped)).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("rastack/auth — building an identity", () => {
|
|
155
|
+
it("authFromClaims reads sub and cognito:groups from a decoded token", () => {
|
|
156
|
+
const auth = authFromClaims({
|
|
157
|
+
sub: "u1",
|
|
158
|
+
"cognito:groups": ["staff", 42],
|
|
159
|
+
email: "x@y.z",
|
|
160
|
+
});
|
|
161
|
+
expect(auth).toEqual({ authenticated: true, sub: "u1", groups: ["staff"] });
|
|
162
|
+
expect(authFromClaims({}).authenticated).toBe(false);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("rastack/auth — policy lookup", () => {
|
|
167
|
+
const manifest = {
|
|
168
|
+
resources: [
|
|
169
|
+
{ app: "airports", model: "airport", fields: [] },
|
|
170
|
+
{
|
|
171
|
+
app: "airports",
|
|
172
|
+
model: "itinerary",
|
|
173
|
+
fields: [],
|
|
174
|
+
permission: "authenticated",
|
|
175
|
+
access: { ownerField: "owner", scope: "owner", adminGroups: ["staff"] },
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
it("policyFromManifest extracts permission + access", () => {
|
|
181
|
+
expect(policyFromManifest(manifest, "airports.itinerary")).toEqual(rls);
|
|
182
|
+
expect(
|
|
183
|
+
policyFromManifest(manifest, { app: "airports", model: "airport" }),
|
|
184
|
+
).toEqual({});
|
|
185
|
+
expect(policyFromManifest(manifest, "airports.missing")).toBeUndefined();
|
|
186
|
+
// Accepts the JSON string form the provider may hold.
|
|
187
|
+
expect(
|
|
188
|
+
policyFromManifest(JSON.stringify(manifest), "airports.itinerary"),
|
|
189
|
+
).toEqual(rls);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("policyFromOpenApi reads the x-rastack-permission / x-rastack-access stamps", () => {
|
|
193
|
+
const doc = {
|
|
194
|
+
paths: {
|
|
195
|
+
"/api/airports/v1/itinerary/": {
|
|
196
|
+
get: {
|
|
197
|
+
operationId: "listItinerary",
|
|
198
|
+
"x-rastack-permission": "authenticated",
|
|
199
|
+
"x-rastack-access": rls.access,
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
expect(policyFromOpenApi(doc, "airports.itinerary")).toEqual(rls);
|
|
205
|
+
expect(policyFromOpenApi(doc, "airports.airport")).toBeUndefined();
|
|
206
|
+
});
|
|
207
|
+
});
|
package/test/compile.spec.ts
CHANGED
|
@@ -81,7 +81,10 @@ describe("rastack compile — resource analysis & FK inference", () => {
|
|
|
81
81
|
|
|
82
82
|
const airportField = terminal.fields.find((f) => f.name === "airport");
|
|
83
83
|
expect(airportField?.type).toBe("fk");
|
|
84
|
-
expect(airportField?.relation).toEqual({
|
|
84
|
+
expect(airportField?.relation).toEqual({
|
|
85
|
+
app: "airports",
|
|
86
|
+
model: "airport",
|
|
87
|
+
});
|
|
85
88
|
|
|
86
89
|
expect(terminal.relations).toEqual([
|
|
87
90
|
{ field: "airport", target: "airports.airport", kind: "fk" },
|
|
@@ -132,7 +135,9 @@ describe("rastack compile — resource analysis & FK inference", () => {
|
|
|
132
135
|
});
|
|
133
136
|
|
|
134
137
|
// Resources without an access block stay unstamped.
|
|
135
|
-
expect(
|
|
138
|
+
expect(
|
|
139
|
+
findResource(resources, "airports", "airport").access,
|
|
140
|
+
).toBeUndefined();
|
|
136
141
|
});
|
|
137
142
|
|
|
138
143
|
it("reports no collisions for a valid graph", () => {
|
|
@@ -176,9 +181,9 @@ describe("rastack compile — OpenAPI emission", () => {
|
|
|
176
181
|
// Local-mode sync extensions, confirmed on update.
|
|
177
182
|
const flightList = doc.paths["/api/airports/v1/flight/"];
|
|
178
183
|
expect(flightList.get["x-rastack-sync"]).toBe("local");
|
|
179
|
-
expect(
|
|
180
|
-
"
|
|
181
|
-
);
|
|
184
|
+
expect(
|
|
185
|
+
doc.paths["/api/airports/v1/flight/{id}/"].put["x-rastack-sync"],
|
|
186
|
+
).toBe("confirmed");
|
|
182
187
|
expect(doc.paths["/api/airports/v1/gate/"].get["x-rastack-sync-load"]).toBe(
|
|
183
188
|
"lazy",
|
|
184
189
|
);
|
|
@@ -186,19 +191,25 @@ describe("rastack compile — OpenAPI emission", () => {
|
|
|
186
191
|
doc.paths["/api/airports/v1/booking/"].post["x-rastack-sync-client-ids"],
|
|
187
192
|
).toBe(true);
|
|
188
193
|
|
|
189
|
-
// Access-controlled resource carries the
|
|
194
|
+
// Access-controlled resource carries the full policy on every op — the
|
|
195
|
+
// permission gate plus the access block `rastack/auth` mirrors in the UI.
|
|
190
196
|
const itineraryList = doc.paths["/api/airports/v1/itinerary/"];
|
|
197
|
+
expect(itineraryList.get["x-rastack-permission"]).toBe("authenticated");
|
|
191
198
|
expect(itineraryList.get["x-rastack-access"]).toEqual({
|
|
192
199
|
ownerField: "owner",
|
|
193
200
|
scope: "owner",
|
|
201
|
+
adminGroups: ["staff"],
|
|
194
202
|
});
|
|
195
203
|
expect(
|
|
196
204
|
doc.paths["/api/airports/v1/itinerary/{id}/"].delete["x-rastack-access"],
|
|
197
|
-
).toEqual({ ownerField: "owner", scope: "owner" });
|
|
205
|
+
).toEqual({ ownerField: "owner", scope: "owner", adminGroups: ["staff"] });
|
|
198
206
|
// Unscoped resources stay unstamped.
|
|
199
207
|
expect(
|
|
200
208
|
doc.paths["/api/airports/v1/airport/"].get["x-rastack-access"],
|
|
201
209
|
).toBeUndefined();
|
|
210
|
+
expect(
|
|
211
|
+
doc.paths["/api/airports/v1/airport/"].get["x-rastack-permission"],
|
|
212
|
+
).toBeUndefined();
|
|
202
213
|
});
|
|
203
214
|
|
|
204
215
|
it("round-trips through the existing hooks codegen", async () => {
|
package/test/components.spec.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
resolveFieldSpecs,
|
|
17
17
|
} from "../components/auto-form/fields";
|
|
18
18
|
import { buildAutoFormModel } from "../components/auto-form/use-auto-form";
|
|
19
|
+
import { anonymousAuth, userAuth, type ResourcePolicy } from "../auth/core";
|
|
19
20
|
import type {
|
|
20
21
|
TableQuery,
|
|
21
22
|
AutoFormLike,
|
|
@@ -406,3 +407,118 @@ describe("buildAutoFormModel — wiring a form hook into field descriptors", ()
|
|
|
406
407
|
expect(transition).toHaveBeenCalledWith("board");
|
|
407
408
|
});
|
|
408
409
|
});
|
|
410
|
+
|
|
411
|
+
// Auth handling built into the components: the models mirror the server's
|
|
412
|
+
// `permission` gate + row-level security (rastack/auth) so the UI never
|
|
413
|
+
// offers a write the API would reject.
|
|
414
|
+
describe("component models — authorization gating", () => {
|
|
415
|
+
const rls: ResourcePolicy = {
|
|
416
|
+
permission: "authenticated",
|
|
417
|
+
access: { ownerField: "owner", scope: "owner", adminGroups: ["staff"] },
|
|
418
|
+
};
|
|
419
|
+
const readOnly: ResourcePolicy = { permission: "authenticatedOrReadOnly" };
|
|
420
|
+
|
|
421
|
+
const makeField = (): AutoFormField =>
|
|
422
|
+
({
|
|
423
|
+
value: "",
|
|
424
|
+
error: "",
|
|
425
|
+
type: "text",
|
|
426
|
+
isChecked: false,
|
|
427
|
+
isError: false,
|
|
428
|
+
isDisabled: false,
|
|
429
|
+
isRequired: false,
|
|
430
|
+
setValue: jest.fn(),
|
|
431
|
+
setTouched: jest.fn(),
|
|
432
|
+
}) as unknown as AutoFormField;
|
|
433
|
+
|
|
434
|
+
const form: AutoFormLike = {
|
|
435
|
+
formValues: { owner: "u1", notes: "hi" },
|
|
436
|
+
getFormField: () => makeField(),
|
|
437
|
+
onSubmit: jest.fn(),
|
|
438
|
+
resetForm: jest.fn(),
|
|
439
|
+
isSubmitDisabled: false,
|
|
440
|
+
isSubmitting: false,
|
|
441
|
+
errors: {},
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
it("auto-form: anonymous writes are blocked by the permission gate", () => {
|
|
445
|
+
const model = buildAutoFormModel(form, {
|
|
446
|
+
auth: anonymousAuth(),
|
|
447
|
+
policy: readOnly,
|
|
448
|
+
action: "update",
|
|
449
|
+
});
|
|
450
|
+
expect(model.canWrite).toBe(false);
|
|
451
|
+
expect(model.canSubmit).toBe(false);
|
|
452
|
+
|
|
453
|
+
const asUser = buildAutoFormModel(form, {
|
|
454
|
+
auth: userAuth("u1"),
|
|
455
|
+
policy: readOnly,
|
|
456
|
+
});
|
|
457
|
+
expect(asUser.canWrite).toBe(true);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("auto-form: no identity wired up ⇒ ungated (server stays the enforcer)", () => {
|
|
461
|
+
const model = buildAutoFormModel(form, { policy: rls });
|
|
462
|
+
expect(model.canWrite).toBe(true);
|
|
463
|
+
expect(model.fields.map((f) => f.name)).toEqual(["owner", "notes"]);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it("auto-form: hides the server-stamped owner field, except for RLS bypassers", () => {
|
|
467
|
+
const model = buildAutoFormModel(form, {
|
|
468
|
+
auth: userAuth("u1"),
|
|
469
|
+
policy: rls,
|
|
470
|
+
});
|
|
471
|
+
expect(model.fields.map((f) => f.name)).toEqual(["notes"]);
|
|
472
|
+
|
|
473
|
+
const staff = buildAutoFormModel(form, {
|
|
474
|
+
auth: userAuth("u1", ["staff"]),
|
|
475
|
+
policy: rls,
|
|
476
|
+
});
|
|
477
|
+
expect(staff.fields.map((f) => f.name)).toEqual(["owner", "notes"]);
|
|
478
|
+
|
|
479
|
+
// An explicit field list is the author's call — never overridden.
|
|
480
|
+
const explicit = buildAutoFormModel(form, {
|
|
481
|
+
auth: userAuth("u1"),
|
|
482
|
+
policy: rls,
|
|
483
|
+
fields: ["owner", "notes"],
|
|
484
|
+
});
|
|
485
|
+
expect(explicit.fields.map((f) => f.name)).toEqual(["owner", "notes"]);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it("data-table: canRead / can / canActOnRow mirror the server's rules", () => {
|
|
489
|
+
const query = {
|
|
490
|
+
data: [
|
|
491
|
+
{ id: 1, owner: "u1" },
|
|
492
|
+
{ id: 2, owner: "u2" },
|
|
493
|
+
],
|
|
494
|
+
isLoading: false,
|
|
495
|
+
} as TableQuery<{ id: number; owner: string }>;
|
|
496
|
+
|
|
497
|
+
const anon = buildDataTableModel(query, {
|
|
498
|
+
auth: anonymousAuth(),
|
|
499
|
+
policy: rls,
|
|
500
|
+
});
|
|
501
|
+
expect(anon.canRead).toBe(false);
|
|
502
|
+
|
|
503
|
+
const mine = buildDataTableModel(query, {
|
|
504
|
+
auth: userAuth("u1"),
|
|
505
|
+
policy: rls,
|
|
506
|
+
});
|
|
507
|
+
expect(mine.canRead).toBe(true);
|
|
508
|
+
expect(mine.can("delete")).toBe(true);
|
|
509
|
+
expect(mine.canActOnRow("delete", { id: 1, owner: "u1" })).toBe(true);
|
|
510
|
+
expect(mine.canActOnRow("delete", { id: 2, owner: "u2" })).toBe(false);
|
|
511
|
+
|
|
512
|
+
const anonPublicRead = buildDataTableModel(query, {
|
|
513
|
+
auth: anonymousAuth(),
|
|
514
|
+
policy: readOnly,
|
|
515
|
+
});
|
|
516
|
+
expect(anonPublicRead.canRead).toBe(true);
|
|
517
|
+
expect(anonPublicRead.can("create")).toBe(false);
|
|
518
|
+
|
|
519
|
+
// No identity wired up ⇒ everything stays enabled.
|
|
520
|
+
const ungated = buildDataTableModel(query, { policy: rls });
|
|
521
|
+
expect(ungated.canRead).toBe(true);
|
|
522
|
+
expect(ungated.can("delete")).toBe(true);
|
|
523
|
+
});
|
|
524
|
+
});
|
package/test/entities.spec.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
const workRoot = join(__dirname, "entities-fixtures");
|
|
18
18
|
const appDir = join(workRoot, "app");
|
|
19
19
|
const classesDir = join(workRoot, "classes");
|
|
20
|
+
const authDir = join(workRoot, "auth");
|
|
20
21
|
const cycleDir = join(workRoot, "cycles");
|
|
21
22
|
const hardCycleDir = join(workRoot, "hard-cycles");
|
|
22
23
|
const mixedDir = join(workRoot, "mixed");
|
|
@@ -82,7 +83,7 @@ import {
|
|
|
82
83
|
PrimaryKey,
|
|
83
84
|
Unique,
|
|
84
85
|
UUID,
|
|
85
|
-
} from "rastack/
|
|
86
|
+
} from "rastack/db";
|
|
86
87
|
import { Terminal } from "./airports";
|
|
87
88
|
|
|
88
89
|
/** A reusable column config — "extend DbConfig once, use it everywhere". */
|
|
@@ -184,6 +185,60 @@ export class BoardingService {
|
|
|
184
185
|
export class SplashScreen extends BoardingService {}
|
|
185
186
|
`;
|
|
186
187
|
|
|
188
|
+
const AUTH_TS = `
|
|
189
|
+
import {
|
|
190
|
+
AdminGroups,
|
|
191
|
+
Group,
|
|
192
|
+
IsAuthenticated,
|
|
193
|
+
IsAuthenticatedOrReadOnly,
|
|
194
|
+
Owner,
|
|
195
|
+
OwnerScoped,
|
|
196
|
+
SharedStorage,
|
|
197
|
+
} from "rastack/auth";
|
|
198
|
+
|
|
199
|
+
// Per-user rows: RLS on owner, physically partitioned per identity. The
|
|
200
|
+
// implements clause is policy, not behaviour — still a plain data class.
|
|
201
|
+
export class Itinerary
|
|
202
|
+
implements IsAuthenticated, AdminGroups<"staff">, OwnerScoped
|
|
203
|
+
{
|
|
204
|
+
owner!: string & Owner;
|
|
205
|
+
notes!: string;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// A group-owned dataset — physically partitioned per group by default.
|
|
209
|
+
export class Report
|
|
210
|
+
implements IsAuthenticatedOrReadOnly, AdminGroups<"staff" | "ops">
|
|
211
|
+
{
|
|
212
|
+
team!: string & Group;
|
|
213
|
+
title!: string;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Group RLS, but explicitly opted out of per-group storage.
|
|
217
|
+
export class Note implements SharedStorage {
|
|
218
|
+
team!: string & Group;
|
|
219
|
+
body!: string;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Interfaces attach the same markers with extends.
|
|
223
|
+
export interface Memo extends IsAuthenticated, AdminGroups<"staff"> {
|
|
224
|
+
body: string;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** JSDoc equivalents, tag-for-marker.
|
|
228
|
+
* @rastackResource
|
|
229
|
+
* @rastackPermission public
|
|
230
|
+
* @rastackAdminGroups staff, ops
|
|
231
|
+
*/
|
|
232
|
+
export interface Bulletin {
|
|
233
|
+
/** @owner */
|
|
234
|
+
author: string;
|
|
235
|
+
text: string;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
declare function useEntityList<T>(name: string): T[];
|
|
239
|
+
export const m = useEntityList<Memo>("memos");
|
|
240
|
+
`;
|
|
241
|
+
|
|
187
242
|
function findResource(
|
|
188
243
|
resources: ResourceModel[],
|
|
189
244
|
app: string,
|
|
@@ -202,6 +257,8 @@ beforeAll(() => {
|
|
|
202
257
|
writeFileSync(join(appDir, "screens.tsx"), SCREENS_TSX);
|
|
203
258
|
mkdirSync(classesDir, { recursive: true });
|
|
204
259
|
writeFileSync(join(classesDir, "airports.ts"), CLASSES_TS);
|
|
260
|
+
mkdirSync(authDir, { recursive: true });
|
|
261
|
+
writeFileSync(join(authDir, "workspace.ts"), AUTH_TS);
|
|
205
262
|
mkdirSync(cycleDir, { recursive: true });
|
|
206
263
|
writeFileSync(join(cycleDir, "org.ts"), SOFT_CYCLE_TS);
|
|
207
264
|
mkdirSync(hardCycleDir, { recursive: true });
|
|
@@ -254,7 +311,10 @@ describe("interface entities — datatypes from the type system", () => {
|
|
|
254
311
|
expect(field("onTime")).toMatchObject({ type: "bool" });
|
|
255
312
|
expect(field("departure")).toMatchObject({ type: "datetime" });
|
|
256
313
|
expect(field("notes")).toMatchObject({ type: "string", null: true });
|
|
257
|
-
expect(field("status")).toMatchObject({
|
|
314
|
+
expect(field("status")).toMatchObject({
|
|
315
|
+
type: "string",
|
|
316
|
+
default: "scheduled",
|
|
317
|
+
});
|
|
258
318
|
});
|
|
259
319
|
|
|
260
320
|
it("reads constraint tags (@maxLength/@unique) off interface properties", () => {
|
|
@@ -270,13 +330,17 @@ describe("interface entities — datatypes from the type system", () => {
|
|
|
270
330
|
it("reads column config from DbConfig brand types", () => {
|
|
271
331
|
const { resources } = analyzeProject(appDir);
|
|
272
332
|
const passenger = findResource(resources, "passengers", "passenger");
|
|
273
|
-
const field = (name: string) =>
|
|
333
|
+
const field = (name: string) =>
|
|
334
|
+
passenger.fields.find((f) => f.name === name);
|
|
274
335
|
|
|
275
336
|
expect(field("id")).toMatchObject({ type: "uuid", primaryKey: true });
|
|
276
337
|
expect(field("fullName")).toMatchObject({ type: "string", maxLength: 80 });
|
|
277
338
|
expect(field("age")).toMatchObject({ type: "int" });
|
|
278
339
|
expect(field("loyaltyPoints")).toMatchObject({ type: "float", default: 0 });
|
|
279
|
-
expect(field("status")).toMatchObject({
|
|
340
|
+
expect(field("status")).toMatchObject({
|
|
341
|
+
type: "string",
|
|
342
|
+
default: "active",
|
|
343
|
+
});
|
|
280
344
|
expect(field("terminal")).toMatchObject({
|
|
281
345
|
type: "fk",
|
|
282
346
|
null: true,
|
|
@@ -305,7 +369,8 @@ describe("interface entities — datatypes from the type system", () => {
|
|
|
305
369
|
expect(flight.fields.find((f) => f.name === "tags")).toBeUndefined();
|
|
306
370
|
expect(
|
|
307
371
|
diagnostics.some(
|
|
308
|
-
(d) =>
|
|
372
|
+
(d) =>
|
|
373
|
+
d.severity === "warning" && d.message.includes("flights.flight.tags"),
|
|
309
374
|
),
|
|
310
375
|
).toBe(true);
|
|
311
376
|
});
|
|
@@ -406,10 +471,62 @@ describe("class entities — plain data classes", () => {
|
|
|
406
471
|
});
|
|
407
472
|
});
|
|
408
473
|
|
|
474
|
+
describe("entity access policy — auth as types", () => {
|
|
475
|
+
it("reads permission / adminGroups / scope markers off a class's implements clause", () => {
|
|
476
|
+
const { resources } = analyzeProject(authDir);
|
|
477
|
+
const itinerary = findResource(resources, "workspace", "itinerary");
|
|
478
|
+
expect(itinerary.permission).toBe("authenticated");
|
|
479
|
+
expect(itinerary.access).toEqual({
|
|
480
|
+
ownerField: "owner",
|
|
481
|
+
scope: "owner",
|
|
482
|
+
adminGroups: ["staff"],
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it("a Group field makes a group-owned dataset — partitioned per group by default", () => {
|
|
487
|
+
const { resources } = analyzeProject(authDir);
|
|
488
|
+
const report = findResource(resources, "workspace", "report");
|
|
489
|
+
expect(report.permission).toBe("authenticatedOrReadOnly");
|
|
490
|
+
expect(report.access).toEqual({
|
|
491
|
+
groupField: "team",
|
|
492
|
+
scope: "group",
|
|
493
|
+
adminGroups: ["ops", "staff"],
|
|
494
|
+
});
|
|
495
|
+
// The group column is an ordinary string field in the schema.
|
|
496
|
+
expect(report.fields.find((f) => f.name === "team")?.type).toBe("string");
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("SharedStorage opts a Group dataset out of physical partitioning", () => {
|
|
500
|
+
const { resources } = analyzeProject(authDir);
|
|
501
|
+
const note = findResource(resources, "workspace", "note");
|
|
502
|
+
expect(note.access).toEqual({ groupField: "team", scope: "shared" });
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it("interfaces attach markers via extends; markers add no columns", () => {
|
|
506
|
+
const { resources } = analyzeProject(authDir);
|
|
507
|
+
const memo = findResource(resources, "workspace", "memo");
|
|
508
|
+
expect(memo.permission).toBe("authenticated");
|
|
509
|
+
expect(memo.access).toEqual({ adminGroups: ["staff"] });
|
|
510
|
+
expect(memo.fields.map((f) => f.name)).toEqual(["body"]);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it("JSDoc tags mirror every marker (@rastackPermission/@rastackAdminGroups/@owner)", () => {
|
|
514
|
+
const { resources } = analyzeProject(authDir);
|
|
515
|
+
const bulletin = findResource(resources, "workspace", "bulletin");
|
|
516
|
+
expect(bulletin.permission).toBe("public");
|
|
517
|
+
expect(bulletin.access).toEqual({
|
|
518
|
+
ownerField: "author",
|
|
519
|
+
adminGroups: ["staff", "ops"],
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
|
|
409
524
|
describe("interface entities — circular dependencies", () => {
|
|
410
525
|
it("reports a breakable cycle (optional self-FK) as a warning", () => {
|
|
411
526
|
const { diagnostics } = analyzeProject(cycleDir);
|
|
412
|
-
const cycle = diagnostics.find((d) =>
|
|
527
|
+
const cycle = diagnostics.find((d) =>
|
|
528
|
+
d.message.includes("Circular dependency"),
|
|
529
|
+
);
|
|
413
530
|
expect(cycle).toBeDefined();
|
|
414
531
|
expect(cycle!.severity).toBe("warning");
|
|
415
532
|
expect(cycle!.message).toContain("org.employee → org.employee");
|