create-astram-app 0.2.0 → 0.3.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 +24 -0
- package/template/frontend/src/auth/AuthContext.jsx +7 -2
- package/template/frontend/src/pages/cross-tenant/GrantForm.jsx +337 -0
- package/template/frontend/src/pages/cross-tenant/GrantsPage.jsx +237 -0
- package/template/frontend/src/pages/cross-tenant/InboxPage.jsx +179 -0
- package/template/frontend/src/shell/nav.js +13 -0
- package/template/server/package.json +4 -4
- package/template/server/src/app.module.ts +28 -2
- package/template/server/src/health/demo.controller.ts +23 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-astram-app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|
|
@@ -29,6 +29,9 @@ import UserForm from './pages/users/UserForm';
|
|
|
29
29
|
import AccessPage from './pages/access/AccessPage';
|
|
30
30
|
import AccessForm from './pages/access/AccessForm';
|
|
31
31
|
import AuditPage from './pages/audit/AuditPage';
|
|
32
|
+
import GrantsPage from './pages/cross-tenant/GrantsPage';
|
|
33
|
+
import GrantForm from './pages/cross-tenant/GrantForm';
|
|
34
|
+
import InboxPage from './pages/cross-tenant/InboxPage';
|
|
32
35
|
|
|
33
36
|
/** The single forced-re-enroll screen path (the enforcement's frontend twin). */
|
|
34
37
|
const REENROLL_PATH = '/account/reenroll';
|
|
@@ -152,6 +155,27 @@ export default function App() {
|
|
|
152
155
|
<Route path="add" element={<AccessForm />} />
|
|
153
156
|
<Route path="edit/:uuid" element={<AccessForm />} />
|
|
154
157
|
</Route>
|
|
158
|
+
{/* Cross-tenant grants — the grant book (mine + incoming) is a LAYOUT route; the
|
|
159
|
+
Offer form (`offer`) renders as a routed-modal overlay under it. The pending
|
|
160
|
+
Inbox is a sibling route. All gated `identity:role:read` (mirrors nav.js). */}
|
|
161
|
+
<Route
|
|
162
|
+
path="/cross-tenant"
|
|
163
|
+
element={
|
|
164
|
+
<RequireAuth gate="identity:role:read">
|
|
165
|
+
<GrantsPage />
|
|
166
|
+
</RequireAuth>
|
|
167
|
+
}
|
|
168
|
+
>
|
|
169
|
+
<Route path="offer" element={<GrantForm />} />
|
|
170
|
+
</Route>
|
|
171
|
+
<Route
|
|
172
|
+
path="/cross-tenant/inbox"
|
|
173
|
+
element={
|
|
174
|
+
<RequireAuth gate="identity:role:read">
|
|
175
|
+
<InboxPage />
|
|
176
|
+
</RequireAuth>
|
|
177
|
+
}
|
|
178
|
+
/>
|
|
155
179
|
{/* Audit — read-only, no child add/edit routes (audit events are immutable). */}
|
|
156
180
|
<Route
|
|
157
181
|
path="/audit"
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
createAccountService,
|
|
16
16
|
createAuditService,
|
|
17
17
|
createAuthService,
|
|
18
|
+
createCrossTenantService,
|
|
18
19
|
createIdentityService,
|
|
19
20
|
isAuthenticated,
|
|
20
21
|
principalOf,
|
|
@@ -64,6 +65,7 @@ export function AuthProvider({ children }) {
|
|
|
64
65
|
auth: createAuthService(http),
|
|
65
66
|
identity: createIdentityService(http),
|
|
66
67
|
audit: createAuditService(http),
|
|
68
|
+
crossTenant: createCrossTenantService(http),
|
|
67
69
|
account: createAccountService(http),
|
|
68
70
|
// The @astram/sdk ShieldClient (Phase E slice 1) — a UX-convenience projection of
|
|
69
71
|
// the caller's OWN coarse grants, fetched once per session via `getAuthFacts`.
|
|
@@ -79,7 +81,7 @@ export function AuthProvider({ children }) {
|
|
|
79
81
|
store,
|
|
80
82
|
};
|
|
81
83
|
}
|
|
82
|
-
const { auth, identity, audit, account, shield, store } = services.current;
|
|
84
|
+
const { auth, identity, audit, crossTenant, account, shield, store } = services.current;
|
|
83
85
|
|
|
84
86
|
const [state, setState] = useState(() => store.load()); // SessionState | null
|
|
85
87
|
// The signed-in user's DISPLAY profile (name/email/company/roles) from GET /auth/me.
|
|
@@ -279,6 +281,9 @@ export function AuthProvider({ children }) {
|
|
|
279
281
|
identity,
|
|
280
282
|
// The shared audit-log service (audit viewer) — same injected web http client.
|
|
281
283
|
audit,
|
|
284
|
+
// The shared cross-tenant grant service (offer/accept/revoke + inbox/mine +
|
|
285
|
+
// offerable-types) — same injected web http client.
|
|
286
|
+
crossTenant,
|
|
282
287
|
// The shared self-scoped account service (change-password today; profile/MFA
|
|
283
288
|
// later) — same injected web http client.
|
|
284
289
|
account,
|
|
@@ -301,7 +306,7 @@ export function AuthProvider({ children }) {
|
|
|
301
306
|
),
|
|
302
307
|
}),
|
|
303
308
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
304
|
-
[state, auth, identity, audit, account, shield, profile, mustReenroll, authFacts],
|
|
309
|
+
[state, auth, identity, audit, crossTenant, account, shield, profile, mustReenroll, authFacts],
|
|
305
310
|
);
|
|
306
311
|
|
|
307
312
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GrantForm — the "Offer a cross-tenant grant" form, rendered as a routed-modal overlay
|
|
3
|
+
* by `/cross-tenant/offer` under GrantsPage's layout (mirrors the identity add/edit
|
|
4
|
+
* forms' routed-modal shape: `useOutletContext` for `onRefresh`, closes via
|
|
5
|
+
* `navigate(-1)`).
|
|
6
|
+
*
|
|
7
|
+
* Composition (all EXISTING components — no new CSS):
|
|
8
|
+
* - `SearchableSelect` for the grantee TENANT picker (over `identity.listCompanies`)
|
|
9
|
+
* - a grantee-kind toggle (user/role) — plain Button variants
|
|
10
|
+
* - `SearchableSelect` for the grantee user/role picker (over `identity.listUsers`/
|
|
11
|
+
* `identity.listRoles`) — resets when the tenant OR kind changes
|
|
12
|
+
* - a plain `Select` for the object type (from `crossTenant.listOfferableTypes` —
|
|
13
|
+
* tenantScoped types ONLY, decision 1)
|
|
14
|
+
* - a `Checkbox` per allowed-action (the type's action vocabulary) — at least one
|
|
15
|
+
* required; NEVER a wildcard option is offered (the writer hard-rejects '*')
|
|
16
|
+
* - a native `DateField` for the expiry, defaulting to now+90 days, converted to a
|
|
17
|
+
* future epoch-ms on submit (decision 2: mandatory, default 90 days)
|
|
18
|
+
*
|
|
19
|
+
* ⛔ Object_type scope ONLY (decision 4): there is no recordId/scope field anywhere in
|
|
20
|
+
* this form — the server hard-codes `scope:'object_type'` from the authenticated
|
|
21
|
+
* principal's context; nothing here could send a record-scoped offer even if it tried.
|
|
22
|
+
*
|
|
23
|
+
* ⛔ `owningTenantId`/`offeredBy` are never sent from here — only
|
|
24
|
+
* `{ granteeTenantId, granteeKind, granteeUuid, typeKey, allowedActions, expiresAtMs }`.
|
|
25
|
+
*/
|
|
26
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
27
|
+
import { useNavigate, useOutletContext } from 'react-router-dom';
|
|
28
|
+
import { toast } from 'sonner';
|
|
29
|
+
import { useAuth } from '@/auth/AuthContext';
|
|
30
|
+
import EntityFormModal from '@/components/EntityFormModal';
|
|
31
|
+
import { Button } from '@/components/ui/button';
|
|
32
|
+
import { Label } from '@/components/ui/label';
|
|
33
|
+
import { Checkbox } from '@/components/ui/checkbox';
|
|
34
|
+
import { DateField } from '@/components/ui/date-field';
|
|
35
|
+
import { SearchableSelect } from '@/components/ui/searchable-select';
|
|
36
|
+
import {
|
|
37
|
+
Select,
|
|
38
|
+
SelectContent,
|
|
39
|
+
SelectItem,
|
|
40
|
+
SelectTrigger,
|
|
41
|
+
SelectValue,
|
|
42
|
+
} from '@/components/ui/select';
|
|
43
|
+
|
|
44
|
+
const DEFAULT_EXPIRY_DAYS = 90;
|
|
45
|
+
|
|
46
|
+
/** now + N days, as a yyyy-mm-dd string for the native date input's default value. */
|
|
47
|
+
function defaultExpiryDateString() {
|
|
48
|
+
const d = new Date(Date.now() + DEFAULT_EXPIRY_DAYS * 24 * 60 * 60 * 1000);
|
|
49
|
+
return d.toISOString().slice(0, 10);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A yyyy-mm-dd date string -> END-OF-DAY epoch-ms (so "today" is still a valid future expiry). */
|
|
53
|
+
function dateStringToEndOfDayMs(dateStr) {
|
|
54
|
+
if (!dateStr) return NaN;
|
|
55
|
+
const d = new Date(`${dateStr}T23:59:59.999`);
|
|
56
|
+
return d.getTime();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export default function GrantForm() {
|
|
60
|
+
const { tokens, identity, crossTenant } = useAuth();
|
|
61
|
+
const token = tokens?.accessToken;
|
|
62
|
+
const navigate = useNavigate();
|
|
63
|
+
const { onRefresh } = useOutletContext() ?? {};
|
|
64
|
+
|
|
65
|
+
const [granteeTenantId, setGranteeTenantId] = useState(null);
|
|
66
|
+
const [granteeTenantLabel, setGranteeTenantLabel] = useState(null);
|
|
67
|
+
|
|
68
|
+
const [granteeKind, setGranteeKind] = useState('user');
|
|
69
|
+
const [granteeUuid, setGranteeUuid] = useState(null);
|
|
70
|
+
const [granteeLabel, setGranteeLabel] = useState(null);
|
|
71
|
+
|
|
72
|
+
const [types, setTypes] = useState([]);
|
|
73
|
+
const [typesLoading, setTypesLoading] = useState(true);
|
|
74
|
+
const [typeKey, setTypeKey] = useState('');
|
|
75
|
+
const [selectedActions, setSelectedActions] = useState(new Set());
|
|
76
|
+
|
|
77
|
+
const [expiryDate, setExpiryDate] = useState(defaultExpiryDateString());
|
|
78
|
+
|
|
79
|
+
const [busy, setBusy] = useState(false);
|
|
80
|
+
const [error, setError] = useState('');
|
|
81
|
+
|
|
82
|
+
// Load the offerable (tenantScoped-only) object types once.
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (!token) return undefined;
|
|
85
|
+
let active = true;
|
|
86
|
+
(async () => {
|
|
87
|
+
setTypesLoading(true);
|
|
88
|
+
const r = await crossTenant.listOfferableTypes(token);
|
|
89
|
+
if (!active) return;
|
|
90
|
+
if (r.ok) {
|
|
91
|
+
setTypes(r.types || []);
|
|
92
|
+
if (r.types?.length === 1) setTypeKey(r.types[0].typeKey);
|
|
93
|
+
} else {
|
|
94
|
+
setError(r.error || 'Could not load the offerable object types.');
|
|
95
|
+
}
|
|
96
|
+
setTypesLoading(false);
|
|
97
|
+
})();
|
|
98
|
+
return () => {
|
|
99
|
+
active = false;
|
|
100
|
+
};
|
|
101
|
+
}, [crossTenant, token]);
|
|
102
|
+
|
|
103
|
+
const currentType = useMemo(() => types.find((t) => t.typeKey === typeKey), [types, typeKey]);
|
|
104
|
+
|
|
105
|
+
// Reset the chosen actions whenever the object type changes (a prior type's actions
|
|
106
|
+
// don't necessarily apply to the new one).
|
|
107
|
+
const onTypeChange = (nextKey) => {
|
|
108
|
+
setTypeKey(nextKey);
|
|
109
|
+
setSelectedActions(new Set());
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const toggleAction = (action) => {
|
|
113
|
+
setSelectedActions((prev) => {
|
|
114
|
+
const next = new Set(prev);
|
|
115
|
+
if (next.has(action)) next.delete(action);
|
|
116
|
+
else next.add(action);
|
|
117
|
+
return next;
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// Reset the grantee user/role whenever the TENANT or KIND changes (a uuid from a
|
|
122
|
+
// different tenant/kind is meaningless).
|
|
123
|
+
const onTenantChange = (value) => {
|
|
124
|
+
setGranteeTenantId(value);
|
|
125
|
+
setGranteeUuid(null);
|
|
126
|
+
setGranteeLabel(null);
|
|
127
|
+
};
|
|
128
|
+
const onKindChange = (kind) => {
|
|
129
|
+
setGranteeKind(kind);
|
|
130
|
+
setGranteeUuid(null);
|
|
131
|
+
setGranteeLabel(null);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const fetchTenants = useCallback(
|
|
135
|
+
async ({ search: term, page, limit }) => {
|
|
136
|
+
const r = await identity.listCompanies(token, { page, limit, search: term, sortBy: 'name', sortOrder: 'asc' });
|
|
137
|
+
if (!r.ok) return { options: [], total: 0, page };
|
|
138
|
+
return {
|
|
139
|
+
options: (r.rows || []).map((row) => ({ value: row.id, label: row.fields?.name || '(unnamed)' })),
|
|
140
|
+
total: r.total ?? 0,
|
|
141
|
+
page: r.page ?? page,
|
|
142
|
+
};
|
|
143
|
+
},
|
|
144
|
+
[identity, token],
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const fetchGrantees = useCallback(
|
|
148
|
+
async ({ search: term, page, limit }) => {
|
|
149
|
+
if (granteeKind === 'role') {
|
|
150
|
+
const r = await identity.listRoles(token, { page, limit, search: term, sortBy: 'name', sortOrder: 'asc' });
|
|
151
|
+
if (!r.ok) return { options: [], total: 0, page };
|
|
152
|
+
return {
|
|
153
|
+
options: (r.rows || []).map((row) => ({ value: row.id, label: row.fields?.name || '(unnamed)' })),
|
|
154
|
+
total: r.total ?? 0,
|
|
155
|
+
page: r.page ?? page,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const r = await identity.listUsers(token, { page, limit, search: term, sortBy: 'username', sortOrder: 'asc' });
|
|
159
|
+
if (!r.ok) return { options: [], total: 0, page };
|
|
160
|
+
return {
|
|
161
|
+
options: (r.rows || []).map((row) => ({ value: row.id, label: row.fields?.username || '(unnamed)' })),
|
|
162
|
+
total: r.total ?? 0,
|
|
163
|
+
page: r.page ?? page,
|
|
164
|
+
};
|
|
165
|
+
},
|
|
166
|
+
[identity, token, granteeKind],
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const close = () => navigate(-1);
|
|
170
|
+
|
|
171
|
+
const onSubmit = async () => {
|
|
172
|
+
if (!granteeTenantId) {
|
|
173
|
+
setError('Pick a grantee tenant.');
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (!granteeUuid) {
|
|
177
|
+
setError(`Pick a grantee ${granteeKind}.`);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (!typeKey) {
|
|
181
|
+
setError('Pick an object type.');
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const allowedActions = [...selectedActions];
|
|
185
|
+
if (allowedActions.length === 0) {
|
|
186
|
+
setError('Pick at least one allowed action.');
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const expiresAtMs = dateStringToEndOfDayMs(expiryDate);
|
|
190
|
+
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) {
|
|
191
|
+
setError('Pick a future expiry date.');
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
setError('');
|
|
196
|
+
setBusy(true);
|
|
197
|
+
const r = await crossTenant.offer(token, {
|
|
198
|
+
granteeTenantId,
|
|
199
|
+
granteeKind,
|
|
200
|
+
granteeUuid,
|
|
201
|
+
typeKey,
|
|
202
|
+
allowedActions,
|
|
203
|
+
expiresAtMs,
|
|
204
|
+
});
|
|
205
|
+
setBusy(false);
|
|
206
|
+
if (!r.ok) {
|
|
207
|
+
setError(r.error || 'Could not offer the grant.');
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
toast.success('Grant offered');
|
|
211
|
+
onRefresh?.();
|
|
212
|
+
close();
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<EntityFormModal
|
|
217
|
+
title="Offer a cross-tenant grant"
|
|
218
|
+
description="Let another tenant's user or role reach into one of your object types, for a bounded set of actions until an expiry."
|
|
219
|
+
open
|
|
220
|
+
onClose={close}
|
|
221
|
+
onSubmit={onSubmit}
|
|
222
|
+
submitLabel="Offer grant"
|
|
223
|
+
busy={busy}
|
|
224
|
+
error={error}
|
|
225
|
+
>
|
|
226
|
+
<div className="space-y-1.5">
|
|
227
|
+
<Label htmlFor="grant-tenant" variant="field">
|
|
228
|
+
Grantee tenant
|
|
229
|
+
</Label>
|
|
230
|
+
<SearchableSelect
|
|
231
|
+
value={granteeTenantId}
|
|
232
|
+
selectedLabel={granteeTenantLabel}
|
|
233
|
+
onChange={onTenantChange}
|
|
234
|
+
onLabelChange={setGranteeTenantLabel}
|
|
235
|
+
fetchPage={fetchTenants}
|
|
236
|
+
placeholder="Select a tenant"
|
|
237
|
+
searchPlaceholder="Search tenants"
|
|
238
|
+
/>
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
<div className="space-y-1.5">
|
|
242
|
+
<Label variant="field">Grant to</Label>
|
|
243
|
+
<div className="flex gap-2">
|
|
244
|
+
<Button
|
|
245
|
+
type="button"
|
|
246
|
+
variant={granteeKind === 'user' ? 'default' : 'outline'}
|
|
247
|
+
size="sm"
|
|
248
|
+
onClick={() => onKindChange('user')}
|
|
249
|
+
>
|
|
250
|
+
User
|
|
251
|
+
</Button>
|
|
252
|
+
<Button
|
|
253
|
+
type="button"
|
|
254
|
+
variant={granteeKind === 'role' ? 'default' : 'outline'}
|
|
255
|
+
size="sm"
|
|
256
|
+
onClick={() => onKindChange('role')}
|
|
257
|
+
>
|
|
258
|
+
Role
|
|
259
|
+
</Button>
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
|
|
263
|
+
<div className="space-y-1.5">
|
|
264
|
+
<Label htmlFor="grant-grantee" variant="field">
|
|
265
|
+
{granteeKind === 'role' ? 'Role' : 'User'}
|
|
266
|
+
</Label>
|
|
267
|
+
<SearchableSelect
|
|
268
|
+
key={`${granteeTenantId ?? ''}:${granteeKind}`}
|
|
269
|
+
value={granteeUuid}
|
|
270
|
+
selectedLabel={granteeLabel}
|
|
271
|
+
onChange={setGranteeUuid}
|
|
272
|
+
onLabelChange={setGranteeLabel}
|
|
273
|
+
fetchPage={fetchGrantees}
|
|
274
|
+
placeholder={granteeKind === 'role' ? 'Select a role' : 'Select a user'}
|
|
275
|
+
searchPlaceholder={granteeKind === 'role' ? 'Search roles' : 'Search users'}
|
|
276
|
+
/>
|
|
277
|
+
</div>
|
|
278
|
+
|
|
279
|
+
<div className="space-y-1.5">
|
|
280
|
+
<Label htmlFor="grant-type" variant="field">
|
|
281
|
+
Object type
|
|
282
|
+
</Label>
|
|
283
|
+
<Select value={typeKey} onValueChange={onTypeChange} disabled={typesLoading || types.length === 0}>
|
|
284
|
+
<SelectTrigger id="grant-type">
|
|
285
|
+
<SelectValue placeholder={typesLoading ? 'Loading…' : 'Select an object type'} />
|
|
286
|
+
</SelectTrigger>
|
|
287
|
+
<SelectContent>
|
|
288
|
+
{types.map((t) => (
|
|
289
|
+
<SelectItem key={t.typeKey} value={t.typeKey}>
|
|
290
|
+
{t.label}
|
|
291
|
+
</SelectItem>
|
|
292
|
+
))}
|
|
293
|
+
</SelectContent>
|
|
294
|
+
</Select>
|
|
295
|
+
{!typesLoading && types.length === 0 && (
|
|
296
|
+
<p className="text-xs text-muted-foreground">No object types are available to offer yet.</p>
|
|
297
|
+
)}
|
|
298
|
+
</div>
|
|
299
|
+
|
|
300
|
+
{currentType && (
|
|
301
|
+
<div className="space-y-1.5">
|
|
302
|
+
<Label variant="field">Allowed actions</Label>
|
|
303
|
+
<div className="flex flex-col gap-2">
|
|
304
|
+
{currentType.actions.map((action) => (
|
|
305
|
+
<label key={action} className="flex items-center gap-2 text-sm">
|
|
306
|
+
<Checkbox
|
|
307
|
+
checked={selectedActions.has(action)}
|
|
308
|
+
onCheckedChange={() => toggleAction(action)}
|
|
309
|
+
/>
|
|
310
|
+
<span className="capitalize">{action}</span>
|
|
311
|
+
</label>
|
|
312
|
+
))}
|
|
313
|
+
</div>
|
|
314
|
+
<p className="text-xs text-muted-foreground">
|
|
315
|
+
Pick at least one. The grantee can never do more than this explicit set.
|
|
316
|
+
</p>
|
|
317
|
+
</div>
|
|
318
|
+
)}
|
|
319
|
+
|
|
320
|
+
<div className="space-y-1.5">
|
|
321
|
+
<Label htmlFor="grant-expiry" variant="field">
|
|
322
|
+
Expires on
|
|
323
|
+
</Label>
|
|
324
|
+
<DateField
|
|
325
|
+
id="grant-expiry"
|
|
326
|
+
value={expiryDate}
|
|
327
|
+
onChange={(e) => setExpiryDate(e.target.value)}
|
|
328
|
+
min={new Date().toISOString().slice(0, 10)}
|
|
329
|
+
/>
|
|
330
|
+
<p className="text-xs text-muted-foreground">
|
|
331
|
+
Defaults to {DEFAULT_EXPIRY_DAYS} days out. The grant must have a future expiry — no
|
|
332
|
+
perpetual grants.
|
|
333
|
+
</p>
|
|
334
|
+
</div>
|
|
335
|
+
</EntityFormModal>
|
|
336
|
+
);
|
|
337
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GrantsPage — my tenant's cross-tenant grant book: the OUTGOING grants my tenant OWNS
|
|
3
|
+
* (any status) + the INCOMING grants ACCEPTED into my tenant. Mirrors the shared
|
|
4
|
+
* identity-admin list shape (AppShell + PageHeader + a controlled `EntityList`), plus
|
|
5
|
+
* an `<Outlet context={{ onRefresh }} />` for the routed-modal offer form at
|
|
6
|
+
* `/cross-tenant/offer`.
|
|
7
|
+
*
|
|
8
|
+
* Two lists on one page (both server-scoped to MY tenant, never a client-chosen
|
|
9
|
+
* tenant): "Outgoing" (rows, any status — Revoke available while not already revoked)
|
|
10
|
+
* and "Incoming" (accepted grants reaching INTO my tenant — read-only here; the grantee
|
|
11
|
+
* side manages acceptance from the Inbox page).
|
|
12
|
+
*
|
|
13
|
+
* ⛔ No raw UUIDs: every column renders a resolved NAME/label (grantee tenant/user/role
|
|
14
|
+
* name, type label, offeredBy name); `row.uuid` is a key/API-arg only.
|
|
15
|
+
*/
|
|
16
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
17
|
+
import { Outlet, useNavigate } from 'react-router-dom';
|
|
18
|
+
import { Handshake, Inbox } from 'lucide-react';
|
|
19
|
+
import { toast } from 'sonner';
|
|
20
|
+
import { useAuth } from '@/auth/AuthContext';
|
|
21
|
+
import AppShell from '@/shell/AppShell';
|
|
22
|
+
import PageHeader from '@/components/PageHeader';
|
|
23
|
+
import EntityList, { EntityListControls } from '@/components/EntityList';
|
|
24
|
+
import ConfirmDialog from '@/components/ConfirmDialog';
|
|
25
|
+
import { Badge } from '@/components/ui/badge';
|
|
26
|
+
import { Button } from '@/components/ui/button';
|
|
27
|
+
|
|
28
|
+
/** Compact absolute date — e.g. "13 Jul 2026"; '—' when missing/invalid. */
|
|
29
|
+
function formatDate(ms) {
|
|
30
|
+
if (!ms && ms !== 0) return '—';
|
|
31
|
+
const d = new Date(ms);
|
|
32
|
+
if (Number.isNaN(d.getTime())) return '—';
|
|
33
|
+
return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function statusVariant(status) {
|
|
37
|
+
if (status === 'accepted') return 'success';
|
|
38
|
+
if (status === 'revoked') return 'destructive';
|
|
39
|
+
return 'info'; // 'offered'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function ActionChips({ actions }) {
|
|
43
|
+
if (!actions || actions.length === 0) return <span className="text-xs text-muted-foreground">—</span>;
|
|
44
|
+
return (
|
|
45
|
+
<div className="flex flex-wrap gap-1">
|
|
46
|
+
{actions.map((a) => (
|
|
47
|
+
<Badge key={a} variant="secondary" className="font-normal">
|
|
48
|
+
{a}
|
|
49
|
+
</Badge>
|
|
50
|
+
))}
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default function GrantsPage() {
|
|
56
|
+
const { tokens, crossTenant } = useAuth();
|
|
57
|
+
const token = tokens?.accessToken;
|
|
58
|
+
const navigate = useNavigate();
|
|
59
|
+
|
|
60
|
+
const [rows, setRows] = useState([]);
|
|
61
|
+
const [incoming, setIncoming] = useState([]);
|
|
62
|
+
const [loading, setLoading] = useState(true);
|
|
63
|
+
const [error, setError] = useState('');
|
|
64
|
+
|
|
65
|
+
const [revokeTarget, setRevokeTarget] = useState(null);
|
|
66
|
+
const [revokeBusy, setRevokeBusy] = useState(false);
|
|
67
|
+
|
|
68
|
+
// Abort a superseded list request (rapid refreshes) — mirrors UsersPage/AuditPage.
|
|
69
|
+
const loadAbortRef = useRef(/** @type {AbortController | null} */ (null));
|
|
70
|
+
|
|
71
|
+
const load = useCallback(async () => {
|
|
72
|
+
if (!token) return;
|
|
73
|
+
loadAbortRef.current?.abort();
|
|
74
|
+
const controller = new AbortController();
|
|
75
|
+
loadAbortRef.current = controller;
|
|
76
|
+
|
|
77
|
+
setLoading(true);
|
|
78
|
+
const r = await crossTenant.listGrants(token, { signal: controller.signal });
|
|
79
|
+
|
|
80
|
+
if (controller.signal.aborted || loadAbortRef.current !== controller) return;
|
|
81
|
+
|
|
82
|
+
if (r.ok) {
|
|
83
|
+
setRows(r.rows || []);
|
|
84
|
+
setIncoming(r.incoming || []);
|
|
85
|
+
setError('');
|
|
86
|
+
} else {
|
|
87
|
+
setRows([]);
|
|
88
|
+
setIncoming([]);
|
|
89
|
+
setError(r.error || 'Could not load your cross-tenant grants.');
|
|
90
|
+
}
|
|
91
|
+
setLoading(false);
|
|
92
|
+
}, [crossTenant, token]);
|
|
93
|
+
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
load();
|
|
96
|
+
return () => loadAbortRef.current?.abort();
|
|
97
|
+
}, [load]);
|
|
98
|
+
|
|
99
|
+
const onConfirmRevoke = async () => {
|
|
100
|
+
if (!revokeTarget) return;
|
|
101
|
+
setRevokeBusy(true);
|
|
102
|
+
const r = await crossTenant.revoke(token, revokeTarget.uuid);
|
|
103
|
+
setRevokeBusy(false);
|
|
104
|
+
if (!r.ok) {
|
|
105
|
+
toast.error(r.error || 'Could not revoke the grant.');
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
setRevokeTarget(null);
|
|
109
|
+
toast.success('Grant revoked');
|
|
110
|
+
await load();
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const outgoingColumns = [
|
|
114
|
+
{
|
|
115
|
+
key: 'granteeTenantName',
|
|
116
|
+
label: 'Grantee tenant',
|
|
117
|
+
render: (row) => <span className="font-medium text-foreground">{row.granteeTenantName}</span>,
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
key: 'granteeName',
|
|
121
|
+
label: 'Grantee',
|
|
122
|
+
render: (row) => (
|
|
123
|
+
<span>
|
|
124
|
+
{row.granteeName}{' '}
|
|
125
|
+
<span className="text-xs text-muted-foreground">({row.granteeKind})</span>
|
|
126
|
+
</span>
|
|
127
|
+
),
|
|
128
|
+
},
|
|
129
|
+
{ key: 'typeLabel', label: 'Object type' },
|
|
130
|
+
{ key: 'actions', label: 'Actions', render: (row) => <ActionChips actions={row.allowedActions} /> },
|
|
131
|
+
{
|
|
132
|
+
key: 'status',
|
|
133
|
+
label: 'Status',
|
|
134
|
+
render: (row) => (
|
|
135
|
+
<Badge variant={statusVariant(row.status)} className="font-normal capitalize">
|
|
136
|
+
{row.status}
|
|
137
|
+
</Badge>
|
|
138
|
+
),
|
|
139
|
+
},
|
|
140
|
+
{ key: 'expiresAtMs', label: 'Expires', render: (row) => formatDate(row.expiresAtMs) },
|
|
141
|
+
{ key: 'offeredByName', label: 'Offered by' },
|
|
142
|
+
{
|
|
143
|
+
key: 'rowActions',
|
|
144
|
+
label: '',
|
|
145
|
+
render: (row) =>
|
|
146
|
+
row.status !== 'revoked' ? (
|
|
147
|
+
<div className="flex justify-end">
|
|
148
|
+
<Button
|
|
149
|
+
type="button"
|
|
150
|
+
variant="ghost"
|
|
151
|
+
size="sm"
|
|
152
|
+
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
|
153
|
+
onClick={() => setRevokeTarget(row)}
|
|
154
|
+
>
|
|
155
|
+
Revoke
|
|
156
|
+
</Button>
|
|
157
|
+
</div>
|
|
158
|
+
) : null,
|
|
159
|
+
},
|
|
160
|
+
];
|
|
161
|
+
|
|
162
|
+
const incomingColumns = [
|
|
163
|
+
{
|
|
164
|
+
key: 'owningTenantName',
|
|
165
|
+
label: 'Owning tenant',
|
|
166
|
+
render: (row) => <span className="font-medium text-foreground">{row.owningTenantName}</span>,
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
key: 'granteeName',
|
|
170
|
+
label: 'Granted to',
|
|
171
|
+
render: (row) => (
|
|
172
|
+
<span>
|
|
173
|
+
{row.granteeName}{' '}
|
|
174
|
+
<span className="text-xs text-muted-foreground">({row.granteeKind})</span>
|
|
175
|
+
</span>
|
|
176
|
+
),
|
|
177
|
+
},
|
|
178
|
+
{ key: 'typeLabel', label: 'Object type' },
|
|
179
|
+
{ key: 'actions', label: 'Actions', render: (row) => <ActionChips actions={row.allowedActions} /> },
|
|
180
|
+
{ key: 'expiresAtMs', label: 'Expires', render: (row) => formatDate(row.expiresAtMs) },
|
|
181
|
+
{ key: 'offeredByName', label: 'Offered by' },
|
|
182
|
+
];
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<AppShell section="Identity" title="Cross-tenant grants">
|
|
186
|
+
<div className="p-4">
|
|
187
|
+
<PageHeader
|
|
188
|
+
section="Identity"
|
|
189
|
+
title="Cross-tenant grants"
|
|
190
|
+
icon={Handshake}
|
|
191
|
+
actions={
|
|
192
|
+
<>
|
|
193
|
+
<Button type="button" variant="outline" size="sm" onClick={() => navigate('/cross-tenant/inbox')}>
|
|
194
|
+
<Inbox className="size-4" />
|
|
195
|
+
Inbox
|
|
196
|
+
</Button>
|
|
197
|
+
<EntityListControls addLabel="Offer grant" onAdd={() => navigate('/cross-tenant/offer')} />
|
|
198
|
+
</>
|
|
199
|
+
}
|
|
200
|
+
/>
|
|
201
|
+
|
|
202
|
+
<div className="mb-2 text-sm font-medium text-foreground">Outgoing — grants you've offered</div>
|
|
203
|
+
<EntityList
|
|
204
|
+
columns={outgoingColumns}
|
|
205
|
+
rows={rows}
|
|
206
|
+
loading={loading}
|
|
207
|
+
error={error}
|
|
208
|
+
emptyLabel="You haven't offered any cross-tenant grants yet."
|
|
209
|
+
/>
|
|
210
|
+
|
|
211
|
+
<div className="mt-6 mb-2 text-sm font-medium text-foreground">
|
|
212
|
+
Incoming — grants accepted into your tenant
|
|
213
|
+
</div>
|
|
214
|
+
<EntityList
|
|
215
|
+
columns={incomingColumns}
|
|
216
|
+
rows={incoming}
|
|
217
|
+
loading={loading}
|
|
218
|
+
error={error}
|
|
219
|
+
emptyLabel="No cross-tenant grants have been accepted into your tenant."
|
|
220
|
+
/>
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
<Outlet context={{ onRefresh: load }} />
|
|
224
|
+
|
|
225
|
+
<ConfirmDialog
|
|
226
|
+
open={!!revokeTarget}
|
|
227
|
+
title={revokeTarget ? `Revoke the grant to “${revokeTarget.granteeTenantName}”?` : 'Revoke grant?'}
|
|
228
|
+
message="This immediately revokes the grant. The grantee tenant will lose access. This cannot be undone."
|
|
229
|
+
confirmLabel="Revoke"
|
|
230
|
+
destructive
|
|
231
|
+
busy={revokeBusy}
|
|
232
|
+
onConfirm={onConfirmRevoke}
|
|
233
|
+
onCancel={() => setRevokeTarget(null)}
|
|
234
|
+
/>
|
|
235
|
+
</AppShell>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InboxPage — my tenant's pending cross-tenant grant OFFERS: grants another tenant's
|
|
3
|
+
* admin has offered TO my tenant, not yet accepted/revoked, unexpired. Each row has
|
|
4
|
+
* Accept (`crossTenant.accept`) and Decline (`crossTenant.revoke` — the grantee side
|
|
5
|
+
* revoking an `offered` grant IS the decline action, per the locked design; no separate
|
|
6
|
+
* endpoint/status exists).
|
|
7
|
+
*
|
|
8
|
+
* Mirrors GrantsPage's list shape (custom-render columns + a themed ConfirmDialog for
|
|
9
|
+
* the destructive action — Decline is framed as a confirm, matching the "destructive
|
|
10
|
+
* operations MUST confirm first" rule, since declining an offer is a one-way action from
|
|
11
|
+
* the grantee's perspective).
|
|
12
|
+
*
|
|
13
|
+
* ⛔ No raw UUIDs: offering company name, type label, action chips, dates only;
|
|
14
|
+
* `row.uuid` is a key/API-arg only.
|
|
15
|
+
*/
|
|
16
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
17
|
+
import { Inbox } 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
|
+
|
|
27
|
+
/** Compact absolute date — e.g. "13 Jul 2026"; '—' when missing/invalid. */
|
|
28
|
+
function formatDate(ms) {
|
|
29
|
+
if (!ms && ms !== 0) return '—';
|
|
30
|
+
const d = new Date(ms);
|
|
31
|
+
if (Number.isNaN(d.getTime())) return '—';
|
|
32
|
+
return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function ActionChips({ actions }) {
|
|
36
|
+
if (!actions || actions.length === 0) return <span className="text-xs text-muted-foreground">—</span>;
|
|
37
|
+
return (
|
|
38
|
+
<div className="flex flex-wrap gap-1">
|
|
39
|
+
{actions.map((a) => (
|
|
40
|
+
<Badge key={a} variant="secondary" className="font-normal">
|
|
41
|
+
{a}
|
|
42
|
+
</Badge>
|
|
43
|
+
))}
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export default function InboxPage() {
|
|
49
|
+
const { tokens, crossTenant } = useAuth();
|
|
50
|
+
const token = tokens?.accessToken;
|
|
51
|
+
|
|
52
|
+
const [rows, setRows] = useState([]);
|
|
53
|
+
const [loading, setLoading] = useState(true);
|
|
54
|
+
const [error, setError] = useState('');
|
|
55
|
+
|
|
56
|
+
const [busyUuid, setBusyUuid] = useState(null);
|
|
57
|
+
const [declineTarget, setDeclineTarget] = useState(null);
|
|
58
|
+
const [declineBusy, setDeclineBusy] = useState(false);
|
|
59
|
+
|
|
60
|
+
// Abort a superseded list request — mirrors UsersPage/AuditPage.
|
|
61
|
+
const loadAbortRef = useRef(/** @type {AbortController | null} */ (null));
|
|
62
|
+
|
|
63
|
+
const load = useCallback(async () => {
|
|
64
|
+
if (!token) return;
|
|
65
|
+
loadAbortRef.current?.abort();
|
|
66
|
+
const controller = new AbortController();
|
|
67
|
+
loadAbortRef.current = controller;
|
|
68
|
+
|
|
69
|
+
setLoading(true);
|
|
70
|
+
const r = await crossTenant.listInbox(token, { signal: controller.signal });
|
|
71
|
+
|
|
72
|
+
if (controller.signal.aborted || loadAbortRef.current !== controller) return;
|
|
73
|
+
|
|
74
|
+
if (r.ok) {
|
|
75
|
+
setRows(r.rows || []);
|
|
76
|
+
setError('');
|
|
77
|
+
} else {
|
|
78
|
+
setRows([]);
|
|
79
|
+
setError(r.error || 'Could not load your inbox.');
|
|
80
|
+
}
|
|
81
|
+
setLoading(false);
|
|
82
|
+
}, [crossTenant, token]);
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
load();
|
|
86
|
+
return () => loadAbortRef.current?.abort();
|
|
87
|
+
}, [load]);
|
|
88
|
+
|
|
89
|
+
const onAccept = async (row) => {
|
|
90
|
+
setBusyUuid(row.uuid);
|
|
91
|
+
const r = await crossTenant.accept(token, row.uuid);
|
|
92
|
+
setBusyUuid(null);
|
|
93
|
+
if (!r.ok) {
|
|
94
|
+
toast.error(r.error || 'Could not accept the offer.');
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
toast.success(`Accepted the offer from ${row.owningTenantName}`);
|
|
98
|
+
await load();
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const onConfirmDecline = async () => {
|
|
102
|
+
if (!declineTarget) return;
|
|
103
|
+
setDeclineBusy(true);
|
|
104
|
+
const r = await crossTenant.revoke(token, declineTarget.uuid);
|
|
105
|
+
setDeclineBusy(false);
|
|
106
|
+
if (!r.ok) {
|
|
107
|
+
toast.error(r.error || 'Could not decline the offer.');
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
setDeclineTarget(null);
|
|
111
|
+
toast.success('Offer declined');
|
|
112
|
+
await load();
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const columns = [
|
|
116
|
+
{
|
|
117
|
+
key: 'owningTenantName',
|
|
118
|
+
label: 'Offering company',
|
|
119
|
+
render: (row) => <span className="font-medium text-foreground">{row.owningTenantName}</span>,
|
|
120
|
+
},
|
|
121
|
+
{ key: 'typeLabel', label: 'Object type' },
|
|
122
|
+
{ key: 'actions', label: 'Actions', render: (row) => <ActionChips actions={row.allowedActions} /> },
|
|
123
|
+
{ key: 'expiresAtMs', label: 'Expires', render: (row) => formatDate(row.expiresAtMs) },
|
|
124
|
+
{ key: 'offeredByName', label: 'Offered by' },
|
|
125
|
+
{
|
|
126
|
+
key: 'rowActions',
|
|
127
|
+
label: '',
|
|
128
|
+
render: (row) => (
|
|
129
|
+
<div className="flex justify-end gap-1">
|
|
130
|
+
<Button
|
|
131
|
+
type="button"
|
|
132
|
+
size="sm"
|
|
133
|
+
disabled={busyUuid === row.uuid}
|
|
134
|
+
onClick={() => onAccept(row)}
|
|
135
|
+
>
|
|
136
|
+
Accept
|
|
137
|
+
</Button>
|
|
138
|
+
<Button
|
|
139
|
+
type="button"
|
|
140
|
+
variant="ghost"
|
|
141
|
+
size="sm"
|
|
142
|
+
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
|
143
|
+
disabled={busyUuid === row.uuid}
|
|
144
|
+
onClick={() => setDeclineTarget(row)}
|
|
145
|
+
>
|
|
146
|
+
Decline
|
|
147
|
+
</Button>
|
|
148
|
+
</div>
|
|
149
|
+
),
|
|
150
|
+
},
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<AppShell section="Identity" title="Grant inbox">
|
|
155
|
+
<div className="p-4">
|
|
156
|
+
<PageHeader section="Identity" title="Grant inbox" icon={Inbox} />
|
|
157
|
+
|
|
158
|
+
<EntityList
|
|
159
|
+
columns={columns}
|
|
160
|
+
rows={rows}
|
|
161
|
+
loading={loading}
|
|
162
|
+
error={error}
|
|
163
|
+
emptyLabel="No pending cross-tenant grant offers."
|
|
164
|
+
/>
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
<ConfirmDialog
|
|
168
|
+
open={!!declineTarget}
|
|
169
|
+
title={declineTarget ? `Decline the offer from “${declineTarget.owningTenantName}”?` : 'Decline this offer?'}
|
|
170
|
+
message="This revokes the offer. The offering tenant would need to offer again for you to reconsider. This cannot be undone."
|
|
171
|
+
confirmLabel="Decline"
|
|
172
|
+
destructive
|
|
173
|
+
busy={declineBusy}
|
|
174
|
+
onConfirm={onConfirmDecline}
|
|
175
|
+
onCancel={() => setDeclineTarget(null)}
|
|
176
|
+
/>
|
|
177
|
+
</AppShell>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
@@ -16,11 +16,16 @@
|
|
|
16
16
|
* via `GET access/role-grants` + `GET permission-catalog`, BOTH gated
|
|
17
17
|
* `role:read` — "managing/reading grants IS role administration")
|
|
18
18
|
* - Audit → `identity:role:read` (audit.controller.ts `GET audit`)
|
|
19
|
+
* - Cross-tenant → `identity:role:read` (cross-tenant-grant.controller.ts `GET
|
|
20
|
+
* cross-tenant-grants` / `GET .../inbox` / `GET .../offerable-types`,
|
|
21
|
+
* all gated `role:read` — reading/managing cross-tenant grants IS role
|
|
22
|
+
* administration; the OFFER write is separately `cross_tenant_grant:share`)
|
|
19
23
|
* Dashboard / Account are left UNGATED — self/owned surfaces every authenticated
|
|
20
24
|
* caller may reach (no admin-only list route backs them).
|
|
21
25
|
*/
|
|
22
26
|
import {
|
|
23
27
|
Building2,
|
|
28
|
+
Handshake,
|
|
24
29
|
History,
|
|
25
30
|
KeyRound,
|
|
26
31
|
LayoutDashboard,
|
|
@@ -69,6 +74,14 @@ export const NAV_SECTIONS = [
|
|
|
69
74
|
matchPrefix: true,
|
|
70
75
|
gate: 'identity:role:read',
|
|
71
76
|
},
|
|
77
|
+
{
|
|
78
|
+
icon: Handshake,
|
|
79
|
+
label: 'Cross-tenant',
|
|
80
|
+
path: '/cross-tenant',
|
|
81
|
+
badge: null,
|
|
82
|
+
matchPrefix: true,
|
|
83
|
+
gate: 'identity:role:read',
|
|
84
|
+
},
|
|
72
85
|
{
|
|
73
86
|
icon: History,
|
|
74
87
|
label: 'Audit',
|
|
@@ -20,11 +20,11 @@
|
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"@astram/core": "^1.0.0",
|
|
23
|
-
"@astram/engine": "^0.1.
|
|
24
|
-
"@astram/guard": "^0.
|
|
23
|
+
"@astram/engine": "^0.1.3",
|
|
24
|
+
"@astram/guard": "^0.5.0",
|
|
25
25
|
"@astram/guide": "^0.1.0",
|
|
26
|
-
"@astram/identity": "^0.
|
|
27
|
-
"@astram/migrate": "^0.
|
|
26
|
+
"@astram/identity": "^0.2.0",
|
|
27
|
+
"@astram/migrate": "^0.2.0",
|
|
28
28
|
"@astram/sdk": "^0.2.0",
|
|
29
29
|
"@nestjs/common": "^11.0.0",
|
|
30
30
|
"@nestjs/core": "^11.0.0",
|
|
@@ -35,10 +35,36 @@ import { DemoController } from './health/demo.controller';
|
|
|
35
35
|
* You then get framework-owned `POST /auth/{login,mfa,refresh,logout}` for free —
|
|
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
|
+
*
|
|
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):
|
|
44
|
+
* 1. Turn auth on (above), pass the identity permission keys (so the frontend admin
|
|
45
|
+
* nav lights up), AND pass `recordAuthorization` (so the decision guard actually
|
|
46
|
+
* GRANTS — without it, decide() denies every classified route):
|
|
47
|
+
* import { identityAuthActionKeys, identityControlPlaneKeys, IdentityFeatureModule,
|
|
48
|
+
* createIdentityAuthzPool, buildIdentityRecordAuthorization } from '@astram/identity';
|
|
49
|
+
* const authzPool = createIdentityAuthzPool();
|
|
50
|
+
* imports: [
|
|
51
|
+
* ShieldModule.forRoot({ auth: true,
|
|
52
|
+
* identityActionKeys: identityAuthActionKeys, identityControlPlaneKeys,
|
|
53
|
+
* recordAuthorization: buildIdentityRecordAuthorization(authzPool) }),
|
|
54
|
+
* IdentityFeatureModule.forRoot(), // mounts /identity/{companies,users,roles}
|
|
55
|
+
* ],
|
|
56
|
+
* `IdentityFeatureModule.forRoot()` self-wires the engine from your `APP_DB_*` env
|
|
57
|
+
* (no separate engine setup). Every route is `@Classified` — enforced by the same
|
|
58
|
+
* deny-by-default guard; `recordAuthorization` makes that guard render REAL decisions
|
|
59
|
+
* from your roles' grants. Omit the module and login still works; the admin pages 404.
|
|
38
60
|
*/
|
|
39
61
|
@Module({
|
|
40
|
-
// Enforcement-only by default (boots with no config). For real login
|
|
41
|
-
//
|
|
62
|
+
// 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):
|
|
64
|
+
// imports: [
|
|
65
|
+
// ShieldModule.forRoot({ auth: true, identityActionKeys, identityControlPlaneKeys }),
|
|
66
|
+
// IdentityFeatureModule.forRoot(),
|
|
67
|
+
// ],
|
|
42
68
|
imports: [ShieldModule.forRoot()],
|
|
43
69
|
controllers: [HealthController, DemoController],
|
|
44
70
|
})
|
|
@@ -2,15 +2,23 @@ import { Controller, Get } from '@nestjs/common';
|
|
|
2
2
|
import { Public } from '@astram/guard';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Demonstration
|
|
5
|
+
* Demonstration route proving the enforcement seam (ADR-0002).
|
|
6
6
|
*
|
|
7
|
-
* - `GET /demo/public`
|
|
8
|
-
*
|
|
7
|
+
* - `GET /demo/public` → `@Public()` → REACHABLE (200): an explicit opt-out of the
|
|
8
|
+
* deny-by-default global guard.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* Deny-by-default has TWO layers, and they differ in WHEN they bite:
|
|
11
|
+
* 1. **Boot-time (fail-closed, strongest):** the identity boot check (`verifyGatable`)
|
|
12
|
+
* scans every route and ABORTS BOOT if any non-`@Public` route lacks `@Classified`.
|
|
13
|
+
* A forgotten declaration doesn't just get denied at request time — the app won't
|
|
14
|
+
* start. That is why the illustrative "unclassified route" below is a COMMENT, not a
|
|
15
|
+
* live handler: a live one would prevent this app from booting. Uncomment it only to
|
|
16
|
+
* SEE the boot abort, then re-comment (or classify it).
|
|
17
|
+
* 2. **Request-time (per-request):** even absent the boot check, the global guard denies
|
|
18
|
+
* an unclassified route with 403.
|
|
19
|
+
*
|
|
20
|
+
* This controller is illustrative — delete it once you've seen enforcement work; a real
|
|
21
|
+
* feature classifies every route with `@Public()` or `@Classified(...)`.
|
|
14
22
|
*/
|
|
15
23
|
@Controller('demo')
|
|
16
24
|
export class DemoController {
|
|
@@ -20,9 +28,12 @@ export class DemoController {
|
|
|
20
28
|
return { message: 'public route reachable — explicit @Public() opt-out' };
|
|
21
29
|
}
|
|
22
30
|
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
31
|
+
// Intentionally NO @Public() / @Classified(). Kept as a COMMENT because the boot-time
|
|
32
|
+
// `verifyGatable` (fail-closed) aborts boot on any unclassified route — a live handler
|
|
33
|
+
// here would stop the app from starting. Uncomment to witness the boot abort, then
|
|
34
|
+
// re-comment or classify it.
|
|
35
|
+
// @Get('unclassified')
|
|
36
|
+
// unclassifiedRoute(): { message: string } {
|
|
37
|
+
// return { message: 'this body must never be reached — the guard denies first' };
|
|
38
|
+
// }
|
|
28
39
|
}
|