@bongos/core 1.19.158 → 1.19.159
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/.bongos-core.json +34 -29
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0172-editable-rank-roles-substrate-axis.md +45 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +77 -5
- package/docs/api-reference.md +4 -3
- package/docs/canonical-permissions.md +16 -2
- package/modules/governance/catalog.js +91 -12
- package/modules/governance/db.js +30 -0
- package/modules/governance/routes/governance.js +106 -24
- package/modules/hall-ui/public/governance.css +3 -0
- package/modules/hall-ui/public/governance.html +1 -0
- package/modules/hall-ui/public/governance.js +110 -31
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/route-rank-check.js +1 -0
- package/src/module-api.js +3 -1
- package/tests/governance_abuse_matrix.mjs +78 -11
- package/tests/governance_catalog.mjs +51 -0
- package/tests/governance_routes.mjs +72 -12
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.159",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.159",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.159",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -414,6 +414,7 @@ const MODULE_EXPECTED_RANKS = {
|
|
|
414
414
|
'POST /governance/roles': 'perm:governance.manage',
|
|
415
415
|
'PUT /governance/roles/:roleKey/permissions/:permissionKey': 'perm:governance.manage',
|
|
416
416
|
'DELETE /governance/roles/:roleKey/permissions/:permissionKey': 'perm:governance.manage',
|
|
417
|
+
'POST /governance/roles/:roleKey/reset': 'perm:governance.manage',
|
|
417
418
|
'POST /governance/assignments': 'perm:governance.manage',
|
|
418
419
|
'DELETE /governance/assignments/:builderId/:roleKey': 'perm:governance.manage',
|
|
419
420
|
},
|
package/src/module-api.js
CHANGED
|
@@ -743,8 +743,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
743
743
|
// landed since 1.19.156 with no explicit bump. run 31958139486. (task 1002620)
|
|
744
744
|
// 1.19.158 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
745
745
|
// landed since 1.19.157 with no explicit bump. run 31958753412. (task 1002620)
|
|
746
|
+
// 1.19.159 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
747
|
+
// landed since 1.19.158 with no explicit bump. run 31958999646. (task 1002620)
|
|
746
748
|
// ---------------------------------------------------------------------------
|
|
747
|
-
const CORE_VERSION = '1.19.
|
|
749
|
+
const CORE_VERSION = '1.19.159'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
748
750
|
|
|
749
751
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
750
752
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -63,9 +63,19 @@ const SYSTEM_KEYS = catalog.systemKeys();
|
|
|
63
63
|
const SEEDED_ROLES = catalog.RANK_ORDER; // xenos/thetes/metic/archon
|
|
64
64
|
|
|
65
65
|
// The keys whose enforcement lives OUTSIDE the app entirely (git pre-push hook,
|
|
66
|
-
// grader pre-pass, post-push main audit, credential placement — ADR 0043).
|
|
67
|
-
//
|
|
68
|
-
|
|
66
|
+
// grader pre-pass, post-push main audit, credential placement — ADR 0043). Read
|
|
67
|
+
// from the CATALOG since ADR 0172 made the class data: enumerating rather than
|
|
68
|
+
// listing means a fourth substrate key is covered by every vector below the day it
|
|
69
|
+
// lands. The three named ones are asserted present so the marker can't be quietly
|
|
70
|
+
// dropped from a row and take the coverage with it.
|
|
71
|
+
const SUBSTRATE_KEYS = catalog.substrateKeys();
|
|
72
|
+
const NAMED_SUBSTRATE = ['deploy.push_main', 'path.protected.modify', 'infra.ssh'];
|
|
73
|
+
|
|
74
|
+
// The rank-roles ADR 0172 made editable — everything below the top of the ladder.
|
|
75
|
+
const EDITABLE_RANK_ROLES = catalog.RANK_ORDER.filter((r) => r !== 'archon');
|
|
76
|
+
// The in-app Archon powers: system, but a real route reads them, so they CAN be
|
|
77
|
+
// delegated to a rank-role. Derived as system-minus-substrate-minus-principal.
|
|
78
|
+
const OWNER_POWERS = SYSTEM_KEYS.filter((k) => !catalog.isSubstrate(k) && !catalog.isPrincipalKey(k));
|
|
69
79
|
|
|
70
80
|
// ===========================================================================
|
|
71
81
|
// The matrix rows — sanity on what is being enumerated
|
|
@@ -73,12 +83,15 @@ const SUBSTRATE_KEYS = ['deploy.push_main', 'path.protected.modify', 'infra.ssh'
|
|
|
73
83
|
|
|
74
84
|
test('the matrix enumerates a non-empty system set including the named powers', () => {
|
|
75
85
|
assert.ok(SYSTEM_KEYS.length > 0, 'no system keys — the matrix would be vacuous');
|
|
76
|
-
for (const k of
|
|
86
|
+
for (const k of NAMED_SUBSTRATE) {
|
|
87
|
+
assert.ok(SUBSTRATE_KEYS.includes(k), `${k} must still carry substrate:true — dropping the marker would silently narrow every vector below`);
|
|
77
88
|
assert.ok(SYSTEM_KEYS.includes(k), `${k} must be system:true`);
|
|
78
89
|
}
|
|
79
90
|
for (const k of SYSTEM_KEYS) {
|
|
80
|
-
assert.equal(catalog.isGrantable(k), false, `${k} must be non-grantable`);
|
|
91
|
+
assert.equal(catalog.isGrantable(k), false, `${k} must be non-grantable to a CUSTOM role`);
|
|
81
92
|
}
|
|
93
|
+
assert.ok(OWNER_POWERS.length > 0, 'no in-app owner powers — V2c would be vacuous');
|
|
94
|
+
assert.ok(EDITABLE_RANK_ROLES.length > 0, 'no editable rank-roles — V2b/V2c would be vacuous');
|
|
82
95
|
});
|
|
83
96
|
|
|
84
97
|
test('every system permission floors at archon or is a principal (never seeded lower)', () => {
|
|
@@ -101,6 +114,7 @@ const stub = {
|
|
|
101
114
|
createRole: async (a) => ({ role_key: a.roleKey, label: a.label, description: a.description ?? null, is_system: false, seeded_rank: null, created_by: a.createdBy, created_at: 't' }),
|
|
102
115
|
grantRolePermission: async () => true,
|
|
103
116
|
revokeRolePermission: async () => true,
|
|
117
|
+
resetRoleGrants: async ({ permissionKeys }) => permissionKeys.length,
|
|
104
118
|
assignRole: async () => true,
|
|
105
119
|
unassignRole: async () => true,
|
|
106
120
|
listRolesWithGrants: async () => [],
|
|
@@ -162,15 +176,67 @@ test('V1: EVERY system permission is refused onto a custom role, before the db',
|
|
|
162
176
|
}
|
|
163
177
|
});
|
|
164
178
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
179
|
+
// ADR 0172 split the old single V2 ("every seeded rank-role refuses every grant")
|
|
180
|
+
// into three, because two thirds of that wall stayed and one third was deliberately
|
|
181
|
+
// removed. Asserting the removed third is as important as asserting the kept ones:
|
|
182
|
+
// it is what stops a later "tighten the governance routes" refactor from quietly
|
|
183
|
+
// reinstating the wall the owner asked to take down.
|
|
184
|
+
|
|
185
|
+
test('V2a: the ARCHON rank-role refuses EVERY write — grant, revoke and reset alike', async () => {
|
|
186
|
+
for (const k of [...SYSTEM_KEYS, 'idea.triage']) {
|
|
187
|
+
for (const method of ['PUT', 'DELETE']) {
|
|
188
|
+
const r = await call(method, `/governance/roles/archon/permissions/${k}`);
|
|
189
|
+
assert.equal(r.status, 403, `archon ← ${method} ${k} must be refused`);
|
|
190
|
+
assert.equal(r.json.error.code, 'archon_role_immutable', `archon ← ${method} ${k}`);
|
|
191
|
+
assert.equal(calls.filter(([c]) => c === 'grantRolePermission' || c === 'revokeRolePermission').length, 0);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const r = await call('POST', '/governance/roles/archon/reset');
|
|
195
|
+
assert.equal(r.status, 403, 'reset must not be a back door into the archon role');
|
|
196
|
+
assert.equal(r.json.error.code, 'archon_role_immutable');
|
|
197
|
+
assert.equal(calls.filter(([c]) => c === 'resetRoleGrants').length, 0);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('V2b: EVERY editable rank-role still refuses every SUBSTRATE and PRINCIPAL key', async () => {
|
|
201
|
+
const neverGrantable = [...SUBSTRATE_KEYS, ...catalog.principalKeys()];
|
|
202
|
+
for (const role of EDITABLE_RANK_ROLES) {
|
|
203
|
+
for (const k of neverGrantable) {
|
|
204
|
+
const r = await call('PUT', `/governance/roles/${role}/permissions/${k}`);
|
|
205
|
+
assert.equal(r.status, 400, `${role} ← ${k} must be refused`);
|
|
206
|
+
assert.equal(r.json.error.code, 'permission_not_grantable', `${role} ← ${k}`);
|
|
207
|
+
assert.equal(calls.filter(([c]) => c === 'grantRolePermission').length, 0,
|
|
208
|
+
`${role} ← ${k}: the refusal must land BEFORE any db write`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('V2c: an editable rank-role ACCEPTS the in-app owner powers (the ADR 0172 lever)', async () => {
|
|
214
|
+
for (const role of EDITABLE_RANK_ROLES) {
|
|
215
|
+
for (const k of [...OWNER_POWERS, 'idea.triage']) {
|
|
168
216
|
const r = await call('PUT', `/governance/roles/${role}/permissions/${k}`);
|
|
169
|
-
assert.equal(r.status,
|
|
170
|
-
assert.equal(
|
|
171
|
-
|
|
217
|
+
assert.equal(r.status, 200, `${role} ← ${k} must be ACCEPTED — this delegation is the owner's decision (ADR 0172)`);
|
|
218
|
+
assert.equal(calls.filter(([c]) => c === 'grantRolePermission').length, 1, `${role} ← ${k} must reach the db`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('V2d: reset restores the CATALOG seed, never anything wider', async () => {
|
|
224
|
+
for (const role of EDITABLE_RANK_ROLES) {
|
|
225
|
+
const r = await call('POST', `/governance/roles/${role}/reset`);
|
|
226
|
+
assert.equal(r.status, 200, `${role} must be resettable`);
|
|
227
|
+
const [, args] = calls.find(([c]) => c === 'resetRoleGrants');
|
|
228
|
+
assert.deepEqual([...args.permissionKeys].sort(), [...catalog.seedForRank(role)].sort(),
|
|
229
|
+
`${role} reset must write exactly the floor-derived seed`);
|
|
230
|
+
for (const k of args.permissionKeys) {
|
|
231
|
+
assert.equal(catalog.isSystem(k), false, `${role} reset wrote system key ${k} — reset must never widen`);
|
|
172
232
|
}
|
|
173
233
|
}
|
|
234
|
+
// A custom role has no catalog default, so reset is refused rather than silently
|
|
235
|
+
// clearing its grants (which would be a destructive verb wearing a restore's name).
|
|
236
|
+
const r = await call('POST', '/governance/roles/custom-role/reset');
|
|
237
|
+
assert.equal(r.status, 400);
|
|
238
|
+
assert.equal(r.json.error.code, 'role_has_no_default');
|
|
239
|
+
assert.equal(calls.filter(([c]) => c === 'resetRoleGrants').length, 0);
|
|
174
240
|
});
|
|
175
241
|
|
|
176
242
|
test('V3: EVERY seeded rank-role refuses assignment (rank is the only writer — R95b)', async () => {
|
|
@@ -213,6 +279,7 @@ test('V5: a caller without governance.manage cannot reach ANY write vector at al
|
|
|
213
279
|
['POST', '/governance/roles', { role_key: 'x-role', label: 'X' }],
|
|
214
280
|
['PUT', '/governance/roles/custom-role/permissions/deploy.push_main', undefined],
|
|
215
281
|
['DELETE', '/governance/roles/custom-role/permissions/idea.triage', undefined],
|
|
282
|
+
['POST', '/governance/roles/metic/reset', undefined],
|
|
216
283
|
['POST', '/governance/assignments', { builder_id: 7, role_key: 'custom-role' }],
|
|
217
284
|
['DELETE', '/governance/assignments/7/custom-role', undefined],
|
|
218
285
|
]) {
|
|
@@ -46,6 +46,57 @@ test('isGrantable: operational→true, system→false, unknown→false', () => {
|
|
|
46
46
|
assert.equal(isSystem('nope.not.a.key'), null); // unknown → null (caller fails closed)
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
+
// ── the substrate axis + per-role grantability (ADR 0172) ────────────────────
|
|
50
|
+
|
|
51
|
+
test('substrate keys are the out-of-band three, and each implies system + archon floor', () => {
|
|
52
|
+
const sub = cat.substrateKeys();
|
|
53
|
+
assert.deepEqual([...sub].sort(), ['deploy.push_main', 'infra.ssh', 'path.protected.modify']);
|
|
54
|
+
for (const k of sub) {
|
|
55
|
+
assert.equal(cat.isSubstrate(k), true);
|
|
56
|
+
assert.equal(isSystem(k), true, `${k} must be system:true`);
|
|
57
|
+
assert.equal(byKey(k).floor, 'archon', `${k} must floor at archon`);
|
|
58
|
+
assert.equal(byKey(k).principal, null, `${k} must not also be a principal key`);
|
|
59
|
+
}
|
|
60
|
+
assert.equal(cat.isSubstrate('governance.manage'), false, 'an in-app archon power is not substrate');
|
|
61
|
+
assert.equal(cat.isSubstrate('nope.not.a.key'), false, 'unknown fails closed');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('isGrantableTo: a sub-Archon rank-role takes owner powers, a custom role does not', () => {
|
|
65
|
+
const metic = { is_system: true, seeded_rank: 'metic' };
|
|
66
|
+
const archon = { is_system: true, seeded_rank: 'archon' };
|
|
67
|
+
const custom = { is_system: false, seeded_rank: null };
|
|
68
|
+
|
|
69
|
+
// The widening: an in-app archon power reaches a rank-role, never a custom one.
|
|
70
|
+
for (const k of ['governance.manage', 'builder.rank.set', 'version.close']) {
|
|
71
|
+
assert.equal(cat.isGrantableTo(k, metic), true, `${k} → metic rank-role`);
|
|
72
|
+
assert.equal(cat.isGrantableTo(k, custom), false, `${k} → custom role must stay refused`);
|
|
73
|
+
}
|
|
74
|
+
// Operational keys reach both.
|
|
75
|
+
assert.equal(cat.isGrantableTo('idea.triage', metic), true);
|
|
76
|
+
assert.equal(cat.isGrantableTo('idea.triage', custom), true);
|
|
77
|
+
// The two walls that did NOT move.
|
|
78
|
+
for (const k of [...cat.substrateKeys(), ...cat.principalKeys()]) {
|
|
79
|
+
assert.equal(cat.isGrantableTo(k, metic), false, `${k} must stay non-grantable to every role`);
|
|
80
|
+
assert.equal(cat.isGrantableTo(k, custom), false, `${k} must stay non-grantable to every role`);
|
|
81
|
+
}
|
|
82
|
+
// The Archon role takes nothing — it already holds everything, and that
|
|
83
|
+
// immutability is what makes lockout unreachable from the tab.
|
|
84
|
+
for (const k of permissionKeys()) {
|
|
85
|
+
assert.equal(cat.isGrantableTo(k, archon), false, `${k} → archon role must be refused`);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('isGrantableTo fails closed on every unrecognised input', () => {
|
|
90
|
+
assert.equal(cat.isGrantableTo('idea.triage', null), false);
|
|
91
|
+
assert.equal(cat.isGrantableTo('idea.triage', undefined), false);
|
|
92
|
+
assert.equal(cat.isGrantableTo('idea.triage', 'metic'), false, 'a bare string is not a role row');
|
|
93
|
+
assert.equal(cat.isGrantableTo('nope.not.a.key', { is_system: true, seeded_rank: 'metic' }), false);
|
|
94
|
+
// A system role claiming a rank the ladder does not define resolves to nothing
|
|
95
|
+
// rather than to "not archon, therefore editable".
|
|
96
|
+
assert.equal(cat.isGrantableTo('idea.triage', { is_system: true, seeded_rank: 'wizard' }), false);
|
|
97
|
+
assert.equal(cat.isGrantableTo('idea.triage', { is_system: true, seeded_rank: null }), false);
|
|
98
|
+
});
|
|
99
|
+
|
|
49
100
|
test('the four rank-roles exist; seed keys all reference real permissions', () => {
|
|
50
101
|
assert.deepEqual(Object.keys(RANK_ROLE_SEED).sort(), [...RANK_ORDER].sort());
|
|
51
102
|
const all = new Set(permissionKeys());
|
|
@@ -11,11 +11,13 @@
|
|
|
11
11
|
//
|
|
12
12
|
// 2. Real in-process HTTP through the actual router: express app + the real
|
|
13
13
|
// requirePermission gate resolving a STUB `governance` port on the seam,
|
|
14
|
-
// db sibling monkeypatched by property. What matters (ADR 0151 §2
|
|
15
|
-
// system
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
14
|
+
// db sibling monkeypatched by property. What matters (ADR 0151 §2, as
|
|
15
|
+
// amended by ADR 0172): a system permission is rejected as NON-GRANTABLE to
|
|
16
|
+
// a CUSTOM role; the three sub-Archon rank-roles ARE grant-editable (and the
|
|
17
|
+
// tests assert that, so a later tightening can't quietly re-close the owner's
|
|
18
|
+
// lever); the ARCHON rank-role and every SUBSTRATE key stay refused; rank-role
|
|
19
|
+
// ASSIGNMENT is still rank-derived (R95b); and every route denies without the
|
|
20
|
+
// `governance.manage` grant.
|
|
19
21
|
//
|
|
20
22
|
// Run: node tests/governance_routes.mjs
|
|
21
23
|
|
|
@@ -55,6 +57,7 @@ test('the routes file declares the full R110 surface', () => {
|
|
|
55
57
|
'POST /governance/roles',
|
|
56
58
|
'PUT /governance/roles/:roleKey/permissions/:permissionKey',
|
|
57
59
|
'DELETE /governance/roles/:roleKey/permissions/:permissionKey',
|
|
60
|
+
'POST /governance/roles/:roleKey/reset',
|
|
58
61
|
'POST /governance/assignments',
|
|
59
62
|
'DELETE /governance/assignments/:builderId/:roleKey',
|
|
60
63
|
];
|
|
@@ -98,10 +101,19 @@ const calls = [];
|
|
|
98
101
|
const stub = {
|
|
99
102
|
listRolesWithGrants: async () => [{ role_key: 'metic', label: 'Metic', description: null, is_system: true, seeded_rank: 'metic', created_by: null, created_at: 't', permissions: ['task.create'] }],
|
|
100
103
|
listAssignments: async () => [{ builder_id: 7, github_login: 'x', display_name: 'X', rank: 'metic', role_key: 'metic', role_label: 'Metic', is_system: true, seeded_rank: 'metic', assigned_by: null, assigned_at: 't' }],
|
|
101
|
-
|
|
104
|
+
// Every rank name is a seeded rank-role; anything else is a custom one. Keyed off
|
|
105
|
+
// the catalog's ladder rather than a hand-listed pair, so a rank added to
|
|
106
|
+
// RANK_ORDER is exercised here instead of quietly falling through as "custom".
|
|
107
|
+
getRoleByKey: async (k) => (k === 'missing' ? null : {
|
|
108
|
+
role_key: k,
|
|
109
|
+
label: k,
|
|
110
|
+
is_system: catalog.RANK_ORDER.includes(k),
|
|
111
|
+
seeded_rank: catalog.RANK_ORDER.includes(k) ? k : null,
|
|
112
|
+
}),
|
|
102
113
|
createRole: async (a) => ({ role_key: a.roleKey, label: a.label, description: a.description ?? null, is_system: false, seeded_rank: null, created_by: a.createdBy, created_at: 't' }),
|
|
103
114
|
grantRolePermission: async () => true,
|
|
104
115
|
revokeRolePermission: async () => true,
|
|
116
|
+
resetRoleGrants: async ({ permissionKeys }) => permissionKeys.length,
|
|
105
117
|
assignRole: async () => true,
|
|
106
118
|
unassignRole: async () => true,
|
|
107
119
|
};
|
|
@@ -259,10 +271,37 @@ test('PUT grant: an unknown permission fails closed', async () => {
|
|
|
259
271
|
assert.equal(r.json.error.code, 'unknown_permission');
|
|
260
272
|
});
|
|
261
273
|
|
|
262
|
-
test('PUT grant: a
|
|
274
|
+
test('PUT grant: a sub-Archon rank-role is EDITABLE (ADR 0172)', async () => {
|
|
263
275
|
const r = await call('PUT', '/governance/roles/metic/permissions/idea.triage');
|
|
276
|
+
assert.equal(r.status, 200);
|
|
277
|
+
const c = calls.find(([k]) => k === 'grantRolePermission');
|
|
278
|
+
assert.deepEqual(c[1], { roleKey: 'metic', permissionKey: 'idea.triage' });
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test('PUT grant: an in-app owner power CAN go to a rank-role but never to a custom one', async () => {
|
|
282
|
+
const rank = await call('PUT', '/governance/roles/metic/permissions/builder.rank.set');
|
|
283
|
+
assert.equal(rank.status, 200, 'the owner may delegate an in-app Archon power to a rank');
|
|
284
|
+
const custom = await call('PUT', '/governance/roles/triage-helper/permissions/builder.rank.set');
|
|
285
|
+
assert.equal(custom.status, 400, 'a custom role keeps the R110 wall');
|
|
286
|
+
assert.equal(custom.json.error.code, 'permission_not_grantable');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('PUT grant: a SUBSTRATE key is refused even on a rank-role (it would confer nothing)', async () => {
|
|
290
|
+
for (const key of ['deploy.push_main', 'path.protected.modify', 'infra.ssh']) {
|
|
291
|
+
const r = await call('PUT', `/governance/roles/metic/permissions/${key}`);
|
|
292
|
+
assert.equal(r.status, 400, `${key} must be refused`);
|
|
293
|
+
assert.equal(r.json.error.code, 'permission_not_grantable');
|
|
294
|
+
assert.match(r.json.error.details.reason || '', /substrate/i,
|
|
295
|
+
'the refusal must name the substrate wall, not the custom-role one — the two are a policy choice apart');
|
|
296
|
+
assert.equal(calls.filter(([k]) => k === 'grantRolePermission').length, 0);
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test('PUT grant: the ARCHON rank-role stays immutable (the lockout guard)', async () => {
|
|
301
|
+
const r = await call('PUT', '/governance/roles/archon/permissions/idea.triage');
|
|
264
302
|
assert.equal(r.status, 403);
|
|
265
|
-
assert.equal(r.json.error.code, '
|
|
303
|
+
assert.equal(r.json.error.code, 'archon_role_immutable');
|
|
304
|
+
assert.equal(calls.filter(([k]) => k === 'grantRolePermission').length, 0);
|
|
266
305
|
});
|
|
267
306
|
|
|
268
307
|
test('PUT grant: an unknown role 404s', async () => {
|
|
@@ -278,10 +317,31 @@ test('DELETE grant revokes without consulting the catalog (stale keys stay remov
|
|
|
278
317
|
assert.deepEqual(c[1], { roleKey: 'triage-helper', permissionKey: 'some.retired.key' });
|
|
279
318
|
});
|
|
280
319
|
|
|
281
|
-
test('DELETE grant on a
|
|
282
|
-
const
|
|
283
|
-
assert.equal(
|
|
284
|
-
|
|
320
|
+
test('DELETE grant: revocable on a sub-Archon rank-role, refused on the Archon one', async () => {
|
|
321
|
+
const metic = await call('DELETE', '/governance/roles/metic/permissions/idea.triage');
|
|
322
|
+
assert.equal(metic.status, 200, 'taking a permission away from a rank is the other half of editable');
|
|
323
|
+
|
|
324
|
+
const archon = await call('DELETE', '/governance/roles/archon/permissions/idea.triage');
|
|
325
|
+
assert.equal(archon.status, 403);
|
|
326
|
+
assert.equal(archon.json.error.code, 'archon_role_immutable');
|
|
327
|
+
assert.equal(calls.filter(([k]) => k === 'revokeRolePermission').length, 0);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// --- reset (ADR 0172) --------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
test('POST reset: a rank-role goes back to its floor-derived catalog seed', async () => {
|
|
333
|
+
const r = await call('POST', '/governance/roles/thetes/reset');
|
|
334
|
+
assert.equal(r.status, 200);
|
|
335
|
+
assert.equal(r.json.seeded_rank, 'thetes');
|
|
336
|
+
const c = calls.find(([k]) => k === 'resetRoleGrants');
|
|
337
|
+
assert.deepEqual([...c[1].permissionKeys].sort(), [...catalog.seedForRank('thetes')].sort());
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('POST reset: refused on a custom role (no catalog default to return to)', async () => {
|
|
341
|
+
const r = await call('POST', '/governance/roles/triage-helper/reset');
|
|
342
|
+
assert.equal(r.status, 400);
|
|
343
|
+
assert.equal(r.json.error.code, 'role_has_no_default');
|
|
344
|
+
assert.equal(calls.filter(([k]) => k === 'resetRoleGrants').length, 0);
|
|
285
345
|
});
|
|
286
346
|
|
|
287
347
|
// --- assignments -------------------------------------------------------------
|