From 1237a196d5b25ca35ab6e5cf6b47314d6775272e Mon Sep 17 00:00:00 2001 From: yap Date: Tue, 22 Sep 2026 23:27:49 +0800 Subject: [PATCH] feat(daemon): sign the acting account into approval events The approval-event chain signs seq/key_id/kind/transaction_id/ receipt_digest/created_at and no account, so a grant and a consume by different uids leave one indistinguishable signed trace. The transaction chain already signs the requester (ChainIdentity::V3); the event chain did not. Add EventIdentity { LegacyV1, V2 { caller_principal } } beside ChainIdentity with the same discipline: an appended suffix on the six-field message, prefix-free framing, exhaustive match. event_message selects per row, so `sysknife audit verify` stays correct over a mixed legacy+V2 chain. Migration 4 on both backends adds chain_version (NOT NULL DEFAULT 1, so every historical row lands on LegacyV1 without a rewrite) and a nullable caller_principal, never backfilled because rewriting an old row's message would report the whole chain as Broken. The stored chain_version is derived from the same identity that was signed, so the column is provably the version whose message produced the signature. Thread CallerPrincipal through approve_transaction (approver), claim_approved_for_execution (executor), and revoke_unconsumed_approval (revoker) across TransactionStore, the AuditStore trait, both backends, and the dispatcher call sites. cancel_queued signs the cancelling account (handle_cancel holds the caller); only the daemon-side stale sweep records Unattributed. Status events stay LegacyV1: they are written from spawned execution tasks with no caller in scope, and signing them with the creator's principal would be a claim the code cannot support. Tests: a committed legacy golden-vector hash (a literal, not recomputed) fails if the six-field encoding is edited even though in-process round-trips stay green; V2 sign/verify, grant-vs-consume by different uids distinguishable, stored-principal tamper Broken, V2->LegacyV1 downgrade Broken, blank/absent principal on V2 Broken, unknown version CannotVerify (exit 2), mixed chain Intact; SQLite + live-Postgres round-trip persistence; migration-count assertions 3->4. Baseline 1873 -> 1883. Closes #249 --- README.md | 2 +- crates/sysknife-daemon/src/audit_chain.rs | 448 ++++++++++++++++++ crates/sysknife-daemon/src/dispatcher.rs | 67 ++- crates/sysknife-daemon/src/store.rs | 27 +- crates/sysknife-daemon/src/store/postgres.rs | 112 ++++- crates/sysknife-daemon/src/transactions.rs | 377 ++++++++++++--- .../sysknife-daemon/tests/postgres_store.rs | 52 +- docs/distro-support.md | 2 +- docs/introduction.md | 2 +- tests/evidence/workspace-tests.json | 2 +- 10 files changed, 964 insertions(+), 127 deletions(-) diff --git a/README.md b/README.md index a0b4b706..9ceb00aa 100644 --- a/README.md +++ b/README.md @@ -315,7 +315,7 @@ milestone. | **Every Ubuntu LTS validated** โ€” 22.04, 24.04 and 26.04 all at 79/79, each with a replay twin that reproduces it | โœ… | | Telegram approval interface | ๐Ÿ“‹ roadmap | -**1,873 Rust tests and 72 frontend tests** form the current deterministic +**1,883 Rust tests and 72 frontend tests** form the current deterministic release baseline. ## Configure your LLM diff --git a/crates/sysknife-daemon/src/audit_chain.rs b/crates/sysknife-daemon/src/audit_chain.rs index 63b77ce2..a4ae1a01 100644 --- a/crates/sysknife-daemon/src/audit_chain.rs +++ b/crates/sysknife-daemon/src/audit_chain.rs @@ -139,6 +139,24 @@ pub const CHAIN_VERSION_V3: u32 = 3; /// stored generation must keep being reproduced byte for byte forever. pub const CHAIN_VERSION_CURRENT: u32 = CHAIN_VERSION_V3; +/// Event-row encoding written before the approver-identity migration: the +/// six-field message with no account among them. Still verifiable โ€” see +/// [`EventIdentity`]. +pub const EVENT_VERSION_LEGACY: u32 = 1; +/// Event-row encoding that appends `caller_principal`, so a verified approval +/// event names the account that granted, consumed or revoked the receipt +/// instead of leaving the investigator to infer it from the daemon's +/// authorization rules (#249). +/// +/// A stable literal for the same reason [`CHAIN_VERSION_V2`] is one: the +/// encoder signs this value and `EventRow::identity` dispatches stored rows +/// against it, so it must not move when a future encoding arrives. +pub const EVENT_VERSION_V2: u32 = 2; +/// The event encoding this binary *writes* for approval events. Always an +/// alias for the newest versioned constant, never used to dispatch a specific +/// generation. +pub const EVENT_VERSION_CURRENT: u32 = EVENT_VERSION_V2; + /// Loaded Ed25519 signing key + its identifier. Construct via /// [`AuditKey::load_or_generate`]. /// @@ -1046,6 +1064,47 @@ impl AuditEventKind { } } +/// Encoding generation of an approval-event row, mirroring what +/// [`ChainIdentity`] does for transaction rows: the `audit_events.chain_version` +/// column selects, per row, which message its signature was made over, so rows +/// written by an older binary keep verifying (#249). +/// +/// A downgrade is not a hiding place, for the same reason as on the transaction +/// chain: rewriting a `V2` row as `LegacyV1` (to erase `caller_principal`) +/// makes verification re-encode it without the identity fields, the stored +/// signature no longer matches, and the row reports `Broken`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EventIdentity<'a> { + /// `chain_version = 1`. The six-field encoding every event row written + /// before the approver-identity migration was signed under. It stays + /// byte-identical forever: historical rows have to keep verifying. + LegacyV1, + /// `chain_version = 2`. Appends the account that performed this event's + /// operation โ€” the approver on `approval_granted`, the executor on + /// `approval_consumed`, the revoker on `approval_revoked`. Status events + /// stay `LegacyV1`: they are written from spawned execution tasks with no + /// caller attribution in scope, and signing an account the code cannot + /// see would be a signed guess. + V2 { + /// Scheme-prefixed identity rendered by [`crate::auth::CallerPrincipal`] + /// (`uid:1000`, `token:vsock`, `none:unattributed`). The scheme is + /// signed along with the value for the same reason as on the + /// transaction chain: the evidence classes differ in strength and an + /// auditor must be able to tell them apart. + caller_principal: &'a str, + }, +} + +impl EventIdentity<'_> { + /// `chain_version` column value for this encoding. + pub fn version(&self) -> u32 { + match self { + Self::LegacyV1 => EVENT_VERSION_LEGACY, + Self::V2 { .. } => EVENT_VERSION_V2, + } + } +} + /// Immutable content of one approval event, signed into the event chain. #[derive(Debug, Clone, PartialEq, Eq)] pub struct EventContent<'a> { @@ -1058,6 +1117,8 @@ pub struct EventContent<'a> { /// signature. pub receipt_digest: &'a str, pub created_at: &'a str, + /// Encoding generation. See [`EventIdentity`] for why this is per-row. + pub identity: EventIdentity<'a>, } impl EventContent<'_> { @@ -1071,6 +1132,18 @@ impl EventContent<'_> { push_field(&mut buf, "transaction_id", self.transaction_id); push_field(&mut buf, "receipt_digest", self.receipt_digest); push_field(&mut buf, "created_at", self.created_at); + // Per-generation suffixes are *appended*, so a legacy row's bytes are + // unchanged and its signature still verifies โ€” the same contract + // `ChainContent::canonical_bytes` documents, for the same reason. The + // match is exhaustive so a new generation that forgets its arm is a + // compile error rather than a silent alias of `LegacyV1`. + match self.identity { + EventIdentity::LegacyV1 => {} + EventIdentity::V2 { caller_principal } => { + push_field(&mut buf, "event_version", &EVENT_VERSION_V2.to_string()); + push_field(&mut buf, "caller_principal", caller_principal); + } + } buf } } @@ -1109,6 +1182,34 @@ pub struct EventRow { pub created_at: String, pub prev_chain_hash: String, pub chain_hash: String, + /// Encoding generation this row was signed under. `1` for every row + /// written before the approver-identity migration โ€” see [`EventIdentity`]. + pub chain_version: u32, + /// Account that performed this event's operation, when the row was signed + /// under an encoding that carries one. `None` (or blank) on legacy rows; + /// a blank on a `chain_version = 2` row is a detected break, mirroring the + /// transaction chain's rule that a row naming nobody must not pass for + /// one naming an account. + pub caller_principal: Option, +} + +impl EventRow { + /// Recover the encoding this row was signed under, so verification can + /// rebuild the exact message that produced `chain_hash`. Mirrors + /// `ChainRow::identity`, including the blank-principal guard. + pub(crate) fn identity(&self) -> Result, RowIdentityError> { + match self.chain_version { + EVENT_VERSION_LEGACY => Ok(EventIdentity::LegacyV1), + EVENT_VERSION_V2 => Ok(EventIdentity::V2 { + caller_principal: self + .caller_principal + .as_deref() + .filter(|p| !p.is_empty()) + .ok_or(RowIdentityError::MissingField("caller_principal"))?, + }), + other => Err(RowIdentityError::UnknownVersion(other)), + } + } } /// Verify the approval-event chain with the daemon's key. @@ -1169,6 +1270,36 @@ fn verify_event_rows( actual: format!("kind={:?}", row.kind), }; }; + // Recover the per-row encoding, mirroring the transaction chain's + // split: a version this binary cannot reproduce is genuinely + // unverifiable (an older binary reading a newer chain), while a + // self-contradictory row โ€” a v2 encoding with no principal to sign โ€” + // is a detected break, not an inability to check. + let identity = match row.identity() { + Ok(identity) => identity, + Err(RowIdentityError::UnknownVersion(v)) => { + return VerifyOutcome::CannotVerify { + reason: format!( + "event seq={} declares chain_version={v}, which this binary cannot \ + reproduce (it understands {EVENT_VERSION_LEGACY}..={EVENT_VERSION_CURRENT}); \ + verify with a build at least as new as the one that wrote the chain", + row.seq + ), + }; + } + Err(RowIdentityError::MissingField(field)) => { + return VerifyOutcome::Broken { + rows_checked, + first_broken_seq: row.seq, + first_broken_transaction_id: row.transaction_id.clone(), + expected: format!( + "a non-empty {field} on a chain_version={} row", + row.chain_version + ), + actual: format!("{field}={:?}", row.caller_principal), + }; + } + }; let content = EventContent { seq: row.seq, key_id: &row.key_id, @@ -1176,6 +1307,7 @@ fn verify_event_rows( transaction_id: &row.transaction_id, receipt_digest: &row.receipt_digest, created_at: &row.created_at, + identity, }; if !signature_ok( vk, @@ -2997,6 +3129,10 @@ mod tests { transaction_id: txid, receipt_digest: "digest-abc", created_at: "2026-04-24T12:00:00Z", + // The pre-existing event-chain tests verify the legacy six-field + // encoding every historical row was signed under. The V2 encoding + // (with a principal) has its own tests below. + identity: EventIdentity::LegacyV1, } } @@ -3017,12 +3153,324 @@ mod tests { created_at: content.created_at.to_string(), prev_chain_hash: prev.clone(), chain_hash: hash.clone(), + chain_version: content.identity.version(), + caller_principal: match content.identity { + EventIdentity::V2 { caller_principal } => Some(caller_principal.to_string()), + EventIdentity::LegacyV1 => None, + }, }); prev = hash; } rows } + /// Build one signed event row under an explicit identity, linked to `prev`. + /// The storage layer derives `chain_version`/`caller_principal` from the + /// same `identity` that was signed, so a row built here is byte-for-byte + /// what `append_event` would persist for that encoding. + #[allow(clippy::too_many_arguments)] + fn event_row( + key: &AuditKey, + seq: u64, + kind: AuditEventKind, + txid: &str, + receipt_digest: &str, + created_at: &str, + identity: EventIdentity<'_>, + prev: &str, + ) -> EventRow { + let content = EventContent { + seq, + key_id: CURRENT_KEY_ID, + kind, + transaction_id: txid, + receipt_digest, + created_at, + identity, + }; + let hash = key.event_hash(&content, prev); + EventRow { + seq, + key_id: CURRENT_KEY_ID.to_string(), + kind: kind.as_str().to_string(), + transaction_id: txid.to_string(), + receipt_digest: receipt_digest.to_string(), + created_at: created_at.to_string(), + prev_chain_hash: prev.to_string(), + chain_hash: hash, + chain_version: identity.version(), + caller_principal: match identity { + EventIdentity::V2 { caller_principal } => Some(caller_principal.to_string()), + EventIdentity::LegacyV1 => None, + }, + } + } + + #[test] + fn a_legacy_event_row_signed_by_the_previous_release_still_verifies() { + // Committed golden vector for the six-field LegacyV1 encoding (#249). + // + // The hash below is a literal, NOT recomputed from `event_hash`. That + // is the whole point: if anyone edits the legacy encoding โ€” reorders + // the six fields, changes a tag, folds in a new field โ€” re-encoding + // the same row produces a different signature and this assertion + // fails, even though the in-process sign/verify round-trip would stay + // green because both sides moved together. The fixed key (`vec![0x42; + // 32]`) and every field value are spelled out so the message is fully + // reproducible by an auditor. + let key = fixed_key(); + let row = EventRow { + seq: 1, + key_id: CURRENT_KEY_ID.to_string(), + kind: AuditEventKind::ApprovalGranted.as_str().to_string(), + transaction_id: "tx-fixture-legacy".to_string(), + receipt_digest: "digest-fixture-legacy".to_string(), + created_at: "2026-08-18T09:00:00.000Z".to_string(), + prev_chain_hash: String::new(), + chain_hash: "1b7c2b864c13c8c2da5436b4a3d0d10ec37c129e97a4525228110c574fccf32b\ + d1a22001acb555faa016f6c50f26d5c783b447fbf866f8ff689ebec2fc7c340a" + .to_string(), + chain_version: EVENT_VERSION_LEGACY, + caller_principal: None, + }; + assert_eq!( + verify_event_chain(&key, &[row]), + VerifyOutcome::Intact { rows_checked: 1 }, + "the committed legacy event signature must still verify" + ); + } + + #[test] + fn a_v2_event_signs_and_verifies_with_the_acting_account() { + let key = fixed_key(); + let row = event_row( + &key, + 1, + AuditEventKind::ApprovalGranted, + "tx1", + "digest1", + "2026-08-18T09:00:00.000Z", + EventIdentity::V2 { + caller_principal: "uid:1000", + }, + "", + ); + assert_eq!(row.chain_version, EVENT_VERSION_V2); + assert_eq!(row.caller_principal.as_deref(), Some("uid:1000")); + assert_eq!( + verify_event_chain(&key, &[row]), + VerifyOutcome::Intact { rows_checked: 1 } + ); + } + + #[test] + fn a_grant_and_a_consume_by_different_accounts_are_distinguishable() { + // The acceptance criterion: the chain must name who granted and who + // consumed, so two accounts leave two different signed records. Both + // rows verify; the principals differ and are read back from the rows, + // not inferred from any authorization rule. + let key = fixed_key(); + let grant = event_row( + &key, + 1, + AuditEventKind::ApprovalGranted, + "tx1", + "digest1", + "2026-08-18T09:00:00.000Z", + EventIdentity::V2 { + caller_principal: "uid:1000", + }, + "", + ); + let consume = event_row( + &key, + 2, + AuditEventKind::ApprovalConsumed, + "tx1", + "digest1", + "2026-08-18T09:05:00.000Z", + EventIdentity::V2 { + caller_principal: "uid:1001", + }, + &grant.chain_hash, + ); + assert_eq!( + verify_event_chain(&key, &[grant.clone(), consume.clone()]), + VerifyOutcome::Intact { rows_checked: 2 } + ); + assert_eq!(grant.caller_principal.as_deref(), Some("uid:1000")); + assert_eq!(consume.caller_principal.as_deref(), Some("uid:1001")); + assert_ne!( + grant.caller_principal, consume.caller_principal, + "grant and consume by different uids must not collapse to one record" + ); + } + + #[test] + fn editing_a_stored_event_principal_reports_broken() { + // Acceptance: tampering the stored `caller_principal` on an event row + // must be detected. The signature covers the principal, so changing + // the column without re-signing breaks verification. + let key = fixed_key(); + let mut row = event_row( + &key, + 1, + AuditEventKind::ApprovalGranted, + "tx1", + "digest1", + "2026-08-18T09:00:00.000Z", + EventIdentity::V2 { + caller_principal: "uid:1000", + }, + "", + ); + assert_eq!( + verify_event_chain(&key, &[row.clone()]), + VerifyOutcome::Intact { rows_checked: 1 } + ); + row.caller_principal = Some("uid:9999".to_string()); + assert!( + matches!( + verify_event_chain(&key, &[row]), + VerifyOutcome::Broken { .. } + ), + "an edited principal must not still verify" + ); + } + + #[test] + fn downgrading_a_v2_event_to_legacy_reports_broken() { + // A downgrade is not a hiding place: rewriting a V2 row as LegacyV1 to + // erase the principal makes verification re-encode it without the + // identity fields, so the stored V2 signature no longer matches. + let key = fixed_key(); + let mut row = event_row( + &key, + 1, + AuditEventKind::ApprovalGranted, + "tx1", + "digest1", + "2026-08-18T09:00:00.000Z", + EventIdentity::V2 { + caller_principal: "uid:1000", + }, + "", + ); + row.chain_version = EVENT_VERSION_LEGACY; + row.caller_principal = None; + assert!(matches!( + verify_event_chain(&key, &[row]), + VerifyOutcome::Broken { .. } + )); + } + + #[test] + fn a_v2_event_naming_nobody_is_broken_not_accepted() { + // A blank or absent principal on a V2 row is a detected break, mirroring + // the transaction chain: a row that claims an identity encoding but + // names no account must not pass for one that does. + let key = fixed_key(); + let mut blank = event_row( + &key, + 1, + AuditEventKind::ApprovalGranted, + "tx1", + "digest1", + "2026-08-18T09:00:00.000Z", + EventIdentity::V2 { + caller_principal: "uid:1000", + }, + "", + ); + blank.caller_principal = Some(String::new()); + assert!( + matches!( + verify_event_chain(&key, &[blank]), + VerifyOutcome::Broken { .. } + ), + "a blank principal on a v2 row must be broken" + ); + + let mut absent = event_row( + &key, + 1, + AuditEventKind::ApprovalGranted, + "tx1", + "digest1", + "2026-08-18T09:00:00.000Z", + EventIdentity::V2 { + caller_principal: "uid:1000", + }, + "", + ); + absent.caller_principal = None; + assert!(matches!( + verify_event_chain(&key, &[absent]), + VerifyOutcome::Broken { .. } + )); + } + + #[test] + fn an_unknown_event_version_cannot_verify_rather_than_breaking() { + // An event row written by a NEWER binary declares a chain_version this + // build cannot reproduce. That is genuinely unverifiable (exit 2), not + // a detected tamper โ€” the same split the transaction chain draws. + let key = fixed_key(); + let mut row = event_row( + &key, + 1, + AuditEventKind::ApprovalGranted, + "tx1", + "digest1", + "2026-08-18T09:00:00.000Z", + EventIdentity::V2 { + caller_principal: "uid:1000", + }, + "", + ); + row.chain_version = EVENT_VERSION_CURRENT + 1; + let outcome = verify_event_chain(&key, &[row]); + assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. })); + assert_eq!(outcome_to_exit_code(&outcome), 2); + } + + #[test] + fn a_mixed_legacy_and_v2_event_chain_verifies() { + // `sysknife audit verify` must stay correct over a chain that spans the + // migration: historical rows on the six-field encoding, new rows with a + // principal, linked head to tail. This is the on-disk shape every host + // that upgrades will have. + let key = fixed_key(); + let legacy = event_row( + &key, + 1, + AuditEventKind::ApprovalGranted, + "tx-old", + "digest-old", + "2026-08-18T09:00:00.000Z", + EventIdentity::LegacyV1, + "", + ); + let v2 = event_row( + &key, + 2, + AuditEventKind::ApprovalGranted, + "tx-new", + "digest-new", + "2026-09-01T09:00:00.000Z", + EventIdentity::V2 { + caller_principal: "uid:1000", + }, + &legacy.chain_hash, + ); + assert_eq!(legacy.chain_version, EVENT_VERSION_LEGACY); + assert_eq!(v2.chain_version, EVENT_VERSION_V2); + assert_eq!( + verify_event_chain(&key, &[legacy, v2]), + VerifyOutcome::Intact { rows_checked: 2 } + ); + } + #[test] fn event_kind_spellings_are_stable() { // These strings are inside the signed message. Renaming one silently diff --git a/crates/sysknife-daemon/src/dispatcher.rs b/crates/sysknife-daemon/src/dispatcher.rs index 441f3170..132cfb7e 100644 --- a/crates/sysknife-daemon/src/dispatcher.rs +++ b/crates/sysknife-daemon/src/dispatcher.rs @@ -1626,7 +1626,11 @@ async fn handle_approve( { return Ok(()); } - let receipt = match state.audit.approve_transaction(transaction_id).await { + let receipt = match state + .audit + .approve_transaction(transaction_id, caller.principal()) + .await + { Ok(receipt) => receipt, // A `DatabaseInvariant` here means the stored approval commitment does // not match the signed preview (tamper / key mismatch) โ€” a fail-closed @@ -1671,7 +1675,11 @@ async fn handle_approve( ) .await; if response.is_err() { - if let Err(e) = state.audit.revoke_unconsumed_approval(transaction_id).await { + if let Err(e) = state + .audit + .revoke_unconsumed_approval(transaction_id, caller.principal()) + .await + { eprintln!( "[sysknife-daemon] failed to revoke undelivered approval for \ {transaction_id}: {e}" @@ -1816,7 +1824,11 @@ async fn handle_cancel( { return Ok(()); } - match state.audit.cancel_queued(transaction_id).await { + match state + .audit + .cancel_queued(transaction_id, caller.principal()) + .await + { Ok(true) => { send_response( framed, @@ -2865,7 +2877,11 @@ async fn handle_execute( let claimed = match state .audit - .claim_approved_for_execution(transaction_id, &receipt_digest(approval_receipt)) + .claim_approved_for_execution( + transaction_id, + &receipt_digest(approval_receipt), + caller.principal(), + ) .await { Ok(c) => c, @@ -4764,7 +4780,11 @@ mod tests { // Claim it (Queued -> Running) so it is in-flight from the store's view. assert!(state .audit - .claim_approved_for_execution(&transaction_id, &receipt_digest(&receipt)) + .claim_approved_for_execution( + &transaction_id, + &receipt_digest(&receipt), + CallerPrincipal::Uid(1000), + ) .await .unwrap()); @@ -4894,7 +4914,11 @@ mod tests { preview_and_approve(&mut framed, "GetMemoryInfo", json!({})).await; assert!(state .audit - .claim_approved_for_execution(&transaction_id, &receipt_digest(&receipt)) + .claim_approved_for_execution( + &transaction_id, + &receipt_digest(&receipt), + CallerPrincipal::Uid(1000), + ) .await .unwrap()); @@ -5579,7 +5603,11 @@ mod tests { ); assert!(state .audit - .claim_approved_for_execution(&transaction_id, &receipt_digest(&receipt)) + .claim_approved_for_execution( + &transaction_id, + &receipt_digest(&receipt), + CallerPrincipal::Uid(1000), + ) .await .unwrap()); } @@ -5633,7 +5661,11 @@ mod tests { ); assert!(state .audit - .claim_approved_for_execution(&transaction_id, &receipt_digest(&receipt)) + .claim_approved_for_execution( + &transaction_id, + &receipt_digest(&receipt), + CallerPrincipal::Uid(1000), + ) .await .unwrap()); } @@ -5843,27 +5875,36 @@ mod tests { async fn approve_transaction( &self, id: &str, + approver: CallerPrincipal, ) -> Result, TransactionStoreError> { - self.0.approve_transaction(id).await + self.0.approve_transaction(id, approver).await } async fn revoke_unconsumed_approval( &self, id: &str, + revoker: CallerPrincipal, ) -> Result { - self.0.revoke_unconsumed_approval(id).await + self.0.revoke_unconsumed_approval(id, revoker).await } async fn claim_approved_for_execution( &self, id: &str, digest: &str, + executor: CallerPrincipal, ) -> Result { - self.0.claim_approved_for_execution(id, digest).await + self.0 + .claim_approved_for_execution(id, digest, executor) + .await } async fn cleanup_stale_queued(&self) -> Result { self.0.cleanup_stale_queued().await } - async fn cancel_queued(&self, id: &str) -> Result { - self.0.cancel_queued(id).await + async fn cancel_queued( + &self, + id: &str, + canceller: CallerPrincipal, + ) -> Result { + self.0.cancel_queued(id, canceller).await } async fn list_transactions( &self, diff --git a/crates/sysknife-daemon/src/store.rs b/crates/sysknife-daemon/src/store.rs index b946364d..5affdf06 100644 --- a/crates/sysknife-daemon/src/store.rs +++ b/crates/sysknife-daemon/src/store.rs @@ -43,6 +43,7 @@ use std::sync::Arc; use sysknife_types::{JobState, PreviewEnvelope, TransactionRecord}; use crate::audit_chain::{AuditKey, ChainRow, EventRow, VerifyOutcome}; +use crate::auth::CallerPrincipal; use crate::transactions::{ NewTransaction, RecordedPreviewedTransaction, TransactionStore, TransactionStoreError, }; @@ -110,17 +111,20 @@ pub trait AuditStore: Send + Sync + std::fmt::Debug { async fn approve_transaction( &self, transaction_id: &str, + approver: CallerPrincipal, ) -> Result, TransactionStoreError>; async fn revoke_unconsumed_approval( &self, transaction_id: &str, + revoker: CallerPrincipal, ) -> Result; async fn claim_approved_for_execution( &self, transaction_id: &str, receipt_digest: &str, + executor: CallerPrincipal, ) -> Result; async fn cleanup_stale_queued(&self) -> Result; @@ -167,7 +171,11 @@ pub trait AuditStore: Send + Sync + std::fmt::Debug { /// `true` iff a queued row was transitioned; a `Running` (in-flight) or /// terminal transaction is never cancelled. See /// [`crate::transactions::TransactionStore::cancel_queued`]. - async fn cancel_queued(&self, transaction_id: &str) -> Result; + async fn cancel_queued( + &self, + transaction_id: &str, + canceller: CallerPrincipal, + ) -> Result; async fn list_transactions( &self, @@ -321,30 +329,33 @@ impl AuditStore for SqliteStore { async fn approve_transaction( &self, transaction_id: &str, + approver: CallerPrincipal, ) -> Result, TransactionStoreError> { let inner = Arc::clone(&self.inner); let id = transaction_id.to_string(); - blocking(move || inner.approve_transaction(&id)).await + blocking(move || inner.approve_transaction(&id, approver)).await } async fn revoke_unconsumed_approval( &self, transaction_id: &str, + revoker: CallerPrincipal, ) -> Result { let inner = Arc::clone(&self.inner); let id = transaction_id.to_string(); - blocking(move || inner.revoke_unconsumed_approval(&id)).await + blocking(move || inner.revoke_unconsumed_approval(&id, revoker)).await } async fn claim_approved_for_execution( &self, transaction_id: &str, receipt_digest: &str, + executor: CallerPrincipal, ) -> Result { let inner = Arc::clone(&self.inner); let id = transaction_id.to_string(); let digest = receipt_digest.to_string(); - blocking(move || inner.claim_approved_for_execution(&id, &digest)).await + blocking(move || inner.claim_approved_for_execution(&id, &digest, executor)).await } async fn cleanup_stale_queued(&self) -> Result { @@ -352,10 +363,14 @@ impl AuditStore for SqliteStore { blocking(move || inner.cleanup_stale_queued()).await } - async fn cancel_queued(&self, transaction_id: &str) -> Result { + async fn cancel_queued( + &self, + transaction_id: &str, + canceller: CallerPrincipal, + ) -> Result { let inner = Arc::clone(&self.inner); let id = transaction_id.to_string(); - blocking(move || inner.cancel_queued(&id)).await + blocking(move || inner.cancel_queued(&id, canceller)).await } async fn list_transactions( diff --git a/crates/sysknife-daemon/src/store/postgres.rs b/crates/sysknife-daemon/src/store/postgres.rs index c3c24e6a..52f09014 100644 --- a/crates/sysknife-daemon/src/store/postgres.rs +++ b/crates/sysknife-daemon/src/store/postgres.rs @@ -37,9 +37,10 @@ use uuid::Uuid; use crate::audit_chain::{ AttributionCensus, AuditEventKind, AuditKey, AuditVerification, BindingOutcome, ChainContent, - ChainIdentity, ChainRow, EventContent, EventRow, VerifyOutcome, CURRENT_KEY_ID, + ChainIdentity, ChainRow, EventContent, EventIdentity, EventRow, VerifyOutcome, CURRENT_KEY_ID, }; use crate::audit_watermark::emit_chain_tip_watermark; +use crate::auth::CallerPrincipal; use crate::store::AuditStore; use crate::transactions::{ NewTransaction, RecordedPreviewedTransaction, TransactionStoreError, @@ -136,6 +137,19 @@ const MIGRATIONS: &[Migration] = &[Migration { name: "caller_principal", statements: &["ALTER TABLE transactions ADD COLUMN IF NOT EXISTS caller_principal TEXT"], }, + // Mirrors SQLite migration 4 (#249). Approver identity in the signed + // event encoding: `chain_version DEFAULT 1` keeps every historical event + // row on the six-field LegacyV1 encoding without touching it; new + // approval events are written at version 2 with the acting account + // signed in. Nullable, never backfilled, for the migration-2 reason. + Migration { + version: 4, + name: "event_approver_identity", + statements: &[ + "ALTER TABLE audit_events ADD COLUMN IF NOT EXISTS chain_version BIGINT NOT NULL DEFAULT 1", + "ALTER TABLE audit_events ADD COLUMN IF NOT EXISTS caller_principal TEXT", + ], + }, ]; /// Configuration for the Postgres backend. Built by `main.rs` from @@ -381,6 +395,7 @@ impl PostgresStore { tx: &mut sqlx_core::transaction::Transaction<'_, sqlx_postgres::Postgres>, key: &AuditKey, transaction_id: &str, + revoker_signed: &str, ) -> Result { // Read the digest before the DELETE: the event names which receipt was // retracted, and after the delete there is nothing left to name. @@ -412,6 +427,9 @@ impl PostgresStore { AuditEventKind::ApprovalRevoked, transaction_id, &digest, + EventIdentity::V2 { + caller_principal: revoker_signed, + }, ) .await?; } @@ -589,7 +607,11 @@ impl AuditStore for PostgresStore { async fn approve_transaction( &self, transaction_id: &str, + approver: CallerPrincipal, ) -> Result, TransactionStoreError> { + // Rendered once so the signed string and the stored column provably + // come from the same value. + let approver_signed = approver.as_signed_str(); let row = sqlx_core::query::query( "SELECT request_hash, approval_id FROM transactions WHERE transaction_id = $1", ) @@ -643,6 +665,9 @@ impl AuditStore for PostgresStore { AuditEventKind::ApprovalGranted, transaction_id, &receipt_digest, + EventIdentity::V2 { + caller_principal: &approver_signed, + }, ) .await?; } @@ -653,11 +678,16 @@ impl AuditStore for PostgresStore { async fn revoke_unconsumed_approval( &self, transaction_id: &str, + revoker: CallerPrincipal, ) -> Result { let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?; - let revoked = - Self::revoke_unconsumed_approval_in_tx(&mut tx, &self.audit_key, transaction_id) - .await?; + let revoked = Self::revoke_unconsumed_approval_in_tx( + &mut tx, + &self.audit_key, + transaction_id, + &revoker.as_signed_str(), + ) + .await?; tx.commit().await.map_err(map_sqlx_err)?; Ok(revoked) } @@ -666,6 +696,7 @@ impl AuditStore for PostgresStore { &self, transaction_id: &str, receipt_digest: &str, + executor: CallerPrincipal, ) -> Result { let queued = serialize(&JobState::Queued)?; let running = serialize(&JobState::Running)?; @@ -707,6 +738,9 @@ impl AuditStore for PostgresStore { AuditEventKind::ApprovalConsumed, transaction_id, receipt_digest, + EventIdentity::V2 { + caller_principal: &executor.as_signed_str(), + }, ) .await?; } @@ -742,8 +776,16 @@ impl AuditStore for PostgresStore { .await .map_err(map_sqlx_err)?; if result.rows_affected() > 0 { - Self::revoke_unconsumed_approval_in_tx(&mut tx, &self.audit_key, &transaction_id) - .await?; + // Daemon-initiated revocation: the stale sweep has no caller + // connection to attribute, and `Unattributed` records exactly + // that rather than inventing an account (#249). + Self::revoke_unconsumed_approval_in_tx( + &mut tx, + &self.audit_key, + &transaction_id, + &CallerPrincipal::Unattributed.as_signed_str(), + ) + .await?; canceled_count += result.rows_affected(); } } @@ -751,7 +793,11 @@ impl AuditStore for PostgresStore { Ok(canceled_count) } - async fn cancel_queued(&self, transaction_id: &str) -> Result { + async fn cancel_queued( + &self, + transaction_id: &str, + canceller: CallerPrincipal, + ) -> Result { // Option A: the `status = $3` (Queued) guard means a Running (in-flight) // transaction is never cancelled. let queued = serialize(&JobState::Queued)?; @@ -768,8 +814,15 @@ impl AuditStore for PostgresStore { .await .map_err(map_sqlx_err)?; if result.rows_affected() > 0 { - Self::revoke_unconsumed_approval_in_tx(&mut tx, &self.audit_key, transaction_id) - .await?; + // Caller-attributed cancel (see the SQLite impl): the revocation + // event names the account that cancelled, not `Unattributed`. + Self::revoke_unconsumed_approval_in_tx( + &mut tx, + &self.audit_key, + transaction_id, + &canceller.as_signed_str(), + ) + .await?; } tx.commit().await.map_err(map_sqlx_err)?; Ok(result.rows_affected() > 0) @@ -945,7 +998,7 @@ async fn audit_events_table_absent(pool: &PgPool) -> Result Result, TransactionStoreError> { let rows = sqlx_core::query::query( "SELECT seq, key_id, kind, transaction_id, receipt_digest, \ - created_at, prev_chain_hash, chain_hash \ + created_at, prev_chain_hash, chain_hash, chain_version, caller_principal \ FROM audit_events ORDER BY seq ASC", ) .fetch_all(pool) @@ -965,6 +1018,7 @@ async fn append_event( kind: AuditEventKind, transaction_id: &str, receipt_digest: &str, + identity: EventIdentity<'_>, ) -> Result<(), TransactionStoreError> { let prev: Option<(i64, String)> = sqlx_core::query_as::query_as( "SELECT seq, chain_hash FROM audit_events ORDER BY seq DESC LIMIT 1 FOR UPDATE", @@ -978,22 +1032,28 @@ async fn append_event( }; let created_at = now_iso(); let key_id = CURRENT_KEY_ID.to_string(); - let chain_hash = key.event_hash( - &EventContent { - seq, - key_id: &key_id, - kind, - transaction_id, - receipt_digest, - created_at: &created_at, - }, - &prev_chain_hash, - ); + // Build the content once and derive the stored version from its identity, + // so the chain_version column is provably the version whose message was + // signed โ€” the same discipline the SQLite backend follows. + let content = EventContent { + seq, + key_id: &key_id, + kind, + transaction_id, + receipt_digest, + created_at: &created_at, + identity, + }; + let chain_hash = key.event_hash(&content, &prev_chain_hash); + let stored_principal = match identity { + EventIdentity::V2 { caller_principal } => Some(caller_principal), + EventIdentity::LegacyV1 => None, + }; sqlx_core::query::query( "INSERT INTO audit_events ( \ seq, key_id, kind, transaction_id, receipt_digest, \ - created_at, chain_hash, prev_chain_hash \ - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + created_at, chain_hash, prev_chain_hash, chain_version, caller_principal \ + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", ) .bind(seq as i64) .bind(&key_id) @@ -1003,6 +1063,8 @@ async fn append_event( .bind(&created_at) .bind(&chain_hash) .bind(&prev_chain_hash) + .bind(identity.version() as i64) + .bind(stored_principal) .execute(&mut **tx) .await .map_err(map_sqlx_err)?; @@ -1203,6 +1265,10 @@ fn row_to_event_row(row: sqlx_postgres::PgRow) -> Result("chain_version") + .map_err(map_sqlx_err)? as u32, + caller_principal: row.try_get("caller_principal").map_err(map_sqlx_err)?, }) } diff --git a/crates/sysknife-daemon/src/transactions.rs b/crates/sysknife-daemon/src/transactions.rs index 91e848dd..82e0aa33 100644 --- a/crates/sysknife-daemon/src/transactions.rs +++ b/crates/sysknife-daemon/src/transactions.rs @@ -1,6 +1,6 @@ use crate::audit_chain::{ - self, AuditEventKind, AuditKey, ChainContent, ChainIdentity, ChainRow, EventContent, EventRow, - VerifyOutcome, CURRENT_KEY_ID, + self, AuditEventKind, AuditKey, ChainContent, ChainIdentity, ChainRow, EventContent, + EventIdentity, EventRow, VerifyOutcome, CURRENT_KEY_ID, }; use crate::audit_watermark::emit_chain_tip_watermark; use crate::auth::CallerPrincipal; @@ -202,6 +202,22 @@ const SQLITE_MIGRATIONS: &[SqliteMigration] = &[ ALTER TABLE transactions ADD COLUMN caller_principal TEXT; "#, }, + // Approver identity in the signed event encoding (#249). + // + // Nullable/defaulted for the same reason the v2 transaction columns are: + // every event row already on disk was signed over the six-field encoding, + // and backfilling either column would rewrite its message and report the + // whole event chain as Broken. `chain_version DEFAULT 1` makes every + // historical row LegacyV1 without touching it; new approval events are + // written at version 2 with the acting account signed in. + SqliteMigration { + version: 4, + name: "event_approver_identity", + sql: r#" + ALTER TABLE audit_events ADD COLUMN chain_version INTEGER NOT NULL DEFAULT 1; + ALTER TABLE audit_events ADD COLUMN caller_principal TEXT; + "#, + }, ]; /// Column list for every `ChainRow` read, shared with the Postgres backend @@ -243,6 +259,8 @@ fn event_row_from_sqlite(row: &rusqlite::Row<'_>) -> rusqlite::Result created_at: row.get(5)?, prev_chain_hash: row.get(6)?, chain_hash: row.get(7)?, + chain_version: row.get::<_, i64>(8)? as u32, + caller_principal: row.get(9)?, }) } @@ -558,6 +576,10 @@ impl TransactionStore { AuditEventKind::from(new_status), transaction_id, "", + // Status events name no account, deliberately: they are written + // from spawned execution tasks with no caller attribution in + // scope (#249). They stay on the legacy six-field encoding. + EventIdentity::LegacyV1, )?; tx.commit()?; Ok(()) @@ -627,9 +649,13 @@ impl TransactionStore { } /// Attach one immutable approval receipt digest to a fresh queued preview. + /// + /// `approver` is the account performing this approval โ€” signed into the + /// event so a verified chain names who approved, not only who asked (#249). pub fn approve_transaction( &self, transaction_id: &str, + approver: CallerPrincipal, ) -> Result, TransactionStoreError> { let key = self .audit_key @@ -637,6 +663,9 @@ impl TransactionStore { .ok_or(TransactionStoreError::AuditChainMissing( "this TransactionStore was opened read-only; cannot approve", ))?; + // Rendered once, before any early return that follows, so the signed + // string and the stored column provably come from the same value. + let approver_signed = approver.as_signed_str(); let Some(record) = self.get(transaction_id)? else { return Ok(None); }; @@ -678,6 +707,9 @@ impl TransactionStore { AuditEventKind::ApprovalGranted, transaction_id, &receipt_digest, + EventIdentity::V2 { + caller_principal: &approver_signed, + }, )?; } tx.commit()?; @@ -686,9 +718,13 @@ impl TransactionStore { /// Remove an approval that was persisted but could not be delivered to the /// caller. Consumed receipts are never revocable. + /// + /// `revoker` is signed into the revocation event: the chain should name + /// the account that retracted a receipt (#249). pub fn revoke_unconsumed_approval( &self, transaction_id: &str, + revoker: CallerPrincipal, ) -> Result { let key = self .audit_key @@ -698,7 +734,12 @@ impl TransactionStore { ))?; let mut conn = self.connection()?; let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; - let revoked = Self::revoke_unconsumed_approval_in_tx(&tx, key, transaction_id)?; + let revoked = Self::revoke_unconsumed_approval_in_tx( + &tx, + key, + transaction_id, + &revoker.as_signed_str(), + )?; tx.commit()?; Ok(revoked) } @@ -707,6 +748,7 @@ impl TransactionStore { conn: &Connection, key: &AuditKey, transaction_id: &str, + revoker_signed: &str, ) -> Result { // Capture the digest before the DELETE: the event has to name which // receipt was retracted, and after the delete there is nothing to name. @@ -734,16 +776,24 @@ impl TransactionStore { AuditEventKind::ApprovalRevoked, transaction_id, &digest, + EventIdentity::V2 { + caller_principal: revoker_signed, + }, )?; } Ok(rows_affected > 0) } /// Atomically consume an approved receipt and transition Queued to Running. + /// + /// `executor` is signed into the consume event: on a host where the account + /// that spends a receipt could ever differ from the one that granted it, + /// the chain records both (#249). pub fn claim_approved_for_execution( &self, transaction_id: &str, receipt_digest: &str, + executor: CallerPrincipal, ) -> Result { let key = self .audit_key @@ -783,6 +833,9 @@ impl TransactionStore { AuditEventKind::ApprovalConsumed, transaction_id, receipt_digest, + EventIdentity::V2 { + caller_principal: &executor.as_signed_str(), + }, )?; } tx.commit()?; @@ -833,7 +886,15 @@ impl TransactionStore { params![canceled_json, transaction_id, queued_json], )?; if rows_affected > 0 { - Self::revoke_unconsumed_approval_in_tx(&tx, key, &transaction_id)?; + // Daemon-initiated revocation: the stale sweep has no caller + // connection to attribute, and `Unattributed` records exactly + // that rather than inventing an account (#249). + Self::revoke_unconsumed_approval_in_tx( + &tx, + key, + &transaction_id, + &CallerPrincipal::Unattributed.as_signed_str(), + )?; canceled += rows_affected; } } @@ -849,7 +910,11 @@ impl TransactionStore { /// never cancelled, so we never leave a half-applied root mutation behind /// a `Canceled` record. Missing or already-terminal transactions return /// `false`. - pub fn cancel_queued(&self, transaction_id: &str) -> Result { + pub fn cancel_queued( + &self, + transaction_id: &str, + canceller: CallerPrincipal, + ) -> Result { let key = self .audit_key .as_ref() @@ -866,7 +931,16 @@ impl TransactionStore { params![canceled_json, transaction_id, queued_json], )?; if rows_affected > 0 { - Self::revoke_unconsumed_approval_in_tx(&tx, key, transaction_id)?; + // The cancel came from a caller connection (`handle_cancel`), so + // the revocation event names the account that cancelled. This is + // NOT the daemon-sweep path: recording `Unattributed` here would + // sign away an identity the code actually holds (#249). + Self::revoke_unconsumed_approval_in_tx( + &tx, + key, + transaction_id, + &canceller.as_signed_str(), + )?; } tx.commit()?; Ok(rows_affected > 0) @@ -1059,7 +1133,7 @@ impl TransactionStore { let conn = self.connection()?; let mut stmt = conn.prepare( "SELECT seq, key_id, kind, transaction_id, receipt_digest, \ - created_at, prev_chain_hash, chain_hash \ + created_at, prev_chain_hash, chain_hash, chain_version, caller_principal \ FROM audit_events ORDER BY seq ASC", )?; let rows = stmt.query_map([], event_row_from_sqlite)?; @@ -1166,6 +1240,7 @@ impl TransactionStore { kind: AuditEventKind, transaction_id: &str, receipt_digest: &str, + identity: EventIdentity<'_>, ) -> Result<(), TransactionStoreError> { let prev_chain_hash = Self::event_chain_tip(conn)?.unwrap_or_default(); let seq: i64 = conn.query_row( @@ -1178,22 +1253,29 @@ impl TransactionStore { row.get(0) })?; let key_id = CURRENT_KEY_ID.to_string(); - let chain_hash = key.event_hash( - &EventContent { - seq: seq as u64, - key_id: &key_id, - kind, - transaction_id, - receipt_digest, - created_at: &created_at, - }, - &prev_chain_hash, - ); + // Build the content once and derive the stored version from its + // identity, so the chain_version column is provably the version whose + // message was signed โ€” the same discipline the transaction insert + // follows. + let content = EventContent { + seq: seq as u64, + key_id: &key_id, + kind, + transaction_id, + receipt_digest, + created_at: &created_at, + identity, + }; + let chain_hash = key.event_hash(&content, &prev_chain_hash); + let stored_principal = match identity { + EventIdentity::V2 { caller_principal } => Some(caller_principal), + EventIdentity::LegacyV1 => None, + }; conn.execute( - "INSERT INTO audit_events ( - seq, key_id, kind, transaction_id, receipt_digest, - created_at, chain_hash, prev_chain_hash - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + "INSERT INTO audit_events (\ + seq, key_id, kind, transaction_id, receipt_digest, \ + created_at, chain_hash, prev_chain_hash, chain_version, caller_principal \ + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", params![ seq, key_id, @@ -1203,6 +1285,8 @@ impl TransactionStore { created_at, chain_hash, prev_chain_hash, + identity.version() as i64, + stored_principal, ], )?; Ok(()) @@ -1447,6 +1531,8 @@ mod tests { use super::*; use crate::audit_chain::CHAIN_VERSION_CURRENT; use crate::audit_chain::CHAIN_VERSION_LEGACY; + use crate::audit_chain::EVENT_VERSION_CURRENT; + use crate::audit_chain::EVENT_VERSION_LEGACY; use std::os::unix::fs::PermissionsExt; use tempfile::tempdir; @@ -1619,7 +1705,10 @@ mod tests { row.get(0) }) .unwrap(); - assert_eq!(version, 3, "opening a v2 database must apply migration 3"); + assert_eq!( + version, 4, + "opening a v2 database must apply every migration up to 4" + ); let rows = store.fetch_chain_rows().unwrap(); assert_eq!(rows.len(), 1); @@ -1760,18 +1849,22 @@ mod tests { // Approve then consume. let a = store.record(queued_transaction()).unwrap(); let receipt = store - .approve_transaction(&a.transaction_id) + .approve_transaction(&a.transaction_id, CallerPrincipal::Uid(1000)) .unwrap() .expect("queued transaction approves"); let digest = audit_chain::approval_receipt_digest(&receipt); assert!(store - .claim_approved_for_execution(&a.transaction_id, &digest) + .claim_approved_for_execution(&a.transaction_id, &digest, CallerPrincipal::Uid(1000)) .unwrap()); // Approve then revoke. let b = store.record(queued_transaction()).unwrap(); - store.approve_transaction(&b.transaction_id).unwrap(); - assert!(store.revoke_unconsumed_approval(&b.transaction_id).unwrap()); + store + .approve_transaction(&b.transaction_id, CallerPrincipal::Uid(1000)) + .unwrap(); + assert!(store + .revoke_unconsumed_approval(&b.transaction_id, CallerPrincipal::Uid(1000)) + .unwrap()); let events = store.fetch_event_rows().unwrap(); let kinds: Vec<&str> = events.iter().map(|e| e.kind.as_str()).collect(); @@ -1790,6 +1883,108 @@ mod tests { ); } + #[test] + fn a_stored_approval_event_names_the_account_that_acted() { + // End-to-end over the real store, not an in-memory fixture: the + // principal threaded through `approve_transaction` / + // `claim_approved_for_execution` must survive the round-trip into the + // persisted event rows, so a verified chain names who approved and who + // executed rather than leaving it to inference (#249). Two different + // uids leave two distinguishable signed records. + let dir = tempdir().unwrap(); + let key = AuditKey::from_bytes(vec![0x42; 32]); + let store = test_store(dir.path().join("tx.db")); + + let tx = store.record(queued_transaction()).unwrap(); + // alice (uid:1000) approves ... + let receipt = store + .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000)) + .unwrap() + .expect("queued transaction approves"); + let digest = audit_chain::approval_receipt_digest(&receipt); + // ... bob (uid:1001) executes. + assert!(store + .claim_approved_for_execution(&tx.transaction_id, &digest, CallerPrincipal::Uid(1001)) + .unwrap()); + + let events = store.fetch_event_rows().unwrap(); + assert_eq!(events.len(), 2); + let granted = events + .iter() + .find(|e| e.kind == "approval_granted") + .expect("a grant event"); + let consumed = events + .iter() + .find(|e| e.kind == "approval_consumed") + .expect("a consume event"); + + // Both rows carry the new encoding and the acting account. + assert_eq!(granted.chain_version, EVENT_VERSION_CURRENT); + assert_eq!(consumed.chain_version, EVENT_VERSION_CURRENT); + assert_eq!( + granted.caller_principal.as_deref(), + Some("uid:1000"), + "the grant must name the approver" + ); + assert_eq!( + consumed.caller_principal.as_deref(), + Some("uid:1001"), + "the consume must name the executor" + ); + assert_ne!( + granted.caller_principal, consumed.caller_principal, + "a grant and a consume by different accounts must be distinguishable" + ); + + // The persisted rows still verify as a chain, and a status event (if + // any were appended) stays on the legacy encoding โ€” covered by the + // unit tests; here we assert the approval rows themselves are intact. + assert_eq!( + store.verify_event_chain(&key).unwrap(), + VerifyOutcome::Intact { rows_checked: 2 } + ); + } + + #[test] + fn a_stored_status_event_carries_no_principal_and_stays_legacy() { + // The complement of the test above: status events are written from + // spawned execution tasks with no caller attribution, so they stay on + // the six-field legacy encoding rather than signing an account the + // code cannot see (#249). The mixed chain still verifies. + let dir = tempdir().unwrap(); + let key = AuditKey::from_bytes(vec![0x42; 32]); + let store = test_store(dir.path().join("tx.db")); + + let tx = store.record(queued_transaction()).unwrap(); + let receipt = store + .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000)) + .unwrap() + .expect("queued transaction approves"); + let digest = audit_chain::approval_receipt_digest(&receipt); + assert!(store + .claim_approved_for_execution(&tx.transaction_id, &digest, CallerPrincipal::Uid(1000)) + .unwrap()); + store + .update_status(&tx.transaction_id, JobState::Succeeded) + .unwrap(); + + let events = store.fetch_event_rows().unwrap(); + let status = events + .iter() + .find(|e| e.kind == "status_succeeded") + .expect("a status event"); + assert_eq!(status.chain_version, EVENT_VERSION_LEGACY); + assert_eq!( + status.caller_principal, None, + "a status event names no account" + ); + // grant + consume (V2) + status (legacy) all verify as one chain. + assert_eq!( + store.verify_event_chain(&key).unwrap(), + VerifyOutcome::Intact { rows_checked: 3 } + ); + } + #[test] fn a_terminal_outcome_is_chained_so_rewriting_status_is_detectable() { // The module documentation says status transitions ARE chained @@ -1805,12 +2000,12 @@ mod tests { let tx = store.record(queued_transaction()).unwrap(); let receipt = store - .approve_transaction(&tx.transaction_id) + .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000)) .unwrap() .expect("queued transaction approves"); let digest = audit_chain::approval_receipt_digest(&receipt); assert!(store - .claim_approved_for_execution(&tx.transaction_id, &digest) + .claim_approved_for_execution(&tx.transaction_id, &digest, CallerPrincipal::Uid(1000)) .unwrap()); store .update_status(&tx.transaction_id, JobState::Succeeded) @@ -1883,12 +2078,18 @@ mod tests { let dir = tempdir().unwrap(); let store = test_store(dir.path().join("tx.db")); let tx = store.record(queued_transaction()).unwrap(); - store.approve_transaction(&tx.transaction_id).unwrap(); - store.approve_transaction(&tx.transaction_id).unwrap(); + store + .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000)) + .unwrap(); + store + .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000)) + .unwrap(); assert_eq!(store.fetch_event_rows().unwrap().len(), 1); // Same for a revoke with nothing to revoke. - assert!(!store.revoke_unconsumed_approval("no-such-tx").unwrap()); + assert!(!store + .revoke_unconsumed_approval("no-such-tx", CallerPrincipal::Uid(1000)) + .unwrap()); assert_eq!(store.fetch_event_rows().unwrap().len(), 1); } @@ -1905,7 +2106,9 @@ mod tests { let store = test_store(&db_path); let a = store.record(queued_transaction()).unwrap(); - store.approve_transaction(&a.transaction_id).unwrap(); + store + .approve_transaction(&a.transaction_id, CallerPrincipal::Uid(1000)) + .unwrap(); // A later transaction commits to the event tip, which is what carries // the binding into the checkpoint-anchored transaction chain. store.record(queued_transaction()).unwrap(); @@ -1954,11 +2157,11 @@ mod tests { let read_only = TransactionStore::open_read_only(&db_path).unwrap(); assert!(matches!( - read_only.revoke_unconsumed_approval("tx"), + read_only.revoke_unconsumed_approval("tx", CallerPrincipal::Uid(1000)), Err(TransactionStoreError::AuditChainMissing(_)) )); assert!(matches!( - read_only.claim_approved_for_execution("tx", "digest"), + read_only.claim_approved_for_execution("tx", "digest", CallerPrincipal::Uid(1000)), Err(TransactionStoreError::AuditChainMissing(_)) )); } @@ -2496,20 +2699,24 @@ mod tests { let store = test_store(dir.path().join("tx.db")); let tx = store.record(queued_transaction()).unwrap(); let receipt = store - .approve_transaction(&tx.transaction_id) + .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000)) .unwrap() .expect("approved"); let digest = audit_chain::approval_receipt_digest(&receipt); assert!( store - .revoke_unconsumed_approval(&tx.transaction_id) + .revoke_unconsumed_approval(&tx.transaction_id, CallerPrincipal::Uid(1000)) .unwrap(), "an unconsumed approval must be revocable" ); assert!( !store - .claim_approved_for_execution(&tx.transaction_id, &digest) + .claim_approved_for_execution( + &tx.transaction_id, + &digest, + CallerPrincipal::Uid(1000) + ) .unwrap(), "a revoked receipt must no longer be claimable" ); @@ -2524,17 +2731,17 @@ mod tests { let store = test_store(dir.path().join("tx.db")); let tx = store.record(queued_transaction()).unwrap(); let receipt = store - .approve_transaction(&tx.transaction_id) + .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000)) .unwrap() .expect("approved"); let digest = audit_chain::approval_receipt_digest(&receipt); assert!(store - .claim_approved_for_execution(&tx.transaction_id, &digest) + .claim_approved_for_execution(&tx.transaction_id, &digest, CallerPrincipal::Uid(1000)) .unwrap()); assert!( !store - .revoke_unconsumed_approval(&tx.transaction_id) + .revoke_unconsumed_approval(&tx.transaction_id, CallerPrincipal::Uid(1000)) .unwrap(), "a consumed approval must not be revocable" ); @@ -2554,18 +2761,18 @@ mod tests { let store = test_store(dir.path().join("tx.db")); let tx = store.record(queued_transaction()).unwrap(); let receipt = store - .approve_transaction(&tx.transaction_id) + .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000)) .unwrap() .expect("approved"); let digest = audit_chain::approval_receipt_digest(&receipt); assert!(store - .claim_approved_for_execution(&tx.transaction_id, &digest) + .claim_approved_for_execution(&tx.transaction_id, &digest, CallerPrincipal::Uid(1000)) .unwrap()); // Running. assert!( store - .approve_transaction(&tx.transaction_id) + .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000)) .unwrap() .is_none(), "a Running transaction must not be approvable" @@ -2577,7 +2784,7 @@ mod tests { .unwrap(); assert!( store - .approve_transaction(&tx.transaction_id) + .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000)) .unwrap() .is_none(), "a completed transaction must not be approvable" @@ -2594,7 +2801,7 @@ mod tests { let store = std::sync::Arc::new(test_store(dir.path().join("tx.db"))); let tx = store.record(queued_transaction()).unwrap(); let receipt = store - .approve_transaction(&tx.transaction_id) + .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000)) .unwrap() .expect("approved"); let digest = audit_chain::approval_receipt_digest(&receipt); @@ -2605,7 +2812,7 @@ mod tests { let id = tx.transaction_id.clone(); let digest = digest.clone(); handles.push(std::thread::spawn(move || { - store.claim_approved_for_execution(&id, &digest) + store.claim_approved_for_execution(&id, &digest, CallerPrincipal::Uid(1000)) })); } let claims: Vec = handles @@ -2631,7 +2838,9 @@ mod tests { let tx = store.record(queued_transaction()).unwrap(); assert!( - store.cancel_queued(&tx.transaction_id).unwrap(), + store + .cancel_queued(&tx.transaction_id, CallerPrincipal::Uid(1000)) + .unwrap(), "a queued transaction is cancelable" ); assert_eq!( @@ -2639,11 +2848,15 @@ mod tests { JobState::Canceled ); assert!( - !store.cancel_queued(&tx.transaction_id).unwrap(), + !store + .cancel_queued(&tx.transaction_id, CallerPrincipal::Uid(1000)) + .unwrap(), "an already-canceled transaction is not cancelable again" ); assert!( - !store.cancel_queued("no-such-transaction").unwrap(), + !store + .cancel_queued("no-such-transaction", CallerPrincipal::Uid(1000)) + .unwrap(), "a missing transaction is not cancelable" ); assert!( @@ -2658,19 +2871,25 @@ mod tests { let store = test_store(dir.path().join("tx.db")); let tx = store.record(queued_transaction()).unwrap(); let receipt = store - .approve_transaction(&tx.transaction_id) + .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000)) .unwrap() .expect("queued transaction is approvable"); let digest = audit_chain::approval_receipt_digest(&receipt); - assert!(store.cancel_queued(&tx.transaction_id).unwrap()); + assert!(store + .cancel_queued(&tx.transaction_id, CallerPrincipal::Uid(1000)) + .unwrap()); assert_eq!( store.get(&tx.transaction_id).unwrap().unwrap().status, JobState::Canceled ); assert!( !store - .claim_approved_for_execution(&tx.transaction_id, &digest) + .claim_approved_for_execution( + &tx.transaction_id, + &digest, + CallerPrincipal::Uid(1000) + ) .unwrap(), "canceling must revoke the unconsumed receipt" ); @@ -2693,16 +2912,18 @@ mod tests { let store = test_store(dir.path().join("tx.db")); let tx = store.record(queued_transaction()).unwrap(); let receipt = store - .approve_transaction(&tx.transaction_id) + .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000)) .unwrap() .expect("approved"); let digest = audit_chain::approval_receipt_digest(&receipt); assert!(store - .claim_approved_for_execution(&tx.transaction_id, &digest) + .claim_approved_for_execution(&tx.transaction_id, &digest, CallerPrincipal::Uid(1000)) .unwrap()); assert!( - !store.cancel_queued(&tx.transaction_id).unwrap(), + !store + .cancel_queued(&tx.transaction_id, CallerPrincipal::Uid(1000)) + .unwrap(), "a running transaction must not be cancelable" ); assert_eq!( @@ -2720,38 +2941,54 @@ mod tests { assert!( !store - .claim_approved_for_execution(&tx.transaction_id, "digest-a") + .claim_approved_for_execution( + &tx.transaction_id, + "digest-a", + CallerPrincipal::Uid(1000) + ) .unwrap(), "an unapproved preview must not execute" ); let receipt = store - .approve_transaction(&tx.transaction_id) + .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000)) .unwrap() .expect("first approval must succeed"); let digest = audit_chain::approval_receipt_digest(&receipt); assert_eq!(tx.approval_id.as_deref(), Some(digest.as_str())); assert!( store - .approve_transaction(&tx.transaction_id) + .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000)) .unwrap() .is_none(), "approval is immutable once issued" ); assert!( !store - .claim_approved_for_execution(&tx.transaction_id, "wrong-digest") + .claim_approved_for_execution( + &tx.transaction_id, + "wrong-digest", + CallerPrincipal::Uid(1000) + ) .unwrap(), "a forged receipt must not execute" ); assert!( store - .claim_approved_for_execution(&tx.transaction_id, &digest) + .claim_approved_for_execution( + &tx.transaction_id, + &digest, + CallerPrincipal::Uid(1000) + ) .unwrap(), "the exact approved receipt must execute" ); assert!( !store - .claim_approved_for_execution(&tx.transaction_id, &digest) + .claim_approved_for_execution( + &tx.transaction_id, + &digest, + CallerPrincipal::Uid(1000) + ) .unwrap(), "the receipt must be one-time" ); @@ -2799,7 +3036,7 @@ mod tests { .unwrap(); let err = store - .approve_transaction(&tx.transaction_id) + .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000)) .expect_err("a forged commitment must be rejected, not approved"); assert!( matches!(err, TransactionStoreError::DatabaseInvariant(_)), @@ -2823,7 +3060,7 @@ mod tests { assert!( store - .approve_transaction(&tx.transaction_id) + .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000)) .unwrap() .is_none(), "a production-format timestamp outside the TTL must not be approved" @@ -2840,7 +3077,7 @@ mod tests { let store = test_store(dir.path().join("tx.db")); let tx = store.record(queued_transaction()).unwrap(); let receipt = store - .approve_transaction(&tx.transaction_id) + .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000)) .unwrap() .expect("a fresh approval succeeds"); let digest = audit_chain::approval_receipt_digest(&receipt); @@ -2857,7 +3094,11 @@ mod tests { assert!( !store - .claim_approved_for_execution(&tx.transaction_id, &digest) + .claim_approved_for_execution( + &tx.transaction_id, + &digest, + CallerPrincipal::Uid(1000) + ) .unwrap(), "an approval aged past the TTL must not be claimable at execute time" ); @@ -2907,7 +3148,7 @@ mod tests { .map(|_| { let transaction = store.record(queued_transaction()).unwrap(); store - .approve_transaction(&transaction.transaction_id) + .approve_transaction(&transaction.transaction_id, CallerPrincipal::Uid(1000)) .unwrap() .expect("stale transaction is approvable"); transaction @@ -2936,7 +3177,7 @@ mod tests { JobState::Canceled ); assert!(!store - .revoke_unconsumed_approval(&transaction.transaction_id) + .revoke_unconsumed_approval(&transaction.transaction_id, CallerPrincipal::Uid(1000)) .unwrap()); } assert_eq!( diff --git a/crates/sysknife-daemon/tests/postgres_store.rs b/crates/sysknife-daemon/tests/postgres_store.rs index 21443b78..92892f8b 100644 --- a/crates/sysknife-daemon/tests/postgres_store.rs +++ b/crates/sysknife-daemon/tests/postgres_store.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use sqlx_core::row::Row; use sqlx_postgres::{PgConnectOptions, PgPoolOptions}; use sysknife_daemon::audit_chain::{AuditKey, BindingOutcome, VerifyOutcome}; +use sysknife_daemon::auth::CallerPrincipal; use sysknife_daemon::store::postgres::{PostgresConfig, PostgresStore}; use sysknife_daemon::store::AuditStore; use sysknife_daemon::transactions::NewTransaction; @@ -185,7 +186,7 @@ async fn migrates_legacy_schema_and_enforces_store_contract() { .await .expect("read schema migration version"); assert_eq!( - migration, 3, + migration, 4, "every migration in MIGRATIONS must have applied" ); assert!(store @@ -259,26 +260,29 @@ async fn migrates_legacy_schema_and_enforces_store_contract() { ); let receipt = store - .approve_transaction(transaction_id) + .approve_transaction(transaction_id, CallerPrincipal::Uid(1000)) .await .expect("approve transaction") .expect("fresh transaction is approved"); let receipt_digest = sysknife_daemon::audit_chain::approval_receipt_digest(&receipt); assert!(store - .approve_transaction(transaction_id) + .approve_transaction(transaction_id, CallerPrincipal::Uid(1000)) .await .expect("reject duplicate approval") .is_none()); assert!(!store - .claim_approved_for_execution(transaction_id, "wrong-digest") + .claim_approved_for_execution(transaction_id, "wrong-digest", CallerPrincipal::Uid(1001)) .await .expect("reject wrong receipt")); + // A different account consumes than the one that granted: the store does + // not police ownership (the dispatcher does), and the chain must record + // both (#249). assert!(store - .claim_approved_for_execution(transaction_id, &receipt_digest) + .claim_approved_for_execution(transaction_id, &receipt_digest, CallerPrincipal::Uid(1001)) .await .expect("claim approved transaction")); assert!(!store - .claim_approved_for_execution(transaction_id, &receipt_digest) + .claim_approved_for_execution(transaction_id, &receipt_digest, CallerPrincipal::Uid(1000)) .await .expect("reject receipt replay")); @@ -319,7 +323,7 @@ async fn migrates_legacy_schema_and_enforces_store_contract() { // Option A must refuse to cancel it and leave it Running. assert!( !store - .cancel_queued(transaction_id) + .cancel_queued(transaction_id, CallerPrincipal::Uid(1000)) .await .expect("cancel_queued query"), "a Running transaction must not be cancelable on Postgres" @@ -354,6 +358,19 @@ async fn migrates_legacy_schema_and_enforces_store_contract() { events.iter().map(|e| e.kind.as_str()).collect::>(), vec!["approval_granted", "approval_consumed"] ); + // #249 acceptance on the Postgres backend: the event rows name the + // accounts that acted, a grant by uid:1000 and a consume by uid:1001 are + // two distinguishable signed records, and both carry the V2 encoding. + assert_eq!(events[0].caller_principal.as_deref(), Some("uid:1000")); + assert_eq!(events[1].caller_principal.as_deref(), Some("uid:1001")); + assert_eq!( + events[0].chain_version, + sysknife_daemon::audit_chain::EVENT_VERSION_CURRENT + ); + assert_eq!( + events[1].chain_version, + sysknife_daemon::audit_chain::EVENT_VERSION_CURRENT + ); assert_eq!(pubkey_only.exit_code(), 0); // cancel_queued success path on Postgres: a fresh, never-claimed Queued @@ -362,7 +379,7 @@ async fn migrates_legacy_schema_and_enforces_store_contract() { let fresh = store.record(new_transaction()).await.expect("record fresh"); assert!( store - .cancel_queued(&fresh.transaction_id) + .cancel_queued(&fresh.transaction_id, CallerPrincipal::Uid(1000)) .await .expect("cancel queued"), "a queued transaction must be cancelable on Postgres" @@ -387,17 +404,21 @@ async fn migrates_legacy_schema_and_enforces_store_contract() { .await .expect("record approved"); let receipt = store - .approve_transaction(&approved.transaction_id) + .approve_transaction(&approved.transaction_id, CallerPrincipal::Uid(1000)) .await .expect("approve fresh transaction") .expect("fresh transaction is approvable"); assert!(store - .cancel_queued(&approved.transaction_id) + .cancel_queued(&approved.transaction_id, CallerPrincipal::Uid(1000)) .await .expect("cancel approved transaction")); let receipt_digest = sysknife_daemon::audit_chain::approval_receipt_digest(&receipt); assert!(!store - .claim_approved_for_execution(&approved.transaction_id, &receipt_digest) + .claim_approved_for_execution( + &approved.transaction_id, + &receipt_digest, + CallerPrincipal::Uid(1000) + ) .await .expect("revoked receipt must not execute")); let events = store.fetch_event_rows().await.expect("fetch events"); @@ -424,12 +445,13 @@ async fn migrates_legacy_schema_and_enforces_store_contract() { .expect("count migrations"); // Idempotence: reconnecting re-runs `initialize`, which must not record a // migration a second time. - assert_eq!(migration_count, 3); + assert_eq!(migration_count, 4); for (version, name) in [ (1_i64, "initial_audit_schema"), (2, "caller_identity_and_approval_events"), (3, "caller_principal"), + (4, "event_approver_identity"), ] { let migration_row = sqlx_core::query::query( "SELECT version, name FROM schema_migrations WHERE version = $1", @@ -824,7 +846,10 @@ async fn an_auditor_denied_the_event_table_cannot_verify() { .await .expect("record previewed transaction"); let receipt = store - .approve_transaction(&recorded.transaction.transaction_id) + .approve_transaction( + &recorded.transaction.transaction_id, + CallerPrincipal::Uid(1000), + ) .await .expect("approve transaction") .expect("fresh transaction is approved"); @@ -832,6 +857,7 @@ async fn an_auditor_denied_the_event_table_cannot_verify() { .claim_approved_for_execution( &recorded.transaction.transaction_id, &sysknife_daemon::audit_chain::approval_receipt_digest(&receipt), + CallerPrincipal::Uid(1000), ) .await .expect("claim approved transaction")); diff --git a/docs/distro-support.md b/docs/distro-support.md index 06d0f572..a2143f50 100644 --- a/docs/distro-support.md +++ b/docs/distro-support.md @@ -91,7 +91,7 @@ family and the atomic story family are implemented and covered by the workspace suite. What is missing is a way to put the helpers somewhere the daemon's own grants already point. -The deterministic workspace baseline is 1,873 Rust tests plus 72 frontend +The deterministic workspace baseline is 1,883 Rust tests plus 72 frontend tests. Those tests verify action construction, policy, approval, storage, and UI behavior, but they do not replace a real distribution VM run. diff --git a/docs/introduction.md b/docs/introduction.md index 1fabd59c..6d6f06d8 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -144,7 +144,7 @@ flow. ## Status -192 typed actions ยท 1,873 Rust tests + 72 frontend tests ยท MIT +192 typed actions ยท 1,883 Rust tests + 72 frontend tests ยท MIT SysKnife is the reference implementation of the [LACS specification](https://github.com/lacs-project/specification) โ€” a diff --git a/tests/evidence/workspace-tests.json b/tests/evidence/workspace-tests.json index 4f4ddef5..f1f10ff1 100644 --- a/tests/evidence/workspace-tests.json +++ b/tests/evidence/workspace-tests.json @@ -4,6 +4,6 @@ "tests": "cargo nextest run --workspace --locked" }, "frontend_tests": 72, - "tests": 1873, + "tests": 1883, "version": 2 }