@openparachute/hub 0.5.14-rc.2 → 0.5.14-rc.21
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/README.md +109 -15
- package/package.json +7 -3
- package/src/__tests__/account-home-ui.test.ts +251 -15
- package/src/__tests__/account-vault-token.test.ts +355 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-mint-token.test.ts +693 -5
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +45 -0
- package/src/__tests__/api-modules.test.ts +92 -75
- package/src/__tests__/api-ready.test.ts +135 -0
- package/src/__tests__/api-revoke-token.test.ts +384 -0
- package/src/__tests__/api-users.test.ts +7 -2
- package/src/__tests__/auth.test.ts +157 -30
- package/src/__tests__/cli.test.ts +44 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-2fa-warning.test.ts +31 -17
- package/src/__tests__/expose-auth-preflight.test.ts +71 -72
- package/src/__tests__/expose-cloudflare.test.ts +582 -11
- package/src/__tests__/expose-interactive.test.ts +10 -4
- package/src/__tests__/expose-public-auto.test.ts +5 -1
- package/src/__tests__/expose.test.ts +52 -2
- package/src/__tests__/hub-server.test.ts +396 -10
- package/src/__tests__/hub.test.ts +85 -6
- package/src/__tests__/init.test.ts +928 -0
- package/src/__tests__/lifecycle.test.ts +464 -2
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/oauth-handlers.test.ts +1252 -83
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
- package/src/__tests__/proxy-error-ui.test.ts +212 -0
- package/src/__tests__/proxy-state.test.ts +192 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +77 -12
- package/src/__tests__/services-manifest.test.ts +122 -4
- package/src/__tests__/setup-wizard.test.ts +633 -53
- package/src/__tests__/status.test.ts +36 -0
- package/src/__tests__/two-factor-flow.test.ts +602 -0
- package/src/__tests__/two-factor.test.ts +183 -0
- package/src/__tests__/upgrade.test.ts +78 -1
- package/src/__tests__/users.test.ts +68 -0
- package/src/__tests__/vault-auth-status.test.ts +312 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +488 -38
- package/src/account-vault-token.ts +282 -0
- package/src/admin-handlers.ts +159 -4
- package/src/admin-login-ui.ts +49 -5
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +14 -0
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +49 -11
- package/src/api-modules.ts +29 -12
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +29 -3
- package/src/cli.ts +112 -25
- package/src/clients.ts +18 -6
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +82 -20
- package/src/commands/auth.ts +165 -24
- package/src/commands/expose-2fa-warning.ts +34 -32
- package/src/commands/expose-auth-preflight.ts +89 -78
- package/src/commands/expose-cloudflare.ts +471 -16
- package/src/commands/expose-interactive.ts +10 -11
- package/src/commands/expose-public-auto.ts +6 -4
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +594 -0
- package/src/commands/install.ts +33 -2
- package/src/commands/lifecycle.ts +386 -17
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +22 -0
- package/src/commands/upgrade.ts +55 -11
- package/src/commands/wizard.ts +847 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -15
- package/src/hub-db.ts +39 -1
- package/src/hub-server.ts +119 -13
- package/src/hub-settings.ts +11 -0
- package/src/hub.ts +82 -14
- package/src/oauth-handlers.ts +298 -21
- package/src/oauth-ui.ts +10 -0
- package/src/operator-token.ts +151 -0
- package/src/pending-login.ts +116 -0
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/rate-limit.ts +51 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +131 -14
- package/src/services-manifest.ts +112 -0
- package/src/setup-wizard.ts +738 -125
- package/src/tailscale/run.ts +28 -11
- package/src/totp.ts +201 -0
- package/src/two-factor-handlers.ts +287 -0
- package/src/two-factor-store.ts +181 -0
- package/src/two-factor-ui.ts +462 -0
- package/src/users.ts +58 -0
- package/src/vault/auth-status.ts +200 -25
- package/src/vault-hub-origin-env.ts +163 -0
- package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
- package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
|
@@ -308,7 +308,7 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
|
|
|
308
308
|
const body = (await resp.json()) as { error: string; error_description: string };
|
|
309
309
|
expect(body.error).toBe("invalid_scope");
|
|
310
310
|
expect(body.error_description).toContain("parachute:host:auth");
|
|
311
|
-
expect(body.error_description).toContain("not
|
|
311
|
+
expect(body.error_description).toContain("not grantable");
|
|
312
312
|
} finally {
|
|
313
313
|
db.close();
|
|
314
314
|
}
|
|
@@ -342,19 +342,104 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
|
|
|
342
342
|
}
|
|
343
343
|
});
|
|
344
344
|
|
|
345
|
-
|
|
345
|
+
// De-escalation (PR-A): a `parachute:host:admin` bearer MAY mint a
|
|
346
|
+
// vault-pinned admin token — host:admin already implies box-wide vault
|
|
347
|
+
// administration, so narrowing it to one vault is a privilege reduction.
|
|
348
|
+
// This is the canonical headless path replacing deprecated pvt_* (vault#282).
|
|
349
|
+
test("200 when host:admin bearer mints vault:<name>:admin (de-escalation)", async () => {
|
|
346
350
|
const h = makeHarness();
|
|
347
351
|
try {
|
|
348
352
|
const { db, userId } = await bootstrap(h.dir);
|
|
349
353
|
try {
|
|
354
|
+
// The default admin operator scope-set carries parachute:host:admin.
|
|
350
355
|
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
351
356
|
const resp = await handleApiMintToken(
|
|
352
357
|
jsonRequest({ scope: "vault:work:admin" }, { authorization: `Bearer ${op.token}` }),
|
|
353
358
|
{ db, issuer: ISSUER },
|
|
354
359
|
);
|
|
355
|
-
expect(resp.status).toBe(
|
|
356
|
-
const body = (await resp.json()) as {
|
|
357
|
-
expect(body.
|
|
360
|
+
expect(resp.status).toBe(200);
|
|
361
|
+
const body = (await resp.json()) as { jti: string; token: string; scope: string };
|
|
362
|
+
expect(body.scope).toBe("vault:work:admin");
|
|
363
|
+
const validated = await validateAccessToken(db, body.token, ISSUER);
|
|
364
|
+
// Audience must be the per-vault resource so vault's strict-equality
|
|
365
|
+
// audience check accepts it.
|
|
366
|
+
expect(validated.payload.aud).toBe("vault.work");
|
|
367
|
+
expect(validated.payload.scope).toBe("vault:work:admin");
|
|
368
|
+
// vault_scope is pinned to the named vault (defense-in-depth — the
|
|
369
|
+
// token can ONLY be used against `work`), matching the canonical
|
|
370
|
+
// session-path mint in admin-vault-admin-token.ts.
|
|
371
|
+
expect(validated.payload.vault_scope).toEqual(["work"]);
|
|
372
|
+
// Registry row written → revocable like any operator mint.
|
|
373
|
+
const row = db
|
|
374
|
+
.query<{ jti: string }, [string]>("SELECT jti FROM tokens WHERE jti = ?")
|
|
375
|
+
.get(body.jti);
|
|
376
|
+
expect(row).not.toBeNull();
|
|
377
|
+
} finally {
|
|
378
|
+
db.close();
|
|
379
|
+
}
|
|
380
|
+
} finally {
|
|
381
|
+
h.cleanup();
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
// Single-consent change (2026-05-29) — INTENTIONAL canGrant widening. Once
|
|
386
|
+
// `vault:<name>:admin` became requestable (`isNonRequestableScope` dropped
|
|
387
|
+
// the per-vault-admin clause), canGrant rule 1 (`!isNonRequestableScope` +
|
|
388
|
+
// bearer holds `parachute:host:auth`) now ADMITS it. A `parachute:host:auth`
|
|
389
|
+
// bearer is an on-box operator credential, so minting a vault-pinned admin
|
|
390
|
+
// from it is a de-escalation, not an escalation. Pinned here so the widening
|
|
391
|
+
// is deliberate, not an accidental regression.
|
|
392
|
+
test("200 when auth-only bearer mints vault:<name>:admin (intentional canGrant widening)", async () => {
|
|
393
|
+
const h = makeHarness();
|
|
394
|
+
try {
|
|
395
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
396
|
+
try {
|
|
397
|
+
const op = await mintOperatorToken(db, userId, {
|
|
398
|
+
issuer: ISSUER,
|
|
399
|
+
scopeSet: "auth",
|
|
400
|
+
});
|
|
401
|
+
const resp = await handleApiMintToken(
|
|
402
|
+
jsonRequest({ scope: "vault:work:admin" }, { authorization: `Bearer ${op.token}` }),
|
|
403
|
+
{ db, issuer: ISSUER },
|
|
404
|
+
);
|
|
405
|
+
expect(resp.status).toBe(200);
|
|
406
|
+
const body = (await resp.json()) as { scope: string; token: string };
|
|
407
|
+
expect(body.scope).toBe("vault:work:admin");
|
|
408
|
+
const validated = await validateAccessToken(db, body.token, ISSUER);
|
|
409
|
+
expect(validated.payload.aud).toBe("vault.work");
|
|
410
|
+
expect(validated.payload.scope).toBe("vault:work:admin");
|
|
411
|
+
} finally {
|
|
412
|
+
db.close();
|
|
413
|
+
}
|
|
414
|
+
} finally {
|
|
415
|
+
h.cleanup();
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// A bare `vault:admin` (no vault name) is NOT a per-vault admin scope —
|
|
420
|
+
// the de-escalation exception only covers `vault:<name>:admin`. It isn't
|
|
421
|
+
// in the non-requestable set either, so it's treated as an ordinary
|
|
422
|
+
// (unnamed) scope and mints — but with the `vault` fallback audience, not
|
|
423
|
+
// a per-vault one. Pinned so a future regex loosening can't silently let
|
|
424
|
+
// an unnamed admin through the named-vault exemption.
|
|
425
|
+
test("bare vault:admin (no name) is not caught by the de-escalation exemption", async () => {
|
|
426
|
+
const h = makeHarness();
|
|
427
|
+
try {
|
|
428
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
429
|
+
try {
|
|
430
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
431
|
+
const resp = await handleApiMintToken(
|
|
432
|
+
jsonRequest({ scope: "vault:admin" }, { authorization: `Bearer ${op.token}` }),
|
|
433
|
+
{ db, issuer: ISSUER },
|
|
434
|
+
);
|
|
435
|
+
// `vault:admin` isn't a per-vault admin scope and isn't in the
|
|
436
|
+
// non-requestable set, so it mints as an ordinary scope. The point
|
|
437
|
+
// of this test is that it does NOT get a per-vault audience/pin.
|
|
438
|
+
expect(resp.status).toBe(200);
|
|
439
|
+
const body = (await resp.json()) as { token: string };
|
|
440
|
+
const validated = await validateAccessToken(db, body.token, ISSUER);
|
|
441
|
+
expect(validated.payload.aud).toBe("vault");
|
|
442
|
+
expect(validated.payload.vault_scope).toEqual([]);
|
|
358
443
|
} finally {
|
|
359
444
|
db.close();
|
|
360
445
|
}
|
|
@@ -363,6 +448,609 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
|
|
|
363
448
|
}
|
|
364
449
|
});
|
|
365
450
|
|
|
451
|
+
// ── Capability attenuation (hub PR — subsumes hub#449's PR-A carve-out) ──
|
|
452
|
+
//
|
|
453
|
+
// A `vault:<name>:admin` bearer may mint a token whose authority is a
|
|
454
|
+
// SUBSET of its own: same-vault read/write/admin. It can NEVER mint a
|
|
455
|
+
// cross-vault scope or any host-level authority. We hand-mint the bearer
|
|
456
|
+
// via signAccessToken (scope `vault:work:admin`, aud `vault.work`,
|
|
457
|
+
// vaultScope `["work"]`) — mirroring how the SPA / mcp-install obtain one.
|
|
458
|
+
async function mintVaultAdminBearer(
|
|
459
|
+
db: ReturnType<typeof openHubDb>,
|
|
460
|
+
userId: string,
|
|
461
|
+
vault: string,
|
|
462
|
+
): Promise<string> {
|
|
463
|
+
const signed = await signAccessToken(db, {
|
|
464
|
+
sub: userId,
|
|
465
|
+
scopes: [`vault:${vault}:admin`],
|
|
466
|
+
audience: `vault.${vault}`,
|
|
467
|
+
clientId: "parachute-hub",
|
|
468
|
+
issuer: ISSUER,
|
|
469
|
+
ttlSeconds: 3600,
|
|
470
|
+
vaultScope: [vault],
|
|
471
|
+
});
|
|
472
|
+
return signed.token;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
describe("capability attenuation — vault:<name>:admin bearer", () => {
|
|
476
|
+
test("mints vault:work:write → 200, aud=vault.work, vault_scope=[work]", async () => {
|
|
477
|
+
const h = makeHarness();
|
|
478
|
+
try {
|
|
479
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
480
|
+
try {
|
|
481
|
+
const bearer = await mintVaultAdminBearer(db, userId, "work");
|
|
482
|
+
const resp = await handleApiMintToken(
|
|
483
|
+
jsonRequest({ scope: "vault:work:write" }, { authorization: `Bearer ${bearer}` }),
|
|
484
|
+
{ db, issuer: ISSUER },
|
|
485
|
+
);
|
|
486
|
+
expect(resp.status).toBe(200);
|
|
487
|
+
const body = (await resp.json()) as { token: string };
|
|
488
|
+
const validated = await validateAccessToken(db, body.token, ISSUER);
|
|
489
|
+
expect(validated.payload.aud).toBe("vault.work");
|
|
490
|
+
expect(validated.payload.scope).toBe("vault:work:write");
|
|
491
|
+
expect(validated.payload.vault_scope).toEqual(["work"]);
|
|
492
|
+
} finally {
|
|
493
|
+
db.close();
|
|
494
|
+
}
|
|
495
|
+
} finally {
|
|
496
|
+
h.cleanup();
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test("mints vault:work:read → 200", async () => {
|
|
501
|
+
const h = makeHarness();
|
|
502
|
+
try {
|
|
503
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
504
|
+
try {
|
|
505
|
+
const bearer = await mintVaultAdminBearer(db, userId, "work");
|
|
506
|
+
const resp = await handleApiMintToken(
|
|
507
|
+
jsonRequest({ scope: "vault:work:read" }, { authorization: `Bearer ${bearer}` }),
|
|
508
|
+
{ db, issuer: ISSUER },
|
|
509
|
+
);
|
|
510
|
+
expect(resp.status).toBe(200);
|
|
511
|
+
const body = (await resp.json()) as { token: string };
|
|
512
|
+
const validated = await validateAccessToken(db, body.token, ISSUER);
|
|
513
|
+
expect(validated.payload.vault_scope).toEqual(["work"]);
|
|
514
|
+
} finally {
|
|
515
|
+
db.close();
|
|
516
|
+
}
|
|
517
|
+
} finally {
|
|
518
|
+
h.cleanup();
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
test("mints vault:work:admin → 200 (same-level allowed)", async () => {
|
|
523
|
+
const h = makeHarness();
|
|
524
|
+
try {
|
|
525
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
526
|
+
try {
|
|
527
|
+
const bearer = await mintVaultAdminBearer(db, userId, "work");
|
|
528
|
+
const resp = await handleApiMintToken(
|
|
529
|
+
jsonRequest({ scope: "vault:work:admin" }, { authorization: `Bearer ${bearer}` }),
|
|
530
|
+
{ db, issuer: ISSUER },
|
|
531
|
+
);
|
|
532
|
+
expect(resp.status).toBe(200);
|
|
533
|
+
const body = (await resp.json()) as { token: string };
|
|
534
|
+
const validated = await validateAccessToken(db, body.token, ISSUER);
|
|
535
|
+
expect(validated.payload.aud).toBe("vault.work");
|
|
536
|
+
expect(validated.payload.scope).toBe("vault:work:admin");
|
|
537
|
+
expect(validated.payload.vault_scope).toEqual(["work"]);
|
|
538
|
+
} finally {
|
|
539
|
+
db.close();
|
|
540
|
+
}
|
|
541
|
+
} finally {
|
|
542
|
+
h.cleanup();
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
// THE CRUX: cross-vault is the security boundary. A work-admin bearer
|
|
547
|
+
// MUST NOT be able to mint authority over any other vault.
|
|
548
|
+
test("mints vault:other:write → 400 (cross-vault BLOCKED)", async () => {
|
|
549
|
+
const h = makeHarness();
|
|
550
|
+
try {
|
|
551
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
552
|
+
try {
|
|
553
|
+
const bearer = await mintVaultAdminBearer(db, userId, "work");
|
|
554
|
+
const resp = await handleApiMintToken(
|
|
555
|
+
jsonRequest({ scope: "vault:other:write" }, { authorization: `Bearer ${bearer}` }),
|
|
556
|
+
{ db, issuer: ISSUER },
|
|
557
|
+
);
|
|
558
|
+
expect(resp.status).toBe(400);
|
|
559
|
+
const body = (await resp.json()) as { error: string; error_description: string };
|
|
560
|
+
expect(body.error).toBe("invalid_scope");
|
|
561
|
+
expect(body.error_description).toContain("vault:other:write");
|
|
562
|
+
} finally {
|
|
563
|
+
db.close();
|
|
564
|
+
}
|
|
565
|
+
} finally {
|
|
566
|
+
h.cleanup();
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
test("mints vault:other:admin → 400 (cross-vault BLOCKED)", async () => {
|
|
571
|
+
const h = makeHarness();
|
|
572
|
+
try {
|
|
573
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
574
|
+
try {
|
|
575
|
+
const bearer = await mintVaultAdminBearer(db, userId, "work");
|
|
576
|
+
const resp = await handleApiMintToken(
|
|
577
|
+
jsonRequest({ scope: "vault:other:admin" }, { authorization: `Bearer ${bearer}` }),
|
|
578
|
+
{ db, issuer: ISSUER },
|
|
579
|
+
);
|
|
580
|
+
expect(resp.status).toBe(400);
|
|
581
|
+
const body = (await resp.json()) as { error: string };
|
|
582
|
+
expect(body.error).toBe("invalid_scope");
|
|
583
|
+
} finally {
|
|
584
|
+
db.close();
|
|
585
|
+
}
|
|
586
|
+
} finally {
|
|
587
|
+
h.cleanup();
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
test("mints parachute:host:auth → 400 (no escalation to host authority)", async () => {
|
|
592
|
+
const h = makeHarness();
|
|
593
|
+
try {
|
|
594
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
595
|
+
try {
|
|
596
|
+
const bearer = await mintVaultAdminBearer(db, userId, "work");
|
|
597
|
+
const resp = await handleApiMintToken(
|
|
598
|
+
jsonRequest({ scope: "parachute:host:auth" }, { authorization: `Bearer ${bearer}` }),
|
|
599
|
+
{ db, issuer: ISSUER },
|
|
600
|
+
);
|
|
601
|
+
expect(resp.status).toBe(400);
|
|
602
|
+
const body = (await resp.json()) as { error: string };
|
|
603
|
+
expect(body.error).toBe("invalid_scope");
|
|
604
|
+
} finally {
|
|
605
|
+
db.close();
|
|
606
|
+
}
|
|
607
|
+
} finally {
|
|
608
|
+
h.cleanup();
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test("mints parachute:host:admin → 400 (no escalation to host authority)", async () => {
|
|
613
|
+
const h = makeHarness();
|
|
614
|
+
try {
|
|
615
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
616
|
+
try {
|
|
617
|
+
const bearer = await mintVaultAdminBearer(db, userId, "work");
|
|
618
|
+
const resp = await handleApiMintToken(
|
|
619
|
+
jsonRequest({ scope: "parachute:host:admin" }, { authorization: `Bearer ${bearer}` }),
|
|
620
|
+
{ db, issuer: ISSUER },
|
|
621
|
+
);
|
|
622
|
+
expect(resp.status).toBe(400);
|
|
623
|
+
const body = (await resp.json()) as { error: string };
|
|
624
|
+
expect(body.error).toBe("invalid_scope");
|
|
625
|
+
} finally {
|
|
626
|
+
db.close();
|
|
627
|
+
}
|
|
628
|
+
} finally {
|
|
629
|
+
h.cleanup();
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
// No host:auth, and scribe:transcribe is not a vault:work scope → blocked.
|
|
634
|
+
test("mints scribe:transcribe → 400 (not a vault:work scope, no host:auth)", async () => {
|
|
635
|
+
const h = makeHarness();
|
|
636
|
+
try {
|
|
637
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
638
|
+
try {
|
|
639
|
+
const bearer = await mintVaultAdminBearer(db, userId, "work");
|
|
640
|
+
const resp = await handleApiMintToken(
|
|
641
|
+
jsonRequest({ scope: "scribe:transcribe" }, { authorization: `Bearer ${bearer}` }),
|
|
642
|
+
{ db, issuer: ISSUER },
|
|
643
|
+
);
|
|
644
|
+
expect(resp.status).toBe(400);
|
|
645
|
+
const body = (await resp.json()) as { error: string };
|
|
646
|
+
expect(body.error).toBe("invalid_scope");
|
|
647
|
+
} finally {
|
|
648
|
+
db.close();
|
|
649
|
+
}
|
|
650
|
+
} finally {
|
|
651
|
+
h.cleanup();
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
test("multi-scope vault:work:read vault:work:write → 200, vault_scope=[work]", async () => {
|
|
656
|
+
const h = makeHarness();
|
|
657
|
+
try {
|
|
658
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
659
|
+
try {
|
|
660
|
+
const bearer = await mintVaultAdminBearer(db, userId, "work");
|
|
661
|
+
const resp = await handleApiMintToken(
|
|
662
|
+
jsonRequest(
|
|
663
|
+
{ scope: "vault:work:read vault:work:write" },
|
|
664
|
+
{ authorization: `Bearer ${bearer}` },
|
|
665
|
+
),
|
|
666
|
+
{ db, issuer: ISSUER },
|
|
667
|
+
);
|
|
668
|
+
expect(resp.status).toBe(200);
|
|
669
|
+
const body = (await resp.json()) as { token: string };
|
|
670
|
+
const validated = await validateAccessToken(db, body.token, ISSUER);
|
|
671
|
+
expect(validated.payload.vault_scope).toEqual(["work"]);
|
|
672
|
+
} finally {
|
|
673
|
+
db.close();
|
|
674
|
+
}
|
|
675
|
+
} finally {
|
|
676
|
+
h.cleanup();
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
// Realistic headless-runner shape: a bearer holding admin over MULTIPLE
|
|
681
|
+
// vaults composes rule 3 across them. Minting same-vault subsets of both
|
|
682
|
+
// succeeds; vault_scope collects every authorized vault name (order-
|
|
683
|
+
// insensitive). aud is first-wins (vault.work), which is fine — a
|
|
684
|
+
// multi-vault token only authenticates against its single aud, the pin is
|
|
685
|
+
// defense-in-depth.
|
|
686
|
+
test("multi-vault-admin bearer mints vault:work:read vault:other:read → 200, vault_scope=[work,other]", async () => {
|
|
687
|
+
const h = makeHarness();
|
|
688
|
+
try {
|
|
689
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
690
|
+
try {
|
|
691
|
+
const bearer = await signAccessToken(db, {
|
|
692
|
+
sub: userId,
|
|
693
|
+
scopes: ["vault:work:admin", "vault:other:admin"],
|
|
694
|
+
audience: "vault.work",
|
|
695
|
+
clientId: "parachute-hub",
|
|
696
|
+
issuer: ISSUER,
|
|
697
|
+
ttlSeconds: 3600,
|
|
698
|
+
vaultScope: ["work", "other"],
|
|
699
|
+
});
|
|
700
|
+
const resp = await handleApiMintToken(
|
|
701
|
+
jsonRequest(
|
|
702
|
+
{ scope: "vault:work:read vault:other:read" },
|
|
703
|
+
{ authorization: `Bearer ${bearer.token}` },
|
|
704
|
+
),
|
|
705
|
+
{ db, issuer: ISSUER },
|
|
706
|
+
);
|
|
707
|
+
expect(resp.status).toBe(200);
|
|
708
|
+
const body = (await resp.json()) as { token: string };
|
|
709
|
+
const validated = await validateAccessToken(db, body.token, ISSUER);
|
|
710
|
+
expect(validated.payload.aud).toBe("vault.work");
|
|
711
|
+
const pin = validated.payload.vault_scope as string[];
|
|
712
|
+
expect(pin).toContain("work");
|
|
713
|
+
expect(pin).toContain("other");
|
|
714
|
+
expect(pin).toHaveLength(2);
|
|
715
|
+
} finally {
|
|
716
|
+
db.close();
|
|
717
|
+
}
|
|
718
|
+
} finally {
|
|
719
|
+
h.cleanup();
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
// One blocked scope rejects the whole request (no partial mint).
|
|
724
|
+
test("multi-scope vault:work:read vault:other:read → 400 (one blocked → all rejected)", async () => {
|
|
725
|
+
const h = makeHarness();
|
|
726
|
+
try {
|
|
727
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
728
|
+
try {
|
|
729
|
+
const bearer = await mintVaultAdminBearer(db, userId, "work");
|
|
730
|
+
const resp = await handleApiMintToken(
|
|
731
|
+
jsonRequest(
|
|
732
|
+
{ scope: "vault:work:read vault:other:read" },
|
|
733
|
+
{ authorization: `Bearer ${bearer}` },
|
|
734
|
+
),
|
|
735
|
+
{ db, issuer: ISSUER },
|
|
736
|
+
);
|
|
737
|
+
expect(resp.status).toBe(400);
|
|
738
|
+
const body = (await resp.json()) as { error: string; error_description: string };
|
|
739
|
+
expect(body.error).toBe("invalid_scope");
|
|
740
|
+
expect(body.error_description).toContain("vault:other:read");
|
|
741
|
+
} finally {
|
|
742
|
+
db.close();
|
|
743
|
+
}
|
|
744
|
+
} finally {
|
|
745
|
+
h.cleanup();
|
|
746
|
+
}
|
|
747
|
+
});
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
describe("capability attenuation — entry gate + regression", () => {
|
|
751
|
+
test("host:auth-only bearer mints vault:work:read → 200 (rule 1, preserved)", async () => {
|
|
752
|
+
const h = makeHarness();
|
|
753
|
+
try {
|
|
754
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
755
|
+
try {
|
|
756
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER, scopeSet: "auth" });
|
|
757
|
+
const resp = await handleApiMintToken(
|
|
758
|
+
jsonRequest({ scope: "vault:work:read" }, { authorization: `Bearer ${op.token}` }),
|
|
759
|
+
{ db, issuer: ISSUER },
|
|
760
|
+
);
|
|
761
|
+
expect(resp.status).toBe(200);
|
|
762
|
+
const body = (await resp.json()) as { token: string };
|
|
763
|
+
const validated = await validateAccessToken(db, body.token, ISSUER);
|
|
764
|
+
// Pure host:auth requestable mint → no vault pin.
|
|
765
|
+
expect(validated.payload.vault_scope).toEqual([]);
|
|
766
|
+
} finally {
|
|
767
|
+
db.close();
|
|
768
|
+
}
|
|
769
|
+
} finally {
|
|
770
|
+
h.cleanup();
|
|
771
|
+
}
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
test("host:auth-only bearer mints vault:work:admin → 200 (single-consent: rule 1 now covers admin)", async () => {
|
|
775
|
+
// Single-consent change (2026-05-29): vault:<name>:admin is requestable
|
|
776
|
+
// now, so canGrant rule 1 admits it for a host:auth bearer. De-escalation
|
|
777
|
+
// from an on-box operator credential — intentional widening.
|
|
778
|
+
const h = makeHarness();
|
|
779
|
+
try {
|
|
780
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
781
|
+
try {
|
|
782
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER, scopeSet: "auth" });
|
|
783
|
+
const resp = await handleApiMintToken(
|
|
784
|
+
jsonRequest({ scope: "vault:work:admin" }, { authorization: `Bearer ${op.token}` }),
|
|
785
|
+
{ db, issuer: ISSUER },
|
|
786
|
+
);
|
|
787
|
+
expect(resp.status).toBe(200);
|
|
788
|
+
const body = (await resp.json()) as { scope: string };
|
|
789
|
+
expect(body.scope).toBe("vault:work:admin");
|
|
790
|
+
} finally {
|
|
791
|
+
db.close();
|
|
792
|
+
}
|
|
793
|
+
} finally {
|
|
794
|
+
h.cleanup();
|
|
795
|
+
}
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
test("host:admin bearer mints vault:work:admin → 200 (rule 2, PR-A preserved)", async () => {
|
|
799
|
+
const h = makeHarness();
|
|
800
|
+
try {
|
|
801
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
802
|
+
try {
|
|
803
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
804
|
+
const resp = await handleApiMintToken(
|
|
805
|
+
jsonRequest({ scope: "vault:work:admin" }, { authorization: `Bearer ${op.token}` }),
|
|
806
|
+
{ db, issuer: ISSUER },
|
|
807
|
+
);
|
|
808
|
+
expect(resp.status).toBe(200);
|
|
809
|
+
const body = (await resp.json()) as { token: string };
|
|
810
|
+
const validated = await validateAccessToken(db, body.token, ISSUER);
|
|
811
|
+
expect(validated.payload.aud).toBe("vault.work");
|
|
812
|
+
expect(validated.payload.vault_scope).toEqual(["work"]);
|
|
813
|
+
} finally {
|
|
814
|
+
db.close();
|
|
815
|
+
}
|
|
816
|
+
} finally {
|
|
817
|
+
h.cleanup();
|
|
818
|
+
}
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
// Entry gate: a bearer with no host:* and no vault-admin holds no minting
|
|
822
|
+
// authority → 403 before any per-scope check.
|
|
823
|
+
test("403 entry gate when bearer holds no minting authority", async () => {
|
|
824
|
+
const h = makeHarness();
|
|
825
|
+
try {
|
|
826
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
827
|
+
try {
|
|
828
|
+
const noAuthority = await signAccessToken(db, {
|
|
829
|
+
sub: userId,
|
|
830
|
+
scopes: ["hub:admin", "scribe:transcribe"],
|
|
831
|
+
audience: "hub",
|
|
832
|
+
clientId: "parachute-hub",
|
|
833
|
+
issuer: ISSUER,
|
|
834
|
+
ttlSeconds: 3600,
|
|
835
|
+
});
|
|
836
|
+
const resp = await handleApiMintToken(
|
|
837
|
+
jsonRequest(
|
|
838
|
+
{ scope: "vault:work:read" },
|
|
839
|
+
{ authorization: `Bearer ${noAuthority.token}` },
|
|
840
|
+
),
|
|
841
|
+
{ db, issuer: ISSUER },
|
|
842
|
+
);
|
|
843
|
+
expect(resp.status).toBe(403);
|
|
844
|
+
const body = (await resp.json()) as { error: string };
|
|
845
|
+
expect(body.error).toBe("insufficient_scope");
|
|
846
|
+
} finally {
|
|
847
|
+
db.close();
|
|
848
|
+
}
|
|
849
|
+
} finally {
|
|
850
|
+
h.cleanup();
|
|
851
|
+
}
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
// A read-only token used AS A BEARER is not minting authority → 403.
|
|
855
|
+
test("403 entry gate when bearer is a vault:work:read token (read is not minting authority)", async () => {
|
|
856
|
+
const h = makeHarness();
|
|
857
|
+
try {
|
|
858
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
859
|
+
try {
|
|
860
|
+
const readOnly = await signAccessToken(db, {
|
|
861
|
+
sub: userId,
|
|
862
|
+
scopes: ["vault:work:read"],
|
|
863
|
+
audience: "vault.work",
|
|
864
|
+
clientId: "parachute-hub",
|
|
865
|
+
issuer: ISSUER,
|
|
866
|
+
ttlSeconds: 3600,
|
|
867
|
+
vaultScope: ["work"],
|
|
868
|
+
});
|
|
869
|
+
const resp = await handleApiMintToken(
|
|
870
|
+
jsonRequest(
|
|
871
|
+
{ scope: "vault:work:read" },
|
|
872
|
+
{ authorization: `Bearer ${readOnly.token}` },
|
|
873
|
+
),
|
|
874
|
+
{ db, issuer: ISSUER },
|
|
875
|
+
);
|
|
876
|
+
expect(resp.status).toBe(403);
|
|
877
|
+
const body = (await resp.json()) as { error: string };
|
|
878
|
+
expect(body.error).toBe("insufficient_scope");
|
|
879
|
+
} finally {
|
|
880
|
+
db.close();
|
|
881
|
+
}
|
|
882
|
+
} finally {
|
|
883
|
+
h.cleanup();
|
|
884
|
+
}
|
|
885
|
+
});
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
// ── Malformed vault-shaped scope guard (defensive hygiene, audit 2026-05-28) ──
|
|
889
|
+
//
|
|
890
|
+
// A `parachute:host:auth` bearer can craft scope strings that LOOK like a
|
|
891
|
+
// named per-vault scope but slip past `isNonRequestableScope`'s strict
|
|
892
|
+
// regexes, so `canGrant` rule 1 would admit them as "requestable" and mint a
|
|
893
|
+
// junk registry row. They grant zero access today (the vault consumer rejects
|
|
894
|
+
// them), so this isn't exploitable now — the mint-time shape check is a
|
|
895
|
+
// backstop against a future consumer-normalization regression + registry
|
|
896
|
+
// hygiene. It's an input-shape check, orthogonal to authority.
|
|
897
|
+
describe("malformed vault-shaped scope rejection", () => {
|
|
898
|
+
const MALFORMED = [
|
|
899
|
+
"vault:work:ADMIN", // uppercase verb
|
|
900
|
+
"vault::admin", // empty name
|
|
901
|
+
"vault:work:read:admin", // extra segment
|
|
902
|
+
"VAULT:work:admin", // uppercase resource
|
|
903
|
+
];
|
|
904
|
+
|
|
905
|
+
for (const scope of MALFORMED) {
|
|
906
|
+
test(`host:auth bearer minting ${scope} → 400 invalid_scope (malformed)`, async () => {
|
|
907
|
+
const h = makeHarness();
|
|
908
|
+
try {
|
|
909
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
910
|
+
try {
|
|
911
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
912
|
+
const resp = await handleApiMintToken(
|
|
913
|
+
jsonRequest({ scope }, { authorization: `Bearer ${op.token}` }),
|
|
914
|
+
{ db, issuer: ISSUER },
|
|
915
|
+
);
|
|
916
|
+
expect(resp.status).toBe(400);
|
|
917
|
+
const body = (await resp.json()) as { error: string; error_description: string };
|
|
918
|
+
expect(body.error).toBe("invalid_scope");
|
|
919
|
+
expect(body.error_description).toContain("malformed vault scope");
|
|
920
|
+
expect(body.error_description).toContain(scope);
|
|
921
|
+
// No junk registry row written — the request was rejected before
|
|
922
|
+
// mint. The only `cli_mint` provenance comes from this endpoint;
|
|
923
|
+
// the operator bearer's own row is `operator` provenance, so a
|
|
924
|
+
// count of zero proves the malformed mint never landed.
|
|
925
|
+
const row = db
|
|
926
|
+
.query<{ n: number }, []>(
|
|
927
|
+
"SELECT COUNT(*) AS n FROM tokens WHERE created_via = 'cli_mint'",
|
|
928
|
+
)
|
|
929
|
+
.get();
|
|
930
|
+
expect(row?.n ?? 0).toBe(0);
|
|
931
|
+
} finally {
|
|
932
|
+
db.close();
|
|
933
|
+
}
|
|
934
|
+
} finally {
|
|
935
|
+
h.cleanup();
|
|
936
|
+
}
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// Regression: well-formed named scopes still mint (host:auth → read/write
|
|
941
|
+
// via rule 1; host:admin → admin via rule 2). The guard is shape-only.
|
|
942
|
+
test("host:auth bearer minting vault:work:read → 200 (well-formed, regression)", async () => {
|
|
943
|
+
const h = makeHarness();
|
|
944
|
+
try {
|
|
945
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
946
|
+
try {
|
|
947
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER, scopeSet: "auth" });
|
|
948
|
+
const resp = await handleApiMintToken(
|
|
949
|
+
jsonRequest({ scope: "vault:work:read" }, { authorization: `Bearer ${op.token}` }),
|
|
950
|
+
{ db, issuer: ISSUER },
|
|
951
|
+
);
|
|
952
|
+
expect(resp.status).toBe(200);
|
|
953
|
+
const body = (await resp.json()) as { scope: string };
|
|
954
|
+
expect(body.scope).toBe("vault:work:read");
|
|
955
|
+
} finally {
|
|
956
|
+
db.close();
|
|
957
|
+
}
|
|
958
|
+
} finally {
|
|
959
|
+
h.cleanup();
|
|
960
|
+
}
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
test("host:auth bearer minting vault:work:write → 200 (well-formed, regression)", async () => {
|
|
964
|
+
const h = makeHarness();
|
|
965
|
+
try {
|
|
966
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
967
|
+
try {
|
|
968
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER, scopeSet: "auth" });
|
|
969
|
+
const resp = await handleApiMintToken(
|
|
970
|
+
jsonRequest({ scope: "vault:work:write" }, { authorization: `Bearer ${op.token}` }),
|
|
971
|
+
{ db, issuer: ISSUER },
|
|
972
|
+
);
|
|
973
|
+
expect(resp.status).toBe(200);
|
|
974
|
+
const body = (await resp.json()) as { scope: string };
|
|
975
|
+
expect(body.scope).toBe("vault:work:write");
|
|
976
|
+
} finally {
|
|
977
|
+
db.close();
|
|
978
|
+
}
|
|
979
|
+
} finally {
|
|
980
|
+
h.cleanup();
|
|
981
|
+
}
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
test("host:admin bearer minting vault:work:admin → 200 (well-formed, attenuation path)", async () => {
|
|
985
|
+
const h = makeHarness();
|
|
986
|
+
try {
|
|
987
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
988
|
+
try {
|
|
989
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
990
|
+
const resp = await handleApiMintToken(
|
|
991
|
+
jsonRequest({ scope: "vault:work:admin" }, { authorization: `Bearer ${op.token}` }),
|
|
992
|
+
{ db, issuer: ISSUER },
|
|
993
|
+
);
|
|
994
|
+
expect(resp.status).toBe(200);
|
|
995
|
+
const body = (await resp.json()) as { scope: string };
|
|
996
|
+
expect(body.scope).toBe("vault:work:admin");
|
|
997
|
+
} finally {
|
|
998
|
+
db.close();
|
|
999
|
+
}
|
|
1000
|
+
} finally {
|
|
1001
|
+
h.cleanup();
|
|
1002
|
+
}
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
// Contrast with the malformed forms above: a WELL-FORMED `vault:work:admin`
|
|
1006
|
+
// clears the shape guard, and (single-consent change, 2026-05-29) now mints
|
|
1007
|
+
// 200 via canGrant rule 1 for a host:auth bearer. The malformed forms are
|
|
1008
|
+
// rejected by the shape guard BEFORE canGrant; this one passes the guard
|
|
1009
|
+
// and is admitted.
|
|
1010
|
+
test("host:auth-only bearer minting well-formed vault:work:admin → 200 (clears shape guard, mints)", async () => {
|
|
1011
|
+
const h = makeHarness();
|
|
1012
|
+
try {
|
|
1013
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
1014
|
+
try {
|
|
1015
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER, scopeSet: "auth" });
|
|
1016
|
+
const resp = await handleApiMintToken(
|
|
1017
|
+
jsonRequest({ scope: "vault:work:admin" }, { authorization: `Bearer ${op.token}` }),
|
|
1018
|
+
{ db, issuer: ISSUER },
|
|
1019
|
+
);
|
|
1020
|
+
expect(resp.status).toBe(200);
|
|
1021
|
+
const body = (await resp.json()) as { scope: string };
|
|
1022
|
+
expect(body.scope).toBe("vault:work:admin");
|
|
1023
|
+
} finally {
|
|
1024
|
+
db.close();
|
|
1025
|
+
}
|
|
1026
|
+
} finally {
|
|
1027
|
+
h.cleanup();
|
|
1028
|
+
}
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
// Non-vault scopes are entirely unaffected by the shape guard.
|
|
1032
|
+
test("host:auth bearer minting scribe:transcribe → 200 (non-vault, unaffected)", async () => {
|
|
1033
|
+
const h = makeHarness();
|
|
1034
|
+
try {
|
|
1035
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
1036
|
+
try {
|
|
1037
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER, scopeSet: "auth" });
|
|
1038
|
+
const resp = await handleApiMintToken(
|
|
1039
|
+
jsonRequest({ scope: "scribe:transcribe" }, { authorization: `Bearer ${op.token}` }),
|
|
1040
|
+
{ db, issuer: ISSUER },
|
|
1041
|
+
);
|
|
1042
|
+
expect(resp.status).toBe(200);
|
|
1043
|
+
const body = (await resp.json()) as { scope: string };
|
|
1044
|
+
expect(body.scope).toBe("scribe:transcribe");
|
|
1045
|
+
} finally {
|
|
1046
|
+
db.close();
|
|
1047
|
+
}
|
|
1048
|
+
} finally {
|
|
1049
|
+
h.cleanup();
|
|
1050
|
+
}
|
|
1051
|
+
});
|
|
1052
|
+
});
|
|
1053
|
+
|
|
366
1054
|
test("405 on non-POST", async () => {
|
|
367
1055
|
const h = makeHarness();
|
|
368
1056
|
try {
|