Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f16c4f2f9 | ||
|
|
cc80188e3d | ||
|
|
dfa0216186 | ||
|
|
0766f4bebe | ||
|
|
ad63507124 | ||
|
|
1cac63fdba | ||
|
|
449661eb87 | ||
|
|
c89aa65fb2 | ||
|
|
2cf0dc7bea |
No files matched your search
+131
@@ -4,6 +4,137 @@ 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.1] — 2026-10-22
|
||||
|
||||
### Changed
|
||||
|
||||
- **`alkcall` dependency consumed at 0.10.1** (patch pickup; the
|
||||
adoption-window ordering fix, alkcall review 013, routed from
|
||||
alkgit's wave-2 gate). A chunk routed by the channels demux inside
|
||||
the `adopt_channel` install→drain window was delivered **ahead of**
|
||||
earlier-parked early arrivals, inverting the channel's byte-stream
|
||||
order, and a related loss variant parked a chunk forever after the
|
||||
drain had removed its queue. This crate reaches the fixed paths via
|
||||
the shared `ChannelManager`; no alkcall API surface used here
|
||||
changed — no code change in this crate. The dependency requirement
|
||||
is tightened `0.10` → `0.10.1` so downstream projects composing
|
||||
this crate with alkcall resolve the fixed version rather than an
|
||||
older 0.10.x lockfile entry.
|
||||
|
||||
Patch bump 0.8.0 → 0.8.1 per the dep-wave convention (0.4.1 rode
|
||||
alkcall 0.7.1): alkcall 0.10.1 is a patch release with no API or
|
||||
wire-format change (`EventEnvelope`, the channels 8-byte chunk
|
||||
header), so nothing semver-relevant moves on this crate's surface.
|
||||
|
||||
## [0.8.0] — 2026-10-17
|
||||
|
||||
### Added
|
||||
|
||||
- **Door-surface Basic auth tolerance (review 008 AH-1, ADR-073).**
|
||||
The extras-door surface now resolves `Authorization: Basic
|
||||
base64(user:pass)` — the password field acts as the token and rides
|
||||
the same `IdentityProvider::resolve_from_token()` backend as Bearer
|
||||
(the GitHub-PAT shape `git-lfs` and git's smart-HTTP transport
|
||||
authenticate with). No new trait method; the provider's grant table
|
||||
governs exactly as it does for Bearer, and "unparseable /
|
||||
unresolvable → None" fallback semantics are preserved. Basic is
|
||||
scoped to the door surface: `bearer_auth_middleware` stays
|
||||
Bearer-only on the gateway endpoints, `/openapi.json`, `/healthz`,
|
||||
and the MCP nest, and the WS upgrade path is untouched — a Basic
|
||||
credential contributes no identity on any path a deployment has not
|
||||
explicitly mounted doors on. New public API:
|
||||
`server::door_auth_middleware`, `server::extract_door_identity`
|
||||
(`bearer_auth_middleware` / `extract_bearer_identity` keep their
|
||||
Bearer-only semantics and their coverage).
|
||||
|
||||
### 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.
|
||||
|
||||
### Documented
|
||||
|
||||
- **Extras door route tables are static (review 008 AH-2).** One doc
|
||||
line on the door-facing surface (`HttpAdapter::with_extra_routes`)
|
||||
and in ADR-046: the merged router already owns the decoy fallback
|
||||
and axum refuses a second, so a door attempting `fallback_layer`
|
||||
panics at construction — enumerate multi-segment namespace shapes
|
||||
(`{user}/{repo}/...`) as pattern routes. Not a mechanism ask; the
|
||||
next door author should not rediscover the panic.
|
||||
|
||||
### 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. Pinned by the WS session-cap integration test running
|
||||
with a builder call after `with_ws_max_sessions`, and by an
|
||||
ownership-wiring-survives-rebuild test at the HTTP surface.
|
||||
- Door-surface Basic parsing no longer requires the password field to
|
||||
be UTF-8: the decoded password bytes reach
|
||||
`IdentityProvider::resolve_from_token()` byte-faithfully, so RFC 7617
|
||||
legacy ISO-8859-1 credentials (e.g. a latin-1 user password) that a
|
||||
provider resolves still authenticate instead of parsing to `None`.
|
||||
|
||||
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
|
||||
|
||||
- **`alkcall` dependency bumped to 0.9.0** (the stub-sweep release: the
|
||||
four registered-but-always-failing stubs across nine published
|
||||
versions are replaced with real implementations — the marked-pub
|
||||
open contract (S-01), `channel/control` (S-02),
|
||||
`channel/resources/subscribe` (S-03), and `op/register`
|
||||
forward-through (S-04)). No code change in this crate — none of the
|
||||
0.9.0 breaking surfaces are constructed or pattern-matched here: no
|
||||
`ManagerError` match arms (the new `InvalidAdopt` variant changes
|
||||
nothing), no `RelayError` matching (the removed `PubTypedOpen`
|
||||
variant is unreachable from this crate), no `Establishment`
|
||||
construction or `Debug` dependence (the WS ferry passes
|
||||
`Option<OpenEstablisher>` through unchanged), and the
|
||||
`op/register` forward-through refactor does not touch the
|
||||
`from_wss` import filter or the adapter-registered leaf bundles.
|
||||
The S-01 marked-pub machinery is consumer-side assembly, not an
|
||||
HTTP-surface concern; the four gateway operations and the WS
|
||||
session shape are unchanged.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `tests/ws_overlay_ops.rs` now expects `register_imported`'s new
|
||||
`Result<(), String>` return (alkcall 0.9.0 API change): the eight
|
||||
connection-overlay registration sites assert success instead of
|
||||
discarding the value. Test-only.
|
||||
- `fuzz/shared` follows the main crate to alkcall 0.9 (the harness
|
||||
replay previously resolved alkcall 0.8.1 alongside alkhttp's 0.9.0,
|
||||
two incompatible copies in one build). Infra-only.
|
||||
|
||||
Minor bump 0.6.0 → 0.7.0 per the dep-wave convention (0.3.0 rode
|
||||
alkcall 0.6.0, 0.4.0 rode 0.7.0, 0.5.0 rode 0.8.0): this crate's
|
||||
public API carries alkcall types through re-exports and the
|
||||
`ProtocolHandler` impl, so a semver-breaking upstream is
|
||||
semver-relevant here.
|
||||
|
||||
## [0.6.0] — 2026-09-29
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+4
-3
@@ -27,9 +27,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "alkcall"
|
||||
version = "0.8.1"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf6af1e4af1460480849adf959c972d65deeafc36f4101064ced1ce6d1adfd5b"
|
||||
checksum = "690d747f42baf385dc22cd120f79d43cf1ea239bc426675e0e1e225b57fc031a"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -48,12 +48,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "alkhttp"
|
||||
version = "0.6.0"
|
||||
version = "0.8.1"
|
||||
dependencies = [
|
||||
"alkcall",
|
||||
"arc-swap",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures",
|
||||
"http",
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "alkhttp"
|
||||
version = "0.6.0"
|
||||
version = "0.8.1"
|
||||
edition = "2021"
|
||||
rust-version = "1.88"
|
||||
license = "MIT OR Apache-2.0"
|
||||
@@ -25,7 +25,7 @@ default = ["server", "client", "h2", "http1"]
|
||||
# driver. `openapi` rides along because /openapi.json is part of the
|
||||
# default surface (ADR-042/045); `http1` rides along so the hyper
|
||||
# auto-builder always has at least one protocol compiled.
|
||||
server = ["dep:axum", "dep:hyper", "dep:hyper-util", "dep:uuid", "openapi", "http1"]
|
||||
server = ["dep:axum", "dep:base64", "dep:hyper", "dep:hyper-util", "dep:uuid", "openapi", "http1"]
|
||||
# Consumer side: the shared outbound client host and the HTTP-backed
|
||||
# import adapters (`from_jsonschema`, `from_openapi` with `openapi`,
|
||||
# `from_mcp` with `mcp`).
|
||||
@@ -46,9 +46,10 @@ h2 = ["server", "dep:hyper", "hyper-util/http2", "hyper/http2"]
|
||||
http1 = ["server", "dep:hyper", "hyper-util/http1", "hyper/http1"]
|
||||
|
||||
[dependencies]
|
||||
alkcall = { version = "0.8.1", features = ["gateway"] }
|
||||
alkcall = { version = "0.10.1", features = ["gateway"] }
|
||||
arc-swap = { version = "1", optional = true }
|
||||
axum = { version = "0.8", optional = true, features = ["ws"] }
|
||||
base64 = { version = "0.22", optional = true }
|
||||
bytes = "1"
|
||||
futures = "0.3"
|
||||
http = "1"
|
||||
|
||||
@@ -161,7 +161,7 @@ A lean single-side build takes `default-features = false` plus the side
|
||||
it needs:
|
||||
|
||||
```toml
|
||||
alkhttp = { version = "0.5.0", default-features = false, features = ["server"] }
|
||||
alkhttp = { version = "0.8.1", default-features = false, features = ["server"] }
|
||||
```
|
||||
|
||||
## Security posture
|
||||
|
||||
@@ -69,6 +69,8 @@ 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`) |
|
||||
| [073](decisions/073-door-surface-basic-auth-tolerance.md) | Door-Surface Basic Auth Tolerance | `door_auth_middleware` on extras doors resolves `Basic base64(user:pass)` (password field as the token) through the same `resolve_from_token` backend; gateway/MCP/WS stay Bearer-only (review 008 AH-1) |
|
||||
|
||||
## Relevant Open Questions
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ Credential presentation per handler:
|
||||
|---------|------------------------|-------------|
|
||||
| SshAdapter | SSH public key handshake | `resolve_from_fingerprint()` |
|
||||
| CallAdapter | AuthToken in first frame | `resolve_from_token()` |
|
||||
| HttpAdapter | `Authorization: Bearer` header | `resolve_from_token()` |
|
||||
| HttpAdapter | `Authorization` header — Bearer (all HTTP surfaces); Basic additionally on the extras-door surface (ADR-073, the password field as the token) | `resolve_from_token()` |
|
||||
| DnsAdapter | AuthToken in query labels | `resolve_from_token()` |
|
||||
| WebTransportAdapter | AuthToken in CONNECT headers | `resolve_from_token()` |
|
||||
| GitAdapter | Signed push certificate | `resolve_from_fingerprint()` |
|
||||
|
||||
@@ -95,6 +95,16 @@ the one-way constraint is that the assembly layer can inject routes
|
||||
and they coexist with the default surface. axum's `Router::merge` /
|
||||
`Router::nest` are the natural primitives.
|
||||
|
||||
**Route tables are static (review 008 AH-2).** The merged router
|
||||
already owns the fallback route (the decoy) — axum refuses a second
|
||||
fallback, and a door attempting `fallback_layer`/`fallback` on the
|
||||
merged router panics at construction, not at request time. Door route
|
||||
tables must therefore be statically enumerated: multi-segment
|
||||
repository namespaces (`/{user}/{repo}/objects/batch` shape) are
|
||||
pattern routes with wildcard segments, never a dynamic fallback match.
|
||||
This is a recorded constraint of the router assembly, not a mechanism
|
||||
ask.
|
||||
|
||||
### 2. Custom routes are raw HTTP, not call-protocol operations
|
||||
|
||||
A custom route is a raw axum handler — it receives an HTTP request and
|
||||
@@ -134,16 +144,21 @@ is natural (`/v1/...` doesn't collide).
|
||||
|
||||
### 4. Custom routes carry the same auth middleware by default; per-route opt-out is the deployment's choice
|
||||
|
||||
Custom routes run under the same Bearer-auth resolution as the default
|
||||
surface (the `Authorization: Bearer` → `resolve_from_token` path). A
|
||||
deployment that wants a custom route to be unauthenticated (a public
|
||||
webhook receiver, a health endpoint with a different shape than
|
||||
`/healthz`) applies axum middleware to opt that route out of auth —
|
||||
the deployment owns its custom routes' middleware stack. The
|
||||
`HttpAdapter` provides the identity provider and the default auth
|
||||
middleware; the custom `Router` the assembly layer passes in can
|
||||
layer its own middleware on top. This is standard axum composition; no
|
||||
alknet-specific mechanism.
|
||||
Custom routes run under the door-surface auth middleware: Bearer
|
||||
resolution identically to the default surface
|
||||
(`Authorization: Bearer` → `resolve_from_token`), plus — ADR-073 —
|
||||
Basic tolerance (`Basic base64(user:pass)`; the password field rides
|
||||
the same `resolve_from_token` backend). The default token-auth surface
|
||||
(gateway endpoints, `/openapi.json`, `/healthz`, MCP) stays
|
||||
Bearer-only: Basic resolution is scoped to the door surface
|
||||
specifically constructed for legacy clients. A deployment that wants a
|
||||
custom route to be unauthenticated (a public webhook receiver, a
|
||||
health endpoint with a different shape than `/healthz`) applies axum
|
||||
middleware to opt that route out of auth — the deployment owns its
|
||||
custom routes' middleware stack. The `HttpAdapter` provides the
|
||||
identity provider and the default middlewares; the custom `Router` the
|
||||
assembly layer passes in can layer its own middleware on top. This is
|
||||
standard axum composition; no alknet-specific mechanism.
|
||||
|
||||
### 5. Custom routes are not part of the published `to_openapi` doc
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,87 @@
|
||||
# ADR-073: Door-Surface Basic Auth Tolerance (git / git-lfs Clients)
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Review 008 AH-1, from alkgit's LFS POC (POC-L): a real `git-lfs`
|
||||
3.7.0 client authenticates the batch endpoint with **Basic**
|
||||
credentials (URL userinfo — the spec-shape behavior of the official
|
||||
client; config `http://user:pass@host/repo`). The shared identity
|
||||
middleware was Bearer-only — the module doc said so explicitly
|
||||
("Bearer-only is the auth mechanism") — so a door mounted under the
|
||||
shared auth layer received the batch request with the middleware
|
||||
already having folded in `None`, and the door had to re-parse the
|
||||
`Authorization` header itself (Basic *or* Bearer) to get any identity.
|
||||
|
||||
The middleware is shared by the gateway endpoints and `to_mcp`. Every
|
||||
HTTP-door surface whose real client authenticates with Basic (LFS
|
||||
today; git's smart-HTTP transport is tomorrow's `git` door) would
|
||||
silently re-implement the fallback locally — the quiet-bypass shape
|
||||
alkcall review 012 records for the ownership seam, one layer up in the
|
||||
same stack.
|
||||
|
||||
## Decision
|
||||
|
||||
**Split the auth surface into token surfaces and the door surface,
|
||||
and teach the door surface Basic — through the same resolution
|
||||
backend, with no new `IdentityProvider` method.**
|
||||
|
||||
- `bearer_auth_middleware` stays Bearer-only and continues to cover
|
||||
the token-auth surfaces: the gateway endpoints, `/openapi.json`,
|
||||
`/healthz`, and the `to_mcp` nest. A Basic header contributes no
|
||||
identity there. `/call`, `/batch`, `/subscribe`, `/publish`, `/mcp`,
|
||||
and the WS upgrade path are token surfaces — this is the "no blanket
|
||||
method" constraint: a Basic credential never gains identity anywhere
|
||||
the deployment has not explicitly set up for it.
|
||||
- `door_auth_middleware` (new) is applied by `build_router` to the
|
||||
extras-door router (`HttpAdapter::with_extra_routes`) — the surface
|
||||
legacy clients authenticate on.
|
||||
- Both middlewares resolve through `IdentityProvider::resolve_from_token()`.
|
||||
Basic parsing: `Basic base64(user:pass)` — the password field (the
|
||||
GitHub-PAT shape; the user segment is ignored) is decoded and fed as
|
||||
the `AuthToken` raw bytes. A colon-less value is treated as a bare
|
||||
password. No new trait method: the provider's grant table governs a
|
||||
Basic credential exactly as it governs the same credential presented
|
||||
as Bearer — and the same backend mechanism that grants privileges to
|
||||
tokens grants them to these credentials (SOCKS5
|
||||
userinfo-style integrations can follow the same pattern later).
|
||||
- Fallback semantics unchanged: no header → `None`; unparseable or
|
||||
unresolvable credential → `None` (unauthenticated, not an error).
|
||||
A parse-time failure never becomes a 4xx of its own.
|
||||
- Doors may opt out with their own middleware stack as before
|
||||
([ADR-046](046-assembly-layer-custom-http-routes.md) §4).
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
|
||||
- `git-lfs` / `git` doors work against a deployment whose provider
|
||||
maps password-tokens through `resolve_from_token`, with zero
|
||||
door-side header re-parsing.
|
||||
- Scoping is structural (per-middleware), not
|
||||
per-path-inspection: Basic tolerance exists only where the
|
||||
assembly layer mounted the door surface.
|
||||
|
||||
**Negative:**
|
||||
|
||||
- A door surface deployment that does *not* want its door credentials
|
||||
resolvable as Basic must now ensure the provider does not resolve
|
||||
those values as tokens — or opt the door out of the shared layer.
|
||||
- Scheme prefixes remain case-sensitive ("Bearer "/"Basic "), matching
|
||||
the pre-existing Bearer parsing; strictly RFC-noncompliant
|
||||
lowercase-scheme clients parse to `None`.
|
||||
|
||||
## References
|
||||
|
||||
- [Review 008](../../reviews/008-http-door-consumer-review.md) — AH-1
|
||||
evidence and disposition space
|
||||
- [ADR-004](004-auth-as-shared-core.md) — the shared-core resolution
|
||||
backend this rides
|
||||
- [ADR-046](046-assembly-layer-custom-http-routes.md) — the extras-door
|
||||
surface the door middleware covers (§4 amended; AH-2 static-route
|
||||
constraint recorded there)
|
||||
- alkcall review 012 — the same-round quiet-bypass analysis this
|
||||
avoids repeating at the door layer
|
||||
@@ -0,0 +1,102 @@
|
||||
# Review 008 — First HTTP-door consumer evidence: shared auth vs LFS Basic, and the extras route-fallback constraint
|
||||
|
||||
## Status
|
||||
|
||||
Resolved — 2026-10-17. AH-1: door-surface Basic tolerance landed as
|
||||
[ADR-073](../architecture/decisions/073-door-surface-basic-auth-tolerance.md)
|
||||
(`door_auth_middleware` on extras doors; password field through the
|
||||
same `resolve_from_token` backend; token surfaces stay Bearer-only).
|
||||
AH-2: recorded on the door-facing surface
|
||||
([ADR-046](../architecture/decisions/046-assembly-layer-custom-http-routes.md)
|
||||
§1). Both ride the alkhttp 0.8.0 release (with the alkcall 0.10
|
||||
ownership wiring, ADR-072). Filed 2026-10-17 as consumer input from
|
||||
alkgit's LFS
|
||||
POC (POC-L, 2026-10-16; findings at
|
||||
`/workspace/@alkdev/alkgit/docs/research/pocl-findings.md`; the POC
|
||||
crate is `/workspace/lfs-poc`, which served a real `git-lfs` 3.7.0
|
||||
client through `with_extra_routes` doors). Ask ledger source: alkgit's
|
||||
`docs/research/substrate-recomposition.md` §Upstream-push
|
||||
candidates #6/#9 (the alkhttp-named items; the gateway-dispatch half
|
||||
of #6 is filed where it belongs — alkcall review 012).
|
||||
|
||||
## Scope
|
||||
|
||||
Two findings, both from the same consumption round (an LFS batch-API
|
||||
door on the extras router). Neither was discovered by a source sweep;
|
||||
both anchors were re-verified by targeted read this session. Severity
|
||||
and shape are the consumer seat's; the disposition belongs to this
|
||||
crate's own session.
|
||||
|
||||
## AH-1 [small, contract-visible] — The shared bearer middleware is Bearer-only; LFS clients authenticate with Basic on the batch
|
||||
|
||||
**Anchor.** `src/server/auth.rs` — the shared identity middleware
|
||||
(`bearer_auth_middleware`); the module doc states the semantics
|
||||
explicitly: *"Malformed `Authorization` header (not `Bearer <token>`)
|
||||
→ `None` (treated as no-token, not an error — Bearer-only is the auth
|
||||
mechanism)"* (lines 1–20, verified this session).
|
||||
|
||||
**Evidence.** Real `git-lfs` 3.7.0 authenticates the batch endpoint
|
||||
with **Basic** credentials (URL userinfo — the spec-shape behavior of
|
||||
the official client; its config is `http://user:pass@host/repo`). An
|
||||
extras door mounted on the shared middleware therefore receives the
|
||||
batch request with the middleware already having resolved `None`
|
||||
(the Basic header is not `Bearer`) — the door had to parse the
|
||||
Authorization header a second time (Basic *or* Bearer) to get any
|
||||
identity at all. The POC's door resolved both schemes locally and
|
||||
passed the real client; the shared middleware contributed nothing on
|
||||
this request shape.
|
||||
|
||||
**Why it matters beyond LFS.** The middleware is shared by gateway
|
||||
endpoints and `to_mcp`; any HTTP-door surface whose
|
||||
real-world client authenticates with Basic (LFS today; git's
|
||||
smart-HTTP transport's basic auth is tomorrow's `git` door) silently
|
||||
loses identity resolution at the middleware and every door
|
||||
re-implements the fallback — the exact quiet-bypass shape review 012
|
||||
(alkcall) records for the ownership seam, one layer up in the same
|
||||
stack.
|
||||
|
||||
**Recommended shape (own session, to be re-derived)**: tolerate Basic
|
||||
userinfo in the shared middleware — parse `Basic base64(user:pass)`
|
||||
into the identity provider's resolve path (or expose a documented
|
||||
per-door hook that receives the raw header before the None fold-in),
|
||||
keeping the current "resolve fails → None" fallback semantics. The
|
||||
doc line "Bearer-only is the auth mechanism" should flip to match
|
||||
whatever lands. Small surface; the decision is whether Basic
|
||||
resolution belongs in the shared middleware (uniform) or stays a
|
||||
door concern (explicit).
|
||||
|
||||
**Verification status**: middleware semantics re-read this session;
|
||||
the client behavior is POC-L's recorded route-shape fact (real
|
||||
client, live). Re-verify by pointing a stock `git-lfs` at a
|
||||
shared-middleware door during resolution.
|
||||
|
||||
## AH-2 [recorded constraint, no ask yet] — Extras doors cannot carry a route fallback; door route tables must be static shapes
|
||||
|
||||
**Anchor/evidence.** POC-L, live: the merged extras router already
|
||||
owns a fallback route (the decoy), and axum refuses a second merge —
|
||||
a door attempting `fallback_layer`/merge fallback on the merged
|
||||
router panics **at construction**. Consequence: door route tables
|
||||
must be statically enumerated; multi-segment repo namespaces
|
||||
(`/user/repo/objects/batch` style) need enumerated pattern routes
|
||||
(`/{user}/{repo}/...` shape), not a dynamic-fallback match. The POC
|
||||
door's static shapes worked for every e2e case.
|
||||
|
||||
**Disposition space.** This is a structural fact of the current
|
||||
router assembly, currently undocumented on the door-facing surface —
|
||||
worth one doc line in the extras/hooks documentation ("route tables
|
||||
are static; no second fallback; enumerate multi-segment patterns
|
||||
explicitly") regardless of whether any mechanism change is ever
|
||||
wanted. Not a mechanism ask today; recorded so the next door author
|
||||
does not rediscover the construction panic.
|
||||
|
||||
## What this review asks for
|
||||
|
||||
| # | Item | Class |
|
||||
|---|---|---|
|
||||
| AH-1 | Basic tolerance (or a documented pre-resolution hook) in the shared identity middleware | small change + doc flip, own session |
|
||||
| AH-2 | doc line: extras route tables are static; no second fallback | docs-only, ride-along |
|
||||
|
||||
Nothing here blocks any door surface that resolves identity
|
||||
door-side (the proven compensation), but the shared-middleware trap
|
||||
(AH-1) compounds alkcall review 012's gateway trap for every future
|
||||
External-facing door — the two are worth one resolution round each.
|
||||
Generated
+4
-3
@@ -27,9 +27,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "alkcall"
|
||||
version = "0.8.1"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf6af1e4af1460480849adf959c972d65deeafc36f4101064ced1ce6d1adfd5b"
|
||||
checksum = "690d747f42baf385dc22cd120f79d43cf1ea239bc426675e0e1e225b57fc031a"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -48,12 +48,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "alkhttp"
|
||||
version = "0.6.0"
|
||||
version = "0.8.1"
|
||||
dependencies = [
|
||||
"alkcall",
|
||||
"arc-swap",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures",
|
||||
"http",
|
||||
|
||||
@@ -14,7 +14,7 @@ mcp = ["alkhttp/mcp"]
|
||||
|
||||
[dependencies]
|
||||
alkhttp = { path = "../.." }
|
||||
alkcall = "0.8.1"
|
||||
alkcall = "0.10"
|
||||
serde_json = "1"
|
||||
bytes = "1"
|
||||
futures = "0.3"
|
||||
|
||||
@@ -465,7 +465,7 @@ impl FromOpenAPI {
|
||||
`paths` for this path's `parameters` (and the path-item's shared \
|
||||
list) has `name` and `in`, and that its $ref, if any, resolves. \
|
||||
The placeholder would otherwise render as a literal `{{}}` path segment",
|
||||
bounded_join(&[path_template.to_string()]),
|
||||
bounded_join(std::slice::from_ref(&path_template)),
|
||||
bounded_join(&unbound)
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
+223
-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,201 @@ 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}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ownership_fallback_wiring_survives_a_later_builder_rebuild() {
|
||||
let provider: Arc<dyn IdentityProvider> =
|
||||
Arc::new(StaticIdentityProvider::new().with_token(
|
||||
"tok-a",
|
||||
identity_with_resources("token-carrier", "blob-namespace", Some(vec!["read"])),
|
||||
));
|
||||
let adapter = crate::server::HttpAdapter::new(provider, registry_with_namespace_gated_op())
|
||||
.with_static_ownership_fallback()
|
||||
.with_decoy(crate::server::DecoyConfig::NotFound);
|
||||
|
||||
let (status, body) = post_call(
|
||||
adapter.router().clone(),
|
||||
"tok-a",
|
||||
"ns/stat",
|
||||
json!({ "namespace": "ns-1" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::OK,
|
||||
"the wiring must survive the with_decoy state rebuild — the pre-router_state() \
|
||||
builder shape silently dropped values set by earlier calls: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
fn registry_with_echo() -> Arc<OperationRegistry> {
|
||||
let registry = OperationRegistry::new();
|
||||
registry
|
||||
@@ -1126,10 +1331,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()),
|
||||
|
||||
+231
-101
@@ -48,7 +48,7 @@ use hyper_util::service::TowerToHyperService;
|
||||
use parking_lot::Mutex;
|
||||
use tracing::error;
|
||||
|
||||
use super::auth::bearer_auth_middleware;
|
||||
use super::auth::{bearer_auth_middleware, door_auth_middleware};
|
||||
use super::decoy::{decoy_fallback, decoy_method_not_allowed};
|
||||
use super::healthz::healthz;
|
||||
use super::state::{DecoyConfig, RouterState};
|
||||
@@ -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,42 +168,33 @@ 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(),
|
||||
};
|
||||
self.decoy = decoy;
|
||||
self.router = build_router(self.router_state(), self.extra_routes.clone());
|
||||
self
|
||||
}
|
||||
|
||||
/// Mount assembly-provided custom routes under the door-surface
|
||||
/// auth middleware (ADR-046, ADR-073) and rebuild the router.
|
||||
/// Reserved paths (`RESERVED_PATHS`) are rejected before the merge.
|
||||
///
|
||||
/// The door middleware resolves Bearer and — ADR-073, review 008
|
||||
/// AH-1 — `Basic base64(user:pass)` (the password field through the
|
||||
/// same `IdentityProvider::resolve_from_token()` backend), the
|
||||
/// surface legacy clients (`git-lfs`, git's smart-HTTP transport)
|
||||
/// authenticate on. The token-auth surface this merges onto stays
|
||||
/// Bearer-only.
|
||||
///
|
||||
/// Door route tables are static (review 008 AH-2): the merged
|
||||
/// router already owns the decoy fallback and axum refuses a
|
||||
/// second one, so a door that attempts `fallback_layer`/`fallback`
|
||||
/// panics at construction — enumerate multi-segment namespace
|
||||
/// shapes ({user}/{repo}/... style) as pattern routes instead.
|
||||
pub fn with_extra_routes(mut self, routes: Router) -> Self {
|
||||
// `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
|
||||
}
|
||||
|
||||
/// Mount assembly-provided custom routes under the bearer-auth
|
||||
/// 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()));
|
||||
self.router = build_router(self.router_state(), Some(routes.clone()));
|
||||
self.extra_routes = Some(routes);
|
||||
self
|
||||
}
|
||||
@@ -211,18 +208,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 +231,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 +273,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 +294,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 +305,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 +380,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",
|
||||
@@ -390,13 +401,29 @@ fn build_router(state: RouterState, extra_routes: Option<Router>) -> Router {
|
||||
// cache (SRV-09), threaded through `RouterState` below.
|
||||
.route("/openapi.json", get(openapi_json_handler))
|
||||
.route("/healthz", get(healthz))
|
||||
// The token-auth surface's bearer layer, applied before the
|
||||
// extras merge: routes registered before this call — the
|
||||
// gateway endpoints, /openapi.json, /healthz — resolve
|
||||
// Bearer-only (ADR-073: a Basic header contributes no identity
|
||||
// on these paths; Basic is scoped to the door surface below).
|
||||
.route_layer(from_fn_with_state(
|
||||
Arc::clone(&auth_state),
|
||||
bearer_auth_middleware,
|
||||
))
|
||||
.fallback(decoy_fallback)
|
||||
.method_not_allowed_fallback(decoy_method_not_allowed);
|
||||
|
||||
let with_extras = match extra_routes {
|
||||
Some(extra) => {
|
||||
enforce_reserved_paths(&extra);
|
||||
let extra: Router<RouterState> = extra.with_state(());
|
||||
// The door surface's auth layer (ADR-073): Basic-tolerant
|
||||
// in addition to Bearer — exactly one resolution per door
|
||||
// request (SRV-11), and the legacy-client surface this is
|
||||
// scoped to.
|
||||
let extra: Router<RouterState> = extra.with_state(()).route_layer(from_fn_with_state(
|
||||
Arc::clone(&auth_state),
|
||||
door_auth_middleware,
|
||||
));
|
||||
default.merge(extra)
|
||||
}
|
||||
None => default,
|
||||
@@ -409,28 +436,13 @@ fn build_router(state: RouterState, extra_routes: Option<Router>) -> Router {
|
||||
// surface. Idempotent for the routers the earlier call covered.
|
||||
let with_extras = with_extras.method_not_allowed_fallback(decoy_method_not_allowed);
|
||||
|
||||
// Applied after the merges (ADR-046 §4): the bearer-auth layer wraps
|
||||
// every route registered before this call (the gateway endpoints,
|
||||
// /openapi.json, /healthz, and the extra routes) — axum 0.8
|
||||
// semantics are that `route_layer` wraps the routes registered
|
||||
// before it, not the ones after. Routes that must not pass through
|
||||
// this layer (the /mcp nest and the WS upgrade route, each with its
|
||||
// own auth) are merged/registered after it — SRV-11: before the
|
||||
// reorder, a route carrying an inner auth layer was also wrapped by
|
||||
// this one and resolved the token twice (the SRV-10 double-resolve,
|
||||
// the old comment claiming the opposite).
|
||||
let with_auth = with_extras.route_layer(from_fn_with_state(
|
||||
Arc::clone(&auth_state),
|
||||
bearer_auth_middleware,
|
||||
));
|
||||
|
||||
// Merged after the router-wide route_layer on purpose: the /mcp
|
||||
// nest carries its own bearer layer (applied around the nested
|
||||
// service, `from_fn_with_state` above), so registering it here keeps
|
||||
// exactly one token resolution per request (SRV-11). nest_service
|
||||
// Merged after the default surface's route_layer: the /mcp nest
|
||||
// carries its own bearer layer (applied around the nested service,
|
||||
// `from_fn_with_state` above), so registering it here keeps exactly
|
||||
// one token resolution per request (SRV-11). nest_service
|
||||
// registers a plain Route endpoint (no MethodRouter), so the decoy
|
||||
// 405 fallback has no interplay with this merge.
|
||||
let with_mcp = with_auth.merge(mcp_router);
|
||||
let with_mcp = with_extras.merge(mcp_router);
|
||||
|
||||
// Registered after the router-wide route_layer on purpose: axum's
|
||||
// route_layer applies only to earlier-registered routes, so the WS
|
||||
@@ -764,7 +776,7 @@ async fn openapi_json_handler(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::server::auth::ResolvedIdentity;
|
||||
use alkcall::core::auth::IdentityProvider;
|
||||
use alkcall::core::auth::{Identity, IdentityProvider};
|
||||
use alkcall::core::types::ProtocolHandler;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
@@ -1235,6 +1247,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()),
|
||||
@@ -1298,6 +1312,122 @@ mod tests {
|
||||
assert_eq!(&body[..], b"none");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn extra_doors_resolve_basic_credentials_but_the_gateway_stays_bearer_only() {
|
||||
struct PasswordProvider;
|
||||
impl IdentityProvider for PasswordProvider {
|
||||
fn resolve_from_fingerprint(&self, _: &str) -> Option<Identity> {
|
||||
None
|
||||
}
|
||||
fn resolve_from_token(
|
||||
&self,
|
||||
token: &alkcall::core::auth::AuthToken,
|
||||
) -> Option<Identity> {
|
||||
(String::from_utf8_lossy(&token.raw) == "lfs-token").then(|| Identity {
|
||||
id: "lfs-client".to_string(),
|
||||
scopes: vec![],
|
||||
resources: std::collections::HashMap::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
let idp: Arc<dyn IdentityProvider> = Arc::new(PasswordProvider);
|
||||
let extra = Router::new().route(
|
||||
"/v1/whoami",
|
||||
get(|ResolvedIdentity(identity): ResolvedIdentity| async move {
|
||||
match identity {
|
||||
Some(id) => id.id,
|
||||
None => "none".to_string(),
|
||||
}
|
||||
}),
|
||||
);
|
||||
let app = build_router(router_state(Arc::clone(&idp)), Some(extra));
|
||||
|
||||
let response = get_with_bearer(
|
||||
app.clone(),
|
||||
"/v1/whoami",
|
||||
Some("Basic dXNlcjpsZnMtdG9rZW4="),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), axum::http::StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
&body[..],
|
||||
b"lfs-client",
|
||||
"a Basic password resolves on the door surface"
|
||||
);
|
||||
|
||||
let registry = OperationRegistry::new();
|
||||
registry
|
||||
.register(alkcall::registry::registration::HandlerRegistration::new(
|
||||
alkcall::registry::spec::OperationSpec::new(
|
||||
"echo/run",
|
||||
alkcall::registry::spec::OperationType::Query,
|
||||
alkcall::registry::spec::Visibility::External,
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({}),
|
||||
vec![],
|
||||
alkcall::registry::spec::AccessControl {
|
||||
required_scopes: vec!["admin".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
),
|
||||
alkcall::registry::registration::HandlerKind::Once(
|
||||
alkcall::registry::registration::make_handler(|input, ctx| async move {
|
||||
alkcall::protocol::wire::ResponseEnvelope::ok(ctx.request_id, input)
|
||||
}),
|
||||
),
|
||||
alkcall::registry::registration::OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
alkcall::core::types::Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let gateway_state = RouterState {
|
||||
registry: Arc::new(registry),
|
||||
identity_provider: Arc::clone(&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()),
|
||||
ws_session_slots: Arc::new(tokio::sync::Semaphore::new(
|
||||
crate::websocket::DEFAULT_WS_MAX_SESSIONS,
|
||||
)),
|
||||
ws_idle_timeout: Some(crate::websocket::DEFAULT_WS_IDLE_TIMEOUT),
|
||||
ws_openable_alpns: None,
|
||||
ws_op_register_acl: alkcall::registry::spec::AccessControl::default(),
|
||||
};
|
||||
let app = build_router(gateway_state, None);
|
||||
|
||||
use tower::ServiceExt;
|
||||
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/call")
|
||||
.header("content-type", "application/json")
|
||||
.header(
|
||||
axum::http::header::AUTHORIZATION,
|
||||
"Basic dXNlcjpsZnMtdG9rZW4=",
|
||||
)
|
||||
.body(axum::body::Body::from(
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"operation": "echo/run",
|
||||
"input": {},
|
||||
}))
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap();
|
||||
let response = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
axum::http::StatusCode::UNAUTHORIZED,
|
||||
"a Basic credential contributes no identity on the token-auth gateway surface"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn extra_route_with_own_layer_can_opt_out_of_default_auth() {
|
||||
async fn public_identity(
|
||||
|
||||
+218
-16
@@ -1,25 +1,42 @@
|
||||
//! Shared Bearer auth axum middleware ([ADR-004]).
|
||||
//! Shared identity-auth axum middlewares ([ADR-004], [ADR-073]).
|
||||
//!
|
||||
//! Resolves the `Authorization: Bearer` header via
|
||||
//! `IdentityProvider::resolve_from_token()` and stashes the resolved
|
||||
//! `Option<Identity>` in request extensions. Shared by the HTTP gateway
|
||||
//! endpoints and the `to_mcp` service.
|
||||
//! Two middleware shapes, one resolution backend
|
||||
//! (`IdentityProvider::resolve_from_token()`), both stashing the
|
||||
//! resolved `Option<Identity>` in request extensions:
|
||||
//!
|
||||
//! Resolution semantics:
|
||||
//! - **[`bearer_auth_middleware`]** — Bearer-only. Applied to the
|
||||
//! gateway endpoints, `/openapi.json`, `/healthz`, and the `to_mcp`
|
||||
//! service. Token-auth surfaces (the gateway is the sole HTTP invoke
|
||||
//! path; MCP's core protocol is token-based) never resolve credentials
|
||||
//! presented as Basic.
|
||||
//! - **[`door_auth_middleware`]** — Basic-tolerant (ADR-073). Applied to
|
||||
//! the extras-door router (`HttpAdapter::with_extra_routes`), the
|
||||
//! surface legacy clients authenticate on: `git-lfs`'s batch API and
|
||||
//! git's smart-HTTP transport send `Basic base64(user:pass)`, with
|
||||
//! the password field acting as the token (the GitHub-PAT shape). The
|
||||
//! password bytes ride the same
|
||||
//! `IdentityProvider::resolve_from_token()` backend as a Bearer
|
||||
//! token — no separate resolution mechanism, no new trait method;
|
||||
//! the provider's grant table governs exactly as it does for Bearer.
|
||||
//! Basic resolution is deliberately scoped to the door surface: a
|
||||
//! Basic credential is never resolved on the bearer-auth surfaces
|
||||
//! unless the deployment explicitly mounts it there.
|
||||
//!
|
||||
//! Common resolution semantics (both middlewares):
|
||||
//! - No `Authorization` header → `None` (request proceeds; the route
|
||||
//! handler / `AccessControl` decides whether to reject).
|
||||
//! - Malformed `Authorization` header (not `Bearer <token>`) → `None`
|
||||
//! (treated as no-token, not an error — Bearer-only is the auth
|
||||
//! mechanism).
|
||||
//! - Token present but resolution fails → `None` (treat as
|
||||
//! - Unrecognized or unparseable `Authorization` header → `None`
|
||||
//! (treated as no-token, not an error).
|
||||
//! - Credential present but resolution fails → `None` (treat as
|
||||
//! unauthenticated, matching the call protocol's per-request identity
|
||||
//! resolution behavior).
|
||||
//!
|
||||
//! This middleware resolves identity and stashes it; it does NOT enforce
|
||||
//! These middlewares resolve identity and stash it; they do NOT enforce
|
||||
//! `AccessControl` (the route handlers / `GatewayDispatch::invoke()` do)
|
||||
//! or map `CallError` codes to HTTP status (the error mapping does).
|
||||
//!
|
||||
//! [ADR-004]: https://docs.rs/alkhttp (docs/architecture/decisions)
|
||||
//! [ADR-073]: https://docs.rs/alkhttp (docs/architecture/decisions)
|
||||
|
||||
use std::convert::Infallible;
|
||||
use std::sync::Arc;
|
||||
@@ -31,9 +48,13 @@ use axum::middleware::Next;
|
||||
use axum::response::Response;
|
||||
use http::request::Parts;
|
||||
|
||||
/// Axum middleware that resolves the `Authorization: Bearer` header via
|
||||
/// Axum middleware for the token-auth surfaces (the gateway endpoints,
|
||||
/// `/openapi.json`, `/healthz`, `to_mcp`): resolves the
|
||||
/// `Authorization: Bearer` header via
|
||||
/// `IdentityProvider::resolve_from_token()` and stashes the resolved
|
||||
/// `Option<Identity>` in request extensions.
|
||||
/// `Option<Identity>` in request extensions. Bearer-only — a Basic
|
||||
/// header contributes no identity (ADR-073 keeps Basic scoped to
|
||||
/// [`door_auth_middleware`]).
|
||||
///
|
||||
/// The state is `Arc<dyn IdentityProvider>` so the middleware can be applied
|
||||
/// via `middleware::from_fn_with_state(idp.clone(), bearer_auth_middleware)`
|
||||
@@ -48,6 +69,26 @@ pub async fn bearer_auth_middleware(
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
/// Axum middleware for the extras-door surface (`HttpAdapter::
|
||||
/// with_extra_routes`, ADR-073): resolves the `Authorization` header —
|
||||
/// Bearer first, then Basic (the password field as the token via the
|
||||
/// same `IdentityProvider::resolve_from_token()` backend) — and stashes
|
||||
/// the resolved `Option<Identity>` in request extensions. This is the
|
||||
/// surface legacy clients (`git-lfs`, git's smart-HTTP transport)
|
||||
/// authenticate on with the Basic scheme.
|
||||
///
|
||||
/// The state is `Arc<dyn IdentityProvider>` (same shape as
|
||||
/// [`bearer_auth_middleware`]).
|
||||
pub async fn door_auth_middleware(
|
||||
State(identity_provider): State<Arc<dyn IdentityProvider>>,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let identity = extract_door_identity(&request, identity_provider.as_ref());
|
||||
request.extensions_mut().insert(identity);
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
/// Extract the `Authorization: Bearer <token>` header and resolve it to
|
||||
/// an `Option<Identity>`. Returns `None` if no token is present (the
|
||||
/// request proceeds unauthenticated; the route handler / `AccessControl`
|
||||
@@ -57,12 +98,63 @@ pub async fn bearer_auth_middleware(
|
||||
pub fn extract_bearer_identity(
|
||||
request: &Request,
|
||||
identity_provider: &dyn IdentityProvider,
|
||||
) -> Option<Identity> {
|
||||
resolve_token_str(
|
||||
bearer_token(request.headers().get(AUTHORIZATION)).map(String::into_bytes),
|
||||
identity_provider,
|
||||
)
|
||||
}
|
||||
|
||||
/// Extract the `Authorization` header with Bearer-first, Basic-tolerant
|
||||
/// semantics (ADR-073): a `Basic base64(user:pass)` header resolves the
|
||||
/// password field (the token; the Git-PAT shape — user, `x-access-token`
|
||||
/// and similar are ignored by resolution) through the same
|
||||
/// `IdentityProvider::resolve_from_token()` backend as a Bearer token.
|
||||
/// The password bytes are passed to the backend with no UTF-8
|
||||
/// round-trip, so RFC 7617 legacy ISO-8859-1 credentials reach the
|
||||
/// provider byte-faithfully. A colon-less value is treated as a bare
|
||||
/// password. Unparseable or
|
||||
/// unresolvable credentials resolve to `None` — the same no-token
|
||||
/// fallback as [`extract_bearer_identity`].
|
||||
pub fn extract_door_identity(
|
||||
request: &Request,
|
||||
identity_provider: &dyn IdentityProvider,
|
||||
) -> Option<Identity> {
|
||||
let header = request.headers().get(AUTHORIZATION)?;
|
||||
let token_str = header.to_str().ok()?.strip_prefix("Bearer ")?;
|
||||
let token = AuthToken {
|
||||
raw: token_str.as_bytes().to_vec(),
|
||||
let credential = match bearer_token(Some(header)) {
|
||||
Some(token) => token.into_bytes(),
|
||||
None => basic_password(header)?,
|
||||
};
|
||||
resolve_token_str(Some(credential), identity_provider)
|
||||
}
|
||||
|
||||
fn bearer_token(header: Option<&http::HeaderValue>) -> Option<String> {
|
||||
header?
|
||||
.to_str()
|
||||
.ok()?
|
||||
.strip_prefix("Bearer ")
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
fn basic_password(header: &http::HeaderValue) -> Option<Vec<u8>> {
|
||||
use base64::Engine as _;
|
||||
|
||||
let encoded = header.to_str().ok()?.strip_prefix("Basic ")?;
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(encoded.trim())
|
||||
.ok()?;
|
||||
let password = match decoded.iter().position(|&byte| byte == b':') {
|
||||
Some(colon) => &decoded[colon + 1..],
|
||||
None => &decoded[..],
|
||||
};
|
||||
Some(password.to_vec())
|
||||
}
|
||||
|
||||
fn resolve_token_str(
|
||||
token_bytes: Option<Vec<u8>>,
|
||||
identity_provider: &dyn IdentityProvider,
|
||||
) -> Option<Identity> {
|
||||
let token = AuthToken { raw: token_bytes? };
|
||||
identity_provider.resolve_from_token(&token)
|
||||
}
|
||||
|
||||
@@ -107,6 +199,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
struct PasswordTokenProvider {
|
||||
password: String,
|
||||
}
|
||||
|
||||
impl IdentityProvider for PasswordTokenProvider {
|
||||
fn resolve_from_fingerprint(&self, _: &str) -> Option<Identity> {
|
||||
None
|
||||
}
|
||||
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity> {
|
||||
(String::from_utf8_lossy(&token.raw) == self.password).then(sample_identity)
|
||||
}
|
||||
}
|
||||
|
||||
struct StaticProvider {
|
||||
identity: Option<Identity>,
|
||||
}
|
||||
@@ -165,6 +270,71 @@ mod tests {
|
||||
assert!(identity.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn door_extract_resolves_basic_password_through_the_same_token_backend() {
|
||||
let idp = PasswordTokenProvider {
|
||||
password: "s3cret".to_string(),
|
||||
};
|
||||
let req = request_with_authorization(Some("Basic dXNlcjpzM2NyZXQ="));
|
||||
let identity = crate::server::auth::extract_door_identity(&req, &idp);
|
||||
assert!(identity.is_some(), "Basic password resolves as the token");
|
||||
assert_eq!(identity.unwrap().id, "worker-a");
|
||||
|
||||
let req = request_with_authorization(Some("Basic OnMzY3JldA=="));
|
||||
assert!(crate::server::auth::extract_door_identity(&req, &idp).is_some());
|
||||
|
||||
let req = request_with_authorization(Some("Bearer s3cret"));
|
||||
assert!(crate::server::auth::extract_door_identity(&req, &idp).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn door_extract_passes_non_utf8_basic_password_bytes_through() {
|
||||
// RFC 7617 legacy ISO-8859-1 credential: "passwörd" (ö = 0xF6 in
|
||||
// latin-1) — invalid UTF-8, must still reach `resolve_from_token`
|
||||
// as the raw bytes rather than parsing to `None`.
|
||||
struct ByteProvider;
|
||||
impl IdentityProvider for ByteProvider {
|
||||
fn resolve_from_fingerprint(&self, _: &str) -> Option<Identity> {
|
||||
None
|
||||
}
|
||||
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity> {
|
||||
(token.raw == b"passw\xf6rd"[..]).then(sample_identity)
|
||||
}
|
||||
}
|
||||
let req = request_with_authorization(Some("Basic dXNlcjpwYXNzd/ZyZA=="));
|
||||
let identity = crate::server::auth::extract_door_identity(&req, &ByteProvider);
|
||||
assert!(
|
||||
identity.is_some(),
|
||||
"a latin-1 password resolves byte-faithfully"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn door_extract_treats_colonless_basic_as_a_bare_password() {
|
||||
struct BareProvider;
|
||||
impl IdentityProvider for BareProvider {
|
||||
fn resolve_from_fingerprint(&self, _: &str) -> Option<Identity> {
|
||||
None
|
||||
}
|
||||
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity> {
|
||||
(String::from_utf8_lossy(&token.raw) == "baretok").then(sample_identity)
|
||||
}
|
||||
}
|
||||
let req = request_with_authorization(Some("Basic YmFyZXRvaw=="));
|
||||
let identity = crate::server::auth::extract_door_identity(&req, &BareProvider);
|
||||
assert!(identity.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn door_extract_returns_none_for_invalid_base64_or_failing_resolution() {
|
||||
let idp = provider(None);
|
||||
let req = request_with_authorization(Some("Basic !not-base64!"));
|
||||
assert!(crate::server::auth::extract_door_identity(&req, idp.as_ref()).is_none());
|
||||
|
||||
let req = request_with_authorization(Some("Basic dXNlcjpwYXNz"));
|
||||
assert!(crate::server::auth::extract_door_identity(&req, idp.as_ref()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_returns_none_when_token_present_but_resolution_fails() {
|
||||
let idp = provider(None);
|
||||
@@ -255,6 +425,38 @@ mod tests {
|
||||
assert_eq!(&bytes[..], b"none");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn middleware_stashes_identity_for_basic_on_the_door_middleware() {
|
||||
let idp: std::sync::Arc<dyn IdentityProvider> =
|
||||
std::sync::Arc::new(PasswordTokenProvider {
|
||||
password: "s3cret".to_string(),
|
||||
});
|
||||
let app: Router<()> = Router::new()
|
||||
.route(
|
||||
"/",
|
||||
get(|req: Request| async move {
|
||||
let identity = req
|
||||
.extensions()
|
||||
.get::<Option<Identity>>()
|
||||
.cloned()
|
||||
.flatten();
|
||||
match identity {
|
||||
Some(id) => (StatusCode::OK, id.id),
|
||||
None => (StatusCode::OK, "none".to_string()),
|
||||
}
|
||||
}),
|
||||
)
|
||||
.layer(from_fn_with_state(idp, door_auth_middleware));
|
||||
|
||||
let req = request_with_authorization(Some("Basic dXNlcjpzM2NyZXQ="));
|
||||
let response = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(&bytes[..], b"worker-a");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolved_identity_extractor_retrieves_stashed_some() {
|
||||
let idp = provider(Some(sample_identity()));
|
||||
|
||||
+4
-1
@@ -5,7 +5,10 @@ pub mod healthz;
|
||||
pub mod state;
|
||||
|
||||
pub use adapter::{HttpAdapter, RESERVED_PATHS, WS_UPGRADE_PATH};
|
||||
pub use auth::{bearer_auth_middleware, extract_bearer_identity, ResolvedIdentity};
|
||||
pub use auth::{
|
||||
bearer_auth_middleware, door_auth_middleware, extract_bearer_identity, extract_door_identity,
|
||||
ResolvedIdentity,
|
||||
};
|
||||
pub use decoy::{decoy_fallback, decoy_method_not_allowed};
|
||||
pub use healthz::healthz;
|
||||
pub use state::DecoyConfig;
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -267,14 +267,17 @@ impl WsSessions {
|
||||
#[derive(Clone)]
|
||||
pub struct OpenableAlpn {
|
||||
/// The open-op spec (Query/Mutation/Sub with the `channel_open`
|
||||
/// marker; a `Pub` open op resolves `channel:pub_open_not_implemented`
|
||||
/// per the upstream C-08 stub until channel adoption lands).
|
||||
/// marker). A `Pub`-typed marked spec is refused loudly at
|
||||
/// registration by the upstream openable path (marked pub
|
||||
/// openables register via alkcall's
|
||||
/// `ChannelCore::register_pub_openable`, which this WS fork
|
||||
/// wrapper does not thread yet).
|
||||
pub spec: OperationSpec,
|
||||
/// The data-plane protocol handler spawned on the allocated
|
||||
/// channel's `Connection` — `Fn(Value, Option<ChannelPlan>,
|
||||
/// Connection, AuthContext) -> JoinHandle<()>`. With an
|
||||
/// establisher attached, the plan (alkcall 0.6.0 /
|
||||
/// ADR-049 amendment 2) is the establisher's `Establishment.plan`:
|
||||
/// establisher attached, the plan (alkcall ADR-049
|
||||
/// amendment 2) is the establisher's `Establishment.plan`:
|
||||
/// the establisher and the handler agree on the concrete type
|
||||
/// (downcast in the ALPN crate); `None` when no establisher is
|
||||
/// registered or it returned `Establishment::default()`
|
||||
|
||||
+19
-8
@@ -212,7 +212,8 @@ async fn browser_registered_op_lands_in_overlay_not_peer_composite() {
|
||||
let overlay_env = conn.overlay_env();
|
||||
assert!(!overlay_env.contains("ui/dragged"));
|
||||
|
||||
conn.register_imported(browser_registration("ui/dragged", AccessControl::default()));
|
||||
conn.register_imported(browser_registration("ui/dragged", AccessControl::default()))
|
||||
.expect("register_imported must accept the unmarked spec");
|
||||
assert!(overlay_env.contains("ui/dragged"));
|
||||
assert!(
|
||||
overlay_env.peer_ids().is_empty(),
|
||||
@@ -226,7 +227,8 @@ async fn hub_call_reaches_browser_op_through_overlay_env() {
|
||||
let dp = dispatcher(registry, provider_with(vec![]));
|
||||
let conn = Arc::new(CallConnection::new_overlay_only(identity("browser", &[])));
|
||||
|
||||
conn.register_imported(browser_registration("ui/dragged", AccessControl::default()));
|
||||
conn.register_imported(browser_registration("ui/dragged", AccessControl::default()))
|
||||
.expect("register_imported must accept the unmarked spec");
|
||||
|
||||
let ctx = hub_root_context(
|
||||
"hub-call-1",
|
||||
@@ -257,7 +259,8 @@ async fn peerref_specific_browser_x_routes_to_nothing() {
|
||||
let registry = echo_registry();
|
||||
let dp = dispatcher(registry, provider_with(vec![]));
|
||||
let conn = Arc::new(CallConnection::new_overlay_only(identity("browser", &[])));
|
||||
conn.register_imported(browser_registration("ui/dragged", AccessControl::default()));
|
||||
conn.register_imported(browser_registration("ui/dragged", AccessControl::default()))
|
||||
.expect("register_imported must accept the unmarked spec");
|
||||
|
||||
let composed_env = dp.compose_root_env(
|
||||
&conn,
|
||||
@@ -300,7 +303,8 @@ async fn access_control_on_browser_op_gates_hub_call() {
|
||||
required_scopes: vec!["ui:write".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
))
|
||||
.expect("register_imported must accept the unmarked spec");
|
||||
let env = conn.overlay_env();
|
||||
let ctx = hub_root_context(
|
||||
"hub-acl-ok",
|
||||
@@ -332,7 +336,9 @@ async fn access_control_on_browser_op_gates_hub_call() {
|
||||
#[tokio::test]
|
||||
async fn overlay_dropped_on_disconnect_no_leak_between_connections() {
|
||||
let conn1 = CallConnection::new_overlay_only(identity("browser-1", &[]));
|
||||
conn1.register_imported(browser_registration("ui/dragged", AccessControl::default()));
|
||||
conn1
|
||||
.register_imported(browser_registration("ui/dragged", AccessControl::default()))
|
||||
.expect("register_imported must accept the unmarked spec");
|
||||
assert!(conn1.overlay_env().contains("ui/dragged"));
|
||||
drop(conn1);
|
||||
|
||||
@@ -345,8 +351,12 @@ async fn overlay_dropped_on_disconnect_no_leak_between_connections() {
|
||||
// Connection-local isolation: each session's overlay is its own.
|
||||
let conn_a = CallConnection::new_overlay_only(identity("browser-a", &[]));
|
||||
let conn_b = CallConnection::new_overlay_only(identity("browser-b", &[]));
|
||||
conn_a.register_imported(browser_registration("ui/dragged", AccessControl::default()));
|
||||
conn_b.register_imported(browser_registration("ui/click", AccessControl::default()));
|
||||
conn_a
|
||||
.register_imported(browser_registration("ui/dragged", AccessControl::default()))
|
||||
.expect("register_imported must accept the unmarked spec");
|
||||
conn_b
|
||||
.register_imported(browser_registration("ui/click", AccessControl::default()))
|
||||
.expect("register_imported must accept the unmarked spec");
|
||||
assert!(conn_a.overlay_env().contains("ui/dragged"));
|
||||
assert!(!conn_a.overlay_env().contains("ui/click"));
|
||||
assert!(conn_b.overlay_env().contains("ui/click"));
|
||||
@@ -356,7 +366,8 @@ async fn overlay_dropped_on_disconnect_no_leak_between_connections() {
|
||||
#[tokio::test]
|
||||
async fn ws_close_mid_call_to_browser_op_aborts_call() {
|
||||
let conn = Arc::new(CallConnection::new_overlay_only(identity("browser", &[])));
|
||||
conn.register_imported(browser_registration("ui/dragged", AccessControl::default()));
|
||||
conn.register_imported(browser_registration("ui/dragged", AccessControl::default()))
|
||||
.expect("register_imported must accept the unmarked spec");
|
||||
|
||||
let rx = {
|
||||
let mut pending = conn.pending().lock();
|
||||
|
||||
@@ -806,9 +806,13 @@ async fn session_cap_rejects_over_limit_with_503_and_frees_slots_on_end() {
|
||||
}
|
||||
|
||||
// The adapter's built-in surface: WS route + bearer middleware, the
|
||||
// whole deployment shape the cap knob configures.
|
||||
let adapter =
|
||||
HttpAdapter::new(Arc::new(StaticTok), Arc::clone(®istry)).with_ws_max_sessions(1);
|
||||
// whole deployment shape the cap knob configures. A builder call
|
||||
// after `with_ws_max_sessions` must not revert the cap (the
|
||||
// pre-`router_state()` builder shape rebuilt the router from the
|
||||
// un-stored fresh semaphore).
|
||||
let adapter = HttpAdapter::new(Arc::new(StaticTok), Arc::clone(®istry))
|
||||
.with_ws_max_sessions(1)
|
||||
.with_decoy(alkhttp::server::DecoyConfig::default());
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = format!("ws://{}", listener.local_addr().unwrap());
|
||||
let app: axum::Router = adapter.router().clone();
|
||||
|
||||
Reference in new issue
Block a user