Skip to main content

cortex/frontend/
audit.rs

1// Copyright 2015-2025 Deyan Ginev. See the LICENSE
2// file at the top-level directory of this distribution.
3//
4// Licensed under the MIT license <LICENSE-MIT or http://opensource.org/licenses/MIT>.
5// This file may not be copied, modified, or distributed
6// except according to those terms.
7
8//! The **accounting** pillar (AAA — `docs/archive/AAA_DESIGN.md`): a Rocket fairing that records
9//! every mutating admin request to the `audit_log`, so "who did what, when, to what, with what
10//! outcome" is observable. Centralizing it in one fairing (rather than a call in each write
11//! handler) means no endpoint can forget to log and new endpoints are audited automatically —
12//! drift-proof, in the spirit of the symmetry contract.
13
14use rocket::fairing::{Fairing, Info, Kind};
15use rocket::http::{Method, Status};
16use rocket::response::Redirect;
17use rocket::serde::json::Json;
18use rocket::{Request, Response, Route, State};
19use rocket_dyn_templates::{Template, context};
20use serde::Serialize;
21
22use crate::backend::DbPool;
23use crate::frontend::actor::{
24  Actor, AdminSession, ReturnTo, actor_carriers, resolve_carriers, sign_in_url,
25};
26use crate::models::{AuditEntry, NewAuditEntry};
27
28/// Rows per audit page. The read is most-recent-first and **page-based** (`?page=`), so this bounds
29/// every response and lets an admin walk back through history 100 at a time — never asking for the
30/// whole table.
31const AUDIT_PAGE_SIZE: i64 = 100;
32
33/// Records every **mutating** request (`POST`/`PUT`/`PATCH`/`DELETE`) to the `audit_log`: the
34/// resolved [`crate::frontend::actor`] (empty if unauthenticated — itself a useful signal), the
35/// matched route's name as the **action** (Rocket sets it to the handler fn, e.g. `delete_corpus`),
36/// the request path as the **target**, and the response status as the **outcome**.
37///
38/// **Best-effort & non-blocking**: a failed audit write is logged and swallowed — accounting must
39/// never fail the action it observes (`docs/DESIGN_PRINCIPLES.md`) — and the insert runs on a
40/// blocking task so a brief diesel round-trip never stalls the async response path.
41pub struct AuditFairing;
42
43#[rocket::async_trait]
44impl Fairing for AuditFairing {
45  fn info(&self) -> Info {
46    Info {
47      name: "Audit log",
48      kind: Kind::Response,
49    }
50  }
51
52  async fn on_response<'r>(&self, request: &'r Request<'_>, response: &mut Response<'r>) {
53    // Only mutating methods are admin "actions taken"; reads are out of scope for the audit log.
54    if !matches!(
55      request.method(),
56      Method::Post | Method::Put | Method::Patch | Method::Delete
57    ) {
58      return;
59    }
60    // The pool is managed state; if it is somehow absent there is nowhere to record (skip
61    // silently).
62    let Some(pool) = request.rocket().state::<DbPool>() else {
63      return;
64    };
65    let action = request
66      .route()
67      .and_then(|route| route.name.as_deref().map(str::to_string))
68      .unwrap_or_else(|| request.method().as_str().to_string());
69    let target = request.uri().path().to_string();
70    // Extract the credential carriers synchronously (cheap), then resolve the actor *inside* the
71    // blocking task — the session-cookie lookup is a DB query that must not run on the async
72    // reactor.
73    let carriers = actor_carriers(request);
74    let outcome = response.status().code.to_string();
75    let pool = pool.clone();
76    rocket::tokio::task::spawn_blocking(move || match pool.get() {
77      Ok(mut connection) => {
78        let actor = resolve_carriers(&mut connection, &carriers).unwrap_or_default();
79        let entry = NewAuditEntry::new(actor, action, target).outcome(outcome);
80        if let Err(error) = entry.record(&mut connection) {
81          tracing::error!(?entry, %error, "audit: failed to record entry");
82        }
83      },
84      Err(error) => {
85        tracing::warn!(action, target, %error, "audit: pool exhausted, dropped audit row");
86      },
87    });
88  }
89}
90
91/// A recorded admin action as exposed over the API/UI — the read view of the `audit_log`. The
92/// timestamp is a formatted string (the model's `at` is a chrono `NaiveDateTime`, not serialized
93/// directly — see `models::audit`).
94#[derive(Debug, Serialize, schemars::JsonSchema)]
95pub struct AuditDto {
96  /// Auto-incremented id (monotonic; usable as a cursor).
97  pub id: i64,
98  /// The identity that acted (empty if the action was unauthenticated).
99  pub actor: String,
100  /// The action verb (the matched route name, e.g. `delete_corpus`).
101  pub action: String,
102  /// The resource acted on (the request path).
103  pub target: String,
104  /// The outcome (an HTTP status code).
105  pub outcome: String,
106  /// Optional short context.
107  pub details: String,
108  /// When it happened, formatted `YYYY-MM-DD HH:MM:SS` (server clock).
109  pub at: String,
110}
111
112impl From<AuditEntry> for AuditDto {
113  fn from(entry: AuditEntry) -> AuditDto {
114    AuditDto {
115      id: entry.id,
116      actor: entry.actor,
117      action: entry.action,
118      target: entry.target,
119      outcome: entry.outcome,
120      details: entry.details,
121      at: crate::frontend::helpers::iso_utc(entry.at),
122    }
123  }
124}
125
126/// One page of the audit log — the shared shape for the agent endpoint and the human screen.
127#[derive(Debug, Serialize, schemars::JsonSchema)]
128pub struct AuditPage {
129  /// The audit rows for this page, most-recent first (at most [`AUDIT_PAGE_SIZE`]).
130  pub entries: Vec<AuditDto>,
131  /// The 0-based page index this response covers.
132  pub page: i64,
133  /// Rows per page (the cap).
134  pub page_size: i64,
135  /// Whether an older page exists (more history beyond this one).
136  pub has_next: bool,
137}
138
139/// Loads one page of the audit log (most-recent first, optional `actor` filter, [`AUDIT_PAGE_SIZE`]
140/// rows per page) as the shared [`AuditPage`] — the core of the agent endpoint and the human screen
141/// (symmetry contract). Fetches one extra row to report `has_next` without a second COUNT query.
142fn load_audit(pool: &DbPool, actor: Option<&str>, page: i64) -> Result<AuditPage, Status> {
143  let page = page.max(0);
144  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
145  let mut entries = AuditEntry::list(
146    &mut connection,
147    actor,
148    AUDIT_PAGE_SIZE + 1,
149    page * AUDIT_PAGE_SIZE,
150  )
151  .map_err(|_| Status::InternalServerError)?;
152  let has_next = entries.len() as i64 > AUDIT_PAGE_SIZE;
153  entries.truncate(AUDIT_PAGE_SIZE as usize);
154  Ok(AuditPage {
155    entries: entries.into_iter().map(AuditDto::from).collect(),
156    page,
157    page_size: AUDIT_PAGE_SIZE,
158    has_next,
159  })
160}
161
162/// The audit log (agent twin of the `/admin/audit` screen): admin actions, most-recent first,
163/// optionally filtered to one `actor`, **paginated** — [`AUDIT_PAGE_SIZE`] rows per `page`
164/// (0-based; `has_next` flags more history). **Token-gated** — reading who-did-what is sensitive,
165/// so it takes an [`Actor`] like the writes it records. `503` if the pool is exhausted.
166#[rocket_okapi::openapi(tag = "Management")]
167#[get("/api/audit?<page>&<actor>")]
168pub fn api_audit(
169  page: Option<i64>,
170  actor: Option<String>,
171  _caller: Actor,
172  pool: &State<DbPool>,
173) -> Result<Json<AuditPage>, Status> {
174  Ok(Json(load_audit(pool, actor.as_deref(), page.unwrap_or(0))?))
175}
176
177/// The audit-log screen (`GET /admin/audit`): the human view of recent admin actions, **signed-in
178/// admins only** (an unauthenticated browser is redirected to the sign-in page). Optional `?actor=`
179/// and `?limit=` mirror the agent endpoint.
180// `Redirect` is a chunky responder, so the `Err` variant trips `result_large_err` — irrelevant for
181// a one-shot page handler (mirrors `admin::admin_page`).
182#[allow(clippy::result_large_err)]
183#[get("/admin/audit?<page>&<actor>")]
184pub fn audit_page(
185  page: Option<i64>,
186  actor: Option<String>,
187  session: Option<AdminSession>,
188  return_to: ReturnTo,
189  pool: &State<DbPool>,
190) -> Result<Template, Redirect> {
191  let session = session.ok_or_else(|| Redirect::to(sign_in_url(false, Some(&return_to.0))))?;
192  // Best-effort, like the dashboard: a pool/db hiccup renders an empty page, never an error page.
193  let audit = load_audit(pool, actor.as_deref(), page.unwrap_or(0)).unwrap_or(AuditPage {
194    entries: Vec::new(),
195    page: 0,
196    page_size: AUDIT_PAGE_SIZE,
197    has_next: false,
198  });
199  let global = serde_json::json!({
200    "title": "Audit log",
201    "description": "Recent CorTeX admin actions and who took them",
202  });
203  Ok(Template::render(
204    "audit",
205    context! {
206      global,
207      owner: session.owner,
208      rows: audit.entries,
209      page: audit.page,
210      has_next: audit.has_next,
211      has_prev: audit.page > 0,
212      prev_page: (audit.page - 1).max(0),
213      next_page: audit.page + 1,
214      actor_filter: actor,
215    },
216  ))
217}
218
219/// The human audit screen (the agent `api_audit` is mounted via `frontend::apidoc`).
220pub fn routes() -> Vec<Route> { routes![audit_page] }