All checks were successful
CI / Format (push) Successful in 40s
CI / CUDA type-check (push) Successful in 1m39s
CI / Clippy (push) Successful in 2m56s
CI / Test (push) Successful in 6m36s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
cortex can now validate locally-unrecognised bearer keys against the
helexa-upstream authority and reserve/settle their budget there — mesh
accounts work for real inference. The EntitlementProvider trait is the
seam, so cortex's enforcement (auth.rs, metering.rs) is otherwise
unchanged.
- entitlements_upstream.rs: UpstreamEntitlementProvider over reqwest →
B2's /authz/v1 (resolve/reserve/settle/release/snapshot), presenting the
operator client bearer. Maps the wire contract back to the trait: granted
→ Reservation, rejected → BudgetError, 401 → InvalidKey. Fail-closed —
unreachable resolve → AuthError::Unavailable (503, never 401);
unreachable reserve → retryable BudgetError::RateLimited (refuse, never
serve un-authorized). settle/release are best-effort (the upstream
sweeper reaps a lost one).
- entitlements_chain.rs: ChainedEntitlementProvider tries local first
(operator + infra keys, no network), falls through to upstream for
unknown keys, and dispatches reserve/settle/release/snapshot to whichever
backend resolved each account (local treats unknown principals as
uncapped, so it can't be the blind default).
- cortex-core: AuthError::Unavailable{retry_after_secs}; [upstream] config
(enabled/url/bearer/timeout). auth.rs maps Unavailable → 503 +
Retry-After distinctly from InvalidKey → 401, regardless of require_auth.
- state.rs wires the chain when [upstream].enabled, else stays purely local.
Tests (upstream_chain.rs, 4): local key resolves without touching upstream;
unknown key falls through to a mock upstream; unknown-everywhere → 401
InvalidKey; upstream-unreachable → Unavailable (503-mapped), with local keys
still resolving. Existing gateway suites updated for the new config field.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
145 lines
6.0 KiB
Rust
145 lines
6.0 KiB
Rust
//! API-key authentication + principal resolution (#49).
|
|
//!
|
|
//! Identity rides standard bearer auth only — `Authorization: Bearer <key>`
|
|
//! — which is what keeps every tier OpenAI-compatible by construction (no
|
|
//! custom required headers or body fields, per #47). The middleware resolves
|
|
//! the key to a [`Principal`] via the [`EntitlementProvider`], carries it in
|
|
//! the request extensions for cortex-side metering/enforcement (#51/#52), and
|
|
//! stamps it as internal headers on the request so it reaches neuron, which
|
|
//! trusts cortex's assertion over WireGuard (#54).
|
|
//!
|
|
//! Anti-spoofing: any client-supplied principal header is **stripped** before
|
|
//! the authoritative value is stamped, so a client can never assert a
|
|
//! principal it didn't authenticate as.
|
|
//!
|
|
//! Rejection contract (#63): missing key under `require_auth`, or any present
|
|
//! but unresolvable key, yields `401 invalid_api_key` in the #60 envelope.
|
|
|
|
use crate::error::envelope_response;
|
|
use crate::state::CortexState;
|
|
use axum::extract::{Request, State};
|
|
use axum::http::header::AUTHORIZATION;
|
|
use axum::http::{HeaderMap, HeaderValue};
|
|
use axum::middleware::Next;
|
|
use axum::response::Response;
|
|
use cortex_core::entitlements::{AuthError, HEADER_ACCOUNT_ID, HEADER_KEY_ID};
|
|
use cortex_core::error_envelope::OpenAiError;
|
|
use std::sync::Arc;
|
|
|
|
/// Endpoints that never require auth: liveness/readiness probes. Everything
|
|
/// else flows through resolution.
|
|
fn is_public(path: &str) -> bool {
|
|
path == "/health" || path == "/"
|
|
}
|
|
|
|
/// Extract the bearer token from an `Authorization` header value, if present
|
|
/// and well-formed. Scheme match is case-insensitive per RFC 7235.
|
|
fn parse_bearer(headers: &HeaderMap) -> Option<String> {
|
|
let raw = headers.get(AUTHORIZATION)?.to_str().ok()?;
|
|
let (scheme, token) = raw.split_once(' ')?;
|
|
if scheme.eq_ignore_ascii_case("bearer") {
|
|
let token = token.trim();
|
|
(!token.is_empty()).then(|| token.to_string())
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Axum middleware: resolve the bearer key, attach the principal, stamp the
|
|
/// internal headers. Wired in `build_app` via `from_fn_with_state`.
|
|
pub async fn require_principal(
|
|
State(fleet): State<Arc<CortexState>>,
|
|
mut req: Request,
|
|
next: Next,
|
|
) -> Response {
|
|
if is_public(req.uri().path()) {
|
|
return next.run(req).await;
|
|
}
|
|
|
|
// Anti-spoof: drop any client-supplied principal headers up front.
|
|
{
|
|
let headers = req.headers_mut();
|
|
headers.remove(HEADER_ACCOUNT_ID);
|
|
headers.remove(HEADER_KEY_ID);
|
|
}
|
|
|
|
match parse_bearer(req.headers()) {
|
|
Some(key) => match fleet.entitlements.resolve(&key).await {
|
|
Ok(principal) => {
|
|
// Stamp the authoritative principal for neuron. Account/key
|
|
// ids come from operator config, so they're valid header
|
|
// values; guard anyway and skip a malformed one rather than
|
|
// panic.
|
|
if let (Ok(account), Ok(key_id)) = (
|
|
HeaderValue::from_str(&principal.account_id),
|
|
HeaderValue::from_str(&principal.key_id),
|
|
) {
|
|
let headers = req.headers_mut();
|
|
headers.insert(HEADER_ACCOUNT_ID, account);
|
|
headers.insert(HEADER_KEY_ID, key_id);
|
|
}
|
|
// Carry the typed principal for cortex-side metering (#51)
|
|
// and budget enforcement (#52).
|
|
req.extensions_mut().insert(principal);
|
|
next.run(req).await
|
|
}
|
|
// The entitlement authority is unreachable (upstream client
|
|
// blip, #57). Fail **closed but distinct**: a transient outage
|
|
// must not reject a real key as `401 invalid_api_key` — it's a
|
|
// retryable `503`. This holds regardless of require_auth: we
|
|
// can't safely serve a key we couldn't authorize.
|
|
Err(AuthError::Unavailable { retry_after_secs }) => {
|
|
envelope_response(OpenAiError::service_unavailable(
|
|
"entitlement authority temporarily unavailable",
|
|
Some(retry_after_secs),
|
|
))
|
|
}
|
|
// A genuinely unrecognized key only hard-fails when auth is
|
|
// *required*. In allow-anonymous mode (the default) we IGNORE it
|
|
// and serve unauthenticated — otherwise the placeholder keys that
|
|
// OpenAI-compatible clients send by default (opencode, Open WebUI,
|
|
// Agent Zero, litellm) would all break though the operator never
|
|
// opted into auth. Pre-#49 the bearer was never inspected; this
|
|
// preserves that for require_auth=false.
|
|
Err(AuthError::InvalidKey) => {
|
|
if fleet.require_auth {
|
|
unauthorized("invalid API key")
|
|
} else {
|
|
tracing::debug!(
|
|
"ignoring unrecognized bearer token (require_auth=false): serving anonymously"
|
|
);
|
|
next.run(req).await
|
|
}
|
|
}
|
|
},
|
|
None => {
|
|
if fleet.require_auth {
|
|
unauthorized("missing API key; supply 'Authorization: Bearer <key>'")
|
|
} else {
|
|
next.run(req).await
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `401 invalid_api_key` in the standard envelope (#63).
|
|
fn unauthorized(message: &str) -> Response {
|
|
envelope_response(OpenAiError::invalid_api_key(message))
|
|
}
|
|
|
|
/// Copy the cortex-stamped principal headers from an inbound [`HeaderMap`]
|
|
/// onto an outbound reqwest builder. Used by the Anthropic proxy paths,
|
|
/// which construct their own upstream requests instead of going through
|
|
/// [`crate::proxy::forward_request`] (which forwards all headers verbatim).
|
|
pub fn forward_principal_headers(
|
|
mut builder: reqwest::RequestBuilder,
|
|
headers: &HeaderMap,
|
|
) -> reqwest::RequestBuilder {
|
|
for name in [HEADER_ACCOUNT_ID, HEADER_KEY_ID] {
|
|
if let Some(value) = headers.get(name) {
|
|
builder = builder.header(name, value);
|
|
}
|
|
}
|
|
builder
|
|
}
|