sec-kn-5-delegation-ttl-viewer-mint.test.mjs
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