minco-plugin-audit 1.0.0

Append-only audit event port and reference memory sink for Minco
Documentation
//! Append-only audit events and a deterministic memory reference sink.
#![forbid(unsafe_code)]

use async_trait::async_trait;
use chrono::{DateTime, Utc};
use minco_core::{
    CapabilityProvision, DataClass, Plugin, PluginContext, PluginDescriptor, PluginError, PluginId,
    PluginStability,
};
use semver::{Version, VersionReq};
use serde::{Deserialize, Serialize};
use std::{collections::BTreeMap, sync::Arc};
use tokio::sync::RwLock;
use uuid::Uuid;

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuditEvent {
    pub id: Uuid,
    pub action: String,
    pub resource_type: String,
    pub resource_id: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub actor_subject: Option<String>,
    pub correlation_id: Uuid,
    pub occurred_at: DateTime<Utc>,
    #[serde(default)]
    pub metadata: BTreeMap<String, serde_json::Value>,
}

impl AuditEvent {
    pub fn new(
        action: impl Into<String>,
        resource_type: impl Into<String>,
        resource_id: impl Into<String>,
        correlation_id: Uuid,
    ) -> Self {
        Self {
            id: Uuid::now_v7(),
            action: action.into(),
            resource_type: resource_type.into(),
            resource_id: resource_id.into(),
            actor_subject: None,
            correlation_id,
            occurred_at: Utc::now(),
            metadata: BTreeMap::new(),
        }
    }
}

#[async_trait]
pub trait AuditSink: Send + Sync + std::fmt::Debug {
    async fn append(&self, event: AuditEvent) -> Result<(), AuditError>;
}

#[derive(Clone)]
pub struct AuditService(pub Arc<dyn AuditSink>);

impl std::fmt::Debug for AuditService {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        formatter.debug_tuple("AuditService").finish()
    }
}

impl AuditService {
    pub fn new(sink: Arc<dyn AuditSink>) -> Self {
        Self(sink)
    }

    pub async fn append(&self, event: AuditEvent) -> Result<(), AuditError> {
        self.0.append(event).await
    }
}

#[derive(Debug, Default)]
pub struct MemoryAuditSink {
    events: RwLock<Vec<AuditEvent>>,
}

impl MemoryAuditSink {
    pub async fn all(&self) -> Vec<AuditEvent> {
        self.events.read().await.clone()
    }
}

#[async_trait]
impl AuditSink for MemoryAuditSink {
    async fn append(&self, event: AuditEvent) -> Result<(), AuditError> {
        if event.action.trim().is_empty() || event.resource_id.trim().is_empty() {
            return Err(AuditError::InvalidEvent);
        }
        self.events.write().await.push(event);
        Ok(())
    }
}

#[derive(Debug, Clone)]
pub struct AuditPlugin {
    service: AuditService,
}

impl AuditPlugin {
    pub fn new(sink: Arc<dyn AuditSink>) -> Self {
        Self {
            service: AuditService::new(sink),
        }
    }

    pub fn memory() -> (Self, Arc<MemoryAuditSink>) {
        let sink = Arc::new(MemoryAuditSink::default());
        (Self::new(sink.clone()), sink)
    }
}

impl Plugin for AuditPlugin {
    fn descriptor(&self) -> PluginDescriptor {
        let mut descriptor = PluginDescriptor::new(
            PluginId::new("audit").expect("static plugin ID"),
            Version::new(1, 0, 0),
            "Durable append-only audit history independent of operational logs",
        );
        descriptor.documentation = Some("https://docs.rs/minco-plugin-audit".into());
        descriptor.core_compatibility =
            VersionReq::parse(concat!("^", env!("CARGO_PKG_VERSION"))).expect("package version");
        descriptor.stability = PluginStability::Beta;
        descriptor.data_classes.extend([
            DataClass::Internal,
            DataClass::Personal,
            DataClass::Confidential,
        ]);
        descriptor.provides.push(CapabilityProvision {
            name: "audit.append".into(),
            version: Version::new(1, 0, 0),
        });
        descriptor
    }

    fn install(&self, context: &mut PluginContext<'_>) -> Result<(), PluginError> {
        context.services().insert(Arc::new(self.service.clone()))?;
        Ok(())
    }
}

#[derive(Debug, thiserror::Error)]
pub enum AuditError {
    #[error("audit events require a non-empty action and resource ID")]
    InvalidEvent,
    #[error("audit append failed: {0}")]
    Append(String),
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn memory_sink_is_append_only_and_ordered() {
        let sink = MemoryAuditSink::default();
        let first = AuditEvent::new("feedback.created", "feedback", "one", Uuid::now_v7());
        let second = AuditEvent::new("feedback.replied", "feedback", "one", Uuid::now_v7());
        sink.append(first).await.unwrap();
        sink.append(second).await.unwrap();
        assert_eq!(
            sink.all()
                .await
                .iter()
                .map(|event| event.action.as_str())
                .collect::<Vec<_>>(),
            ["feedback.created", "feedback.replied"]
        );
    }
}