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