Skip to main content

kasl_server/
audit.rs

1//! The audit log: who did what, to whom, and when.
2//!
3//! Two properties make it worth a table rather than a log line (ADR 0010).
4//! It is queryable - "everything that happened to this person" is a `WHERE`,
5//! not a grep across rotated files. And it is part of the data, so it survives
6//! wherever the database is backed up to.
7//!
8//! Writing an entry must never cost a request its work. Every recorded action
9//! has already happened by the time it is logged; a failure here is reported
10//! loudly and swallowed, because refusing a token revocation because its audit
11//! entry would not write is worse in every direction.
12
13use axum::{
14    Json,
15    extract::{Query, State},
16    response::IntoResponse,
17};
18use chrono::{DateTime, Utc};
19use serde::{Deserialize, Serialize};
20use sqlx::PgPool;
21use uuid::Uuid;
22
23use crate::{app::AppState, error::ApiError, login::CurrentUser};
24
25/// What happened. Dotted, past tense, `subject.verb`.
26///
27/// Constants rather than an enum: the set is open - the finance milestone adds
28/// its own - and a string that reaches the database unchanged is one less place
29/// for a rename to silently reclassify history.
30pub mod action {
31    pub const USER_CREATED: &str = "user.created";
32    pub const USER_UPDATED: &str = "user.updated";
33    pub const AGENT_ISSUED: &str = "agent.issued";
34    pub const AGENT_REVOKED: &str = "agent.revoked";
35    pub const DEPARTMENT_CREATED: &str = "department.created";
36    pub const DEPARTMENT_UPDATED: &str = "department.updated";
37    pub const DEPARTMENT_DELETED: &str = "department.deleted";
38    pub const DEPARTMENT_ASSIGNED: &str = "department.assigned";
39    pub const LOGIN_SUCCEEDED: &str = "auth.login";
40    pub const LOGIN_FAILED: &str = "auth.login_failed";
41    pub const PASSWORD_CHANGED: &str = "auth.password_changed";
42    pub const SESSIONS_ENDED: &str = "auth.sessions_ended";
43    pub const PRIVACY_LEVEL_CHANGED: &str = "privacy.level_changed";
44    pub const CALENDAR_YEAR_REPLACED: &str = "calendar.year_replaced";
45    pub const STANDARD_HOURS_CHANGED: &str = "calendar.standard_hours_changed";
46    pub const WORK_RATE_CHANGED: &str = "calendar.work_rate_changed";
47    pub const ALERT_ACKNOWLEDGED: &str = "alert.acknowledged";
48    pub const ALERT_THRESHOLDS_CHANGED: &str = "alert.thresholds_changed";
49    pub const DEMO_SEEDED: &str = "demo.seeded";
50}
51
52/// One entry being written.
53///
54/// Built with the chained setters below so a call site reads as a sentence and
55/// so adding a field later does not touch every caller.
56#[derive(Debug, Default)]
57pub struct Entry {
58    actor_id: Option<Uuid>,
59    actor_email: Option<String>,
60    action: String,
61    target_id: Option<Uuid>,
62    target_label: Option<String>,
63    details: Option<serde_json::Value>,
64}
65
66impl Entry {
67    pub fn new(action: &str) -> Self {
68        Self {
69            action: action.to_string(),
70            ..Default::default()
71        }
72    }
73
74    /// The person who acted. Absent for the server acting on its own.
75    pub fn by(mut self, actor_id: Uuid) -> Self {
76        self.actor_id = Some(actor_id);
77        self
78    }
79
80    /// The actor's email, kept as text so an entry stays readable after a
81    /// rename or a deletion.
82    pub fn by_email(mut self, email: impl Into<String>) -> Self {
83        self.actor_email = Some(email.into());
84        self
85    }
86
87    pub fn on(mut self, target_id: Uuid) -> Self {
88        self.target_id = Some(target_id);
89        self
90    }
91
92    /// A human label for the target - an email, a department name.
93    pub fn labelled(mut self, label: impl Into<String>) -> Self {
94        self.target_label = Some(label.into());
95        self
96    }
97
98    /// Extra context. Never credentials: this is read in an admin UI and
99    /// pasted into support tickets.
100    pub fn with(mut self, details: serde_json::Value) -> Self {
101        self.details = Some(details);
102        self
103    }
104
105    /// Writes the entry, or complains loudly and carries on.
106    ///
107    /// The action being recorded has already happened. Failing the request
108    /// because its audit entry did not write would undo nothing - the token is
109    /// already revoked - and would turn a full disk into an outage.
110    pub async fn record(self, pool: &PgPool) {
111        let result = sqlx::query(
112            "INSERT INTO audit_log (actor_id, actor_email, action, target_id, target_label, details)
113             VALUES ($1, $2, $3, $4, $5, $6)",
114        )
115        .bind(self.actor_id)
116        .bind(self.actor_email.as_deref())
117        .bind(&self.action)
118        .bind(self.target_id)
119        .bind(self.target_label.as_deref())
120        .bind(self.details.as_ref())
121        .execute(pool)
122        .await;
123
124        if let Err(error) = result {
125            // At error level on purpose: an audit log that stops recording
126            // without anyone noticing is worse than one that never existed,
127            // because it is trusted.
128            tracing::error!(%error, action = %self.action, "failed to write an audit entry");
129        }
130    }
131}
132
133/// An entry as the admin screens read it.
134#[derive(Debug, Serialize, sqlx::FromRow)]
135pub struct AuditRow {
136    pub id: i64,
137    pub actor_id: Option<Uuid>,
138    pub actor_email: Option<String>,
139    pub action: String,
140    pub target_id: Option<Uuid>,
141    pub target_label: Option<String>,
142    pub details: Option<serde_json::Value>,
143    pub at: DateTime<Utc>,
144}
145
146/// Which slice of the log to read.
147#[derive(Debug, Deserialize)]
148pub struct AuditQuery {
149    /// Everything this person did.
150    pub actor_id: Option<Uuid>,
151    /// Everything done to this person or thing.
152    pub target_id: Option<Uuid>,
153    /// One kind of action, e.g. `agent.issued`.
154    pub action: Option<String>,
155    pub since: Option<DateTime<Utc>>,
156    pub until: Option<DateTime<Utc>>,
157    /// How many entries to return. Clamped; see `MAX_LIMIT`.
158    pub limit: Option<i64>,
159    /// How many to skip, for paging back through history.
160    pub offset: Option<i64>,
161}
162
163/// The most entries one request may return.
164///
165/// A bound rather than a page size an admin can raise: this table grows without
166/// limit, and an unbounded read of it is a way to take the server down with a
167/// single request.
168const MAX_LIMIT: i64 = 500;
169const DEFAULT_LIMIT: i64 = 100;
170
171// Neither constant has a unit test. Asserting `DEFAULT_LIMIT <= MAX_LIMIT` here
172// would compare two literals and pass at compile time regardless of what the
173// handler does with them; the clamp is exercised against the running handler in
174// tests/audit.rs, where a request for more than the ceiling must come back with
175// exactly the ceiling.
176
177/// Reads the log. Administrators only.
178///
179/// A manager is deliberately not admitted. The log records who changed what,
180/// and until a manager can change anything (ADR 0008) their view of it would
181/// consist entirely of other people's actions.
182pub async fn list(State(state): State<AppState>, user: CurrentUser, Query(query): Query<AuditQuery>) -> Result<impl IntoResponse, ApiError> {
183    user.require_admin()?;
184
185    let limit = query.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT);
186    let offset = query.offset.unwrap_or(0).max(0);
187
188    let entries: Vec<AuditRow> = sqlx::query_as(
189        "SELECT id, actor_id, actor_email, action, target_id, target_label, details, at
190         FROM audit_log
191         WHERE ($1::uuid IS NULL OR actor_id = $1)
192           AND ($2::uuid IS NULL OR target_id = $2)
193           AND ($3::text IS NULL OR action = $3)
194           AND ($4::timestamptz IS NULL OR at >= $4)
195           AND ($5::timestamptz IS NULL OR at <= $5)
196         ORDER BY at DESC, id DESC
197         LIMIT $6 OFFSET $7",
198    )
199    .bind(query.actor_id)
200    .bind(query.target_id)
201    .bind(query.action.as_deref())
202    .bind(query.since)
203    .bind(query.until)
204    .bind(limit)
205    .bind(offset)
206    .fetch_all(&state.pool)
207    .await?;
208
209    Ok(Json(entries))
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn an_entry_reads_as_a_sentence() {
218        let actor = Uuid::new_v4();
219        let target = Uuid::new_v4();
220        let entry = Entry::new(action::AGENT_ISSUED)
221            .by(actor)
222            .by_email("boss@example.test")
223            .on(target)
224            .labelled("ivan-laptop");
225
226        assert_eq!(entry.action, "agent.issued");
227        assert_eq!(entry.actor_id, Some(actor));
228        assert_eq!(entry.target_id, Some(target));
229        assert_eq!(entry.target_label.as_deref(), Some("ivan-laptop"));
230    }
231
232    #[test]
233    fn an_entry_without_an_actor_is_allowed() {
234        // Provisioning from the environment at startup has no person behind it,
235        // and refusing to record it would leave the least explicable changes
236        // unrecorded.
237        let entry = Entry::new(action::USER_CREATED);
238        assert!(entry.actor_id.is_none() && entry.actor_email.is_none());
239    }
240}