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)
364 lines
12 KiB
Rust
364 lines
12 KiB
Rust
//! Table tests for `policy::decide`: among the grants that cover a call, which one wins, what
|
|
//! the result is labelled, and which reason is given when none is left. Do not edit.
|
|
|
|
#[path = "support/build.rs"]
|
|
mod build;
|
|
|
|
use brokerd::policy::{Label, decide};
|
|
use build::{
|
|
allowed, asked, denied, fetch, grant, now, private, read, reason, request, secret, set, shell,
|
|
};
|
|
use proto::{DataClass, DenyReason, Mode};
|
|
|
|
#[test]
|
|
fn the_most_restrictive_mode_wins_among_three_matching_grants() {
|
|
let paths = &["/home/kyle/notes"];
|
|
let auto = || grant("b-auto", "read_file", Mode::Auto).paths(paths);
|
|
let ask = || grant("c-ask", "read_file", Mode::Ask).paths(paths);
|
|
let deny = || grant("a-deny", "read_file", Mode::Deny).paths(paths);
|
|
let req = || read("/home/kyle/notes/a.md");
|
|
|
|
let denial = denied(decide(
|
|
req(),
|
|
&set(vec![auto(), ask(), deny()]),
|
|
private(),
|
|
now(),
|
|
));
|
|
assert_eq!(denial.reason, DenyReason::DeniedByGrant);
|
|
assert_eq!(denial.grant.as_deref(), Some("a-deny"));
|
|
assert_eq!(denial.grant_sha256, Some(deny().done().sha256));
|
|
|
|
let ask_wins = asked(decide(req(), &set(vec![auto(), ask()]), private(), now()));
|
|
assert_eq!(ask_wins.grant(), "c-ask");
|
|
assert_eq!(
|
|
allowed(decide(req(), &set(vec![auto()]), private(), now())).grant(),
|
|
"b-auto"
|
|
);
|
|
|
|
// Deny beats a longer path and a lower id: the mode comes first.
|
|
let narrow_auto = grant("a-auto", "read_file", Mode::Auto).paths(&["/home/kyle/notes/deep"]);
|
|
let wide_deny = grant("z-deny", "read_file", Mode::Deny).paths(&["/home"]);
|
|
let outcome = decide(
|
|
read("/home/kyle/notes/deep/x"),
|
|
&set(vec![narrow_auto, wide_deny]),
|
|
private(),
|
|
now(),
|
|
);
|
|
assert_eq!(denied(outcome).grant.as_deref(), Some("z-deny"));
|
|
}
|
|
|
|
#[test]
|
|
fn within_a_mode_the_longest_matched_path_wins_and_then_the_lowest_id() {
|
|
let grants = set(vec![
|
|
grant("a-wide", "read_file", Mode::Auto).paths(&["/home/kyle"]),
|
|
grant("z-narrow", "read_file", Mode::Auto).paths(&["/home/kyle/notes"]),
|
|
]);
|
|
let d = allowed(decide(
|
|
read("/home/kyle/notes/a.md"),
|
|
&grants,
|
|
private(),
|
|
now(),
|
|
));
|
|
assert_eq!(
|
|
(d.grant(), d.matched_path()),
|
|
("z-narrow", Some("/home/kyle/notes"))
|
|
);
|
|
let d = allowed(decide(read("/home/kyle/other"), &grants, private(), now()));
|
|
assert_eq!(d.grant(), "a-wide");
|
|
|
|
// Equal paths: the lowest id in byte order, whatever order the grants were given in.
|
|
let tie = set(vec![
|
|
grant("g-10", "read_file", Mode::Auto).paths(&["/srv"]),
|
|
grant("g-2", "read_file", Mode::Auto).paths(&["/srv"]),
|
|
grant("g-1z", "read_file", Mode::Auto).paths(&["/srv"]),
|
|
]);
|
|
assert_eq!(
|
|
allowed(decide(read("/srv/x"), &tie, private(), now())).grant(),
|
|
"g-10"
|
|
);
|
|
|
|
// Grants with no matched path all tie, so the id decides.
|
|
let hosts = set(vec![
|
|
grant("m", "http_fetch", Mode::Auto).hosts(&["*.example.com"]),
|
|
grant("b", "http_fetch", Mode::Auto).hosts(&["www.example.com"]),
|
|
]);
|
|
let d = allowed(decide(
|
|
fetch("https://www.example.com/"),
|
|
&hosts,
|
|
private(),
|
|
now(),
|
|
));
|
|
assert_eq!(d.grant(), "b");
|
|
}
|
|
|
|
/// The example in the spec. Whichever id sorts first, the read is labelled `secret`, and the
|
|
/// narrower grant is the one recorded and mounted.
|
|
#[test]
|
|
fn the_label_is_combined_over_every_matching_grant() {
|
|
for (home, keys) in [("a-home", "b-keys"), ("z-home", "b-keys")] {
|
|
let grants = set(vec![
|
|
grant(home, "read_file", Mode::Auto)
|
|
.paths(&["/home/kyle"])
|
|
.trusted(),
|
|
grant(keys, "read_file", Mode::Auto)
|
|
.paths(&["/home/kyle/keys"])
|
|
.class(DataClass::Secret)
|
|
.trusted(),
|
|
]);
|
|
let d = allowed(decide(
|
|
read("/home/kyle/keys/id"),
|
|
&grants,
|
|
private(),
|
|
now(),
|
|
));
|
|
assert_eq!(d.grant(), keys);
|
|
assert_eq!(
|
|
d.label(),
|
|
Label {
|
|
class: DataClass::Secret,
|
|
untrusted: false
|
|
}
|
|
);
|
|
// Outside `keys` only the wide grant matches, so only its label counts.
|
|
let d = allowed(decide(read("/home/kyle/todo"), &grants, private(), now()));
|
|
assert_eq!(
|
|
d.label(),
|
|
Label {
|
|
class: DataClass::Private,
|
|
untrusted: false
|
|
}
|
|
);
|
|
}
|
|
|
|
// The winner says trusted and public; another matching grant says otherwise, and it counts.
|
|
let grants = set(vec![
|
|
grant("narrow", "read_file", Mode::Auto)
|
|
.paths(&["/srv/pub/docs"])
|
|
.class(DataClass::Public)
|
|
.trusted(),
|
|
grant("wide", "read_file", Mode::Auto)
|
|
.paths(&["/srv/pub"])
|
|
.class(DataClass::Private),
|
|
]);
|
|
let d = allowed(decide(read("/srv/pub/docs/x"), &grants, private(), now()));
|
|
assert_eq!(d.grant(), "narrow");
|
|
assert_eq!(
|
|
d.label(),
|
|
Label {
|
|
class: DataClass::Private,
|
|
untrusted: true
|
|
}
|
|
);
|
|
|
|
// An `ask` winner carries the combined label too.
|
|
let grants = set(vec![
|
|
grant("asks", "read_file", Mode::Ask)
|
|
.paths(&["/srv"])
|
|
.class(DataClass::Public)
|
|
.trusted(),
|
|
grant("labels", "read_file", Mode::Auto)
|
|
.paths(&["/srv"])
|
|
.class(DataClass::Secret),
|
|
]);
|
|
let ask = asked(decide(read("/srv/x"), &grants, private(), now()));
|
|
assert_eq!(
|
|
ask.label(),
|
|
Label {
|
|
class: DataClass::Secret,
|
|
untrusted: true
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_grant_ruled_out_by_taint_or_expiry_adds_nothing_to_the_label() {
|
|
let grants = set(vec![
|
|
grant("live", "read_file", Mode::Auto)
|
|
.paths(&["/srv"])
|
|
.class(DataClass::Public)
|
|
.trusted(),
|
|
grant("old", "read_file", Mode::Auto)
|
|
.paths(&["/srv"])
|
|
.class(DataClass::Secret)
|
|
.expires("2026-01-01T00:00:00.000Z"),
|
|
]);
|
|
let d = allowed(decide(read("/srv/x"), &grants, private(), now()));
|
|
assert_eq!(
|
|
d.label(),
|
|
Label {
|
|
class: DataClass::Public,
|
|
untrusted: false
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_grant_expires_exactly_at_its_time() {
|
|
let at = |when: &str| set(vec![grant("g", "shell", Mode::Auto).expires(when)]);
|
|
let d = allowed(decide(
|
|
shell(None),
|
|
&at("2026-09-18T12:00:00.001Z"),
|
|
private(),
|
|
now(),
|
|
));
|
|
assert_eq!(d.expires(), Some(build::ts("2026-09-18T12:00:00.001Z")));
|
|
for when in [
|
|
"2026-09-18T12:00:00.000Z",
|
|
"2026-09-18T11:59:59.999Z",
|
|
"2020-01-01T00:00:00.000Z",
|
|
] {
|
|
assert_eq!(
|
|
reason(decide(shell(None), &at(when), private(), now())),
|
|
DenyReason::GrantExpired,
|
|
"{when}"
|
|
);
|
|
}
|
|
let never = set(vec![grant("g", "shell", Mode::Auto)]);
|
|
assert_eq!(
|
|
allowed(decide(shell(None), &never, private(), now())).expires(),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_grant_applies_up_to_its_max_taint() {
|
|
let grants = set(vec![
|
|
grant("g", "shell", Mode::Auto).max_taint(DataClass::Private),
|
|
]);
|
|
allowed(decide(shell(None), &grants, private(), now()));
|
|
allowed(decide(
|
|
shell(None),
|
|
&grants,
|
|
build::at(DataClass::Public),
|
|
now(),
|
|
));
|
|
assert_eq!(
|
|
reason(decide(shell(None), &grants, secret(), now())),
|
|
DenyReason::TaintTooHigh
|
|
);
|
|
// The untrusted flag is not an input to matching.
|
|
let mut state = private();
|
|
state.untrusted = true;
|
|
allowed(decide(shell(None), &grants, state, now()));
|
|
}
|
|
|
|
#[test]
|
|
fn the_reason_when_nothing_is_left() {
|
|
let expired = || grant("e", "shell", Mode::Auto).expires("2026-01-01T00:00:00.000Z");
|
|
let tainted = || grant("t", "shell", Mode::Auto).max_taint(DataClass::Private);
|
|
let both = || {
|
|
grant("b", "shell", Mode::Auto)
|
|
.expires("2026-01-01T00:00:00.000Z")
|
|
.max_taint(DataClass::Private)
|
|
};
|
|
let cases = [
|
|
// One candidate expired and another too tainted: expiry is reported first.
|
|
(set(vec![expired(), tainted()]), DenyReason::GrantExpired),
|
|
(set(vec![tainted(), both()]), DenyReason::TaintTooHigh),
|
|
// Ruled out by both is ruled out "only" by neither.
|
|
(set(vec![both()]), DenyReason::NoGrant),
|
|
(set(vec![expired()]), DenyReason::GrantExpired),
|
|
];
|
|
for (grants, want) in cases {
|
|
assert_eq!(reason(decide(shell(None), &grants, secret(), now())), want);
|
|
}
|
|
// "Only by expiry" means it would have matched: an expired grant for other arguments, or
|
|
// for another tool, is no reason to say `grant_expired`.
|
|
let elsewhere = set(vec![
|
|
grant("p", "read_file", Mode::Auto)
|
|
.paths(&["/srv"])
|
|
.expires("2026-01-01T00:00:00.000Z"),
|
|
grant("w", "write_file", Mode::Auto)
|
|
.paths(&["/home"])
|
|
.expires("2026-01-01T00:00:00.000Z"),
|
|
]);
|
|
assert_eq!(
|
|
reason(decide(read("/home/kyle/x"), &elsewhere, private(), now())),
|
|
DenyReason::NoGrant
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_deny_grant_denies_at_every_taint_until_it_expires() {
|
|
let grants = |deny_expires: Option<&str>| {
|
|
let deny = grant("no-internal", "http_fetch", Mode::Deny).hosts(&["internal.example.com"]);
|
|
let deny = match deny_expires {
|
|
Some(when) => deny.expires(when),
|
|
None => deny,
|
|
};
|
|
set(vec![
|
|
grant("any", "http_fetch", Mode::Auto).hosts(&["*.example.com"]),
|
|
deny,
|
|
])
|
|
};
|
|
let url = "https://internal.example.com/";
|
|
for state in [build::at(DataClass::Public), private(), secret()] {
|
|
let denial = denied(decide(fetch(url), &grants(None), state, now()));
|
|
assert_eq!(denial.reason, DenyReason::DeniedByGrant);
|
|
assert_eq!(denial.grant.as_deref(), Some("no-internal"));
|
|
}
|
|
allowed(decide(
|
|
fetch("https://www.example.com/"),
|
|
&grants(None),
|
|
secret(),
|
|
now(),
|
|
));
|
|
// An expired deny no longer denies: `expires` on a deny grant means "forbid this until then".
|
|
let lapsed = grants(Some("2026-09-18T12:00:00.000Z"));
|
|
assert_eq!(
|
|
allowed(decide(fetch(url), &lapsed, private(), now())).grant(),
|
|
"any"
|
|
);
|
|
}
|
|
|
|
/// Documented, not liked: an `ask` grant with a lower `max_taint` than an `auto` grant over the
|
|
/// same arguments drops out when taint rises, and the call then runs without asking.
|
|
#[test]
|
|
fn an_ask_grant_with_a_lower_max_taint_stops_asking_when_taint_rises() {
|
|
let grants = set(vec![
|
|
grant("asks", "shell", Mode::Ask).max_taint(DataClass::Private),
|
|
grant("runs", "shell", Mode::Auto),
|
|
]);
|
|
assert_eq!(
|
|
asked(decide(shell(None), &grants, private(), now())).grant(),
|
|
"asks"
|
|
);
|
|
assert_eq!(
|
|
allowed(decide(shell(None), &grants, secret(), now())).grant(),
|
|
"runs"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_decision_and_an_ask_carry_what_the_broker_and_the_runner_need() {
|
|
let grants = set(vec![
|
|
grant("asks", "write_file", Mode::Ask)
|
|
.paths(&["/home/kyle/scratch"])
|
|
.expires("2027-01-01T00:00:00.000Z")
|
|
.class(DataClass::Public),
|
|
]);
|
|
let req = request(
|
|
"write_file",
|
|
r#"{ "content": "hello", "path": "/home/kyle/scratch/a.txt" }"#,
|
|
);
|
|
let ask = asked(decide(req.clone(), &grants, private(), now()));
|
|
assert_eq!(ask.request(), &req);
|
|
assert_eq!(
|
|
ask.args().canonical_json(),
|
|
r#"{"path":"/home/kyle/scratch/a.txt","content":"hello"}"#
|
|
);
|
|
assert_eq!(ask.grant(), "asks");
|
|
assert_eq!(ask.grant_sha256(), proto::sha256(b"asks").unwrap());
|
|
assert_eq!(ask.matched_path(), Some("/home/kyle/scratch"));
|
|
assert_eq!(ask.paths(), ["/home/kyle/scratch"]);
|
|
assert!(ask.hosts().is_empty());
|
|
assert_eq!(ask.expires(), Some(build::ts("2027-01-01T00:00:00.000Z")));
|
|
assert_eq!(
|
|
ask.label(),
|
|
Label {
|
|
class: DataClass::Public,
|
|
untrusted: true
|
|
}
|
|
);
|
|
}
|