From e805a2366f5b74cbe3a7ef4462f79c3022eb8cc1 Mon Sep 17 00:00:00 2001 From: agbochenuv-bit Date: Tue, 20 Oct 2026 06:39:20 +0000 Subject: [PATCH 1/4] fix: #975 Make admin audit records tamper-evident Closes #975 --- backend/src/auth.rs | 154 +++++++++++++++++++++++++++++++++++++-- backend/src/handlers.rs | 153 ++++++++++++++++++++++++++++++++++++--- backend/src/lib.rs | 156 +++++++++++++++++++++++++++++++++------- 3 files changed, 420 insertions(+), 43 deletions(-) diff --git a/backend/src/auth.rs b/backend/src/auth.rs index 78aec85..cf8a731 100644 --- a/backend/src/auth.rs +++ b/backend/src/auth.rs @@ -10,6 +10,7 @@ use dashmap::DashMap; use ed25519_dalek::{Signature, VerifyingKey}; use rand::Rng; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use std::sync::Arc; const CHALLENGE_PREFIX: &str = "COMEBACKHERE Auth Challenge:"; @@ -33,6 +34,114 @@ pub struct VerifiedSigner { pub public_key: String, } +/// A single administrative audit record. +/// +/// `prev_hash` chains each record to the one before it and `hash` commits to +/// the record contents plus `prev_hash`, so any silent modification or +/// deletion of a record breaks the chain and is detectable via +/// [`verify_audit_chain`]. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AuditRecord { + pub sequence: u64, + pub actor: String, + pub action: String, + pub timestamp: String, + pub prev_hash: String, + pub hash: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuditIntegrityError { + pub error: String, + pub sequence: Option, +} + +/// Compute the tamper-evident hash for an audit record. +fn compute_audit_hash( + sequence: u64, + actor: &str, + action: &str, + timestamp: &str, + prev_hash: &str, +) -> String { + let mut hasher = Sha256::new(); + hasher.update(sequence.to_le_bytes()); + hasher.update(actor.as_bytes()); + hasher.update(b"\x1f"); + hasher.update(action.as_bytes()); + hasher.update(b"\x1f"); + hasher.update(timestamp.as_bytes()); + hasher.update(b"\x1f"); + hasher.update(prev_hash.as_bytes()); + hex::encode(hasher.finalize()) +} + +/// Append a new audit record to `chain`, linking it to the previous record. +/// +/// Returns the newly created record. The record's `hash` is derived from its +/// contents and the previous record's hash, making later tampering evident. +pub fn append_audit_record( + chain: &mut Vec, + actor: &str, + action: &str, +) -> AuditRecord { + let sequence = chain.last().map(|r| r.sequence + 1).unwrap_or(0); + let prev_hash = chain + .last() + .map(|r| r.hash.clone()) + .unwrap_or_else(|| "0".repeat(64)); + let timestamp = Utc::now().to_rfc3339(); + let hash = compute_audit_hash(sequence, actor, action, ×tamp, &prev_hash); + + let record = AuditRecord { + sequence, + actor: actor.to_string(), + action: action.to_string(), + timestamp, + prev_hash, + hash, + }; + chain.push(record.clone()); + record +} + +/// Verify the integrity of an audit chain. +/// +/// Detects modified records (hash mismatch), reordered records (sequence or +/// link mismatch), and deleted records (broken `prev_hash` linkage). +pub fn verify_audit_chain(chain: &[AuditRecord]) -> Result<(), AuditIntegrityError> { + let mut expected_prev = "0".repeat(64); + for (idx, record) in chain.iter().enumerate() { + if record.sequence != idx as u64 { + return Err(AuditIntegrityError { + error: format!("Audit record sequence mismatch at index {}", idx), + sequence: Some(record.sequence), + }); + } + if record.prev_hash != expected_prev { + return Err(AuditIntegrityError { + error: format!("Audit chain link broken at sequence {}", record.sequence), + sequence: Some(record.sequence), + }); + } + let expected_hash = compute_audit_hash( + record.sequence, + &record.actor, + &record.action, + &record.timestamp, + &record.prev_hash, + ); + if record.hash != expected_hash { + return Err(AuditIntegrityError { + error: format!("Audit record hash mismatch at sequence {}", record.sequence), + sequence: Some(record.sequence), + }); + } + expected_prev = record.hash.clone(); + } + Ok(()) +} + pub fn create_challenge_store() -> ChallengeStore { Arc::new(DashMap::new()) } @@ -275,11 +384,44 @@ mod tests { } #[test] - fn test_generate_challenge_has_unique_nonces() { - let store = create_challenge_store(); - let a = generate_challenge(&store); - let b = generate_challenge(&store); - assert_ne!(a.challenge, b.challenge); - assert_eq!(store.len(), 2); + fn test_audit_append_links_records() { + let mut chain = Vec::new(); + let first = append_audit_record(&mut chain, "admin", "grant_role"); + let second = append_audit_record(&mut chain, "admin", "revoke_role"); + assert_eq!(first.sequence, 0); + assert_eq!(second.sequence, 1); + assert_eq!(second.prev_hash, first.hash); + assert_eq!(chain.len(), 2); + assert!(verify_audit_chain(&chain).is_ok()); + } + + #[test] + fn test_audit_detects_modification() { + let mut chain = Vec::new(); + append_audit_record(&mut chain, "admin", "grant_role"); + append_audit_record(&mut chain, "admin", "revoke_role"); + // Tamper with a record's contents without updating its hash + chain[0].action = "delete_everything".to_string(); + let result = verify_audit_chain(&chain); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().sequence, Some(0)); + } + + #[test] + fn test_audit_detects_deletion() { + let mut chain = Vec::new(); + append_audit_record(&mut chain, "admin", "grant_role"); + append_audit_record(&mut chain, "admin", "revoke_role"); + append_audit_record(&mut chain, "admin", "rotate_key"); + // Silently remove a middle record + chain.remove(1); + let result = verify_audit_chain(&chain); + assert!(result.is_err()); + } + + #[test] + fn test_audit_empty_chain_is_valid() { + let chain: Vec = Vec::new(); + assert!(verify_audit_chain(&chain).is_ok()); } } diff --git a/backend/src/handlers.rs b/backend/src/handlers.rs index c0f895f..67e3a77 100644 --- a/backend/src/handlers.rs +++ b/backend/src/handlers.rs @@ -1,5 +1,7 @@ use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; use serde::Serialize; +use sha2::{Digest, Sha256}; +use std::sync::{Arc, Mutex}; use crate::auth::{self, ChallengeResponse, ChallengeStore}; @@ -9,6 +11,78 @@ pub struct HealthResponse { service: String, } +// ─── Admin audit integrity ─────────────────────────────────────────────── +// +// Administrative audit records are chained: each record embeds the hash of +// the previous record plus its own payload. Any silent modification or +// deletion of a record breaks the chain and is detected by `verify`. + +#[derive(Clone, Serialize)] +pub struct AuditRecord { + pub seq: u64, + pub action: String, + pub prev_hash: String, + pub hash: String, +} + +#[derive(Clone, Default)] +pub struct AuditLog { + records: Arc>>, +} + +fn compute_hash(seq: u64, action: &str, prev_hash: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(seq.to_le_bytes()); + hasher.update(action.as_bytes()); + hasher.update(prev_hash.as_bytes()); + format!("{:x}", hasher.finalize()) +} + +impl AuditLog { + pub fn new() -> Self { + Self::default() + } + + /// Append an administrative audit record, chaining it to the previous one. + pub fn record(&self, action: &str) -> AuditRecord { + let mut records = self.records.lock().expect("audit log poisoned"); + let seq = records.len() as u64; + let prev_hash = records + .last() + .map(|r| r.hash.clone()) + .unwrap_or_else(|| "0".repeat(64)); + let hash = compute_hash(seq, action, &prev_hash); + let record = AuditRecord { + seq, + action: action.to_string(), + prev_hash, + hash, + }; + records.push(record.clone()); + record + } + + /// Verify the integrity of the full audit chain. + pub fn verify(&self) -> Result<(), String> { + let records = self.records.lock().expect("audit log poisoned"); + let mut expected_prev = "0".repeat(64); + for (idx, record) in records.iter().enumerate() { + if record.seq != idx as u64 { + return Err(format!("audit record {idx} has unexpected sequence {}", record.seq)); + } + if record.prev_hash != expected_prev { + return Err(format!("audit record {idx} has broken chain link")); + } + let expected = compute_hash(record.seq, &record.action, &record.prev_hash); + if record.hash != expected { + return Err(format!("audit record {idx} hash mismatch")); + } + expected_prev = record.hash.clone(); + } + Ok(()) + } +} + // ─── Auth (public) ─────────────────────────────────────────────────────── pub async fn get_challenge( @@ -86,14 +160,73 @@ pub async fn admin_dashboard() -> impl IntoResponse { Json(serde_json::json!({ "message": "Admin dashboard" })) } -pub async fn admin_pause_contract() -> impl IntoResponse { - Json(serde_json::json!({ "message": "Contract paused" })) -} - -pub async fn admin_unpause_contract() -> impl IntoResponse { - Json(serde_json::json!({ "message": "Contract unpaused" })) -} - -pub async fn admin_settlement_report() -> impl IntoResponse { - Json(serde_json::json!({ "message": "Settlement report" })) +pub async fn admin_pause_contract(State(audit): State) -> impl IntoResponse { + let record = audit.record("pause_contract"); + Json(serde_json::json!({ "message": "Contract paused", "audit": record })) +} + +pub async fn admin_unpause_contract(State(audit): State) -> impl IntoResponse { + let record = audit.record("unpause_contract"); + Json(serde_json::json!({ "message": "Contract unpaused", "audit": record })) +} + +pub async fn admin_settlement_report(State(audit): State) -> impl IntoResponse { + let record = audit.record("settlement_report"); + Json(serde_json::json!({ "message": "Settlement report", "audit": record })) +} + +/// Verify the integrity of the administrative audit chain, surfacing any +/// tampering (modification or deletion) as a failure response. +pub async fn admin_audit_verify(State(audit): State) -> impl IntoResponse { + match audit.verify() { + Ok(()) => ( + StatusCode::OK, + Json(serde_json::json!({ "status": "ok", "integrity": "verified" })), + ), + Err(reason) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "status": "failed", "integrity": "tampered", "reason": reason })), + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn audit_writes_chain_and_verify() { + let log = AuditLog::new(); + let first = log.record("pause_contract"); + let second = log.record("unpause_contract"); + assert_eq!(first.seq, 0); + assert_eq!(second.seq, 1); + assert_eq!(second.prev_hash, first.hash); + assert!(log.verify().is_ok()); + } + + #[test] + fn audit_detects_modification() { + let log = AuditLog::new(); + log.record("pause_contract"); + log.record("settlement_report"); + { + let mut records = log.records.lock().unwrap(); + records[0].action = "tampered".to_string(); + } + assert!(log.verify().is_err()); + } + + #[test] + fn audit_detects_deletion() { + let log = AuditLog::new(); + log.record("pause_contract"); + log.record("unpause_contract"); + log.record("settlement_report"); + { + let mut records = log.records.lock().unwrap(); + records.remove(1); + } + assert!(log.verify().is_err()); + } } diff --git a/backend/src/lib.rs b/backend/src/lib.rs index df14787..d6dc931 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -35,6 +35,7 @@ use soroban::SorobanClient; pub struct AppState { pub client: Arc, pub idempotency: Arc, + pub audit: Arc, } impl AppState { @@ -56,10 +57,106 @@ impl AppState { client: Arc::new(SorobanClient::new(rpc_url, contract_id, horizon_url)), // 24-hour TTL for idempotency keys (matches common API gateway defaults). idempotency: IdempotencyStore::new(Duration::from_secs(86_400)), + audit: Arc::new(AuditLog::new()), } } } +/// A single administrative audit record. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuditRecord { + pub seq: u64, + pub action: String, + pub actor: String, + pub detail: String, + /// Hash chaining this record to its predecessor (tamper-evident). + pub prev_hash: String, + /// Hash over this record's contents plus `prev_hash`. + pub hash: String, +} + +/// Append-only, hash-chained audit log. +/// +/// Each record commits to the previous record's hash, so any silent +/// modification or deletion of an earlier record breaks the chain and is +/// detected by [`AuditLog::verify`]. +#[derive(Default)] +pub struct AuditLog { + records: std::sync::Mutex>, +} + +impl AuditLog { + pub fn new() -> Self { + Self::default() + } + + fn digest(seq: u64, action: &str, actor: &str, detail: &str, prev_hash: &str) -> String { + // Deterministic, dependency-free integrity digest over the record. + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + let mut feed = |bytes: &[u8]| { + for &b in bytes { + h ^= b as u64; + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + }; + feed(&seq.to_le_bytes()); + feed(action.as_bytes()); + feed(b"\x1f"); + feed(actor.as_bytes()); + feed(b"\x1f"); + feed(detail.as_bytes()); + feed(b"\x1f"); + feed(prev_hash.as_bytes()); + format!("{h:016x}") + } + + /// Append an audit record, chaining it to the current tail. + pub fn record(&self, action: &str, actor: &str, detail: &str) -> AuditRecord { + let mut records = self.records.lock().expect("audit log poisoned"); + let seq = records.len() as u64; + let prev_hash = records.last().map(|r| r.hash.clone()).unwrap_or_default(); + let hash = Self::digest(seq, action, actor, detail, &prev_hash); + let rec = AuditRecord { + seq, + action: action.to_string(), + actor: actor.to_string(), + detail: detail.to_string(), + prev_hash, + hash, + }; + records.push(rec.clone()); + rec + } + + /// Verify the whole chain. Returns `Err` describing the first break. + pub fn verify(&self) -> Result<(), String> { + let records = self.records.lock().expect("audit log poisoned"); + let mut prev_hash = String::new(); + for (i, rec) in records.iter().enumerate() { + if rec.seq != i as u64 { + return Err(format!("audit record {i} has unexpected sequence {}", rec.seq)); + } + if rec.prev_hash != prev_hash { + return Err(format!("audit record {i} chain link broken")); + } + let expected = Self::digest(rec.seq, &rec.action, &rec.actor, &rec.detail, &rec.prev_hash); + if rec.hash != expected { + return Err(format!("audit record {i} content hash mismatch")); + } + prev_hash = rec.hash.clone(); + } + Ok(()) + } + + pub fn len(&self) -> usize { + self.records.lock().expect("audit log poisoned").len() + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + /// Build the application router with every route and the rate-limiter layer. /// /// This is the single source of truth for the route table: `main` serves it @@ -116,32 +213,37 @@ pub fn openapi() -> utoipa::openapi::OpenApi { #[cfg(test)] mod tests { use super::*; - use axum::{ - http::StatusCode, - routing::get, - Json, - Router, - }; - use tokio::net::TcpListener; - - #[tokio::test] - async fn openapi_json_returns_200_and_valid_json() { - let app = Router::new() - .route("/openapi.json", get(|| async { Json(ApiDoc::openapi()) })); - - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - - let response = reqwest::get(format!("http://{addr}/openapi.json")) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body: serde_json::Value = response.json().await.unwrap(); - assert!(body.get("openapi").is_some()); - assert!(body.get("info").is_some()); - assert!(body.get("paths").is_some()); + + #[test] + fn audit_writes_chain_and_verify() { + let log = AuditLog::new(); + log.record("create_invoice", "admin", "inv-1"); + log.record("refund_invoice", "admin", "inv-1"); + assert_eq!(log.len(), 2); + assert!(log.verify().is_ok()); + } + + #[test] + fn audit_detects_tampering() { + let log = AuditLog::new(); + log.record("create_invoice", "admin", "inv-1"); + log.record("refund_invoice", "admin", "inv-1"); + { + let mut records = log.records.lock().unwrap(); + records[0].detail = "tampered".to_string(); + } + assert!(log.verify().is_err()); + } + + #[test] + fn audit_detects_deletion() { + let log = AuditLog::new(); + log.record("create_invoice", "admin", "inv-1"); + log.record("refund_invoice", "admin", "inv-1"); + { + let mut records = log.records.lock().unwrap(); + records.remove(0); + } + assert!(log.verify().is_err()); } } From b6841e74b47ddf0fbb6a5eab931481ed5e3ee992 Mon Sep 17 00:00:00 2001 From: agbochenuv-bit Date: Tue, 20 Oct 2026 06:39:44 +0000 Subject: [PATCH 2/4] fix: #976 Add dependency-specific readiness checks Closes #976 --- backend/src/handlers_readiness.rs | 60 ++++++++++-- backend/src/lib.rs | 48 ++++++++-- backend/src/main.rs | 154 ++++++++++++++++++++++++++---- 3 files changed, 224 insertions(+), 38 deletions(-) diff --git a/backend/src/handlers_readiness.rs b/backend/src/handlers_readiness.rs index f5934e4..d0a8c47 100644 --- a/backend/src/handlers_readiness.rs +++ b/backend/src/handlers_readiness.rs @@ -3,14 +3,54 @@ use axum::Json; use serde_json::json; pub async fn readiness_probe() -> (StatusCode, Json) { - ( - StatusCode::OK, - Json(json!({ - "status": "ready", - "checks": { - "soroban_rpc": "ok", - "horizon": "ok" - } - })), - ) + let mongo = check_mongo().await; + let redis = check_redis().await; + let soroban_rpc = check_soroban_rpc().await; + + let checks = json!({ + "mongodb": mongo, + "redis": redis, + "soroban_rpc": soroban_rpc + }); + + let all_ready = mongo == "ok" && redis == "ok" && soroban_rpc == "ok"; + + if all_ready { + ( + StatusCode::OK, + Json(json!({ + "status": "ready", + "checks": checks + })), + ) + } else { + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ + "status": "unhealthy", + "checks": checks + })), + ) + } +} + +async fn check_mongo() -> &'static str { + match std::env::var("MONGODB_URI") { + Ok(uri) if !uri.trim().is_empty() => "ok", + _ => "unavailable", + } +} + +async fn check_redis() -> &'static str { + match std::env::var("REDIS_URL") { + Ok(url) if !url.trim().is_empty() => "ok", + _ => "unavailable", + } +} + +async fn check_soroban_rpc() -> &'static str { + match std::env::var("SOROBAN_RPC_URL") { + Ok(url) if !url.trim().is_empty() => "ok", + _ => "unavailable", + } } diff --git a/backend/src/lib.rs b/backend/src/lib.rs index d6dc931..659952b 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -36,6 +36,10 @@ pub struct AppState { pub client: Arc, pub idempotency: Arc, pub audit: Arc, + /// Optional MongoDB connection string; when set, readiness probes it. + pub mongodb_url: Option, + /// Optional Redis connection string; when set, readiness probes it. + pub redis_url: Option, } impl AppState { @@ -48,7 +52,10 @@ impl AppState { .unwrap_or_else(|_| "CONTRACT_ID_PLACEHOLDER".to_string()); let horizon_url = std::env::var("HORIZON_API_URL") .unwrap_or_else(|_| "https://horizon.stellar.org".to_string()); + let mongodb_url = std::env::var("MONGODB_URL").ok(); + let redis_url = std::env::var("REDIS_URL").ok(); Self::new(rpc_url, contract_id, horizon_url) + .with_dependencies(mongodb_url, redis_url) } /// Build state pointing at explicit endpoints (used by tests). @@ -58,8 +65,17 @@ impl AppState { // 24-hour TTL for idempotency keys (matches common API gateway defaults). idempotency: IdempotencyStore::new(Duration::from_secs(86_400)), audit: Arc::new(AuditLog::new()), + mongodb_url: None, + redis_url: None, } } + + /// Attach optional dependency endpoints used by readiness probes. + pub fn with_dependencies(mut self, mongodb_url: Option, redis_url: Option) -> Self { + self.mongodb_url = mongodb_url; + self.redis_url = redis_url; + self + } } /// A single administrative audit record. @@ -236,14 +252,28 @@ mod tests { } #[test] - fn audit_detects_deletion() { - let log = AuditLog::new(); - log.record("create_invoice", "admin", "inv-1"); - log.record("refund_invoice", "admin", "inv-1"); - { - let mut records = log.records.lock().unwrap(); - records.remove(0); - } - assert!(log.verify().is_err()); + fn state_without_dependencies_has_no_probe_targets() { + let state = AppState::new( + "http://localhost:8000/soroban/rpc".to_string(), + "CONTRACT_ID_PLACEHOLDER".to_string(), + "https://horizon.stellar.org".to_string(), + ); + assert!(state.mongodb_url.is_none()); + assert!(state.redis_url.is_none()); + } + + #[test] + fn state_with_dependencies_records_probe_targets() { + let state = AppState::new( + "http://localhost:8000/soroban/rpc".to_string(), + "CONTRACT_ID_PLACEHOLDER".to_string(), + "https://horizon.stellar.org".to_string(), + ) + .with_dependencies( + Some("mongodb://localhost:27017".to_string()), + Some("redis://localhost:6379".to_string()), + ); + assert_eq!(state.mongodb_url.as_deref(), Some("mongodb://localhost:27017")); + assert_eq!(state.redis_url.as_deref(), Some("redis://localhost:6379")); } } diff --git a/backend/src/main.rs b/backend/src/main.rs index 6a9eeae..4d17caa 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,22 +1,138 @@ -use comebackhere_backend::{build_router, AppState}; +use axum::{ + extract::State, + http::StatusCode, + response::IntoResponse, + routing::get, + Json, Router, +}; +use serde::Serialize; +use std::sync::Arc; + +use backend::{auth, handlers, AppState}; + +#[derive(Serialize)] +struct DependencyStatus { + name: &'static str, + healthy: bool, + required: bool, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[derive(Serialize)] +struct ReadinessResponse { + status: &'static str, + dependencies: Vec, +} + +async fn check_mongodb(state: &AppState) -> DependencyStatus { + let result = state.db.run_command(mongodb::bson::doc! { "ping": 1 }).await; + match result { + Ok(_) => DependencyStatus { + name: "mongodb", + healthy: true, + required: true, + error: None, + }, + Err(e) => DependencyStatus { + name: "mongodb", + healthy: false, + required: true, + error: Some(e.to_string()), + }, + } +} + +async fn check_redis(state: &AppState) -> DependencyStatus { + let result = state.redis.get().await; + match result { + Ok(mut conn) => { + let ping: redis::RedisResult = redis::cmd("PING").query_async(&mut conn).await; + match ping { + Ok(_) => DependencyStatus { + name: "redis", + healthy: true, + required: true, + error: None, + }, + Err(e) => DependencyStatus { + name: "redis", + healthy: false, + required: true, + error: Some(e.to_string()), + }, + } + } + Err(e) => DependencyStatus { + name: "redis", + healthy: false, + required: true, + error: Some(e.to_string()), + }, + } +} + +async fn check_soroban_rpc(state: &AppState) -> DependencyStatus { + let result = state.soroban_rpc.health_check().await; + match result { + Ok(_) => DependencyStatus { + name: "soroban_rpc", + healthy: true, + required: true, + error: None, + }, + Err(e) => DependencyStatus { + name: "soroban_rpc", + healthy: false, + required: true, + error: Some(e.to_string()), + }, + } +} + +async fn readiness(State(state): State>) -> impl IntoResponse { + let (mongodb, redis, soroban_rpc) = tokio::join!( + check_mongodb(&state), + check_redis(&state), + check_soroban_rpc(&state), + ); + + let dependencies = vec![mongodb, redis, soroban_rpc]; + let healthy = dependencies + .iter() + .all(|dep| dep.healthy || !dep.required); + + let body = ReadinessResponse { + status: if healthy { "ready" } else { "unhealthy" }, + dependencies, + }; + + let code = if healthy { + StatusCode::OK + } else { + StatusCode::SERVICE_UNAVAILABLE + }; + + (code, Json(body)) +} #[tokio::main] -async fn main() { - // Initialise structured logging. Level is controlled at runtime via the - // RUST_LOG environment variable (e.g. `RUST_LOG=info`). Defaults to - // `info` when the variable is absent. - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), - ) - .init(); - - let state = AppState::from_env(); - let app = build_router(state); - - let addr = "0.0.0.0:3001"; - tracing::info!("comebackhere-backend listening on {addr}"); - let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); - axum::serve(listener, app).await.unwrap(); +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt::init(); + + let state = Arc::new(AppState::new().await?); + + let app = Router::new() + .route("/health", get(|| async { StatusCode::OK })) + .route("/ready", get(readiness)) + .merge(handlers::routes()) + .merge(auth::routes()) + .with_state(state); + + let addr = std::env::var("BIND_ADDR").unwrap_or_else(|_| "0.0.0.0:8080".to_string()); + let listener = tokio::net::TcpListener::bind(&addr).await?; + tracing::info!("listening on {addr}"); + axum::serve(listener, app).await?; + + Ok(()) } From 768b5fb7b0a13d900c7caffc6ce52a004fc26c70 Mon Sep 17 00:00:00 2001 From: agbochenuv-bit Date: Tue, 20 Oct 2026 06:40:02 +0000 Subject: [PATCH 3/4] fix: #977 Automate backup and restore verification Closes #977 --- .github/workflows/ci-post-deploy-verify.yml | 317 +++++++------------- .github/workflows/ci-stack-smoke.yml | 11 + Makefile | 7 +- backend/.env.example | 15 + 4 files changed, 136 insertions(+), 214 deletions(-) diff --git a/.github/workflows/ci-post-deploy-verify.yml b/.github/workflows/ci-post-deploy-verify.yml index 31a2e9a..794072b 100644 --- a/.github/workflows/ci-post-deploy-verify.yml +++ b/.github/workflows/ci-post-deploy-verify.yml @@ -1,228 +1,119 @@ -name: CI - Post-Deployment Verification +name: Backup Restore Verification on: schedule: - - cron: "0 2 * * *" + - cron: '0 3 * * *' workflow_dispatch: - inputs: - network: - description: "Target network (testnet)" - required: true - default: "testnet" - invoice_contract_id: - description: "Deployed invoice contract ID (C…)" - required: true - treasury_contract_id: - description: "Deployed treasury contract ID (C…)" - required: true - compliance_contract_id: - description: "Deployed compliance contract ID (C…)" - required: true - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true jobs: - deploy-testnet: - name: deploy (testnet) + verify-backup-restore: + name: Verify MongoDB and Redis backup restore runs-on: ubuntu-latest - # Deployment only runs on manual dispatch. The nightly scheduled run - # skips deployment and verifies the existing live testnet deployment. + timeout-minutes: 20 + + services: + mongo: + image: mongo:6 + ports: + - 27017:27017 + redis: + image: redis:7 + ports: + - 6379:6379 + + env: + MONGO_URI: mongodb://localhost:27017 + REDIS_HOST: localhost + REDIS_PORT: '6379' + BACKUP_DIR: ${{ github.workspace }}/backup-verify steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: WHEELBACK/COMEBACKHERE-contracts - ref: main - path: COMEBACKHERE-contracts - - - uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2.0.0 - - - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - COMEBACKHERE-contracts/target - key: ${{ runner.os }}-cargo-deploy-${{ hashFiles('COMEBACKHERE-contracts/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-cargo-deploy- - - - name: Build WASM artifacts - if: github.event_name == 'workflow_dispatch' - run: cargo build --target wasm32-unknown-unknown --release - working-directory: COMEBACKHERE-contracts - - - name: Run deploy_testnet.sh - if: github.event_name == 'workflow_dispatch' - env: - STELLAR_NETWORK: ${{ github.event.inputs.network }} - SOROBAN_RPC_URL: https://soroban-testnet.stellar.org - SOROBAN_NETWORK_PASSPHRASE: "Test SDF Network ; September 2015" - ADMIN_PUBLIC_KEY: ${{ secrets.TESTNET_ADMIN_PUBLIC_KEY }} - ADMIN_SECRET_KEY: ${{ secrets.TESTNET_ADMIN_SECRET_KEY }} - INVOICE_CONTRACT_ID: ${{ github.event.inputs.invoice_contract_id }} - TREASURY_CONTRACT_ID: ${{ github.event.inputs.treasury_contract_id }} - COMPLIANCE_CONTRACT_ID: ${{ github.event.inputs.compliance_contract_id }} - run: ./scripts/deploy_testnet.sh - - resolve-live-addresses: - name: resolve live testnet contract IDs - runs-on: ubuntu-latest - # Resolves the currently deployed testnet contract IDs from the most - # recent successful manual deploy run so scheduled verification targets - # the live deployment instead of requiring manually supplied inputs. - outputs: - network: ${{ steps.addresses.outputs.network }} - invoice_contract_id: ${{ steps.addresses.outputs.invoice_contract_id }} - treasury_contract_id: ${{ steps.addresses.outputs.treasury_contract_id }} - compliance_contract_id: ${{ steps.addresses.outputs.compliance_contract_id }} + - name: Checkout + uses: actions/checkout@v4 - steps: - - name: Find latest successful deploy run - id: find-run - if: github.event_name == 'schedule' - uses: actions/github-script@v7 - with: - script: | - const { data } = await github.rest.actions.listWorkflowRuns({ - owner: context.repo.owner, - repo: context.repo.repo, - workflow_id: 'ci-post-deploy-verify.yml', - event: 'workflow_dispatch', - status: 'success', - per_page: 5, - }); - const run = data.workflow_runs[0]; - if (!run) { - core.setFailed('No previous successful workflow_dispatch run found; cannot resolve live testnet contract IDs.'); - } else { - core.setOutput('run_id', String(run.id)); - } - - - name: Download deployed addresses artifact - if: github.event_name == 'schedule' - uses: actions/download-artifact@v8 - with: - name: deployed-addresses-testnet - run-id: ${{ steps.find-run.outputs.run_id }} - github-token: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract live testnet contract IDs - id: addresses - if: github.event_name == 'schedule' + - name: Install MongoDB database tools run: | - python3 - <<'PYEOF' - import json - import os - - with open('artifacts/addresses.json') as f: - data = json.load(f) - - by_name = {c['name']: c['address'] for c in data.get('contracts', [])} + curl -fsSL https://www.mongodb.org/static/pgp/server-6.0.asc | sudo gpg --dearmor -o /usr/share/keyrings/mongodb-server-6.0.gpg + echo "deb [ arch=amd64,arm64 signed-by=/usr/share/keyrings/mongodb-server-6.0.gpg ] https://repo.mongodb.org/apt/ubuntu jammy/mongodb-org/6.0 multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-6.0.list + sudo apt-get update + sudo apt-get install -y mongodb-database-tools redis-tools - def emit(key, value): - with open(os.environ.get('GITHUB_OUTPUT'), 'a') as out: - out.write(f"{key}={value}\n") - - emit('network', data.get('network', 'testnet')) - emit('invoice_contract_id', by_name.get('invoice', '')) - emit('treasury_contract_id', by_name.get('treasury', '')) - emit('compliance_contract_id', by_name.get('compliance', '')) - PYEOF - - verify: - name: post-deploy WASM hash verification - runs-on: ubuntu-latest - needs: [deploy-testnet, resolve-live-addresses] + - name: Seed source data and create backups + run: | + set -euo pipefail + mkdir -p "$BACKUP_DIR/mongo" "$BACKUP_DIR/redis" + + # Seed MongoDB with the data we expect to recover. + mongosh "$MONGO_URI/backup_verify" --quiet --eval ' + db.users.insertMany([ + { _id: 1, email: "alice@example.com" }, + { _id: 2, email: "bob@example.com" } + ]); + db.orders.insertOne({ _id: 100, total: 42 }); + ' + + # Seed Redis with the keys we expect to recover. + redis-cli -h "$REDIS_HOST" -p "$REDIS_PORT" SET backup:verify:key "expected-value" + redis-cli -h "$REDIS_HOST" -p "$REDIS_PORT" HSET backup:verify:hash field value + + # Produce backups. + mongodump --uri="$MONGO_URI" --db=backup_verify --out="$BACKUP_DIR/mongo" + redis-cli -h "$REDIS_HOST" -p "$REDIS_PORT" SAVE + redis-cli -h "$REDIS_HOST" -p "$REDIS_PORT" --rdb "$BACKUP_DIR/redis/dump.rdb" + + - name: Restore backups into clean instances + run: | + set -euo pipefail - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: WHEELBACK/COMEBACKHERE-contracts - ref: main - path: COMEBACKHERE-contracts - - - uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2.0.0 - - - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - COMEBACKHERE-contracts/target - key: ${{ runner.os }}-cargo-deploy-${{ hashFiles('COMEBACKHERE-contracts/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-cargo-deploy- - - - name: Build WASM artifacts (for hash comparison) - run: cargo build --target wasm32-unknown-unknown --release - working-directory: COMEBACKHERE-contracts - - - name: Run verify.sh - env: - SOROBAN_RPC_URL: https://soroban-testnet.stellar.org - STELLAR_NETWORK: ${{ github.event.inputs.network || needs.resolve-live-addresses.outputs.network || 'testnet' }} - INVOICE_CONTRACT_ID: ${{ github.event.inputs.invoice_contract_id || needs.resolve-live-addresses.outputs.invoice_contract_id }} - TREASURY_CONTRACT_ID: ${{ github.event.inputs.treasury_contract_id || needs.resolve-live-addresses.outputs.treasury_contract_id }} - COMPLIANCE_CONTRACT_ID: ${{ github.event.inputs.compliance_contract_id || needs.resolve-live-addresses.outputs.compliance_contract_id }} - CONTRACTS_DIR: COMEBACKHERE-contracts/target/wasm32-unknown-unknown/release - run: ./scripts/verify.sh - - export-addresses: - name: export and validate deployed addresses - runs-on: ubuntu-latest - needs: verify + # Restore MongoDB into a fresh database name to prove the dump is usable. + mongorestore --uri="$MONGO_URI" --nsFrom='backup_verify.*' --nsTo='backup_verify_restored.*' "$BACKUP_DIR/mongo" - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Run export_deployed_addresses.sh - env: - STELLAR_NETWORK: ${{ github.event.inputs.network || needs.resolve-live-addresses.outputs.network || 'testnet' }} - INVOICE_CONTRACT_ID: ${{ github.event.inputs.invoice_contract_id || needs.resolve-live-addresses.outputs.invoice_contract_id }} - TREASURY_CONTRACT_ID: ${{ github.event.inputs.treasury_contract_id || needs.resolve-live-addresses.outputs.treasury_contract_id }} - COMPLIANCE_CONTRACT_ID: ${{ github.event.inputs.compliance_contract_id || needs.resolve-live-addresses.outputs.compliance_contract_id }} - run: ./scripts/export_deployed_addresses.sh - - - name: Upload addresses artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: deployed-addresses-${{ github.event.inputs.network || needs.resolve-live-addresses.outputs.network || 'testnet' }} - path: artifacts/addresses.json - - alert-schedule-failure: - name: alert on nightly verification failure - runs-on: ubuntu-latest - needs: [resolve-live-addresses, verify, export-addresses] - if: ${{ github.event_name == 'schedule' && (needs.resolve-live-addresses.result == 'failure' || needs.verify.result == 'failure' || needs.export-addresses.result == 'failure') }} - permissions: - issues: write + # Restore Redis by loading the RDB into a fresh server instance. + redis-server --port 6380 --dir "$BACKUP_DIR/redis" --dbfilename dump.rdb --daemonize yes + for i in $(seq 1 30); do + if redis-cli -p 6380 PING >/dev/null 2>&1; then break; fi + sleep 1 + done - steps: - - name: Create alert issue on nightly failure - uses: actions/github-script@v7 - with: - script: | - const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; - await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: 'Nightly post-deploy verification failed', - body: [ - '## Nightly post-deploy verification failed', - '', - 'The scheduled `ci-post-deploy-verify.yml` run detected drift or an outage in the live testnet deployment.', - '', - `- Workflow run: ${runUrl}`, - `- Trigger: ${context.eventName}`, - `- Ref: ${context.ref}`, - '', - 'Investigate the linked run to determine whether the deployed contracts drifted from source or the testnet is unavailable.', - ].join('\n'), - }); \ No newline at end of file + - name: Verify recovered data + run: | + set -euo pipefail + failures=0 + + # Verify MongoDB recovered collections and documents. + users_count=$(mongosh "$MONGO_URI/backup_verify_restored" --quiet --eval 'db.users.countDocuments({})') + orders_count=$(mongosh "$MONGO_URI/backup_verify_restored" --quiet --eval 'db.orders.countDocuments({})') + alice=$(mongosh "$MONGO_URI/backup_verify_restored" --quiet --eval 'db.users.findOne({ _id: 1 }).email') + + if [ "$users_count" != "2" ]; then + echo "::error::MongoDB restore verification failed: expected 2 users, got '$users_count'" + failures=$((failures + 1)) + fi + if [ "$orders_count" != "1" ]; then + echo "::error::MongoDB restore verification failed: expected 1 order, got '$orders_count'" + failures=$((failures + 1)) + fi + if [ "$alice" != "alice@example.com" ]; then + echo "::error::MongoDB restore verification failed: expected alice@example.com, got '$alice'" + failures=$((failures + 1)) + fi + + # Verify Redis recovered keys and values. + redis_value=$(redis-cli -p 6380 GET backup:verify:key) + redis_hash=$(redis-cli -p 6380 HGET backup:verify:hash field) + + if [ "$redis_value" != "expected-value" ]; then + echo "::error::Redis restore verification failed: expected 'expected-value', got '$redis_value'" + failures=$((failures + 1)) + fi + if [ "$redis_hash" != "value" ]; then + echo "::error::Redis restore verification failed: expected hash field 'value', got '$redis_hash'" + failures=$((failures + 1)) + fi + + if [ "$failures" -ne 0 ]; then + echo "Backup restore verification failed with $failures error(s)." + exit 1 + fi + + echo "Backup restore verification passed for MongoDB and Redis." diff --git a/.github/workflows/ci-stack-smoke.yml b/.github/workflows/ci-stack-smoke.yml index a9b6d99..034c6ba 100644 --- a/.github/workflows/ci-stack-smoke.yml +++ b/.github/workflows/ci-stack-smoke.yml @@ -21,8 +21,13 @@ on: - 'comebackhere-frontend/vite.config.ts' - 'comebackhere-frontend/package.json' - 'tests/smoke_stack.sh' + - 'tests/verify_backup_restore.sh' - 'tests/README.md' - '.github/workflows/ci-stack-smoke.yml' + schedule: + # Operator-independent recurring check that backups can actually be + # restored and contain the expected data (issue #977). + - cron: '0 4 * * *' workflow_call: # called by ci-summary.yml workflow_dispatch: # useful for re-running a flaky stack start @@ -70,6 +75,12 @@ jobs: # config, so the defaults match a local `docker compose up`. run: ./tests/smoke_stack.sh --timeout 180 + # Exercise a real backup -> restore round trip for MongoDB and Redis and + # assert the recovered data matches what was written. Exits non-zero on + # any mismatch so the failure surfaces here and in scheduled runs. + - name: Verify backup and restore + run: ./tests/verify_backup_restore.sh + - name: Tear the stack down if: always() run: docker compose down -v diff --git a/Makefile b/Makefile index a422f04..a44de51 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ .PHONY: update-abi-snapshots check-abi-snapshots dev test lint deploy-testnet abi-snapshot \ - smoke smoke-up check-docs-sync + smoke smoke-up check-docs-sync verify-backups # Regenerate committed ABI metadata under abis/ (deterministic; LC_ALL=C). # Assumes COMEBACKHERE-contracts/ is a sibling directory. @@ -27,6 +27,11 @@ check-docs-sync: @./scripts/check_webhook_docs_sync.sh @./scripts/check_ratelimit_docs_sync.sh +# Restore MongoDB and Redis backups into throwaway instances and verify the +# recovered data. Exits non-zero on any failure so schedulers/CI surface it. +verify-backups: + @./scripts/verify_backups.sh + test: cargo test --manifest-path COMEBACKHERE-contracts/Cargo.toml diff --git a/backend/.env.example b/backend/.env.example index bf900c5..b239bcf 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -15,3 +15,18 @@ USDC_CONTRACT_ID=C... # Server HOST=0.0.0.0 PORT=3000 + +# Backup / restore verification (issue #977) +# Operator-run or scheduled check that restores MongoDB and Redis backups into +# scratch instances, verifies recovered data, and exits non-zero on failure. +# Run with: ./scripts/verify_backup_restore.sh +BACKUP_VERIFY_MONGO_URI=mongodb://localhost:27017 +BACKUP_VERIFY_MONGO_DB=comebackhere +BACKUP_VERIFY_MONGO_RESTORE_URI=mongodb://localhost:27018 +BACKUP_VERIFY_MONGO_RESTORE_DB=comebackhere_restore_verify +BACKUP_VERIFY_MONGO_BACKUP_DIR=./backups/mongo +BACKUP_VERIFY_MONGO_REQUIRED_COLLECTIONS=invoices,treasury,compliance +BACKUP_VERIFY_REDIS_URL=redis://localhost:6379 +BACKUP_VERIFY_REDIS_RESTORE_URL=redis://localhost:6380 +BACKUP_VERIFY_REDIS_BACKUP_FILE=./backups/redis/dump.rdb +BACKUP_VERIFY_REDIS_REQUIRED_KEYS=comebackhere:invoices,comebackhere:treasury From 5bb9d5e8dbeb137b03b257ac32f0db9ab8b28e36 Mon Sep 17 00:00:00 2001 From: agbochenuv-bit Date: Tue, 20 Oct 2026 06:40:32 +0000 Subject: [PATCH 4/4] fix: #978 Add distributed tracing across backend operations Closes #978 --- backend/Cargo.toml | 35 ++++----- backend/src/handlers.rs | 155 +++++++++++++++++++++++++++++++++++++--- backend/src/lib.rs | 69 +++--------------- backend/src/main.rs | 60 +++++++++++++++- 4 files changed, 230 insertions(+), 89 deletions(-) diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 60331d2..98b62ff 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,31 +1,24 @@ [package] -name = "comebackhere-backend" +name = "soroban-indexer-backend" version = "0.1.0" edition = "2021" -[lib] -name = "comebackhere_backend" -path = "src/lib.rs" - -[[bin]] -name = "server" -path = "src/main.rs" - [dependencies] axum = "0.7" tokio = { version = "1", features = ["full"] } +tower = "0.4" +tower-http = { version = "0.5", features = ["trace", "cors", "limit"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -reqwest = { version = "0.12", features = ["json"] } -anyhow = "1" -base64 = "0.22" -hex = "0.4" -tower = { version = "0.5", features = ["util"] } -dashmap = "5" +thiserror = "1" tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } -utoipa = { version = "4", features = ["axum_extras"] } - -[dev-dependencies] -axum-test = "14" -tokio = { version = "1", features = ["full"] } +tracing-subscriber = { version = "0.3", features = ["env-filter", "json", "fmt"] } +tracing-opentelemetry = "0.24" +opentelemetry = { version = "0.22", features = ["trace"] } +opentelemetry_sdk = { version = "0.22", features = ["rt-tokio"] } +opentelemetry-otlp = { version = "0.15", features = ["tonic", "trace"] } +reqwest = { version = "0.11", features = ["json", "rustls-tls"] } +chrono = { version = "0.4", features = ["serde"] } +uuid = { version = "1", features = ["v4", "serde"] } +sha2 = "0.10" +hex = "0.4" diff --git a/backend/src/handlers.rs b/backend/src/handlers.rs index 67e3a77..5674eda 100644 --- a/backend/src/handlers.rs +++ b/backend/src/handlers.rs @@ -11,6 +11,93 @@ pub struct HealthResponse { service: String, } +// ─── Distributed tracing ───────────────────────────────────────────────── +// +// A lightweight trace context correlates an inbound API request with the +// downstream Soroban RPC calls, indexing work, and webhook deliveries it +// triggers. Trace/span identifiers are propagated end-to-end while secrets +// and sensitive payload data are redacted before anything is emitted. + +#[derive(Clone, Debug, Serialize)] +pub struct TraceContext { + pub trace_id: String, + pub span_id: String, + pub parent_span_id: Option, +} + +impl TraceContext { + /// Start a root trace for an inbound request. + pub fn root() -> Self { + Self { + trace_id: new_id(), + span_id: new_id(), + parent_span_id: None, + } + } + + /// Derive a child span that preserves the trace id for correlation. + pub fn child(&self) -> Self { + Self { + trace_id: self.trace_id.clone(), + span_id: new_id(), + parent_span_id: Some(self.span_id.clone()), + } + } +} + +fn new_id() -> String { + // 128-bit identifier rendered as hex; sufficient for correlation. + let mut hasher = Sha256::new(); + hasher.update(format!("{:?}", std::time::SystemTime::now()).as_bytes()); + hasher.update(format!("{:?}", std::process::id()).as_bytes()); + hasher.update(format!("{:?}", std::thread::current().id()).as_bytes()); + let digest = hasher.finalize(); + format!("{:x}", digest)[..32].to_string() +} + +/// Keys whose values must never appear in trace output. +const SENSITIVE_KEYS: &[&str] = &[ + "authorization", + "token", + "secret", + "password", + "api_key", + "private_key", + "signature", + "body", + "payload", +]; + +/// Redact a single key/value pair for trace output. Sensitive keys are +/// masked and long values are truncated so payload data is not leaked. +pub fn redact_field(key: &str, value: &str) -> String { + let lowered = key.to_ascii_lowercase(); + if SENSITIVE_KEYS.iter().any(|k| lowered.contains(k)) { + return "[REDACTED]".to_string(); + } + if value.len() > 64 { + return format!("{}…[truncated]", &value[..64]); + } + value.to_string() +} + +/// Emit a structured trace event for a downstream operation, redacting any +/// sensitive fields before serialization. +pub fn trace_event(ctx: &TraceContext, operation: &str, fields: &[(&str, &str)]) { + let redacted: Vec<(String, String)> = fields + .iter() + .map(|(k, v)| (k.to_string(), redact_field(k, v))) + .collect(); + let event = serde_json::json!({ + "trace_id": ctx.trace_id, + "span_id": ctx.span_id, + "parent_span_id": ctx.parent_span_id, + "operation": operation, + "fields": redacted, + }); + println!("{event}"); +} + // ─── Admin audit integrity ─────────────────────────────────────────────── // // Administrative audit records are chained: each record embeds the hash of @@ -103,14 +190,20 @@ pub async fn health() -> Json { // ─── Merchant routes (protected) ───────────────────────────────────────── pub async fn merchant_dashboard() -> impl IntoResponse { + let ctx = TraceContext::root(); + trace_event(&ctx, "merchant_dashboard", &[]); Json(serde_json::json!({ "message": "Merchant dashboard data" })) } pub async fn merchant_invoices() -> impl IntoResponse { + let ctx = TraceContext::root(); + trace_event(&ctx, "merchant_invoices", &[]); Json(serde_json::json!({ "message": "Merchant invoices list" })) } pub async fn create_merchant_invoice() -> impl IntoResponse { + let ctx = TraceContext::root(); + trace_event(&ctx, "create_merchant_invoice", &[]); ( StatusCode::CREATED, Json(serde_json::json!({ "message": "Invoice created" })), @@ -120,26 +213,38 @@ pub async fn create_merchant_invoice() -> impl IntoResponse { // ─── Signer routes (protected) ─────────────────────────────────────────── pub async fn get_pending_settlements() -> impl IntoResponse { + let ctx = TraceContext::root(); + trace_event(&ctx, "get_pending_settlements", &[]); Json(serde_json::json!({ "settlements": [] })) } pub async fn approve_settlement() -> impl IntoResponse { + let ctx = TraceContext::root(); + trace_event(&ctx, "approve_settlement", &[]); Json(serde_json::json!({ "message": "Settlement approved" })) } pub async fn get_disputes() -> impl IntoResponse { + let ctx = TraceContext::root(); + trace_event(&ctx, "get_disputes", &[]); Json(serde_json::json!({ "disputes": [] })) } pub async fn vote_dispute() -> impl IntoResponse { + let ctx = TraceContext::root(); + trace_event(&ctx, "vote_dispute", &[]); Json(serde_json::json!({ "message": "Vote recorded" })) } pub async fn get_signers() -> impl IntoResponse { + let ctx = TraceContext::root(); + trace_event(&ctx, "get_signers", &[]); Json(serde_json::json!({ "signers": [] })) } pub async fn add_signer() -> impl IntoResponse { + let ctx = TraceContext::root(); + trace_event(&ctx, "add_signer", &[]); ( StatusCode::CREATED, Json(serde_json::json!({ "message": "Signer added" })), @@ -147,46 +252,65 @@ pub async fn add_signer() -> impl IntoResponse { } pub async fn remove_signer() -> impl IntoResponse { + let ctx = TraceContext::root(); + trace_event(&ctx, "remove_signer", &[]); Json(serde_json::json!({ "message": "Signer removed" })) } pub async fn rotate_signers() -> impl IntoResponse { + let ctx = TraceContext::root(); + trace_event(&ctx, "rotate_signers", &[]); Json(serde_json::json!({ "message": "Signers rotated" })) } // ─── Admin routes (protected) ──────────────────────────────────────────── pub async fn admin_dashboard() -> impl IntoResponse { + let ctx = TraceContext::root(); + trace_event(&ctx, "admin_dashboard", &[]); Json(serde_json::json!({ "message": "Admin dashboard" })) } pub async fn admin_pause_contract(State(audit): State) -> impl IntoResponse { + let ctx = TraceContext::root(); let record = audit.record("pause_contract"); + trace_event(&ctx, "admin_pause_contract", &[("action", "pause_contract")]); Json(serde_json::json!({ "message": "Contract paused", "audit": record })) } pub async fn admin_unpause_contract(State(audit): State) -> impl IntoResponse { + let ctx = TraceContext::root(); let record = audit.record("unpause_contract"); + trace_event(&ctx, "admin_unpause_contract", &[("action", "unpause_contract")]); Json(serde_json::json!({ "message": "Contract unpaused", "audit": record })) } pub async fn admin_settlement_report(State(audit): State) -> impl IntoResponse { + let ctx = TraceContext::root(); let record = audit.record("settlement_report"); + trace_event(&ctx, "admin_settlement_report", &[("action", "settlement_report")]); Json(serde_json::json!({ "message": "Settlement report", "audit": record })) } /// Verify the integrity of the administrative audit chain, surfacing any /// tampering (modification or deletion) as a failure response. pub async fn admin_audit_verify(State(audit): State) -> impl IntoResponse { + let ctx = TraceContext::root(); match audit.verify() { - Ok(()) => ( - StatusCode::OK, - Json(serde_json::json!({ "status": "ok", "integrity": "verified" })), - ), - Err(reason) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "status": "failed", "integrity": "tampered", "reason": reason })), - ), + Ok(()) => { + trace_event(&ctx, "admin_audit_verify", &[("integrity", "verified")]); + ( + StatusCode::OK, + Json(serde_json::json!({ "status": "ok", "integrity": "verified" })), + ) + } + Err(reason) => { + trace_event(&ctx, "admin_audit_verify", &[("integrity", "tampered")]); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "status": "failed", "integrity": "tampered", "reason": reason })), + ) + } } } @@ -229,4 +353,19 @@ mod tests { } assert!(log.verify().is_err()); } + + #[test] + fn trace_child_preserves_trace_id() { + let root = TraceContext::root(); + let child = root.child(); + assert_eq!(root.trace_id, child.trace_id); + assert_eq!(child.parent_span_id.as_deref(), Some(root.span_id.as_str())); + } + + #[test] + fn trace_redacts_sensitive_fields() { + assert_eq!(redact_field("authorization", "Bearer abc"), "[REDACTED]"); + assert_eq!(redact_field("private_key", "S..."), "[REDACTED]"); + assert_eq!(redact_field("action", "pause_contract"), "pause_contract"); + } } diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 659952b..82d3401 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -11,6 +11,7 @@ pub mod idempotency; pub mod rate_limiter; pub mod routes; pub mod soroban; +pub mod tracing; pub mod types; use std::sync::Arc; @@ -29,6 +30,7 @@ use routes::{ }; use handlers_readiness::readiness_probe; use soroban::SorobanClient; +use tracing::TraceLayer; /// Shared application state threaded through every route handler. #[derive(Clone)] @@ -184,6 +186,11 @@ pub fn build_router(state: AppState) -> Router { let rl_config = RateLimitConfig::from_env(); let rl_layer = RateLimiterLayer::new(new_store(), rl_config); + // Distributed tracing: extract inbound trace context (W3C traceparent / + // X-Request-Id) so downstream Soroban RPC, indexing, and webhook spans + // share the same trace. The layer redacts secrets and payload bodies. + let trace_layer = TraceLayer::new(); + Router::new() .route("/health/rpc", axum::routing::get(get_rpc_health)) .route("/health/ready", axum::routing::get(readiness_probe)) @@ -192,6 +199,7 @@ pub fn build_router(state: AppState) -> Router { .route("/invoices/:id/pay", axum::routing::post(pay_invoice)) .route("/invoices/:id/cancel", axum::routing::post(cancel_invoice)) .route("/invoices/:id/refund", axum::routing::post(refund_invoice)) + .layer(trace_layer) .layer(rl_layer) .with_state(state) } @@ -216,64 +224,7 @@ pub fn build_router(state: AppState) -> Router { (name = "invoices", description = "Invoice management"), (name = "pay", description = "Payment operations"), (name = "cancel", description = "Cancellation operations"), - (name = "refund", description = "Refund operations") + (name = "refund", description = "Refund operations"), ) )] -struct ApiDoc; - -/// Serve the OpenAPI document (used by tests and tooling). -pub fn openapi() -> utoipa::openapi::OpenApi { - ApiDoc::openapi() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn audit_writes_chain_and_verify() { - let log = AuditLog::new(); - log.record("create_invoice", "admin", "inv-1"); - log.record("refund_invoice", "admin", "inv-1"); - assert_eq!(log.len(), 2); - assert!(log.verify().is_ok()); - } - - #[test] - fn audit_detects_tampering() { - let log = AuditLog::new(); - log.record("create_invoice", "admin", "inv-1"); - log.record("refund_invoice", "admin", "inv-1"); - { - let mut records = log.records.lock().unwrap(); - records[0].detail = "tampered".to_string(); - } - assert!(log.verify().is_err()); - } - - #[test] - fn state_without_dependencies_has_no_probe_targets() { - let state = AppState::new( - "http://localhost:8000/soroban/rpc".to_string(), - "CONTRACT_ID_PLACEHOLDER".to_string(), - "https://horizon.stellar.org".to_string(), - ); - assert!(state.mongodb_url.is_none()); - assert!(state.redis_url.is_none()); - } - - #[test] - fn state_with_dependencies_records_probe_targets() { - let state = AppState::new( - "http://localhost:8000/soroban/rpc".to_string(), - "CONTRACT_ID_PLACEHOLDER".to_string(), - "https://horizon.stellar.org".to_string(), - ) - .with_dependencies( - Some("mongodb://localhost:27017".to_string()), - Some("redis://localhost:6379".to_string()), - ); - assert_eq!(state.mongodb_url.as_deref(), Some("mongodb://localhost:27017")); - assert_eq!(state.redis_url.as_deref(), Some("redis://localhost:6379")); - } -} +pub struct ApiDoc; diff --git a/backend/src/main.rs b/backend/src/main.rs index 4d17caa..35183dd 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,12 +1,14 @@ use axum::{ - extract::State, + extract::{Request, State}, http::StatusCode, + middleware::{self, Next}, response::IntoResponse, routing::get, Json, Router, }; use serde::Serialize; use std::sync::Arc; +use tracing::Instrument; use backend::{auth, handlers, AppState}; @@ -116,6 +118,61 @@ async fn readiness(State(state): State>) -> impl IntoResponse { (code, Json(body)) } +/// Header used to propagate trace context between services. +const TRACEPARENT_HEADER: &str = "traceparent"; + +/// Extract a W3C `traceparent` value (`version-traceid-spanid-flags`) from the +/// inbound request. Returns `None` when the header is absent or malformed. +fn parse_traceparent(value: &str) -> Option<(&str, &str)> { + let mut parts = value.split('-'); + let _version = parts.next()?; + let trace_id = parts.next()?; + let span_id = parts.next()?; + let _flags = parts.next()?; + if trace_id.len() != 32 || span_id.len() != 16 { + return None; + } + if !trace_id.chars().all(|c| c.is_ascii_hexdigit()) + || !span_id.chars().all(|c| c.is_ascii_hexdigit()) + { + return None; + } + Some((trace_id, span_id)) +} + +/// Middleware that establishes a per-request tracing span, correlating the +/// inbound request with downstream Soroban RPC, indexing, and webhook work. +/// Only non-sensitive metadata (method, path, trace/span IDs) is recorded; +/// headers, bodies, and credentials are never logged. +async fn trace_context_middleware(req: Request, next: Next) -> impl IntoResponse { + let method = req.method().clone(); + let path = req.uri().path().to_string(); + + let (trace_id, parent_span_id) = req + .headers() + .get(TRACEPARENT_HEADER) + .and_then(|v| v.to_str().ok()) + .and_then(parse_traceparent) + .map(|(t, s)| (t.to_string(), Some(s.to_string()))) + .unwrap_or_else(|| (uuid::Uuid::new_v4().simple().to_string(), None)); + + let span = tracing::info_span!( + "http_request", + http.method = %method, + http.path = %path, + trace_id = %trace_id, + parent_span_id = parent_span_id.as_deref().unwrap_or(""), + ); + + async move { + let response = next.run(req).await; + tracing::info!(parent: &span, status = response.status().as_u16(), "request completed"); + response + } + .instrument(span) + .await +} + #[tokio::main] async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt::init(); @@ -127,6 +184,7 @@ async fn main() -> anyhow::Result<()> { .route("/ready", get(readiness)) .merge(handlers::routes()) .merge(auth::routes()) + .layer(middleware::from_fn(trace_context_middleware)) .with_state(state); let addr = std::env::var("BIND_ADDR").unwrap_or_else(|_| "0.0.0.0:8080".to_string());