//! HTTP handlers for quarantine management (Content Defense Phase 7C). //! //! # Security Warning //! //! These admin endpoints do NOT include authentication middleware. //! In production deployments, these endpoints MUST be protected by one of: //! //! 1. **Network-level protection**: Run admin endpoints on a separate port //! that is only accessible from trusted networks (e.g., internal VPN). //! //! 2. **Reverse proxy authentication**: Use nginx/envoy/etc. to require //! authentication before requests reach these endpoints. //! //! 3. **Custom middleware**: Implement an `admin_auth` middleware layer //! that validates admin API keys or JWT tokens. //! //! Failing to protect these endpoints allows anyone to approve spam content //! or reject legitimate content from the quarantine queue. use crate::{ dto::{ ErrorResponse, QuarantineApproveResponse, QuarantineEventDto, QuarantineGetResponse, QuarantineListParams, QuarantineListResponse, }, AppState, }; use axum::{ extract::{Path, Query, State}, http::StatusCode, Json, }; use stemedb_storage::{QuarantineStore, StorageError}; use tracing::instrument; /// GET /v1/admin/quarantine /// /// List pending quarantine events (or all events if include_reviewed=true). #[utoipa::path( get, path = "/v1/admin/quarantine", params( ("limit" = Option, Query, description = "Maximum number of events to return (default: 100)"), ("include_reviewed" = Option, Query, description = "Include reviewed events (default: false)") ), responses( (status = 200, description = "Quarantine events retrieved successfully", body = QuarantineListResponse), (status = 500, description = "Internal server error", body = ErrorResponse) ), tag = "admin" )] #[instrument(skip(state))] pub async fn list_quarantine( State(state): State, Query(params): Query, ) -> std::result::Result, (StatusCode, Json)> { let store = &state.quarantine_store; let events = if params.include_reviewed { store.list_all(params.limit).await.map_err(|e| { tracing::error!(error = %e, "Failed to list all quarantine events"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: "Failed to retrieve quarantine events".to_string(), code: "QUARANTINE_RETRIEVAL_ERROR".to_string(), }), ) })? } else { store.list_pending(params.limit).await.map_err(|e| { tracing::error!(error = %e, "Failed to list pending quarantine events"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: "Failed to retrieve pending quarantine events".to_string(), code: "QUARANTINE_RETRIEVAL_ERROR".to_string(), }), ) })? }; let pending_count = store.pending_count().await.map_err(|e| { tracing::error!(error = %e, "Failed to count pending quarantine events"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: "Failed to count pending quarantine events".to_string(), code: "QUARANTINE_COUNT_ERROR".to_string(), }), ) })?; let dtos: Vec = events.iter().map(QuarantineEventDto::from).collect(); Ok(Json(QuarantineListResponse { quarantined: dtos, count: events.len(), pending_count })) } /// GET /v1/admin/quarantine/{hash} /// /// Get a specific quarantine event by hash (includes assertion bytes). #[utoipa::path( get, path = "/v1/admin/quarantine/{hash}", params( ("hash" = String, Path, description = "Hex-encoded hash of the quarantined assertion") ), responses( (status = 200, description = "Quarantine event retrieved successfully", body = QuarantineGetResponse), (status = 404, description = "Quarantine event not found", body = ErrorResponse), (status = 400, description = "Invalid hash format", body = ErrorResponse), (status = 500, description = "Internal server error", body = ErrorResponse) ), tag = "admin" )] #[instrument(skip(state))] pub async fn get_quarantine( State(state): State, Path(hash_hex): Path, ) -> std::result::Result, (StatusCode, Json)> { let hash = parse_hash(&hash_hex)?; let store = &state.quarantine_store; let event = store.get_quarantine(&hash).await.map_err(|e| { tracing::error!(error = %e, hash = %hash_hex, "Failed to get quarantine event"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: "Failed to retrieve quarantine event".to_string(), code: "QUARANTINE_RETRIEVAL_ERROR".to_string(), }), ) })?; match event { Some(event) => { let dto = QuarantineEventDto::with_assertion_bytes(&event); Ok(Json(QuarantineGetResponse { event: dto })) } None => Err(( StatusCode::NOT_FOUND, Json(ErrorResponse { error: "Quarantine event not found".to_string(), code: "QUARANTINE_NOT_FOUND".to_string(), }), )), } } /// POST /v1/admin/quarantine/{hash}/approve /// /// Approve a quarantined assertion, returning the assertion bytes for indexing. #[utoipa::path( post, path = "/v1/admin/quarantine/{hash}/approve", params( ("hash" = String, Path, description = "Hex-encoded hash of the quarantined assertion") ), responses( (status = 200, description = "Quarantine event approved successfully", body = QuarantineApproveResponse), (status = 404, description = "Quarantine event not found", body = ErrorResponse), (status = 400, description = "Invalid hash format", body = ErrorResponse), (status = 500, description = "Internal server error", body = ErrorResponse) ), tag = "admin" )] #[instrument(skip(state))] pub async fn approve_quarantine( State(state): State, Path(hash_hex): Path, ) -> std::result::Result, (StatusCode, Json)> { let start = std::time::Instant::now(); metrics::counter!("stemedb_http_requests_total", "method" => "POST", "path" => "/v1/admin/quarantine/{hash}/approve").increment(1); let hash = parse_hash(&hash_hex)?; let store = &state.quarantine_store; let event = store.approve(&hash).await.map_err(|e| match e { StorageError::NotFound(_) => ( StatusCode::NOT_FOUND, Json(ErrorResponse { error: "Quarantine event not found".to_string(), code: "QUARANTINE_NOT_FOUND".to_string(), }), ), _ => { tracing::error!(error = %e, hash = %hash_hex, "Failed to approve quarantine event"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: "Failed to approve quarantine event".to_string(), code: "QUARANTINE_APPROVE_ERROR".to_string(), }), ) } })?; tracing::info!(hash = %hash_hex, "Quarantine event approved"); // Track request duration (success case) metrics::histogram!("stemedb_http_request_duration_seconds", "method" => "POST", "path" => "/v1/admin/quarantine/{hash}/approve", "status" => "200" ) .record(start.elapsed().as_secs_f64()); Ok(Json(QuarantineApproveResponse { hash: hash_hex, message: "Assertion approved and ready for indexing".to_string(), assertion_bytes_hex: hex::encode(&event.assertion_bytes), })) } /// POST /v1/admin/quarantine/{hash}/reject /// /// Reject a quarantined assertion (remains in quarantine for audit trail). #[utoipa::path( post, path = "/v1/admin/quarantine/{hash}/reject", params( ("hash" = String, Path, description = "Hex-encoded hash of the quarantined assertion") ), responses( (status = 200, description = "Quarantine event rejected successfully"), (status = 404, description = "Quarantine event not found", body = ErrorResponse), (status = 400, description = "Invalid hash format", body = ErrorResponse), (status = 500, description = "Internal server error", body = ErrorResponse) ), tag = "admin" )] #[instrument(skip(state))] pub async fn reject_quarantine( State(state): State, Path(hash_hex): Path, ) -> std::result::Result)> { let start = std::time::Instant::now(); metrics::counter!("stemedb_http_requests_total", "method" => "POST", "path" => "/v1/admin/quarantine/{hash}/reject").increment(1); let hash = parse_hash(&hash_hex)?; let store = &state.quarantine_store; store.reject(&hash).await.map_err(|e| match e { StorageError::NotFound(_) => ( StatusCode::NOT_FOUND, Json(ErrorResponse { error: "Quarantine event not found".to_string(), code: "QUARANTINE_NOT_FOUND".to_string(), }), ), _ => { tracing::error!(error = %e, hash = %hash_hex, "Failed to reject quarantine event"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: "Failed to reject quarantine event".to_string(), code: "QUARANTINE_REJECT_ERROR".to_string(), }), ) } })?; tracing::info!(hash = %hash_hex, "Quarantine event rejected"); // Track request duration (success case) metrics::histogram!("stemedb_http_request_duration_seconds", "method" => "POST", "path" => "/v1/admin/quarantine/{hash}/reject", "status" => "200" ) .record(start.elapsed().as_secs_f64()); Ok(StatusCode::OK) } /// Parse and validate a hex-encoded hash. fn parse_hash(hash_hex: &str) -> std::result::Result<[u8; 32], (StatusCode, Json)> { let hash_bytes = hex::decode(hash_hex).map_err(|_| { ( StatusCode::BAD_REQUEST, Json(ErrorResponse { error: "Invalid hash format (must be hex)".to_string(), code: "INVALID_HASH_FORMAT".to_string(), }), ) })?; if hash_bytes.len() != 32 { return Err(( StatusCode::BAD_REQUEST, Json(ErrorResponse { error: "Hash must be 32 bytes (64 hex characters)".to_string(), code: "INVALID_HASH_LENGTH".to_string(), }), )); } let mut hash = [0u8; 32]; hash.copy_from_slice(&hash_bytes); Ok(hash) }