1use crate::audit_chain::{
2 self, AuditEventKind, AuditKey, ChainContent, ChainIdentity, ChainRow, EventContent, EventRow,
3 VerifyOutcome, CURRENT_KEY_ID,
4};
5use crate::audit_watermark::emit_chain_tip_watermark;
6use crate::auth::CallerPrincipal;
7use rusqlite::{params, Connection, TransactionBehavior};
8use serde::{de::DeserializeOwned, Deserialize, Serialize};
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use subtle::ConstantTimeEq;
12use sysknife_types::{CallerRole, JobState, PreviewEnvelope, RiskLevel, TransactionRecord};
13use uuid::Uuid;
14
15pub(crate) const APPROVAL_RECEIPT_TTL_MINUTES: i64 = 15;
26
27#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
35pub struct JobHistoryEntry {
36 pub transaction_id: String,
37 pub action_name: String,
38 pub risk_level: RiskLevel,
39 pub status: JobState,
40 pub summary: String,
41 pub created_at: String,
42}
43
44#[derive(Clone, Debug, PartialEq, Eq)]
50pub struct NewTransaction {
51 pub request_id: String,
52 pub request_hash: String,
53 pub action_name: String,
54 pub risk_level: RiskLevel,
55 pub summary: String,
75 pub warnings: Vec<String>,
76 pub caller_role: CallerRole,
85 pub caller_principal: CallerPrincipal,
93}
94
95struct SqliteMigration {
104 version: i64,
105 name: &'static str,
106 sql: &'static str,
107}
108
109const SQLITE_MIGRATIONS: &[SqliteMigration] = &[
127 SqliteMigration {
128 version: 1,
129 name: "initial_audit_schema",
130 sql: r#"
131 CREATE TABLE IF NOT EXISTS transactions (
132 transaction_id TEXT PRIMARY KEY,
133 request_id TEXT NOT NULL,
134 request_hash TEXT NOT NULL,
135 action_name TEXT NOT NULL,
136 risk_level TEXT NOT NULL,
137 status TEXT NOT NULL,
138 approval_id TEXT,
139 summary TEXT NOT NULL,
140 warnings_json TEXT NOT NULL,
141 created_at TEXT NOT NULL DEFAULT (datetime('now')),
142 seq INTEGER NOT NULL UNIQUE,
143 key_id TEXT NOT NULL,
144 chain_hash TEXT NOT NULL,
145 prev_chain_hash TEXT NOT NULL DEFAULT ''
146 );
147
148 CREATE TABLE IF NOT EXISTS transaction_previews (
149 transaction_id TEXT PRIMARY KEY,
150 preview_json TEXT NOT NULL
151 );
152
153 CREATE TABLE IF NOT EXISTS transaction_approvals (
154 transaction_id TEXT PRIMARY KEY,
155 receipt_digest TEXT NOT NULL,
156 approved_at TEXT NOT NULL DEFAULT (datetime('now')),
157 consumed_at TEXT
158 );
159
160 CREATE INDEX IF NOT EXISTS transactions_seq_idx ON transactions(seq);
161 "#,
162 },
163 SqliteMigration {
172 version: 2,
173 name: "caller_identity_and_approval_events",
174 sql: r#"
175 ALTER TABLE transactions ADD COLUMN chain_version INTEGER NOT NULL DEFAULT 1;
176 ALTER TABLE transactions ADD COLUMN caller_role TEXT;
177 ALTER TABLE transactions ADD COLUMN event_tip TEXT;
178
179 CREATE TABLE IF NOT EXISTS audit_events (
180 seq INTEGER PRIMARY KEY,
181 key_id TEXT NOT NULL,
182 kind TEXT NOT NULL,
183 transaction_id TEXT NOT NULL,
184 receipt_digest TEXT NOT NULL,
185 created_at TEXT NOT NULL,
186 chain_hash TEXT NOT NULL,
187 prev_chain_hash TEXT NOT NULL DEFAULT ''
188 );
189
190 CREATE INDEX IF NOT EXISTS audit_events_transaction_idx
191 ON audit_events(transaction_id);
192 "#,
193 },
194 SqliteMigration {
199 version: 3,
200 name: "caller_principal",
201 sql: r#"
202 ALTER TABLE transactions ADD COLUMN caller_principal TEXT;
203 "#,
204 },
205];
206
207const CHAIN_ROW_COLUMNS: &str = "seq, key_id, transaction_id, request_id, request_hash, \
214 action_name, risk_level, summary, approval_id, warnings_json, \
215 created_at, prev_chain_hash, chain_hash, chain_version, caller_role, event_tip, \
216 caller_principal";
217
218fn chain_row_from_sqlite(row: &rusqlite::Row<'_>) -> rusqlite::Result<ChainRow> {
219 Ok(ChainRow {
220 seq: row.get::<_, i64>(0)? as u64,
221 key_id: row.get(1)?,
222 transaction_id: row.get(2)?,
223 request_id: row.get(3)?,
224 request_hash: row.get(4)?,
225 action_name: row.get(5)?,
226 risk_level: deserialize_field(&row.get::<_, String>(6)?).map_err(|e| {
227 rusqlite::Error::FromSqlConversionFailure(6, rusqlite::types::Type::Text, Box::new(e))
228 })?,
229 summary: row.get(7)?,
230 approval_id: row.get(8)?,
231 warnings_json: row.get(9)?,
232 created_at: row.get(10)?,
233 prev_chain_hash: row.get(11)?,
234 chain_hash: row.get(12)?,
235 chain_version: row.get::<_, i64>(13)? as u32,
236 caller_role: row.get(14)?,
237 event_tip: row.get(15)?,
238 caller_principal: row.get(16)?,
239 })
240}
241
242fn event_row_from_sqlite(row: &rusqlite::Row<'_>) -> rusqlite::Result<EventRow> {
243 Ok(EventRow {
244 seq: row.get::<_, i64>(0)? as u64,
245 key_id: row.get(1)?,
246 kind: row.get(2)?,
247 transaction_id: row.get(3)?,
248 receipt_digest: row.get(4)?,
249 created_at: row.get(5)?,
250 prev_chain_hash: row.get(6)?,
251 chain_hash: row.get(7)?,
252 })
253}
254
255#[derive(Clone, Debug)]
256pub struct TransactionStore {
257 path: PathBuf,
258 audit_key: Option<Arc<AuditKey>>,
261}
262
263#[derive(Clone, Debug, PartialEq)]
264pub struct RecordedPreviewedTransaction {
265 pub transaction: TransactionRecord,
266 pub preview: PreviewEnvelope,
267}
268
269struct InsertedTransaction {
270 record: TransactionRecord,
271 seq: u64,
272 chain_hash: String,
273}
274
275#[derive(Debug, thiserror::Error)]
276pub enum TransactionStoreError {
277 #[error("io error: {0}")]
278 Io(#[from] std::io::Error),
279
280 #[error("sqlite error: {0}")]
281 Sqlite(#[from] rusqlite::Error),
282
283 #[error("json error: {0}")]
284 Json(#[from] serde_json::Error),
285
286 #[error("database invariant violation: {0}")]
287 DatabaseInvariant(String),
288
289 #[error("transaction not found: {0}")]
290 NotFound(String),
291
292 #[error("invalid transition from {from:?} to {to:?}")]
293 InvalidTransition { from: JobState, to: JobState },
294
295 #[error("audit chain misconfiguration: {0}")]
296 AuditChainMissing(&'static str),
297
298 #[error("transaction {0} status changed concurrently; retry")]
306 ConcurrentStatusChange(String),
307}
308
309impl TransactionStore {
310 pub fn open_read_only(path: impl AsRef<Path>) -> Result<Self, TransactionStoreError> {
314 let path = path.as_ref().to_path_buf();
315 if let Some(parent) = path.parent() {
316 ensure_private_dir(parent)?;
317 }
318
319 let store = Self {
320 path,
321 audit_key: None,
322 };
323 store.initialize()?;
324 Ok(store)
325 }
326
327 pub fn open(path: impl AsRef<Path>) -> Result<Self, TransactionStoreError> {
334 let db_path = path.as_ref();
335 let key_path = std::env::var("SYSKNIFE_AUDIT_KEY_PATH")
336 .map(PathBuf::from)
337 .unwrap_or_else(|_| {
338 db_path
339 .parent()
340 .unwrap_or_else(|| Path::new("."))
341 .join("audit-key")
342 });
343 let key = AuditKey::load_or_generate(&key_path).map_err(|e| {
344 TransactionStoreError::Io(std::io::Error::new(
345 std::io::ErrorKind::InvalidData,
346 format!("audit key load failed: {e}"),
347 ))
348 })?;
349 Self::open_with_key(path, Arc::new(key))
350 }
351
352 pub fn open_with_key(
355 path: impl AsRef<Path>,
356 audit_key: Arc<AuditKey>,
357 ) -> Result<Self, TransactionStoreError> {
358 let path = path.as_ref().to_path_buf();
359 if let Some(parent) = path.parent() {
360 ensure_private_dir(parent)?;
361 }
362
363 let store = Self {
364 path,
365 audit_key: Some(audit_key),
366 };
367 store.initialize()?;
368 Ok(store)
369 }
370
371 pub fn record(
372 &self,
373 transaction: NewTransaction,
374 ) -> Result<TransactionRecord, TransactionStoreError> {
375 let key = self
376 .audit_key
377 .as_ref()
378 .ok_or(TransactionStoreError::AuditChainMissing(
379 "this TransactionStore was opened read-only; cannot record",
380 ))?;
381 let mut conn = self.connection()?;
382 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
387 let transaction_id = Uuid::new_v4().to_string();
388 let inserted = Self::insert_transaction(&tx, key, &transaction_id, transaction)?;
389 tx.commit()?;
390 emit_chain_tip_watermark(inserted.seq, &inserted.chain_hash);
391 Ok(inserted.record)
392 }
393
394 pub fn record_previewed(
395 &self,
396 transaction: NewTransaction,
397 preview: PreviewEnvelope,
398 ) -> Result<RecordedPreviewedTransaction, TransactionStoreError> {
399 let key = self
400 .audit_key
401 .as_ref()
402 .ok_or(TransactionStoreError::AuditChainMissing(
403 "this TransactionStore was opened read-only; cannot record",
404 ))?;
405 let mut conn = self.connection()?;
406 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
411 let transaction_id = Uuid::new_v4().to_string();
412 let inserted = Self::insert_transaction(&tx, key, &transaction_id, transaction)?;
413 Self::insert_preview(&tx, &inserted.record.transaction_id, &preview)?;
414 tx.commit()?;
415 emit_chain_tip_watermark(inserted.seq, &inserted.chain_hash);
416
417 Ok(RecordedPreviewedTransaction {
418 transaction: inserted.record,
419 preview,
420 })
421 }
422
423 pub fn get(
424 &self,
425 transaction_id: &str,
426 ) -> Result<Option<TransactionRecord>, TransactionStoreError> {
427 let conn = self.connection()?;
428 let mut stmt = conn.prepare(
429 "SELECT
430 transaction_id,
431 request_id,
432 request_hash,
433 action_name,
434 risk_level,
435 status,
436 approval_id,
437 summary,
438 warnings_json
439 FROM transactions
440 WHERE transaction_id = ?1",
441 )?;
442 let mut rows = stmt.query(params![transaction_id])?;
443 if let Some(row) = rows.next()? {
444 Ok(Some(row_to_record(row)?))
445 } else {
446 Ok(None)
447 }
448 }
449
450 pub fn get_preview(
451 &self,
452 transaction_id: &str,
453 ) -> Result<Option<PreviewEnvelope>, TransactionStoreError> {
454 let conn = self.connection()?;
455 let mut stmt = conn.prepare(
456 "SELECT preview_json
457 FROM transaction_previews
458 WHERE transaction_id = ?1",
459 )?;
460 let mut rows = stmt.query(params![transaction_id])?;
461 if let Some(row) = rows.next()? {
462 let preview_json: String = row.get(0)?;
463 Ok(Some(serde_json::from_str(&preview_json)?))
464 } else {
465 Ok(None)
466 }
467 }
468
469 pub fn update_status(
470 &self,
471 transaction_id: &str,
472 new_status: JobState,
473 ) -> Result<(), TransactionStoreError> {
474 let conn = self.connection()?;
475
476 let current_status: String = conn
478 .query_row(
479 "SELECT status FROM transactions WHERE transaction_id = ?1",
480 params![transaction_id],
481 |row| row.get(0),
482 )
483 .map_err(|e| match e {
484 rusqlite::Error::QueryReturnedNoRows => {
485 TransactionStoreError::NotFound(transaction_id.to_string())
486 }
487 other => TransactionStoreError::Sqlite(other),
488 })?;
489
490 let current: JobState = deserialize_field(¤t_status)?;
491 if !crate::jobs::allowed_transition(¤t, &new_status) {
492 return Err(TransactionStoreError::InvalidTransition {
493 from: current,
494 to: new_status,
495 });
496 }
497
498 let rows_affected = conn.execute(
510 "UPDATE transactions SET status = ?1 WHERE transaction_id = ?2 AND status = ?3",
511 params![
512 serialize_field(&new_status)?,
513 transaction_id,
514 current_status
515 ],
516 )?;
517 if rows_affected == 0 {
518 return Err(TransactionStoreError::ConcurrentStatusChange(
519 transaction_id.to_string(),
520 ));
521 }
522 Ok(())
523 }
524
525 pub fn approve_transaction(
527 &self,
528 transaction_id: &str,
529 ) -> Result<Option<String>, TransactionStoreError> {
530 let key = self
531 .audit_key
532 .as_ref()
533 .ok_or(TransactionStoreError::AuditChainMissing(
534 "this TransactionStore was opened read-only; cannot approve",
535 ))?;
536 let Some(record) = self.get(transaction_id)? else {
537 return Ok(None);
538 };
539 let receipt = key.approval_receipt(transaction_id, &record.request_hash);
540 let receipt_digest = audit_chain::approval_receipt_digest(&receipt);
541 let Some(committed_digest) = record.approval_id.as_deref() else {
542 return Err(TransactionStoreError::DatabaseInvariant(format!(
543 "transaction {transaction_id} has no signed approval commitment"
544 )));
545 };
546 if !bool::from(receipt_digest.as_bytes().ct_eq(committed_digest.as_bytes())) {
547 return Err(TransactionStoreError::DatabaseInvariant(format!(
548 "transaction {transaction_id} approval commitment does not match its signed preview"
549 )));
550 }
551
552 let mut conn = self.connection()?;
555 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
556 let queued_json = serialize_field(&JobState::Queued)?;
557 let rows_affected = tx.execute(
558 &format!(
559 "INSERT INTO transaction_approvals (transaction_id, receipt_digest) \
560 SELECT transaction_id, ?1 FROM transactions \
561 WHERE transaction_id = ?2 \
562 AND status = ?3 \
563 AND julianday(created_at) > julianday('now', '-{APPROVAL_RECEIPT_TTL_MINUTES} minutes') \
564 AND NOT EXISTS ( \
565 SELECT 1 FROM transaction_approvals WHERE transaction_id = ?2 \
566 )"
567 ),
568 params![receipt_digest, transaction_id, queued_json],
569 )?;
570 if rows_affected > 0 {
571 Self::append_event(
572 &tx,
573 key,
574 AuditEventKind::ApprovalGranted,
575 transaction_id,
576 &receipt_digest,
577 )?;
578 }
579 tx.commit()?;
580 Ok((rows_affected > 0).then_some(receipt))
581 }
582
583 pub fn revoke_unconsumed_approval(
586 &self,
587 transaction_id: &str,
588 ) -> Result<bool, TransactionStoreError> {
589 let key = self
590 .audit_key
591 .as_ref()
592 .ok_or(TransactionStoreError::AuditChainMissing(
593 "this TransactionStore was opened read-only; cannot revoke",
594 ))?;
595 let mut conn = self.connection()?;
596 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
597 let digest: Option<String> = tx
600 .query_row(
601 "SELECT receipt_digest FROM transaction_approvals \
602 WHERE transaction_id = ?1 AND consumed_at IS NULL",
603 params![transaction_id],
604 |row| row.get(0),
605 )
606 .map(Some)
607 .or_else(|e| match e {
608 rusqlite::Error::QueryReturnedNoRows => Ok(None),
609 other => Err(other),
610 })?;
611 let rows_affected = tx.execute(
612 "DELETE FROM transaction_approvals \
613 WHERE transaction_id = ?1 AND consumed_at IS NULL",
614 params![transaction_id],
615 )?;
616 if rows_affected > 0 {
617 let digest = digest.ok_or_else(|| {
618 TransactionStoreError::DatabaseInvariant(format!(
619 "revoked an approval for {transaction_id} that had no receipt digest"
620 ))
621 })?;
622 Self::append_event(
623 &tx,
624 key,
625 AuditEventKind::ApprovalRevoked,
626 transaction_id,
627 &digest,
628 )?;
629 }
630 tx.commit()?;
631 Ok(rows_affected > 0)
632 }
633
634 pub fn claim_approved_for_execution(
636 &self,
637 transaction_id: &str,
638 receipt_digest: &str,
639 ) -> Result<bool, TransactionStoreError> {
640 let key = self
641 .audit_key
642 .as_ref()
643 .ok_or(TransactionStoreError::AuditChainMissing(
644 "this TransactionStore was opened read-only; cannot claim",
645 ))?;
646 let mut conn = self.connection()?;
647 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
648 let queued_json = serialize_field(&JobState::Queued)?;
649 let running_json = serialize_field(&JobState::Running)?;
650 let rows_affected = tx.execute(
651 &format!(
652 "UPDATE transactions SET status = ?1 \
653 WHERE transaction_id = ?2 \
654 AND status = ?3 \
655 AND julianday(created_at) > julianday('now', '-{APPROVAL_RECEIPT_TTL_MINUTES} minutes') \
656 AND EXISTS ( \
657 SELECT 1 FROM transaction_approvals \
658 WHERE transaction_id = ?2 \
659 AND receipt_digest = ?4 \
660 AND consumed_at IS NULL \
661 )"
662 ),
663 params![running_json, transaction_id, queued_json, receipt_digest],
664 )?;
665 if rows_affected > 0 {
666 tx.execute(
667 "UPDATE transaction_approvals \
668 SET consumed_at = datetime('now') \
669 WHERE transaction_id = ?1 AND consumed_at IS NULL",
670 params![transaction_id],
671 )?;
672 Self::append_event(
673 &tx,
674 key,
675 AuditEventKind::ApprovalConsumed,
676 transaction_id,
677 receipt_digest,
678 )?;
679 }
680 tx.commit()?;
681 Ok(rows_affected > 0)
682 }
683
684 pub fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError> {
699 let conn = self.connection()?;
700 let canceled_json = serialize_field(&JobState::Canceled)?;
701 let queued_json = serialize_field(&JobState::Queued)?;
702 let rows_affected = conn.execute(
703 &format!(
704 "UPDATE transactions SET status = ?1 \
705 WHERE status = ?2 \
706 AND julianday(created_at) <= julianday('now', '-{APPROVAL_RECEIPT_TTL_MINUTES} minutes')"
707 ),
708 params![canceled_json, queued_json],
709 )?;
710 Ok(rows_affected as u64)
711 }
712
713 pub fn cancel_queued(&self, transaction_id: &str) -> Result<bool, TransactionStoreError> {
722 let conn = self.connection()?;
723 let canceled_json = serialize_field(&JobState::Canceled)?;
724 let queued_json = serialize_field(&JobState::Queued)?;
725 let rows_affected = conn.execute(
726 "UPDATE transactions SET status = ?1 \
727 WHERE transaction_id = ?2 AND status = ?3",
728 params![canceled_json, transaction_id, queued_json],
729 )?;
730 Ok(rows_affected > 0)
731 }
732
733 pub fn list_transactions(
742 &self,
743 limit: u32,
744 status_filter: Option<&str>,
745 action_filter: Option<&str>,
746 since_hours: Option<u32>,
747 ) -> Result<Vec<TransactionRecord>, TransactionStoreError> {
748 let conn = self.connection()?;
749 let (filter_sql, param_values) =
750 Self::build_history_filter(limit, status_filter, action_filter, since_hours)?;
751 let sql = format!(
752 "SELECT transaction_id, request_id, request_hash, action_name, \
753 risk_level, status, approval_id, summary, warnings_json \
754 FROM transactions WHERE 1=1{filter_sql}"
755 );
756 let params_ref: Vec<&dyn rusqlite::types::ToSql> =
757 param_values.iter().map(|b| b.as_ref()).collect();
758
759 let mut stmt = conn.prepare(&sql)?;
760 let rows = stmt.query_map(params_ref.as_slice(), |row| Ok(row_to_record(row)))?;
761
762 let mut results = Vec::new();
763 for row in rows {
764 results.push(row??);
765 }
766 Ok(results)
767 }
768
769 pub fn list_history(
774 &self,
775 limit: u32,
776 status_filter: Option<&str>,
777 action_filter: Option<&str>,
778 since_hours: Option<u32>,
779 ) -> Result<Vec<JobHistoryEntry>, TransactionStoreError> {
780 let conn = self.connection()?;
781 let (filter_sql, param_values) =
782 Self::build_history_filter(limit, status_filter, action_filter, since_hours)?;
783 let sql = format!(
784 "SELECT transaction_id, action_name, risk_level, status, summary, created_at \
785 FROM transactions WHERE 1=1{filter_sql}"
786 );
787 let params_ref: Vec<&dyn rusqlite::types::ToSql> =
788 param_values.iter().map(|b| b.as_ref()).collect();
789
790 let mut stmt = conn.prepare(&sql)?;
791 let rows = stmt.query_map(params_ref.as_slice(), |row| {
792 Ok((|| {
793 Ok::<JobHistoryEntry, TransactionStoreError>(JobHistoryEntry {
794 transaction_id: row.get(0)?,
795 action_name: row.get(1)?,
796 risk_level: deserialize_field(&row.get::<_, String>(2)?)?,
797 status: deserialize_field(&row.get::<_, String>(3)?)?,
798 summary: row.get(4)?,
799 created_at: row.get(5)?,
800 })
801 })())
802 })?;
803
804 let mut results = Vec::new();
805 for row in rows {
806 results.push(row??);
807 }
808 Ok(results)
809 }
810
811 fn build_history_filter(
816 limit: u32,
817 status_filter: Option<&str>,
818 action_filter: Option<&str>,
819 since_hours: Option<u32>,
820 ) -> Result<(String, Vec<Box<dyn rusqlite::types::ToSql>>), TransactionStoreError> {
821 let mut sql = String::new();
822 let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
823
824 if let Some(status) = status_filter {
825 let validated: JobState = deserialize_field(&format!("\"{status}\""))?;
829 let status_json = serialize_field(&validated)?;
830 sql.push_str(" AND status = ?");
831 param_values.push(Box::new(status_json));
832 }
833
834 if let Some(action) = action_filter {
835 sql.push_str(" AND action_name = ?");
836 param_values.push(Box::new(action.to_string()));
837 }
838
839 if let Some(hours) = since_hours {
840 sql.push_str(" AND julianday(created_at) > julianday('now', '-' || ? || ' hours')");
841 param_values.push(Box::new(hours));
842 }
843
844 sql.push_str(" ORDER BY seq DESC LIMIT ?");
845 param_values.push(Box::new(limit.min(100)));
846
847 Ok((sql, param_values))
848 }
849
850 fn connection(&self) -> Result<Connection, TransactionStoreError> {
851 let conn = Connection::open(&self.path)?;
852 conn.pragma_update(None, "journal_mode", "WAL")?;
863 conn.pragma_update(None, "synchronous", "NORMAL")?;
864 conn.pragma_update(None, "foreign_keys", "ON")?;
865 conn.busy_timeout(std::time::Duration::from_secs(5))?;
866 Ok(conn)
867 }
868
869 fn initialize(&self) -> Result<(), TransactionStoreError> {
870 let mut conn = self.connection()?;
871 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
872 tx.execute_batch(
873 "CREATE TABLE IF NOT EXISTS schema_migrations (\
874 version INTEGER PRIMARY KEY,\
875 name TEXT NOT NULL,\
876 applied_at TEXT NOT NULL DEFAULT (datetime('now'))\
877 );",
878 )?;
879 let current: i64 = tx.query_row(
880 "SELECT COALESCE(MAX(version), 0) FROM schema_migrations",
881 [],
882 |row| row.get(0),
883 )?;
884 let latest = SQLITE_MIGRATIONS.last().map_or(0, |m| m.version);
885 if current > latest {
886 return Err(TransactionStoreError::DatabaseInvariant(format!(
887 "sqlite schema version {current} is newer than this binary supports ({latest})"
888 )));
889 }
890 for migration in SQLITE_MIGRATIONS.iter().filter(|m| m.version > current) {
891 tx.execute_batch(migration.sql)?;
892 tx.execute(
893 "INSERT INTO schema_migrations (version, name) VALUES (?1, ?2)",
894 params![migration.version, migration.name],
895 )?;
896 }
897 tx.commit()?;
898 Ok(())
899 }
900
901 pub fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
903 let conn = self.connection()?;
904 let mut stmt = conn.prepare(&format!(
905 "SELECT {CHAIN_ROW_COLUMNS} FROM transactions ORDER BY seq ASC"
906 ))?;
907 let rows = stmt.query_map([], chain_row_from_sqlite)?;
908 let mut out = Vec::new();
909 for row in rows {
910 out.push(row?);
911 }
912 Ok(out)
913 }
914
915 pub fn fetch_event_rows(&self) -> Result<Vec<EventRow>, TransactionStoreError> {
917 let conn = self.connection()?;
918 let mut stmt = conn.prepare(
919 "SELECT seq, key_id, kind, transaction_id, receipt_digest, \
920 created_at, prev_chain_hash, chain_hash \
921 FROM audit_events ORDER BY seq ASC",
922 )?;
923 let rows = stmt.query_map([], event_row_from_sqlite)?;
924 let mut out = Vec::new();
925 for row in rows {
926 out.push(row?);
927 }
928 Ok(out)
929 }
930
931 pub fn verify_audit_chain(
933 &self,
934 key: &AuditKey,
935 ) -> Result<VerifyOutcome, TransactionStoreError> {
936 let rows = self.fetch_chain_rows()?;
937 Ok(audit_chain::verify_chain(key, &rows))
938 }
939
940 pub fn verify_event_chain(
942 &self,
943 key: &AuditKey,
944 ) -> Result<VerifyOutcome, TransactionStoreError> {
945 let rows = self.fetch_event_rows()?;
946 Ok(audit_chain::verify_event_chain(key, &rows))
947 }
948
949 pub fn verify_event_binding(
952 &self,
953 ) -> Result<audit_chain::BindingOutcome, TransactionStoreError> {
954 let tx_rows = self.fetch_chain_rows()?;
955 let event_rows = self.fetch_event_rows()?;
956 Ok(audit_chain::verify_event_binding(&tx_rows, &event_rows))
957 }
958
959 pub fn verify_audit_chain_with_pubkey(
962 &self,
963 verifying_key_hex: &str,
964 ) -> Result<VerifyOutcome, TransactionStoreError> {
965 let rows = self.fetch_chain_rows()?;
966 Ok(audit_chain::verify_chain_with_pubkey(
967 verifying_key_hex,
968 &rows,
969 ))
970 }
971
972 pub fn fetch_chain_row(
975 &self,
976 transaction_id: &str,
977 ) -> Result<Option<ChainRow>, TransactionStoreError> {
978 let conn = self.connection()?;
979 let mut stmt = conn.prepare(&format!(
980 "SELECT {CHAIN_ROW_COLUMNS} FROM transactions WHERE transaction_id = ?1"
981 ))?;
982 let mut rows = stmt.query(params![transaction_id])?;
983 if let Some(row) = rows.next()? {
984 Ok(Some(chain_row_from_sqlite(row)?))
985 } else {
986 Ok(None)
987 }
988 }
989
990 fn next_seq_and_prev_hash(conn: &Connection) -> Result<(u64, String), TransactionStoreError> {
994 let prev: Option<(i64, String)> = conn
995 .query_row(
996 "SELECT seq, chain_hash FROM transactions ORDER BY seq DESC LIMIT 1",
997 [],
998 |row| Ok((row.get(0)?, row.get(1)?)),
999 )
1000 .map(Some)
1001 .or_else(|e| match e {
1002 rusqlite::Error::QueryReturnedNoRows => Ok(None),
1003 other => Err(other),
1004 })?;
1005 Ok(match prev {
1006 Some((seq, hash)) => ((seq as u64) + 1, hash),
1007 None => (1, String::new()),
1008 })
1009 }
1010
1011 fn event_chain_tip(conn: &Connection) -> Result<Option<String>, TransactionStoreError> {
1014 conn.query_row(
1015 "SELECT chain_hash FROM audit_events ORDER BY seq DESC LIMIT 1",
1016 [],
1017 |row| row.get(0),
1018 )
1019 .map(Some)
1020 .or_else(|e| match e {
1021 rusqlite::Error::QueryReturnedNoRows => Ok(None),
1022 other => Err(other.into()),
1023 })
1024 }
1025
1026 fn append_event(
1031 conn: &Connection,
1032 key: &AuditKey,
1033 kind: AuditEventKind,
1034 transaction_id: &str,
1035 receipt_digest: &str,
1036 ) -> Result<(), TransactionStoreError> {
1037 let prev_chain_hash = Self::event_chain_tip(conn)?.unwrap_or_default();
1038 let seq: i64 = conn.query_row(
1039 "SELECT COALESCE(MAX(seq), 0) + 1 FROM audit_events",
1040 [],
1041 |row| row.get(0),
1042 )?;
1043 let created_at: String =
1044 conn.query_row("SELECT strftime('%Y-%m-%dT%H:%M:%fZ', 'now')", [], |row| {
1045 row.get(0)
1046 })?;
1047 let key_id = CURRENT_KEY_ID.to_string();
1048 let chain_hash = key.event_hash(
1049 &EventContent {
1050 seq: seq as u64,
1051 key_id: &key_id,
1052 kind,
1053 transaction_id,
1054 receipt_digest,
1055 created_at: &created_at,
1056 },
1057 &prev_chain_hash,
1058 );
1059 conn.execute(
1060 "INSERT INTO audit_events (
1061 seq, key_id, kind, transaction_id, receipt_digest,
1062 created_at, chain_hash, prev_chain_hash
1063 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1064 params![
1065 seq,
1066 key_id,
1067 kind.as_str(),
1068 transaction_id,
1069 receipt_digest,
1070 created_at,
1071 chain_hash,
1072 prev_chain_hash,
1073 ],
1074 )?;
1075 Ok(())
1076 }
1077
1078 fn insert_transaction(
1079 conn: &Connection,
1080 key: &AuditKey,
1081 transaction_id: &str,
1082 transaction: NewTransaction,
1083 ) -> Result<InsertedTransaction, TransactionStoreError> {
1084 let request_id = transaction.request_id;
1085 let request_hash = transaction.request_hash;
1086 let action_name = transaction.action_name;
1087 let risk_level = transaction.risk_level;
1088 let status = JobState::Queued;
1090 let approval_id = Some(key.approval_commitment(transaction_id, &request_hash));
1097 let summary = transaction.summary;
1098 let warnings = transaction.warnings;
1099 let warnings_json = serde_json::to_string(&warnings)?;
1100
1101 let (seq, prev_chain_hash) = Self::next_seq_and_prev_hash(conn)?;
1104
1105 let created_at: String =
1109 conn.query_row("SELECT strftime('%Y-%m-%dT%H:%M:%fZ', 'now')", [], |row| {
1110 row.get(0)
1111 })?;
1112
1113 let key_id = CURRENT_KEY_ID.to_string();
1114 let event_tip = Self::event_chain_tip(conn)?.unwrap_or_default();
1118 let caller_role = transaction.caller_role.as_str();
1119 let caller_principal = transaction.caller_principal.as_signed_str();
1120 let identity = ChainIdentity::V3 {
1124 caller_role,
1125 event_tip: &event_tip,
1126 caller_principal: &caller_principal,
1127 };
1128 let chain_hash = key.chain_hash(
1129 &ChainContent {
1130 seq,
1131 key_id: &key_id,
1132 transaction_id,
1133 request_id: &request_id,
1134 request_hash: &request_hash,
1135 action_name: &action_name,
1136 risk_level,
1137 summary: &summary,
1138 approval_id: approval_id.as_deref(),
1139 warnings_json: &warnings_json,
1140 created_at: &created_at,
1141 identity,
1142 },
1143 &prev_chain_hash,
1144 );
1145
1146 let record = TransactionRecord {
1147 transaction_id: transaction_id.to_string(),
1148 request_id: request_id.clone(),
1149 request_hash: request_hash.clone(),
1150 action_name: action_name.clone(),
1151 risk_level,
1152 status,
1153 approval_id: approval_id.clone(),
1154 summary: summary.clone(),
1155 warnings: warnings.clone(),
1156 };
1157
1158 conn.execute(
1159 "INSERT INTO transactions (
1160 transaction_id,
1161 request_id,
1162 request_hash,
1163 action_name,
1164 risk_level,
1165 status,
1166 approval_id,
1167 summary,
1168 warnings_json,
1169 created_at,
1170 seq,
1171 key_id,
1172 chain_hash,
1173 prev_chain_hash,
1174 chain_version,
1175 caller_role,
1176 event_tip,
1177 caller_principal
1178 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
1179 params![
1180 transaction_id,
1181 request_id,
1182 request_hash,
1183 action_name,
1184 serialize_field(&risk_level)?,
1185 serialize_field(&status)?,
1186 approval_id,
1187 summary,
1188 warnings_json,
1189 created_at,
1190 seq as i64,
1191 key_id,
1192 chain_hash,
1193 prev_chain_hash,
1194 identity.version() as i64,
1195 caller_role,
1196 event_tip,
1197 caller_principal,
1198 ],
1199 )?;
1200
1201 Ok(InsertedTransaction {
1202 record,
1203 seq,
1204 chain_hash,
1205 })
1206 }
1207
1208 fn insert_preview(
1209 conn: &Connection,
1210 transaction_id: &str,
1211 preview: &PreviewEnvelope,
1212 ) -> Result<(), TransactionStoreError> {
1213 conn.execute(
1214 "INSERT INTO transaction_previews (transaction_id, preview_json)
1215 VALUES (?1, ?2)",
1216 params![transaction_id, serde_json::to_string(preview)?],
1217 )?;
1218 Ok(())
1219 }
1220}
1221
1222fn ensure_private_dir(dir: &Path) -> std::io::Result<()> {
1229 use std::os::unix::fs::DirBuilderExt;
1230 if dir.exists() {
1231 return Ok(());
1232 }
1233 std::fs::DirBuilder::new()
1234 .recursive(true)
1235 .mode(0o700)
1236 .create(dir)
1237}
1238
1239fn row_to_record(row: &rusqlite::Row) -> Result<TransactionRecord, TransactionStoreError> {
1240 Ok(TransactionRecord {
1241 transaction_id: row.get(0)?,
1242 request_id: row.get(1)?,
1243 request_hash: row.get(2)?,
1244 action_name: row.get(3)?,
1245 risk_level: deserialize_field(&row.get::<_, String>(4)?)?,
1246 status: deserialize_field(&row.get::<_, String>(5)?)?,
1247 approval_id: row.get(6)?,
1248 summary: row.get(7)?,
1249 warnings: serde_json::from_str(&row.get::<_, String>(8)?)?,
1250 })
1251}
1252
1253fn serialize_field<T: Serialize>(value: &T) -> Result<String, serde_json::Error> {
1254 serde_json::to_string(value)
1255}
1256
1257fn deserialize_field<T: DeserializeOwned>(value: &str) -> Result<T, serde_json::Error> {
1258 serde_json::from_str(value)
1259}
1260
1261#[cfg(test)]
1262mod tests {
1263 use super::*;
1264 use crate::audit_chain::CHAIN_VERSION_CURRENT;
1265 use crate::audit_chain::CHAIN_VERSION_LEGACY;
1266 use std::os::unix::fs::PermissionsExt;
1267 use tempfile::tempdir;
1268
1269 fn test_store(path: impl AsRef<Path>) -> TransactionStore {
1273 let key = Arc::new(AuditKey::from_bytes(vec![0x42; 32]));
1274 TransactionStore::open_with_key(path, key).unwrap()
1275 }
1276
1277 fn legacy_v1_database(path: &Path, key: &AuditKey) -> String {
1284 let conn = Connection::open(path).unwrap();
1285 conn.execute_batch(
1286 "CREATE TABLE schema_migrations (
1287 version INTEGER PRIMARY KEY,
1288 name TEXT NOT NULL,
1289 applied_at TEXT NOT NULL DEFAULT (datetime('now'))
1290 );",
1291 )
1292 .unwrap();
1293 conn.execute_batch(SQLITE_MIGRATIONS[0].sql).unwrap();
1294 conn.execute(
1295 "INSERT INTO schema_migrations (version, name) VALUES (1, ?1)",
1296 params![SQLITE_MIGRATIONS[0].name],
1297 )
1298 .unwrap();
1299
1300 let transaction_id = "tx-legacy";
1301 let created_at = "2026-04-24T12:00:00.000Z";
1302 let chain_hash = key.chain_hash(
1303 &ChainContent {
1304 seq: 1,
1305 key_id: CURRENT_KEY_ID,
1306 transaction_id,
1307 request_id: "req-legacy",
1308 request_hash: "hash-legacy",
1309 action_name: "UpdateSystem",
1310 risk_level: RiskLevel::High,
1311 summary: "Upgrade the system",
1312 approval_id: None,
1313 warnings_json: "[]",
1314 created_at,
1315 identity: ChainIdentity::LegacyV1,
1316 },
1317 "",
1318 );
1319 conn.execute(
1320 "INSERT INTO transactions (
1321 transaction_id, request_id, request_hash, action_name, risk_level,
1322 status, approval_id, summary, warnings_json, created_at,
1323 seq, key_id, chain_hash, prev_chain_hash
1324 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?8, ?9, 1, ?10, ?11, '')",
1325 params![
1326 transaction_id,
1327 "req-legacy",
1328 "hash-legacy",
1329 "UpdateSystem",
1330 serialize_field(&RiskLevel::High).unwrap(),
1331 serialize_field(&JobState::Queued).unwrap(),
1332 "Upgrade the system",
1333 "[]",
1334 created_at,
1335 CURRENT_KEY_ID,
1336 chain_hash,
1337 ],
1338 )
1339 .unwrap();
1340 transaction_id.to_string()
1341 }
1342
1343 fn v2_database(path: &Path, key: &AuditKey) -> String {
1350 let conn = Connection::open(path).unwrap();
1351 conn.execute_batch(
1352 "CREATE TABLE schema_migrations (
1353 version INTEGER PRIMARY KEY,
1354 name TEXT NOT NULL,
1355 applied_at TEXT NOT NULL DEFAULT (datetime('now'))
1356 );",
1357 )
1358 .unwrap();
1359 for migration in &SQLITE_MIGRATIONS[0..=1] {
1360 conn.execute_batch(migration.sql).unwrap();
1361 conn.execute(
1362 "INSERT INTO schema_migrations (version, name) VALUES (?1, ?2)",
1363 params![migration.version, migration.name],
1364 )
1365 .unwrap();
1366 }
1367
1368 let transaction_id = "tx-v2";
1369 let created_at = "2026-07-01T12:00:00.000Z";
1370 let chain_hash = key.chain_hash(
1371 &ChainContent {
1372 seq: 1,
1373 key_id: CURRENT_KEY_ID,
1374 transaction_id,
1375 request_id: "req-v2",
1376 request_hash: "hash-v2",
1377 action_name: "UpdateSystem",
1378 risk_level: RiskLevel::High,
1379 summary: "Upgrade the system",
1380 approval_id: None,
1381 warnings_json: "[]",
1382 created_at,
1383 identity: ChainIdentity::V2 {
1384 caller_role: "admin",
1385 event_tip: "",
1386 },
1387 },
1388 "",
1389 );
1390 conn.execute(
1391 "INSERT INTO transactions (
1392 transaction_id, request_id, request_hash, action_name, risk_level,
1393 status, approval_id, summary, warnings_json, created_at,
1394 seq, key_id, chain_hash, prev_chain_hash,
1395 chain_version, caller_role, event_tip
1396 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?8, ?9, 1, ?10, ?11, '', 2, 'admin', '')",
1397 params![
1398 transaction_id,
1399 "req-v2",
1400 "hash-v2",
1401 "UpdateSystem",
1402 serialize_field(&RiskLevel::High).unwrap(),
1403 serialize_field(&JobState::Queued).unwrap(),
1404 "Upgrade the system",
1405 "[]",
1406 created_at,
1407 CURRENT_KEY_ID,
1408 chain_hash,
1409 ],
1410 )
1411 .unwrap();
1412 transaction_id.to_string()
1413 }
1414
1415 #[test]
1423 fn a_v2_database_keeps_verifying_and_accepts_v3_rows() {
1424 let dir = tempdir().unwrap();
1425 let db_path = dir.path().join("tx.db");
1426 let key = AuditKey::from_bytes(vec![0x42; 32]);
1427 let old_transaction = v2_database(&db_path, &key);
1428
1429 let store = test_store(&db_path);
1430
1431 let version: i64 = store
1432 .connection()
1433 .unwrap()
1434 .query_row("SELECT MAX(version) FROM schema_migrations", [], |row| {
1435 row.get(0)
1436 })
1437 .unwrap();
1438 assert_eq!(version, 3, "opening a v2 database must apply migration 3");
1439
1440 let rows = store.fetch_chain_rows().unwrap();
1441 assert_eq!(rows.len(), 1);
1442 assert_eq!(rows[0].transaction_id, old_transaction);
1443 assert_eq!(rows[0].chain_version, audit_chain::CHAIN_VERSION_V2);
1444 assert_eq!(
1445 rows[0].caller_principal, None,
1446 "migration 3 must not invent a principal for a row signed without one"
1447 );
1448 assert!(matches!(
1449 store.verify_audit_chain(&key).unwrap(),
1450 audit_chain::VerifyOutcome::Intact { rows_checked: 1 }
1451 ));
1452
1453 let mut fresh = queued_transaction();
1455 fresh.caller_principal = CallerPrincipal::Uid(4242);
1456 store.record(fresh).unwrap();
1457
1458 let rows = store.fetch_chain_rows().unwrap();
1459 assert_eq!(rows.len(), 2);
1460 assert_eq!(rows[1].chain_version, audit_chain::CHAIN_VERSION_CURRENT);
1461 assert_eq!(rows[1].caller_principal.as_deref(), Some("uid:4242"));
1462 assert_eq!(
1463 rows[1].prev_chain_hash, rows[0].chain_hash,
1464 "the v3 row must chain onto the v2 row, not start a new chain"
1465 );
1466 assert!(matches!(
1467 store.verify_audit_chain(&key).unwrap(),
1468 audit_chain::VerifyOutcome::Intact { rows_checked: 2 }
1469 ));
1470 }
1471
1472 #[test]
1473 fn a_database_written_before_the_migration_still_verifies_after_upgrading() {
1474 let dir = tempdir().unwrap();
1479 let db_path = dir.path().join("tx.db");
1480 let key = AuditKey::from_bytes(vec![0x42; 32]);
1481 legacy_v1_database(&db_path, &key);
1482
1483 let store = test_store(&db_path);
1484 assert_eq!(
1485 store.verify_audit_chain(&key).unwrap(),
1486 VerifyOutcome::Intact { rows_checked: 1 }
1487 );
1488 let rows = store.fetch_chain_rows().unwrap();
1489 assert_eq!(rows[0].chain_version, CHAIN_VERSION_LEGACY);
1490 assert_eq!(rows[0].caller_role, None);
1491 }
1492
1493 #[test]
1494 fn rows_appended_after_the_migration_chain_onto_legacy_rows() {
1495 let dir = tempdir().unwrap();
1499 let db_path = dir.path().join("tx.db");
1500 let key = AuditKey::from_bytes(vec![0x42; 32]);
1501 legacy_v1_database(&db_path, &key);
1502
1503 let store = test_store(&db_path);
1504 store.record(queued_transaction()).unwrap();
1505
1506 let rows = store.fetch_chain_rows().unwrap();
1507 assert_eq!(rows.len(), 2);
1508 assert_eq!(rows[1].prev_chain_hash, rows[0].chain_hash);
1509 assert_eq!(rows[1].chain_version, CHAIN_VERSION_CURRENT);
1510 assert_eq!(
1511 store.verify_audit_chain(&key).unwrap(),
1512 VerifyOutcome::Intact { rows_checked: 2 }
1513 );
1514 }
1515
1516 #[test]
1517 fn a_schema_newer_than_this_binary_is_refused() {
1518 let dir = tempdir().unwrap();
1519 let db_path = dir.path().join("tx.db");
1520 let key = AuditKey::from_bytes(vec![0x42; 32]);
1521 legacy_v1_database(&db_path, &key);
1522 let conn = Connection::open(&db_path).unwrap();
1523 conn.execute(
1524 "INSERT INTO schema_migrations (version, name) VALUES (99, 'from the future')",
1525 [],
1526 )
1527 .unwrap();
1528 drop(conn);
1529
1530 let err = TransactionStore::open_with_key(&db_path, Arc::new(key)).unwrap_err();
1531 assert!(
1532 matches!(err, TransactionStoreError::DatabaseInvariant(ref m) if m.contains("99")),
1533 "expected a refusal naming the unsupported version, got {err:?}"
1534 );
1535 }
1536
1537 #[test]
1540 fn the_signed_row_records_which_role_asked() {
1541 let dir = tempdir().unwrap();
1542 let store = test_store(dir.path().join("tx.db"));
1543 let mut tx = queued_transaction();
1544 tx.caller_role = CallerRole::Admin;
1545 store.record(tx).unwrap();
1546
1547 let rows = store.fetch_chain_rows().unwrap();
1548 assert_eq!(rows[0].caller_role.as_deref(), Some("admin"));
1549 }
1550
1551 #[test]
1552 fn approving_consuming_and_revoking_each_append_a_chained_event() {
1553 let dir = tempdir().unwrap();
1554 let key = AuditKey::from_bytes(vec![0x42; 32]);
1555 let store = test_store(dir.path().join("tx.db"));
1556
1557 let a = store.record(queued_transaction()).unwrap();
1559 let receipt = store
1560 .approve_transaction(&a.transaction_id)
1561 .unwrap()
1562 .expect("queued transaction approves");
1563 let digest = audit_chain::approval_receipt_digest(&receipt);
1564 assert!(store
1565 .claim_approved_for_execution(&a.transaction_id, &digest)
1566 .unwrap());
1567
1568 let b = store.record(queued_transaction()).unwrap();
1570 store.approve_transaction(&b.transaction_id).unwrap();
1571 assert!(store.revoke_unconsumed_approval(&b.transaction_id).unwrap());
1572
1573 let events = store.fetch_event_rows().unwrap();
1574 let kinds: Vec<&str> = events.iter().map(|e| e.kind.as_str()).collect();
1575 assert_eq!(
1576 kinds,
1577 vec![
1578 "approval_granted",
1579 "approval_consumed",
1580 "approval_granted",
1581 "approval_revoked",
1582 ]
1583 );
1584 assert_eq!(
1585 store.verify_event_chain(&key).unwrap(),
1586 VerifyOutcome::Intact { rows_checked: 4 }
1587 );
1588 }
1589
1590 #[test]
1591 fn a_failed_approval_appends_no_event() {
1592 let dir = tempdir().unwrap();
1596 let store = test_store(dir.path().join("tx.db"));
1597 let tx = store.record(queued_transaction()).unwrap();
1598 store.approve_transaction(&tx.transaction_id).unwrap();
1599 store.approve_transaction(&tx.transaction_id).unwrap();
1600 assert_eq!(store.fetch_event_rows().unwrap().len(), 1);
1601
1602 assert!(!store.revoke_unconsumed_approval("no-such-tx").unwrap());
1604 assert_eq!(store.fetch_event_rows().unwrap().len(), 1);
1605 }
1606
1607 #[test]
1608 fn deleting_the_record_that_an_approval_happened_no_longer_goes_unnoticed() {
1609 let dir = tempdir().unwrap();
1615 let db_path = dir.path().join("tx.db");
1616 let key = AuditKey::from_bytes(vec![0x42; 32]);
1617 let store = test_store(&db_path);
1618
1619 let a = store.record(queued_transaction()).unwrap();
1620 store.approve_transaction(&a.transaction_id).unwrap();
1621 store.record(queued_transaction()).unwrap();
1624
1625 assert_eq!(
1626 store.verify_event_chain(&key).unwrap(),
1627 VerifyOutcome::Intact { rows_checked: 1 }
1628 );
1629 assert_eq!(
1630 store.verify_event_binding().unwrap(),
1631 audit_chain::BindingOutcome::Consistent {
1632 bindings_checked: 1
1633 }
1634 );
1635
1636 let conn = Connection::open(&db_path).unwrap();
1638 conn.execute("DELETE FROM transaction_approvals", [])
1639 .unwrap();
1640 conn.execute("DELETE FROM audit_events", []).unwrap();
1641 drop(conn);
1642
1643 assert_eq!(
1647 store.verify_event_chain(&key).unwrap(),
1648 VerifyOutcome::Intact { rows_checked: 0 }
1649 );
1650 match store.verify_event_binding().unwrap() {
1651 audit_chain::BindingOutcome::MissingEvent {
1652 transaction_seq, ..
1653 } => assert_eq!(transaction_seq, 2),
1654 other => panic!("expected MissingEvent, got {other:?}"),
1655 }
1656 }
1657
1658 #[test]
1659 fn a_read_only_store_cannot_revoke_or_claim() {
1660 let dir = tempdir().unwrap();
1663 let db_path = dir.path().join("tx.db");
1664 let store = test_store(&db_path);
1665 store.record(queued_transaction()).unwrap();
1666
1667 let read_only = TransactionStore::open_read_only(&db_path).unwrap();
1668 assert!(matches!(
1669 read_only.revoke_unconsumed_approval("tx"),
1670 Err(TransactionStoreError::AuditChainMissing(_))
1671 ));
1672 assert!(matches!(
1673 read_only.claim_approved_for_execution("tx", "digest"),
1674 Err(TransactionStoreError::AuditChainMissing(_))
1675 ));
1676 }
1677
1678 #[test]
1685 fn record_persists_the_caller_principal_under_the_current_encoding() {
1686 let dir = tempdir().unwrap();
1687 let store = test_store(dir.path().join("tx.db"));
1688 let mut tx = queued_transaction();
1689 tx.caller_principal = CallerPrincipal::Uid(4242);
1690 store.record(tx).unwrap();
1691
1692 let conn = store.connection().unwrap();
1693 let (version, role, principal): (i64, Option<String>, Option<String>) = conn
1694 .query_row(
1695 "SELECT chain_version, caller_role, caller_principal FROM transactions",
1696 [],
1697 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1698 )
1699 .unwrap();
1700
1701 assert_eq!(version, i64::from(audit_chain::CHAIN_VERSION_CURRENT));
1702 assert_eq!(role.as_deref(), Some("dev"));
1703 assert_eq!(
1704 principal.as_deref(),
1705 Some("uid:4242"),
1706 "the row must name the account the dispatcher resolved"
1707 );
1708
1709 let key = AuditKey::from_bytes(vec![0x42; 32]);
1711 assert!(matches!(
1712 store.verify_audit_chain(&key).unwrap(),
1713 audit_chain::VerifyOutcome::Intact { .. }
1714 ));
1715 }
1716
1717 #[test]
1718 fn record_writes_audit_chain_columns() {
1719 let dir = tempdir().unwrap();
1720 let store = test_store(dir.path().join("tx.db"));
1721 let _record = store.record(queued_transaction()).unwrap();
1722
1723 let conn = store.connection().unwrap();
1724 let (seq, key_id, chain_hash, prev): (i64, String, String, String) = conn
1725 .query_row(
1726 "SELECT seq, key_id, chain_hash, prev_chain_hash FROM transactions",
1727 [],
1728 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
1729 )
1730 .unwrap();
1731 assert_eq!(seq, 1, "first row gets seq=1");
1732 assert_eq!(key_id, audit_chain::CURRENT_KEY_ID);
1733 assert_eq!(prev, "", "first row has empty prev_chain_hash");
1734 assert_eq!(
1735 chain_hash.len(),
1736 audit_chain::HASH_HEX_LEN,
1737 "chain_hash is a hex-encoded Ed25519 signature"
1738 );
1739 }
1740
1741 #[test]
1742 fn sequential_records_produce_chained_hashes() {
1743 let dir = tempdir().unwrap();
1744 let store = test_store(dir.path().join("tx.db"));
1745 store.record(queued_transaction()).unwrap();
1746 store.record(queued_transaction()).unwrap();
1747 store.record(queued_transaction()).unwrap();
1748
1749 let rows = store.fetch_chain_rows().unwrap();
1750 assert_eq!(rows.len(), 3);
1751 assert_eq!(rows[0].seq, 1);
1752 assert_eq!(rows[1].seq, 2);
1753 assert_eq!(rows[2].seq, 3);
1754 assert_eq!(rows[1].prev_chain_hash, rows[0].chain_hash);
1756 assert_eq!(rows[2].prev_chain_hash, rows[1].chain_hash);
1757 }
1758
1759 #[test]
1771 fn concurrent_record_keeps_chain_intact_and_seqs_contiguous() {
1772 const WORKERS: usize = 8;
1773 const RECORDS_PER_WORKER: usize = 10;
1774 const TOTAL: usize = WORKERS * RECORDS_PER_WORKER;
1775
1776 let dir = tempdir().unwrap();
1777 let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
1778
1779 let mut handles = Vec::with_capacity(WORKERS);
1780 for w in 0..WORKERS {
1781 let store = std::sync::Arc::clone(&store);
1782 handles.push(std::thread::spawn(move || {
1783 for r in 0..RECORDS_PER_WORKER {
1784 let tx = NewTransaction {
1785 request_id: format!("worker-{w}-record-{r}"),
1786 request_hash: format!("hash-{w}-{r}"),
1787 action_name: "GetSystemState".to_string(),
1788 risk_level: RiskLevel::Low,
1789 summary: format!("worker {w} record {r}"),
1790 warnings: vec![],
1791 caller_role: CallerRole::Dev,
1792 caller_principal: CallerPrincipal::Uid(3000 + w as u32),
1796 };
1797 store
1798 .record(tx)
1799 .expect("record must succeed under contention");
1800 }
1801 }));
1802 }
1803 for h in handles {
1804 h.join().expect("worker thread did not panic");
1805 }
1806
1807 let key = AuditKey::from_bytes(vec![0x42; 32]);
1809 let outcome = store.verify_audit_chain(&key).unwrap();
1810 match outcome {
1811 VerifyOutcome::Intact { rows_checked } => {
1812 assert_eq!(
1813 rows_checked, TOTAL as u64,
1814 "expected {TOTAL} rows checked, got {rows_checked}"
1815 );
1816 }
1817 other => panic!("chain must be Intact under concurrent writes; got {other:?}"),
1818 }
1819
1820 {
1824 let conn = store.connection().unwrap();
1825 let mut stmt = conn
1826 .prepare("SELECT summary, caller_principal FROM transactions")
1827 .unwrap();
1828 let pairs: Vec<(String, Option<String>)> = stmt
1829 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
1830 .unwrap()
1831 .map(|r| r.unwrap())
1832 .collect();
1833 assert_eq!(pairs.len(), TOTAL);
1834 for (summary, principal) in pairs {
1835 let worker: u32 = summary
1836 .split_whitespace()
1837 .nth(1)
1838 .and_then(|w| w.parse().ok())
1839 .unwrap_or_else(|| panic!("unexpected summary {summary:?}"));
1840 assert_eq!(
1841 principal.as_deref(),
1842 Some(format!("uid:{}", 3000 + worker).as_str()),
1843 "row {summary:?} was attributed to the wrong account"
1844 );
1845 }
1846 }
1847
1848 let conn = store.connection().unwrap();
1850 let mut stmt = conn
1851 .prepare("SELECT seq FROM transactions ORDER BY seq ASC")
1852 .unwrap();
1853 let seqs: Vec<i64> = stmt
1854 .query_map([], |row| row.get::<_, i64>(0))
1855 .unwrap()
1856 .map(|r| r.unwrap())
1857 .collect();
1858 assert_eq!(seqs.len(), TOTAL, "row count mismatch");
1859 for (i, s) in seqs.iter().enumerate() {
1860 assert_eq!(
1861 *s,
1862 (i as i64) + 1,
1863 "seq column must be contiguous 1..={TOTAL}; saw {s} at position {i}"
1864 );
1865 }
1866 }
1867
1868 #[test]
1869 fn verify_audit_chain_intact_after_inserts() {
1870 let dir = tempdir().unwrap();
1871 let store = test_store(dir.path().join("tx.db"));
1872 for _ in 0..3 {
1873 store.record(queued_transaction()).unwrap();
1874 }
1875 let key = AuditKey::from_bytes(vec![0x42; 32]);
1876 let outcome = store.verify_audit_chain(&key).unwrap();
1877 assert!(matches!(outcome, VerifyOutcome::Intact { rows_checked: 3 }));
1878 }
1879
1880 #[test]
1881 fn verify_audit_chain_with_pubkey_intact_after_inserts() {
1882 let dir = tempdir().unwrap();
1883 let store = test_store(dir.path().join("tx.db"));
1884 for _ in 0..3 {
1885 store.record(queued_transaction()).unwrap();
1886 }
1887 let key = AuditKey::from_bytes(vec![0x42; 32]);
1889 let outcome = store
1890 .verify_audit_chain_with_pubkey(&key.verifying_key_hex())
1891 .unwrap();
1892 assert!(matches!(outcome, VerifyOutcome::Intact { rows_checked: 3 }));
1893 }
1894
1895 #[test]
1896 fn verify_audit_chain_with_wrong_pubkey_is_broken() {
1897 let dir = tempdir().unwrap();
1898 let store = test_store(dir.path().join("tx.db"));
1899 store.record(queued_transaction()).unwrap();
1900 let other = AuditKey::from_bytes(vec![0x99; 32]);
1902 let outcome = store
1903 .verify_audit_chain_with_pubkey(&other.verifying_key_hex())
1904 .unwrap();
1905 assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
1906 }
1907
1908 #[test]
1909 fn verify_detects_tampered_summary() {
1910 let dir = tempdir().unwrap();
1911 let store = test_store(dir.path().join("tx.db"));
1912 let tx = store.record(queued_transaction()).unwrap();
1913
1914 let conn = store.connection().unwrap();
1917 conn.execute(
1918 "UPDATE transactions SET summary = ?1 WHERE transaction_id = ?2",
1919 params!["EVIL CHANGE", tx.transaction_id],
1920 )
1921 .unwrap();
1922
1923 let key = AuditKey::from_bytes(vec![0x42; 32]);
1924 let outcome = store.verify_audit_chain(&key).unwrap();
1925 assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
1926 }
1927
1928 #[test]
1929 fn status_update_does_not_break_chain() {
1930 let dir = tempdir().unwrap();
1932 let store = test_store(dir.path().join("tx.db"));
1933 let tx = store.record(queued_transaction()).unwrap();
1934 store
1935 .update_status(&tx.transaction_id, JobState::Running)
1936 .unwrap();
1937 store
1938 .update_status(&tx.transaction_id, JobState::Succeeded)
1939 .unwrap();
1940
1941 let key = AuditKey::from_bytes(vec![0x42; 32]);
1942 let outcome = store.verify_audit_chain(&key).unwrap();
1943 assert!(
1944 matches!(outcome, VerifyOutcome::Intact { rows_checked: 1 }),
1945 "status mutation must not break the chain (status not in chain content): {outcome:?}"
1946 );
1947 }
1948
1949 #[test]
1950 fn open_read_only_rejects_record() {
1951 let dir = tempdir().unwrap();
1952 let key_path = dir.path().join("audit-key");
1953 std::fs::write(&key_path, vec![0x42; 32]).unwrap();
1954 std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
1955
1956 let store = TransactionStore::open_read_only(dir.path().join("tx.db")).unwrap();
1957 let result = store.record(queued_transaction());
1958 assert!(matches!(
1959 result,
1960 Err(TransactionStoreError::AuditChainMissing(_))
1961 ));
1962 }
1963
1964 #[test]
1965 fn ensure_private_dir_creates_with_0700_mode() {
1966 let root = tempdir().unwrap();
1967 let target = root.path().join("a/b/c");
1968 ensure_private_dir(&target).unwrap();
1969 assert!(target.is_dir());
1970 let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
1971 assert_eq!(mode, 0o700, "leaf dir must be 0o700, got {mode:o}");
1972 }
1973
1974 #[test]
1975 fn ensure_private_dir_is_idempotent_and_does_not_widen_existing_mode() {
1976 let root = tempdir().unwrap();
1977 let target = root.path().join("preexisting");
1978 std::fs::create_dir(&target).unwrap();
1979 std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
1980 ensure_private_dir(&target).unwrap();
1981 let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
1983 assert_eq!(mode, 0o755);
1984 }
1985
1986 #[test]
1987 fn open_creates_parent_with_private_mode() {
1988 let root = tempdir().unwrap();
1989 let db_path = root.path().join("nested/dirs/daemon.sqlite");
1990 let _store = test_store(&db_path);
1991 let parent = db_path.parent().unwrap();
1992 let mode = std::fs::metadata(parent).unwrap().permissions().mode() & 0o777;
1993 assert_eq!(mode, 0o700);
1994 }
1995
1996 fn queued_transaction() -> NewTransaction {
1997 NewTransaction {
1998 request_id: "req-1".to_string(),
1999 request_hash: "hash-abc".to_string(),
2000 action_name: "UpdateSystem".to_string(),
2001 risk_level: RiskLevel::High,
2002 summary: "Upgrade the system".to_string(),
2003 warnings: vec![],
2004 caller_role: CallerRole::Dev,
2005 caller_principal: CallerPrincipal::Uid(1000),
2006 }
2007 }
2008
2009 #[test]
2010 fn update_status_transitions_queued_to_running() {
2011 let dir = tempdir().unwrap();
2012 let store = test_store(dir.path().join("tx.db"));
2013 let tx = store.record(queued_transaction()).unwrap();
2014
2015 store
2016 .update_status(&tx.transaction_id, JobState::Running)
2017 .unwrap();
2018
2019 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2020 assert_eq!(updated.status, JobState::Running);
2021 }
2022
2023 #[test]
2024 fn update_status_transitions_running_to_succeeded() {
2025 let dir = tempdir().unwrap();
2026 let store = test_store(dir.path().join("tx.db"));
2027 let tx = store.record(queued_transaction()).unwrap();
2028
2029 store
2030 .update_status(&tx.transaction_id, JobState::Running)
2031 .unwrap();
2032 store
2033 .update_status(&tx.transaction_id, JobState::Succeeded)
2034 .unwrap();
2035
2036 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2037 assert_eq!(updated.status, JobState::Succeeded);
2038 }
2039
2040 #[test]
2041 fn update_status_for_unknown_id_returns_not_found() {
2042 let dir = tempdir().unwrap();
2043 let store = test_store(dir.path().join("tx.db"));
2044
2045 let result = store.update_status("does-not-exist", JobState::Running);
2046 assert!(
2047 matches!(result, Err(TransactionStoreError::NotFound(ref id)) if id == "does-not-exist"),
2048 "expected NotFound, got: {result:?}"
2049 );
2050 }
2051
2052 #[test]
2053 fn update_status_leaves_other_fields_intact() {
2054 let dir = tempdir().unwrap();
2055 let store = test_store(dir.path().join("tx.db"));
2056 let tx = store.record(queued_transaction()).unwrap();
2057
2058 store
2059 .update_status(&tx.transaction_id, JobState::Running)
2060 .unwrap();
2061 store
2062 .update_status(&tx.transaction_id, JobState::Failed)
2063 .unwrap();
2064
2065 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2066 assert_eq!(updated.action_name, "UpdateSystem");
2067 assert_eq!(updated.risk_level, RiskLevel::High);
2068 assert_eq!(updated.status, JobState::Failed);
2069 }
2070
2071 #[test]
2080 fn update_status_is_atomic_under_concurrent_conflicting_transitions() {
2081 let dir = tempdir().unwrap();
2082 let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
2083 let tx = store.record(queued_transaction()).unwrap();
2084 store
2085 .update_status(&tx.transaction_id, JobState::Running)
2086 .unwrap();
2087
2088 let store_a = std::sync::Arc::clone(&store);
2089 let id_a = tx.transaction_id.clone();
2090 let succeed = std::thread::spawn(move || store_a.update_status(&id_a, JobState::Succeeded));
2091
2092 let store_b = std::sync::Arc::clone(&store);
2093 let id_b = tx.transaction_id.clone();
2094 let fail = std::thread::spawn(move || store_b.update_status(&id_b, JobState::Failed));
2095
2096 let succeed_result = succeed.join().unwrap();
2097 let fail_result = fail.join().unwrap();
2098
2099 let oks = [succeed_result.is_ok(), fail_result.is_ok()]
2101 .into_iter()
2102 .filter(|ok| *ok)
2103 .count();
2104 assert_eq!(
2105 oks, 1,
2106 "exactly one concurrent transition should succeed: succeed={succeed_result:?} \
2107 fail={fail_result:?}"
2108 );
2109
2110 let loser = if succeed_result.is_ok() {
2126 &fail_result
2127 } else {
2128 &succeed_result
2129 };
2130 match loser {
2131 Err(TransactionStoreError::ConcurrentStatusChange(id)) => {
2132 assert_eq!(id, &tx.transaction_id);
2133 }
2134 Err(TransactionStoreError::InvalidTransition { from, to }) => {
2135 let winner = if succeed_result.is_ok() {
2137 JobState::Succeeded
2138 } else {
2139 JobState::Failed
2140 };
2141 assert_eq!(
2142 *from, winner,
2143 "the loser must have observed the winner's committed status"
2144 );
2145 assert_ne!(*to, winner, "the loser must be the other transition");
2146 }
2147 other => panic!(
2148 "the losing transition must be refused with either \
2149 ConcurrentStatusChange or InvalidTransition, got: {other:?}"
2150 ),
2151 }
2152
2153 let final_status = store.get(&tx.transaction_id).unwrap().unwrap().status;
2156 if succeed_result.is_ok() {
2157 assert_eq!(final_status, JobState::Succeeded);
2158 } else {
2159 assert_eq!(final_status, JobState::Failed);
2160 }
2161 }
2162
2163 #[test]
2164 fn revoke_unconsumed_approval_removes_an_unused_receipt() {
2165 let dir = tempdir().unwrap();
2168 let store = test_store(dir.path().join("tx.db"));
2169 let tx = store.record(queued_transaction()).unwrap();
2170 let receipt = store
2171 .approve_transaction(&tx.transaction_id)
2172 .unwrap()
2173 .expect("approved");
2174 let digest = audit_chain::approval_receipt_digest(&receipt);
2175
2176 assert!(
2177 store
2178 .revoke_unconsumed_approval(&tx.transaction_id)
2179 .unwrap(),
2180 "an unconsumed approval must be revocable"
2181 );
2182 assert!(
2183 !store
2184 .claim_approved_for_execution(&tx.transaction_id, &digest)
2185 .unwrap(),
2186 "a revoked receipt must no longer be claimable"
2187 );
2188 }
2189
2190 #[test]
2191 fn revoke_cannot_retract_an_approval_that_was_already_used() {
2192 let dir = tempdir().unwrap();
2196 let store = test_store(dir.path().join("tx.db"));
2197 let tx = store.record(queued_transaction()).unwrap();
2198 let receipt = store
2199 .approve_transaction(&tx.transaction_id)
2200 .unwrap()
2201 .expect("approved");
2202 let digest = audit_chain::approval_receipt_digest(&receipt);
2203 assert!(store
2204 .claim_approved_for_execution(&tx.transaction_id, &digest)
2205 .unwrap());
2206
2207 assert!(
2208 !store
2209 .revoke_unconsumed_approval(&tx.transaction_id)
2210 .unwrap(),
2211 "a consumed approval must not be revocable"
2212 );
2213 assert_eq!(
2214 store.get(&tx.transaction_id).unwrap().unwrap().status,
2215 JobState::Running,
2216 "a failed revocation must not disturb the running transaction"
2217 );
2218 }
2219
2220 #[test]
2221 fn approve_refuses_a_transaction_that_is_no_longer_queued() {
2222 let dir = tempdir().unwrap();
2226 let store = test_store(dir.path().join("tx.db"));
2227 let tx = store.record(queued_transaction()).unwrap();
2228 let receipt = store
2229 .approve_transaction(&tx.transaction_id)
2230 .unwrap()
2231 .expect("approved");
2232 let digest = audit_chain::approval_receipt_digest(&receipt);
2233 assert!(store
2234 .claim_approved_for_execution(&tx.transaction_id, &digest)
2235 .unwrap());
2236
2237 assert!(
2239 store
2240 .approve_transaction(&tx.transaction_id)
2241 .unwrap()
2242 .is_none(),
2243 "a Running transaction must not be approvable"
2244 );
2245
2246 store
2248 .update_status(&tx.transaction_id, JobState::Succeeded)
2249 .unwrap();
2250 assert!(
2251 store
2252 .approve_transaction(&tx.transaction_id)
2253 .unwrap()
2254 .is_none(),
2255 "a completed transaction must not be approvable"
2256 );
2257 }
2258
2259 #[test]
2260 fn only_one_of_two_concurrent_claims_can_execute() {
2261 let dir = tempdir().unwrap();
2266 let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
2267 let tx = store.record(queued_transaction()).unwrap();
2268 let receipt = store
2269 .approve_transaction(&tx.transaction_id)
2270 .unwrap()
2271 .expect("approved");
2272 let digest = audit_chain::approval_receipt_digest(&receipt);
2273
2274 let mut handles = Vec::new();
2275 for _ in 0..2 {
2276 let store = std::sync::Arc::clone(&store);
2277 let id = tx.transaction_id.clone();
2278 let digest = digest.clone();
2279 handles.push(std::thread::spawn(move || {
2280 store.claim_approved_for_execution(&id, &digest)
2281 }));
2282 }
2283 let claims: Vec<bool> = handles
2284 .into_iter()
2285 .map(|h| h.join().unwrap().unwrap_or(false))
2286 .collect();
2287
2288 assert_eq!(
2289 claims.iter().filter(|c| **c).count(),
2290 1,
2291 "exactly one claim may win: {claims:?}"
2292 );
2293 assert_eq!(
2294 store.get(&tx.transaction_id).unwrap().unwrap().status,
2295 JobState::Running
2296 );
2297 }
2298
2299 #[test]
2300 fn cancel_queued_cancels_a_queued_transaction_once() {
2301 let dir = tempdir().unwrap();
2302 let store = test_store(dir.path().join("tx.db"));
2303 let tx = store.record(queued_transaction()).unwrap();
2304
2305 assert!(
2306 store.cancel_queued(&tx.transaction_id).unwrap(),
2307 "a queued transaction is cancelable"
2308 );
2309 assert_eq!(
2310 store.get(&tx.transaction_id).unwrap().unwrap().status,
2311 JobState::Canceled
2312 );
2313 assert!(
2314 !store.cancel_queued(&tx.transaction_id).unwrap(),
2315 "an already-canceled transaction is not cancelable again"
2316 );
2317 assert!(
2318 !store.cancel_queued("no-such-transaction").unwrap(),
2319 "a missing transaction is not cancelable"
2320 );
2321 }
2322
2323 #[test]
2324 fn cancel_queued_refuses_a_running_transaction() {
2325 let dir = tempdir().unwrap();
2328 let store = test_store(dir.path().join("tx.db"));
2329 let tx = store.record(queued_transaction()).unwrap();
2330 let receipt = store
2331 .approve_transaction(&tx.transaction_id)
2332 .unwrap()
2333 .expect("approved");
2334 let digest = audit_chain::approval_receipt_digest(&receipt);
2335 assert!(store
2336 .claim_approved_for_execution(&tx.transaction_id, &digest)
2337 .unwrap());
2338
2339 assert!(
2340 !store.cancel_queued(&tx.transaction_id).unwrap(),
2341 "a running transaction must not be cancelable"
2342 );
2343 assert_eq!(
2344 store.get(&tx.transaction_id).unwrap().unwrap().status,
2345 JobState::Running,
2346 "cancel must not disturb a running transaction"
2347 );
2348 }
2349
2350 #[test]
2351 fn approved_receipt_is_required_and_consumed_once() {
2352 let dir = tempdir().unwrap();
2353 let store = test_store(dir.path().join("tx.db"));
2354 let tx = store.record(queued_transaction()).unwrap();
2355
2356 assert!(
2357 !store
2358 .claim_approved_for_execution(&tx.transaction_id, "digest-a")
2359 .unwrap(),
2360 "an unapproved preview must not execute"
2361 );
2362 let receipt = store
2363 .approve_transaction(&tx.transaction_id)
2364 .unwrap()
2365 .expect("first approval must succeed");
2366 let digest = audit_chain::approval_receipt_digest(&receipt);
2367 assert_eq!(tx.approval_id.as_deref(), Some(digest.as_str()));
2368 assert!(
2369 store
2370 .approve_transaction(&tx.transaction_id)
2371 .unwrap()
2372 .is_none(),
2373 "approval is immutable once issued"
2374 );
2375 assert!(
2376 !store
2377 .claim_approved_for_execution(&tx.transaction_id, "wrong-digest")
2378 .unwrap(),
2379 "a forged receipt must not execute"
2380 );
2381 assert!(
2382 store
2383 .claim_approved_for_execution(&tx.transaction_id, &digest)
2384 .unwrap(),
2385 "the exact approved receipt must execute"
2386 );
2387 assert!(
2388 !store
2389 .claim_approved_for_execution(&tx.transaction_id, &digest)
2390 .unwrap(),
2391 "the receipt must be one-time"
2392 );
2393 }
2394
2395 #[test]
2396 fn approval_commitment_is_covered_by_the_signed_chain() {
2397 let dir = tempdir().unwrap();
2398 let store = test_store(dir.path().join("tx.db"));
2399 let tx = store.record(queued_transaction()).unwrap();
2400 assert!(tx.approval_id.is_some());
2401
2402 store
2403 .connection()
2404 .unwrap()
2405 .execute(
2406 "UPDATE transactions SET approval_id = 'forged' WHERE transaction_id = ?1",
2407 params![tx.transaction_id],
2408 )
2409 .unwrap();
2410
2411 let key = AuditKey::from_bytes(vec![0x42; 32]);
2412 assert!(matches!(
2413 store.verify_audit_chain(&key).unwrap(),
2414 VerifyOutcome::Broken { .. }
2415 ));
2416 }
2417
2418 #[test]
2419 fn approve_rejects_a_forged_commitment_at_runtime() {
2420 let dir = tempdir().unwrap();
2424 let store = test_store(dir.path().join("tx.db"));
2425 let tx = store.record(queued_transaction()).unwrap();
2426
2427 store
2428 .connection()
2429 .unwrap()
2430 .execute(
2431 "UPDATE transactions SET approval_id = 'forged' WHERE transaction_id = ?1",
2432 params![tx.transaction_id],
2433 )
2434 .unwrap();
2435
2436 let err = store
2437 .approve_transaction(&tx.transaction_id)
2438 .expect_err("a forged commitment must be rejected, not approved");
2439 assert!(
2440 matches!(err, TransactionStoreError::DatabaseInvariant(_)),
2441 "forged commitment must surface as a DatabaseInvariant, got {err:?}"
2442 );
2443 }
2444
2445 #[test]
2446 fn stale_iso_timestamp_cannot_be_approved() {
2447 let dir = tempdir().unwrap();
2448 let store = test_store(dir.path().join("tx.db"));
2449 let tx = store.record(queued_transaction()).unwrap();
2450 let conn = store.connection().unwrap();
2451 conn.execute(
2452 "UPDATE transactions \
2453 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
2454 WHERE transaction_id = ?1",
2455 params![tx.transaction_id],
2456 )
2457 .unwrap();
2458
2459 assert!(
2460 store
2461 .approve_transaction(&tx.transaction_id)
2462 .unwrap()
2463 .is_none(),
2464 "a production-format timestamp outside the TTL must not be approved"
2465 );
2466 }
2467
2468 #[test]
2469 fn stale_iso_timestamp_cannot_be_claimed_for_execution() {
2470 let dir = tempdir().unwrap();
2475 let store = test_store(dir.path().join("tx.db"));
2476 let tx = store.record(queued_transaction()).unwrap();
2477 let receipt = store
2478 .approve_transaction(&tx.transaction_id)
2479 .unwrap()
2480 .expect("a fresh approval succeeds");
2481 let digest = audit_chain::approval_receipt_digest(&receipt);
2482
2483 let conn = store.connection().unwrap();
2485 conn.execute(
2486 "UPDATE transactions \
2487 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
2488 WHERE transaction_id = ?1",
2489 params![tx.transaction_id],
2490 )
2491 .unwrap();
2492
2493 assert!(
2494 !store
2495 .claim_approved_for_execution(&tx.transaction_id, &digest)
2496 .unwrap(),
2497 "an approval aged past the TTL must not be claimable at execute time"
2498 );
2499 assert_eq!(
2500 store.get(&tx.transaction_id).unwrap().unwrap().status,
2501 JobState::Queued,
2502 "a TTL-expired claim must leave the transaction Queued, never Running"
2503 );
2504 }
2505
2506 #[test]
2507 fn cleanup_stale_queued_cancels_old_records() {
2508 let dir = tempdir().unwrap();
2509 let store = test_store(dir.path().join("tx.db"));
2510
2511 let fresh = store.record(queued_transaction()).unwrap();
2513 let stale = store.record(queued_transaction()).unwrap();
2514
2515 let conn = store.connection().unwrap();
2517 conn.execute(
2518 "UPDATE transactions \
2519 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
2520 WHERE transaction_id = ?1",
2521 params![stale.transaction_id],
2522 )
2523 .unwrap();
2524
2525 let canceled = store.cleanup_stale_queued().unwrap();
2526 assert_eq!(canceled, 1, "only the stale record should be canceled");
2527
2528 let stale_record = store.get(&stale.transaction_id).unwrap().unwrap();
2530 assert_eq!(stale_record.status, JobState::Canceled);
2531
2532 let fresh_record = store.get(&fresh.transaction_id).unwrap().unwrap();
2534 assert_eq!(fresh_record.status, JobState::Queued);
2535 }
2536
2537 #[test]
2540 fn update_status_rejects_queued_to_succeeded() {
2541 let dir = tempdir().unwrap();
2542 let store = test_store(dir.path().join("tx.db"));
2543 let tx = store.record(queued_transaction()).unwrap();
2544
2545 let result = store.update_status(&tx.transaction_id, JobState::Succeeded);
2546 assert!(
2547 matches!(
2548 result,
2549 Err(TransactionStoreError::InvalidTransition {
2550 from: JobState::Queued,
2551 to: JobState::Succeeded,
2552 })
2553 ),
2554 "Queued -> Succeeded must be rejected (must go through Running first): {result:?}"
2555 );
2556 }
2557
2558 #[test]
2559 fn update_status_rejects_succeeded_to_running() {
2560 let dir = tempdir().unwrap();
2561 let store = test_store(dir.path().join("tx.db"));
2562 let tx = store.record(queued_transaction()).unwrap();
2563
2564 store
2565 .update_status(&tx.transaction_id, JobState::Running)
2566 .unwrap();
2567 store
2568 .update_status(&tx.transaction_id, JobState::Succeeded)
2569 .unwrap();
2570
2571 let result = store.update_status(&tx.transaction_id, JobState::Running);
2572 assert!(
2573 matches!(
2574 result,
2575 Err(TransactionStoreError::InvalidTransition {
2576 from: JobState::Succeeded,
2577 to: JobState::Running,
2578 })
2579 ),
2580 "Succeeded -> Running must be rejected (terminal state): {result:?}"
2581 );
2582 }
2583
2584 #[test]
2585 fn update_status_accepts_running_to_failed() {
2586 let dir = tempdir().unwrap();
2587 let store = test_store(dir.path().join("tx.db"));
2588 let tx = store.record(queued_transaction()).unwrap();
2589
2590 store
2591 .update_status(&tx.transaction_id, JobState::Running)
2592 .unwrap();
2593 store
2594 .update_status(&tx.transaction_id, JobState::Failed)
2595 .unwrap();
2596
2597 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2598 assert_eq!(updated.status, JobState::Failed);
2599 }
2600
2601 #[test]
2602 fn update_status_accepts_running_to_rolled_back() {
2603 let dir = tempdir().unwrap();
2604 let store = test_store(dir.path().join("tx.db"));
2605 let tx = store.record(queued_transaction()).unwrap();
2606
2607 store
2608 .update_status(&tx.transaction_id, JobState::Running)
2609 .unwrap();
2610 store
2611 .update_status(&tx.transaction_id, JobState::RolledBack)
2612 .unwrap();
2613
2614 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2615 assert_eq!(updated.status, JobState::RolledBack);
2616 }
2617
2618 #[test]
2621 fn list_transactions_returns_empty_for_fresh_store() {
2622 let dir = tempdir().unwrap();
2623 let store = test_store(dir.path().join("tx.db"));
2624 let results = store.list_transactions(10, None, None, None).unwrap();
2625 assert!(results.is_empty());
2626 }
2627
2628 #[test]
2629 fn list_transactions_returns_all_records_ordered_by_newest_first() {
2630 let dir = tempdir().unwrap();
2631 let store = test_store(dir.path().join("tx.db"));
2632 store.record(queued_transaction()).unwrap();
2633
2634 let mut second = queued_transaction();
2635 second.action_name = "GetDiskUsage".to_string();
2636 second.risk_level = RiskLevel::Low;
2637 store.record(second).unwrap();
2638
2639 let results = store.list_transactions(10, None, None, None).unwrap();
2640 assert_eq!(results.len(), 2);
2641 assert_eq!(results[0].action_name, "GetDiskUsage");
2643 assert_eq!(results[1].action_name, "UpdateSystem");
2644 }
2645
2646 #[test]
2647 fn list_history_populates_created_at_and_risk_level() {
2648 let dir = tempdir().unwrap();
2649 let store = test_store(dir.path().join("tx.db"));
2650 store.record(queued_transaction()).unwrap();
2651
2652 let entries = store.list_history(10, None, None, None).unwrap();
2653 assert_eq!(entries.len(), 1);
2654 let entry = &entries[0];
2655 assert_eq!(entry.action_name, "UpdateSystem");
2656 assert_eq!(entry.risk_level, RiskLevel::High);
2657 assert_eq!(entry.status, JobState::Queued);
2658 assert!(
2659 !entry.created_at.is_empty(),
2660 "created_at must be populated from the stored row, not left blank"
2661 );
2662 }
2663
2664 #[test]
2665 fn list_history_applies_the_same_filters_as_list_transactions() {
2666 let dir = tempdir().unwrap();
2667 let store = test_store(dir.path().join("tx.db"));
2668 store.record(queued_transaction()).unwrap();
2669 let mut low = queued_transaction();
2670 low.action_name = "GetDiskUsage".to_string();
2671 low.risk_level = RiskLevel::Low;
2672 store.record(low).unwrap();
2673
2674 let only = store
2675 .list_history(10, None, Some("GetDiskUsage"), None)
2676 .unwrap();
2677 assert_eq!(only.len(), 1);
2678 assert_eq!(only[0].action_name, "GetDiskUsage");
2679 }
2680
2681 #[test]
2682 fn list_transactions_respects_limit() {
2683 let dir = tempdir().unwrap();
2684 let store = test_store(dir.path().join("tx.db"));
2685 for _ in 0..5 {
2686 store.record(queued_transaction()).unwrap();
2687 }
2688 let results = store.list_transactions(3, None, None, None).unwrap();
2689 assert_eq!(results.len(), 3);
2690 }
2691
2692 #[test]
2693 fn list_transactions_filters_by_status() {
2694 let dir = tempdir().unwrap();
2695 let store = test_store(dir.path().join("tx.db"));
2696 let tx = store.record(queued_transaction()).unwrap();
2697 store
2698 .update_status(&tx.transaction_id, JobState::Running)
2699 .unwrap();
2700 store
2701 .update_status(&tx.transaction_id, JobState::Succeeded)
2702 .unwrap();
2703
2704 store.record(queued_transaction()).unwrap();
2706
2707 let succeeded = store
2708 .list_transactions(10, Some("succeeded"), None, None)
2709 .unwrap();
2710 assert_eq!(succeeded.len(), 1);
2711 assert_eq!(succeeded[0].status, JobState::Succeeded);
2712
2713 let queued = store
2714 .list_transactions(10, Some("queued"), None, None)
2715 .unwrap();
2716 assert_eq!(queued.len(), 1);
2717 assert_eq!(queued[0].status, JobState::Queued);
2718 }
2719
2720 #[test]
2721 fn list_transactions_filters_by_action_name() {
2722 let dir = tempdir().unwrap();
2723 let store = test_store(dir.path().join("tx.db"));
2724 store.record(queued_transaction()).unwrap(); let mut disk = queued_transaction();
2727 disk.action_name = "GetDiskUsage".to_string();
2728 store.record(disk).unwrap();
2729
2730 let results = store
2731 .list_transactions(10, None, Some("GetDiskUsage"), None)
2732 .unwrap();
2733 assert_eq!(results.len(), 1);
2734 assert_eq!(results[0].action_name, "GetDiskUsage");
2735 }
2736
2737 #[test]
2738 fn list_transactions_filters_by_since_hours() {
2739 let dir = tempdir().unwrap();
2740 let store = test_store(dir.path().join("tx.db"));
2741
2742 let old = store.record(queued_transaction()).unwrap();
2744 let conn = store.connection().unwrap();
2745 conn.execute(
2746 "UPDATE transactions \
2747 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-48 hours') \
2748 WHERE transaction_id = ?1",
2749 params![old.transaction_id],
2750 )
2751 .unwrap();
2752
2753 store.record(queued_transaction()).unwrap();
2755
2756 let results = store.list_transactions(10, None, None, Some(24)).unwrap();
2758 assert_eq!(results.len(), 1);
2759
2760 let results = store.list_transactions(10, None, None, Some(72)).unwrap();
2762 assert_eq!(results.len(), 2);
2763 }
2764
2765 #[test]
2766 fn list_transactions_rejects_invalid_status_filter() {
2767 let dir = tempdir().unwrap();
2768 let store = test_store(dir.path().join("tx.db"));
2769 store.record(queued_transaction()).unwrap();
2770 let result = store.list_transactions(10, Some("bogus"), None, None);
2771 assert!(result.is_err(), "invalid status filter should return error");
2772 }
2773
2774 #[test]
2782 fn record_emits_one_watermark_per_entry() {
2783 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2784 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
2785
2786 let dir = tempdir().unwrap();
2787 let store = test_store(dir.path().join("tx.db"));
2788 store.record(queued_transaction()).unwrap();
2789
2790 let calls = crate::audit_watermark::take_watermarks(&sink);
2791 assert_eq!(calls.len(), 1, "expected exactly 1 watermark per record()");
2792 }
2793
2794 #[test]
2796 fn record_previewed_emits_one_watermark() {
2797 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2798 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
2799
2800 let dir = tempdir().unwrap();
2801 let store = test_store(dir.path().join("tx.db"));
2802 let preview = PreviewEnvelope {
2803 summary: "Upgrade the system".to_string(),
2804 risk_level: RiskLevel::High,
2805 current_state: serde_json::Value::Null,
2806 proposed_change: serde_json::Value::Null,
2807 expected_side_effects: vec![],
2808 reboot_required: false,
2809 rollback_available: false,
2810 warnings: vec![],
2811 request_hash: sysknife_types::RequestHash::from("hash-abc".to_string()),
2812 };
2813 store
2814 .record_previewed(queued_transaction(), preview)
2815 .unwrap();
2816
2817 let calls = crate::audit_watermark::take_watermarks(&sink);
2818 assert_eq!(
2819 calls.len(),
2820 1,
2821 "expected exactly 1 watermark per record_previewed()"
2822 );
2823 }
2824
2825 #[test]
2827 fn watermark_seq_and_hash_match_chain_row() {
2828 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2829 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
2830
2831 let dir = tempdir().unwrap();
2832 let store = test_store(dir.path().join("tx.db"));
2833 store.record(queued_transaction()).unwrap();
2834
2835 let rows = store.fetch_chain_rows().unwrap();
2836 assert_eq!(rows.len(), 1);
2837 let row = &rows[0];
2838
2839 let calls = crate::audit_watermark::take_watermarks(&sink);
2840 assert_eq!(calls.len(), 1);
2841 assert_eq!(calls[0].seq, row.seq, "watermark seq must match chain row");
2842 assert_eq!(
2843 calls[0].chain_hash_hex, row.chain_hash,
2844 "watermark chain_hash_hex must match stored chain_hash"
2845 );
2846 }
2847
2848 #[test]
2850 fn multiple_records_produce_one_watermark_each() {
2851 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2852 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
2853
2854 let dir = tempdir().unwrap();
2855 let store = test_store(dir.path().join("tx.db"));
2856 for _ in 0..3 {
2857 store.record(queued_transaction()).unwrap();
2858 }
2859
2860 let calls = crate::audit_watermark::take_watermarks(&sink);
2861 assert_eq!(calls.len(), 3, "one watermark per record call");
2862 assert_eq!(calls[0].seq, 1);
2863 assert_eq!(calls[1].seq, 2);
2864 assert_eq!(calls[2].seq, 3);
2865 }
2866
2867 #[test]
2881 fn no_watermark_emitted_before_any_record() {
2882 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2883 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
2884
2885 let dir = tempdir().unwrap();
2886 let _store = test_store(dir.path().join("tx.db"));
2887
2888 let calls = crate::audit_watermark::take_watermarks(&sink);
2890 assert!(
2891 calls.is_empty(),
2892 "no watermark must be emitted without a record() call"
2893 );
2894 }
2895}