create-astram-app 0.3.0 → 0.5.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 +25 -0
- package/template/frontend/src/auth/AuthContext.jsx +21 -2
- package/template/frontend/src/components/MultiSelectChecklist.jsx +166 -0
- package/template/frontend/src/components/ShareDialog.jsx +269 -0
- package/template/frontend/src/index.css +4 -2
- package/template/frontend/src/pages/access/RecordPoliciesPage.jsx +390 -0
- package/template/frontend/src/pages/service-accounts/KeyRevealDialog.jsx +104 -0
- package/template/frontend/src/pages/service-accounts/ServiceAccountAccessDialog.jsx +231 -0
- package/template/frontend/src/pages/service-accounts/ServiceAccountActionsMenu.jsx +56 -0
- package/template/frontend/src/pages/service-accounts/ServiceAccountsPage.jsx +309 -0
- package/template/frontend/src/pages/service-accounts/accessModel.js +136 -0
- package/template/frontend/src/pages/service-accounts/accessModel.test.mjs +161 -0
- package/template/frontend/src/shell/nav.js +20 -0
- package/template/server/package.json +4 -4
- package/template/server/src/app.module.ts +21 -9
- package/template/server/test/enforcement.e2e.spec.ts +73 -6
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* accessModel — the pure (no React, no I/O) logic behind the service-account access dialog
|
|
3
|
+
* (Phase 4): turn the server's `assignable` set + an existing account into picker options, work out
|
|
4
|
+
* which of the agent's items are LOCKED (assigned by someone with more access), validate, and build
|
|
5
|
+
* a minimal PATCH. Kept separate so it is unit-tested with `node --test` (accessModel.test.mjs).
|
|
6
|
+
*
|
|
7
|
+
* The server is the authority (ADR-0029 ceiling): `assignable` is computed from the same ceiling the
|
|
8
|
+
* write enforces. An agent's existing role/node that is NOT assignable to the editor is shown checked
|
|
9
|
+
* + disabled and is NEVER sent — a non-root PATCH keeps it server-side (sending it would be a 403).
|
|
10
|
+
* Options carry `name` for display; `id` is only the value sent back (never rendered, ADR-0012).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const LOCKED_HINT = 'assigned by someone with more access';
|
|
14
|
+
export const NO_ROLES_WARNING = 'This agent will be denied everything until it has a role.';
|
|
15
|
+
export const SCOPE_REQUIRED = 'Choose at least one scope node.';
|
|
16
|
+
|
|
17
|
+
/** Depth of each node by walking `parentId` inside the set (cycle-safe; unknown parent ⇒ top). */
|
|
18
|
+
export function withDepth(nodes) {
|
|
19
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
20
|
+
const depthOf = (n) => {
|
|
21
|
+
let depth = 0;
|
|
22
|
+
const seen = new Set([n.id]);
|
|
23
|
+
let p = n.parentId;
|
|
24
|
+
while (p && byId.has(p) && !seen.has(p)) {
|
|
25
|
+
seen.add(p);
|
|
26
|
+
depth += 1;
|
|
27
|
+
p = byId.get(p).parentId;
|
|
28
|
+
}
|
|
29
|
+
return depth;
|
|
30
|
+
};
|
|
31
|
+
return nodes.map((n) => ({ id: n.id, name: n.name, depth: depthOf(n) }));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Items the agent holds that the editor cannot grant, labelled from the view (same order as ids). */
|
|
35
|
+
function lockedItems(ids, labels, assignableIds) {
|
|
36
|
+
const out = [];
|
|
37
|
+
(ids || []).forEach((id, i) => {
|
|
38
|
+
if (!assignableIds.has(id))
|
|
39
|
+
out.push({ id, name: (labels && labels[i]) || 'Unnamed', depth: 0, locked: true });
|
|
40
|
+
});
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build the picker model. `account` is null for create.
|
|
46
|
+
* - create: no roles ticked; every assignable scope node ticked ("defaults to everything you can reach").
|
|
47
|
+
* - edit: the agent's assignable items ticked; its non-assignable items appended as locked.
|
|
48
|
+
*/
|
|
49
|
+
export function buildAccessModel(assignable, account) {
|
|
50
|
+
const roles = (assignable && assignable.roles) || [];
|
|
51
|
+
const nodes = (assignable && assignable.scopeNodes) || [];
|
|
52
|
+
const roleIds = new Set(roles.map((r) => r.id));
|
|
53
|
+
const nodeIds = new Set(nodes.map((n) => n.id));
|
|
54
|
+
|
|
55
|
+
const roleOptions = roles.map((r) => ({ id: r.id, name: r.name, depth: 0 }));
|
|
56
|
+
const scopeOptions = withDepth(nodes);
|
|
57
|
+
|
|
58
|
+
if (!account) {
|
|
59
|
+
return {
|
|
60
|
+
roleOptions,
|
|
61
|
+
scopeOptions,
|
|
62
|
+
initialRoles: [],
|
|
63
|
+
initialScope: nodes.map((n) => n.id),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const lockedRoles = lockedItems(account.roleIds, account.roleLabels, roleIds);
|
|
67
|
+
const lockedScope = lockedItems(account.scopeSubtreeNodeIds, account.scopeLabels, nodeIds);
|
|
68
|
+
return {
|
|
69
|
+
roleOptions: [...roleOptions, ...lockedRoles],
|
|
70
|
+
scopeOptions: [...scopeOptions, ...lockedScope],
|
|
71
|
+
initialRoles: (account.roleIds || []).filter((id) => roleIds.has(id)),
|
|
72
|
+
initialScope: (account.scopeSubtreeNodeIds || []).filter((id) => nodeIds.has(id)),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Number of locked options (always held by the agent, never sent). */
|
|
77
|
+
export function lockedCount(options) {
|
|
78
|
+
return options.filter((o) => o.locked).length;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Order-insensitive equality of two id lists. */
|
|
82
|
+
export function sameIds(a, b) {
|
|
83
|
+
const sa = new Set(a);
|
|
84
|
+
const sb = new Set(b);
|
|
85
|
+
if (sa.size !== sb.size) return false;
|
|
86
|
+
for (const x of sa) if (!sb.has(x)) return false;
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Field errors for the form (empty object ⇒ valid). When the scope will be sent (`requireScope`,
|
|
92
|
+
* default true) it needs ≥1 node — the server rejects an empty scope on edit with a 400.
|
|
93
|
+
*/
|
|
94
|
+
export function validateAccess({ label, scope, requireScope = true }) {
|
|
95
|
+
const errors = {};
|
|
96
|
+
if (!label || !label.trim()) errors.label = 'Give the service account a name.';
|
|
97
|
+
if (requireScope && (!scope || scope.length === 0)) errors.scope = SCOPE_REQUIRED;
|
|
98
|
+
return errors;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Does the agent end up with no role at all (ticked + locked)? → show the non-blocking warning. */
|
|
102
|
+
export function hasNoRoles(roles, roleOptions) {
|
|
103
|
+
return roles.length === 0 && lockedCount(roleOptions) === 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Create body: always sends roles + ≥1 scope node (only assignable ids — never locked ones). */
|
|
107
|
+
export function createBody({ label, roles, scope }) {
|
|
108
|
+
return { label: label.trim(), roleIds: [...roles], scopeSubtreeNodeIds: [...scope] };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Minimal PATCH for an edit: only the fields that changed. `roles` / `scope` hold assignable ids
|
|
113
|
+
* only (locked items are preserved by the server). Returns `{}` when nothing changed.
|
|
114
|
+
*/
|
|
115
|
+
export function editPatch({ account, label, roles, scope, initialRoles, initialScope }) {
|
|
116
|
+
const patch = {};
|
|
117
|
+
const nextLabel = label.trim();
|
|
118
|
+
if (nextLabel !== account.label) patch.label = nextLabel;
|
|
119
|
+
if (!sameIds(roles, initialRoles)) patch.roleIds = [...roles];
|
|
120
|
+
if (!sameIds(scope, initialScope)) patch.scopeSubtreeNodeIds = [...scope];
|
|
121
|
+
return patch;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Route a 400 server message to the field it is about (else the form-level error). */
|
|
125
|
+
export function fieldOfServerError(message) {
|
|
126
|
+
const m = (message || '').toLowerCase();
|
|
127
|
+
if (m.includes('scope')) return 'scope';
|
|
128
|
+
if (m.includes('label') || m.includes('name')) return 'label';
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** First `max` names + a "+N" overflow count, for the row badges. */
|
|
133
|
+
export function summarizeNames(names, max = 3) {
|
|
134
|
+
const list = names || [];
|
|
135
|
+
return { shown: list.slice(0, max), more: Math.max(0, list.length - max) };
|
|
136
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// accessModel.test.mjs — Phase 4: the service-account access dialog's pure logic (picker
|
|
2
|
+
// options, locked items, validation, minimal PATCH). No React/DOM; runs under Node's built-in
|
|
3
|
+
// runner with zero dependencies:
|
|
4
|
+
//
|
|
5
|
+
// node --test src/pages/service-accounts/accessModel.test.mjs
|
|
6
|
+
import { test, describe } from 'node:test';
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import {
|
|
9
|
+
withDepth,
|
|
10
|
+
buildAccessModel,
|
|
11
|
+
lockedCount,
|
|
12
|
+
sameIds,
|
|
13
|
+
validateAccess,
|
|
14
|
+
hasNoRoles,
|
|
15
|
+
createBody,
|
|
16
|
+
editPatch,
|
|
17
|
+
fieldOfServerError,
|
|
18
|
+
summarizeNames,
|
|
19
|
+
SCOPE_REQUIRED,
|
|
20
|
+
} from './accessModel.js';
|
|
21
|
+
|
|
22
|
+
const ASSIGNABLE = {
|
|
23
|
+
roles: [
|
|
24
|
+
{ id: 'r-ingest', name: 'Ingest' },
|
|
25
|
+
{ id: 'r-audit', name: 'Auditor' },
|
|
26
|
+
],
|
|
27
|
+
scopeNodes: [
|
|
28
|
+
{ id: 'n-acme', name: 'Acme', parentId: null },
|
|
29
|
+
{ id: 'n-east', name: 'Acme East', parentId: 'n-acme' },
|
|
30
|
+
{ id: 'n-east-1', name: 'East 1', parentId: 'n-east' },
|
|
31
|
+
],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const ACCOUNT = {
|
|
35
|
+
id: 'sa-1',
|
|
36
|
+
label: 'Ingest Bot',
|
|
37
|
+
roleIds: ['r-ingest', 'r-finance'],
|
|
38
|
+
roleLabels: ['Ingest', 'Finance Admin'],
|
|
39
|
+
scopeSubtreeNodeIds: ['n-east', 'n-beta'],
|
|
40
|
+
scopeLabels: ['Acme East', 'Beta'],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
describe('withDepth', () => {
|
|
44
|
+
test('indents by parent chain inside the set', () => {
|
|
45
|
+
assert.deepEqual(
|
|
46
|
+
withDepth(ASSIGNABLE.scopeNodes).map((n) => n.depth),
|
|
47
|
+
[0, 1, 2],
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
test('a parent outside the set, or a cycle, does not loop or over-indent', () => {
|
|
51
|
+
const d = withDepth([
|
|
52
|
+
{ id: 'a', name: 'A', parentId: 'b' },
|
|
53
|
+
{ id: 'b', name: 'B', parentId: 'a' },
|
|
54
|
+
{ id: 'c', name: 'C', parentId: 'zzz' },
|
|
55
|
+
]);
|
|
56
|
+
assert.equal(d.find((n) => n.id === 'c').depth, 0);
|
|
57
|
+
assert.ok(d.every((n) => n.depth <= 1));
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('buildAccessModel', () => {
|
|
62
|
+
test('create: no roles ticked, every reachable node ticked, nothing locked', () => {
|
|
63
|
+
const m = buildAccessModel(ASSIGNABLE, null);
|
|
64
|
+
assert.deepEqual(m.initialRoles, []);
|
|
65
|
+
assert.deepEqual(m.initialScope, ['n-acme', 'n-east', 'n-east-1']);
|
|
66
|
+
assert.equal(lockedCount(m.roleOptions) + lockedCount(m.scopeOptions), 0);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('edit: assignable items ticked; out-of-ceiling items appended LOCKED with their names', () => {
|
|
70
|
+
const m = buildAccessModel(ASSIGNABLE, ACCOUNT);
|
|
71
|
+
assert.deepEqual(m.initialRoles, ['r-ingest']);
|
|
72
|
+
assert.deepEqual(m.initialScope, ['n-east']);
|
|
73
|
+
const lockedRole = m.roleOptions.find((o) => o.locked);
|
|
74
|
+
assert.deepEqual(lockedRole, {
|
|
75
|
+
id: 'r-finance',
|
|
76
|
+
name: 'Finance Admin',
|
|
77
|
+
depth: 0,
|
|
78
|
+
locked: true,
|
|
79
|
+
});
|
|
80
|
+
const lockedNode = m.scopeOptions.find((o) => o.locked);
|
|
81
|
+
assert.equal(lockedNode.name, 'Beta');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('edit by root (everything assignable): nothing locked', () => {
|
|
85
|
+
const all = {
|
|
86
|
+
roles: [...ASSIGNABLE.roles, { id: 'r-finance', name: 'Finance Admin' }],
|
|
87
|
+
scopeNodes: [...ASSIGNABLE.scopeNodes, { id: 'n-beta', name: 'Beta', parentId: null }],
|
|
88
|
+
};
|
|
89
|
+
const m = buildAccessModel(all, ACCOUNT);
|
|
90
|
+
assert.equal(lockedCount(m.roleOptions) + lockedCount(m.scopeOptions), 0);
|
|
91
|
+
assert.deepEqual(m.initialRoles.sort(), ['r-finance', 'r-ingest']);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('validation + warnings', () => {
|
|
96
|
+
test('a name and at least one scope node are required', () => {
|
|
97
|
+
assert.deepEqual(validateAccess({ label: ' ', scope: [] }), {
|
|
98
|
+
label: 'Give the service account a name.',
|
|
99
|
+
scope: SCOPE_REQUIRED,
|
|
100
|
+
});
|
|
101
|
+
assert.deepEqual(validateAccess({ label: 'Bot', scope: ['n-acme'] }), {});
|
|
102
|
+
});
|
|
103
|
+
test('an unsent (unchanged) scope is not validated — rename of an all-locked agent still works', () => {
|
|
104
|
+
assert.deepEqual(validateAccess({ label: 'Bot', scope: [], requireScope: false }), {});
|
|
105
|
+
});
|
|
106
|
+
test('no-roles warning only when nothing is ticked AND nothing is locked', () => {
|
|
107
|
+
const create = buildAccessModel(ASSIGNABLE, null);
|
|
108
|
+
assert.equal(hasNoRoles([], create.roleOptions), true);
|
|
109
|
+
const edit = buildAccessModel(ASSIGNABLE, ACCOUNT);
|
|
110
|
+
assert.equal(hasNoRoles([], edit.roleOptions), false); // the locked role still applies
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('request bodies never carry locked items', () => {
|
|
115
|
+
test('createBody sends trimmed name + roles + scope', () => {
|
|
116
|
+
assert.deepEqual(createBody({ label: ' Bot ', roles: ['r-ingest'], scope: ['n-acme'] }), {
|
|
117
|
+
label: 'Bot',
|
|
118
|
+
roleIds: ['r-ingest'],
|
|
119
|
+
scopeSubtreeNodeIds: ['n-acme'],
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('editPatch: unchanged → {}; only changed fields are sent; locked ids never appear', () => {
|
|
124
|
+
const m = buildAccessModel(ASSIGNABLE, ACCOUNT);
|
|
125
|
+
const base = { account: ACCOUNT, initialRoles: m.initialRoles, initialScope: m.initialScope };
|
|
126
|
+
assert.deepEqual(
|
|
127
|
+
editPatch({ ...base, label: 'Ingest Bot', roles: ['r-ingest'], scope: ['n-east'] }),
|
|
128
|
+
{},
|
|
129
|
+
);
|
|
130
|
+
assert.deepEqual(
|
|
131
|
+
editPatch({ ...base, label: 'Renamed', roles: ['r-ingest'], scope: ['n-east'] }),
|
|
132
|
+
{
|
|
133
|
+
label: 'Renamed',
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
const p = editPatch({ ...base, label: 'Ingest Bot', roles: [], scope: ['n-east', 'n-acme'] });
|
|
137
|
+
assert.deepEqual(p, { roleIds: [], scopeSubtreeNodeIds: ['n-east', 'n-acme'] });
|
|
138
|
+
assert.ok(!JSON.stringify(p).includes('r-finance'));
|
|
139
|
+
assert.ok(!JSON.stringify(p).includes('n-beta'));
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('sameIds is order-insensitive', () => {
|
|
143
|
+
assert.equal(sameIds(['a', 'b'], ['b', 'a']), true);
|
|
144
|
+
assert.equal(sameIds(['a'], ['a', 'b']), false);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('misc', () => {
|
|
149
|
+
test('fieldOfServerError routes 400s to the right field', () => {
|
|
150
|
+
assert.equal(fieldOfServerError('choose at least one scope node'), 'scope');
|
|
151
|
+
assert.equal(fieldOfServerError('label cannot be empty'), 'label');
|
|
152
|
+
assert.equal(fieldOfServerError('no fields to update'), null);
|
|
153
|
+
});
|
|
154
|
+
test('summarizeNames: first 3 + overflow count', () => {
|
|
155
|
+
assert.deepEqual(summarizeNames(['a', 'b', 'c', 'd', 'e']), {
|
|
156
|
+
shown: ['a', 'b', 'c'],
|
|
157
|
+
more: 2,
|
|
158
|
+
});
|
|
159
|
+
assert.deepEqual(summarizeNames(undefined), { shown: [], more: 0 });
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -16,6 +16,9 @@
|
|
|
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
|
+
* - Service accounts → `identity:service_account:read`
|
|
20
|
+
* (service-account.controller.ts `GET service-accounts`; create/edit are
|
|
21
|
+
* service_account:create|update, keys are service_account:manage_keys)
|
|
19
22
|
* - Cross-tenant → `identity:role:read` (cross-tenant-grant.controller.ts `GET
|
|
20
23
|
* cross-tenant-grants` / `GET .../inbox` / `GET .../offerable-types`,
|
|
21
24
|
* all gated `role:read` — reading/managing cross-tenant grants IS role
|
|
@@ -24,6 +27,7 @@
|
|
|
24
27
|
* caller may reach (no admin-only list route backs them).
|
|
25
28
|
*/
|
|
26
29
|
import {
|
|
30
|
+
Bot,
|
|
27
31
|
Building2,
|
|
28
32
|
Handshake,
|
|
29
33
|
History,
|
|
@@ -74,6 +78,22 @@ export const NAV_SECTIONS = [
|
|
|
74
78
|
matchPrefix: true,
|
|
75
79
|
gate: 'identity:role:read',
|
|
76
80
|
},
|
|
81
|
+
{
|
|
82
|
+
icon: ShieldCheck,
|
|
83
|
+
label: 'Record policies',
|
|
84
|
+
path: '/record-policies',
|
|
85
|
+
badge: null,
|
|
86
|
+
matchPrefix: true,
|
|
87
|
+
gate: 'identity:role:read',
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
icon: Bot,
|
|
91
|
+
label: 'Service accounts',
|
|
92
|
+
path: '/service-accounts',
|
|
93
|
+
badge: null,
|
|
94
|
+
matchPrefix: true,
|
|
95
|
+
gate: 'identity:service_account:read',
|
|
96
|
+
},
|
|
77
97
|
{
|
|
78
98
|
icon: Handshake,
|
|
79
99
|
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.3.0",
|
|
23
|
+
"@astram/engine": "^0.3.0",
|
|
24
|
+
"@astram/guard": "^0.7.0",
|
|
25
25
|
"@astram/guide": "^0.1.0",
|
|
26
|
-
"@astram/identity": "^0.
|
|
26
|
+
"@astram/identity": "^0.4.0",
|
|
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],
|
|
@@ -1,5 +1,13 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { Controller, Get, type INestApplication, type ModuleMetadata } from '@nestjs/common';
|
|
2
|
+
import { DiscoveryModule } from '@nestjs/core';
|
|
2
3
|
import { Test } from '@nestjs/testing';
|
|
4
|
+
import { checkCompleteness, createModuleRegistry } from '@astram/core';
|
|
5
|
+
import {
|
|
6
|
+
CatalogGatabilityError,
|
|
7
|
+
CatalogReconciler,
|
|
8
|
+
OperationInventoryScanner,
|
|
9
|
+
ShieldModule,
|
|
10
|
+
} from '@astram/guard';
|
|
3
11
|
import request from 'supertest';
|
|
4
12
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
5
13
|
import { AppModule } from '../src/app.module';
|
|
@@ -46,11 +54,70 @@ describe('__APP_NAME__ — framework enforcement + health (e2e)', () => {
|
|
|
46
54
|
message: expect.stringContaining('public route reachable'),
|
|
47
55
|
});
|
|
48
56
|
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A deliberately UNCLASSIFIED controller (no @Public(), no @Classified()). It lives
|
|
61
|
+
* only in this test file — never in src/ — because a live unclassified route would
|
|
62
|
+
* abort a real boot (see demo.controller.ts). Used to prove both deny-by-default layers.
|
|
63
|
+
*/
|
|
64
|
+
@Controller('probe')
|
|
65
|
+
class UnclassifiedProbeController {
|
|
66
|
+
@Get('unclassified')
|
|
67
|
+
unclassifiedRoute(): { message: string } {
|
|
68
|
+
return { message: 'this body must never be reached — the guard denies first' };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Reflect every mounted route of `imports`/`controllers` into an OperationInventory. */
|
|
73
|
+
async function scanRoutes(opts: Pick<ModuleMetadata, 'imports' | 'controllers'>) {
|
|
74
|
+
const moduleRef = await Test.createTestingModule({
|
|
75
|
+
imports: [DiscoveryModule, ...(opts.imports ?? [])],
|
|
76
|
+
controllers: opts.controllers ?? [],
|
|
77
|
+
providers: [OperationInventoryScanner],
|
|
78
|
+
}).compile();
|
|
79
|
+
const inventory = moduleRef.get(OperationInventoryScanner).scan('app');
|
|
80
|
+
await moduleRef.close();
|
|
81
|
+
return inventory;
|
|
82
|
+
}
|
|
49
83
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
// The
|
|
54
|
-
|
|
84
|
+
describe('__APP_NAME__ — deny-by-default (ADR-0002)', () => {
|
|
85
|
+
it('BOOT layer: an unclassified route ABORTS boot via verifyGatable (fail-closed)', async () => {
|
|
86
|
+
const inventory = await scanRoutes({ controllers: [UnclassifiedProbeController] });
|
|
87
|
+
// The same check the identity boot provider runs: a forgotten declaration means the
|
|
88
|
+
// app refuses to start, not that the route silently serves traffic.
|
|
89
|
+
expect(() => new CatalogReconciler().verifyGatable(inventory, createModuleRegistry())).toThrow(
|
|
90
|
+
CatalogGatabilityError,
|
|
91
|
+
);
|
|
92
|
+
expect(() => new CatalogReconciler().verifyGatable(inventory, createModuleRegistry())).toThrow(
|
|
93
|
+
/UnclassifiedProbeController#unclassifiedRoute/,
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('BOOT layer: every route in YOUR AppModule is @Public() or @Classified() (none stranded)', async () => {
|
|
98
|
+
// Fails the moment a route is added without a classification — the same routes the
|
|
99
|
+
// boot check would strand. (Registering @Classified keys is the identity module's job,
|
|
100
|
+
// so only the "forgotten declaration" half is asserted here.)
|
|
101
|
+
const inventory = await scanRoutes({ imports: [AppModule] });
|
|
102
|
+
expect(inventory.operations.length).toBeGreaterThan(0);
|
|
103
|
+
const { strandedOperations } = checkCompleteness(inventory, createModuleRegistry());
|
|
104
|
+
expect(strandedOperations.map((o) => o.operationId)).toEqual([]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('REQUEST layer: an unclassified route is DENIED (403) before its handler runs', async () => {
|
|
108
|
+
const moduleRef = await Test.createTestingModule({
|
|
109
|
+
imports: [ShieldModule.forRoot()],
|
|
110
|
+
controllers: [UnclassifiedProbeController],
|
|
111
|
+
}).compile();
|
|
112
|
+
const probeApp = moduleRef.createNestApplication();
|
|
113
|
+
await probeApp.init();
|
|
114
|
+
try {
|
|
115
|
+
const res = await request(probeApp.getHttpServer()).get('/probe/unclassified');
|
|
116
|
+
expect(res.status).toBe(403);
|
|
117
|
+
// The guard denies BEFORE the handler body runs.
|
|
118
|
+
expect(res.body.message).toContain('no access classification');
|
|
119
|
+
} finally {
|
|
120
|
+
await probeApp.close();
|
|
121
|
+
}
|
|
55
122
|
});
|
|
56
123
|
});
|