The crate had a single path convention and it was per-agent: `swarm/agents/<agent>/matrix/<account>`. The secrets still to move into the store do not fit it — one belongs to a hive, one to a swarm service, one to the controller itself — so each would have picked its own shape, and each would have been a separate grant to get wrong. mara ruled the scheme on the epic: `swarm/<kind>/<name>/<secret>`, over `agents`, `hives`, `services` and `controller`. This lands it. `Kind` is an enum rather than free strings for one reason: the store's grant is written in nix and cannot be reached from Rust, so a misspelled kind is a 403 at provision time and not a compile error. `Kind::ALL` lets a test enumerate the set instead of restating it, which is what makes adding a kind a deliberate edit rather than an accidental grant. Note `Kind` sits beside `checked_segment`'s existing `kind` argument, which means something else entirely — the label of the name being validated. They are not the same concept and should not be merged. Nothing about the rendered policy changes. `policy::render` still grants read on the agent kind alone; the other kinds are absent on purpose, because what a hive may read of its own kind is a boundary question and not a consequence of the namespace growing. The controller's write grant likewise stays scoped to `agents/` — it widens when a path outside it gains a writer, not when the kinds are declared. Verified: `cargo test -p swarm-secret-client` 23 passed, 0 failed. The two tests pinning the rendered strings (`the_document_grants_read_over_the_whole_agent_prefix` and matrix's path assertion) still assert the same literals they did before, which is what shows this is a faithful port rather than a reshape. `nix fmt` 710 emitted, 10 formatted, 0 changed; the three scripts/check-*.sh lints pass with the change staged. No reference to the removed `path::AGENT_PREFIX` survives in the crate or in nix — checked with a scoped pattern, because the unqualified name also belongs to hive-host-sock's container prefix and greps for it are answering a different question.
123 lines
4.8 KiB
Rust
123 lines
4.8 KiB
Rust
//! 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.
|
|
//!
|
|
//! ⚠️ Every hive gets the same document, and it grants read on **every**
|
|
//! agent's credentials rather than on the ones that hive hosts. That is a
|
|
//! decision, not an oversight: an agent's path does not name its hive, so a
|
|
//! per-hive grant has to be enumerated and re-emitted, and an enumeration that
|
|
//! can silently drift advertises a boundary it does not hold. A wide grant that
|
|
//! says so beats a narrow one that only looks narrow. The narrower shapes, and
|
|
//! what they would cost, are in `docs/trust-boundary/security.md`.
|
|
//!
|
|
//! Rendering stays separate from writing so the text can be asserted with no
|
|
//! store to talk to.
|
|
|
|
use crate::{
|
|
Error,
|
|
path::{Kind, MOUNT, ROOT, 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 document every hive's policy holds: read on every agent's
|
|
/// credentials.
|
|
///
|
|
/// Takes no arguments because it depends on nothing — same text for every
|
|
/// hive, unchanged by which agents exist. That is what makes it a deploy-time
|
|
/// object rather than derived state with a re-emission to get wrong.
|
|
///
|
|
/// Read-only: the controller mints these and never reads one back.
|
|
/// ⚠️ Still the agent kind alone. The other kinds are deliberately absent: a
|
|
/// hive has no business reading a service's or the controller's credentials,
|
|
/// and what a hive may read of its *own* kind is a boundary question this
|
|
/// module's header answers only for agents. Widening it is a decision, not a
|
|
/// consequence of the namespace growing.
|
|
#[must_use]
|
|
pub fn render() -> String {
|
|
format!(
|
|
"path \"{MOUNT}/data/{ROOT}/{}/*\" {{\n capabilities = [\"read\"]\n}}\n",
|
|
Kind::Agent.as_str()
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn the_document_grants_read_over_the_whole_agent_prefix() {
|
|
assert_eq!(
|
|
render(),
|
|
"path \"secret/data/swarm/agents/*\" {\n capabilities = [\"read\"]\n}\n"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_grant_is_read_only() {
|
|
// The half of the old policy that survives the widening: a hive reads
|
|
// credentials, and a hive that could write one could hand itself an
|
|
// agent's identity.
|
|
let p = render();
|
|
assert!(!p.contains("create"));
|
|
assert!(!p.contains("update"));
|
|
assert!(!p.contains("delete"));
|
|
assert!(!p.contains("list"));
|
|
}
|
|
|
|
#[test]
|
|
fn no_name_reaches_the_document_at_all() {
|
|
// Why the injection cases that used to live here are gone rather than
|
|
// relaxed: nothing interpolates into the text any more, so there is no
|
|
// stanza for a name to close. `hive_object_name` still validates,
|
|
// because a name does reach the policy's *identifier*.
|
|
let p = render();
|
|
assert!(!p.contains("pr1ma"));
|
|
assert_eq!(p.matches("path \"").count(), 1, "one stanza, no per-agent");
|
|
assert!(
|
|
hive_object_name("atlas/*\" { capabilities = [\"root\"] }").is_err(),
|
|
"the object NAME is still a place a name can do damage"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_legal_charset_is_actually_reachable() {
|
|
// The control for the case above: if every name were refused, that
|
|
// assertion would pass while proving nothing.
|
|
assert!(hive_object_name("a-b_C9").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn every_hive_gets_a_byte_identical_document() {
|
|
// The property the deploy-time write depends on: nothing about a hive
|
|
// or its agents changes the text, so there is nothing to re-emit.
|
|
assert_eq!(render(), render());
|
|
}
|
|
|
|
#[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));
|
|
}
|
|
}
|