Skip to main content

adminx_audit/
store.rs

1// adminx-audit/src/store.rs
2//
3// The `Auditor` implementation: turn an `AuditEntry` into a row and write it
4// through adminx-core's `Storage`, so the same code serves SeaORM (SQL) and
5// MongoDB without naming either.
6
7use adminx_core::audit::{AuditEntry, Auditor};
8use adminx_core::storage::{storage, FilterClause, FilterOp, QueryOptions, StorageError};
9use async_trait::async_trait;
10use serde_json::{Map, Value};
11
12/// Where audit rows are written. Exposed so a deployment can point the log at a
13/// separate table (or a separate database, via a storage backend of its own).
14pub const TABLE: &str = "adminx_audit_versions";
15
16/// Writes audit entries through the globally registered `Storage`.
17pub struct StorageAuditor {
18    strict: bool,
19}
20
21impl StorageAuditor {
22    pub fn new(strict: bool) -> Self {
23        Self { strict }
24    }
25
26    /// Flatten an entry into the column map the storage layer inserts.
27    ///
28    /// `changes` is serialized to a JSON *string* rather than nested as a JSON
29    /// value: it is the one representation both a SQL `TEXT`/`JSONB` column and
30    /// a Mongo document accept unchanged, and it keeps the row shape flat for
31    /// the generic list/filter machinery the panel reuses.
32    fn row(entry: &AuditEntry) -> Map<String, Value> {
33        let mut row = Map::new();
34        row.insert("item_type".into(), Value::String(entry.item_type.clone()));
35        row.insert("item_id".into(), Value::String(entry.item_id.clone()));
36        row.insert(
37            "event".into(),
38            Value::String(entry.event.as_str().to_string()),
39        );
40        row.insert(
41            "whodunnit".into(),
42            entry
43                .whodunnit
44                .clone()
45                .map(Value::String)
46                .unwrap_or(Value::Null),
47        );
48        row.insert(
49            "whodunnit_email".into(),
50            entry
51                .whodunnit_email
52                .clone()
53                .map(Value::String)
54                .unwrap_or(Value::Null),
55        );
56        row.insert(
57            "changes".into(),
58            Value::String(
59                serde_json::to_string(&Value::Object(entry.changes.clone()))
60                    .unwrap_or_else(|_| "{}".into()),
61            ),
62        );
63        // Recorded here rather than left to a column default, so the timestamp
64        // is identical across backends and doesn't depend on DB clock config.
65        row.insert(
66            "created_at".into(),
67            Value::String(chrono::Utc::now().to_rfc3339()),
68        );
69        row
70    }
71}
72
73#[async_trait]
74impl Auditor for StorageAuditor {
75    async fn record(&self, entry: AuditEntry) -> Result<(), StorageError> {
76        storage().create(TABLE, Self::row(&entry)).await?;
77        Ok(())
78    }
79
80    /// Newest first, which for an append-only table is descending primary key —
81    /// stable even when two entries share a `created_at` timestamp.
82    async fn history(
83        &self,
84        item_type: &str,
85        item_id: &str,
86        limit: u64,
87    ) -> Result<Vec<Value>, StorageError> {
88        let opts = QueryOptions {
89            page: 1,
90            per_page: limit,
91            sort_by: Some("id".to_string()),
92            sort_desc: true,
93            filters: vec![
94                FilterClause {
95                    field: "item_type".into(),
96                    op: FilterOp::Eq,
97                    value: item_type.to_string(),
98                },
99                FilterClause {
100                    field: "item_id".into(),
101                    op: FilterOp::Eq,
102                    value: item_id.to_string(),
103                },
104            ],
105        };
106        Ok(storage().list(TABLE, &opts).await?.rows)
107    }
108
109    fn strict(&self) -> bool {
110        self.strict
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use adminx_core::audit::Event;
118    use adminx_core::request::{Claims, ReqCtx};
119    use serde_json::json;
120
121    fn entry() -> AuditEntry {
122        let ctx = ReqCtx::new().with_claims(Claims {
123            sub: "7".into(),
124            email: "admin@example.com".into(),
125            ..Default::default()
126        });
127        let mut changes = Map::new();
128        changes.insert("title".into(), json!(["Old", "New"]));
129        AuditEntry::new(&ctx, "posts", "42", Event::Update, changes)
130    }
131
132    #[test]
133    fn row_carries_the_actor_and_the_target() {
134        let row = StorageAuditor::row(&entry());
135        assert_eq!(row["item_type"], json!("posts"));
136        assert_eq!(row["item_id"], json!("42"));
137        assert_eq!(row["event"], json!("update"));
138        assert_eq!(row["whodunnit"], json!("7"));
139        assert_eq!(row["whodunnit_email"], json!("admin@example.com"));
140    }
141
142    #[test]
143    fn changes_serialize_to_a_json_string() {
144        let row = StorageAuditor::row(&entry());
145        let text = row["changes"].as_str().expect("changes must be a string");
146        let parsed: Value = serde_json::from_str(text).expect("must be valid JSON");
147        assert_eq!(parsed["title"], json!(["Old", "New"]));
148    }
149
150    #[test]
151    fn an_anonymous_actor_is_null_not_empty() {
152        // Auth unconfigured: the log should say "nobody was identified", not
153        // record an empty string that reads like a real user id.
154        let ctx = ReqCtx::new();
155        let e = AuditEntry::new(&ctx, "posts", "1", Event::Create, Map::new());
156        let row = StorageAuditor::row(&e);
157        assert_eq!(row["whodunnit"], Value::Null);
158        assert_eq!(row["whodunnit_email"], Value::Null);
159    }
160
161    #[test]
162    fn created_at_is_rfc3339() {
163        let row = StorageAuditor::row(&entry());
164        let at = row["created_at"].as_str().unwrap();
165        assert!(
166            chrono::DateTime::parse_from_rfc3339(at).is_ok(),
167            "not RFC3339: {at}"
168        );
169    }
170}