1use async_trait::async_trait;
30use sqlx_core::row::Row;
31use sqlx_postgres::{PgConnectOptions, PgPool, PgPoolOptions};
32use std::str::FromStr;
33use std::time::Duration;
34use subtle::ConstantTimeEq;
35use sysknife_types::{JobState, PreviewEnvelope, TransactionRecord};
36use uuid::Uuid;
37
38use crate::audit_chain::{
39 AttributionCensus, AuditEventKind, AuditKey, AuditVerification, BindingOutcome, ChainContent,
40 ChainIdentity, ChainRow, EventContent, EventRow, VerifyOutcome, CURRENT_KEY_ID,
41};
42use crate::audit_watermark::emit_chain_tip_watermark;
43use crate::store::AuditStore;
44use crate::transactions::{
45 NewTransaction, RecordedPreviewedTransaction, TransactionStoreError,
46 APPROVAL_RECEIPT_TTL_MINUTES,
47};
48
49const MIGRATION_LOCK_ID: i64 = 0x5359_534b_4e49_4645;
50
51use crate::store::CHAIN_ROW_COLUMNS;
60
61struct Migration {
62 version: i64,
63 name: &'static str,
64 statements: &'static [&'static str],
65}
66
67const MIGRATIONS: &[Migration] = &[Migration {
68 version: 1,
69 name: "initial_audit_schema",
70 statements: &[
71 r#"
72 CREATE TABLE IF NOT EXISTS transactions (
73 transaction_id TEXT PRIMARY KEY,
74 request_id TEXT NOT NULL,
75 request_hash TEXT NOT NULL,
76 action_name TEXT NOT NULL,
77 risk_level TEXT NOT NULL,
78 status TEXT NOT NULL,
79 approval_id TEXT,
80 summary TEXT NOT NULL,
81 warnings_json TEXT NOT NULL,
82 created_at TEXT NOT NULL,
83 seq BIGINT NOT NULL UNIQUE,
84 key_id TEXT NOT NULL,
85 chain_hash TEXT NOT NULL,
86 prev_chain_hash TEXT NOT NULL DEFAULT ''
87 )
88 "#,
89 r#"
90 CREATE TABLE IF NOT EXISTS transaction_approvals (
91 transaction_id TEXT PRIMARY KEY,
92 receipt_digest TEXT NOT NULL,
93 approved_at TEXT NOT NULL,
94 consumed_at TEXT
95 )
96 "#,
97 r#"
98 CREATE TABLE IF NOT EXISTS transaction_previews (
99 transaction_id TEXT PRIMARY KEY,
100 preview_json TEXT NOT NULL
101 )
102 "#,
103 "CREATE INDEX IF NOT EXISTS transactions_seq_idx ON transactions(seq)",
104 ],
105},
106 Migration {
110 version: 2,
111 name: "caller_identity_and_approval_events",
112 statements: &[
113 "ALTER TABLE transactions ADD COLUMN IF NOT EXISTS chain_version BIGINT NOT NULL DEFAULT 1",
114 "ALTER TABLE transactions ADD COLUMN IF NOT EXISTS caller_role TEXT",
115 "ALTER TABLE transactions ADD COLUMN IF NOT EXISTS event_tip TEXT",
116 r#"
117 CREATE TABLE IF NOT EXISTS audit_events (
118 seq BIGINT PRIMARY KEY,
119 key_id TEXT NOT NULL,
120 kind TEXT NOT NULL,
121 transaction_id TEXT NOT NULL,
122 receipt_digest TEXT NOT NULL,
123 created_at TEXT NOT NULL,
124 chain_hash TEXT NOT NULL,
125 prev_chain_hash TEXT NOT NULL DEFAULT ''
126 )
127 "#,
128 "CREATE INDEX IF NOT EXISTS audit_events_transaction_idx ON audit_events(transaction_id)",
129 ],
130 },
131 Migration {
135 version: 3,
136 name: "caller_principal",
137 statements: &["ALTER TABLE transactions ADD COLUMN IF NOT EXISTS caller_principal TEXT"],
138 },
139];
140
141#[derive(Clone, Debug)]
144pub struct PostgresConfig {
145 pub url: String,
147 pub max_connections: u32,
150 pub acquire_timeout: Duration,
153 pub statement_cache_capacity: usize,
157}
158
159impl Default for PostgresConfig {
160 fn default() -> Self {
161 Self {
162 url: String::new(),
163 max_connections: 8,
164 acquire_timeout: Duration::from_secs(10),
165 statement_cache_capacity: 100,
166 }
167 }
168}
169
170#[derive(Debug, Clone)]
172pub struct PostgresStore {
173 pool: PgPool,
174 audit_key: std::sync::Arc<AuditKey>,
175}
176
177struct InsertedTransaction {
178 record: TransactionRecord,
179 seq: u64,
180 chain_hash: String,
181}
182
183impl PostgresStore {
184 async fn connect_pool(config: &PostgresConfig) -> Result<PgPool, TransactionStoreError> {
185 let mut connect_opts = PgConnectOptions::from_str(&config.url).map_err(|e| {
186 TransactionStoreError::Io(std::io::Error::new(
187 std::io::ErrorKind::InvalidInput,
188 format!("invalid postgres URL: {e}"),
189 ))
190 })?;
191 crate::pg_tls::require_tls_for_remote(&connect_opts).map_err(|msg| {
197 TransactionStoreError::Io(std::io::Error::new(
198 std::io::ErrorKind::PermissionDenied,
199 msg,
200 ))
201 })?;
202 connect_opts = connect_opts.statement_cache_capacity(config.statement_cache_capacity);
203 PgPoolOptions::new()
204 .max_connections(config.max_connections)
205 .acquire_timeout(config.acquire_timeout)
206 .connect_with(connect_opts)
207 .await
208 .map_err(map_sqlx_err)
209 }
210
211 pub async fn connect(
214 config: &PostgresConfig,
215 audit_key: std::sync::Arc<AuditKey>,
216 ) -> Result<Self, TransactionStoreError> {
217 let pool = Self::connect_pool(config).await?;
218
219 let store = Self { pool, audit_key };
220 store.initialize().await?;
221 Ok(store)
222 }
223
224 pub async fn verify_all_with_pubkey(
228 config: &PostgresConfig,
229 verifying_key_hex: &str,
230 ) -> Result<AuditVerification, TransactionStoreError> {
231 let pool = Self::connect_pool(config).await?;
232 let tx_rows = fetch_chain_rows_from_pool(&pool).await?;
233 let event_rows = match fetch_event_rows_from_pool(&pool).await {
234 Ok(rows) => rows,
235 Err(err) => {
236 match audit_events_table_absent(&pool).await {
253 Ok(true) => Vec::new(),
254 Ok(false) => {
255 return Ok(Self::unreadable_events(
256 verifying_key_hex,
257 &tx_rows,
258 format!("could not read the approval-event table: {err}"),
259 ))
260 }
261 Err(probe_err) => {
262 return Ok(Self::unreadable_events(
263 verifying_key_hex,
264 &tx_rows,
265 format!(
266 "could not read the approval-event table ({err}), and could not \
267 determine whether it exists ({probe_err})"
268 ),
269 ))
270 }
271 }
272 }
273 };
274 Ok(crate::audit_chain::verify_all_with_pubkey(
275 verifying_key_hex,
276 &tx_rows,
277 &event_rows,
278 ))
279 }
280
281 fn unreadable_events(
286 verifying_key_hex: &str,
287 tx_rows: &[ChainRow],
288 reason: String,
289 ) -> AuditVerification {
290 AuditVerification {
291 chain: crate::audit_chain::verify_chain_with_pubkey(verifying_key_hex, tx_rows),
292 events: VerifyOutcome::CannotVerify { reason },
293 binding: BindingOutcome::NotChecked,
294 attribution: Some(AttributionCensus::of(tx_rows)),
295 status: None,
296 }
297 }
298
299 pub async fn read_chain_rows(
303 config: &PostgresConfig,
304 ) -> Result<Vec<ChainRow>, TransactionStoreError> {
305 let pool = Self::connect_pool(config).await?;
306 fetch_chain_rows_from_pool(&pool).await
307 }
308
309 pub async fn verify_all(
311 &self,
312 key: &AuditKey,
313 ) -> Result<AuditVerification, TransactionStoreError> {
314 let tx_rows = fetch_chain_rows_from_pool(&self.pool).await?;
315 let event_rows = fetch_event_rows_from_pool(&self.pool).await?;
316 Ok(crate::audit_chain::verify_all(key, &tx_rows, &event_rows))
317 }
318
319 async fn initialize(&self) -> Result<(), TransactionStoreError> {
322 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
323 sqlx_core::query::query("SELECT pg_advisory_xact_lock($1)")
324 .bind(MIGRATION_LOCK_ID)
325 .execute(&mut *tx)
326 .await
327 .map_err(map_sqlx_err)?;
328
329 sqlx_core::query::query(
330 r#"
331 CREATE TABLE IF NOT EXISTS schema_migrations (
332 version BIGINT PRIMARY KEY,
333 name TEXT NOT NULL,
334 applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
335 )
336 "#,
337 )
338 .execute(&mut *tx)
339 .await
340 .map_err(map_sqlx_err)?;
341
342 let current: i64 = sqlx_core::query_scalar::query_scalar(
343 "SELECT COALESCE(MAX(version), 0) FROM schema_migrations",
344 )
345 .fetch_one(&mut *tx)
346 .await
347 .map_err(map_sqlx_err)?;
348 let latest = MIGRATIONS.last().map_or(0, |migration| migration.version);
349 if current > latest {
350 return Err(TransactionStoreError::DatabaseInvariant(format!(
351 "postgres schema version {current} is newer than this binary supports ({latest})"
352 )));
353 }
354
355 for migration in MIGRATIONS
356 .iter()
357 .filter(|migration| migration.version > current)
358 {
359 for statement in migration.statements {
362 sqlx_core::query::query(*statement)
363 .execute(&mut *tx)
364 .await
365 .map_err(map_sqlx_err)?;
366 }
367 sqlx_core::query::query(
368 "INSERT INTO schema_migrations (version, name) VALUES ($1, $2)",
369 )
370 .bind(migration.version)
371 .bind(migration.name)
372 .execute(&mut *tx)
373 .await
374 .map_err(map_sqlx_err)?;
375 }
376
377 tx.commit().await.map_err(map_sqlx_err)
378 }
379
380 async fn revoke_unconsumed_approval_in_tx(
381 tx: &mut sqlx_core::transaction::Transaction<'_, sqlx_postgres::Postgres>,
382 key: &AuditKey,
383 transaction_id: &str,
384 ) -> Result<bool, TransactionStoreError> {
385 let digest: Option<String> = sqlx_core::query_scalar::query_scalar(
388 "SELECT receipt_digest FROM transaction_approvals \
389 WHERE transaction_id = $1 AND consumed_at IS NULL",
390 )
391 .bind(transaction_id)
392 .fetch_optional(&mut **tx)
393 .await
394 .map_err(map_sqlx_err)?;
395 let result = sqlx_core::query::query(
396 "DELETE FROM transaction_approvals \
397 WHERE transaction_id = $1 AND consumed_at IS NULL",
398 )
399 .bind(transaction_id)
400 .execute(&mut **tx)
401 .await
402 .map_err(map_sqlx_err)?;
403 if result.rows_affected() > 0 {
404 let digest = digest.ok_or_else(|| {
405 TransactionStoreError::DatabaseInvariant(format!(
406 "revoked an approval for {transaction_id} that had no receipt digest"
407 ))
408 })?;
409 append_event(
410 tx,
411 key,
412 AuditEventKind::ApprovalRevoked,
413 transaction_id,
414 &digest,
415 )
416 .await?;
417 }
418 Ok(result.rows_affected() > 0)
419 }
420}
421
422fn map_sqlx_err(e: sqlx_core::Error) -> TransactionStoreError {
427 let message = format!("postgres: {e}");
428 let invariant = match &e {
429 sqlx_core::Error::Database(db) => db
430 .code()
431 .is_some_and(|code| code.starts_with("22") || code.starts_with("23")),
432 sqlx_core::Error::TypeNotFound { .. }
433 | sqlx_core::Error::ColumnIndexOutOfBounds { .. }
434 | sqlx_core::Error::ColumnNotFound(_)
435 | sqlx_core::Error::ColumnDecode { .. }
436 | sqlx_core::Error::Decode(_) => true,
437 _ => false,
438 };
439 if invariant {
440 TransactionStoreError::DatabaseInvariant(message)
441 } else {
442 TransactionStoreError::Io(std::io::Error::other(message))
443 }
444}
445
446fn serialize<T: serde::Serialize>(v: &T) -> Result<String, TransactionStoreError> {
447 serde_json::to_string(v).map_err(TransactionStoreError::Json)
448}
449
450fn deserialize<T: serde::de::DeserializeOwned>(s: &str) -> Result<T, TransactionStoreError> {
451 serde_json::from_str(s).map_err(TransactionStoreError::Json)
452}
453
454fn now_iso() -> String {
462 chrono::Utc::now()
463 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
464 .to_string()
465}
466
467#[async_trait]
472impl AuditStore for PostgresStore {
473 async fn record(
474 &self,
475 transaction: NewTransaction,
476 ) -> Result<TransactionRecord, TransactionStoreError> {
477 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
478 let transaction_id = Uuid::new_v4().to_string();
479 let inserted =
480 insert_transaction(&mut tx, &self.audit_key, &transaction_id, transaction).await?;
481 tx.commit().await.map_err(map_sqlx_err)?;
482 emit_chain_tip_watermark(inserted.seq, &inserted.chain_hash);
483 Ok(inserted.record)
484 }
485
486 async fn record_previewed(
487 &self,
488 transaction: NewTransaction,
489 preview: PreviewEnvelope,
490 ) -> Result<RecordedPreviewedTransaction, TransactionStoreError> {
491 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
492 let transaction_id = Uuid::new_v4().to_string();
493 let inserted =
494 insert_transaction(&mut tx, &self.audit_key, &transaction_id, transaction).await?;
495
496 sqlx_core::query::query(
497 "INSERT INTO transaction_previews (transaction_id, preview_json) VALUES ($1, $2)",
498 )
499 .bind(&inserted.record.transaction_id)
500 .bind(serialize(&preview)?)
501 .execute(&mut *tx)
502 .await
503 .map_err(map_sqlx_err)?;
504
505 tx.commit().await.map_err(map_sqlx_err)?;
506 emit_chain_tip_watermark(inserted.seq, &inserted.chain_hash);
507 Ok(RecordedPreviewedTransaction {
508 transaction: inserted.record,
509 preview,
510 })
511 }
512
513 async fn get(
514 &self,
515 transaction_id: &str,
516 ) -> Result<Option<TransactionRecord>, TransactionStoreError> {
517 let row = sqlx_core::query::query(
518 "SELECT transaction_id, request_id, request_hash, action_name, risk_level, \
519 status, approval_id, summary, warnings_json \
520 FROM transactions WHERE transaction_id = $1",
521 )
522 .bind(transaction_id)
523 .fetch_optional(&self.pool)
524 .await
525 .map_err(map_sqlx_err)?;
526 row.map(row_to_record).transpose()
527 }
528
529 async fn get_preview(
530 &self,
531 transaction_id: &str,
532 ) -> Result<Option<PreviewEnvelope>, TransactionStoreError> {
533 let row = sqlx_core::query::query(
534 "SELECT preview_json FROM transaction_previews WHERE transaction_id = $1",
535 )
536 .bind(transaction_id)
537 .fetch_optional(&self.pool)
538 .await
539 .map_err(map_sqlx_err)?;
540 match row {
541 Some(r) => {
542 let s: String = r.try_get("preview_json").map_err(map_sqlx_err)?;
543 Ok(Some(deserialize(&s)?))
544 }
545 None => Ok(None),
546 }
547 }
548
549 async fn update_status(
550 &self,
551 transaction_id: &str,
552 new_status: JobState,
553 ) -> Result<(), TransactionStoreError> {
554 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
563 let current_str: Option<String> = sqlx_core::query_scalar::query_scalar(
564 "SELECT status FROM transactions WHERE transaction_id = $1 FOR UPDATE",
565 )
566 .bind(transaction_id)
567 .fetch_optional(&mut *tx)
568 .await
569 .map_err(map_sqlx_err)?;
570 let current_str = current_str
571 .ok_or_else(|| TransactionStoreError::NotFound(transaction_id.to_string()))?;
572 let current: JobState = deserialize(¤t_str)?;
573 if !crate::jobs::allowed_transition(¤t, &new_status) {
574 return Err(TransactionStoreError::InvalidTransition {
575 from: current,
576 to: new_status,
577 });
578 }
579 sqlx_core::query::query("UPDATE transactions SET status = $1 WHERE transaction_id = $2")
580 .bind(serialize(&new_status)?)
581 .bind(transaction_id)
582 .execute(&mut *tx)
583 .await
584 .map_err(map_sqlx_err)?;
585 tx.commit().await.map_err(map_sqlx_err)?;
586 Ok(())
587 }
588
589 async fn approve_transaction(
590 &self,
591 transaction_id: &str,
592 ) -> Result<Option<String>, TransactionStoreError> {
593 let row = sqlx_core::query::query(
594 "SELECT request_hash, approval_id FROM transactions WHERE transaction_id = $1",
595 )
596 .bind(transaction_id)
597 .fetch_optional(&self.pool)
598 .await
599 .map_err(map_sqlx_err)?;
600 let Some(row) = row else {
601 return Ok(None);
602 };
603 let request_hash: String = row.try_get("request_hash").map_err(map_sqlx_err)?;
604 let committed_digest: Option<String> = row.try_get("approval_id").map_err(map_sqlx_err)?;
605 let receipt = self
606 .audit_key
607 .approval_receipt(transaction_id, &request_hash);
608 let receipt_digest = crate::audit_chain::approval_receipt_digest(&receipt);
609 let commitment_matches = committed_digest.as_deref().is_some_and(|committed| {
610 bool::from(receipt_digest.as_bytes().ct_eq(committed.as_bytes()))
611 });
612 if !commitment_matches {
613 return Err(TransactionStoreError::DatabaseInvariant(format!(
614 "transaction {transaction_id} approval commitment does not match its signed preview"
615 )));
616 }
617 let queued = serialize(&JobState::Queued)?;
618 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
621 let result = sqlx_core::query::query(
622 sqlx_core::sql_str::AssertSqlSafe(format!(
623 "INSERT INTO transaction_approvals \
624 (transaction_id, receipt_digest, approved_at) \
625 SELECT transaction_id, $1, $2 FROM transactions \
626 WHERE transaction_id = $3 \
627 AND status = $4 \
628 AND created_at::timestamptz > now() - INTERVAL '{APPROVAL_RECEIPT_TTL_MINUTES} minutes' \
629 ON CONFLICT (transaction_id) DO NOTHING"
630 )),
631 )
632 .bind(&receipt_digest)
633 .bind(now_iso())
634 .bind(transaction_id)
635 .bind(&queued)
636 .execute(&mut *tx)
637 .await
638 .map_err(map_sqlx_err)?;
639 if result.rows_affected() > 0 {
640 append_event(
641 &mut tx,
642 &self.audit_key,
643 AuditEventKind::ApprovalGranted,
644 transaction_id,
645 &receipt_digest,
646 )
647 .await?;
648 }
649 tx.commit().await.map_err(map_sqlx_err)?;
650 Ok((result.rows_affected() > 0).then_some(receipt))
651 }
652
653 async fn revoke_unconsumed_approval(
654 &self,
655 transaction_id: &str,
656 ) -> Result<bool, TransactionStoreError> {
657 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
658 let revoked =
659 Self::revoke_unconsumed_approval_in_tx(&mut tx, &self.audit_key, transaction_id)
660 .await?;
661 tx.commit().await.map_err(map_sqlx_err)?;
662 Ok(revoked)
663 }
664
665 async fn claim_approved_for_execution(
666 &self,
667 transaction_id: &str,
668 receipt_digest: &str,
669 ) -> Result<bool, TransactionStoreError> {
670 let queued = serialize(&JobState::Queued)?;
671 let running = serialize(&JobState::Running)?;
672 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
673 let result = sqlx_core::query::query(
674 sqlx_core::sql_str::AssertSqlSafe(format!(
675 "UPDATE transactions SET status = $1 \
676 WHERE transaction_id = $2 \
677 AND status = $3 \
678 AND created_at::timestamptz > now() - INTERVAL '{APPROVAL_RECEIPT_TTL_MINUTES} minutes' \
679 AND EXISTS ( \
680 SELECT 1 FROM transaction_approvals \
681 WHERE transaction_id = $2 \
682 AND receipt_digest = $4 \
683 AND consumed_at IS NULL \
684 )"
685 )),
686 )
687 .bind(&running)
688 .bind(transaction_id)
689 .bind(&queued)
690 .bind(receipt_digest)
691 .execute(&mut *tx)
692 .await
693 .map_err(map_sqlx_err)?;
694 if result.rows_affected() > 0 {
695 sqlx_core::query::query(
696 "UPDATE transaction_approvals SET consumed_at = $1 \
697 WHERE transaction_id = $2 AND consumed_at IS NULL",
698 )
699 .bind(now_iso())
700 .bind(transaction_id)
701 .execute(&mut *tx)
702 .await
703 .map_err(map_sqlx_err)?;
704 append_event(
705 &mut tx,
706 &self.audit_key,
707 AuditEventKind::ApprovalConsumed,
708 transaction_id,
709 receipt_digest,
710 )
711 .await?;
712 }
713 tx.commit().await.map_err(map_sqlx_err)?;
714 Ok(result.rows_affected() > 0)
715 }
716
717 async fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError> {
718 let queued = serialize(&JobState::Queued)?;
719 let canceled = serialize(&JobState::Canceled)?;
720 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
721 let stale_ids: Vec<String> = sqlx_core::query_scalar::query_scalar(
722 sqlx_core::sql_str::AssertSqlSafe(format!(
723 "SELECT transaction_id FROM transactions \
724 WHERE status = $1 \
725 AND created_at::timestamptz <= now() - INTERVAL '{APPROVAL_RECEIPT_TTL_MINUTES} minutes'"
726 )),
727 )
728 .bind(&queued)
729 .fetch_all(&mut *tx)
730 .await
731 .map_err(map_sqlx_err)?;
732 let mut canceled_count = 0;
733 for transaction_id in stale_ids {
734 let result = sqlx_core::query::query(
735 "UPDATE transactions SET status = $1 \
736 WHERE transaction_id = $2 AND status = $3",
737 )
738 .bind(&canceled)
739 .bind(&transaction_id)
740 .bind(&queued)
741 .execute(&mut *tx)
742 .await
743 .map_err(map_sqlx_err)?;
744 if result.rows_affected() > 0 {
745 Self::revoke_unconsumed_approval_in_tx(&mut tx, &self.audit_key, &transaction_id)
746 .await?;
747 canceled_count += result.rows_affected();
748 }
749 }
750 tx.commit().await.map_err(map_sqlx_err)?;
751 Ok(canceled_count)
752 }
753
754 async fn cancel_queued(&self, transaction_id: &str) -> Result<bool, TransactionStoreError> {
755 let queued = serialize(&JobState::Queued)?;
758 let canceled = serialize(&JobState::Canceled)?;
759 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
760 let result = sqlx_core::query::query(
761 "UPDATE transactions SET status = $1 \
762 WHERE transaction_id = $2 AND status = $3",
763 )
764 .bind(&canceled)
765 .bind(transaction_id)
766 .bind(&queued)
767 .execute(&mut *tx)
768 .await
769 .map_err(map_sqlx_err)?;
770 if result.rows_affected() > 0 {
771 Self::revoke_unconsumed_approval_in_tx(&mut tx, &self.audit_key, transaction_id)
772 .await?;
773 }
774 tx.commit().await.map_err(map_sqlx_err)?;
775 Ok(result.rows_affected() > 0)
776 }
777
778 async fn list_transactions(
779 &self,
780 limit: u32,
781 status_filter: Option<&str>,
782 action_filter: Option<&str>,
783 since_hours: Option<u32>,
784 ) -> Result<Vec<TransactionRecord>, TransactionStoreError> {
785 let limit = limit.min(100) as i64;
786
787 let validated_status: Option<String> = status_filter
790 .map(|s| -> Result<String, TransactionStoreError> {
791 let parsed: JobState = deserialize(&format!("\"{s}\""))?;
792 serialize(&parsed)
793 })
794 .transpose()?;
795
796 let mut sql = String::from(
797 "SELECT transaction_id, request_id, request_hash, action_name, risk_level, \
798 status, approval_id, summary, warnings_json \
799 FROM transactions WHERE TRUE",
800 );
801 let mut idx = 1;
802 if validated_status.is_some() {
803 sql.push_str(&format!(" AND status = ${idx}"));
804 idx += 1;
805 }
806 if action_filter.is_some() {
807 sql.push_str(&format!(" AND action_name = ${idx}"));
808 idx += 1;
809 }
810 if since_hours.is_some() {
811 sql.push_str(&format!(
812 " AND created_at::timestamptz > now() - (${idx} || ' hours')::INTERVAL"
813 ));
814 idx += 1;
815 }
816 sql.push_str(&format!(" ORDER BY seq DESC LIMIT ${idx}"));
817
818 let mut q = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(sql.as_str()));
819 if let Some(s) = &validated_status {
820 q = q.bind(s);
821 }
822 if let Some(a) = action_filter {
823 q = q.bind(a);
824 }
825 if let Some(h) = since_hours {
826 q = q.bind(h.to_string());
827 }
828 q = q.bind(limit);
829
830 let rows = q.fetch_all(&self.pool).await.map_err(map_sqlx_err)?;
831 rows.into_iter().map(row_to_record).collect()
832 }
833
834 async fn list_history(
835 &self,
836 limit: u32,
837 status_filter: Option<&str>,
838 action_filter: Option<&str>,
839 since_hours: Option<u32>,
840 ) -> Result<Vec<crate::transactions::JobHistoryEntry>, TransactionStoreError> {
841 let limit = limit.min(100) as i64;
844 let validated_status: Option<String> = status_filter
845 .map(|s| -> Result<String, TransactionStoreError> {
846 let parsed: JobState = deserialize(&format!("\"{s}\""))?;
847 serialize(&parsed)
848 })
849 .transpose()?;
850
851 let mut sql = String::from(
852 "SELECT transaction_id, action_name, risk_level, status, summary, created_at \
853 FROM transactions WHERE TRUE",
854 );
855 let mut idx = 1;
856 if validated_status.is_some() {
857 sql.push_str(&format!(" AND status = ${idx}"));
858 idx += 1;
859 }
860 if action_filter.is_some() {
861 sql.push_str(&format!(" AND action_name = ${idx}"));
862 idx += 1;
863 }
864 if since_hours.is_some() {
865 sql.push_str(&format!(
866 " AND created_at::timestamptz > now() - (${idx} || ' hours')::INTERVAL"
867 ));
868 idx += 1;
869 }
870 sql.push_str(&format!(" ORDER BY seq DESC LIMIT ${idx}"));
871
872 let mut q = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(sql.as_str()));
873 if let Some(s) = &validated_status {
874 q = q.bind(s);
875 }
876 if let Some(a) = action_filter {
877 q = q.bind(a);
878 }
879 if let Some(h) = since_hours {
880 q = q.bind(h.to_string());
881 }
882 q = q.bind(limit);
883
884 let rows = q.fetch_all(&self.pool).await.map_err(map_sqlx_err)?;
885 rows.into_iter().map(row_to_history_entry).collect()
886 }
887
888 async fn fetch_chain_row(
889 &self,
890 transaction_id: &str,
891 ) -> Result<Option<ChainRow>, TransactionStoreError> {
892 let row = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(format!(
893 "SELECT {CHAIN_ROW_COLUMNS} FROM transactions WHERE transaction_id = $1"
894 )))
895 .bind(transaction_id)
896 .fetch_optional(&self.pool)
897 .await
898 .map_err(map_sqlx_err)?;
899 row.map(row_to_chain_row).transpose()
900 }
901
902 async fn fetch_event_rows(&self) -> Result<Vec<EventRow>, TransactionStoreError> {
903 fetch_event_rows_from_pool(&self.pool).await
904 }
905
906 async fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
907 fetch_chain_rows_from_pool(&self.pool).await
908 }
909
910 async fn verify_audit_chain(
911 &self,
912 key: &AuditKey,
913 ) -> Result<VerifyOutcome, TransactionStoreError> {
914 let rows = self.fetch_chain_rows().await?;
915 Ok(crate::audit_chain::verify_chain(key, &rows))
916 }
917}
918
919async fn fetch_chain_rows_from_pool(pool: &PgPool) -> Result<Vec<ChainRow>, TransactionStoreError> {
920 let rows = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(format!(
921 "SELECT {CHAIN_ROW_COLUMNS} FROM transactions ORDER BY seq ASC"
922 )))
923 .fetch_all(pool)
924 .await
925 .map_err(map_sqlx_err)?;
926 rows.into_iter().map(row_to_chain_row).collect()
927}
928
929async fn audit_events_table_absent(pool: &PgPool) -> Result<bool, TransactionStoreError> {
936 let present: Option<String> =
937 sqlx_core::query_scalar::query_scalar("SELECT to_regclass('audit_events')::text")
938 .fetch_one(pool)
939 .await
940 .map_err(map_sqlx_err)?;
941 Ok(present.is_none())
942}
943
944async fn fetch_event_rows_from_pool(pool: &PgPool) -> Result<Vec<EventRow>, TransactionStoreError> {
946 let rows = sqlx_core::query::query(
947 "SELECT seq, key_id, kind, transaction_id, receipt_digest, \
948 created_at, prev_chain_hash, chain_hash \
949 FROM audit_events ORDER BY seq ASC",
950 )
951 .fetch_all(pool)
952 .await
953 .map_err(map_sqlx_err)?;
954 rows.into_iter().map(row_to_event_row).collect()
955}
956
957async fn append_event(
963 tx: &mut sqlx_core::transaction::Transaction<'_, sqlx_postgres::Postgres>,
964 key: &AuditKey,
965 kind: AuditEventKind,
966 transaction_id: &str,
967 receipt_digest: &str,
968) -> Result<(), TransactionStoreError> {
969 let prev: Option<(i64, String)> = sqlx_core::query_as::query_as(
970 "SELECT seq, chain_hash FROM audit_events ORDER BY seq DESC LIMIT 1 FOR UPDATE",
971 )
972 .fetch_optional(&mut **tx)
973 .await
974 .map_err(map_sqlx_err)?;
975 let (seq, prev_chain_hash) = match prev {
976 Some((s, h)) => ((s as u64) + 1, h),
977 None => (1, String::new()),
978 };
979 let created_at = now_iso();
980 let key_id = CURRENT_KEY_ID.to_string();
981 let chain_hash = key.event_hash(
982 &EventContent {
983 seq,
984 key_id: &key_id,
985 kind,
986 transaction_id,
987 receipt_digest,
988 created_at: &created_at,
989 },
990 &prev_chain_hash,
991 );
992 sqlx_core::query::query(
993 "INSERT INTO audit_events ( \
994 seq, key_id, kind, transaction_id, receipt_digest, \
995 created_at, chain_hash, prev_chain_hash \
996 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
997 )
998 .bind(seq as i64)
999 .bind(&key_id)
1000 .bind(kind.as_str())
1001 .bind(transaction_id)
1002 .bind(receipt_digest)
1003 .bind(&created_at)
1004 .bind(&chain_hash)
1005 .bind(&prev_chain_hash)
1006 .execute(&mut **tx)
1007 .await
1008 .map_err(map_sqlx_err)?;
1009 Ok(())
1010}
1011
1012async fn insert_transaction(
1017 tx: &mut sqlx_core::transaction::Transaction<'_, sqlx_postgres::Postgres>,
1018 key: &AuditKey,
1019 transaction_id: &str,
1020 transaction: NewTransaction,
1021) -> Result<InsertedTransaction, TransactionStoreError> {
1022 let warnings_json = serialize(&transaction.warnings)?;
1023 let status = JobState::Queued;
1024 let created_at = now_iso();
1025 let key_id = CURRENT_KEY_ID.to_string();
1026 let approval_id = Some(key.approval_commitment(transaction_id, &transaction.request_hash));
1029
1030 let prev: Option<(i64, String)> = sqlx_core::query_as::query_as(
1036 "SELECT seq, chain_hash FROM transactions ORDER BY seq DESC LIMIT 1 FOR UPDATE",
1037 )
1038 .fetch_optional(&mut **tx)
1039 .await
1040 .map_err(map_sqlx_err)?;
1041 let (seq, prev_chain_hash) = match prev {
1042 Some((s, h)) => ((s as u64) + 1, h),
1043 None => (1, String::new()),
1044 };
1045
1046 let event_tip: String = sqlx_core::query_scalar::query_scalar(
1049 "SELECT COALESCE((SELECT chain_hash FROM audit_events ORDER BY seq DESC LIMIT 1), '')",
1050 )
1051 .fetch_one(&mut **tx)
1052 .await
1053 .map_err(map_sqlx_err)?;
1054 let caller_role = transaction.caller_role.as_str();
1055 let caller_principal = transaction.caller_principal.as_signed_str();
1056
1057 let identity = ChainIdentity::V3 {
1060 caller_role,
1061 event_tip: &event_tip,
1062 caller_principal: &caller_principal,
1063 };
1064 let chain_hash = key.chain_hash(
1065 &ChainContent {
1066 seq,
1067 key_id: &key_id,
1068 transaction_id,
1069 request_id: &transaction.request_id,
1070 request_hash: &transaction.request_hash,
1071 action_name: &transaction.action_name,
1072 risk_level: transaction.risk_level,
1073 summary: &transaction.summary,
1074 approval_id: approval_id.as_deref(),
1075 warnings_json: &warnings_json,
1076 created_at: &created_at,
1077 identity,
1078 },
1079 &prev_chain_hash,
1080 );
1081
1082 sqlx_core::query::query(
1083 "INSERT INTO transactions ( \
1084 transaction_id, request_id, request_hash, action_name, risk_level, \
1085 status, approval_id, summary, warnings_json, created_at, \
1086 seq, key_id, chain_hash, prev_chain_hash, \
1087 chain_version, caller_role, event_tip, caller_principal \
1088 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)",
1089 )
1090 .bind(transaction_id)
1091 .bind(&transaction.request_id)
1092 .bind(&transaction.request_hash)
1093 .bind(&transaction.action_name)
1094 .bind(serialize(&transaction.risk_level)?)
1095 .bind(serialize(&status)?)
1096 .bind(&approval_id)
1097 .bind(&transaction.summary)
1098 .bind(&warnings_json)
1099 .bind(&created_at)
1100 .bind(seq as i64)
1101 .bind(&key_id)
1102 .bind(&chain_hash)
1103 .bind(&prev_chain_hash)
1104 .bind(i64::from(identity.version()))
1105 .bind(caller_role)
1106 .bind(&event_tip)
1107 .bind(&caller_principal)
1108 .execute(&mut **tx)
1109 .await
1110 .map_err(map_sqlx_err)?;
1111
1112 Ok(InsertedTransaction {
1113 record: TransactionRecord {
1114 transaction_id: transaction_id.to_string(),
1115 request_id: transaction.request_id,
1116 request_hash: transaction.request_hash,
1117 action_name: transaction.action_name,
1118 risk_level: transaction.risk_level,
1119 status,
1120 approval_id,
1121 summary: transaction.summary,
1122 warnings: transaction.warnings,
1123 },
1124 seq,
1125 chain_hash,
1126 })
1127}
1128
1129fn row_to_history_entry(
1134 row: sqlx_postgres::PgRow,
1135) -> Result<crate::transactions::JobHistoryEntry, TransactionStoreError> {
1136 Ok(crate::transactions::JobHistoryEntry {
1137 transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
1138 action_name: row.try_get("action_name").map_err(map_sqlx_err)?,
1139 risk_level: deserialize(
1140 &row.try_get::<String, _>("risk_level")
1141 .map_err(map_sqlx_err)?,
1142 )?,
1143 status: deserialize(&row.try_get::<String, _>("status").map_err(map_sqlx_err)?)?,
1144 summary: row.try_get("summary").map_err(map_sqlx_err)?,
1145 created_at: row.try_get("created_at").map_err(map_sqlx_err)?,
1146 })
1147}
1148
1149fn row_to_record(row: sqlx_postgres::PgRow) -> Result<TransactionRecord, TransactionStoreError> {
1150 Ok(TransactionRecord {
1151 transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
1152 request_id: row.try_get("request_id").map_err(map_sqlx_err)?,
1153 request_hash: row.try_get("request_hash").map_err(map_sqlx_err)?,
1154 action_name: row.try_get("action_name").map_err(map_sqlx_err)?,
1155 risk_level: deserialize(
1156 &row.try_get::<String, _>("risk_level")
1157 .map_err(map_sqlx_err)?,
1158 )?,
1159 status: deserialize(&row.try_get::<String, _>("status").map_err(map_sqlx_err)?)?,
1160 approval_id: row.try_get("approval_id").map_err(map_sqlx_err)?,
1161 summary: row.try_get("summary").map_err(map_sqlx_err)?,
1162 warnings: deserialize(
1163 &row.try_get::<String, _>("warnings_json")
1164 .map_err(map_sqlx_err)?,
1165 )?,
1166 })
1167}
1168
1169fn row_to_chain_row(row: sqlx_postgres::PgRow) -> Result<ChainRow, TransactionStoreError> {
1170 Ok(ChainRow {
1171 seq: row.try_get::<i64, _>("seq").map_err(map_sqlx_err)? as u64,
1172 key_id: row.try_get("key_id").map_err(map_sqlx_err)?,
1173 transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
1174 request_id: row.try_get("request_id").map_err(map_sqlx_err)?,
1175 request_hash: row.try_get("request_hash").map_err(map_sqlx_err)?,
1176 action_name: row.try_get("action_name").map_err(map_sqlx_err)?,
1177 risk_level: deserialize(
1178 &row.try_get::<String, _>("risk_level")
1179 .map_err(map_sqlx_err)?,
1180 )?,
1181 summary: row.try_get("summary").map_err(map_sqlx_err)?,
1182 approval_id: row.try_get("approval_id").map_err(map_sqlx_err)?,
1183 warnings_json: row.try_get("warnings_json").map_err(map_sqlx_err)?,
1184 created_at: row.try_get("created_at").map_err(map_sqlx_err)?,
1185 prev_chain_hash: row.try_get("prev_chain_hash").map_err(map_sqlx_err)?,
1186 chain_hash: row.try_get("chain_hash").map_err(map_sqlx_err)?,
1187 chain_version: row
1188 .try_get::<i64, _>("chain_version")
1189 .map_err(map_sqlx_err)? as u32,
1190 caller_role: row.try_get("caller_role").map_err(map_sqlx_err)?,
1191 event_tip: row.try_get("event_tip").map_err(map_sqlx_err)?,
1192 caller_principal: row.try_get("caller_principal").map_err(map_sqlx_err)?,
1193 })
1194}
1195
1196fn row_to_event_row(row: sqlx_postgres::PgRow) -> Result<EventRow, TransactionStoreError> {
1197 Ok(EventRow {
1198 seq: row.try_get::<i64, _>("seq").map_err(map_sqlx_err)? as u64,
1199 key_id: row.try_get("key_id").map_err(map_sqlx_err)?,
1200 kind: row.try_get("kind").map_err(map_sqlx_err)?,
1201 transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
1202 receipt_digest: row.try_get("receipt_digest").map_err(map_sqlx_err)?,
1203 created_at: row.try_get("created_at").map_err(map_sqlx_err)?,
1204 prev_chain_hash: row.try_get("prev_chain_hash").map_err(map_sqlx_err)?,
1205 chain_hash: row.try_get("chain_hash").map_err(map_sqlx_err)?,
1206 })
1207}
1208
1209#[cfg(test)]
1210mod tests {
1211 use super::*;
1212
1213 #[test]
1216 fn default_config_keeps_statement_cache_enabled() {
1217 let c = PostgresConfig::default();
1218 assert!(c.statement_cache_capacity > 0);
1219 assert!(c.max_connections >= 4);
1220 assert!(c.acquire_timeout >= Duration::from_secs(5));
1221 }
1222
1223 #[test]
1224 fn url_parsing_accepts_standard_postgres_url() {
1225 let opts = PgConnectOptions::from_str(
1226 "postgres://user:pass@host.example.com:5432/audit?sslmode=require",
1227 )
1228 .expect("standard URL parses");
1229 let _ = opts.statement_cache_capacity(0);
1231 }
1232
1233 #[test]
1234 fn now_iso_produces_a_parseable_rfc3339_timestamp() {
1235 let ts = now_iso();
1242 let parsed = chrono::DateTime::parse_from_rfc3339(&ts);
1243 assert!(
1244 parsed.is_ok(),
1245 "now_iso() must emit a parseable RFC 3339 timestamp, got {ts:?} ({parsed:?})"
1246 );
1247 assert_eq!(
1248 ts.len(),
1249 "2026-07-27T12:34:56.789Z".len(),
1250 "timestamp shape changed unexpectedly: {ts}"
1251 );
1252 }
1253
1254 #[test]
1255 fn now_iso_matches_the_sqlite_backends_timestamp_shape() {
1256 let ts = now_iso();
1262 let (date, rest) = ts.split_once('T').expect("timestamp has a T separator");
1263 assert_eq!(date.len(), "2026-07-27".len(), "date part: {ts}");
1264 assert!(rest.ends_with('Z'), "must be UTC with a Z suffix: {ts}");
1265 let time = rest.trim_end_matches('Z');
1266 let fields: Vec<&str> = time.split(':').collect();
1267 assert_eq!(fields.len(), 3, "expected HH:MM:SS.mmm, got {time:?}");
1268 assert!(
1269 fields[2].contains('.'),
1270 "seconds field must carry fractional milliseconds: {time:?}"
1271 );
1272 let (secs, millis) = fields[2].split_once('.').expect("checked above");
1273 assert_eq!(secs.len(), 2, "seconds must be two digits: {time:?}");
1274 assert_eq!(millis.len(), 3, "expected millisecond precision: {time:?}");
1275 }
1276
1277 #[test]
1278 fn schema_decode_errors_are_classified_as_invariant_failures() {
1279 let error = map_sqlx_err(sqlx_core::Error::ColumnNotFound("chain_hash".to_string()));
1280 assert!(matches!(error, TransactionStoreError::DatabaseInvariant(_)));
1281 }
1282}