cortex/models/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 persistent record of admin
9//! actions and who took them ("observability of actions taken"). Auth-agnostic — `actor` is
10//! whatever the auth layer resolved (today an admin token's `owner`), so this survives any future
11//! auth upgrade.
12
13use chrono::NaiveDateTime;
14use diesel::prelude::*;
15use diesel::result::Error;
16
17use crate::schema::audit_log;
18
19/// A recorded admin action (the read model for the audit view). Not `Serialize` directly — like the
20/// other timestamped models, the JSON/API surface uses a DTO with a formatted-string `at` (chrono's
21/// serde feature is intentionally off crate-wide).
22#[derive(Queryable, Identifiable, Debug, Clone)]
23#[diesel(table_name = audit_log)]
24pub struct AuditEntry {
25 /// Auto-incremented id.
26 pub id: i64,
27 /// The identity that acted (the signed-in admin / API token owner; empty if unresolved).
28 pub actor: String,
29 /// What was done — a stable verb (`rerun`, `import_corpus`, `deactivate_service`, …).
30 pub action: String,
31 /// The resource acted on (e.g. `corpus` or `corpus/service`); may be empty.
32 pub target: String,
33 /// The result (an HTTP status or `ok`/`denied`); may be empty.
34 pub outcome: String,
35 /// Optional short context (a params summary); never secrets.
36 pub details: String,
37 /// When it happened (server clock).
38 pub at: NaiveDateTime,
39}
40
41/// An admin action to record.
42#[derive(Insertable, Debug)]
43#[diesel(table_name = audit_log)]
44pub struct NewAuditEntry {
45 /// The acting identity (the token's `owner` / signed-in admin).
46 pub actor: String,
47 /// The action verb.
48 pub action: String,
49 /// The resource acted on.
50 pub target: String,
51 /// The outcome.
52 pub outcome: String,
53 /// Optional short context.
54 pub details: String,
55}
56
57impl NewAuditEntry {
58 /// A minimal entry: actor + action + target, no extra outcome/details.
59 pub fn new(
60 actor: impl Into<String>,
61 action: impl Into<String>,
62 target: impl Into<String>,
63 ) -> Self {
64 NewAuditEntry {
65 actor: actor.into(),
66 action: action.into(),
67 target: target.into(),
68 outcome: String::new(),
69 details: String::new(),
70 }
71 }
72
73 /// Sets the outcome (e.g. `ok`, `denied`, an HTTP status), builder-style.
74 pub fn outcome(mut self, outcome: impl Into<String>) -> Self {
75 self.outcome = outcome.into();
76 self
77 }
78
79 /// Records the action. **Best-effort**: the caller should ignore an error (a failed audit write
80 /// must never fail the action it describes — accounting is observability, not a gate).
81 pub fn record(&self, connection: &mut PgConnection) -> Result<usize, Error> {
82 diesel::insert_into(audit_log::table)
83 .values(self)
84 .execute(connection)
85 }
86}
87
88impl AuditEntry {
89 /// Lists audit entries, most-recent first, optionally filtered to a single `actor`, capped at
90 /// `limit` and starting at `offset` (for page-based pagination). The caller is responsible for
91 /// clamping `limit`/`offset` to sane bounds (see the read view).
92 pub fn list(
93 connection: &mut PgConnection,
94 actor: Option<&str>,
95 limit: i64,
96 offset: i64,
97 ) -> Result<Vec<Self>, Error> {
98 let mut query = audit_log::table.into_boxed();
99 if let Some(actor) = actor {
100 query = query.filter(audit_log::actor.eq(actor));
101 }
102 query
103 .order(audit_log::at.desc())
104 .limit(limit)
105 .offset(offset)
106 .get_results(connection)
107 }
108
109 /// Lists recent audit entries, most-recent first, capped at `limit` (no actor filter).
110 pub fn recent(connection: &mut PgConnection, limit: i64) -> Result<Vec<Self>, Error> {
111 Self::list(connection, None, limit, 0)
112 }
113}