diff --git a/crates/brokerd/src/ledger.rs b/crates/brokerd/src/ledger.rs new file mode 100644 index 0000000..bb35b1a --- /dev/null +++ b/crates/brokerd/src/ledger.rs @@ -0,0 +1,499 @@ +//! The ledger: the audit writer and every session's state behind one lock, with the three steps +//! that hold it. Without the one lock, two results for one session could each read `private` and +//! the second write would put the taint back down; after any failed append or panic while the lock was held, the ledger refuses every later step, and only the next start's check puts a partial line on disk right. + +use std::sync::{Mutex, MutexGuard}; + +use proto::{ + ApprovalAnswer, AuditEvent, CallId, DataClass, DecisionRecord, DenyReason, GrantProblem, Mode, + SessionId, Timestamp, ToolRequest, ToolResponse, +}; + +use crate::approvals::Verdict; +use crate::audit::{AuditError, Writer}; +use crate::grants::GrantSet; +use crate::policy::{Ask, Denial, Label, SessionState}; +use crate::state::StateStore; + +/// The message `finish` returns when the raised taint and the record are not both on disk. +pub const NOT_RECORDED: &str = "the result could not be recorded"; +/// After a panic while the lock was held: every call is denied until brokerd is restarted. +pub const POISONED: &str = "brokerd: a thread panicked while holding the ledger; every call is denied until brokerd is restarted\nsee docs/runbook.md#audit-unavailable"; +/// After a failed append: every call is denied until brokerd is restarted. +pub const STOPPED: &str = "brokerd: an earlier audit write failed; every call is denied until brokerd is restarted\nsee docs/runbook.md#audit-unavailable"; + +/// What `grants::load` returns: a valid set, or the list of problems that made it invalid. +pub type Grants = Result>; + +/// The audit writer behind one lock, so a step that fails to record refuses every later step. +pub trait AuditSink: Send { + fn append(&mut self, time: Timestamp, event: AuditEvent) -> Result; +} + +impl AuditSink for Writer { + fn append(&mut self, time: Timestamp, event: AuditEvent) -> Result { + Writer::append(self, time, event) + } +} + +/// A call the broker is finishing: the session, the call, the decision it was approved under, and +/// the label the result carries. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Call { + pub session: SessionId, + pub call: CallId, + pub decision: u64, + pub label: Label, +} + +impl Call { + /// The call and label of an approved decision, with the seq it was approved under. + pub fn of(decision: &crate::policy::Decision, seq: u64) -> Call { + let request = decision.request(); + Call { + session: request.session.clone(), + call: request.call, + decision: seq, + label: decision.label(), + } + } +} + +/// How a call was decided. +#[derive(Debug)] +pub enum Decided { + Allowed { + decision: crate::policy::Decision, + seq: u64, + }, + Ask { + ask: Ask, + seq: u64, + state: SessionState, + }, + Denied(DenyReason), +} + +/// The value a decided call carries past the record, or the denial reason when there is none. +enum RecordPayload { + Allowed(crate::policy::Decision), + Ask(Ask), + Denial(Denial), + Simple(DenyReason), +} + +/// An owner's answer to a pending call. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Answer { + Approved { + by: Option, + }, + Refused { + by: Option, + reason: Option, + }, + Expired, +} + +/// What an approval leaves: the verdict (run or deny) and the record written. +#[derive(Debug)] +pub struct Answered { + pub verdict: Verdict, + pub outcome: DecisionRecord, +} + +struct Inner { + audit: Box, + state: StateStore, + stopped: bool, +} + +/// The audit writer and every session's state behind one lock. +pub struct Ledger { + inner: Mutex, + log: Box, +} + +impl Ledger { + /// A ledger over the audit writer, the session store, and a sink for its notices. + pub fn new( + audit: Box, + state: StateStore, + log: Box, + ) -> Ledger { + Ledger { + inner: Mutex::new(Inner { + audit, + state, + stopped: false, + }), + log, + } + } + + /// The lock, `None` when the step cannot proceed: poisoned → `POISONED`, stopped → `STOPPED`. + fn lock(&self) -> Option> { + let guard = match self.inner.lock() { + Ok(guard) => guard, + Err(_) => { + (self.log)(POISONED); + return None; + } + }; + if guard.stopped { + (self.log)(STOPPED); + return None; + } + Some(guard) + } + + /// Read a session's state, logging and returning "unknown" when it cannot be read. + fn state_read( + &self, + guard: &MutexGuard, + session: &SessionId, + ) -> Result { + match guard.state.read(session) { + Ok(state) => Ok(state), + Err(e) => { + (self.log)(&format!("brokerd: {e}")); + Err(()) + } + } + } + + /// Append a record, stopping the ledger on failure and logging why. `Ok(seq)` or `Err`. + fn append( + &self, + guard: &mut MutexGuard, + now: Timestamp, + event: AuditEvent, + ) -> Result { + match guard.audit.append(now, event) { + Ok(seq) => Ok(seq), + Err(e) => { + guard.stopped = true; + (self.log)(&format!("brokerd: {e}")); + Err(()) + } + } + } + + /// The refusal `finish` returns when the result could not be recorded. + fn not_recorded() -> ToolResponse { + ToolResponse::Failed { + message: NOT_RECORDED.to_string(), + } + } + + /// The answer when the audit is unavailable: deny the verdict and record the denial. + fn audit_unavailable() -> Answered { + Answered { + verdict: Verdict::Denied(DenyReason::AuditUnavailable), + outcome: DecisionRecord::Denied { + reason: DenyReason::AuditUnavailable, + }, + } + } + + /// A denial verdict and its matching record, for one reason. + fn denied(reason: DenyReason) -> (Verdict, DecisionRecord) { + (Verdict::Denied(reason), DecisionRecord::Denied { reason }) + } + + /// The outcome of an approved re-decision: the named grant's mode, else allowed. + fn outcome_for(&self, decision: &crate::policy::Decision, grants: &Grants) -> DecisionRecord { + let mode = match grants { + Ok(set) => set + .grants() + .iter() + .find(|g| g.id == decision.grant()) + .map(|g| g.grant.mode), + Err(_) => None, + }; + match mode { + Some(Mode::Ask) => DecisionRecord::Ask {}, + _ => DecisionRecord::Allowed {}, + } + } + + /// Decide and record a call. + pub fn decide(&self, request: ToolRequest, grants: &Grants, now: Timestamp) -> Decided { + let session = request.session.clone(); + let call = request.call; + let tool = request.tool.clone(); + let arguments = request.arguments.clone(); + + // 1. Lock fails → denied, nothing written. + let mut guard = match self.lock() { + Some(guard) => guard, + None => return Decided::Denied(DenyReason::AuditUnavailable), + }; + + // 2. Read the state, keeping the Result. The recorded state is "unknown" when unreadable. + let state = self.state_read(&guard, &session); + let recorded = match &state { + Ok(state) => *state, + Err(()) => SessionState { + taint: DataClass::Secret, + untrusted: true, + }, + }; + + // 3. The outcome, first that applies. + let (outcome, grant, grant_sha256, payload) = match grants { + Err(_) => ( + DecisionRecord::Denied { + reason: DenyReason::GrantsInvalid, + }, + None, + None, + RecordPayload::Simple(DenyReason::GrantsInvalid), + ), + Ok(set) => match state { + Err(()) => ( + DecisionRecord::Denied { + reason: DenyReason::StateUnreadable, + }, + None, + None, + RecordPayload::Simple(DenyReason::StateUnreadable), + ), + Ok(state) => match crate::policy::decide(request, set, state, now) { + crate::policy::Outcome::Allowed(decision) => ( + DecisionRecord::Allowed {}, + Some(decision.grant().to_string()), + Some(decision.grant_sha256()), + RecordPayload::Allowed(decision), + ), + crate::policy::Outcome::Ask(ask) => ( + DecisionRecord::Ask {}, + Some(ask.grant().to_string()), + Some(ask.grant_sha256()), + RecordPayload::Ask(ask), + ), + crate::policy::Outcome::Denied(denial) => ( + DecisionRecord::Denied { + reason: denial.reason, + }, + denial.grant.clone(), + denial.grant_sha256, + RecordPayload::Denial(denial), + ), + }, + }, + }; + + // 4. Write the record, then return the verdict. + let event = AuditEvent::Decision { + session, + call, + tool, + arguments, + outcome, + grant, + grant_sha256, + taint: recorded.taint, + untrusted: recorded.untrusted, + }; + match self.append(&mut guard, now, event) { + Ok(seq) => match payload { + RecordPayload::Simple(reason) => Decided::Denied(reason), + RecordPayload::Allowed(decision) => Decided::Allowed { decision, seq }, + RecordPayload::Ask(ask) => Decided::Ask { + ask, + seq, + state: recorded, + }, + RecordPayload::Denial(denial) => Decided::Denied(denial.reason), + }, + Err(()) => Decided::Denied(DenyReason::AuditUnavailable), + } + } + + /// Re-decide an approval and record it. + pub fn answer( + &self, + ask: Ask, + decision: u64, + answer: Answer, + grants: &Grants, + now: Timestamp, + ) -> Answered { + // 1. Lock fails → step fails. + let mut guard = match self.lock() { + Some(guard) => guard, + None => return Self::audit_unavailable(), + }; + + // 2. Read the state of the ask's session. + let session = ask.request().session.clone(); + let call = ask.request().call; + let state = self.state_read(&guard, &session); + let recorded = match &state { + Ok(state) => *state, + Err(()) => SessionState { + taint: DataClass::Secret, + untrusted: true, + }, + }; + + // 3. The answer and its fields. + let (answer, by, reason) = match &answer { + Answer::Approved { by } => (ApprovalAnswer::Approved, by.clone(), None), + Answer::Refused { by, reason } => (ApprovalAnswer::Refused, by.clone(), reason.clone()), + Answer::Expired => (ApprovalAnswer::Expired, None, None), + }; + + // 4. The re-decision (approved only); every other answer is a denial. + let redecide = match (answer, grants) { + (ApprovalAnswer::Approved, Ok(set)) => match &state { + Ok(state) => crate::policy::redecide(ask, set, *state, now), + Err(()) => Err(Denial::new(DenyReason::StateUnreadable)), + }, + _ => Err(Denial::new(DenyReason::GrantsInvalid)), + }; + + // 5. The grant fields, then the verdict and outcome together. + let (grant, grant_sha256) = match &redecide { + Ok(decision) => ( + Some(decision.grant().to_string()), + Some(decision.grant_sha256()), + ), + Err(_) => (None, None), + }; + let (verdict, outcome) = match (answer, redecide) { + (ApprovalAnswer::Approved, Ok(decision)) => { + let outcome = self.outcome_for(&decision, grants); + (Verdict::Run(Box::new(decision)), outcome) + } + (ApprovalAnswer::Approved, Err(denial)) => Self::denied(denial.reason), + (ApprovalAnswer::Refused, _) => Self::denied(DenyReason::ApprovalRefused), + (ApprovalAnswer::Expired, _) => Self::denied(DenyReason::ApprovalExpired), + }; + + // 6. Record the approval, or step fails. + match self.append( + &mut guard, + now, + AuditEvent::Approval { + session, + call, + decision, + answer, + by, + post: None, + reason, + outcome: outcome.clone(), + grant, + grant_sha256, + taint: recorded.taint, + untrusted: recorded.untrusted, + }, + ) { + Ok(_) => Answered { verdict, outcome }, + Err(()) => Self::audit_unavailable(), + } + } + + /// Raise the state, hash the response, and record a result, or stop the ledger on failure. + fn record_result( + &self, + guard: &mut MutexGuard<'_, Inner>, + call: &Call, + now: Timestamp, + status: proto::ResultStatus, + taint_after: DataClass, + response: &ToolResponse, + ) -> ToolResponse { + let (sha256, bytes) = match response { + ToolResponse::Result { content, .. } => match proto::sha256(content.as_bytes()) { + Ok(sha) => (sha, u64::try_from(content.len()).unwrap_or(u64::MAX)), + Err(_) => return Self::not_recorded(), + }, + ToolResponse::Failed { message } => match proto::sha256(message.as_bytes()) { + Ok(sha) => (sha, u64::try_from(message.len()).unwrap_or(u64::MAX)), + Err(_) => return Self::not_recorded(), + }, + _ => return Self::not_recorded(), + }; + let (class, untrusted, truncated) = match response { + ToolResponse::Result { + class, + untrusted, + truncated, + .. + } => (*class, *untrusted, *truncated), + ToolResponse::Failed { .. } => (call.label.class, call.label.untrusted, false), + _ => return Self::not_recorded(), + }; + let event = AuditEvent::Result { + session: call.session.clone(), + call: call.call, + decision: call.decision, + status, + class, + untrusted, + truncated, + bytes, + sha256, + taint_after, + }; + match self.append(guard, now, event) { + Ok(_) => response.clone(), + Err(()) => Self::not_recorded(), + } + } + + /// Raise the state and record a result. + pub fn finish(&self, call: &Call, response: ToolResponse, now: Timestamp) -> ToolResponse { + // 1. Lock fails → cannot record. + let mut guard = match self.lock() { + Some(guard) => guard, + None => return Self::not_recorded(), + }; + + match &response { + // 3. A result raises the state, then is recorded. + ToolResponse::Result { .. } => { + let raised = match self.state_read(&guard, &call.session) { + Ok(state) => match guard.state.raise(&call.session, state, call.label) { + Ok(raised) => raised, + Err(e) => { + (self.log)(&format!("brokerd: {e}")); + return Self::not_recorded(); + } + }, + Err(()) => return Self::not_recorded(), + }; + self.record_result( + &mut guard, + call, + now, + proto::ResultStatus::Result, + raised.taint, + &response, + ) + } + // 4. A failure changes no state, recorded by its message. + ToolResponse::Failed { .. } => { + let taint_after = match self.state_read(&guard, &call.session) { + Ok(state) => state.taint, + Err(()) => DataClass::Secret, + }; + self.record_result( + &mut guard, + call, + now, + proto::ResultStatus::Failed, + taint_after, + &response, + ) + } + // 5. The runner never returns these; nothing to record. + ToolResponse::Denied { .. } | ToolResponse::PendingApproval { .. } => { + Self::not_recorded() + } + } + } +} diff --git a/crates/brokerd/src/lib.rs b/crates/brokerd/src/lib.rs index d5a2b22..692d85c 100644 --- a/crates/brokerd/src/lib.rs +++ b/crates/brokerd/src/lib.rs @@ -5,6 +5,7 @@ pub mod args; pub mod audit; pub mod config; pub mod grants; +pub mod ledger; pub mod policy; pub mod runner; pub mod state; diff --git a/crates/brokerd/tests/ledger.rs b/crates/brokerd/tests/ledger.rs new file mode 100644 index 0000000..fca7785 --- /dev/null +++ b/crates/brokerd/tests/ledger.rs @@ -0,0 +1,383 @@ +//! The ledger's first and third steps: decide and record; raise the state and record the result. +//! And what happens after a failed append or a panic. Do not edit. + +#[path = "support/build.rs"] +mod build; +#[path = "support/rig.rs"] +mod rig; +#[path = "support/sink.rs"] +mod sink; +#[path = "support/tmp.rs"] +mod tmp; + +use std::os::unix::fs::PermissionsExt; +use std::panic::AssertUnwindSafe; + +use brokerd::ledger::{Call, Decided, Grants, NOT_RECORDED, POISONED, STOPPED}; +use brokerd::policy::{Label, SessionState}; +use build::{grant, now, read, set}; +use proto::{ + AuditEvent, CallId, DataClass, DecisionRecord, DenyReason, GrantProblem, Mode, ResultStatus, + SessionId, ToolResponse, +}; +use rig::Rig; + +fn one(grants: Vec) -> Grants { + Ok(set(grants)) +} + +fn notes(mode: Mode) -> Grants { + one(vec![grant("n", "read_file", mode).paths(&["/n"])]) +} + +fn call(label: Label) -> Call { + Call { + session: SessionId::new("s1").unwrap(), + call: CallId(1), + decision: 0, + label, + } +} + +fn secret() -> Label { + Label { + class: DataClass::Secret, + untrusted: true, + } +} + +fn result(content: &str, label: Label) -> ToolResponse { + ToolResponse::Result { + content: content.to_string(), + class: label.class, + untrusted: label.untrusted, + truncated: false, + } +} + +#[test] +fn an_allowed_call_is_recorded_in_full() { + let rig = Rig::new("ledger-allowed"); + let ledger = rig.ledger(); + let request = read("/n/a"); + let arguments = request.arguments.clone(); + match ledger.decide(request, ¬es(Mode::Auto), now()) { + Decided::Allowed { decision, seq } => { + assert_eq!(seq, 0); + assert_eq!(decision.grant(), "n"); + } + other => panic!("{other:?}"), + } + let records = rig.records(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].time, now()); + assert_eq!( + records[0].event, + AuditEvent::Decision { + session: SessionId::new("s1").unwrap(), + call: CallId(1), + tool: "read_file".to_string(), + arguments, + outcome: DecisionRecord::Allowed {}, + grant: Some("n".to_string()), + grant_sha256: Some(proto::sha256(b"n").unwrap()), + taint: DataClass::Private, + untrusted: false, + } + ); +} + +#[test] +fn an_ask_is_recorded_as_ask_with_the_state_it_was_decided_at() { + let rig = Rig::new("ledger-ask"); + let ledger = rig.ledger(); + match ledger.decide(read("/n/a"), ¬es(Mode::Ask), now()) { + Decided::Ask { ask, seq, state } => { + assert_eq!((seq, ask.grant()), (0, "n")); + assert_eq!(state, SessionState::default()); + } + other => panic!("{other:?}"), + } + match &rig.events()[0] { + AuditEvent::Decision { outcome, grant, .. } => { + assert_eq!(*outcome, DecisionRecord::Ask {}); + assert_eq!(grant.as_deref(), Some("n")); + } + other => panic!("{other:?}"), + } +} + +#[test] +fn a_denial_names_a_grant_only_when_a_grant_denied() { + let rig = Rig::new("ledger-denied"); + let ledger = rig.ledger(); + let none = ledger.decide(read("/n/a"), &one(Vec::new()), now()); + assert!( + matches!(none, Decided::Denied(DenyReason::NoGrant)), + "{none:?}" + ); + let deny = one(vec![grant("d", "read_file", Mode::Deny).paths(&["/n"])]); + let by = ledger.decide(read("/n/a"), &deny, now()); + assert!( + matches!(by, Decided::Denied(DenyReason::DeniedByGrant)), + "{by:?}" + ); + let events = rig.events(); + let named: Vec<(DecisionRecord, Option)> = events + .iter() + .map(|e| match e { + AuditEvent::Decision { outcome, grant, .. } => (outcome.clone(), grant.clone()), + other => panic!("{other:?}"), + }) + .collect(); + assert_eq!( + named, + [ + ( + DecisionRecord::Denied { + reason: DenyReason::NoGrant + }, + None + ), + ( + DecisionRecord::Denied { + reason: DenyReason::DeniedByGrant + }, + Some("d".to_string()) + ), + ] + ); +} + +#[test] +fn invalid_grants_come_first_then_an_unreadable_state() { + let rig = Rig::new("ledger-order"); + let ledger = rig.ledger(); + std::fs::create_dir_all(rig.cfg.state_dir()).unwrap(); + std::fs::write(rig.state_file("s1"), "not json").unwrap(); + let invalid: Grants = Err(vec![GrantProblem { + file: "x.toml".to_string(), + line: None, + problem: "bad".to_string(), + }]); + let first = ledger.decide(read("/n/a"), &invalid, now()); + assert!( + matches!(first, Decided::Denied(DenyReason::GrantsInvalid)), + "{first:?}" + ); + let second = ledger.decide(read("/n/a"), ¬es(Mode::Auto), now()); + assert!( + matches!(second, Decided::Denied(DenyReason::StateUnreadable)), + "{second:?}" + ); + // A state brokerd cannot read is recorded as the most sensitive one. + for event in rig.events() { + match event { + AuditEvent::Decision { + taint, untrusted, .. + } => assert_eq!((taint, untrusted), (DataClass::Secret, true)), + other => panic!("{other:?}"), + } + } + let damaged = rig.lines.with("see docs/runbook.md#broker-state-damaged"); + assert!(!damaged.is_empty(), "{:?}", rig.lines.all()); +} + +#[test] +fn a_failed_append_denies_this_call_and_every_later_one() { + let rig = Rig::new("ledger-stop"); + let ledger = rig.ledger(); + rig.switch.fail(true); + let first = ledger.decide(read("/n/a"), ¬es(Mode::Auto), now()); + assert!( + matches!(first, Decided::Denied(DenyReason::AuditUnavailable)), + "{first:?}" + ); + assert_eq!(rig.switch.attempts(), 1); + // The sink would work again; the ledger does not try it. + rig.switch.fail(false); + let later = ledger.decide(read("/n/a"), ¬es(Mode::Auto), now()); + assert!( + matches!(later, Decided::Denied(DenyReason::AuditUnavailable)), + "{later:?}" + ); + let finished = ledger.finish(&call(secret()), result("x", secret()), now()); + assert_eq!( + finished, + ToolResponse::Failed { + message: NOT_RECORDED.to_string() + } + ); + assert_eq!(rig.switch.attempts(), 1, "nothing more was written"); + assert!(rig.records().is_empty()); + assert!(!rig.state_file("s1").exists(), "the state was not raised"); + assert!(!rig.lines.with(STOPPED).is_empty()); + assert!( + rig.lines + .all() + .iter() + .all(|l| !l.contains("runbook") || l.ends_with("see docs/runbook.md#audit-unavailable")), + "{:?}", + rig.lines.all() + ); +} + +#[test] +fn a_panic_while_holding_the_ledger_denies_every_later_call() { + let rig = Rig::new("ledger-poison"); + let ledger = rig.ledger(); + rig.switch.panic_next(); + let panicked = std::panic::catch_unwind(AssertUnwindSafe(|| { + ledger.decide(read("/n/a"), ¬es(Mode::Auto), now()) + })); + assert!(panicked.is_err()); + let later = ledger.decide(read("/n/a"), ¬es(Mode::Auto), now()); + assert!( + matches!(later, Decided::Denied(DenyReason::AuditUnavailable)), + "{later:?}" + ); + assert_eq!(rig.switch.attempts(), 1); + assert!( + !rig.lines.with(POISONED).is_empty(), + "{:?}", + rig.lines.all() + ); + assert!(POISONED.ends_with("see docs/runbook.md#audit-unavailable")); +} + +#[test] +fn a_result_raises_the_state_then_is_recorded() { + let rig = Rig::new("ledger-result"); + let ledger = rig.ledger(); + let answer = ledger.finish(&call(secret()), result("key", secret()), now()); + assert_eq!(answer, result("key", secret()), "passed on unchanged"); + let state = rig.state().read(&SessionId::new("s1").unwrap()).unwrap(); + assert_eq!( + state, + SessionState { + taint: DataClass::Secret, + untrusted: true + } + ); + assert_eq!( + rig.events(), + [AuditEvent::Result { + session: SessionId::new("s1").unwrap(), + call: CallId(1), + decision: 0, + status: ResultStatus::Result, + class: DataClass::Secret, + untrusted: true, + truncated: false, + bytes: 3, + sha256: proto::sha256(b"key").unwrap(), + taint_after: DataClass::Secret, + }] + ); +} + +#[test] +fn taint_never_goes_down() { + let rig = Rig::new("ledger-down"); + let ledger = rig.ledger(); + let public = Label { + class: DataClass::Public, + untrusted: false, + }; + ledger.finish(&call(secret()), result("a", secret()), now()); + ledger.finish(&call(public), result("b", public), now()); + let after: Vec = rig + .events() + .iter() + .map(|e| match e { + AuditEvent::Result { taint_after, .. } => *taint_after, + other => panic!("{other:?}"), + }) + .collect(); + assert_eq!(after, [DataClass::Secret, DataClass::Secret]); + let state = rig.state().read(&SessionId::new("s1").unwrap()).unwrap(); + assert_eq!((state.taint, state.untrusted), (DataClass::Secret, true)); +} + +#[test] +fn a_failure_changes_no_state_and_is_recorded_by_its_message() { + let rig = Rig::new("ledger-failed"); + let ledger = rig.ledger(); + let failed = ToolResponse::Failed { + message: "the tool timed out".to_string(), + }; + assert_eq!( + ledger.finish(&call(secret()), failed.clone(), now()), + failed + ); + assert!(!rig.state_file("s1").exists()); + match &rig.events()[0] { + AuditEvent::Result { + status, + class, + bytes, + sha256, + taint_after, + .. + } => { + assert_eq!(*status, ResultStatus::Failed); + assert_eq!( + *class, + DataClass::Secret, + "the label the result would have had" + ); + assert_eq!(*bytes, 18); + assert_eq!(*sha256, proto::sha256(b"the tool timed out").unwrap()); + assert_eq!(*taint_after, DataClass::Private, "the taint before"); + } + other => panic!("{other:?}"), + } +} + +#[test] +fn a_state_that_cannot_be_written_withholds_the_content() { + if tmp::running_as_root("a_state_that_cannot_be_written_withholds_the_content") { + return; + } + let rig = Rig::new("ledger-ro"); + let ledger = rig.ledger(); + let dir = rig.cfg.state_dir(); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).unwrap(); + let answer = ledger.finish(&call(secret()), result("key", secret()), now()); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap(); + assert_eq!( + answer, + ToolResponse::Failed { + message: NOT_RECORDED.to_string() + } + ); + assert!( + rig.records().is_empty(), + "no Result for a state not on disk" + ); + assert!( + !rig.lines + .with("see docs/runbook.md#broker-state-damaged") + .is_empty() + ); +} + +#[test] +fn a_result_record_that_cannot_be_written_withholds_the_content() { + let rig = Rig::new("ledger-norecord"); + let ledger = rig.ledger(); + rig.switch.fail(true); + let answer = ledger.finish(&call(secret()), result("key", secret()), now()); + assert_eq!( + answer, + ToolResponse::Failed { + message: NOT_RECORDED.to_string() + } + ); + assert!( + !rig.lines + .with("see docs/runbook.md#audit-unavailable") + .is_empty() + ); +} diff --git a/crates/brokerd/tests/ledger_answer.rs b/crates/brokerd/tests/ledger_answer.rs new file mode 100644 index 0000000..cd543d9 --- /dev/null +++ b/crates/brokerd/tests/ledger_answer.rs @@ -0,0 +1,262 @@ +//! The ledger's second step: an approval decides again and is recorded; a refusal and an expiry +//! are recorded as denials. Do not edit. + +#[path = "support/build.rs"] +mod build; +#[path = "support/rig.rs"] +mod rig; +#[path = "support/sink.rs"] +mod sink; +#[path = "support/tmp.rs"] +mod tmp; + +use brokerd::approvals::Verdict; +use brokerd::ledger::{Answer, Answered, Decided, Grants, Ledger}; +use brokerd::policy::{Ask, Label}; +use build::{grant, now, read, set}; +use proto::{ + ApprovalAnswer, AuditEvent, CallId, DataClass, DecisionRecord, DenyReason, Mode, SessionId, +}; +use rig::Rig; + +fn asking() -> Grants { + Ok(set(vec![grant("n", "read_file", Mode::Ask).paths(&["/n"])])) +} + +fn pending(ledger: &Ledger) -> Ask { + match ledger.decide(read("/n/deep/a"), &asking(), now()) { + Decided::Ask { ask, seq: 0, .. } => ask, + other => panic!("{other:?}"), + } +} + +fn bxctl() -> Answer { + Answer::Approved { + by: Some("bxctl".to_string()), + } +} + +fn denied(reason: DenyReason) -> DecisionRecord { + DecisionRecord::Denied { reason } +} + +fn verdict_reason(answered: &Answered) -> Option { + match &answered.verdict { + Verdict::Run(_) => None, + Verdict::Denied(reason) => Some(*reason), + } +} + +/// The one `Approval` record, as (answer, by, reason, outcome, grant). +type Row = ( + ApprovalAnswer, + Option, + Option, + DecisionRecord, + Option, +); + +fn approval(rig: &Rig) -> Row { + let events = rig.events(); + assert_eq!(events.len(), 2, "a decision and one approval: {events:?}"); + match &events[1] { + AuditEvent::Approval { + session, + call, + decision, + answer, + by, + post, + reason, + outcome, + grant, + .. + } => { + assert_eq!(session, &SessionId::new("s1").unwrap()); + assert_eq!((*call, *decision, post), (CallId(1), 0, &None)); + ( + *answer, + by.clone(), + reason.clone(), + outcome.clone(), + grant.clone(), + ) + } + other => panic!("{other:?}"), + } +} + +#[test] +fn an_approval_that_still_asks_lets_the_call_run() { + let rig = Rig::new("answer-ask"); + let ledger = rig.ledger(); + let ask = pending(&ledger); + let answered = ledger.answer(ask, 0, bxctl(), &asking(), now()); + match &answered.verdict { + Verdict::Run(decision) => assert_eq!(decision.grant(), "n"), + other => panic!("{other:?}"), + } + assert_eq!(answered.outcome, DecisionRecord::Ask {}); + let row = approval(&rig); + assert_eq!( + row, + ( + ApprovalAnswer::Approved, + Some("bxctl".to_string()), + None, + DecisionRecord::Ask {}, + Some("n".to_string()) + ) + ); +} + +#[test] +fn an_approval_under_a_grant_that_is_now_auto_records_allowed() { + let rig = Rig::new("answer-auto"); + let ledger = rig.ledger(); + let ask = pending(&ledger); + let now_auto = Ok(set(vec![ + grant("n", "read_file", Mode::Auto).paths(&["/n"]), + ])); + let answered = ledger.answer(ask, 0, bxctl(), &now_auto, now()); + assert!(matches!(answered.verdict, Verdict::Run(_))); + assert_eq!(answered.outcome, DecisionRecord::Allowed {}); +} + +#[test] +fn the_approval_names_the_grant_matched_now() { + let rig = Rig::new("answer-grant"); + let ledger = rig.ledger(); + let ask = pending(&ledger); + let wider = Ok(set(vec![ + grant("n", "read_file", Mode::Ask).paths(&["/n"]), + grant("z-deep", "read_file", Mode::Ask).paths(&["/n/deep"]), + ])); + let answered = ledger.answer(ask, 0, bxctl(), &wider, now()); + match &answered.verdict { + Verdict::Run(decision) => assert_eq!(decision.grant(), "z-deep"), + other => panic!("{other:?}"), + } + assert_eq!(approval(&rig).4.as_deref(), Some("z-deep")); +} + +#[test] +fn an_approval_after_the_grant_is_gone_is_denied() { + let rig = Rig::new("answer-gone"); + let ledger = rig.ledger(); + let ask = pending(&ledger); + let answered = ledger.answer(ask, 0, bxctl(), &Ok(set(Vec::new())), now()); + assert_eq!(verdict_reason(&answered), Some(DenyReason::NoGrant)); + assert_eq!(answered.outcome, denied(DenyReason::NoGrant)); + let row = approval(&rig); + assert_eq!( + (row.0, row.3, row.4), + (ApprovalAnswer::Approved, denied(DenyReason::NoGrant), None) + ); +} + +#[test] +fn an_approval_after_the_taint_rose_is_denied() { + let rig = Rig::new("answer-taint"); + let ledger = rig.ledger(); + let low = || { + Ok(set(vec![ + grant("n", "read_file", Mode::Ask) + .paths(&["/n"]) + .max_taint(DataClass::Private), + ])) + }; + let ask = match ledger.decide(read("/n/a"), &low(), now()) { + Decided::Ask { ask, .. } => ask, + other => panic!("{other:?}"), + }; + // Another call of the session read a secret while this one waited. + let s1 = SessionId::new("s1").unwrap(); + let label = Label { + class: DataClass::Secret, + untrusted: false, + }; + rig.state() + .raise(&s1, rig.state().read(&s1).unwrap(), label) + .unwrap(); + let answered = ledger.answer(ask, 0, bxctl(), &low(), now()); + assert_eq!(verdict_reason(&answered), Some(DenyReason::TaintTooHigh)); + match &rig.events()[1] { + AuditEvent::Approval { taint, outcome, .. } => { + assert_eq!(*taint, DataClass::Secret, "the state at the re-decision"); + assert_eq!(*outcome, denied(DenyReason::TaintTooHigh)); + } + other => panic!("{other:?}"), + } +} + +#[test] +fn an_approval_with_invalid_grants_is_denied() { + let rig = Rig::new("answer-invalid"); + let ledger = rig.ledger(); + let ask = pending(&ledger); + let answered = ledger.answer(ask, 0, bxctl(), &Err(Vec::new()), now()); + assert_eq!(verdict_reason(&answered), Some(DenyReason::GrantsInvalid)); +} + +#[test] +fn a_refusal_is_recorded_with_the_owners_reason() { + let rig = Rig::new("answer-refuse"); + let ledger = rig.ledger(); + let ask = pending(&ledger); + let refused = Answer::Refused { + by: Some("bxctl".to_string()), + reason: Some("not now".to_string()), + }; + let answered = ledger.answer(ask, 0, refused, &asking(), now()); + assert_eq!(verdict_reason(&answered), Some(DenyReason::ApprovalRefused)); + assert_eq!( + approval(&rig), + ( + ApprovalAnswer::Refused, + Some("bxctl".to_string()), + Some("not now".to_string()), + denied(DenyReason::ApprovalRefused), + None + ) + ); +} + +#[test] +fn an_expiry_is_recorded_by_nobody() { + let rig = Rig::new("answer-expire"); + let ledger = rig.ledger(); + let ask = pending(&ledger); + let answered = ledger.answer(ask, 0, Answer::Expired, &asking(), now()); + assert_eq!(verdict_reason(&answered), Some(DenyReason::ApprovalExpired)); + assert_eq!( + approval(&rig), + ( + ApprovalAnswer::Expired, + None, + None, + denied(DenyReason::ApprovalExpired), + None + ) + ); +} + +#[test] +fn an_approval_that_cannot_be_recorded_does_not_run() { + let rig = Rig::new("answer-norecord"); + let ledger = rig.ledger(); + let ask = pending(&ledger); + rig.switch.fail(true); + let answered = ledger.answer(ask, 0, bxctl(), &asking(), now()); + assert_eq!( + verdict_reason(&answered), + Some(DenyReason::AuditUnavailable) + ); + assert_eq!(answered.outcome, denied(DenyReason::AuditUnavailable)); + assert_eq!(rig.events().len(), 1, "only the decision"); + assert!( + !rig.lines + .with("see docs/runbook.md#audit-unavailable") + .is_empty() + ); +} diff --git a/crates/brokerd/tests/support/rig.rs b/crates/brokerd/tests/support/rig.rs new file mode 100644 index 0000000..ddbc24d --- /dev/null +++ b/crates/brokerd/tests/support/rig.rs @@ -0,0 +1,118 @@ +//! A home for ledger and broker tests: grants, audit log, session state, a flaky audit sink and +//! a log to read. Do not edit. +//! +//! Included with `#[path = "support/rig.rs"] mod rig;`, beside `tmp` and `sink`. The broker +//! tests add `client`. + +#![allow(dead_code)] // each test file uses a different part of this module + +use std::path::PathBuf; + +use brokerd::audit::Writer; +use brokerd::config::{Approvals, Config, Paths, Sockets}; +use brokerd::ledger::Ledger; +use brokerd::state::StateStore; +use proto::{AuditEvent, AuditRecord, CallId, SessionId, ToolRequest}; + +use crate::sink::{Flaky, Lines, Switch}; +use crate::tmp::TempDir; + +pub struct Rig { + pub dir: TempDir, + pub cfg: Config, + pub switch: Switch, + pub lines: Lines, +} + +impl Rig { + pub fn new(tag: &str) -> Rig { + Rig::with_ttl(tag, 900_000) + } + + pub fn with_ttl(tag: &str, ttl_ms: u64) -> Rig { + let dir = TempDir::new(tag); + let grants = dir.path().join("grants"); + std::fs::create_dir_all(&grants).unwrap(); + let cfg = Config { + paths: Paths { + home: dir.path().to_path_buf(), + grants, + }, + sockets: Sockets::default(), + approvals: Approvals { ttl_ms }, + }; + Rig { + dir, + cfg, + switch: Switch::default(), + lines: Lines::default(), + } + } + + pub fn state(&self) -> StateStore { + StateStore::new(&self.cfg.state_dir()) + } + + /// Opens the audit log (once: the writer holds its lock) behind the flaky sink. + pub fn ledger(&self) -> Ledger { + let opened = Writer::open(&self.cfg.audit_dir(), false).unwrap(); + let sink = Flaky { + writer: opened.writer, + switch: self.switch.clone(), + }; + Ledger::new(Box::new(sink), self.state(), self.lines.sink()) + } + + /// Writes `grants/.toml`. + pub fn grant(&self, id: &str, text: &str) { + std::fs::write(self.cfg.paths.grants.join(format!("{id}.toml")), text).unwrap(); + } + + pub fn remove_grant(&self, id: &str) { + std::fs::remove_file(self.cfg.paths.grants.join(format!("{id}.toml"))).unwrap(); + } + + pub fn state_file(&self, session: &str) -> PathBuf { + self.cfg.state_dir().join(format!("{session}.json")) + } + + /// Every record in the audit log, in order. + pub fn records(&self) -> Vec { + let dir = self.cfg.audit_dir(); + let mut names: Vec = std::fs::read_dir(&dir) + .unwrap() + .map(|e| e.unwrap().file_name().into_string().unwrap()) + .filter(|n| n.ends_with(".jsonl")) + .collect(); + names.sort(); + let mut out = Vec::new(); + for name in names { + let text = std::fs::read_to_string(dir.join(name)).unwrap(); + for line in text.lines() { + out.push(serde_json::from_str(line).unwrap()); + } + } + out + } + + pub fn events(&self) -> Vec { + self.records().into_iter().map(|r| r.event).collect() + } +} + +/// A grant file's text. `extra` goes before `[constraints]`, `constraints` after it. +pub fn grant_text(tool: &str, mode: &str, extra: &str, constraints: &str) -> String { + format!( + "tool = \"{tool}\"\nmode = \"{mode}\"\nmax_taint = \"secret\"\nresult_class = \"private\"\n\ + untrusted = false\n{extra}\n[constraints]\n{constraints}\n" + ) +} + +pub fn request(session: &str, call: u64, tool: &str, arguments: &str) -> ToolRequest { + ToolRequest { + session: SessionId::new(session).unwrap(), + call: CallId(call), + tool: tool.to_string(), + arguments: arguments.to_string(), + } +} diff --git a/crates/brokerd/tests/support/sink.rs b/crates/brokerd/tests/support/sink.rs new file mode 100644 index 0000000..abea275 --- /dev/null +++ b/crates/brokerd/tests/support/sink.rs @@ -0,0 +1,78 @@ +//! An audit sink that fails on demand, and a log that tests can read. Do not edit. +//! +//! Included with `#[path = "support/sink.rs"] mod sink;`. + +#![allow(dead_code)] // each test file uses a different part of this module + +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use brokerd::audit::{AuditError, Writer}; +use brokerd::ledger::AuditSink; +use proto::{AuditEvent, Timestamp}; + +/// Switches shared between a test and its `Flaky` sink. +#[derive(Clone, Default)] +pub struct Switch { + fail: Arc, + panic: Arc, + attempts: Arc, +} + +impl Switch { + /// Every append from now on fails, without writing anything. + pub fn fail(&self, on: bool) { + self.fail.store(on, Ordering::SeqCst); + } + /// The next append panics, as a bug part-way through a write would. + pub fn panic_next(&self) { + self.panic.store(true, Ordering::SeqCst); + } + /// How many appends the ledger has asked for. + pub fn attempts(&self) -> usize { + self.attempts.load(Ordering::SeqCst) + } +} + +/// A real `Writer` behind a switch. +pub struct Flaky { + pub writer: Writer, + pub switch: Switch, +} + +impl AuditSink for Flaky { + fn append(&mut self, time: Timestamp, event: AuditEvent) -> Result { + self.switch.attempts.fetch_add(1, Ordering::SeqCst); + if self.switch.panic.swap(false, Ordering::SeqCst) { + panic!("a bug part-way through a write"); + } + if self.switch.fail.load(Ordering::SeqCst) { + return Err(AuditError::Io { + what: "cannot write to the test log".to_string(), + source: std::io::Error::other("the disk is full"), + }); + } + self.writer.append(time, event) + } +} + +/// Collects every line a ledger or broker prints. +#[derive(Clone, Default)] +pub struct Lines(Arc>>); + +impl Lines { + pub fn sink(&self) -> Box { + let lines = Arc::clone(&self.0); + Box::new(move |line| lines.lock().unwrap().push(line.to_string())) + } + pub fn all(&self) -> Vec { + self.0.lock().unwrap().clone() + } + /// The lines that hold `text`. + pub fn with(&self, text: &str) -> Vec { + self.all() + .into_iter() + .filter(|l| l.contains(text)) + .collect() + } +} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 87bacc8..565e7b0 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone. | Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model | |---|---|---|---|---|---|---|---| +| M3a/12-brokerd-ledger | 2026-09-20 | done | 2 | fail | none | Wrote crates/brokerd/src/ledger.rs (499 lines): Ledger + Inner { audit, state, stopped } behind one Mutex, and the three steps that hold it. decide copies the request out, reads state then policy::decide, and records the outcome (allowed/ask/denied, grant fields set together) as AuditEvent::Decision; answer re-decides an approval (approved only) and records AuditEvent::Approval with the answer/by/reason; finish raises the state for a Result and records AuditEvent::Result by its message otherwise, returning response unchanged only once the raised taint and the record are both on disk. Helpers not_recorded/audit_unavailable/denied; every append Err sets stopped through the one append method, and finish logs the raise error "brokerd: {e}" before stopping. Step 5 verified: each numbered exit points at a line and every append Err goes through the one stopped place. Trimmed 588 to 499 by compressing the module doc; one clippy fix (needless `return` in the answer append match, which is the tail expression). 11 + 9 tests pass; `make gate` prints `gate: ok`. | ? | | M3a/08-brokerd-state | 2026-09-18 | done | 1 | pass | none | Wrote crates/brokerd/src/state.rs: RUNBOOK, StateError (Unreadable/Write with hand-written Display ending in RUNBOOK and std::error::Error), StateStore (new does not touch disk, path joins /.json, read, raise) and the private StateFile with deny_unknown_fields. read has exactly one default path (ErrorKind::NotFound); Public taint is Unreadable; raise computes max(taint,label,Private) and ORs untrusted, always writes atomically in six steps mapping any error to Write(path, err). `cargo fmt` put `state` after `runner` in lib.rs. 9 tests pass; `make gate` prints `gate: ok`. | Laguna S 2.1 | | M1/01-workspace-and-gate | 2026-09-17 | done | 1 | pass | none | Crate skeletons, Cargo files and the given Makefile/deny.toml/test-gate-scripts.sh were already present untracked from a prior attempt; I verified them against the plan and created only the missing gate scripts, dependencies.md, egress.md and this log row. | Laguna S 2.1 | | M1/02-proto-values | 2026-09-17 | done | 1 | pass | none | Implemented ValueError, SessionId, Epoch, CallId, Hash32 and Timestamp in crates/proto/src/ids.rs and DataClass in class.rs, using serde try_from/into for string-backed JSON validation, a hand-written hex encoder and humantime for RFC 3339 parsing with canonical re-serialization. | Laguna S 2.1 |