Claims now flow through StemeDB's append-only knowledge graph instead of mutable TOML files. This resolves all 6 critical claim-bypass code paths: - Bridge: lossless AuthoredClaim ↔ Assertion round-trip (comparison, status, lifecycle mapping) - LocalEpisteme: ingest_authored_claim() and fetch_authored_claims() with AUTHORED_CLAIM predicate index - EpistemeClaimStore: ClaimStore trait backed by StemeDB (append-only delete via deprecation) - CLI handlers: all claim commands read/write through StemeDB - Scanner: loads claims from StemeDB with auto-migration fallback to TOML - Export: new `aphoria claims export` serializes StemeDB claims to TOML/JSON Also cleans up dead code (EpistemeConfig.url), renames ingest_claims→ingest_observations, fixes ClaimFilter.authority_tier type, adds Draft variant to ClaimStatus, and fixes pre-existing clippy warnings (too_many_arguments, filter_next→rfind). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
301 lines
11 KiB
Rust
301 lines
11 KiB
Rust
//! 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<usize>, Query, description = "Maximum number of events to return (default: 100)"),
|
|
("include_reviewed" = Option<bool>, 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<AppState>,
|
|
Query(params): Query<QuarantineListParams>,
|
|
) -> std::result::Result<Json<QuarantineListResponse>, (StatusCode, Json<ErrorResponse>)> {
|
|
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<QuarantineEventDto> = 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<AppState>,
|
|
Path(hash_hex): Path<String>,
|
|
) -> std::result::Result<Json<QuarantineGetResponse>, (StatusCode, Json<ErrorResponse>)> {
|
|
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<AppState>,
|
|
Path(hash_hex): Path<String>,
|
|
) -> std::result::Result<Json<QuarantineApproveResponse>, (StatusCode, Json<ErrorResponse>)> {
|
|
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<AppState>,
|
|
Path(hash_hex): Path<String>,
|
|
) -> std::result::Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
|
|
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<ErrorResponse>)> {
|
|
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)
|
|
}
|