@openparachute/hub 0.6.1-rc.2 → 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 +156 -60
- 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 +53 -8
- package/src/scope-explanations.ts +13 -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 () => {
|
|
@@ -95,7 +95,10 @@ function fixtureLoadServicesManifest(): ServicesManifest {
|
|
|
95
95
|
|
|
96
96
|
describe("authorizationServerMetadata", () => {
|
|
97
97
|
test("emits RFC 8414 fields rooted at the issuer", async () => {
|
|
98
|
-
const res = authorizationServerMetadata({
|
|
98
|
+
const res = authorizationServerMetadata({
|
|
99
|
+
issuer: ISSUER,
|
|
100
|
+
loadServicesManifest: fixtureLoadServicesManifest,
|
|
101
|
+
});
|
|
99
102
|
expect(res.status).toBe(200);
|
|
100
103
|
const body = (await res.json()) as Record<string, unknown>;
|
|
101
104
|
expect(body.issuer).toBe(ISSUER);
|
|
@@ -110,15 +113,21 @@ describe("authorizationServerMetadata", () => {
|
|
|
110
113
|
const scopesSupported = body.scopes_supported as string[];
|
|
111
114
|
expect(scopesSupported).toContain("vault:read");
|
|
112
115
|
expect(scopesSupported).toContain("vault:admin");
|
|
113
|
-
expect(scopesSupported).toContain("scribe:transcribe");
|
|
116
|
+
expect(scopesSupported).toContain("scribe:transcribe"); // scribe is in the fixture manifest
|
|
114
117
|
expect(scopesSupported).toContain("hub:admin");
|
|
118
|
+
// channel isn't in the fixture manifest → its scopes aren't advertised
|
|
119
|
+
// (hub#…: optional-module scopes only surface when the module is installed).
|
|
120
|
+
expect(scopesSupported).not.toContain("channel:send");
|
|
115
121
|
});
|
|
116
122
|
|
|
117
123
|
test("does NOT advertise non-requestable operator-only scopes", async () => {
|
|
118
124
|
// #96: parachute:host:admin is operator-only. RFC 8414 §2 frames
|
|
119
125
|
// scopes_supported as scopes a client *can* request — advertising what
|
|
120
126
|
// we always reject would mislead clients.
|
|
121
|
-
const res = authorizationServerMetadata({
|
|
127
|
+
const res = authorizationServerMetadata({
|
|
128
|
+
issuer: ISSUER,
|
|
129
|
+
loadServicesManifest: fixtureLoadServicesManifest,
|
|
130
|
+
});
|
|
122
131
|
const body = (await res.json()) as Record<string, unknown>;
|
|
123
132
|
const scopesSupported = body.scopes_supported as string[];
|
|
124
133
|
expect(scopesSupported).not.toContain("parachute:host:admin");
|
|
@@ -143,6 +152,7 @@ describe("authorizationServerMetadata", () => {
|
|
|
143
152
|
const res = authorizationServerMetadata({
|
|
144
153
|
issuer: ISSUER,
|
|
145
154
|
loadDeclaredScopes: () => declared,
|
|
155
|
+
loadServicesManifest: fixtureLoadServicesManifest,
|
|
146
156
|
});
|
|
147
157
|
const body = (await res.json()) as Record<string, unknown>;
|
|
148
158
|
const scopesSupported = body.scopes_supported as string[];
|
|
@@ -157,11 +167,84 @@ describe("authorizationServerMetadata", () => {
|
|
|
157
167
|
// NON_REQUESTABLE filter still applies even when the scope is declared
|
|
158
168
|
expect(scopesSupported).not.toContain("parachute:host:admin");
|
|
159
169
|
});
|
|
170
|
+
|
|
171
|
+
test("advertises an optional module's scopes only when it's installed", async () => {
|
|
172
|
+
// FIRST_PARTY_SCOPES carries scribe:* + channel:send statically. On a
|
|
173
|
+
// vault-only hub they must NOT be advertised — a discovery client (e.g.
|
|
174
|
+
// claude.ai's connector UI) lists the catalog verbatim, so a friend
|
|
175
|
+
// connecting one vault was shown Scribe + Channel access the hub can't
|
|
176
|
+
// honor. Vault + hub are core and always advertised.
|
|
177
|
+
const declared = new Set<string>([
|
|
178
|
+
"vault:read",
|
|
179
|
+
"vault:write",
|
|
180
|
+
"vault:admin",
|
|
181
|
+
"scribe:transcribe",
|
|
182
|
+
"scribe:admin",
|
|
183
|
+
"channel:send",
|
|
184
|
+
"hub:admin",
|
|
185
|
+
]);
|
|
186
|
+
const vaultOnly = {
|
|
187
|
+
services: [
|
|
188
|
+
{
|
|
189
|
+
name: "parachute-vault",
|
|
190
|
+
port: 1940,
|
|
191
|
+
paths: ["/vault/default"],
|
|
192
|
+
health: "/health",
|
|
193
|
+
version: "0.5.1",
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
};
|
|
197
|
+
const res = authorizationServerMetadata({
|
|
198
|
+
issuer: ISSUER,
|
|
199
|
+
loadDeclaredScopes: () => declared,
|
|
200
|
+
loadServicesManifest: () => vaultOnly as unknown as ServicesManifest,
|
|
201
|
+
});
|
|
202
|
+
const scopes = ((await res.json()) as Record<string, unknown>).scopes_supported as string[];
|
|
203
|
+
// core scopes survive
|
|
204
|
+
expect(scopes).toContain("vault:read");
|
|
205
|
+
expect(scopes).toContain("vault:admin");
|
|
206
|
+
expect(scopes).toContain("hub:admin");
|
|
207
|
+
// uninstalled optional-module scopes are dropped
|
|
208
|
+
expect(scopes).not.toContain("scribe:transcribe");
|
|
209
|
+
expect(scopes).not.toContain("scribe:admin");
|
|
210
|
+
expect(scopes).not.toContain("channel:send");
|
|
211
|
+
|
|
212
|
+
// ...but once scribe is installed, its scopes ARE advertised again.
|
|
213
|
+
const withScribe = {
|
|
214
|
+
services: [
|
|
215
|
+
{
|
|
216
|
+
name: "parachute-vault",
|
|
217
|
+
port: 1940,
|
|
218
|
+
paths: ["/vault/default"],
|
|
219
|
+
health: "/health",
|
|
220
|
+
version: "0.5.1",
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
name: "parachute-scribe",
|
|
224
|
+
port: 1943,
|
|
225
|
+
paths: ["/scribe"],
|
|
226
|
+
health: "/health",
|
|
227
|
+
version: "0.4.5",
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
};
|
|
231
|
+
const res2 = authorizationServerMetadata({
|
|
232
|
+
issuer: ISSUER,
|
|
233
|
+
loadDeclaredScopes: () => declared,
|
|
234
|
+
loadServicesManifest: () => withScribe as unknown as ServicesManifest,
|
|
235
|
+
});
|
|
236
|
+
const scopes2 = ((await res2.json()) as Record<string, unknown>).scopes_supported as string[];
|
|
237
|
+
expect(scopes2).toContain("scribe:transcribe");
|
|
238
|
+
expect(scopes2).not.toContain("channel:send"); // channel still not installed
|
|
239
|
+
});
|
|
160
240
|
});
|
|
161
241
|
|
|
162
242
|
describe("protectedResourceMetadata (RFC 9728, closes hub#393)", () => {
|
|
163
243
|
test("emits the required RFC 9728 fields rooted at the issuer", async () => {
|
|
164
|
-
const res = protectedResourceMetadata({
|
|
244
|
+
const res = protectedResourceMetadata({
|
|
245
|
+
issuer: ISSUER,
|
|
246
|
+
loadServicesManifest: fixtureLoadServicesManifest,
|
|
247
|
+
});
|
|
165
248
|
expect(res.status).toBe(200);
|
|
166
249
|
expect(res.headers.get("content-type")).toMatch(/application\/json/);
|
|
167
250
|
const body = (await res.json()) as Record<string, unknown>;
|
|
@@ -185,6 +268,7 @@ describe("protectedResourceMetadata (RFC 9728, closes hub#393)", () => {
|
|
|
185
268
|
const res = protectedResourceMetadata({
|
|
186
269
|
issuer: ISSUER,
|
|
187
270
|
loadDeclaredScopes: () => declared,
|
|
271
|
+
loadServicesManifest: fixtureLoadServicesManifest,
|
|
188
272
|
});
|
|
189
273
|
const body = (await res.json()) as Record<string, unknown>;
|
|
190
274
|
const scopes = body.scopes_supported as string[];
|
|
@@ -3904,7 +3988,10 @@ describe("refresh-token rotation + /oauth/revoke (#73)", () => {
|
|
|
3904
3988
|
});
|
|
3905
3989
|
|
|
3906
3990
|
test("authorizationServerMetadata advertises revocation_endpoint", async () => {
|
|
3907
|
-
const res = authorizationServerMetadata({
|
|
3991
|
+
const res = authorizationServerMetadata({
|
|
3992
|
+
issuer: ISSUER,
|
|
3993
|
+
loadServicesManifest: fixtureLoadServicesManifest,
|
|
3994
|
+
});
|
|
3908
3995
|
const body = (await res.json()) as Record<string, unknown>;
|
|
3909
3996
|
expect(body.revocation_endpoint).toBe(`${ISSUER}/oauth/revoke`);
|
|
3910
3997
|
});
|
|
@@ -8455,12 +8542,12 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
|
|
|
8455
8542
|
// Test 9 — privesc: read/write assigned (non-owner) user requests
|
|
8456
8543
|
// vault:work:admin + vault:work:write → admin DROPPED, token has write only,
|
|
8457
8544
|
// recorded grant lacks admin.
|
|
8458
|
-
test("[9] non-owner
|
|
8545
|
+
test("[9] non-owner ASSIGNED to the vault requests admin+write → BOTH granted (assigned users hold admin)", async () => {
|
|
8459
8546
|
const { db, cleanup } = await makeDb();
|
|
8460
8547
|
try {
|
|
8461
8548
|
await createUser(db, "owner", "pw"); // first user = owner; consumes the admin slot.
|
|
8462
8549
|
const friend = await createUser(db, "friend", "pw", { allowMulti: true });
|
|
8463
|
-
setUserVaults(db, friend.id, ["work"]); // role=write → verbs [read, write]
|
|
8550
|
+
setUserVaults(db, friend.id, ["work"]); // role=write → verbs [read, write, admin]
|
|
8464
8551
|
const session = createSession(db, { userId: friend.id });
|
|
8465
8552
|
const reg = registerClient(db, {
|
|
8466
8553
|
redirectUris: ["https://app.example/cb"],
|
|
@@ -8477,20 +8564,20 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
|
|
|
8477
8564
|
expect(consentRes.status).toBe(302);
|
|
8478
8565
|
const code = new URL(consentRes.headers.get("location") ?? "").searchParams.get("code");
|
|
8479
8566
|
const { scope, aud } = await redeemToScopeAud(db, code ?? "", reg.client.clientId, verifier);
|
|
8480
|
-
// admin
|
|
8481
|
-
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"]);
|
|
8482
8569
|
expect(aud).toBe("vault.work");
|
|
8483
|
-
// Recorded grant
|
|
8570
|
+
// Recorded grant includes admin.
|
|
8484
8571
|
const grant = findGrant(db, friend.id, reg.client.clientId);
|
|
8485
8572
|
expect(grant?.scopes).toContain("vault:work:write");
|
|
8486
|
-
expect(grant?.scopes).
|
|
8573
|
+
expect(grant?.scopes).toContain("vault:work:admin");
|
|
8487
8574
|
} finally {
|
|
8488
8575
|
cleanup();
|
|
8489
8576
|
}
|
|
8490
8577
|
});
|
|
8491
8578
|
|
|
8492
8579
|
// Test 10 — non-owner admin-ONLY request → REFUSED (clear error), no token.
|
|
8493
|
-
test("[10] non-owner admin-only request →
|
|
8580
|
+
test("[10] non-owner assigned, admin-only request → GRANTED (holds admin on their vault)", async () => {
|
|
8494
8581
|
const { db, cleanup } = await makeDb();
|
|
8495
8582
|
try {
|
|
8496
8583
|
await createUser(db, "owner", "pw");
|
|
@@ -8501,7 +8588,7 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
|
|
|
8501
8588
|
redirectUris: ["https://app.example/cb"],
|
|
8502
8589
|
status: "approved",
|
|
8503
8590
|
});
|
|
8504
|
-
const { challenge } = makePkce();
|
|
8591
|
+
const { verifier, challenge } = makePkce();
|
|
8505
8592
|
const consentRes = await submitConsent(
|
|
8506
8593
|
db,
|
|
8507
8594
|
session.id,
|
|
@@ -8509,14 +8596,17 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
|
|
|
8509
8596
|
"vault:work:admin",
|
|
8510
8597
|
challenge,
|
|
8511
8598
|
);
|
|
8512
|
-
//
|
|
8599
|
+
// Assigned user holds admin on their vault → minted, not refused.
|
|
8513
8600
|
expect(consentRes.status).toBe(302);
|
|
8514
8601
|
const loc = new URL(consentRes.headers.get("location") ?? "");
|
|
8515
8602
|
expect(loc.origin + loc.pathname).toBe("https://app.example/cb");
|
|
8516
|
-
|
|
8517
|
-
expect(
|
|
8518
|
-
|
|
8519
|
-
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");
|
|
8520
8610
|
} finally {
|
|
8521
8611
|
cleanup();
|
|
8522
8612
|
}
|
|
@@ -8524,7 +8614,7 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
|
|
|
8524
8614
|
|
|
8525
8615
|
// Test 11 — non-owner unnamed vault:admin + picks assigned vault → after
|
|
8526
8616
|
// narrowing, admin dropped (cap runs post-narrow).
|
|
8527
|
-
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 () => {
|
|
8528
8618
|
const { db, cleanup } = await makeDb();
|
|
8529
8619
|
try {
|
|
8530
8620
|
await createUser(db, "owner", "pw");
|
|
@@ -8545,16 +8635,16 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
|
|
|
8545
8635
|
challenge,
|
|
8546
8636
|
{ vault_pick: "work" },
|
|
8547
8637
|
);
|
|
8548
|
-
// narrowVaultScopes → vault:work:admin + vault:work:write; cap
|
|
8549
|
-
//
|
|
8638
|
+
// narrowVaultScopes → vault:work:admin + vault:work:write; cap KEEPS
|
|
8639
|
+
// both (assigned user holds admin on their picked vault) → mints both.
|
|
8550
8640
|
expect(consentRes.status).toBe(302);
|
|
8551
8641
|
const code = new URL(consentRes.headers.get("location") ?? "").searchParams.get("code");
|
|
8552
8642
|
expect(code).toBeTruthy();
|
|
8553
8643
|
const { scope } = await redeemToScopeAud(db, code ?? "", reg.client.clientId, verifier);
|
|
8554
|
-
expect(scope.split(" ").sort()).toEqual(["vault:work:write"]);
|
|
8644
|
+
expect(scope.split(" ").sort()).toEqual(["vault:work:admin", "vault:work:write"]);
|
|
8555
8645
|
const grant = findGrant(db, friend.id, reg.client.clientId);
|
|
8556
8646
|
expect(grant?.scopes).toContain("vault:work:write");
|
|
8557
|
-
expect(grant?.scopes).
|
|
8647
|
+
expect(grant?.scopes).toContain("vault:work:admin");
|
|
8558
8648
|
} finally {
|
|
8559
8649
|
cleanup();
|
|
8560
8650
|
}
|
|
@@ -8683,35 +8773,41 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
|
|
|
8683
8773
|
// issueAuthCodeRedirect with an admin scope via ANY path (skip-consent /
|
|
8684
8774
|
// same-hub / consent). Assert no grants row ever contains an un-held admin
|
|
8685
8775
|
// verb across each path.
|
|
8686
|
-
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 () => {
|
|
8687
8777
|
const { db, cleanup } = await makeDb();
|
|
8688
8778
|
try {
|
|
8689
8779
|
await createUser(db, "owner", "pw");
|
|
8690
8780
|
const friend = await createUser(db, "friend", "pw", { allowMulti: true });
|
|
8691
|
-
|
|
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"]);
|
|
8692
8784
|
const session = createSession(db, { userId: friend.id });
|
|
8693
8785
|
|
|
8694
|
-
// Path A — consent-submit
|
|
8695
|
-
//
|
|
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.)
|
|
8696
8790
|
const regConsent = registerClient(db, {
|
|
8697
8791
|
redirectUris: ["https://app.example/cb"],
|
|
8698
8792
|
status: "approved",
|
|
8699
8793
|
});
|
|
8700
8794
|
{
|
|
8701
8795
|
const { challenge } = makePkce();
|
|
8702
|
-
await submitConsent(
|
|
8796
|
+
const res = await submitConsent(
|
|
8703
8797
|
db,
|
|
8704
8798
|
session.id,
|
|
8705
8799
|
regConsent.client.clientId,
|
|
8706
|
-
"vault:
|
|
8800
|
+
"vault:other:admin",
|
|
8707
8801
|
challenge,
|
|
8708
8802
|
);
|
|
8709
|
-
|
|
8710
|
-
|
|
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();
|
|
8711
8807
|
}
|
|
8712
8808
|
|
|
8713
|
-
// Path B — same-hub client requesting admin
|
|
8714
|
-
// 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.
|
|
8715
8811
|
const regSameHub = registerClient(db, {
|
|
8716
8812
|
redirectUris: ["https://app.example/cb"],
|
|
8717
8813
|
status: "approved",
|
|
@@ -8728,7 +8824,7 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
|
|
|
8728
8824
|
response_type: "code",
|
|
8729
8825
|
code_challenge: challenge,
|
|
8730
8826
|
code_challenge_method: "S256",
|
|
8731
|
-
scope: "vault:
|
|
8827
|
+
scope: "vault:other:admin",
|
|
8732
8828
|
}),
|
|
8733
8829
|
{ headers: { cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, TTL_S)}` } },
|
|
8734
8830
|
),
|
|
@@ -8736,22 +8832,23 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
|
|
|
8736
8832
|
);
|
|
8737
8833
|
expect(res.status).toBe(200); // consent, not silent-mint
|
|
8738
8834
|
const g = findGrant(db, friend.id, regSameHub.client.clientId);
|
|
8739
|
-
expect(g?.scopes ?? []).not.toContain("vault:
|
|
8835
|
+
expect(g?.scopes ?? []).not.toContain("vault:other:admin");
|
|
8740
8836
|
}
|
|
8741
8837
|
|
|
8742
|
-
// Path C — skip-consent:
|
|
8743
|
-
//
|
|
8744
|
-
//
|
|
8745
|
-
//
|
|
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).
|
|
8746
8842
|
const regSkip = registerClient(db, {
|
|
8747
8843
|
redirectUris: ["https://app.example/cb"],
|
|
8748
8844
|
status: "approved",
|
|
8749
8845
|
});
|
|
8750
|
-
recordGrant(db, friend.id, regSkip.client.clientId, [
|
|
8846
|
+
recordGrant(db, friend.id, regSkip.client.clientId, [
|
|
8847
|
+
"vault:work:write",
|
|
8848
|
+
"vault:other:admin",
|
|
8849
|
+
]);
|
|
8751
8850
|
{
|
|
8752
8851
|
const { verifier, challenge } = makePkce();
|
|
8753
|
-
// Request only the held write scope so skip-consent fires (covered by
|
|
8754
|
-
// the seeded grant) and routes through issueAuthCodeRedirect.
|
|
8755
8852
|
const res = handleAuthorizeGet(
|
|
8756
8853
|
db,
|
|
8757
8854
|
new Request(
|
|
@@ -8770,37 +8867,36 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
|
|
|
8770
8867
|
expect(res.status).toBe(302); // skip-consent silent mint of the held scope
|
|
8771
8868
|
const code = new URL(res.headers.get("location") ?? "").searchParams.get("code");
|
|
8772
8869
|
const { scope } = await redeemToScopeAud(db, code ?? "", regSkip.client.clientId, verifier);
|
|
8773
|
-
expect(scope.split(" ")).not.toContain("vault:
|
|
8870
|
+
expect(scope.split(" ")).not.toContain("vault:other:admin");
|
|
8774
8871
|
}
|
|
8775
8872
|
} finally {
|
|
8776
8873
|
cleanup();
|
|
8777
8874
|
}
|
|
8778
8875
|
});
|
|
8779
8876
|
|
|
8780
|
-
// Reviewer fold (security-relevant): test 15 path C only requested
|
|
8781
|
-
//
|
|
8782
|
-
//
|
|
8783
|
-
//
|
|
8784
|
-
// skip-consent gate fires (the requested
|
|
8785
|
-
//
|
|
8786
|
-
//
|
|
8787
|
-
//
|
|
8788
|
-
//
|
|
8789
|
-
|
|
8790
|
-
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 () => {
|
|
8791
8887
|
const { db, cleanup } = await makeDb();
|
|
8792
8888
|
try {
|
|
8793
8889
|
await createUser(db, "owner", "pw");
|
|
8794
8890
|
const friend = await createUser(db, "friend", "pw", { allowMulti: true });
|
|
8795
|
-
setUserVaults(db, friend.id, ["work"]); //
|
|
8891
|
+
setUserVaults(db, friend.id, ["work"]); // holds work:* (incl. admin) — NOT "other"
|
|
8796
8892
|
const session = createSession(db, { userId: friend.id });
|
|
8797
8893
|
const reg = registerClient(db, {
|
|
8798
8894
|
redirectUris: ["https://app.example/cb"],
|
|
8799
8895
|
status: "approved",
|
|
8800
8896
|
});
|
|
8801
|
-
// Poisoned grant: already lists vault:
|
|
8802
|
-
// coverage check would pass for a direct admin request).
|
|
8803
|
-
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"]);
|
|
8804
8900
|
|
|
8805
8901
|
const { challenge } = makePkce();
|
|
8806
8902
|
const res = handleAuthorizeGet(
|
|
@@ -8812,14 +8908,14 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
|
|
|
8812
8908
|
response_type: "code",
|
|
8813
8909
|
code_challenge: challenge,
|
|
8814
8910
|
code_challenge_method: "S256",
|
|
8815
|
-
scope: "vault:
|
|
8911
|
+
scope: "vault:other:admin",
|
|
8816
8912
|
state: "poisoned-direct",
|
|
8817
8913
|
}),
|
|
8818
8914
|
{ headers: { cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, TTL_S)}` } },
|
|
8819
8915
|
),
|
|
8820
8916
|
capDeps,
|
|
8821
8917
|
);
|
|
8822
|
-
// The cap leaves an EMPTY set (
|
|
8918
|
+
// The cap leaves an EMPTY set (friend isn't assigned "other") → refuse.
|
|
8823
8919
|
// 302 to redirect_uri with invalid_scope and NO code — no token minted.
|
|
8824
8920
|
expect(res.status).toBe(302);
|
|
8825
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
|
@@ -388,13 +388,53 @@ function oauthErrorRedirect(
|
|
|
388
388
|
*
|
|
389
389
|
* Closes hub#393.
|
|
390
390
|
*/
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Optional first-party modules whose scopes `FIRST_PARTY_SCOPES` carries
|
|
394
|
+
* statically (it's `Object.keys(SCOPE_EXPLANATIONS)`), paired with the
|
|
395
|
+
* services.json entry that means "installed." Vault + hub are core and always
|
|
396
|
+
* advertised; these are the modules a hub may not have.
|
|
397
|
+
*/
|
|
398
|
+
const OPTIONAL_MODULE_SCOPES: ReadonlyArray<readonly [prefix: string, service: string]> = [
|
|
399
|
+
["scribe:", "parachute-scribe"],
|
|
400
|
+
["channel:", "parachute-channel"],
|
|
401
|
+
];
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* The scope set to advertise in `scopes_supported` (RFC 8414 + RFC 9728): the
|
|
405
|
+
* requestable declared scopes, minus any OPTIONAL module's scopes when that
|
|
406
|
+
* module isn't installed.
|
|
407
|
+
*
|
|
408
|
+
* Why: `FIRST_PARTY_SCOPES` is static, so a vault-only hub still advertised
|
|
409
|
+
* `scribe:*` + `channel:send`. Discovery clients list the advertised catalog
|
|
410
|
+
* verbatim — claude.ai's connector UI showed a friend connecting ONE vault a
|
|
411
|
+
* request for Scribe + Channel access the hub can't even honor. So advertise an
|
|
412
|
+
* optional module's scopes only when its service is present in services.json.
|
|
413
|
+
* (Trims the ADVERTISEMENT only; issuance/validation still use the full
|
|
414
|
+
* `loadDeclaredScopes` set, and the per-vault PRM stays vault-narrowed.)
|
|
415
|
+
*/
|
|
416
|
+
function advertisedScopes(declared: ReadonlySet<string>, manifest: ServicesManifest): string[] {
|
|
417
|
+
const installed = new Set(manifest.services.map((s) => s.name));
|
|
418
|
+
return Array.from(declared)
|
|
419
|
+
.filter(isRequestableScope)
|
|
420
|
+
.filter((scope) => {
|
|
421
|
+
for (const [prefix, service] of OPTIONAL_MODULE_SCOPES) {
|
|
422
|
+
if (scope.startsWith(prefix) && !installed.has(service)) return false;
|
|
423
|
+
}
|
|
424
|
+
return true;
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
391
428
|
export function protectedResourceMetadata(deps: OAuthDeps): Response {
|
|
392
429
|
const iss = deps.issuer;
|
|
393
430
|
const declared = (deps.loadDeclaredScopes ?? loadDeclaredScopes)();
|
|
394
431
|
return jsonResponse({
|
|
395
432
|
resource: iss,
|
|
396
433
|
authorization_servers: [iss],
|
|
397
|
-
scopes_supported:
|
|
434
|
+
scopes_supported: advertisedScopes(
|
|
435
|
+
declared,
|
|
436
|
+
(deps.loadServicesManifest ?? readServicesManifest)(),
|
|
437
|
+
),
|
|
398
438
|
bearer_methods_supported: ["header"],
|
|
399
439
|
resource_documentation: "https://parachute.computer",
|
|
400
440
|
// Intentional omission: `resource_signing_alg_values_supported` +
|
|
@@ -435,7 +475,10 @@ export function authorizationServerMetadata(deps: OAuthDeps): Response {
|
|
|
435
475
|
// — RFC 8414 §2 frames `scopes_supported` as "the OAuth 2.0 [...] scope
|
|
436
476
|
// values that this authorization server supports" for clients to request.
|
|
437
477
|
// Advertising what we always reject would mislead clients.
|
|
438
|
-
scopes_supported:
|
|
478
|
+
scopes_supported: advertisedScopes(
|
|
479
|
+
declared,
|
|
480
|
+
(deps.loadServicesManifest ?? readServicesManifest)(),
|
|
481
|
+
),
|
|
439
482
|
});
|
|
440
483
|
}
|
|
441
484
|
|
|
@@ -1150,12 +1193,14 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
|
|
|
1150
1193
|
* - The authority source of truth today is `isFirstAdmin` for owner-wide
|
|
1151
1194
|
* authority and `user_vaults.role` (via `vaultVerbsForRole`) for assigned
|
|
1152
1195
|
* users.
|
|
1153
|
-
* - `vaultVerbsForRole`
|
|
1154
|
-
*
|
|
1155
|
-
*
|
|
1156
|
-
*
|
|
1157
|
-
*
|
|
1158
|
-
*
|
|
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.
|
|
1159
1204
|
* - Applied inside `issueAuthCodeRedirect` (the single choke-point ALL mint
|
|
1160
1205
|
* paths funnel through: consent-submit, skip-consent, and same-hub
|
|
1161
1206
|
* auto-trust), the CAPPED set is what gets both recorded (`recordGrant`)
|
|
@@ -45,6 +45,12 @@ export const SCOPE_EXPLANATIONS: Record<string, ScopeExplanation> = {
|
|
|
45
45
|
label: "Full vault access plus configuration changes (rotate tokens, change settings).",
|
|
46
46
|
level: "admin",
|
|
47
47
|
},
|
|
48
|
+
// Optional-module scopes (scribe / channel). These are in FIRST_PARTY_SCOPES
|
|
49
|
+
// (= Object.keys(this map)) but the modules may not be installed — so they're
|
|
50
|
+
// GATED in `OPTIONAL_MODULE_SCOPES` (oauth-handlers.ts) and only advertised in
|
|
51
|
+
// `scopes_supported` when the service is in services.json. If you add scopes
|
|
52
|
+
// for another optional module here, add a matching gate there too, or a
|
|
53
|
+
// vault-only hub will over-advertise them (the bug behind hub#489).
|
|
48
54
|
"scribe:transcribe": {
|
|
49
55
|
label: "Send audio to Scribe for transcription.",
|
|
50
56
|
level: "write",
|
|
@@ -146,10 +152,13 @@ export const NON_REQUESTABLE_SCOPES: ReadonlySet<string> = new Set([
|
|
|
146
152
|
* enforced at the shared mint choke-point (`capScopesToUserAuthority` applied
|
|
147
153
|
* inside `issueAuthCodeRedirect` in `oauth-handlers.ts`): an OAuth flow caps
|
|
148
154
|
* named vault verbs to those the consenting user actually holds on that vault.
|
|
149
|
-
* `vaultVerbsForRole`
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
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.
|
|
153
162
|
*
|
|
154
163
|
* `vault:<name>:admin` also remains mintable by operator-proving local paths,
|
|
155
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
|
}
|