use std::sync::Arc;
use async_trait::async_trait;
use dbnexus::{AuditEvent, AuditQueryFilters, AuditSeverity, AuditStorage};
use crate::integrations::Database;
use crate::{InklogError, LogRecord};
pub struct InklogAuditStorage {
db: Arc<dyn Database>,
}
impl InklogAuditStorage {
pub fn new(db: Arc<dyn Database>) -> Self {
Self { db }
}
fn severity_to_level(severity: &AuditSeverity) -> &'static str {
match severity {
AuditSeverity::Info | AuditSeverity::Low => "INFO",
AuditSeverity::Medium => "WARN",
AuditSeverity::High => "ERROR",
AuditSeverity::Critical => "FATAL",
}
}
pub fn to_log_record(event: &AuditEvent) -> LogRecord {
let mut record = LogRecord::new(
tracing::Level::INFO,
format!("audit::{}", event.entity_type),
event.to_json().unwrap_or_else(|_| "{}".to_string()),
);
record.level = Self::severity_to_level(&event.severity).to_string();
record.timestamp = event.timestamp;
record.fields.insert(
"operation".to_string(),
serde_json::to_value(&event.operation).unwrap_or(serde_json::Value::Null),
);
record.fields.insert(
"user_id".to_string(),
serde_json::Value::String(event.user_id.clone()),
);
record.fields.insert(
"entity_id".to_string(),
serde_json::Value::String(event.entity_id.clone()),
);
record.fields.insert(
"result".to_string(),
serde_json::to_value(&event.result).unwrap_or(serde_json::Value::Null),
);
record.fields.insert(
"request_id".to_string(),
serde_json::Value::String(event.request_id.clone()),
);
if let Some(error) = &event.error_message {
record.fields.insert(
"error_message".to_string(),
serde_json::Value::String(error.clone()),
);
}
record
}
fn write_only_error(op: &str) -> Box<dyn std::error::Error + Send + Sync> {
Box::new(InklogError::ConfigError(format!(
"InklogAuditStorage is a write-only bridge; '{op}' is not supported. \
Query the inklog log table directly or use dbnexus DbAuditStorage."
)))
}
}
#[async_trait]
impl AuditStorage for InklogAuditStorage {
async fn store(
&self,
event: &AuditEvent,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let record = Self::to_log_record(event);
self.db.insert_batch(std::slice::from_ref(&record)).await?;
Ok(())
}
async fn query(
&self,
_filters: &AuditQueryFilters,
) -> Result<Vec<AuditEvent>, Box<dyn std::error::Error + Send + Sync>> {
Err(Self::write_only_error("query"))
}
async fn cleanup(
&self,
_before: &chrono::DateTime<chrono::Utc>,
) -> Result<u64, Box<dyn std::error::Error + Send + Sync>> {
Err(Self::write_only_error("cleanup"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::integrations::MockDatabaseAdapter;
use dbnexus::{AuditOperation, AuditStatus};
fn sample_event() -> AuditEvent {
AuditEvent::update(
"users",
"42",
"admin",
Some(r#"{"a":1}"#.to_string()),
Some(r#"{"a":2}"#.to_string()),
)
.with_user("root", "10.0.0.1")
.with_result(AuditStatus::Failure)
.with_severity(AuditSeverity::High)
.with_request_id("req-1")
}
fn sample_event_with_error() -> AuditEvent {
let mut e = sample_event();
e.error_message = Some("boom".to_string());
e
}
#[test]
fn test_to_log_record_mapping() {
let event = sample_event_with_error();
let record = InklogAuditStorage::to_log_record(&event);
assert_eq!(record.level, "ERROR");
assert_eq!(record.target, "audit::users");
let json: serde_json::Value = serde_json::from_str(&record.message).unwrap();
assert_eq!(json["entity_id"], serde_json::json!("42"));
assert_eq!(
record.fields.get("user_id").unwrap(),
&serde_json::json!("admin")
);
assert_eq!(
record.fields.get("entity_id").unwrap(),
&serde_json::json!("42")
);
assert_eq!(
record.fields.get("operation").unwrap(),
&serde_json::json!("Update")
);
assert_eq!(
record.fields.get("error_message").unwrap(),
&serde_json::json!("boom")
);
assert_eq!(record.timestamp, event.timestamp);
}
#[test]
fn test_severity_to_level_mapping() {
assert_eq!(
InklogAuditStorage::severity_to_level(&AuditSeverity::Info),
"INFO"
);
assert_eq!(
InklogAuditStorage::severity_to_level(&AuditSeverity::Medium),
"WARN"
);
assert_eq!(
InklogAuditStorage::severity_to_level(&AuditSeverity::Critical),
"FATAL"
);
}
#[tokio::test]
async fn test_store_lands_audit_event_in_inklog_db_stream() {
let mock = Arc::new(MockDatabaseAdapter::new());
let storage = InklogAuditStorage::new(mock.clone() as Arc<dyn Database>);
storage
.store(&sample_event())
.await
.expect("store must succeed");
let records = mock.get_records();
assert_eq!(
records.len(),
1,
"audit event must appear in the inklog write stream"
);
assert_eq!(records[0].target, "audit::users");
assert!(records[0].message.contains("entity_id"));
}
#[tokio::test]
async fn test_query_and_cleanup_are_write_only() {
let storage = InklogAuditStorage::new(Arc::new(MockDatabaseAdapter::new()));
let err = storage
.query(&AuditQueryFilters::default())
.await
.expect_err("query must be unsupported");
assert!(err.to_string().contains("write-only"));
let err = storage
.cleanup(&chrono::Utc::now())
.await
.expect_err("cleanup must be unsupported");
assert!(err.to_string().contains("write-only"));
}
#[test]
fn test_operation_serialization_covers_custom_variant() {
let mut event = sample_event();
event.operation = AuditOperation::Other("purge".to_string());
event.error_message = Some("boom".to_string());
let record = InklogAuditStorage::to_log_record(&event);
assert_eq!(
record.fields.get("operation").unwrap(),
&serde_json::json!({"Other": "purge"})
);
}
}