use async_trait::async_trait;
use chrono::{DateTime, Utc};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use crate::context::Context;
use crate::errors::ModuleError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditAction {
UpdateConfig,
ReloadModule,
ToggleFeature,
}
impl AuditAction {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::UpdateConfig => "update_config",
Self::ReloadModule => "reload_module",
Self::ToggleFeature => "toggle_feature",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditChange {
pub before: serde_json::Value,
pub after: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEntry {
pub timestamp: DateTime<Utc>,
pub action: AuditAction,
pub target_module_id: String,
pub actor_id: String,
pub actor_type: String,
pub trace_id: String,
pub change: AuditChange,
}
#[async_trait]
pub trait AuditStore: Send + Sync {
async fn append(&self, entry: AuditEntry) -> Result<(), ModuleError>;
async fn query(
&self,
_module_id: Option<&str>,
_actor_id: Option<&str>,
_since: Option<DateTime<Utc>>,
) -> Result<Vec<AuditEntry>, ModuleError> {
Ok(Vec::new())
}
}
#[derive(Debug, Clone, Default)]
pub struct InMemoryAuditStore {
entries: Arc<Mutex<Vec<AuditEntry>>>,
}
impl InMemoryAuditStore {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn entries(&self) -> Vec<AuditEntry> {
self.entries.lock().clone()
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.lock().len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.lock().is_empty()
}
}
#[async_trait]
impl AuditStore for InMemoryAuditStore {
async fn append(&self, entry: AuditEntry) -> Result<(), ModuleError> {
self.entries.lock().push(entry);
Ok(())
}
async fn query(
&self,
module_id: Option<&str>,
actor_id: Option<&str>,
since: Option<DateTime<Utc>>,
) -> Result<Vec<AuditEntry>, ModuleError> {
let entries = self.entries.lock().clone();
Ok(entries
.into_iter()
.filter(|e| module_id.is_none_or(|m| e.target_module_id == m))
.filter(|e| actor_id.is_none_or(|a| e.actor_id == a))
.filter(|e| since.is_none_or(|t| e.timestamp >= t))
.collect())
}
}
pub(crate) fn build_audit_entry(
action: AuditAction,
target_module_id: &str,
ctx: &Context<serde_json::Value>,
change: AuditChange,
) -> AuditEntry {
let (actor_id, actor_type) = ctx.identity.as_ref().map_or_else(
|| ("unknown".to_string(), "unknown".to_string()),
|id| (id.id().to_string(), id.identity_type().to_string()),
);
AuditEntry {
timestamp: Utc::now(),
action,
target_module_id: target_module_id.to_string(),
actor_id,
actor_type,
trace_id: ctx.trace_id.clone(),
change,
}
}
pub(crate) async fn record_audit(store: Option<&Arc<dyn AuditStore>>, entry: AuditEntry) {
if let Some(store) = store {
if let Err(e) = store.append(entry.clone()).await {
tracing::warn!(error = %e, target_module = %entry.target_module_id, "AuditStore append failed");
}
} else {
tracing::info!(
action = %entry.action.as_str(),
target_module_id = %entry.target_module_id,
actor_id = %entry.actor_id,
actor_type = %entry.actor_type,
trace_id = %entry.trace_id,
"audit (no store configured)"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::Identity;
use std::collections::HashMap;
fn ctx_with_identity(id: &str, type_: &str) -> Context<serde_json::Value> {
let identity = Identity::new(id.to_string(), type_.to_string(), vec![], HashMap::new());
Context {
trace_id: "trace-1".to_string(),
identity: Some(identity),
services: serde_json::Value::Null,
caller_id: None,
data: Arc::new(parking_lot::RwLock::new(HashMap::new())),
call_chain: vec![],
redacted_inputs: None,
redacted_output: None,
cancel_token: None,
global_deadline: None,
executor: None,
}
}
#[test]
fn build_entry_extracts_identity() {
let ctx = ctx_with_identity("user-abc", "user");
let entry = build_audit_entry(
AuditAction::UpdateConfig,
"system.control.update_config",
&ctx,
AuditChange {
before: serde_json::json!(1),
after: serde_json::json!(2),
},
);
assert_eq!(entry.actor_id, "user-abc");
assert_eq!(entry.actor_type, "user");
assert_eq!(entry.trace_id, "trace-1");
assert_eq!(entry.action, AuditAction::UpdateConfig);
}
#[tokio::test]
async fn in_memory_store_round_trip() {
let store = InMemoryAuditStore::new();
let ctx = ctx_with_identity("svc-1", "service");
let entry = build_audit_entry(
AuditAction::ToggleFeature,
"risky.module",
&ctx,
AuditChange {
before: serde_json::json!(true),
after: serde_json::json!(false),
},
);
store.append(entry).await.unwrap();
assert_eq!(store.len(), 1);
let by_module = store.query(Some("risky.module"), None, None).await.unwrap();
assert_eq!(by_module.len(), 1);
assert_eq!(by_module[0].actor_id, "svc-1");
let by_actor = store.query(None, Some("nope"), None).await.unwrap();
assert!(by_actor.is_empty());
}
}