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}