Skip to main content

phoxia_auditlog/
lib.rs

1//! phoxia-auditlog — Automatic audit logging for Axum apps.
2//!
3//! # Quick start
4//!
5//! ```rust,no_run
6//! use phoxia_auditlog::{AuditLayer, AuditConfig};
7//! use sqlx::PgPool;
8//!
9//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
10//! let pool = PgPool::connect("postgres://...").await?;
11//! let config = AuditConfig::new(pool, "my-service");
12//! let (layer, _ctx) = AuditLayer::new(config);
13//! // Add `layer` to your Axum router with `.layer(layer)`
14//! # Ok(())
15//! # }
16//! ```
17
18pub mod auditable;
19pub mod config;
20pub mod context;
21pub mod event;
22pub mod layer;
23pub mod service;
24pub mod writer;
25
26pub use auditable::Auditable;
27pub use config::AuditConfig;
28pub use context::AuditContext;
29pub use event::AuditEvent;
30pub use layer::AuditLayer;
31
32// Re-exported for the audit! and audit_diff! macros
33pub use chrono;
34pub use serde_json;
35
36/// Log an explicit audit event from a handler.
37///
38/// # Usage
39///
40/// ```rust,no_run
41/// # use phoxia_auditlog::audit;
42/// # use phoxia_auditlog::AuditContext;
43/// # // In real code, get the context from AuditLayer::new()
44/// # // This is just a demonstration of the macro syntax
45/// # fn example(ctx: AuditContext) {
46/// audit!(ctx, "user.login", {
47///     "method": "passkey",
48///     "success": true,
49/// });
50/// # }
51/// ```
52///
53/// The first argument is an [`AuditContext`]. The second is the action name.
54/// The third is a JSON object literal using `serde_json::json!` syntax.
55#[macro_export]
56macro_rules! audit {
57    // Form: audit!(ctx, "action.name", { key: value, ... })
58    ($ctx:expr, $action:expr, { $($key:tt : $value:expr),* $(,)? }) => {
59        {
60            let event = $crate::AuditEvent {
61                user_id: None,
62                action: $action.into(),
63                ip: None,
64                method: None,
65                path: None,
66                status: None,
67                latency_ms: None,
68                metadata: Some($crate::serde_json::json!({ $($key: $value),* })),
69                service_name: String::new(),
70                ts: $crate::chrono::Utc::now(),
71            };
72            $crate::AuditContext::send(&$ctx, event);
73        }
74    };
75    // Form: audit!(ctx, "action.name") — no metadata
76    ($ctx:expr, $action:expr) => {
77        {
78            let event = $crate::AuditEvent {
79                user_id: None,
80                action: $action.into(),
81                ip: None,
82                method: None,
83                path: None,
84                status: None,
85                latency_ms: None,
86                metadata: None,
87                service_name: String::new(),
88                ts: $crate::chrono::Utc::now(),
89            };
90            $crate::AuditContext::send(&$ctx, event);
91        }
92    };
93}
94
95/// Log an audit event with before/after diff of a mutated value.
96///
97/// # Usage
98///
99/// ```rust,ignore
100/// let old_user = state.db.get_user(id).await?;
101/// let new_user = state.db.update_user(id, &payload).await?;
102///
103/// audit_diff!(state.audit, "user.updated", &old_user, &new_user);
104/// ```
105///
106/// The metadata will contain `before`, `after`, and `changed` (list of field names).
107/// (Extra metadata keys supported in a future version.)
108#[macro_export]
109macro_rules! audit_diff {
110    ($ctx:expr, $action:expr, $old:expr, $new:expr) => {
111        {
112            let changed: std::collections::HashSet<String> =
113                $crate::Auditable::changed_fields($old, $new);
114
115            let metadata = $crate::serde_json::json!({
116                "before": $crate::Auditable::to_audit_json($old),
117                "after": $crate::Auditable::to_audit_json($new),
118                "changed": changed.iter().collect::<Vec<_>>(),
119            });
120
121            let event = $crate::AuditEvent {
122                user_id: None,
123                action: $action.into(),
124                ip: None,
125                method: None,
126                path: None,
127                status: None,
128                latency_ms: None,
129                metadata: Some(metadata),
130                service_name: String::new(),
131                ts: $crate::chrono::Utc::now(),
132            };
133            $crate::AuditContext::send(&$ctx, event);
134        }
135    };
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn audit_macro_constructs_event_with_metadata() {
144        let (ctx, mut rx) = AuditContext::channel();
145
146        audit!(ctx, "user.login", {
147            "method": "passkey",
148            "success": true,
149        });
150
151        let event = rx.try_recv().expect("event should be sent");
152        assert_eq!(event.action, "user.login");
153        assert_eq!(
154            event.metadata,
155            Some(serde_json::json!({"method": "passkey", "success": true}))
156        );
157    }
158
159    #[test]
160    fn audit_macro_no_metadata() {
161        let (ctx, mut rx) = AuditContext::channel();
162
163        audit!(ctx, "health.check");
164
165        let event = rx.try_recv().expect("event should be sent");
166        assert_eq!(event.action, "health.check");
167        assert!(event.metadata.is_none());
168    }
169
170    #[test]
171    fn audit_diff_macro_includes_before_after_changed() {
172        use crate::auditable::Auditable;
173        use serde_json::Value;
174
175        #[derive(Clone)]
176        struct User {
177            name: String,
178        }
179        impl Auditable for User {
180            fn to_audit_json(&self) -> Value {
181                serde_json::json!({"name": self.name})
182            }
183        }
184
185        let (ctx, mut rx) = AuditContext::channel();
186        let old = User { name: "Alice".into() };
187        let new = User { name: "Bob".into() };
188
189        audit_diff!(ctx, "user.renamed", &old, &new);
190
191        let event = rx.try_recv().expect("event sent");
192        assert_eq!(event.action, "user.renamed");
193
194        let meta = event.metadata.expect("metadata present");
195        assert_eq!(meta["before"]["name"], "Alice");
196        assert_eq!(meta["after"]["name"], "Bob");
197        assert!(meta["changed"]
198            .as_array()
199            .unwrap()
200            .contains(&serde_json::Value::String("name".into())));
201    }
202}