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:
@@ -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<build::Build>) -> 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<String>)> = 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<DataClass> = 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()
|
||||
);
|
||||
}
|
||||
@@ -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<DenyReason> {
|
||||
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<String>,
|
||||
Option<String>,
|
||||
DecisionRecord,
|
||||
Option<String>,
|
||||
);
|
||||
|
||||
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()
|
||||
);
|
||||
}
|
||||
@@ -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/<id>.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<AuditRecord> {
|
||||
let dir = self.cfg.audit_dir();
|
||||
let mut names: Vec<String> = 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<AuditEvent> {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
@@ -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<AtomicBool>,
|
||||
panic: Arc<AtomicBool>,
|
||||
attempts: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
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<u64, AuditError> {
|
||||
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<Mutex<Vec<String>>>);
|
||||
|
||||
impl Lines {
|
||||
pub fn sink(&self) -> Box<dyn Fn(&str) + Send + Sync> {
|
||||
let lines = Arc::clone(&self.0);
|
||||
Box::new(move |line| lines.lock().unwrap().push(line.to_string()))
|
||||
}
|
||||
pub fn all(&self) -> Vec<String> {
|
||||
self.0.lock().unwrap().clone()
|
||||
}
|
||||
/// The lines that hold `text`.
|
||||
pub fn with(&self, text: &str) -> Vec<String> {
|
||||
self.all()
|
||||
.into_iter()
|
||||
.filter(|l| l.contains(text))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user