create-astram-app 0.3.0 → 0.4.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 +1 -1
- package/template/frontend/package.json +1 -1
- package/template/frontend/src/App.jsx +12 -0
- package/template/frontend/src/auth/AuthContext.jsx +15 -2
- package/template/frontend/src/components/ShareDialog.jsx +269 -0
- package/template/frontend/src/pages/access/RecordPoliciesPage.jsx +390 -0
- package/template/frontend/src/shell/nav.js +8 -0
- package/template/server/package.json +4 -4
- package/template/server/src/app.module.ts +21 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-astram-app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Scaffolder for a new Astram app — emits a running, framework-enforced consumer project (empty src/features/ + deny-by-default enforcement) that installs the immutable @astram/core, @astram/guard, and @astram/sdk framework.",
|
|
6
6
|
"type": "module",
|
|
@@ -28,6 +28,7 @@ import UsersPage from './pages/users/UsersPage';
|
|
|
28
28
|
import UserForm from './pages/users/UserForm';
|
|
29
29
|
import AccessPage from './pages/access/AccessPage';
|
|
30
30
|
import AccessForm from './pages/access/AccessForm';
|
|
31
|
+
import RecordPoliciesPage from './pages/access/RecordPoliciesPage';
|
|
31
32
|
import AuditPage from './pages/audit/AuditPage';
|
|
32
33
|
import GrantsPage from './pages/cross-tenant/GrantsPage';
|
|
33
34
|
import GrantForm from './pages/cross-tenant/GrantForm';
|
|
@@ -155,6 +156,17 @@ export default function App() {
|
|
|
155
156
|
<Route path="add" element={<AccessForm />} />
|
|
156
157
|
<Route path="edit/:uuid" element={<AccessForm />} />
|
|
157
158
|
</Route>
|
|
159
|
+
{/* Guided rule-builder — author/list/delete record-access policies (§9.1). Gated
|
|
160
|
+
identity:role:read (policy authoring IS access administration; the write gate is
|
|
161
|
+
role:update, enforced server-side on POST/DELETE). */}
|
|
162
|
+
<Route
|
|
163
|
+
path="/record-policies"
|
|
164
|
+
element={
|
|
165
|
+
<RequireAuth gate="identity:role:read">
|
|
166
|
+
<RecordPoliciesPage />
|
|
167
|
+
</RequireAuth>
|
|
168
|
+
}
|
|
169
|
+
/>
|
|
158
170
|
{/* Cross-tenant grants — the grant book (mine + incoming) is a LAYOUT route; the
|
|
159
171
|
Offer form (`offer`) renders as a routed-modal overlay under it. The pending
|
|
160
172
|
Inbox is a sibling route. All gated `identity:role:read` (mirrors nav.js). */}
|
|
@@ -17,6 +17,8 @@ import {
|
|
|
17
17
|
createAuthService,
|
|
18
18
|
createCrossTenantService,
|
|
19
19
|
createIdentityService,
|
|
20
|
+
createRecordPolicyService,
|
|
21
|
+
createRecordShareService,
|
|
20
22
|
isAuthenticated,
|
|
21
23
|
principalOf,
|
|
22
24
|
} from '@astram/frontend-core';
|
|
@@ -66,6 +68,11 @@ export function AuthProvider({ children }) {
|
|
|
66
68
|
identity: createIdentityService(http),
|
|
67
69
|
audit: createAuditService(http),
|
|
68
70
|
crossTenant: createCrossTenantService(http),
|
|
71
|
+
// The guided rule-builder surface (§9.1) — author/list/delete record-access policies +
|
|
72
|
+
// the field/operator schema that drives the builder's dropdowns.
|
|
73
|
+
recordPolicy: createRecordPolicyService(http),
|
|
74
|
+
// The owner-share surface (§9.2) — share/unshare/list a record's shares (owner-only).
|
|
75
|
+
recordShare: createRecordShareService(http),
|
|
69
76
|
account: createAccountService(http),
|
|
70
77
|
// The @astram/sdk ShieldClient (Phase E slice 1) — a UX-convenience projection of
|
|
71
78
|
// the caller's OWN coarse grants, fetched once per session via `getAuthFacts`.
|
|
@@ -81,7 +88,8 @@ export function AuthProvider({ children }) {
|
|
|
81
88
|
store,
|
|
82
89
|
};
|
|
83
90
|
}
|
|
84
|
-
const { auth, identity, audit, crossTenant, account, shield, store } =
|
|
91
|
+
const { auth, identity, audit, crossTenant, recordPolicy, recordShare, account, shield, store } =
|
|
92
|
+
services.current;
|
|
85
93
|
|
|
86
94
|
const [state, setState] = useState(() => store.load()); // SessionState | null
|
|
87
95
|
// The signed-in user's DISPLAY profile (name/email/company/roles) from GET /auth/me.
|
|
@@ -284,6 +292,11 @@ export function AuthProvider({ children }) {
|
|
|
284
292
|
// The shared cross-tenant grant service (offer/accept/revoke + inbox/mine +
|
|
285
293
|
// offerable-types) — same injected web http client.
|
|
286
294
|
crossTenant,
|
|
295
|
+
// The shared guided rule-builder service (author/list/delete record policies + the
|
|
296
|
+
// field/operator schema) — same injected web http client.
|
|
297
|
+
recordPolicy,
|
|
298
|
+
// The shared owner-share service (share/unshare/list a record's shares) — same client.
|
|
299
|
+
recordShare,
|
|
287
300
|
// The shared self-scoped account service (change-password today; profile/MFA
|
|
288
301
|
// later) — same injected web http client.
|
|
289
302
|
account,
|
|
@@ -306,7 +319,7 @@ export function AuthProvider({ children }) {
|
|
|
306
319
|
),
|
|
307
320
|
}),
|
|
308
321
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
309
|
-
[state, auth, identity, audit, crossTenant, account, shield, profile, mustReenroll, authFacts],
|
|
322
|
+
[state, auth, identity, audit, crossTenant, recordPolicy, recordShare, account, shield, profile, mustReenroll, authFacts],
|
|
310
323
|
);
|
|
311
324
|
|
|
312
325
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ShareDialog — the reusable OWNER-SHARE widget (§9.2 / list-scoping engine Phase 6).
|
|
3
|
+
*
|
|
4
|
+
* A record's owner opens this from a record's detail/list to manage WHO can see it: it lists the
|
|
5
|
+
* record's current shares (grantee NAMES, never raw uuids), lets the owner add a share (grantee
|
|
6
|
+
* kind → user/role searchable picker → action → Add), and revoke one with a confirm. The server
|
|
7
|
+
* enforces owner-only (a non-owner gets 403); the tenant + sharer are resolved server-side.
|
|
8
|
+
*
|
|
9
|
+
* Props:
|
|
10
|
+
* open, onOpenChange — dialog control.
|
|
11
|
+
* typeKey, recordId — the record being shared (the owner-bound is server-enforced).
|
|
12
|
+
* recordLabel — a human label for the title (never the uuid).
|
|
13
|
+
* actions — the action vocabulary to offer (defaults to ['read']).
|
|
14
|
+
*/
|
|
15
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
16
|
+
import { Share2, Trash2 } from 'lucide-react';
|
|
17
|
+
import { toast } from 'sonner';
|
|
18
|
+
import { useAuth } from '@/auth/AuthContext';
|
|
19
|
+
import {
|
|
20
|
+
Dialog,
|
|
21
|
+
DialogContent,
|
|
22
|
+
DialogHeader,
|
|
23
|
+
DialogTitle,
|
|
24
|
+
DialogDescription,
|
|
25
|
+
} from '@/components/ui/dialog';
|
|
26
|
+
import { Button } from '@/components/ui/button';
|
|
27
|
+
import { Label } from '@/components/ui/label';
|
|
28
|
+
import { Badge } from '@/components/ui/badge';
|
|
29
|
+
import {
|
|
30
|
+
Select,
|
|
31
|
+
SelectContent,
|
|
32
|
+
SelectItem,
|
|
33
|
+
SelectTrigger,
|
|
34
|
+
SelectValue,
|
|
35
|
+
} from '@/components/ui/select';
|
|
36
|
+
import { SearchableSelect } from '@/components/ui/searchable-select';
|
|
37
|
+
import ConfirmDialog from '@/components/ConfirmDialog';
|
|
38
|
+
|
|
39
|
+
export default function ShareDialog({
|
|
40
|
+
open,
|
|
41
|
+
onOpenChange,
|
|
42
|
+
typeKey,
|
|
43
|
+
recordId,
|
|
44
|
+
recordLabel,
|
|
45
|
+
actions = ['read'],
|
|
46
|
+
}) {
|
|
47
|
+
const { tokens, recordShare, identity } = useAuth();
|
|
48
|
+
const token = tokens?.accessToken;
|
|
49
|
+
|
|
50
|
+
const [shares, setShares] = useState([]);
|
|
51
|
+
const [loading, setLoading] = useState(false);
|
|
52
|
+
const [error, setError] = useState('');
|
|
53
|
+
|
|
54
|
+
// The add-share form.
|
|
55
|
+
const [granteeKind, setGranteeKind] = useState('user');
|
|
56
|
+
const [grantee, setGrantee] = useState(null); // { value, label }
|
|
57
|
+
const [action, setAction] = useState(actions[0] ?? 'read');
|
|
58
|
+
const [adding, setAdding] = useState(false);
|
|
59
|
+
|
|
60
|
+
const [revokeTarget, setRevokeTarget] = useState(null);
|
|
61
|
+
const [revokeBusy, setRevokeBusy] = useState(false);
|
|
62
|
+
|
|
63
|
+
const load = useCallback(async () => {
|
|
64
|
+
if (!token || !open) return;
|
|
65
|
+
setLoading(true);
|
|
66
|
+
const r = await recordShare.listShares(token, typeKey, recordId);
|
|
67
|
+
if (r.ok) {
|
|
68
|
+
setShares(r.shares);
|
|
69
|
+
setError('');
|
|
70
|
+
} else {
|
|
71
|
+
setShares([]);
|
|
72
|
+
setError(r.error || 'Could not load the shares.');
|
|
73
|
+
}
|
|
74
|
+
setLoading(false);
|
|
75
|
+
}, [recordShare, token, typeKey, recordId, open]);
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
load();
|
|
79
|
+
}, [load]);
|
|
80
|
+
|
|
81
|
+
// Reset the grantee picker when the kind flips (user vs role are different directories).
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
setGrantee(null);
|
|
84
|
+
}, [granteeKind]);
|
|
85
|
+
|
|
86
|
+
// Fetch grantee options (users or roles) for the searchable picker — value=uuid, label=name.
|
|
87
|
+
const fetchGrantees = useCallback(
|
|
88
|
+
async ({ search, page }) => {
|
|
89
|
+
const opts = { page, limit: 10, search };
|
|
90
|
+
const r =
|
|
91
|
+
granteeKind === 'role'
|
|
92
|
+
? await identity.listRoles(token, opts)
|
|
93
|
+
: await identity.listUsers(token, opts);
|
|
94
|
+
if (!r.ok) return { options: [], total: 0, page: page ?? 1 };
|
|
95
|
+
const options = (r.rows || []).map((row) => ({
|
|
96
|
+
value: row.id ?? row.uuid,
|
|
97
|
+
label:
|
|
98
|
+
granteeKind === 'role'
|
|
99
|
+
? row.fields?.name ?? '(role)'
|
|
100
|
+
: row.fields?.username ?? row.fields?.email ?? '(user)',
|
|
101
|
+
}));
|
|
102
|
+
return { options, total: r.total ?? options.length, page: page ?? 1 };
|
|
103
|
+
},
|
|
104
|
+
[identity, token, granteeKind],
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const onAdd = async () => {
|
|
108
|
+
if (!grantee) {
|
|
109
|
+
toast.error('Pick a grantee.');
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
setAdding(true);
|
|
113
|
+
const r = await recordShare.share(token, {
|
|
114
|
+
typeKey,
|
|
115
|
+
recordId,
|
|
116
|
+
granteeKind,
|
|
117
|
+
granteeUuid: grantee.value,
|
|
118
|
+
action,
|
|
119
|
+
});
|
|
120
|
+
setAdding(false);
|
|
121
|
+
if (!r.ok) {
|
|
122
|
+
toast.error(r.error || 'Could not share the record.');
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
toast.success('Shared');
|
|
126
|
+
setGrantee(null);
|
|
127
|
+
await load();
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const onConfirmRevoke = async () => {
|
|
131
|
+
if (!revokeTarget) return;
|
|
132
|
+
setRevokeBusy(true);
|
|
133
|
+
const r = await recordShare.unshare(token, {
|
|
134
|
+
typeKey,
|
|
135
|
+
recordId,
|
|
136
|
+
granteeKind: revokeTarget.granteeKind,
|
|
137
|
+
granteeUuid: revokeTarget.granteeUuid,
|
|
138
|
+
action: revokeTarget.action,
|
|
139
|
+
});
|
|
140
|
+
setRevokeBusy(false);
|
|
141
|
+
if (!r.ok) {
|
|
142
|
+
toast.error(r.error || 'Could not revoke the share.');
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
setRevokeTarget(null);
|
|
146
|
+
toast.success('Share revoked');
|
|
147
|
+
await load();
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
152
|
+
<DialogContent className="max-w-lg">
|
|
153
|
+
<DialogHeader>
|
|
154
|
+
<DialogTitle className="flex items-center gap-2">
|
|
155
|
+
<Share2 className="size-4" />
|
|
156
|
+
Share {recordLabel ? `“${recordLabel}”` : 'record'}
|
|
157
|
+
</DialogTitle>
|
|
158
|
+
<DialogDescription>
|
|
159
|
+
Grant a user or role access to this record. Only you (the owner) can manage its shares.
|
|
160
|
+
</DialogDescription>
|
|
161
|
+
</DialogHeader>
|
|
162
|
+
|
|
163
|
+
{/* Current shares */}
|
|
164
|
+
<div className="space-y-2">
|
|
165
|
+
<Label>Shared with</Label>
|
|
166
|
+
{loading ? (
|
|
167
|
+
<div className="text-sm text-muted-foreground">Loading…</div>
|
|
168
|
+
) : error ? (
|
|
169
|
+
<div className="text-sm text-destructive">{error}</div>
|
|
170
|
+
) : shares.length === 0 ? (
|
|
171
|
+
<div className="text-sm text-muted-foreground">Not shared with anyone yet.</div>
|
|
172
|
+
) : (
|
|
173
|
+
<ul className="divide-y divide-border rounded-md border border-border">
|
|
174
|
+
{shares.map((s) => (
|
|
175
|
+
<li
|
|
176
|
+
key={`${s.granteeKind}:${s.granteeUuid}:${s.action}`}
|
|
177
|
+
className="flex items-center justify-between px-3 py-2"
|
|
178
|
+
>
|
|
179
|
+
<span className="flex items-center gap-2 text-sm">
|
|
180
|
+
<span className="font-medium text-foreground">{s.granteeName}</span>
|
|
181
|
+
<span className="text-xs text-muted-foreground">({s.granteeKind})</span>
|
|
182
|
+
<Badge variant="secondary" className="font-normal">
|
|
183
|
+
{s.action}
|
|
184
|
+
</Badge>
|
|
185
|
+
</span>
|
|
186
|
+
<Button
|
|
187
|
+
type="button"
|
|
188
|
+
variant="ghost"
|
|
189
|
+
size="sm"
|
|
190
|
+
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
|
191
|
+
onClick={() => setRevokeTarget(s)}
|
|
192
|
+
>
|
|
193
|
+
<Trash2 className="size-4" />
|
|
194
|
+
Revoke
|
|
195
|
+
</Button>
|
|
196
|
+
</li>
|
|
197
|
+
))}
|
|
198
|
+
</ul>
|
|
199
|
+
)}
|
|
200
|
+
</div>
|
|
201
|
+
|
|
202
|
+
{/* Add a share */}
|
|
203
|
+
<div className="mt-2 space-y-3 rounded-md border border-border p-3">
|
|
204
|
+
<Label>Add access</Label>
|
|
205
|
+
<div className="flex flex-wrap items-end gap-2">
|
|
206
|
+
<div className="space-y-1">
|
|
207
|
+
<Label className="text-xs text-muted-foreground">Grantee kind</Label>
|
|
208
|
+
<Select value={granteeKind} onValueChange={setGranteeKind}>
|
|
209
|
+
<SelectTrigger className="w-28">
|
|
210
|
+
<SelectValue />
|
|
211
|
+
</SelectTrigger>
|
|
212
|
+
<SelectContent>
|
|
213
|
+
<SelectItem value="user">User</SelectItem>
|
|
214
|
+
<SelectItem value="role">Role</SelectItem>
|
|
215
|
+
</SelectContent>
|
|
216
|
+
</Select>
|
|
217
|
+
</div>
|
|
218
|
+
<div className="min-w-48 flex-1 space-y-1">
|
|
219
|
+
<Label className="text-xs text-muted-foreground">
|
|
220
|
+
{granteeKind === 'role' ? 'Role' : 'User'}
|
|
221
|
+
</Label>
|
|
222
|
+
<SearchableSelect
|
|
223
|
+
value={grantee?.value ?? null}
|
|
224
|
+
selectedLabel={grantee?.label ?? null}
|
|
225
|
+
onChange={(value) =>
|
|
226
|
+
setGrantee(value ? { value, label: grantee?.label ?? '' } : null)
|
|
227
|
+
}
|
|
228
|
+
onLabelChange={(label) =>
|
|
229
|
+
setGrantee((g) => (g ? { ...g, label: label ?? '' } : g))
|
|
230
|
+
}
|
|
231
|
+
fetchPage={fetchGrantees}
|
|
232
|
+
placeholder={`Pick a ${granteeKind}`}
|
|
233
|
+
/>
|
|
234
|
+
</div>
|
|
235
|
+
<div className="space-y-1">
|
|
236
|
+
<Label className="text-xs text-muted-foreground">Action</Label>
|
|
237
|
+
<Select value={action} onValueChange={setAction}>
|
|
238
|
+
<SelectTrigger className="w-28">
|
|
239
|
+
<SelectValue />
|
|
240
|
+
</SelectTrigger>
|
|
241
|
+
<SelectContent>
|
|
242
|
+
{actions.map((a) => (
|
|
243
|
+
<SelectItem key={a} value={a}>
|
|
244
|
+
{a}
|
|
245
|
+
</SelectItem>
|
|
246
|
+
))}
|
|
247
|
+
</SelectContent>
|
|
248
|
+
</Select>
|
|
249
|
+
</div>
|
|
250
|
+
<Button type="button" size="sm" onClick={onAdd} disabled={adding || !grantee}>
|
|
251
|
+
{adding ? 'Adding…' : 'Add'}
|
|
252
|
+
</Button>
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
</DialogContent>
|
|
256
|
+
|
|
257
|
+
<ConfirmDialog
|
|
258
|
+
open={!!revokeTarget}
|
|
259
|
+
title={revokeTarget ? `Revoke ${revokeTarget.granteeName}'s access?` : 'Revoke access?'}
|
|
260
|
+
message="This removes their access to this record. This cannot be undone."
|
|
261
|
+
confirmLabel="Revoke"
|
|
262
|
+
destructive
|
|
263
|
+
busy={revokeBusy}
|
|
264
|
+
onConfirm={onConfirmRevoke}
|
|
265
|
+
onCancel={() => setRevokeTarget(null)}
|
|
266
|
+
/>
|
|
267
|
+
</Dialog>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RecordPoliciesPage — the GUIDED RULE-BUILDER (§9.1 / list-scoping engine Phase 5).
|
|
3
|
+
*
|
|
4
|
+
* Author record-access policies through a STRUCTURED, no-code form — never a raw expression:
|
|
5
|
+
* pick object type → load its schema → pick action + effect → add conditions
|
|
6
|
+
* (field dropdown → operator dropdown VALID for that field's type → value input typed per
|
|
7
|
+
* operator) → Save. The current policies for the selected type are listed with delete-confirm.
|
|
8
|
+
*
|
|
9
|
+
* ⛔ The field/operator dropdowns come from the SERVER schema
|
|
10
|
+
* (`GET /identity/record-policies/schema`), so the builder can only compose combinations the
|
|
11
|
+
* server accepts — one source of truth, no client-side operator table. The server re-validates
|
|
12
|
+
* (`validateGuidedPolicy`) and enforces the delegated-admin ceiling on save.
|
|
13
|
+
* ⛔ The target TENANT is never sent — the server authors in the caller's own tenant.
|
|
14
|
+
* ⛔ No raw UUID is shown — `policyId` is a delete key only.
|
|
15
|
+
*/
|
|
16
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
17
|
+
import { ShieldCheck, Plus, Trash2, Info } from 'lucide-react';
|
|
18
|
+
import { toast } from 'sonner';
|
|
19
|
+
import { useAuth } from '@/auth/AuthContext';
|
|
20
|
+
import AppShell from '@/shell/AppShell';
|
|
21
|
+
import PageHeader from '@/components/PageHeader';
|
|
22
|
+
import EntityList from '@/components/EntityList';
|
|
23
|
+
import ConfirmDialog from '@/components/ConfirmDialog';
|
|
24
|
+
import { Badge } from '@/components/ui/badge';
|
|
25
|
+
import { Button } from '@/components/ui/button';
|
|
26
|
+
import { Input } from '@/components/ui/input';
|
|
27
|
+
import { Label } from '@/components/ui/label';
|
|
28
|
+
import {
|
|
29
|
+
Select,
|
|
30
|
+
SelectContent,
|
|
31
|
+
SelectItem,
|
|
32
|
+
SelectTrigger,
|
|
33
|
+
SelectValue,
|
|
34
|
+
} from '@/components/ui/select';
|
|
35
|
+
|
|
36
|
+
/** The object types this app lets an admin author policies for. Extend as the app grows. */
|
|
37
|
+
const AUTHORABLE_TYPES = ['note'];
|
|
38
|
+
|
|
39
|
+
/** Human labels for the closed operator set. */
|
|
40
|
+
const OPERATOR_LABELS = {
|
|
41
|
+
eq: 'equals',
|
|
42
|
+
ne: 'does not equal',
|
|
43
|
+
in: 'is any of',
|
|
44
|
+
not_in: 'is none of',
|
|
45
|
+
gt: 'is greater than',
|
|
46
|
+
lt: 'is less than',
|
|
47
|
+
gte: 'is at least',
|
|
48
|
+
lte: 'is at most',
|
|
49
|
+
contains: 'contains',
|
|
50
|
+
not_contains: 'does not contain',
|
|
51
|
+
is_null: 'is empty',
|
|
52
|
+
is_not_null: 'is not empty',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** A fresh, empty condition row. */
|
|
56
|
+
const emptyCondition = () => ({ field: '', operator: '', value: '' });
|
|
57
|
+
|
|
58
|
+
export default function RecordPoliciesPage() {
|
|
59
|
+
const { tokens, recordPolicy } = useAuth();
|
|
60
|
+
const token = tokens?.accessToken;
|
|
61
|
+
|
|
62
|
+
const [typeKey, setTypeKey] = useState(AUTHORABLE_TYPES[0] ?? '');
|
|
63
|
+
const [schema, setSchema] = useState(null);
|
|
64
|
+
const [policies, setPolicies] = useState([]);
|
|
65
|
+
const [loading, setLoading] = useState(true);
|
|
66
|
+
const [error, setError] = useState('');
|
|
67
|
+
|
|
68
|
+
// The in-progress policy being built.
|
|
69
|
+
const [action, setAction] = useState('');
|
|
70
|
+
const [effect, setEffect] = useState('allow');
|
|
71
|
+
const [conditions, setConditions] = useState([emptyCondition()]);
|
|
72
|
+
const [saving, setSaving] = useState(false);
|
|
73
|
+
|
|
74
|
+
const [deleteTarget, setDeleteTarget] = useState(null);
|
|
75
|
+
const [deleteBusy, setDeleteBusy] = useState(false);
|
|
76
|
+
|
|
77
|
+
const fieldsByKey = useMemo(() => {
|
|
78
|
+
const m = new Map();
|
|
79
|
+
for (const f of schema?.fields ?? []) m.set(f.key, f);
|
|
80
|
+
return m;
|
|
81
|
+
}, [schema]);
|
|
82
|
+
|
|
83
|
+
const load = useCallback(async () => {
|
|
84
|
+
if (!token || !typeKey) return;
|
|
85
|
+
setLoading(true);
|
|
86
|
+
const [s, p] = await Promise.all([
|
|
87
|
+
recordPolicy.schema(token, typeKey),
|
|
88
|
+
recordPolicy.list(token, typeKey),
|
|
89
|
+
]);
|
|
90
|
+
if (s.ok) {
|
|
91
|
+
setSchema(s.schema);
|
|
92
|
+
// Default the action to the type's first action.
|
|
93
|
+
setAction((prev) => prev || s.schema.actions?.[0] || '');
|
|
94
|
+
setError('');
|
|
95
|
+
} else {
|
|
96
|
+
setSchema(null);
|
|
97
|
+
setError(s.error || 'Could not load the policy schema for this type.');
|
|
98
|
+
}
|
|
99
|
+
setPolicies(p.ok ? p.policies : []);
|
|
100
|
+
setLoading(false);
|
|
101
|
+
}, [recordPolicy, token, typeKey]);
|
|
102
|
+
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
load();
|
|
105
|
+
}, [load]);
|
|
106
|
+
|
|
107
|
+
// Reset the builder when the type changes (fields/actions differ per type).
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
setAction('');
|
|
110
|
+
setConditions([emptyCondition()]);
|
|
111
|
+
}, [typeKey]);
|
|
112
|
+
|
|
113
|
+
const setCondition = (i, patch) =>
|
|
114
|
+
setConditions((cs) => cs.map((c, idx) => (idx === i ? { ...c, ...patch } : c)));
|
|
115
|
+
const addCondition = () => setConditions((cs) => [...cs, emptyCondition()]);
|
|
116
|
+
const removeCondition = (i) =>
|
|
117
|
+
setConditions((cs) => (cs.length <= 1 ? cs : cs.filter((_, idx) => idx !== i)));
|
|
118
|
+
|
|
119
|
+
const onSave = async () => {
|
|
120
|
+
if (!action) {
|
|
121
|
+
toast.error('Pick an action.');
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
// Build the structured conditions, dropping incomplete rows; a nullary op needs no value.
|
|
125
|
+
const built = [];
|
|
126
|
+
for (const c of conditions) {
|
|
127
|
+
if (!c.field || !c.operator) continue;
|
|
128
|
+
const fieldSchema = fieldsByKey.get(c.field);
|
|
129
|
+
const isNullary = fieldSchema?.nullaryOperators?.includes(c.operator);
|
|
130
|
+
const isArray = fieldSchema?.arrayOperators?.includes(c.operator);
|
|
131
|
+
if (isNullary) {
|
|
132
|
+
built.push({ field: c.field, operator: c.operator });
|
|
133
|
+
} else if (isArray) {
|
|
134
|
+
const values = String(c.value ?? '')
|
|
135
|
+
.split(',')
|
|
136
|
+
.map((v) => v.trim())
|
|
137
|
+
.filter(Boolean);
|
|
138
|
+
built.push({ field: c.field, operator: c.operator, value: values });
|
|
139
|
+
} else {
|
|
140
|
+
built.push({ field: c.field, operator: c.operator, value: c.value });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
setSaving(true);
|
|
145
|
+
const r = await recordPolicy.author(token, { typeKey, action, effect, conditions: built });
|
|
146
|
+
setSaving(false);
|
|
147
|
+
if (!r.ok) {
|
|
148
|
+
toast.error(r.error || 'Could not author the policy.');
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
toast.success('Policy authored');
|
|
152
|
+
setConditions([emptyCondition()]);
|
|
153
|
+
await load();
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const onConfirmDelete = async () => {
|
|
157
|
+
if (!deleteTarget) return;
|
|
158
|
+
setDeleteBusy(true);
|
|
159
|
+
const r = await recordPolicy.remove(token, deleteTarget.policyId);
|
|
160
|
+
setDeleteBusy(false);
|
|
161
|
+
if (!r.ok) {
|
|
162
|
+
toast.error(r.error || 'Could not delete the policy.');
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
setDeleteTarget(null);
|
|
166
|
+
toast.success('Policy deleted');
|
|
167
|
+
await load();
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// Render one policy as a human sentence: "allow read where branch equals north AND …".
|
|
171
|
+
const describe = (policy) => {
|
|
172
|
+
const parts = (policy.conditions ?? []).map((c) => {
|
|
173
|
+
const label = OPERATOR_LABELS[c.operator] ?? c.operator;
|
|
174
|
+
const val = Array.isArray(c.value) ? c.value.join(', ') : c.value;
|
|
175
|
+
const isNullary = c.operator === 'is_null' || c.operator === 'is_not_null';
|
|
176
|
+
return `${c.field} ${label}${isNullary ? '' : ` ${val}`}`;
|
|
177
|
+
});
|
|
178
|
+
const where = parts.length ? ` where ${parts.join(' AND ')}` : ' (all records)';
|
|
179
|
+
return `${policy.effect} ${policy.action}${where}`;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const policyColumns = [
|
|
183
|
+
{
|
|
184
|
+
key: 'effect',
|
|
185
|
+
label: 'Effect',
|
|
186
|
+
render: (row) => (
|
|
187
|
+
<Badge variant={row.policy.effect === 'deny' ? 'destructive' : 'success'} className="capitalize font-normal">
|
|
188
|
+
{row.policy.effect}
|
|
189
|
+
</Badge>
|
|
190
|
+
),
|
|
191
|
+
},
|
|
192
|
+
{ key: 'action', label: 'Action', render: (row) => row.policy.action },
|
|
193
|
+
{ key: 'rule', label: 'Rule', render: (row) => <span className="text-sm">{describe(row.policy)}</span> },
|
|
194
|
+
{
|
|
195
|
+
key: 'rowActions',
|
|
196
|
+
label: '',
|
|
197
|
+
render: (row) => (
|
|
198
|
+
<div className="flex justify-end">
|
|
199
|
+
<Button
|
|
200
|
+
type="button"
|
|
201
|
+
variant="ghost"
|
|
202
|
+
size="sm"
|
|
203
|
+
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
|
204
|
+
onClick={() => setDeleteTarget(row)}
|
|
205
|
+
>
|
|
206
|
+
<Trash2 className="size-4" />
|
|
207
|
+
Delete
|
|
208
|
+
</Button>
|
|
209
|
+
</div>
|
|
210
|
+
),
|
|
211
|
+
},
|
|
212
|
+
];
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
<AppShell section="Identity" title="Record policies">
|
|
216
|
+
<div className="p-4">
|
|
217
|
+
<PageHeader section="Identity" title="Record policies" icon={ShieldCheck} />
|
|
218
|
+
|
|
219
|
+
{/* Type picker */}
|
|
220
|
+
<div className="mb-6 flex flex-wrap items-end gap-3">
|
|
221
|
+
<div className="space-y-1">
|
|
222
|
+
<Label htmlFor="policy-type">Object type</Label>
|
|
223
|
+
<Select value={typeKey} onValueChange={setTypeKey}>
|
|
224
|
+
<SelectTrigger id="policy-type" className="w-48">
|
|
225
|
+
<SelectValue placeholder="Pick a type" />
|
|
226
|
+
</SelectTrigger>
|
|
227
|
+
<SelectContent>
|
|
228
|
+
{AUTHORABLE_TYPES.map((t) => (
|
|
229
|
+
<SelectItem key={t} value={t}>
|
|
230
|
+
{t}
|
|
231
|
+
</SelectItem>
|
|
232
|
+
))}
|
|
233
|
+
</SelectContent>
|
|
234
|
+
</Select>
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
|
|
238
|
+
{/* The guided builder */}
|
|
239
|
+
{schema && (
|
|
240
|
+
<div className="mb-8 rounded-lg border border-border bg-card p-4">
|
|
241
|
+
<div className="mb-4 text-sm font-medium text-foreground">Build a rule</div>
|
|
242
|
+
|
|
243
|
+
<div className="mb-4 flex flex-wrap items-end gap-3">
|
|
244
|
+
<div className="space-y-1">
|
|
245
|
+
<Label htmlFor="policy-effect">Effect</Label>
|
|
246
|
+
<Select value={effect} onValueChange={setEffect}>
|
|
247
|
+
<SelectTrigger id="policy-effect" className="w-36">
|
|
248
|
+
<SelectValue />
|
|
249
|
+
</SelectTrigger>
|
|
250
|
+
<SelectContent>
|
|
251
|
+
<SelectItem value="allow">Allow</SelectItem>
|
|
252
|
+
<SelectItem value="deny">Deny</SelectItem>
|
|
253
|
+
</SelectContent>
|
|
254
|
+
</Select>
|
|
255
|
+
</div>
|
|
256
|
+
<div className="space-y-1">
|
|
257
|
+
<Label htmlFor="policy-action">Action</Label>
|
|
258
|
+
<Select value={action} onValueChange={setAction}>
|
|
259
|
+
<SelectTrigger id="policy-action" className="w-40">
|
|
260
|
+
<SelectValue placeholder="Pick an action" />
|
|
261
|
+
</SelectTrigger>
|
|
262
|
+
<SelectContent>
|
|
263
|
+
{(schema.actions ?? []).map((a) => (
|
|
264
|
+
<SelectItem key={a} value={a}>
|
|
265
|
+
{a}
|
|
266
|
+
</SelectItem>
|
|
267
|
+
))}
|
|
268
|
+
</SelectContent>
|
|
269
|
+
</Select>
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
|
|
273
|
+
<div className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
274
|
+
Where (all conditions must match)
|
|
275
|
+
</div>
|
|
276
|
+
<div className="space-y-2">
|
|
277
|
+
{conditions.map((c, i) => {
|
|
278
|
+
const fieldSchema = fieldsByKey.get(c.field);
|
|
279
|
+
const validOps = fieldSchema?.validOperators ?? [];
|
|
280
|
+
const isNullary = fieldSchema?.nullaryOperators?.includes(c.operator);
|
|
281
|
+
const isArray = fieldSchema?.arrayOperators?.includes(c.operator);
|
|
282
|
+
const isContains = c.operator === 'contains' || c.operator === 'not_contains';
|
|
283
|
+
return (
|
|
284
|
+
<div key={i} className="flex flex-wrap items-center gap-2">
|
|
285
|
+
{/* Field */}
|
|
286
|
+
<Select
|
|
287
|
+
value={c.field}
|
|
288
|
+
onValueChange={(field) => setCondition(i, { field, operator: '', value: '' })}
|
|
289
|
+
>
|
|
290
|
+
<SelectTrigger className="w-40">
|
|
291
|
+
<SelectValue placeholder="Field" />
|
|
292
|
+
</SelectTrigger>
|
|
293
|
+
<SelectContent>
|
|
294
|
+
{(schema.fields ?? []).map((f) => (
|
|
295
|
+
<SelectItem key={f.key} value={f.key}>
|
|
296
|
+
{f.label}
|
|
297
|
+
</SelectItem>
|
|
298
|
+
))}
|
|
299
|
+
</SelectContent>
|
|
300
|
+
</Select>
|
|
301
|
+
|
|
302
|
+
{/* Operator — only those VALID for the chosen field's type */}
|
|
303
|
+
<Select
|
|
304
|
+
value={c.operator}
|
|
305
|
+
onValueChange={(operator) => setCondition(i, { operator, value: '' })}
|
|
306
|
+
disabled={!c.field}
|
|
307
|
+
>
|
|
308
|
+
<SelectTrigger className="w-44">
|
|
309
|
+
<SelectValue placeholder="Operator" />
|
|
310
|
+
</SelectTrigger>
|
|
311
|
+
<SelectContent>
|
|
312
|
+
{validOps.map((op) => (
|
|
313
|
+
<SelectItem key={op} value={op}>
|
|
314
|
+
{OPERATOR_LABELS[op] ?? op}
|
|
315
|
+
</SelectItem>
|
|
316
|
+
))}
|
|
317
|
+
</SelectContent>
|
|
318
|
+
</Select>
|
|
319
|
+
|
|
320
|
+
{/* Value — hidden for nullary ops; a comma list hint for array ops */}
|
|
321
|
+
{!isNullary && (
|
|
322
|
+
<Input
|
|
323
|
+
className="w-56"
|
|
324
|
+
placeholder={isArray ? 'value1, value2, …' : 'value'}
|
|
325
|
+
value={c.value ?? ''}
|
|
326
|
+
onChange={(e) => setCondition(i, { value: e.target.value })}
|
|
327
|
+
disabled={!c.operator}
|
|
328
|
+
/>
|
|
329
|
+
)}
|
|
330
|
+
|
|
331
|
+
<Button
|
|
332
|
+
type="button"
|
|
333
|
+
variant="ghost"
|
|
334
|
+
size="icon"
|
|
335
|
+
onClick={() => removeCondition(i)}
|
|
336
|
+
disabled={conditions.length <= 1}
|
|
337
|
+
aria-label="Remove condition"
|
|
338
|
+
>
|
|
339
|
+
<Trash2 className="size-4" />
|
|
340
|
+
</Button>
|
|
341
|
+
|
|
342
|
+
{isContains && (
|
|
343
|
+
<span className="inline-flex items-center gap-1 text-xs text-amber-600 dark:text-amber-500">
|
|
344
|
+
<Info className="size-3.5" />
|
|
345
|
+
“contains” is not index-friendly on large tables.
|
|
346
|
+
</span>
|
|
347
|
+
)}
|
|
348
|
+
</div>
|
|
349
|
+
);
|
|
350
|
+
})}
|
|
351
|
+
</div>
|
|
352
|
+
|
|
353
|
+
<div className="mt-3 flex items-center gap-2">
|
|
354
|
+
<Button type="button" variant="outline" size="sm" onClick={addCondition}>
|
|
355
|
+
<Plus className="size-4" />
|
|
356
|
+
Add condition
|
|
357
|
+
</Button>
|
|
358
|
+
<Button type="button" size="sm" onClick={onSave} disabled={saving}>
|
|
359
|
+
{saving ? 'Saving…' : 'Save policy'}
|
|
360
|
+
</Button>
|
|
361
|
+
</div>
|
|
362
|
+
</div>
|
|
363
|
+
)}
|
|
364
|
+
|
|
365
|
+
{/* Current policies */}
|
|
366
|
+
<div className="mb-2 text-sm font-medium text-foreground">
|
|
367
|
+
Current policies for “{typeKey}”
|
|
368
|
+
</div>
|
|
369
|
+
<EntityList
|
|
370
|
+
columns={policyColumns}
|
|
371
|
+
rows={policies}
|
|
372
|
+
loading={loading}
|
|
373
|
+
error={error}
|
|
374
|
+
emptyLabel="No record policies yet — build one above."
|
|
375
|
+
/>
|
|
376
|
+
</div>
|
|
377
|
+
|
|
378
|
+
<ConfirmDialog
|
|
379
|
+
open={!!deleteTarget}
|
|
380
|
+
title="Delete this record policy?"
|
|
381
|
+
message="This removes the rule. Access to matching records reverts to the remaining policies. This cannot be undone."
|
|
382
|
+
confirmLabel="Delete"
|
|
383
|
+
destructive
|
|
384
|
+
busy={deleteBusy}
|
|
385
|
+
onConfirm={onConfirmDelete}
|
|
386
|
+
onCancel={() => setDeleteTarget(null)}
|
|
387
|
+
/>
|
|
388
|
+
</AppShell>
|
|
389
|
+
);
|
|
390
|
+
}
|
|
@@ -74,6 +74,14 @@ export const NAV_SECTIONS = [
|
|
|
74
74
|
matchPrefix: true,
|
|
75
75
|
gate: 'identity:role:read',
|
|
76
76
|
},
|
|
77
|
+
{
|
|
78
|
+
icon: ShieldCheck,
|
|
79
|
+
label: 'Record policies',
|
|
80
|
+
path: '/record-policies',
|
|
81
|
+
badge: null,
|
|
82
|
+
matchPrefix: true,
|
|
83
|
+
gate: 'identity:role:read',
|
|
84
|
+
},
|
|
77
85
|
{
|
|
78
86
|
icon: Handshake,
|
|
79
87
|
label: 'Cross-tenant',
|
|
@@ -19,11 +19,11 @@
|
|
|
19
19
|
"postinstall": "node scripts/sync-astram-guide.mjs"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@astram/core": "^1.
|
|
23
|
-
"@astram/engine": "^0.
|
|
24
|
-
"@astram/guard": "^0.
|
|
22
|
+
"@astram/core": "^1.2.0",
|
|
23
|
+
"@astram/engine": "^0.2.0",
|
|
24
|
+
"@astram/guard": "^0.6.0",
|
|
25
25
|
"@astram/guide": "^0.1.0",
|
|
26
|
-
"@astram/identity": "^0.
|
|
26
|
+
"@astram/identity": "^0.3.1",
|
|
27
27
|
"@astram/migrate": "^0.2.0",
|
|
28
28
|
"@astram/sdk": "^0.2.0",
|
|
29
29
|
"@nestjs/common": "^11.0.0",
|
|
@@ -36,11 +36,12 @@ import { DemoController } from './health/demo.controller';
|
|
|
36
36
|
* no controllers to write. (Leaving `auth` off keeps the zero-config enforcement
|
|
37
37
|
* boot; turning it on requires the config above, by design — fail-closed.)
|
|
38
38
|
*
|
|
39
|
-
* ── OPTIONAL: the identity ADMIN CONSOLE (companies/users/roles CRUD)
|
|
40
|
-
* The framework also ships the identity management surface — company/user/role CRUD
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
39
|
+
* ── OPTIONAL: the identity ADMIN CONSOLE (companies/users/roles CRUD + authz) ───
|
|
40
|
+
* The framework also ships the identity management surface — company/user/role CRUD,
|
|
41
|
+
* memberships, access/grants, audit, cross-tenant grants, the GUIDED RULE-BUILDER
|
|
42
|
+
* (record-access policies), and OWNER SHARES — as controllers you mount by importing
|
|
43
|
+
* ONE module. `@astram/guard` never imports `@astram/identity` (the dependency graph is
|
|
44
|
+
* identity → guard), so the consumer wires it (ADR-0026):
|
|
44
45
|
* 1. Turn auth on (above), pass the identity permission keys (so the frontend admin
|
|
45
46
|
* nav lights up), AND pass `recordAuthorization` (so the decision guard actually
|
|
46
47
|
* GRANTS — without it, decide() denies every classified route):
|
|
@@ -51,19 +52,30 @@ import { DemoController } from './health/demo.controller';
|
|
|
51
52
|
* ShieldModule.forRoot({ auth: true,
|
|
52
53
|
* identityActionKeys: identityAuthActionKeys, identityControlPlaneKeys,
|
|
53
54
|
* recordAuthorization: buildIdentityRecordAuthorization(authzPool) }),
|
|
54
|
-
* IdentityFeatureModule.forRoot(), // mounts /identity/{companies,users,roles}
|
|
55
|
+
* IdentityFeatureModule.forRoot(), // mounts /identity/{companies,users,roles,…}
|
|
55
56
|
* ],
|
|
56
57
|
* `IdentityFeatureModule.forRoot()` self-wires the engine from your `APP_DB_*` env
|
|
57
58
|
* (no separate engine setup). Every route is `@Classified` — enforced by the same
|
|
58
59
|
* deny-by-default guard; `recordAuthorization` makes that guard render REAL decisions
|
|
59
60
|
* from your roles' grants. Omit the module and login still works; the admin pages 404.
|
|
61
|
+
*
|
|
62
|
+
* ⛔ IF YOU REGISTER YOUR OWN OWNED OBJECT TYPES (e.g. a `Note`): pass them BOTH to
|
|
63
|
+
* `buildIdentityRecordAuthorization(authzPool, [Note])` (so their action keys project
|
|
64
|
+
* into gate-5 grants) AND to `IdentityFeatureModule.forRoot({ extraObjects: [Note] })`
|
|
65
|
+
* (so the GUIDED RULE-BUILDER schema + the OWNER-SHARE resolver know them — else a
|
|
66
|
+
* record of your type is NOT policy-authorable and NOT shareable: "type '…' is not
|
|
67
|
+
* registered — cannot share"). The frontend ships the matching pages/widget
|
|
68
|
+
* (RecordPoliciesPage, ShareDialog) already wired to `useAuth().recordPolicy` /
|
|
69
|
+
* `.recordShare`.
|
|
60
70
|
*/
|
|
61
71
|
@Module({
|
|
62
72
|
// Enforcement-only by default (boots with no config). For real login + the identity
|
|
63
|
-
// admin console
|
|
73
|
+
// admin console (incl. the guided rule-builder + owner shares), use (see the OPTIONAL
|
|
74
|
+
// blocks above for the full form + the extraObjects note for your OWN owned types):
|
|
64
75
|
// imports: [
|
|
65
|
-
// ShieldModule.forRoot({ auth: true, identityActionKeys, identityControlPlaneKeys
|
|
66
|
-
//
|
|
76
|
+
// ShieldModule.forRoot({ auth: true, identityActionKeys, identityControlPlaneKeys,
|
|
77
|
+
// recordAuthorization: buildIdentityRecordAuthorization(authzPool, [/* YourType */]) }),
|
|
78
|
+
// IdentityFeatureModule.forRoot({ /* extraObjects: [YourType] */ }),
|
|
67
79
|
// ],
|
|
68
80
|
imports: [ShieldModule.forRoot()],
|
|
69
81
|
controllers: [HealthController, DemoController],
|