diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ab083b..ca904a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,41 @@ All notable changes to this crate are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this crate adheres to [Semantic Versioning](https://semver.org/). +## [0.8.0] — 2026-10-17 + +### Changed + +- **`alkcall` dependency bumped to 0.10.0** (the gateway ownership-seam + release, alkcall review 012) and the loud ownership default passes + through to the HTTP surface (ADR-072): + `HttpAdapter::with_ownership_provider` / + `HttpAdapter::with_static_ownership_fallback` thread the provider + into both spines this crate constructs — the gateway endpoints' + per-request spine and the MCP nest's spine (single wiring point in + `GatewayState::dispatch`). An unwired, undeclared spine now refuses + a `resource_type`-gated op with a `FORBIDDEN` envelope (**403** with + identity / **401** without) — the behavior break is inherited, not + re-decided; the WS native-session path is untouched (it consults + ownership natively). +- The gateway test-state builder threads ownership configuration; the + three dispositions (unwired refusal, live grant table, declared + static fallback) are pinned at the HTTP surface by + `call_resource_gated_*` regression tests. + +### Fixed + +- `HttpAdapter::with_ws_max_sessions` never stored the freshly built + session-slot semaphore on the adapter — any subsequent builder call + rebuilt the router with the pre-cap-change semaphore, silently + reverting the cap. Builder state is now assembled in one shared + `router_state()` helper, so every `with_*` rebuild sees the current + configuration. + +Minor bump 0.7.0 → 0.8.0 per the dep-wave convention (0.4.0 rode +alkcall 0.7.0, 0.5.0 rode 0.8.0, 0.7.0 rode 0.9.0): the re-exported +alkcall types are on this crate's public API, so a semver-moving +upstream is semver-relevant here. + ## [0.7.0] — 2026-10-14 ### Changed diff --git a/Cargo.lock b/Cargo.lock index a3ad4c9..581c86a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -27,9 +27,9 @@ dependencies = [ [[package]] name = "alkcall" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3562f1f7b348cdeb3d9181eaf57b0a2fb3f988d4280e8e225dadfd7f763cb84" +checksum = "6b8ef0591beff0e3fe64b7277a67d8e7caeccf71c27538b4b7aced028d22ca44" dependencies = [ "async-trait", "bytes", @@ -48,7 +48,7 @@ dependencies = [ [[package]] name = "alkhttp" -version = "0.7.0" +version = "0.8.0" dependencies = [ "alkcall", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index 8772f8d..2ac8000 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "alkhttp" -version = "0.7.0" +version = "0.8.0" edition = "2021" rust-version = "1.88" license = "MIT OR Apache-2.0" @@ -46,7 +46,7 @@ h2 = ["server", "dep:hyper", "hyper-util/http2", "hyper/http2"] http1 = ["server", "dep:hyper", "hyper-util/http1", "hyper/http1"] [dependencies] -alkcall = { version = "0.9", features = ["gateway"] } +alkcall = { version = "0.10", features = ["gateway"] } arc-swap = { version = "1", optional = true } axum = { version = "0.8", optional = true, features = ["ws"] } bytes = "1" diff --git a/docs/architecture/README.md b/docs/architecture/README.md index df1dfd1..ddfed41 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -69,6 +69,7 @@ types — the former `alknet-core` and `alknet-call` merged). | [069](decisions/069-webtransport-out-of-scope.md) | WebTransport Out of Scope | h3/WebTransport removed from alkhttp scope entirely (an alknet concern); supersedes the deferral framing of ADR-044 | | [070](decisions/070-from-wss-consumer-adapter.md) | `from_wss` Consumer Adapter | Import a remote node's operations over WSS — same-protocol importer, channels-over-WS as transport; `wss` feature gate | | [071](decisions/071-dispatch-schema-guard.md) | Dispatch-Spine `services/schema` Op-Path Guard | `GatewayDispatch` applies the GET `/schema` visibility+ACL checks to the meta-op's inner `name` (review-002 PRJ-16); alkcall CF-004 is the complete fix, this stays as defense-in-depth (now via the promoted `alkcall::gateway` shared check — spine + guard themselves live in alkcall per ADR-048) | +| [072](decisions/072-http-surface-ownership-wiring.md) | HTTP-Surface Ownership Wiring (Gateway + MCP) | alkcall 0.10's loud ownership default passes through: `HttpAdapter::with_ownership_provider` / `with_static_ownership_fallback` thread the provider into the gateway and MCP spines (single wiring in `GatewayState::dispatch`) | ## Relevant Open Questions diff --git a/docs/architecture/decisions/072-http-surface-ownership-wiring.md b/docs/architecture/decisions/072-http-surface-ownership-wiring.md new file mode 100644 index 0000000..47d8838 --- /dev/null +++ b/docs/architecture/decisions/072-http-surface-ownership-wiring.md @@ -0,0 +1,89 @@ +# ADR-072: HTTP-Surface Ownership Wiring (Gateway + MCP) + +## Status + +Accepted + +## Context + +alkcall 0.10.0 landed the gateway ownership seam (alkcall review 012, +downstream evidence from alkgit's LFS POC): the dispatch spine's +previously-silent static `Identity.resources` fallback becomes loud — a +`resource_type`-gated op served through an unwired spine is refused +with a `FORBIDDEN` error envelope unless the deployment wires a +runtime ownership provider +(`GatewayDispatch::with_ownership_provider`) or declares the static +fallback explicitly +(`GatewayDispatch::with_static_ownership_fallback`). The change closes +the two-authorization-paths drift: a namespace-ACL-gated op served +through an HTTP door previously ran against the token-carried static +claim, while the same op on the native wire consulted the revocable +grant table. + +alkhttp is the only live spine consumer. It constructs the spine +internally in two places — the gateway endpoints' per-request spine +(`gateway::routes::GatewayState`) and the MCP nest's spine +(`server::build_router`) — so an assembly layer wiring a provider for +the native wire cannot reach those spines through alkcall's API. Once +alkhttp rode 0.10, any `resource_type`-gated op in the registry would +break on the HTTP surface even for deployments that wired providers +correctly. + +## Decision + +**alkhttp mirrors alkcall's builder shape on `HttpAdapter` and threads +the wiring into every spine it constructs.** + +- `HttpAdapter::with_ownership_provider(Arc)` + and `HttpAdapter::with_static_ownership_fallback()` — the two + alkcall-named opt-ins, one method each, no alkcall-specific + vocabulary changed. `OwnershipProvider` is re-exported from + `alkhttp::gateway` next to `GatewayDispatch`. +- The wiring lives on `RouterState` and is threaded by + `GatewayState::dispatch()` (routes) and `RouterState::dispatch()` + (the MCP nest — the gateway routes extract `GatewayState` via + `FromRef`, so `GatewayState` is the single wiring point). +- Both spines share one wiring: the gateway endpoints and the MCP + surface are the same door contract; no separate MCP wiring exists. + +The door contract (alkcall review 012, unchanged here): any +deployment serving `resource_type`-gated ops through the HTTP surface +must wire the same provider the native wire consults — +visible-surface = authorized-surface. The loud default passes through +from alkcall unchanged: an unwired, undeclared spine refuses a +`resource_type`-gated op with the spine's `FORBIDDEN` envelope, which +the gateway error mapping serves as 403 (with identity) / 401 +(without). + +The WS native-session path needs no wiring — it speaks the native +call protocol, which consults ownership natively in alkcall's serving +layer (alkcall ADR-048). + +## Consequences + +**Positive:** + +- The HTTP surface cannot silently diverge from the native wire's + ownership truth — alkcall's loud default is inheritable at the door + layer, and `with_static_ownership_fallback` keeps the static-claims + posture a code-review-visible declaration here too. +- alkcall's builder shape is mirrored on alkhttp's builder, so an + assembly layer configures both spines the same way. + +**Negative:** + +- Deployments upgrading alkhttp to the 0.10-riding release must wire + the provider (or declare the fallback) before serving + `resource_type`-gated ops over HTTP — the 403 refusal is the + reminder, not a compile error. + +## References + +- alkcall review 012 + (`/workspace/@alkdev/alkcall/docs/reviews/012-gateway-ownership-seam-consumer-input.md`) + — the consumer evidence and the two recorded decisions +- alkcall 0.10.0 CHANGELOG (the loud-default behavior break) +- [ADR-048](048-websocket-native-session-not-gateway.md) — the WS path + this does not touch +- [Review 008](../../reviews/008-http-door-consumer-review.md) + — the same consumption round's alkhttp-filed findings (AH-1, AH-2) \ No newline at end of file diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index d785040..34d736c 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -27,9 +27,9 @@ dependencies = [ [[package]] name = "alkcall" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3562f1f7b348cdeb3d9181eaf57b0a2fb3f988d4280e8e225dadfd7f763cb84" +checksum = "6b8ef0591beff0e3fe64b7277a67d8e7caeccf71c27538b4b7aced028d22ca44" dependencies = [ "async-trait", "bytes", @@ -48,7 +48,7 @@ dependencies = [ [[package]] name = "alkhttp" -version = "0.6.0" +version = "0.8.0" dependencies = [ "alkcall", "arc-swap", @@ -1405,7 +1405,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1756,7 +1756,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2513,7 +2513,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/fuzz/shared/Cargo.toml b/fuzz/shared/Cargo.toml index 51e4fba..5067348 100644 --- a/fuzz/shared/Cargo.toml +++ b/fuzz/shared/Cargo.toml @@ -14,7 +14,7 @@ mcp = ["alkhttp/mcp"] [dependencies] alkhttp = { path = "../.." } -alkcall = "0.9" +alkcall = "0.10" serde_json = "1" bytes = "1" futures = "0.3" diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index bc057ff..8bbd119 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -1,8 +1,11 @@ pub mod error; pub mod routes; +pub use alkcall::core::ownership::OwnershipProvider; pub use alkcall::gateway::{schema_disclosure_denial, GatewayDispatch, DEFAULT_DEADLINE}; pub use routes::CallRequest; +#[cfg(feature = "mcp")] +pub(crate) use routes::GatewayState; /// The maximum number of operations accepted in a single batch, /// enforced identically by the HTTP `POST /batch` endpoint, the diff --git a/src/gateway/routes.rs b/src/gateway/routes.rs index 769ed1d..ed9d613 100644 --- a/src/gateway/routes.rs +++ b/src/gateway/routes.rs @@ -82,21 +82,30 @@ type ByteStream = futures::stream::BoxStream<'static, Result #[derive(Clone)] pub(crate) struct GatewayState { registry: Arc, + ownership_provider: Option>, + static_ownership_fallback: bool, } impl GatewayState { - pub(crate) fn new(registry: Arc) -> Self { - Self { registry } - } - - fn dispatch(&self) -> GatewayDispatch { - GatewayDispatch::new(Arc::clone(&self.registry)) + pub(crate) fn dispatch(&self) -> GatewayDispatch { + let mut dispatch = GatewayDispatch::new(Arc::clone(&self.registry)); + if let Some(provider) = &self.ownership_provider { + dispatch = dispatch.with_ownership_provider(Arc::clone(provider)); + } + if self.static_ownership_fallback { + dispatch = dispatch.with_static_ownership_fallback(); + } + dispatch } } impl FromRef for GatewayState { fn from_ref(state: &RouterState) -> Self { - GatewayState::new(Arc::clone(&state.registry)) + Self { + registry: Arc::clone(&state.registry), + ownership_provider: state.ownership_provider.clone(), + static_ownership_fallback: state.static_ownership_fallback, + } } } @@ -803,6 +812,7 @@ fn error_event(operation: &str) -> Result { mod tests { use super::*; use alkcall::core::auth::IdentityProvider; + use alkcall::core::ownership::OwnershipStore; use alkcall::core::types::Capabilities; use alkcall::registry::discovery::{ services_list_handler, services_list_spec, services_schema_handler, services_schema_spec, @@ -887,6 +897,175 @@ mod tests { make_handler(|input, ctx| async move { ResponseEnvelope::ok(ctx.request_id, input) }) } + // --- Review 012 / ADR-048 amendment: ownership wiring through the + // --- HTTP gateway surface ------------------------------------------- + + fn namespace_acl() -> AccessControl { + AccessControl { + resource_type: Some("blob-namespace".to_string()), + resource_action: Some("read".to_string()), + ..Default::default() + } + } + + fn namespace_spec(name: &str) -> OperationSpec { + let mut spec = external_spec(name, namespace_acl()); + spec.resource_id_path = Some("$.namespace".to_string()); + spec + } + + fn registry_with_namespace_gated_op() -> Arc { + let registry = OperationRegistry::new(); + registry + .register(HandlerRegistration::new( + namespace_spec("ns/stat"), + HandlerKind::Once(echo_handler()), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + Arc::new(registry) + } + + fn identity_with_resources( + id: &str, + resource_type: &str, + actions: Option>, + ) -> Identity { + let mut resources = HashMap::new(); + if let Some(actions) = actions { + resources.insert( + resource_type.to_string(), + actions.into_iter().map(|a| a.to_string()).collect(), + ); + } + Identity { + id: id.to_string(), + scopes: Vec::new(), + resources, + } + } + + async fn post_call( + router: axum::Router, + token: &str, + operation: &str, + input: serde_json::Value, + ) -> (axum::http::StatusCode, serde_json::Value) { + let req = Request::builder() + .method("POST") + .uri("/call") + .header("content-type", "application/json") + .header(axum::http::header::AUTHORIZATION, format!("Bearer {token}")) + .body(Body::from( + serde_json::to_vec(&json!({ + "operation": operation, + "input": input, + })) + .unwrap(), + )) + .unwrap(); + let (status, body) = send(router, req).await; + (status, body) + } + + #[tokio::test] + async fn call_resource_gated_op_without_provider_is_refused() { + let provider: Arc = + Arc::new(StaticIdentityProvider::new().with_token( + "tok-a", + identity_with_resources("token-carrier", "blob-namespace", Some(vec!["read"])), + )); + let router = + build_router_with_ownership(registry_with_namespace_gated_op(), provider, None, false); + let (status, body) = + post_call(router, "tok-a", "ns/stat", json!({ "namespace": "ns-1" })).await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "the loud default refuses a resource-gated op on an unwired spine even with a static claim: {body}" + ); + assert_eq!(body.get("code"), Some(&json!("FORBIDDEN"))); + } + + #[tokio::test] + async fn call_resource_gated_op_with_wired_provider_consults_the_live_grant_table() { + let store = Arc::new(alkcall::core::ownership::InMemoryOwnershipStore::new()); + store + .record( + &identity_with_resources("tenant-a", "blob-namespace", None), + "blob-namespace", + "ns-1", + ) + .await + .unwrap(); + + let idp: Arc = Arc::new( + StaticIdentityProvider::new() + .with_token( + "tok-a", + identity_with_resources("tenant-a", "blob-namespace", None), + ) + .with_token( + "tok-claim", + identity_with_resources("token-carrier", "blob-namespace", Some(vec!["read"])), + ), + ); + let router = build_router_with_ownership( + registry_with_namespace_gated_op(), + Arc::clone(&idp), + Some(store), + false, + ); + + let (status, body) = post_call( + router.clone(), + "tok-a", + "ns/stat", + json!({ "namespace": "ns-1" }), + ) + .await; + assert_eq!( + status, + StatusCode::OK, + "a live grant admits without any static claim on the identity: {body}" + ); + + let (status, body) = post_call( + router, + "tok-claim", + "ns/stat", + json!({ "namespace": "ns-1" }), + ) + .await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "a token-carried static claim is denied without a live grant: {body}" + ); + assert_eq!(body.get("code"), Some(&json!("FORBIDDEN"))); + } + + #[tokio::test] + async fn call_resource_gated_op_with_declared_static_fallback_serves_the_static_claim() { + let provider: Arc = + Arc::new(StaticIdentityProvider::new().with_token( + "tok-a", + identity_with_resources("token-carrier", "blob-namespace", Some(vec!["read"])), + )); + let router = + build_router_with_ownership(registry_with_namespace_gated_op(), provider, None, true); + let (status, body) = + post_call(router, "tok-a", "ns/stat", json!({ "namespace": "ns-1" })).await; + assert_eq!( + status, + StatusCode::OK, + "the declared fallback keeps the ADR-011 static-claim check: {body}" + ); + } + fn registry_with_echo() -> Arc { let registry = OperationRegistry::new(); registry @@ -1126,10 +1305,21 @@ mod tests { fn build_router( registry: Arc, provider: Arc, + ) -> axum::Router { + build_router_with_ownership(registry, provider, None, false) + } + + fn build_router_with_ownership( + registry: Arc, + provider: Arc, + ownership_provider: Option>, + static_ownership_fallback: bool, ) -> axum::Router { let state = RouterState { registry: Arc::clone(®istry), identity_provider: Arc::clone(&provider), + ownership_provider, + static_ownership_fallback, decoy: crate::server::DecoyConfig::NotFound, openapi_doc: crate::server::adapter::CachedOpenAPIDoc::new(®istry), ws_sessions: Arc::new(crate::websocket::WsSessions::new()), diff --git a/src/server/adapter.rs b/src/server/adapter.rs index eceaf35..ec6b97a 100644 --- a/src/server/adapter.rs +++ b/src/server/adapter.rs @@ -89,6 +89,8 @@ pub const RESERVED_PATHS: &[&str] = &[ pub struct HttpAdapter { identity_provider: Arc, registry: Arc, + ownership_provider: Option>, + static_ownership_fallback: bool, decoy: DecoyConfig, extra_routes: Option, alpn: &'static [u8], @@ -133,6 +135,8 @@ impl HttpAdapter { let state = RouterState { registry: Arc::clone(®istry), identity_provider: Arc::clone(&identity_provider), + ownership_provider: None, + static_ownership_fallback: false, decoy: decoy.clone(), openapi_doc: openapi_doc.clone(), ws_sessions: Arc::clone(&ws_sessions), @@ -145,6 +149,8 @@ impl HttpAdapter { Self { identity_provider, registry, + ownership_provider: None, + static_ownership_fallback: false, decoy, extra_routes: None, alpn, @@ -162,23 +168,8 @@ impl HttpAdapter { /// Set the decoy surface for unregistered paths and rebuild the /// router (custom routes are preserved — SRV-05). pub fn with_decoy(mut self, decoy: DecoyConfig) -> Self { - self.decoy = decoy.clone(); - let state = RouterState { - registry: Arc::clone(&self.registry), - identity_provider: Arc::clone(&self.identity_provider), - decoy, - openapi_doc: self.openapi_doc.clone(), - ws_sessions: Arc::clone(&self.ws_sessions), - ws_session_slots: Arc::clone(&self.ws_session_slots), - ws_idle_timeout: self.ws_idle_timeout, - ws_openable_alpns: self.ws_openable_alpns.clone(), - ws_op_register_acl: self.ws_op_register_acl.clone(), - }; - // `extra_routes` is borrowed, not consumed (SRV-05): a builder - // call after `with_extra_routes` must keep the custom routes in - // the rebuild — `with_extra_routes` stores a verified clone, so - // re-merging it is safe. - self.router = build_router(state, self.extra_routes.clone()); + self.decoy = decoy; + self.router = build_router(self.router_state(), self.extra_routes.clone()); self } @@ -186,18 +177,11 @@ impl HttpAdapter { /// middleware (ADR-046) and rebuild the router. Reserved paths /// (`RESERVED_PATHS`) are rejected before the merge. pub fn with_extra_routes(mut self, routes: Router) -> Self { - let state = RouterState { - registry: Arc::clone(&self.registry), - identity_provider: Arc::clone(&self.identity_provider), - decoy: self.decoy.clone(), - openapi_doc: self.openapi_doc.clone(), - ws_sessions: Arc::clone(&self.ws_sessions), - ws_session_slots: Arc::clone(&self.ws_session_slots), - ws_idle_timeout: self.ws_idle_timeout, - ws_openable_alpns: self.ws_openable_alpns.clone(), - ws_op_register_acl: self.ws_op_register_acl.clone(), - }; - self.router = build_router(state, Some(routes.clone())); + // `extra_routes` is borrowed, not consumed (SRV-05): a builder + // call after `with_extra_routes` must keep the custom routes in + // the rebuild — `with_extra_routes` stores a verified clone, so + // re-merging it is safe. + self.router = build_router(self.router_state(), Some(routes.clone())); self.extra_routes = Some(routes); self } @@ -211,18 +195,8 @@ impl HttpAdapter { /// Default: [`crate::websocket::DEFAULT_WS_MAX_SESSIONS`] (64). pub fn with_ws_max_sessions(mut self, max_sessions: usize) -> Self { self.ws_max_sessions = max_sessions; - let state = RouterState { - registry: Arc::clone(&self.registry), - identity_provider: Arc::clone(&self.identity_provider), - decoy: self.decoy.clone(), - openapi_doc: self.openapi_doc.clone(), - ws_sessions: Arc::clone(&self.ws_sessions), - ws_session_slots: Self::rebuild_session_slots(max_sessions), - ws_idle_timeout: self.ws_idle_timeout, - ws_openable_alpns: self.ws_openable_alpns.clone(), - ws_op_register_acl: self.ws_op_register_acl.clone(), - }; - self.router = build_router(state, self.extra_routes.clone()); + self.ws_session_slots = Self::rebuild_session_slots(max_sessions); + self.router = build_router(self.router_state(), self.extra_routes.clone()); self } @@ -244,17 +218,7 @@ impl HttpAdapter { /// Default: [`crate::websocket::DEFAULT_WS_IDLE_TIMEOUT`] (60 s). pub fn with_ws_idle_timeout(mut self, idle_timeout: Option) -> Self { self.ws_idle_timeout = idle_timeout; - let state = RouterState { - registry: Arc::clone(&self.registry), - identity_provider: Arc::clone(&self.identity_provider), - decoy: self.decoy.clone(), - openapi_doc: self.openapi_doc.clone(), - ws_sessions: Arc::clone(&self.ws_sessions), - ws_session_slots: Arc::clone(&self.ws_session_slots), - ws_idle_timeout: self.ws_idle_timeout, - ws_openable_alpns: self.ws_openable_alpns.clone(), - ws_op_register_acl: self.ws_op_register_acl.clone(), - }; + let state = self.router_state(); self.router = build_router(state, self.extra_routes.clone()); self } @@ -296,17 +260,7 @@ impl HttpAdapter { openables: Vec, ) -> Self { self.ws_openable_alpns = Some(openables.into()); - let state = RouterState { - registry: Arc::clone(&self.registry), - identity_provider: Arc::clone(&self.identity_provider), - decoy: self.decoy.clone(), - openapi_doc: self.openapi_doc.clone(), - ws_sessions: Arc::clone(&self.ws_sessions), - ws_session_slots: Arc::clone(&self.ws_session_slots), - ws_idle_timeout: self.ws_idle_timeout, - ws_openable_alpns: self.ws_openable_alpns.clone(), - ws_op_register_acl: self.ws_op_register_acl.clone(), - }; + let state = self.router_state(); self.router = build_router(state, self.extra_routes.clone()); self } @@ -327,17 +281,7 @@ impl HttpAdapter { /// extension instead. pub fn with_ws_op_register_acl(mut self, acl: alkcall::registry::spec::AccessControl) -> Self { self.ws_op_register_acl = acl; - let state = RouterState { - registry: Arc::clone(&self.registry), - identity_provider: Arc::clone(&self.identity_provider), - decoy: self.decoy.clone(), - openapi_doc: self.openapi_doc.clone(), - ws_sessions: Arc::clone(&self.ws_sessions), - ws_session_slots: Arc::clone(&self.ws_session_slots), - ws_idle_timeout: self.ws_idle_timeout, - ws_openable_alpns: self.ws_openable_alpns.clone(), - ws_op_register_acl: self.ws_op_register_acl.clone(), - }; + let state = self.router_state(); self.router = build_router(state, self.extra_routes.clone()); self } @@ -348,6 +292,60 @@ impl HttpAdapter { Arc::new(tokio::sync::Semaphore::new(max_sessions)) } + /// The router state assembled from the adapter's current + /// configuration; every builder's rebuild goes through here. + fn router_state(&self) -> RouterState { + RouterState { + registry: Arc::clone(&self.registry), + identity_provider: Arc::clone(&self.identity_provider), + ownership_provider: self.ownership_provider.clone(), + static_ownership_fallback: self.static_ownership_fallback, + decoy: self.decoy.clone(), + openapi_doc: self.openapi_doc.clone(), + ws_sessions: Arc::clone(&self.ws_sessions), + ws_session_slots: Arc::clone(&self.ws_session_slots), + ws_idle_timeout: self.ws_idle_timeout, + ws_openable_alpns: self.ws_openable_alpns.clone(), + ws_op_register_acl: self.ws_op_register_acl.clone(), + } + } + + /// Wire the ownership provider (alkcall 0.10 review 012 / ADR-048 + /// amendment) every dispatch spine this adapter serves consults — + /// the gateway endpoints' spine and the MCP nest's spine alike. + /// The door contract applies unchanged: any HTTP-door deployment + /// serving `resource_type`-gated ops through the gateway or MCP + /// surface must wire the same provider the native wire path + /// consults (visible-surface = authorized-surface). Without a + /// provider (or a declared [`Self::with_static_ownership_fallback`]) + /// a `resource_type`-gated op served over HTTP is refused with a + /// `FORBIDDEN` envelope — the spine's loud default. + /// + /// Default: `None` (no provider wired). + pub fn with_ownership_provider( + mut self, + provider: Arc, + ) -> Self { + self.ownership_provider = Some(provider); + self.router = build_router(self.router_state(), self.extra_routes.clone()); + self + } + + /// Declare the static `Identity.resources` fallback + /// ([`Self::with_ownership_provider`]'s doc for the disposition + /// space): ops whose `access_control` gates on a `resource_type` + /// are served on the HTTP surface through the token-carried static + /// claims instead of being refused for the missing provider wiring. + /// A code-review-visible declaration of "no runtime ownership model + /// here," never the accidental default. + /// + /// Default: `false` (the loud default is active). + pub fn with_static_ownership_fallback(mut self) -> Self { + self.static_ownership_fallback = true; + self.router = build_router(self.router_state(), self.extra_routes.clone()); + self + } + /// The configured decoy surface (assembly introspection). pub fn decoy(&self) -> &DecoyConfig { &self.decoy @@ -369,7 +367,7 @@ fn build_router(state: RouterState, extra_routes: Option) -> Router { #[cfg(feature = "mcp")] let mcp_router: Router = { - let dispatch = crate::gateway::GatewayDispatch::new(Arc::clone(&state.registry)); + let dispatch = state.dispatch(); Router::new() .nest_service( "/mcp", @@ -1235,6 +1233,8 @@ mod tests { RouterState { registry: empty_registry(), identity_provider: idp, + ownership_provider: None, + static_ownership_fallback: false, decoy: DecoyConfig::default(), openapi_doc: CachedOpenAPIDoc::new(&OperationRegistry::new()), ws_sessions: Arc::new(crate::websocket::WsSessions::new()), diff --git a/src/server/state.rs b/src/server/state.rs index 787a58a..7ebce4e 100644 --- a/src/server/state.rs +++ b/src/server/state.rs @@ -6,6 +6,12 @@ use std::sync::Arc; use alkcall::core::auth::IdentityProvider; use alkcall::registry::registration::OperationRegistry; +#[cfg(feature = "mcp")] +use axum::extract::FromRef; + +#[cfg(feature = "mcp")] +use crate::gateway::GatewayState; + /// The stealth decoy surface for paths that are not registered /// operations and not part of the reserved default surface (the 6 /// gateway endpoints, `/healthz`, `/openapi.json`, the MCP route, the @@ -39,6 +45,12 @@ pub enum DecoyConfig { pub(crate) struct RouterState { pub(crate) registry: Arc, pub(crate) identity_provider: Arc, + /// The ownership provider threaded into every dispatch spine this + /// state serves (gateway routes and the MCP nest). `None` + no + /// static-fallback declaration means resource-gated ops are refused + /// by the spine's loud default. + pub(crate) ownership_provider: Option>, + pub(crate) static_ownership_fallback: bool, pub(crate) decoy: DecoyConfig, pub(crate) openapi_doc: crate::server::adapter::CachedOpenAPIDoc, pub(crate) ws_sessions: Arc, @@ -79,6 +91,16 @@ impl axum::extract::FromRef for DecoyConfig { } } +impl RouterState { + /// A dispatch spine wired with this state's ownership configuration + /// (the MCP nest's builder; the gateway routes extract + /// [`GatewayState`] via `FromRef`). + #[cfg(feature = "mcp")] + pub(crate) fn dispatch(&self) -> crate::gateway::GatewayDispatch { + GatewayState::from_ref(self).dispatch() + } +} + impl axum::extract::FromRef for crate::server::adapter::CachedOpenAPIDoc { fn from_ref(state: &RouterState) -> Self { state.openapi_doc.clone() @@ -99,6 +121,8 @@ mod tests { let state = RouterState { registry: Arc::new(OperationRegistry::new()), identity_provider: Arc::new(NoopProvider), + ownership_provider: None, + static_ownership_fallback: false, decoy: DecoyConfig::Redirect { to: "https://example.com".to_string(), },