Add the ledger: the audit writer and session state behind one lock

Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
2026-09-20 03:30:37 -07:00
parent caf8fd6eca
commit cff22ce579
7 changed files with 1342 additions and 0 deletions
+499
View File
@@ -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<GrantSet, Vec<GrantProblem>>;
/// 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<u64, AuditError>;
}
impl AuditSink for Writer {
fn append(&mut self, time: Timestamp, event: AuditEvent) -> Result<u64, AuditError> {
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<String>,
},
Refused {
by: Option<String>,
reason: Option<String>,
},
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<dyn AuditSink>,
state: StateStore,
stopped: bool,
}
/// The audit writer and every session's state behind one lock.
pub struct Ledger {
inner: Mutex<Inner>,
log: Box<dyn Fn(&str) + Send + Sync>,
}
impl Ledger {
/// A ledger over the audit writer, the session store, and a sink for its notices.
pub fn new(
audit: Box<dyn AuditSink>,
state: StateStore,
log: Box<dyn Fn(&str) + Send + Sync>,
) -> 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<MutexGuard<'_, Inner>> {
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<Inner>,
session: &SessionId,
) -> Result<SessionState, ()> {
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<Inner>,
now: Timestamp,
event: AuditEvent,
) -> Result<u64, ()> {
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()
}
}
}
}
+1
View File
@@ -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;