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
52#[async_trait]
60pub trait AuditStore: Send + Sync + std::fmt::Debug {
61 async fn record(
62 &self,
63 transaction: NewTransaction,
64 ) -> Result<TransactionRecord, TransactionStoreError>;
65
66 async fn record_previewed(
67 &self,
68 transaction: NewTransaction,
69 preview: PreviewEnvelope,
70 ) -> Result<RecordedPreviewedTransaction, TransactionStoreError>;
71
72 async fn get(
73 &self,
74 transaction_id: &str,
75 ) -> Result<Option<TransactionRecord>, TransactionStoreError>;
76
77 async fn get_preview(
78 &self,
79 transaction_id: &str,
80 ) -> Result<Option<PreviewEnvelope>, TransactionStoreError>;
81
82 async fn update_status(
83 &self,
84 transaction_id: &str,
85 new_status: JobState,
86 ) -> Result<(), TransactionStoreError>;
87
88 async fn approve_transaction(
89 &self,
90 transaction_id: &str,
91 ) -> Result<Option<String>, TransactionStoreError>;
92
93 async fn revoke_unconsumed_approval(
94 &self,
95 transaction_id: &str,
96 ) -> Result<bool, TransactionStoreError>;
97
98 async fn claim_approved_for_execution(
99 &self,
100 transaction_id: &str,
101 receipt_digest: &str,
102 ) -> Result<bool, TransactionStoreError>;
103
104 async fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError>;
105
106 async fn reconcile_interrupted_running(&self) -> Result<u64, TransactionStoreError> {
122 let mut total = 0u64;
123 loop {
124 let running = self
125 .list_transactions(100, Some("running"), None, None)
126 .await?;
127 if running.is_empty() {
128 break;
129 }
130 let batch = running.len();
131 for tx in running {
132 self.update_status(&tx.transaction_id, JobState::Failed)
133 .await?;
134 }
135 total += batch as u64;
136 if batch < 100 {
138 break;
139 }
140 }
141 Ok(total)
142 }
143
144 async fn cancel_queued(&self, transaction_id: &str) -> Result<bool, TransactionStoreError>;
149
150 async fn list_transactions(
151 &self,
152 limit: u32,
153 status_filter: Option<&str>,
154 action_filter: Option<&str>,
155 since_hours: Option<u32>,
156 ) -> Result<Vec<TransactionRecord>, TransactionStoreError>;
157
158 async fn list_history(
161 &self,
162 limit: u32,
163 status_filter: Option<&str>,
164 action_filter: Option<&str>,
165 since_hours: Option<u32>,
166 ) -> Result<Vec<crate::transactions::JobHistoryEntry>, TransactionStoreError>;
167
168 async fn fetch_chain_row(
169 &self,
170 transaction_id: &str,
171 ) -> Result<Option<ChainRow>, TransactionStoreError>;
172
173 async fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError>;
174
175 async fn fetch_event_rows(&self) -> Result<Vec<EventRow>, TransactionStoreError>;
178
179 async fn verify_audit_chain(
180 &self,
181 key: &AuditKey,
182 ) -> Result<VerifyOutcome, TransactionStoreError>;
183
184 async fn verify_event_chain(
191 &self,
192 key: &AuditKey,
193 ) -> Result<VerifyOutcome, TransactionStoreError> {
194 let rows = self.fetch_event_rows().await?;
195 Ok(crate::audit_chain::verify_event_chain(key, &rows))
196 }
197
198 async fn verify_event_binding(
201 &self,
202 ) -> Result<crate::audit_chain::BindingOutcome, TransactionStoreError> {
203 let tx_rows = self.fetch_chain_rows().await?;
204 let event_rows = self.fetch_event_rows().await?;
205 Ok(crate::audit_chain::verify_event_binding(
206 &tx_rows,
207 &event_rows,
208 ))
209 }
210}
211
212#[derive(Debug, Clone)]
217pub struct SqliteStore {
218 inner: Arc<TransactionStore>,
219}
220
221impl SqliteStore {
222 pub fn new(inner: TransactionStore) -> Self {
223 Self {
224 inner: Arc::new(inner),
225 }
226 }
227
228 pub fn inner(&self) -> &TransactionStore {
232 &self.inner
233 }
234}
235
236async fn blocking<F, R>(f: F) -> R
243where
244 F: FnOnce() -> R + Send + 'static,
245 R: Send + 'static,
246{
247 tokio::task::spawn_blocking(f)
248 .await
249 .expect("audit-store blocking task panicked")
250}
251
252#[async_trait]
253impl AuditStore for SqliteStore {
254 async fn record(
255 &self,
256 transaction: NewTransaction,
257 ) -> Result<TransactionRecord, TransactionStoreError> {
258 let inner = Arc::clone(&self.inner);
259 blocking(move || inner.record(transaction)).await
260 }
261
262 async fn record_previewed(
263 &self,
264 transaction: NewTransaction,
265 preview: PreviewEnvelope,
266 ) -> Result<RecordedPreviewedTransaction, TransactionStoreError> {
267 let inner = Arc::clone(&self.inner);
268 blocking(move || inner.record_previewed(transaction, preview)).await
269 }
270
271 async fn get(
272 &self,
273 transaction_id: &str,
274 ) -> Result<Option<TransactionRecord>, TransactionStoreError> {
275 let inner = Arc::clone(&self.inner);
276 let id = transaction_id.to_string();
277 blocking(move || inner.get(&id)).await
278 }
279
280 async fn get_preview(
281 &self,
282 transaction_id: &str,
283 ) -> Result<Option<PreviewEnvelope>, TransactionStoreError> {
284 let inner = Arc::clone(&self.inner);
285 let id = transaction_id.to_string();
286 blocking(move || inner.get_preview(&id)).await
287 }
288
289 async fn update_status(
290 &self,
291 transaction_id: &str,
292 new_status: JobState,
293 ) -> Result<(), TransactionStoreError> {
294 let inner = Arc::clone(&self.inner);
295 let id = transaction_id.to_string();
296 blocking(move || inner.update_status(&id, new_status)).await
297 }
298
299 async fn approve_transaction(
300 &self,
301 transaction_id: &str,
302 ) -> Result<Option<String>, TransactionStoreError> {
303 let inner = Arc::clone(&self.inner);
304 let id = transaction_id.to_string();
305 blocking(move || inner.approve_transaction(&id)).await
306 }
307
308 async fn revoke_unconsumed_approval(
309 &self,
310 transaction_id: &str,
311 ) -> Result<bool, TransactionStoreError> {
312 let inner = Arc::clone(&self.inner);
313 let id = transaction_id.to_string();
314 blocking(move || inner.revoke_unconsumed_approval(&id)).await
315 }
316
317 async fn claim_approved_for_execution(
318 &self,
319 transaction_id: &str,
320 receipt_digest: &str,
321 ) -> Result<bool, TransactionStoreError> {
322 let inner = Arc::clone(&self.inner);
323 let id = transaction_id.to_string();
324 let digest = receipt_digest.to_string();
325 blocking(move || inner.claim_approved_for_execution(&id, &digest)).await
326 }
327
328 async fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError> {
329 let inner = Arc::clone(&self.inner);
330 blocking(move || inner.cleanup_stale_queued()).await
331 }
332
333 async fn cancel_queued(&self, transaction_id: &str) -> Result<bool, TransactionStoreError> {
334 let inner = Arc::clone(&self.inner);
335 let id = transaction_id.to_string();
336 blocking(move || inner.cancel_queued(&id)).await
337 }
338
339 async fn list_transactions(
340 &self,
341 limit: u32,
342 status_filter: Option<&str>,
343 action_filter: Option<&str>,
344 since_hours: Option<u32>,
345 ) -> Result<Vec<TransactionRecord>, TransactionStoreError> {
346 let inner = Arc::clone(&self.inner);
347 let status = status_filter.map(str::to_string);
348 let action = action_filter.map(str::to_string);
349 blocking(move || {
350 inner.list_transactions(limit, status.as_deref(), action.as_deref(), since_hours)
351 })
352 .await
353 }
354
355 async fn list_history(
356 &self,
357 limit: u32,
358 status_filter: Option<&str>,
359 action_filter: Option<&str>,
360 since_hours: Option<u32>,
361 ) -> Result<Vec<crate::transactions::JobHistoryEntry>, TransactionStoreError> {
362 let inner = Arc::clone(&self.inner);
363 let status = status_filter.map(str::to_string);
364 let action = action_filter.map(str::to_string);
365 blocking(move || {
366 inner.list_history(limit, status.as_deref(), action.as_deref(), since_hours)
367 })
368 .await
369 }
370
371 async fn fetch_chain_row(
372 &self,
373 transaction_id: &str,
374 ) -> Result<Option<ChainRow>, TransactionStoreError> {
375 let inner = Arc::clone(&self.inner);
376 let id = transaction_id.to_string();
377 blocking(move || inner.fetch_chain_row(&id)).await
378 }
379
380 async fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
381 let inner = Arc::clone(&self.inner);
382 blocking(move || inner.fetch_chain_rows()).await
383 }
384
385 async fn fetch_event_rows(&self) -> Result<Vec<EventRow>, TransactionStoreError> {
386 let inner = Arc::clone(&self.inner);
387 blocking(move || inner.fetch_event_rows()).await
388 }
389
390 async fn verify_audit_chain(
391 &self,
392 key: &AuditKey,
393 ) -> Result<VerifyOutcome, TransactionStoreError> {
394 let rows = self.fetch_chain_rows().await?;
399 Ok(crate::audit_chain::verify_chain(key, &rows))
400 }
401}
402
403pub use postgres::PostgresStore;
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409 use crate::audit_chain::AuditKey;
410 use crate::auth::CallerPrincipal;
411 use crate::transactions::NewTransaction;
412 use sysknife_types::{CallerRole, RiskLevel};
413 use tempfile::tempdir;
414
415 fn sqlite_store(path: std::path::PathBuf) -> SqliteStore {
416 let key = Arc::new(AuditKey::from_bytes(vec![0x42; 32]));
417 SqliteStore::new(crate::transactions::TransactionStore::open_with_key(path, key).unwrap())
418 }
419
420 fn new_tx(request_id: &str) -> NewTransaction {
421 NewTransaction {
422 request_id: request_id.to_string(),
423 request_hash: format!("hash-{request_id}"),
424 action_name: "UpdateSystem".to_string(),
425 risk_level: RiskLevel::High,
426 summary: "Upgrade the system".to_string(),
427 warnings: vec![],
428 caller_role: CallerRole::Dev,
429 caller_principal: CallerPrincipal::Uid(1000),
430 }
431 }
432
433 #[tokio::test]
434 async fn reconcile_interrupted_running_fails_only_running_rows() {
435 let dir = tempdir().unwrap();
436 let store = sqlite_store(dir.path().join("tx.db"));
437
438 let running = store.record(new_tx("running")).await.unwrap();
441 store
442 .update_status(&running.transaction_id, JobState::Running)
443 .await
444 .unwrap();
445
446 let queued = store.record(new_tx("queued")).await.unwrap();
447
448 let succeeded = store.record(new_tx("succeeded")).await.unwrap();
449 store
450 .update_status(&succeeded.transaction_id, JobState::Running)
451 .await
452 .unwrap();
453 store
454 .update_status(&succeeded.transaction_id, JobState::Succeeded)
455 .await
456 .unwrap();
457
458 let reconciled = store.reconcile_interrupted_running().await.unwrap();
459 assert_eq!(reconciled, 1, "only the one Running row is reconciled");
460
461 assert_eq!(
462 store
463 .get(&running.transaction_id)
464 .await
465 .unwrap()
466 .unwrap()
467 .status,
468 JobState::Failed,
469 "orphaned Running row must become Failed"
470 );
471 assert_eq!(
472 store
473 .get(&queued.transaction_id)
474 .await
475 .unwrap()
476 .unwrap()
477 .status,
478 JobState::Queued,
479 "Queued row is untouched"
480 );
481 assert_eq!(
482 store
483 .get(&succeeded.transaction_id)
484 .await
485 .unwrap()
486 .unwrap()
487 .status,
488 JobState::Succeeded,
489 "terminal Succeeded row is untouched"
490 );
491
492 assert_eq!(store.reconcile_interrupted_running().await.unwrap(), 0);
494 }
495}