Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
29 changes: 24 additions & 5 deletions doc/modules/sd.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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?;
Expand Down
46 changes: 36 additions & 10 deletions doc/reporter.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <jwt-token>
```

**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"]
Comment thread
bakhterets marked this conversation as resolved.
Outdated
}
```

**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`:
Expand All @@ -166,7 +182,11 @@ pub struct IncidentData { title, description, impact, components, start_date, sy
pub type ComponentCache = HashMap<(String, Vec<ComponentAttribute>), 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<Vec<StatusDashboardComponent>>
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
```
Expand Down
6 changes: 5 additions & 1 deletion src/bin/reporter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
bakhterets marked this conversation as resolved.
Outdated
//!

use glob::glob;

Expand Down Expand Up @@ -177,6 +190,10 @@ pub struct StatusDashboardConfig {
pub url: String,
/// JWT token signature secret
pub secret: Option<String>,
Comment thread
bakhterets marked this conversation as resolved.
Outdated
/// JWT token preferred_username claim
pub jwt_preferred_username: Option<String>,
Comment thread
bakhterets marked this conversation as resolved.
Outdated
/// JWT token group claim (will be placed into "groups" array in JWT payload)
pub jwt_group: Option<String>,
Comment thread
bakhterets marked this conversation as resolved.
Outdated
}

/// Health metrics query configuration
Expand Down
32 changes: 28 additions & 4 deletions src/sd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -65,15 +66,38 @@ pub type ComponentCache = HashMap<(String, Vec<ComponentAttribute>), 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<Sha256> = 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(),
Comment thread
bakhterets marked this conversation as resolved.
Outdated
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));
Comment thread
bakhterets marked this conversation as resolved.
Outdated
}

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());
Expand Down
100 changes: 96 additions & 4 deletions tests/integration_sd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Comment thread
bakhterets marked this conversation as resolved.
Outdated
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
Expand Down