use async_trait::async_trait;
use std::sync::Arc;
use sysknife_types::{JobState, PreviewEnvelope, TransactionRecord};
use crate::audit_chain::{AuditKey, ChainRow, EventRow, VerifyOutcome};
use crate::transactions::{
NewTransaction, RecordedPreviewedTransaction, TransactionStore, TransactionStoreError,
};
pub mod postgres;
pub(crate) const CHAIN_ROW_COLUMNS: &str =
"seq, key_id, transaction_id, request_id, request_hash, \
action_name, risk_level, summary, approval_id, warnings_json, \
created_at, prev_chain_hash, chain_hash, chain_version, caller_role, event_tip, \
caller_principal";
#[async_trait]
pub trait AuditStore: Send + Sync + std::fmt::Debug {
async fn record(
&self,
transaction: NewTransaction,
) -> Result<TransactionRecord, TransactionStoreError>;
async fn record_previewed(
&self,
transaction: NewTransaction,
preview: PreviewEnvelope,
) -> Result<RecordedPreviewedTransaction, TransactionStoreError>;
async fn get(
&self,
transaction_id: &str,
) -> Result<Option<TransactionRecord>, TransactionStoreError>;
async fn get_preview(
&self,
transaction_id: &str,
) -> Result<Option<PreviewEnvelope>, TransactionStoreError>;
async fn update_status(
&self,
transaction_id: &str,
new_status: JobState,
) -> Result<(), TransactionStoreError>;
async fn approve_transaction(
&self,
transaction_id: &str,
) -> Result<Option<String>, TransactionStoreError>;
async fn revoke_unconsumed_approval(
&self,
transaction_id: &str,
) -> Result<bool, TransactionStoreError>;
async fn claim_approved_for_execution(
&self,
transaction_id: &str,
receipt_digest: &str,
) -> Result<bool, TransactionStoreError>;
async fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError>;
async fn reconcile_interrupted_running(&self) -> Result<u64, TransactionStoreError> {
let mut total = 0u64;
loop {
let running = self
.list_transactions(100, Some("running"), None, None)
.await?;
if running.is_empty() {
break;
}
let batch = running.len();
for tx in running {
self.update_status(&tx.transaction_id, JobState::Failed)
.await?;
}
total += batch as u64;
if batch < 100 {
break;
}
}
Ok(total)
}
async fn cancel_queued(&self, transaction_id: &str) -> Result<bool, TransactionStoreError>;
async fn list_transactions(
&self,
limit: u32,
status_filter: Option<&str>,
action_filter: Option<&str>,
since_hours: Option<u32>,
) -> Result<Vec<TransactionRecord>, TransactionStoreError>;
async fn list_history(
&self,
limit: u32,
status_filter: Option<&str>,
action_filter: Option<&str>,
since_hours: Option<u32>,
) -> Result<Vec<crate::transactions::JobHistoryEntry>, TransactionStoreError>;
async fn fetch_chain_row(
&self,
transaction_id: &str,
) -> Result<Option<ChainRow>, TransactionStoreError>;
async fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError>;
async fn fetch_event_rows(&self) -> Result<Vec<EventRow>, TransactionStoreError>;
async fn verify_audit_chain(
&self,
key: &AuditKey,
) -> Result<VerifyOutcome, TransactionStoreError>;
async fn verify_event_chain(
&self,
key: &AuditKey,
) -> Result<VerifyOutcome, TransactionStoreError> {
let rows = self.fetch_event_rows().await?;
Ok(crate::audit_chain::verify_event_chain(key, &rows))
}
async fn verify_event_binding(
&self,
) -> Result<crate::audit_chain::BindingOutcome, TransactionStoreError> {
let tx_rows = self.fetch_chain_rows().await?;
let event_rows = self.fetch_event_rows().await?;
Ok(crate::audit_chain::verify_event_binding(
&tx_rows,
&event_rows,
))
}
}
#[derive(Debug, Clone)]
pub struct SqliteStore {
inner: Arc<TransactionStore>,
}
impl SqliteStore {
pub fn new(inner: TransactionStore) -> Self {
Self {
inner: Arc::new(inner),
}
}
pub fn inner(&self) -> &TransactionStore {
&self.inner
}
}
async fn blocking<F, R>(f: F) -> R
where
F: FnOnce() -> R + Send + 'static,
R: Send + 'static,
{
tokio::task::spawn_blocking(f)
.await
.expect("audit-store blocking task panicked")
}
#[async_trait]
impl AuditStore for SqliteStore {
async fn record(
&self,
transaction: NewTransaction,
) -> Result<TransactionRecord, TransactionStoreError> {
let inner = Arc::clone(&self.inner);
blocking(move || inner.record(transaction)).await
}
async fn record_previewed(
&self,
transaction: NewTransaction,
preview: PreviewEnvelope,
) -> Result<RecordedPreviewedTransaction, TransactionStoreError> {
let inner = Arc::clone(&self.inner);
blocking(move || inner.record_previewed(transaction, preview)).await
}
async fn get(
&self,
transaction_id: &str,
) -> Result<Option<TransactionRecord>, TransactionStoreError> {
let inner = Arc::clone(&self.inner);
let id = transaction_id.to_string();
blocking(move || inner.get(&id)).await
}
async fn get_preview(
&self,
transaction_id: &str,
) -> Result<Option<PreviewEnvelope>, TransactionStoreError> {
let inner = Arc::clone(&self.inner);
let id = transaction_id.to_string();
blocking(move || inner.get_preview(&id)).await
}
async fn update_status(
&self,
transaction_id: &str,
new_status: JobState,
) -> Result<(), TransactionStoreError> {
let inner = Arc::clone(&self.inner);
let id = transaction_id.to_string();
blocking(move || inner.update_status(&id, new_status)).await
}
async fn approve_transaction(
&self,
transaction_id: &str,
) -> Result<Option<String>, TransactionStoreError> {
let inner = Arc::clone(&self.inner);
let id = transaction_id.to_string();
blocking(move || inner.approve_transaction(&id)).await
}
async fn revoke_unconsumed_approval(
&self,
transaction_id: &str,
) -> Result<bool, TransactionStoreError> {
let inner = Arc::clone(&self.inner);
let id = transaction_id.to_string();
blocking(move || inner.revoke_unconsumed_approval(&id)).await
}
async fn claim_approved_for_execution(
&self,
transaction_id: &str,
receipt_digest: &str,
) -> Result<bool, TransactionStoreError> {
let inner = Arc::clone(&self.inner);
let id = transaction_id.to_string();
let digest = receipt_digest.to_string();
blocking(move || inner.claim_approved_for_execution(&id, &digest)).await
}
async fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError> {
let inner = Arc::clone(&self.inner);
blocking(move || inner.cleanup_stale_queued()).await
}
async fn cancel_queued(&self, transaction_id: &str) -> Result<bool, TransactionStoreError> {
let inner = Arc::clone(&self.inner);
let id = transaction_id.to_string();
blocking(move || inner.cancel_queued(&id)).await
}
async fn list_transactions(
&self,
limit: u32,
status_filter: Option<&str>,
action_filter: Option<&str>,
since_hours: Option<u32>,
) -> Result<Vec<TransactionRecord>, TransactionStoreError> {
let inner = Arc::clone(&self.inner);
let status = status_filter.map(str::to_string);
let action = action_filter.map(str::to_string);
blocking(move || {
inner.list_transactions(limit, status.as_deref(), action.as_deref(), since_hours)
})
.await
}
async fn list_history(
&self,
limit: u32,
status_filter: Option<&str>,
action_filter: Option<&str>,
since_hours: Option<u32>,
) -> Result<Vec<crate::transactions::JobHistoryEntry>, TransactionStoreError> {
let inner = Arc::clone(&self.inner);
let status = status_filter.map(str::to_string);
let action = action_filter.map(str::to_string);
blocking(move || {
inner.list_history(limit, status.as_deref(), action.as_deref(), since_hours)
})
.await
}
async fn fetch_chain_row(
&self,
transaction_id: &str,
) -> Result<Option<ChainRow>, TransactionStoreError> {
let inner = Arc::clone(&self.inner);
let id = transaction_id.to_string();
blocking(move || inner.fetch_chain_row(&id)).await
}
async fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
let inner = Arc::clone(&self.inner);
blocking(move || inner.fetch_chain_rows()).await
}
async fn fetch_event_rows(&self) -> Result<Vec<EventRow>, TransactionStoreError> {
let inner = Arc::clone(&self.inner);
blocking(move || inner.fetch_event_rows()).await
}
async fn verify_audit_chain(
&self,
key: &AuditKey,
) -> Result<VerifyOutcome, TransactionStoreError> {
let rows = self.fetch_chain_rows().await?;
Ok(crate::audit_chain::verify_chain(key, &rows))
}
}
pub use postgres::PostgresStore;
#[cfg(test)]
mod tests {
use super::*;
use crate::audit_chain::AuditKey;
use crate::auth::CallerPrincipal;
use crate::transactions::NewTransaction;
use sysknife_types::{CallerRole, RiskLevel};
use tempfile::tempdir;
fn sqlite_store(path: std::path::PathBuf) -> SqliteStore {
let key = Arc::new(AuditKey::from_bytes(vec![0x42; 32]));
SqliteStore::new(crate::transactions::TransactionStore::open_with_key(path, key).unwrap())
}
#[test]
fn chain_row_columns_pinned() {
let columns: Vec<&str> = crate::store::CHAIN_ROW_COLUMNS
.split(',')
.map(str::trim)
.collect();
assert_eq!(
columns,
[
"seq",
"key_id",
"transaction_id",
"request_id",
"request_hash",
"action_name",
"risk_level",
"summary",
"approval_id",
"warnings_json",
"created_at",
"prev_chain_hash",
"chain_hash",
"chain_version",
"caller_role",
"event_tip",
"caller_principal",
],
"CHAIN_ROW_COLUMNS changed — both ChainRow mappers read these \
columns in this order, so update them in the same change"
);
assert_eq!(columns.len(), 17, "column count drifted from 17");
}
fn new_tx(request_id: &str) -> NewTransaction {
NewTransaction {
request_id: request_id.to_string(),
request_hash: format!("hash-{request_id}"),
action_name: "UpdateSystem".to_string(),
risk_level: RiskLevel::High,
summary: "Upgrade the system".to_string(),
warnings: vec![],
caller_role: CallerRole::Dev,
caller_principal: CallerPrincipal::Uid(1000),
}
}
#[tokio::test]
async fn reconcile_interrupted_running_fails_only_running_rows() {
let dir = tempdir().unwrap();
let store = sqlite_store(dir.path().join("tx.db"));
let running = store.record(new_tx("running")).await.unwrap();
store
.update_status(&running.transaction_id, JobState::Running)
.await
.unwrap();
let queued = store.record(new_tx("queued")).await.unwrap();
let succeeded = store.record(new_tx("succeeded")).await.unwrap();
store
.update_status(&succeeded.transaction_id, JobState::Running)
.await
.unwrap();
store
.update_status(&succeeded.transaction_id, JobState::Succeeded)
.await
.unwrap();
let reconciled = store.reconcile_interrupted_running().await.unwrap();
assert_eq!(reconciled, 1, "only the one Running row is reconciled");
assert_eq!(
store
.get(&running.transaction_id)
.await
.unwrap()
.unwrap()
.status,
JobState::Failed,
"orphaned Running row must become Failed"
);
assert_eq!(
store
.get(&queued.transaction_id)
.await
.unwrap()
.unwrap()
.status,
JobState::Queued,
"Queued row is untouched"
);
assert_eq!(
store
.get(&succeeded.transaction_id)
.await
.unwrap()
.unwrap()
.status,
JobState::Succeeded,
"terminal Succeeded row is untouched"
);
assert_eq!(store.reconcile_interrupted_running().await.unwrap(), 0);
}
}