@fuzdev/fuz_app 0.106.1 → 0.107.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/dist/actions/CLAUDE.md +42 -23
- package/dist/actions/action_bridge.d.ts +34 -3
- package/dist/actions/action_bridge.d.ts.map +1 -1
- package/dist/actions/action_bridge.js +32 -1
- package/dist/actions/action_rpc.d.ts +5 -4
- package/dist/actions/action_rpc.d.ts.map +1 -1
- package/dist/actions/action_rpc.js +4 -3
- package/dist/actions/action_spec.d.ts +8 -2
- package/dist/actions/action_spec.d.ts.map +1 -1
- package/dist/actions/action_spec.js +5 -2
- package/dist/actions/compile_action_registry.d.ts +26 -14
- package/dist/actions/compile_action_registry.d.ts.map +1 -1
- package/dist/actions/compile_action_registry.js +29 -14
- package/dist/actions/perform_action.d.ts +26 -16
- package/dist/actions/perform_action.d.ts.map +1 -1
- package/dist/actions/perform_action.js +69 -44
- package/dist/actions/register_action_ws.d.ts +4 -4
- package/dist/actions/register_action_ws.js +5 -5
- package/dist/actions/register_ws_endpoint.d.ts +13 -5
- package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
- package/dist/actions/register_ws_endpoint.js +15 -6
- package/dist/auth/CLAUDE.md +16 -9
- package/dist/auth/audit_log_route_schema.d.ts.map +1 -1
- package/dist/auth/audit_log_route_schema.js +11 -1
- package/dist/auth/audit_log_routes.d.ts.map +1 -1
- package/dist/auth/audit_log_routes.js +4 -9
- package/dist/auth/auth_guard_resolver.d.ts +48 -21
- package/dist/auth/auth_guard_resolver.d.ts.map +1 -1
- package/dist/auth/auth_guard_resolver.js +60 -26
- package/dist/auth/bearer_auth.d.ts +1 -1
- package/dist/auth/bearer_auth.js +1 -1
- package/dist/auth/middleware.js +1 -1
- package/dist/auth/request_context.d.ts +54 -24
- package/dist/auth/request_context.d.ts.map +1 -1
- package/dist/auth/request_context.js +117 -39
- package/dist/auth/token_scope.d.ts +109 -21
- package/dist/auth/token_scope.d.ts.map +1 -1
- package/dist/auth/token_scope.js +106 -21
- package/dist/http/CLAUDE.md +23 -11
- package/dist/http/auth_shape.d.ts +58 -3
- package/dist/http/auth_shape.d.ts.map +1 -1
- package/dist/http/auth_shape.js +63 -5
- package/dist/http/error_schemas.d.ts +24 -1
- package/dist/http/error_schemas.d.ts.map +1 -1
- package/dist/http/error_schemas.js +36 -14
- package/dist/http/route_spec.d.ts +29 -26
- package/dist/http/route_spec.d.ts.map +1 -1
- package/dist/http/route_spec.js +12 -16
- package/dist/http/surface_query.d.ts +2 -2
- package/dist/http/surface_query.js +2 -2
- package/dist/server/serve_fact_route.d.ts.map +1 -1
- package/dist/server/serve_fact_route.js +12 -10
- package/dist/testing/CLAUDE.md +39 -3
- package/dist/testing/cross_backend/conformance_case.js +1 -1
- package/dist/testing/cross_backend/testing_backdoor.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_backdoor.js +6 -5
- package/dist/testing/cross_backend/token_scope_surface.d.ts +9 -0
- package/dist/testing/cross_backend/token_scope_surface.d.ts.map +1 -0
- package/dist/testing/cross_backend/token_scope_surface.js +170 -0
- package/dist/testing/integration.d.ts.map +1 -1
- package/dist/testing/integration.js +3 -2
- package/dist/testing/rpc_attack_surface.d.ts.map +1 -1
- package/dist/testing/rpc_attack_surface.js +4 -2
- package/package.json +1 -1
|
@@ -1,56 +1,90 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Auth guard resolver for the route spec system.
|
|
3
3
|
*
|
|
4
|
-
* Maps the
|
|
5
|
-
* `credential_types`) to two-phase middleware sets that
|
|
4
|
+
* Maps the five-axis `RouteAuth` (`account` / `actor` / `roles` /
|
|
5
|
+
* `credential_types` / `required_scope`) to two-phase middleware sets that
|
|
6
6
|
* `apply_route_specs` weaves into the per-route pipeline:
|
|
7
7
|
*
|
|
8
|
-
* - `
|
|
9
|
-
*
|
|
10
|
-
* 'required'`
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
8
|
+
* - `pre_authorization` runs before the authorization phase, and holds every
|
|
9
|
+
* gate that reads only what the auth middleware already set: `require_auth`
|
|
10
|
+
* whenever `auth.account === 'required'` or `auth.actor === 'required'`
|
|
11
|
+
* (per registry-time invariant 3, `actor: 'required'` today implies a
|
|
12
|
+
* credential — accountless actors are out of scope for v1),
|
|
13
|
+
* `require_credential_types(types)` whenever `auth.credential_types?.length`,
|
|
14
|
+
* and the token-scope guard whenever `auth.required_scope` names a capability.
|
|
15
|
+
* All three fire before any body parsing, so a caller they refuse never sees
|
|
16
|
+
* route-shape information — and before the authorization phase, so a wrong
|
|
17
|
+
* channel costs no actor resolution.
|
|
18
|
+
* - `post_authorization` runs after the dispatcher's authorization phase has
|
|
19
|
+
* populated `RequestContext`, and holds the one gate that needs it:
|
|
20
|
+
* `require_role(roles)`, whenever `auth.roles?.length`, reads
|
|
21
|
+
* `c.var.request_context.role_grants`.
|
|
22
|
+
*
|
|
23
|
+
* The resulting order — credential presence, then credential type, then scope,
|
|
24
|
+
* then role — is the spine's canonical coarse-to-fine ordering, the same one
|
|
25
|
+
* `check_action_auth_post_authorization` runs for actions. A route declaring
|
|
26
|
+
* both a credential gate and a surface gate therefore answers with the
|
|
27
|
+
* coarser fact ("this channel may never call me") rather than the finer one
|
|
28
|
+
* ("your token is scoped"), on both dispatch surfaces alike.
|
|
19
29
|
*
|
|
20
30
|
* Public routes (`auth.account === 'none' && auth.actor === 'none'`)
|
|
21
31
|
* yield empty guard arrays. `'optional'` axes contribute no
|
|
22
|
-
* pre-
|
|
32
|
+
* pre-authorization 401; the authorization phase sets `RequestContext`
|
|
23
33
|
* to whatever the credential supports and the post-authorization
|
|
24
34
|
* gates decide whether the actor's role_grants / credential type match.
|
|
25
35
|
*
|
|
26
36
|
* @module
|
|
27
37
|
*/
|
|
28
|
-
import { require_auth, require_credential_types, require_role } from './request_context.js';
|
|
38
|
+
import { require_auth, require_credential_types, require_role, require_token_scope } from './request_context.js';
|
|
39
|
+
import { is_public_auth } from '../http/auth_shape.js';
|
|
29
40
|
/**
|
|
30
41
|
* Standard auth guard resolver for fuz_app.
|
|
31
42
|
*
|
|
32
|
-
* Reads each axis of the
|
|
33
|
-
*
|
|
43
|
+
* Reads each axis of the `RouteAuth` shape and emits the corresponding
|
|
44
|
+
* middleware:
|
|
34
45
|
*
|
|
35
|
-
* - `account === 'required'` or `actor === 'required'` → pre-
|
|
46
|
+
* - `account === 'required'` or `actor === 'required'` → pre-authorization `require_auth`
|
|
47
|
+
* - `credential_types?.length` → pre-authorization `require_credential_types(types)`
|
|
48
|
+
* - `required_scope` → pre-authorization `require_token_scope(capability)`
|
|
36
49
|
* - `roles?.length` → post-authorization `require_role(roles)` (multi-role any-of)
|
|
37
|
-
* - `credential_types?.length` → post-authorization `require_credential_types(types)`
|
|
38
50
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
51
|
+
* Guards run in declaration order, which is the coarse-to-fine order above:
|
|
52
|
+
* presence first (an anonymous caller holds neither a credential type nor a
|
|
53
|
+
* scope to check), then the channel, then the scope, then the role — the last
|
|
54
|
+
* being the only one that needs the resolved `RequestContext`.
|
|
55
|
+
*
|
|
56
|
+
* @throws Error if `auth.required_scope` is not a well-formed capability string
|
|
57
|
+
* (via `require_token_scope`) — `RouteAuth` types it as a plain string to keep
|
|
58
|
+
* `http/` free of an `auth/` import, so this is where the value is checked.
|
|
59
|
+
* The check is on *shape*, not membership: the vocabulary is deliberately open
|
|
60
|
+
* so a consumer can name its own surface, and a `surface:` name decides
|
|
61
|
+
* nothing (rule 3 is all-or-nothing), so the closed set would only have been
|
|
62
|
+
* a spelling check on a diagnostic string. fuz_app's own declarations are
|
|
63
|
+
* pinned to the surfaces it actually mounts by the surface census.
|
|
64
|
+
* @throws Error if `auth.required_scope` sits on an unrestricted route
|
|
65
|
+
* (`account: 'none' && actor: 'none'`). The same holder reaches that route
|
|
66
|
+
* by dropping the credential, so the guard reads as a control and enforces
|
|
67
|
+
* nothing — the shape `compile_action_registry` refuses on an `ActionSpec`
|
|
68
|
+
* for the same reason. Extends `RouteAuth`'s leaf invariant, which the
|
|
69
|
+
* route pipeline never parses.
|
|
42
70
|
*/
|
|
43
71
|
export const fuz_auth_guard_resolver = (auth) => {
|
|
44
|
-
const
|
|
72
|
+
const pre_authorization = [];
|
|
45
73
|
const post_authorization = [];
|
|
46
74
|
if (auth.account === 'required' || auth.actor === 'required') {
|
|
47
|
-
|
|
75
|
+
pre_authorization.push(require_auth);
|
|
48
76
|
}
|
|
49
77
|
if (auth.credential_types?.length) {
|
|
50
|
-
|
|
78
|
+
pre_authorization.push(require_credential_types(auth.credential_types));
|
|
79
|
+
}
|
|
80
|
+
if (auth.required_scope !== undefined) {
|
|
81
|
+
if (is_public_auth(auth)) {
|
|
82
|
+
throw new Error(`auth.required_scope "${auth.required_scope}" on an unrestricted route (account: 'none', actor: 'none') — the same holder reaches it by dropping the credential, so the guard would enforce nothing. Gate the route on a credential, or drop the field.`);
|
|
83
|
+
}
|
|
84
|
+
pre_authorization.push(require_token_scope(auth.required_scope));
|
|
51
85
|
}
|
|
52
86
|
if (auth.roles?.length) {
|
|
53
87
|
post_authorization.push(require_role(auth.roles));
|
|
54
88
|
}
|
|
55
|
-
return {
|
|
89
|
+
return { pre_authorization, post_authorization };
|
|
56
90
|
};
|
|
@@ -23,7 +23,7 @@ import { type RateLimiter } from '../rate_limiter.js';
|
|
|
23
23
|
*
|
|
24
24
|
* Soft-fails for invalid, expired, or empty tokens — calls `next()` without
|
|
25
25
|
* setting account identity, letting downstream auth enforcement (the RPC
|
|
26
|
-
* dispatcher's pre-
|
|
26
|
+
* dispatcher's pre-authorization / post-authorization auth gates or
|
|
27
27
|
* `require_auth`) return a consistent JSON-RPC or route-level error. This
|
|
28
28
|
* avoids leaking token-specific diagnostics
|
|
29
29
|
* (`invalid_token`, `account_not_found`) that could aid enumeration attacks,
|
package/dist/auth/bearer_auth.js
CHANGED
|
@@ -26,7 +26,7 @@ import { rate_limit_exceeded_response } from '../rate_limiter.js';
|
|
|
26
26
|
*
|
|
27
27
|
* Soft-fails for invalid, expired, or empty tokens — calls `next()` without
|
|
28
28
|
* setting account identity, letting downstream auth enforcement (the RPC
|
|
29
|
-
* dispatcher's pre-
|
|
29
|
+
* dispatcher's pre-authorization / post-authorization auth gates or
|
|
30
30
|
* `require_auth`) return a consistent JSON-RPC or route-level error. This
|
|
31
31
|
* avoids leaking token-specific diagnostics
|
|
32
32
|
* (`invalid_token`, `account_not_found`) that could aid enumeration attacks,
|
package/dist/auth/middleware.js
CHANGED
|
@@ -53,7 +53,7 @@ export const create_auth_middleware_specs = async (deps, options) => {
|
|
|
53
53
|
// Bearer middleware soft-fails for invalid/expired tokens (calls next()
|
|
54
54
|
// without setting context). Only 429 is a hard-fail from this layer.
|
|
55
55
|
// Auth enforcement (401/403) happens downstream — the RPC dispatcher's
|
|
56
|
-
// pre-
|
|
56
|
+
// pre-authorization / post-authorization auth gates, or `require_auth` /
|
|
57
57
|
// `require_role` on REST — producing consistent JSON-RPC or
|
|
58
58
|
// route-level errors.
|
|
59
59
|
errors: { 429: RateLimitError }
|
|
@@ -38,7 +38,6 @@ import type { Context, MiddlewareHandler } from 'hono';
|
|
|
38
38
|
import type { Logger } from '@fuzdev/fuz_util/log.ts';
|
|
39
39
|
import { type Account, type Actor, type RoleGrant } from './account_schema.js';
|
|
40
40
|
import type { QueryDeps } from '../db/query_deps.js';
|
|
41
|
-
import { type TokenSurface } from './token_scope.js';
|
|
42
41
|
import type { RouteSpec } from '../http/route_spec.js';
|
|
43
42
|
import { type RouteAuth } from '../http/auth_shape.js';
|
|
44
43
|
import { ERROR_ACTOR_REQUIRED, ERROR_ACTOR_NOT_ON_ACCOUNT, ERROR_NO_ACTORS_ON_ACCOUNT, ERROR_ACCOUNT_VANISHED } from '../http/error_schemas.js';
|
|
@@ -231,22 +230,48 @@ export declare const resolve_acting_actor: (deps: QueryDeps, account_id: string,
|
|
|
231
230
|
*/
|
|
232
231
|
export declare const create_request_context_middleware: (deps: QueryDeps, log: Logger, session_context_key?: string) => MiddlewareHandler;
|
|
233
232
|
/**
|
|
234
|
-
* Refuse a non-RPC
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
* `upgradeWebSocket`'s callback
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
233
|
+
* Refuse a non-RPC surface to a narrowed token — rule 3, for callers that are
|
|
234
|
+
* not route specs. Under the Rust twin's name
|
|
235
|
+
* (`token_scope_surface_denied_response`).
|
|
236
|
+
*
|
|
237
|
+
* The WS upgrade is the spine's one such caller: `upgradeWebSocket`'s callback
|
|
238
|
+
* must return `WSEvents` and so cannot produce a denial, which is why the gate
|
|
239
|
+
* runs as pre-upgrade middleware calling this rather than as a route-spec
|
|
240
|
+
* declaration. A consumer hand-rolling its own upgrade is in the same position
|
|
241
|
+
* and reaches for the same function.
|
|
242
|
+
*
|
|
243
|
+
* Takes the whole capability string, not a bare surface name — one spelling
|
|
244
|
+
* across route-spec declarations and direct calls alike, and the twin of the
|
|
245
|
+
* Rust signature. It needs no parse: the surface arm's identifier is pure label,
|
|
246
|
+
* since rule 3 never asks *which* surface. And it is deliberately not narrowed
|
|
247
|
+
* to the spine's four — the name decides nothing, so there is nothing for a
|
|
248
|
+
* closed set to protect, while a consumer surface would have nothing to pass.
|
|
249
|
+
* fuz_app's own uses are pinned two ways by the surface census: the exact call
|
|
250
|
+
* site, and the scan asserting every `'surface:<name>'` literal in `src/lib`
|
|
251
|
+
* names a surface the spine actually mounts.
|
|
244
252
|
*
|
|
245
253
|
* @param c - the request context, after the auth middleware chain
|
|
246
|
-
* @param
|
|
254
|
+
* @param capability - the `surface:<name>` this route demands; names itself in the denial
|
|
247
255
|
* @returns the 403 to return, or `null` when the caller may proceed
|
|
248
256
|
*/
|
|
249
|
-
export declare const token_scope_surface_denial: (c: Context,
|
|
257
|
+
export declare const token_scope_surface_denial: (c: Context, capability: string) => Response | null;
|
|
258
|
+
/**
|
|
259
|
+
* Create middleware refusing a route to a credential whose scope does not admit
|
|
260
|
+
* `capability`.
|
|
261
|
+
*
|
|
262
|
+
* The route-spec form, mounted by `fuz_auth_guard_resolver` from
|
|
263
|
+
* `auth.required_scope`. Lands in the `pre_authorization` phase, so a narrowed
|
|
264
|
+
* token is told about its scope rather than about the role it also lacks — and
|
|
265
|
+
* learns nothing about the route's input shape on the way.
|
|
266
|
+
*
|
|
267
|
+
* Parses at construction, so a malformed capability is a registration-time
|
|
268
|
+
* throw rather than a guard that mounts and reports nonsense.
|
|
269
|
+
*
|
|
270
|
+
* @param capability - the capability string this route demands, e.g. `surface:audit_stream`
|
|
271
|
+
* @returns middleware that 403s a credential whose scope lacks it
|
|
272
|
+
* @throws Error if `capability` is not a well-formed `rpc:<method>` / `surface:<name>` string
|
|
273
|
+
*/
|
|
274
|
+
export declare const require_token_scope: (capability: string) => MiddlewareHandler;
|
|
250
275
|
/**
|
|
251
276
|
* Middleware that requires authentication.
|
|
252
277
|
*
|
|
@@ -284,6 +309,12 @@ export declare const require_role: (roles: ReadonlyArray<string>) => MiddlewareH
|
|
|
284
309
|
* Returns 401 if unauthenticated, 403 with
|
|
285
310
|
* `ERROR_CREDENTIAL_TYPE_REQUIRED` + `required_credential_types` echoing
|
|
286
311
|
* the spec's allowlist when the wire-side credential isn't in it.
|
|
312
|
+
*
|
|
313
|
+
* Reads only `ACCOUNT_ID_KEY` + `CREDENTIAL_TYPE_KEY`, both set by the auth
|
|
314
|
+
* middleware — never the resolved `RequestContext`. That is why
|
|
315
|
+
* `fuz_auth_guard_resolver` mounts it in the `pre_authorization` phase: a
|
|
316
|
+
* wrong channel is refused before the route resolves an actor for it, so the
|
|
317
|
+
* denial costs no DB work and discloses no account state.
|
|
287
318
|
* Body shape is symmetric with the role gate (`ERROR_INSUFFICIENT_PERMISSIONS` +
|
|
288
319
|
* `required_roles`) and matches what the RPC dispatcher's post-auth
|
|
289
320
|
* gate emits for the same condition. Today's only credential gate is
|
|
@@ -403,8 +434,8 @@ export type AuthorizationResult = {
|
|
|
403
434
|
* Apply the dispatcher's authorization phase against the flat-record
|
|
404
435
|
* `RouteAuth` shape. Shared by the route-spec wrapper, the HTTP RPC
|
|
405
436
|
* dispatcher, and the per-message WS dispatcher. Phase order:
|
|
406
|
-
* pre-
|
|
407
|
-
*
|
|
437
|
+
* pre-authorization 401 → authorization phase → post-authorization 403 →
|
|
438
|
+
* input validation 400.
|
|
408
439
|
*
|
|
409
440
|
* Pure data — the function does not touch a Hono context. Each transport
|
|
410
441
|
* passes `account_id` (extracted from its own credential surface) and
|
|
@@ -419,7 +450,7 @@ export type AuthorizationResult = {
|
|
|
419
450
|
* never see a `RequestContext`.
|
|
420
451
|
* - `account_id == null` on any non-public route → same null
|
|
421
452
|
* `request_context`. The `'required'` callers were already rejected at
|
|
422
|
-
* the pre-
|
|
453
|
+
* the pre-authorization gate in the dispatcher; only genuine anonymous
|
|
423
454
|
* access on an `'optional'` axis lands here.
|
|
424
455
|
* - `actor === 'none'` → builds account-only context via
|
|
425
456
|
* `build_account_context`. Null lookup → `account_vanished` 500 failure.
|
|
@@ -437,16 +468,15 @@ export declare const apply_authorization_phase: (deps: QueryDeps, account_id: st
|
|
|
437
468
|
/**
|
|
438
469
|
* Create the route-spec authorization handler used by `apply_route_specs`.
|
|
439
470
|
*
|
|
440
|
-
* Reads `acting`
|
|
441
|
-
*
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
* skip the phase entirely.
|
|
471
|
+
* Reads the `acting` selector via `read_route_acting` — `c.var.validated_query`
|
|
472
|
+
* on GETs, the raw body on mutations, since input validation now runs after
|
|
473
|
+
* the authority gates. Public routes (`auth.account === 'none' &&
|
|
474
|
+
* auth.actor === 'none'`) skip the phase entirely.
|
|
445
475
|
*
|
|
446
476
|
* Per registry-time invariant 2, `auth.actor !== 'none'` ⟺ the input
|
|
447
|
-
* (or query) schema declares `acting?: ActingActor` — so
|
|
448
|
-
*
|
|
449
|
-
*
|
|
477
|
+
* (or query) schema declares `acting?: ActingActor` — so the selector is
|
|
478
|
+
* present on exactly the specs that read it, and input validation is what
|
|
479
|
+
* rejects a malformed one.
|
|
450
480
|
*
|
|
451
481
|
* Resolved contexts land on `REQUEST_CONTEXT_KEY` so the post-authorization
|
|
452
482
|
* REST middleware (`require_role`, `require_credential_types`) reads the
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"request_context.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/request_context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;AACvD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAEtD,OAAO,EACN,KAAK,OAAO,EACZ,KAAK,KAAK,EAEV,KAAK,SAAS,EACd,MAAM,qBAAqB,CAAC;AAY7B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;
|
|
1
|
+
{"version":3,"file":"request_context.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/request_context.ts"],"names":[],"mappings":"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"}
|
|
@@ -39,8 +39,9 @@ import { hash_session_token, session_touch_fire_and_forget, query_session_get_va
|
|
|
39
39
|
import { query_account_by_id, query_actor_by_id, query_active_actors_by_account } from './account_queries.js';
|
|
40
40
|
import { query_role_grant_find_active_for_actor } from './role_grant_queries.js';
|
|
41
41
|
import { ACCOUNT_ID_KEY, AUTH_API_TOKEN_ID_KEY, CREDENTIAL_TYPE_KEY, TOKEN_SCOPE_KEY, TEST_CONTEXT_PRESET_KEY } from '../hono_context.js';
|
|
42
|
-
import {
|
|
43
|
-
import { is_public_auth, needs_actor } from '../http/auth_shape.js';
|
|
42
|
+
import { parse_token_scope_capability, token_scope_admits_capability, token_scope_denied_body, token_scope_full } from './token_scope.js';
|
|
43
|
+
import { is_public_auth, needs_actor, parse_acting } from '../http/auth_shape.js';
|
|
44
|
+
import { is_null_schema } from '../http/schema_helpers.js';
|
|
44
45
|
import { ERROR_AUTHENTICATION_REQUIRED, ERROR_INSUFFICIENT_PERMISSIONS, ERROR_CREDENTIAL_TYPE_REQUIRED, ERROR_ACTOR_REQUIRED, ERROR_ACTOR_NOT_ON_ACCOUNT, ERROR_NO_ACTORS_ON_ACCOUNT, ERROR_ACCOUNT_VANISHED } from '../http/error_schemas.js';
|
|
45
46
|
/** Hono context variable name for the request context. */
|
|
46
47
|
export const REQUEST_CONTEXT_KEY = 'request_context';
|
|
@@ -240,27 +241,78 @@ export const create_request_context_middleware = (deps, log, session_context_key
|
|
|
240
241
|
};
|
|
241
242
|
};
|
|
242
243
|
/**
|
|
243
|
-
* Refuse a
|
|
244
|
+
* Refuse a capability the calling credential's scope does not admit.
|
|
244
245
|
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
* `
|
|
246
|
+
* The decision every token-scope gate outside the action dispatcher shares.
|
|
247
|
+
* Private because the two exported forms below are the whole surface: a route
|
|
248
|
+
* spec declares `auth.required_scope` and gets `require_token_scope`; anything
|
|
249
|
+
* that is not a route spec calls `token_scope_surface_denial`.
|
|
248
250
|
*
|
|
249
251
|
* An unset `TOKEN_SCOPE_KEY` is the anonymous caller, who holds no credential to
|
|
250
252
|
* narrow, so it passes. Sharing that branch is the point — hand-rolled per
|
|
251
253
|
* surface it is one `&&` from either refusing every anonymous read or admitting
|
|
252
254
|
* an authenticated caller whose middleware forgot to set the key.
|
|
255
|
+
*/
|
|
256
|
+
const token_scope_denial = (c, capability) => {
|
|
257
|
+
const scope = c.get(TOKEN_SCOPE_KEY);
|
|
258
|
+
if (scope && !token_scope_admits_capability(scope, capability)) {
|
|
259
|
+
return c.json(token_scope_denied_body(capability.capability), 403);
|
|
260
|
+
}
|
|
261
|
+
return null;
|
|
262
|
+
};
|
|
263
|
+
/**
|
|
264
|
+
* Refuse a non-RPC surface to a narrowed token — rule 3, for callers that are
|
|
265
|
+
* not route specs. Under the Rust twin's name
|
|
266
|
+
* (`token_scope_surface_denied_response`).
|
|
267
|
+
*
|
|
268
|
+
* The WS upgrade is the spine's one such caller: `upgradeWebSocket`'s callback
|
|
269
|
+
* must return `WSEvents` and so cannot produce a denial, which is why the gate
|
|
270
|
+
* runs as pre-upgrade middleware calling this rather than as a route-spec
|
|
271
|
+
* declaration. A consumer hand-rolling its own upgrade is in the same position
|
|
272
|
+
* and reaches for the same function.
|
|
273
|
+
*
|
|
274
|
+
* Takes the whole capability string, not a bare surface name — one spelling
|
|
275
|
+
* across route-spec declarations and direct calls alike, and the twin of the
|
|
276
|
+
* Rust signature. It needs no parse: the surface arm's identifier is pure label,
|
|
277
|
+
* since rule 3 never asks *which* surface. And it is deliberately not narrowed
|
|
278
|
+
* to the spine's four — the name decides nothing, so there is nothing for a
|
|
279
|
+
* closed set to protect, while a consumer surface would have nothing to pass.
|
|
280
|
+
* fuz_app's own uses are pinned two ways by the surface census: the exact call
|
|
281
|
+
* site, and the scan asserting every `'surface:<name>'` literal in `src/lib`
|
|
282
|
+
* names a surface the spine actually mounts.
|
|
253
283
|
*
|
|
254
284
|
* @param c - the request context, after the auth middleware chain
|
|
255
|
-
* @param
|
|
285
|
+
* @param capability - the `surface:<name>` this route demands; names itself in the denial
|
|
256
286
|
* @returns the 403 to return, or `null` when the caller may proceed
|
|
257
287
|
*/
|
|
258
|
-
export const token_scope_surface_denial = (c,
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
288
|
+
export const token_scope_surface_denial = (c, capability) => token_scope_denial(c, { kind: 'surface', capability });
|
|
289
|
+
/**
|
|
290
|
+
* Create middleware refusing a route to a credential whose scope does not admit
|
|
291
|
+
* `capability`.
|
|
292
|
+
*
|
|
293
|
+
* The route-spec form, mounted by `fuz_auth_guard_resolver` from
|
|
294
|
+
* `auth.required_scope`. Lands in the `pre_authorization` phase, so a narrowed
|
|
295
|
+
* token is told about its scope rather than about the role it also lacks — and
|
|
296
|
+
* learns nothing about the route's input shape on the way.
|
|
297
|
+
*
|
|
298
|
+
* Parses at construction, so a malformed capability is a registration-time
|
|
299
|
+
* throw rather than a guard that mounts and reports nonsense.
|
|
300
|
+
*
|
|
301
|
+
* @param capability - the capability string this route demands, e.g. `surface:audit_stream`
|
|
302
|
+
* @returns middleware that 403s a credential whose scope lacks it
|
|
303
|
+
* @throws Error if `capability` is not a well-formed `rpc:<method>` / `surface:<name>` string
|
|
304
|
+
*/
|
|
305
|
+
export const require_token_scope = (capability) => {
|
|
306
|
+
const parsed = parse_token_scope_capability(capability);
|
|
307
|
+
if (parsed === null) {
|
|
308
|
+
throw new Error(`auth.required_scope "${capability}" is not a valid token-scope capability \u2014 expected 'rpc:<method>' or 'surface:<name>', where a surface name is lowercase letters and underscores`);
|
|
262
309
|
}
|
|
263
|
-
return
|
|
310
|
+
return async (c, next) => {
|
|
311
|
+
const denied = token_scope_denial(c, parsed);
|
|
312
|
+
if (denied)
|
|
313
|
+
return denied;
|
|
314
|
+
await next();
|
|
315
|
+
};
|
|
264
316
|
};
|
|
265
317
|
/**
|
|
266
318
|
* Middleware that requires authentication.
|
|
@@ -315,6 +367,12 @@ export const require_role = (roles) => {
|
|
|
315
367
|
* Returns 401 if unauthenticated, 403 with
|
|
316
368
|
* `ERROR_CREDENTIAL_TYPE_REQUIRED` + `required_credential_types` echoing
|
|
317
369
|
* the spec's allowlist when the wire-side credential isn't in it.
|
|
370
|
+
*
|
|
371
|
+
* Reads only `ACCOUNT_ID_KEY` + `CREDENTIAL_TYPE_KEY`, both set by the auth
|
|
372
|
+
* middleware — never the resolved `RequestContext`. That is why
|
|
373
|
+
* `fuz_auth_guard_resolver` mounts it in the `pre_authorization` phase: a
|
|
374
|
+
* wrong channel is refused before the route resolves an actor for it, so the
|
|
375
|
+
* denial costs no DB work and discloses no account state.
|
|
318
376
|
* Body shape is symmetric with the role gate (`ERROR_INSUFFICIENT_PERMISSIONS` +
|
|
319
377
|
* `required_roles`) and matches what the RPC dispatcher's post-auth
|
|
320
378
|
* gate emits for the same condition. Today's only credential gate is
|
|
@@ -421,8 +479,8 @@ export const build_account_context = async (deps, account_id) => {
|
|
|
421
479
|
* Apply the dispatcher's authorization phase against the flat-record
|
|
422
480
|
* `RouteAuth` shape. Shared by the route-spec wrapper, the HTTP RPC
|
|
423
481
|
* dispatcher, and the per-message WS dispatcher. Phase order:
|
|
424
|
-
* pre-
|
|
425
|
-
*
|
|
482
|
+
* pre-authorization 401 → authorization phase → post-authorization 403 →
|
|
483
|
+
* input validation 400.
|
|
426
484
|
*
|
|
427
485
|
* Pure data — the function does not touch a Hono context. Each transport
|
|
428
486
|
* passes `account_id` (extracted from its own credential surface) and
|
|
@@ -437,7 +495,7 @@ export const build_account_context = async (deps, account_id) => {
|
|
|
437
495
|
* never see a `RequestContext`.
|
|
438
496
|
* - `account_id == null` on any non-public route → same null
|
|
439
497
|
* `request_context`. The `'required'` callers were already rejected at
|
|
440
|
-
* the pre-
|
|
498
|
+
* the pre-authorization gate in the dispatcher; only genuine anonymous
|
|
441
499
|
* access on an `'optional'` axis lands here.
|
|
442
500
|
* - `actor === 'none'` → builds account-only context via
|
|
443
501
|
* `build_account_context`. Null lookup → `account_vanished` 500 failure.
|
|
@@ -457,7 +515,7 @@ export const apply_authorization_phase = async (deps, account_id, auth, acting_v
|
|
|
457
515
|
if (account_id == null) {
|
|
458
516
|
// Optional-auth route hit without a credential — leave `RequestContext`
|
|
459
517
|
// null so the handler can branch on it. `'required'` callers already
|
|
460
|
-
// got rejected at the pre-
|
|
518
|
+
// got rejected at the pre-authorization gate.
|
|
461
519
|
return { ok: true, request_context: null };
|
|
462
520
|
}
|
|
463
521
|
if (!needs_actor(auth)) {
|
|
@@ -496,16 +554,15 @@ export const apply_authorization_phase = async (deps, account_id, auth, acting_v
|
|
|
496
554
|
/**
|
|
497
555
|
* Create the route-spec authorization handler used by `apply_route_specs`.
|
|
498
556
|
*
|
|
499
|
-
* Reads `acting`
|
|
500
|
-
*
|
|
501
|
-
*
|
|
502
|
-
*
|
|
503
|
-
* skip the phase entirely.
|
|
557
|
+
* Reads the `acting` selector via `read_route_acting` — `c.var.validated_query`
|
|
558
|
+
* on GETs, the raw body on mutations, since input validation now runs after
|
|
559
|
+
* the authority gates. Public routes (`auth.account === 'none' &&
|
|
560
|
+
* auth.actor === 'none'`) skip the phase entirely.
|
|
504
561
|
*
|
|
505
562
|
* Per registry-time invariant 2, `auth.actor !== 'none'` ⟺ the input
|
|
506
|
-
* (or query) schema declares `acting?: ActingActor` — so
|
|
507
|
-
*
|
|
508
|
-
*
|
|
563
|
+
* (or query) schema declares `acting?: ActingActor` — so the selector is
|
|
564
|
+
* present on exactly the specs that read it, and input validation is what
|
|
565
|
+
* rejects a malformed one.
|
|
509
566
|
*
|
|
510
567
|
* Resolved contexts land on `REQUEST_CONTEXT_KEY` so the post-authorization
|
|
511
568
|
* REST middleware (`require_role`, `require_credential_types`) reads the
|
|
@@ -523,7 +580,7 @@ export const create_fuz_authorization_handler = (deps) => {
|
|
|
523
580
|
return;
|
|
524
581
|
if (is_public_auth(spec.auth))
|
|
525
582
|
return;
|
|
526
|
-
const acting_value = needs_actor(spec.auth) ?
|
|
583
|
+
const acting_value = needs_actor(spec.auth) ? await read_route_acting(c, spec) : undefined;
|
|
527
584
|
const account_id = c.get(ACCOUNT_ID_KEY) ?? null;
|
|
528
585
|
const result = await apply_authorization_phase(deps, account_id, spec.auth, acting_value);
|
|
529
586
|
if (!result.ok)
|
|
@@ -538,22 +595,43 @@ export const create_fuz_authorization_handler = (deps) => {
|
|
|
538
595
|
};
|
|
539
596
|
};
|
|
540
597
|
/**
|
|
541
|
-
* Read `acting`
|
|
542
|
-
*
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
546
|
-
*
|
|
547
|
-
*
|
|
548
|
-
*
|
|
549
|
-
* `
|
|
598
|
+
* Read the `acting` actor selector for the authorization phase.
|
|
599
|
+
*
|
|
600
|
+
* REST is bi-located: GETs declare `acting` on `query`, mutations on `input`.
|
|
601
|
+
* Query validation still runs ahead of the authorization phase, so the GET
|
|
602
|
+
* half reads a typed Zod field. Input validation now runs *after* the
|
|
603
|
+
* authority gates (nothing about a route's body shape reaches a caller those
|
|
604
|
+
* gates refuse), so the mutation half reads the raw body instead.
|
|
605
|
+
*
|
|
606
|
+
* The raw read mirrors `create_input_validation`'s own skip conditions exactly
|
|
607
|
+
* — GETs and null-input specs — so this never parses a body the route itself
|
|
608
|
+
* would have left alone. That matters for the routes carrying raw bytes rather
|
|
609
|
+
* than JSON (git smart-HTTP, binary uploads): they declare `input: z.null()`,
|
|
610
|
+
* so their stream reaches the handler untouched, and they carry `acting` on
|
|
611
|
+
* `query` regardless.
|
|
612
|
+
*
|
|
613
|
+
* `parse_acting` owns what counts as a selector and why a malformed one reads
|
|
614
|
+
* as omitted.
|
|
615
|
+
*
|
|
616
|
+
* @param c - the request context, after query validation
|
|
617
|
+
* @param spec - the route being dispatched; decides where `acting` can live
|
|
618
|
+
* @returns the actor id, or `undefined` when absent, malformed, or unreadable
|
|
550
619
|
*/
|
|
551
|
-
const
|
|
552
|
-
const validated_input = c.get('validated_input');
|
|
553
|
-
if (validated_input && typeof validated_input.acting === 'string')
|
|
554
|
-
return validated_input.acting;
|
|
620
|
+
const read_route_acting = async (c, spec) => {
|
|
555
621
|
const validated_query = c.get('validated_query');
|
|
556
622
|
if (validated_query && typeof validated_query.acting === 'string')
|
|
557
623
|
return validated_query.acting;
|
|
558
|
-
|
|
624
|
+
if (spec.method === 'GET' || is_null_schema(spec.input))
|
|
625
|
+
return undefined;
|
|
626
|
+
let body;
|
|
627
|
+
try {
|
|
628
|
+
body = await c.req.json();
|
|
629
|
+
}
|
|
630
|
+
catch {
|
|
631
|
+
// Malformed body — input validation answers with the 400.
|
|
632
|
+
return undefined;
|
|
633
|
+
}
|
|
634
|
+
if (typeof body !== 'object' || body === null)
|
|
635
|
+
return undefined;
|
|
636
|
+
return parse_acting(body.acting);
|
|
559
637
|
};
|