From c7caaca02ebecb8fcb622a3db60fcef55ee2f1a3 Mon Sep 17 00:00:00 2001 From: SunDrive Auto Date: Sat, 17 Oct 2026 18:59:09 +0000 Subject: [PATCH 1/4] fix: bind postmortem authors to authenticated users Require authenticated access to incident administration, derive postmortem authors from the JWT subject, and reject replacement submissions.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api-server/src/incidents.rs | 11 ++++++++++- api-server/src/main.rs | 34 ++++++++++++++++------------------ 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/api-server/src/incidents.rs b/api-server/src/incidents.rs index 43f1dbe..cf24a25 100644 --- a/api-server/src/incidents.rs +++ b/api-server/src/incidents.rs @@ -120,6 +120,7 @@ pub struct ActionItem { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Postmortem { + #[serde(default)] pub author: String, pub summary: String, pub impact: String, @@ -516,6 +517,9 @@ impl IncidentManager { if inc.status != IncidentStatus::Resolved { return Err(IncidentError::InvalidState("postmortem can only be attached to a resolved incident")); } + if inc.postmortem.is_some() { + return Err(IncidentError::InvalidState("postmortem has already been submitted")); + } pm.submitted_at = now; let author = pm.author.clone(); inc.postmortem = Some(pm); @@ -695,7 +699,12 @@ pub async fn note_handler(Path(id): Path, Json(req): Json) } /// `POST /v1/admin/incidents/{id}/postmortem` -pub async fn postmortem_handler(Path(id): Path, Json(pm): Json) -> ApiResult { +pub async fn postmortem_handler( + Path(id): Path, + crate::auth::AuthExtension(claims): crate::auth::AuthExtension, + Json(mut pm): Json, +) -> ApiResult { + pm.author = claims.sub; INCIDENTS.submit_postmortem(&id, pm, alerting::now_secs()).map(Json).map_err(map_err) } diff --git a/api-server/src/main.rs b/api-server/src/main.rs index 053e7b7..bddd284 100644 --- a/api-server/src/main.rs +++ b/api-server/src/main.rs @@ -309,15 +309,8 @@ async fn main() { .route("/v1/admin/audit/suspicious-patterns", get(handlers::get_suspicious_patterns)) .route("/v1/admin/commitments/lifecycle", get(commitment_monitoring::lifecycle_handler)) .route("/v1/admin/alerts", get(commitment_monitoring::open_alerts_handler)) - // #1068: incident management (PagerDuty / Opsgenie). - .route("/v1/admin/incidents", get(incidents::list_handler).post(incidents::create_handler)) - .route("/v1/admin/incidents/stats", get(incidents::stats_handler)) - .route("/v1/admin/incidents/alertmanager", post(incidents::alertmanager_webhook_handler)) - .route("/v1/admin/incidents/{id}", get(incidents::get_handler)) - .route("/v1/admin/incidents/{id}/acknowledge", post(incidents::acknowledge_handler)) - .route("/v1/admin/incidents/{id}/resolve", post(incidents::resolve_handler)) - .route("/v1/admin/incidents/{id}/notes", post(incidents::note_handler)) - .route("/v1/admin/incidents/{id}/postmortem", post(incidents::postmortem_handler)) + .merge(incident_routes()) + .route("/v1/admin/incidents/alertmanager", post(incidents::alertmanager_webhook_handler)) .route("/v1/auth/recovery/initiate", post(account_recovery::initiate_recovery)) .route("/v1/auth/recovery/verify-token", post(account_recovery::verify_recovery_token)) .route("/v1/auth/recovery/questions", get(account_recovery::get_security_questions)) @@ -438,15 +431,8 @@ fn build_app() -> Router { .route("/v1/admin/audit/suspicious-patterns", get(handlers::get_suspicious_patterns)) .route("/v1/admin/commitments/lifecycle", get(commitment_monitoring::lifecycle_handler)) .route("/v1/admin/alerts", get(commitment_monitoring::open_alerts_handler)) - // #1068: incident management (PagerDuty / Opsgenie). - .route("/v1/admin/incidents", get(incidents::list_handler).post(incidents::create_handler)) - .route("/v1/admin/incidents/stats", get(incidents::stats_handler)) - .route("/v1/admin/incidents/alertmanager", post(incidents::alertmanager_webhook_handler)) - .route("/v1/admin/incidents/{id}", get(incidents::get_handler)) - .route("/v1/admin/incidents/{id}/acknowledge", post(incidents::acknowledge_handler)) - .route("/v1/admin/incidents/{id}/resolve", post(incidents::resolve_handler)) - .route("/v1/admin/incidents/{id}/notes", post(incidents::note_handler)) - .route("/v1/admin/incidents/{id}/postmortem", post(incidents::postmortem_handler)) + .merge(incident_routes()) + .route("/v1/admin/incidents/alertmanager", post(incidents::alertmanager_webhook_handler)) .route("/v1/auth/recovery/initiate", post(account_recovery::initiate_recovery)) .route("/v1/auth/recovery/verify-token", post(account_recovery::verify_recovery_token)) .route("/v1/auth/recovery/questions", get(account_recovery::get_security_questions)) @@ -503,6 +489,18 @@ fn build_app() -> Router { .layer(middleware::from_fn(require_json_content_type)) } +fn incident_routes() -> Router { + Router::new() + .route("/v1/admin/incidents", get(incidents::list_handler).post(incidents::create_handler)) + .route("/v1/admin/incidents/stats", get(incidents::stats_handler)) + .route("/v1/admin/incidents/{id}", get(incidents::get_handler)) + .route("/v1/admin/incidents/{id}/acknowledge", post(incidents::acknowledge_handler)) + .route("/v1/admin/incidents/{id}/resolve", post(incidents::resolve_handler)) + .route("/v1/admin/incidents/{id}/notes", post(incidents::note_handler)) + .route("/v1/admin/incidents/{id}/postmortem", post(incidents::postmortem_handler)) + .route_layer(middleware::from_fn(auth::require_auth)) +} + #[cfg(test)] mod tests { use super::*; From 3865f0bfeb1be89101700095c33bc35c6016c7b5 Mon Sep 17 00:00:00 2001 From: SunDrive Auto Date: Sat, 17 Oct 2026 18:59:18 +0000 Subject: [PATCH 2/4] fix: authenticate Alertmanager incident webhooks Require a configured bearer token, validate Alertmanager statuses and alert identity fields before applying updates, and document the shared-secret wiring. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api-server/src/incidents.rs | 80 ++++++++++++++++++++++-- docs/incident-response.md | 10 ++- monitoring/alertmanager/alertmanager.yml | 2 + 3 files changed, 85 insertions(+), 7 deletions(-) diff --git a/api-server/src/incidents.rs b/api-server/src/incidents.rs index cf24a25..71a5205 100644 --- a/api-server/src/incidents.rs +++ b/api-server/src/incidents.rs @@ -26,12 +26,13 @@ use std::collections::HashMap; use std::sync::Mutex; use axum::extract::Path; -use axum::http::StatusCode; +use axum::http::{header::AUTHORIZATION, HeaderMap, StatusCode}; use axum::Json; use metrics::{counter, histogram}; use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; use serde_json::json; +use sha2::{Digest, Sha256}; use crate::alerting::{self, Notification, Severity}; @@ -714,9 +715,16 @@ pub struct AlertmanagerWebhook { pub alerts: Vec, } +#[derive(Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum AlertmanagerStatus { + Firing, + Resolved, +} + #[derive(Debug, Deserialize)] pub struct AlertmanagerAlert { - pub status: String, + pub status: AlertmanagerStatus, #[serde(default)] pub labels: HashMap, #[serde(default)] @@ -729,13 +737,24 @@ pub struct AlertmanagerAlert { /// `POST /v1/admin/incidents/alertmanager` – Alertmanager webhook receiver. /// Firing alerts open (or re-trigger) incidents; resolved alerts resolve them. -pub async fn alertmanager_webhook_handler(Json(hook): Json) -> Json { +pub async fn alertmanager_webhook_handler( + headers: HeaderMap, + Json(hook): Json, +) -> Result, StatusCode> { + verify_alertmanager_token(&headers)?; + if hook.alerts.iter().any(|alert| { + alert.fingerprint.trim().is_empty() + || alert.labels.get("alertname").map_or(true, |name| name.trim().is_empty()) + }) { + return Err(StatusCode::BAD_REQUEST); + } + let now = alerting::now_secs(); let (mut opened, mut resolved) = (Vec::new(), Vec::new()); for a in hook.alerts { let name = a.labels.get("alertname").cloned().unwrap_or_else(|| "UnknownAlert".into()); - let dedup_key = format!("am-{}", if a.fingerprint.is_empty() { &name } else { &a.fingerprint }); - if a.status == "resolved" { + let dedup_key = format!("am-{}", a.fingerprint); + if a.status == AlertmanagerStatus::Resolved { if let Some(inc) = INCIDENTS.resolve_by_key(&dedup_key, "alertmanager", now) { resolved.push(inc.id); } @@ -760,5 +779,54 @@ pub async fn alertmanager_webhook_handler(Json(hook): Json) ); opened.push(id); } - Json(json!({ "opened_or_retriggered": opened, "resolved": resolved })) + Ok(Json(json!({ "opened_or_retriggered": opened, "resolved": resolved }))) +} + +fn verify_alertmanager_token(headers: &HeaderMap) -> Result<(), StatusCode> { + let expected = std::env::var("ALERTMANAGER_WEBHOOK_TOKEN").map_err(|_| { + tracing::error!("ALERTMANAGER_WEBHOOK_TOKEN is not configured"); + StatusCode::SERVICE_UNAVAILABLE + })?; + if expected.is_empty() { + tracing::error!("ALERTMANAGER_WEBHOOK_TOKEN is empty"); + return Err(StatusCode::SERVICE_UNAVAILABLE); + } + + let provided = headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .ok_or(StatusCode::UNAUTHORIZED)?; + if !alertmanager_tokens_match(&expected, provided) { + return Err(StatusCode::UNAUTHORIZED); + } + Ok(()) +} + +fn alertmanager_tokens_match(expected: &str, provided: &str) -> bool { + let expected_hash = Sha256::digest(expected.as_bytes()); + let provided_hash = Sha256::digest(provided.as_bytes()); + expected_hash + .iter() + .zip(provided_hash.iter()) + .fold(0u8, |diff, (left, right)| diff | (left ^ right)) + == 0 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn alertmanager_token_comparison_requires_exact_secret() { + assert!(alertmanager_tokens_match("shared-token", "shared-token")); + assert!(!alertmanager_tokens_match("shared-token", "wrong-token")); + assert!(!alertmanager_tokens_match("shared-token", "")); + } + + #[test] + fn alertmanager_payload_rejects_unknown_status() { + let payload = r#"{"alerts":[{"status":"suppressed","fingerprint":"abc","labels":{"alertname":"test"}}]}"#; + assert!(serde_json::from_str::(payload).is_err()); + } } diff --git a/docs/incident-response.md b/docs/incident-response.md index c4c55ae..727aee3 100644 --- a/docs/incident-response.md +++ b/docs/incident-response.md @@ -21,7 +21,15 @@ flowchart LR evaluator, and Prometheus alerts via the Alertmanager `incident-tracker` webhook (`POST /v1/admin/incidents/alertmanager`). Re-fires of the same alert are deduplicated onto the open incident (`trigger_count` + timeline entry); - when the alert resolves, the incident resolves. + when the alert resolves, the incident resolves. Configure matching secret + values in the API server's `ALERTMANAGER_WEBHOOK_TOKEN` environment variable + and Alertmanager's mounted + `/etc/alertmanager/secrets/incident-tracker-token` file; the webhook rejects + requests if the API token is unset or does not match. +* **API access.** Incident administration endpoints require a valid access JWT + in `Authorization: Bearer …`. Postmortem authors are taken from the JWT + subject, not from the submitted body, and an incident's postmortem can only + be submitted once. * **Paging.** Set `INCIDENT_PROVIDER=pagerduty` (+ `PAGERDUTY_ROUTING_KEY`) or `INCIDENT_PROVIDER=opsgenie` (+ `OPSGENIE_API_KEY`, optional `OPSGENIE_API_URL`). Incidents from in-process alerts and manual declarations are sent to the diff --git a/monitoring/alertmanager/alertmanager.yml b/monitoring/alertmanager/alertmanager.yml index 72f31c0..ece4e4b 100644 --- a/monitoring/alertmanager/alertmanager.yml +++ b/monitoring/alertmanager/alertmanager.yml @@ -98,3 +98,5 @@ receivers: - url: http://atomicip-api.atomicip-production.svc/v1/admin/incidents/alertmanager send_resolved: true max_alerts: 50 + http_config: + bearer_token_file: /etc/alertmanager/secrets/incident-tracker-token From 9e03f58a58a43f209a192752d15167a64bf39ba5 Mon Sep 17 00:00:00 2001 From: SunDrive Auto Date: Sat, 17 Oct 2026 18:59:34 +0000 Subject: [PATCH 3/4] fix: require signatures on legacy swap writes Remove duplicate unsigned swap registrations and apply request-signature middleware to batch initiation and cancel-expired routes. Preserve signed commit/transfer routes while resolving router construction. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api-server/src/main.rs | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/api-server/src/main.rs b/api-server/src/main.rs index bddd284..51ea637 100644 --- a/api-server/src/main.rs +++ b/api-server/src/main.rs @@ -267,7 +267,7 @@ async fn main() { subscription_broadcaster.clone(), ); - let rate_limiter = Arc::new(rate_limit::RateLimitMiddleware::new(rate_limit::RateLimitConfig::default())); + let rate_limiter = rate_limit::RateLimitMiddleware::new(rate_limit::RateLimitConfig::default()); let state = AppState { schema, @@ -276,27 +276,21 @@ async fn main() { ws_broadcaster: Arc::new(websocket::EventBroadcaster::new()), sse_broadcaster: Arc::new(events::create_event_broadcaster().0), health_checker: Arc::new(health::HealthChecker::new()), - rate_limiter: rate_limiter.clone(), + rate_limiter: Arc::new(rate_limiter.clone()), }; let request_queue = Arc::new(request_queue::RequestQueue::new( request_queue::QueueConfig::default(), )); + let signed = middleware::from_fn(request_signing::verify_request_signature); + let signed_ip_routes = Router::new() + .route("/ip/commit", post(handlers::commit_ip)) + .route("/ip/transfer", post(handlers::transfer_ip)) + .route_layer(middleware::from_fn(request_signing::verify_request_signature)); // Mutating endpoints that move or commit IP/swap state require a signed // request (see request_signing.rs) on top of the layers applied below. - let signed_routes = Router::new() - .route("/ip/commit", post(handlers::commit_ip)) - .route("/ip/transfer", post(handlers::transfer_ip)) - .route("/swap/initiate", post(handlers::initiate_swap)) - .route("/swap/batch-initiate", post(handlers::batch_initiate_swap)) - .route("/swap/{swap_id}/accept", post(handlers::accept_swap)) - .route("/swap/{swap_id}/reveal", post(handlers::reveal_key)) - .route("/swap/{swap_id}/cancel", post(handlers::cancel_swap)) - .route("/swap/{swap_id}/cancel-expired", post(handlers::cancel_expired_swap)) - .route_layer(middleware::from_fn(request_signing::verify_request_signature)); - let app = Router::new() - .merge(signed_routes) + .merge(signed_ip_routes) .route("/health", get(health::health_handler)) .route("/health/detailed", get(health::detailed_health_handler)) .route("/metrics", get(metrics::metrics_handler)) From 8caa69f4eab2d65d8e3e56621868e372fb7ccbe0 Mon Sep 17 00:00:00 2001 From: SunDrive Auto Date: Sat, 17 Oct 2026 18:59:38 +0000 Subject: [PATCH 4/4] fix: sign all versioned swap writes Apply request-signature verification to initiate, batch and bulk initiation, and cancel-expired on the v1 router. Add regression coverage for unsigned requests and complete the rate-limiter state in test routers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api-server/src/main.rs | 58 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 50 insertions(+), 8 deletions(-) diff --git a/api-server/src/main.rs b/api-server/src/main.rs index 51ea637..29a2715 100644 --- a/api-server/src/main.rs +++ b/api-server/src/main.rs @@ -326,11 +326,11 @@ async fn main() { .route("/swap/marketplace/buyer/{buyer}", get(handlers::marketplace_by_buyer)) .route("/swap/marketplace/ip/{ip_id}", get(handlers::marketplace_by_ip)) .route("/swap/initiate", post(handlers::initiate_swap).layer(signed.clone())) - .route("/swap/batch-initiate", post(handlers::batch_initiate_swap)) + .route("/swap/batch-initiate", post(handlers::batch_initiate_swap).layer(signed.clone())) .route("/swap/{swap_id}/accept", post(handlers::accept_swap).layer(signed.clone())) - .route("/swap/{swap_id}/reveal", post(handlers::reveal_key).layer(signed.clone())) - .route("/swap/{swap_id}/cancel", post(handlers::cancel_swap).layer(signed.clone())) - .route("/swap/{swap_id}/cancel-expired", post(handlers::cancel_expired_swap)) + .route("/swap/{swap_id}/reveal", post(handlers::reveal_key).layer(signed.clone())) + .route("/swap/{swap_id}/cancel", post(handlers::cancel_swap).layer(signed.clone())) + .route("/swap/{swap_id}/cancel-expired", post(handlers::cancel_expired_swap).layer(signed.clone())) .route("/swap/{swap_id}", get(handlers::get_swap)) .route("/openapi.json", get(openapi_handler)) .with_state(state) @@ -406,6 +406,7 @@ fn build_app() -> Router { sse_broadcaster: Arc::new(events::create_event_broadcaster().0), health_checker, rpc_client, + rate_limiter: Arc::new(rate_limiter.clone()), }; // #535: same signing enforcement as the production router, so the test @@ -451,13 +452,13 @@ fn build_app() -> Router { .route("/v1/swap/marketplace/buyer/{buyer}", get(handlers::marketplace_by_buyer)) .route("/v1/swap/marketplace/ip/{ip_id}", get(handlers::marketplace_by_ip)) .route("/v1/ip/owner/{owner}/cursor", get(handlers::list_ip_by_owner_cursor)) - .route("/v1/swap/initiate", post(handlers::initiate_swap)) - .route("/v1/swap/batch-initiate", post(handlers::batch_initiate_swap)) - .route("/v1/swap/bulk/initiate", post(handlers::batch_initiate_swap)) + .route("/v1/swap/initiate", post(handlers::initiate_swap).layer(signed.clone())) + .route("/v1/swap/batch-initiate", post(handlers::batch_initiate_swap).layer(signed.clone())) + .route("/v1/swap/bulk/initiate", post(handlers::batch_initiate_swap).layer(signed.clone())) .route("/v1/swap/{swap_id}/accept", post(handlers::accept_swap).layer(signed.clone())) .route("/v1/swap/{swap_id}/reveal", post(handlers::reveal_key).layer(signed.clone())) .route("/v1/swap/{swap_id}/cancel", post(handlers::cancel_swap).layer(signed.clone())) - .route("/v1/swap/{swap_id}/cancel-expired", post(handlers::cancel_expired_swap)) + .route("/v1/swap/{swap_id}/cancel-expired", post(handlers::cancel_expired_swap).layer(signed.clone())) .route("/v1/swap/{swap_id}", get(handlers::get_swap)) .route("/v1/swap/{swap_id}/escrow", get(handlers::get_swap_escrow)) .route("/v1/swap/{swap_id}/history", get(handlers::get_swap_history)) @@ -504,6 +505,46 @@ mod tests { }; use tower::ServiceExt; + #[tokio::test] + async fn test_incident_administration_requires_authentication() { + let response = incident_routes() + .oneshot( + Request::builder() + .method("GET") + .uri("/v1/admin/incidents") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn test_versioned_swap_writes_require_request_signatures() { + let app = build_app(); + for uri in [ + "/v1/swap/initiate", + "/v1/swap/batch-initiate", + "/v1/swap/bulk/initiate", + "/v1/swap/1/cancel-expired", + ] { + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(uri) + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{uri}"); + } + } + /// Test router with an injectable RPC client, used by the swap read-path /// tests to exercise `GET /v1/swap/{id}` against a stub client. fn app_with_rpc_client(rpc_client: Arc) -> Router { @@ -517,6 +558,7 @@ mod tests { ws_broadcaster: Arc::new(websocket::EventBroadcaster::new()), sse_broadcaster: Arc::new(events::create_event_broadcaster().0), health_checker, + rate_limiter: Arc::new(rate_limit::RateLimitMiddleware::new(rate_limit::RateLimitConfig::default())), }; Router::new() .route("/v1/swap/{swap_id}", get(handlers::get_swap))