minco_plugin_audit/
lib.rs1#![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}