diff --git a/Cargo.lock b/Cargo.lock index 445f52059..00d89b319 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3092,6 +3092,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "subtle", "tempfile", "testcontainers-modules", "tokio", diff --git a/crates/ourios-semconv/src/lib.rs b/crates/ourios-semconv/src/lib.rs index f2092c61c..735b7e724 100644 --- a/crates/ourios-semconv/src/lib.rs +++ b/crates/ourios-semconv/src/lib.rs @@ -196,6 +196,9 @@ pub const EVENT_OURIOS_RECEIVER_SNAPSHOT_ERROR: &str = "ourios.receiver.snapshot /// `ourios.receiver.wal.truncated` log event name. pub const EVENT_OURIOS_RECEIVER_WAL_TRUNCATED: &str = "ourios.receiver.wal.truncated"; +/// `ourios.server.auth.open_mode` log event name. +pub const EVENT_OURIOS_SERVER_AUTH_OPEN_MODE: &str = "ourios.server.auth.open_mode"; + /// `ourios.server.compaction.disabled` log event name. pub const EVENT_OURIOS_SERVER_COMPACTION_DISABLED: &str = "ourios.server.compaction.disabled"; diff --git a/crates/ourios-server/Cargo.toml b/crates/ourios-server/Cargo.toml index 5115dc58e..b20ebacc3 100644 --- a/crates/ourios-server/Cargo.toml +++ b/crates/ourios-server/Cargo.toml @@ -56,6 +56,9 @@ clap = { version = "4", default-features = false, features = ["std", "derive", " # these events to OTel log records pushed over OTLP (the dogfooding path) and # mirrors them to stderr; this crate only emits. tracing = { version = "0.1", default-features = false, features = ["std"] } +# Constant-time bearer-token comparison for the RFC 0026 token store (§6). +# Already in the dep tree transitively (rustls), so no new cargo-deny surface. +subtle = { version = "2.6", default-features = false } # RFC 0020 configuration-file schema (config::file): deserialise the YAML into # the schema, then substitute `${env:…}` in the typed scalar leaves (schema # validation runs on the raw text first, so an error never echoes a resolved diff --git a/crates/ourios-server/src/auth.rs b/crates/ourios-server/src/auth.rs new file mode 100644 index 000000000..d2d5435a3 --- /dev/null +++ b/crates/ourios-server/src/auth.rs @@ -0,0 +1,348 @@ +//! The resolved RFC 0026 token store: bearer-token authentication and +//! tenant binding. +//! +//! [`build_token_store`] validates the parsed `auth` section +//! ([`config::file::AuthSection`](crate::config::file::AuthSection)) into a +//! [`TokenStore`] — the shape both enforcement points consume: the OTLP +//! receiver's listeners (RFC 0026 §3.2) and the HTTP query API (§3.3). An +//! absent section resolves to `None` (open mode, §3.1); an empty token list +//! is a startup error rather than a locked-out server. +//! +//! Token comparison is constant-time by construction +//! ([`TokenStore::authenticate`] goes through `subtle`), and no type here +//! renders a token value: `Debug` shows entry names and tenant sets only, +//! and validation errors name an entry's `name`/index, never its token +//! (RFC 0026 §3.1 / RFC 0020 §3.5). + +use std::collections::BTreeSet; +use std::fmt; + +use subtle::ConstantTimeEq; + +use crate::config::file::{AuthSection, TokenEntry}; + +/// The tenant set a token is bound to (RFC 0026 §3.1): the single wildcard +/// `"*"`, or an exact-string allow-list. An enum rather than an optional +/// list so "all tenants" and "no tenants" cannot be conflated. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TenantSet { + /// `tenants: ["*"]` — every tenant is allowed. + All, + /// An exact-string allow-list (non-empty by construction). + Listed(BTreeSet), +} + +impl TenantSet { + /// Whether `tenant` falls inside this set. + #[must_use] + pub fn allows(&self, tenant: &str) -> bool { + match self { + Self::All => true, + Self::Listed(set) => set.contains(tenant), + } + } +} + +/// One validated token: the audit/metric label, the secret value, and the +/// tenant binding. +#[derive(Clone, PartialEq, Eq)] +pub struct ResolvedToken { + name: String, + token: String, + tenants: TenantSet, +} + +impl ResolvedToken { + /// The audit/metric label for this token — never secret (RFC 0026 §3.4). + #[must_use] + pub fn name(&self) -> &str { + &self.name + } + + /// The tenant set this token may speak for. + #[must_use] + pub fn tenants(&self) -> &TenantSet { + &self.tenants + } +} + +impl fmt::Debug for ResolvedToken { + /// Never renders the token value — name and tenant binding only. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ResolvedToken") + .field("name", &self.name) + .field("token", &"") + .field("tenants", &self.tenants) + .finish() + } +} + +/// The validated `auth.tokens` store (RFC 0026 §3.1) — non-empty by +/// construction ([`build_token_store`]). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TokenStore { + entries: Vec, +} + +impl TokenStore { + /// Authenticate a presented bearer token, returning the matching entry. + /// + /// Every candidate comparison is constant-time in the token *content* + /// (`subtle`; a length mismatch short-circuits, which reveals only + /// length), so a probing client cannot binary-search a configured token + /// byte by byte through response timing. + #[must_use] + pub fn authenticate(&self, presented: &str) -> Option<&ResolvedToken> { + self.entries + .iter() + .find(|entry| entry.token.as_bytes().ct_eq(presented.as_bytes()).into()) + } +} + +/// Validate the parsed `auth` section into the resolved [`TokenStore`] +/// (RFC 0026 §3.1). `None` in, `None` out — open mode. +/// +/// # Errors +/// +/// A present section must hold at least one token; every entry must carry a +/// non-empty `name` (unique across entries, it is the audit label), a +/// `token` that resolved non-empty (an empty one means the `${env:…}` +/// variable was unset), a unique token value (two entries with one value +/// would make the tenant binding ambiguous), and a non-empty `tenants` list +/// — exact ids without surrounding whitespace, or the wildcard `"*"` alone. +/// Error text names entries by `name`/index only, never a token value. +pub fn build_token_store(section: Option<&AuthSection>) -> Result, String> { + let Some(section) = section else { + return Ok(None); + }; + if section.tokens.is_empty() { + return Err( + "auth.tokens must not be empty — remove the auth section entirely for \ + open mode (RFC 0026 §3.1)" + .to_string(), + ); + } + let mut entries: Vec = Vec::with_capacity(section.tokens.len()); + for (index, entry) in section.tokens.iter().enumerate() { + let name = match entry.name.as_deref() { + Some(n) if !n.is_empty() && n.trim() == n => n.to_string(), + _ => { + return Err(format!( + "auth.tokens[{index}].name must be a non-empty label without \ + surrounding whitespace (it is the audit/metric label, RFC 0026 §3.4)" + )); + } + }; + if entries.iter().any(|e| e.name == name) { + return Err(format!( + "auth.tokens[{index}].name {name:?} duplicates an earlier entry — \ + names are the audit/metric label and must be unique" + )); + } + let token = match entry.token.as_deref() { + Some(t) if !t.is_empty() => t.to_string(), + // A literal empty string never reaches here (the parse-time + // reference rule rejects it), so empty means the reference + // resolved against an unset variable. + Some(_) => { + return Err(format!( + "auth.tokens[{index}] ({name}): token resolved to empty — is the \ + ${{env:…}} variable set?" + )); + } + None => { + return Err(format!( + "auth.tokens[{index}] ({name}): token is required — an \ + ${{env:…}} reference (RFC 0026 §3.1)" + )); + } + }; + if let Some(prior) = entries + .iter() + .find(|e| e.token.as_bytes().ct_eq(token.as_bytes()).into()) + { + return Err(format!( + "auth.tokens[{index}] ({name}): token value duplicates entry {:?} — \ + one value bound to two tenant sets is ambiguous", + prior.name, + )); + } + entries.push(ResolvedToken { + name, + token, + tenants: build_tenant_set(index, entry)?, + }); + } + Ok(Some(TokenStore { entries })) +} + +/// Validate one entry's `tenants` list into a [`TenantSet`]. +fn build_tenant_set(index: usize, entry: &TokenEntry) -> Result { + if entry.tenants.is_empty() { + return Err(format!( + "auth.tokens[{index}].tenants must not be empty — list the allowed \ + tenants, or [\"*\"] for all (RFC 0026 §3.1)" + )); + } + if entry.tenants.iter().any(|t| t == "*") { + if entry.tenants.len() > 1 { + return Err(format!( + "auth.tokens[{index}].tenants: the wildcard \"*\" must be the only \ + entry (RFC 0026 §3.1 — no patterns)" + )); + } + return Ok(TenantSet::All); + } + if entry.tenants.iter().any(|t| t.is_empty() || t.trim() != t) { + return Err(format!( + "auth.tokens[{index}].tenants entries must be non-empty tenant ids \ + without surrounding whitespace" + )); + } + Ok(TenantSet::Listed(entry.tenants.iter().cloned().collect())) +} + +#[cfg(test)] +mod tests { + use crate::config::file::{AuthSection, TokenEntry}; + + use super::{TenantSet, build_token_store}; + + fn entry(name: &str, token: &str, tenants: &[&str]) -> TokenEntry { + TokenEntry { + name: Some(name.to_string()), + token: Some(token.to_string()), + tenants: tenants.iter().map(|t| (*t).to_string()).collect(), + } + } + + fn section(tokens: Vec) -> AuthSection { + AuthSection { tokens } + } + + /// The constant-time comparison helper's API shape (RFC 0026 §6): a + /// configured token authenticates to its entry, an unknown or empty + /// presentation to `None`, and a prefix (the length-mismatch + /// short-circuit) to `None`. + #[test] + fn authenticate_matches_exact_tokens_only() { + let store = build_token_store(Some(§ion(vec![ + entry("edge", "tok-edge-1", &["acme"]), + entry("admin", "tok-admin-2", &["*"]), + ]))) + .expect("valid") + .expect("enabled"); + + assert_eq!( + store.authenticate("tok-edge-1").expect("match").name(), + "edge" + ); + assert_eq!( + store.authenticate("tok-admin-2").expect("match").name(), + "admin" + ); + assert!(store.authenticate("tok-edge-2").is_none()); + assert!( + store.authenticate("tok-edge-").is_none(), + "prefix is no match" + ); + assert!(store.authenticate("").is_none()); + } + + /// Tenant binding: a listed set allows exactly its members; the wildcard + /// allows everything (RFC 0026 §3.1). + #[test] + fn tenant_sets_allow_members_and_wildcard_allows_all() { + let store = build_token_store(Some(§ion(vec![ + entry("edge", "t1", &["acme", "globex"]), + entry("admin", "t2", &["*"]), + ]))) + .expect("valid") + .expect("enabled"); + + let edge = store.authenticate("t1").expect("edge"); + assert!(edge.tenants().allows("acme")); + assert!(edge.tenants().allows("globex")); + assert!(!edge.tenants().allows("initech")); + assert!( + !edge.tenants().allows("*"), + "no literal-star tenant leaks in" + ); + + let admin = store.authenticate("t2").expect("admin"); + assert_eq!(*admin.tenants(), TenantSet::All); + assert!(admin.tenants().allows("anything")); + } + + /// An absent section is open mode (`None`); an empty token list is a + /// startup error, not a locked-out server (RFC 0026 §3.1). + #[test] + fn absent_is_open_mode_and_empty_is_an_error() { + assert!(build_token_store(None).expect("open mode").is_none()); + let err = build_token_store(Some(§ion(Vec::new()))).expect_err("empty list"); + assert!(err.contains("auth.tokens"), "names the key: {err}"); + } + + /// Each validation arm rejects with an error that names the entry by + /// index/name and never echoes a token value. + #[test] + fn validation_errors_name_entries_never_values() { + let cases: Vec<(AuthSection, &str)> = vec![ + (section(vec![entry("", "tok-a", &["x"])]), "name"), + ( + section(vec![ + entry("a", "tok-a", &["x"]), + entry("a", "tok-b", &["y"]), + ]), + "duplicates", + ), + (section(vec![entry("a", "", &["x"])]), "resolved to empty"), + ( + section(vec![TokenEntry { + name: Some("a".to_string()), + token: None, + tenants: vec!["x".to_string()], + }]), + "required", + ), + ( + section(vec![ + entry("a", "tok-same", &["x"]), + entry("b", "tok-same", &["y"]), + ]), + "ambiguous", + ), + (section(vec![entry("a", "tok-a", &[])]), "tenants"), + (section(vec![entry("a", "tok-a", &["*", "acme"])]), "only"), + (section(vec![entry("a", "tok-a", &[" acme"])]), "whitespace"), + ]; + for (auth, needle) in cases { + let err = build_token_store(Some(&auth)).expect_err("invalid"); + assert!(err.contains(needle), "{needle:?} not in {err:?}"); + assert!(!err.contains("tok-"), "no token value leaks: {err:?}"); + } + } + + /// `Debug` renders names and tenant sets, never a token value — the + /// store is safe to log (RFC 0026 §3.1 / RFC 0020 §3.5). + #[test] + fn debug_never_renders_a_token_value() { + let store = build_token_store(Some(§ion(vec![entry( + "edge", + "s3cr3t-token", + &["acme"], + )]))) + .expect("valid") + .expect("enabled"); + let rendered = format!("{store:?}"); + assert!(rendered.contains("edge"), "names stay visible: {rendered}"); + assert!( + !rendered.contains("s3cr3t-token"), + "token redacted: {rendered}" + ); + assert!( + rendered.contains(""), + "shows presence: {rendered}" + ); + } +} diff --git a/crates/ourios-server/src/config/file.rs b/crates/ourios-server/src/config/file.rs index 0a417059f..139fd9f89 100644 --- a/crates/ourios-server/src/config/file.rs +++ b/crates/ourios-server/src/config/file.rs @@ -79,6 +79,13 @@ pub enum FileConfigError { /// The offending `storage.s3.*` credential field name. key: &'static str, }, + /// An `auth.tokens[…].token` holds an inline literal instead of an + /// `${env:…}` reference (RFC 0026 §3.1). Names the entry's position only, + /// never the value. + InlineToken { + /// The offending entry's index in `auth.tokens`. + index: usize, + }, } impl fmt::Display for FileConfigError { @@ -91,6 +98,11 @@ impl fmt::Display for FileConfigError { "storage.s3.{key} must be an ${{env:…}} reference, not an inline \ literal (RFC 0020 §3.5)" ), + Self::InlineToken { index } => write!( + f, + "auth.tokens[{index}].token must be an ${{env:…}} reference, not \ + an inline literal (RFC 0026 §3.1)" + ), } } } @@ -100,7 +112,7 @@ impl std::error::Error for FileConfigError { match self { Self::Substitution(e) => Some(e), Self::Schema(e) => Some(e), - Self::InlineCredential { .. } => None, + Self::InlineCredential { .. } | Self::InlineToken { .. } => None, } } } @@ -122,6 +134,11 @@ pub struct FileConfig { pub querier: QuerierSection, /// Background compaction (`compaction.*`, RFC 0009). pub compaction: CompactionSection, + /// Bearer-token authentication (`auth.*`, RFC 0026). `Option` because + /// presence is meaningful: an absent section is open mode, a present one + /// enables enforcement (and an empty token list inside it is a startup + /// error) — see RFC 0026 §3.1. + pub auth: Option, } /// `storage.*` — the data + audit store backend selection (RFC 0019 §3.1/§3.2). @@ -230,6 +247,48 @@ pub struct QuerierSection { pub default_window_secs: Option, } +/// `auth.*` — bearer-token authentication and tenant binding (RFC 0026 §3.1). +#[derive(Debug, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct AuthSection { + /// The configured tokens. Empty (or absent inside a present `auth` + /// section) is a startup configuration error — a locked-out server is + /// never the intent (RFC 0026 §3.1). + pub tokens: Vec, +} + +/// One `auth.tokens[…]` entry (RFC 0026 §3.1). +/// +/// The `token` field is **secret**: the manual [`fmt::Debug`] impl redacts its +/// value (showing only presence), mirroring [`S3Section`], and [`parse`] +/// rejects an inline literal — the file may hold it only as an `${env:…}` +/// reference. +#[derive(Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct TokenEntry { + /// Audit/metric label for this token — never secret (RFC 0026 §3.4). + #[serde(deserialize_with = "scalar_opt")] + pub name: Option, + /// The bearer token value (**secret**; `${env:…}` reference only). + #[serde(deserialize_with = "scalar_opt")] + pub token: Option, + /// The allowed tenant set: exact tenant ids, or the single wildcard `"*"`. + #[serde(deserialize_with = "scalar_vec")] + pub tenants: Vec, +} + +impl fmt::Debug for TokenEntry { + /// Redacts the token value — a `Debug` rendering shows only whether it is + /// present, never its value (RFC 0026 §3.1 / RFC 0020 §3.5). + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("TokenEntry") + .field("name", &self.name) + .field("token", &self.token.as_ref().map(|_| "")) + .field("tenants", &self.tenants) + .finish() + } +} + /// `compaction.*` — the background compaction sweep (RFC 0009 §3.2). #[derive(Debug, Default, Deserialize)] #[serde(default, deny_unknown_fields)] @@ -273,6 +332,9 @@ pub fn parse( // Enforce §3.5 on the *raw* credential values — after substitution a // reference is indistinguishable from a literal. check_credentials_are_references(&config.storage.s3)?; + if let Some(auth) = &config.auth { + check_tokens_are_references(auth)?; + } config .substitute(lookup) .map_err(FileConfigError::Substitution)?; @@ -299,6 +361,24 @@ fn check_credentials_are_references(s3: &S3Section) -> Result<(), FileConfigErro Ok(()) } +/// Enforce RFC 0026 §3.1: bearer-token values must be `${env:…}` references, +/// never inline literals, so config files stay committable. Runs on the **raw** +/// (pre-substitution) values; the [`check_credentials_are_references`] rule, +/// applied to `auth.tokens`. Unlike an S3 credential, an **empty** token is a +/// literal like any other — there is no unset-with-fallback reading for a +/// bearer token. Only an *absent* token (no `token` key) is deferred to the +/// token-store validation, which can name the entry. +fn check_tokens_are_references(auth: &AuthSection) -> Result<(), FileConfigError> { + for (index, entry) in auth.tokens.iter().enumerate() { + if let Some(raw) = &entry.token + && !is_env_reference(raw) + { + return Err(FileConfigError::InlineToken { index }); + } + } + Ok(()) +} + /// Whether `raw` is a single `${env:NAME}` / `${NAME}` substitution reference /// spanning the whole value, with no default or an **empty** default /// (`${env:NAME:-}`). A literal, a partial (`foo-${…}`), two references, or a @@ -327,7 +407,27 @@ impl FileConfig { self.storage.substitute(lookup)?; self.receiver.substitute(lookup)?; self.querier.substitute(lookup)?; - self.compaction.substitute(lookup) + self.compaction.substitute(lookup)?; + if let Some(auth) = &mut self.auth { + auth.substitute(lookup)?; + } + Ok(()) + } +} + +impl AuthSection { + fn substitute( + &mut self, + lookup: &dyn Fn(&str) -> Option, + ) -> Result<(), MalformedReference> { + for entry in &mut self.tokens { + substitute(&mut entry.name, lookup)?; + substitute(&mut entry.token, lookup)?; + for tenant in &mut entry.tenants { + *tenant = env_subst::resolve(tenant, lookup)?; + } + } + Ok(()) } } @@ -797,6 +897,101 @@ compaction: assert!(cfg.compaction.enabled.is_none()); } + /// RFC0026.1 (schema) — `auth.tokens` parses with per-entry `${env:…}` + /// substitution on name, token, and tenant elements; a file with no + /// `auth` section parses to `None` (open mode), distinguishable from a + /// present-but-empty section. + #[test] + fn auth_tokens_parse_and_substitute() { + let lookup = env(&[("TOK_EDGE", "s3cr3t-edge"), ("TENANT", "acme")]); + let yaml = " +auth: + tokens: + - name: edge-collector + token: ${env:TOK_EDGE} + tenants: + - ${env:TENANT} + - globex + - name: admin-cli + token: ${env:TOK_ADMIN} + tenants: [\"*\"] +"; + let cfg = parse(yaml, &lookup).expect("valid"); + let auth = cfg.auth.expect("auth section present"); + assert_eq!(auth.tokens.len(), 2); + assert_eq!(auth.tokens[0].name.as_deref(), Some("edge-collector")); + assert_eq!(auth.tokens[0].token.as_deref(), Some("s3cr3t-edge")); + assert_eq!(auth.tokens[0].tenants, ["acme", "globex"]); + assert_eq!(auth.tokens[1].token.as_deref(), Some("")); // undefined, no default + assert_eq!(auth.tokens[1].tenants, ["*"]); + + assert!( + parse("storage:\n backend: local\n", &lookup) + .expect("valid") + .auth + .is_none(), + "no auth section parses to None (open mode)", + ); + let empty = parse("auth:\n tokens: []\n", &lookup).expect("valid"); + assert!( + empty.auth.expect("present").tokens.is_empty(), + "a present-but-empty section is distinguishable from an absent one", + ); + } + + /// RFC0026.1 (secret hygiene) — an inline-literal token is rejected with + /// an error naming the entry's index, never the value; and a resolved + /// token is redacted in the entry's `Debug` (the [`S3Section`] rules, + /// applied to `auth.tokens`). + #[test] + fn inline_token_literal_is_rejected_and_debug_redacts() { + let lookup = env(&[("TOK", "s3cr3t-token-value")]); + + let err = parse( + "auth:\n tokens:\n - name: a\n token: ${env:TOK}\n tenants: [x]\n - name: b\n token: hardcoded-secret\n tenants: [y]\n", + &lookup, + ) + .expect_err("inline literal"); + assert!( + matches!(err, FileConfigError::InlineToken { index: 1 }), + "got {err:?}", + ); + let msg = err.to_string(); + assert!( + msg.contains("auth.tokens[1].token"), + "names the entry: {msg}" + ); + assert!(!msg.contains("hardcoded-secret"), "never the value: {msg}"); + + // An empty string is a literal like any other — a bearer token has no + // unset-with-fallback reading (unlike an S3 credential). + let err = parse( + "auth:\n tokens:\n - name: a\n token: \"\"\n tenants: [x]\n", + &lookup, + ) + .expect_err("empty literal"); + assert!( + matches!(err, FileConfigError::InlineToken { index: 0 }), + "got {err:?}", + ); + + let cfg = parse( + "auth:\n tokens:\n - name: a\n token: ${env:TOK}\n tenants: [x]\n", + &lookup, + ) + .expect("valid"); + let rendered = format!("{:?}", cfg.auth.expect("present").tokens[0]); + assert!(rendered.contains("\"a\""), "the name stays visible"); + assert!( + !rendered.contains("s3cr3t-token-value"), + "token redacted: {rendered}", + ); + assert!( + rendered.contains(""), + "shows presence: {rendered}" + ); + } + /// An omitted section leaves its fields unset (`None`), matching an unset /// environment variable — the schema does not require every section. #[test] diff --git a/crates/ourios-server/src/lib.rs b/crates/ourios-server/src/lib.rs index 357d3b4ab..9e63931fa 100644 --- a/crates/ourios-server/src/lib.rs +++ b/crates/ourios-server/src/lib.rs @@ -8,7 +8,9 @@ //! the binary (`src/receiver.rs`); the querier lives here so its `serve` / //! `router` are testable without spawning the process. The **configuration** //! pieces (RFC 0020) live here too, in [`config`], so the substitution -//! resolver and schema are unit-testable. +//! resolver and schema are unit-testable, as does the RFC 0026 token store +//! ([`auth`]) that both roles' enforcement points consume. +pub mod auth; pub mod config; pub mod querier; diff --git a/crates/ourios-server/src/main.rs b/crates/ourios-server/src/main.rs index 57a1779f3..93930d13d 100644 --- a/crates/ourios-server/src/main.rs +++ b/crates/ourios-server/src/main.rs @@ -86,6 +86,12 @@ struct ServerConfig { /// (`storage.promoted_attributes`, §3.2) — applied by every write path /// (receiver flushes and compaction rewrites; §3.4). promoted: PromotedAttributes, + /// The RFC 0026 token store (`auth.tokens`), or `None` for open mode. + /// Config-file only (§3.1 — tokens ride the `${env:…}` indirection); the + /// env-only path always resolves open. Enforcement on the listeners lands + /// with the ingest/query slices; this slice resolves and validates the + /// store and makes open mode observable at startup. + auth: Option, } /// Resolved querier-role configuration (RFC 0016 §3.2). @@ -252,6 +258,7 @@ fn server_config_from_file(file: &FileConfig) -> Result { &file.storage.promoted_attributes.resource, &file.storage.promoted_attributes.log, )?; + config.auth = ourios_server::auth::build_token_store(file.auth.as_ref())?; Ok(config) } @@ -467,6 +474,7 @@ fn build_config( receiver: None, querier: None, promoted: PromotedAttributes::default(), + auth: None, }) } @@ -550,6 +558,18 @@ async fn main() -> Result<(), Box> { // OTEL_EXPORTER_OTLP_ENDPOINT et al. tune the exporter. let telemetry = ourios_telemetry::init(&TelemetryConfig::new("ourios-server"))?; + // RFC 0026 §3.1 open mode: with no `auth` configured, any client that can + // reach a listener can write into and read from any tenant. Warn once at + // startup so the exposure is a visible choice, not a silent default. A + // compactor-only process binds nothing, so it has nothing to expose. + if config.auth.is_none() && (config.receiver.is_some() || config.querier.is_some()) { + tracing::warn!( + name: ourios_semconv::EVENT_OURIOS_SERVER_AUTH_OPEN_MODE, + "auth is not configured: the network listeners accept unauthenticated \ + requests for any tenant (RFC 0026 open mode)" + ); + } + // 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. @@ -866,6 +886,46 @@ storage: ); } + /// Scenario RFC0026.1 (mapping) — the file's `auth` section resolves + /// through the shared validators like every other section: a token that + /// arrived via `${env:…}` substitution authenticates in the resolved + /// store, an absent section resolves open (`None`), and an empty token + /// list fails the mapping. The schema/substitution/redaction arms live in + /// `config::file`, the store validation matrix in `ourios_server::auth`, + /// and the startup-observable arms in `tests/rfc0026_auth.rs`. + /// See `docs/rfcs/0026-authentication-tenant-binding.md` §5. + #[test] + fn rfc0026_1_auth_section_maps_onto_the_token_store() { + let yaml = "\ +storage: + backend: local + local: + bucket_root: /var/lib/ourios +auth: + tokens: + - name: edge-collector + token: ${env:TOK} + tenants: [acme] +"; + let file = parse(yaml, &|name| { + (name == "TOK").then(|| "resolved-token".to_owned()) + }) + .expect("well-formed file"); + let config = server_config_from_file(&file).expect("valid"); + let store = config.auth.expect("auth resolved"); + assert_eq!( + store.authenticate("resolved-token").expect("match").name(), + "edge-collector", + ); + + let open = server_config("storage:\n local:\n bucket_root: /x\n").expect("valid"); + assert!(open.auth.is_none(), "no auth section resolves open"); + + let err = server_config("storage:\n local:\n bucket_root: /x\nauth:\n tokens: []\n") + .expect_err("empty token list"); + assert!(err.contains("auth.tokens"), "names the key: {err}"); + } + /// `config_from_file` end-to-end through the real filesystem: a valid file /// reads and resolves, and both failure paths name the offending file — a /// missing file via the read-error prefix, a parse failure via the diff --git a/crates/ourios-server/tests/rfc0026_auth.rs b/crates/ourios-server/tests/rfc0026_auth.rs index 3fa816c68..bceae780b 100644 --- a/crates/ourios-server/tests/rfc0026_auth.rs +++ b/crates/ourios-server/tests/rfc0026_auth.rs @@ -5,21 +5,127 @@ //! `.5`, and `.7`) live in //! `crates/ourios-ingester/tests/rfc0026_auth.rs`. //! -//! Stubs are `#[ignore]`d so the default run stays green while the -//! RFC is red; each names the green slice that discharges it. +//! Remaining stubs are `#[ignore]`d so the default run stays green +//! while the RFC is red; each names the green slice that discharges +//! it. `.1` is green: the schema/substitution/redaction arms live in +//! `ourios_server::config::file`, the store-validation matrix in +//! `ourios_server::auth`, the file→store mapping in `src/main.rs` +//! (`rfc0026_1_*`), and the startup-observable arms — the empty-list +//! startup error and the open-mode warning — here, against the spawned +//! binary (the `rfc0016_5_7_served_querier` pattern). -/// Scenario RFC0026.1 — token store configuration. +use std::io::Write as _; +use std::process::Stdio; +use std::time::Duration; + +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Command; +use tokio::time::timeout; + +/// Scenario RFC0026.1 (startup error) — an *empty* `auth.tokens` list is a +/// startup configuration error (a locked-out server is never the intent, +/// RFC 0026 §3.1): the process exits non-zero, naming the key. /// See `docs/rfcs/0026-authentication-tenant-binding.md` §5. -#[test] -#[ignore = "RFC0026.1 stub — implemented in the config green slice"] -fn rfc0026_1_token_store_configuration() { - todo!( - "RFC0026.1 — auth.tokens resolves env-var indirection (the RFC 0020 \ - substitution syntax) at startup; empty tokens list is a startup \ - error; a missing auth section starts open with a structured warning" +#[tokio::test] +async fn rfc0026_1_empty_token_list_is_a_startup_error() { + let tmp = tempfile::TempDir::new().expect("temp"); + let config_path = tmp.path().join("ourios.yaml"); + let mut file = std::fs::File::create(&config_path).expect("create config"); + write!( + file, + "storage:\n local:\n bucket_root: {}\nauth:\n tokens: []\n", + tmp.path().display(), + ) + .expect("write config"); + + let output = timeout( + Duration::from_secs(15), + Command::new(env!("CARGO_BIN_EXE_ourios-server")) + .arg("--config") + .arg(&config_path) + // If the startup error ever regressed into a running server, the + // timeout would drop this future — don't leave that child behind. + .kill_on_drop(true) + .output(), + ) + .await + .expect("server exits before timeout") + .expect("run ourios-server"); + + assert!( + !output.status.success(), + "an empty auth.tokens list must fail startup, got {:?}", + output.status, + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("auth.tokens"), + "the error names the key: {stderr}" ); } +/// Scenario RFC0026.1 (open mode) — a missing `auth` section starts in open +/// mode: the role comes up, and a structured startup warning names the +/// exposure (RFC 0026 §3.1). The human-readable copy asserted here reaches +/// stderr through the tracing `fmt` mirror (which renders the target and +/// message, not the event name); the registry-backed event name +/// (`ourios.server.auth.open_mode`) travels the `OTel` Logs signal, where +/// `weaver registry live-check` enforces it in CI. +/// See `docs/rfcs/0026-authentication-tenant-binding.md` §5. +#[tokio::test] +async fn rfc0026_1_missing_auth_section_starts_open_with_a_warning() { + let tmp = tempfile::TempDir::new().expect("temp"); + let mut child = Command::new(env!("CARGO_BIN_EXE_ourios-server")) + .env("OURIOS_BUCKET_ROOT", tmp.path()) + .env("OURIOS_QUERIER_ENABLED", "1") + .env("OURIOS_QUERIER_HTTP_ADDR", "127.0.0.1:0") + // Deterministic regardless of the harness environment: an inherited + // RUST_LOG=error would filter the warning off stderr. + .env("RUST_LOG", "info") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .expect("spawn ourios-server"); + + // The warning precedes the role start, so it appears promptly; the + // timeout bounds the scan if it never does. + let stderr = child.stderr.take().expect("server stderr piped"); + let mut lines = BufReader::new(stderr).lines(); + let saw_warning = timeout(Duration::from_secs(15), async { + while let Some(line) = lines.next_line().await.expect("read stderr") { + if line.contains("RFC 0026 open mode") { + return true; + } + } + false + }) + .await + .expect("warning appears before timeout"); + assert!(saw_warning, "the open-mode warning names the exposure"); + + // Keep draining stderr: the server logs there for its lifetime, and an + // undrained pipe can fill and block it before the stdout line below. + tokio::spawn(async move { while lines.next_line().await.ok().flatten().is_some() {} }); + + // Open mode is *open*: the role still starts (the listener binds). + let stdout = child.stdout.take().expect("server stdout piped"); + let mut out_lines = BufReader::new(stdout).lines(); + let bound = timeout(Duration::from_secs(15), async { + while let Some(line) = out_lines.next_line().await.expect("read stdout") { + if line.contains("querier HTTP listening on") { + return true; + } + } + false + }) + .await + .expect("querier binds before timeout"); + assert!(bound, "open mode still serves"); + + child.kill().await.expect("kill the server"); +} + /// Scenario RFC0026.4 — query enforcement and status contract. /// See `docs/rfcs/0026-authentication-tenant-binding.md` §5. #[test] diff --git a/semconv/registry/events.yaml b/semconv/registry/events.yaml index 481dbea0b..e9dca3e98 100644 --- a/semconv/registry/events.yaml +++ b/semconv/registry/events.yaml @@ -19,6 +19,15 @@ groups: brief: > Installing the SIGTERM handler failed; SIGINT remains the shutdown path. + - id: event.ourios.server.auth.open_mode + type: event + name: ourios.server.auth.open_mode + stability: development + brief: > + A network role started with no `auth` section configured + (RFC 0026 §3.1 open mode): the listeners accept unauthenticated + requests, and any client that can reach them can write into and + read from any tenant. - id: event.ourios.compaction.sweep.error type: event name: ourios.compaction.sweep.error