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 let census = audit_chain::AttributionCensus::of(&rows);
1479 assert_eq!(census.rows(), 2);
1480 assert_eq!(census.named(), 1, "only the v3 row names uid:4242");
1481 assert_eq!(
1482 census.not_recorded(),
1483 1,
1484 "the v2 row's encoding never signed a principal"
1485 );
1486 assert_eq!(census.attribution_failed(), 0);
1487 assert_eq!(census.unattested(), 0);
1488 assert_eq!(census.unnamed(), 1);
1489 }
1490
1491 #[test]
1492 fn a_database_written_before_the_migration_still_verifies_after_upgrading() {
1493 let dir = tempdir().unwrap();
1498 let db_path = dir.path().join("tx.db");
1499 let key = AuditKey::from_bytes(vec![0x42; 32]);
1500 legacy_v1_database(&db_path, &key);
1501
1502 let store = test_store(&db_path);
1503 assert_eq!(
1504 store.verify_audit_chain(&key).unwrap(),
1505 VerifyOutcome::Intact { rows_checked: 1 }
1506 );
1507 let rows = store.fetch_chain_rows().unwrap();
1508 assert_eq!(rows[0].chain_version, CHAIN_VERSION_LEGACY);
1509 assert_eq!(rows[0].caller_role, None);
1510 }
1511
1512 #[test]
1513 fn rows_appended_after_the_migration_chain_onto_legacy_rows() {
1514 let dir = tempdir().unwrap();
1518 let db_path = dir.path().join("tx.db");
1519 let key = AuditKey::from_bytes(vec![0x42; 32]);
1520 legacy_v1_database(&db_path, &key);
1521
1522 let store = test_store(&db_path);
1523 store.record(queued_transaction()).unwrap();
1524
1525 let rows = store.fetch_chain_rows().unwrap();
1526 assert_eq!(rows.len(), 2);
1527 assert_eq!(rows[1].prev_chain_hash, rows[0].chain_hash);
1528 assert_eq!(rows[1].chain_version, CHAIN_VERSION_CURRENT);
1529 assert_eq!(
1530 store.verify_audit_chain(&key).unwrap(),
1531 VerifyOutcome::Intact { rows_checked: 2 }
1532 );
1533 }
1534
1535 #[test]
1536 fn a_schema_newer_than_this_binary_is_refused() {
1537 let dir = tempdir().unwrap();
1538 let db_path = dir.path().join("tx.db");
1539 let key = AuditKey::from_bytes(vec![0x42; 32]);
1540 legacy_v1_database(&db_path, &key);
1541 let conn = Connection::open(&db_path).unwrap();
1542 conn.execute(
1543 "INSERT INTO schema_migrations (version, name) VALUES (99, 'from the future')",
1544 [],
1545 )
1546 .unwrap();
1547 drop(conn);
1548
1549 let err = TransactionStore::open_with_key(&db_path, Arc::new(key)).unwrap_err();
1550 assert!(
1551 matches!(err, TransactionStoreError::DatabaseInvariant(ref m) if m.contains("99")),
1552 "expected a refusal naming the unsupported version, got {err:?}"
1553 );
1554 }
1555
1556 #[test]
1559 fn the_signed_row_records_which_role_asked() {
1560 let dir = tempdir().unwrap();
1561 let store = test_store(dir.path().join("tx.db"));
1562 let mut tx = queued_transaction();
1563 tx.caller_role = CallerRole::Admin;
1564 store.record(tx).unwrap();
1565
1566 let rows = store.fetch_chain_rows().unwrap();
1567 assert_eq!(rows[0].caller_role.as_deref(), Some("admin"));
1568 }
1569
1570 #[test]
1571 fn approving_consuming_and_revoking_each_append_a_chained_event() {
1572 let dir = tempdir().unwrap();
1573 let key = AuditKey::from_bytes(vec![0x42; 32]);
1574 let store = test_store(dir.path().join("tx.db"));
1575
1576 let a = store.record(queued_transaction()).unwrap();
1578 let receipt = store
1579 .approve_transaction(&a.transaction_id)
1580 .unwrap()
1581 .expect("queued transaction approves");
1582 let digest = audit_chain::approval_receipt_digest(&receipt);
1583 assert!(store
1584 .claim_approved_for_execution(&a.transaction_id, &digest)
1585 .unwrap());
1586
1587 let b = store.record(queued_transaction()).unwrap();
1589 store.approve_transaction(&b.transaction_id).unwrap();
1590 assert!(store.revoke_unconsumed_approval(&b.transaction_id).unwrap());
1591
1592 let events = store.fetch_event_rows().unwrap();
1593 let kinds: Vec<&str> = events.iter().map(|e| e.kind.as_str()).collect();
1594 assert_eq!(
1595 kinds,
1596 vec![
1597 "approval_granted",
1598 "approval_consumed",
1599 "approval_granted",
1600 "approval_revoked",
1601 ]
1602 );
1603 assert_eq!(
1604 store.verify_event_chain(&key).unwrap(),
1605 VerifyOutcome::Intact { rows_checked: 4 }
1606 );
1607 }
1608
1609 #[test]
1610 fn a_failed_approval_appends_no_event() {
1611 let dir = tempdir().unwrap();
1615 let store = test_store(dir.path().join("tx.db"));
1616 let tx = store.record(queued_transaction()).unwrap();
1617 store.approve_transaction(&tx.transaction_id).unwrap();
1618 store.approve_transaction(&tx.transaction_id).unwrap();
1619 assert_eq!(store.fetch_event_rows().unwrap().len(), 1);
1620
1621 assert!(!store.revoke_unconsumed_approval("no-such-tx").unwrap());
1623 assert_eq!(store.fetch_event_rows().unwrap().len(), 1);
1624 }
1625
1626 #[test]
1627 fn deleting_the_record_that_an_approval_happened_no_longer_goes_unnoticed() {
1628 let dir = tempdir().unwrap();
1634 let db_path = dir.path().join("tx.db");
1635 let key = AuditKey::from_bytes(vec![0x42; 32]);
1636 let store = test_store(&db_path);
1637
1638 let a = store.record(queued_transaction()).unwrap();
1639 store.approve_transaction(&a.transaction_id).unwrap();
1640 store.record(queued_transaction()).unwrap();
1643
1644 assert_eq!(
1645 store.verify_event_chain(&key).unwrap(),
1646 VerifyOutcome::Intact { rows_checked: 1 }
1647 );
1648 assert_eq!(
1649 store.verify_event_binding().unwrap(),
1650 audit_chain::BindingOutcome::Consistent {
1651 bindings_checked: 1
1652 }
1653 );
1654
1655 let conn = Connection::open(&db_path).unwrap();
1657 conn.execute("DELETE FROM transaction_approvals", [])
1658 .unwrap();
1659 conn.execute("DELETE FROM audit_events", []).unwrap();
1660 drop(conn);
1661
1662 assert_eq!(
1666 store.verify_event_chain(&key).unwrap(),
1667 VerifyOutcome::Intact { rows_checked: 0 }
1668 );
1669 match store.verify_event_binding().unwrap() {
1670 audit_chain::BindingOutcome::MissingEvent {
1671 transaction_seq, ..
1672 } => assert_eq!(transaction_seq, 2),
1673 other => panic!("expected MissingEvent, got {other:?}"),
1674 }
1675 }
1676
1677 #[test]
1678 fn a_read_only_store_cannot_revoke_or_claim() {
1679 let dir = tempdir().unwrap();
1682 let db_path = dir.path().join("tx.db");
1683 let store = test_store(&db_path);
1684 store.record(queued_transaction()).unwrap();
1685
1686 let read_only = TransactionStore::open_read_only(&db_path).unwrap();
1687 assert!(matches!(
1688 read_only.revoke_unconsumed_approval("tx"),
1689 Err(TransactionStoreError::AuditChainMissing(_))
1690 ));
1691 assert!(matches!(
1692 read_only.claim_approved_for_execution("tx", "digest"),
1693 Err(TransactionStoreError::AuditChainMissing(_))
1694 ));
1695 }
1696
1697 #[test]
1704 fn record_persists_the_caller_principal_under_the_current_encoding() {
1705 let dir = tempdir().unwrap();
1706 let store = test_store(dir.path().join("tx.db"));
1707 let mut tx = queued_transaction();
1708 tx.caller_principal = CallerPrincipal::Uid(4242);
1709 store.record(tx).unwrap();
1710
1711 let conn = store.connection().unwrap();
1712 let (version, role, principal): (i64, Option<String>, Option<String>) = conn
1713 .query_row(
1714 "SELECT chain_version, caller_role, caller_principal FROM transactions",
1715 [],
1716 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1717 )
1718 .unwrap();
1719
1720 assert_eq!(version, i64::from(audit_chain::CHAIN_VERSION_CURRENT));
1721 assert_eq!(role.as_deref(), Some("dev"));
1722 assert_eq!(
1723 principal.as_deref(),
1724 Some("uid:4242"),
1725 "the row must name the account the dispatcher resolved"
1726 );
1727
1728 let key = AuditKey::from_bytes(vec![0x42; 32]);
1730 assert!(matches!(
1731 store.verify_audit_chain(&key).unwrap(),
1732 audit_chain::VerifyOutcome::Intact { .. }
1733 ));
1734 }
1735
1736 #[test]
1737 fn record_writes_audit_chain_columns() {
1738 let dir = tempdir().unwrap();
1739 let store = test_store(dir.path().join("tx.db"));
1740 let _record = store.record(queued_transaction()).unwrap();
1741
1742 let conn = store.connection().unwrap();
1743 let (seq, key_id, chain_hash, prev): (i64, String, String, String) = conn
1744 .query_row(
1745 "SELECT seq, key_id, chain_hash, prev_chain_hash FROM transactions",
1746 [],
1747 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
1748 )
1749 .unwrap();
1750 assert_eq!(seq, 1, "first row gets seq=1");
1751 assert_eq!(key_id, audit_chain::CURRENT_KEY_ID);
1752 assert_eq!(prev, "", "first row has empty prev_chain_hash");
1753 assert_eq!(
1754 chain_hash.len(),
1755 audit_chain::HASH_HEX_LEN,
1756 "chain_hash is a hex-encoded Ed25519 signature"
1757 );
1758 }
1759
1760 #[test]
1761 fn sequential_records_produce_chained_hashes() {
1762 let dir = tempdir().unwrap();
1763 let store = test_store(dir.path().join("tx.db"));
1764 store.record(queued_transaction()).unwrap();
1765 store.record(queued_transaction()).unwrap();
1766 store.record(queued_transaction()).unwrap();
1767
1768 let rows = store.fetch_chain_rows().unwrap();
1769 assert_eq!(rows.len(), 3);
1770 assert_eq!(rows[0].seq, 1);
1771 assert_eq!(rows[1].seq, 2);
1772 assert_eq!(rows[2].seq, 3);
1773 assert_eq!(rows[1].prev_chain_hash, rows[0].chain_hash);
1775 assert_eq!(rows[2].prev_chain_hash, rows[1].chain_hash);
1776 }
1777
1778 #[test]
1790 fn concurrent_record_keeps_chain_intact_and_seqs_contiguous() {
1791 const WORKERS: usize = 8;
1792 const RECORDS_PER_WORKER: usize = 10;
1793 const TOTAL: usize = WORKERS * RECORDS_PER_WORKER;
1794
1795 let dir = tempdir().unwrap();
1796 let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
1797
1798 let mut handles = Vec::with_capacity(WORKERS);
1799 for w in 0..WORKERS {
1800 let store = std::sync::Arc::clone(&store);
1801 handles.push(std::thread::spawn(move || {
1802 for r in 0..RECORDS_PER_WORKER {
1803 let tx = NewTransaction {
1804 request_id: format!("worker-{w}-record-{r}"),
1805 request_hash: format!("hash-{w}-{r}"),
1806 action_name: "GetSystemState".to_string(),
1807 risk_level: RiskLevel::Low,
1808 summary: format!("worker {w} record {r}"),
1809 warnings: vec![],
1810 caller_role: CallerRole::Dev,
1811 caller_principal: CallerPrincipal::Uid(3000 + w as u32),
1815 };
1816 store
1817 .record(tx)
1818 .expect("record must succeed under contention");
1819 }
1820 }));
1821 }
1822 for h in handles {
1823 h.join().expect("worker thread did not panic");
1824 }
1825
1826 let key = AuditKey::from_bytes(vec![0x42; 32]);
1828 let outcome = store.verify_audit_chain(&key).unwrap();
1829 match outcome {
1830 VerifyOutcome::Intact { rows_checked } => {
1831 assert_eq!(
1832 rows_checked, TOTAL as u64,
1833 "expected {TOTAL} rows checked, got {rows_checked}"
1834 );
1835 }
1836 other => panic!("chain must be Intact under concurrent writes; got {other:?}"),
1837 }
1838
1839 {
1843 let conn = store.connection().unwrap();
1844 let mut stmt = conn
1845 .prepare("SELECT summary, caller_principal FROM transactions")
1846 .unwrap();
1847 let pairs: Vec<(String, Option<String>)> = stmt
1848 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
1849 .unwrap()
1850 .map(|r| r.unwrap())
1851 .collect();
1852 assert_eq!(pairs.len(), TOTAL);
1853 for (summary, principal) in pairs {
1854 let worker: u32 = summary
1855 .split_whitespace()
1856 .nth(1)
1857 .and_then(|w| w.parse().ok())
1858 .unwrap_or_else(|| panic!("unexpected summary {summary:?}"));
1859 assert_eq!(
1860 principal.as_deref(),
1861 Some(format!("uid:{}", 3000 + worker).as_str()),
1862 "row {summary:?} was attributed to the wrong account"
1863 );
1864 }
1865 }
1866
1867 let conn = store.connection().unwrap();
1869 let mut stmt = conn
1870 .prepare("SELECT seq FROM transactions ORDER BY seq ASC")
1871 .unwrap();
1872 let seqs: Vec<i64> = stmt
1873 .query_map([], |row| row.get::<_, i64>(0))
1874 .unwrap()
1875 .map(|r| r.unwrap())
1876 .collect();
1877 assert_eq!(seqs.len(), TOTAL, "row count mismatch");
1878 for (i, s) in seqs.iter().enumerate() {
1879 assert_eq!(
1880 *s,
1881 (i as i64) + 1,
1882 "seq column must be contiguous 1..={TOTAL}; saw {s} at position {i}"
1883 );
1884 }
1885 }
1886
1887 #[test]
1888 fn verify_audit_chain_intact_after_inserts() {
1889 let dir = tempdir().unwrap();
1890 let store = test_store(dir.path().join("tx.db"));
1891 for _ in 0..3 {
1892 store.record(queued_transaction()).unwrap();
1893 }
1894 let key = AuditKey::from_bytes(vec![0x42; 32]);
1895 let outcome = store.verify_audit_chain(&key).unwrap();
1896 assert!(matches!(outcome, VerifyOutcome::Intact { rows_checked: 3 }));
1897 }
1898
1899 #[test]
1900 fn verify_audit_chain_with_pubkey_intact_after_inserts() {
1901 let dir = tempdir().unwrap();
1902 let store = test_store(dir.path().join("tx.db"));
1903 for _ in 0..3 {
1904 store.record(queued_transaction()).unwrap();
1905 }
1906 let key = AuditKey::from_bytes(vec![0x42; 32]);
1908 let outcome = store
1909 .verify_audit_chain_with_pubkey(&key.verifying_key_hex())
1910 .unwrap();
1911 assert!(matches!(outcome, VerifyOutcome::Intact { rows_checked: 3 }));
1912 }
1913
1914 #[test]
1915 fn verify_audit_chain_with_wrong_pubkey_is_broken() {
1916 let dir = tempdir().unwrap();
1917 let store = test_store(dir.path().join("tx.db"));
1918 store.record(queued_transaction()).unwrap();
1919 let other = AuditKey::from_bytes(vec![0x99; 32]);
1921 let outcome = store
1922 .verify_audit_chain_with_pubkey(&other.verifying_key_hex())
1923 .unwrap();
1924 assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
1925 }
1926
1927 #[test]
1928 fn verify_detects_tampered_summary() {
1929 let dir = tempdir().unwrap();
1930 let store = test_store(dir.path().join("tx.db"));
1931 let tx = store.record(queued_transaction()).unwrap();
1932
1933 let conn = store.connection().unwrap();
1936 conn.execute(
1937 "UPDATE transactions SET summary = ?1 WHERE transaction_id = ?2",
1938 params!["EVIL CHANGE", tx.transaction_id],
1939 )
1940 .unwrap();
1941
1942 let key = AuditKey::from_bytes(vec![0x42; 32]);
1943 let outcome = store.verify_audit_chain(&key).unwrap();
1944 assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
1945 }
1946
1947 #[test]
1948 fn status_update_does_not_break_chain() {
1949 let dir = tempdir().unwrap();
1951 let store = test_store(dir.path().join("tx.db"));
1952 let tx = store.record(queued_transaction()).unwrap();
1953 store
1954 .update_status(&tx.transaction_id, JobState::Running)
1955 .unwrap();
1956 store
1957 .update_status(&tx.transaction_id, JobState::Succeeded)
1958 .unwrap();
1959
1960 let key = AuditKey::from_bytes(vec![0x42; 32]);
1961 let outcome = store.verify_audit_chain(&key).unwrap();
1962 assert!(
1963 matches!(outcome, VerifyOutcome::Intact { rows_checked: 1 }),
1964 "status mutation must not break the chain (status not in chain content): {outcome:?}"
1965 );
1966 }
1967
1968 #[test]
1969 fn open_read_only_rejects_record() {
1970 let dir = tempdir().unwrap();
1971 let key_path = dir.path().join("audit-key");
1972 std::fs::write(&key_path, vec![0x42; 32]).unwrap();
1973 std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
1974
1975 let store = TransactionStore::open_read_only(dir.path().join("tx.db")).unwrap();
1976 let result = store.record(queued_transaction());
1977 assert!(matches!(
1978 result,
1979 Err(TransactionStoreError::AuditChainMissing(_))
1980 ));
1981 }
1982
1983 #[test]
1984 fn ensure_private_dir_creates_with_0700_mode() {
1985 let root = tempdir().unwrap();
1986 let target = root.path().join("a/b/c");
1987 ensure_private_dir(&target).unwrap();
1988 assert!(target.is_dir());
1989 let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
1990 assert_eq!(mode, 0o700, "leaf dir must be 0o700, got {mode:o}");
1991 }
1992
1993 #[test]
1994 fn ensure_private_dir_is_idempotent_and_does_not_widen_existing_mode() {
1995 let root = tempdir().unwrap();
1996 let target = root.path().join("preexisting");
1997 std::fs::create_dir(&target).unwrap();
1998 std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
1999 ensure_private_dir(&target).unwrap();
2000 let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
2002 assert_eq!(mode, 0o755);
2003 }
2004
2005 #[test]
2006 fn open_creates_parent_with_private_mode() {
2007 let root = tempdir().unwrap();
2008 let db_path = root.path().join("nested/dirs/daemon.sqlite");
2009 let _store = test_store(&db_path);
2010 let parent = db_path.parent().unwrap();
2011 let mode = std::fs::metadata(parent).unwrap().permissions().mode() & 0o777;
2012 assert_eq!(mode, 0o700);
2013 }
2014
2015 fn queued_transaction() -> NewTransaction {
2016 NewTransaction {
2017 request_id: "req-1".to_string(),
2018 request_hash: "hash-abc".to_string(),
2019 action_name: "UpdateSystem".to_string(),
2020 risk_level: RiskLevel::High,
2021 summary: "Upgrade the system".to_string(),
2022 warnings: vec![],
2023 caller_role: CallerRole::Dev,
2024 caller_principal: CallerPrincipal::Uid(1000),
2025 }
2026 }
2027
2028 #[test]
2029 fn update_status_transitions_queued_to_running() {
2030 let dir = tempdir().unwrap();
2031 let store = test_store(dir.path().join("tx.db"));
2032 let tx = store.record(queued_transaction()).unwrap();
2033
2034 store
2035 .update_status(&tx.transaction_id, JobState::Running)
2036 .unwrap();
2037
2038 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2039 assert_eq!(updated.status, JobState::Running);
2040 }
2041
2042 #[test]
2043 fn update_status_transitions_running_to_succeeded() {
2044 let dir = tempdir().unwrap();
2045 let store = test_store(dir.path().join("tx.db"));
2046 let tx = store.record(queued_transaction()).unwrap();
2047
2048 store
2049 .update_status(&tx.transaction_id, JobState::Running)
2050 .unwrap();
2051 store
2052 .update_status(&tx.transaction_id, JobState::Succeeded)
2053 .unwrap();
2054
2055 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2056 assert_eq!(updated.status, JobState::Succeeded);
2057 }
2058
2059 #[test]
2060 fn update_status_for_unknown_id_returns_not_found() {
2061 let dir = tempdir().unwrap();
2062 let store = test_store(dir.path().join("tx.db"));
2063
2064 let result = store.update_status("does-not-exist", JobState::Running);
2065 assert!(
2066 matches!(result, Err(TransactionStoreError::NotFound(ref id)) if id == "does-not-exist"),
2067 "expected NotFound, got: {result:?}"
2068 );
2069 }
2070
2071 #[test]
2072 fn update_status_leaves_other_fields_intact() {
2073 let dir = tempdir().unwrap();
2074 let store = test_store(dir.path().join("tx.db"));
2075 let tx = store.record(queued_transaction()).unwrap();
2076
2077 store
2078 .update_status(&tx.transaction_id, JobState::Running)
2079 .unwrap();
2080 store
2081 .update_status(&tx.transaction_id, JobState::Failed)
2082 .unwrap();
2083
2084 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2085 assert_eq!(updated.action_name, "UpdateSystem");
2086 assert_eq!(updated.risk_level, RiskLevel::High);
2087 assert_eq!(updated.status, JobState::Failed);
2088 }
2089
2090 #[test]
2099 fn update_status_is_atomic_under_concurrent_conflicting_transitions() {
2100 let dir = tempdir().unwrap();
2101 let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
2102 let tx = store.record(queued_transaction()).unwrap();
2103 store
2104 .update_status(&tx.transaction_id, JobState::Running)
2105 .unwrap();
2106
2107 let store_a = std::sync::Arc::clone(&store);
2108 let id_a = tx.transaction_id.clone();
2109 let succeed = std::thread::spawn(move || store_a.update_status(&id_a, JobState::Succeeded));
2110
2111 let store_b = std::sync::Arc::clone(&store);
2112 let id_b = tx.transaction_id.clone();
2113 let fail = std::thread::spawn(move || store_b.update_status(&id_b, JobState::Failed));
2114
2115 let succeed_result = succeed.join().unwrap();
2116 let fail_result = fail.join().unwrap();
2117
2118 let oks = [succeed_result.is_ok(), fail_result.is_ok()]
2120 .into_iter()
2121 .filter(|ok| *ok)
2122 .count();
2123 assert_eq!(
2124 oks, 1,
2125 "exactly one concurrent transition should succeed: succeed={succeed_result:?} \
2126 fail={fail_result:?}"
2127 );
2128
2129 let loser = if succeed_result.is_ok() {
2145 &fail_result
2146 } else {
2147 &succeed_result
2148 };
2149 match loser {
2150 Err(TransactionStoreError::ConcurrentStatusChange(id)) => {
2151 assert_eq!(id, &tx.transaction_id);
2152 }
2153 Err(TransactionStoreError::InvalidTransition { from, to }) => {
2154 let winner = if succeed_result.is_ok() {
2156 JobState::Succeeded
2157 } else {
2158 JobState::Failed
2159 };
2160 assert_eq!(
2161 *from, winner,
2162 "the loser must have observed the winner's committed status"
2163 );
2164 assert_ne!(*to, winner, "the loser must be the other transition");
2165 }
2166 other => panic!(
2167 "the losing transition must be refused with either \
2168 ConcurrentStatusChange or InvalidTransition, got: {other:?}"
2169 ),
2170 }
2171
2172 let final_status = store.get(&tx.transaction_id).unwrap().unwrap().status;
2175 if succeed_result.is_ok() {
2176 assert_eq!(final_status, JobState::Succeeded);
2177 } else {
2178 assert_eq!(final_status, JobState::Failed);
2179 }
2180 }
2181
2182 #[test]
2183 fn revoke_unconsumed_approval_removes_an_unused_receipt() {
2184 let dir = tempdir().unwrap();
2187 let store = test_store(dir.path().join("tx.db"));
2188 let tx = store.record(queued_transaction()).unwrap();
2189 let receipt = store
2190 .approve_transaction(&tx.transaction_id)
2191 .unwrap()
2192 .expect("approved");
2193 let digest = audit_chain::approval_receipt_digest(&receipt);
2194
2195 assert!(
2196 store
2197 .revoke_unconsumed_approval(&tx.transaction_id)
2198 .unwrap(),
2199 "an unconsumed approval must be revocable"
2200 );
2201 assert!(
2202 !store
2203 .claim_approved_for_execution(&tx.transaction_id, &digest)
2204 .unwrap(),
2205 "a revoked receipt must no longer be claimable"
2206 );
2207 }
2208
2209 #[test]
2210 fn revoke_cannot_retract_an_approval_that_was_already_used() {
2211 let dir = tempdir().unwrap();
2215 let store = test_store(dir.path().join("tx.db"));
2216 let tx = store.record(queued_transaction()).unwrap();
2217 let receipt = store
2218 .approve_transaction(&tx.transaction_id)
2219 .unwrap()
2220 .expect("approved");
2221 let digest = audit_chain::approval_receipt_digest(&receipt);
2222 assert!(store
2223 .claim_approved_for_execution(&tx.transaction_id, &digest)
2224 .unwrap());
2225
2226 assert!(
2227 !store
2228 .revoke_unconsumed_approval(&tx.transaction_id)
2229 .unwrap(),
2230 "a consumed approval must not be revocable"
2231 );
2232 assert_eq!(
2233 store.get(&tx.transaction_id).unwrap().unwrap().status,
2234 JobState::Running,
2235 "a failed revocation must not disturb the running transaction"
2236 );
2237 }
2238
2239 #[test]
2240 fn approve_refuses_a_transaction_that_is_no_longer_queued() {
2241 let dir = tempdir().unwrap();
2245 let store = test_store(dir.path().join("tx.db"));
2246 let tx = store.record(queued_transaction()).unwrap();
2247 let receipt = store
2248 .approve_transaction(&tx.transaction_id)
2249 .unwrap()
2250 .expect("approved");
2251 let digest = audit_chain::approval_receipt_digest(&receipt);
2252 assert!(store
2253 .claim_approved_for_execution(&tx.transaction_id, &digest)
2254 .unwrap());
2255
2256 assert!(
2258 store
2259 .approve_transaction(&tx.transaction_id)
2260 .unwrap()
2261 .is_none(),
2262 "a Running transaction must not be approvable"
2263 );
2264
2265 store
2267 .update_status(&tx.transaction_id, JobState::Succeeded)
2268 .unwrap();
2269 assert!(
2270 store
2271 .approve_transaction(&tx.transaction_id)
2272 .unwrap()
2273 .is_none(),
2274 "a completed transaction must not be approvable"
2275 );
2276 }
2277
2278 #[test]
2279 fn only_one_of_two_concurrent_claims_can_execute() {
2280 let dir = tempdir().unwrap();
2285 let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
2286 let tx = store.record(queued_transaction()).unwrap();
2287 let receipt = store
2288 .approve_transaction(&tx.transaction_id)
2289 .unwrap()
2290 .expect("approved");
2291 let digest = audit_chain::approval_receipt_digest(&receipt);
2292
2293 let mut handles = Vec::new();
2294 for _ in 0..2 {
2295 let store = std::sync::Arc::clone(&store);
2296 let id = tx.transaction_id.clone();
2297 let digest = digest.clone();
2298 handles.push(std::thread::spawn(move || {
2299 store.claim_approved_for_execution(&id, &digest)
2300 }));
2301 }
2302 let claims: Vec<bool> = handles
2303 .into_iter()
2304 .map(|h| h.join().unwrap().unwrap_or(false))
2305 .collect();
2306
2307 assert_eq!(
2308 claims.iter().filter(|c| **c).count(),
2309 1,
2310 "exactly one claim may win: {claims:?}"
2311 );
2312 assert_eq!(
2313 store.get(&tx.transaction_id).unwrap().unwrap().status,
2314 JobState::Running
2315 );
2316 }
2317
2318 #[test]
2319 fn cancel_queued_cancels_a_queued_transaction_once() {
2320 let dir = tempdir().unwrap();
2321 let store = test_store(dir.path().join("tx.db"));
2322 let tx = store.record(queued_transaction()).unwrap();
2323
2324 assert!(
2325 store.cancel_queued(&tx.transaction_id).unwrap(),
2326 "a queued transaction is cancelable"
2327 );
2328 assert_eq!(
2329 store.get(&tx.transaction_id).unwrap().unwrap().status,
2330 JobState::Canceled
2331 );
2332 assert!(
2333 !store.cancel_queued(&tx.transaction_id).unwrap(),
2334 "an already-canceled transaction is not cancelable again"
2335 );
2336 assert!(
2337 !store.cancel_queued("no-such-transaction").unwrap(),
2338 "a missing transaction is not cancelable"
2339 );
2340 }
2341
2342 #[test]
2343 fn cancel_queued_refuses_a_running_transaction() {
2344 let dir = tempdir().unwrap();
2347 let store = test_store(dir.path().join("tx.db"));
2348 let tx = store.record(queued_transaction()).unwrap();
2349 let receipt = store
2350 .approve_transaction(&tx.transaction_id)
2351 .unwrap()
2352 .expect("approved");
2353 let digest = audit_chain::approval_receipt_digest(&receipt);
2354 assert!(store
2355 .claim_approved_for_execution(&tx.transaction_id, &digest)
2356 .unwrap());
2357
2358 assert!(
2359 !store.cancel_queued(&tx.transaction_id).unwrap(),
2360 "a running transaction must not be cancelable"
2361 );
2362 assert_eq!(
2363 store.get(&tx.transaction_id).unwrap().unwrap().status,
2364 JobState::Running,
2365 "cancel must not disturb a running transaction"
2366 );
2367 }
2368
2369 #[test]
2370 fn approved_receipt_is_required_and_consumed_once() {
2371 let dir = tempdir().unwrap();
2372 let store = test_store(dir.path().join("tx.db"));
2373 let tx = store.record(queued_transaction()).unwrap();
2374
2375 assert!(
2376 !store
2377 .claim_approved_for_execution(&tx.transaction_id, "digest-a")
2378 .unwrap(),
2379 "an unapproved preview must not execute"
2380 );
2381 let receipt = store
2382 .approve_transaction(&tx.transaction_id)
2383 .unwrap()
2384 .expect("first approval must succeed");
2385 let digest = audit_chain::approval_receipt_digest(&receipt);
2386 assert_eq!(tx.approval_id.as_deref(), Some(digest.as_str()));
2387 assert!(
2388 store
2389 .approve_transaction(&tx.transaction_id)
2390 .unwrap()
2391 .is_none(),
2392 "approval is immutable once issued"
2393 );
2394 assert!(
2395 !store
2396 .claim_approved_for_execution(&tx.transaction_id, "wrong-digest")
2397 .unwrap(),
2398 "a forged receipt must not execute"
2399 );
2400 assert!(
2401 store
2402 .claim_approved_for_execution(&tx.transaction_id, &digest)
2403 .unwrap(),
2404 "the exact approved receipt must execute"
2405 );
2406 assert!(
2407 !store
2408 .claim_approved_for_execution(&tx.transaction_id, &digest)
2409 .unwrap(),
2410 "the receipt must be one-time"
2411 );
2412 }
2413
2414 #[test]
2415 fn approval_commitment_is_covered_by_the_signed_chain() {
2416 let dir = tempdir().unwrap();
2417 let store = test_store(dir.path().join("tx.db"));
2418 let tx = store.record(queued_transaction()).unwrap();
2419 assert!(tx.approval_id.is_some());
2420
2421 store
2422 .connection()
2423 .unwrap()
2424 .execute(
2425 "UPDATE transactions SET approval_id = 'forged' WHERE transaction_id = ?1",
2426 params![tx.transaction_id],
2427 )
2428 .unwrap();
2429
2430 let key = AuditKey::from_bytes(vec![0x42; 32]);
2431 assert!(matches!(
2432 store.verify_audit_chain(&key).unwrap(),
2433 VerifyOutcome::Broken { .. }
2434 ));
2435 }
2436
2437 #[test]
2438 fn approve_rejects_a_forged_commitment_at_runtime() {
2439 let dir = tempdir().unwrap();
2443 let store = test_store(dir.path().join("tx.db"));
2444 let tx = store.record(queued_transaction()).unwrap();
2445
2446 store
2447 .connection()
2448 .unwrap()
2449 .execute(
2450 "UPDATE transactions SET approval_id = 'forged' WHERE transaction_id = ?1",
2451 params![tx.transaction_id],
2452 )
2453 .unwrap();
2454
2455 let err = store
2456 .approve_transaction(&tx.transaction_id)
2457 .expect_err("a forged commitment must be rejected, not approved");
2458 assert!(
2459 matches!(err, TransactionStoreError::DatabaseInvariant(_)),
2460 "forged commitment must surface as a DatabaseInvariant, got {err:?}"
2461 );
2462 }
2463
2464 #[test]
2465 fn stale_iso_timestamp_cannot_be_approved() {
2466 let dir = tempdir().unwrap();
2467 let store = test_store(dir.path().join("tx.db"));
2468 let tx = store.record(queued_transaction()).unwrap();
2469 let conn = store.connection().unwrap();
2470 conn.execute(
2471 "UPDATE transactions \
2472 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
2473 WHERE transaction_id = ?1",
2474 params![tx.transaction_id],
2475 )
2476 .unwrap();
2477
2478 assert!(
2479 store
2480 .approve_transaction(&tx.transaction_id)
2481 .unwrap()
2482 .is_none(),
2483 "a production-format timestamp outside the TTL must not be approved"
2484 );
2485 }
2486
2487 #[test]
2488 fn stale_iso_timestamp_cannot_be_claimed_for_execution() {
2489 let dir = tempdir().unwrap();
2494 let store = test_store(dir.path().join("tx.db"));
2495 let tx = store.record(queued_transaction()).unwrap();
2496 let receipt = store
2497 .approve_transaction(&tx.transaction_id)
2498 .unwrap()
2499 .expect("a fresh approval succeeds");
2500 let digest = audit_chain::approval_receipt_digest(&receipt);
2501
2502 let conn = store.connection().unwrap();
2504 conn.execute(
2505 "UPDATE transactions \
2506 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
2507 WHERE transaction_id = ?1",
2508 params![tx.transaction_id],
2509 )
2510 .unwrap();
2511
2512 assert!(
2513 !store
2514 .claim_approved_for_execution(&tx.transaction_id, &digest)
2515 .unwrap(),
2516 "an approval aged past the TTL must not be claimable at execute time"
2517 );
2518 assert_eq!(
2519 store.get(&tx.transaction_id).unwrap().unwrap().status,
2520 JobState::Queued,
2521 "a TTL-expired claim must leave the transaction Queued, never Running"
2522 );
2523 }
2524
2525 #[test]
2526 fn cleanup_stale_queued_cancels_old_records() {
2527 let dir = tempdir().unwrap();
2528 let store = test_store(dir.path().join("tx.db"));
2529
2530 let fresh = store.record(queued_transaction()).unwrap();
2532 let stale = store.record(queued_transaction()).unwrap();
2533
2534 let conn = store.connection().unwrap();
2536 conn.execute(
2537 "UPDATE transactions \
2538 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
2539 WHERE transaction_id = ?1",
2540 params![stale.transaction_id],
2541 )
2542 .unwrap();
2543
2544 let canceled = store.cleanup_stale_queued().unwrap();
2545 assert_eq!(canceled, 1, "only the stale record should be canceled");
2546
2547 let stale_record = store.get(&stale.transaction_id).unwrap().unwrap();
2549 assert_eq!(stale_record.status, JobState::Canceled);
2550
2551 let fresh_record = store.get(&fresh.transaction_id).unwrap().unwrap();
2553 assert_eq!(fresh_record.status, JobState::Queued);
2554 }
2555
2556 #[test]
2559 fn update_status_rejects_queued_to_succeeded() {
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 let result = store.update_status(&tx.transaction_id, JobState::Succeeded);
2565 assert!(
2566 matches!(
2567 result,
2568 Err(TransactionStoreError::InvalidTransition {
2569 from: JobState::Queued,
2570 to: JobState::Succeeded,
2571 })
2572 ),
2573 "Queued -> Succeeded must be rejected (must go through Running first): {result:?}"
2574 );
2575 }
2576
2577 #[test]
2578 fn update_status_rejects_succeeded_to_running() {
2579 let dir = tempdir().unwrap();
2580 let store = test_store(dir.path().join("tx.db"));
2581 let tx = store.record(queued_transaction()).unwrap();
2582
2583 store
2584 .update_status(&tx.transaction_id, JobState::Running)
2585 .unwrap();
2586 store
2587 .update_status(&tx.transaction_id, JobState::Succeeded)
2588 .unwrap();
2589
2590 let result = store.update_status(&tx.transaction_id, JobState::Running);
2591 assert!(
2592 matches!(
2593 result,
2594 Err(TransactionStoreError::InvalidTransition {
2595 from: JobState::Succeeded,
2596 to: JobState::Running,
2597 })
2598 ),
2599 "Succeeded -> Running must be rejected (terminal state): {result:?}"
2600 );
2601 }
2602
2603 #[test]
2604 fn update_status_accepts_running_to_failed() {
2605 let dir = tempdir().unwrap();
2606 let store = test_store(dir.path().join("tx.db"));
2607 let tx = store.record(queued_transaction()).unwrap();
2608
2609 store
2610 .update_status(&tx.transaction_id, JobState::Running)
2611 .unwrap();
2612 store
2613 .update_status(&tx.transaction_id, JobState::Failed)
2614 .unwrap();
2615
2616 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2617 assert_eq!(updated.status, JobState::Failed);
2618 }
2619
2620 #[test]
2621 fn update_status_accepts_running_to_rolled_back() {
2622 let dir = tempdir().unwrap();
2623 let store = test_store(dir.path().join("tx.db"));
2624 let tx = store.record(queued_transaction()).unwrap();
2625
2626 store
2627 .update_status(&tx.transaction_id, JobState::Running)
2628 .unwrap();
2629 store
2630 .update_status(&tx.transaction_id, JobState::RolledBack)
2631 .unwrap();
2632
2633 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2634 assert_eq!(updated.status, JobState::RolledBack);
2635 }
2636
2637 #[test]
2640 fn list_transactions_returns_empty_for_fresh_store() {
2641 let dir = tempdir().unwrap();
2642 let store = test_store(dir.path().join("tx.db"));
2643 let results = store.list_transactions(10, None, None, None).unwrap();
2644 assert!(results.is_empty());
2645 }
2646
2647 #[test]
2648 fn list_transactions_returns_all_records_ordered_by_newest_first() {
2649 let dir = tempdir().unwrap();
2650 let store = test_store(dir.path().join("tx.db"));
2651 store.record(queued_transaction()).unwrap();
2652
2653 let mut second = queued_transaction();
2654 second.action_name = "GetDiskUsage".to_string();
2655 second.risk_level = RiskLevel::Low;
2656 store.record(second).unwrap();
2657
2658 let results = store.list_transactions(10, None, None, None).unwrap();
2659 assert_eq!(results.len(), 2);
2660 assert_eq!(results[0].action_name, "GetDiskUsage");
2662 assert_eq!(results[1].action_name, "UpdateSystem");
2663 }
2664
2665 #[test]
2666 fn list_history_populates_created_at_and_risk_level() {
2667 let dir = tempdir().unwrap();
2668 let store = test_store(dir.path().join("tx.db"));
2669 store.record(queued_transaction()).unwrap();
2670
2671 let entries = store.list_history(10, None, None, None).unwrap();
2672 assert_eq!(entries.len(), 1);
2673 let entry = &entries[0];
2674 assert_eq!(entry.action_name, "UpdateSystem");
2675 assert_eq!(entry.risk_level, RiskLevel::High);
2676 assert_eq!(entry.status, JobState::Queued);
2677 assert!(
2678 !entry.created_at.is_empty(),
2679 "created_at must be populated from the stored row, not left blank"
2680 );
2681 }
2682
2683 #[test]
2684 fn list_history_applies_the_same_filters_as_list_transactions() {
2685 let dir = tempdir().unwrap();
2686 let store = test_store(dir.path().join("tx.db"));
2687 store.record(queued_transaction()).unwrap();
2688 let mut low = queued_transaction();
2689 low.action_name = "GetDiskUsage".to_string();
2690 low.risk_level = RiskLevel::Low;
2691 store.record(low).unwrap();
2692
2693 let only = store
2694 .list_history(10, None, Some("GetDiskUsage"), None)
2695 .unwrap();
2696 assert_eq!(only.len(), 1);
2697 assert_eq!(only[0].action_name, "GetDiskUsage");
2698 }
2699
2700 #[test]
2701 fn list_transactions_respects_limit() {
2702 let dir = tempdir().unwrap();
2703 let store = test_store(dir.path().join("tx.db"));
2704 for _ in 0..5 {
2705 store.record(queued_transaction()).unwrap();
2706 }
2707 let results = store.list_transactions(3, None, None, None).unwrap();
2708 assert_eq!(results.len(), 3);
2709 }
2710
2711 #[test]
2712 fn list_transactions_filters_by_status() {
2713 let dir = tempdir().unwrap();
2714 let store = test_store(dir.path().join("tx.db"));
2715 let tx = store.record(queued_transaction()).unwrap();
2716 store
2717 .update_status(&tx.transaction_id, JobState::Running)
2718 .unwrap();
2719 store
2720 .update_status(&tx.transaction_id, JobState::Succeeded)
2721 .unwrap();
2722
2723 store.record(queued_transaction()).unwrap();
2725
2726 let succeeded = store
2727 .list_transactions(10, Some("succeeded"), None, None)
2728 .unwrap();
2729 assert_eq!(succeeded.len(), 1);
2730 assert_eq!(succeeded[0].status, JobState::Succeeded);
2731
2732 let queued = store
2733 .list_transactions(10, Some("queued"), None, None)
2734 .unwrap();
2735 assert_eq!(queued.len(), 1);
2736 assert_eq!(queued[0].status, JobState::Queued);
2737 }
2738
2739 #[test]
2740 fn list_transactions_filters_by_action_name() {
2741 let dir = tempdir().unwrap();
2742 let store = test_store(dir.path().join("tx.db"));
2743 store.record(queued_transaction()).unwrap(); let mut disk = queued_transaction();
2746 disk.action_name = "GetDiskUsage".to_string();
2747 store.record(disk).unwrap();
2748
2749 let results = store
2750 .list_transactions(10, None, Some("GetDiskUsage"), None)
2751 .unwrap();
2752 assert_eq!(results.len(), 1);
2753 assert_eq!(results[0].action_name, "GetDiskUsage");
2754 }
2755
2756 #[test]
2757 fn list_transactions_filters_by_since_hours() {
2758 let dir = tempdir().unwrap();
2759 let store = test_store(dir.path().join("tx.db"));
2760
2761 let old = store.record(queued_transaction()).unwrap();
2763 let conn = store.connection().unwrap();
2764 conn.execute(
2765 "UPDATE transactions \
2766 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-48 hours') \
2767 WHERE transaction_id = ?1",
2768 params![old.transaction_id],
2769 )
2770 .unwrap();
2771
2772 store.record(queued_transaction()).unwrap();
2774
2775 let results = store.list_transactions(10, None, None, Some(24)).unwrap();
2777 assert_eq!(results.len(), 1);
2778
2779 let results = store.list_transactions(10, None, None, Some(72)).unwrap();
2781 assert_eq!(results.len(), 2);
2782 }
2783
2784 #[test]
2785 fn list_transactions_rejects_invalid_status_filter() {
2786 let dir = tempdir().unwrap();
2787 let store = test_store(dir.path().join("tx.db"));
2788 store.record(queued_transaction()).unwrap();
2789 let result = store.list_transactions(10, Some("bogus"), None, None);
2790 assert!(result.is_err(), "invalid status filter should return error");
2791 }
2792
2793 #[test]
2801 fn record_emits_one_watermark_per_entry() {
2802 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2803 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
2804
2805 let dir = tempdir().unwrap();
2806 let store = test_store(dir.path().join("tx.db"));
2807 store.record(queued_transaction()).unwrap();
2808
2809 let calls = crate::audit_watermark::take_watermarks(&sink);
2810 assert_eq!(calls.len(), 1, "expected exactly 1 watermark per record()");
2811 }
2812
2813 #[test]
2815 fn record_previewed_emits_one_watermark() {
2816 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2817 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
2818
2819 let dir = tempdir().unwrap();
2820 let store = test_store(dir.path().join("tx.db"));
2821 let preview = PreviewEnvelope {
2822 summary: "Upgrade the system".to_string(),
2823 risk_level: RiskLevel::High,
2824 current_state: serde_json::Value::Null,
2825 proposed_change: serde_json::Value::Null,
2826 expected_side_effects: vec![],
2827 reboot_required: false,
2828 rollback_available: false,
2829 warnings: vec![],
2830 request_hash: sysknife_types::RequestHash::from("hash-abc".to_string()),
2831 };
2832 store
2833 .record_previewed(queued_transaction(), preview)
2834 .unwrap();
2835
2836 let calls = crate::audit_watermark::take_watermarks(&sink);
2837 assert_eq!(
2838 calls.len(),
2839 1,
2840 "expected exactly 1 watermark per record_previewed()"
2841 );
2842 }
2843
2844 #[test]
2846 fn watermark_seq_and_hash_match_chain_row() {
2847 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2848 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
2849
2850 let dir = tempdir().unwrap();
2851 let store = test_store(dir.path().join("tx.db"));
2852 store.record(queued_transaction()).unwrap();
2853
2854 let rows = store.fetch_chain_rows().unwrap();
2855 assert_eq!(rows.len(), 1);
2856 let row = &rows[0];
2857
2858 let calls = crate::audit_watermark::take_watermarks(&sink);
2859 assert_eq!(calls.len(), 1);
2860 assert_eq!(calls[0].seq, row.seq, "watermark seq must match chain row");
2861 assert_eq!(
2862 calls[0].chain_hash_hex, row.chain_hash,
2863 "watermark chain_hash_hex must match stored chain_hash"
2864 );
2865 }
2866
2867 #[test]
2869 fn multiple_records_produce_one_watermark_each() {
2870 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2871 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
2872
2873 let dir = tempdir().unwrap();
2874 let store = test_store(dir.path().join("tx.db"));
2875 for _ in 0..3 {
2876 store.record(queued_transaction()).unwrap();
2877 }
2878
2879 let calls = crate::audit_watermark::take_watermarks(&sink);
2880 assert_eq!(calls.len(), 3, "one watermark per record call");
2881 assert_eq!(calls[0].seq, 1);
2882 assert_eq!(calls[1].seq, 2);
2883 assert_eq!(calls[2].seq, 3);
2884 }
2885
2886 #[test]
2900 fn no_watermark_emitted_before_any_record() {
2901 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2902 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
2903
2904 let dir = tempdir().unwrap();
2905 let _store = test_store(dir.path().join("tx.db"));
2906
2907 let calls = crate::audit_watermark::take_watermarks(&sink);
2909 assert!(
2910 calls.is_empty(),
2911 "no watermark must be emitted without a record() call"
2912 );
2913 }
2914}