photoncloud-monorepo/iam/crates/iam-audit/src/sink.rs
centra 8f94aee1fa Fix R8: Convert submodule gitlinks to regular directories
- Remove gitlinks (160000 mode) for chainfire, flaredb, iam
- Add workspace contents as regular tracked files
- Update flake.nix to use simple paths instead of builtins.fetchGit

This resolves the nix build failure where submodule directories
appeared empty in the nix store.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 16:51:20 +09:00

305 lines
7.1 KiB
Rust

//! Audit sinks
//!
//! Pluggable destinations for audit events.
use std::path::Path;
use std::sync::Arc;
use async_trait::async_trait;
use tokio::fs::{File, OpenOptions};
use tokio::io::AsyncWriteExt;
use tokio::sync::{Mutex, RwLock};
use crate::event::AuditEvent;
/// Result type for audit operations
pub type Result<T> = std::result::Result<T, AuditError>;
/// Audit error
#[derive(Debug, thiserror::Error)]
pub enum AuditError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Serialization error: {0}")]
Serialization(#[from] serde_json::Error),
#[error("Sink error: {0}")]
Sink(String),
}
/// Trait for audit event sinks
#[async_trait]
pub trait AuditSink: Send + Sync {
/// Write an audit event to the sink
async fn write(&self, event: &AuditEvent) -> Result<()>;
/// Flush any buffered events
async fn flush(&self) -> Result<()>;
/// Close the sink
async fn close(&self) -> Result<()>;
}
/// File-based audit sink
///
/// Writes audit events as JSON lines to a file.
pub struct FileSink {
file: Mutex<File>,
path: String,
}
impl FileSink {
/// Create a new file sink
pub async fn new(path: impl AsRef<Path>) -> Result<Self> {
let path_str = path.as_ref().to_string_lossy().to_string();
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.await?;
Ok(Self {
file: Mutex::new(file),
path: path_str,
})
}
/// Get the file path
pub fn path(&self) -> &str {
&self.path
}
}
#[async_trait]
impl AuditSink for FileSink {
async fn write(&self, event: &AuditEvent) -> Result<()> {
let json = event.to_json()?;
let line = format!("{}\n", json);
let mut file = self.file.lock().await;
file.write_all(line.as_bytes()).await?;
Ok(())
}
async fn flush(&self) -> Result<()> {
let mut file = self.file.lock().await;
file.flush().await?;
Ok(())
}
async fn close(&self) -> Result<()> {
self.flush().await
}
}
/// In-memory audit sink (for testing)
pub struct MemorySink {
events: RwLock<Vec<AuditEvent>>,
max_events: usize,
}
impl MemorySink {
/// Create a new memory sink
pub fn new(max_events: usize) -> Self {
Self {
events: RwLock::new(Vec::new()),
max_events,
}
}
/// Create with default max events (10000)
pub fn default_capacity() -> Self {
Self::new(10000)
}
/// Get all events
pub async fn events(&self) -> Vec<AuditEvent> {
self.events.read().await.clone()
}
/// Get event count
pub async fn count(&self) -> usize {
self.events.read().await.len()
}
/// Clear all events
pub async fn clear(&self) {
self.events.write().await.clear();
}
/// Find events by principal ID
pub async fn find_by_principal(&self, principal_id: &str) -> Vec<AuditEvent> {
self.events
.read()
.await
.iter()
.filter(|e| e.principal_id.as_deref() == Some(principal_id))
.cloned()
.collect()
}
}
#[async_trait]
impl AuditSink for MemorySink {
async fn write(&self, event: &AuditEvent) -> Result<()> {
let mut events = self.events.write().await;
// If at capacity, remove oldest event
if events.len() >= self.max_events {
events.remove(0);
}
events.push(event.clone());
Ok(())
}
async fn flush(&self) -> Result<()> {
Ok(())
}
async fn close(&self) -> Result<()> {
Ok(())
}
}
/// Multi-sink that writes to multiple sinks
pub struct MultiSink {
sinks: Vec<Arc<dyn AuditSink>>,
}
impl MultiSink {
/// Create a new multi-sink
pub fn new(sinks: Vec<Arc<dyn AuditSink>>) -> Self {
Self { sinks }
}
/// Add a sink
pub fn add(&mut self, sink: Arc<dyn AuditSink>) {
self.sinks.push(sink);
}
}
#[async_trait]
impl AuditSink for MultiSink {
async fn write(&self, event: &AuditEvent) -> Result<()> {
for sink in &self.sinks {
sink.write(event).await?;
}
Ok(())
}
async fn flush(&self) -> Result<()> {
for sink in &self.sinks {
sink.flush().await?;
}
Ok(())
}
async fn close(&self) -> Result<()> {
for sink in &self.sinks {
sink.close().await?;
}
Ok(())
}
}
/// Null sink that discards all events
pub struct NullSink;
#[async_trait]
impl AuditSink for NullSink {
async fn write(&self, _event: &AuditEvent) -> Result<()> {
Ok(())
}
async fn flush(&self) -> Result<()> {
Ok(())
}
async fn close(&self) -> Result<()> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[tokio::test]
async fn test_file_sink() {
let dir = tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
let sink = FileSink::new(&path).await.unwrap();
let event = AuditEvent::authn_success("alice", "jwt");
sink.write(&event).await.unwrap();
sink.flush().await.unwrap();
// Read back and verify
let contents = tokio::fs::read_to_string(&path).await.unwrap();
assert!(contents.contains("alice"));
assert!(contents.contains("jwt"));
}
#[tokio::test]
async fn test_memory_sink() {
let sink = MemorySink::new(100);
let event1 = AuditEvent::authn_success("alice", "jwt");
let event2 = AuditEvent::authn_success("bob", "mtls");
sink.write(&event1).await.unwrap();
sink.write(&event2).await.unwrap();
assert_eq!(sink.count().await, 2);
let alice_events = sink.find_by_principal("alice").await;
assert_eq!(alice_events.len(), 1);
}
#[tokio::test]
async fn test_memory_sink_capacity() {
let sink = MemorySink::new(2);
for i in 0..5 {
let event = AuditEvent::authn_success(&format!("user-{}", i), "jwt");
sink.write(&event).await.unwrap();
}
// Should only have last 2 events
assert_eq!(sink.count().await, 2);
let events = sink.events().await;
assert_eq!(events[0].principal_id, Some("user-3".to_string()));
assert_eq!(events[1].principal_id, Some("user-4".to_string()));
}
#[tokio::test]
async fn test_multi_sink() {
let sink1 = Arc::new(MemorySink::new(100));
let sink2 = Arc::new(MemorySink::new(100));
let multi = MultiSink::new(vec![sink1.clone(), sink2.clone()]);
let event = AuditEvent::authn_success("alice", "jwt");
multi.write(&event).await.unwrap();
assert_eq!(sink1.count().await, 1);
assert_eq!(sink2.count().await, 1);
}
#[tokio::test]
async fn test_null_sink() {
let sink = NullSink;
let event = AuditEvent::authn_success("alice", "jwt");
// Should not error
sink.write(&event).await.unwrap();
sink.flush().await.unwrap();
sink.close().await.unwrap();
}
}