stemedb/crates/stemedb-api/src/handlers/audit.rs
jordan 422e2d4416 feat(aphoria): wire claims through StemeDB — Gap Closure Phase 1
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>
2026-02-12 02:02:51 -07:00

175 lines
6.1 KiB
Rust

//! Handlers for query audit trail endpoints.
use axum::{
extract::{Path, Query as AxumQuery, State},
Json,
};
use tracing::instrument;
use crate::{
dto::{AuditQueryParams, QueryAuditListResponse, QueryAuditResponse},
error::{ApiError, Result},
hex as hex_utils,
state::AppState,
};
use stemedb_storage::{AuditStore, GenericAuditStore};
/// List recent query audits with optional filters.
///
/// Returns a paginated list of query audit records. Use the `agent_id` and
/// time range filters to narrow down results for incident investigation.
///
/// # Query Parameters
///
/// - `agent_id` (optional): Filter by agent public key (hex-encoded, 32 bytes)
/// - `subject` (optional): Filter by subject (case-insensitive substring match)
/// - `predicate` (optional): Filter by predicate (case-insensitive substring match)
/// - `from` (optional): Start of time range (Unix timestamp)
/// - `to` (optional): End of time range (Unix timestamp)
/// - `limit` (optional): Maximum number of results (default: 100)
#[utoipa::path(
get,
path = "/v1/audit/queries",
params(AuditQueryParams),
responses(
(status = 200, description = "List of query audits", body = QueryAuditListResponse),
(status = 400, description = "Invalid request", body = crate::dto::ErrorResponse),
(status = 500, description = "Internal server error", body = crate::dto::ErrorResponse)
),
tag = "audit"
)]
#[instrument(skip(state), fields(
agent_id = ?params.agent_id,
subject = ?params.subject,
predicate = ?params.predicate,
from = ?params.from,
to = ?params.to,
limit = params.limit
))]
pub async fn list_audits(
State(state): State<AppState>,
AxumQuery(params): AxumQuery<AuditQueryParams>,
) -> Result<Json<QueryAuditListResponse>> {
let start = std::time::Instant::now();
metrics::counter!("stemedb_http_requests_total", "method" => "GET", "path" => "/v1/audit/queries").increment(1);
let audit_store = GenericAuditStore::new(state.store.clone());
// Fetch a larger set to allow for subject/predicate filtering
// (storage layer doesn't have an index by subject/predicate)
let fetch_limit = if params.subject.is_some() || params.predicate.is_some() {
params.limit * 10 // Fetch more to compensate for filtering
} else {
params.limit
};
let mut audits = if let Some(agent_id_hex) = &params.agent_id {
// Filter by agent with limit pushed down to storage layer
let agent_id = hex_utils::decode_agent_id(agent_id_hex)?;
let from = params.from.unwrap_or(0);
let to = params.to;
audit_store.get_audits_for_agent(&agent_id, from, to, fetch_limit).await?
} else {
// List recent (limit already applied in storage layer)
audit_store.list_recent_audits(fetch_limit).await?
};
// Apply time range filter if not already filtered by agent
if params.agent_id.is_none() {
if let Some(from_ts) = params.from {
audits.retain(|a| a.timestamp >= from_ts);
}
if let Some(to_ts) = params.to {
audits.retain(|a| a.timestamp <= to_ts);
}
}
// Apply subject filter (case-insensitive substring match)
if let Some(ref subject_filter) = params.subject {
let filter_lower = subject_filter.to_lowercase();
audits.retain(|a| {
a.params
.subject
.as_ref()
.map(|s| s.to_lowercase().contains(&filter_lower))
.unwrap_or(false)
});
}
// Apply predicate filter (case-insensitive substring match)
if let Some(ref predicate_filter) = params.predicate {
let filter_lower = predicate_filter.to_lowercase();
audits.retain(|a| {
a.params
.predicate
.as_ref()
.map(|p| p.to_lowercase().contains(&filter_lower))
.unwrap_or(false)
});
}
// Apply final limit
audits.truncate(params.limit);
let total_count = audits.len();
// Convert to response DTOs
let audit_responses: Vec<QueryAuditResponse> =
audits.into_iter().map(QueryAuditResponse::from).collect();
// Track request duration (success case)
metrics::histogram!("stemedb_http_request_duration_seconds",
"method" => "GET",
"path" => "/v1/audit/queries",
"status" => "200"
)
.record(start.elapsed().as_secs_f64());
Ok(Json(QueryAuditListResponse { audits: audit_responses, total_count }))
}
/// Get a specific query audit by ID.
///
/// Returns the full audit record including all contributing assertions
/// that influenced the query result.
#[utoipa::path(
get,
path = "/v1/audit/query/{id}",
params(
("id" = String, Path, description = "Query ID (hex-encoded, 32 bytes)")
),
responses(
(status = 200, description = "Query audit details", body = QueryAuditResponse),
(status = 400, description = "Invalid query ID", body = crate::dto::ErrorResponse),
(status = 404, description = "Audit not found", body = crate::dto::ErrorResponse),
(status = 500, description = "Internal server error", body = crate::dto::ErrorResponse)
),
tag = "audit"
)]
#[instrument(skip(state), fields(query_id = %id))]
pub async fn get_audit(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<QueryAuditResponse>> {
let start = std::time::Instant::now();
metrics::counter!("stemedb_http_requests_total", "method" => "GET", "path" => "/v1/audit/query/{id}").increment(1);
let query_id = hex_utils::decode_hash_32(&id)?;
let audit_store = GenericAuditStore::new(state.store.clone());
match audit_store.get_audit(&query_id).await? {
Some(audit) => {
// Track request duration (success case)
metrics::histogram!("stemedb_http_request_duration_seconds",
"method" => "GET",
"path" => "/v1/audit/query/{id}",
"status" => "200"
)
.record(start.elapsed().as_secs_f64());
Ok(Json(QueryAuditResponse::from(audit)))
}
None => Err(ApiError::NotFound(format!("Query audit not found: {}", id))),
}
}