Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 4 additions & 24 deletions crates/ourios-core/src/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,21 +130,6 @@ pub struct AuthConfig {
pub oidc: Option<OidcConfig>,
}

impl AuthConfig {
/// The store the enforcement points consume today.
///
/// With OIDC configured but no static tokens this is an *empty* store:
/// auth stays enforced (every bearer is rejected) rather than open. The
/// RFC 0029 verifier slice replaces the gates' store parameter with the
/// full config and retires this bridge.
#[must_use]
pub fn enforcement_store(&self) -> TokenStore {
self.static_tokens.clone().unwrap_or(TokenStore {
entries: Vec::new(),
})
}
}

/// Validate a raw `auth` section's halves into the resolved [`AuthConfig`]
/// (RFC 0026 §3.1 + RFC 0029 §3.1). Callers only invoke this for a present
/// `auth` section — an absent section is open mode and never reaches here.
Expand Down Expand Up @@ -266,9 +251,7 @@ impl fmt::Debug for ResolvedToken {

/// The validated `auth.tokens` store (RFC 0026 §3.1). Non-empty when it
/// comes from [`build_token_store`] (an empty *config list* is a startup
/// error); the one deliberate empty state is
/// [`AuthConfig::enforcement_store`]'s oidc-only bridge, where matching
/// nothing — rejecting every bearer — is the point.
/// error).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TokenStore {
entries: Vec<ResolvedToken>,
Expand Down Expand Up @@ -598,19 +581,16 @@ mod tests {
let oidc_only = build_auth_config(None, Some(&oidc_spec())).expect("oidc-only");
assert!(oidc_only.static_tokens.is_none());
assert_eq!(oidc_only.oidc.as_ref().expect("oidc").audience(), "ourios");
let bridge = oidc_only.enforcement_store();
assert!(
bridge.authenticate("any-bearer").is_none(),
"the bridge store matches nothing — enforced, not open"
);

let both = build_auth_config(
Some(&[spec("edge", "tok-edge", &["acme"])]),
Some(&oidc_spec()),
)
.expect("both halves");
assert_eq!(
both.enforcement_store()
both.static_tokens
.as_ref()
.expect("static half intact")
.authenticate("tok-edge")
.expect("static half intact")
.name(),
Expand Down
54 changes: 27 additions & 27 deletions crates/ourios-server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -576,29 +576,22 @@ fn warn_if_open_mode(config: &ServerConfig) {
}
}

/// The store the listeners enforce against (RFC 0026 §3.2/§3.3) — each
/// role derives its own instance from the one resolved config. Open mode
/// stays `None`; an oidc-only config yields an empty store — enforced,
/// not open — until the RFC 0029 verifier slice teaches the gates the
/// full [`ourios_server::auth::AuthConfig`].
fn enforcement_store(
config: &ServerConfig,
) -> Option<std::sync::Arc<ourios_server::auth::TokenStore>> {
config
.auth
.as_ref()
.map(|auth| std::sync::Arc::new(auth.enforcement_store()))
}

/// Build the ingest listeners' RFC 0026/0029 credential resolver from the
/// Build a network role's RFC 0026/0029 credential resolver from the
/// resolved auth config. OIDC discovery contacts the issuer once, here at
/// startup — a failure (unreachable issuer, issuer mismatch, unusable
/// JWKS) is a startup error, not a degraded mode (§3.2: with no cached
/// keys nothing could ever verify).
async fn ingest_resolver(
/// `None` when no network role is enabled (nothing to authenticate — and
/// no issuer round-trip on a compactor-only process); otherwise built
/// exactly once and cloned into each role, so OIDC discovery runs once
/// and the roles share the verifier's JWKS cache + refresh throttle.
async fn auth_resolver(
config: &ServerConfig,
) -> Result<ourios_ingester::receiver::AuthResolver, String> {
) -> Result<Option<ourios_ingester::receiver::AuthResolver>, String> {
use ourios_ingester::receiver::AuthResolver;
if config.receiver.is_none() && config.querier.is_none() {
return Ok(None);
}
let static_store = config
.auth
.as_ref()
Expand All @@ -609,12 +602,12 @@ async fn ingest_resolver(
let verifier = ourios_core::auth::oidc::OidcVerifier::discover(oidc)
.await
.map_err(|e| format!("auth.oidc: {e}"))?;
Ok(AuthResolver::with_oidc(
Ok(Some(AuthResolver::with_oidc(
static_store,
std::sync::Arc::new(verifier),
))
)))
}
None => Ok(AuthResolver::static_only(static_store)),
None => Ok(Some(AuthResolver::static_only(static_store))),
}
}

Expand Down Expand Up @@ -665,6 +658,9 @@ async fn main() -> Result<(), Box<dyn Error>> {
// Start the OTLP receiver role if enabled (RFC 0003 §9). Report the
// bound addresses on stdout so an operator — or a test binding `:0` —
// learns the actual ports.
// One resolver, built once, shared by every enabled network role.
let resolver = auth_resolver(&config).await?;

let receiver = match &config.receiver {
// The receiver's RFC 0014 data write path runs on the resolved store
// (local or S3, RFC 0019 slice 2c) — the same store the querier reads
Expand All @@ -679,7 +675,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
// handle is cheap to share, the compactor keeps the original).
store: store.clone(),
promoted: config.promoted.clone(),
auth: ingest_resolver(&config).await?,
auth: resolver.clone().expect("resolver built for enabled roles"),
})
.await?;
println!("receiver gRPC listening on {}", handle.grpc_addr);
Expand All @@ -700,7 +696,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
// The querier engine is Store-capable (RFC 0019 slice 2a), so it
// reads whichever backend config resolved (local or S3).
store: config.store.clone(),
auth: enforcement_store(&config),
auth: resolver.clone().expect("resolver built for enabled roles"),
default_window_nanos: params.default_window_nanos,
mcp_enabled: params.mcp_enabled,
})
Expand Down Expand Up @@ -1010,7 +1006,11 @@ auth:
})
.expect("well-formed file");
let config = server_config_from_file(&file).expect("valid");
let store = config.auth.expect("auth resolved").enforcement_store();
let store = config
.auth
.expect("auth resolved")
.static_tokens
.expect("static half");
assert_eq!(
store.authenticate("resolved-token").expect("match").name(),
"edge-collector",
Expand Down Expand Up @@ -1055,10 +1055,10 @@ auth:
assert_eq!(oidc.issuer(), "https://dex.internal.example");
assert_eq!(oidc.name_claim(), "sub", "core default applied");
assert!(auth.static_tokens.is_none(), "no static half");
assert!(
auth.enforcement_store().authenticate("any").is_none(),
"oidc-only enforces (rejects) rather than opening the gates",
);
// Enforced-not-open for oidc-only is a resolver/serving property
// now (the `enforcement_store` bridge is retired): the served
// `rfc0029_oidc` arms assert the wire-level 401.
assert!(auth.oidc.is_some() && auth.static_tokens.is_none());

let err = server_config(
"storage:\n local:\n bucket_root: /x\nauth:\n oidc:\n issuer: https://x\n tenant_claim: t\n",
Expand Down
66 changes: 39 additions & 27 deletions crates/ourios-server/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,8 @@ use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use ourios_core::auth::TokenStore;
use ourios_core::tenant::TenantId;
use ourios_ingester::receiver::authenticate_bearer;
use ourios_ingester::receiver::{AuthBinding, AuthResolver};
use ourios_querier::Querier;
use ourios_querier::dsl::{self, Statement};
use rmcp::handler::server::ServerHandler;
Expand Down Expand Up @@ -112,7 +111,7 @@ pub(crate) struct TemplateDriftArgs {
pub(crate) struct OuriosMcp {
querier: Arc<Querier>,
default_window_nanos: u64,
auth: Option<Arc<TokenStore>>,
auth: AuthResolver,
metrics: Arc<crate::querier::QuerierMetrics>,
}

Expand Down Expand Up @@ -141,22 +140,28 @@ impl OuriosMcp {
ctx: &rmcp::service::RequestContext<rmcp::RoleServer>,
tenant: &str,
) -> Result<(), ErrorData> {
let Some(store) = self.auth.as_deref() else {
if self.auth.is_open() {
return Ok(());
};
let authorization = ctx
}
// The transport layer (`require_bearer`) already authenticated and
// cached the resolved binding on the request — read it from the
// forwarded parts rather than verifying the credential twice. A
// missing binding here means the transport gate did not run: fail
// closed.
let binding = ctx
.extensions
.get::<axum::http::request::Parts>()
.and_then(|parts| parts.headers.get(header::AUTHORIZATION))
.and_then(|value| value.to_str().ok());
let binding = authenticate_bearer(Some(store), authorization)
.map_err(|_| ErrorData::invalid_request("a valid bearer token is required", None))?;
.and_then(|parts| parts.extensions.get::<AuthBinding>());
match binding {
Some(binding) if !binding.tenants().allows(tenant) => Err(ErrorData::invalid_request(
Some(binding) if binding.tenants().allows(tenant) => Ok(()),
Some(_) => Err(ErrorData::invalid_request(
"the tenant is outside the authenticated token's allowed set",
None,
)),
_ => Ok(()),
None => Err(ErrorData::invalid_request(
"a valid bearer token is required",
None,
)),
}
}
}
Expand All @@ -166,7 +171,7 @@ impl OuriosMcp {
fn new(
querier: Arc<Querier>,
default_window_nanos: u64,
auth: Option<Arc<TokenStore>>,
auth: AuthResolver,
metrics: Arc<crate::querier::QuerierMetrics>,
) -> Self {
Self {
Expand Down Expand Up @@ -417,21 +422,28 @@ fn json_content<T: serde::Serialize>(value: &T) -> Result<CallToolResult, ErrorD
Ok(CallToolResult::success(vec![ContentBlock::text(text)]))
}

/// The RFC 0026 bearer gate as an axum layer over the MCP service
/// (§3.1): open mode passes through; with a store, a missing/malformed/
/// unknown credential is one undifferentiated 401 before any MCP
/// dispatch.
async fn require_bearer(
auth: Option<Arc<TokenStore>>,
request: Request<Body>,
next: Next,
) -> Response {
/// The RFC 0026/0029 bearer gate as an axum layer over the MCP service
/// (§3.1): open mode passes through; with auth configured (static
/// tokens, OIDC, or both), a missing/malformed/unknown/unverifiable
/// credential is one undifferentiated 401 before any MCP dispatch.
async fn require_bearer(auth: AuthResolver, mut request: Request<Body>, next: Next) -> Response {
let authorization = request
.headers()
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok());
if authenticate_bearer(auth.as_deref(), authorization).is_err() {
return (StatusCode::UNAUTHORIZED, "a valid bearer token is required").into_response();
.and_then(|value| value.to_str().ok())
.map(str::to_owned);
match auth.authenticate(authorization.as_deref()).await {
Ok(None) => {}
// Cache the resolved binding on the request so the per-tool
// tenant check reads it from the forwarded parts instead of
// verifying the credential a second time (with OIDC that second
// pass could even refetch the JWKS).
Ok(Some(binding)) => {
request.extensions_mut().insert(binding);
}
Err(_) => {
return (StatusCode::UNAUTHORIZED, "a valid bearer token is required").into_response();
}
}
next.run(request).await
}
Expand All @@ -445,7 +457,7 @@ async fn require_bearer(
pub(crate) fn mcp_router(
querier: Arc<Querier>,
default_window_nanos: u64,
auth: Option<Arc<TokenStore>>,
auth: AuthResolver,
metrics: Arc<crate::querier::QuerierMetrics>,
) -> Router {
// (`StreamableHttpServerConfig` is `#[non_exhaustive]`; mutate a
Expand All @@ -460,7 +472,7 @@ pub(crate) fn mcp_router(
// contract): the role never comes up serving a malformed resource.
let _ = *GRAMMAR_SECTION;
let mut config = StreamableHttpServerConfig::default();
if auth.is_some() {
if !auth.is_open() {
config.allowed_hosts = Vec::new();
}
let handler_auth = auth.clone();
Expand Down
31 changes: 18 additions & 13 deletions crates/ourios-server/src/querier.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,7 @@ use axum::response::{IntoResponse, Response};
use axum::routing::post;
use opentelemetry::metrics::{Counter, Histogram};
use opentelemetry::{KeyValue, global};
use ourios_core::auth::TokenStore;
use ourios_ingester::receiver::auth::{AuthBinding, authenticate_bearer};
use ourios_ingester::receiver::auth::{AuthBinding, AuthResolver};
use serde::Serialize;
use tokio::net::TcpListener;
use tokio::sync::watch;
Expand Down Expand Up @@ -77,11 +76,12 @@ pub struct QuerierConfig {
/// Serve the RFC 0027 MCP surface at `/mcp` (`querier.mcp.enabled`;
/// default off). The RFC 0026 gate applies to it identically.
pub mcp_enabled: bool,
/// The RFC 0026 token store; `None` is open mode (§3.1). With a store,
/// every request must carry a known `Authorization: Bearer` credential
/// (→ 401) and its `x-ourios-tenant` must fall inside the token's set
/// The RFC 0026/0029 credential resolver (`is_open()` = open mode,
/// §3.1). Otherwise every request must carry a resolvable
/// `Authorization: Bearer` credential (→ 401) and its
/// `x-ourios-tenant` must fall inside the resolved binding's set
/// (→ 403); the missing-tenant 400 is unchanged (§3.3).
pub auth: Option<Arc<TokenStore>>,
pub auth: AuthResolver,
/// The look-back applied to a query with no `range(...)` stage — the
/// server-supplied default window the DSL compiler expects (RFC 0002 §4 P5;
/// RFC 0016 §7).
Expand Down Expand Up @@ -214,22 +214,26 @@ struct QuerierState {
querier: Arc<Querier>,
default_window_nanos: u64,
metrics: Arc<QuerierMetrics>,
auth: Option<Arc<TokenStore>>,
auth: AuthResolver,
}

/// Build the querier role's axum router over a **local** store root (RFC 0016
/// §3.3). Split out from [`serve`] so it can be driven in-process by tests; the
/// local backend is the test/dev default and the RFC 0019 regression guard.
pub fn router(bucket_root: PathBuf, default_window_nanos: u64) -> Router {
router_with_auth(bucket_root, default_window_nanos, None)
router_with_auth(
bucket_root,
default_window_nanos,
AuthResolver::static_only(None),
)
}

/// [`router_with_auth`] plus the RFC 0027 `/mcp` surface — the fully
/// optioned constructor tests drive (RFC0027.1/.2).
pub fn router_with_mcp(
bucket_root: PathBuf,
default_window_nanos: u64,
auth: Option<Arc<TokenStore>>,
auth: AuthResolver,
mcp_enabled: bool,
) -> Router {
router_from_querier(
Expand All @@ -240,12 +244,13 @@ pub fn router_with_mcp(
)
}

/// [`router`] with an RFC 0026 token store — the authenticated variant
/// [`router`] with an RFC 0026/0029 credential resolver — the authenticated
/// variant (`AuthResolver::is_open()` = open mode)
/// (`None` = open mode, identical to [`router`]).
pub fn router_with_auth(
bucket_root: PathBuf,
default_window_nanos: u64,
auth: Option<Arc<TokenStore>>,
auth: AuthResolver,
) -> Router {
router_from_querier(Querier::new(bucket_root), default_window_nanos, auth, false)
}
Expand All @@ -256,7 +261,7 @@ pub fn router_with_auth(
fn router_from_querier(
querier: Querier,
default_window_nanos: u64,
auth: Option<Arc<TokenStore>>,
auth: AuthResolver,
mcp_enabled: bool,
) -> Router {
let state = QuerierState {
Expand Down Expand Up @@ -345,7 +350,7 @@ async fn handle_query(
let authorization = headers
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok());
let Ok(binding) = authenticate_bearer(state.auth.as_deref(), authorization) else {
let Ok(binding) = state.auth.authenticate(authorization).await else {
// RFC 0026 §3.4: the rejection records on the existing
// `ourios.query.duration` histogram, kind `rejected`
// (pre-dispatch), tagged with `error.type`.
Expand Down
Loading