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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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"]
}
```

**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)
//!

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>,
/// JWT token preferred_username claim
pub jwt_preferred_username: Option<String>,
/// JWT token group claim (will be placed into "groups" array in JWT payload)
pub jwt_group: Option<String>,
}

/// 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(),
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());
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"),
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