rastack 0.0.50 → 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 +4 -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.d.ts +1 -7
- package/dist/compile/analyze.js +19 -70
- 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 +82 -18
- package/dist/define/index.js +27 -21
- package/dist/define/manifest.d.ts +64 -0
- package/dist/define/manifest.js +259 -0
- 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/plugin/core.d.ts +108 -0
- package/dist/plugin/core.js +198 -0
- package/dist/plugin/index.d.ts +112 -0
- package/dist/plugin/index.js +203 -0
- package/dist/wasm/rastack_wasm.js +1 -1
- package/dist/wasm/rastack_wasm_bg.wasm +0 -0
- package/hooks/data.ts +18 -6
- package/hooks/entity.ts +11 -5
- package/hooks/form/entity-form.ts +12 -8
- package/hooks/manifest.ts +50 -14
- package/hooks/registry.ts +25 -8
- package/package.json +1 -1
- package/plugin.ts +8 -0
- package/provider/index.ts +1 -5
- package/provider/provider.tsx +61 -16
- package/provider/types.ts +26 -7
- package/provider/warehouse.ts +4 -3
- package/provider/wasm.ts +3 -1
- package/runtime.ts +1 -0
- package/src/compile/analyze.ts +27 -65
- 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 +118 -24
- package/src/define/manifest.ts +287 -0
- package/src/define/markers.ts +83 -0
- package/src/define/types.ts +11 -0
- package/src/plugin/core.ts +236 -0
- package/src/plugin/index.ts +243 -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/test/plugin.spec.ts +315 -0
- package/test/runtime-manifest.spec.ts +309 -0
- package/test/typed-hooks.spec.ts +7 -2
- package/types.ts +7 -2
- package/wasm/rastack_wasm.js +1 -1
- package/wasm/rastack_wasm_bg.wasm +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
|
4
4
|
|
|
5
|
+
### [0.0.52](https://github.com/theserverkid/reactapistack/compare/v0.0.51...v0.0.52) (2026-07-16)
|
|
6
|
+
|
|
7
|
+
### [0.0.51](https://github.com/theserverkid/reactapistack/compare/v0.0.50...v0.0.51) (2026-07-16)
|
|
8
|
+
|
|
5
9
|
### [0.0.50](https://github.com/theserverkid/reactapistack/compare/v0.0.49...v0.0.50) (2026-07-15)
|
|
6
10
|
|
|
7
11
|
### [0.0.49](https://github.com/theserverkid/reactapistack/compare/v0.0.48...v0.0.49) (2026-07-14)
|
package/auth/context.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The React seam for `rastack/auth` — a tiny context carrying the signed-in
|
|
3
|
+
* {@link RastackAuth} and (when available) the compiled manifest, so the
|
|
4
|
+
* data-connected components can gate their affordances with zero wiring.
|
|
5
|
+
*
|
|
6
|
+
* `<RAStackProvider>` provides it automatically from its `auth` / `identity`
|
|
7
|
+
* config; `<RastackAuthProvider>` is the standalone provider for apps that
|
|
8
|
+
* don't use the runtime provider (e.g. a pure `remote`-mode admin screen).
|
|
9
|
+
*
|
|
10
|
+
* Deliberately JSX-free (plain `createElement`) so the pure model builders in
|
|
11
|
+
* `rastack/components` can import the hooks from a `.ts` module graph.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
createContext,
|
|
16
|
+
createElement,
|
|
17
|
+
useContext,
|
|
18
|
+
useMemo,
|
|
19
|
+
type ReactNode,
|
|
20
|
+
} from "react";
|
|
21
|
+
import {
|
|
22
|
+
policyFromManifest,
|
|
23
|
+
type ManifestLike,
|
|
24
|
+
type RastackAuth,
|
|
25
|
+
type ResourcePolicy,
|
|
26
|
+
type ResourceRef,
|
|
27
|
+
} from "./core";
|
|
28
|
+
|
|
29
|
+
export interface RastackAccessValue {
|
|
30
|
+
/** The signed-in identity, or `undefined` when no identity is wired up. */
|
|
31
|
+
auth?: RastackAuth;
|
|
32
|
+
/** `schema.rastack.json` (object or JSON string) for policy lookups. */
|
|
33
|
+
manifest?: ManifestLike | string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const RastackAccessContext = createContext<RastackAccessValue>({});
|
|
37
|
+
|
|
38
|
+
export interface RastackAuthProviderProps extends RastackAccessValue {
|
|
39
|
+
children?: ReactNode;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Provide the identity (and optionally the manifest) to the component tree. */
|
|
43
|
+
export function RastackAuthProvider(props: RastackAuthProviderProps) {
|
|
44
|
+
const { auth, manifest, children } = props;
|
|
45
|
+
const value = useMemo<RastackAccessValue>(
|
|
46
|
+
() => ({ auth, manifest }),
|
|
47
|
+
[auth, manifest],
|
|
48
|
+
);
|
|
49
|
+
return createElement(RastackAccessContext.Provider, { value }, children);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The current identity, or `undefined` when none is provided — in which case
|
|
54
|
+
* the components stay ungated and the server remains the sole enforcer.
|
|
55
|
+
*/
|
|
56
|
+
export function useRastackAuth(): RastackAuth | undefined {
|
|
57
|
+
return useContext(RastackAccessContext).auth;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Look a resource's access policy up from the provided manifest. Returns
|
|
62
|
+
* `undefined` (⇒ the default `authenticated` gate) when no manifest is
|
|
63
|
+
* provided or the resource isn't in it.
|
|
64
|
+
*/
|
|
65
|
+
export function useResourcePolicy(
|
|
66
|
+
ref: ResourceRef | undefined,
|
|
67
|
+
): ResourcePolicy | undefined {
|
|
68
|
+
const { manifest } = useContext(RastackAccessContext);
|
|
69
|
+
return useMemo(
|
|
70
|
+
() => (ref == null ? undefined : policyFromManifest(manifest, ref)),
|
|
71
|
+
[manifest, ref],
|
|
72
|
+
);
|
|
73
|
+
}
|
package/auth/core.ts
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rastack/auth` — the client-side authorization core.
|
|
3
|
+
*
|
|
4
|
+
* The server (`rastack-api-core`) is the enforcer: every request runs through
|
|
5
|
+
* the resource's `permission` gate, tenant scoping, and the row-level owner
|
|
6
|
+
* filter, identically on Lambda and in the in-browser WASM engine. What this
|
|
7
|
+
* module adds is the *client's view* of those same rules, so the UI can limit
|
|
8
|
+
* the paths it exposes — hide a delete button the server would 403, render a
|
|
9
|
+
* form read-only for an anonymous visitor — instead of discovering the answer
|
|
10
|
+
* as a failed request.
|
|
11
|
+
*
|
|
12
|
+
* Everything here is pure and mirrors `rust/rastack-api-core` exactly:
|
|
13
|
+
* - {@link can} ⇔ `check_permission` + the RLS reachability check
|
|
14
|
+
* - {@link bypassesRls} ⇔ `AuthContext::bypasses_rls`
|
|
15
|
+
* - {@link ownsRow} ⇔ the owner filter `enforced_owner` derives
|
|
16
|
+
*
|
|
17
|
+
* These are UI affordances, never security: the engine re-checks every call.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** The resource-level permission gate. Unset means `authenticated` — secure by default. */
|
|
21
|
+
export type PermissionPolicy =
|
|
22
|
+
"authenticated" | "authenticatedOrReadOnly" | "public";
|
|
23
|
+
|
|
24
|
+
/** The five CRUD surfaces a policy is checked against. */
|
|
25
|
+
export type AuthAction = "list" | "retrieve" | "create" | "update" | "delete";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The signed-in identity, as the framework sees it everywhere — the TypeScript
|
|
29
|
+
* mirror of the Rust `AuthContext`. Build one from your auth library's session
|
|
30
|
+
* ({@link userAuth} / {@link authFromClaims}) and hand it to
|
|
31
|
+
* `<RAStackProvider auth={…}>`; the provider runs the WASM engine as this
|
|
32
|
+
* identity and the components gate their affordances with it.
|
|
33
|
+
*/
|
|
34
|
+
export interface RastackAuth {
|
|
35
|
+
/** The caller presented a verified identity. */
|
|
36
|
+
authenticated: boolean;
|
|
37
|
+
/** Verified subject — the Cognito `sub` claim. Also the tenant key. */
|
|
38
|
+
sub?: string;
|
|
39
|
+
/** Verified group claims (`cognito:groups`). */
|
|
40
|
+
groups?: string[];
|
|
41
|
+
/**
|
|
42
|
+
* Trusted contexts (the dev server with auth disabled) bypass every check,
|
|
43
|
+
* exactly like the Rust `AuthContext::trusted()`.
|
|
44
|
+
*/
|
|
45
|
+
trusted?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** No identity. Only `public` resources (and reads of `authenticatedOrReadOnly`) pass. */
|
|
49
|
+
export function anonymousAuth(): RastackAuth {
|
|
50
|
+
return { authenticated: false, groups: [] };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A verified end user. */
|
|
54
|
+
export function userAuth(sub: string, groups: string[] = []): RastackAuth {
|
|
55
|
+
return { authenticated: true, sub, groups };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Full access, no row filters — local dev without auth configured. */
|
|
59
|
+
export function trustedAuth(): RastackAuth {
|
|
60
|
+
return { authenticated: true, groups: [], trusted: true };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Build a {@link RastackAuth} from a decoded JWT payload (a Cognito ID/access
|
|
65
|
+
* token): `sub` becomes the subject, `cognito:groups` the groups.
|
|
66
|
+
*/
|
|
67
|
+
export function authFromClaims(claims: Record<string, unknown>): RastackAuth {
|
|
68
|
+
const sub = typeof claims.sub === "string" ? claims.sub : undefined;
|
|
69
|
+
const raw = claims["cognito:groups"];
|
|
70
|
+
const groups = Array.isArray(raw)
|
|
71
|
+
? raw.filter((g): g is string => typeof g === "string")
|
|
72
|
+
: [];
|
|
73
|
+
return { authenticated: sub != null, sub, groups };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A resource's `access` block, as compiled into the manifest. */
|
|
77
|
+
export interface ResourceAccess {
|
|
78
|
+
/** Field stamped with the owning subject; rows are filtered to their owner. */
|
|
79
|
+
ownerField?: string;
|
|
80
|
+
/** Field holding the owning group; rows are filtered to its members. */
|
|
81
|
+
groupField?: string;
|
|
82
|
+
/**
|
|
83
|
+
* `"owner"` physically partitions the table per identity
|
|
84
|
+
* (`tenants/{sub}/…`); `"group"` per group (`groups/{name}/…` — the default
|
|
85
|
+
* for group-owned datasets); `"shared"` keeps one table.
|
|
86
|
+
*/
|
|
87
|
+
scope?: "owner" | "group" | "shared";
|
|
88
|
+
/** Token groups that bypass the row filters. */
|
|
89
|
+
adminGroups?: string[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Everything access control needs to know about one resource — the
|
|
94
|
+
* `permission` gate plus the row-level `access` block. This is a structural
|
|
95
|
+
* subset of the manifest's `ResourceModel`, so a manifest resource assigns
|
|
96
|
+
* directly; {@link policyFromManifest} / {@link policyFromOpenApi} extract it.
|
|
97
|
+
*/
|
|
98
|
+
export interface ResourcePolicy {
|
|
99
|
+
permission?: PermissionPolicy | string;
|
|
100
|
+
access?: ResourceAccess;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Create/update/delete mutate; list/retrieve only read. */
|
|
104
|
+
export function isWriteAction(action: AuthAction): boolean {
|
|
105
|
+
return action === "create" || action === "update" || action === "delete";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Whether this identity bypasses row-level security on the resource — trusted
|
|
110
|
+
* contexts and members of a configured admin group do.
|
|
111
|
+
*/
|
|
112
|
+
export function bypassesRls(
|
|
113
|
+
auth: RastackAuth,
|
|
114
|
+
policy: ResourcePolicy | undefined,
|
|
115
|
+
): boolean {
|
|
116
|
+
if (auth.trusted) return true;
|
|
117
|
+
const adminGroups = policy?.access?.adminGroups ?? [];
|
|
118
|
+
const groups = auth.groups ?? [];
|
|
119
|
+
return adminGroups.some((g) => groups.includes(g));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The "is admin" role, driven from the token's group claims: whether this
|
|
124
|
+
* identity is in one of the resource's `adminGroups` (or is a trusted
|
|
125
|
+
* context) and therefore sees and acts on every row. An alias of
|
|
126
|
+
* {@link bypassesRls} with the name UIs actually want.
|
|
127
|
+
*/
|
|
128
|
+
export const isAdmin = bypassesRls;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Whether the identity may perform `action` on the resource at all — the
|
|
132
|
+
* client-side mirror of the server's `permission` gate plus the row-level
|
|
133
|
+
* reachability rule (an RLS-protected resource needs a subject, unless the
|
|
134
|
+
* identity bypasses RLS).
|
|
135
|
+
*
|
|
136
|
+
* A `null`/`undefined` auth means "no identity is wired up" and gates nothing:
|
|
137
|
+
* the UI stays fully enabled and the server remains the sole enforcer. Pass
|
|
138
|
+
* {@link anonymousAuth} to model a signed-out visitor.
|
|
139
|
+
*/
|
|
140
|
+
export function can(
|
|
141
|
+
auth: RastackAuth | null | undefined,
|
|
142
|
+
policy: ResourcePolicy | undefined,
|
|
143
|
+
action: AuthAction,
|
|
144
|
+
): boolean {
|
|
145
|
+
if (auth == null) return true;
|
|
146
|
+
if (auth.trusted) return true;
|
|
147
|
+
|
|
148
|
+
// The permission gate. Unset means `authenticated` — secure by default.
|
|
149
|
+
const write = isWriteAction(action);
|
|
150
|
+
const permitted =
|
|
151
|
+
policy?.permission === "public"
|
|
152
|
+
? true
|
|
153
|
+
: policy?.permission === "authenticatedOrReadOnly"
|
|
154
|
+
? auth.authenticated || !write
|
|
155
|
+
: auth.authenticated;
|
|
156
|
+
if (!permitted) return false;
|
|
157
|
+
|
|
158
|
+
// Row-level security / tenant scoping need a subject to resolve rows or the
|
|
159
|
+
// tenant view — without one the server answers 401.
|
|
160
|
+
const access = policy?.access;
|
|
161
|
+
const ownerSecured = Boolean(access?.ownerField) || access?.scope === "owner";
|
|
162
|
+
if (ownerSecured && auth.sub == null && !bypassesRls(auth, policy)) {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Group-owned resources: reads outside any group just come back empty, but
|
|
167
|
+
// a write can never succeed — there is no group to place the row in or no
|
|
168
|
+
// row a membership guard would ever pass.
|
|
169
|
+
if (
|
|
170
|
+
access?.groupField &&
|
|
171
|
+
write &&
|
|
172
|
+
(auth.groups ?? []).length === 0 &&
|
|
173
|
+
!bypassesRls(auth, policy)
|
|
174
|
+
) {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Whether the identity owns a specific row under the resource's row-level
|
|
182
|
+
* security — mirrors the row guards the server applies: the owner filter
|
|
183
|
+
* (`ownerField` must carry the caller's subject) *and* the group-membership
|
|
184
|
+
* filter (`groupField` must carry one of the caller's groups). Admin groups
|
|
185
|
+
* and trusted contexts pass unfiltered; rows of an unsecured resource are
|
|
186
|
+
* "owned" by everyone.
|
|
187
|
+
*/
|
|
188
|
+
export function ownsRow(
|
|
189
|
+
auth: RastackAuth | null | undefined,
|
|
190
|
+
policy: ResourcePolicy | undefined,
|
|
191
|
+
row: Record<string, unknown>,
|
|
192
|
+
): boolean {
|
|
193
|
+
if (auth == null) return true;
|
|
194
|
+
if (bypassesRls(auth, policy)) return true;
|
|
195
|
+
const ownerField = policy?.access?.ownerField;
|
|
196
|
+
if (ownerField) {
|
|
197
|
+
if (auth.sub == null || String(row[ownerField] ?? "") !== auth.sub) {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const groupField = policy?.access?.groupField;
|
|
202
|
+
if (groupField) {
|
|
203
|
+
if (!(auth.groups ?? []).includes(String(row[groupField] ?? ""))) {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** {@link can} on the resource *and* {@link ownsRow} on the specific row. */
|
|
211
|
+
export function canActOnRow(
|
|
212
|
+
auth: RastackAuth | null | undefined,
|
|
213
|
+
policy: ResourcePolicy | undefined,
|
|
214
|
+
action: AuthAction,
|
|
215
|
+
row: Record<string, unknown>,
|
|
216
|
+
): boolean {
|
|
217
|
+
return can(auth, policy, action) && ownsRow(auth, policy, row);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// -- policy lookup --------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The structural slice of `schema.rastack.json` the policy lookup reads.
|
|
224
|
+
* Deliberately loose (any object with a `resources` array) so a manifest
|
|
225
|
+
* however it was loaded — imported JSON, `JSON.parse`, the typed compiler
|
|
226
|
+
* output — assigns without casts.
|
|
227
|
+
*/
|
|
228
|
+
export interface ManifestLike {
|
|
229
|
+
resources?: Array<Record<string, unknown>>;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Accept `"app.model"` or `{ app, model }`. */
|
|
233
|
+
export type ResourceRef = string | { app: string; model: string };
|
|
234
|
+
|
|
235
|
+
function splitRef(ref: ResourceRef): { app: string; model: string } {
|
|
236
|
+
if (typeof ref !== "string") return ref;
|
|
237
|
+
const dot = ref.indexOf(".");
|
|
238
|
+
if (dot === -1)
|
|
239
|
+
throw new Error(`Invalid resource ref "${ref}" — expected "app.model".`);
|
|
240
|
+
return { app: ref.slice(0, dot), model: ref.slice(dot + 1) };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Extract a resource's {@link ResourcePolicy} from the compiled manifest
|
|
245
|
+
* (`schema.rastack.json`, object or JSON string), or `undefined` when the
|
|
246
|
+
* resource isn't found.
|
|
247
|
+
*/
|
|
248
|
+
export function policyFromManifest(
|
|
249
|
+
manifest: ManifestLike | string | null | undefined,
|
|
250
|
+
ref: ResourceRef,
|
|
251
|
+
): ResourcePolicy | undefined {
|
|
252
|
+
if (manifest == null) return undefined;
|
|
253
|
+
const doc: ManifestLike =
|
|
254
|
+
typeof manifest === "string" ? JSON.parse(manifest) : manifest;
|
|
255
|
+
const { app, model } = splitRef(ref);
|
|
256
|
+
const resource = doc.resources?.find(
|
|
257
|
+
(r) => r.app === app && r.model === model,
|
|
258
|
+
);
|
|
259
|
+
if (!resource) return undefined;
|
|
260
|
+
const policy: ResourcePolicy = {};
|
|
261
|
+
if (resource.permission != null) {
|
|
262
|
+
policy.permission = resource.permission as string;
|
|
263
|
+
}
|
|
264
|
+
if (resource.access != null) {
|
|
265
|
+
policy.access = resource.access as ResourceAccess;
|
|
266
|
+
}
|
|
267
|
+
return policy;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Extract a resource's {@link ResourcePolicy} from the compiled OpenAPI
|
|
272
|
+
* document — the `x-rastack-permission` / `x-rastack-access` stamps on its
|
|
273
|
+
* `/api/{app}/v1/{model}/` operations.
|
|
274
|
+
*/
|
|
275
|
+
export function policyFromOpenApi(
|
|
276
|
+
doc: { paths?: Record<string, any> } | string | null | undefined,
|
|
277
|
+
ref: ResourceRef,
|
|
278
|
+
): ResourcePolicy | undefined {
|
|
279
|
+
if (doc == null) return undefined;
|
|
280
|
+
const parsed: { paths?: Record<string, any> } =
|
|
281
|
+
typeof doc === "string" ? JSON.parse(doc) : doc;
|
|
282
|
+
const { app, model } = splitRef(ref);
|
|
283
|
+
const path = parsed.paths?.[`/api/${app}/v1/${model}/`];
|
|
284
|
+
if (!path) return undefined;
|
|
285
|
+
const operation = path.get ?? path.post ?? Object.values(path)[0];
|
|
286
|
+
if (!operation || typeof operation !== "object") return undefined;
|
|
287
|
+
const policy: ResourcePolicy = {};
|
|
288
|
+
const permission = (operation as any)["x-rastack-permission"];
|
|
289
|
+
if (permission != null) policy.permission = permission;
|
|
290
|
+
const access = (operation as any)["x-rastack-access"];
|
|
291
|
+
if (access != null) policy.access = access;
|
|
292
|
+
return policy;
|
|
293
|
+
}
|
package/auth/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rastack/auth` — the whole auth surface of the stack:
|
|
3
|
+
*
|
|
4
|
+
* - **Authoring** (types the compiler reads): the entity markers
|
|
5
|
+
* (`IsAuthenticated`, `AdminGroups<…>`, `OwnerScoped`, `GroupScoped`, …)
|
|
6
|
+
* and the `Owner` / `Group` access-control field brands.
|
|
7
|
+
* - **Runtime**: the TypeScript auth type ({@link RastackAuth}), the
|
|
8
|
+
* client-side mirror of the server's authorization rules ({@link can},
|
|
9
|
+
* row-level security helpers, policy lookup), and the React context the
|
|
10
|
+
* provider and components share.
|
|
11
|
+
*
|
|
12
|
+
* See `docs/security-and-local-cache.md`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export * from "../src/define/auth";
|
|
16
|
+
export * from "./core";
|
|
17
|
+
export {
|
|
18
|
+
RastackAuthProvider,
|
|
19
|
+
useRastackAuth,
|
|
20
|
+
useResourcePolicy,
|
|
21
|
+
type RastackAccessValue,
|
|
22
|
+
type RastackAuthProviderProps,
|
|
23
|
+
} from "./context";
|
|
@@ -13,10 +13,11 @@ import {
|
|
|
13
13
|
useAutoForm,
|
|
14
14
|
type AutoFormFieldModel,
|
|
15
15
|
type AutoFormModel,
|
|
16
|
+
type AutoFormModelOptions,
|
|
16
17
|
} from "./use-auto-form";
|
|
17
|
-
import type { AutoFormInputKind
|
|
18
|
+
import type { AutoFormInputKind } from "./fields";
|
|
18
19
|
|
|
19
|
-
export interface AutoFormProps extends
|
|
20
|
+
export interface AutoFormProps extends AutoFormModelOptions {
|
|
20
21
|
/** The form hook result (`useUpdateXForm(...)`) driving the form. */
|
|
21
22
|
form: AutoFormLike;
|
|
22
23
|
submitLabel?: React.ReactNode;
|
|
@@ -61,6 +62,10 @@ export function AutoForm(props: AutoFormProps): React.JSX.Element {
|
|
|
61
62
|
form,
|
|
62
63
|
fields,
|
|
63
64
|
exclude,
|
|
65
|
+
auth,
|
|
66
|
+
policy,
|
|
67
|
+
resource,
|
|
68
|
+
action,
|
|
64
69
|
submitLabel = "Save",
|
|
65
70
|
onCancel,
|
|
66
71
|
cancelLabel = "Cancel",
|
|
@@ -72,7 +77,14 @@ export function AutoForm(props: AutoFormProps): React.JSX.Element {
|
|
|
72
77
|
className,
|
|
73
78
|
} = props;
|
|
74
79
|
|
|
75
|
-
const model = useAutoForm(form, {
|
|
80
|
+
const model = useAutoForm(form, {
|
|
81
|
+
fields,
|
|
82
|
+
exclude,
|
|
83
|
+
auth,
|
|
84
|
+
policy,
|
|
85
|
+
resource,
|
|
86
|
+
action,
|
|
87
|
+
});
|
|
76
88
|
const [touched, setTouched] = useState<Set<string>>(() => new Set());
|
|
77
89
|
const [submitted, setSubmitted] = useState(false);
|
|
78
90
|
|
|
@@ -87,6 +99,7 @@ export function AutoForm(props: AutoFormProps): React.JSX.Element {
|
|
|
87
99
|
|
|
88
100
|
const handleSubmit = (e: React.FormEvent) => {
|
|
89
101
|
e.preventDefault();
|
|
102
|
+
if (!model.canWrite) return;
|
|
90
103
|
setSubmitted(true);
|
|
91
104
|
model.submit();
|
|
92
105
|
onSubmit?.();
|
|
@@ -210,18 +223,25 @@ export function AutoForm(props: AutoFormProps): React.JSX.Element {
|
|
|
210
223
|
{children ? (
|
|
211
224
|
children(model)
|
|
212
225
|
) : (
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
}}
|
|
226
|
+
// Authorization is enforced by the API; here it shapes the UI — an
|
|
227
|
+
// identity the policy denies writes to gets every control disabled.
|
|
228
|
+
<fieldset
|
|
229
|
+
disabled={!model.canWrite}
|
|
230
|
+
style={{ border: 0, margin: 0, padding: 0, minInlineSize: 0 }}
|
|
219
231
|
>
|
|
220
|
-
|
|
221
|
-
|
|
232
|
+
<div
|
|
233
|
+
style={{
|
|
234
|
+
display: "grid",
|
|
235
|
+
gridTemplateColumns: `repeat(${Math.max(1, columns)}, minmax(0, 1fr))`,
|
|
236
|
+
gap: tokens.space.lg,
|
|
237
|
+
}}
|
|
238
|
+
>
|
|
239
|
+
{model.fields.map(renderField)}
|
|
240
|
+
</div>
|
|
241
|
+
</fieldset>
|
|
222
242
|
)}
|
|
223
243
|
|
|
224
|
-
{!hideActions && !children ? (
|
|
244
|
+
{!hideActions && !children && model.canWrite ? (
|
|
225
245
|
<div
|
|
226
246
|
style={{
|
|
227
247
|
display: "flex",
|
|
@@ -12,6 +12,15 @@ import type React from "react";
|
|
|
12
12
|
import type { FieldOption } from "../../hooks/form/interfaces";
|
|
13
13
|
import type { AutoFormField, AutoFormLike } from "../types";
|
|
14
14
|
import { humanizeKey } from "../util";
|
|
15
|
+
import {
|
|
16
|
+
bypassesRls,
|
|
17
|
+
can,
|
|
18
|
+
useRastackAuth,
|
|
19
|
+
useResourcePolicy,
|
|
20
|
+
type RastackAuth,
|
|
21
|
+
type ResourcePolicy,
|
|
22
|
+
type ResourceRef,
|
|
23
|
+
} from "../../auth";
|
|
15
24
|
import {
|
|
16
25
|
inputKindForFieldType,
|
|
17
26
|
resolveFieldSpecs,
|
|
@@ -53,6 +62,13 @@ export interface AutoFormModel {
|
|
|
53
62
|
isSubmitting: boolean;
|
|
54
63
|
/** `true` when the form is valid and not mid-submit. */
|
|
55
64
|
canSubmit: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Whether the current identity may write this resource at all — the client
|
|
67
|
+
* mirror of the server's `permission` gate + row-level security. `false`
|
|
68
|
+
* renders the form read-only (`<AutoForm>` disables every control and hides
|
|
69
|
+
* the actions row). Always `true` when no auth/policy is wired up.
|
|
70
|
+
*/
|
|
71
|
+
canWrite: boolean;
|
|
56
72
|
errors: Record<string, unknown>;
|
|
57
73
|
/**
|
|
58
74
|
* State-machine actions — feature-detected from `useForm(Entity)` results
|
|
@@ -61,13 +77,54 @@ export interface AutoFormModel {
|
|
|
61
77
|
transitions: AutoFormTransitionModel[];
|
|
62
78
|
}
|
|
63
79
|
|
|
80
|
+
/** Authorization inputs shared by `useAutoForm` and `<AutoForm>`. */
|
|
81
|
+
export interface AutoFormAuthOptions {
|
|
82
|
+
/**
|
|
83
|
+
* The identity to gate against. Defaults to the ambient identity from
|
|
84
|
+
* `<RAStackProvider auth={…}>` / `<RastackAuthProvider>`; pass `null` to
|
|
85
|
+
* opt out of gating for this form.
|
|
86
|
+
*/
|
|
87
|
+
auth?: RastackAuth | null;
|
|
88
|
+
/** The resource's compiled `permission` + `access` policy. */
|
|
89
|
+
policy?: ResourcePolicy;
|
|
90
|
+
/**
|
|
91
|
+
* `"app.model"` — looks `policy` up from the provider's manifest when one
|
|
92
|
+
* is available (always the case in `local`/`s3` mode).
|
|
93
|
+
*/
|
|
94
|
+
resource?: ResourceRef;
|
|
95
|
+
/** Which write this form performs. Default `"update"`. */
|
|
96
|
+
action?: "create" | "update";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export type AutoFormModelOptions = AutoFormOptions & AutoFormAuthOptions;
|
|
100
|
+
|
|
64
101
|
/** Pure model assembly — see {@link useAutoForm}. Testable with a mock form. */
|
|
65
102
|
export function buildAutoFormModel(
|
|
66
103
|
form: AutoFormLike,
|
|
67
|
-
options:
|
|
104
|
+
options: AutoFormModelOptions = {},
|
|
68
105
|
): AutoFormModel {
|
|
106
|
+
const auth = options.auth ?? undefined;
|
|
107
|
+
const policy = options.policy;
|
|
108
|
+
const canWrite = can(auth, policy, options.action ?? "update");
|
|
109
|
+
|
|
110
|
+
// The owner field is stamped from the verified token on create and
|
|
111
|
+
// immutable after that — never user-editable. Drop it from inferred field
|
|
112
|
+
// lists (an explicit `fields` entry still wins) unless the identity
|
|
113
|
+
// bypasses row-level security (admins may see whose row it is).
|
|
114
|
+
const ownerField = policy?.access?.ownerField;
|
|
115
|
+
const hideOwner =
|
|
116
|
+
ownerField != null &&
|
|
117
|
+
!options.fields?.length &&
|
|
118
|
+
auth != null &&
|
|
119
|
+
!bypassesRls(auth, policy);
|
|
120
|
+
|
|
69
121
|
const fieldNames = Object.keys(form.formValues ?? {});
|
|
70
|
-
const specs = resolveFieldSpecs(fieldNames,
|
|
122
|
+
const specs = resolveFieldSpecs(fieldNames, {
|
|
123
|
+
...options,
|
|
124
|
+
exclude: hideOwner
|
|
125
|
+
? [...(options.exclude ?? []), ownerField]
|
|
126
|
+
: options.exclude,
|
|
127
|
+
});
|
|
71
128
|
|
|
72
129
|
const fields: AutoFormFieldModel[] = specs.map((spec) => {
|
|
73
130
|
const field = form.getFormField(spec.name);
|
|
@@ -102,16 +159,28 @@ export function buildAutoFormModel(
|
|
|
102
159
|
submit: () => form.onSubmit(),
|
|
103
160
|
reset: () => form.resetForm(),
|
|
104
161
|
isSubmitting: form.isSubmitting,
|
|
105
|
-
canSubmit: !form.isSubmitDisabled && !form.isSubmitting,
|
|
162
|
+
canSubmit: canWrite && !form.isSubmitDisabled && !form.isSubmitting,
|
|
163
|
+
canWrite,
|
|
106
164
|
errors: (form.errors as Record<string, unknown>) ?? {},
|
|
107
165
|
transitions,
|
|
108
166
|
};
|
|
109
167
|
}
|
|
110
168
|
|
|
111
|
-
/**
|
|
169
|
+
/**
|
|
170
|
+
* React hook wrapper around {@link buildAutoFormModel}. Auth handling is built
|
|
171
|
+
* in: the identity defaults to the ambient one from `<RAStackProvider
|
|
172
|
+
* auth={…}>`, and passing `resource: "app.model"` resolves the policy from the
|
|
173
|
+
* provider's manifest — so inside the provider, gating needs zero wiring.
|
|
174
|
+
*/
|
|
112
175
|
export function useAutoForm(
|
|
113
176
|
form: AutoFormLike,
|
|
114
|
-
options:
|
|
177
|
+
options: AutoFormModelOptions = {},
|
|
115
178
|
): AutoFormModel {
|
|
116
|
-
|
|
179
|
+
const ambientAuth = useRastackAuth();
|
|
180
|
+
const ambientPolicy = useResourcePolicy(options.resource);
|
|
181
|
+
return buildAutoFormModel(form, {
|
|
182
|
+
...options,
|
|
183
|
+
auth: options.auth !== undefined ? options.auth : ambientAuth,
|
|
184
|
+
policy: options.policy ?? ambientPolicy,
|
|
185
|
+
});
|
|
117
186
|
}
|