use tokio::sync::mpsc;
use tracing::{debug, error, info, warn};
use uuid::Uuid;
mod helpers;
mod types;
pub use self::helpers::{
access_denied, config_changed, episode_completed, episode_created, episode_deleted,
relationship_added, relationship_removed, tags_modified,
};
pub use self::types::{
ActorType, AuditConfig, AuditEntry, AuditEventType, AuditLogLevel, AuditOutput, AuditResult,
};
#[derive(Clone)]
pub struct AuditLogger {
config: AuditConfig,
sender: Option<mpsc::UnboundedSender<AuditEntry>>,
}
impl AuditLogger {
#[must_use]
pub fn new(config: AuditConfig) -> Self {
let sender = if config.enabled {
let (tx, mut rx) = mpsc::unbounded_channel::<AuditEntry>();
tokio::spawn(async move {
while let Some(entry) = rx.recv().await {
Self::write_entry(&entry);
}
});
Some(tx)
} else {
None
};
Self { config, sender }
}
#[must_use]
pub fn disabled() -> Self {
Self {
config: AuditConfig {
enabled: false,
..Default::default()
},
sender: None,
}
}
#[must_use]
pub fn is_enabled(&self) -> bool {
self.config.enabled
}
pub fn log(&self, entry: AuditEntry) {
if !self.config.enabled {
return;
}
if !self.config.should_log(entry.level) {
return;
}
if let Some(ref sender) = self.sender {
if let Err(e) = sender.send(entry) {
debug!("Failed to send audit entry: {}", e);
}
}
}
pub fn log_sync(&self, entry: &AuditEntry) -> anyhow::Result<()> {
if !self.config.enabled {
return Ok(());
}
if !self.config.should_log(entry.level) {
return Ok(());
}
Self::write_entry(entry);
Ok(())
}
fn write_entry(entry: &AuditEntry) {
match Self::format_entry(entry) {
Ok(json) => {
match entry.level {
AuditLogLevel::Debug => debug!(target: "audit", "{}", json),
AuditLogLevel::Info => info!(target: "audit", "{}", json),
AuditLogLevel::Warn => warn!(target: "audit", "{}", json),
AuditLogLevel::Error | AuditLogLevel::Critical => {
error!(target: "audit", "{}", json);
}
}
}
Err(e) => {
error!("Failed to format audit entry: {}", e);
}
}
}
fn format_entry(entry: &AuditEntry) -> anyhow::Result<String> {
entry.to_json()
}
#[must_use]
pub fn config(&self) -> &AuditConfig {
&self.config
}
}
impl Default for AuditLogger {
fn default() -> Self {
Self::new(AuditConfig::default())
}
}
#[derive(Debug, Clone)]
pub struct AuditContext {
pub actor: ActorType,
pub session_id: Option<String>,
pub ip_address: Option<String>,
pub request_id: Option<String>,
}
impl AuditContext {
#[must_use]
pub fn new(actor: ActorType) -> Self {
Self {
actor,
session_id: None,
ip_address: None,
request_id: Some(Uuid::new_v4().to_string()),
}
}
#[must_use]
pub fn system() -> Self {
Self::new(ActorType::System("memory-core".to_string()))
}
#[must_use]
pub fn anonymous() -> Self {
Self::new(ActorType::Unknown)
}
#[must_use]
pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
self.session_id = Some(session_id.into());
self
}
#[must_use]
pub fn with_ip_address(mut self, ip: impl Into<String>) -> Self {
self.ip_address = Some(ip.into());
self
}
#[must_use]
pub fn with_request_id(mut self, request_id: impl Into<String>) -> Self {
self.request_id = Some(request_id.into());
self
}
}
impl Default for AuditContext {
fn default() -> Self {
Self::system()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_audit_config_default() {
let config = AuditConfig::default();
assert!(!config.enabled);
assert!(matches!(config.log_level, AuditLogLevel::Info));
assert!(matches!(config.output_destination, AuditOutput::Stdout));
assert_eq!(config.retention_days, 90);
}
#[test]
fn test_audit_config_should_log() {
let config = AuditConfig {
enabled: true,
log_level: AuditLogLevel::Warn,
..Default::default()
};
assert!(!config.should_log(AuditLogLevel::Debug));
assert!(!config.should_log(AuditLogLevel::Info));
assert!(config.should_log(AuditLogLevel::Warn));
assert!(config.should_log(AuditLogLevel::Error));
assert!(config.should_log(AuditLogLevel::Critical));
}
#[test]
fn test_audit_config_disabled() {
let config = AuditConfig {
enabled: false,
..Default::default()
};
assert!(!config.should_log(AuditLogLevel::Critical));
}
#[test]
fn test_audit_entry_creation() {
let entry = AuditEntry::new(
AuditEventType::EpisodeCreated,
ActorType::System("test".to_string()),
);
assert_eq!(entry.event_type, AuditEventType::EpisodeCreated);
assert!(matches!(entry.actor, ActorType::System(_)));
assert!(entry.details.is_empty());
}
#[test]
fn test_audit_entry_with_details() {
let entry = AuditEntry::new(
AuditEventType::EpisodeCreated,
ActorType::System("test".to_string()),
)
.with_detail("key", "value")
.unwrap()
.with_resource_id("resource-123");
assert_eq!(entry.resource_id, Some("resource-123".to_string()));
assert!(entry.details.contains_key("key"));
}
#[test]
fn test_audit_entry_to_json() {
let entry = AuditEntry::new(
AuditEventType::EpisodeCreated,
ActorType::System("test".to_string()),
)
.with_resource_id("test-id");
let json = entry.to_json().unwrap();
assert!(json.contains("test-id"));
assert!(json.contains("episode_created"));
}
#[test]
fn test_actor_type_display() {
assert_eq!(
ActorType::User("alice".to_string()).to_string(),
"user:alice"
);
assert_eq!(
ActorType::System("worker".to_string()).to_string(),
"system:worker"
);
assert_eq!(
ActorType::Service("api".to_string()).to_string(),
"service:api"
);
assert_eq!(ActorType::Unknown.to_string(), "unknown");
}
#[test]
fn test_audit_log_level_display() {
assert_eq!(AuditLogLevel::Debug.to_string(), "DEBUG");
assert_eq!(AuditLogLevel::Info.to_string(), "INFO");
assert_eq!(AuditLogLevel::Warn.to_string(), "WARN");
assert_eq!(AuditLogLevel::Error.to_string(), "ERROR");
assert_eq!(AuditLogLevel::Critical.to_string(), "CRITICAL");
}
#[test]
fn test_audit_event_type_display() {
assert_eq!(
AuditEventType::EpisodeCreated.to_string(),
"episode_created"
);
assert_eq!(
AuditEventType::EpisodeCompleted.to_string(),
"episode_completed"
);
assert_eq!(AuditEventType::AccessDenied.to_string(), "access_denied");
}
#[test]
fn test_audit_context_creation() {
let context = AuditContext::system();
assert!(matches!(context.actor, ActorType::System(_)));
assert!(context.request_id.is_some());
let user_context = AuditContext::new(ActorType::User("bob".to_string()));
assert!(matches!(user_context.actor, ActorType::User(_)));
}
#[test]
fn test_helper_functions() {
let context = AuditContext::system();
let episode_id = Uuid::new_v4();
let entry = episode_created(&context, episode_id, "Test task", "code_generation");
assert_eq!(entry.event_type, AuditEventType::EpisodeCreated);
assert_eq!(entry.resource_id, Some(episode_id.to_string()));
let entry = episode_completed(&context, episode_id, "Success", true);
assert_eq!(entry.event_type, AuditEventType::EpisodeCompleted);
assert!(matches!(entry.level, AuditLogLevel::Info));
let entry = episode_deleted(&context, episode_id);
assert_eq!(entry.event_type, AuditEventType::EpisodeDeleted);
assert!(matches!(entry.level, AuditLogLevel::Warn));
}
#[test]
fn test_access_denied() {
let context = AuditContext::system();
let entry = access_denied(
&context,
"episode-123",
"delete",
"insufficient_permissions",
);
assert_eq!(entry.event_type, AuditEventType::AccessDenied);
assert!(matches!(entry.level, AuditLogLevel::Critical));
assert!(matches!(entry.result, AuditResult::Denied { .. }));
}
#[test]
fn test_config_changed() {
let context = AuditContext::system();
let entry = config_changed(&context, "max_episodes", "1000", "2000");
assert_eq!(entry.event_type, AuditEventType::ConfigChanged);
assert!(matches!(entry.level, AuditLogLevel::Warn));
}
#[test]
fn test_relationship_helpers() {
let context = AuditContext::system();
let rel_id = Uuid::new_v4();
let from = Uuid::new_v4();
let to = Uuid::new_v4();
let entry = relationship_added(&context, rel_id, from, to, "DependsOn");
assert_eq!(entry.event_type, AuditEventType::RelationshipAdded);
let entry = relationship_removed(&context, rel_id);
assert_eq!(entry.event_type, AuditEventType::RelationshipRemoved);
}
#[test]
fn test_tags_modified() {
let context = AuditContext::system();
let episode_id = Uuid::new_v4();
let tags = vec!["tag1".to_string(), "tag2".to_string()];
let entry = tags_modified(&context, episode_id, "added", &tags);
assert_eq!(entry.event_type, AuditEventType::TagsAdded);
}
#[tokio::test]
async fn test_audit_logger_disabled() {
let logger = AuditLogger::disabled();
assert!(!logger.is_enabled());
let entry = AuditEntry::new(
AuditEventType::EpisodeCreated,
ActorType::System("test".to_string()),
);
logger.log(entry); }
#[tokio::test]
async fn test_audit_logger_log_sync() {
let config = AuditConfig {
enabled: true,
log_level: AuditLogLevel::Debug,
..Default::default()
};
let logger = AuditLogger::new(config);
let entry = AuditEntry::new(
AuditEventType::EpisodeCreated,
ActorType::System("test".to_string()),
);
let result = logger.log_sync(&entry);
assert!(result.is_ok());
}
}