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