Skip to main content

ferro_audit/
entry.rs

1//! `AuditEntry` — the persisted audit log row + chainable builder API.
2//!
3//! The builder enforces the only validation rule (`action` is required;
4//! D-10, D-16) and emits a `tracing::warn!` diagnostic if `write()` is
5//! called without a target (also D-10). The DB-stamped `created_at`
6//! (D-22) is re-fetched after INSERT per RESEARCH Pitfall 1 / F-12 — the
7//! SQLite driver does not return the default value in the INSERT response
8//! for UUID-PK entities.
9
10use sea_orm::{ActiveModelTrait, ActiveValue::Set, ConnectionTrait, DbErr, EntityTrait};
11use serde_json::Value as JsonValue;
12use uuid::Uuid;
13
14use crate::actor::AuditActor;
15use crate::entity;
16use crate::error::AuditError;
17use crate::target::AuditTarget;
18
19/// One persisted row of the `audit_log` table.
20///
21/// Type-aliased to the SeaORM `Model` so query helpers can return
22/// `Vec<AuditEntry>` directly without an intermediate conversion.
23pub type AuditEntry = entity::Model;
24
25impl AuditEntry {
26    /// Entry point. Returns a chainable builder for an audit entry.
27    ///
28    /// `action` is the only required field (D-10). The builder defaults
29    /// `actor` to `AuditActor::System` (D-10) and leaves every other field
30    /// at `None` until set. Call `.write(&conn)` to persist.
31    ///
32    /// # Example
33    /// ```rust,ignore
34    /// use ferro_audit::{AuditEntry, AuditActor, AuditTarget};
35    /// use serde_json::json;
36    ///
37    /// AuditEntry::record("inventory.stock.adjust")
38    ///     .actor(AuditActor::User("u_42".into()))
39    ///     .target(AuditTarget::new("inventory.unit", "abc"))
40    ///     .before(json!({ "quantity": 5 }))
41    ///     .after(json!({ "quantity": 4 }))
42    ///     .write(&conn)
43    ///     .await?;
44    /// ```
45    pub fn record(action: impl Into<String>) -> AuditEntryBuilder {
46        AuditEntryBuilder {
47            action: action.into(),
48            actor: AuditActor::System,
49            target: None,
50            before: None,
51            after: None,
52            reason: None,
53            correlation_id: None,
54            tenant_id: None,
55        }
56    }
57}
58
59/// Chainable builder for an audit entry.
60///
61/// Construct via [`AuditEntry::record`]; every setter consumes the builder
62/// and returns `Self`. Terminate with `.write(&conn).await?` to persist.
63#[derive(Debug)]
64pub struct AuditEntryBuilder {
65    action: String,
66    actor: AuditActor,
67    target: Option<AuditTarget>,
68    before: Option<JsonValue>,
69    after: Option<JsonValue>,
70    reason: Option<String>,
71    correlation_id: Option<Uuid>,
72    tenant_id: Option<String>,
73}
74
75impl AuditEntryBuilder {
76    /// Set the actor for this entry. Defaults to `AuditActor::System` (D-10).
77    pub fn actor(mut self, actor: AuditActor) -> Self {
78        self.actor = actor;
79        self
80    }
81
82    /// Set the target this entry is about. Optional but strongly recommended
83    /// (without a target, `history_for_target` will not return this entry —
84    /// D-10 emits a `tracing::warn!` diagnostic at `write()` time).
85    pub fn target(mut self, target: AuditTarget) -> Self {
86        self.target = Some(target);
87        self
88    }
89
90    /// JSON snapshot of the target state BEFORE the action.
91    pub fn before(mut self, before: JsonValue) -> Self {
92        self.before = Some(before);
93        self
94    }
95
96    /// JSON snapshot of the target state AFTER the action.
97    pub fn after(mut self, after: JsonValue) -> Self {
98        self.after = Some(after);
99        self
100    }
101
102    /// Free-text cause / reason (e.g. `"order_committed"`).
103    pub fn reason(mut self, reason: impl Into<String>) -> Self {
104        self.reason = Some(reason.into());
105        self
106    }
107
108    /// Caller-supplied correlation id. Optional (D-12); future framework
109    /// plumbing may populate this automatically.
110    pub fn correlation(mut self, correlation_id: Uuid) -> Self {
111        self.correlation_id = Some(correlation_id);
112        self
113    }
114
115    /// Tenant scoping. Stringly-typed (D-13) — ferro has no first-class
116    /// tenant primitive yet.
117    pub fn tenant(mut self, tenant_id: impl Into<String>) -> Self {
118        self.tenant_id = Some(tenant_id.into());
119        self
120    }
121
122    /// Persist the audit entry. Returns the persisted [`AuditEntry`] with
123    /// the generated `id` and DB-stamped `created_at`.
124    ///
125    /// Errors:
126    /// - [`AuditError::MissingAction`] if `action` is empty (D-10, D-16)
127    /// - [`AuditError::Db`] on any SeaORM error
128    ///
129    /// Missing target is NOT an error (D-10) — a `tracing::warn!` diagnostic
130    /// is emitted instead.
131    pub async fn write<C: ConnectionTrait>(self, conn: &C) -> Result<AuditEntry, AuditError> {
132        if self.action.is_empty() {
133            return Err(AuditError::MissingAction);
134        }
135
136        if self.target.is_none() {
137            tracing::warn!(
138                action = %self.action,
139                "audit entry written without a target — history_for_target will not find this entry"
140            );
141        }
142
143        let new_id = Uuid::new_v4();
144        let (target_kind, target_id) = match self.target {
145            Some(t) => (Some(t.kind), Some(t.id)),
146            None => (None, None),
147        };
148
149        let actor_kind = self.actor.kind().to_string();
150        let actor_id = self.actor.id().map(|s| s.to_string());
151
152        let active = entity::ActiveModel {
153            id: Set(new_id),
154            tenant_id: Set(self.tenant_id),
155            actor_kind: Set(actor_kind),
156            actor_id: Set(actor_id),
157            action: Set(self.action),
158            target_kind: Set(target_kind),
159            target_id: Set(target_id),
160            before: Set(self.before),
161            after: Set(self.after),
162            reason: Set(self.reason),
163            correlation_id: Set(self.correlation_id),
164            // created_at intentionally NotSet — DB default
165            // `CURRENT_TIMESTAMP` fires at INSERT time (D-22).
166            created_at: sea_orm::ActiveValue::NotSet,
167        };
168
169        active.insert(conn).await?;
170
171        // RESEARCH Pitfall 1 / F-12: re-fetch by id to populate
172        // DB-stamped `created_at`. SeaORM SQLite + UUID PK does not
173        // return the default value in the INSERT response.
174        let persisted = entity::Entity::find_by_id(new_id)
175            .one(conn)
176            .await?
177            .ok_or_else(|| {
178                AuditError::Db(DbErr::RecordNotFound(
179                    "audit_log: row vanished after INSERT".to_string(),
180                ))
181            })?;
182
183        Ok(persisted)
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use chrono::NaiveDateTime;
191    use sea_orm::{Database, DatabaseConnection};
192    use sea_orm_migration::prelude::*;
193    use serde_json::json;
194
195    struct TestMigrator;
196
197    #[async_trait::async_trait]
198    impl MigratorTrait for TestMigrator {
199        fn migrations() -> Vec<Box<dyn sea_orm_migration::MigrationTrait>> {
200            vec![Box::new(crate::migration::Migration)]
201        }
202    }
203
204    async fn fresh_db() -> DatabaseConnection {
205        let conn = Database::connect("sqlite::memory:")
206            .await
207            .expect("connect sqlite::memory:");
208        TestMigrator::up(&conn, None).await.expect("run migration");
209        conn
210    }
211
212    #[tokio::test]
213    async fn happy_path() {
214        let conn = fresh_db().await;
215
216        let entry = AuditEntry::record("inventory.stock.adjust")
217            .actor(AuditActor::User("u_42".into()))
218            .target(AuditTarget::new("inventory.unit", "abc"))
219            .before(json!({ "quantity": 5 }))
220            .after(json!({ "quantity": 4 }))
221            .reason("order_committed")
222            .write(&conn)
223            .await
224            .expect("write happy_path");
225
226        assert_ne!(entry.id, Uuid::nil(), "id should be a fresh UUIDv4");
227        assert_ne!(
228            entry.created_at,
229            NaiveDateTime::default(),
230            "created_at should be DB-stamped"
231        );
232        assert_eq!(entry.action, "inventory.stock.adjust");
233        assert_eq!(entry.actor_kind, "user");
234        assert_eq!(entry.actor_id, Some("u_42".to_string()));
235        assert_eq!(entry.target_kind, Some("inventory.unit".to_string()));
236        assert_eq!(entry.target_id, Some("abc".to_string()));
237        assert_eq!(entry.before, Some(json!({ "quantity": 5 })));
238        assert_eq!(entry.after, Some(json!({ "quantity": 4 })));
239        assert_eq!(entry.reason, Some("order_committed".to_string()));
240    }
241
242    #[tokio::test]
243    async fn missing_action() {
244        let conn = fresh_db().await;
245
246        let err = AuditEntry::record("")
247            .actor(AuditActor::System)
248            .target(AuditTarget::new("inventory.unit", "abc"))
249            .write(&conn)
250            .await
251            .expect_err("empty action must fail");
252
253        assert!(matches!(err, AuditError::MissingAction));
254        assert_eq!(err.to_string(), "audit: action is required");
255    }
256
257    #[tokio::test]
258    async fn missing_target_writes() {
259        let conn = fresh_db().await;
260
261        // The tracing::warn! is fire-and-forget — we don't capture it in a
262        // subscriber here (would require tracing-subscriber as dev-dep).
263        // The test asserts the WRITE succeeds despite the missing target,
264        // which is the load-bearing behavior contract (D-10).
265        let entry = AuditEntry::record("user.password_reset_requested")
266            .actor(AuditActor::User("u_42".into()))
267            .write(&conn)
268            .await
269            .expect("write without target should succeed");
270
271        assert_eq!(entry.target_kind, None);
272        assert_eq!(entry.target_id, None);
273        assert_eq!(entry.action, "user.password_reset_requested");
274    }
275
276    #[tokio::test]
277    async fn json_roundtrip() {
278        let conn = fresh_db().await;
279
280        let complex = json!({
281            "quantity": 42,
282            "status": "reserved",
283            "tags": ["urgent", "vip"],
284            "nested": { "depth": 2, "items": [1, 2, 3] }
285        });
286
287        let entry = AuditEntry::record("inventory.unit.updated")
288            .target(AuditTarget::new("inventory.unit", "abc"))
289            .after(complex.clone())
290            .write(&conn)
291            .await
292            .expect("write json_roundtrip");
293
294        // Re-read via the entity directly to confirm DB round-trip.
295        let read_back = entity::Entity::find_by_id(entry.id)
296            .one(&conn)
297            .await
298            .expect("re-fetch")
299            .expect("entry exists");
300
301        assert_eq!(read_back.after, Some(complex));
302    }
303
304    #[tokio::test]
305    async fn actor_null_id() {
306        let conn = fresh_db().await;
307
308        // System actor → actor_id is NULL in DB
309        let sys_entry = AuditEntry::record("system.cleanup")
310            .actor(AuditActor::System)
311            .target(AuditTarget::new("system.task", "cleanup"))
312            .write(&conn)
313            .await
314            .expect("write system");
315        assert_eq!(sys_entry.actor_kind, "system");
316        assert_eq!(sys_entry.actor_id, None);
317
318        // Anonymous actor → actor_id is NULL in DB
319        let anon_entry = AuditEntry::record("public.page_view")
320            .actor(AuditActor::Anonymous)
321            .target(AuditTarget::new("page", "/about"))
322            .write(&conn)
323            .await
324            .expect("write anonymous");
325        assert_eq!(anon_entry.actor_kind, "anonymous");
326        assert_eq!(anon_entry.actor_id, None);
327
328        // ApiClient with id → actor_id is the contained string
329        let api_entry = AuditEntry::record("api.token.refresh")
330            .actor(AuditActor::ApiClient("oauth_client_xyz".into()))
331            .target(AuditTarget::new("api.client", "oauth_client_xyz"))
332            .write(&conn)
333            .await
334            .expect("write api_client");
335        assert_eq!(api_entry.actor_kind, "api_client");
336        assert_eq!(api_entry.actor_id, Some("oauth_client_xyz".to_string()));
337    }
338}