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 AuditEventKind, AuditKey, AuditVerification, ChainContent, ChainIdentity, ChainRow,
40 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 = fetch_event_rows_from_pool(&pool).await.unwrap_or_default();
239 Ok(crate::audit_chain::verify_all_with_pubkey(
240 verifying_key_hex,
241 &tx_rows,
242 &event_rows,
243 ))
244 }
245
246 pub async fn verify_all(
248 &self,
249 key: &AuditKey,
250 ) -> Result<AuditVerification, TransactionStoreError> {
251 let tx_rows = fetch_chain_rows_from_pool(&self.pool).await?;
252 let event_rows = fetch_event_rows_from_pool(&self.pool).await?;
253 Ok(crate::audit_chain::verify_all(key, &tx_rows, &event_rows))
254 }
255
256 async fn initialize(&self) -> Result<(), TransactionStoreError> {
259 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
260 sqlx_core::query::query("SELECT pg_advisory_xact_lock($1)")
261 .bind(MIGRATION_LOCK_ID)
262 .execute(&mut *tx)
263 .await
264 .map_err(map_sqlx_err)?;
265
266 sqlx_core::query::query(
267 r#"
268 CREATE TABLE IF NOT EXISTS schema_migrations (
269 version BIGINT PRIMARY KEY,
270 name TEXT NOT NULL,
271 applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
272 )
273 "#,
274 )
275 .execute(&mut *tx)
276 .await
277 .map_err(map_sqlx_err)?;
278
279 let current: i64 = sqlx_core::query_scalar::query_scalar(
280 "SELECT COALESCE(MAX(version), 0) FROM schema_migrations",
281 )
282 .fetch_one(&mut *tx)
283 .await
284 .map_err(map_sqlx_err)?;
285 let latest = MIGRATIONS.last().map_or(0, |migration| migration.version);
286 if current > latest {
287 return Err(TransactionStoreError::DatabaseInvariant(format!(
288 "postgres schema version {current} is newer than this binary supports ({latest})"
289 )));
290 }
291
292 for migration in MIGRATIONS
293 .iter()
294 .filter(|migration| migration.version > current)
295 {
296 for statement in migration.statements {
299 sqlx_core::query::query(*statement)
300 .execute(&mut *tx)
301 .await
302 .map_err(map_sqlx_err)?;
303 }
304 sqlx_core::query::query(
305 "INSERT INTO schema_migrations (version, name) VALUES ($1, $2)",
306 )
307 .bind(migration.version)
308 .bind(migration.name)
309 .execute(&mut *tx)
310 .await
311 .map_err(map_sqlx_err)?;
312 }
313
314 tx.commit().await.map_err(map_sqlx_err)
315 }
316}
317
318fn map_sqlx_err(e: sqlx_core::Error) -> TransactionStoreError {
323 let message = format!("postgres: {e}");
324 let invariant = match &e {
325 sqlx_core::Error::Database(db) => db
326 .code()
327 .is_some_and(|code| code.starts_with("22") || code.starts_with("23")),
328 sqlx_core::Error::TypeNotFound { .. }
329 | sqlx_core::Error::ColumnIndexOutOfBounds { .. }
330 | sqlx_core::Error::ColumnNotFound(_)
331 | sqlx_core::Error::ColumnDecode { .. }
332 | sqlx_core::Error::Decode(_) => true,
333 _ => false,
334 };
335 if invariant {
336 TransactionStoreError::DatabaseInvariant(message)
337 } else {
338 TransactionStoreError::Io(std::io::Error::other(message))
339 }
340}
341
342fn serialize<T: serde::Serialize>(v: &T) -> Result<String, TransactionStoreError> {
343 serde_json::to_string(v).map_err(TransactionStoreError::Json)
344}
345
346fn deserialize<T: serde::de::DeserializeOwned>(s: &str) -> Result<T, TransactionStoreError> {
347 serde_json::from_str(s).map_err(TransactionStoreError::Json)
348}
349
350fn now_iso() -> String {
358 chrono::Utc::now()
359 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
360 .to_string()
361}
362
363#[async_trait]
368impl AuditStore for PostgresStore {
369 async fn record(
370 &self,
371 transaction: NewTransaction,
372 ) -> Result<TransactionRecord, TransactionStoreError> {
373 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
374 let transaction_id = Uuid::new_v4().to_string();
375 let inserted =
376 insert_transaction(&mut tx, &self.audit_key, &transaction_id, transaction).await?;
377 tx.commit().await.map_err(map_sqlx_err)?;
378 emit_chain_tip_watermark(inserted.seq, &inserted.chain_hash);
379 Ok(inserted.record)
380 }
381
382 async fn record_previewed(
383 &self,
384 transaction: NewTransaction,
385 preview: PreviewEnvelope,
386 ) -> Result<RecordedPreviewedTransaction, TransactionStoreError> {
387 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
388 let transaction_id = Uuid::new_v4().to_string();
389 let inserted =
390 insert_transaction(&mut tx, &self.audit_key, &transaction_id, transaction).await?;
391
392 sqlx_core::query::query(
393 "INSERT INTO transaction_previews (transaction_id, preview_json) VALUES ($1, $2)",
394 )
395 .bind(&inserted.record.transaction_id)
396 .bind(serialize(&preview)?)
397 .execute(&mut *tx)
398 .await
399 .map_err(map_sqlx_err)?;
400
401 tx.commit().await.map_err(map_sqlx_err)?;
402 emit_chain_tip_watermark(inserted.seq, &inserted.chain_hash);
403 Ok(RecordedPreviewedTransaction {
404 transaction: inserted.record,
405 preview,
406 })
407 }
408
409 async fn get(
410 &self,
411 transaction_id: &str,
412 ) -> Result<Option<TransactionRecord>, TransactionStoreError> {
413 let row = sqlx_core::query::query(
414 "SELECT transaction_id, request_id, request_hash, action_name, risk_level, \
415 status, approval_id, summary, warnings_json \
416 FROM transactions WHERE transaction_id = $1",
417 )
418 .bind(transaction_id)
419 .fetch_optional(&self.pool)
420 .await
421 .map_err(map_sqlx_err)?;
422 row.map(row_to_record).transpose()
423 }
424
425 async fn get_preview(
426 &self,
427 transaction_id: &str,
428 ) -> Result<Option<PreviewEnvelope>, TransactionStoreError> {
429 let row = sqlx_core::query::query(
430 "SELECT preview_json FROM transaction_previews WHERE transaction_id = $1",
431 )
432 .bind(transaction_id)
433 .fetch_optional(&self.pool)
434 .await
435 .map_err(map_sqlx_err)?;
436 match row {
437 Some(r) => {
438 let s: String = r.try_get("preview_json").map_err(map_sqlx_err)?;
439 Ok(Some(deserialize(&s)?))
440 }
441 None => Ok(None),
442 }
443 }
444
445 async fn update_status(
446 &self,
447 transaction_id: &str,
448 new_status: JobState,
449 ) -> Result<(), TransactionStoreError> {
450 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
459 let current_str: Option<String> = sqlx_core::query_scalar::query_scalar(
460 "SELECT status FROM transactions WHERE transaction_id = $1 FOR UPDATE",
461 )
462 .bind(transaction_id)
463 .fetch_optional(&mut *tx)
464 .await
465 .map_err(map_sqlx_err)?;
466 let current_str = current_str
467 .ok_or_else(|| TransactionStoreError::NotFound(transaction_id.to_string()))?;
468 let current: JobState = deserialize(¤t_str)?;
469 if !crate::jobs::allowed_transition(¤t, &new_status) {
470 return Err(TransactionStoreError::InvalidTransition {
471 from: current,
472 to: new_status,
473 });
474 }
475 sqlx_core::query::query("UPDATE transactions SET status = $1 WHERE transaction_id = $2")
476 .bind(serialize(&new_status)?)
477 .bind(transaction_id)
478 .execute(&mut *tx)
479 .await
480 .map_err(map_sqlx_err)?;
481 tx.commit().await.map_err(map_sqlx_err)?;
482 Ok(())
483 }
484
485 async fn approve_transaction(
486 &self,
487 transaction_id: &str,
488 ) -> Result<Option<String>, TransactionStoreError> {
489 let row = sqlx_core::query::query(
490 "SELECT request_hash, approval_id FROM transactions WHERE transaction_id = $1",
491 )
492 .bind(transaction_id)
493 .fetch_optional(&self.pool)
494 .await
495 .map_err(map_sqlx_err)?;
496 let Some(row) = row else {
497 return Ok(None);
498 };
499 let request_hash: String = row.try_get("request_hash").map_err(map_sqlx_err)?;
500 let committed_digest: Option<String> = row.try_get("approval_id").map_err(map_sqlx_err)?;
501 let receipt = self
502 .audit_key
503 .approval_receipt(transaction_id, &request_hash);
504 let receipt_digest = crate::audit_chain::approval_receipt_digest(&receipt);
505 let commitment_matches = committed_digest.as_deref().is_some_and(|committed| {
506 bool::from(receipt_digest.as_bytes().ct_eq(committed.as_bytes()))
507 });
508 if !commitment_matches {
509 return Err(TransactionStoreError::DatabaseInvariant(format!(
510 "transaction {transaction_id} approval commitment does not match its signed preview"
511 )));
512 }
513 let queued = serialize(&JobState::Queued)?;
514 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
517 let result = sqlx_core::query::query(
518 sqlx_core::sql_str::AssertSqlSafe(format!(
519 "INSERT INTO transaction_approvals \
520 (transaction_id, receipt_digest, approved_at) \
521 SELECT transaction_id, $1, $2 FROM transactions \
522 WHERE transaction_id = $3 \
523 AND status = $4 \
524 AND created_at::timestamptz > now() - INTERVAL '{APPROVAL_RECEIPT_TTL_MINUTES} minutes' \
525 ON CONFLICT (transaction_id) DO NOTHING"
526 )),
527 )
528 .bind(&receipt_digest)
529 .bind(now_iso())
530 .bind(transaction_id)
531 .bind(&queued)
532 .execute(&mut *tx)
533 .await
534 .map_err(map_sqlx_err)?;
535 if result.rows_affected() > 0 {
536 append_event(
537 &mut tx,
538 &self.audit_key,
539 AuditEventKind::ApprovalGranted,
540 transaction_id,
541 &receipt_digest,
542 )
543 .await?;
544 }
545 tx.commit().await.map_err(map_sqlx_err)?;
546 Ok((result.rows_affected() > 0).then_some(receipt))
547 }
548
549 async fn revoke_unconsumed_approval(
550 &self,
551 transaction_id: &str,
552 ) -> Result<bool, TransactionStoreError> {
553 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
554 let digest: Option<String> = sqlx_core::query_scalar::query_scalar(
557 "SELECT receipt_digest FROM transaction_approvals \
558 WHERE transaction_id = $1 AND consumed_at IS NULL",
559 )
560 .bind(transaction_id)
561 .fetch_optional(&mut *tx)
562 .await
563 .map_err(map_sqlx_err)?;
564 let result = sqlx_core::query::query(
565 "DELETE FROM transaction_approvals \
566 WHERE transaction_id = $1 AND consumed_at IS NULL",
567 )
568 .bind(transaction_id)
569 .execute(&mut *tx)
570 .await
571 .map_err(map_sqlx_err)?;
572 if result.rows_affected() > 0 {
573 let digest = digest.ok_or_else(|| {
574 TransactionStoreError::DatabaseInvariant(format!(
575 "revoked an approval for {transaction_id} that had no receipt digest"
576 ))
577 })?;
578 append_event(
579 &mut tx,
580 &self.audit_key,
581 AuditEventKind::ApprovalRevoked,
582 transaction_id,
583 &digest,
584 )
585 .await?;
586 }
587 tx.commit().await.map_err(map_sqlx_err)?;
588 Ok(result.rows_affected() > 0)
589 }
590
591 async fn claim_approved_for_execution(
592 &self,
593 transaction_id: &str,
594 receipt_digest: &str,
595 ) -> Result<bool, TransactionStoreError> {
596 let queued = serialize(&JobState::Queued)?;
597 let running = serialize(&JobState::Running)?;
598 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
599 let result = sqlx_core::query::query(
600 sqlx_core::sql_str::AssertSqlSafe(format!(
601 "UPDATE transactions SET status = $1 \
602 WHERE transaction_id = $2 \
603 AND status = $3 \
604 AND created_at::timestamptz > now() - INTERVAL '{APPROVAL_RECEIPT_TTL_MINUTES} minutes' \
605 AND EXISTS ( \
606 SELECT 1 FROM transaction_approvals \
607 WHERE transaction_id = $2 \
608 AND receipt_digest = $4 \
609 AND consumed_at IS NULL \
610 )"
611 )),
612 )
613 .bind(&running)
614 .bind(transaction_id)
615 .bind(&queued)
616 .bind(receipt_digest)
617 .execute(&mut *tx)
618 .await
619 .map_err(map_sqlx_err)?;
620 if result.rows_affected() > 0 {
621 sqlx_core::query::query(
622 "UPDATE transaction_approvals SET consumed_at = $1 \
623 WHERE transaction_id = $2 AND consumed_at IS NULL",
624 )
625 .bind(now_iso())
626 .bind(transaction_id)
627 .execute(&mut *tx)
628 .await
629 .map_err(map_sqlx_err)?;
630 append_event(
631 &mut tx,
632 &self.audit_key,
633 AuditEventKind::ApprovalConsumed,
634 transaction_id,
635 receipt_digest,
636 )
637 .await?;
638 }
639 tx.commit().await.map_err(map_sqlx_err)?;
640 Ok(result.rows_affected() > 0)
641 }
642
643 async fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError> {
644 let queued = serialize(&JobState::Queued)?;
645 let canceled = serialize(&JobState::Canceled)?;
646 let result = sqlx_core::query::query(
647 sqlx_core::sql_str::AssertSqlSafe(format!(
648 "UPDATE transactions SET status = $1 \
649 WHERE status = $2 \
650 AND created_at::timestamptz <= now() - INTERVAL '{APPROVAL_RECEIPT_TTL_MINUTES} minutes'"
651 )),
652 )
653 .bind(&canceled)
654 .bind(&queued)
655 .execute(&self.pool)
656 .await
657 .map_err(map_sqlx_err)?;
658 Ok(result.rows_affected())
659 }
660
661 async fn cancel_queued(&self, transaction_id: &str) -> Result<bool, TransactionStoreError> {
662 let queued = serialize(&JobState::Queued)?;
665 let canceled = serialize(&JobState::Canceled)?;
666 let result = sqlx_core::query::query(
667 "UPDATE transactions SET status = $1 \
668 WHERE transaction_id = $2 AND status = $3",
669 )
670 .bind(&canceled)
671 .bind(transaction_id)
672 .bind(&queued)
673 .execute(&self.pool)
674 .await
675 .map_err(map_sqlx_err)?;
676 Ok(result.rows_affected() > 0)
677 }
678
679 async fn list_transactions(
680 &self,
681 limit: u32,
682 status_filter: Option<&str>,
683 action_filter: Option<&str>,
684 since_hours: Option<u32>,
685 ) -> Result<Vec<TransactionRecord>, TransactionStoreError> {
686 let limit = limit.min(100) as i64;
687
688 let validated_status: Option<String> = status_filter
691 .map(|s| -> Result<String, TransactionStoreError> {
692 let parsed: JobState = deserialize(&format!("\"{s}\""))?;
693 serialize(&parsed)
694 })
695 .transpose()?;
696
697 let mut sql = String::from(
698 "SELECT transaction_id, request_id, request_hash, action_name, risk_level, \
699 status, approval_id, summary, warnings_json \
700 FROM transactions WHERE TRUE",
701 );
702 let mut idx = 1;
703 if validated_status.is_some() {
704 sql.push_str(&format!(" AND status = ${idx}"));
705 idx += 1;
706 }
707 if action_filter.is_some() {
708 sql.push_str(&format!(" AND action_name = ${idx}"));
709 idx += 1;
710 }
711 if since_hours.is_some() {
712 sql.push_str(&format!(
713 " AND created_at::timestamptz > now() - (${idx} || ' hours')::INTERVAL"
714 ));
715 idx += 1;
716 }
717 sql.push_str(&format!(" ORDER BY seq DESC LIMIT ${idx}"));
718
719 let mut q = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(sql.as_str()));
720 if let Some(s) = &validated_status {
721 q = q.bind(s);
722 }
723 if let Some(a) = action_filter {
724 q = q.bind(a);
725 }
726 if let Some(h) = since_hours {
727 q = q.bind(h.to_string());
728 }
729 q = q.bind(limit);
730
731 let rows = q.fetch_all(&self.pool).await.map_err(map_sqlx_err)?;
732 rows.into_iter().map(row_to_record).collect()
733 }
734
735 async fn list_history(
736 &self,
737 limit: u32,
738 status_filter: Option<&str>,
739 action_filter: Option<&str>,
740 since_hours: Option<u32>,
741 ) -> Result<Vec<crate::transactions::JobHistoryEntry>, TransactionStoreError> {
742 let limit = limit.min(100) as i64;
745 let validated_status: Option<String> = status_filter
746 .map(|s| -> Result<String, TransactionStoreError> {
747 let parsed: JobState = deserialize(&format!("\"{s}\""))?;
748 serialize(&parsed)
749 })
750 .transpose()?;
751
752 let mut sql = String::from(
753 "SELECT transaction_id, action_name, risk_level, status, summary, created_at \
754 FROM transactions WHERE TRUE",
755 );
756 let mut idx = 1;
757 if validated_status.is_some() {
758 sql.push_str(&format!(" AND status = ${idx}"));
759 idx += 1;
760 }
761 if action_filter.is_some() {
762 sql.push_str(&format!(" AND action_name = ${idx}"));
763 idx += 1;
764 }
765 if since_hours.is_some() {
766 sql.push_str(&format!(
767 " AND created_at::timestamptz > now() - (${idx} || ' hours')::INTERVAL"
768 ));
769 idx += 1;
770 }
771 sql.push_str(&format!(" ORDER BY seq DESC LIMIT ${idx}"));
772
773 let mut q = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(sql.as_str()));
774 if let Some(s) = &validated_status {
775 q = q.bind(s);
776 }
777 if let Some(a) = action_filter {
778 q = q.bind(a);
779 }
780 if let Some(h) = since_hours {
781 q = q.bind(h.to_string());
782 }
783 q = q.bind(limit);
784
785 let rows = q.fetch_all(&self.pool).await.map_err(map_sqlx_err)?;
786 rows.into_iter().map(row_to_history_entry).collect()
787 }
788
789 async fn fetch_chain_row(
790 &self,
791 transaction_id: &str,
792 ) -> Result<Option<ChainRow>, TransactionStoreError> {
793 let row = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(format!(
794 "SELECT {CHAIN_ROW_COLUMNS} FROM transactions WHERE transaction_id = $1"
795 )))
796 .bind(transaction_id)
797 .fetch_optional(&self.pool)
798 .await
799 .map_err(map_sqlx_err)?;
800 row.map(row_to_chain_row).transpose()
801 }
802
803 async fn fetch_event_rows(&self) -> Result<Vec<EventRow>, TransactionStoreError> {
804 fetch_event_rows_from_pool(&self.pool).await
805 }
806
807 async fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
808 fetch_chain_rows_from_pool(&self.pool).await
809 }
810
811 async fn verify_audit_chain(
812 &self,
813 key: &AuditKey,
814 ) -> Result<VerifyOutcome, TransactionStoreError> {
815 let rows = self.fetch_chain_rows().await?;
816 Ok(crate::audit_chain::verify_chain(key, &rows))
817 }
818}
819
820async fn fetch_chain_rows_from_pool(pool: &PgPool) -> Result<Vec<ChainRow>, TransactionStoreError> {
821 let rows = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(format!(
822 "SELECT {CHAIN_ROW_COLUMNS} FROM transactions ORDER BY seq ASC"
823 )))
824 .fetch_all(pool)
825 .await
826 .map_err(map_sqlx_err)?;
827 rows.into_iter().map(row_to_chain_row).collect()
828}
829
830async fn fetch_event_rows_from_pool(pool: &PgPool) -> Result<Vec<EventRow>, TransactionStoreError> {
832 let rows = sqlx_core::query::query(
833 "SELECT seq, key_id, kind, transaction_id, receipt_digest, \
834 created_at, prev_chain_hash, chain_hash \
835 FROM audit_events ORDER BY seq ASC",
836 )
837 .fetch_all(pool)
838 .await
839 .map_err(map_sqlx_err)?;
840 rows.into_iter().map(row_to_event_row).collect()
841}
842
843async fn append_event(
849 tx: &mut sqlx_core::transaction::Transaction<'_, sqlx_postgres::Postgres>,
850 key: &AuditKey,
851 kind: AuditEventKind,
852 transaction_id: &str,
853 receipt_digest: &str,
854) -> Result<(), TransactionStoreError> {
855 let prev: Option<(i64, String)> = sqlx_core::query_as::query_as(
856 "SELECT seq, chain_hash FROM audit_events ORDER BY seq DESC LIMIT 1 FOR UPDATE",
857 )
858 .fetch_optional(&mut **tx)
859 .await
860 .map_err(map_sqlx_err)?;
861 let (seq, prev_chain_hash) = match prev {
862 Some((s, h)) => ((s as u64) + 1, h),
863 None => (1, String::new()),
864 };
865 let created_at = now_iso();
866 let key_id = CURRENT_KEY_ID.to_string();
867 let chain_hash = key.event_hash(
868 &EventContent {
869 seq,
870 key_id: &key_id,
871 kind,
872 transaction_id,
873 receipt_digest,
874 created_at: &created_at,
875 },
876 &prev_chain_hash,
877 );
878 sqlx_core::query::query(
879 "INSERT INTO audit_events ( \
880 seq, key_id, kind, transaction_id, receipt_digest, \
881 created_at, chain_hash, prev_chain_hash \
882 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
883 )
884 .bind(seq as i64)
885 .bind(&key_id)
886 .bind(kind.as_str())
887 .bind(transaction_id)
888 .bind(receipt_digest)
889 .bind(&created_at)
890 .bind(&chain_hash)
891 .bind(&prev_chain_hash)
892 .execute(&mut **tx)
893 .await
894 .map_err(map_sqlx_err)?;
895 Ok(())
896}
897
898async fn insert_transaction(
903 tx: &mut sqlx_core::transaction::Transaction<'_, sqlx_postgres::Postgres>,
904 key: &AuditKey,
905 transaction_id: &str,
906 transaction: NewTransaction,
907) -> Result<InsertedTransaction, TransactionStoreError> {
908 let warnings_json = serialize(&transaction.warnings)?;
909 let status = JobState::Queued;
910 let created_at = now_iso();
911 let key_id = CURRENT_KEY_ID.to_string();
912 let approval_id = Some(key.approval_commitment(transaction_id, &transaction.request_hash));
915
916 let prev: Option<(i64, String)> = sqlx_core::query_as::query_as(
922 "SELECT seq, chain_hash FROM transactions ORDER BY seq DESC LIMIT 1 FOR UPDATE",
923 )
924 .fetch_optional(&mut **tx)
925 .await
926 .map_err(map_sqlx_err)?;
927 let (seq, prev_chain_hash) = match prev {
928 Some((s, h)) => ((s as u64) + 1, h),
929 None => (1, String::new()),
930 };
931
932 let event_tip: String = sqlx_core::query_scalar::query_scalar(
935 "SELECT COALESCE((SELECT chain_hash FROM audit_events ORDER BY seq DESC LIMIT 1), '')",
936 )
937 .fetch_one(&mut **tx)
938 .await
939 .map_err(map_sqlx_err)?;
940 let caller_role = transaction.caller_role.as_str();
941 let caller_principal = transaction.caller_principal.as_signed_str();
942
943 let identity = ChainIdentity::V3 {
946 caller_role,
947 event_tip: &event_tip,
948 caller_principal: &caller_principal,
949 };
950 let chain_hash = key.chain_hash(
951 &ChainContent {
952 seq,
953 key_id: &key_id,
954 transaction_id,
955 request_id: &transaction.request_id,
956 request_hash: &transaction.request_hash,
957 action_name: &transaction.action_name,
958 risk_level: transaction.risk_level,
959 summary: &transaction.summary,
960 approval_id: approval_id.as_deref(),
961 warnings_json: &warnings_json,
962 created_at: &created_at,
963 identity,
964 },
965 &prev_chain_hash,
966 );
967
968 sqlx_core::query::query(
969 "INSERT INTO transactions ( \
970 transaction_id, request_id, request_hash, action_name, risk_level, \
971 status, approval_id, summary, warnings_json, created_at, \
972 seq, key_id, chain_hash, prev_chain_hash, \
973 chain_version, caller_role, event_tip, caller_principal \
974 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)",
975 )
976 .bind(transaction_id)
977 .bind(&transaction.request_id)
978 .bind(&transaction.request_hash)
979 .bind(&transaction.action_name)
980 .bind(serialize(&transaction.risk_level)?)
981 .bind(serialize(&status)?)
982 .bind(&approval_id)
983 .bind(&transaction.summary)
984 .bind(&warnings_json)
985 .bind(&created_at)
986 .bind(seq as i64)
987 .bind(&key_id)
988 .bind(&chain_hash)
989 .bind(&prev_chain_hash)
990 .bind(i64::from(identity.version()))
991 .bind(caller_role)
992 .bind(&event_tip)
993 .bind(&caller_principal)
994 .execute(&mut **tx)
995 .await
996 .map_err(map_sqlx_err)?;
997
998 Ok(InsertedTransaction {
999 record: TransactionRecord {
1000 transaction_id: transaction_id.to_string(),
1001 request_id: transaction.request_id,
1002 request_hash: transaction.request_hash,
1003 action_name: transaction.action_name,
1004 risk_level: transaction.risk_level,
1005 status,
1006 approval_id,
1007 summary: transaction.summary,
1008 warnings: transaction.warnings,
1009 },
1010 seq,
1011 chain_hash,
1012 })
1013}
1014
1015fn row_to_history_entry(
1020 row: sqlx_postgres::PgRow,
1021) -> Result<crate::transactions::JobHistoryEntry, TransactionStoreError> {
1022 Ok(crate::transactions::JobHistoryEntry {
1023 transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
1024 action_name: row.try_get("action_name").map_err(map_sqlx_err)?,
1025 risk_level: deserialize(
1026 &row.try_get::<String, _>("risk_level")
1027 .map_err(map_sqlx_err)?,
1028 )?,
1029 status: deserialize(&row.try_get::<String, _>("status").map_err(map_sqlx_err)?)?,
1030 summary: row.try_get("summary").map_err(map_sqlx_err)?,
1031 created_at: row.try_get("created_at").map_err(map_sqlx_err)?,
1032 })
1033}
1034
1035fn row_to_record(row: sqlx_postgres::PgRow) -> Result<TransactionRecord, TransactionStoreError> {
1036 Ok(TransactionRecord {
1037 transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
1038 request_id: row.try_get("request_id").map_err(map_sqlx_err)?,
1039 request_hash: row.try_get("request_hash").map_err(map_sqlx_err)?,
1040 action_name: row.try_get("action_name").map_err(map_sqlx_err)?,
1041 risk_level: deserialize(
1042 &row.try_get::<String, _>("risk_level")
1043 .map_err(map_sqlx_err)?,
1044 )?,
1045 status: deserialize(&row.try_get::<String, _>("status").map_err(map_sqlx_err)?)?,
1046 approval_id: row.try_get("approval_id").map_err(map_sqlx_err)?,
1047 summary: row.try_get("summary").map_err(map_sqlx_err)?,
1048 warnings: deserialize(
1049 &row.try_get::<String, _>("warnings_json")
1050 .map_err(map_sqlx_err)?,
1051 )?,
1052 })
1053}
1054
1055fn row_to_chain_row(row: sqlx_postgres::PgRow) -> Result<ChainRow, TransactionStoreError> {
1056 Ok(ChainRow {
1057 seq: row.try_get::<i64, _>("seq").map_err(map_sqlx_err)? as u64,
1058 key_id: row.try_get("key_id").map_err(map_sqlx_err)?,
1059 transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
1060 request_id: row.try_get("request_id").map_err(map_sqlx_err)?,
1061 request_hash: row.try_get("request_hash").map_err(map_sqlx_err)?,
1062 action_name: row.try_get("action_name").map_err(map_sqlx_err)?,
1063 risk_level: deserialize(
1064 &row.try_get::<String, _>("risk_level")
1065 .map_err(map_sqlx_err)?,
1066 )?,
1067 summary: row.try_get("summary").map_err(map_sqlx_err)?,
1068 approval_id: row.try_get("approval_id").map_err(map_sqlx_err)?,
1069 warnings_json: row.try_get("warnings_json").map_err(map_sqlx_err)?,
1070 created_at: row.try_get("created_at").map_err(map_sqlx_err)?,
1071 prev_chain_hash: row.try_get("prev_chain_hash").map_err(map_sqlx_err)?,
1072 chain_hash: row.try_get("chain_hash").map_err(map_sqlx_err)?,
1073 chain_version: row
1074 .try_get::<i64, _>("chain_version")
1075 .map_err(map_sqlx_err)? as u32,
1076 caller_role: row.try_get("caller_role").map_err(map_sqlx_err)?,
1077 event_tip: row.try_get("event_tip").map_err(map_sqlx_err)?,
1078 caller_principal: row.try_get("caller_principal").map_err(map_sqlx_err)?,
1079 })
1080}
1081
1082fn row_to_event_row(row: sqlx_postgres::PgRow) -> Result<EventRow, TransactionStoreError> {
1083 Ok(EventRow {
1084 seq: row.try_get::<i64, _>("seq").map_err(map_sqlx_err)? as u64,
1085 key_id: row.try_get("key_id").map_err(map_sqlx_err)?,
1086 kind: row.try_get("kind").map_err(map_sqlx_err)?,
1087 transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
1088 receipt_digest: row.try_get("receipt_digest").map_err(map_sqlx_err)?,
1089 created_at: row.try_get("created_at").map_err(map_sqlx_err)?,
1090 prev_chain_hash: row.try_get("prev_chain_hash").map_err(map_sqlx_err)?,
1091 chain_hash: row.try_get("chain_hash").map_err(map_sqlx_err)?,
1092 })
1093}
1094
1095#[cfg(test)]
1096mod tests {
1097 use super::*;
1098
1099 #[test]
1102 fn default_config_keeps_statement_cache_enabled() {
1103 let c = PostgresConfig::default();
1104 assert!(c.statement_cache_capacity > 0);
1105 assert!(c.max_connections >= 4);
1106 assert!(c.acquire_timeout >= Duration::from_secs(5));
1107 }
1108
1109 #[test]
1110 fn url_parsing_accepts_standard_postgres_url() {
1111 let opts = PgConnectOptions::from_str(
1112 "postgres://user:pass@host.example.com:5432/audit?sslmode=require",
1113 )
1114 .expect("standard URL parses");
1115 let _ = opts.statement_cache_capacity(0);
1117 }
1118
1119 #[test]
1120 fn now_iso_produces_a_parseable_rfc3339_timestamp() {
1121 let ts = now_iso();
1128 let parsed = chrono::DateTime::parse_from_rfc3339(&ts);
1129 assert!(
1130 parsed.is_ok(),
1131 "now_iso() must emit a parseable RFC 3339 timestamp, got {ts:?} ({parsed:?})"
1132 );
1133 assert_eq!(
1134 ts.len(),
1135 "2026-07-27T12:34:56.789Z".len(),
1136 "timestamp shape changed unexpectedly: {ts}"
1137 );
1138 }
1139
1140 #[test]
1141 fn now_iso_matches_the_sqlite_backends_timestamp_shape() {
1142 let ts = now_iso();
1148 let (date, rest) = ts.split_once('T').expect("timestamp has a T separator");
1149 assert_eq!(date.len(), "2026-07-27".len(), "date part: {ts}");
1150 assert!(rest.ends_with('Z'), "must be UTC with a Z suffix: {ts}");
1151 let time = rest.trim_end_matches('Z');
1152 let fields: Vec<&str> = time.split(':').collect();
1153 assert_eq!(fields.len(), 3, "expected HH:MM:SS.mmm, got {time:?}");
1154 assert!(
1155 fields[2].contains('.'),
1156 "seconds field must carry fractional milliseconds: {time:?}"
1157 );
1158 let (secs, millis) = fields[2].split_once('.').expect("checked above");
1159 assert_eq!(secs.len(), 2, "seconds must be two digits: {time:?}");
1160 assert_eq!(millis.len(), 3, "expected millisecond precision: {time:?}");
1161 }
1162
1163 #[test]
1164 fn schema_decode_errors_are_classified_as_invariant_failures() {
1165 let error = map_sqlx_err(sqlx_core::Error::ColumnNotFound("chain_hash".to_string()));
1166 assert!(matches!(error, TransactionStoreError::DatabaseInvariant(_)));
1167 }
1168}