sec-kn-5-delegation-ttl-viewer-mint.test.mjs
408 lines 15.2 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago
1 /**
2 * SEC-KN-5 — seven-tier coverage for P12 (policy TTL ceiling) + P13 (admin-only grant mint).
3 *
4 * Frozen requirements: Pass 2 P12 / P13
5 * (`~/scooling/docs/PRE-BUILD-SECURITY-AUDIT-FINDINGS-PASS2.md`) —
6 * vault policy `max_ttl_seconds` must not raise the ceiling above `MAX_TTL_SECONDS` (86400);
7 * self-hosted `POST /api/v1/delegation/grants` must require `admin` (not viewer).
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 os from 'node:os';
16 import path from 'node:path';
17 import { performance } from 'node:perf_hooks';
18 import { fileURLToPath } from 'node:url';
19
20 import {
21 DELEGATION_POLICY_FILE,
22 DEFAULT_TTL_SECONDS,
23 MAX_TTL_SECONDS,
24 handleDelegationGrantMintRequest,
25 readVaultDelegationPolicy,
26 seedDelegationFixtures,
27 } from '../lib/agent/delegation.mjs';
28 import {
29 makeAgentIdentity,
30 makeDelegationConsent,
31 } from './fixtures/agent/delegation-helpers.mjs';
32
33 const __dirname = path.dirname(fileURLToPath(import.meta.url));
34 const ROOT = path.resolve(__dirname, '..');
35 const DELEGATION_SRC = path.join(ROOT, 'lib/agent/delegation.mjs');
36 const HUB_SERVER_SRC = path.join(ROOT, 'hub/server.mjs');
37
38 const OVERSIZE_TTL = 604800; // 7 days — the audit's example widening of SD-10
39 const UNDER_CAP_TTL = 7200;
40
41 /**
42 * Pre-fix P12 behavior — accept any max_ttl_seconds > 0 with no ceiling.
43 * Security tier asserts current code diverges from this on oversize policies.
44 *
45 * @param {string} dataDir
46 * @returns {{ defaultTtlSeconds: number, maxTtlSeconds: number }}
47 */
48 function readVaultDelegationPolicyLegacyUnclamped(dataDir) {
49 const fp = path.join(dataDir, DELEGATION_POLICY_FILE);
50 let policy = {};
51 try {
52 policy = JSON.parse(fs.readFileSync(fp, 'utf8'));
53 } catch {
54 policy = {};
55 }
56 const d = policy.delegation && typeof policy.delegation === 'object' ? policy.delegation : {};
57 const defaultTtl =
58 typeof d.default_ttl_seconds === 'number' && d.default_ttl_seconds > 0
59 ? d.default_ttl_seconds
60 : DEFAULT_TTL_SECONDS;
61 const maxTtl =
62 typeof d.max_ttl_seconds === 'number' && d.max_ttl_seconds > 0
63 ? d.max_ttl_seconds
64 : MAX_TTL_SECONDS;
65 return { defaultTtlSeconds: defaultTtl, maxTtlSeconds: maxTtl };
66 }
67
68 /**
69 * Pre-fix P13 role list on grant mint — viewer could issue runtime bearer authority.
70 * @param {string} role
71 * @returns {boolean}
72 */
73 function grantMintRoleAllowedLegacy(role) {
74 return new Set(['viewer', 'editor', 'admin', 'evaluator']).has(role);
75 }
76
77 /**
78 * Fixed P13 role check — admin only.
79 * @param {string} role
80 * @returns {boolean}
81 */
82 function grantMintRoleAllowedFixed(role) {
83 return role === 'admin';
84 }
85
86 /**
87 * @param {string} dataDir
88 * @param {{ maxTtl?: number, defaultTtl?: number, enabled?: boolean }} [opts]
89 */
90 function writePolicy(dataDir, opts = {}) {
91 fs.writeFileSync(
92 path.join(dataDir, DELEGATION_POLICY_FILE),
93 JSON.stringify({
94 delegation: {
95 enabled: opts.enabled !== false,
96 default_ttl_seconds: opts.defaultTtl ?? DEFAULT_TTL_SECONDS,
97 max_ttl_seconds: opts.maxTtl ?? MAX_TTL_SECONDS,
98 },
99 }),
100 'utf8',
101 );
102 }
103
104 function mkDataDir() {
105 return fs.mkdtempSync(path.join(os.tmpdir(), 'kt-sec-kn-5-'));
106 }
107
108 /**
109 * @param {string} dataDir
110 * @param {{ ttlSeconds?: number, maxTtl?: number }} [opts]
111 */
112 function seedAndMint(dataDir, opts = {}) {
113 writePolicy(dataDir, { maxTtl: opts.maxTtl ?? OVERSIZE_TTL });
114 process.env.DELEGATION_ENABLED = '1';
115 const identity = makeAgentIdentity({ agentId: 'agent_sec_kn5_01' });
116 const consent = makeDelegationConsent({
117 consentId: 'dcons_sec_kn5_01',
118 agentId: identity.agent_id,
119 });
120 seedDelegationFixtures(dataDir, 'default', identity, consent);
121 return handleDelegationGrantMintRequest({
122 dataDir,
123 vaultId: 'default',
124 consentId: consent.consent_id,
125 actorAgentId: identity.agent_id,
126 taskRef: 'task_hw_week3',
127 ttlSeconds: opts.ttlSeconds,
128 });
129 }
130
131 // ---------------------------------------------------------------------------
132 // Tier 1 — unit
133 // ---------------------------------------------------------------------------
134 describe('SEC-KN-5 unit — P12 TTL clamp + P13 role gate', () => {
135 test('oversize max_ttl_seconds clamps to MAX_TTL_SECONDS', () => {
136 const dir = mkDataDir();
137 try {
138 writePolicy(dir, { maxTtl: OVERSIZE_TTL });
139 const policy = readVaultDelegationPolicy(dir);
140 assert.equal(policy.maxTtlSeconds, MAX_TTL_SECONDS);
141 assert.ok(policy.maxTtlSeconds < OVERSIZE_TTL);
142 } finally {
143 fs.rmSync(dir, { recursive: true, force: true });
144 }
145 });
146
147 test('under-cap max_ttl_seconds is preserved', () => {
148 const dir = mkDataDir();
149 try {
150 writePolicy(dir, { maxTtl: UNDER_CAP_TTL });
151 const policy = readVaultDelegationPolicy(dir);
152 assert.equal(policy.maxTtlSeconds, UNDER_CAP_TTL);
153 } finally {
154 fs.rmSync(dir, { recursive: true, force: true });
155 }
156 });
157
158 test('missing max_ttl_seconds defaults to MAX_TTL_SECONDS', () => {
159 const dir = mkDataDir();
160 try {
161 fs.writeFileSync(
162 path.join(dir, DELEGATION_POLICY_FILE),
163 JSON.stringify({ delegation: { enabled: true, default_ttl_seconds: 3600 } }),
164 'utf8',
165 );
166 assert.equal(readVaultDelegationPolicy(dir).maxTtlSeconds, MAX_TTL_SECONDS);
167 } finally {
168 fs.rmSync(dir, { recursive: true, force: true });
169 }
170 });
171
172 test('zero / negative max_ttl_seconds falls back to MAX_TTL_SECONDS', () => {
173 const dir = mkDataDir();
174 try {
175 writePolicy(dir, { maxTtl: 0 });
176 assert.equal(readVaultDelegationPolicy(dir).maxTtlSeconds, MAX_TTL_SECONDS);
177 writePolicy(dir, { maxTtl: -100 });
178 assert.equal(readVaultDelegationPolicy(dir).maxTtlSeconds, MAX_TTL_SECONDS);
179 } finally {
180 fs.rmSync(dir, { recursive: true, force: true });
181 }
182 });
183
184 test('exact MAX_TTL_SECONDS is accepted without further reduction', () => {
185 const dir = mkDataDir();
186 try {
187 writePolicy(dir, { maxTtl: MAX_TTL_SECONDS });
188 assert.equal(readVaultDelegationPolicy(dir).maxTtlSeconds, MAX_TTL_SECONDS);
189 } finally {
190 fs.rmSync(dir, { recursive: true, force: true });
191 }
192 });
193
194 test('source: readVaultDelegationPolicy clamps with Math.min(..., MAX_TTL_SECONDS)', () => {
195 const src = fs.readFileSync(DELEGATION_SRC, 'utf8');
196 const fnStart = src.indexOf('export function readVaultDelegationPolicy');
197 assert.ok(fnStart >= 0);
198 const fnEnd = src.indexOf('\nexport function', fnStart + 1);
199 const body = src.slice(fnStart, fnEnd > 0 ? fnEnd : undefined);
200 assert.match(body, /Math\.min\([^)]*MAX_TTL_SECONDS/);
201 });
202
203 test('source: POST /delegation/grants is requireRole(admin) only', () => {
204 const src = fs.readFileSync(HUB_SERVER_SRC, 'utf8');
205 assert.match(src, /app\.post\('\/api\/v1\/delegation\/grants', requireRole\('admin'\)/);
206 assert.doesNotMatch(
207 src,
208 /app\.post\('\/api\/v1\/delegation\/grants', requireRole\('viewer'/,
209 );
210 });
211 });
212
213 // ---------------------------------------------------------------------------
214 // Tier 2 — integration (mint path consumes clamped policy)
215 // ---------------------------------------------------------------------------
216 describe('SEC-KN-5 integration — mint respects clamped ceiling', () => {
217 test('client ttl_seconds of 604800 is clamped to 86400 via policy ceiling', () => {
218 const dir = mkDataDir();
219 try {
220 const mint = seedAndMint(dir, { maxTtl: OVERSIZE_TTL, ttlSeconds: OVERSIZE_TTL });
221 assert.equal(mint.ok, true, mint.error ?? mint.code);
222 const issued = Date.parse(mint.payload.grant.issued_at);
223 const expires = Date.parse(mint.payload.grant.expires_at);
224 const ttlSec = Math.round((expires - issued) / 1000);
225 assert.equal(ttlSec, MAX_TTL_SECONDS);
226 assert.ok(ttlSec < OVERSIZE_TTL);
227 } finally {
228 delete process.env.DELEGATION_ENABLED;
229 fs.rmSync(dir, { recursive: true, force: true });
230 }
231 });
232
233 test('under-cap policy still allows mint up to that policy max (not forced to 86400)', () => {
234 const dir = mkDataDir();
235 try {
236 const mint = seedAndMint(dir, { maxTtl: UNDER_CAP_TTL, ttlSeconds: UNDER_CAP_TTL });
237 assert.equal(mint.ok, true, mint.error ?? mint.code);
238 const issued = Date.parse(mint.payload.grant.issued_at);
239 const expires = Date.parse(mint.payload.grant.expires_at);
240 const ttlSec = Math.round((expires - issued) / 1000);
241 assert.equal(ttlSec, UNDER_CAP_TTL);
242 } finally {
243 delete process.env.DELEGATION_ENABLED;
244 fs.rmSync(dir, { recursive: true, force: true });
245 }
246 });
247
248 test('consent propose route still allows viewer (P13 is mint-only)', () => {
249 const src = fs.readFileSync(HUB_SERVER_SRC, 'utf8');
250 assert.match(
251 src,
252 /app\.post\('\/api\/v1\/delegation\/consents', requireRole\('viewer', 'editor', 'admin', 'evaluator'\)/,
253 );
254 });
255 });
256
257 // ---------------------------------------------------------------------------
258 // Tier 3 — e2e (policy file → read → mint → grant TTL)
259 // ---------------------------------------------------------------------------
260 describe('SEC-KN-5 e2e — oversize policy cannot widen grant lifetime', () => {
261 test('default mint (no client ttl) against oversize policy uses default ≤ 86400', () => {
262 const dir = mkDataDir();
263 try {
264 const mint = seedAndMint(dir, { maxTtl: OVERSIZE_TTL });
265 assert.equal(mint.ok, true, mint.error ?? mint.code);
266 const issued = Date.parse(mint.payload.grant.issued_at);
267 const expires = Date.parse(mint.payload.grant.expires_at);
268 const ttlSec = Math.round((expires - issued) / 1000);
269 assert.ok(ttlSec <= MAX_TTL_SECONDS);
270 assert.equal(ttlSec, DEFAULT_TTL_SECONDS);
271 } finally {
272 delete process.env.DELEGATION_ENABLED;
273 fs.rmSync(dir, { recursive: true, force: true });
274 }
275 });
276
277 test('MAX_TTL_SECONDS constant remains 86400 (SD-10 parity)', () => {
278 assert.equal(MAX_TTL_SECONDS, 86400);
279 });
280 });
281
282 // ---------------------------------------------------------------------------
283 // Tier 4 — stress
284 // ---------------------------------------------------------------------------
285 describe('SEC-KN-5 stress — many oversize policies all clamp', () => {
286 test('2_000 oversize policy reads never exceed MAX_TTL_SECONDS', () => {
287 const dir = mkDataDir();
288 try {
289 for (let i = 0; i < 2000; i++) {
290 writePolicy(dir, { maxTtl: OVERSIZE_TTL + i });
291 const policy = readVaultDelegationPolicy(dir);
292 assert.equal(policy.maxTtlSeconds, MAX_TTL_SECONDS);
293 }
294 } finally {
295 fs.rmSync(dir, { recursive: true, force: true });
296 }
297 });
298 });
299
300 // ---------------------------------------------------------------------------
301 // Tier 5 — data-integrity
302 // ---------------------------------------------------------------------------
303 describe('SEC-KN-5 data-integrity — clamp is deterministic and non-mutating', () => {
304 test('repeated reads of the same oversize policy yield identical clamped values', () => {
305 const dir = mkDataDir();
306 try {
307 writePolicy(dir, { maxTtl: OVERSIZE_TTL, defaultTtl: 1800 });
308 const a = readVaultDelegationPolicy(dir);
309 const b = readVaultDelegationPolicy(dir);
310 assert.deepEqual(a, b);
311 assert.equal(a.maxTtlSeconds, MAX_TTL_SECONDS);
312 assert.equal(a.defaultTtlSeconds, 1800);
313 const raw = JSON.parse(fs.readFileSync(path.join(dir, DELEGATION_POLICY_FILE), 'utf8'));
314 assert.equal(raw.delegation.max_ttl_seconds, OVERSIZE_TTL, 'clamp must not rewrite the file');
315 } finally {
316 fs.rmSync(dir, { recursive: true, force: true });
317 }
318 });
319 });
320
321 // ---------------------------------------------------------------------------
322 // Tier 6 — performance
323 // ---------------------------------------------------------------------------
324 describe('SEC-KN-5 performance — clamp path is bounded', () => {
325 test('10_000 clamped reads complete within 2s', () => {
326 const dir = mkDataDir();
327 try {
328 writePolicy(dir, { maxTtl: OVERSIZE_TTL });
329 const t0 = performance.now();
330 for (let i = 0; i < 10_000; i++) {
331 const p = readVaultDelegationPolicy(dir);
332 assert.equal(p.maxTtlSeconds, MAX_TTL_SECONDS);
333 }
334 const elapsed = performance.now() - t0;
335 assert.ok(elapsed < 2000, `expected <2000ms, got ${elapsed.toFixed(1)}ms`);
336 } finally {
337 fs.rmSync(dir, { recursive: true, force: true });
338 }
339 });
340 });
341
342 // ---------------------------------------------------------------------------
343 // Tier 7 — security (regression vs pre-fix)
344 // ---------------------------------------------------------------------------
345 describe('SEC-KN-5 security — regressions vs pre-fix P12/P13', () => {
346 test('P12 security regression: legacy accepts 604800; fixed clamps to 86400', () => {
347 const dir = mkDataDir();
348 try {
349 writePolicy(dir, { maxTtl: OVERSIZE_TTL });
350 const legacy = readVaultDelegationPolicyLegacyUnclamped(dir);
351 const fixed = readVaultDelegationPolicy(dir);
352 assert.equal(legacy.maxTtlSeconds, OVERSIZE_TTL, 'sanity: legacy widens SD-10');
353 assert.equal(fixed.maxTtlSeconds, MAX_TTL_SECONDS);
354 assert.notEqual(
355 fixed.maxTtlSeconds,
356 legacy.maxTtlSeconds,
357 'fixed clamp must diverge from pre-fix unclamped accept',
358 );
359 } finally {
360 fs.rmSync(dir, { recursive: true, force: true });
361 }
362 });
363
364 test('P12: mint against oversize policy cannot produce a grant TTL > 86400', () => {
365 const dir = mkDataDir();
366 try {
367 const mint = seedAndMint(dir, { maxTtl: OVERSIZE_TTL, ttlSeconds: OVERSIZE_TTL });
368 assert.equal(mint.ok, true);
369 const issued = Date.parse(mint.payload.grant.issued_at);
370 const expires = Date.parse(mint.payload.grant.expires_at);
371 const ttlSec = Math.round((expires - issued) / 1000);
372 assert.ok(ttlSec <= MAX_TTL_SECONDS);
373 // Legacy path would have used Math.min(requested, unclampedPolicyMax) = 604800.
374 assert.notEqual(ttlSec, OVERSIZE_TTL);
375 } finally {
376 delete process.env.DELEGATION_ENABLED;
377 fs.rmSync(dir, { recursive: true, force: true });
378 }
379 });
380
381 test('P13 security regression: legacy allows viewer mint; fixed refuses viewer', () => {
382 assert.equal(grantMintRoleAllowedLegacy('viewer'), true, 'sanity: pre-fix viewer could mint');
383 assert.equal(grantMintRoleAllowedLegacy('admin'), true);
384 assert.equal(grantMintRoleAllowedFixed('viewer'), false);
385 assert.equal(grantMintRoleAllowedFixed('editor'), false);
386 assert.equal(grantMintRoleAllowedFixed('evaluator'), false);
387 assert.equal(grantMintRoleAllowedFixed('admin'), true);
388 assert.notEqual(
389 grantMintRoleAllowedFixed('viewer'),
390 grantMintRoleAllowedLegacy('viewer'),
391 'fixed admin-only gate must diverge from pre-fix viewer-inclusive list',
392 );
393 });
394
395 test('P13 source regression: hub/server.mjs no longer lists viewer on grant mint', () => {
396 const src = fs.readFileSync(HUB_SERVER_SRC, 'utf8');
397 // Extract the grants POST middleware list and assert viewer is absent.
398 const m = src.match(
399 /app\.post\('\/api\/v1\/delegation\/grants',\s*requireRole\(([^)]+)\)/,
400 );
401 assert.ok(m, 'grant mint route must exist');
402 const args = m[1];
403 assert.match(args, /'admin'/);
404 assert.doesNotMatch(args, /'viewer'/);
405 assert.doesNotMatch(args, /'editor'/);
406 assert.doesNotMatch(args, /'evaluator'/);
407 });
408 });
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago