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
75pub(crate) const NEWEST_STATUS_EVENT_SQL: &str = "(SELECT e.kind FROM audit_events e \
84 WHERE e.transaction_id = t.transaction_id AND e.kind LIKE 'status_%' \
85 ORDER BY e.seq DESC LIMIT 1)";
86pub(crate) const APPROVAL_CONSUMED_SQL: &str = "EXISTS (SELECT 1 FROM audit_events e \
88 WHERE e.transaction_id = t.transaction_id AND e.kind = 'approval_consumed')";
89
90#[async_trait]
98pub trait AuditStore: Send + Sync + std::fmt::Debug {
99 async fn record(
100 &self,
101 transaction: NewTransaction,
102 ) -> Result<TransactionRecord, TransactionStoreError>;
103
104 async fn record_previewed(
105 &self,
106 transaction: NewTransaction,
107 preview: PreviewEnvelope,
108 ) -> Result<RecordedPreviewedTransaction, TransactionStoreError>;
109
110 async fn get(
111 &self,
112 transaction_id: &str,
113 ) -> Result<Option<TransactionRecord>, TransactionStoreError>;
114
115 async fn get_preview(
116 &self,
117 transaction_id: &str,
118 ) -> Result<Option<PreviewEnvelope>, TransactionStoreError>;
119
120 async fn update_status(
121 &self,
122 transaction_id: &str,
123 new_status: JobState,
124 ) -> Result<(), TransactionStoreError>;
125
126 async fn approve_transaction(
127 &self,
128 transaction_id: &str,
129 approver: CallerPrincipal,
130 ) -> Result<Option<String>, TransactionStoreError>;
131
132 async fn revoke_unconsumed_approval(
133 &self,
134 transaction_id: &str,
135 revoker: CallerPrincipal,
136 ) -> Result<bool, TransactionStoreError>;
137
138 async fn claim_approved_for_execution(
139 &self,
140 transaction_id: &str,
141 receipt_digest: &str,
142 executor: CallerPrincipal,
143 ) -> Result<bool, TransactionStoreError>;
144
145 async fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError>;
146
147 async fn reconcile_interrupted_running(&self) -> Result<u64, TransactionStoreError> {
163 let mut total = 0u64;
164 loop {
165 let running = self
166 .list_transactions(100, Some("running"), None, None)
167 .await?;
168 if running.is_empty() {
169 break;
170 }
171 let batch = running.len();
172 for tx in running {
173 self.update_status(&tx.transaction_id, JobState::Failed)
174 .await?;
175 }
176 total += batch as u64;
177 if batch < 100 {
179 break;
180 }
181 }
182 Ok(total)
183 }
184
185 async fn cancel_queued(
190 &self,
191 transaction_id: &str,
192 canceller: CallerPrincipal,
193 ) -> Result<bool, TransactionStoreError>;
194
195 async fn list_transactions(
196 &self,
197 limit: u32,
198 status_filter: Option<&str>,
199 action_filter: Option<&str>,
200 since: Option<crate::transactions::HistorySince>,
201 ) -> Result<Vec<TransactionRecord>, TransactionStoreError>;
202
203 async fn list_history(
206 &self,
207 limit: u32,
208 status_filter: Option<&str>,
209 action_filter: Option<&str>,
210 since: Option<crate::transactions::HistorySince>,
211 ) -> Result<Vec<crate::transactions::JobHistoryEntry>, TransactionStoreError>;
212
213 async fn fetch_chain_row(
214 &self,
215 transaction_id: &str,
216 ) -> Result<Option<ChainRow>, TransactionStoreError>;
217
218 async fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError>;
219
220 async fn fetch_event_rows(&self) -> Result<Vec<EventRow>, TransactionStoreError>;
223
224 async fn verify_audit_chain(
225 &self,
226 key: &AuditKey,
227 ) -> Result<VerifyOutcome, TransactionStoreError>;
228
229 async fn verify_event_chain(
236 &self,
237 key: &AuditKey,
238 ) -> Result<VerifyOutcome, TransactionStoreError> {
239 let rows = self.fetch_event_rows().await?;
240 Ok(crate::audit_chain::verify_event_chain(key, &rows))
241 }
242
243 async fn verify_event_binding(
246 &self,
247 ) -> Result<crate::audit_chain::BindingOutcome, TransactionStoreError> {
248 let tx_rows = self.fetch_chain_rows().await?;
249 let event_rows = self.fetch_event_rows().await?;
250 Ok(crate::audit_chain::verify_event_binding(
251 &tx_rows,
252 &event_rows,
253 ))
254 }
255}
256
257#[derive(Debug, Clone)]
262pub struct SqliteStore {
263 inner: Arc<TransactionStore>,
264}
265
266impl SqliteStore {
267 pub fn new(inner: TransactionStore) -> Self {
268 Self {
269 inner: Arc::new(inner),
270 }
271 }
272
273 pub fn inner(&self) -> &TransactionStore {
277 &self.inner
278 }
279}
280
281async fn blocking<F, R>(f: F) -> R
288where
289 F: FnOnce() -> R + Send + 'static,
290 R: Send + 'static,
291{
292 tokio::task::spawn_blocking(f)
293 .await
294 .expect("audit-store blocking task panicked")
295}
296
297#[async_trait]
298impl AuditStore for SqliteStore {
299 async fn record(
300 &self,
301 transaction: NewTransaction,
302 ) -> Result<TransactionRecord, TransactionStoreError> {
303 let inner = Arc::clone(&self.inner);
304 blocking(move || inner.record(transaction)).await
305 }
306
307 async fn record_previewed(
308 &self,
309 transaction: NewTransaction,
310 preview: PreviewEnvelope,
311 ) -> Result<RecordedPreviewedTransaction, TransactionStoreError> {
312 let inner = Arc::clone(&self.inner);
313 blocking(move || inner.record_previewed(transaction, preview)).await
314 }
315
316 async fn get(
317 &self,
318 transaction_id: &str,
319 ) -> Result<Option<TransactionRecord>, TransactionStoreError> {
320 let inner = Arc::clone(&self.inner);
321 let id = transaction_id.to_string();
322 blocking(move || inner.get(&id)).await
323 }
324
325 async fn get_preview(
326 &self,
327 transaction_id: &str,
328 ) -> Result<Option<PreviewEnvelope>, TransactionStoreError> {
329 let inner = Arc::clone(&self.inner);
330 let id = transaction_id.to_string();
331 blocking(move || inner.get_preview(&id)).await
332 }
333
334 async fn update_status(
335 &self,
336 transaction_id: &str,
337 new_status: JobState,
338 ) -> Result<(), TransactionStoreError> {
339 let inner = Arc::clone(&self.inner);
340 let id = transaction_id.to_string();
341 blocking(move || inner.update_status(&id, new_status)).await
342 }
343
344 async fn approve_transaction(
345 &self,
346 transaction_id: &str,
347 approver: CallerPrincipal,
348 ) -> Result<Option<String>, TransactionStoreError> {
349 let inner = Arc::clone(&self.inner);
350 let id = transaction_id.to_string();
351 blocking(move || inner.approve_transaction(&id, approver)).await
352 }
353
354 async fn revoke_unconsumed_approval(
355 &self,
356 transaction_id: &str,
357 revoker: CallerPrincipal,
358 ) -> Result<bool, TransactionStoreError> {
359 let inner = Arc::clone(&self.inner);
360 let id = transaction_id.to_string();
361 blocking(move || inner.revoke_unconsumed_approval(&id, revoker)).await
362 }
363
364 async fn claim_approved_for_execution(
365 &self,
366 transaction_id: &str,
367 receipt_digest: &str,
368 executor: CallerPrincipal,
369 ) -> Result<bool, TransactionStoreError> {
370 let inner = Arc::clone(&self.inner);
371 let id = transaction_id.to_string();
372 let digest = receipt_digest.to_string();
373 blocking(move || inner.claim_approved_for_execution(&id, &digest, executor)).await
374 }
375
376 async fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError> {
377 let inner = Arc::clone(&self.inner);
378 blocking(move || inner.cleanup_stale_queued()).await
379 }
380
381 async fn cancel_queued(
382 &self,
383 transaction_id: &str,
384 canceller: CallerPrincipal,
385 ) -> Result<bool, TransactionStoreError> {
386 let inner = Arc::clone(&self.inner);
387 let id = transaction_id.to_string();
388 blocking(move || inner.cancel_queued(&id, canceller)).await
389 }
390
391 async fn list_transactions(
392 &self,
393 limit: u32,
394 status_filter: Option<&str>,
395 action_filter: Option<&str>,
396 since: Option<crate::transactions::HistorySince>,
397 ) -> Result<Vec<TransactionRecord>, TransactionStoreError> {
398 let inner = Arc::clone(&self.inner);
399 let status = status_filter.map(str::to_string);
400 let action = action_filter.map(str::to_string);
401 blocking(move || {
402 inner.list_transactions(limit, status.as_deref(), action.as_deref(), since)
403 })
404 .await
405 }
406
407 async fn list_history(
408 &self,
409 limit: u32,
410 status_filter: Option<&str>,
411 action_filter: Option<&str>,
412 since: Option<crate::transactions::HistorySince>,
413 ) -> Result<Vec<crate::transactions::JobHistoryEntry>, TransactionStoreError> {
414 let inner = Arc::clone(&self.inner);
415 let status = status_filter.map(str::to_string);
416 let action = action_filter.map(str::to_string);
417 blocking(move || inner.list_history(limit, status.as_deref(), action.as_deref(), since))
418 .await
419 }
420
421 async fn fetch_chain_row(
422 &self,
423 transaction_id: &str,
424 ) -> Result<Option<ChainRow>, TransactionStoreError> {
425 let inner = Arc::clone(&self.inner);
426 let id = transaction_id.to_string();
427 blocking(move || inner.fetch_chain_row(&id)).await
428 }
429
430 async fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
431 let inner = Arc::clone(&self.inner);
432 blocking(move || inner.fetch_chain_rows()).await
433 }
434
435 async fn fetch_event_rows(&self) -> Result<Vec<EventRow>, TransactionStoreError> {
436 let inner = Arc::clone(&self.inner);
437 blocking(move || inner.fetch_event_rows()).await
438 }
439
440 async fn verify_audit_chain(
441 &self,
442 key: &AuditKey,
443 ) -> Result<VerifyOutcome, TransactionStoreError> {
444 let rows = self.fetch_chain_rows().await?;
449 Ok(crate::audit_chain::verify_chain(key, &rows))
450 }
451}
452
453pub use postgres::PostgresStore;
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459 use crate::audit_chain::AuditKey;
460 use crate::auth::CallerPrincipal;
461 use crate::transactions::NewTransaction;
462 use sysknife_types::{CallerRole, RiskLevel};
463 use tempfile::tempdir;
464
465 fn sqlite_store(path: std::path::PathBuf) -> SqliteStore {
466 let key = Arc::new(AuditKey::from_bytes(vec![0x42; 32]));
467 SqliteStore::new(crate::transactions::TransactionStore::open_with_key(path, key).unwrap())
468 }
469
470 #[test]
479 fn chain_row_columns_pinned() {
480 let columns: Vec<&str> = crate::store::CHAIN_ROW_COLUMNS
481 .split(',')
482 .map(str::trim)
483 .collect();
484 assert_eq!(
485 columns,
486 [
487 "seq",
488 "key_id",
489 "transaction_id",
490 "request_id",
491 "request_hash",
492 "action_name",
493 "risk_level",
494 "summary",
495 "approval_id",
496 "warnings_json",
497 "created_at",
498 "prev_chain_hash",
499 "chain_hash",
500 "chain_version",
501 "caller_role",
502 "event_tip",
503 "caller_principal",
504 ],
505 "CHAIN_ROW_COLUMNS changed — both ChainRow mappers read these \
506 columns in this order, so update them in the same change"
507 );
508 assert_eq!(columns.len(), 17, "column count drifted from 17");
509 }
510
511 fn new_tx(request_id: &str) -> NewTransaction {
512 NewTransaction {
513 request_id: request_id.to_string(),
514 request_hash: format!("hash-{request_id}"),
515 action_name: "UpdateSystem".to_string(),
516 risk_level: RiskLevel::High,
517 summary: "Upgrade the system".to_string(),
518 warnings: vec![],
519 caller_role: CallerRole::Dev,
520 caller_principal: CallerPrincipal::Uid(1000),
521 }
522 }
523
524 #[tokio::test]
525 async fn reconcile_interrupted_running_fails_only_running_rows() {
526 let dir = tempdir().unwrap();
527 let store = sqlite_store(dir.path().join("tx.db"));
528
529 let running = store.record(new_tx("running")).await.unwrap();
532 store
533 .update_status(&running.transaction_id, JobState::Running)
534 .await
535 .unwrap();
536
537 let queued = store.record(new_tx("queued")).await.unwrap();
538
539 let succeeded = store.record(new_tx("succeeded")).await.unwrap();
540 store
541 .update_status(&succeeded.transaction_id, JobState::Running)
542 .await
543 .unwrap();
544 store
545 .update_status(&succeeded.transaction_id, JobState::Succeeded)
546 .await
547 .unwrap();
548
549 let reconciled = store.reconcile_interrupted_running().await.unwrap();
550 assert_eq!(reconciled, 1, "only the one Running row is reconciled");
551
552 assert_eq!(
553 store
554 .get(&running.transaction_id)
555 .await
556 .unwrap()
557 .unwrap()
558 .status,
559 JobState::Failed,
560 "orphaned Running row must become Failed"
561 );
562 assert_eq!(
563 store
564 .get(&queued.transaction_id)
565 .await
566 .unwrap()
567 .unwrap()
568 .status,
569 JobState::Queued,
570 "Queued row is untouched"
571 );
572 assert_eq!(
573 store
574 .get(&succeeded.transaction_id)
575 .await
576 .unwrap()
577 .unwrap()
578 .status,
579 JobState::Succeeded,
580 "terminal Succeeded row is untouched"
581 );
582
583 assert_eq!(store.reconcile_interrupted_running().await.unwrap(), 0);
585 }
586}