@openparachute/hub 0.5.14-rc.8 → 0.6.0
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-ops.test.ts +45 -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__/expose-2fa-warning.test.ts +31 -17
- package/src/__tests__/expose-auth-preflight.test.ts +71 -72
- package/src/__tests__/expose-cloudflare.test.ts +482 -14
- package/src/__tests__/expose.test.ts +52 -2
- package/src/__tests__/hub-server.test.ts +97 -0
- package/src/__tests__/hub.test.ts +85 -6
- package/src/__tests__/init.test.ts +102 -1
- package/src/__tests__/lifecycle.test.ts +464 -2
- 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__/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 +335 -15
- 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 +47 -6
- package/src/__tests__/vault-hub-origin-env.test.ts +263 -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-revoke-token.ts +107 -21
- package/src/api-users.ts +29 -3
- package/src/cli.ts +26 -21
- package/src/clients.ts +18 -6
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +39 -44
- 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 +370 -12
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +33 -2
- package/src/commands/lifecycle.ts +386 -17
- package/src/commands/status.ts +22 -0
- package/src/commands/upgrade.ts +55 -11
- package/src/commands/wizard.ts +8 -4
- package/src/env-file.ts +10 -0
- package/src/help.ts +3 -1
- package/src/hub-db.ts +39 -1
- package/src/hub-server.ts +52 -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/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 +77 -7
- 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 +71 -19
- 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
package/src/api-account.ts
CHANGED
|
@@ -55,12 +55,15 @@ import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
|
|
|
55
55
|
import { changePasswordRateLimiter } from "./rate-limit.ts";
|
|
56
56
|
import { isHttpsRequest } from "./request-protocol.ts";
|
|
57
57
|
import { findActiveSession } from "./sessions.ts";
|
|
58
|
+
import { isTotpEnrolled } from "./two-factor-store.ts";
|
|
58
59
|
import {
|
|
59
60
|
PASSWORD_MAX_LEN,
|
|
60
61
|
UserNotFoundError,
|
|
62
|
+
type VaultVerb,
|
|
61
63
|
getUserById,
|
|
62
64
|
isFirstAdmin,
|
|
63
65
|
validatePassword,
|
|
66
|
+
vaultVerbsForUserVault,
|
|
64
67
|
verifyPassword,
|
|
65
68
|
} from "./users.ts";
|
|
66
69
|
|
|
@@ -489,6 +492,15 @@ export function handleAccountHomeGet(req: Request, deps: AccountHomeDeps): Respo
|
|
|
489
492
|
const adminFlag = isFirstAdmin(deps.db, user.id);
|
|
490
493
|
const csrf = ensureCsrfToken(req);
|
|
491
494
|
const extra: Record<string, string> = csrf.setCookie ? { "set-cookie": csrf.setCookie } : {};
|
|
495
|
+
// Per-vault mintable verbs for the "mint an access token" affordance on each
|
|
496
|
+
// tile. Reads the assignment role (today always write → ["read", "write"])
|
|
497
|
+
// so the UI only ever offers a verb the POST handler would accept. Empty for
|
|
498
|
+
// the admin / no-vault branches (no assigned vaults to iterate).
|
|
499
|
+
const mintableVerbs: Record<string, VaultVerb[]> = {};
|
|
500
|
+
for (const v of user.assignedVaults) {
|
|
501
|
+
const verbs = vaultVerbsForUserVault(deps.db, user.id, v);
|
|
502
|
+
if (verbs && verbs.length > 0) mintableVerbs[v] = verbs;
|
|
503
|
+
}
|
|
492
504
|
return htmlResponse(
|
|
493
505
|
renderAccountHome({
|
|
494
506
|
username: user.username,
|
|
@@ -497,6 +509,8 @@ export function handleAccountHomeGet(req: Request, deps: AccountHomeDeps): Respo
|
|
|
497
509
|
hubOrigin: deps.hubOrigin,
|
|
498
510
|
isFirstAdmin: adminFlag,
|
|
499
511
|
csrfToken: csrf.token,
|
|
512
|
+
twoFactorEnabled: isTotpEnrolled(deps.db, user.id),
|
|
513
|
+
mintableVerbs,
|
|
500
514
|
}),
|
|
501
515
|
200,
|
|
502
516
|
extra,
|
package/src/api-mint-token.ts
CHANGED
|
@@ -8,10 +8,31 @@
|
|
|
8
8
|
* - the future admin SPA when the operator wants to mint a one-shot
|
|
9
9
|
* scope-narrow token without dropping to a terminal.
|
|
10
10
|
*
|
|
11
|
-
* Auth
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* Auth — capability attenuation: any bearer may mint a token whose authority
|
|
12
|
+
* is a SUBSET of its own. A requested scope `s` is grantable (`canGrant`) iff:
|
|
13
|
+
*
|
|
14
|
+
* 1. `s` is requestable AND the bearer holds `parachute:host:auth`
|
|
15
|
+
* — host:auth mints any requestable scope (vault/scribe verbs, etc.).
|
|
16
|
+
* 2. `s` is `vault:<N>:admin` AND the bearer holds `parachute:host:admin`
|
|
17
|
+
* — box-wide admin attenuates to one named vault's admin.
|
|
18
|
+
* 3. `s` is `vault:<N>:<verb>` (verb ∈ read/write/admin) AND the bearer
|
|
19
|
+
* holds `vault:<N>:admin` for the SAME `<N>` — a vault-admin attenuates
|
|
20
|
+
* to any same-vault subset, including an equal-level admin.
|
|
21
|
+
*
|
|
22
|
+
* Otherwise `s` is refused (400 `invalid_scope`). This single rule subsumes
|
|
23
|
+
* the former two-part guard: the old hard `parachute:host:auth` gate is now
|
|
24
|
+
* rule 1, and PR-A's `host:admin → vault:<name>:admin` carve-out (hub#449) is
|
|
25
|
+
* now rule 2. Rule 3 is new — it lets a `vault:<name>:admin` bearer mint
|
|
26
|
+
* same-vault sub-tokens (the canonical headless path to per-vault admin,
|
|
27
|
+
* replacing deprecated `pvt_*` — vault#282 — and the path the SPA tokens
|
|
28
|
+
* page uses via session → /admin/host-admin-token → here). Cross-vault and
|
|
29
|
+
* host-authority escalation are always blocked: a `vault:work:admin` bearer
|
|
30
|
+
* can never mint `vault:other:*` or any `parachute:host:*`.
|
|
31
|
+
*
|
|
32
|
+
* Entry gate: the bearer must hold at least one minting authority —
|
|
33
|
+
* `parachute:host:auth`, `parachute:host:admin`, or some `vault:<*>:admin`.
|
|
34
|
+
* A bearer with none (e.g. a read-only token) gets 403 `insufficient_scope`
|
|
35
|
+
* before any per-scope check; it cannot mint anything.
|
|
15
36
|
*
|
|
16
37
|
* Why a separate endpoint instead of extending /admin/host-admin-token:
|
|
17
38
|
* that endpoint is session-cookie-gated for the SPA's needs and only
|
|
@@ -26,14 +47,38 @@
|
|
|
26
47
|
import type { Database } from "bun:sqlite";
|
|
27
48
|
import { inferAudience } from "./jwt-audience.ts";
|
|
28
49
|
import { recordTokenMint, signAccessToken, validateAccessToken } from "./jwt-sign.ts";
|
|
29
|
-
import {
|
|
50
|
+
import {
|
|
51
|
+
MINT_HOST_ADMIN_SCOPE,
|
|
52
|
+
MINT_HOST_AUTH_SCOPE,
|
|
53
|
+
canGrant,
|
|
54
|
+
hasMintingAuthority,
|
|
55
|
+
} from "./scope-attenuation.ts";
|
|
56
|
+
import {
|
|
57
|
+
isVaultAdminScope,
|
|
58
|
+
isWellFormedOrNonVaultScope,
|
|
59
|
+
vaultScopeName,
|
|
60
|
+
} from "./scope-explanations.ts";
|
|
61
|
+
|
|
62
|
+
// Re-export `canGrant` so existing importers (and the symmetric revoke path)
|
|
63
|
+
// have a single name to reach for; the implementation lives in the shared
|
|
64
|
+
// `scope-attenuation.ts` module alongside `hasMintingAuthority`.
|
|
65
|
+
export { canGrant } from "./scope-attenuation.ts";
|
|
30
66
|
|
|
31
67
|
/** Default lifetime when --expires-in / `expires_in` is omitted. Matches the CLI. */
|
|
32
68
|
export const API_MINT_TOKEN_DEFAULT_TTL_SECONDS = 90 * 24 * 60 * 60;
|
|
33
69
|
/** Hard cap. Matches the CLI's --expires-in upper bound. */
|
|
34
70
|
export const API_MINT_TOKEN_MAX_TTL_SECONDS = 365 * 24 * 60 * 60;
|
|
35
|
-
/**
|
|
36
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Bearer scope that authorises minting any *requestable* scope (rule 1 of the
|
|
73
|
+
* attenuation model). Re-exported alias of the shared `MINT_HOST_AUTH_SCOPE`
|
|
74
|
+
* for back-compat with existing importers.
|
|
75
|
+
*/
|
|
76
|
+
export const API_MINT_TOKEN_HOST_AUTH_SCOPE = MINT_HOST_AUTH_SCOPE;
|
|
77
|
+
/**
|
|
78
|
+
* Bearer scope that authorises minting `vault:<name>:admin` (rule 2).
|
|
79
|
+
* Re-exported alias of the shared `MINT_HOST_ADMIN_SCOPE`.
|
|
80
|
+
*/
|
|
81
|
+
export const API_MINT_TOKEN_VAULT_ADMIN_BEARER_SCOPE = MINT_HOST_ADMIN_SCOPE;
|
|
37
82
|
/** client_id stamped on minted tokens. Matches the CLI flow's value. */
|
|
38
83
|
export const API_MINT_TOKEN_CLIENT_ID = "parachute-hub";
|
|
39
84
|
|
|
@@ -87,12 +132,17 @@ export async function handleApiMintToken(req: Request, deps: ApiMintTokenDeps):
|
|
|
87
132
|
return jsonError(401, "unauthenticated", `bearer token invalid — ${msg}`);
|
|
88
133
|
}
|
|
89
134
|
|
|
90
|
-
// 3.
|
|
91
|
-
|
|
135
|
+
// 3. Entry gate — the bearer must hold at least one minting authority
|
|
136
|
+
// (`parachute:host:auth`, `parachute:host:admin`, or some
|
|
137
|
+
// `vault:<*>:admin`). A bearer with none can mint nothing under the
|
|
138
|
+
// attenuation model, so we 403 before per-scope checks. Per-scope
|
|
139
|
+
// grantability (which authority covers which scope) is enforced below
|
|
140
|
+
// via `canGrant`.
|
|
141
|
+
if (!hasMintingAuthority(bearerScopes)) {
|
|
92
142
|
return jsonError(
|
|
93
143
|
403,
|
|
94
144
|
"insufficient_scope",
|
|
95
|
-
`bearer token
|
|
145
|
+
`bearer token holds no minting authority (need ${API_MINT_TOKEN_HOST_AUTH_SCOPE}, ${API_MINT_TOKEN_VAULT_ADMIN_BEARER_SCOPE}, or vault:<name>:admin)`,
|
|
96
146
|
);
|
|
97
147
|
}
|
|
98
148
|
|
|
@@ -117,19 +167,40 @@ export async function handleApiMintToken(req: Request, deps: ApiMintTokenDeps):
|
|
|
117
167
|
return jsonError(400, "invalid_request", "scope must contain at least one scope");
|
|
118
168
|
}
|
|
119
169
|
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
|
|
170
|
+
// Shape guard (defensive hygiene — adversarial audit 2026-05-28): reject any
|
|
171
|
+
// scope that is shaped like a *named* per-vault scope but malformed —
|
|
172
|
+
// `vault:work:ADMIN` (uppercase verb), `vault::admin` (empty name),
|
|
173
|
+
// `vault:work:read:admin` (extra segment), `VAULT:work:admin` (uppercase
|
|
174
|
+
// resource). These slip past `isNonRequestableScope`'s strict regexes, so
|
|
175
|
+
// `canGrant` rule 1 would admit them as "requestable" and mint a junk
|
|
176
|
+
// registry row. They grant zero access today (the vault consumer's
|
|
177
|
+
// `decomposeVaultScope` rejects all four), so this is NOT exploitable now —
|
|
178
|
+
// the check is a backstop against a future consumer-normalization regression
|
|
179
|
+
// plus registry hygiene. It's an input-shape check, orthogonal to authority,
|
|
180
|
+
// so it runs for ALL callers before any `canGrant` attenuation. Non-vault
|
|
181
|
+
// scopes and the unnamed `vault:<verb>` forms are unaffected.
|
|
182
|
+
const malformed = scopes.filter((s) => !isWellFormedOrNonVaultScope(s));
|
|
183
|
+
if (malformed.length > 0) {
|
|
184
|
+
return jsonError(
|
|
185
|
+
400,
|
|
186
|
+
"invalid_scope",
|
|
187
|
+
`malformed vault scope ${malformed.join(", ")}; expected vault:<name>:<read|write|admin>`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Capability-attenuation guard: every requested scope must be a subset of
|
|
192
|
+
// the bearer's own authority under `canGrant` (rules in the file docstring).
|
|
193
|
+
// A `parachute:host:auth` bearer mints any requestable scope; a
|
|
194
|
+
// `parachute:host:admin` bearer additionally mints `vault:<name>:admin`; a
|
|
195
|
+
// `vault:<name>:admin` bearer mints same-vault subsets only. Anything else
|
|
196
|
+
// — host:* escalation, cross-vault, a non-requestable with no covering
|
|
197
|
+
// authority — is blocked. One blocked scope rejects the whole request.
|
|
198
|
+
const blocked = scopes.filter((s) => !canGrant(bearerScopes, s));
|
|
128
199
|
if (blocked.length > 0) {
|
|
129
200
|
return jsonError(
|
|
130
201
|
400,
|
|
131
202
|
"invalid_scope",
|
|
132
|
-
`scope ${blocked.join(", ")} is not
|
|
203
|
+
`scope ${blocked.join(", ")} is not grantable by this bearer; use OAuth flow or operator rotation`,
|
|
133
204
|
);
|
|
134
205
|
}
|
|
135
206
|
|
|
@@ -183,6 +254,40 @@ export async function handleApiMintToken(req: Request, deps: ApiMintTokenDeps):
|
|
|
183
254
|
permissionsCanonical = JSON.stringify(permissionsClaim);
|
|
184
255
|
}
|
|
185
256
|
|
|
257
|
+
// Derive the `vault_scope` pin. Collect the set of vault names `<N>` from
|
|
258
|
+
// every requested `vault:<N>:<verb>` scope that was authorized via a
|
|
259
|
+
// vault-scoped authority — rule 2 (host:admin → vault:<N>:admin) or rule 3
|
|
260
|
+
// (vault:<N>:admin → same-vault subset). These are the vault-scoped mints,
|
|
261
|
+
// so we pin the token to those vault(s): it can ONLY ever be used against
|
|
262
|
+
// them (defense-in-depth + least privilege), matching the canonical
|
|
263
|
+
// session-path mint in `admin-vault-admin-token.ts`.
|
|
264
|
+
//
|
|
265
|
+
// Pure `parachute:host:auth` requestable mints (a `vault:<N>:read/write`
|
|
266
|
+
// granted by rule 1 with no covering vault-admin authority) stay UNpinned
|
|
267
|
+
// (`[]`) — the "no per-user restriction" sentinel; the scope string +
|
|
268
|
+
// audience are the authorization-bearing gate there, as before. We
|
|
269
|
+
// distinguish by checking the bearer's own vault-scoped authority: a vault
|
|
270
|
+
// name is pinned only when the bearer held `vault:<N>:admin` (rule 3) or
|
|
271
|
+
// host:admin and the scope is admin (rule 2).
|
|
272
|
+
//
|
|
273
|
+
// Note: `audience` is single-valued and `inferAudience` is first-wins, so a
|
|
274
|
+
// multi-vault request gets `aud=vault.<first>` and only authenticates
|
|
275
|
+
// against that vault. Mint one token per vault for the multi-vault case.
|
|
276
|
+
// The canonical consumers (mcp-install, SPA tokens page) request a single
|
|
277
|
+
// vault.
|
|
278
|
+
const bearerHasHostAdmin = bearerScopes.includes(API_MINT_TOKEN_VAULT_ADMIN_BEARER_SCOPE);
|
|
279
|
+
const vaultScopePinSet = new Set<string>();
|
|
280
|
+
for (const s of scopes) {
|
|
281
|
+
const name = vaultScopeName(s);
|
|
282
|
+
if (name === null) continue;
|
|
283
|
+
const grantedByVaultAdminBearer = bearerScopes.includes(`vault:${name}:admin`); // rule 3
|
|
284
|
+
const grantedByHostAdminForAdmin = isVaultAdminScope(s) && bearerHasHostAdmin; // rule 2
|
|
285
|
+
if (grantedByVaultAdminBearer || grantedByHostAdminForAdmin) {
|
|
286
|
+
vaultScopePinSet.add(name);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const vaultScopePin = [...vaultScopePinSet];
|
|
290
|
+
|
|
186
291
|
// 6. Mint + register.
|
|
187
292
|
const minted = await signAccessToken(deps.db, {
|
|
188
293
|
sub: subject,
|
|
@@ -192,11 +297,14 @@ export async function handleApiMintToken(req: Request, deps: ApiMintTokenDeps):
|
|
|
192
297
|
issuer: deps.issuer,
|
|
193
298
|
ttlSeconds,
|
|
194
299
|
// Operator-driven CLI/API mint — the bearer already cleared the
|
|
195
|
-
// `
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
|
|
300
|
+
// attenuation guard. `vault_scope` is `[]` (no restriction) for any
|
|
301
|
+
// verb scope granted by rule 1, or the named vault(s) for vault-scoped
|
|
302
|
+
// mints authorized via rule 2 / rule 3 (see above). The pin tracks the
|
|
303
|
+
// grant rule, not the bearer: a host:admin bearer minting
|
|
304
|
+
// `vault:work:write` goes through rule 1 (write is requestable), so it
|
|
305
|
+
// ALSO gets `vault_scope:[]` — only its `vault:work:admin` mints (rule 2)
|
|
306
|
+
// are pinned.
|
|
307
|
+
vaultScope: vaultScopePin,
|
|
200
308
|
...(permissionsClaim !== undefined ? { extraClaims: { permissions: permissionsClaim } } : {}),
|
|
201
309
|
...(deps.now !== undefined ? { now: deps.now } : {}),
|
|
202
310
|
});
|
package/src/api-modules-ops.ts
CHANGED
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
import type { Database } from "bun:sqlite";
|
|
36
36
|
import { randomUUID } from "node:crypto";
|
|
37
37
|
import { dirname } from "node:path";
|
|
38
|
+
import { MissingDependencyError, type MissingDependencyWire } from "@openparachute/depcheck";
|
|
38
39
|
import { CURATED_MODULES, type CuratedModuleShort } from "./api-modules.ts";
|
|
39
40
|
import { isLinked as defaultIsLinked } from "./bun-link.ts";
|
|
40
41
|
import { PARACHUTE_INSTALL_CHANNEL_ENV } from "./commands/install.ts";
|
|
@@ -49,11 +50,7 @@ import {
|
|
|
49
50
|
getSpec,
|
|
50
51
|
synthesizeManifestForKnownModule,
|
|
51
52
|
} from "./service-spec.ts";
|
|
52
|
-
import {
|
|
53
|
-
findService,
|
|
54
|
-
readManifestLenient,
|
|
55
|
-
removeService,
|
|
56
|
-
} from "./services-manifest.ts";
|
|
53
|
+
import { findService, readManifestLenient, removeService } from "./services-manifest.ts";
|
|
57
54
|
import type { ModuleState, SpawnRequest, Supervisor } from "./supervisor.ts";
|
|
58
55
|
import { WELL_KNOWN_PATH, type regenerateWellKnown } from "./well-known.ts";
|
|
59
56
|
|
|
@@ -81,6 +78,15 @@ export interface Operation {
|
|
|
81
78
|
log: string[];
|
|
82
79
|
/** Error message when status is `failed`. Mirrored from the underlying throw. */
|
|
83
80
|
error?: string;
|
|
81
|
+
/**
|
|
82
|
+
* Structured error detail when the failure is a known typed error — today
|
|
83
|
+
* only `MissingDependencyError.toWire()` (a missing external binary like
|
|
84
|
+
* `bun` / `git` during install). The operations-polling SPA switches on
|
|
85
|
+
* `error_detail.error_type === "missing_dependency"` to render a dedicated
|
|
86
|
+
* install card; the plain `error` string is the fallback for everything
|
|
87
|
+
* else. Wire shape matches `@openparachute/depcheck`'s `MissingDependencyWire`.
|
|
88
|
+
*/
|
|
89
|
+
error_detail?: MissingDependencyWire;
|
|
84
90
|
startedAt: string;
|
|
85
91
|
finishedAt?: string;
|
|
86
92
|
}
|
|
@@ -89,7 +95,11 @@ export interface OperationsRegistry {
|
|
|
89
95
|
create(kind: OperationKind, short: string): Operation;
|
|
90
96
|
get(id: string): Operation | undefined;
|
|
91
97
|
/** Append a log line + (optionally) advance status. */
|
|
92
|
-
update(
|
|
98
|
+
update(
|
|
99
|
+
id: string,
|
|
100
|
+
patch: Partial<Pick<Operation, "status" | "error" | "error_detail">>,
|
|
101
|
+
logLine?: string,
|
|
102
|
+
): void;
|
|
93
103
|
}
|
|
94
104
|
|
|
95
105
|
/**
|
|
@@ -122,11 +132,16 @@ class InMemoryOperationsRegistry implements OperationsRegistry {
|
|
|
122
132
|
return this.ops.get(id);
|
|
123
133
|
}
|
|
124
134
|
|
|
125
|
-
update(
|
|
135
|
+
update(
|
|
136
|
+
id: string,
|
|
137
|
+
patch: Partial<Pick<Operation, "status" | "error" | "error_detail">>,
|
|
138
|
+
logLine?: string,
|
|
139
|
+
): void {
|
|
126
140
|
const op = this.ops.get(id);
|
|
127
141
|
if (!op) return;
|
|
128
142
|
if (patch.status) op.status = patch.status;
|
|
129
143
|
if (patch.error !== undefined) op.error = patch.error;
|
|
144
|
+
if (patch.error_detail !== undefined) op.error_detail = patch.error_detail;
|
|
130
145
|
if (logLine) op.log.push(logLine);
|
|
131
146
|
if (patch.status === "succeeded" || patch.status === "failed") {
|
|
132
147
|
op.finishedAt = this.clock().toISOString();
|
|
@@ -520,13 +535,37 @@ export async function handleInstall(
|
|
|
520
535
|
// immediately + the work runs in the background. Errors get logged
|
|
521
536
|
// to the operation; nothing throws back to the request handler.
|
|
522
537
|
void runInstall(op.id, short, spec, deps, bodyChannel).catch((err) => {
|
|
523
|
-
|
|
524
|
-
registry.update(op.id, { status: "failed", error: msg }, `install failed: ${msg}`);
|
|
538
|
+
failOperation(registry, op.id, "install", err);
|
|
525
539
|
});
|
|
526
540
|
|
|
527
541
|
return acceptedOp(op.id);
|
|
528
542
|
}
|
|
529
543
|
|
|
544
|
+
/**
|
|
545
|
+
* Mark an async op failed, attaching the structured `error_detail` wire when
|
|
546
|
+
* the underlying throw is a `MissingDependencyError` (a missing external
|
|
547
|
+
* binary like `bun` / `git` during install). The operations-polling SPA reads
|
|
548
|
+
* `error_detail` to render the dedicated install card; the plain `error`
|
|
549
|
+
* string is the fallback for every other failure.
|
|
550
|
+
*/
|
|
551
|
+
function failOperation(
|
|
552
|
+
registry: OperationsRegistry,
|
|
553
|
+
opId: string,
|
|
554
|
+
verb: string,
|
|
555
|
+
err: unknown,
|
|
556
|
+
): void {
|
|
557
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
558
|
+
if (err instanceof MissingDependencyError) {
|
|
559
|
+
registry.update(
|
|
560
|
+
opId,
|
|
561
|
+
{ status: "failed", error: msg, error_detail: err.toWire() },
|
|
562
|
+
`${verb} failed: ${err.binary} not installed`,
|
|
563
|
+
);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
registry.update(opId, { status: "failed", error: msg }, `${verb} failed: ${msg}`);
|
|
567
|
+
}
|
|
568
|
+
|
|
530
569
|
/**
|
|
531
570
|
* Internal install runner. Exported so non-API callers (the first-boot
|
|
532
571
|
* wizard at `/admin/setup`, hub#259) can drive the same install →
|
|
@@ -722,8 +761,7 @@ export async function handleUpgrade(
|
|
|
722
761
|
const spec = specFor(short);
|
|
723
762
|
|
|
724
763
|
void runUpgrade(op.id, short, spec, deps).catch((err) => {
|
|
725
|
-
|
|
726
|
-
registry.update(op.id, { status: "failed", error: msg }, `upgrade failed: ${msg}`);
|
|
764
|
+
failOperation(registry, op.id, "upgrade", err);
|
|
727
765
|
});
|
|
728
766
|
return acceptedOp(op.id);
|
|
729
767
|
}
|
package/src/api-revoke-token.ts
CHANGED
|
@@ -1,38 +1,73 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `POST /api/auth/revoke-token` — HTTP companion to `parachute auth
|
|
3
|
-
* revoke-token <jti>` (hub#221) and the
|
|
4
|
-
*
|
|
3
|
+
* revoke-token <jti>` (hub#221) and the backing endpoint for the admin
|
|
4
|
+
* UI's revoke action. Closes hub#220.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* Auth — capability attenuation, SYMMETRIC to mint-token (hub#452): you may
|
|
7
|
+
* revoke exactly what you could have minted. After validating the bearer
|
|
8
|
+
* (signature / issuer / expiry — same as today):
|
|
9
|
+
*
|
|
10
|
+
* 1. If the bearer holds `parachute:host:auth` → it may revoke ANY jti
|
|
11
|
+
* (the original, broadest behavior — preserved unchanged).
|
|
12
|
+
* 2. Otherwise the bearer must clear the entry gate — it must hold at least
|
|
13
|
+
* one minting authority (`parachute:host:auth`, `parachute:host:admin`,
|
|
14
|
+
* or some `vault:<*>:admin`, via `hasMintingAuthority`). A bearer with
|
|
15
|
+
* none (e.g. a read-only token) gets 403 up front — it can revoke
|
|
16
|
+
* nothing, just as it can mint nothing.
|
|
17
|
+
* 3. The per-jti authority check then governs what such a bearer may
|
|
18
|
+
* actually revoke: the target jti is revocable iff EVERY one of its
|
|
19
|
+
* recorded scopes satisfies `canGrant(bearerScopes, scope)` — i.e. the
|
|
20
|
+
* bearer could have minted that exact token. A `vault:work:admin` bearer
|
|
21
|
+
* can revoke a `vault:work:write` or `vault:work:admin` jti, but NOT a
|
|
22
|
+
* `vault:other:*` jti and NOT a `parachute:host:*` jti — the same
|
|
23
|
+
* cross-vault / host-escalation walls mint enforces.
|
|
24
|
+
*
|
|
25
|
+
* Idempotency / no-info-leak: an UNKNOWN jti (no `tokens` row — never minted
|
|
26
|
+
* or already purged) returns the SAME 404 `not_found` the endpoint has always
|
|
27
|
+
* returned, for every caller including host:auth. The per-jti authority check
|
|
28
|
+
* only runs when the row is FOUND. So an attenuated bearer probing a jti it
|
|
29
|
+
* doesn't own cannot distinguish "exists but not yours" from "doesn't exist"
|
|
30
|
+
* by the unknown-jti path — it gets the identical 404 a host:auth bearer
|
|
31
|
+
* would. A jti that EXISTS but is out of the bearer's authority returns 403
|
|
32
|
+
* (and is NOT revoked): the caller already knows the jti string, so "exists
|
|
33
|
+
* but not yours" leaks nothing beyond what it already holds — and returning
|
|
34
|
+
* idempotent-ok there would be a lie (it revoked nothing).
|
|
10
35
|
*
|
|
11
36
|
* Body: `{ jti: string }`.
|
|
12
37
|
*
|
|
13
|
-
* Responses (
|
|
14
|
-
* mint-token and the rest of the hub's bearer-protected admin API):
|
|
38
|
+
* Responses (OAuth 2.0 error-shape vocabulary, matching mint-token):
|
|
15
39
|
*
|
|
16
40
|
* - 200 `{ jti, revoked_at }` — success. Idempotent: re-revoking an
|
|
17
|
-
* already-revoked jti returns the existing `revoked_at` and 200
|
|
18
|
-
* same as the CLI's exit-0-with-existing-timestamp behavior.
|
|
41
|
+
* already-revoked jti returns the existing `revoked_at` and 200.
|
|
19
42
|
* - 400 `invalid_request` — missing/malformed body, missing jti.
|
|
20
43
|
* - 401 `unauthenticated` — missing or invalid bearer.
|
|
21
|
-
* - 403 `insufficient_scope` — bearer
|
|
44
|
+
* - 403 `insufficient_scope` — bearer holds no minting authority (entry
|
|
45
|
+
* gate), or the target jti carries a scope the bearer couldn't have
|
|
46
|
+
* minted (per-jti authority check).
|
|
22
47
|
* - 404 `not_found` — no `tokens` row matches the jti.
|
|
23
48
|
* - 405 `method_not_allowed` — non-POST.
|
|
24
49
|
*
|
|
25
50
|
* Identity field in audit-friendly success: not echoed in the response
|
|
26
51
|
* body (the JSON shape is intentionally minimal — `jti` + `revoked_at`
|
|
27
52
|
* is all a UI consumer needs); operator-side audit lives in hub logs.
|
|
28
|
-
* Mirrors the CLI's design where `identity=` was added for stdout but
|
|
29
|
-
* the wire response stays narrow.
|
|
30
53
|
*/
|
|
31
54
|
import type { Database } from "bun:sqlite";
|
|
32
55
|
import { findTokenRowByJti, revokeTokenByJti, validateAccessToken } from "./jwt-sign.ts";
|
|
56
|
+
import { MINT_HOST_AUTH_SCOPE, canGrant, hasMintingAuthority } from "./scope-attenuation.ts";
|
|
33
57
|
|
|
34
|
-
/**
|
|
35
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Scope that authorises revoking ANY jti unconditionally (rule 1). A bearer
|
|
60
|
+
* without it may still revoke via attenuation (rule 3) if it clears the
|
|
61
|
+
* `hasMintingAuthority` entry gate.
|
|
62
|
+
*/
|
|
63
|
+
export const API_REVOKE_TOKEN_REQUIRED_SCOPE = MINT_HOST_AUTH_SCOPE;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Maximum accepted length of a caller-supplied `jti`. A real jti is a UUID or
|
|
67
|
+
* short opaque token; anything materially longer is malformed input. Capping
|
|
68
|
+
* it keeps the verbatim-echoed value out of structured logs from bloating.
|
|
69
|
+
*/
|
|
70
|
+
export const MAX_JTI_LENGTH = 256;
|
|
36
71
|
|
|
37
72
|
export interface ApiRevokeTokenDeps {
|
|
38
73
|
db: Database;
|
|
@@ -80,12 +115,19 @@ export async function handleApiRevokeToken(
|
|
|
80
115
|
return jsonError(401, "unauthenticated", `bearer token invalid — ${msg}`);
|
|
81
116
|
}
|
|
82
117
|
|
|
83
|
-
// 3.
|
|
84
|
-
|
|
118
|
+
// 3. Entry gate. A `parachute:host:auth` bearer may revoke anything
|
|
119
|
+
// (rule 1) and skips the per-jti authority check below. Any other
|
|
120
|
+
// bearer must hold SOME minting authority (host:admin or a
|
|
121
|
+
// `vault:<*>:admin`) to attempt a revoke at all — a bearer with none
|
|
122
|
+
// can revoke nothing under attenuation, so we 403 it here rather than
|
|
123
|
+
// looking up the jti. Whether such a bearer may revoke a SPECIFIC jti
|
|
124
|
+
// is decided per-jti in step 5 via `canGrant`.
|
|
125
|
+
const bearerHasHostAuth = bearerScopes.includes(API_REVOKE_TOKEN_REQUIRED_SCOPE);
|
|
126
|
+
if (!bearerHasHostAuth && !hasMintingAuthority(bearerScopes)) {
|
|
85
127
|
return jsonError(
|
|
86
128
|
403,
|
|
87
129
|
"insufficient_scope",
|
|
88
|
-
`bearer token
|
|
130
|
+
`bearer token holds no revoke authority (need ${API_REVOKE_TOKEN_REQUIRED_SCOPE}, parachute:host:admin, or vault:<name>:admin)`,
|
|
89
131
|
);
|
|
90
132
|
}
|
|
91
133
|
|
|
@@ -103,15 +145,59 @@ export async function handleApiRevokeToken(
|
|
|
103
145
|
if (typeof body.jti !== "string" || body.jti.length === 0) {
|
|
104
146
|
return jsonError(400, "invalid_request", "jti is required and must be a non-empty string");
|
|
105
147
|
}
|
|
148
|
+
// Cap the jti length. It's echoed verbatim into `error_description` and
|
|
149
|
+
// structured log lines; a real jti is a UUID/short token (well under 256
|
|
150
|
+
// chars), so a longer value is malformed input — reject it before it can
|
|
151
|
+
// bloat log lines. JSON-encoded responses already neutralize injection;
|
|
152
|
+
// this is a size guard, not an escaping one.
|
|
153
|
+
if (body.jti.length > MAX_JTI_LENGTH) {
|
|
154
|
+
return jsonError(400, "invalid_request", `jti exceeds ${MAX_JTI_LENGTH}-character maximum`);
|
|
155
|
+
}
|
|
106
156
|
const jti = body.jti;
|
|
107
157
|
|
|
108
|
-
// 5. Lookup + revoke. Order: row-existence first
|
|
109
|
-
//
|
|
110
|
-
//
|
|
158
|
+
// 5. Lookup + per-jti authority + revoke. Order: row-existence first
|
|
159
|
+
// (404 if missing — same response for every caller, no leak), then the
|
|
160
|
+
// attenuation authority check (for non-host:auth bearers), then attempt
|
|
161
|
+
// revoke. Idempotent: if already revoked, surface the existing revoked_at
|
|
162
|
+
// — same CLI semantics from hub#221.
|
|
111
163
|
const existing = findTokenRowByJti(deps.db, jti);
|
|
112
164
|
if (!existing) {
|
|
113
165
|
return jsonError(404, "not_found", `no token with jti ${jti} found in registry`);
|
|
114
166
|
}
|
|
167
|
+
|
|
168
|
+
// Per-jti authority (rule 3 / symmetric to mint attenuation). A host:auth
|
|
169
|
+
// bearer skips this — it may revoke anything. Any other bearer may revoke
|
|
170
|
+
// this jti only if EVERY one of its recorded scopes is one the bearer could
|
|
171
|
+
// have minted (`canGrant`). One out-of-authority scope (cross-vault, a
|
|
172
|
+
// host:* scope, etc.) blocks the whole revoke with 403 — and the token is
|
|
173
|
+
// left intact. The caller already knows the jti, so "exists but not yours"
|
|
174
|
+
// leaks nothing beyond what it holds; idempotent-ok would falsely imply a
|
|
175
|
+
// revoke happened.
|
|
176
|
+
if (!bearerHasHostAuth) {
|
|
177
|
+
// A scopeless target (recorded `scopes: []`) would otherwise pass the
|
|
178
|
+
// `canGrant` filter vacuously — `[].filter(...)` is empty, so
|
|
179
|
+
// `ungrantable.length === 0`. That's silently permissive: any bearer
|
|
180
|
+
// clearing the entry gate could revoke a zero-scope token. Such tokens
|
|
181
|
+
// shouldn't exist (the CLI/SPA never mint them), but if one does, only a
|
|
182
|
+
// host:auth bearer may revoke it — a non-host:auth bearer has no
|
|
183
|
+
// attenuation authority that "covers" the empty scope set.
|
|
184
|
+
if (existing.scopes.length === 0) {
|
|
185
|
+
return jsonError(
|
|
186
|
+
403,
|
|
187
|
+
"insufficient_scope",
|
|
188
|
+
`bearer token cannot revoke jti ${jti}: target has no recorded scopes (only ${API_REVOKE_TOKEN_REQUIRED_SCOPE} may revoke a scopeless token)`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
const ungrantable = existing.scopes.filter((s) => !canGrant(bearerScopes, s));
|
|
192
|
+
if (ungrantable.length > 0) {
|
|
193
|
+
return jsonError(
|
|
194
|
+
403,
|
|
195
|
+
"insufficient_scope",
|
|
196
|
+
`bearer token cannot revoke jti ${jti}: its scope(s) ${ungrantable.join(", ")} are outside the bearer's authority`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
115
201
|
if (existing.revokedAt) {
|
|
116
202
|
return ok({ jti, revoked_at: existing.revokedAt });
|
|
117
203
|
}
|
package/src/api-users.ts
CHANGED
|
@@ -336,7 +336,16 @@ export async function handleCreateUser(req: Request, deps: ApiUsersDeps): Promis
|
|
|
336
336
|
}
|
|
337
337
|
}
|
|
338
338
|
|
|
339
|
-
/**
|
|
339
|
+
/**
|
|
340
|
+
* DELETE /api/users/:id — hard-delete + token revocation + session/grant
|
|
341
|
+
* cleanup.
|
|
342
|
+
*
|
|
343
|
+
* Success returns `200 { ok: true, revocation_lag_seconds: 60 }` (was a bare
|
|
344
|
+
* 204 pre-consistency-fix) so the SPA can warn that the deleted user's
|
|
345
|
+
* tokens linger ~60s on resource-server revocation caches — same surface
|
|
346
|
+
* the reset-password path carries. The race-tolerant "row already gone"
|
|
347
|
+
* path stays a bodyless 204 (nothing was revoked here, no lag to report).
|
|
348
|
+
*/
|
|
340
349
|
export async function handleDeleteUser(
|
|
341
350
|
req: Request,
|
|
342
351
|
userId: string,
|
|
@@ -390,11 +399,28 @@ export async function handleDeleteUser(
|
|
|
390
399
|
if (!removed) {
|
|
391
400
|
// Race: row deleted by a concurrent request. Operator's intent
|
|
392
401
|
// (no such user) is already satisfied — same shape as the grant-
|
|
393
|
-
// revoke race in `admin-grants.ts`.
|
|
402
|
+
// revoke race in `admin-grants.ts`. No tokens were revoked by THIS
|
|
403
|
+
// call, so there's no revocation lag to warn about; keep the bodyless
|
|
404
|
+
// 204 for the race path.
|
|
394
405
|
return new Response(null, { status: 204 });
|
|
395
406
|
}
|
|
396
407
|
console.log(`user deleted: id=${userId} username=${target.username}`);
|
|
397
|
-
|
|
408
|
+
// `revocation_lag_seconds`: same consistency fix the reset-password path
|
|
409
|
+
// got (smoke 2026-05-27 finding 3). Deleting a user revokes their tokens
|
|
410
|
+
// in hub's DB immediately, but resource servers (vault, scribe, …) cache
|
|
411
|
+
// the revocation list via scope-guard's `REVOCATION_CACHE_TTL_MS = 60_000`
|
|
412
|
+
// — a deleted user's tokens linger for up to ~60s on those caches. Surface
|
|
413
|
+
// that so the admin isn't surprised when a just-deleted user's client can
|
|
414
|
+
// still read for a minute (relevant in the stolen-device / compromise
|
|
415
|
+
// threat model). 200 + body instead of the old bare 204 so the SPA can
|
|
416
|
+
// render the warning banner.
|
|
417
|
+
return new Response(
|
|
418
|
+
JSON.stringify({ ok: true, revocation_lag_seconds: REVOCATION_LAG_SECONDS }),
|
|
419
|
+
{
|
|
420
|
+
status: 200,
|
|
421
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
422
|
+
},
|
|
423
|
+
);
|
|
398
424
|
}
|
|
399
425
|
|
|
400
426
|
/**
|