Skip to main content

rustlavel_audit/
entry.rs

1//! One audit entry, and the builder that fills it in.
2
3use rustlavel_core::Json;
4
5/// A row in the trail, as it goes in and as it comes back out.
6#[derive(Debug, Clone, PartialEq)]
7pub struct Entry {
8    pub id: i64,
9    /// Who did it. `None` for something the system did on its own, or for an
10    /// action taken before signing in — a failed login has no user yet.
11    pub user_id: Option<i64>,
12    /// Their name at the time, not as it is now.
13    pub user_name: Option<String>,
14    pub event: String,
15    pub model_type: Option<String>,
16    pub model_id: Option<String>,
17    pub description: Option<String>,
18    pub properties: Json,
19    pub ip_address: Option<String>,
20    pub user_agent: Option<String>,
21    /// `YYYY-MM-DD HH:MM:SS` in UTC, the way the rest of the framework stores
22    /// a time: it sorts as text, and all three databases keep it identically.
23    pub created_at: String,
24}
25
26/// `Json` has no `Default`, and giving one to `properties` here would be
27/// choosing between "no properties" and "an empty object" for every caller.
28/// `Json::Null` is the honest one: nothing was recorded.
29impl Default for Entry {
30    fn default() -> Entry {
31        Entry {
32            id: 0,
33            user_id: None,
34            user_name: None,
35            event: String::new(),
36            model_type: None,
37            model_id: None,
38            description: None,
39            properties: Json::Null,
40            ip_address: None,
41            user_agent: None,
42            created_at: String::new(),
43        }
44    }
45}
46
47impl Entry {
48    pub fn new(event: impl Into<String>) -> Entry {
49        Entry { event: event.into(), ..Default::default() }
50    }
51
52    /// A one-line summary, falling back to something readable when the caller
53    /// left the description off.
54    pub fn summary(&self) -> String {
55        if let Some(description) = self.description.as_deref().filter(|d| !d.is_empty()) {
56            return description.to_string();
57        }
58        let who = self.user_name.as_deref().unwrap_or("Somebody");
59        match (&self.model_type, &self.model_id) {
60            (Some(kind), Some(id)) => format!("{who} performed {} on {kind} #{id}", self.event),
61            _ => format!("{who} performed {}", self.event),
62        }
63    }
64}
65
66/// Fills in an [`Entry`] and writes it.
67///
68/// Made by [`Trail::event`](crate::Trail::event) or, with the request context
69/// already filled in, by `req.audit(...)`.
70pub struct Builder {
71    pub(crate) trail: crate::store::Trail,
72    pub(crate) entry: Entry,
73}
74
75impl Builder {
76    /// Who did it. Takes the name as well, because a name read back later is
77    /// the name the account has now, which is not what happened.
78    pub fn by(mut self, id: i64, name: impl Into<String>) -> Builder {
79        self.entry.user_id = Some(id);
80        self.entry.user_name = Some(name.into());
81        self
82    }
83
84    /// The actor's name, when the id is not to hand.
85    ///
86    /// Separate from [`by`](Self::by) because the two are not always known
87    /// together: a request that is *in the middle of* signing somebody in has
88    /// their name but no identity on the request yet, and one recording a
89    /// failed attempt has neither.
90    pub fn named(mut self, name: impl Into<String>) -> Builder {
91        self.entry.user_name = Some(name.into());
92        self
93    }
94
95    /// What was acted on: a type name and its key.
96    pub fn on(mut self, kind: impl Into<String>, id: impl std::fmt::Display) -> Builder {
97        self.entry.model_type = Some(kind.into());
98        self.entry.model_id = Some(id.to_string());
99        self
100    }
101
102    /// The sentence somebody will read on the audit page.
103    pub fn describe(mut self, description: impl Into<String>) -> Builder {
104        self.entry.description = Some(description.into());
105        self
106    }
107
108    pub fn from_ip(mut self, ip: impl Into<String>) -> Builder {
109        self.entry.ip_address = Some(ip.into());
110        self
111    }
112
113    pub fn user_agent(mut self, agent: impl Into<String>) -> Builder {
114        self.entry.user_agent = Some(agent.into());
115        self
116    }
117
118    /// One extra field. Call it as often as needed.
119    pub fn with(mut self, key: &str, value: impl Into<Json>) -> Builder {
120        let mut properties = match std::mem::replace(&mut self.entry.properties, Json::Null) {
121            Json::Object(map) => map,
122            _ => Default::default(),
123        };
124        properties.insert(key.to_string(), value.into());
125        self.entry.properties = Json::Object(properties);
126        self
127    }
128
129    /// The before and after of a change, as one property each.
130    pub fn changed(self, from: impl Into<Json>, to: impl Into<Json>) -> Builder {
131        self.with("from", from).with("to", to)
132    }
133
134    /// The entry as it stands, without writing it. For a test.
135    pub fn entry(&self) -> &Entry {
136        &self.entry
137    }
138
139    /// Write it.
140    pub async fn save(self) -> rustlavel_core::Result<i64> {
141        self.trail.write(self.entry).await
142    }
143
144    /// Write it, and swallow a failure into a warning.
145    ///
146    /// For a caller on a path that must not fail because the trail is
147    /// unavailable — a sign-out, say. Losing an entry is bad; refusing to sign
148    /// somebody out because the audit table is missing is worse, and it is the
149    /// kind of coupling that takes an application down.
150    pub async fn record(self) {
151        let event = self.entry.event.clone();
152        if let Err(error) = self.save().await {
153            rustlavel_core::warn!("could not write the audit entry for {event}: {error}");
154        }
155    }
156}