From 62de71640c028f7ea376e806bfc0879972248590 Mon Sep 17 00:00:00 2001 From: Ilia Bakhterev Date: Tue, 17 Feb 2026 17:10:33 +0100 Subject: [PATCH] added JWT claims (preferred_username, groups) for Status Dashboard rbac auth --- Cargo.toml | 1 + doc/modules/sd.md | 29 ++++++++++-- doc/reporter.md | 46 ++++++++++++++---- src/bin/reporter.rs | 6 ++- src/config.rs | 17 +++++++ src/sd.rs | 32 +++++++++++-- tests/integration_sd.rs | 100 ++++++++++++++++++++++++++++++++++++++-- 7 files changed, 207 insertions(+), 24 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9a43c57..89167bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ tracing-subscriber = { version = "~0.3", features = ["env-filter"] } uuid = { version = "~1.3", features = ["v4", "fast-rng"] } [dev-dependencies] +base64 = "~0.21" mockito = "~1.0" serial_test = "3.3.1" tempfile = "~3.5" diff --git a/doc/modules/sd.md b/doc/modules/sd.md index 5227292..ea18c24 100644 --- a/doc/modules/sd.md +++ b/doc/modules/sd.md @@ -93,18 +93,33 @@ Key: `(component_name, sorted_attributes)` → Value: `component_id` #### `build_auth_headers` ```rust -pub fn build_auth_headers(secret: Option<&str>) -> HeaderMap +pub fn build_auth_headers( + secret: Option<&str>, + preferred_username: Option<&str>, + group: Option<&str>, +) -> HeaderMap ``` Generates HMAC-JWT authorization headers for Status Dashboard API. - Creates Bearer token using HMAC-SHA256 signing - Returns empty HeaderMap if no secret provided (optional auth) +- Optionally includes `preferred_username` claim for audit logging +- Optionally includes `groups` array claim with single group for authorization **Example**: ```rust -let headers = build_auth_headers(Some("my-secret")); -// Headers contain: Authorization: Bearer eyJ... +// With claims +let headers = build_auth_headers( + Some("status-dashboard-secret"), + Some("operator-sd"), + Some("sd-operators"), +); +// JWT payload: {"preferred_username": "operator-sd", "groups": ["sd-operators"]} + +// Without claims (backward compatible) +let headers = build_auth_headers(Some("my-secret"), None, None); +// JWT payload: {} ``` ### Component Management @@ -184,8 +199,12 @@ use cloudmon_metrics::sd::{ Component, ComponentAttribute, }; -// Build auth headers -let headers = build_auth_headers(config.secret.as_deref()); +// Build auth headers with optional claims +let headers = build_auth_headers( + config.secret.as_deref(), + config.jwt_preferred_username.as_deref(), + config.jwt_group.as_deref(), +); // Fetch and cache components let components = fetch_components(&client, &url, &headers).await?; diff --git a/doc/reporter.md b/doc/reporter.md index 45e96d2..7302586 100644 --- a/doc/reporter.md +++ b/doc/reporter.md @@ -138,19 +138,35 @@ Incidents are created with static, secure payloads: ### 5. Authentication -The reporter uses HMAC-JWT for authentication (unchanged from V1): +The reporter uses HMAC-JWT for authentication with optional claims: ```rust -// Generate HMAC-JWT token -let headers = build_auth_headers(secret.as_deref()); +// Generate HMAC-JWT token with optional claims +let headers = build_auth_headers( + secret.as_deref(), + preferred_username.as_deref(), + group.as_deref(), +); // Headers contain: Authorization: Bearer ``` **Token Format**: - Algorithm: HMAC-SHA256 -- Claims: `{"stackmon": "dummy"}` +- Claims (when configured): + - `preferred_username`: User identifier for audit logging + - `groups`: Array containing single group for authorization - Optional: No secret = no auth header (for environments without auth) +**Example JWT Payload** (with all claims configured): +```json +{ + "preferred_username": "operator-sd", + "groups": ["sd-operators"] +} +``` + +**Backward Compatibility**: If `jwt_preferred_username` and `jwt_group` are not configured, the JWT payload will be empty (same behavior as before). + ## Module Structure The Status Dashboard integration is consolidated in `src/sd.rs`: @@ -166,7 +182,11 @@ pub struct IncidentData { title, description, impact, components, start_date, sy pub type ComponentCache = HashMap<(String, Vec), u32>; // Authentication -pub fn build_auth_headers(secret: Option<&str>) -> HeaderMap +pub fn build_auth_headers( + secret: Option<&str>, + preferred_username: Option<&str>, + group: Option<&str>, +) -> HeaderMap // V2 API Functions pub async fn fetch_components(...) -> Result> @@ -194,12 +214,16 @@ convertor: status_dashboard: url: "https://dashboard.example.com" secret: "your-jwt-secret" + jwt_preferred_username: "operator-sd" # Optional: user identifier for JWT + jwt_group: "sd-operators" # Optional: group for authorization ``` -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|---------------------------------------| -| `url` | string | Yes | - | Status Dashboard API URL | -| `secret` | string | No | - | JWT signing secret for authentication | +| Property | Type | Required | Default | Description | +|------------------------|--------|----------|---------|--------------------------------------------------| +| `url` | string | Yes | - | Status Dashboard API URL | +| `secret` | string | No | - | JWT signing secret for authentication | +| `jwt_preferred_username` | string | No | - | Username claim for JWT (audit logging) | +| `jwt_group` | string | No | - | Group claim for JWT (placed into `groups` array) | ### Health Query Configuration @@ -282,7 +306,9 @@ spec: Override configuration: ```bash -MP_STATUS_DASHBOARD__SECRET=new-secret \ +MP_STATUS_DASHBOARD__SECRET=status-dashboard-secret \ +MP_STATUS_DASHBOARD__JWT_PREFERRED_USERNAME=operator-sd \ +MP_STATUS_DASHBOARD__JWT_GROUP=sd-operators \ MP_CONVERTOR__URL=http://convertor-svc:3005 \ cloudmon-metrics-reporter --config config.yaml ``` diff --git a/src/bin/reporter.rs b/src/bin/reporter.rs index f6895d7..442427a 100644 --- a/src/bin/reporter.rs +++ b/src/bin/reporter.rs @@ -121,7 +121,11 @@ async fn metric_watcher(config: &Config) { // 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 headers = build_auth_headers( + sdb_config.secret.as_deref(), + sdb_config.jwt_preferred_username.as_deref(), + sdb_config.jwt_group.as_deref(), + ); // Initialize component ID cache at startup with retry logic (T024, T025, T026, T027) // Per FR-006: 3 retry attempts with 60-second delays diff --git a/src/config.rs b/src/config.rs index 350b2cc..ac032e4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -33,7 +33,20 @@ //! expressions: //! - expression: 'a + b-c && d-e' //! weight: 1 +//! status_dashboard: +//! url: 'https://status-dashboard.example.com' +//! secret: 'status-dashboard-jwt-secret' +//! jwt_preferred_username: 'operator-sd' +//! jwt_group: 'sd-operators' //! ``` +//! +//! # Environment variables +//! Configuration can be overridden with environment variables using the `MP_` prefix +//! and `__` as separator for nested values. Examples: +//! - `MP_STATUS_DASHBOARD__SECRET` - JWT signing secret +//! - `MP_STATUS_DASHBOARD__JWT_PREFERRED_USERNAME` - JWT preferred_username claim +//! - `MP_STATUS_DASHBOARD__JWT_GROUP` - JWT group claim (will be placed into groups array) +//! use glob::glob; @@ -177,6 +190,10 @@ pub struct StatusDashboardConfig { pub url: String, /// JWT token signature secret pub secret: Option, + /// JWT token preferred_username claim + pub jwt_preferred_username: Option, + /// JWT token group claim (will be placed into "groups" array in JWT payload) + pub jwt_group: Option, } /// Health metrics query configuration diff --git a/src/sd.rs b/src/sd.rs index bfae872..4101be2 100644 --- a/src/sd.rs +++ b/src/sd.rs @@ -8,8 +8,9 @@ use hmac::{Hmac, Mac}; use jwt::SignWithKey; use reqwest::header::HeaderMap; use serde::{Deserialize, Serialize}; +use serde_json; use sha2::Sha256; -use std::collections::{BTreeMap, HashMap}; +use std::collections::HashMap; /// Component attribute (key-value pair) for identifying components #[derive(Clone, Deserialize, Serialize, Debug, PartialEq, Eq, Hash, Ord, PartialOrd)] @@ -65,15 +66,38 @@ pub type ComponentCache = HashMap<(String, Vec), u32>; /// /// # Arguments /// * `secret` - Optional HMAC secret for JWT signing +/// * `preferred_username` - Optional preferred_username claim for JWT +/// * `group` - Optional group claim for JWT (will be placed into "groups" array in JWT payload) /// /// # Returns /// HeaderMap with Authorization header if secret provided, empty otherwise -pub fn build_auth_headers(secret: Option<&str>) -> HeaderMap { +pub fn build_auth_headers( + secret: Option<&str>, + preferred_username: Option<&str>, + group: Option<&str>, +) -> HeaderMap { 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"); + + // Build claims as a JSON Value to support complex types + let mut claims_map = serde_json::Map::new(); + + // Add preferred_username if provided + if let Some(username) = preferred_username { + claims_map.insert( + "preferred_username".to_string(), + serde_json::Value::String(username.to_string()), + ); + } + + // Add group as array if provided (Status Dashboard expects "groups" claim name) + if let Some(group_value) = group { + let groups_json = vec![serde_json::Value::String(group_value.to_string())]; + claims_map.insert("groups".to_string(), serde_json::Value::Array(groups_json)); + } + + let claims = serde_json::Value::Object(claims_map); let token_str = claims.sign_with_key(&key).unwrap(); let bearer = format!("Bearer {}", token_str); headers.insert(reqwest::header::AUTHORIZATION, bearer.parse().unwrap()); diff --git a/tests/integration_sd.rs b/tests/integration_sd.rs index 860acea..3b3abe1 100644 --- a/tests/integration_sd.rs +++ b/tests/integration_sd.rs @@ -473,17 +473,109 @@ fn test_multiple_components_same_name() { /// 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")); + // Test with secret only (no claims) - should not panic + let headers = build_auth_headers(Some("test-secret"), None, None); assert!(headers.contains_key(reqwest::header::AUTHORIZATION)); let auth_value = headers.get(reqwest::header::AUTHORIZATION).unwrap(); let auth_str = auth_value.to_str().unwrap(); assert!(auth_str.starts_with("Bearer ")); - // Test without secret (optional auth) - let headers_empty = build_auth_headers(None); + // Test without secret (optional auth) - should not panic + let headers_empty = build_auth_headers(None, None, None); assert!(!headers_empty.contains_key(reqwest::header::AUTHORIZATION)); + + // Test with secret and claims - should not panic + let headers_with_claims = build_auth_headers( + Some("test-secret"), + Some("operator-sd"), + Some("sd-operators"), + ); + assert!(headers_with_claims.contains_key(reqwest::header::AUTHORIZATION)); + + // Test with only preferred_username (no group) - should not panic + let headers_username_only = build_auth_headers(Some("test-secret"), Some("operator-sd"), None); + assert!(headers_username_only.contains_key(reqwest::header::AUTHORIZATION)); + + // Test with only group (no preferred_username) - should not panic + let headers_group_only = build_auth_headers(Some("test-secret"), None, Some("sd-operators")); + assert!(headers_group_only.contains_key(reqwest::header::AUTHORIZATION)); +} + +/// Test build_auth_headers with claims - verify JWT payload structure +#[test] +fn test_build_auth_headers_with_claims() { + use base64::{engine::general_purpose, Engine as _}; + + // Generate token with all claims + let headers = build_auth_headers( + Some("test-secret"), + Some("operator-sd"), + Some("sd-operators"), + ); + + let auth_value = headers.get(reqwest::header::AUTHORIZATION).unwrap(); + let auth_str = auth_value.to_str().unwrap(); + let token = &auth_str[7..]; + + let parts: Vec<&str> = token.split('.').collect(); + assert_eq!(parts.len(), 3, "JWT should have 3 parts"); + + let payload_decoded = general_purpose::URL_SAFE_NO_PAD + .decode(parts[1]) + .expect("Failed to decode JWT payload"); + let payload_str = String::from_utf8(payload_decoded).expect("Failed to parse payload as UTF-8"); + let payload: serde_json::Value = + serde_json::from_str(&payload_str).expect("Failed to parse payload as JSON"); + + assert_eq!( + payload.get("preferred_username").and_then(|v| v.as_str()), + Some("operator-sd"), + "preferred_username should be 'operator-sd'" + ); + + // Verify groups claim is an array with single element + let groups = payload.get("groups").expect("groups claim should exist"); + assert!(groups.is_array(), "groups should be an array"); + let groups_array = groups.as_array().expect("groups should be array"); + assert_eq!(groups_array.len(), 1, "groups array should have 1 element"); + assert_eq!( + groups_array[0].as_str(), + Some("sd-operators"), + "groups[0] should be 'sd-operators'" + ); +} + +/// Test build_auth_headers without claims - verify empty payload is valid JWT +#[test] +fn test_build_auth_headers_without_claims() { + use base64::{engine::general_purpose, Engine as _}; + + // Generate token without claims (backward compatibility) + let headers = build_auth_headers(Some("test-secret"), None, None); + + let auth_value = headers.get(reqwest::header::AUTHORIZATION).unwrap(); + let auth_str = auth_value.to_str().unwrap(); + let token = &auth_str[7..]; // Remove "Bearer " prefix + + // Split JWT into parts + let parts: Vec<&str> = token.split('.').collect(); + assert_eq!(parts.len(), 3, "JWT should have 3 parts"); + + // Decode payload (second part) + let payload_decoded = general_purpose::URL_SAFE_NO_PAD + .decode(parts[1]) + .expect("Failed to decode JWT payload"); + let payload_str = String::from_utf8(payload_decoded).expect("Failed to parse payload as UTF-8"); + let payload: serde_json::Value = + serde_json::from_str(&payload_str).expect("Failed to parse payload as JSON"); + + // Verify payload is empty object (no claims) + assert!(payload.is_object(), "payload should be an object"); + assert!( + payload.as_object().unwrap().is_empty(), + "payload should be empty when no claims provided" + ); } /// Test create_incident failure - verify error handling when API returns error