Skip to main content

minco_plugin_audit/
lib.rs

1//! Append-only audit events and a deterministic memory reference sink.
2#![forbid(unsafe_code)]
3
4use async_trait::async_trait;
5use chrono::{DateTime, Utc};
6use minco_core::{
7    CapabilityProvision, DataClass, Plugin, PluginContext, PluginDescriptor, PluginError, PluginId,
8    PluginStability,
9};
10use semver::{Version, VersionReq};
11use serde::{Deserialize, Serialize};
12use std::{collections::BTreeMap, sync::Arc};
13use tokio::sync::RwLock;
14use uuid::Uuid;
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub struct AuditEvent {
18    pub id: Uuid,
19    pub action: String,
20    pub resource_type: String,
21    pub resource_id: String,
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub actor_subject: Option<String>,
24    pub correlation_id: Uuid,
25    pub occurred_at: DateTime<Utc>,
26    #[serde(default)]
27    pub metadata: BTreeMap<String, serde_json::Value>,
28}
29
30impl AuditEvent {
31    pub fn new(
32        action: impl Into<String>,
33        resource_type: impl Into<String>,
34        resource_id: impl Into<String>,
35        correlation_id: Uuid,
36    ) -> Self {
37        Self {
38            id: Uuid::now_v7(),
39            action: action.into(),
40            resource_type: resource_type.into(),
41            resource_id: resource_id.into(),
42            actor_subject: None,
43            correlation_id,
44            occurred_at: Utc::now(),
45            metadata: BTreeMap::new(),
46        }
47    }
48}
49
50#[async_trait]
51pub trait AuditSink: Send + Sync + std::fmt::Debug {
52    async fn append(&self, event: AuditEvent) -> Result<(), AuditError>;
53}
54
55#[derive(Clone)]
56pub struct AuditService(pub Arc<dyn AuditSink>);
57
58impl std::fmt::Debug for AuditService {
59    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        formatter.debug_tuple("AuditService").finish()
61    }
62}
63
64impl AuditService {
65    pub fn new(sink: Arc<dyn AuditSink>) -> Self {
66        Self(sink)
67    }
68
69    pub async fn append(&self, event: AuditEvent) -> Result<(), AuditError> {
70        self.0.append(event).await
71    }
72}
73
74#[derive(Debug, Default)]
75pub struct MemoryAuditSink {
76    events: RwLock<Vec<AuditEvent>>,
77}
78
79impl MemoryAuditSink {
80    pub async fn all(&self) -> Vec<AuditEvent> {
81        self.events.read().await.clone()
82    }
83}
84
85#[async_trait]
86impl AuditSink for MemoryAuditSink {
87    async fn append(&self, event: AuditEvent) -> Result<(), AuditError> {
88        if event.action.trim().is_empty() || event.resource_id.trim().is_empty() {
89            return Err(AuditError::InvalidEvent);
90        }
91        self.events.write().await.push(event);
92        Ok(())
93    }
94}
95
96#[derive(Debug, Clone)]
97pub struct AuditPlugin {
98    service: AuditService,
99}
100
101impl AuditPlugin {
102    pub fn new(sink: Arc<dyn AuditSink>) -> Self {
103        Self {
104            service: AuditService::new(sink),
105        }
106    }
107
108    pub fn memory() -> (Self, Arc<MemoryAuditSink>) {
109        let sink = Arc::new(MemoryAuditSink::default());
110        (Self::new(sink.clone()), sink)
111    }
112}
113
114impl Plugin for AuditPlugin {
115    fn descriptor(&self) -> PluginDescriptor {
116        let mut descriptor = PluginDescriptor::new(
117            PluginId::new("audit").expect("static plugin ID"),
118            Version::new(1, 0, 0),
119            "Durable append-only audit history independent of operational logs",
120        );
121        descriptor.documentation = Some("https://docs.rs/minco-plugin-audit".into());
122        descriptor.core_compatibility =
123            VersionReq::parse(concat!("^", env!("CARGO_PKG_VERSION"))).expect("package version");
124        descriptor.stability = PluginStability::Beta;
125        descriptor.data_classes.extend([
126            DataClass::Internal,
127            DataClass::Personal,
128            DataClass::Confidential,
129        ]);
130        descriptor.provides.push(CapabilityProvision {
131            name: "audit.append".into(),
132            version: Version::new(1, 0, 0),
133        });
134        descriptor
135    }
136
137    fn install(&self, context: &mut PluginContext<'_>) -> Result<(), PluginError> {
138        context.services().insert(Arc::new(self.service.clone()))?;
139        Ok(())
140    }
141}
142
143#[derive(Debug, thiserror::Error)]
144pub enum AuditError {
145    #[error("audit events require a non-empty action and resource ID")]
146    InvalidEvent,
147    #[error("audit append failed: {0}")]
148    Append(String),
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[tokio::test]
156    async fn memory_sink_is_append_only_and_ordered() {
157        let sink = MemoryAuditSink::default();
158        let first = AuditEvent::new("feedback.created", "feedback", "one", Uuid::now_v7());
159        let second = AuditEvent::new("feedback.replied", "feedback", "one", Uuid::now_v7());
160        sink.append(first).await.unwrap();
161        sink.append(second).await.unwrap();
162        assert_eq!(
163            sink.all()
164                .await
165                .iter()
166                .map(|event| event.action.as_str())
167                .collect::<Vec<_>>(),
168            ["feedback.created", "feedback.replied"]
169        );
170    }
171}