sec-kn-3-mcp-access-role-cap.test.mjs
393 lines 14.9 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago
1 /**
2 * SEC-KN-3 — seven-tier coverage for mcp_access role cap + no agent self-apply.
3 *
4 * Frozen requirement: Pass 2 P6
5 * (`~/scooling/docs/PRE-BUILD-SECURITY-AUDIT-FINDINGS-PASS2.md`) —
6 * agent tokens must not inherit admin via HUB_ADMIN_USER_IDS allowlist;
7 * agent tokens must never satisfy personal self-apply (human review only).
8 *
9 * Tiers: unit · integration · e2e · stress · data-integrity · performance · security
10 */
11
12 import { test, describe } from 'node:test';
13 import assert from 'node:assert/strict';
14 import fs from 'node:fs';
15 import path from 'node:path';
16 import { performance } from 'node:perf_hooks';
17 import { fileURLToPath } from 'node:url';
18 import {
19 isMcpAccessPayload,
20 roleFromMcpAccessScopes,
21 roleFromVerifiedAccessPayload,
22 mayApplyAdminAllowlistOverride,
23 applyAdminAllowlistOverrideLegacy,
24 subFromVerifiedPayload,
25 } from '../hub/gateway/access-token-authz.mjs';
26 import {
27 roleEligibleForPersonalSelfApply,
28 isPersonalSelfApplyClass,
29 personalSelfApplyAllowsApprove,
30 SCOOLING_REVIEW_TRAY_INTENT,
31 } from '../lib/hub-proposal-personal-self-apply.mjs';
32
33 const __dirname = path.dirname(fileURLToPath(import.meta.url));
34 const ROOT = path.resolve(__dirname, '..');
35 const SERVER_SRC = path.join(ROOT, 'hub/gateway/server.mjs');
36 const AUTHZ_SRC = path.join(ROOT, 'hub/gateway/access-token-authz.mjs');
37 const SELF_APPLY_SRC = path.join(ROOT, 'lib/hub-proposal-personal-self-apply.mjs');
38
39 const ADMIN_SUB = 'google:admin-owner';
40 const MEMBER_SUB = 'google:learner';
41
42 /** Allowlist roleForSub: admin sub → admin, everyone else → member. */
43 function roleForSubAllowlist(sub) {
44 return sub === ADMIN_SUB ? 'admin' : 'member';
45 }
46
47 /**
48 * Pre-fix resolveHostedActorRole JWT path (Pass 2 P6) — elevates via roleForSub
49 * and then applies the admin allowlist even for mcp_access.
50 *
51 * @param {object} payload
52 * @param {(sub: string|null|undefined) => string} roleForSub
53 * @returns {{ role: string, mayApproveProposals: boolean }}
54 */
55 function resolveHostedActorRoleLegacy(payload, roleForSub) {
56 let role = payload.role || roleForSub(payload.sub);
57 role = applyAdminAllowlistOverrideLegacy(role, payload.sub, roleForSub);
58 const mayApproveProposals = role === 'admin';
59 return { role, mayApproveProposals };
60 }
61
62 /**
63 * Fixed resolve path used by gateway for mcp_access / web JWTs.
64 * @param {object} payload
65 * @param {(sub: string|null|undefined) => string} roleForSub
66 * @returns {{ role: string, mayApproveProposals: boolean, isMcpAccess: boolean }}
67 */
68 function resolveHostedActorRoleFixed(payload, roleForSub) {
69 const capped = roleFromVerifiedAccessPayload(payload, roleForSub);
70 let role = capped.role;
71 let mayApproveProposals =
72 role === 'admin' || (role === 'evaluator' && false);
73 if (
74 mayApplyAdminAllowlistOverride(payload) &&
75 payload.sub &&
76 role !== 'admin' &&
77 roleForSub(payload.sub) === 'admin'
78 ) {
79 role = 'admin';
80 mayApproveProposals = true;
81 }
82 return { role, mayApproveProposals, isMcpAccess: capped.isMcpAccess };
83 }
84
85 function matchingProposal(overrides = {}) {
86 return {
87 status: 'proposed',
88 intent: SCOOLING_REVIEW_TRAY_INTENT,
89 external_ref: 'scooling.review:sec-kn-3',
90 path: 'reviewed/sec-kn-3.md',
91 review_severity: 'standard',
92 ...overrides,
93 };
94 }
95
96 // ---------------------------------------------------------------------------
97 // Tier 1 — unit
98 // ---------------------------------------------------------------------------
99 describe('SEC-KN-3 unit — mcp_access role cap + human-actor gate', () => {
100 test('roleFromMcpAccessScopes: vault:write alone is member; admin scopes elevate', () => {
101 assert.equal(roleFromMcpAccessScopes(['vault:write']), 'member');
102 assert.equal(roleFromMcpAccessScopes(['vault:read', 'vault:write']), 'member');
103 assert.equal(roleFromMcpAccessScopes(['vault:read']), 'member');
104 assert.equal(roleFromMcpAccessScopes(['admin']), 'admin');
105 assert.equal(roleFromMcpAccessScopes(['vault:admin']), 'admin');
106 assert.equal(roleFromMcpAccessScopes(undefined), 'member');
107 });
108
109 test('mcp_access admin-sub with vault:write does not inherit admin via roleForSub', () => {
110 const payload = {
111 sub: ADMIN_SUB,
112 type: 'mcp_access',
113 scopes: ['vault:write'],
114 };
115 const fixed = roleFromVerifiedAccessPayload(payload, roleForSubAllowlist);
116 assert.equal(fixed.isMcpAccess, true);
117 assert.equal(fixed.role, 'member');
118 assert.equal(mayApplyAdminAllowlistOverride(payload), false);
119 });
120
121 test('web-session JWT still uses role claim / roleForSub', () => {
122 const adminSession = { sub: ADMIN_SUB, role: 'admin' };
123 assert.equal(roleFromVerifiedAccessPayload(adminSession, roleForSubAllowlist).role, 'admin');
124 assert.equal(mayApplyAdminAllowlistOverride(adminSession), true);
125
126 const memberNoRole = { sub: MEMBER_SUB };
127 assert.equal(roleFromVerifiedAccessPayload(memberNoRole, roleForSubAllowlist).role, 'member');
128 });
129
130 test('roleEligibleForPersonalSelfApply rejects agent / mcp_access / humanActor:false', () => {
131 assert.equal(roleEligibleForPersonalSelfApply('member'), true);
132 assert.equal(roleEligibleForPersonalSelfApply('admin'), true);
133 assert.equal(roleEligibleForPersonalSelfApply('member', { humanActor: false }), false);
134 assert.equal(roleEligibleForPersonalSelfApply('member', { tokenType: 'mcp_access' }), false);
135 assert.equal(roleEligibleForPersonalSelfApply('admin', { actorKind: 'agent' }), false);
136 assert.equal(roleEligibleForPersonalSelfApply('member', { actorKind: 'human' }), true);
137 });
138
139 test('isMcpAccessPayload is strict on type claim', () => {
140 assert.equal(isMcpAccessPayload({ type: 'mcp_access' }), true);
141 assert.equal(isMcpAccessPayload({ type: 'web' }), false);
142 assert.equal(isMcpAccessPayload(null), false);
143 });
144 });
145
146 // ---------------------------------------------------------------------------
147 // Tier 2 — integration (gateway wiring + self-apply class)
148 // ---------------------------------------------------------------------------
149 describe('SEC-KN-3 integration — gateway wiring + self-apply class', () => {
150 test('server.mjs imports roleFromVerifiedAccessPayload and skips allowlist for mcp_access', () => {
151 const src = fs.readFileSync(SERVER_SRC, 'utf8');
152 assert.ok(src.includes('roleFromVerifiedAccessPayload'));
153 assert.ok(src.includes('mayApplyAdminAllowlistOverride'));
154 assert.ok(src.includes('isMcpAccessPayload'));
155 // Phase C extends the ternary with agent_access; mcp_access arm must remain first.
156 assert.ok(
157 src.includes("tokenType: isMcpAccess ? 'mcp_access' : isAgentAccess ? 'agent_access' : null") ||
158 src.includes("tokenType: isMcpAccess ? 'mcp_access' : null"),
159 'tokenType classifies mcp_access (and agent_access when Phase C is present)',
160 );
161 assert.ok(src.includes('humanActor: !isMcpAccess'));
162 });
163
164 test('access-token-authz documents SEC-KN-3 / Pass 2 P6', () => {
165 const src = fs.readFileSync(AUTHZ_SRC, 'utf8');
166 assert.ok(src.includes('SEC-KN-3'));
167 assert.ok(src.includes('Pass 2 P6'));
168 });
169
170 test('self-apply class false for mcp_access member with vault:write fingerprint', () => {
171 assert.equal(
172 isPersonalSelfApplyClass({
173 proposal: matchingProposal(),
174 hasVaultWrite: true,
175 partitionOwned: true,
176 role: 'member',
177 tokenType: 'mcp_access',
178 humanActor: false,
179 actorKind: 'agent',
180 }),
181 false,
182 );
183 assert.equal(
184 isPersonalSelfApplyClass({
185 proposal: matchingProposal(),
186 hasVaultWrite: true,
187 partitionOwned: true,
188 role: 'member',
189 actorKind: 'human',
190 }),
191 true,
192 );
193 });
194 });
195
196 // ---------------------------------------------------------------------------
197 // Tier 3 — e2e (full resolve + approve eligibility matrix)
198 // ---------------------------------------------------------------------------
199 describe('SEC-KN-3 e2e — resolve + approve eligibility matrix', () => {
200 test('admin-sub mcp_access vault:write → member, no approve via admin, no self-apply', () => {
201 const payload = {
202 sub: ADMIN_SUB,
203 type: 'mcp_access',
204 scopes: ['vault:read', 'vault:write'],
205 };
206 const resolved = resolveHostedActorRoleFixed(payload, roleForSubAllowlist);
207 assert.equal(resolved.role, 'member');
208 assert.equal(resolved.mayApproveProposals, false);
209 assert.equal(resolved.isMcpAccess, true);
210
211 const canAdminApprove = resolved.role === 'admin' || resolved.mayApproveProposals;
212 assert.equal(canAdminApprove, false);
213
214 assert.equal(
215 personalSelfApplyAllowsApprove({
216 proposal: matchingProposal(),
217 hasVaultWrite: true,
218 partitionOwned: true,
219 role: resolved.role,
220 humanActor: !resolved.isMcpAccess,
221 tokenType: resolved.isMcpAccess ? 'mcp_access' : null,
222 actorKind: resolved.isMcpAccess ? 'agent' : 'human',
223 }),
224 false,
225 );
226 });
227
228 test('web-session admin-sub still gets allowlist admin + mayApprove', () => {
229 const payload = { sub: ADMIN_SUB };
230 const resolved = resolveHostedActorRoleFixed(payload, roleForSubAllowlist);
231 assert.equal(resolved.role, 'admin');
232 assert.equal(resolved.mayApproveProposals, true);
233 assert.equal(resolved.isMcpAccess, false);
234 });
235
236 test('mcp_access with explicit admin scope remains admin (scope-granted, not allowlist)', () => {
237 const payload = {
238 sub: MEMBER_SUB,
239 type: 'mcp_access',
240 scopes: ['vault:read', 'vault:write', 'admin'],
241 };
242 const resolved = resolveHostedActorRoleFixed(payload, roleForSubAllowlist);
243 assert.equal(resolved.role, 'admin');
244 assert.equal(resolved.mayApproveProposals, true);
245 });
246 });
247
248 // ---------------------------------------------------------------------------
249 // Tier 4 — stress
250 // ---------------------------------------------------------------------------
251 describe('SEC-KN-3 stress — many mcp_access payloads stay non-admin', () => {
252 test('1000 admin-sub vault:write tokens never inherit admin', () => {
253 for (let i = 0; i < 1000; i++) {
254 const payload = {
255 sub: ADMIN_SUB,
256 type: 'mcp_access',
257 scopes: i % 2 === 0 ? ['vault:write'] : ['vault:read', 'vault:write'],
258 jti: `stress-${i}`,
259 };
260 const resolved = resolveHostedActorRoleFixed(payload, roleForSubAllowlist);
261 assert.equal(resolved.role, 'member');
262 assert.equal(mayApplyAdminAllowlistOverride(payload), false);
263 assert.equal(
264 roleEligibleForPersonalSelfApply(resolved.role, {
265 tokenType: 'mcp_access',
266 humanActor: false,
267 }),
268 false,
269 );
270 }
271 });
272 });
273
274 // ---------------------------------------------------------------------------
275 // Tier 5 — data-integrity
276 // ---------------------------------------------------------------------------
277 describe('SEC-KN-3 data-integrity — no elevation side effects', () => {
278 test('roleFromVerifiedAccessPayload does not mutate payload', () => {
279 const payload = {
280 sub: ADMIN_SUB,
281 type: 'mcp_access',
282 scopes: ['vault:write'],
283 };
284 const before = JSON.stringify(payload);
285 roleFromVerifiedAccessPayload(payload, roleForSubAllowlist);
286 assert.equal(JSON.stringify(payload), before);
287 });
288
289 test('REST identity for mcp_access vault:write still resolves sub (role is separate)', () => {
290 const payload = {
291 sub: ADMIN_SUB,
292 type: 'mcp_access',
293 scopes: ['vault:write'],
294 };
295 assert.equal(subFromVerifiedPayload(payload, { method: 'POST' }), ADMIN_SUB);
296 assert.equal(roleFromVerifiedAccessPayload(payload, roleForSubAllowlist).role, 'member');
297 });
298
299 test('self-apply source documents human-actor / agent exclusion', () => {
300 const src = fs.readFileSync(SELF_APPLY_SRC, 'utf8');
301 assert.ok(src.includes('SEC-KN-3'));
302 assert.ok(src.includes('mcp_access'));
303 assert.ok(src.includes('humanActor'));
304 });
305 });
306
307 // ---------------------------------------------------------------------------
308 // Tier 6 — performance
309 // ---------------------------------------------------------------------------
310 describe('SEC-KN-3 performance — bounded role resolve time', () => {
311 test('10k roleFromVerifiedAccessPayload calls under 200ms', () => {
312 const payload = {
313 sub: ADMIN_SUB,
314 type: 'mcp_access',
315 scopes: ['vault:read', 'vault:write'],
316 };
317 const t0 = performance.now();
318 for (let i = 0; i < 10_000; i++) {
319 roleFromVerifiedAccessPayload(payload, roleForSubAllowlist);
320 mayApplyAdminAllowlistOverride(payload);
321 roleEligibleForPersonalSelfApply('member', { tokenType: 'mcp_access' });
322 }
323 const elapsed = performance.now() - t0;
324 assert.ok(elapsed < 200, `expected <200ms, got ${elapsed.toFixed(2)}ms`);
325 });
326 });
327
328 // ---------------------------------------------------------------------------
329 // Tier 7 — security (regression must FAIL against pre-fix allowlist inheritance)
330 // ---------------------------------------------------------------------------
331 describe('SEC-KN-3 security — regression vs allowlist inheritance', () => {
332 test('security regression: legacy elevates mcp_access admin-sub; fixed does not', () => {
333 const payload = {
334 sub: ADMIN_SUB,
335 type: 'mcp_access',
336 scopes: ['vault:write'],
337 };
338 const legacy = resolveHostedActorRoleLegacy(payload, roleForSubAllowlist);
339 assert.equal(
340 legacy.role,
341 'admin',
342 'sanity: pre-fix path still models allowlist inheritance for mcp_access',
343 );
344 assert.equal(legacy.mayApproveProposals, true);
345
346 const fixed = resolveHostedActorRoleFixed(payload, roleForSubAllowlist);
347 assert.equal(fixed.role, 'member');
348 assert.notEqual(
349 fixed.role,
350 legacy.role,
351 'fixed behavior must diverge from pre-fix allowlist inheritance',
352 );
353 assert.equal(fixed.mayApproveProposals, false);
354 });
355
356 test('security regression: legacy self-apply eligible for agent member; fixed rejects', () => {
357 // Pre-fix: roleEligibleForPersonalSelfApply(role) with no human-actor test.
358 function roleEligibleLegacy(role) {
359 const r = String(role || '').trim();
360 return r === 'member' || r === 'editor' || r === 'admin';
361 }
362 assert.equal(roleEligibleLegacy('member'), true, 'sanity: legacy allows member agents');
363
364 assert.equal(
365 roleEligibleForPersonalSelfApply('member', {
366 tokenType: 'mcp_access',
367 humanActor: false,
368 actorKind: 'agent',
369 }),
370 false,
371 );
372 assert.notEqual(
373 roleEligibleForPersonalSelfApply('member', {
374 tokenType: 'mcp_access',
375 humanActor: false,
376 }),
377 roleEligibleLegacy('member'),
378 'fixed human-actor gate must diverge from pre-fix role-only check',
379 );
380 });
381
382 test('discard / approve admin path: vault:write mcp_access on admin sub cannot discard', () => {
383 const payload = {
384 sub: ADMIN_SUB,
385 type: 'mcp_access',
386 scopes: ['vault:write'],
387 };
388 const fixed = resolveHostedActorRoleFixed(payload, roleForSubAllowlist);
389 assert.notEqual(fixed.role, 'admin');
390 // Discard requires role === 'admin' in assertHostedProposalApproveDiscard.
391 assert.equal(fixed.role === 'admin', false);
392 });
393 });
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago