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