swarm-secret-client: render a hive's read policy, and refuse names that inject
The controller writes an agent's credential; the hive reads it back with its own token. Nothing says which paths that token may read, so the read half of a delivery has no identity at all and answers 403. This is the pure half of the fix: the policy text is a function of a hive name and its agent set, so the shape can be asserted with no store to talk to. One stanza per hosted agent rather than a prefix grant. An agent's credential path does not name the hive hosting it -- deliberately, since agents migrate -- so "this hive's agents" has no prefix expression and must be enumerated. The grant is read-only: the controller mints these and never reads one back. The paths come from MOUNT and AGENT_PREFIX rather than literals, so the policy cannot drift from the module that builds the paths it grants. checked_segment runs before any name reaches HCL. That is policy injection rather than path traversal -- a name can close a stanza and open a wider one -- so the test carries a real injection string, paired with the reachable-charset control that stops it passing by refusing everything. An empty agent set renders an empty policy, which grants nothing.
This commit is contained in:
parent
004cf7f449
commit
7792b62609
2 changed files with 152 additions and 0 deletions
|
|
@ -8,6 +8,11 @@
|
|||
//! ([`matrix`]). Each of those is a thing the controller and a hive must say
|
||||
//! identically, so it is said once here.
|
||||
//!
|
||||
//! [`policy`] is the same kind of agreement seen from the other side: which of
|
||||
//! those paths a hive's own token may read. It belongs here rather than in the
|
||||
//! controller because the grant and the path are one statement — spelled
|
||||
//! differently they produce a 403 that names neither.
|
||||
//!
|
||||
//! [`client`] is deliberately ignorant of all of it: it moves whatever type a
|
||||
//! caller names, so a second kind of secret is a new module beside [`matrix`]
|
||||
//! and not another field on a struct shared with it.
|
||||
|
|
@ -15,6 +20,7 @@
|
|||
pub mod client;
|
||||
pub mod matrix;
|
||||
pub mod path;
|
||||
pub mod policy;
|
||||
|
||||
pub use client::SecretStore;
|
||||
|
||||
|
|
|
|||
146
swarm-secret-client/src/policy.rs
Normal file
146
swarm-secret-client/src/policy.rs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
//! The read agreement: which credentials a hive's own token may fetch.
|
||||
//!
|
||||
//! The mirror of [`crate::matrix`]. That module says where a credential lives;
|
||||
//! this one says who is allowed to read it, and the two have to agree on the
|
||||
//! same path or a delivery fails with a 403 that names nothing.
|
||||
//!
|
||||
//! Rendering is separate from writing on purpose: the text is a pure function
|
||||
//! of a hive name and its agent set, so the shape that matters can be asserted
|
||||
//! without a store to talk to.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use crate::{
|
||||
Error,
|
||||
path::{AGENT_PREFIX, MOUNT, checked_segment},
|
||||
};
|
||||
|
||||
/// Namespace for a hive's own policy and cert-auth role.
|
||||
///
|
||||
/// The controller's own grant is scoped to `hive-*` for both, so this prefix is
|
||||
/// the difference between a hive the controller may provision and a policy it
|
||||
/// must not be able to rewrite — including its own.
|
||||
pub const HIVE_PREFIX: &str = "hive-";
|
||||
|
||||
/// The policy and cert-auth role name for `hive`. One name, both objects: the
|
||||
/// role attaches the policy by spelling it identically.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::PathSegment`] when `hive` holds anything but `[A-Za-z0-9_-]`.
|
||||
pub fn hive_object_name(hive: &str) -> Result<String, Error> {
|
||||
checked_segment("hive", hive)?;
|
||||
Ok(format!("{HIVE_PREFIX}{hive}"))
|
||||
}
|
||||
|
||||
/// Render the policy granting `hive` read on exactly the agents it hosts.
|
||||
///
|
||||
/// One stanza per agent rather than a prefix grant: an agent's credential path
|
||||
/// does not name the hive hosting it (agents migrate), so "this hive's agents"
|
||||
/// has no prefix expression and has to be enumerated.
|
||||
///
|
||||
/// An empty `agents` renders an empty policy, which grants nothing. That is the
|
||||
/// correct reading of a hive with no agents, and it fails closed.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::PathSegment`] when `hive` or any agent name holds anything but
|
||||
/// `[A-Za-z0-9_-]` — which is what stops a name from closing the stanza and
|
||||
/// opening a wider one.
|
||||
pub fn render(hive: &str, agents: &[&str]) -> Result<String, Error> {
|
||||
checked_segment("hive", hive)?;
|
||||
let mut out = String::new();
|
||||
for agent in agents {
|
||||
checked_segment("agent", agent)?;
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"path \"{MOUNT}/data/{AGENT_PREFIX}/{agent}/*\" {{\n capabilities = [\"read\"]\n}}"
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn one_agent_renders_one_read_stanza_under_the_agent_prefix() {
|
||||
let p = render("pr1ma", &["atlas"]).expect("both segments are legal");
|
||||
assert_eq!(
|
||||
p,
|
||||
"path \"secret/data/swarm/agents/atlas/*\" {\n capabilities = [\"read\"]\n}\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_hosted_agent_gets_its_own_stanza_and_nothing_else_does() {
|
||||
let p = render("pr1ma", &["atlas", "iris"]).expect("legal");
|
||||
assert_eq!(p.matches("path \"").count(), 2, "one stanza per agent");
|
||||
assert!(p.contains("/atlas/*"));
|
||||
assert!(p.contains("/iris/*"));
|
||||
assert!(!p.contains("argus"), "an agent not passed is not granted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_grant_is_read_only_and_never_a_prefix_over_all_agents() {
|
||||
// Both halves of what makes this a least-privilege policy rather than
|
||||
// the broad grant that was considered and rejected.
|
||||
let p = render("pr1ma", &["atlas"]).expect("legal");
|
||||
assert!(!p.contains("create"));
|
||||
assert!(!p.contains("update"));
|
||||
assert!(!p.contains("delete"));
|
||||
assert!(
|
||||
!p.contains(&format!("{MOUNT}/data/{AGENT_PREFIX}/*")),
|
||||
"a wildcard directly under the agent prefix would grant every agent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_cannot_close_the_stanza_and_open_a_wider_one() {
|
||||
// The reason `checked_segment` runs before the name reaches HCL: these
|
||||
// are policy injection, not path traversal, and a `contains("..")`
|
||||
// check catches none of them.
|
||||
for bad in [
|
||||
"atlas/*\" { capabilities = [\"root\"] }\npath \"secret/data",
|
||||
"*",
|
||||
"../argus",
|
||||
"a b",
|
||||
"",
|
||||
] {
|
||||
assert!(
|
||||
render("pr1ma", &[bad]).is_err(),
|
||||
"agent name {bad:?} must be refused"
|
||||
);
|
||||
assert!(
|
||||
render(bad, &["atlas"]).is_err(),
|
||||
"hive name {bad:?} must be refused"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_legal_charset_is_actually_reachable() {
|
||||
// The control for the case above: if every name were refused, that test
|
||||
// would pass while proving nothing.
|
||||
assert!(render("a-b_C9", &["d-e_F0"]).is_ok());
|
||||
assert!(hive_object_name("a-b_C9").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hive_with_no_agents_grants_nothing() {
|
||||
let p = render("pr1ma", &[]).expect("a hive may legitimately host none");
|
||||
assert!(
|
||||
p.is_empty(),
|
||||
"no stanza means no capability, which is closed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_object_name_sits_inside_the_namespace_the_controller_may_write() {
|
||||
// `hive-` is what the controller's own policy scopes both
|
||||
// `sys/policies/acl/` and `auth/cert/certs/` to, so a name outside it
|
||||
// is one the controller cannot create at all.
|
||||
let n = hive_object_name("pr1ma").expect("legal");
|
||||
assert_eq!(n, "hive-pr1ma");
|
||||
assert!(n.starts_with(HIVE_PREFIX));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue