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 WEBHOOK_TESTED: &str = "webhook.tested";
50    pub const DEMO_SEEDED: &str = "demo.seeded";
51}
52
53/// One entry being written.
54///
55/// Built with the chained setters below so a call site reads as a sentence and
56/// so adding a field later does not touch every caller.
57#[derive(Debug, Default)]
58pub struct Entry {
59    actor_id: Option<Uuid>,
60    actor_email: Option<String>,
61    action: String,
62    target_id: Option<Uuid>,
63    target_label: Option<String>,
64    details: Option<serde_json::Value>,
65}
66
67impl Entry {
68    pub fn new(action: &str) -> Self {
69        Self {
70            action: action.to_string(),
71            ..Default::default()
72        }
73    }
74
75    /// The person who acted. Absent for the server acting on its own.
76    pub fn by(mut self, actor_id: Uuid) -> Self {
77        self.actor_id = Some(actor_id);
78        self
79    }
80
81    /// The actor's email, kept as text so an entry stays readable after a
82    /// rename or a deletion.
83    pub fn by_email(mut self, email: impl Into<String>) -> Self {
84        self.actor_email = Some(email.into());
85        self
86    }
87
88    pub fn on(mut self, target_id: Uuid) -> Self {
89        self.target_id = Some(target_id);
90        self
91    }
92
93    /// A human label for the target - an email, a department name.
94    pub fn labelled(mut self, label: impl Into<String>) -> Self {
95        self.target_label = Some(label.into());
96        self
97    }
98
99    /// Extra context. Never credentials: this is read in an admin UI and
100    /// pasted into support tickets.
101    pub fn with(mut self, details: serde_json::Value) -> Self {
102        self.details = Some(details);
103        self
104    }
105
106    /// Writes the entry, or complains loudly and carries on.
107    ///
108    /// The action being recorded has already happened. Failing the request
109    /// because its audit entry did not write would undo nothing - the token is
110    /// already revoked - and would turn a full disk into an outage.
111    pub async fn record(self, pool: &PgPool) {
112        let result = sqlx::query(
113            "INSERT INTO audit_log (actor_id, actor_email, action, target_id, target_label, details)
114             VALUES ($1, $2, $3, $4, $5, $6)",
115        )
116        .bind(self.actor_id)
117        .bind(self.actor_email.as_deref())
118        .bind(&self.action)
119        .bind(self.target_id)
120        .bind(self.target_label.as_deref())
121        .bind(self.details.as_ref())
122        .execute(pool)
123        .await;
124
125        if let Err(error) = result {
126            // At error level on purpose: an audit log that stops recording
127            // without anyone noticing is worse than one that never existed,
128            // because it is trusted.
129            tracing::error!(%error, action = %self.action, "failed to write an audit entry");
130        }
131    }
132}
133
134/// An entry as the admin screens read it.
135#[derive(Debug, Serialize, sqlx::FromRow)]
136pub struct AuditRow {
137    pub id: i64,
138    pub actor_id: Option<Uuid>,
139    pub actor_email: Option<String>,
140    pub action: String,
141    pub target_id: Option<Uuid>,
142    pub target_label: Option<String>,
143    pub details: Option<serde_json::Value>,
144    pub at: DateTime<Utc>,
145}
146
147/// Which slice of the log to read.
148#[derive(Debug, Deserialize)]
149pub struct AuditQuery {
150    /// Everything this person did.
151    pub actor_id: Option<Uuid>,
152    /// Everything done to this person or thing.
153    pub target_id: Option<Uuid>,
154    /// One kind of action, e.g. `agent.issued`.
155    pub action: Option<String>,
156    pub since: Option<DateTime<Utc>>,
157    pub until: Option<DateTime<Utc>>,
158    /// How many entries to return. Clamped; see `MAX_LIMIT`.
159    pub limit: Option<i64>,
160    /// How many to skip, for paging back through history.
161    pub offset: Option<i64>,
162}
163
164/// The most entries one request may return.
165///
166/// A bound rather than a page size an admin can raise: this table grows without
167/// limit, and an unbounded read of it is a way to take the server down with a
168/// single request.
169const MAX_LIMIT: i64 = 500;
170const DEFAULT_LIMIT: i64 = 100;
171
172// Neither constant has a unit test. Asserting `DEFAULT_LIMIT <= MAX_LIMIT` here
173// would compare two literals and pass at compile time regardless of what the
174// handler does with them; the clamp is exercised against the running handler in
175// tests/audit.rs, where a request for more than the ceiling must come back with
176// exactly the ceiling.
177
178/// Reads the log. Administrators only.
179///
180/// A manager is deliberately not admitted. The log records who changed what,
181/// and until a manager can change anything (ADR 0008) their view of it would
182/// consist entirely of other people's actions.
183pub async fn list(State(state): State<AppState>, user: CurrentUser, Query(query): Query<AuditQuery>) -> Result<impl IntoResponse, ApiError> {
184    user.require_admin()?;
185
186    let limit = query.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT);
187    let offset = query.offset.unwrap_or(0).max(0);
188
189    let entries: Vec<AuditRow> = sqlx::query_as(
190        "SELECT id, actor_id, actor_email, action, target_id, target_label, details, at
191         FROM audit_log
192         WHERE ($1::uuid IS NULL OR actor_id = $1)
193           AND ($2::uuid IS NULL OR target_id = $2)
194           AND ($3::text IS NULL OR action = $3)
195           AND ($4::timestamptz IS NULL OR at >= $4)
196           AND ($5::timestamptz IS NULL OR at <= $5)
197         ORDER BY at DESC, id DESC
198         LIMIT $6 OFFSET $7",
199    )
200    .bind(query.actor_id)
201    .bind(query.target_id)
202    .bind(query.action.as_deref())
203    .bind(query.since)
204    .bind(query.until)
205    .bind(limit)
206    .bind(offset)
207    .fetch_all(&state.pool)
208    .await?;
209
210    Ok(Json(entries))
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn an_entry_reads_as_a_sentence() {
219        let actor = Uuid::new_v4();
220        let target = Uuid::new_v4();
221        let entry = Entry::new(action::AGENT_ISSUED)
222            .by(actor)
223            .by_email("boss@example.test")
224            .on(target)
225            .labelled("ivan-laptop");
226
227        assert_eq!(entry.action, "agent.issued");
228        assert_eq!(entry.actor_id, Some(actor));
229        assert_eq!(entry.target_id, Some(target));
230        assert_eq!(entry.target_label.as_deref(), Some("ivan-laptop"));
231    }
232
233    #[test]
234    fn an_entry_without_an_actor_is_allowed() {
235        // Provisioning from the environment at startup has no person behind it,
236        // and refusing to record it would leave the least explicable changes
237        // unrecorded.
238        let entry = Entry::new(action::USER_CREATED);
239        assert!(entry.actor_id.is_none() && entry.actor_email.is_none());
240    }
241}