tensor_wasm_api/server.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3
4//! Axum router builder and listener.
5
6use std::net::SocketAddr;
7use std::sync::atomic::{AtomicBool, Ordering};
8use std::sync::Arc;
9use std::time::Duration;
10
11use axum::http::StatusCode;
12use axum::response::{IntoResponse, Response};
13use axum::routing::{delete, get, post};
14use axum::{Json, Router};
15use serde_json::json;
16use subtle::ConstantTimeEq;
17use tower::ServiceBuilder;
18
19use crate::audit::{audit_log_middleware, AuditConfig, TrustedProxies};
20use crate::http_metrics::{http_metrics_middleware, HttpMetricsLayerConfig, RouteAllowList};
21use crate::middleware::{
22 bearer_auth, body_limit_layer, concurrency_limit_layer, cors_layer, host_validate,
23 tenant_scope, timeout_layer, trace_layer_with_propagation, AuthConfig, CorsConfig,
24 KernelPublishTokens, TenantConfig, TrustedHosts, ENV_API_TOKENS, ENV_TRUSTED_HOSTS,
25 INVOKE_CONCURRENCY_LIMIT, MAX_REQUEST_BODY_BYTES, PROBE_CONCURRENCY_LIMIT,
26 READ_CONCURRENCY_LIMIT, WRITE_CONCURRENCY_LIMIT,
27};
28use crate::rate_limit::{rate_limit, RateLimitConfig, RateLimiter};
29use crate::routes::{
30 create_function, delete_function, get_job, healthz, invoke_function, invoke_function_async,
31 invoke_function_stream, metrics, snapshot_restore, snapshot_save, AppState,
32};
33use crate::trace_propagation::{inject_trace_id_header, install_w3c_propagator};
34
35/// Build the axum Router with all routes and middleware applied.
36///
37/// Reads [`AuthConfig`] (`$TENSOR_WASM_API_TOKENS`), [`TenantConfig`]
38/// (`$TENSOR_WASM_API_REQUIRE_TENANT`), [`RateLimitConfig`]
39/// (`$TENSOR_WASM_API_RATE_LIMIT_QPS` / `$TENSOR_WASM_API_RATE_LIMIT_BURST`),
40/// and [`CorsConfig`] (`$TENSOR_WASM_API_CORS_ALLOWED_ORIGINS`) from the
41/// process environment. Empty / unset `TENSOR_WASM_API_TOKENS` puts the
42/// gateway in dev mode (auth disabled with a startup warning); unset or
43/// zero rate-limit knobs disable the limiter (pass-through); empty / unset
44/// CORS allowlist rejects every cross-origin request (safe default).
45///
46/// If any bare (unscoped) entries are present in `TENSOR_WASM_API_TOKENS`,
47/// `AuthConfig::from_env` emits a one-shot deprecation warning naming the
48/// count — scoped tokens (`token:tenant=...`) are the supported form going
49/// forward and bare entries are scheduled for removal in v1.0.
50pub fn build_router(state: Arc<AppState>) -> Router {
51 let auth = AuthConfig::from_env();
52 let tenant = TenantConfig::from_env();
53 let limiter = RateLimiter::new(RateLimitConfig::from_env());
54 let audit = AuditConfig::from_env();
55 let cors = CorsConfig::from_env();
56 // M5: read the top-level `AppConfig` (snapshot HMAC signing key +
57 // require-signature toggle) from the environment and thread it onto
58 // the shared `AppState` so the `/snapshot/save` and `/snapshot/restore`
59 // handlers consume `TENSOR_WASM_API_SNAPSHOT_HMAC_KEY`. This is the
60 // production caller that turns the previously-inert key into a
61 // live knob (closes finding M5). A malformed key is a hard startup
62 // error — we refuse to come up serving snapshot routes under a
63 // misconfigured signing key rather than silently degrade.
64 let state = apply_app_config_from_env(state);
65 build_router_with_audit(state, auth, tenant, limiter, audit, cors)
66}
67
68/// Read [`AppConfig::from_env`](crate::config::AppConfig::from_env) and
69/// install it onto `state` so the snapshot routes pick up the operator's
70/// signing key.
71///
72/// On a malformed snapshot key / toggle this logs the parse error and
73/// **panics** — unlike the kernel registry (which degrades to `503`), a
74/// snapshot signing-key misconfiguration must be fatal at startup, because
75/// silently dropping the key would downgrade restore integrity (a wrong
76/// or empty key would let unverified blobs through once the routes are
77/// reachable). The hard-fail mirrors the contract documented on
78/// `AppConfig::from_env`.
79///
80/// `state` arrives behind an `Arc`; we recover the inner value when we are
81/// the sole owner (the normal startup case) and otherwise clone-and-replace
82/// the `app_config` field so the contract holds even if a caller has
83/// already shared the handle.
84fn apply_app_config_from_env(state: Arc<AppState>) -> Arc<AppState> {
85 let cfg = crate::config::AppConfig::from_env().unwrap_or_else(|e| {
86 // Key-free: `ConfigError::Display` never prints the secret bytes.
87 panic!(
88 "invalid snapshot configuration (TENSOR_WASM_API_SNAPSHOT_*): {e}; \
89 refusing to start with a misconfigured snapshot signing key",
90 );
91 });
92 match Arc::try_unwrap(state) {
93 Ok(inner) => Arc::new(inner.with_app_config(cfg)),
94 Err(shared) => {
95 // The handle was already cloned elsewhere; rebuild a fresh
96 // `AppState` carrying the same registries/executor with the
97 // config applied. Cloning `AppState` is cheap — the maps,
98 // metrics, and executor all sit behind their own `Arc`.
99 Arc::new((*shared).clone().with_app_config(cfg))
100 }
101 }
102}
103
104/// Build the router with explicit auth / tenant config and the rate limiter
105/// disabled. Backwards-compatible shim retained for integration tests that
106/// pre-date the per-token rate limiter; new tests should call
107/// [`build_router_with_full_config`].
108pub fn build_router_with_config(
109 state: Arc<AppState>,
110 auth: AuthConfig,
111 tenant: TenantConfig,
112) -> Router {
113 build_router_with_full_config(
114 state,
115 auth,
116 tenant,
117 RateLimiter::new(RateLimitConfig::disabled()),
118 )
119}
120
121/// Build the router with explicitly supplied auth, tenant, and rate-limit
122/// config. Used by integration tests so they can drive the gateway without
123/// poisoning the process environment.
124///
125/// The audit log defaults to the no-op sink in this constructor: existing
126/// integration tests that pre-date the audit middleware must not have
127/// their stdout polluted by audit records. Tests that exercise the audit
128/// path explicitly should call [`build_router_with_audit`].
129pub fn build_router_with_full_config(
130 state: Arc<AppState>,
131 auth: AuthConfig,
132 tenant: TenantConfig,
133 limiter: RateLimiter,
134) -> Router {
135 build_router_with_audit(
136 state,
137 auth,
138 tenant,
139 limiter,
140 AuditConfig::disabled(),
141 CorsConfig::default(),
142 )
143}
144
145/// Build the router with full configuration including the audit sink and
146/// the CORS allowlist.
147///
148/// The outer ServiceBuilder is layered top-to-bottom: `host_validate`
149/// (with its `TrustedHosts` extension) is the outermost pair — T19 perf
150/// — so hostile Host probes are rejected with `400` before any trace
151/// span is allocated or any propagator hop runs. The trace layer wraps
152/// the rest of the stack, followed by `inject_trace_id_header`, the
153/// CORS layer (so cross-origin preflight short-circuits before any
154/// expensive downstream work), the body limit (which guards every
155/// downstream layer from oversized payloads), the per-request timeout
156/// and the global concurrency cap. Auth and tenant resolution are
157/// `from_fn` middleware that run after the size cap (so a request that
158/// would be rejected with 413 does not consume an auth slot). The
159/// per-token rate limiter runs after bearer auth so it can read the
160/// AuthContext the auth layer inserts. The audit middleware sits
161/// innermost, after every other layer has resolved the actor / tenant /
162/// scope, so the synthesised record captures the same identity the
163/// handler saw.
164pub fn build_router_with_audit(
165 state: Arc<AppState>,
166 auth: AuthConfig,
167 tenant: TenantConfig,
168 limiter: RateLimiter,
169 audit: AuditConfig,
170 cors: CorsConfig,
171) -> Router {
172 build_router_with_trusted_proxies(
173 state,
174 auth,
175 tenant,
176 limiter,
177 audit,
178 cors,
179 TrustedProxies::from_env(),
180 )
181}
182
183/// Build the router with full configuration *and* an explicit XFCC
184/// trusted-proxy allowlist.
185///
186/// Mirrors [`build_router_with_audit`] but lets the caller inject an
187/// explicit [`TrustedProxies`] instead of reading
188/// `TENSOR_WASM_API_TRUSTED_XFCC_PROXIES` from the ambient process
189/// environment. Integration tests that exercise the XFCC-gating path use
190/// this constructor so parallel tests do not race on a global env var.
191///
192/// See the module-level comment block on
193/// `crate::audit::extract_client_cert_subject_gated` for the threat
194/// model.
195pub fn build_router_with_trusted_proxies(
196 state: Arc<AppState>,
197 auth: AuthConfig,
198 tenant: TenantConfig,
199 limiter: RateLimiter,
200 audit: AuditConfig,
201 cors: CorsConfig,
202 trusted_proxies: TrustedProxies,
203) -> Router {
204 // Defer to the kernel-publish-tokens variant, reading the
205 // production default from the env. Existing callers that pre-date
206 // the kernel-publish gate keep their call site untouched.
207 build_router_full(
208 state,
209 auth,
210 tenant,
211 limiter,
212 audit,
213 cors,
214 trusted_proxies,
215 KernelPublishTokens::from_env(),
216 )
217}
218
219/// Build the router with every override exposed, including the explicit
220/// [`KernelPublishTokens`] allowlist for `POST /kernels`.
221///
222/// This is the lowest-level public builder. Integration tests use it to
223/// exercise the kernel-publish authorization gate without poisoning the
224/// process environment via `TENSOR_WASM_API_KERNEL_PUBLISH_TOKENS`.
225/// Production code reaches it transitively from
226/// [`build_router_with_trusted_proxies`], which reads
227/// [`KernelPublishTokens::from_env`] internally.
228///
229/// All other parameters behave identically to
230/// [`build_router_with_trusted_proxies`].
231#[allow(clippy::too_many_arguments)]
232pub fn build_router_with_kernel_publish_tokens(
233 state: Arc<AppState>,
234 auth: AuthConfig,
235 tenant: TenantConfig,
236 limiter: RateLimiter,
237 audit: AuditConfig,
238 cors: CorsConfig,
239 trusted_proxies: TrustedProxies,
240 kernel_publish_tokens: KernelPublishTokens,
241) -> Router {
242 build_router_full(
243 state,
244 auth,
245 tenant,
246 limiter,
247 audit,
248 cors,
249 trusted_proxies,
250 kernel_publish_tokens,
251 )
252}
253
254/// Process-wide latch for the T16 "tokens set but trusted_hosts unset"
255/// startup warning. The warning fires at most once per process even if
256/// the router builders are invoked multiple times (tests routinely
257/// rebuild the router for each case; production starts the gateway once).
258static T16_HOST_WARN_FIRED: AtomicBool = AtomicBool::new(false);
259
260/// SECURITY (H3): environment variable carrying an optional bearer token
261/// that gates `GET /metrics`. When set to a non-empty value the metrics
262/// scrape endpoint requires `Authorization: Bearer <token>` matching this
263/// value (constant-time compared); when unset or empty the endpoint stays
264/// unauthenticated (the historical Prometheus-scrape posture) and a
265/// one-shot `warn!` is emitted at startup. See `MetricsAuth` and
266/// `metrics_auth_gate`.
267pub const ENV_METRICS_TOKEN: &str = "TENSOR_WASM_API_METRICS_TOKEN";
268
269/// Process-wide latch for the H3 "metrics endpoint is unauthenticated"
270/// startup warning. Like [`T16_HOST_WARN_FIRED`] this fires at most once
271/// per process so repeated `build_router*` calls in the test suite do not
272/// flood the log.
273static H3_METRICS_WARN_FIRED: AtomicBool = AtomicBool::new(false);
274
275/// SECURITY (H3): resolved gate for the unauthenticated `/metrics` scrape
276/// endpoint.
277///
278/// `/metrics` is documented `security: []` in
279/// `openapi/tensor-wasm-api.yaml` so Prometheus scrapers and k8s tooling
280/// can hit it without a bearer token — the default (env unset) preserves
281/// that posture. Operators that expose the listener on a shared interface
282/// can opt into a bearer-token gate by setting
283/// [`ENV_METRICS_TOKEN`]; when set, `metrics_auth_gate` requires
284/// `Authorization: Bearer <token>` and returns `401` otherwise.
285#[derive(Debug, Clone, Default)]
286struct MetricsAuth {
287 /// The configured token, or `None` when the endpoint is left
288 /// unauthenticated. `Arc<str>` so cloning into every request's
289 /// extensions does not copy the secret bytes.
290 token: Option<Arc<str>>,
291}
292
293impl MetricsAuth {
294 /// Load the optional metrics token from [`ENV_METRICS_TOKEN`].
295 ///
296 /// Unset / empty (after trimming) → unauthenticated (the historical
297 /// posture); the one-shot startup warning is emitted here so operators
298 /// running an internet-facing listener without an ingress ACL notice
299 /// the open scrape target. A non-empty value enables the bearer gate.
300 fn from_env() -> Self {
301 let raw = std::env::var(ENV_METRICS_TOKEN).unwrap_or_default();
302 let trimmed = raw.trim();
303 if trimmed.is_empty() {
304 // One-shot warning: the scrape target carries per-tenant
305 // operational data and is unauthenticated. Idempotent across
306 // router rebuilds via the CAS latch.
307 if H3_METRICS_WARN_FIRED
308 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
309 .is_ok()
310 {
311 tracing::warn!(
312 target: "tensor_wasm_api::server",
313 "GET /metrics is unauthenticated (TENSOR_WASM_API_METRICS_TOKEN \
314 unset) — it exposes per-tenant gauges and route/error \
315 distributions. This is the expected Prometheus-scrape \
316 posture behind an internal interface or ingress ACL; set \
317 TENSOR_WASM_API_METRICS_TOKEN=<token> to require \
318 `Authorization: Bearer <token>` on the scrape path.",
319 );
320 }
321 Self { token: None }
322 } else {
323 tracing::info!(
324 target: "tensor_wasm_api::server",
325 "GET /metrics requires a bearer token (TENSOR_WASM_API_METRICS_TOKEN set)",
326 );
327 Self {
328 token: Some(Arc::from(trimmed)),
329 }
330 }
331 }
332}
333
334/// Reset the H3 metrics startup-warning latch.
335///
336/// Test-only, mirroring [`__reset_host_validation_warn_for_test`]: each
337/// scenario in an integration test that asserts on the warning needs a
338/// clean "not yet warned" state. Production code MUST NOT call this — the
339/// warning is fire-once for the life of the process by design.
340#[doc(hidden)]
341pub fn __reset_metrics_warn_for_test() {
342 H3_METRICS_WARN_FIRED.store(false, Ordering::Release);
343}
344
345/// SECURITY (H3): bearer-token gate applied ONLY to the `/metrics` route.
346///
347/// The handler in `routes.rs` is left untouched (it is owned by another
348/// agent and is `security: []` by contract); this middleware wraps the
349/// route in `server.rs` so the gate is purely additive and opt-in:
350///
351/// * No [`MetricsAuth`] in the extensions, or `token == None` → pass
352/// through unauthenticated (the default Prometheus-scrape posture).
353/// * Token configured → require `Authorization: Bearer <token>` and reject
354/// with `401 unauthorized` (standard `{ "error": { kind, message } }`
355/// envelope) otherwise. The comparison is constant-time
356/// ([`subtle::ConstantTimeEq`]) so a timing side-channel cannot be used
357/// to recover the token byte-by-byte, mirroring the bearer allowlist
358/// compare in `crate::middleware::AuthConfig::scope_for`.
359async fn metrics_auth_gate(req: axum::extract::Request, next: axum::middleware::Next) -> Response {
360 let configured = req
361 .extensions()
362 .get::<MetricsAuth>()
363 .and_then(|m| m.token.clone());
364
365 let Some(expected) = configured else {
366 // Unauthenticated posture (default): pass through unchanged.
367 return next.run(req).await;
368 };
369
370 // A token is required. Recover the `Authorization: Bearer <token>`
371 // header and constant-time compare against the configured value.
372 let presented = req
373 .headers()
374 .get(axum::http::header::AUTHORIZATION)
375 .and_then(|h| h.to_str().ok())
376 .and_then(parse_bearer_token);
377
378 let authorized = match presented {
379 // `ct_eq` requires equal-length inputs to be meaningful; a length
380 // mismatch is an immediate non-match. We still run `ct_eq` only
381 // when lengths match so the byte loop does not early-exit on the
382 // first differing byte.
383 Some(tok) => {
384 let a = tok.as_bytes();
385 let b = expected.as_bytes();
386 a.len() == b.len() && a.ct_eq(b).into()
387 }
388 None => false,
389 };
390
391 if !authorized {
392 return metrics_unauthorized();
393 }
394 next.run(req).await
395}
396
397/// Render the `401` envelope for a rejected `/metrics` request. Mirrors the
398/// `{ "error": { "kind", "message" } }` shape produced by
399/// `crate::middleware::envelope` (which is private to that module, so this
400/// is a local copy rather than a shared import).
401fn metrics_unauthorized() -> Response {
402 (
403 StatusCode::UNAUTHORIZED,
404 Json(json!({
405 "error": {
406 "kind": "unauthorized",
407 "message": "GET /metrics requires a valid Authorization: Bearer <token> header",
408 }
409 })),
410 )
411 .into_response()
412}
413
414/// Parse the credentials of a `Bearer` `Authorization` header value.
415///
416/// Local minimal mirror of `crate::middleware::parse_bearer_credentials`
417/// (which is private to that module and lives in a file this change must
418/// not touch). Splits on the first space/tab, matches the scheme
419/// case-insensitively, trims surrounding BWS, and rejects control bytes so
420/// a CR/LF/NUL cannot be smuggled into a downstream log line. Returns
421/// `None` for a missing/empty credential so the caller treats it as a
422/// non-match.
423fn parse_bearer_token(value: &str) -> Option<&str> {
424 if value.bytes().any(|b| b != b'\t' && (b < 0x20 || b == 0x7F)) {
425 return None;
426 }
427 let split = value
428 .as_bytes()
429 .iter()
430 .position(|&b| b == b' ' || b == b'\t')?;
431 let (scheme, rest) = value.split_at(split);
432 if !scheme.eq_ignore_ascii_case("bearer") {
433 return None;
434 }
435 let token = rest.trim_matches(|c: char| c == ' ' || c == '\t');
436 if token.is_empty() {
437 None
438 } else {
439 Some(token)
440 }
441}
442
443/// One-shot startup safety check: when the gateway is in production mode
444/// (`TENSOR_WASM_API_TOKENS` set to a non-empty value) but the operator
445/// has not configured a `Host` allowlist (`TENSOR_WASM_API_TRUSTED_HOSTS`
446/// unset or empty), emit a single `tracing::warn!` that points at the
447/// virtual-host-confusion / cache-poisoning risk. The runtime
448/// `host_validate` middleware itself stays a no-op when no allowlist is
449/// configured — this only adds operator-facing visibility so a
450/// production deployment behind a misconfigured ingress that forwards
451/// arbitrary `Host` headers is no longer silent.
452///
453/// Closes the T16 audit finding. The atomic latch makes the warning
454/// idempotent across repeated `build_router*` calls in the same process
455/// (the integration test suite alone rebuilds the router dozens of
456/// times); operators see exactly one log line per process.
457fn maybe_warn_host_validation_disabled() {
458 let tokens_set = std::env::var(ENV_API_TOKENS).is_ok_and(|v| !v.is_empty());
459 if !tokens_set {
460 return;
461 }
462 let trusted_hosts_set =
463 std::env::var(ENV_TRUSTED_HOSTS).is_ok_and(|v| !TrustedHosts::from_raw(&v).is_empty());
464 if trusted_hosts_set {
465 return;
466 }
467 // CAS so we only emit once per process. `Acquire`/`Release` is
468 // overkill for a log latch but matches the pattern used by the
469 // `install_w3c_propagator` `Once` elsewhere in this module.
470 if T16_HOST_WARN_FIRED
471 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
472 .is_ok()
473 {
474 tracing::warn!(
475 target: "tensor_wasm_api::server",
476 "TENSOR_WASM_API_TOKENS is set (production-mode) but \
477 TENSOR_WASM_API_TRUSTED_HOSTS is unset — Host header \
478 validation is disabled. This is OK behind an ingress that \
479 strips/validates Host, otherwise enable trusted hosts to \
480 avoid virtual-host confusion. Set \
481 TENSOR_WASM_API_TRUSTED_HOSTS=host1.example.com,host2.example.com \
482 to enable.",
483 );
484 }
485}
486
487/// Reset the T16 startup-warning latch.
488///
489/// Used by the integration test in
490/// `tests/host_validate_startup_warn.rs` so each scenario starts from a
491/// clean "not yet warned" state. Production code MUST NOT call this:
492/// the warning is fire-once for the life of the process by design, and
493/// resetting it lets the misconfiguration warning fire repeatedly on
494/// every router rebuild. The function is hidden from rustdoc and prefixed
495/// with `__` to discourage external callers; it is `pub` only so the
496/// integration test binary (which lives outside the crate root) can
497/// reach it without depending on `#[cfg(test)]` visibility, which does
498/// not extend to integration tests.
499#[doc(hidden)]
500pub fn __reset_host_validation_warn_for_test() {
501 T16_HOST_WARN_FIRED.store(false, Ordering::Release);
502}
503
504#[allow(clippy::too_many_arguments)]
505fn build_router_full(
506 state: Arc<AppState>,
507 auth: AuthConfig,
508 tenant: TenantConfig,
509 limiter: RateLimiter,
510 audit: AuditConfig,
511 cors: CorsConfig,
512 trusted_proxies: TrustedProxies,
513 kernel_publish_tokens: KernelPublishTokens,
514) -> Router {
515 // When `kernel-registry-api` is OFF the kernel router below is not
516 // built, so the parameter is unused on that build axis. Drop it
517 // explicitly under cfg-off to silence the unused-variables lint
518 // without poking holes in attribute placement.
519 #[cfg(not(feature = "kernel-registry-api"))]
520 let _ = kernel_publish_tokens;
521 // T16: one-shot warning when production-mode tokens are configured
522 // but the Host allowlist is not. The runtime middleware behaviour is
523 // unchanged (pass-through with no allowlist); only the operator-
524 // facing visibility is new.
525 maybe_warn_host_validation_disabled();
526 // Wire the W3C Trace Context propagator globally. Idempotent across
527 // calls; safe to invoke on every router rebuild (tests do that
528 // routinely). Without this, the tower `trace_layer_with_propagation`
529 // would silently see an empty parent context for every inbound
530 // `traceparent` header and start a fresh root span — collapsing the
531 // distributed-tracing invariant the gateway documents in
532 // `docs/OBSERVABILITY.md`.
533 install_w3c_propagator();
534
535 // HTTP metrics middleware sits OUTSIDE bearer_auth so 401 responses
536 // are counted as well — the SLO doc (`docs/SLO.md` §2.1
537 // `availability_http`) defines the SLI as a ratio over *every* HTTP
538 // response, including auth rejections, and the burn-rate alerts in
539 // §5 rely on that. Placing it inside the auth gate would drop ~all
540 // probe traffic from the rate panels in the reference dashboard.
541 let http_metrics_cfg = HttpMetricsLayerConfig {
542 metrics: Arc::clone(&state.metrics),
543 routes: RouteAllowList::new_default(),
544 };
545
546 // Layers that apply to EVERY route (protected and probe alike): tracing,
547 // trace-id injection, the body cap, the timeout, the concurrency cap,
548 // and HTTP metrics counting. Auth / tenant / rate-limit / audit are
549 // intentionally NOT in this stack — `/healthz` and `/metrics` are
550 // documented in `openapi/tensor-wasm-api.yaml` as `security: []` (no
551 // auth) so that k8s liveness/readiness probes and Prometheus scrapers
552 // can hit them without holding bearer tokens.
553 //
554 // T19 perf: `host_validate` (with its `TrustedHosts` extension) is
555 // layered OUTSIDE `trace_layer_with_propagation` so a hostile Host
556 // probe gets rejected with `400` before any trace span is allocated
557 // or any `traceparent` parent context is propagated. Under a probe
558 // storm against a production deployment that has the allowlist set,
559 // this keeps the trace pipeline (and any downstream OTel collector
560 // budget) from being burnt on rejections. Rejections short-circuit
561 // before the trace layer (and before `http_metrics_middleware`
562 // which sits inside the trace layer), so a rejected request gets
563 // neither a span nor a metric increment — intentional tradeoff: a
564 // hostile Host probe storm cannot burn trace budget or metric
565 // cardinality, at the cost of losing observability on the rejected
566 // 4xx itself. The Host allowlist is opt-in via
567 // `TENSOR_WASM_API_TRUSTED_HOSTS`; deployments that leave it unset
568 // are unaffected by the re-order (the middleware is a pass-through
569 // and never short-circuits).
570 let common_layers = ServiceBuilder::new()
571 // api S-30: reject requests whose Host header isn't in the
572 // operator-configured allowlist (TENSOR_WASM_API_TRUSTED_HOSTS).
573 // Default (env unset) is permissive — the previous behaviour —
574 // because most local-dev deployments don't set the env var.
575 // Production behind a layered proxy should set the allowlist.
576 //
577 // The parsed allowlist travels as an `axum::Extension<TrustedHosts>`
578 // so tests can override at build time
579 // (`router.layer(axum::Extension(TrustedHosts::from_hosts([...])))`)
580 // without poisoning the process environment. Inserted BEFORE the
581 // `from_fn(host_validate)` layer so the middleware sees it on the
582 // request extensions when it runs.
583 //
584 // T19 perf: this pair sits OUTERMOST so hostile Host probes are
585 // rejected before the trace layer allocates a span for them.
586 .layer(axum::Extension(TrustedHosts::from_env()))
587 .layer(axum::middleware::from_fn(host_validate))
588 .layer(trace_layer_with_propagation())
589 // `inject_trace_id_header` runs inside the trace layer so the
590 // current span (the one the trace layer just created with its
591 // parent already attached) is the one whose `trace_id` we surface
592 // back to the caller via the `x-trace-id` response header. This
593 // is the operator-facing handle the `docs/runbooks/trace-id.md`
594 // runbook points at; without it operators have to read journald
595 // to recover the trace id of a failed request.
596 .layer(axum::middleware::from_fn(inject_trace_id_header))
597 // CORS sits near the outer edge so the layer can short-circuit
598 // browser preflight (`OPTIONS`) without it needing to clear bearer
599 // auth, rate-limit, or audit. The default config has an empty
600 // origin allowlist — cross-origin browser callers get no
601 // `Access-Control-Allow-Origin` header and the browser blocks the
602 // request — operators widen the surface via
603 // `TENSOR_WASM_API_CORS_ALLOWED_ORIGINS`. The headers and methods
604 // admitted on a cross-origin request mirror the API contract in
605 // `API.md`: `Authorization`, `Content-Type`, `X-TensorWasm-Tenant`,
606 // and `Traceparent`; methods `GET`, `POST`, `DELETE`.
607 .layer(cors_layer(&cors))
608 .layer(body_limit_layer(MAX_REQUEST_BODY_BYTES))
609 .layer(timeout_layer(Duration::from_secs(30)))
610 // NOTE (api S-26): the global ConcurrencyLimit(64) is removed.
611 // Per-route caps below isolate budgets so a probe storm cannot
612 // starve /invoke, and a noisy /invoke cannot starve probes.
613 // Pass the auth + tenant + limiter + audit config into the request
614 // extensions so the protected stack's `from_fn` middleware can pick
615 // them up without capturing through a type-erased closure. The
616 // probe stack does not consume these but inserting them is cheap
617 // and keeps the stacks symmetric — relevant if a future probe
618 // grows an auth-aware behaviour (e.g. degraded-mode signalling).
619 .layer(axum::Extension(auth))
620 .layer(axum::Extension(tenant))
621 .layer(axum::Extension(limiter))
622 .layer(axum::Extension(audit))
623 // XFCC spoofing mitigation: parsed allowlist of reverse-proxy peer
624 // addresses whose `X-Forwarded-Client-Cert` headers the audit
625 // middleware will trust. Empty / unset
626 // (`TENSOR_WASM_API_TRUSTED_XFCC_PROXIES`) = trust nobody, drop
627 // every inbound XFCC. See `crate::audit::TrustedProxies` and the
628 // threat-model comment on `extract_client_cert_subject_gated`.
629 .layer(axum::Extension(trusted_proxies))
630 .layer(axum::Extension(http_metrics_cfg))
631 // Metrics emission wraps every downstream layer (including
632 // bearer_auth) so 401s, 429s, and handler responses all get
633 // counted — see the comment block above.
634 .layer(axum::middleware::from_fn(http_metrics_middleware));
635
636 // Probe stack: `/healthz` and `/metrics` deliberately bypass bearer
637 // auth, tenant scope, the per-token rate limiter, and the audit log.
638 // OpenAPI (`openapi/tensor-wasm-api.yaml` `paths./healthz` and
639 // `paths./metrics`) declares `security: []` for both, k8s probes do
640 // not carry an Authorization header, and Prometheus scrapers typically
641 // share a single credential across many endpoints — protecting these
642 // here would break the published contract and silently disable
643 // upstream health checks.
644 // SECURITY (H3): `/metrics` is mounted in its own sub-router so the
645 // opt-in bearer gate (`metrics_auth_gate`) can be layered onto it
646 // WITHOUT touching the handler in `routes.rs` or gating `/healthz`.
647 // The endpoint stays `security: []` in
648 // `openapi/tensor-wasm-api.yaml` by default — Prometheus scrapers and
649 // k8s tooling expect an unauthenticated scrape target, and the
650 // endpoint exposes per-tenant operational data (the HTTP-metric
651 // families carry a `route` label, and the registry also surfaces
652 // per-tenant gauges). Operators MUST bind the listener to an internal
653 // interface or place `/metrics` behind an ingress ACL whenever the
654 // listener is internet-facing.
655 //
656 // When `TENSOR_WASM_API_METRICS_TOKEN` is set, `metrics_auth_gate`
657 // requires `Authorization: Bearer <token>` (constant-time compared)
658 // and returns `401` otherwise; when unset, the gate is a pass-through
659 // and `MetricsAuth::from_env` emits a one-shot startup `warn!` that
660 // the scrape target is unauthenticated. The `MetricsAuth` extension
661 // is inserted on this sub-router only, so `/healthz` and every other
662 // route are oblivious to it. The published spec documents `/metrics`
663 // as `security: []` (open by default, matching the unset-env posture)
664 // and its `description` notes that setting
665 // `TENSOR_WASM_API_METRICS_TOKEN` opts the endpoint into a bearer gate;
666 // see `openapi/tensor-wasm-api.yaml` and `crates/tensor-wasm-api/openapi.json`.
667 let metrics_router = Router::new()
668 .route("/metrics", get(metrics))
669 .layer(axum::middleware::from_fn(metrics_auth_gate))
670 .layer(axum::Extension(MetricsAuth::from_env()));
671
672 let probe_router = Router::new()
673 .route("/healthz", get(healthz))
674 .merge(metrics_router)
675 // api S-26: probes get their own generous budget. A k8s deployment
676 // can have many replicas all scraping at once without affecting
677 // invoke capacity.
678 .layer(concurrency_limit_layer(PROBE_CONCURRENCY_LIMIT));
679
680 // Protected stack: everything that operates on tenant data. Auth /
681 // tenant resolution / rate limit / audit all run on top of
682 // `common_layers`, in the same order as before so the audit record
683 // still observes the final status and any handler-stamped outcome
684 // extension.
685 // api S-26: split protected routes into three sub-routers by class so
686 // each gets an isolated concurrency budget. Invoke is tightest because
687 // calls hold a Wasmtime instance lock; writes are middle; reads are
688 // loosest.
689 let invoke_router = Router::new()
690 .route("/functions/:id/invoke", post(invoke_function))
691 .route("/functions/:id/invoke-async", post(invoke_function_async))
692 // Streaming invoke (roadmap feature #2). Restored by B7.1 and
693 // wired end-to-end by T34: the handler builds an mpsc channel,
694 // installs the sender on a `StreamingContext`, threads it
695 // through `SpawnConfig::with_streaming` to the executor, and
696 // drains the receiver into the SSE / chunked-transfer response
697 // body. See `docs/STREAMING.md`.
698 .route("/functions/:id/invoke-stream", post(invoke_function_stream))
699 .layer(concurrency_limit_layer(INVOKE_CONCURRENCY_LIMIT));
700 // M5: `/snapshot/save` and `/snapshot/restore` join the write-class
701 // budget. Both are POSTs that do CPU-bound crypto + (de)compression
702 // work on the blocking pool, comparable in weight to a deploy. They
703 // sit under the same protected stack as the other resource routes
704 // (bearer_auth + tenant_scope + rate_limit + audit applied below on
705 // `protected_router`), so they inherit:
706 // * bearer auth — an unauthenticated caller gets `401`, not `503`;
707 // * tenant scope — `X-TensorWasm-Tenant` is resolved and the handler
708 // runs `authorize_tenant` + a per-resource owner check (save: the
709 // function's `tenant_id`; restore: the HMAC-authenticated snapshot
710 // metadata's `tenant_id`) so cross-tenant access is `403
711 // tenant_scope_denied`;
712 // * per-token rate limiting and audit logging.
713 // When `TENSOR_WASM_API_SNAPSHOT_HMAC_KEY` is unset both handlers
714 // return `503 snapshot_signing_not_configured` (feature-detect shape).
715 let write_router = Router::new()
716 .route("/functions", post(create_function))
717 .route("/functions/:id", delete(delete_function))
718 .route("/snapshot/save", post(snapshot_save))
719 .route("/snapshot/restore", post(snapshot_restore))
720 .layer(concurrency_limit_layer(WRITE_CONCURRENCY_LIMIT));
721 let read_router = Router::new()
722 .route("/jobs/:id", get(get_job))
723 .layer(concurrency_limit_layer(READ_CONCURRENCY_LIMIT));
724
725 // Desired execution order (request flows outer -> inner):
726 // bearer_auth -> tenant_scope -> rate_limit -> audit -> handler
727 // so the auth layer rejects unauthenticated callers FIRST (before a
728 // rate-limit permit is spent), tenant_scope then resolves the
729 // `TenantId`, the per-token limiter reads the `AuthContext`, and the
730 // audit middleware runs INNERMOST so the record it synthesises sees
731 // the resolved actor / tenant / scope and the handler's final status.
732 //
733 // CRITICAL: in axum's `Router::layer`, the LAST `.layer(...)` added is
734 // the OUTERMOST (it runs first) — the opposite of `ServiceBuilder`.
735 // The calls below are therefore written innermost-first: `audit` is
736 // added first (runs last) and `bearer_auth` is added last (runs
737 // first). The `rate_limit_runs_after_bearer_auth` integration test
738 // pins this ordering.
739 let protected_router = invoke_router
740 .merge(write_router)
741 .merge(read_router)
742 .layer(axum::middleware::from_fn(audit_log_middleware))
743 .layer(axum::middleware::from_fn(rate_limit))
744 .layer(axum::middleware::from_fn(tenant_scope))
745 .layer(axum::middleware::from_fn(bearer_auth));
746
747 // OpenAI-compat shim stack (B4.9). The two `/v1/...` routes accept
748 // off-the-shelf OpenAI SDK requests; those clients send
749 // `Authorization: Bearer <api_key>` and typically NO
750 // `X-TensorWasm-Tenant` header. The `tenant_scope` middleware tolerates
751 // the absent-header case under the default policy
752 // (`TENSOR_WASM_API_REQUIRE_TENANT` unset) by inserting
753 // `TenantId(0)` into the request extensions, which is exactly the
754 // resolution the v0.4 wiring step will want as a fallback — the
755 // handler then runs the `authorize_tenant` gate against the bearer
756 // token's `TokenScope` so a scoped token that does not cover tenant 0
757 // still receives `403 tenant_scope_denied` (not `501`). T2 security
758 // fix: the gate must exist on these routes *before* v0.4 wires real
759 // execution; otherwise the locked URL surface inherits a hole. See
760 // `crates/tensor-wasm-api/src/openai.rs` and `docs/OPENAI-COMPAT.md`.
761 //
762 // Operators that set `TENSOR_WASM_API_REQUIRE_TENANT=1` deliberately
763 // opt into the stricter posture: the OpenAI routes then require the
764 // header too, and clients must use the gateway's native shim that
765 // injects `X-TensorWasm-Tenant` (or migrate to scoped tokens once v0.4
766 // surfaces tenant resolution from the bearer scope itself).
767 //
768 // Bearer auth, rate-limit, and audit still apply so an unauthenticated
769 // OpenAI client receives `401` (not `501`), and the v0.4 wiring step
770 // can rely on the same actor/tenant/audit pipeline as the native
771 // routes once tenant inference moves out of the header layer.
772 //
773 // Concurrency budget: share INVOKE_CONCURRENCY_LIMIT — T41 executes
774 // the same `TensorWasmExecutor` spawn path as native `/invoke`, so
775 // the budgets track in lockstep. Both OpenAI handlers now take a
776 // `State<Arc<AppState>>` extractor (T41) so they can read the
777 // `openai_model_map` and dispatch through the shared executor; the
778 // explicit `Router::<Arc<AppState>>::new()` annotation lines the
779 // sub-router up with `protected_router` and `probe_router` for the
780 // outer `.merge(...)` call.
781 let openai_router: Router<Arc<AppState>> = Router::new()
782 .route("/v1/completions", post(crate::openai::completions_handler))
783 .route(
784 "/v1/chat/completions",
785 post(crate::openai::chat_completions_handler),
786 )
787 // Innermost-first (see `protected_router`): bearer_auth added last
788 // runs first (outermost), audit added first runs last (innermost),
789 // concurrency stays closest to the handler.
790 .layer(concurrency_limit_layer(INVOKE_CONCURRENCY_LIMIT))
791 .layer(axum::middleware::from_fn(audit_log_middleware))
792 .layer(axum::middleware::from_fn(rate_limit))
793 .layer(axum::middleware::from_fn(tenant_scope))
794 .layer(axum::middleware::from_fn(bearer_auth));
795
796 // Kernel registry routes (B6.4 — roadmap feature #3 server-side).
797 // The endpoints are write-class (publish) and read-class (list /
798 // resolve); we put them behind the same `WRITE_CONCURRENCY_LIMIT`
799 // budget the function-mutating endpoints use.
800 //
801 // **T1 security fix.** Earlier scaffolding mounted these OUTSIDE
802 // `tenant_scope` on the rationale that the kernel registry is
803 // operator-scope (one HMAC key per deployment). That rationale was
804 // wrong for two reasons: (1) the `publish_kernel` handler took the
805 // tenant extension as `Option<...>` and ignored it, so any
806 // allowlisted token — including a tenant-1 token, or any caller at
807 // all in dev mode — could publish; (2) the documented
808 // `kernel-publish` scope check was unimplemented. Both holes are
809 // now closed:
810 //
811 // * The router sits under `bearer_auth` + `tenant_scope` so the
812 // handler can rely on the tenant being established and on the
813 // caller having cleared the API token allowlist.
814 // * An `axum::Extension<KernelPublishTokens>` carries the parsed
815 // `TENSOR_WASM_API_KERNEL_PUBLISH_TOKENS` allowlist into the
816 // handler. `publish_kernel` rejects dev-mode calls outright
817 // (`kernel_publish_disabled_in_dev_mode`) and any non-
818 // publish-scoped token with `kernel_publish_scope_required`.
819 // GET routes admit any authenticated tenant.
820 //
821 // Inserting the publish-tokens extension at the kernel router level
822 // (rather than `common_layers`) keeps the surface tight — every
823 // other route is oblivious to it. The list value flows in via the
824 // `kernel_publish_tokens` parameter so tests can call
825 // [`build_router_with_kernel_publish_tokens`] with an explicit
826 // allowlist (no env poisoning); production callers reach
827 // [`build_router_with_trusted_proxies`], which fills the parameter
828 // from `TENSOR_WASM_API_KERNEL_PUBLISH_TOKENS` via
829 // [`KernelPublishTokens::from_env`].
830 //
831 // The routes are gated behind the `kernel-registry-api` feature so
832 // the default build keeps the dep graph lean. Operators flip
833 // `--features kernel-registry-api` plus set
834 // `TENSOR_WASM_API_KERNEL_HMAC_KEY` to enable them; when the env
835 // var is unset the handlers themselves return
836 // `503 kernel_registry_not_configured` (so adding the routes here
837 // is safe even without the secret configured).
838 #[cfg(feature = "kernel-registry-api")]
839 let kernel_router: Router<Arc<AppState>> = Router::new()
840 .route(
841 "/kernels",
842 post(crate::kernels::publish_kernel).get(crate::kernels::list_kernels),
843 )
844 .route(
845 "/kernels/:name/:version",
846 get(crate::kernels::resolve_kernel),
847 )
848 // Layer ordering mirrors the protected_router stack. In axum's
849 // `Router::layer` the LAST `.layer(...)` added is the OUTERMOST
850 // (runs first), so the calls below are written innermost-first:
851 // bearer_auth is added last and therefore runs FIRST, putting the
852 // AuthContext in the request extensions before tenant_scope,
853 // rate_limit, and the innermost audit middleware run. The
854 // KernelPublishTokens extension and the concurrency limit are
855 // added first (inner-most) so they sit just above the handler —
856 // the publish-scope check reads KernelPublishTokens via
857 // `Extension<...>` in the handler signature, so the layer that
858 // installs it must run before the handler but after any layer that
859 // might short-circuit (auth / rate-limit). The
860 // `rate_limit_runs_after_bearer_auth` integration test pins this
861 // ordering.
862 .layer(concurrency_limit_layer(WRITE_CONCURRENCY_LIMIT))
863 .layer(axum::Extension(kernel_publish_tokens))
864 .layer(axum::middleware::from_fn(audit_log_middleware))
865 .layer(axum::middleware::from_fn(rate_limit))
866 .layer(axum::middleware::from_fn(tenant_scope))
867 .layer(axum::middleware::from_fn(bearer_auth));
868
869 let router = protected_router.merge(probe_router).merge(openai_router);
870 #[cfg(feature = "kernel-registry-api")]
871 let router = router.merge(kernel_router);
872 router.layer(common_layers).with_state(state)
873}
874
875/// Bind and serve the router on the given address.
876///
877/// The listener is wrapped with
878/// [`axum::Router::into_make_service_with_connect_info`] so the audit
879/// middleware can recover the immediate TCP peer's `SocketAddr` from the
880/// request extensions. The XFCC trusted-proxy gate
881/// (`crate::audit::TrustedProxies`) depends on that peer information to
882/// decide whether to honour the `X-Forwarded-Client-Cert` header.
883pub async fn serve(state: Arc<AppState>, addr: SocketAddr) -> anyhow::Result<()> {
884 let router = build_router(state);
885 let listener = tokio::net::TcpListener::bind(addr).await?;
886 tracing::info!(target: "tensor_wasm_api::server", %addr, "tensor-wasm-api listening");
887 axum::serve(
888 listener,
889 router.into_make_service_with_connect_info::<SocketAddr>(),
890 )
891 .await?;
892 Ok(())
893}
894
895// ---------------------------------------------------------------------------
896// Tests
897// ---------------------------------------------------------------------------
898
899#[cfg(test)]
900mod tests {
901 use super::*;
902
903 // SECURITY (H3): bearer parsing for the `/metrics` gate.
904 #[test]
905 fn parse_bearer_token_accepts_canonical_scheme() {
906 assert_eq!(parse_bearer_token("Bearer abc123"), Some("abc123"));
907 assert_eq!(parse_bearer_token("bearer abc123"), Some("abc123"));
908 assert_eq!(parse_bearer_token("BEARER\tabc123"), Some("abc123"));
909 assert_eq!(parse_bearer_token("Bearer spaced "), Some("spaced"));
910 }
911
912 #[test]
913 fn parse_bearer_token_rejects_non_bearer_and_empty() {
914 assert_eq!(parse_bearer_token("Basic abc123"), None);
915 assert_eq!(parse_bearer_token("Bearer"), None);
916 assert_eq!(parse_bearer_token("Bearer "), None);
917 assert_eq!(parse_bearer_token(""), None);
918 }
919
920 #[test]
921 fn parse_bearer_token_rejects_control_bytes() {
922 // CR/LF/NUL must not survive into a downstream log line.
923 assert_eq!(parse_bearer_token("Bearer ab\r\nc"), None);
924 assert_eq!(parse_bearer_token("Bearer ab\0c"), None);
925 }
926
927 // SECURITY (H3): an unconfigured `MetricsAuth` keeps the endpoint open.
928 #[test]
929 fn metrics_auth_default_is_unauthenticated() {
930 let auth = MetricsAuth::default();
931 assert!(auth.token.is_none());
932 }
933
934 // SECURITY (H3): the 401 envelope carries the standard error shape.
935 #[test]
936 fn metrics_unauthorized_envelope_shape() {
937 let resp = metrics_unauthorized();
938 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
939 }
940}