@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
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
/**
|
|
3
|
+
* Cross-backend parity suite for **token scoping's non-RPC surface rule** —
|
|
4
|
+
* *a narrowed api token is RPC-only*.
|
|
5
|
+
*
|
|
6
|
+
* ## Why this suite exists
|
|
7
|
+
*
|
|
8
|
+
* Every existing cross-impl token-scope assertion is the `rpc:<method>` arm
|
|
9
|
+
* (the `token_scope_cases` batch in the conformance security slate). The
|
|
10
|
+
* `surface:<name>` arm had **none** — and it is the load-bearing half of the
|
|
11
|
+
* design. Both spines implement it, at the same named surfaces, returning a
|
|
12
|
+
* denial body that is an explicit cross-impl contract
|
|
13
|
+
* (`token_scope_denied_body` ↔ `fuz_auth::token_scope_surface_denied_response`),
|
|
14
|
+
* and nothing compared the two.
|
|
15
|
+
*
|
|
16
|
+
* That gap has the shape this control family keeps finding: the per-method arm
|
|
17
|
+
* was the one everybody looked at, so it was the one that got a gate. The
|
|
18
|
+
* surface arm is where a divergence could sit indefinitely — the schema gate
|
|
19
|
+
* can't see it (no column), the action-manifest gate can't see it (no method),
|
|
20
|
+
* and the spec-derived suites can't see it (these are REST routes, not the
|
|
21
|
+
* declared RPC surface).
|
|
22
|
+
*
|
|
23
|
+
* ## What it pins
|
|
24
|
+
*
|
|
25
|
+
* - **The denial body, byte for byte** — status, `error`, and the exact
|
|
26
|
+
* `required_scope` capability string, per surface. A spine that renamed a
|
|
27
|
+
* surface, dropped the `surface:` prefix, or emitted the JSON-RPC envelope
|
|
28
|
+
* from a REST route fails here.
|
|
29
|
+
* - **Scope outranks role** — a narrowed token whose account *also* lacks the
|
|
30
|
+
* gating role must hear `token_scope_required`, not
|
|
31
|
+
* `insufficient_permissions`. This is the observable the TS↔Rust ordering
|
|
32
|
+
* divergence produced before both spines converged on running the surface
|
|
33
|
+
* gate ahead of the role gate; it was explicitly *invisible* to the
|
|
34
|
+
* cross-backend suites, because every scoped-token probe used a token whose
|
|
35
|
+
* account happened to hold the role.
|
|
36
|
+
*
|
|
37
|
+
* Two non-vacuity controls hold the negatives honest: the narrowed token must
|
|
38
|
+
* still reach the RPC method it *does* name (otherwise a spine that rejected
|
|
39
|
+
* the credential outright would pass every denial), and a **full** bearer must
|
|
40
|
+
* reach the same surface (otherwise a route broken for all bearers would pass
|
|
41
|
+
* too).
|
|
42
|
+
*
|
|
43
|
+
* ## Why the WS upgrade isn't here
|
|
44
|
+
*
|
|
45
|
+
* Rule 3's third probeable surface is the WebSocket upgrade, and it is
|
|
46
|
+
* deliberately omitted: `create_ws_transport` threads cookies, not bearer
|
|
47
|
+
* headers, and a refused upgrade surfaces as a thrown connection error whose
|
|
48
|
+
* body — the thing this suite exists to compare — isn't readable. The two HTTP
|
|
49
|
+
* surfaces are where the denial body is observable, which is what the parity
|
|
50
|
+
* gate needs. The WS gate keeps its per-spine coverage (fuz_app's surface
|
|
51
|
+
* census pins the call site; the Rust census pins its own).
|
|
52
|
+
*
|
|
53
|
+
* `$lib`-free by contract (relative specifiers only), like the sibling
|
|
54
|
+
* cross-backend suites.
|
|
55
|
+
*
|
|
56
|
+
* @module
|
|
57
|
+
*/
|
|
58
|
+
import { describe, assert } from 'vitest';
|
|
59
|
+
import { account_token_create_action_spec } from '../../auth/account_action_specs.js';
|
|
60
|
+
import { ERROR_TOKEN_SCOPE_REQUIRED } from '../../http/error_schemas.js';
|
|
61
|
+
import { test_if } from './capabilities.js';
|
|
62
|
+
import { cross_rpc_call } from './cell_cross_helpers.js';
|
|
63
|
+
import { SPINE_RPC_PATH } from './spine_surface_constants.js';
|
|
64
|
+
/** Default audit-log SSE path — the `audit_stream` surface. */
|
|
65
|
+
const DEFAULT_SSE_PATH = '/api/admin/audit/stream';
|
|
66
|
+
/**
|
|
67
|
+
* A well-formed hash that no fact has. The scope gate is a pre-authorization
|
|
68
|
+
* guard, so it fires before the handler ever looks the hash up — but *params*
|
|
69
|
+
* validation runs ahead of every auth gate (it is part of addressing the
|
|
70
|
+
* route), so the value still has to parse.
|
|
71
|
+
*/
|
|
72
|
+
const ABSENT_FACT_HASH = `blake3:${'0'.repeat(64)}`;
|
|
73
|
+
/** The single RPC method the narrowed tokens in this suite admit. */
|
|
74
|
+
const ADMITTED_METHOD = 'account_verify';
|
|
75
|
+
/** The flat denial body both spines must return, for a given surface. */
|
|
76
|
+
const surface_denial_body = (surface) => ({
|
|
77
|
+
error: ERROR_TOKEN_SCOPE_REQUIRED,
|
|
78
|
+
required_scope: `surface:${surface}`
|
|
79
|
+
});
|
|
80
|
+
/**
|
|
81
|
+
* Mint a narrowed bearer token over a session, through the production
|
|
82
|
+
* `account_token_create` path.
|
|
83
|
+
*
|
|
84
|
+
* Deliberately not a fixture-level credential: minting it here means the token
|
|
85
|
+
* is produced by the same handler a real minter would use, on whichever
|
|
86
|
+
* account the caller chose — which is what lets the ordering case put a
|
|
87
|
+
* narrowed token on an account that lacks the gating role.
|
|
88
|
+
*/
|
|
89
|
+
const mint_narrowed_token = async (transport, rpc_path, session_headers) => {
|
|
90
|
+
const res = await cross_rpc_call(transport, rpc_path, account_token_create_action_spec.method, { scope: { kind: 'methods', methods: [ADMITTED_METHOD] } }, session_headers);
|
|
91
|
+
assert.ok(res.ok, `minting a narrowed token failed: ${JSON.stringify(res.error)}`);
|
|
92
|
+
const token = res.result?.token;
|
|
93
|
+
assert.ok(typeof token === 'string' && token.length > 0, 'mint returned no token');
|
|
94
|
+
return token;
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Assert a bearer GET is refused with the canonical surface denial.
|
|
98
|
+
*
|
|
99
|
+
* `origin: null` so the bearer credential isn't discarded as browser context,
|
|
100
|
+
* and a fresh jar so no session cookie rides along and answers instead.
|
|
101
|
+
*/
|
|
102
|
+
const assert_surface_denied = async (fresh_transport, path, token, surface) => {
|
|
103
|
+
const res = await fresh_transport({ origin: null })(path, {
|
|
104
|
+
headers: { authorization: `Bearer ${token}` }
|
|
105
|
+
});
|
|
106
|
+
assert.strictEqual(res.status, 403, `${path} must refuse a narrowed token with 403`);
|
|
107
|
+
const body = await res.json();
|
|
108
|
+
assert.deepStrictEqual(body, surface_denial_body(surface), `${path} must return the canonical flat surface denial`);
|
|
109
|
+
};
|
|
110
|
+
export const describe_token_scope_surface_cross_tests = (options) => {
|
|
111
|
+
const { setup_test, capabilities } = options;
|
|
112
|
+
const rpc_path = options.rpc_path ?? SPINE_RPC_PATH;
|
|
113
|
+
const sse_path = options.sse_path ?? DEFAULT_SSE_PATH;
|
|
114
|
+
describe('token scope non-RPC surface parity', () => {
|
|
115
|
+
/**
|
|
116
|
+
* The non-vacuity control for every denial below: a narrowed token is
|
|
117
|
+
* still a working credential. Without this, a spine that rejected the
|
|
118
|
+
* token outright — or never minted it — would pass all four negatives.
|
|
119
|
+
*/
|
|
120
|
+
test_if(true, 'a narrowed token still reaches the method it names', async () => {
|
|
121
|
+
const fixture = await setup_test();
|
|
122
|
+
const token = await mint_narrowed_token(fixture.transport, rpc_path, fixture.create_session_headers());
|
|
123
|
+
const res = await cross_rpc_call(fixture.fresh_transport({ origin: null }), rpc_path, ADMITTED_METHOD, undefined, { authorization: `Bearer ${token}` });
|
|
124
|
+
assert.ok(res.ok, `admitted method must succeed: ${JSON.stringify(res.error)}`);
|
|
125
|
+
});
|
|
126
|
+
test_if(capabilities.sse, 'a narrowed token is refused the audit stream (surface:audit_stream)', async () => {
|
|
127
|
+
const fixture = await setup_test();
|
|
128
|
+
const token = await mint_narrowed_token(fixture.transport, rpc_path, fixture.create_session_headers());
|
|
129
|
+
await assert_surface_denied(fixture.fresh_transport, sse_path, token, 'audit_stream');
|
|
130
|
+
});
|
|
131
|
+
test_if(capabilities.fact_serving, 'a narrowed token is refused the bare-hash fact read (surface:fact_bare)', async () => {
|
|
132
|
+
const fixture = await setup_test();
|
|
133
|
+
const token = await mint_narrowed_token(fixture.transport, rpc_path, fixture.create_session_headers());
|
|
134
|
+
await assert_surface_denied(fixture.fresh_transport, `/api/facts/${ABSENT_FACT_HASH}`, token, 'fact_bare');
|
|
135
|
+
});
|
|
136
|
+
/**
|
|
137
|
+
* The surface-level non-vacuity control. A **full** bearer on the same
|
|
138
|
+
* route must get past the scope gate — proving the denial above is the
|
|
139
|
+
* scope refusing a narrowed token, not the route refusing every bearer.
|
|
140
|
+
* A 404 (no such fact) is the expected outcome; anything that isn't the
|
|
141
|
+
* scope denial satisfies the property, so the assertion is on what the
|
|
142
|
+
* response must *not* be.
|
|
143
|
+
*/
|
|
144
|
+
test_if(capabilities.fact_serving, 'a full token is not refused by the scope gate on the same route', async () => {
|
|
145
|
+
const fixture = await setup_test();
|
|
146
|
+
const res = await fixture.fresh_transport({ origin: null })(`/api/facts/${ABSENT_FACT_HASH}`, { headers: fixture.create_bearer_headers() });
|
|
147
|
+
const body = (await res.json().catch(() => undefined));
|
|
148
|
+
assert.notStrictEqual(body?.error, ERROR_TOKEN_SCOPE_REQUIRED, 'a full-authority bearer must not be refused on scope grounds');
|
|
149
|
+
});
|
|
150
|
+
/**
|
|
151
|
+
* **Scope outranks role**, and this is the case that had no cross-impl
|
|
152
|
+
* pin while the two spines disagreed about it.
|
|
153
|
+
*
|
|
154
|
+
* The surfaces are role-gated, so a narrowed token on an account without
|
|
155
|
+
* the role could be refused by either gate. The scope gate must answer:
|
|
156
|
+
* it is the coarser fact about a credential that was never going to be
|
|
157
|
+
* admitted, and `insufficient_permissions` would instead report on the
|
|
158
|
+
* deployment's role structure to that credential. Every other
|
|
159
|
+
* scoped-token probe in the slate uses the keeper's token, whose account
|
|
160
|
+
* *holds* the role — so both orderings look identical there, which is
|
|
161
|
+
* exactly why this divergence survived as long as it did.
|
|
162
|
+
*/
|
|
163
|
+
test_if(capabilities.fact_serving, 'scope outranks role — a narrowed token lacking the role hears about its scope', async () => {
|
|
164
|
+
const fixture = await setup_test();
|
|
165
|
+
const non_admin = await fixture.create_account({ username: 'scope_before_role' });
|
|
166
|
+
const token = await mint_narrowed_token(fixture.fresh_transport(), rpc_path, non_admin.create_session_headers());
|
|
167
|
+
await assert_surface_denied(fixture.fresh_transport, `/api/facts/${ABSENT_FACT_HASH}`, token, 'fact_bare');
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"integration.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/integration.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAsB7B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAMhE,OAAO,EAKN,KAAK,uBAAuB,EAC5B,MAAM,kBAAkB,CAAC;AAkB1B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACzD,OAAO,EAAW,KAAK,mBAAmB,EAAE,MAAM,iCAAiC,CAAC;AACpF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,0BAA0B,CAAC;AAG1D;;GAEG;AACH,MAAM,WAAW,8BAA8B;IAC9C;;;;OAIG;IACH,UAAU,EAAE,SAAS,CAAC;IACtB;;;;;;;;;OASG;IACH,cAAc,EAAE,cAAc,CAAC;IAC/B,kEAAkE;IAClE,YAAY,EAAE,mBAAmB,CAAC;IAClC;;;;OAIG;IACH,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC;;;;;;;;;;;;OAYG;IACH,aAAa,EAAE,uBAAuB,CAAC;IACvC;;;;;;;OAOG;IACH,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,eAAO,MAAM,mCAAmC,GAC/C,SAAS,8BAA8B,KACrC,
|
|
1
|
+
{"version":3,"file":"integration.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/integration.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAsB7B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAMhE,OAAO,EAKN,KAAK,uBAAuB,EAC5B,MAAM,kBAAkB,CAAC;AAkB1B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACzD,OAAO,EAAW,KAAK,mBAAmB,EAAE,MAAM,iCAAiC,CAAC;AACpF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,0BAA0B,CAAC;AAG1D;;GAEG;AACH,MAAM,WAAW,8BAA8B;IAC9C;;;;OAIG;IACH,UAAU,EAAE,SAAS,CAAC;IACtB;;;;;;;;;OASG;IACH,cAAc,EAAE,cAAc,CAAC;IAC/B,kEAAkE;IAClE,YAAY,EAAE,mBAAmB,CAAC;IAClC;;;;OAIG;IACH,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC;;;;;;;;;;;;OAYG;IACH,aAAa,EAAE,uBAAuB,CAAC;IACvC;;;;;;;OAOG;IACH,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,eAAO,MAAM,mCAAmC,GAC/C,SAAS,8BAA8B,KACrC,IA86CF,CAAC"}
|
|
@@ -685,8 +685,9 @@ export const describe_standard_integration_tests = (options) => {
|
|
|
685
685
|
// Pick an admin REST route where a clean role-denial 403 is
|
|
686
686
|
// reachable: account-grain (`actor: 'none'`, so the authorization
|
|
687
687
|
// phase doesn't first demand an acting actor → `actor_required`)
|
|
688
|
-
// and input/param/query-free (
|
|
689
|
-
//
|
|
688
|
+
// and input/param/query-free (params and query still validate ahead
|
|
689
|
+
// of the auth phase — only body validation moved behind the gates,
|
|
690
|
+
// per the 401 → authz → 403 → 400 order). Admin surfaces are
|
|
690
691
|
// RPC-first now; a consumer may expose no such REST route (fuz_app
|
|
691
692
|
// itself, zzz) — then this REST-era check is a no-op and RPC denial
|
|
692
693
|
// is covered by `describe_rpc_attack_surface_tests`.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rpc_attack_surface.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/rpc_attack_surface.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAkB7B,OAAO,KAAK,EAGX,cAAc,EACd,MAAM,oBAAoB,CAAC;AA0B5B,uDAAuD;AACvD,MAAM,WAAW,uBAAuB;IACvC,+FAA+F;IAC/F,KAAK,EAAE,MAAM,cAAc,CAAC;IAC5B,yDAAyD;IACzD,KAAK,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CACrB;
|
|
1
|
+
{"version":3,"file":"rpc_attack_surface.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/rpc_attack_surface.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAkB7B,OAAO,KAAK,EAGX,cAAc,EACd,MAAM,oBAAoB,CAAC;AA0B5B,uDAAuD;AACvD,MAAM,WAAW,uBAAuB;IACvC,+FAA+F;IAC/F,KAAK,EAAE,MAAM,cAAc,CAAC;IAC5B,yDAAyD;IACzD,KAAK,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CACrB;AA8dD;;;;;;;;;GASG;AACH,eAAO,MAAM,iCAAiC,GAAI,SAAS,uBAAuB,KAAG,IAOpF,CAAC"}
|
|
@@ -83,8 +83,10 @@ const describe_rpc_auth = (options) => {
|
|
|
83
83
|
const app = apps.by_role.get(wrong_role);
|
|
84
84
|
if (!app)
|
|
85
85
|
throw new Error(`No test app for role '${wrong_role}'`);
|
|
86
|
-
// Send valid params so
|
|
87
|
-
//
|
|
86
|
+
// Send valid params so the probe varies only the role. Dispatcher
|
|
87
|
+
// ordering is 401 → authz → 403 → 400, so the role gate trips
|
|
88
|
+
// ahead of validation regardless; valid params also keep the
|
|
89
|
+
// authorization phase from answering `actor_required` first.
|
|
88
90
|
const action = find_rpc_action(rpc_endpoint_specs, endpoint.path, method.name);
|
|
89
91
|
const params = action ? generate_valid_body(action.spec.input) : undefined;
|
|
90
92
|
const res = await app.request(endpoint.path, create_rpc_post_init(method.name, params));
|