1use async_trait::async_trait;
42use std::sync::Arc;
43use sysknife_types::{JobState, PreviewEnvelope, TransactionRecord};
44
45use crate::audit_chain::{AuditKey, ChainRow, EventRow, VerifyOutcome};
46use crate::transactions::{
47 NewTransaction, RecordedPreviewedTransaction, TransactionStore, TransactionStoreError,
48};
49
50pub mod postgres;
51
52pub(crate) const CHAIN_ROW_COLUMNS: &str =
69 "seq, key_id, transaction_id, request_id, request_hash, \
70 action_name, risk_level, summary, approval_id, warnings_json, \
71 created_at, prev_chain_hash, chain_hash, chain_version, caller_role, event_tip, \
72 caller_principal";
73
74#[async_trait]
82pub trait AuditStore: Send + Sync + std::fmt::Debug {
83 async fn record(
84 &self,
85 transaction: NewTransaction,
86 ) -> Result<TransactionRecord, TransactionStoreError>;
87
88 async fn record_previewed(
89 &self,
90 transaction: NewTransaction,
91 preview: PreviewEnvelope,
92 ) -> Result<RecordedPreviewedTransaction, TransactionStoreError>;
93
94 async fn get(
95 &self,
96 transaction_id: &str,
97 ) -> Result<Option<TransactionRecord>, TransactionStoreError>;
98
99 async fn get_preview(
100 &self,
101 transaction_id: &str,
102 ) -> Result<Option<PreviewEnvelope>, TransactionStoreError>;
103
104 async fn update_status(
105 &self,
106 transaction_id: &str,
107 new_status: JobState,
108 ) -> Result<(), TransactionStoreError>;
109
110 async fn approve_transaction(
111 &self,
112 transaction_id: &str,
113 ) -> Result<Option<String>, TransactionStoreError>;
114
115 async fn revoke_unconsumed_approval(
116 &self,
117 transaction_id: &str,
118 ) -> Result<bool, TransactionStoreError>;
119
120 async fn claim_approved_for_execution(
121 &self,
122 transaction_id: &str,
123 receipt_digest: &str,
124 ) -> Result<bool, TransactionStoreError>;
125
126 async fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError>;
127
128 async fn reconcile_interrupted_running(&self) -> Result<u64, TransactionStoreError> {
144 let mut total = 0u64;
145 loop {
146 let running = self
147 .list_transactions(100, Some("running"), None, None)
148 .await?;
149 if running.is_empty() {
150 break;
151 }
152 let batch = running.len();
153 for tx in running {
154 self.update_status(&tx.transaction_id, JobState::Failed)
155 .await?;
156 }
157 total += batch as u64;
158 if batch < 100 {
160 break;
161 }
162 }
163 Ok(total)
164 }
165
166 async fn cancel_queued(&self, transaction_id: &str) -> Result<bool, TransactionStoreError>;
171
172 async fn list_transactions(
173 &self,
174 limit: u32,
175 status_filter: Option<&str>,
176 action_filter: Option<&str>,
177 since_hours: Option<u32>,
178 ) -> Result<Vec<TransactionRecord>, TransactionStoreError>;
179
180 async fn list_history(
183 &self,
184 limit: u32,
185 status_filter: Option<&str>,
186 action_filter: Option<&str>,
187 since_hours: Option<u32>,
188 ) -> Result<Vec<crate::transactions::JobHistoryEntry>, TransactionStoreError>;
189
190 async fn fetch_chain_row(
191 &self,
192 transaction_id: &str,
193 ) -> Result<Option<ChainRow>, TransactionStoreError>;
194
195 async fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError>;
196
197 async fn fetch_event_rows(&self) -> Result<Vec<EventRow>, TransactionStoreError>;
200
201 async fn verify_audit_chain(
202 &self,
203 key: &AuditKey,
204 ) -> Result<VerifyOutcome, TransactionStoreError>;
205
206 async fn verify_event_chain(
213 &self,
214 key: &AuditKey,
215 ) -> Result<VerifyOutcome, TransactionStoreError> {
216 let rows = self.fetch_event_rows().await?;
217 Ok(crate::audit_chain::verify_event_chain(key, &rows))
218 }
219
220 async fn verify_event_binding(
223 &self,
224 ) -> Result<crate::audit_chain::BindingOutcome, TransactionStoreError> {
225 let tx_rows = self.fetch_chain_rows().await?;
226 let event_rows = self.fetch_event_rows().await?;
227 Ok(crate::audit_chain::verify_event_binding(
228 &tx_rows,
229 &event_rows,
230 ))
231 }
232}
233
234#[derive(Debug, Clone)]
239pub struct SqliteStore {
240 inner: Arc<TransactionStore>,
241}
242
243impl SqliteStore {
244 pub fn new(inner: TransactionStore) -> Self {
245 Self {
246 inner: Arc::new(inner),
247 }
248 }
249
250 pub fn inner(&self) -> &TransactionStore {
254 &self.inner
255 }
256}
257
258async fn blocking<F, R>(f: F) -> R
265where
266 F: FnOnce() -> R + Send + 'static,
267 R: Send + 'static,
268{
269 tokio::task::spawn_blocking(f)
270 .await
271 .expect("audit-store blocking task panicked")
272}
273
274#[async_trait]
275impl AuditStore for SqliteStore {
276 async fn record(
277 &self,
278 transaction: NewTransaction,
279 ) -> Result<TransactionRecord, TransactionStoreError> {
280 let inner = Arc::clone(&self.inner);
281 blocking(move || inner.record(transaction)).await
282 }
283
284 async fn record_previewed(
285 &self,
286 transaction: NewTransaction,
287 preview: PreviewEnvelope,
288 ) -> Result<RecordedPreviewedTransaction, TransactionStoreError> {
289 let inner = Arc::clone(&self.inner);
290 blocking(move || inner.record_previewed(transaction, preview)).await
291 }
292
293 async fn get(
294 &self,
295 transaction_id: &str,
296 ) -> Result<Option<TransactionRecord>, TransactionStoreError> {
297 let inner = Arc::clone(&self.inner);
298 let id = transaction_id.to_string();
299 blocking(move || inner.get(&id)).await
300 }
301
302 async fn get_preview(
303 &self,
304 transaction_id: &str,
305 ) -> Result<Option<PreviewEnvelope>, TransactionStoreError> {
306 let inner = Arc::clone(&self.inner);
307 let id = transaction_id.to_string();
308 blocking(move || inner.get_preview(&id)).await
309 }
310
311 async fn update_status(
312 &self,
313 transaction_id: &str,
314 new_status: JobState,
315 ) -> Result<(), TransactionStoreError> {
316 let inner = Arc::clone(&self.inner);
317 let id = transaction_id.to_string();
318 blocking(move || inner.update_status(&id, new_status)).await
319 }
320
321 async fn approve_transaction(
322 &self,
323 transaction_id: &str,
324 ) -> Result<Option<String>, TransactionStoreError> {
325 let inner = Arc::clone(&self.inner);
326 let id = transaction_id.to_string();
327 blocking(move || inner.approve_transaction(&id)).await
328 }
329
330 async fn revoke_unconsumed_approval(
331 &self,
332 transaction_id: &str,
333 ) -> Result<bool, TransactionStoreError> {
334 let inner = Arc::clone(&self.inner);
335 let id = transaction_id.to_string();
336 blocking(move || inner.revoke_unconsumed_approval(&id)).await
337 }
338
339 async fn claim_approved_for_execution(
340 &self,
341 transaction_id: &str,
342 receipt_digest: &str,
343 ) -> Result<bool, TransactionStoreError> {
344 let inner = Arc::clone(&self.inner);
345 let id = transaction_id.to_string();
346 let digest = receipt_digest.to_string();
347 blocking(move || inner.claim_approved_for_execution(&id, &digest)).await
348 }
349
350 async fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError> {
351 let inner = Arc::clone(&self.inner);
352 blocking(move || inner.cleanup_stale_queued()).await
353 }
354
355 async fn cancel_queued(&self, transaction_id: &str) -> Result<bool, TransactionStoreError> {
356 let inner = Arc::clone(&self.inner);
357 let id = transaction_id.to_string();
358 blocking(move || inner.cancel_queued(&id)).await
359 }
360
361 async fn list_transactions(
362 &self,
363 limit: u32,
364 status_filter: Option<&str>,
365 action_filter: Option<&str>,
366 since_hours: Option<u32>,
367 ) -> Result<Vec<TransactionRecord>, TransactionStoreError> {
368 let inner = Arc::clone(&self.inner);
369 let status = status_filter.map(str::to_string);
370 let action = action_filter.map(str::to_string);
371 blocking(move || {
372 inner.list_transactions(limit, status.as_deref(), action.as_deref(), since_hours)
373 })
374 .await
375 }
376
377 async fn list_history(
378 &self,
379 limit: u32,
380 status_filter: Option<&str>,
381 action_filter: Option<&str>,
382 since_hours: Option<u32>,
383 ) -> Result<Vec<crate::transactions::JobHistoryEntry>, TransactionStoreError> {
384 let inner = Arc::clone(&self.inner);
385 let status = status_filter.map(str::to_string);
386 let action = action_filter.map(str::to_string);
387 blocking(move || {
388 inner.list_history(limit, status.as_deref(), action.as_deref(), since_hours)
389 })
390 .await
391 }
392
393 async fn fetch_chain_row(
394 &self,
395 transaction_id: &str,
396 ) -> Result<Option<ChainRow>, TransactionStoreError> {
397 let inner = Arc::clone(&self.inner);
398 let id = transaction_id.to_string();
399 blocking(move || inner.fetch_chain_row(&id)).await
400 }
401
402 async fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
403 let inner = Arc::clone(&self.inner);
404 blocking(move || inner.fetch_chain_rows()).await
405 }
406
407 async fn fetch_event_rows(&self) -> Result<Vec<EventRow>, TransactionStoreError> {
408 let inner = Arc::clone(&self.inner);
409 blocking(move || inner.fetch_event_rows()).await
410 }
411
412 async fn verify_audit_chain(
413 &self,
414 key: &AuditKey,
415 ) -> Result<VerifyOutcome, TransactionStoreError> {
416 let rows = self.fetch_chain_rows().await?;
421 Ok(crate::audit_chain::verify_chain(key, &rows))
422 }
423}
424
425pub use postgres::PostgresStore;
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431 use crate::audit_chain::AuditKey;
432 use crate::auth::CallerPrincipal;
433 use crate::transactions::NewTransaction;
434 use sysknife_types::{CallerRole, RiskLevel};
435 use tempfile::tempdir;
436
437 fn sqlite_store(path: std::path::PathBuf) -> SqliteStore {
438 let key = Arc::new(AuditKey::from_bytes(vec![0x42; 32]));
439 SqliteStore::new(crate::transactions::TransactionStore::open_with_key(path, key).unwrap())
440 }
441
442 #[test]
451 fn chain_row_columns_pinned() {
452 let columns: Vec<&str> = crate::store::CHAIN_ROW_COLUMNS
453 .split(',')
454 .map(str::trim)
455 .collect();
456 assert_eq!(
457 columns,
458 [
459 "seq",
460 "key_id",
461 "transaction_id",
462 "request_id",
463 "request_hash",
464 "action_name",
465 "risk_level",
466 "summary",
467 "approval_id",
468 "warnings_json",
469 "created_at",
470 "prev_chain_hash",
471 "chain_hash",
472 "chain_version",
473 "caller_role",
474 "event_tip",
475 "caller_principal",
476 ],
477 "CHAIN_ROW_COLUMNS changed — both ChainRow mappers read these \
478 columns in this order, so update them in the same change"
479 );
480 assert_eq!(columns.len(), 17, "column count drifted from 17");
481 }
482
483 fn new_tx(request_id: &str) -> NewTransaction {
484 NewTransaction {
485 request_id: request_id.to_string(),
486 request_hash: format!("hash-{request_id}"),
487 action_name: "UpdateSystem".to_string(),
488 risk_level: RiskLevel::High,
489 summary: "Upgrade the system".to_string(),
490 warnings: vec![],
491 caller_role: CallerRole::Dev,
492 caller_principal: CallerPrincipal::Uid(1000),
493 }
494 }
495
496 #[tokio::test]
497 async fn reconcile_interrupted_running_fails_only_running_rows() {
498 let dir = tempdir().unwrap();
499 let store = sqlite_store(dir.path().join("tx.db"));
500
501 let running = store.record(new_tx("running")).await.unwrap();
504 store
505 .update_status(&running.transaction_id, JobState::Running)
506 .await
507 .unwrap();
508
509 let queued = store.record(new_tx("queued")).await.unwrap();
510
511 let succeeded = store.record(new_tx("succeeded")).await.unwrap();
512 store
513 .update_status(&succeeded.transaction_id, JobState::Running)
514 .await
515 .unwrap();
516 store
517 .update_status(&succeeded.transaction_id, JobState::Succeeded)
518 .await
519 .unwrap();
520
521 let reconciled = store.reconcile_interrupted_running().await.unwrap();
522 assert_eq!(reconciled, 1, "only the one Running row is reconciled");
523
524 assert_eq!(
525 store
526 .get(&running.transaction_id)
527 .await
528 .unwrap()
529 .unwrap()
530 .status,
531 JobState::Failed,
532 "orphaned Running row must become Failed"
533 );
534 assert_eq!(
535 store
536 .get(&queued.transaction_id)
537 .await
538 .unwrap()
539 .unwrap()
540 .status,
541 JobState::Queued,
542 "Queued row is untouched"
543 );
544 assert_eq!(
545 store
546 .get(&succeeded.transaction_id)
547 .await
548 .unwrap()
549 .unwrap()
550 .status,
551 JobState::Succeeded,
552 "terminal Succeeded row is untouched"
553 );
554
555 assert_eq!(store.reconcile_interrupted_running().await.unwrap(), 0);
557 }
558}