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: Option<crate::transactions::HistorySince>,
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: Option<crate::transactions::HistorySince>,
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: Option<crate::transactions::HistorySince>,
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)
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: Option<crate::transactions::HistorySince>,
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 || inner.list_history(limit, status.as_deref(), action.as_deref(), since))
403 .await
404 }
405
406 async fn fetch_chain_row(
407 &self,
408 transaction_id: &str,
409 ) -> Result<Option<ChainRow>, TransactionStoreError> {
410 let inner = Arc::clone(&self.inner);
411 let id = transaction_id.to_string();
412 blocking(move || inner.fetch_chain_row(&id)).await
413 }
414
415 async fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
416 let inner = Arc::clone(&self.inner);
417 blocking(move || inner.fetch_chain_rows()).await
418 }
419
420 async fn fetch_event_rows(&self) -> Result<Vec<EventRow>, TransactionStoreError> {
421 let inner = Arc::clone(&self.inner);
422 blocking(move || inner.fetch_event_rows()).await
423 }
424
425 async fn verify_audit_chain(
426 &self,
427 key: &AuditKey,
428 ) -> Result<VerifyOutcome, TransactionStoreError> {
429 let rows = self.fetch_chain_rows().await?;
434 Ok(crate::audit_chain::verify_chain(key, &rows))
435 }
436}
437
438pub use postgres::PostgresStore;
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444 use crate::audit_chain::AuditKey;
445 use crate::auth::CallerPrincipal;
446 use crate::transactions::NewTransaction;
447 use sysknife_types::{CallerRole, RiskLevel};
448 use tempfile::tempdir;
449
450 fn sqlite_store(path: std::path::PathBuf) -> SqliteStore {
451 let key = Arc::new(AuditKey::from_bytes(vec![0x42; 32]));
452 SqliteStore::new(crate::transactions::TransactionStore::open_with_key(path, key).unwrap())
453 }
454
455 #[test]
464 fn chain_row_columns_pinned() {
465 let columns: Vec<&str> = crate::store::CHAIN_ROW_COLUMNS
466 .split(',')
467 .map(str::trim)
468 .collect();
469 assert_eq!(
470 columns,
471 [
472 "seq",
473 "key_id",
474 "transaction_id",
475 "request_id",
476 "request_hash",
477 "action_name",
478 "risk_level",
479 "summary",
480 "approval_id",
481 "warnings_json",
482 "created_at",
483 "prev_chain_hash",
484 "chain_hash",
485 "chain_version",
486 "caller_role",
487 "event_tip",
488 "caller_principal",
489 ],
490 "CHAIN_ROW_COLUMNS changed — both ChainRow mappers read these \
491 columns in this order, so update them in the same change"
492 );
493 assert_eq!(columns.len(), 17, "column count drifted from 17");
494 }
495
496 fn new_tx(request_id: &str) -> NewTransaction {
497 NewTransaction {
498 request_id: request_id.to_string(),
499 request_hash: format!("hash-{request_id}"),
500 action_name: "UpdateSystem".to_string(),
501 risk_level: RiskLevel::High,
502 summary: "Upgrade the system".to_string(),
503 warnings: vec![],
504 caller_role: CallerRole::Dev,
505 caller_principal: CallerPrincipal::Uid(1000),
506 }
507 }
508
509 #[tokio::test]
510 async fn reconcile_interrupted_running_fails_only_running_rows() {
511 let dir = tempdir().unwrap();
512 let store = sqlite_store(dir.path().join("tx.db"));
513
514 let running = store.record(new_tx("running")).await.unwrap();
517 store
518 .update_status(&running.transaction_id, JobState::Running)
519 .await
520 .unwrap();
521
522 let queued = store.record(new_tx("queued")).await.unwrap();
523
524 let succeeded = store.record(new_tx("succeeded")).await.unwrap();
525 store
526 .update_status(&succeeded.transaction_id, JobState::Running)
527 .await
528 .unwrap();
529 store
530 .update_status(&succeeded.transaction_id, JobState::Succeeded)
531 .await
532 .unwrap();
533
534 let reconciled = store.reconcile_interrupted_running().await.unwrap();
535 assert_eq!(reconciled, 1, "only the one Running row is reconciled");
536
537 assert_eq!(
538 store
539 .get(&running.transaction_id)
540 .await
541 .unwrap()
542 .unwrap()
543 .status,
544 JobState::Failed,
545 "orphaned Running row must become Failed"
546 );
547 assert_eq!(
548 store
549 .get(&queued.transaction_id)
550 .await
551 .unwrap()
552 .unwrap()
553 .status,
554 JobState::Queued,
555 "Queued row is untouched"
556 );
557 assert_eq!(
558 store
559 .get(&succeeded.transaction_id)
560 .await
561 .unwrap()
562 .unwrap()
563 .status,
564 JobState::Succeeded,
565 "terminal Succeeded row is untouched"
566 );
567
568 assert_eq!(store.reconcile_interrupted_running().await.unwrap(), 0);
570 }
571}