diff --git a/docs/architecture/decisions/048-dispatch-spine-gateway-module.md b/docs/architecture/decisions/048-dispatch-spine-gateway-module.md index ae45422..9744dc2 100644 --- a/docs/architecture/decisions/048-dispatch-spine-gateway-module.md +++ b/docs/architecture/decisions/048-dispatch-spine-gateway-module.md @@ -129,43 +129,73 @@ Amended: threading is opt-in, additive. - **The door contract** (review 012's compensation pattern, blessed - as a doc-contract line rather than accepted as the durable answer - for anything beyond the no-ownership-model case): the ADR-011 - static fallback is correct *only* where a deployment genuinely has - no runtime ownership model. A door (alkhttp feature doors, the LFS - and `git` doors, hub/relay surfaces) serving an op whose - authorization truth is a revocable grant table MUST wire the same - provider the native wire path consults — visible-surface = - authorized-surface. Without a wiring, `GatewayDispatch` on an - External namespace-ACL op silently admits token-carried static - claims: the spine cannot distinguish "no ownership model in this - deployment" from "forgot to wire it", which is why the contract is - a doc line and the loud default below is a separate, surveyed - decision. + as a doc-contract line and — since the loud default below ruled — + enforced by the spine): the ADR-011 static fallback is correct + *only* where a deployment genuinely has no runtime ownership model. + A door (alkhttp feature doors, the LFS and `git` doors, hub/relay + surfaces) serving an op whose authorization truth is a revocable + grant table MUST wire the same provider the native wire path + consults — visible-surface = authorized-surface. -- **Deferred, not decided here — the loud default:** making the spine - refuse (an error envelope) an op whose spec carries - `resource_id_path` when no provider is wired. The refusal is - mechanically precise (the spine can see the spec), but it changes - default outcomes for ownership-relevant ops — consumer-visible — - and needs the blast radius surveyed (which doors route such ops - through `GatewayDispatch` today) before it is ruled on. Tracked in - review 012; decided after the survey. Until then the door contract - above is the answer in force. +- **The loud default (ruled 2026-10-17 — review 012):** the spine + refuses (an error envelope, `FORBIDDEN`) a dispatched op whose spec + carries `access_control.resource_type` when no ownership provider is + wired — `invoke`, `invoke_streaming`, and `invoke_sink` alike, + pre-dispatch. The survey (this session) found the blast radius + minimal: alkhttp is the only live `GatewayDispatch` consumer in the + family and it is unpublished, its three construction sites wire no + provider, and alkblobs is the only live op producer with + `resource_type` ACLs (its own serving sides are native-wire and + correctly wired). The refusal message is operator-directed (names + the wired opt-ins); fail-closed and indistinguishable in code from a + normal ACL denial. + + **Predicate refinement:** the refusal predicate is + `access_control.resource_type.is_some()`, not + `resource_id_path.is_some()` as review 012 first drafted. + `AccessControl::check` consults the provider whenever the resource + type is set — including the `list` case (`owns_any`) where + `resource_id_path` is `None`, which silently admits any + token-carried static claim too. `resource_action`-only ACLs are + exempt: no provider is consulted for those even on the native wire, + so static resolution there is family-wide semantics, not gateway + drift. Internal-visibility ops are also exempt (the registry answers + the spec-404; the refusal must not acknowledge a never-visible op). + + **The escape hatch:** `with_static_ownership_fallback()` — a + declared opt-in to the ADR-011 static `Identity.resources` fallback + for deployments that genuinely have no runtime ownership model. This + turns the door contract from a doc line into an enforceable + construct: the static fallback survives only as a code-review-visible + declaration, never as the accidental default. The default spine is + loud for `resource_type`-gated ops; wiring + `with_ownership_provider` keeps the live grant table. Verification: the in-crate regression tests -(`gateway_without_provider_admits_only_the_static_claim`, -`gateway_with_provider_consults_the_live_grant_table`, -`gateway_streaming_with_provider_denies_a_static_claim_only_identity`, -all in `src/gateway/dispatch.rs`) pin the static-fallback behavior, -the live-table override, and revocation live on the gateway surface; -POC-L's `tests/gateway_gap.rs` re-run against the published 0.9.0 -remains green (the default path is unchanged). +(`gateway_with_provider_consults_the_live_grant_table`, +`gateway_streaming_with_provider_denies_a_static_claim_only_identity`) +pin the live-table override and revocation live on the gateway +surface; `gateway_refuses_a_resource_gated_op_without_a_provider`, +`gateway_streaming_and_sink_refuse_a_resource_gated_op_without_a_provider`, +and `gateway_static_fallback_opt_out_serves_resource_gated_ops` pin +the refusal (all three entry points), the opt-out, and the preserved +static-fallback mechanics under the declaration; +`gateway_refusal_spares_action_only_internal_and_unknown_ops` pins the +predicate exemptions (the pre-ruling pin +`gateway_without_provider_admits_only_the_static_claim` is superseded — +its unwired-spine-admits behavior is now the refusal and the opt-out +tests). POC-L's `tests/gateway_gap.rs`, written against published +0.9.0, pinned the pre-ruling gap: under this ruling its +unwired-spine second branch now answers `FORBIDDEN` pre-dispatch — +the POC is archival evidence of the hole, not a living conformance +suite; re-run it against the wired or opt-out spine if it is revived. Semver note: additive — committed to main without a release (no 0.9.x publish); it rides the next release batch (0.10.0's class), keeping the per-alkcall-release downstream churn to one coordinated -update. +update. The loud default ships in the same unreleased batch as the +review-012 amendment, so no published alkcall version ever carried the +silent unwired-spine answer past 0.9.0. ### What the spine deliberately does NOT include diff --git a/docs/reviews/012-gateway-ownership-seam-consumer-input.md b/docs/reviews/012-gateway-ownership-seam-consumer-input.md index 657b714..2a8e56b 100644 --- a/docs/reviews/012-gateway-ownership-seam-consumer-input.md +++ b/docs/reviews/012-gateway-ownership-seam-consumer-input.md @@ -2,14 +2,22 @@ ## Status -Resolved — filed 2026-10-17, resolved same session. Report-only -consumer input; resolution shape 1 is implemented this session -(ADR-048 amendment "Ownership provider threading"; the threading, -the in-crate regression tests, and the re-run of POC-L's -`tests/gateway_gap.rs` against published 0.9.0 are listed there). -Committed unreleased — additive work rides the next release batch -(0.10.0's class), keeping the six-consumer downstream churn to one -coordinated update. +Resolved — filed 2026-10-17, resolved in two sessions. Report-only +consumer input; resolution shape 1 is implemented (ADR-048 amendment +"Ownership provider threading"; the threading, the in-crate regression +tests, and the re-run of POC-L's `tests/gateway_gap.rs` against +published 0.9.0 are listed there). The deferred loud-default ruling +(shape below, decision 2) was ruled in the follow-up session, after +the blast-radius survey: **adopt, with a refined predicate and an +explicit opt-out** — the spine refuses (`FORBIDDEN`, pre-dispatch, all +three entry points) an op whose spec gates on `access_control.resource_type` +when no provider is wired and no static fallback is declared; the +predicate is `resource_type` (covering the `list`/`owns_any` case the +draft wording missed), and `with_static_ownership_fallback()` is the +declared escape hatch for genuinely-static deployments. Implemented in +the same unreleased batch (ADR-048 "The loud default" section; commit +rides the 0.10.0 release class), keeping the six-consumer downstream +churn to one coordinated update. Two decisions recorded here as required by the resolution session: @@ -22,17 +30,20 @@ Two decisions recorded here as required by the resolution session: record-carried-grants pattern (alkgit ADR-011, alkfs, alkdocker) is explicitly **not** an alkcall item — pattern-level now, micro-crate if a third consumer shows up. -2. **Deferred, not decided:** the loud default — refusing (an error - envelope) an op whose spec has `resource_id_path` when no provider - is wired inside the dispatch spine. It is mechanically precise - (the spine can see the spec) but consumer-visible; the blast - radius must be surveyed first (which doors route - `resource_id_path` ops through `GatewayDispatch` today — known - case: alkblobs' `blobs/stat` via alkhttp). Ruled on in a - subsequent session after that survey; until then the door - contract (wire the provider, or hand-assemble the context) is the - answer in force. This ruling, once made, belongs in the same - release batch — the reason it is decided before 0.10.0 publishes. +2. **Adopted (ruled in the follow-up session, superseding the + original deferral):** the loud default — refusing (an error + envelope) an op whose spec gates on `resource_type` when no + provider is wired inside the dispatch spine. Survey outcome: the + blast radius is minimal (alkhttp is the only live spine consumer + and is unpublished; alkblobs is the only live `resource_type` op + producer and serves those ops native-wire with its providers + wired; no published consumer of 0.9.0's spine relies on the + silent fallback for such ops). Two refinements against the draft + wording: the predicate is `resource_type` not `resource_id_path` + (the `list` case silently falls back too), and the loud default + carries an explicit `with_static_ownership_fallback()` opt-out so + the door contract is enforceable, not just documented. Full ruling + in ADR-048's "The loud default" section. Input basis: diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 874505d..3d6c388 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -27,7 +27,7 @@ dependencies = [ [[package]] name = "alkcall" -version = "0.8.1" +version = "0.9.0" dependencies = [ "async-trait", "bytes", diff --git a/src/gateway/dispatch.rs b/src/gateway/dispatch.rs index 72ce4db..2f92ab2 100644 --- a/src/gateway/dispatch.rs +++ b/src/gateway/dispatch.rs @@ -10,7 +10,9 @@ //! None` — wire-ingress only), resolves the registration's //! composition authority / capabilities / scoped env into it, carries //! the spine-wide ownership provider when one is wired (ADR-011; the -//! ADR-048 amendment — review 012's door contract), and bounds Once-ops +//! ADR-048 amendment — review 012's door contract), refuses a +//! `resource_type`-gated op served without a provider or a declared +//! static fallback (review 012's loud-default ruling), and bounds Once-ops //! and sink dispatch with a configurable deadline //! while leaving streaming subscriptions unbounded (ADR-021: //! subscriptions are long-lived). @@ -59,6 +61,7 @@ pub struct GatewayDispatch { registry: Arc, deadline: Option, ownership_provider: Option>, + static_ownership_fallback: bool, invoke_count: AtomicUsize, } @@ -70,6 +73,7 @@ impl GatewayDispatch { registry, deadline: Some(DEFAULT_DEADLINE), ownership_provider: None, + static_ownership_fallback: false, invoke_count: AtomicUsize::new(0), } } @@ -77,20 +81,36 @@ impl GatewayDispatch { /// Wire the ownership provider the spine's root contexts carry /// (ADR-011): an op whose `access_control` gates on a /// `resource_type` then consults this provider with the - /// `resource_id_path`-extracted resource ID at check time — the - /// live, revocable grant table — instead of the static - /// `Identity.resources` fallback. The door contract: any door - /// serving ops whose authorization truth is a dynamic grant table - /// MUST wire the same provider the native wire path consults — - /// visible-surface = authorized-surface (review 012 / ADR-048 - /// amendment). The default ([`GatewayDispatch::new`]) is `None` — - /// the ADR-011 static fallback, correct only where the deployment - /// genuinely has no runtime ownership model. + /// `resource_id_path`-extracted resource ID at check time (the + /// live, revocable grant table) — or `owns_any` for the `list` + /// case — instead of the static `Identity.resources` fallback. + /// The door contract: any door serving ops whose authorization + /// truth is a dynamic grant table MUST wire the same provider the + /// native wire path consults — visible-surface = + /// authorized-surface (review 012 / ADR-048 amendment). The + /// default ([`GatewayDispatch::new`]) is `None`, and the default + /// policy is loud: without a provider or a declared + /// [`GatewayDispatch::with_static_ownership_fallback`], a + /// `resource_type`-gated op is refused (an error envelope, + /// review 012's ruling). pub fn with_ownership_provider(mut self, provider: Arc) -> Self { self.ownership_provider = Some(provider); self } + /// Declare this deployment's static `Identity.resources` fallback + /// as a deliberate choice: ops whose `access_control` gates on a + /// `resource_type` are served through the ADR-011 static-fallback + /// check (token-carried claims), not refused for the missing + /// provider wiring. Use this only where the deployment genuinely + /// has no runtime ownership model — review 012's door contract + /// makes this a code-review-visible decision, not the accidental + /// default. + pub fn with_static_ownership_fallback(mut self) -> Self { + self.static_ownership_fallback = true; + self + } + /// Override the Once/sink deadline: `Some(duration)` bounds every /// [`GatewayDispatch::invoke`] and [`GatewayDispatch::invoke_sink`] /// dispatch (a hung handler surfaces as a retryable `TIMEOUT` error @@ -131,6 +151,14 @@ impl GatewayDispatch { return ResponseEnvelope::error(uuid::Uuid::new_v4().to_string(), error); } let request_id = uuid::Uuid::new_v4().to_string(); + if let Some(error) = ownership_wiring_refusal( + &self.registry, + &operation_name, + self.ownership_provider.as_ref(), + self.static_ownership_fallback, + ) { + return ResponseEnvelope::error(request_id, error); + } let context = self.build_root_context(&request_id, &operation_name, identity); let fut = self.registry.invoke(&operation_name, input, context); match self.deadline { @@ -166,6 +194,16 @@ impl GatewayDispatch { })); } let request_id = uuid::Uuid::new_v4().to_string(); + if let Some(error) = ownership_wiring_refusal( + &self.registry, + &operation_name, + self.ownership_provider.as_ref(), + self.static_ownership_fallback, + ) { + return Box::pin(futures::stream::once(async move { + ResponseEnvelope::error(request_id, error) + })); + } let context = self.build_root_context_streaming(&request_id, &operation_name, identity); self.registry .invoke_streaming(&operation_name, input, context) @@ -186,6 +224,14 @@ impl GatewayDispatch { ) -> ResponseEnvelope { let operation_name = strip_leading_slash(op).to_string(); let request_id = uuid::Uuid::new_v4().to_string(); + if let Some(error) = ownership_wiring_refusal( + &self.registry, + &operation_name, + self.ownership_provider.as_ref(), + self.static_ownership_fallback, + ) { + return ResponseEnvelope::error(request_id, error); + } let context = self.build_root_context_sink(&request_id, &operation_name, identity); let fut = self .registry @@ -297,6 +343,44 @@ fn schema_via_call_denial( schema_disclosure_denial(registry, name, identity) } +/// The loud ownership-wiring default (review 012's ruling, ADR-048 +/// amendment): when the dispatched op's `access_control` gates on a +/// `resource_type` and no ownership provider is wired on the spine, +/// refuse with `FORBIDDEN` rather than silently applying the ADR-011 +/// static `Identity.resources` fallback — admission-by-claim at a door +/// is exactly the visible-surface ≠ authorized-surface drift. The +/// predicate is `resource_type` (not `resource_id_path`): `AccessControl::check` +/// consults the provider whenever the resource type is set, including +/// the `list` case (`owns_any`) where `resource_id_path` is `None`. +/// `resource_action`-only ACLs are exempt — no provider is consulted +/// for those even on the native wire, so static resolution there is +/// family-wide semantics, not gateway drift. Internal-visibility ops +/// are exempt too (the registry's dispatch answers `NOT_FOUND`; the +/// refusal must not acknowledge a never-visible op). The +/// [`GatewayDispatch::with_static_ownership_fallback`] declaration is +/// the deliberate escape hatch. +fn ownership_wiring_refusal( + registry: &OperationRegistry, + operation: &str, + ownership_provider: Option<&Arc>, + static_ownership_fallback: bool, +) -> Option { + if ownership_provider.is_some() || static_ownership_fallback { + return None; + } + let registration = registry.registration(operation)?; + let spec = ®istration.spec; + if spec.visibility == Visibility::Internal || spec.access_control.resource_type.is_none() { + return None; + } + let resource_type = spec.access_control.resource_type.as_deref().unwrap_or(""); + Some(CallError::forbidden(format!( + "operation '{operation}' gates on resource ownership ({resource_type}), but no ownership \ + provider is wired on this dispatch spine; the deployment must wire \ + GatewayDispatch::with_ownership_provider or declare with_static_ownership_fallback" + ))) +} + /// The schema-disclosure check shared by every transport surface: /// Internal ops are invisible (`NOT_FOUND` regardless of caller); /// ACL-forbidden ops are denied with `FORBIDDEN`. One implementation so @@ -858,35 +942,13 @@ mod tests { } } - #[tokio::test] - async fn gateway_without_provider_admits_only_the_static_claim() { - let dispatch = GatewayDispatch::new(namespace_registry()); - let input = serde_json::json!({ "namespace": "ns-1" }); - - let envelope = dispatch - .invoke( - Some(identity_with_static_claim("token-carrier", true)), - "ns/stat", - input.clone(), - ) - .await; - assert!( - envelope.result.is_ok(), - "the static fallback admits the token-carried claim, got {envelope:?}" - ); - - let envelope = dispatch - .invoke( - Some(identity_with_static_claim("stranger", false)), - "ns/stat", - input, - ) - .await; - match envelope.result { - Err(error) => assert_eq!(error.code, "FORBIDDEN"), - Ok(v) => panic!("the static fallback denies a claimless caller, got {v:?}"), - } - } + // Review 012's ruling replaces the pre-ruling pin + // (`gateway_without_provider_admits_only_the_static_claim`): the + // loud default refuses the unwired spine (see + // `gateway_refuses_a_resource_gated_op_without_a_provider`), the + // opt-out keeps the fallback (see + // `gateway_static_fallback_opt_out_serves_resource_gated_ops`), + // and the wired provider keeps the live-table behavior below. #[tokio::test] async fn gateway_with_provider_consults_the_live_grant_table() { @@ -986,4 +1048,218 @@ mod tests { Ok(v) => panic!("the provider gate applies to the streaming path too, got {v:?}"), } } + + // --- Review 012 ruling: the loud ownership-wiring default ------------ + + #[tokio::test] + async fn gateway_refuses_a_resource_gated_op_without_a_provider() { + let dispatch = GatewayDispatch::new(namespace_registry()); + let envelope = dispatch + .invoke( + Some(identity_with_static_claim("token-carrier", true)), + "ns/stat", + serde_json::json!({ "namespace": "ns-1" }), + ) + .await; + match envelope.result { + Err(error) => { + assert_eq!(error.code, "FORBIDDEN"); + assert!( + error.message.contains("with_static_ownership_fallback"), + "the refusal names the deliberate opt-out, got {error:?}" + ); + assert!( + error.message.contains("blob-namespace"), + "the refusal names the resource type, got {error:?}" + ); + } + Ok(v) => panic!( + "the loud default must refuse a resource-gated op with no wired provider, got {v:?}" + ), + } + } + + #[tokio::test] + async fn gateway_streaming_and_sink_refuse_a_resource_gated_op_without_a_provider() { + let registry = OperationRegistry::new(); + let registry = { + let mut sink_spec = namespace_spec("ns/put"); + sink_spec.op_type = OperationType::Pub; + let mut stream_spec = namespace_spec("ns/stream"); + stream_spec.op_type = OperationType::Sub; + registry + .register(HandlerRegistration::new( + stream_spec, + HandlerKind::Stream(make_streaming_handler(|_input, ctx| { + let request_id = ctx.request_id.clone(); + futures::stream::once(async move { + ResponseEnvelope::ok(request_id, serde_json::json!({})) + }) + })), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + registry + .register(HandlerRegistration::new( + sink_spec, + HandlerKind::Sink(make_sink_handler(|_input, ctx, _chunks| async move { + ResponseEnvelope::ok(ctx.request_id, serde_json::json!({})) + })), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + Arc::new(registry) + }; + let dispatch = GatewayDispatch::new(Arc::clone(®istry)); + + let mut stream = dispatch.invoke_streaming( + Some(identity_with_static_claim("token-carrier", true)), + "ns/stream", + serde_json::json!({ "namespace": "ns-1" }), + ); + let envelopes: Vec = stream.by_ref().collect().await; + assert_eq!(envelopes.len(), 1, "the refusal is the only envelope"); + match &envelopes[0].result { + Err(error) => assert_eq!(error.code, "FORBIDDEN"), + Ok(v) => panic!("the streaming path refuses unwired resource-gated ops, got {v:?}"), + } + + let chunks: crate::registry::registration::PublishStream = + Box::pin(futures::stream::empty()); + let envelope = dispatch + .invoke_sink( + Some(identity_with_static_claim("token-carrier", true)), + "ns/put", + serde_json::json!({ "namespace": "ns-1" }), + chunks, + ) + .await; + match envelope.result { + Err(error) => assert_eq!(error.code, "FORBIDDEN"), + Ok(v) => panic!("the sink path refuses unwired resource-gated ops, got {v:?}"), + } + } + + #[tokio::test] + async fn gateway_static_fallback_opt_out_serves_resource_gated_ops() { + let dispatch = GatewayDispatch::new(namespace_registry()).with_static_ownership_fallback(); + let envelope = dispatch + .invoke( + Some(identity_with_static_claim("token-carrier", true)), + "ns/stat", + serde_json::json!({ "namespace": "ns-1" }), + ) + .await; + assert!( + envelope.result.is_ok(), + "the declared static fallback serves the op through the ADR-011 fallback, got {envelope:?}" + ); + + let envelope = dispatch + .invoke( + Some(identity_with_static_claim("stranger", false)), + "ns/stat", + serde_json::json!({ "namespace": "ns-1" }), + ) + .await; + match envelope.result { + Err(error) => assert_eq!(error.code, "FORBIDDEN"), + Ok(v) => panic!("the static fallback still denies a claimless caller, got {v:?}"), + } + } + + #[tokio::test] + async fn gateway_refusal_spares_action_only_internal_and_unknown_ops() { + let registry = OperationRegistry::new(); + let mut action_only = spec("act/run", Visibility::External, OperationType::Query); + action_only.access_control = AccessControl { + resource_action: Some("read".to_string()), + ..Default::default() + }; + let mut internal_gated = namespace_spec("internal/hidden"); + internal_gated.visibility = Visibility::Internal; + registry + .register(HandlerRegistration::new( + action_only, + HandlerKind::Once(make_handler(|input, ctx| async move { + ResponseEnvelope::ok(ctx.request_id, input) + })), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + registry + .register(HandlerRegistration::new( + internal_gated, + HandlerKind::Once(make_handler(|_input, ctx| async move { + ResponseEnvelope::ok(ctx.request_id, serde_json::json!({})) + })), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + let dispatch = GatewayDispatch::new(Arc::new(registry)); + + let mut action_identity_resources = HashMap::new(); + action_identity_resources.insert("some-resource".to_string(), vec!["read".to_string()]); + let envelope = dispatch + .invoke( + Some(Identity { + id: "action-holder".to_string(), + scopes: Vec::new(), + resources: action_identity_resources, + }), + "act/run", + serde_json::json!({}), + ) + .await; + assert!( + envelope.result.is_ok(), + "a resource_action-only ACL never consults a provider on the wire either — no refusal, got {envelope:?}" + ); + + let envelope = dispatch + .invoke( + Some(identity_with_static_claim("snooping", false)), + "internal/hidden", + serde_json::json!({ "namespace": "ns-1" }), + ) + .await; + match envelope.result { + Err(error) => { + assert_eq!( + error.code, "NOT_FOUND", + "an Internal op stays unacknowledged instead of the refusal" + ); + assert!( + !error.message.contains("with_static_ownership_fallback") + && !error.message.contains("blob-namespace"), + "the refusal text must not leak on an Internal op, got {error:?}" + ); + } + Ok(v) => panic!("an Internal op must never run, got {v:?}"), + } + + let envelope = dispatch + .invoke( + Some(identity_with_static_claim("anyone", false)), + "missing/op", + serde_json::json!({}), + ) + .await; + match envelope.result { + Err(error) => assert_eq!(error.code, "NOT_FOUND"), + Ok(v) => panic!("an unknown op keeps its NOT_FOUND, got {v:?}"), + } + } }