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] }