Implemented decide and redecide in crates/brokerd/src/policy.rs: SessionState, Label, Denial, private Matched, and Decision/Ask (private fields, Debug only, nine getters each) with the Outcome enum. decide rejects an unknown tool (args not parsed) and malformed arguments before matching, then runs the M1-M5 matching pass in id order and returns Allowed/Ask/Denied by the winner's mode; redecide re-runs matching now and rebuilds the Decision from the Ask. Seven doctests (six compile_fail, one compiling) guard the two facts. policy 7, policy_matching 10, policy_redecide 7, policy_property 4, doc 7 all pass; make gate ok. Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
235 lines
8.4 KiB
Rust
235 lines
8.4 KiB
Rust
//! Property test for `policy`: random grant sets, states and requests, each decided twice, once
|
|
//! by `policy::decide` and once by an oracle. Every case must agree. Do not edit.
|
|
//!
|
|
//! The generator and the oracle are in `support/oracle.rs`. If this test fails, the oracle is
|
|
//! the specification and `policy` is wrong.
|
|
//!
|
|
//! The generator is a seeded xorshift, so a failure can be replayed: the message names the seed
|
|
//! and the case. `BOXMAKER_POLICY_SEED=<n>` runs one more seed, and `BOXMAKER_POLICY_CASES=<n>`
|
|
//! changes how many cases each seed runs (default 3000).
|
|
|
|
#[path = "support/oracle.rs"]
|
|
mod oracle;
|
|
|
|
use brokerd::grants::{GrantSet, LoadedGrant};
|
|
use brokerd::policy::{Outcome, SessionState, decide, redecide};
|
|
use oracle::{CLASSES, Expected, NOW_MS, Rng, oracle, some_grant, some_grants, some_request};
|
|
use proto::{DataClass, DenyReason, Mode, Timestamp};
|
|
|
|
const SEEDS: [u64; 5] = [1, 2, 3, 0xB0C5, 20_260_918];
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// The comparison.
|
|
|
|
fn observed(outcome: &Outcome) -> Expected {
|
|
match outcome {
|
|
Outcome::Allowed(d) => Expected::Allowed {
|
|
grant: d.grant().to_string(),
|
|
path: d.matched_path().map(str::to_string),
|
|
class: d.label().class,
|
|
untrusted: d.label().untrusted,
|
|
},
|
|
Outcome::Ask(a) => Expected::Ask {
|
|
grant: a.grant().to_string(),
|
|
path: a.matched_path().map(str::to_string),
|
|
class: a.label().class,
|
|
untrusted: a.label().untrusted,
|
|
},
|
|
Outcome::Denied(denial) => Expected::Denied {
|
|
reason: denial.reason,
|
|
grant: denial.grant.clone(),
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Allowed is 0, ask is 1, denied is 2.
|
|
fn restrictiveness(expected: &Expected) -> u8 {
|
|
match expected {
|
|
Expected::Allowed { .. } => 0,
|
|
Expected::Ask { .. } => 1,
|
|
Expected::Denied { .. } => 2,
|
|
}
|
|
}
|
|
|
|
fn cases() -> usize {
|
|
match std::env::var("BOXMAKER_POLICY_CASES") {
|
|
Ok(text) => text
|
|
.parse()
|
|
.expect("BOXMAKER_POLICY_CASES must be a number"),
|
|
Err(_) => 3000,
|
|
}
|
|
}
|
|
|
|
fn seeds() -> Vec<u64> {
|
|
let mut seeds = SEEDS.to_vec();
|
|
if let Ok(text) = std::env::var("BOXMAKER_POLICY_SEED") {
|
|
seeds.push(text.parse().expect("BOXMAKER_POLICY_SEED must be a number"));
|
|
}
|
|
seeds
|
|
}
|
|
|
|
fn now() -> Timestamp {
|
|
Timestamp::from_unix_millis(NOW_MS).unwrap()
|
|
}
|
|
|
|
fn state(rng: &mut Rng) -> SessionState {
|
|
SessionState {
|
|
taint: rng.pick(&CLASSES),
|
|
untrusted: rng.chance(50),
|
|
}
|
|
}
|
|
|
|
fn valid(grants: &[LoadedGrant]) -> GrantSet {
|
|
GrantSet::from_grants(grants.to_vec()).expect("the generator only makes valid grants")
|
|
}
|
|
|
|
#[test]
|
|
fn decide_agrees_with_the_oracle() {
|
|
let mut kinds = [0usize; 3];
|
|
for seed in seeds() {
|
|
let mut rng = Rng::new(seed);
|
|
for case in 0..cases() {
|
|
let grants = some_grants(&mut rng);
|
|
let request = some_request(&mut rng);
|
|
let state = state(&mut rng);
|
|
let want = oracle(&request, &grants, state);
|
|
let got = observed(&decide(request.clone(), &valid(&grants), state, now()));
|
|
assert_eq!(
|
|
got, want,
|
|
"seed {seed} case {case}\nrequest: {request:?}\nstate: {state:?}\ngrants: {grants:#?}"
|
|
);
|
|
kinds[restrictiveness(&want) as usize] += 1;
|
|
}
|
|
}
|
|
// The generator must reach every kind of outcome, or the test proves little.
|
|
for (kind, count) in ["allowed", "ask", "denied"].iter().zip(kinds) {
|
|
assert!(count > 200, "only {count} cases were {kind}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn redecide_agrees_with_the_oracle_under_new_grants_and_a_new_state() {
|
|
let mut approvals = 0;
|
|
for seed in seeds() {
|
|
let mut rng = Rng::new(seed ^ 0xA5A5);
|
|
for case in 0..cases() {
|
|
let grants = some_grants(&mut rng);
|
|
let request = some_request(&mut rng);
|
|
let first = state(&mut rng);
|
|
let Outcome::Ask(ask) = decide(request.clone(), &valid(&grants), first, now()) else {
|
|
continue;
|
|
};
|
|
approvals += 1;
|
|
// Half the time nothing has changed; otherwise the grants or the state have.
|
|
let (later_grants, later) = if rng.chance(50) {
|
|
(grants.clone(), first)
|
|
} else {
|
|
(some_grants(&mut rng), state(&mut rng))
|
|
};
|
|
let want = oracle(&request, &later_grants, later);
|
|
let got = redecide(ask, &valid(&later_grants), later, now());
|
|
let context = format!(
|
|
"seed {seed} case {case}\nrequest: {request:?}\nlater: {later:?}\ngrants: {later_grants:#?}"
|
|
);
|
|
match (want, got) {
|
|
(
|
|
Expected::Allowed {
|
|
grant,
|
|
path,
|
|
class,
|
|
untrusted,
|
|
},
|
|
Ok(d),
|
|
)
|
|
| (
|
|
Expected::Ask {
|
|
grant,
|
|
path,
|
|
class,
|
|
untrusted,
|
|
},
|
|
Ok(d),
|
|
) => {
|
|
assert_eq!(d.grant(), grant, "{context}");
|
|
assert_eq!(d.matched_path().map(str::to_string), path, "{context}");
|
|
assert_eq!(
|
|
(d.label().class, d.label().untrusted),
|
|
(class, untrusted),
|
|
"{context}"
|
|
);
|
|
assert_eq!(d.request(), &request, "{context}");
|
|
}
|
|
(Expected::Denied { reason, grant }, Err(denial)) => {
|
|
assert_eq!((denial.reason, denial.grant), (reason, grant), "{context}");
|
|
}
|
|
(want, got) => panic!("wanted {want:?}, got {got:?}\n{context}"),
|
|
}
|
|
}
|
|
}
|
|
assert!(approvals > 200, "only {approvals} cases asked");
|
|
}
|
|
|
|
/// Adding a `deny` grant to a set never makes any outcome less restrictive.
|
|
#[test]
|
|
fn adding_a_deny_grant_never_loosens_an_outcome() {
|
|
for seed in seeds() {
|
|
let mut rng = Rng::new(seed ^ 0x5A5A);
|
|
for case in 0..cases() {
|
|
let grants = some_grants(&mut rng);
|
|
let request = some_request(&mut rng);
|
|
let state = state(&mut rng);
|
|
let mut extra = some_grant(&mut rng, 90);
|
|
extra.grant.mode = Mode::Deny;
|
|
extra.grant.max_taint = DataClass::Secret;
|
|
let mut with_deny = grants.clone();
|
|
with_deny.push(extra);
|
|
|
|
let before = observed(&decide(request.clone(), &valid(&grants), state, now()));
|
|
let after = observed(&decide(request.clone(), &valid(&with_deny), state, now()));
|
|
assert!(
|
|
restrictiveness(&after) >= restrictiveness(&before),
|
|
"seed {seed} case {case}: {before:?} became {after:?}\nrequest: {request:?}\ngrants: {with_deny:#?}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A call that is `denied_by_grant` at one taint is `denied_by_grant` at every higher taint:
|
|
/// reading a secret can never switch off a prohibition.
|
|
#[test]
|
|
fn a_prohibition_holds_at_every_higher_taint() {
|
|
let mut prohibitions = 0;
|
|
for seed in seeds() {
|
|
let mut rng = Rng::new(seed ^ 0x0F0F);
|
|
for case in 0..cases() {
|
|
let grants = some_grants(&mut rng);
|
|
let request = some_request(&mut rng);
|
|
let mut denied_below = false;
|
|
for taint in CLASSES {
|
|
let state = SessionState {
|
|
taint,
|
|
untrusted: false,
|
|
};
|
|
let outcome = observed(&decide(request.clone(), &valid(&grants), state, now()));
|
|
let by_grant = matches!(
|
|
outcome,
|
|
Expected::Denied {
|
|
reason: DenyReason::DeniedByGrant,
|
|
..
|
|
}
|
|
);
|
|
assert!(
|
|
by_grant || !denied_below,
|
|
"seed {seed} case {case}: a deny stopped applying at {taint:?}\nrequest: {request:?}\ngrants: {grants:#?}"
|
|
);
|
|
denied_below = by_grant;
|
|
}
|
|
prohibitions += usize::from(denied_below);
|
|
}
|
|
}
|
|
assert!(
|
|
prohibitions > 200,
|
|
"only {prohibitions} cases were prohibited"
|
|
);
|
|
}
|