use async_trait::async_trait;
use sqlx_core::row::Row;
use sqlx_postgres::{PgConnectOptions, PgPool, PgPoolOptions};
use std::str::FromStr;
use std::time::Duration;
use subtle::ConstantTimeEq;
use sysknife_types::{JobState, PreviewEnvelope, TransactionRecord};
use uuid::Uuid;
use crate::audit_chain::{
AttributionCensus, AuditEventKind, AuditKey, AuditVerification, BindingOutcome, ChainContent,
ChainIdentity, ChainRow, EventContent, EventIdentity, EventRow, StatusEvidence, StatusOutcome,
VerifyOutcome, CURRENT_KEY_ID,
};
use crate::audit_watermark::emit_chain_tip_watermark;
use crate::auth::CallerPrincipal;
use crate::store::AuditStore;
use crate::transactions::{
HistorySince, NewTransaction, RecordedPreviewedTransaction, TransactionStoreError,
APPROVAL_RECEIPT_TTL_MINUTES,
};
const MIGRATION_LOCK_ID: i64 = 0x5359_534b_4e49_4645;
use crate::store::CHAIN_ROW_COLUMNS;
use crate::store::{APPROVAL_CONSUMED_SQL, NEWEST_STATUS_EVENT_SQL};
struct Migration {
version: i64,
name: &'static str,
statements: &'static [&'static str],
}
const MIGRATIONS: &[Migration] = &[Migration {
version: 1,
name: "initial_audit_schema",
statements: &[
r#"
CREATE TABLE IF NOT EXISTS transactions (
transaction_id TEXT PRIMARY KEY,
request_id TEXT NOT NULL,
request_hash TEXT NOT NULL,
action_name TEXT NOT NULL,
risk_level TEXT NOT NULL,
status TEXT NOT NULL,
approval_id TEXT,
summary TEXT NOT NULL,
warnings_json TEXT NOT NULL,
created_at TEXT NOT NULL,
seq BIGINT NOT NULL UNIQUE,
key_id TEXT NOT NULL,
chain_hash TEXT NOT NULL,
prev_chain_hash TEXT NOT NULL DEFAULT ''
)
"#,
r#"
CREATE TABLE IF NOT EXISTS transaction_approvals (
transaction_id TEXT PRIMARY KEY,
receipt_digest TEXT NOT NULL,
approved_at TEXT NOT NULL,
consumed_at TEXT
)
"#,
r#"
CREATE TABLE IF NOT EXISTS transaction_previews (
transaction_id TEXT PRIMARY KEY,
preview_json TEXT NOT NULL
)
"#,
"CREATE INDEX IF NOT EXISTS transactions_seq_idx ON transactions(seq)",
],
},
Migration {
version: 2,
name: "caller_identity_and_approval_events",
statements: &[
"ALTER TABLE transactions ADD COLUMN IF NOT EXISTS chain_version BIGINT NOT NULL DEFAULT 1",
"ALTER TABLE transactions ADD COLUMN IF NOT EXISTS caller_role TEXT",
"ALTER TABLE transactions ADD COLUMN IF NOT EXISTS event_tip TEXT",
r#"
CREATE TABLE IF NOT EXISTS audit_events (
seq BIGINT PRIMARY KEY,
key_id TEXT NOT NULL,
kind TEXT NOT NULL,
transaction_id TEXT NOT NULL,
receipt_digest TEXT NOT NULL,
created_at TEXT NOT NULL,
chain_hash TEXT NOT NULL,
prev_chain_hash TEXT NOT NULL DEFAULT ''
)
"#,
"CREATE INDEX IF NOT EXISTS audit_events_transaction_idx ON audit_events(transaction_id)",
],
},
Migration {
version: 3,
name: "caller_principal",
statements: &["ALTER TABLE transactions ADD COLUMN IF NOT EXISTS caller_principal TEXT"],
},
Migration {
version: 4,
name: "event_approver_identity",
statements: &[
"ALTER TABLE audit_events ADD COLUMN IF NOT EXISTS chain_version BIGINT NOT NULL DEFAULT 1",
"ALTER TABLE audit_events ADD COLUMN IF NOT EXISTS caller_principal TEXT",
],
},
Migration {
version: 5,
name: "status_event_boundary",
statements: &[
"ALTER TABLE transactions ADD COLUMN IF NOT EXISTS predates_status_events BOOLEAN NOT NULL DEFAULT TRUE",
"ALTER TABLE transactions ALTER COLUMN predates_status_events SET DEFAULT FALSE",
],
},
];
#[derive(Clone, Debug)]
pub struct PostgresConfig {
pub url: String,
pub max_connections: u32,
pub acquire_timeout: Duration,
pub statement_cache_capacity: usize,
}
impl Default for PostgresConfig {
fn default() -> Self {
Self {
url: String::new(),
max_connections: 8,
acquire_timeout: Duration::from_secs(10),
statement_cache_capacity: 100,
}
}
}
#[derive(Debug, Clone)]
pub struct PostgresStore {
pool: PgPool,
audit_key: std::sync::Arc<AuditKey>,
}
struct InsertedTransaction {
record: TransactionRecord,
seq: u64,
chain_hash: String,
}
impl PostgresStore {
async fn connect_pool(config: &PostgresConfig) -> Result<PgPool, TransactionStoreError> {
let mut connect_opts = PgConnectOptions::from_str(&config.url).map_err(|e| {
TransactionStoreError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("invalid postgres URL: {e}"),
))
})?;
crate::pg_tls::require_tls_for_remote(&connect_opts).map_err(|msg| {
TransactionStoreError::Io(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
msg,
))
})?;
connect_opts = connect_opts.statement_cache_capacity(config.statement_cache_capacity);
PgPoolOptions::new()
.max_connections(config.max_connections)
.acquire_timeout(config.acquire_timeout)
.connect_with(connect_opts)
.await
.map_err(map_sqlx_err)
}
pub async fn connect(
config: &PostgresConfig,
audit_key: std::sync::Arc<AuditKey>,
) -> Result<Self, TransactionStoreError> {
let pool = Self::connect_pool(config).await?;
let store = Self { pool, audit_key };
store.initialize().await?;
Ok(store)
}
pub async fn verify_all_with_pubkey(
config: &PostgresConfig,
verifying_key_hex: &str,
) -> Result<AuditVerification, TransactionStoreError> {
let pool = Self::connect_pool(config).await?;
let tx_rows = fetch_chain_rows_from_pool(&pool).await?;
let event_rows = match fetch_event_rows_from_pool(&pool).await {
Ok(rows) => rows,
Err(err) => {
match audit_events_table_absent(&pool).await {
Ok(true) => Vec::new(),
Ok(false) => {
return Ok(Self::unreadable_events(
verifying_key_hex,
&tx_rows,
format!("could not read the approval-event table: {err}"),
))
}
Err(probe_err) => {
return Ok(Self::unreadable_events(
verifying_key_hex,
&tx_rows,
format!(
"could not read the approval-event table ({err}), and could not \
determine whether it exists ({probe_err})"
),
))
}
}
}
};
let mut verification =
crate::audit_chain::verify_all_with_pubkey(verifying_key_hex, &tx_rows, &event_rows);
verification.status = Some(status_verdict(&pool).await);
Ok(verification)
}
fn unreadable_events(
verifying_key_hex: &str,
tx_rows: &[ChainRow],
reason: String,
) -> AuditVerification {
AuditVerification {
chain: crate::audit_chain::verify_chain_with_pubkey(verifying_key_hex, tx_rows),
status: Some(StatusOutcome::CannotVerify {
detail: format!("status cross-check not run: {reason}"),
}),
events: VerifyOutcome::CannotVerify { reason },
binding: BindingOutcome::NotChecked,
attribution: Some(AttributionCensus::of(tx_rows)),
}
}
pub async fn read_chain_rows(
config: &PostgresConfig,
) -> Result<Vec<ChainRow>, TransactionStoreError> {
let pool = Self::connect_pool(config).await?;
fetch_chain_rows_from_pool(&pool).await
}
pub async fn verify_all(
&self,
key: &AuditKey,
) -> Result<AuditVerification, TransactionStoreError> {
let tx_rows = fetch_chain_rows_from_pool(&self.pool).await?;
let event_rows = fetch_event_rows_from_pool(&self.pool).await?;
let mut verification = crate::audit_chain::verify_all(key, &tx_rows, &event_rows);
verification.status = Some(status_verdict(&self.pool).await);
Ok(verification)
}
async fn initialize(&self) -> Result<(), TransactionStoreError> {
let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
sqlx_core::query::query("SELECT pg_advisory_xact_lock($1)")
.bind(MIGRATION_LOCK_ID)
.execute(&mut *tx)
.await
.map_err(map_sqlx_err)?;
sqlx_core::query::query(
r#"
CREATE TABLE IF NOT EXISTS schema_migrations (
version BIGINT PRIMARY KEY,
name TEXT NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"#,
)
.execute(&mut *tx)
.await
.map_err(map_sqlx_err)?;
let current: i64 = sqlx_core::query_scalar::query_scalar(
"SELECT COALESCE(MAX(version), 0) FROM schema_migrations",
)
.fetch_one(&mut *tx)
.await
.map_err(map_sqlx_err)?;
let latest = MIGRATIONS.last().map_or(0, |migration| migration.version);
if current > latest {
return Err(TransactionStoreError::DatabaseInvariant(format!(
"postgres schema version {current} is newer than this binary supports ({latest})"
)));
}
for migration in MIGRATIONS
.iter()
.filter(|migration| migration.version > current)
{
for statement in migration.statements {
sqlx_core::query::query(*statement)
.execute(&mut *tx)
.await
.map_err(map_sqlx_err)?;
}
sqlx_core::query::query(
"INSERT INTO schema_migrations (version, name) VALUES ($1, $2)",
)
.bind(migration.version)
.bind(migration.name)
.execute(&mut *tx)
.await
.map_err(map_sqlx_err)?;
}
tx.commit().await.map_err(map_sqlx_err)
}
async fn revoke_unconsumed_approval_in_tx(
tx: &mut sqlx_core::transaction::Transaction<'_, sqlx_postgres::Postgres>,
key: &AuditKey,
transaction_id: &str,
revoker_signed: &str,
) -> Result<bool, TransactionStoreError> {
let digest: Option<String> = sqlx_core::query_scalar::query_scalar(
"SELECT receipt_digest FROM transaction_approvals \
WHERE transaction_id = $1 AND consumed_at IS NULL",
)
.bind(transaction_id)
.fetch_optional(&mut **tx)
.await
.map_err(map_sqlx_err)?;
let result = sqlx_core::query::query(
"DELETE FROM transaction_approvals \
WHERE transaction_id = $1 AND consumed_at IS NULL",
)
.bind(transaction_id)
.execute(&mut **tx)
.await
.map_err(map_sqlx_err)?;
if result.rows_affected() > 0 {
let digest = digest.ok_or_else(|| {
TransactionStoreError::DatabaseInvariant(format!(
"revoked an approval for {transaction_id} that had no receipt digest"
))
})?;
append_event(
tx,
key,
AuditEventKind::ApprovalRevoked,
transaction_id,
&digest,
EventIdentity::V2 {
caller_principal: revoker_signed,
},
)
.await?;
}
Ok(result.rows_affected() > 0)
}
}
fn map_sqlx_err(e: sqlx_core::Error) -> TransactionStoreError {
let message = format!("postgres: {e}");
let invariant = match &e {
sqlx_core::Error::Database(db) => db
.code()
.is_some_and(|code| code.starts_with("22") || code.starts_with("23")),
sqlx_core::Error::TypeNotFound { .. }
| sqlx_core::Error::ColumnIndexOutOfBounds { .. }
| sqlx_core::Error::ColumnNotFound(_)
| sqlx_core::Error::ColumnDecode { .. }
| sqlx_core::Error::Decode(_) => true,
_ => false,
};
if invariant {
TransactionStoreError::DatabaseInvariant(message)
} else {
TransactionStoreError::Io(std::io::Error::other(message))
}
}
fn serialize<T: serde::Serialize>(v: &T) -> Result<String, TransactionStoreError> {
serde_json::to_string(v).map_err(TransactionStoreError::Json)
}
fn deserialize<T: serde::de::DeserializeOwned>(s: &str) -> Result<T, TransactionStoreError> {
serde_json::from_str(s).map_err(TransactionStoreError::Json)
}
fn now_iso() -> String {
chrono::Utc::now()
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
.to_string()
}
#[async_trait]
impl AuditStore for PostgresStore {
async fn record(
&self,
transaction: NewTransaction,
) -> Result<TransactionRecord, TransactionStoreError> {
let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
let transaction_id = Uuid::new_v4().to_string();
let inserted =
insert_transaction(&mut tx, &self.audit_key, &transaction_id, transaction).await?;
tx.commit().await.map_err(map_sqlx_err)?;
emit_chain_tip_watermark(inserted.seq, &inserted.chain_hash);
Ok(inserted.record)
}
async fn record_previewed(
&self,
transaction: NewTransaction,
preview: PreviewEnvelope,
) -> Result<RecordedPreviewedTransaction, TransactionStoreError> {
let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
let transaction_id = Uuid::new_v4().to_string();
let inserted =
insert_transaction(&mut tx, &self.audit_key, &transaction_id, transaction).await?;
sqlx_core::query::query(
"INSERT INTO transaction_previews (transaction_id, preview_json) VALUES ($1, $2)",
)
.bind(&inserted.record.transaction_id)
.bind(serialize(&preview)?)
.execute(&mut *tx)
.await
.map_err(map_sqlx_err)?;
tx.commit().await.map_err(map_sqlx_err)?;
emit_chain_tip_watermark(inserted.seq, &inserted.chain_hash);
Ok(RecordedPreviewedTransaction {
transaction: inserted.record,
preview,
})
}
async fn get(
&self,
transaction_id: &str,
) -> Result<Option<TransactionRecord>, TransactionStoreError> {
let row = sqlx_core::query::query(
"SELECT transaction_id, request_id, request_hash, action_name, risk_level, \
status, approval_id, summary, warnings_json \
FROM transactions WHERE transaction_id = $1",
)
.bind(transaction_id)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_err)?;
row.map(row_to_record).transpose()
}
async fn get_preview(
&self,
transaction_id: &str,
) -> Result<Option<PreviewEnvelope>, TransactionStoreError> {
let row = sqlx_core::query::query(
"SELECT preview_json FROM transaction_previews WHERE transaction_id = $1",
)
.bind(transaction_id)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_err)?;
match row {
Some(r) => {
let s: String = r.try_get("preview_json").map_err(map_sqlx_err)?;
Ok(Some(deserialize(&s)?))
}
None => Ok(None),
}
}
async fn update_status(
&self,
transaction_id: &str,
new_status: JobState,
) -> Result<(), TransactionStoreError> {
let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
let current_str: Option<String> = sqlx_core::query_scalar::query_scalar(
"SELECT status FROM transactions WHERE transaction_id = $1 FOR UPDATE",
)
.bind(transaction_id)
.fetch_optional(&mut *tx)
.await
.map_err(map_sqlx_err)?;
let current_str = current_str
.ok_or_else(|| TransactionStoreError::NotFound(transaction_id.to_string()))?;
let current: JobState = deserialize(¤t_str)?;
if !crate::jobs::allowed_transition(¤t, &new_status) {
return Err(TransactionStoreError::InvalidTransition {
from: current,
to: new_status,
});
}
sqlx_core::query::query("UPDATE transactions SET status = $1 WHERE transaction_id = $2")
.bind(serialize(&new_status)?)
.bind(transaction_id)
.execute(&mut *tx)
.await
.map_err(map_sqlx_err)?;
append_event(
&mut tx,
&self.audit_key,
AuditEventKind::from(new_status),
transaction_id,
"",
EventIdentity::LegacyV1,
)
.await?;
tx.commit().await.map_err(map_sqlx_err)?;
Ok(())
}
async fn approve_transaction(
&self,
transaction_id: &str,
approver: CallerPrincipal,
) -> Result<Option<String>, TransactionStoreError> {
let approver_signed = approver.as_signed_str();
let row = sqlx_core::query::query(
"SELECT request_hash, approval_id FROM transactions WHERE transaction_id = $1",
)
.bind(transaction_id)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_err)?;
let Some(row) = row else {
return Ok(None);
};
let request_hash: String = row.try_get("request_hash").map_err(map_sqlx_err)?;
let committed_digest: Option<String> = row.try_get("approval_id").map_err(map_sqlx_err)?;
let receipt = self
.audit_key
.approval_receipt(transaction_id, &request_hash);
let receipt_digest = crate::audit_chain::approval_receipt_digest(&receipt);
let commitment_matches = committed_digest.as_deref().is_some_and(|committed| {
bool::from(receipt_digest.as_bytes().ct_eq(committed.as_bytes()))
});
if !commitment_matches {
return Err(TransactionStoreError::DatabaseInvariant(format!(
"transaction {transaction_id} approval commitment does not match its signed preview"
)));
}
let queued = serialize(&JobState::Queued)?;
let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
let result = sqlx_core::query::query(
sqlx_core::sql_str::AssertSqlSafe(format!(
"INSERT INTO transaction_approvals \
(transaction_id, receipt_digest, approved_at) \
SELECT transaction_id, $1, $2 FROM transactions \
WHERE transaction_id = $3 \
AND status = $4 \
AND created_at::timestamptz > now() - INTERVAL '{APPROVAL_RECEIPT_TTL_MINUTES} minutes' \
ON CONFLICT (transaction_id) DO NOTHING"
)),
)
.bind(&receipt_digest)
.bind(now_iso())
.bind(transaction_id)
.bind(&queued)
.execute(&mut *tx)
.await
.map_err(map_sqlx_err)?;
if result.rows_affected() > 0 {
append_event(
&mut tx,
&self.audit_key,
AuditEventKind::ApprovalGranted,
transaction_id,
&receipt_digest,
EventIdentity::V2 {
caller_principal: &approver_signed,
},
)
.await?;
}
tx.commit().await.map_err(map_sqlx_err)?;
Ok((result.rows_affected() > 0).then_some(receipt))
}
async fn revoke_unconsumed_approval(
&self,
transaction_id: &str,
revoker: CallerPrincipal,
) -> Result<bool, TransactionStoreError> {
let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
let revoked = Self::revoke_unconsumed_approval_in_tx(
&mut tx,
&self.audit_key,
transaction_id,
&revoker.as_signed_str(),
)
.await?;
tx.commit().await.map_err(map_sqlx_err)?;
Ok(revoked)
}
async fn claim_approved_for_execution(
&self,
transaction_id: &str,
receipt_digest: &str,
executor: CallerPrincipal,
) -> Result<bool, TransactionStoreError> {
let queued = serialize(&JobState::Queued)?;
let running = serialize(&JobState::Running)?;
let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
let result = sqlx_core::query::query(
sqlx_core::sql_str::AssertSqlSafe(format!(
"UPDATE transactions SET status = $1 \
WHERE transaction_id = $2 \
AND status = $3 \
AND created_at::timestamptz > now() - INTERVAL '{APPROVAL_RECEIPT_TTL_MINUTES} minutes' \
AND EXISTS ( \
SELECT 1 FROM transaction_approvals \
WHERE transaction_id = $2 \
AND receipt_digest = $4 \
AND consumed_at IS NULL \
)"
)),
)
.bind(&running)
.bind(transaction_id)
.bind(&queued)
.bind(receipt_digest)
.execute(&mut *tx)
.await
.map_err(map_sqlx_err)?;
if result.rows_affected() > 0 {
sqlx_core::query::query(
"UPDATE transaction_approvals SET consumed_at = $1 \
WHERE transaction_id = $2 AND consumed_at IS NULL",
)
.bind(now_iso())
.bind(transaction_id)
.execute(&mut *tx)
.await
.map_err(map_sqlx_err)?;
append_event(
&mut tx,
&self.audit_key,
AuditEventKind::ApprovalConsumed,
transaction_id,
receipt_digest,
EventIdentity::V2 {
caller_principal: &executor.as_signed_str(),
},
)
.await?;
append_event(
&mut tx,
&self.audit_key,
AuditEventKind::StatusRunning,
transaction_id,
"",
EventIdentity::LegacyV1,
)
.await?;
}
tx.commit().await.map_err(map_sqlx_err)?;
Ok(result.rows_affected() > 0)
}
async fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError> {
let queued = serialize(&JobState::Queued)?;
let canceled = serialize(&JobState::Canceled)?;
let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
let stale_ids: Vec<String> = sqlx_core::query_scalar::query_scalar(
sqlx_core::sql_str::AssertSqlSafe(format!(
"SELECT transaction_id FROM transactions \
WHERE status = $1 \
AND created_at::timestamptz <= now() - INTERVAL '{APPROVAL_RECEIPT_TTL_MINUTES} minutes'"
)),
)
.bind(&queued)
.fetch_all(&mut *tx)
.await
.map_err(map_sqlx_err)?;
let mut canceled_count = 0;
for transaction_id in stale_ids {
let result = sqlx_core::query::query(
"UPDATE transactions SET status = $1 \
WHERE transaction_id = $2 AND status = $3",
)
.bind(&canceled)
.bind(&transaction_id)
.bind(&queued)
.execute(&mut *tx)
.await
.map_err(map_sqlx_err)?;
if result.rows_affected() > 0 {
Self::revoke_unconsumed_approval_in_tx(
&mut tx,
&self.audit_key,
&transaction_id,
&CallerPrincipal::Unattributed.as_signed_str(),
)
.await?;
append_event(
&mut tx,
&self.audit_key,
AuditEventKind::StatusCanceled,
&transaction_id,
"",
EventIdentity::LegacyV1,
)
.await?;
canceled_count += result.rows_affected();
}
}
tx.commit().await.map_err(map_sqlx_err)?;
Ok(canceled_count)
}
async fn cancel_queued(
&self,
transaction_id: &str,
canceller: CallerPrincipal,
) -> Result<bool, TransactionStoreError> {
let queued = serialize(&JobState::Queued)?;
let canceled = serialize(&JobState::Canceled)?;
let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
let result = sqlx_core::query::query(
"UPDATE transactions SET status = $1 \
WHERE transaction_id = $2 AND status = $3",
)
.bind(&canceled)
.bind(transaction_id)
.bind(&queued)
.execute(&mut *tx)
.await
.map_err(map_sqlx_err)?;
if result.rows_affected() > 0 {
Self::revoke_unconsumed_approval_in_tx(
&mut tx,
&self.audit_key,
transaction_id,
&canceller.as_signed_str(),
)
.await?;
append_event(
&mut tx,
&self.audit_key,
AuditEventKind::StatusCanceled,
transaction_id,
"",
EventIdentity::LegacyV1,
)
.await?;
}
tx.commit().await.map_err(map_sqlx_err)?;
Ok(result.rows_affected() > 0)
}
async fn list_transactions(
&self,
limit: u32,
status_filter: Option<&str>,
action_filter: Option<&str>,
since: Option<HistorySince>,
) -> Result<Vec<TransactionRecord>, TransactionStoreError> {
let limit = limit.min(100) as i64;
let validated_status: Option<String> = status_filter
.map(|s| -> Result<String, TransactionStoreError> {
let parsed: JobState = deserialize(&format!("\"{s}\""))?;
serialize(&parsed)
})
.transpose()?;
let mut sql = String::from(
"SELECT transaction_id, request_id, request_hash, action_name, risk_level, \
status, approval_id, summary, warnings_json \
FROM transactions WHERE TRUE",
);
let mut idx = 1;
if validated_status.is_some() {
sql.push_str(&format!(" AND status = ${idx}"));
idx += 1;
}
if action_filter.is_some() {
sql.push_str(&format!(" AND action_name = ${idx}"));
idx += 1;
}
match since {
Some(HistorySince::Hours(_)) => {
sql.push_str(&format!(
" AND created_at::timestamptz > now() - (${idx} || ' hours')::INTERVAL"
));
idx += 1;
}
Some(HistorySince::Cutoff(_)) => {
sql.push_str(&format!(
" AND created_at::timestamptz >= ${idx}::timestamptz"
));
idx += 1;
}
None => {}
}
sql.push_str(&format!(" ORDER BY seq DESC LIMIT ${idx}"));
let mut q = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(sql.as_str()));
if let Some(s) = &validated_status {
q = q.bind(s);
}
if let Some(a) = action_filter {
q = q.bind(a);
}
match since {
Some(HistorySince::Hours(h)) => q = q.bind(h.to_string()),
Some(HistorySince::Cutoff(at)) => q = q.bind(HistorySince::cutoff_rfc3339(&at)),
None => {}
}
q = q.bind(limit);
let rows = q.fetch_all(&self.pool).await.map_err(map_sqlx_err)?;
rows.into_iter().map(row_to_record).collect()
}
async fn list_history(
&self,
limit: u32,
status_filter: Option<&str>,
action_filter: Option<&str>,
since: Option<HistorySince>,
) -> Result<Vec<crate::transactions::JobHistoryEntry>, TransactionStoreError> {
let limit = limit.min(100) as i64;
let validated_status: Option<String> = status_filter
.map(|s| -> Result<String, TransactionStoreError> {
let parsed: JobState = deserialize(&format!("\"{s}\""))?;
serialize(&parsed)
})
.transpose()?;
let mut sql = String::from(
"SELECT transaction_id, action_name, risk_level, status, summary, created_at \
FROM transactions WHERE TRUE",
);
let mut idx = 1;
if validated_status.is_some() {
sql.push_str(&format!(" AND status = ${idx}"));
idx += 1;
}
if action_filter.is_some() {
sql.push_str(&format!(" AND action_name = ${idx}"));
idx += 1;
}
match since {
Some(HistorySince::Hours(_)) => {
sql.push_str(&format!(
" AND created_at::timestamptz > now() - (${idx} || ' hours')::INTERVAL"
));
idx += 1;
}
Some(HistorySince::Cutoff(_)) => {
sql.push_str(&format!(
" AND created_at::timestamptz >= ${idx}::timestamptz"
));
idx += 1;
}
None => {}
}
sql.push_str(&format!(" ORDER BY seq DESC LIMIT ${idx}"));
let mut q = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(sql.as_str()));
if let Some(s) = &validated_status {
q = q.bind(s);
}
if let Some(a) = action_filter {
q = q.bind(a);
}
match since {
Some(HistorySince::Hours(h)) => q = q.bind(h.to_string()),
Some(HistorySince::Cutoff(at)) => q = q.bind(HistorySince::cutoff_rfc3339(&at)),
None => {}
}
q = q.bind(limit);
let rows = q.fetch_all(&self.pool).await.map_err(map_sqlx_err)?;
rows.into_iter().map(row_to_history_entry).collect()
}
async fn fetch_chain_row(
&self,
transaction_id: &str,
) -> Result<Option<ChainRow>, TransactionStoreError> {
let row = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(format!(
"SELECT {CHAIN_ROW_COLUMNS} FROM transactions WHERE transaction_id = $1"
)))
.bind(transaction_id)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_err)?;
row.map(row_to_chain_row).transpose()
}
async fn fetch_event_rows(&self) -> Result<Vec<EventRow>, TransactionStoreError> {
fetch_event_rows_from_pool(&self.pool).await
}
async fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
fetch_chain_rows_from_pool(&self.pool).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))
}
}
async fn fetch_chain_rows_from_pool(pool: &PgPool) -> Result<Vec<ChainRow>, TransactionStoreError> {
let rows = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(format!(
"SELECT {CHAIN_ROW_COLUMNS} FROM transactions ORDER BY seq ASC"
)))
.fetch_all(pool)
.await
.map_err(map_sqlx_err)?;
rows.into_iter().map(row_to_chain_row).collect()
}
async fn audit_events_table_absent(pool: &PgPool) -> Result<bool, TransactionStoreError> {
let present: Option<String> =
sqlx_core::query_scalar::query_scalar("SELECT to_regclass('audit_events')::text")
.fetch_one(pool)
.await
.map_err(map_sqlx_err)?;
Ok(present.is_none())
}
async fn status_verdict(pool: &PgPool) -> StatusOutcome {
status_matches_chain_from_pool(pool)
.await
.unwrap_or_else(|error| StatusOutcome::CannotVerify {
detail: format!("status cross-check failed: {error}"),
})
}
async fn status_matches_chain_from_pool(
pool: &PgPool,
) -> Result<StatusOutcome, TransactionStoreError> {
let events_present = !audit_events_table_absent(pool).await?;
let boundary_present: bool = sqlx_core::query_scalar::query_scalar(
"SELECT EXISTS (SELECT 1 FROM pg_attribute \
WHERE attrelid = to_regclass('transactions') \
AND attname = 'predates_status_events' AND NOT attisdropped)",
)
.fetch_one(pool)
.await
.map_err(map_sqlx_err)?;
let (newest, consumed) = if events_present {
(NEWEST_STATUS_EVENT_SQL, APPROVAL_CONSUMED_SQL)
} else {
("NULL::text", "FALSE")
};
let predates = if boundary_present {
"t.predates_status_events"
} else {
"TRUE"
};
let rows = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(format!(
"SELECT t.transaction_id, t.status, {newest} AS newest, {consumed} AS consumed, \
{predates} AS predates FROM transactions t"
)))
.fetch_all(pool)
.await
.map_err(map_sqlx_err)?;
let evidence = rows
.into_iter()
.map(|row| {
Ok(StatusEvidence {
transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
status: deserialize(&row.try_get::<String, _>("status").map_err(map_sqlx_err)?)?,
newest_status_event: row.try_get("newest").map_err(map_sqlx_err)?,
approval_consumed: row.try_get("consumed").map_err(map_sqlx_err)?,
predates_status_events: row.try_get("predates").map_err(map_sqlx_err)?,
})
})
.collect::<Result<Vec<_>, TransactionStoreError>>()?;
Ok(crate::audit_chain::status_outcome(evidence))
}
async fn fetch_event_rows_from_pool(pool: &PgPool) -> Result<Vec<EventRow>, TransactionStoreError> {
let rows = sqlx_core::query::query(
"SELECT seq, key_id, kind, transaction_id, receipt_digest, \
created_at, prev_chain_hash, chain_hash, chain_version, caller_principal \
FROM audit_events ORDER BY seq ASC",
)
.fetch_all(pool)
.await
.map_err(map_sqlx_err)?;
rows.into_iter().map(row_to_event_row).collect()
}
async fn append_event(
tx: &mut sqlx_core::transaction::Transaction<'_, sqlx_postgres::Postgres>,
key: &AuditKey,
kind: AuditEventKind,
transaction_id: &str,
receipt_digest: &str,
identity: EventIdentity<'_>,
) -> Result<(), TransactionStoreError> {
let prev: Option<(i64, String)> = sqlx_core::query_as::query_as(
"SELECT seq, chain_hash FROM audit_events ORDER BY seq DESC LIMIT 1 FOR UPDATE",
)
.fetch_optional(&mut **tx)
.await
.map_err(map_sqlx_err)?;
let (seq, prev_chain_hash) = match prev {
Some((s, h)) => ((s as u64) + 1, h),
None => (1, String::new()),
};
let created_at = now_iso();
let key_id = CURRENT_KEY_ID.to_string();
let content = EventContent {
seq,
key_id: &key_id,
kind,
transaction_id,
receipt_digest,
created_at: &created_at,
identity,
};
let chain_hash = key.event_hash(&content, &prev_chain_hash);
let stored_principal = match identity {
EventIdentity::V2 { caller_principal } => Some(caller_principal),
EventIdentity::LegacyV1 => None,
};
sqlx_core::query::query(
"INSERT INTO audit_events ( \
seq, key_id, kind, transaction_id, receipt_digest, \
created_at, chain_hash, prev_chain_hash, chain_version, caller_principal \
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
)
.bind(seq as i64)
.bind(&key_id)
.bind(kind.as_str())
.bind(transaction_id)
.bind(receipt_digest)
.bind(&created_at)
.bind(&chain_hash)
.bind(&prev_chain_hash)
.bind(identity.version() as i64)
.bind(stored_principal)
.execute(&mut **tx)
.await
.map_err(map_sqlx_err)?;
Ok(())
}
async fn insert_transaction(
tx: &mut sqlx_core::transaction::Transaction<'_, sqlx_postgres::Postgres>,
key: &AuditKey,
transaction_id: &str,
transaction: NewTransaction,
) -> Result<InsertedTransaction, TransactionStoreError> {
let warnings_json = serialize(&transaction.warnings)?;
let status = JobState::Queued;
let created_at = now_iso();
let key_id = CURRENT_KEY_ID.to_string();
let approval_id = Some(key.approval_commitment(transaction_id, &transaction.request_hash));
let prev: Option<(i64, String)> = sqlx_core::query_as::query_as(
"SELECT seq, chain_hash FROM transactions ORDER BY seq DESC LIMIT 1 FOR UPDATE",
)
.fetch_optional(&mut **tx)
.await
.map_err(map_sqlx_err)?;
let (seq, prev_chain_hash) = match prev {
Some((s, h)) => ((s as u64) + 1, h),
None => (1, String::new()),
};
let event_tip: String = sqlx_core::query_scalar::query_scalar(
"SELECT COALESCE((SELECT chain_hash FROM audit_events ORDER BY seq DESC LIMIT 1), '')",
)
.fetch_one(&mut **tx)
.await
.map_err(map_sqlx_err)?;
let caller_role = transaction.caller_role.as_str();
let caller_principal = transaction.caller_principal.as_signed_str();
let identity = ChainIdentity::V4 {
caller_role,
event_tip: &event_tip,
caller_principal: &caller_principal,
};
let chain_hash = key.chain_hash(
&ChainContent {
seq,
key_id: &key_id,
transaction_id,
request_id: &transaction.request_id,
request_hash: &transaction.request_hash,
action_name: &transaction.action_name,
risk_level: transaction.risk_level,
summary: &transaction.summary,
approval_id: approval_id.as_deref(),
warnings_json: &warnings_json,
created_at: &created_at,
identity,
},
&prev_chain_hash,
);
sqlx_core::query::query(
"INSERT INTO transactions ( \
transaction_id, request_id, request_hash, action_name, risk_level, \
status, approval_id, summary, warnings_json, created_at, \
seq, key_id, chain_hash, prev_chain_hash, \
chain_version, caller_role, event_tip, caller_principal \
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)",
)
.bind(transaction_id)
.bind(&transaction.request_id)
.bind(&transaction.request_hash)
.bind(&transaction.action_name)
.bind(serialize(&transaction.risk_level)?)
.bind(serialize(&status)?)
.bind(&approval_id)
.bind(&transaction.summary)
.bind(&warnings_json)
.bind(&created_at)
.bind(seq as i64)
.bind(&key_id)
.bind(&chain_hash)
.bind(&prev_chain_hash)
.bind(i64::from(identity.version()))
.bind(caller_role)
.bind(&event_tip)
.bind(&caller_principal)
.execute(&mut **tx)
.await
.map_err(map_sqlx_err)?;
Ok(InsertedTransaction {
record: TransactionRecord {
transaction_id: transaction_id.to_string(),
request_id: transaction.request_id,
request_hash: transaction.request_hash,
action_name: transaction.action_name,
risk_level: transaction.risk_level,
status,
approval_id,
summary: transaction.summary,
warnings: transaction.warnings,
},
seq,
chain_hash,
})
}
fn row_to_history_entry(
row: sqlx_postgres::PgRow,
) -> Result<crate::transactions::JobHistoryEntry, TransactionStoreError> {
Ok(crate::transactions::JobHistoryEntry {
transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
action_name: row.try_get("action_name").map_err(map_sqlx_err)?,
risk_level: deserialize(
&row.try_get::<String, _>("risk_level")
.map_err(map_sqlx_err)?,
)?,
status: deserialize(&row.try_get::<String, _>("status").map_err(map_sqlx_err)?)?,
summary: row.try_get("summary").map_err(map_sqlx_err)?,
created_at: row.try_get("created_at").map_err(map_sqlx_err)?,
})
}
fn row_to_record(row: sqlx_postgres::PgRow) -> Result<TransactionRecord, TransactionStoreError> {
Ok(TransactionRecord {
transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
request_id: row.try_get("request_id").map_err(map_sqlx_err)?,
request_hash: row.try_get("request_hash").map_err(map_sqlx_err)?,
action_name: row.try_get("action_name").map_err(map_sqlx_err)?,
risk_level: deserialize(
&row.try_get::<String, _>("risk_level")
.map_err(map_sqlx_err)?,
)?,
status: deserialize(&row.try_get::<String, _>("status").map_err(map_sqlx_err)?)?,
approval_id: row.try_get("approval_id").map_err(map_sqlx_err)?,
summary: row.try_get("summary").map_err(map_sqlx_err)?,
warnings: deserialize(
&row.try_get::<String, _>("warnings_json")
.map_err(map_sqlx_err)?,
)?,
})
}
fn row_to_chain_row(row: sqlx_postgres::PgRow) -> Result<ChainRow, TransactionStoreError> {
Ok(ChainRow {
seq: row.try_get::<i64, _>("seq").map_err(map_sqlx_err)? as u64,
key_id: row.try_get("key_id").map_err(map_sqlx_err)?,
transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
request_id: row.try_get("request_id").map_err(map_sqlx_err)?,
request_hash: row.try_get("request_hash").map_err(map_sqlx_err)?,
action_name: row.try_get("action_name").map_err(map_sqlx_err)?,
risk_level: deserialize(
&row.try_get::<String, _>("risk_level")
.map_err(map_sqlx_err)?,
)?,
summary: row.try_get("summary").map_err(map_sqlx_err)?,
approval_id: row.try_get("approval_id").map_err(map_sqlx_err)?,
warnings_json: row.try_get("warnings_json").map_err(map_sqlx_err)?,
created_at: row.try_get("created_at").map_err(map_sqlx_err)?,
prev_chain_hash: row.try_get("prev_chain_hash").map_err(map_sqlx_err)?,
chain_hash: row.try_get("chain_hash").map_err(map_sqlx_err)?,
chain_version: row
.try_get::<i64, _>("chain_version")
.map_err(map_sqlx_err)? as u32,
caller_role: row.try_get("caller_role").map_err(map_sqlx_err)?,
event_tip: row.try_get("event_tip").map_err(map_sqlx_err)?,
caller_principal: row.try_get("caller_principal").map_err(map_sqlx_err)?,
})
}
fn row_to_event_row(row: sqlx_postgres::PgRow) -> Result<EventRow, TransactionStoreError> {
Ok(EventRow {
seq: row.try_get::<i64, _>("seq").map_err(map_sqlx_err)? as u64,
key_id: row.try_get("key_id").map_err(map_sqlx_err)?,
kind: row.try_get("kind").map_err(map_sqlx_err)?,
transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
receipt_digest: row.try_get("receipt_digest").map_err(map_sqlx_err)?,
created_at: row.try_get("created_at").map_err(map_sqlx_err)?,
prev_chain_hash: row.try_get("prev_chain_hash").map_err(map_sqlx_err)?,
chain_hash: row.try_get("chain_hash").map_err(map_sqlx_err)?,
chain_version: row
.try_get::<i64, _>("chain_version")
.map_err(map_sqlx_err)? as u32,
caller_principal: row.try_get("caller_principal").map_err(map_sqlx_err)?,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config_keeps_statement_cache_enabled() {
let c = PostgresConfig::default();
assert!(c.statement_cache_capacity > 0);
assert!(c.max_connections >= 4);
assert!(c.acquire_timeout >= Duration::from_secs(5));
}
#[test]
fn url_parsing_accepts_standard_postgres_url() {
let opts = PgConnectOptions::from_str(
"postgres://user:pass@host.example.com:5432/audit?sslmode=require",
)
.expect("standard URL parses");
let _ = opts.statement_cache_capacity(0);
}
#[test]
fn now_iso_produces_a_parseable_rfc3339_timestamp() {
let ts = now_iso();
let parsed = chrono::DateTime::parse_from_rfc3339(&ts);
assert!(
parsed.is_ok(),
"now_iso() must emit a parseable RFC 3339 timestamp, got {ts:?} ({parsed:?})"
);
assert_eq!(
ts.len(),
"2026-07-27T12:34:56.789Z".len(),
"timestamp shape changed unexpectedly: {ts}"
);
}
#[test]
fn now_iso_matches_the_sqlite_backends_timestamp_shape() {
let ts = now_iso();
let (date, rest) = ts.split_once('T').expect("timestamp has a T separator");
assert_eq!(date.len(), "2026-07-27".len(), "date part: {ts}");
assert!(rest.ends_with('Z'), "must be UTC with a Z suffix: {ts}");
let time = rest.trim_end_matches('Z');
let fields: Vec<&str> = time.split(':').collect();
assert_eq!(fields.len(), 3, "expected HH:MM:SS.mmm, got {time:?}");
assert!(
fields[2].contains('.'),
"seconds field must carry fractional milliseconds: {time:?}"
);
let (secs, millis) = fields[2].split_once('.').expect("checked above");
assert_eq!(secs.len(), 2, "seconds must be two digits: {time:?}");
assert_eq!(millis.len(), 3, "expected millisecond precision: {time:?}");
}
#[test]
fn schema_decode_errors_are_classified_as_invariant_failures() {
let error = map_sqlx_err(sqlx_core::Error::ColumnNotFound("chain_hash".to_string()));
assert!(matches!(error, TransactionStoreError::DatabaseInvariant(_)));
}
}