1use cortex_core::tibet::{TibetToken, Provenance};
18use cortex_core::crypto::ContentHash;
19use cortex_core::error::{CortexError, CortexResult};
20use cortex_airlock::AirlockSession;
21use chrono::{DateTime, Utc};
22use serde::{Serialize, Deserialize};
23
24#[derive(Clone, Debug, Serialize, Deserialize)]
26pub struct AuditEntry {
27 pub token: TibetToken,
28 pub query_hash: ContentHash,
29 pub chunks_accessed: usize,
30 pub chunks_denied: usize,
31 pub response_hash: ContentHash,
32 pub airlock_duration_ms: f64,
33 pub timestamp: DateTime<Utc>,
34}
35
36pub struct AuditTrail {
38 db: sled::Db,
39 chain: Provenance,
40}
41
42#[derive(Clone, Debug, Serialize, Deserialize)]
44pub struct AuditStats {
45 pub total_queries: usize,
46 pub total_chunks_accessed: usize,
47 pub total_chunks_denied: usize,
48 pub unique_actors: usize,
49 pub chain_intact: bool,
50 pub first_entry: Option<DateTime<Utc>>,
51 pub last_entry: Option<DateTime<Utc>>,
52}
53
54impl AuditTrail {
55 pub fn open(path: &str) -> CortexResult<Self> {
56 let db = sled::open(path)
57 .map_err(|e| CortexError::Storage(e.to_string()))?;
58
59 let chain = if let Some(bytes) = db.get(b"__chain__")
61 .map_err(|e| CortexError::Storage(e.to_string()))? {
62 serde_json::from_slice(&bytes).unwrap_or_default()
63 } else {
64 Provenance::new()
65 };
66
67 Ok(Self { db, chain })
68 }
69
70 pub fn record_session(
72 &mut self,
73 session: &AirlockSession,
74 query_hash: ContentHash,
75 response_hash: ContentHash,
76 ) -> CortexResult<AuditEntry> {
77 let mut token = TibetToken::new(
78 query_hash.clone(),
79 format!("Query processed via airlock {}", session.session_id),
80 &session.actor,
81 session.jis_level,
82 )
83 .with_access_stats(session.chunks_processed, session.chunks_denied)
84 .with_airlock_time(session.duration_ms);
85
86 if let Some(prev) = self.chain.latest() {
88 token = token.with_parent(&prev.token_id);
89 }
90
91 let entry = AuditEntry {
92 token: token.clone(),
93 query_hash,
94 chunks_accessed: session.chunks_processed,
95 chunks_denied: session.chunks_denied,
96 response_hash,
97 airlock_duration_ms: session.duration_ms,
98 timestamp: Utc::now(),
99 };
100
101 let entry_bytes = serde_json::to_vec(&entry)?;
103 self.db
104 .insert(token.token_id.as_bytes(), entry_bytes)
105 .map_err(|e| CortexError::Storage(e.to_string()))?;
106
107 self.chain.append(token);
109 let chain_bytes = serde_json::to_vec(&self.chain)?;
110 self.db
111 .insert(b"__chain__", chain_bytes)
112 .map_err(|e| CortexError::Storage(e.to_string()))?;
113
114 self.db
115 .flush()
116 .map_err(|e| CortexError::Storage(e.to_string()))?;
117
118 Ok(entry)
119 }
120
121 pub fn record_event(
123 &mut self,
124 actor: &str,
125 jis_level: u8,
126 event_hash: ContentHash,
127 description: &str,
128 ) -> CortexResult<AuditEntry> {
129 let mut token = TibetToken::new(
130 event_hash.clone(),
131 description,
132 actor,
133 jis_level,
134 );
135
136 if let Some(prev) = self.chain.latest() {
137 token = token.with_parent(&prev.token_id);
138 }
139
140 let entry = AuditEntry {
141 token: token.clone(),
142 query_hash: event_hash,
143 chunks_accessed: 0,
144 chunks_denied: 0,
145 response_hash: ContentHash("sha256:event".into()),
146 airlock_duration_ms: 0.0,
147 timestamp: Utc::now(),
148 };
149
150 let entry_bytes = serde_json::to_vec(&entry)?;
151 self.db
152 .insert(token.token_id.as_bytes(), entry_bytes)
153 .map_err(|e| CortexError::Storage(e.to_string()))?;
154
155 self.chain.append(token);
156 let chain_bytes = serde_json::to_vec(&self.chain)?;
157 self.db
158 .insert(b"__chain__", chain_bytes)
159 .map_err(|e| CortexError::Storage(e.to_string()))?;
160
161 Ok(entry)
162 }
163
164 pub fn verify_chain(&self) -> bool {
166 self.chain.verify_chain()
167 }
168
169 pub fn stats(&self) -> CortexResult<AuditStats> {
171 let mut total_accessed = 0usize;
172 let mut total_denied = 0usize;
173 let mut actors = std::collections::HashSet::new();
174
175 let entries: Vec<AuditEntry> = self.db
176 .iter()
177 .filter_map(|r| r.ok())
178 .filter(|(k, _)| k.as_ref() != b"__chain__")
179 .filter_map(|(_, v)| serde_json::from_slice(&v).ok())
180 .collect();
181
182 for entry in &entries {
183 total_accessed += entry.chunks_accessed;
184 total_denied += entry.chunks_denied;
185 actors.insert(entry.token.eromheen.actor.clone());
186 }
187
188 Ok(AuditStats {
189 total_queries: entries.len(),
190 total_chunks_accessed: total_accessed,
191 total_chunks_denied: total_denied,
192 unique_actors: actors.len(),
193 chain_intact: self.verify_chain(),
194 first_entry: entries.iter().map(|e| e.timestamp).min(),
195 last_entry: entries.iter().map(|e| e.timestamp).max(),
196 })
197 }
198
199 pub fn chain(&self) -> &Provenance {
201 &self.chain
202 }
203
204 pub fn chain_len(&self) -> usize {
206 self.chain.len()
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 fn temp_trail() -> AuditTrail {
215 let dir = tempfile::tempdir().unwrap();
216 AuditTrail::open(dir.path().to_str().unwrap()).unwrap()
217 }
218
219 #[test]
220 fn test_record_and_verify() {
221 let mut trail = temp_trail();
222
223 let session = AirlockSession {
224 session_id: "test_001".into(),
225 actor: "analyst@company.com".into(),
226 jis_level: 2,
227 chunks_processed: 5,
228 chunks_denied: 3,
229 duration_ms: 12.5,
230 input_hash: ContentHash("sha256:input".into()),
231 output_hash: ContentHash("sha256:output".into()),
232 };
233
234 let entry = trail.record_session(
235 &session,
236 ContentHash("sha256:query_hash".into()),
237 ContentHash("sha256:response_hash".into()),
238 ).unwrap();
239
240 assert_eq!(entry.chunks_accessed, 5);
241 assert_eq!(entry.chunks_denied, 3);
242 assert_eq!(trail.chain_len(), 1);
243 assert!(trail.verify_chain());
244 }
245
246 #[test]
247 fn test_chain_integrity() {
248 let mut trail = temp_trail();
249
250 for i in 0..5 {
251 let session = AirlockSession {
252 session_id: format!("session_{i}"),
253 actor: "user@test.com".into(),
254 jis_level: 1,
255 chunks_processed: i,
256 chunks_denied: 0,
257 duration_ms: 1.0,
258 input_hash: ContentHash(format!("sha256:input_{i}")),
259 output_hash: ContentHash(format!("sha256:output_{i}")),
260 };
261
262 trail.record_session(
263 &session,
264 ContentHash(format!("sha256:query_{i}")),
265 ContentHash(format!("sha256:response_{i}")),
266 ).unwrap();
267 }
268
269 assert_eq!(trail.chain_len(), 5);
270 assert!(trail.verify_chain());
271 }
272
273 #[test]
274 fn test_audit_stats() {
275 let mut trail = temp_trail();
276
277 for actor in &["alice@co.com", "bob@co.com", "alice@co.com"] {
278 let session = AirlockSession {
279 session_id: "s".into(),
280 actor: actor.to_string(),
281 jis_level: 1,
282 chunks_processed: 10,
283 chunks_denied: 5,
284 duration_ms: 1.0,
285 input_hash: ContentHash("sha256:in".into()),
286 output_hash: ContentHash("sha256:out".into()),
287 };
288
289 trail.record_session(
290 &session,
291 ContentHash("sha256:q".into()),
292 ContentHash("sha256:r".into()),
293 ).unwrap();
294 }
295
296 let stats = trail.stats().unwrap();
297 assert_eq!(stats.total_queries, 3);
298 assert_eq!(stats.total_chunks_accessed, 30);
299 assert_eq!(stats.total_chunks_denied, 15);
300 assert_eq!(stats.unique_actors, 2);
301 assert!(stats.chain_intact);
302 }
303}