diff --git a/Cargo.toml b/Cargo.toml index 9a43c57..696dd3c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,24 +25,22 @@ chrono = "~0.4" config = "~0.13" evalexpr = "~9.0" glob = "~0.3" -jwt = "~0.16" itertools = "~0.10" -hmac = "~0.12" http = "~0.2" new_string_template = "~1.4" regex = "~1.8" -reqwest = { version = "~0.11", default-features = false, features = ["rustls-tls", "json"] } +reqwest = { version = "~0.12", default-features = false, features = ["rustls-tls", "json"] } schemars = "~0.8" serde = { version = "~1.0", features = ["derive"] } serde_json = "~1.0" serde_yaml = "~0.9" -sha2 = "~0.10" tokio = { version = "~1.42", features = ["full"] } tower = { version = "~0.4" } tower-http = { version = "~0.4", features = ["trace", "request-id", "util"] } tracing = "~0.1" tracing-subscriber = { version = "~0.3", features = ["env-filter"] } uuid = { version = "~1.3", features = ["v4", "fast-rng"] } +zitadel = { version = "~5.7", default-features = false, features = ["credentials"] } [dev-dependencies] mockito = "~1.0" @@ -51,6 +49,9 @@ tempfile = "~3.5" tokio-test = "*" tower = { version = "0.4", features = ["util"] } hyper = { version = "0.14", features = ["full"] } +jsonwebtoken = { version = "~11.1", default-features = false, features = ["use_pem", "aws_lc_rs"] } +rand = "~0.8" +rsa = "~0.9" [target.'cfg(all(target_env = "musl", target_pointer_width = "64"))'.dependencies.jemallocator] diff --git a/src/bin/reporter.rs b/src/bin/reporter.rs index f6895d7..91c0f4d 100644 --- a/src/bin/reporter.rs +++ b/src/bin/reporter.rs @@ -6,6 +6,8 @@ extern crate anyhow; +use anyhow::Context; +use cloudmon_metrics::config::OidcIdentity; use cloudmon_metrics::sd::{ build_auth_headers, build_component_id_cache, build_incident_data, create_incident, fetch_components, find_component_id, Component, ComponentAttribute, @@ -17,24 +19,14 @@ use reqwest::ClientBuilder; use tokio::signal; use tokio::time::{sleep, Duration}; -use serde::{Deserialize, Serialize}; - use std::collections::HashMap; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; const CLIENT_TIMEOUT_SECS: u64 = 2; -/// Component status for V1 API (legacy, will be replaced) -#[derive(Deserialize, Serialize, Debug)] -pub struct ComponentStatus { - pub name: String, - pub impact: u8, - pub attributes: Vec, -} - #[tokio::main] -async fn main() { +async fn main() -> anyhow::Result<()> { //Enable logging tracing_subscriber::registry() .with(tracing_subscriber::EnvFilter::new( @@ -46,7 +38,15 @@ async fn main() { tracing::info!("starting cloudmon-metrics-reporter"); // Parse config - let config = Config::new("config.yaml").unwrap(); + let config = Config::new("config.yaml").context("Failed to load config.yaml")?; + + // Fail closed: the service identity comes from the key file, which is read here once at startup + let oidc_identity = config + .status_dashboard + .as_ref() + .map(|sdb_config| sdb_config.oidc_identity()) + .transpose() + .context("Invalid status_dashboard OIDC configuration")?; // Set up CTRL+C handlers let ctrl_c = async { @@ -68,15 +68,26 @@ async fn main() { // Execute metric_watcher unless need to stop tokio::select! { - _ = metric_watcher(&config) => {}, + res = metric_watcher(&config, oidc_identity.as_ref()) => { + if let Err(e) = &res { + tracing::error!(error = %e, "metric reporter stopped with an error"); + } + // Fatal reporter errors must terminate the process with a non-zero exit code + res.context("metric reporter failed")?; + }, _ = ctrl_c => {}, _ = terminate => {}, } tracing::info!("stopped cloudmon-metrics-reporter"); + + Ok(()) } -async fn metric_watcher(config: &Config) { +async fn metric_watcher( + config: &Config, + oidc_identity: Option<&OidcIdentity>, +) -> anyhow::Result<()> { tracing::info!("starting metric reporter thread"); // Init reqwest client let req_client: reqwest::Client = ClientBuilder::new() @@ -118,10 +129,8 @@ async fn metric_watcher(config: &Config) { .status_dashboard .as_ref() .expect("Status dashboard section is missing"); - - // Build authorization headers using status_dashboard module (T021, T022, T023 - US3) - // VERIFIED: Existing HMAC-JWT mechanism works unchanged with V2 endpoints - let headers = build_auth_headers(sdb_config.secret.as_deref()); + let oidc_identity = + oidc_identity.context("Status Dashboard OIDC service identity is missing")?; // Initialize component ID cache at startup with retry logic (T024, T025, T026, T027) // Per FR-006: 3 retry attempts with 60-second delays @@ -136,6 +145,11 @@ async fn metric_watcher(config: &Config) { "attempting to fetch components from Status Dashboard" ); + // D6: a fresh token is requested per authenticated call, it is never reused or cached + let headers = build_auth_headers(oidc_identity) + .await + .context("Failed to obtain Status Dashboard authorization headers")?; + match fetch_components(&req_client, &sdb_config.url, &headers).await { Ok(components) => { tracing::info!( @@ -175,8 +189,7 @@ async fn metric_watcher(config: &Config) { let mut component_cache = match component_cache { Some(cache) => cache, None => { - tracing::error!("component cache initialization failed, exiting metric_watcher"); - return; + anyhow::bail!("component cache initialization failed, exiting metric_watcher"); } }; @@ -235,31 +248,45 @@ async fn metric_watcher(config: &Config) { "component not found in cache, attempting cache refresh" ); - match fetch_components( - &req_client, - &sdb_config.url, - &headers, - ) - .await - { - Ok(components) => { - tracing::info!( - component_count = components.len(), - "cache refreshed" - ); - component_cache = - build_component_id_cache(components); - // Retry lookup after refresh - component_id = find_component_id( - &component_cache, - comp, - ); + match build_auth_headers(oidc_identity).await { + Ok(headers) => { + match fetch_components( + &req_client, + &sdb_config.url, + &headers, + ) + .await + { + Ok(components) => { + tracing::info!( + component_count = + components.len(), + "cache refreshed" + ); + component_cache = + build_component_id_cache( + components, + ); + component_id = find_component_id( + &component_cache, + comp, + ); + } + Err(e) => { + tracing::warn!( + error = %e, + component_name = + comp.name.as_str(), + "failed to refresh component cache" + ); + } + } } Err(e) => { - tracing::warn!( + tracing::error!( error = %e, component_name = comp.name.as_str(), - "failed to refresh component cache" + "failed to obtain authorization headers, skipping component cache refresh" ); } } @@ -302,30 +329,44 @@ async fn metric_watcher(config: &Config) { "creating incident: health metric indicates service degradation" ); - // Create incident via V2 API - match create_incident( - &req_client, - &sdb_config.url, - &headers, - &incident_data, - ) - .await - { - Ok(_) => { - tracing::info!( - component_id = id, - impact = impact, - "incident created successfully" - ); + match build_auth_headers(oidc_identity).await { + Ok(headers) => { + match create_incident( + &req_client, + &sdb_config.url, + &headers, + &incident_data, + ) + .await + { + Ok(_) => { + tracing::info!( + component_id = id, + impact = impact, + "incident created successfully" + ); + } + Err(e) => { + tracing::error!( + error = %e, + component_id = id, + service = component.0 + .as_str(), + environment = env + .name + .as_str(), + "failed to create incident" + ); + } + } } Err(e) => { - // Error logging with details (FR-015) tracing::error!( error = %e, component_id = id, service = component.0.as_str(), environment = env.name.as_str(), - "failed to create incident" + "failed to obtain authorization headers, skipping incident creation" ); } } diff --git a/src/config.rs b/src/config.rs index 350b2cc..f1859bf 100644 --- a/src/config.rs +++ b/src/config.rs @@ -37,6 +37,7 @@ use glob::glob; +use anyhow::Context; use schemars::JsonSchema; use serde::Deserialize; use std::{ @@ -45,9 +46,10 @@ use std::{ path::Path, }; -use config::{ConfigError, Environment, File}; - +use crate::oidc::{load_service_account, normalize_issuer}; use crate::types::{BinaryMetricRawDef, EnvironmentDef, FlagMetricDef, ServiceHealthDef}; +use config::{ConfigError, Environment, File}; +use zitadel::credentials::ServiceAccount; /// A Configuration structure #[derive(Clone, Debug, Deserialize, JsonSchema)] @@ -100,7 +102,7 @@ impl Config { } // merge environment variables (subelements separated by "__") - // MP_STATUS_DASHBOARD__SECRET goes to status_dashboard.secret + s = s.add_source( Environment::with_prefix("MP") .prefix_separator("_") @@ -175,8 +177,139 @@ pub enum DatasourceType { pub struct StatusDashboardConfig { /// Status dashboard URL pub url: String, - /// JWT token signature secret - pub secret: Option, + /// Zitadel OIDC issuer URL + pub oidc_issuer: Option, + /// Path to the Zitadel machine user key file. + /// + /// The file is downloaded from the Zitadel Console for a machine user (service user) and has + /// `type: serviceaccount`; it is read once at startup and any other key type fails startup. + pub oidc_key_file: Option, + /// OIDC scopes of the token request, sent as one space-joined `scope` parameter. + /// + /// `MP_STATUS_DASHBOARD__OIDC_SCOPES` has to contain both + /// `urn:zitadel:iam:org:project:role:sd_reporters`, so that the roles are reported in the + /// `groups` claim, and `urn:zitadel:iam:org:project:id::aud`, which makes `aud` the + /// project id the Status Dashboard verifies against `SD_OIDC_CLIENT_ID`. `` is the + /// Zitadel project shared by the Status Dashboard and the machine user. Without the audience + /// scope Zitadel puts the client id into `aud`, which the backend rejects. + /// + /// There is no default, because the audience scope carries the project id of the deployment. + pub oidc_scopes: Option>, +} + +pub const OIDC_ISSUER_ENV_KEY: &str = "MP_STATUS_DASHBOARD__OIDC_ISSUER"; +pub const OIDC_KEY_FILE_ENV_KEY: &str = "MP_STATUS_DASHBOARD__OIDC_KEY_FILE"; +pub const OIDC_SCOPES_ENV_KEY: &str = "MP_STATUS_DASHBOARD__OIDC_SCOPES"; + +const OIDC_ROLE_SCOPE_PREFIX: &str = "urn:zitadel:iam:org:project:role:"; +const OIDC_AUDIENCE_SCOPE_PREFIX: &str = "urn:zitadel:iam:org:project:id:"; +const OIDC_AUDIENCE_SCOPE_SUFFIX: &str = ":aud"; +const OIDC_SCOPES_EXAMPLE: &str = concat!( + " oidc_scopes:\n", + " - \"urn:zitadel:iam:org:project:role:sd_reporters\"\n", + " - \"urn:zitadel:iam:org:project:id::aud\"", +); + +pub struct OidcIdentity { + pub issuer: String, + pub service_account: ServiceAccount, + pub scopes: Vec, +} + +impl std::fmt::Debug for OidcIdentity { + /// The crate renders the loaded key material in its own `Debug` output, so it is not forwarded. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OidcIdentity") + .field("issuer", &self.issuer) + .field("service_account", &"") + .field("scopes", &self.scopes) + .finish() + } +} + +impl StatusDashboardConfig { + /// Reporting is fail-closed: an incomplete identity must stop startup, so every problem names + /// the offending configuration key. + pub fn oidc_identity(&self) -> anyhow::Result { + let issuer = self + .oidc_issuer + .as_deref() + .filter(|issuer| !issuer.trim().is_empty()); + let key_file_path = self + .oidc_key_file + .as_deref() + .filter(|key_file| !key_file.trim().is_empty()); + let scopes = self.oidc_scopes.as_deref().unwrap_or_default(); + + if let (Some(issuer), Some(key_file_path)) = (issuer, key_file_path) { + validate_oidc_scopes(scopes)?; + + let service_account = load_service_account(key_file_path).with_context(|| { + format!( + "{} does not point to a usable Zitadel machine user key file", + OIDC_KEY_FILE_ENV_KEY + ) + })?; + + return Ok(OidcIdentity { + issuer: normalize_issuer(issuer), + service_account, + scopes: scopes.to_vec(), + }); + } + + let mut missing = Vec::new(); + if issuer.is_none() { + missing.push(OIDC_ISSUER_ENV_KEY); + } + if key_file_path.is_none() { + missing.push(OIDC_KEY_FILE_ENV_KEY); + } + if scopes.is_empty() { + missing.push(OIDC_SCOPES_ENV_KEY); + } + + anyhow::bail!( + "Status Dashboard OIDC service identity is incomplete, missing: {}", + missing.join(", ") + ) + } +} + +/// The Status Dashboard takes the token audience and the reporter role from scopes that Zitadel +/// only applies when the project audience scope is requested, so a scope list with either scope +/// class missing would fail with 401 at report time instead of at startup. +fn validate_oidc_scopes(scopes: &[String]) -> anyhow::Result<()> { + let mut missing = Vec::new(); + if !scopes + .iter() + .any(|scope| scope.starts_with(OIDC_ROLE_SCOPE_PREFIX)) + { + missing.push(format!( + "a scope starting with \"{OIDC_ROLE_SCOPE_PREFIX}\"" + )); + } + if !scopes.iter().any(|scope| { + scope.starts_with(OIDC_AUDIENCE_SCOPE_PREFIX) && scope.ends_with(OIDC_AUDIENCE_SCOPE_SUFFIX) + }) { + missing.push(format!( + "a scope starting with \"{OIDC_AUDIENCE_SCOPE_PREFIX}\" and ending with \"{OIDC_AUDIENCE_SCOPE_SUFFIX}\"" + )); + } + + if missing.is_empty() { + return Ok(()); + } + + anyhow::bail!( + "{} (status_dashboard.oidc_scopes) must contain both a project role scope and the project \ + audience scope of the Zitadel project shared with the Status Dashboard, missing {}. \ + Zitadel only reports the project roles claim when the audience scope is requested, so a \ + token requested without it is rejected. Set for example:\n{}", + OIDC_SCOPES_ENV_KEY, + missing.join(" and "), + OIDC_SCOPES_EXAMPLE + ) } /// Health metrics query configuration @@ -314,12 +447,15 @@ mod test { config_file.write_all(CONFIG_STR1.as_bytes()).unwrap(); - env::set_var("MP_STATUS_DASHBOARD__SECRET", "val"); + env::set_var("MP_STATUS_DASHBOARD__OIDC_KEY_FILE", "val"); let _config = config::Config::new(config_file.path().to_str().unwrap()).unwrap(); - assert_eq!(_config.status_dashboard.unwrap().secret.unwrap(), "val"); + assert_eq!( + _config.status_dashboard.unwrap().oidc_key_file.unwrap(), + "val" + ); // Clean up to avoid affecting other tests - env::remove_var("MP_STATUS_DASHBOARD__SECRET"); + env::remove_var("MP_STATUS_DASHBOARD__OIDC_KEY_FILE"); } /// Test merging of the config with conf.d elements @@ -533,4 +669,362 @@ mod test { println!("Generated JSON schema at: {}", schema_path.display()); } + + const ROLE_SCOPE: &str = "urn:zitadel:iam:org:project:role:sd_reporters"; + const AUDIENCE_SCOPE: &str = "urn:zitadel:iam:org:project:id:392066917738875090:aud"; + + fn status_dashboard_section() -> super::StatusDashboardConfig { + serde_yaml::from_str(&format!( + "url: https://status.example.com\noidc_scopes:\n - \"{ROLE_SCOPE}\"\n - \"{AUDIENCE_SCOPE}\"\n" + )) + .unwrap() + } + + fn write_key_file(dir: &tempfile::TempDir) -> String { + use crate::oidc::test_keys::service_account_key_file_json; + + let path = dir.path().join("service-account.json"); + std::fs::write(&path, service_account_key_file_json()).unwrap(); + path.to_str().unwrap().to_string() + } + + fn scopes_config(scopes: Option>, key_file: &str) -> super::StatusDashboardConfig { + super::StatusDashboardConfig { + oidc_issuer: Some("https://zitadel.example.com".to_string()), + oidc_key_file: Some(key_file.to_string()), + oidc_scopes: scopes, + ..status_dashboard_section() + } + } + + #[test] + fn test_oidc_identity_reports_missing_configuration_keys() { + let both_missing = super::StatusDashboardConfig { + oidc_scopes: None, + ..status_dashboard_section() + }; + let message = format!("{:#}", both_missing.oidc_identity().unwrap_err()); + for key in [ + super::OIDC_ISSUER_ENV_KEY, + super::OIDC_KEY_FILE_ENV_KEY, + super::OIDC_SCOPES_ENV_KEY, + ] { + assert!(message.contains(key), "{} not reported: {}", key, message); + } + + let key_file_missing = super::StatusDashboardConfig { + oidc_issuer: Some("https://zitadel.example.com".to_string()), + ..status_dashboard_section() + }; + let message = format!("{:#}", key_file_missing.oidc_identity().unwrap_err()); + assert!( + message.contains(super::OIDC_KEY_FILE_ENV_KEY), + "unexpected error: {}", + message + ); + assert!( + !message.contains(super::OIDC_ISSUER_ENV_KEY), + "unexpected error: {}", + message + ); + + let issuer_missing = super::StatusDashboardConfig { + oidc_key_file: Some("service-account.json".to_string()), + ..status_dashboard_section() + }; + let message = format!("{:#}", issuer_missing.oidc_identity().unwrap_err()); + assert!( + message.contains(super::OIDC_ISSUER_ENV_KEY), + "unexpected error: {}", + message + ); + assert!( + !message.contains(super::OIDC_KEY_FILE_ENV_KEY), + "unexpected error: {}", + message + ); + } + + #[test] + fn test_oidc_identity_rejects_scopes_without_the_project_audience() { + let dir = Builder::new().tempdir().unwrap(); + let key_file = write_key_file(&dir); + let project_id_without_audience = "urn:zitadel:iam:org:project:id:392066917738875090"; + + let cases = [ + vec![ROLE_SCOPE.to_string()], + vec![ + ROLE_SCOPE.to_string(), + project_id_without_audience.to_string(), + ], + ]; + + for scopes in cases { + let config = scopes_config(Some(scopes.clone()), &key_file); + let message = format!("{:#}", config.oidc_identity().unwrap_err()); + + assert!( + message.contains(super::OIDC_SCOPES_ENV_KEY), + "{:?} not reported: {}", + scopes, + message + ); + assert!( + message.contains(&format!( + "a scope starting with \"{}\" and ending with \"{}\"", + super::OIDC_AUDIENCE_SCOPE_PREFIX, + super::OIDC_AUDIENCE_SCOPE_SUFFIX + )), + "the missing audience scope is not named: {}", + message + ); + assert!( + !message.contains(&format!( + "a scope starting with \"{}\"", + super::OIDC_ROLE_SCOPE_PREFIX + )), + "a configured role scope is reported as missing: {}", + message + ); + assert!( + message.contains("urn:zitadel:iam:org:project:id::aud"), + "the audience scope example is missing: {}", + message + ); + } + } + + #[test] + fn test_oidc_identity_rejects_scopes_without_a_project_role() { + let dir = Builder::new().tempdir().unwrap(); + let key_file = write_key_file(&dir); + + let cases = [ + vec![AUDIENCE_SCOPE.to_string()], + vec!["openid".to_string(), AUDIENCE_SCOPE.to_string()], + ]; + + for scopes in cases { + let config = scopes_config(Some(scopes.clone()), &key_file); + let message = format!("{:#}", config.oidc_identity().unwrap_err()); + + assert!( + message.contains(super::OIDC_SCOPES_ENV_KEY), + "{:?} not reported: {}", + scopes, + message + ); + assert!( + message.contains(&format!( + "a scope starting with \"{}\"", + super::OIDC_ROLE_SCOPE_PREFIX + )), + "the missing role scope is not named: {}", + message + ); + assert!( + !message.contains(&format!( + "a scope starting with \"{}\" and ending with \"{}\"", + super::OIDC_AUDIENCE_SCOPE_PREFIX, + super::OIDC_AUDIENCE_SCOPE_SUFFIX + )), + "a configured audience scope is reported as missing: {}", + message + ); + assert!( + message.contains("urn:zitadel:iam:org:project:role:sd_reporters"), + "the role scope example is missing: {}", + message + ); + } + } + + #[test] + fn test_oidc_identity_rejects_scopes_that_are_not_configured() { + let dir = Builder::new().tempdir().unwrap(); + let key_file = write_key_file(&dir); + let cases: [Option>; 2] = [None, Some(Vec::new())]; + + for scopes in cases { + let config = scopes_config(scopes, &key_file); + let message = format!("{:#}", config.oidc_identity().unwrap_err()); + + assert!( + message.contains(super::OIDC_SCOPES_ENV_KEY), + "{} not reported: {}", + super::OIDC_SCOPES_ENV_KEY, + message + ); + for prefix in [ + super::OIDC_ROLE_SCOPE_PREFIX, + super::OIDC_AUDIENCE_SCOPE_PREFIX, + ] { + assert!( + message.contains(prefix), + "{} not reported: {}", + prefix, + message + ); + } + } + } + + #[test] + fn test_oidc_identity_accepts_configured_role_and_audience_scopes() { + let dir = Builder::new().tempdir().unwrap(); + let key_file = write_key_file(&dir); + let scopes = vec![ + "openid".to_string(), + ROLE_SCOPE.to_string(), + AUDIENCE_SCOPE.to_string(), + ]; + + let config = scopes_config(Some(scopes.clone()), &key_file); + let identity = config.oidc_identity().unwrap(); + + assert_eq!(identity.scopes, scopes); + assert_eq!(identity.issuer, "https://zitadel.example.com"); + } + + #[test] + fn test_oidc_identity_reports_the_failing_key_file() { + use crate::oidc::test_keys::{ + application_key_file_json, key_file_json_with_type, KEY_ID, USER_ID, + }; + + let dir = Builder::new().tempdir().unwrap(); + + let invalid_json = dir.path().join("invalid.json"); + std::fs::write(&invalid_json, "{ not json }").unwrap(); + + let unknown_type = dir.path().join("unknown-type.json"); + std::fs::write(&unknown_type, key_file_json_with_type("widget")).unwrap(); + + let application = dir.path().join("application.json"); + std::fs::write(&application, application_key_file_json()).unwrap(); + + let empty_user = dir.path().join("empty-user.json"); + std::fs::write( + &empty_user, + serde_json::json!({ + "type": "serviceaccount", + "keyId": KEY_ID, + "key": "-----BEGIN", + "userId": "", + }) + .to_string(), + ) + .unwrap(); + + let empty_key = dir.path().join("empty-key.json"); + std::fs::write( + &empty_key, + serde_json::json!({ + "type": "serviceaccount", + "keyId": KEY_ID, + "key": "", + "userId": USER_ID, + }) + .to_string(), + ) + .unwrap(); + + let cases = [ + dir.path().join("does-not-exist.json"), + invalid_json, + unknown_type, + application, + empty_user, + empty_key, + ]; + let mut messages = Vec::new(); + + for path in cases { + let config = super::StatusDashboardConfig { + oidc_issuer: Some("https://zitadel.example.com".to_string()), + oidc_key_file: Some(path.to_str().unwrap().to_string()), + ..status_dashboard_section() + }; + + let message = format!("{:#}", config.oidc_identity().unwrap_err()); + assert!( + message.contains(super::OIDC_KEY_FILE_ENV_KEY), + "{} not reported: {}", + super::OIDC_KEY_FILE_ENV_KEY, + message + ); + messages.push(message); + } + + assert!( + messages[0].contains("cannot be read"), + "unexpected error: {}", + messages[0] + ); + assert!( + messages[1].contains("not valid JSON"), + "unexpected error: {}", + messages[1] + ); + for index in [2, 3] { + assert!( + messages[index].contains("serviceaccount"), + "unexpected error: {}", + messages[index] + ); + } + assert!( + messages[4].contains("empty userId"), + "unexpected error: {}", + messages[4] + ); + assert!( + messages[5].contains("empty key"), + "unexpected error: {}", + messages[5] + ); + + for (index, message) in messages.iter().enumerate() { + assert!( + !messages[..index].contains(message), + "error messages are not distinguishable: {}", + message + ); + } + } + + #[test] + fn test_oidc_identity_loads_the_key_file_and_normalizes_the_issuer() { + let dir = Builder::new().tempdir().unwrap(); + let key_file = write_key_file(&dir); + + let config = super::StatusDashboardConfig { + oidc_issuer: Some("https://zitadel.example.com/".to_string()), + oidc_key_file: Some(key_file.clone()), + ..status_dashboard_section() + }; + + let identity = config.oidc_identity().unwrap(); + + assert_eq!(identity.issuer, "https://zitadel.example.com"); + assert_eq!( + identity.token_url(), + "https://zitadel.example.com/oauth/v2/token" + ); + let rendered = format!("{:?}", identity); + assert!( + rendered.contains("https://zitadel.example.com"), + "unexpected debug output: {}", + rendered + ); + assert!( + !rendered.contains("PRIVATE KEY") && !rendered.contains("key_id"), + "key material leaked into debug output: {}", + rendered + ); + assert_eq!( + identity.scopes, + vec![ROLE_SCOPE.to_string(), AUDIENCE_SCOPE.to_string()] + ); + } } diff --git a/src/lib.rs b/src/lib.rs index a5f2c2c..df2336e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,5 +6,6 @@ pub mod api; pub mod common; pub mod config; pub mod graphite; +pub mod oidc; pub mod sd; pub mod types; diff --git a/src/oidc.rs b/src/oidc.rs new file mode 100644 index 0000000..aed25e0 --- /dev/null +++ b/src/oidc.rs @@ -0,0 +1,295 @@ +//! Zitadel machine user identity. The OAuth flow (discovery, assertion signing, token request) is +//! delegated to the `zitadel` crate; only `type: "serviceaccount"` key files are supported. + +use anyhow::Context; +use serde::Deserialize; +use zitadel::credentials::{AuthenticationOptions, ServiceAccount}; + +use crate::config::OidcIdentity; + +const SUPPORTED_KEY_TYPE: &str = "serviceaccount"; + +#[derive(Deserialize)] +struct KeyFileJson { + #[serde(rename = "type")] + key_type: Option, + #[serde(rename = "keyId")] + key_id: Option, + key: Option, + #[serde(rename = "userId")] + user_id: Option, +} + +pub fn load_service_account(path: &str) -> anyhow::Result { + let content = std::fs::read_to_string(path) + .with_context(|| format!("the key file {} cannot be read", path))?; + + validate_key_file(&content)?; + + ServiceAccount::load_from_json(&content) + .map_err(|err| anyhow::anyhow!("{err}")) + .context("the key file is not a valid Zitadel service account key file") +} + +/// The crate only rejects malformed JSON, so the credential fields it ignores are validated here +/// to keep startup fail-closed. +fn validate_key_file(json: &str) -> anyhow::Result<()> { + let file: KeyFileJson = serde_json::from_str(json).context("the key file is not valid JSON")?; + + match file.key_type.as_deref() { + Some(SUPPORTED_KEY_TYPE) => {} + Some(other) => anyhow::bail!( + "the key file has type \"{}\", only \"{}\" keys of a Zitadel machine user are supported", + other, + SUPPORTED_KEY_TYPE + ), + None => anyhow::bail!( + "the key file has no type, expected \"{}\"", + SUPPORTED_KEY_TYPE + ), + } + + required_field(file.key_id, "keyId")?; + required_field(file.key, "key")?; + required_field(file.user_id, "userId")?; + + Ok(()) +} + +fn required_field(value: Option, name: &str) -> anyhow::Result { + match value { + Some(value) if !value.trim().is_empty() => Ok(value), + Some(_) => anyhow::bail!("the key file has an empty {}", name), + None => anyhow::bail!("the key file has no {}", name), + } +} + +impl OidcIdentity { + /// Fixed Zitadel token endpoint, referenced in error messages only. + pub fn token_url(&self) -> String { + format!("{}/oauth/v2/token", self.issuer) + } +} + +/// The crate rejects a configured issuer that does not match the discovered one, which fails when +/// the URL keeps a trailing slash. +pub fn normalize_issuer(issuer: &str) -> String { + issuer.trim_end_matches('/').to_string() +} + +pub async fn fetch_service_token(identity: &OidcIdentity) -> anyhow::Result { + let options = AuthenticationOptions { + scopes: identity.scopes.clone(), + ..Default::default() + }; + + let access_token = identity + .service_account + .authenticate_with_options(&identity.issuer, &options) + .await + .map_err(|err| anyhow::anyhow!("{err}")) + .with_context(|| { + format!( + "Failed to request a Zitadel service token from {}", + identity.token_url() + ) + })?; + + if access_token.trim().is_empty() { + anyhow::bail!( + "Zitadel service token response from {} has no access_token", + identity.token_url() + ); + } + + Ok(access_token) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::oidc::test_keys::{ + application_key_file_json, key_file_json_with_type, service_account_key_file_json, USER_ID, + }; + use tempfile::Builder; + + #[test] + fn test_load_service_account_key_file() { + let file = Builder::new().suffix(".json").tempfile().unwrap(); + std::fs::write(file.path(), service_account_key_file_json()).unwrap(); + + let service_account = load_service_account(file.path().to_str().unwrap()).unwrap(); + assert!(format!("{:?}", service_account).contains(USER_ID)); + } + + #[test] + fn test_load_service_account_rejects_unusable_key_files() { + let dir = Builder::new().tempdir().unwrap(); + + let invalid_json = dir.path().join("invalid.json"); + std::fs::write(&invalid_json, "{ not json }").unwrap(); + + let unknown_type = dir.path().join("unknown-type.json"); + std::fs::write(&unknown_type, key_file_json_with_type("widget")).unwrap(); + + let application_type = dir.path().join("application.json"); + std::fs::write(&application_type, application_key_file_json()).unwrap(); + + let empty_key = dir.path().join("empty-key.json"); + std::fs::write( + &empty_key, + serde_json::json!({ + "type": SUPPORTED_KEY_TYPE, + "keyId": "1234", + "key": "", + "userId": USER_ID, + }) + .to_string(), + ) + .unwrap(); + + let cases = [ + dir.path().join("does-not-exist.json"), + invalid_json, + unknown_type, + application_type, + empty_key, + ]; + let mut messages = Vec::new(); + + for path in cases { + let message = format!( + "{:#}", + load_service_account(path.to_str().unwrap()).unwrap_err() + ); + messages.push(message); + } + + assert!( + messages[0].contains("cannot be read"), + "unexpected error: {}", + messages[0] + ); + assert!( + messages[1].contains("not valid JSON"), + "unexpected error: {}", + messages[1] + ); + for index in [2, 3] { + assert!( + messages[index].contains(SUPPORTED_KEY_TYPE), + "unexpected error: {}", + messages[index] + ); + } + assert!( + messages[4].contains("empty key"), + "unexpected error: {}", + messages[4] + ); + + for (index, message) in messages.iter().enumerate() { + assert!( + !messages[..index].contains(message), + "error messages are not distinguishable: {}", + message + ); + } + } + + #[test] + fn test_normalize_issuer() { + assert_eq!( + normalize_issuer("https://zitadel.example.com/"), + "https://zitadel.example.com" + ); + assert_eq!( + normalize_issuer("https://zitadel.example.com"), + "https://zitadel.example.com" + ); + } +} + +#[cfg(test)] +pub(crate) mod test_keys { + use super::SUPPORTED_KEY_TYPE; + use rsa::pkcs1::EncodeRsaPrivateKey; + use rsa::pkcs8::LineEnding; + use std::sync::OnceLock; + + pub const KEY_ID: &str = "81693565968962154"; + pub const USER_ID: &str = "392040635458125910"; + pub const CLIENT_ID: &str = "392040635458125999"; + + fn private_key_pem() -> &'static str { + static PEM: OnceLock = OnceLock::new(); + + PEM.get_or_init(|| { + let private_key = rsa::RsaPrivateKey::new(&mut rand::thread_rng(), 2048) + .expect("failed to generate a test RSA key"); + + private_key + .to_pkcs1_pem(LineEnding::LF) + .expect("failed to encode a PKCS#1 test key") + .to_string() + }) + } + + pub fn service_account_key_file_json() -> String { + key_file_json( + Some(SUPPORTED_KEY_TYPE), + KEY_ID, + private_key_pem(), + Some(("userId", USER_ID)), + ) + } + + pub fn application_key_file_json() -> String { + key_file_json( + Some("application"), + KEY_ID, + private_key_pem(), + Some(("clientId", CLIENT_ID)), + ) + } + + pub fn key_file_json_with_type(key_type: &str) -> String { + key_file_json( + Some(key_type), + KEY_ID, + private_key_pem(), + Some(("userId", USER_ID)), + ) + } + + fn key_file_json( + key_type: Option<&str>, + key_id: &str, + key: &str, + id: Option<(&str, &str)>, + ) -> String { + let mut file = serde_json::Map::new(); + if let Some(key_type) = key_type { + file.insert( + "type".to_string(), + serde_json::Value::String(key_type.to_string()), + ); + } + file.insert( + "keyId".to_string(), + serde_json::Value::String(key_id.to_string()), + ); + file.insert( + "key".to_string(), + serde_json::Value::String(key.to_string()), + ); + if let Some((name, value)) = id { + file.insert( + name.to_string(), + serde_json::Value::String(value.to_string()), + ); + } + + serde_json::Value::Object(file).to_string() + } +} diff --git a/src/sd.rs b/src/sd.rs index bfae872..29f9319 100644 --- a/src/sd.rs +++ b/src/sd.rs @@ -3,13 +3,12 @@ //! This module contains all functionality for integrating with the Status Dashboard API, //! including component management, incident creation, cache operations, and authentication. -use anyhow; -use hmac::{Hmac, Mac}; -use jwt::SignWithKey; -use reqwest::header::HeaderMap; +use reqwest::header::{HeaderMap, HeaderValue}; use serde::{Deserialize, Serialize}; -use sha2::Sha256; -use std::collections::{BTreeMap, HashMap}; +use std::collections::HashMap; + +use crate::config::OidcIdentity; +use crate::oidc::fetch_service_token; /// Component attribute (key-value pair) for identifying components #[derive(Clone, Deserialize, Serialize, Debug, PartialEq, Eq, Hash, Ord, PartialOrd)] @@ -25,14 +24,6 @@ pub struct Component { pub attributes: Vec, } -/// Component status for V1 API (legacy, deprecated - use V2 IncidentData instead) -#[derive(Clone, Deserialize, Serialize, Debug)] -pub struct ComponentStatus { - pub name: String, - pub impact: u8, - pub attributes: Vec, -} - /// Component data from Status Dashboard API V2 GET /v2/components response #[derive(Clone, Deserialize, Serialize, Debug)] pub struct StatusDashboardComponent { @@ -58,27 +49,14 @@ pub struct IncidentData { /// Component ID cache: maps (component_name, sorted_attributes) to component_id pub type ComponentCache = HashMap<(String, Vec), u32>; -/// Generate HMAC-JWT authorization headers for Status Dashboard API -/// -/// Creates a Bearer token using HMAC-SHA256 signing with the provided secret. -/// Returns empty HeaderMap if no secret is provided (for optional auth environments). -/// -/// # Arguments -/// * `secret` - Optional HMAC secret for JWT signing -/// -/// # Returns -/// HeaderMap with Authorization header if secret provided, empty otherwise -pub fn build_auth_headers(secret: Option<&str>) -> HeaderMap { +/// D6: a token is requested for every authenticated call, never cached in-process, and a failed +/// request must not degrade into an unauthenticated one. +pub async fn build_auth_headers(identity: &OidcIdentity) -> anyhow::Result { + let token = fetch_service_token(identity).await?; let mut headers = HeaderMap::new(); - if let Some(secret) = secret { - let key: Hmac = Hmac::new_from_slice(secret.as_bytes()).unwrap(); - let mut claims = BTreeMap::new(); - claims.insert("stackmon", "dummy"); - let token_str = claims.sign_with_key(&key).unwrap(); - let bearer = format!("Bearer {}", token_str); - headers.insert(reqwest::header::AUTHORIZATION, bearer.parse().unwrap()); - } - headers + let value = HeaderValue::from_str(&format!("Bearer {}", token))?; + headers.insert(reqwest::header::AUTHORIZATION, value); + Ok(headers) } /// Fetch all components from Status Dashboard API V2 diff --git a/tests/fixtures/mod.rs b/tests/fixtures/mod.rs index de4078d..dcbb8d5 100644 --- a/tests/fixtures/mod.rs +++ b/tests/fixtures/mod.rs @@ -15,3 +15,5 @@ pub mod configs; pub mod graphite_responses; #[allow(dead_code)] pub mod helpers; +#[allow(dead_code)] +pub mod service_account; diff --git a/tests/fixtures/service_account.rs b/tests/fixtures/service_account.rs new file mode 100644 index 0000000..f7ea38b --- /dev/null +++ b/tests/fixtures/service_account.rs @@ -0,0 +1,91 @@ +//! Zitadel key file fixtures: the RSA key pair is generated once per test binary in the PKCS#1 PEM +//! format the Zitadel Console exports. + +use rsa::pkcs1::EncodeRsaPrivateKey; +use std::path::Path; +use std::sync::OnceLock; + +pub const KEY_ID: &str = "81693565968962154"; +pub const USER_ID: &str = "392040635458125910"; +pub const CLIENT_ID: &str = "392040635458125999"; + +fn private_key_pem() -> &'static str { + static PEM: OnceLock = OnceLock::new(); + + PEM.get_or_init(|| { + let private_key = rsa::RsaPrivateKey::new(&mut rand::thread_rng(), 2048) + .expect("failed to generate a test RSA key"); + + private_key + .to_pkcs1_pem(rsa::pkcs8::LineEnding::LF) + .expect("failed to encode a PKCS#1 test key") + .to_string() + }) +} + +pub fn public_key_pem() -> String { + static PEM: OnceLock = OnceLock::new(); + + PEM.get_or_init(|| { + use rsa::pkcs1::DecodeRsaPrivateKey; + use rsa::pkcs8::{EncodePublicKey, LineEnding}; + + let private_key = rsa::RsaPrivateKey::from_pkcs1_pem(private_key_pem()) + .expect("the test key must be a PKCS#1 PEM private key"); + + private_key + .to_public_key() + .to_public_key_pem(LineEnding::LF) + .expect("failed to encode the test public key") + }) + .clone() +} + +pub fn service_account_key_file_json() -> String { + static KEY_FILE: OnceLock = OnceLock::new(); + + KEY_FILE + .get_or_init(|| { + serde_json::json!({ + "type": "serviceaccount", + "keyId": KEY_ID, + "key": private_key_pem(), + "userId": USER_ID, + }) + .to_string() + }) + .clone() +} + +pub fn application_key_file_json() -> String { + static KEY_FILE: OnceLock = OnceLock::new(); + + KEY_FILE + .get_or_init(|| { + serde_json::json!({ + "type": "application", + "keyId": KEY_ID, + "key": private_key_pem(), + "clientId": CLIENT_ID, + }) + .to_string() + }) + .clone() +} + +fn write_key_file(path: &Path, content: String) -> String { + std::fs::write(path, content) + .unwrap_or_else(|err| panic!("failed to write {}: {}", path.display(), err)); + + path.to_str() + .expect("the key file path is not valid UTF-8") + .to_string() +} + +pub fn write_service_account_key_file(path: &Path) -> String { + write_key_file(path, service_account_key_file_json()) +} + +pub fn write_application_key_file(path: &Path) -> String { + write_key_file(path, application_key_file_json()) +} diff --git a/tests/integration_e2e_reporter.rs b/tests/integration_e2e_reporter.rs index be7205a..bc875cc 100644 --- a/tests/integration_e2e_reporter.rs +++ b/tests/integration_e2e_reporter.rs @@ -112,11 +112,19 @@ use std::time::Duration; use regex::Regex; +#[allow(dead_code)] +#[path = "fixtures/service_account.rs"] +mod service_account; +use service_account::write_service_account_key_file; + const GRAPHITE_URL: &str = "http://localhost:8080"; const CARBON_HOST: &str = "localhost"; const CARBON_PORT: u16 = 2003; const CONVERTOR_PORT: u16 = 3005; const STATUS_DASHBOARD_PORT: u16 = 9999; +const SERVICE_ACCOUNT_KEY_FILE: &str = "target/e2e-service-account.json"; +const REPORTER_SCOPE: &str = "urn:zitadel:iam:org:project:role:sd_reporters"; +const AUDIENCE_SCOPE: &str = "urn:zitadel:iam:org:project:id:392066917738875090:aud"; // ============================================================================ // Test Infrastructure @@ -471,6 +479,9 @@ fn start_mock_status_dashboard() -> Option { // Clean up any existing process on the port kill_process_on_port(STATUS_DASHBOARD_PORT); + // The mock doubles as the Zitadel issuer, so the reporter discovers its token endpoint here + let issuer = format!("http://localhost:{}", STATUS_DASHBOARD_PORT); + // Use a Python HTTP server that supports IPv4/IPv6 and runs indefinitely let mock_server = Command::new("python3") .args([ @@ -484,7 +495,26 @@ import socket class Handler(http.server.BaseHTTPRequestHandler): def do_GET(self): - if '/v2/components' in self.path: + if '/.well-known/openid-configuration' in self.path: + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.end_headers() + response = json.dumps({{ + "issuer": "{issuer}", + "authorization_endpoint": "{issuer}/oauth/v2/authorize", + "token_endpoint": "{issuer}/oauth/v2/token", + "jwks_uri": "{issuer}/oauth/v2/keys", + "response_types_supported": ["code"], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256"] + }}) + self.wfile.write(response.encode()) + elif '/oauth/v2/keys' in self.path: + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(json.dumps({{"keys": []}}).encode()) + elif '/v2/components' in self.path: self.send_response(200) self.send_header('Content-Type', 'application/json') self.end_headers() @@ -497,7 +527,13 @@ class Handler(http.server.BaseHTTPRequestHandler): self.end_headers() def do_POST(self): - if '/v2/events' in self.path: + if '/oauth/v2/token' in self.path: + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.end_headers() + response = json.dumps({{"access_token": "mock-access-token", "token_type": "Bearer", "expires_in": 3600}}) + self.wfile.write(response.encode()) + elif '/v2/events' in self.path: self.send_response(200) self.send_header('Content-Type', 'application/json') self.end_headers() @@ -786,7 +822,11 @@ server: status_dashboard: url: 'http://localhost:{}' - secret: 'test-secret-key' + oidc_issuer: 'http://localhost:{}' + oidc_key_file: '{}' + oidc_scopes: + - '{role_scope}' + - '{audience_scope}' metric_templates: api_down: @@ -853,6 +893,8 @@ health_query: GRAPHITE_URL, CONVERTOR_PORT, STATUS_DASHBOARD_PORT, + STATUS_DASHBOARD_PORT, + SERVICE_ACCOUNT_KEY_FILE, service, service, service, @@ -862,7 +904,9 @@ health_query: service, service, service, - service + service, + role_scope = REPORTER_SCOPE, + audience_scope = AUDIENCE_SCOPE ) } @@ -903,6 +947,9 @@ async fn test_e2e_reporter_log_validation() { let mut scenarios_run = 0; let config_path = "config.yaml"; + let key_file = write_service_account_key_file(std::path::Path::new(SERVICE_ACCOUNT_KEY_FILE)); + println!("service account key file written to {}", key_file); + for scenario in scenarios { println!("\n============================================================"); println!("test scenario: {}", scenario.name.to_uppercase()); diff --git a/tests/integration_sd.rs b/tests/integration_sd.rs index 860acea..229174e 100644 --- a/tests/integration_sd.rs +++ b/tests/integration_sd.rs @@ -4,11 +4,325 @@ //! T028-T037: Validate end-to-end Status Dashboard API integration with mocked endpoints use chrono::DateTime; +use cloudmon_metrics::config::{OidcIdentity, StatusDashboardConfig}; use cloudmon_metrics::sd::{ build_auth_headers, build_component_id_cache, build_incident_data, create_incident, fetch_components, find_component_id, Component, ComponentAttribute, IncidentData, StatusDashboardComponent, }; +use mockito::Matcher; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +#[allow(dead_code)] +#[path = "fixtures/service_account.rs"] +mod service_account; +use service_account::write_service_account_key_file; + +const AUTH_SCHEME: &str = "Bearer"; +const JWT_BEARER_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:jwt-bearer"; +/// Scope the `zitadel` crate always requests in addition to the configured scopes +const OPENID_SCOPE: &str = "openid"; +const REPORTER_SCOPE: &str = "urn:zitadel:iam:org:project:role:sd_reporters"; +const AUDIENCE_SCOPE: &str = "urn:zitadel:iam:org:project:id:392066917738875090:aud"; +const TOKEN_RESPONSE: &str = + r#"{"access_token":"mock-access-token","token_type":"Bearer","expires_in":3600}"#; +const OIDC_KEY_FILE_ENV_KEY: &str = "MP_STATUS_DASHBOARD__OIDC_KEY_FILE"; +const OIDC_ISSUER_ENV_KEY: &str = "MP_STATUS_DASHBOARD__OIDC_ISSUER"; +const OIDC_SCOPES_ENV_KEY: &str = "MP_STATUS_DASHBOARD__OIDC_SCOPES"; + +fn key_file() -> (tempfile::TempDir, String) { + let dir = tempfile::tempdir().expect("failed to create a temp dir"); + let path = write_service_account_key_file(&dir.path().join("service-account.json")); + + (dir, path) +} + +fn status_dashboard_config(issuer: &str, key_file: &str) -> StatusDashboardConfig { + StatusDashboardConfig { + url: "https://status.example.com".to_string(), + oidc_issuer: Some(issuer.to_string()), + oidc_key_file: Some(key_file.to_string()), + oidc_scopes: Some(vec![REPORTER_SCOPE.to_string(), AUDIENCE_SCOPE.to_string()]), + } +} + +fn service_identity(issuer: &str) -> (tempfile::TempDir, OidcIdentity) { + let (dir, key_file) = key_file(); + let identity = status_dashboard_config(issuer, &key_file) + .oidc_identity() + .expect("the key file must resolve a service identity"); + + (dir, identity) +} + +#[derive(Clone, Debug)] +struct TokenRequest { + method: String, + path: String, + body: String, + authorization: Option, +} + +impl TokenRequest { + fn capture(request: &mockito::Request) -> Self { + Self { + method: request.method().to_string(), + path: request.path().to_string(), + body: request + .body() + .map(|body| String::from_utf8_lossy(body).to_string()) + .unwrap_or_default(), + authorization: request + .header("authorization") + .first() + .map(|value| value.to_string()), + } + } + + fn form_field(&self, name: &str) -> Option { + self.body.split('&').find_map(|pair| { + let (field, value) = pair.split_once('=')?; + (percent_decode(field) == name).then(|| percent_decode(value)) + }) + } + + fn assertion(&self) -> String { + self.form_field("assertion") + .expect("the token request must carry an assertion") + } +} + +struct OidcProvider { + discovery: mockito::Mock, + jwks: mockito::Mock, + token: TokenEndpoint, +} + +impl OidcProvider { + /// The crate runs OIDC discovery and a JWKS fetch before every token request, so one metadata + /// request is served per token call. + async fn create( + server: &mut mockito::ServerGuard, + calls: usize, + status: usize, + response: impl Fn(usize) -> String + Send + Sync + 'static, + ) -> Self { + let (discovery, jwks) = mock_oidc_metadata(server, calls).await; + + Self { + discovery, + jwks, + token: TokenEndpoint::create(server, calls, status, response).await, + } + } + + async fn healthy(server: &mut mockito::ServerGuard, calls: usize) -> Self { + Self::create(server, calls, 200, |_| TOKEN_RESPONSE.to_string()).await + } + + async fn assert(&self) { + self.discovery.assert_async().await; + self.jwks.assert_async().await; + self.token.assert().await; + } + + fn requests(&self) -> Vec { + self.token.requests() + } +} + +async fn mock_oidc_metadata( + server: &mut mockito::ServerGuard, + calls: usize, +) -> (mockito::Mock, mockito::Mock) { + let url = server.url(); + let document = serde_json::json!({ + "issuer": url, + "authorization_endpoint": format!("{}/oauth/v2/authorize", url), + "token_endpoint": format!("{}/oauth/v2/token", url), + "jwks_uri": format!("{}/oauth/v2/keys", url), + "response_types_supported": ["code"], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256"], + "grant_types_supported": [ + "authorization_code", + "urn:ietf:params:oauth:grant-type:jwt-bearer", + ], + }) + .to_string(); + + let discovery = server + .mock("GET", "/.well-known/openid-configuration") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(document) + .expect(calls) + .create_async() + .await; + + // The reporter never verifies the token itself, so an empty key set is enough + let jwks = server + .mock("GET", "/oauth/v2/keys") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"keys":[]}"#) + .expect(calls) + .create_async() + .await; + + (discovery, jwks) +} + +struct TokenEndpoint { + mock: mockito::Mock, + requests: Arc>>, +} + +impl TokenEndpoint { + async fn create( + server: &mut mockito::ServerGuard, + calls: usize, + status: usize, + response: impl Fn(usize) -> String + Send + Sync + 'static, + ) -> Self { + let requests: Arc>> = Arc::new(Mutex::new(Vec::new())); + let recorder = Arc::clone(&requests); + let served = Arc::new(AtomicUsize::new(0)); + let call_counter = Arc::clone(&served); + + let mock = server + .mock("POST", "/oauth/v2/token") + .match_header("content-type", "application/x-www-form-urlencoded") + // The JWT profile flow must never fall back to HTTP Basic auth + .match_header("authorization", Matcher::Missing) + .with_status(status) + .with_header("content-type", "application/json") + .with_body_from_request(move |request| { + let call = call_counter.fetch_add(1, Ordering::SeqCst) + 1; + recorder + .lock() + .expect("token request recorder is poisoned") + .push(TokenRequest::capture(request)); + response(call).into_bytes() + }) + .expect(calls) + .create_async() + .await; + + Self { mock, requests } + } + + async fn assert(&self) { + self.mock.assert_async().await; + } + + fn requests(&self) -> Vec { + self.requests + .lock() + .expect("token request recorder is poisoned") + .clone() + } +} + +fn percent_decode(value: &str) -> String { + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + + while index < bytes.len() { + match bytes[index] { + b'+' => { + decoded.push(b' '); + index += 1; + } + b'%' if index + 2 < bytes.len() => { + let hex = std::str::from_utf8(&bytes[index + 1..index + 3]) + .expect("form encoding is not valid UTF-8"); + decoded.push(u8::from_str_radix(hex, 16).expect("invalid percent encoding")); + index += 3; + } + byte => { + decoded.push(byte); + index += 1; + } + } + } + + String::from_utf8(decoded).expect("form encoded value is not valid UTF-8") +} + +#[derive(Debug, serde::Deserialize)] +struct AssertionClaims { + iss: String, + sub: String, + aud: String, + iat: i64, + exp: i64, +} + +fn verified_assertion_claims(assertion: &str) -> AssertionClaims { + let header = jsonwebtoken::decode_header(assertion).expect("the assertion must be a JWS"); + assert_eq!(header.alg, jsonwebtoken::Algorithm::RS256); + assert_eq!(header.kid.as_deref(), Some(service_account::KEY_ID)); + + let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::RS256); + validation.validate_aud = false; + + jsonwebtoken::decode::( + assertion, + &jsonwebtoken::DecodingKey::from_rsa_pem(service_account::public_key_pem().as_bytes()) + .expect("the derived public key must be a valid PEM RSA key"), + &validation, + ) + .expect("the assertion must verify against the public key of the key file") + .claims +} + +#[tokio::test] +async fn test_build_auth_headers() { + let mut server = mockito::Server::new_async().await; + let provider = OidcProvider::healthy(&mut server, 1).await; + + let (_key_dir, identity) = service_identity(&server.url()); + + let headers = build_auth_headers(&identity).await.unwrap(); + + let auth_value = headers.get(reqwest::header::AUTHORIZATION).unwrap(); + assert_eq!( + auth_value.to_str().unwrap(), + format!("{} mock-access-token", AUTH_SCHEME) + ); + + provider.assert().await; + + let requests = provider.requests(); + assert_eq!(requests.len(), 1, "exactly one token request is expected"); + + let request = &requests[0]; + assert_eq!(request.method, "POST"); + assert_eq!(request.path, "/oauth/v2/token"); + assert_eq!( + request.form_field("grant_type").as_deref(), + Some(JWT_BEARER_GRANT_TYPE) + ); + // The crate always attaches the openid scope before the configured ones + assert_eq!( + request.form_field("scope").as_deref(), + Some(format!("{} {} {}", OPENID_SCOPE, REPORTER_SCOPE, AUDIENCE_SCOPE).as_str()) + ); + assert_eq!( + request.authorization, None, + "the token request must not authenticate with HTTP Basic" + ); + + let claims = verified_assertion_claims(&request.assertion()); + assert_eq!(claims.iss, service_account::USER_ID); + assert_eq!(claims.sub, service_account::USER_ID); + assert_eq!(claims.aud, server.url()); + assert_eq!(claims.exp - claims.iat, 3600); +} /// T029: Test fetch_components_success - verify component fetching and parsing #[tokio::test] @@ -470,20 +784,205 @@ fn test_multiple_components_same_name() { assert_eq!(find_component_id(&cache, &target_nl), Some(200)); } -/// Test build_auth_headers - verify JWT token generation -#[test] -fn test_build_auth_headers() { - // Test with secret - let headers = build_auth_headers(Some("test-secret")); - assert!(headers.contains_key(reqwest::header::AUTHORIZATION)); +#[tokio::test] +async fn test_build_auth_headers_multiple_scopes() { + let mut server = mockito::Server::new_async().await; + let provider = OidcProvider::healthy(&mut server, 1).await; + + let (_key_dir, key_file) = key_file(); + let mut cfg = status_dashboard_config(&server.url(), &key_file); + cfg.oidc_scopes = Some(vec![REPORTER_SCOPE.to_string(), AUDIENCE_SCOPE.to_string()]); + + let expected_scope = format!("{} {} {}", OPENID_SCOPE, REPORTER_SCOPE, AUDIENCE_SCOPE); + let identity = cfg.oidc_identity().unwrap(); + + let headers = build_auth_headers(&identity).await.unwrap(); let auth_value = headers.get(reqwest::header::AUTHORIZATION).unwrap(); - let auth_str = auth_value.to_str().unwrap(); - assert!(auth_str.starts_with("Bearer ")); + assert_eq!( + auth_value.to_str().unwrap(), + format!("{} mock-access-token", AUTH_SCHEME) + ); + + provider.assert().await; + + let requests = provider.requests(); + assert_eq!( + requests[0].form_field("scope").as_deref(), + Some(expected_scope.as_str()), + "the scopes must be joined by a single space, in the configured order" + ); + assert_eq!( + requests[0].body.matches("scope=").count(), + 1, + "the scope must be sent as exactly one field: {}", + requests[0].body + ); +} + +#[test] +fn test_oidc_identity_rejects_scopes_without_the_audience_scope() { + let (_key_dir, key_file) = key_file(); + let mut cfg = status_dashboard_config("https://zitadel.example.com", &key_file); + cfg.oidc_scopes = Some(vec![REPORTER_SCOPE.to_string()]); + + let message = format!("{:#}", cfg.oidc_identity().unwrap_err()); + + assert!( + message.contains(OIDC_SCOPES_ENV_KEY), + "{} not reported: {}", + OIDC_SCOPES_ENV_KEY, + message + ); + assert!( + message.contains("urn:zitadel:iam:org:project:id::aud"), + "the audience scope example is missing: {}", + message + ); +} + +#[tokio::test] +async fn test_build_auth_headers_token_endpoint_error() { + for status in [400usize, 401, 500, 503] { + let mut server = mockito::Server::new_async().await; + let provider = OidcProvider::create(&mut server, 1, status, |_| { + r#"{"error":"invalid_client"}"#.to_string() + }) + .await; + + let (_key_dir, identity) = service_identity(&server.url()); + + let err = build_auth_headers(&identity).await.unwrap_err(); + let message = format!("{:#}", err); + let assertion = provider.requests()[0].assertion(); + + assert!( + message.contains(&format!("{}/oauth/v2/token", server.url())), + "status {}: the failing endpoint is missing from the error: {}", + status, + message + ); + assert!( + !message.contains(&assertion), + "the signed assertion leaked into the error: {}", + message + ); + assert!( + !message.contains("PRIVATE KEY"), + "key material leaked into the error: {}", + message + ); + + provider.assert().await; + } +} - // Test without secret (optional auth) - let headers_empty = build_auth_headers(None); - assert!(!headers_empty.contains_key(reqwest::header::AUTHORIZATION)); +#[tokio::test] +async fn test_build_auth_headers_without_a_usable_access_token() { + for body in [ + r#"{"token_type":"Bearer","expires_in":3600}"#, + r#"{"access_token":"","token_type":"Bearer"}"#, + r#"{"access_token":null,"token_type":"Bearer"}"#, + "not json at all", + ] { + let mut server = mockito::Server::new_async().await; + let provider = OidcProvider::create(&mut server, 1, 200, move |_| body.to_string()).await; + + let (_key_dir, identity) = service_identity(&server.url()); + + let err = build_auth_headers(&identity) + .await + .expect_err("a response without a usable access token must be an error"); + let message = format!("{:#}", err); + + assert!( + !message.contains("Bearer "), + "an unusable access token produced credentials: {}", + message + ); + assert!( + message.contains(&format!("{}/oauth/v2/token", server.url())), + "unexpected error: {}", + message + ); + + provider.assert().await; + } +} + +#[test] +fn test_oidc_identity_requires_all_credentials() { + let (_key_dir, key_file) = key_file(); + let complete = status_dashboard_config("https://zitadel.example.com", &key_file); + assert!(complete.oidc_identity().is_ok()); + + let cases = [ + ( + StatusDashboardConfig { + oidc_issuer: None, + ..complete.clone() + }, + OIDC_ISSUER_ENV_KEY, + ), + ( + StatusDashboardConfig { + oidc_key_file: None, + ..complete.clone() + }, + OIDC_KEY_FILE_ENV_KEY, + ), + ( + StatusDashboardConfig { + oidc_issuer: None, + oidc_key_file: None, + ..complete.clone() + }, + OIDC_KEY_FILE_ENV_KEY, + ), + ]; + + for (cfg, expected_key) in cases { + let message = format!("{:#}", cfg.oidc_identity().unwrap_err()); + assert!( + message.contains(expected_key), + "missing {} not reported: {}", + expected_key, + message + ); + } + + let all_missing = StatusDashboardConfig { + oidc_issuer: None, + oidc_key_file: None, + ..complete.clone() + }; + let message = format!("{:#}", all_missing.oidc_identity().unwrap_err()); + for key in [OIDC_ISSUER_ENV_KEY, OIDC_KEY_FILE_ENV_KEY] { + assert!( + message.contains(key), + "missing {} not reported: {}", + key, + message + ); + } +} + +#[test] +fn test_status_dashboard_requires_oidc_scopes() { + let cfg: StatusDashboardConfig = + serde_yaml::from_str("url: https://status.example.com\n").unwrap(); + + assert!(cfg.oidc_scopes.is_none()); + assert!(cfg.oidc_issuer.is_none()); + assert!(cfg.oidc_key_file.is_none()); + + let message = format!("{:#}", cfg.oidc_identity().unwrap_err()); + assert!( + message.contains(OIDC_SCOPES_ENV_KEY), + "{} not reported: {}", + OIDC_SCOPES_ENV_KEY, + message + ); } /// Test create_incident failure - verify error handling when API returns error @@ -554,3 +1053,286 @@ async fn test_fetch_components_failure() { mock.assert_async().await; } + +#[tokio::test] +async fn test_authenticated_requests_fetch_a_fresh_service_token() { + assert_fresh_token_per_authenticated_request().await; +} + +/// The crate signs assertions with a second-resolution `iat` and no `jti`, so two acquisitions in +/// the same second yield the same assertion; crossing the boundary makes freshness observable. +async fn wait_for_next_second() { + let elapsed = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("the system clock must be after the unix epoch"); + + tokio::time::sleep(Duration::from_millis( + u64::from(1000 - elapsed.subsec_millis()) + 50, + )) + .await; +} + +async fn assert_fresh_token_per_authenticated_request() { + let mut server = mockito::Server::new_async().await; + + let provider = OidcProvider::create(&mut server, 2, 200, |call| { + format!( + r#"{{"access_token":"fresh-token-{}","token_type":"Bearer","expires_in":3600}}"#, + call + ) + }) + .await; + + let components_body = r#"[{"id":218,"name":"Object Storage Service","attributes":[{"name":"region","value":"EU-DE"}]}]"#; + + let component_report = server + .mock("GET", "/v2/components") + .match_header( + "authorization", + Matcher::Exact(format!("{} fresh-token-1", AUTH_SCHEME)), + ) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(components_body) + .expect(1) + .create_async() + .await; + + let incident_report = server + .mock("POST", "/v2/events") + .match_header( + "authorization", + Matcher::Exact(format!("{} fresh-token-2", AUTH_SCHEME)), + ) + .match_header("content-type", "application/json") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"result":[{"component_id":218,"incident_id":456}]}"#) + .expect(1) + .create_async() + .await; + + let client = reqwest::Client::new(); + let (_key_dir, identity) = service_identity(&server.url()); + + let headers = build_auth_headers(&identity).await.unwrap(); + fetch_components(&client, &server.url(), &headers) + .await + .expect("the component fetch must use the token fetched for it"); + + wait_for_next_second().await; + + let headers = build_auth_headers(&identity).await.unwrap(); + create_incident( + &client, + &server.url(), + &headers, + &build_incident_data(218, 2, 1705929045), + ) + .await + .expect("the incident report must use the token fetched for it"); + + let requests = provider.requests(); + assert_eq!( + requests.len(), + 2, + "the token endpoint must be called once per authenticated request" + ); + + let assertions = [requests[0].assertion(), requests[1].assertion()]; + assert_ne!( + assertions[0], assertions[1], + "every token request must carry its own freshly signed assertion" + ); + + provider.assert().await; + component_report.assert_async().await; + incident_report.assert_async().await; +} + +#[tokio::test] +async fn test_no_status_dashboard_request_when_service_token_fails() { + let mut server = mockito::Server::new_async().await; + + let provider = OidcProvider::create(&mut server, 1, 503, |_| { + r#"{"error":"temporarily_unavailable"}"#.to_string() + }) + .await; + + let components_mock = server + .mock("GET", "/v2/components") + .expect(0) + .create_async() + .await; + let events_mock = server + .mock("POST", "/v2/events") + .expect(0) + .create_async() + .await; + + let (_key_dir, identity) = service_identity(&server.url()); + + let err = build_auth_headers(&identity) + .await + .expect_err("a failing token endpoint must not provide authorization headers"); + let message = format!("{:#}", err); + + assert!( + message.contains(&format!("{}/oauth/v2/token", server.url())), + "the failing token endpoint is missing from the error: {}", + message + ); + assert!( + !message.contains(&provider.requests()[0].assertion()), + "the signed assertion leaked into the error: {}", + message + ); + assert!( + !message.contains("PRIVATE KEY"), + "key material leaked into the error: {}", + message + ); + + provider.assert().await; + components_mock.assert_async().await; + events_mock.assert_async().await; +} + +#[tokio::test] +async fn test_reporter_exits_non_zero_on_fatal_startup_error() { + let mut server = mockito::Server::new_async().await; + + let provider = OidcProvider::create(&mut server, 1, 503, |_| { + r#"{"error":"temporarily_unavailable"}"#.to_string() + }) + .await; + + let components_mock = server + .mock("GET", "/v2/components") + .expect(0) + .create_async() + .await; + + let dir = tempfile::tempdir().unwrap(); + let key_file = write_service_account_key_file(&dir.path().join("service-account.json")); + + let config = format!( + r#"--- +datasource: + url: '{url}' +server: + port: 3005 +environments: + - name: test-env +flag_metrics: [] +health_metrics: {{}} +status_dashboard: + url: '{url}' + oidc_issuer: '{url}' + oidc_key_file: '{key_file}' + oidc_scopes: + - '{role_scope}' + - '{audience_scope}' +"#, + url = server.url(), + key_file = key_file, + role_scope = REPORTER_SCOPE, + audience_scope = AUDIENCE_SCOPE, + ); + + std::fs::write(dir.path().join("config.yaml"), config).unwrap(); + + let child = tokio::process::Command::new(env!("CARGO_BIN_EXE_cloudmon-metrics-reporter")) + .current_dir(dir.path()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("failed to start cloudmon-metrics-reporter"); + + let output = tokio::time::timeout(Duration::from_secs(60), child.wait_with_output()) + .await + .expect("the reporter did not exit on a fatal error") + .expect("failed to collect reporter output"); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert_eq!( + output.status.code(), + Some(1), + "a fatal error must exit non-zero, stdout: {}, stderr: {}", + stdout, + stderr + ); + assert!( + stderr.contains("metric reporter failed"), + "unexpected error output: {}", + stderr + ); + assert!( + !stderr.contains("PRIVATE KEY") && !stdout.contains("PRIVATE KEY"), + "key material leaked into the reporter output" + ); + assert!( + !stderr.contains(&provider.requests()[0].assertion()), + "the signed assertion leaked into the reporter output" + ); + + provider.assert().await; + components_mock.assert_async().await; +} + +#[tokio::test] +async fn test_reporter_reports_an_unusable_service_account_key_file() { + let dir = tempfile::tempdir().unwrap(); + + let config = format!( + r#"--- +datasource: + url: 'http://127.0.0.1:1' +server: + port: 3005 +environments: + - name: test-env +flag_metrics: [] +health_metrics: {{}} +status_dashboard: + url: 'http://127.0.0.1:1' + oidc_issuer: 'http://127.0.0.1:1' + oidc_key_file: 'service-account.json' + oidc_scopes: + - '{role_scope}' + - '{audience_scope}' +"#, + role_scope = REPORTER_SCOPE, + audience_scope = AUDIENCE_SCOPE, + ); + + std::fs::write(dir.path().join("config.yaml"), config).unwrap(); + + let child = tokio::process::Command::new(env!("CARGO_BIN_EXE_cloudmon-metrics-reporter")) + .current_dir(dir.path()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("failed to start cloudmon-metrics-reporter"); + + let output = tokio::time::timeout(Duration::from_secs(60), child.wait_with_output()) + .await + .expect("the reporter did not exit on a missing key file") + .expect("failed to collect reporter output"); + + let stderr = String::from_utf8_lossy(&output.stderr); + + assert_ne!( + output.status.code(), + Some(0), + "a missing key file must fail closed, stderr: {}", + stderr + ); + assert!( + stderr.contains(OIDC_KEY_FILE_ENV_KEY), + "the failing configuration key must be named: {}", + stderr + ); +}