Load grant files, failing closed on any invalid file
crates/brokerd/src/grants.rs reads grants/*.toml into a GrantSet: load reports every problem in every file and returns either a complete valid set or the full problem list, never a partial one; from_grants sorts by id and collects every rule-2..9 problem; render prints each problem then the runbook pointer. All 17 grants tests pass; make gate prints gate: ok. Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -0,0 +1,375 @@
|
||||
//! Loading the owner's grant files into a `GrantSet`, failing closed.
|
||||
//!
|
||||
//! A mistyped `deny` grant that was skipped would silently become an allow wherever another grant
|
||||
//! matches, so if any file is invalid the whole set is invalid. `load` therefore returns either a
|
||||
//! complete valid set or the full list of problems, never a partial one, and reports every problem
|
||||
//! in every file rather than only the first.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::args::{ToolName, valid_host_pattern, valid_path};
|
||||
use proto::{DataClass, Grant, GrantProblem, Mode, sha256};
|
||||
|
||||
/// Where the owner reads the rules behind a rejected set.
|
||||
pub const RUNBOOK: &str = "see docs/runbook.md#grants-invalid";
|
||||
|
||||
/// A grant read from one file: the id (the file stem), the parsed grant, and the file's hash.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LoadedGrant {
|
||||
pub id: String, // the file stem
|
||||
pub grant: proto::Grant,
|
||||
pub sha256: proto::Hash32, // of the file's bytes as read
|
||||
}
|
||||
|
||||
/// A set that passed every rule, in id order. Its field is private: `from_grants` and `load` are
|
||||
/// the only ways to make one that is not empty.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct GrantSet {
|
||||
grants: Vec<LoadedGrant>,
|
||||
}
|
||||
|
||||
impl GrantSet {
|
||||
/// Build a set, checking every grant and refusing the whole set if any grant is invalid.
|
||||
pub fn from_grants(grants: Vec<LoadedGrant>) -> Result<GrantSet, Vec<GrantProblem>> {
|
||||
let mut grants = grants;
|
||||
grants.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
|
||||
let mut problems: Vec<GrantProblem> = Vec::new();
|
||||
let mut seen: Vec<String> = Vec::new();
|
||||
for grant in &grants {
|
||||
if seen.iter().any(|id| id == &grant.id) {
|
||||
push(
|
||||
&mut problems,
|
||||
format!("{}.toml", grant.id),
|
||||
None,
|
||||
"two grants have this id",
|
||||
);
|
||||
}
|
||||
seen.push(grant.id.clone());
|
||||
check_grant(grant, &mut problems);
|
||||
}
|
||||
|
||||
if problems.is_empty() {
|
||||
Ok(GrantSet { grants })
|
||||
} else {
|
||||
Err(problems)
|
||||
}
|
||||
}
|
||||
|
||||
/// The grants in id order.
|
||||
pub fn grants(&self) -> &[LoadedGrant] {
|
||||
&self.grants
|
||||
}
|
||||
}
|
||||
|
||||
/// A file name is a valid grant id when it is 1 to 64 characters of `a-z`, `0-9` and `-`.
|
||||
pub fn valid_id(id: &str) -> bool {
|
||||
if !(1..=64).contains(&id.len()) {
|
||||
return false;
|
||||
}
|
||||
id.bytes()
|
||||
.all(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'-'))
|
||||
}
|
||||
|
||||
/// Read every `*.toml` in `dir` into a `GrantSet`, or report every problem found.
|
||||
pub fn load(dir: &Path) -> Result<GrantSet, Vec<GrantProblem>> {
|
||||
// Step 1: the directory itself must be readable. A missing directory is not an empty set.
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(error) => {
|
||||
return Err(vec![GrantProblem {
|
||||
file: dir.to_string_lossy().to_string(),
|
||||
line: None,
|
||||
problem: format!("the grants directory cannot be read: {}", error),
|
||||
}]);
|
||||
}
|
||||
};
|
||||
|
||||
// Step 2: collect and sort the file names, so problems come out in the same order every time.
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
for entry in entries {
|
||||
match entry {
|
||||
Ok(entry) => names.push(entry.file_name().to_string_lossy().to_string()),
|
||||
Err(error) => {
|
||||
// An entry that cannot be read is the same problem as the directory; keep going.
|
||||
names.push(String::new());
|
||||
return Err(vec![GrantProblem {
|
||||
file: dir.to_string_lossy().to_string(),
|
||||
line: None,
|
||||
problem: format!("the grants directory cannot be read: {}", error),
|
||||
}]);
|
||||
}
|
||||
}
|
||||
}
|
||||
names.sort();
|
||||
|
||||
// Steps 3-7: read each file, keeping the valid grants and recording every other problem.
|
||||
let mut kept: Vec<LoadedGrant> = Vec::new();
|
||||
let mut problems: Vec<GrantProblem> = Vec::new();
|
||||
for name in &names {
|
||||
if !name.ends_with(".toml") {
|
||||
continue;
|
||||
}
|
||||
let id = &name[..name.len() - ".toml".len()];
|
||||
let path = dir.join(name);
|
||||
|
||||
let bytes = match std::fs::read(&path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => {
|
||||
// Never treat an unreadable file as absent.
|
||||
problems.push(GrantProblem {
|
||||
file: name.clone(),
|
||||
line: None,
|
||||
problem: format!("cannot be read: {}", error),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let text = match std::str::from_utf8(&bytes) {
|
||||
Ok(text) => text,
|
||||
Err(_) => {
|
||||
problems.push(GrantProblem {
|
||||
file: name.clone(),
|
||||
line: None,
|
||||
problem: "is not UTF-8".to_string(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match toml::from_str::<Grant>(text) {
|
||||
Ok(grant) => {
|
||||
let sha256 = match sha256(&bytes) {
|
||||
Ok(sha256) => sha256,
|
||||
Err(error) => {
|
||||
problems.push(GrantProblem {
|
||||
file: name.clone(),
|
||||
line: None,
|
||||
problem: error.to_string(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
kept.push(LoadedGrant {
|
||||
id: id.to_string(),
|
||||
grant,
|
||||
sha256,
|
||||
});
|
||||
}
|
||||
Err(error) => {
|
||||
problems.push(GrantProblem {
|
||||
file: name.clone(),
|
||||
line: Some(span_line(text, &error)),
|
||||
problem: error.message().to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 8: even when the steps above found problems, still check the valid grants so the owner
|
||||
// sees everything at once. Return Err if anything at all was reported.
|
||||
let mut all = problems;
|
||||
let set = match GrantSet::from_grants(kept) {
|
||||
Ok(set) => Some(set),
|
||||
Err(value_problems) => {
|
||||
all.extend(value_problems);
|
||||
None
|
||||
}
|
||||
};
|
||||
all.sort_by(|a, b| a.file.cmp(&b.file));
|
||||
match set {
|
||||
Some(set) => {
|
||||
if all.is_empty() {
|
||||
Ok(set)
|
||||
} else {
|
||||
Err(all)
|
||||
}
|
||||
}
|
||||
None => Err(all),
|
||||
}
|
||||
}
|
||||
|
||||
/// The 1-based line of a parse error's start: the number of newlines before it, plus one.
|
||||
fn span_line(text: &str, error: &toml::de::Error) -> u64 {
|
||||
match error.span() {
|
||||
Some(range) => {
|
||||
let before = match text.get(..range.start) {
|
||||
Some(before) => before,
|
||||
None => text,
|
||||
};
|
||||
before.bytes().filter(|&b| b == b'\n').count() as u64 + 1
|
||||
}
|
||||
None => 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check one grant and push every problem it has. `file` is `"<id>.toml"`, `line` is `None`.
|
||||
fn check_grant(grant: &LoadedGrant, problems: &mut Vec<GrantProblem>) {
|
||||
let id = &grant.id;
|
||||
let inner = &grant.grant;
|
||||
let file = format!("{}.toml", id);
|
||||
|
||||
// Rule 2: the id must be a valid grant id.
|
||||
if !valid_id(id) {
|
||||
push(
|
||||
problems,
|
||||
file.clone(),
|
||||
None,
|
||||
"the file name is not a valid grant id",
|
||||
);
|
||||
}
|
||||
|
||||
// Rule 3: the tool must be one of the four; an unknown tool skips rule 6 but not the others.
|
||||
match ToolName::parse(&grant.grant.tool) {
|
||||
None => push(
|
||||
problems,
|
||||
file.clone(),
|
||||
None,
|
||||
"unknown tool; only read_file, write_file, shell and http_fetch",
|
||||
),
|
||||
Some(tool) => check_tool_constraints(tool, &file, inner, problems),
|
||||
}
|
||||
|
||||
// Rule 4: secrets are not supported until M4.
|
||||
if inner.secret.is_some() {
|
||||
push(
|
||||
problems,
|
||||
file.clone(),
|
||||
None,
|
||||
"secrets are not supported until M4",
|
||||
);
|
||||
}
|
||||
// Rule 5: patterns are not supported.
|
||||
if !inner.constraints.patterns.is_empty() {
|
||||
push(problems, file.clone(), None, "patterns are not supported");
|
||||
}
|
||||
// Rule 7: no path may be the whole file system or an invalid absolute path.
|
||||
for path in &inner.constraints.paths {
|
||||
if path == "/" {
|
||||
push(
|
||||
problems,
|
||||
file.clone(),
|
||||
None,
|
||||
"a grant of the whole file system is not supported",
|
||||
);
|
||||
} else if !valid_path(path) {
|
||||
push(
|
||||
problems,
|
||||
file.clone(),
|
||||
None,
|
||||
format!("{:?} is not a valid absolute path", path),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Rule 8: every host must be a valid host pattern.
|
||||
for host in &inner.constraints.hosts {
|
||||
if !valid_host_pattern(host) {
|
||||
push(
|
||||
problems,
|
||||
file.clone(),
|
||||
None,
|
||||
format!("{:?} is not a valid host pattern", host),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Rule 9: a deny grant must apply at every taint.
|
||||
if inner.mode == Mode::Deny && inner.max_taint != DataClass::Secret {
|
||||
push(
|
||||
problems,
|
||||
file,
|
||||
None,
|
||||
"a deny grant must apply at every taint",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rule 6: the per-tool checks on paths and hosts. `file` is `"<id>.toml"`.
|
||||
fn check_tool_constraints(
|
||||
tool: ToolName,
|
||||
file: &str,
|
||||
grant: &Grant,
|
||||
problems: &mut Vec<GrantProblem>,
|
||||
) {
|
||||
let tool = tool.as_str();
|
||||
match tool {
|
||||
"read_file" | "write_file" => {
|
||||
if grant.constraints.paths.is_empty() {
|
||||
push(
|
||||
problems,
|
||||
file.to_string(),
|
||||
None,
|
||||
format!("{} needs at least one path", tool),
|
||||
);
|
||||
}
|
||||
if !grant.constraints.hosts.is_empty() {
|
||||
push(
|
||||
problems,
|
||||
file.to_string(),
|
||||
None,
|
||||
format!("{} does not take hosts", tool),
|
||||
);
|
||||
}
|
||||
}
|
||||
"shell" => {
|
||||
if !grant.constraints.hosts.is_empty() {
|
||||
push(
|
||||
problems,
|
||||
file.to_string(),
|
||||
None,
|
||||
format!("{} does not take hosts", tool),
|
||||
);
|
||||
}
|
||||
}
|
||||
"http_fetch" => {
|
||||
if grant.constraints.hosts.is_empty() {
|
||||
push(
|
||||
problems,
|
||||
file.to_string(),
|
||||
None,
|
||||
"http_fetch needs at least one host",
|
||||
);
|
||||
}
|
||||
if !grant.constraints.paths.is_empty() {
|
||||
push(
|
||||
problems,
|
||||
file.to_string(),
|
||||
None,
|
||||
"http_fetch does not take paths",
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// One line per problem, then `RUNBOOK` on its own line; every line ends with `\n`.
|
||||
pub fn render(problems: &[GrantProblem]) -> String {
|
||||
let mut out = String::new();
|
||||
for problem in problems {
|
||||
match problem.line {
|
||||
Some(line) => {
|
||||
out.push_str(&format!("{}:{}: {}\n", problem.file, line, problem.problem));
|
||||
}
|
||||
None => {
|
||||
out.push_str(&format!("{}: {}\n", problem.file, problem.problem));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(RUNBOOK);
|
||||
out.push('\n');
|
||||
out
|
||||
}
|
||||
|
||||
fn push(
|
||||
problems: &mut Vec<GrantProblem>,
|
||||
file: String,
|
||||
line: Option<u64>,
|
||||
problem: impl Into<String>,
|
||||
) {
|
||||
problems.push(GrantProblem {
|
||||
file,
|
||||
line,
|
||||
problem: problem.into(),
|
||||
});
|
||||
}
|
||||
@@ -2,5 +2,6 @@
|
||||
|
||||
pub mod args;
|
||||
pub mod config;
|
||||
pub mod grants;
|
||||
pub mod policy;
|
||||
pub mod runner;
|
||||
|
||||
Reference in New Issue
Block a user