@openparachute/hub 0.6.1-rc.3 → 0.6.1-rc.4
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/package.json +1 -1
- package/src/__tests__/account-home-ui.test.ts +6 -4
- package/src/__tests__/account-vault-token.test.ts +8 -5
- package/src/__tests__/oauth-handlers.test.ts +64 -55
- package/src/__tests__/users.test.ts +9 -5
- package/src/account-home-ui.ts +6 -1
- package/src/account-vault-token.ts +15 -14
- package/src/oauth-handlers.ts +8 -6
- package/src/scope-explanations.ts +7 -4
- package/src/users.ts +22 -15
package/package.json
CHANGED
|
@@ -310,7 +310,9 @@ describe("renderAccountHome", () => {
|
|
|
310
310
|
expect(html).not.toContain('data-testid="mint-verb-write"');
|
|
311
311
|
});
|
|
312
312
|
|
|
313
|
-
test("mint affordance —
|
|
313
|
+
test("mint affordance — offers the admin verb when the user holds it", () => {
|
|
314
|
+
// 2026-05-30: assigned users hold read/write/admin on their vault, so the
|
|
315
|
+
// mint form offers admin (the live `vaultVerbsForUserVault` returns it).
|
|
314
316
|
const html = renderAccountHome({
|
|
315
317
|
username: "alice",
|
|
316
318
|
assignedVaults: ["work"],
|
|
@@ -319,10 +321,10 @@ describe("renderAccountHome", () => {
|
|
|
319
321
|
isFirstAdmin: false,
|
|
320
322
|
csrfToken: CSRF,
|
|
321
323
|
twoFactorEnabled: false,
|
|
322
|
-
mintableVerbs: { work: ["read", "write"] },
|
|
324
|
+
mintableVerbs: { work: ["read", "write", "admin"] },
|
|
323
325
|
});
|
|
324
|
-
expect(html).
|
|
325
|
-
expect(html).
|
|
326
|
+
expect(html).toContain('value="admin"');
|
|
327
|
+
expect(html).toContain('data-testid="mint-verb-admin"');
|
|
326
328
|
});
|
|
327
329
|
|
|
328
330
|
test("mint affordance — absent when no mintable verbs (admin / no-vault / unmapped role)", () => {
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* - UNassigned vault → 403 (cannot mint for a vault not in the
|
|
11
11
|
* user's `user_vaults` assignment — blocks
|
|
12
12
|
* cross-vault).
|
|
13
|
-
* - `admin` verb →
|
|
13
|
+
* - `admin` verb → minted for an ASSIGNED vault (2026-05-30:
|
|
14
|
+
* assigned users hold full vault authority).
|
|
14
15
|
* - Broader/garbage verb → rejected.
|
|
15
16
|
* - First admin → 403 (no `user_vaults` rows → unrestricted
|
|
16
17
|
* admins use the SPA path, not this one).
|
|
@@ -245,17 +246,19 @@ describe("handleAccountVaultTokenPost — authorization gates (adversarial)", ()
|
|
|
245
246
|
expect(res.status).toBe(403);
|
|
246
247
|
});
|
|
247
248
|
|
|
248
|
-
test("admin verb
|
|
249
|
+
test("200 mints vault:<name>:admin when verb=admin (assigned users hold admin, 2026-05-30)", async () => {
|
|
249
250
|
const { cookie, csrfToken } = await seedFriend(["work"]);
|
|
250
251
|
const res = await handleAccountVaultTokenPost(
|
|
251
252
|
mintReq("work", { cookie, csrfToken, verb: "admin" }),
|
|
252
253
|
"work",
|
|
253
254
|
deps(),
|
|
254
255
|
);
|
|
255
|
-
expect(res.status).toBe(
|
|
256
|
+
expect(res.status).toBe(200);
|
|
256
257
|
const html = await res.text();
|
|
257
|
-
|
|
258
|
-
|
|
258
|
+
const token = html.match(/data-testid="minted-token-value">([^<]+)</)?.[1] as string;
|
|
259
|
+
const validated = await validateAccessToken(harness.db, token, ISSUER);
|
|
260
|
+
const scopeClaim = (validated.payload as { scope?: string }).scope ?? "";
|
|
261
|
+
expect(scopeClaim.split(/\s+/)).toEqual(["vault:work:admin"]);
|
|
259
262
|
});
|
|
260
263
|
|
|
261
264
|
test("a garbage / broader verb is rejected", async () => {
|
|
@@ -8542,12 +8542,12 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
|
|
|
8542
8542
|
// Test 9 — privesc: read/write assigned (non-owner) user requests
|
|
8543
8543
|
// vault:work:admin + vault:work:write → admin DROPPED, token has write only,
|
|
8544
8544
|
// recorded grant lacks admin.
|
|
8545
|
-
test("[9] non-owner
|
|
8545
|
+
test("[9] non-owner ASSIGNED to the vault requests admin+write → BOTH granted (assigned users hold admin)", async () => {
|
|
8546
8546
|
const { db, cleanup } = await makeDb();
|
|
8547
8547
|
try {
|
|
8548
8548
|
await createUser(db, "owner", "pw"); // first user = owner; consumes the admin slot.
|
|
8549
8549
|
const friend = await createUser(db, "friend", "pw", { allowMulti: true });
|
|
8550
|
-
setUserVaults(db, friend.id, ["work"]); // role=write → verbs [read, write]
|
|
8550
|
+
setUserVaults(db, friend.id, ["work"]); // role=write → verbs [read, write, admin]
|
|
8551
8551
|
const session = createSession(db, { userId: friend.id });
|
|
8552
8552
|
const reg = registerClient(db, {
|
|
8553
8553
|
redirectUris: ["https://app.example/cb"],
|
|
@@ -8564,20 +8564,20 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
|
|
|
8564
8564
|
expect(consentRes.status).toBe(302);
|
|
8565
8565
|
const code = new URL(consentRes.headers.get("location") ?? "").searchParams.get("code");
|
|
8566
8566
|
const { scope, aud } = await redeemToScopeAud(db, code ?? "", reg.client.clientId, verifier);
|
|
8567
|
-
// admin
|
|
8568
|
-
expect(scope.split(" ").sort()).toEqual(["vault:work:write"]);
|
|
8567
|
+
// Assigned user holds admin on their own vault → BOTH kept (2026-05-30 policy).
|
|
8568
|
+
expect(scope.split(" ").sort()).toEqual(["vault:work:admin", "vault:work:write"]);
|
|
8569
8569
|
expect(aud).toBe("vault.work");
|
|
8570
|
-
// Recorded grant
|
|
8570
|
+
// Recorded grant includes admin.
|
|
8571
8571
|
const grant = findGrant(db, friend.id, reg.client.clientId);
|
|
8572
8572
|
expect(grant?.scopes).toContain("vault:work:write");
|
|
8573
|
-
expect(grant?.scopes).
|
|
8573
|
+
expect(grant?.scopes).toContain("vault:work:admin");
|
|
8574
8574
|
} finally {
|
|
8575
8575
|
cleanup();
|
|
8576
8576
|
}
|
|
8577
8577
|
});
|
|
8578
8578
|
|
|
8579
8579
|
// Test 10 — non-owner admin-ONLY request → REFUSED (clear error), no token.
|
|
8580
|
-
test("[10] non-owner admin-only request →
|
|
8580
|
+
test("[10] non-owner assigned, admin-only request → GRANTED (holds admin on their vault)", async () => {
|
|
8581
8581
|
const { db, cleanup } = await makeDb();
|
|
8582
8582
|
try {
|
|
8583
8583
|
await createUser(db, "owner", "pw");
|
|
@@ -8588,7 +8588,7 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
|
|
|
8588
8588
|
redirectUris: ["https://app.example/cb"],
|
|
8589
8589
|
status: "approved",
|
|
8590
8590
|
});
|
|
8591
|
-
const { challenge } = makePkce();
|
|
8591
|
+
const { verifier, challenge } = makePkce();
|
|
8592
8592
|
const consentRes = await submitConsent(
|
|
8593
8593
|
db,
|
|
8594
8594
|
session.id,
|
|
@@ -8596,14 +8596,17 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
|
|
|
8596
8596
|
"vault:work:admin",
|
|
8597
8597
|
challenge,
|
|
8598
8598
|
);
|
|
8599
|
-
//
|
|
8599
|
+
// Assigned user holds admin on their vault → minted, not refused.
|
|
8600
8600
|
expect(consentRes.status).toBe(302);
|
|
8601
8601
|
const loc = new URL(consentRes.headers.get("location") ?? "");
|
|
8602
8602
|
expect(loc.origin + loc.pathname).toBe("https://app.example/cb");
|
|
8603
|
-
|
|
8604
|
-
expect(
|
|
8605
|
-
|
|
8606
|
-
expect(
|
|
8603
|
+
const code = loc.searchParams.get("code");
|
|
8604
|
+
expect(code).toBeTruthy();
|
|
8605
|
+
const { scope, aud } = await redeemToScopeAud(db, code ?? "", reg.client.clientId, verifier);
|
|
8606
|
+
expect(scope.split(" ")).toEqual(["vault:work:admin"]);
|
|
8607
|
+
expect(aud).toBe("vault.work");
|
|
8608
|
+
const grant = findGrant(db, friend.id, reg.client.clientId);
|
|
8609
|
+
expect(grant?.scopes).toContain("vault:work:admin");
|
|
8607
8610
|
} finally {
|
|
8608
8611
|
cleanup();
|
|
8609
8612
|
}
|
|
@@ -8611,7 +8614,7 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
|
|
|
8611
8614
|
|
|
8612
8615
|
// Test 11 — non-owner unnamed vault:admin + picks assigned vault → after
|
|
8613
8616
|
// narrowing, admin dropped (cap runs post-narrow).
|
|
8614
|
-
test("[11] non-owner unnamed vault:admin + picks assigned vault → admin
|
|
8617
|
+
test("[11] non-owner unnamed vault:admin + picks assigned vault → admin KEPT post-narrow", async () => {
|
|
8615
8618
|
const { db, cleanup } = await makeDb();
|
|
8616
8619
|
try {
|
|
8617
8620
|
await createUser(db, "owner", "pw");
|
|
@@ -8632,16 +8635,16 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
|
|
|
8632
8635
|
challenge,
|
|
8633
8636
|
{ vault_pick: "work" },
|
|
8634
8637
|
);
|
|
8635
|
-
// narrowVaultScopes → vault:work:admin + vault:work:write; cap
|
|
8636
|
-
//
|
|
8638
|
+
// narrowVaultScopes → vault:work:admin + vault:work:write; cap KEEPS
|
|
8639
|
+
// both (assigned user holds admin on their picked vault) → mints both.
|
|
8637
8640
|
expect(consentRes.status).toBe(302);
|
|
8638
8641
|
const code = new URL(consentRes.headers.get("location") ?? "").searchParams.get("code");
|
|
8639
8642
|
expect(code).toBeTruthy();
|
|
8640
8643
|
const { scope } = await redeemToScopeAud(db, code ?? "", reg.client.clientId, verifier);
|
|
8641
|
-
expect(scope.split(" ").sort()).toEqual(["vault:work:write"]);
|
|
8644
|
+
expect(scope.split(" ").sort()).toEqual(["vault:work:admin", "vault:work:write"]);
|
|
8642
8645
|
const grant = findGrant(db, friend.id, reg.client.clientId);
|
|
8643
8646
|
expect(grant?.scopes).toContain("vault:work:write");
|
|
8644
|
-
expect(grant?.scopes).
|
|
8647
|
+
expect(grant?.scopes).toContain("vault:work:admin");
|
|
8645
8648
|
} finally {
|
|
8646
8649
|
cleanup();
|
|
8647
8650
|
}
|
|
@@ -8770,35 +8773,41 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
|
|
|
8770
8773
|
// issueAuthCodeRedirect with an admin scope via ANY path (skip-consent /
|
|
8771
8774
|
// same-hub / consent). Assert no grants row ever contains an un-held admin
|
|
8772
8775
|
// verb across each path.
|
|
8773
|
-
test("[15] bypass-proof: no mint path
|
|
8776
|
+
test("[15] bypass-proof: no mint path grants admin on a vault the user is NOT assigned", async () => {
|
|
8774
8777
|
const { db, cleanup } = await makeDb();
|
|
8775
8778
|
try {
|
|
8776
8779
|
await createUser(db, "owner", "pw");
|
|
8777
8780
|
const friend = await createUser(db, "friend", "pw", { allowMulti: true });
|
|
8778
|
-
|
|
8781
|
+
// Assigned to "work" only → holds work:read/write/admin, but NOT "other"
|
|
8782
|
+
// (which exists in CAP_MANIFEST). "other" is the un-held boundary.
|
|
8783
|
+
setUserVaults(db, friend.id, ["work"]);
|
|
8779
8784
|
const session = createSession(db, { userId: friend.id });
|
|
8780
8785
|
|
|
8781
|
-
// Path A — consent-submit
|
|
8782
|
-
//
|
|
8786
|
+
// Path A — consent-submit admin on the UNASSIGNED "other" → the cap
|
|
8787
|
+
// empties the set (friend doesn't hold it) → invalid_scope refusal, no
|
|
8788
|
+
// grant. (Granting admin on the user's OWN assigned vault is exercised by
|
|
8789
|
+
// [9]/[10]; here we isolate the un-held boundary.)
|
|
8783
8790
|
const regConsent = registerClient(db, {
|
|
8784
8791
|
redirectUris: ["https://app.example/cb"],
|
|
8785
8792
|
status: "approved",
|
|
8786
8793
|
});
|
|
8787
8794
|
{
|
|
8788
8795
|
const { challenge } = makePkce();
|
|
8789
|
-
await submitConsent(
|
|
8796
|
+
const res = await submitConsent(
|
|
8790
8797
|
db,
|
|
8791
8798
|
session.id,
|
|
8792
8799
|
regConsent.client.clientId,
|
|
8793
|
-
"vault:
|
|
8800
|
+
"vault:other:admin",
|
|
8794
8801
|
challenge,
|
|
8795
8802
|
);
|
|
8796
|
-
|
|
8797
|
-
|
|
8803
|
+
// The consent-submit assignment gate rejects a request naming a vault
|
|
8804
|
+
// the user isn't assigned (400) — refused before any mint, no grant.
|
|
8805
|
+
expect(res.status).toBe(400);
|
|
8806
|
+
expect(findGrant(db, friend.id, regConsent.client.clientId)).toBeNull();
|
|
8798
8807
|
}
|
|
8799
8808
|
|
|
8800
|
-
// Path B — same-hub client requesting admin
|
|
8801
|
-
// blocks the silent path); no grant
|
|
8809
|
+
// Path B — same-hub client requesting admin on the UNASSIGNED "other" →
|
|
8810
|
+
// consent renders (admin gate blocks the silent path); no grant with it.
|
|
8802
8811
|
const regSameHub = registerClient(db, {
|
|
8803
8812
|
redirectUris: ["https://app.example/cb"],
|
|
8804
8813
|
status: "approved",
|
|
@@ -8815,7 +8824,7 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
|
|
|
8815
8824
|
response_type: "code",
|
|
8816
8825
|
code_challenge: challenge,
|
|
8817
8826
|
code_challenge_method: "S256",
|
|
8818
|
-
scope: "vault:
|
|
8827
|
+
scope: "vault:other:admin",
|
|
8819
8828
|
}),
|
|
8820
8829
|
{ headers: { cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, TTL_S)}` } },
|
|
8821
8830
|
),
|
|
@@ -8823,22 +8832,23 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
|
|
|
8823
8832
|
);
|
|
8824
8833
|
expect(res.status).toBe(200); // consent, not silent-mint
|
|
8825
8834
|
const g = findGrant(db, friend.id, regSameHub.client.clientId);
|
|
8826
|
-
expect(g?.scopes ?? []).not.toContain("vault:
|
|
8835
|
+
expect(g?.scopes ?? []).not.toContain("vault:other:admin");
|
|
8827
8836
|
}
|
|
8828
8837
|
|
|
8829
|
-
// Path C — skip-consent:
|
|
8830
|
-
//
|
|
8831
|
-
//
|
|
8832
|
-
//
|
|
8838
|
+
// Path C — skip-consent: a grant row seeded with an UNASSIGNED-vault admin
|
|
8839
|
+
// verb. The cap at issueAuthCodeRedirect is the authority, not the grant
|
|
8840
|
+
// row — request only the held write scope (skip-consent fires) and the
|
|
8841
|
+
// minted token carries no other:admin (the un-held verb never rides along).
|
|
8833
8842
|
const regSkip = registerClient(db, {
|
|
8834
8843
|
redirectUris: ["https://app.example/cb"],
|
|
8835
8844
|
status: "approved",
|
|
8836
8845
|
});
|
|
8837
|
-
recordGrant(db, friend.id, regSkip.client.clientId, [
|
|
8846
|
+
recordGrant(db, friend.id, regSkip.client.clientId, [
|
|
8847
|
+
"vault:work:write",
|
|
8848
|
+
"vault:other:admin",
|
|
8849
|
+
]);
|
|
8838
8850
|
{
|
|
8839
8851
|
const { verifier, challenge } = makePkce();
|
|
8840
|
-
// Request only the held write scope so skip-consent fires (covered by
|
|
8841
|
-
// the seeded grant) and routes through issueAuthCodeRedirect.
|
|
8842
8852
|
const res = handleAuthorizeGet(
|
|
8843
8853
|
db,
|
|
8844
8854
|
new Request(
|
|
@@ -8857,37 +8867,36 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
|
|
|
8857
8867
|
expect(res.status).toBe(302); // skip-consent silent mint of the held scope
|
|
8858
8868
|
const code = new URL(res.headers.get("location") ?? "").searchParams.get("code");
|
|
8859
8869
|
const { scope } = await redeemToScopeAud(db, code ?? "", regSkip.client.clientId, verifier);
|
|
8860
|
-
expect(scope.split(" ")).not.toContain("vault:
|
|
8870
|
+
expect(scope.split(" ")).not.toContain("vault:other:admin");
|
|
8861
8871
|
}
|
|
8862
8872
|
} finally {
|
|
8863
8873
|
cleanup();
|
|
8864
8874
|
}
|
|
8865
8875
|
});
|
|
8866
8876
|
|
|
8867
|
-
// Reviewer fold (security-relevant): test 15 path C only requested
|
|
8868
|
-
//
|
|
8869
|
-
//
|
|
8870
|
-
//
|
|
8871
|
-
// skip-consent gate fires (the requested
|
|
8872
|
-
//
|
|
8873
|
-
//
|
|
8874
|
-
//
|
|
8875
|
-
//
|
|
8876
|
-
|
|
8877
|
-
test("[15b] non-owner requests admin DIRECTLY against a POISONED-grant client → cap refuses, no admin token", async () => {
|
|
8877
|
+
// Reviewer fold (security-relevant): test 15 path C only requested the held
|
|
8878
|
+
// `write` scope, proving the cap doesn't re-record an un-held verb. This case
|
|
8879
|
+
// requests admin on the UNASSIGNED "other" vault DIRECTLY against a client
|
|
8880
|
+
// whose grant row already lists `vault:other:admin` (poisoned). The
|
|
8881
|
+
// skip-consent gate fires (the requested scope IS covered by the poisoned
|
|
8882
|
+
// grant) and routes through issueAuthCodeRedirect — where the CAP, not the
|
|
8883
|
+
// grant lookup, drops the un-held verb. Admin-only request → caps to EMPTY →
|
|
8884
|
+
// invalid_scope refusal, no code, no token. Pins that the cap-before-
|
|
8885
|
+
// issueAuthCode invariant holds even when a stale grant satisfies coverage.
|
|
8886
|
+
test("[15b] non-owner requests admin on an UNASSIGNED vault against a POISONED-grant client → cap refuses", async () => {
|
|
8878
8887
|
const { db, cleanup } = await makeDb();
|
|
8879
8888
|
try {
|
|
8880
8889
|
await createUser(db, "owner", "pw");
|
|
8881
8890
|
const friend = await createUser(db, "friend", "pw", { allowMulti: true });
|
|
8882
|
-
setUserVaults(db, friend.id, ["work"]); //
|
|
8891
|
+
setUserVaults(db, friend.id, ["work"]); // holds work:* (incl. admin) — NOT "other"
|
|
8883
8892
|
const session = createSession(db, { userId: friend.id });
|
|
8884
8893
|
const reg = registerClient(db, {
|
|
8885
8894
|
redirectUris: ["https://app.example/cb"],
|
|
8886
8895
|
status: "approved",
|
|
8887
8896
|
});
|
|
8888
|
-
// Poisoned grant: already lists vault:
|
|
8889
|
-
// coverage check would pass for a direct admin request).
|
|
8890
|
-
recordGrant(db, friend.id, reg.client.clientId, ["vault:work:write", "vault:
|
|
8897
|
+
// Poisoned grant: already lists vault:other:admin (so the skip-consent
|
|
8898
|
+
// coverage check would pass for a direct other:admin request).
|
|
8899
|
+
recordGrant(db, friend.id, reg.client.clientId, ["vault:work:write", "vault:other:admin"]);
|
|
8891
8900
|
|
|
8892
8901
|
const { challenge } = makePkce();
|
|
8893
8902
|
const res = handleAuthorizeGet(
|
|
@@ -8899,14 +8908,14 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
|
|
|
8899
8908
|
response_type: "code",
|
|
8900
8909
|
code_challenge: challenge,
|
|
8901
8910
|
code_challenge_method: "S256",
|
|
8902
|
-
scope: "vault:
|
|
8911
|
+
scope: "vault:other:admin",
|
|
8903
8912
|
state: "poisoned-direct",
|
|
8904
8913
|
}),
|
|
8905
8914
|
{ headers: { cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, TTL_S)}` } },
|
|
8906
8915
|
),
|
|
8907
8916
|
capDeps,
|
|
8908
8917
|
);
|
|
8909
|
-
// The cap leaves an EMPTY set (
|
|
8918
|
+
// The cap leaves an EMPTY set (friend isn't assigned "other") → refuse.
|
|
8910
8919
|
// 302 to redirect_uri with invalid_scope and NO code — no token minted.
|
|
8911
8920
|
expect(res.status).toBe(302);
|
|
8912
8921
|
const loc = new URL(res.headers.get("location") ?? "");
|
|
@@ -740,10 +740,13 @@ describe("getFirstAdminId / isFirstAdmin", () => {
|
|
|
740
740
|
|
|
741
741
|
describe("vaultVerbsForRole / vaultVerbsForUserVault (friend token-mint cap)", () => {
|
|
742
742
|
test("vaultVerbsForRole maps roles to verbs, fails closed on unknown", () => {
|
|
743
|
-
|
|
743
|
+
// Assigned users (role=write, today's default) hold FULL vault authority
|
|
744
|
+
// incl. admin (2026-05-30 policy: any assigned user gets admin). A
|
|
745
|
+
// deliberate read-only assignment stays read-only. Unknown role strings
|
|
746
|
+
// (including the literal "admin") map to [] — only the recognised roles
|
|
747
|
+
// grant verbs; never silently default to write.
|
|
748
|
+
expect(vaultVerbsForRole("write")).toEqual(["read", "write", "admin"]);
|
|
744
749
|
expect(vaultVerbsForRole("read")).toEqual(["read"]);
|
|
745
|
-
// Unknown / future roles grant NO mintable verb — never silently
|
|
746
|
-
// default to write. `admin` is explicitly NOT a mintable role here.
|
|
747
750
|
expect(vaultVerbsForRole("admin")).toEqual([]);
|
|
748
751
|
expect(vaultVerbsForRole("owner")).toEqual([]);
|
|
749
752
|
expect(vaultVerbsForRole("")).toEqual([]);
|
|
@@ -757,8 +760,9 @@ describe("vaultVerbsForRole / vaultVerbsForUserVault (friend token-mint cap)", (
|
|
|
757
760
|
allowMulti: true,
|
|
758
761
|
assignedVaults: ["work"],
|
|
759
762
|
});
|
|
760
|
-
// createUser/setUserVaults insert role='write' today → read+write
|
|
761
|
-
|
|
763
|
+
// createUser/setUserVaults insert role='write' today → read+write+admin
|
|
764
|
+
// (assigned users hold full vault authority, 2026-05-30 policy).
|
|
765
|
+
expect(vaultVerbsForUserVault(db, friend.id, "work")).toEqual(["read", "write", "admin"]);
|
|
762
766
|
} finally {
|
|
763
767
|
cleanup();
|
|
764
768
|
}
|
package/src/account-home-ui.ts
CHANGED
|
@@ -384,7 +384,12 @@ function renderTokenMintBlock(
|
|
|
384
384
|
const radios = verbs
|
|
385
385
|
.map((verb, i) => {
|
|
386
386
|
const checked = i === 0 ? " checked" : "";
|
|
387
|
-
const label =
|
|
387
|
+
const label =
|
|
388
|
+
verb === "read"
|
|
389
|
+
? "Read-only"
|
|
390
|
+
: verb === "admin"
|
|
391
|
+
? "Full (read, write, rotate tokens + config)"
|
|
392
|
+
: "Read + write";
|
|
388
393
|
return `
|
|
389
394
|
<label class="mint-verb-option">
|
|
390
395
|
<input type="radio" name="verb" value="${verb}"${checked}
|
|
@@ -21,12 +21,10 @@
|
|
|
21
21
|
* `vaultVerbsForUserVault` (which returns `null` for an unassigned
|
|
22
22
|
* vault), NOT from the verb-blind `assignedVaults` array.
|
|
23
23
|
* 3. **Scope cap.** The requested verb MUST be one the user's assignment
|
|
24
|
-
* role permits
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* `admin` is not in the form's vocabulary at all and is rejected at the
|
|
29
|
-
* parse step as an invalid verb.
|
|
24
|
+
* role permits. As of 2026-05-30 an assigned user holds the full set
|
|
25
|
+
* `["read", "write", "admin"]` on their vault (`vaultVerbsForUserVault`),
|
|
26
|
+
* so this surface mints admin tokens too — consistent with the OAuth
|
|
27
|
+
* flow. A verb outside the held set, or for an unassigned vault, → 403.
|
|
30
28
|
*
|
|
31
29
|
* The first admin (unrestricted, empty `assignedVaults`) has no `user_vaults`
|
|
32
30
|
* rows, so gate 2 returns `null` for every vault and the admin gets a 403
|
|
@@ -81,7 +79,7 @@ import { type VaultVerb, getUserById, isFirstAdmin, vaultVerbsForUserVault } fro
|
|
|
81
79
|
/** Matches the manifest vault-name validator + `/admin/vault-admin-token`. */
|
|
82
80
|
const VAULT_NAME_RE = /^[a-zA-Z0-9_-]+$/;
|
|
83
81
|
/** Verbs this surface will ever mint. `admin` is deliberately absent. */
|
|
84
|
-
const ALLOWED_VERBS: readonly VaultVerb[] = ["read", "write"];
|
|
82
|
+
const ALLOWED_VERBS: readonly VaultVerb[] = ["read", "write", "admin"];
|
|
85
83
|
/** client_id stamped on the minted JWT + registry row. */
|
|
86
84
|
const ACCOUNT_VAULT_TOKEN_CLIENT_ID = "parachute-account";
|
|
87
85
|
|
|
@@ -199,14 +197,15 @@ export async function handleAccountVaultTokenPost(
|
|
|
199
197
|
return renderHome(400, { mintError: `"${vaultName}" is not a valid vault name.` });
|
|
200
198
|
}
|
|
201
199
|
|
|
202
|
-
// Verb parse — must be
|
|
203
|
-
// else
|
|
204
|
-
//
|
|
200
|
+
// Verb parse — must be one of read/write/admin (this surface's vocabulary).
|
|
201
|
+
// Anything else is rejected here, well before authority is even consulted;
|
|
202
|
+
// the per-vault authority cap (gate 3 below) then drops verbs the user
|
|
203
|
+
// doesn't actually hold.
|
|
205
204
|
const verbRaw = form.get("verb");
|
|
206
205
|
const verb = typeof verbRaw === "string" ? verbRaw : "";
|
|
207
206
|
if (!ALLOWED_VERBS.includes(verb as VaultVerb)) {
|
|
208
207
|
return renderHome(400, {
|
|
209
|
-
mintError: "Pick an access level (read or
|
|
208
|
+
mintError: "Pick an access level (read, write, or admin).",
|
|
210
209
|
});
|
|
211
210
|
}
|
|
212
211
|
const requestedVerb = verb as VaultVerb;
|
|
@@ -217,9 +216,11 @@ export async function handleAccountVaultTokenPost(
|
|
|
217
216
|
// (fail-closed for an unknown role): 403.
|
|
218
217
|
// - [...] → the verbs the assignment role permits. The requested verb
|
|
219
218
|
// must be in this set (gate 3): else 403.
|
|
220
|
-
// This is the cap to the user's actual authority — it blocks minting for
|
|
221
|
-
//
|
|
222
|
-
//
|
|
219
|
+
// This is the cap to the user's actual authority — it blocks minting for an
|
|
220
|
+
// unassigned vault or a verb the role doesn't grant. Assigned users hold
|
|
221
|
+
// read/write/admin on their vault (2026-05-30), so admin mints for an
|
|
222
|
+
// assigned vault; the cap still refuses admin (and everything else) for a
|
|
223
|
+
// vault the user isn't assigned (`allowedForUser === null`).
|
|
223
224
|
const allowedForUser = vaultVerbsForUserVault(deps.db, user.id, vaultName);
|
|
224
225
|
if (allowedForUser === null) {
|
|
225
226
|
return renderHome(403, {
|
package/src/oauth-handlers.ts
CHANGED
|
@@ -1193,12 +1193,14 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
|
|
|
1193
1193
|
* - The authority source of truth today is `isFirstAdmin` for owner-wide
|
|
1194
1194
|
* authority and `user_vaults.role` (via `vaultVerbsForRole`) for assigned
|
|
1195
1195
|
* users.
|
|
1196
|
-
* - `vaultVerbsForRole`
|
|
1197
|
-
*
|
|
1198
|
-
*
|
|
1199
|
-
*
|
|
1200
|
-
*
|
|
1201
|
-
*
|
|
1196
|
+
* - `vaultVerbsForRole` maps write→[read,write,admin] (2026-05-30: any
|
|
1197
|
+
* assigned user holds FULL vault authority, incl. admin), read→[read],
|
|
1198
|
+
* unknown→[]. This helper reads the held verb set and admits ONLY held
|
|
1199
|
+
* verbs — no hardcoded allow/deny of admin. So an assigned user gets
|
|
1200
|
+
* `vault:<their-vault>:admin`, while a user gets NOTHING for a vault they
|
|
1201
|
+
* aren't assigned (held=null → every verb dropped). The cap is the
|
|
1202
|
+
* forward-compatible single source: it admitted admin automatically the
|
|
1203
|
+
* moment the role mapping changed — no edit here was needed.
|
|
1202
1204
|
* - Applied inside `issueAuthCodeRedirect` (the single choke-point ALL mint
|
|
1203
1205
|
* paths funnel through: consent-submit, skip-consent, and same-hub
|
|
1204
1206
|
* auto-trust), the CAPPED set is what gets both recorded (`recordGrant`)
|
|
@@ -152,10 +152,13 @@ export const NON_REQUESTABLE_SCOPES: ReadonlySet<string> = new Set([
|
|
|
152
152
|
* enforced at the shared mint choke-point (`capScopesToUserAuthority` applied
|
|
153
153
|
* inside `issueAuthCodeRedirect` in `oauth-handlers.ts`): an OAuth flow caps
|
|
154
154
|
* named vault verbs to those the consenting user actually holds on that vault.
|
|
155
|
-
* `vaultVerbsForRole`
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
155
|
+
* `vaultVerbsForRole` returns admin for an assigned user (2026-05-30: any
|
|
156
|
+
* assigned user holds full vault authority on their own vault), so a non-owner
|
|
157
|
+
* can delegate `vault:<their-vault>:admin` to their client. The cap still
|
|
158
|
+
* drops admin (and every verb) for a vault the user is NOT assigned to
|
|
159
|
+
* (held=null), and an admin-only request the cap empties is refused outright
|
|
160
|
+
* (never minted as a zero-scope token). The hub owner (isFirstAdmin) holds
|
|
161
|
+
* admin everywhere by construction.
|
|
159
162
|
*
|
|
160
163
|
* `vault:<name>:admin` also remains mintable by operator-proving local paths,
|
|
161
164
|
* all of which require already-established authority:
|
package/src/users.ts
CHANGED
|
@@ -146,30 +146,37 @@ function readVaultsForUser(db: Database, userId: string): string[] {
|
|
|
146
146
|
|
|
147
147
|
/**
|
|
148
148
|
* The per-vault verbs a `user_vaults.role` grants. The schema's `role`
|
|
149
|
-
* column is `TEXT NOT NULL DEFAULT 'write'
|
|
150
|
-
*
|
|
151
|
-
* `
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
149
|
+
* column is `TEXT NOT NULL DEFAULT 'write'`; today every assignment is created
|
|
150
|
+
* with `role = 'write'`. This is the single place the verb-cap lives, so the
|
|
151
|
+
* OAuth mint cap (`capScopesToUserAuthority`) and the `/account` mint UI both
|
|
152
|
+
* read authority from here.
|
|
153
|
+
*
|
|
154
|
+
* **Assigned users hold FULL vault authority (read + write + admin)** as of
|
|
155
|
+
* 2026-05-30 (Aaron's call: "any assigned user gets admin"). The point of the
|
|
156
|
+
* multi-user flow is that someone given a vault — owned or shared — can connect
|
|
157
|
+
* their own client (e.g. Claude MCP) to it and grant everything they'd want,
|
|
158
|
+
* including `vault:<name>:admin` (token creation + config). Owner-vs-shared is
|
|
159
|
+
* NOT distinguished today; a shared user gets admin too (explicit trade-off).
|
|
155
160
|
*
|
|
156
161
|
* Mapping:
|
|
157
|
-
* - `write` (today's
|
|
158
|
-
* - `read`
|
|
162
|
+
* - `write` (today's default) → `["read", "write", "admin"]`
|
|
163
|
+
* - `read` (forward-compat) → `["read"]` — a *deliberate* read-only
|
|
164
|
+
* assignment stays read-only even under the any-assigned-user-gets-admin
|
|
165
|
+
* policy. Not created by any flow today.
|
|
159
166
|
* - anything else (unknown role) → `[]` — fail closed. An unrecognised
|
|
160
167
|
* role grants no minting authority rather than silently defaulting to
|
|
161
168
|
* write. (Defense-in-depth: a hand-edited / future row with a role this
|
|
162
|
-
* code doesn't understand should not be treated as broad
|
|
169
|
+
* code doesn't understand should not be treated as broad.)
|
|
163
170
|
*
|
|
164
|
-
*
|
|
165
|
-
* the
|
|
166
|
-
*
|
|
167
|
-
*
|
|
171
|
+
* Scope of the widening: this only affects `vault:<name>:<verb>` for vaults
|
|
172
|
+
* the user is assigned. Hub-level admin (`hub:admin`) + host operator scopes
|
|
173
|
+
* (`parachute:host:*`) are NOT vault scopes and remain ungrantable by
|
|
174
|
+
* non-admins — the cap's named-vault branch is the only thing this touches.
|
|
168
175
|
*/
|
|
169
|
-
export type VaultVerb = "read" | "write";
|
|
176
|
+
export type VaultVerb = "read" | "write" | "admin";
|
|
170
177
|
|
|
171
178
|
export function vaultVerbsForRole(role: string): VaultVerb[] {
|
|
172
|
-
if (role === "write") return ["read", "write"];
|
|
179
|
+
if (role === "write") return ["read", "write", "admin"];
|
|
173
180
|
if (role === "read") return ["read"];
|
|
174
181
|
return [];
|
|
175
182
|
}
|