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