Skip to main content

modo/audit/
entry.rs

1use crate::ip::ClientInfo;
2
3/// An audit event to be recorded.
4///
5/// Constructed with four required fields (actor, action, resource_type,
6/// resource_id) and optional builder methods for metadata, client context,
7/// and tenant.
8///
9/// ```
10/// use modo::audit::AuditEntry;
11///
12/// let entry = AuditEntry::new("user_123", "user.role.changed", "user", "usr_abc")
13///     .metadata(serde_json::json!({"old_role": "editor"}))
14///     .tenant_id("tenant_1");
15/// ```
16#[derive(Debug, Clone)]
17pub struct AuditEntry {
18    actor: String,
19    action: String,
20    resource_type: String,
21    resource_id: String,
22    metadata: Option<serde_json::Value>,
23    client_info: Option<ClientInfo>,
24    tenant_id: Option<String>,
25}
26
27impl AuditEntry {
28    /// Create a new audit entry with four required fields.
29    pub fn new(
30        actor: impl Into<String>,
31        action: impl Into<String>,
32        resource_type: impl Into<String>,
33        resource_id: impl Into<String>,
34    ) -> Self {
35        Self {
36            actor: actor.into(),
37            action: action.into(),
38            resource_type: resource_type.into(),
39            resource_id: resource_id.into(),
40            metadata: None,
41            client_info: None,
42            tenant_id: None,
43        }
44    }
45
46    /// Attach metadata to the audit entry.
47    ///
48    /// Accepts a [`serde_json::Value`] — use [`serde_json::json!`] at the
49    /// call site for structured data, or [`serde_json::to_value`] for custom
50    /// types.
51    pub fn metadata(mut self, meta: serde_json::Value) -> Self {
52        self.metadata = Some(meta);
53        self
54    }
55
56    /// Attach client context (IP, user-agent, fingerprint).
57    pub fn client_info(mut self, info: ClientInfo) -> Self {
58        self.client_info = Some(info);
59        self
60    }
61
62    /// Set tenant ID for multi-tenant apps.
63    pub fn tenant_id(mut self, id: impl Into<String>) -> Self {
64        self.tenant_id = Some(id.into());
65        self
66    }
67
68    /// Returns the actor who performed the action.
69    pub fn actor(&self) -> &str {
70        &self.actor
71    }
72
73    /// Returns the action identifier (e.g. `"user.role.changed"`).
74    pub fn action(&self) -> &str {
75        &self.action
76    }
77
78    /// Returns the resource type (e.g. `"user"`, `"document"`).
79    pub fn resource_type(&self) -> &str {
80        &self.resource_type
81    }
82
83    /// Returns the resource identifier.
84    pub fn resource_id(&self) -> &str {
85        &self.resource_id
86    }
87
88    /// Returns the optional metadata JSON value.
89    pub fn metadata_value(&self) -> Option<&serde_json::Value> {
90        self.metadata.as_ref()
91    }
92
93    /// Returns the optional client context.
94    pub fn client_info_value(&self) -> Option<&ClientInfo> {
95        self.client_info.as_ref()
96    }
97
98    /// Returns the optional tenant ID.
99    pub fn tenant_id_value(&self) -> Option<&str> {
100        self.tenant_id.as_deref()
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn new_sets_required_fields() {
110        let entry = AuditEntry::new("user_123", "user.created", "user", "usr_abc");
111        assert_eq!(entry.actor(), "user_123");
112        assert_eq!(entry.action(), "user.created");
113        assert_eq!(entry.resource_type(), "user");
114        assert_eq!(entry.resource_id(), "usr_abc");
115        assert!(entry.metadata_value().is_none());
116        assert!(entry.client_info_value().is_none());
117        assert!(entry.tenant_id_value().is_none());
118    }
119
120    #[test]
121    fn metadata_with_json_value() {
122        let entry = AuditEntry::new("user_123", "user.role.changed", "user", "usr_abc")
123            .metadata(serde_json::json!({"old_role": "editor", "new_role": "admin"}));
124        let meta = entry.metadata_value().unwrap();
125        assert_eq!(meta["old_role"], "editor");
126        assert_eq!(meta["new_role"], "admin");
127    }
128
129    #[test]
130    fn metadata_with_serializable_struct() {
131        #[derive(serde::Serialize)]
132        struct RoleChange {
133            old_role: String,
134            new_role: String,
135        }
136
137        let entry = AuditEntry::new("user_123", "user.role.changed", "user", "usr_abc").metadata(
138            serde_json::to_value(RoleChange {
139                old_role: "editor".into(),
140                new_role: "admin".into(),
141            })
142            .unwrap(),
143        );
144        let meta = entry.metadata_value().unwrap();
145        assert_eq!(meta["old_role"], "editor");
146        assert_eq!(meta["new_role"], "admin");
147    }
148
149    #[test]
150    fn client_info_attached() {
151        use crate::ip::ClientInfo;
152
153        let info = ClientInfo::new().ip("1.2.3.4").user_agent("Bot/1.0");
154        let entry = AuditEntry::new("system", "job.ran", "job", "job_1").client_info(info);
155        let ci = entry.client_info_value().unwrap();
156        assert_eq!(ci.ip_value(), Some("1.2.3.4"));
157        assert_eq!(ci.user_agent_value(), Some("Bot/1.0"));
158    }
159
160    #[test]
161    fn tenant_id_set() {
162        let entry =
163            AuditEntry::new("user_123", "doc.deleted", "document", "doc_1").tenant_id("tenant_abc");
164        assert_eq!(entry.tenant_id_value(), Some("tenant_abc"));
165    }
166}