feat(server): thread alkcall 0.10 ownership wiring onto the HTTP surface
Ride the alkcall 0.10 gateway ownership seam (review 012, ADR-048 amendment) into the door layer: - HttpAdapter::with_ownership_provider / HttpAdapter::with_static_ownership_fallback mirror alkcall's builder shape; OwnershipProvider re-exported next to GatewayDispatch - RouterState carries the wiring; GatewayState::dispatch is the single spine builder for both the gateway routes and the MCP nest - loud default passes through: a resource_type-gated op on an unwired spine is refused (FORBIDDEN envelope -> 403/401) on HTTP exactly as on the native wire - WS native-session path untouched (consults ownership natively) fix: with_ws_max_sessions never stored the fresh session-slot semaphore; later builder calls silently reverted the cap. Builder state now assembled through one router_state() helper. infra: fuzz/shared follows to alkcall 0.10 (two alkcall copies in one build would type-clash); alkhttp 0.8.0 per the dep-wave convention. Verification: cargo test (444 pass, incl. 3 new call_resource_gated_* HTTP-surface pins), clippy --all-targets -D warnings, fmt --check, test --all-features (520), fuzz corpus replay (21/21), doc.
This commit is contained in:
1 parent
1cac63fdba
commit
ad63507124
11 files changed
+436
-94
No files matched your search
@@ -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
|
||||
|
||||
Generated
+3
-3
@@ -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",
|
||||
|
||||
+2
-2
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<dyn OwnershipProvider>)`
|
||||
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)
|
||||
Generated
+6
-6
@@ -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]]
|
||||
|
||||
@@ -14,7 +14,7 @@ mcp = ["alkhttp/mcp"]
|
||||
|
||||
[dependencies]
|
||||
alkhttp = { path = "../.." }
|
||||
alkcall = "0.9"
|
||||
alkcall = "0.10"
|
||||
serde_json = "1"
|
||||
bytes = "1"
|
||||
futures = "0.3"
|
||||
|
||||
@@ -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
|
||||
|
||||
+197
-7
@@ -82,21 +82,30 @@ type ByteStream = futures::stream::BoxStream<'static, Result<Bytes, axum::Error>
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct GatewayState {
|
||||
registry: Arc<OperationRegistry>,
|
||||
ownership_provider: Option<Arc<dyn alkcall::core::ownership::OwnershipProvider>>,
|
||||
static_ownership_fallback: bool,
|
||||
}
|
||||
|
||||
impl GatewayState {
|
||||
pub(crate) fn new(registry: Arc<OperationRegistry>) -> 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<RouterState> 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<Event, Infallible> {
|
||||
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<OperationRegistry> {
|
||||
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<Vec<&str>>,
|
||||
) -> 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<dyn IdentityProvider> =
|
||||
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<dyn IdentityProvider> = 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<dyn IdentityProvider> =
|
||||
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<OperationRegistry> {
|
||||
let registry = OperationRegistry::new();
|
||||
registry
|
||||
@@ -1126,10 +1305,21 @@ mod tests {
|
||||
fn build_router(
|
||||
registry: Arc<OperationRegistry>,
|
||||
provider: Arc<dyn IdentityProvider>,
|
||||
) -> axum::Router {
|
||||
build_router_with_ownership(registry, provider, None, false)
|
||||
}
|
||||
|
||||
fn build_router_with_ownership(
|
||||
registry: Arc<OperationRegistry>,
|
||||
provider: Arc<dyn IdentityProvider>,
|
||||
ownership_provider: Option<Arc<dyn alkcall::core::ownership::OwnershipProvider>>,
|
||||
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()),
|
||||
|
||||
+75
-75
@@ -89,6 +89,8 @@ pub const RESERVED_PATHS: &[&str] = &[
|
||||
pub struct HttpAdapter {
|
||||
identity_provider: Arc<dyn alkcall::core::auth::IdentityProvider>,
|
||||
registry: Arc<OperationRegistry>,
|
||||
ownership_provider: Option<Arc<dyn alkcall::core::ownership::OwnershipProvider>>,
|
||||
static_ownership_fallback: bool,
|
||||
decoy: DecoyConfig,
|
||||
extra_routes: Option<Router>,
|
||||
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<Duration>) -> 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<crate::websocket::OpenableAlpn>,
|
||||
) -> 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<dyn alkcall::core::ownership::OwnershipProvider>,
|
||||
) -> 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>) -> Router {
|
||||
|
||||
#[cfg(feature = "mcp")]
|
||||
let mcp_router: Router<RouterState> = {
|
||||
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()),
|
||||
|
||||
@@ -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<OperationRegistry>,
|
||||
pub(crate) identity_provider: Arc<dyn IdentityProvider>,
|
||||
/// 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<Arc<dyn alkcall::core::ownership::OwnershipProvider>>,
|
||||
pub(crate) static_ownership_fallback: bool,
|
||||
pub(crate) decoy: DecoyConfig,
|
||||
pub(crate) openapi_doc: crate::server::adapter::CachedOpenAPIDoc,
|
||||
pub(crate) ws_sessions: Arc<crate::websocket::WsSessions>,
|
||||
@@ -79,6 +91,16 @@ impl axum::extract::FromRef<RouterState> 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<RouterState> 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(),
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user