use std::collections::HashMap;
use std::sync::Arc;
use axum::{
extract::{Query, State},
http::{HeaderMap, HeaderValue, StatusCode},
response::{IntoResponse, Response},
};
use chrono::{DateTime, Utc};
use oxirs_core::audit::AuditEvent;
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
use crate::auth::types::Permission;
use crate::server::AppState;
#[derive(Debug, Deserialize)]
pub struct AuditQuery {
pub from: Option<String>,
pub to: Option<String>,
pub actor: Option<String>,
pub action: Option<String>,
pub limit: Option<usize>,
pub format: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuditFormat {
Json,
Jsonl,
Csv,
}
impl AuditFormat {
pub fn parse(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"json" => Some(Self::Json),
"jsonl" | "ndjson" => Some(Self::Jsonl),
"csv" => Some(Self::Csv),
_ => None,
}
}
pub fn content_type(self) -> &'static str {
match self {
Self::Json => "application/json",
Self::Jsonl => "application/x-ndjson",
Self::Csv => "text/csv; charset=utf-8",
}
}
}
#[derive(Debug, Serialize)]
pub struct AuditStats {
pub total_events: usize,
pub events_by_kind: HashMap<String, usize>,
pub events_by_actor: HashMap<String, usize>,
pub events_by_action: HashMap<String, usize>,
pub oldest_event_ts: Option<DateTime<Utc>>,
pub newest_event_ts: Option<DateTime<Utc>>,
pub success_count: usize,
pub failure_count: usize,
pub partial_success_count: usize,
}
pub fn parse_ts(s: &str) -> Result<DateTime<Utc>, String> {
if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
return Ok(dt.with_timezone(&Utc));
}
let s_with_z = if s.contains('T') {
format!("{}Z", s.trim_end_matches('Z'))
} else {
format!("{}T00:00:00Z", s)
};
DateTime::parse_from_rfc3339(&s_with_z)
.map(|dt| dt.with_timezone(&Utc))
.map_err(|e| format!("invalid timestamp '{}': {}", s, e))
}
pub fn filter_events(
events: Vec<AuditEvent>,
from: Option<DateTime<Utc>>,
to: Option<DateTime<Utc>>,
actor: Option<&str>,
action_prefix: Option<&str>,
) -> Vec<AuditEvent> {
events
.into_iter()
.filter(|e| {
if let Some(from_ts) = from {
if e.timestamp < from_ts {
return false;
}
}
if let Some(to_ts) = to {
if e.timestamp > to_ts {
return false;
}
}
if let Some(actor_id) = actor {
if e.actor.actor_id != actor_id {
return false;
}
}
if let Some(prefix) = action_prefix {
if !e.action.starts_with(prefix) {
return false;
}
}
true
})
.collect()
}
pub fn limit_recent(mut events: Vec<AuditEvent>, limit: Option<usize>) -> Vec<AuditEvent> {
if let Some(n) = limit {
let len = events.len();
if len > n {
events.drain(0..(len - n));
}
}
events
}
pub fn format_as_json(events: &[AuditEvent]) -> Result<String, String> {
serde_json::to_string(events).map_err(|e| format!("JSON serialisation error: {}", e))
}
pub fn format_as_jsonl(events: &[AuditEvent]) -> Result<String, String> {
let mut buf = String::new();
for event in events {
let line = serde_json::to_string(event)
.map_err(|e| format!("JSONL serialisation error: {}", e))?;
buf.push_str(&line);
buf.push('\n');
}
Ok(buf)
}
pub fn format_as_csv(events: &[AuditEvent]) -> Result<String, String> {
let mut buf = String::new();
buf.push_str(
"event_id,timestamp,kind,action,actor_id,actor_type,resource_type,resource_id,outcome,duration_ms\r\n",
);
for e in events {
let outcome_str = match &e.outcome {
oxirs_core::audit::AuditOutcome::Success => "success".to_string(),
oxirs_core::audit::AuditOutcome::Failure { reason } => {
format!("failure:{}", reason)
}
oxirs_core::audit::AuditOutcome::PartialSuccess { details } => {
format!("partial_success:{}", details)
}
};
let kind_str = format!("{:?}", e.kind).to_ascii_lowercase();
let actor_type_str = format!("{:?}", e.actor.actor_type).to_ascii_lowercase();
let duration_str = e.duration_ms.map(|d| d.to_string()).unwrap_or_default();
let fields: [&str; 10] = [
&e.event_id,
&e.timestamp.to_rfc3339(),
&kind_str,
&e.action,
&e.actor.actor_id,
&actor_type_str,
&e.resource.resource_type,
&e.resource.resource_id,
&outcome_str,
&duration_str,
];
let row: Vec<String> = fields.iter().map(|f| csv_escape(f)).collect();
buf.push_str(&row.join(","));
buf.push_str("\r\n");
}
Ok(buf)
}
fn csv_escape(s: &str) -> String {
if s.contains(',') || s.contains('"') || s.contains('\n') || s.contains('\r') {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s.to_string()
}
}
pub fn compute_stats(events: &[AuditEvent]) -> AuditStats {
let mut events_by_kind: HashMap<String, usize> = HashMap::new();
let mut events_by_actor: HashMap<String, usize> = HashMap::new();
let mut events_by_action: HashMap<String, usize> = HashMap::new();
let mut oldest: Option<DateTime<Utc>> = None;
let mut newest: Option<DateTime<Utc>> = None;
let mut success_count = 0usize;
let mut failure_count = 0usize;
let mut partial_success_count = 0usize;
for e in events {
let kind_str = serde_json::to_value(&e.kind)
.ok()
.and_then(|v| v.as_str().map(str::to_string))
.unwrap_or_else(|| format!("{:?}", e.kind).to_ascii_lowercase());
*events_by_kind.entry(kind_str).or_insert(0) += 1;
*events_by_actor.entry(e.actor.actor_id.clone()).or_insert(0) += 1;
*events_by_action.entry(e.action.clone()).or_insert(0) += 1;
let ts = e.timestamp;
oldest = Some(oldest.map_or(ts, |prev| prev.min(ts)));
newest = Some(newest.map_or(ts, |prev| prev.max(ts)));
match &e.outcome {
oxirs_core::audit::AuditOutcome::Success => success_count += 1,
oxirs_core::audit::AuditOutcome::Failure { .. } => failure_count += 1,
oxirs_core::audit::AuditOutcome::PartialSuccess { .. } => partial_success_count += 1,
}
}
AuditStats {
total_events: events.len(),
events_by_kind,
events_by_actor,
events_by_action,
oldest_event_ts: oldest,
newest_event_ts: newest,
success_count,
failure_count,
partial_success_count,
}
}
fn extract_bearer(headers: &HeaderMap) -> Option<&str> {
headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|s| {
s.strip_prefix("Bearer ")
.or_else(|| s.strip_prefix("bearer "))
})
}
pub fn check_audit_permission(state: &AppState, headers: &HeaderMap) -> Result<(), Box<Response>> {
if !state.config.security.auth_required {
debug!("Auth not required — allowing audit log access");
return Ok(());
}
let token = match extract_bearer(headers) {
Some(t) => t,
None => {
warn!("Audit log request rejected: missing Authorization header");
return Err(Box::new(error_response(
StatusCode::UNAUTHORIZED,
"missing_auth",
"Authorization: Bearer <token> header is required",
)));
}
};
let auth_service = match &state.auth_service {
Some(svc) => svc,
None => {
warn!("Audit log request rejected: auth service unavailable");
return Err(Box::new(error_response(
StatusCode::UNAUTHORIZED,
"auth_unavailable",
"Authentication service is not configured",
)));
}
};
let validation = match auth_service.validate_jwt_token(token) {
Ok(v) => v,
Err(_) => {
warn!("Audit log request rejected: invalid or expired token");
return Err(Box::new(error_response(
StatusCode::UNAUTHORIZED,
"invalid_token",
"Token is invalid or expired",
)));
}
};
let has_permission = validation.user.permissions.contains(&Permission::ReadAudit)
|| validation.user.permissions.contains(&Permission::Admin)
|| validation
.user
.permissions
.contains(&Permission::GlobalAdmin)
|| validation
.user
.roles
.iter()
.any(|r| r == "admin" || r == "auditor");
if !has_permission {
warn!(
"Audit log access denied for user '{}'",
validation.user.username
);
return Err(Box::new(error_response(
StatusCode::FORBIDDEN,
"permission_denied",
"ReadAudit permission (or admin role) is required to access audit logs",
)));
}
Ok(())
}
fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
let body = serde_json::json!({
"error": code,
"message": message,
});
let json_body = match serde_json::to_string(&body) {
Ok(s) => s,
Err(_) => format!(r#"{{"error":"{}","message":"{}"}}"#, code, message),
};
let mut resp = Response::new(json_body.into());
*resp.status_mut() = status;
resp.headers_mut()
.insert("content-type", HeaderValue::from_static("application/json"));
resp
}
pub async fn get_audit_log(
State(state): State<Arc<AppState>>,
Query(params): Query<AuditQuery>,
headers: HeaderMap,
) -> impl IntoResponse {
if let Err(resp) = check_audit_permission(&state, &headers) {
return *resp;
}
let s = params.format.as_deref().unwrap_or("json");
let fmt = match AuditFormat::parse(s) {
Some(f) => f,
None => {
return error_response(
StatusCode::BAD_REQUEST,
"invalid_format",
&format!("unknown format '{}'; supported values: json, jsonl, csv", s),
);
}
};
let from_ts = match params.from.as_deref().map(parse_ts).transpose() {
Ok(v) => v,
Err(e) => {
return error_response(StatusCode::BAD_REQUEST, "invalid_from_timestamp", &e);
}
};
let to_ts = match params.to.as_deref().map(parse_ts).transpose() {
Ok(v) => v,
Err(e) => {
return error_response(StatusCode::BAD_REQUEST, "invalid_to_timestamp", &e);
}
};
let raw_events: Vec<AuditEvent> = state.audit_logger.events();
let filtered = filter_events(
raw_events,
from_ts,
to_ts,
params.actor.as_deref(),
params.action.as_deref(),
);
let limited = limit_recent(filtered, params.limit);
let body = match fmt {
AuditFormat::Json => format_as_json(&limited),
AuditFormat::Jsonl => format_as_jsonl(&limited),
AuditFormat::Csv => format_as_csv(&limited),
};
let body = match body {
Ok(s) => s,
Err(e) => {
return error_response(StatusCode::INTERNAL_SERVER_ERROR, "serialisation_error", &e);
}
};
let content_type = fmt.content_type();
let mut resp = Response::new(body.into());
resp.headers_mut().insert(
"content-type",
match HeaderValue::from_str(content_type) {
Ok(v) => v,
Err(_) => HeaderValue::from_static("application/octet-stream"),
},
);
resp
}
pub async fn get_audit_stats(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> impl IntoResponse {
if let Err(resp) = check_audit_permission(&state, &headers) {
return *resp;
}
let events = state.audit_logger.events();
let stats = compute_stats(&events);
match serde_json::to_string(&stats) {
Ok(body) => {
let mut resp = Response::new(body.into());
resp.headers_mut()
.insert("content-type", HeaderValue::from_static("application/json"));
resp
}
Err(e) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"serialisation_error",
&e.to_string(),
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Datelike as _;
use chrono::Timelike as _;
use chrono::Utc;
use oxirs_core::audit::{
event::ActorType, AuditActor, AuditEvent, AuditEventKind, AuditLogger as CoreAuditLogger,
AuditOutcome, AuditResource, InMemoryAuditLogger,
};
fn make_event(
kind: AuditEventKind,
action: &str,
actor_id: &str,
outcome: AuditOutcome,
) -> AuditEvent {
AuditEvent::new(
kind,
action,
AuditActor {
actor_id: actor_id.to_string(),
actor_type: ActorType::User,
ip_address: Some("127.0.0.1".to_string()),
session_id: None,
},
AuditResource {
resource_type: "dataset".to_string(),
resource_id: "ds-main".to_string(),
tenant_id: None,
},
outcome,
)
}
fn make_logger_with_events(n: usize) -> InMemoryAuditLogger {
let logger = InMemoryAuditLogger::new();
for i in 0..n {
let kind = if i % 2 == 0 {
AuditEventKind::DataAccess
} else {
AuditEventKind::Admin
};
let outcome = if i % 3 == 0 {
AuditOutcome::Failure {
reason: "test".to_string(),
}
} else {
AuditOutcome::Success
};
logger
.log(make_event(kind, &format!("action.{}", i), "alice", outcome))
.expect("log should succeed");
}
logger
}
#[test]
fn test_parse_ts_rfc3339() {
let dt = parse_ts("2026-01-15T12:00:00Z").expect("valid RFC-3339");
assert_eq!(dt.year(), 2026);
assert_eq!(dt.month(), 1);
assert_eq!(dt.day(), 15);
}
#[test]
fn test_parse_ts_with_offset() {
let dt = parse_ts("2026-05-17T09:30:00+09:00").expect("with TZ offset");
assert_eq!(dt.hour(), 0);
assert_eq!(dt.minute(), 30);
}
#[test]
fn test_parse_ts_date_only() {
let dt = parse_ts("2026-05-17").expect("date-only");
assert_eq!(dt.year(), 2026);
assert_eq!(dt.month(), 5);
assert_eq!(dt.day(), 17);
assert_eq!(dt.hour(), 0);
}
#[test]
fn test_parse_ts_naive_no_z() {
let dt = parse_ts("2026-03-10T08:00:00").expect("naive datetime");
assert_eq!(dt.year(), 2026);
}
#[test]
fn test_parse_ts_invalid() {
assert!(parse_ts("not-a-date").is_err());
assert!(parse_ts("2026-99-99").is_err());
}
#[test]
fn test_audit_format_parse() {
assert_eq!(AuditFormat::parse("json"), Some(AuditFormat::Json));
assert_eq!(AuditFormat::parse("JSON"), Some(AuditFormat::Json));
assert_eq!(AuditFormat::parse("jsonl"), Some(AuditFormat::Jsonl));
assert_eq!(AuditFormat::parse("ndjson"), Some(AuditFormat::Jsonl));
assert_eq!(AuditFormat::parse("csv"), Some(AuditFormat::Csv));
assert_eq!(AuditFormat::parse("unknown"), None);
}
#[test]
fn test_audit_format_content_type() {
assert_eq!(AuditFormat::Json.content_type(), "application/json");
assert!(AuditFormat::Jsonl.content_type().contains("ndjson"));
assert!(AuditFormat::Csv.content_type().contains("text/csv"));
}
#[test]
fn test_filter_events_no_filter_passes_all() {
let logger = make_logger_with_events(10);
let events = logger.events();
let result = filter_events(events.clone(), None, None, None, None);
assert_eq!(result.len(), events.len());
}
#[test]
fn test_filter_events_by_actor() {
let logger = InMemoryAuditLogger::new();
logger
.log(make_event(
AuditEventKind::DataAccess,
"sparql.select",
"alice",
AuditOutcome::Success,
))
.expect("log");
logger
.log(make_event(
AuditEventKind::DataAccess,
"sparql.select",
"bob",
AuditOutcome::Success,
))
.expect("log");
let events = logger.events();
let result = filter_events(events, None, None, Some("alice"), None);
assert_eq!(result.len(), 1);
assert_eq!(result[0].actor.actor_id, "alice");
}
#[test]
fn test_filter_events_by_action_prefix() {
let logger = InMemoryAuditLogger::new();
logger
.log(make_event(
AuditEventKind::DataAccess,
"sparql.select",
"alice",
AuditOutcome::Success,
))
.expect("log");
logger
.log(make_event(
AuditEventKind::Admin,
"admin.config",
"alice",
AuditOutcome::Success,
))
.expect("log");
let events = logger.events();
let result = filter_events(events, None, None, None, Some("sparql."));
assert_eq!(result.len(), 1);
assert_eq!(result[0].action, "sparql.select");
}
#[test]
fn test_filter_events_by_timestamp_range() {
let logger = InMemoryAuditLogger::new();
logger
.log(make_event(
AuditEventKind::DataAccess,
"sparql.select",
"alice",
AuditOutcome::Success,
))
.expect("log");
let events = logger.events();
let future = Utc::now() + chrono::Duration::hours(1);
let result = filter_events(events, Some(future), None, None, None);
assert_eq!(result.len(), 0, "from in future should exclude all events");
}
#[test]
fn test_limit_recent_no_limit() {
let logger = make_logger_with_events(20);
let events = logger.events();
let result = limit_recent(events.clone(), None);
assert_eq!(result.len(), events.len());
}
#[test]
fn test_limit_recent_smaller_than_total() {
let logger = make_logger_with_events(20);
let events = logger.events();
let result = limit_recent(events, Some(5));
assert_eq!(result.len(), 5);
}
#[test]
fn test_limit_recent_larger_than_total() {
let logger = make_logger_with_events(5);
let events = logger.events();
let result = limit_recent(events, Some(100));
assert_eq!(result.len(), 5);
}
#[test]
fn test_format_as_json_empty() {
let out = format_as_json(&[]).expect("serialise empty");
assert_eq!(out, "[]");
}
#[test]
fn test_format_as_json_is_valid_json_array() {
let logger = make_logger_with_events(3);
let events = logger.events();
let out = format_as_json(&events).expect("serialise");
let parsed: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
assert!(parsed.is_array());
assert_eq!(parsed.as_array().map(|a| a.len()), Some(3));
}
#[test]
fn test_format_as_jsonl_empty() {
let out = format_as_jsonl(&[]).expect("serialise empty");
assert!(out.is_empty());
}
#[test]
fn test_format_as_jsonl_line_count() {
let logger = make_logger_with_events(4);
let events = logger.events();
let out = format_as_jsonl(&events).expect("serialise");
assert_eq!(out.lines().count(), 4);
for line in out.lines() {
let v: serde_json::Value = serde_json::from_str(line).expect("valid JSON per line");
assert!(v.is_object());
}
}
#[test]
fn test_format_as_csv_has_header() {
let out = format_as_csv(&[]).expect("serialise empty");
assert!(out.contains("event_id,timestamp,kind,action"));
}
#[test]
fn test_format_as_csv_row_count() {
let logger = make_logger_with_events(5);
let events = logger.events();
let out = format_as_csv(&events).expect("serialise");
let lines: Vec<&str> = out.split("\r\n").filter(|s| !s.is_empty()).collect();
assert_eq!(lines.len(), 6); }
#[test]
fn test_csv_escape_no_special_chars() {
assert_eq!(csv_escape("hello"), "hello");
}
#[test]
fn test_csv_escape_with_comma() {
let esc = csv_escape("hello,world");
assert_eq!(esc, "\"hello,world\"");
}
#[test]
fn test_csv_escape_with_quote() {
let esc = csv_escape("say \"hi\"");
assert_eq!(esc, "\"say \"\"hi\"\"\"");
}
#[test]
fn test_compute_stats_empty() {
let stats = compute_stats(&[]);
assert_eq!(stats.total_events, 0);
assert!(stats.oldest_event_ts.is_none());
assert!(stats.newest_event_ts.is_none());
assert_eq!(stats.success_count, 0);
assert_eq!(stats.failure_count, 0);
}
#[test]
fn test_compute_stats_counts() {
let logger = make_logger_with_events(9);
let events = logger.events();
let stats = compute_stats(&events);
assert_eq!(stats.total_events, 9);
assert_eq!(
stats
.events_by_kind
.get("data_access")
.copied()
.unwrap_or(0),
5
);
assert_eq!(stats.events_by_kind.get("admin").copied().unwrap_or(0), 4);
assert_eq!(stats.failure_count, 3);
assert_eq!(stats.success_count, 6);
}
#[test]
fn test_compute_stats_timestamps_present() {
let logger = make_logger_with_events(3);
let events = logger.events();
let stats = compute_stats(&events);
assert!(stats.oldest_event_ts.is_some());
assert!(stats.newest_event_ts.is_some());
if let (Some(oldest), Some(newest)) = (stats.oldest_event_ts, stats.newest_event_ts) {
assert!(oldest <= newest);
}
}
#[test]
fn test_extract_bearer_present() {
let mut headers = HeaderMap::new();
headers.insert(
"authorization",
HeaderValue::from_static("Bearer mytoken123"),
);
assert_eq!(extract_bearer(&headers), Some("mytoken123"));
}
#[test]
fn test_extract_bearer_missing() {
let headers = HeaderMap::new();
assert_eq!(extract_bearer(&headers), None);
}
#[test]
fn test_extract_bearer_lowercase_prefix() {
let mut headers = HeaderMap::new();
headers.insert("authorization", HeaderValue::from_static("bearer tok42"));
assert_eq!(extract_bearer(&headers), Some("tok42"));
}
#[test]
fn test_extract_bearer_not_bearer_scheme() {
let mut headers = HeaderMap::new();
headers.insert(
"authorization",
HeaderValue::from_static("Basic dXNlcjpwYXNz"),
);
assert_eq!(extract_bearer(&headers), None);
}
}