Skip to main content

quipu_core/
access.rs

1//! Meta-audit ("access log"): records of *reads and administrative actions
2//! against the audit store itself* — who queried what, who ran a redrive or a
3//! retention pass. In regulated environments (HIPAA access reports, financial
4//! audit-trail reviews) the act of looking at audit data is itself auditable.
5//!
6//! Design notes:
7//!
8//! - Access records live in their **own append-only table** (`root/access/`),
9//!   not in the main log table. That keeps their retention independent
10//!   (retention drops whole segments per table, so mixing record kinds in one
11//!   table would make per-kind retention impossible) and keeps the main hash
12//!   chain / checkpoint machinery untouched. The access table carries its own
13//!   tamper-evidence chain and is covered by
14//!   [`crate::AuditStore::verify_integrity`].
15//! - **No self-reference loop**: recording an access is a plain table append —
16//!   it never goes through a query path, so it can never trigger another
17//!   access record. Querying the access log *is* an access and is recorded
18//!   (exactly one record per query), but that recording again is just an
19//!   append: growth is strictly one record per externally-initiated
20//!   operation, never recursive.
21//! - **Search probe values are never recorded.** A query summary (see
22//!   [`summarize_log_query`]) keeps the *shape* of the query — which fields
23//!   were filtered, in which mode, over which time range — but drops filter
24//!   values and custom-column values. Otherwise a search against an
25//!   HMAC/RSA-protected field would leak its probe plaintext into the access
26//!   log, defeating the field protection.
27
28use crate::id::Uid;
29use crate::query::LogQuery;
30use serde::{Deserialize, Serialize};
31
32/// Entity-type names starting with this prefix are reserved for quipu's own
33/// internal record kinds (e.g. [`ACCESS_TYPE`]) and cannot be defined via
34/// [`crate::AuditStore::define_type`].
35pub const RESERVED_TYPE_PREFIX: &str = "quipu_";
36
37/// The reserved type name under which meta-audit records are kept.
38pub const ACCESS_TYPE: &str = "quipu_access";
39
40/// One meta-audit row: a single read or administrative operation against the
41/// audit store.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct AccessRecord {
44    pub id: Uid,
45    /// UTC-micros when the operation ran.
46    pub timestamp: u64,
47    /// Who performed the operation. In server mode this is the bearer
48    /// token's role; in embedded mode the host-supplied identity (or
49    /// `"local"` for direct [`crate::AuditStore::query`] calls).
50    pub actor: String,
51    /// What was done: `query_logs`, `list_entities`, `entity_history`,
52    /// `query_access`, `redrive_dlq`, `apply_retention`, `flush`, `dlq_len`,
53    /// `verify`, `auth_reload`, ...
54    pub operation: String,
55    /// Sanitized parameter summary (JSON). Never contains search probe
56    /// values or custom-column values — see [`summarize_log_query`].
57    pub params: String,
58    /// How many rows/items the operation returned or affected, if countable.
59    pub result_count: Option<u64>,
60}
61
62impl AccessRecord {
63    pub fn new(
64        actor: impl Into<String>,
65        operation: impl Into<String>,
66        params: impl Into<String>,
67        result_count: Option<u64>,
68    ) -> Self {
69        Self {
70            id: Uid::generate(),
71            timestamp: crate::time::now_micros(),
72            actor: actor.into(),
73            operation: operation.into(),
74            params: params.into(),
75            result_count,
76        }
77    }
78
79    /// Override the record's timestamp (UTC micros) — for hosts that record
80    /// after the fact, and for retention tests.
81    pub fn at(mut self, timestamp: u64) -> Self {
82        self.timestamp = timestamp;
83        self
84    }
85}
86
87/// Filter for reading the access log back. All set conditions are AND-ed.
88#[derive(Debug, Clone, Default, Serialize, Deserialize)]
89#[serde(default)]
90pub struct AccessQuery {
91    /// Inclusive UTC-micros range.
92    pub from_micros: Option<u64>,
93    pub to_micros: Option<u64>,
94    /// Exact actor match.
95    pub actor: Option<String>,
96    /// Exact operation match.
97    pub operation: Option<String>,
98    pub limit: Option<usize>,
99}
100
101impl AccessQuery {
102    pub fn matches(&self, rec: &AccessRecord) -> bool {
103        if self.from_micros.is_some_and(|from| rec.timestamp < from) {
104            return false;
105        }
106        if self.to_micros.is_some_and(|to| rec.timestamp > to) {
107            return false;
108        }
109        if self.actor.as_ref().is_some_and(|a| *a != rec.actor) {
110            return false;
111        }
112        if self
113            .operation
114            .as_ref()
115            .is_some_and(|op| *op != rec.operation)
116        {
117            return false;
118        }
119        true
120    }
121}
122
123/// Render a [`LogQuery`] as a JSON summary that is safe to persist in the
124/// access log: filter *shapes* (entity type, field name, match mode,
125/// include_past) are kept, filter *values* and custom-column values are
126/// dropped (only custom keys survive). Method and url_prefix are kept as-is —
127/// both are plaintext columns of the main log already.
128pub fn summarize_log_query(q: &LogQuery) -> String {
129    fn filter_shape(f: &crate::query::TargetFilter) -> serde_json::Value {
130        serde_json::json!({
131            "entity_type": f.entity_type,
132            "field": f.field,
133            "mode": f.mode,
134            "include_past": f.include_past,
135        })
136    }
137    let mut out = serde_json::Map::new();
138    if let Some(v) = q.from_micros {
139        out.insert("from_micros".into(), v.into());
140    }
141    if let Some(v) = q.to_micros {
142        out.insert("to_micros".into(), v.into());
143    }
144    if let Some(v) = &q.method {
145        out.insert("method".into(), v.clone().into());
146    }
147    if let Some(v) = &q.url_prefix {
148        out.insert("url_prefix".into(), v.clone().into());
149    }
150    if let Some(f) = &q.actor {
151        out.insert("actor_filter".into(), filter_shape(f));
152    }
153    if !q.targets.is_empty() {
154        out.insert(
155            "target_filters".into(),
156            q.targets.iter().map(filter_shape).collect(),
157        );
158    }
159    if !q.custom.is_empty() {
160        out.insert(
161            "custom_keys".into(),
162            q.custom.keys().cloned().collect::<Vec<_>>().into(),
163        );
164    }
165    if let Some(v) = q.limit {
166        out.insert("limit".into(), v.into());
167    }
168    serde_json::Value::Object(out).to_string()
169}
170
171/// Render an [`AccessQuery`] as a JSON summary. Access-query parameters carry
172/// no protected material (actor names and operation names are stored
173/// plaintext in the access log anyway), so they are recorded verbatim.
174pub fn summarize_access_query(q: &AccessQuery) -> String {
175    let mut out = serde_json::Map::new();
176    if let Some(v) = q.from_micros {
177        out.insert("from_micros".into(), v.into());
178    }
179    if let Some(v) = q.to_micros {
180        out.insert("to_micros".into(), v.into());
181    }
182    if let Some(v) = &q.actor {
183        out.insert("actor".into(), v.clone().into());
184    }
185    if let Some(v) = &q.operation {
186        out.insert("operation".into(), v.clone().into());
187    }
188    if let Some(v) = q.limit {
189        out.insert("limit".into(), v.into());
190    }
191    serde_json::Value::Object(out).to_string()
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use crate::model::Value;
198    use crate::query::TargetFilter;
199
200    #[test]
201    fn summary_never_contains_probe_values() {
202        let q = LogQuery {
203            actor: Some(TargetFilter::exact(
204                "user",
205                "name",
206                Value::Text("SECRET-ACTOR".into()),
207            )),
208            targets: vec![
209                TargetFilter::exact("patient", "ssn", Value::Text("SECRET-123".into())).contains(),
210            ],
211            custom: [("note".to_string(), Value::Text("SECRET-NOTE".into()))]
212                .into_iter()
213                .collect(),
214            ..Default::default()
215        };
216        let s = summarize_log_query(&q);
217        assert!(!s.contains("SECRET-123"), "target probe leaked: {s}");
218        assert!(!s.contains("SECRET-ACTOR"), "actor probe leaked: {s}");
219        assert!(!s.contains("SECRET-NOTE"), "custom value leaked: {s}");
220        // the shape survives
221        assert!(s.contains("patient"));
222        assert!(s.contains("ssn"));
223        assert!(s.contains("contains"));
224        assert!(s.contains("note"));
225    }
226}