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()
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user