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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-astram-app",
3
- "version": "0.3.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",
@@ -12,7 +12,7 @@
12
12
  "test": "node --test src/**/*.test.mjs"
13
13
  },
14
14
  "dependencies": {
15
- "@astram/frontend-core": "^0.2.0",
15
+ "@astram/frontend-core": "^0.3.0",
16
16
  "@astram/sdk": "^0.2.0",
17
17
  "@radix-ui/react-alert-dialog": "^1.1.19",
18
18
  "@radix-ui/react-avatar": "^1.1.10",
@@ -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 } = services.current;
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.0.0",
23
- "@astram/engine": "^0.1.3",
24
- "@astram/guard": "^0.5.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.2.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
- * (memberships, access, audit in later versions) — as controllers you mount by
42
- * importing ONE module. `@astram/guard` never imports `@astram/identity` (the
43
- * dependency graph is identity → guard), so the consumer wires it (ADR-0026):
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, use (see the OPTIONAL blocks above for the full form):
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
- // IdentityFeatureModule.forRoot(),
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],