696 lines
30 KiB
Rust
696 lines
30 KiB
Rust
//! The read agreement: which credentials a principal's own token may fetch.
|
|
//!
|
|
//! The mirror of [`crate::matrix`] and [`crate::queue`]. Those modules say
|
|
//! 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.
|
|
//!
|
|
//! The hive document ([`render_shared_hives`]) has one stanza per kind a hive
|
|
//! reads, and they are not all scoped alike — which is the point rather than
|
|
//! an inconsistency:
|
|
//!
|
|
//! ⚠️ The **agent** stanza 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`.
|
|
//!
|
|
//! The **hive** stanza has no such problem and is therefore narrow: that path
|
|
//! names its principal, so scoping it to the reader's own name costs nothing
|
|
//! and drifts nowhere. Do not widen it to match the agent stanza — the
|
|
//! asymmetry is the point, and only holds if a one-per-hive credential is
|
|
//! stored under [`Kind::Hive`] — see docs/trust-boundary/security.md.
|
|
//!
|
|
//! A hive reads nothing under `swarm/services/` or `swarm/controller/`. A swarm
|
|
//! service's OIDC client secret is read under a certificate that is not a
|
|
//! hive's: `swarm-bao.nix`'s per-service readers, and its granter for the UI's.
|
|
//!
|
|
//! Rendering stays separate from writing so the text can be asserted with no store to talk to.
|
|
|
|
use crate::{
|
|
Error, backend_env,
|
|
path::{Kind, MOUNT, ROOT, checked_segment},
|
|
};
|
|
|
|
/// Namespace for a hive's cert-auth role and the policies the controller writes.
|
|
///
|
|
/// 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 cert-auth role name for `hive`.
|
|
///
|
|
/// # 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}"))
|
|
}
|
|
|
|
/// Namespace for an agent's cert-auth role and certificate common name.
|
|
///
|
|
/// Deliberately *inside* [`HIVE_PREFIX`] rather than beside it.
|
|
/// `sys/policies/acl/hive-*` and `auth/cert/certs/hive-*` are the whole of what
|
|
/// the controller's grant lets it create (`swarm-bao.nix`'s
|
|
/// `controllerPolicyText`), and the controller is the only principal that
|
|
/// learns an agent exists — a name outside that prefix is one nothing can
|
|
/// write. It costs no authority: the controller already holds `create`/`update`
|
|
/// on `secret/data/swarm/agents/*`, so it can already replace every credential
|
|
/// the shared policies grant read on.
|
|
///
|
|
/// It still cannot collide with a hive's. [`hive_object_name`] renders
|
|
/// `hive-<hive>`, so a collision needs a hive named `agent-<agent>` — and
|
|
/// `nix/reserved-hive-fragments.nix` forbids the substring `agent` in any hive
|
|
/// name (and `hive` too), applied to every entry of the swarm directory in
|
|
/// `swarm.nix`. The fragment list is what to read before renaming either.
|
|
///
|
|
/// Host principals' **common names** are operator-set options
|
|
/// (`deploy.bao.controllerCommonName`, `secretPublisherCommonName`, …) that
|
|
/// may spell this prefix, and that is harmless: an agent's cert-auth role pins
|
|
/// the store's agent CA (`deploy.bao.agentPkiMountPath`), which signs no host
|
|
/// leaf, and every host role pins `deploy.bao.clientCaFile`, which signs no
|
|
/// agent leaf. A CN match alone logs nobody in.
|
|
pub const AGENT_PREFIX: &str = "hive-agent-";
|
|
|
|
/// The cert-auth role name for `agent`, and the common name of the certificate
|
|
/// that authenticates it. One string, two objects: the role matches the subject
|
|
/// by spelling both the same.
|
|
///
|
|
/// Injective in `agent` — the prefix is fixed and [`checked_segment`] has
|
|
/// already refused anything that could re-punctuate the suffix. Agent names
|
|
/// are one swarm-wide namespace (an agent's path is `swarm/agents/<agent>`,
|
|
/// with no hive segment), so two agents sharing a name would share every
|
|
/// object this names. swarm-controller's `create_agent`, the one place an
|
|
/// agent is created, refuses a name the swarm has already placed on another
|
|
/// hive. An agent no hive's wanted state declares is invisible to that check.
|
|
///
|
|
/// # Errors
|
|
/// [`Error::PathSegment`] when `agent` holds anything but `[A-Za-z0-9_-]`.
|
|
pub fn agent_object_name(agent: &str) -> Result<String, Error> {
|
|
checked_segment("agent", agent)?;
|
|
Ok(format!("{AGENT_PREFIX}{agent}"))
|
|
}
|
|
|
|
/// The agents among `names`, a listing of cert-auth roles: the inverse of
|
|
/// [`agent_object_name`], for a caller that needs the set of agents holding a
|
|
/// store identity.
|
|
///
|
|
/// A name that carries the prefix but whose suffix is not a legal segment is
|
|
/// dropped rather than returned: [`agent_object_name`] could not have produced
|
|
/// it, so it is not an agent this crate minted.
|
|
#[must_use]
|
|
pub fn agents_from_role_names(names: &[String]) -> Vec<String> {
|
|
names
|
|
.iter()
|
|
.filter_map(|name| name.strip_prefix(AGENT_PREFIX))
|
|
.filter(|agent| checked_segment("agent", agent).is_ok())
|
|
.map(str::to_owned)
|
|
.collect()
|
|
}
|
|
|
|
/// One read stanza. With [`list_stanza`] and [`create_stanza`], the only shapes
|
|
/// this module emits, so what an agent may write is a property of the renderers
|
|
/// rather than of each call site.
|
|
fn read_stanza(path: &str) -> String {
|
|
format!("path \"{path}\" {{\n capabilities = [\"read\"]\n}}\n")
|
|
}
|
|
|
|
/// One list stanza: the key names under a `metadata/` path. Neither a value
|
|
/// nor the metadata itself, which take `read` on `data/` and on `metadata/`.
|
|
fn list_stanza(path: &str) -> String {
|
|
format!("path \"{path}\" {{\n capabilities = [\"list\"]\n}}\n")
|
|
}
|
|
|
|
/// One create stanza: `create` and `read`, never `update`. A leaf that holds
|
|
/// nothing takes a first write and the holder cannot replace it afterwards,
|
|
/// which is what a `-cas=0` write needs and all it gets.
|
|
fn create_stanza(path: &str) -> String {
|
|
format!("path \"{path}\" {{\n capabilities = [\"create\", \"read\"]\n}}\n")
|
|
}
|
|
|
|
/// The one policy every hive's cert-auth role attaches.
|
|
///
|
|
/// Inside [`HIVE_PREFIX`] so that the controller may write it. It is the object
|
|
/// name of a hive called `hives`, and `nix/reserved-hive-fragments.nix` forbids
|
|
/// the substring `hive` in every hive name, so no hive spells it.
|
|
pub const SHARED_HIVE_POLICY: &str = "hive-hives";
|
|
|
|
/// Render the policy [`SHARED_HIVE_POLICY`] holds: read on every agent's
|
|
/// credentials and on this hive's own, with the hive's name taken from the alias
|
|
/// of its certificate login on the cert auth mount `mount_accessor`.
|
|
///
|
|
/// A hive's certificate carries its bare name as the common name, and that is
|
|
/// the alias name, so the own-path stanza reads `swarm/hives/<hive>/` for
|
|
/// whichever hive presents the token. A login whose alias is not a hive's name
|
|
/// reads a path where nothing is stored. The `agents/` and `hives/` segments
|
|
/// are fixed text, never taken from the identity.
|
|
///
|
|
/// The controller kind and the `swarm/services/` tree are deliberately absent:
|
|
/// a hive has no business reading the credentials of the thing that provisions
|
|
/// it, and a swarm service's OIDC client secret is read under a certificate that
|
|
/// is not a hive's. Adding either is a boundary decision, not a consequence of
|
|
/// the namespace growing.
|
|
///
|
|
/// The hive's *own* kind is granted, and only that one: a hive holds the queue
|
|
/// credential its own agents authenticate with, so it reads the one principal
|
|
/// named after itself, which is why the path takes the name from the alias
|
|
/// instead of widening to the whole kind.
|
|
///
|
|
/// Read-only: the controller mints these and never reads one back.
|
|
///
|
|
/// # Errors
|
|
/// [`Error::PathSegment`] when `mount_accessor` holds anything but
|
|
/// `[A-Za-z0-9_-]`: it is interpolated into the policy text, where it could
|
|
/// close the stanza.
|
|
pub fn render_shared_hives(mount_accessor: &str) -> Result<String, Error> {
|
|
checked_segment("mount accessor", mount_accessor)?;
|
|
let own = format!("{{{{identity.entity.aliases.{mount_accessor}.name}}}}");
|
|
let agents = read_stanza(&format!(
|
|
"{MOUNT}/data/{ROOT}/{}/*",
|
|
<&str>::from(Kind::Agent)
|
|
));
|
|
let own = read_stanza(&format!(
|
|
"{MOUNT}/data/{ROOT}/{}/{own}/*",
|
|
<&str>::from(Kind::Hive)
|
|
));
|
|
Ok(format!("{agents}{own}"))
|
|
}
|
|
|
|
/// The one policy every agent's cert-auth role attaches.
|
|
///
|
|
/// Inside [`HIVE_PREFIX`] so that the controller may write it, and without an
|
|
/// agent's suffix so that it is never an agent's object name:
|
|
/// [`agents_from_role_names`] needs `hive-agent-` with a segment after it. The
|
|
/// hive name that would spell it is `agent`, which `nix/reserved-hive-fragments.nix`
|
|
/// forbids.
|
|
pub const SHARED_AGENT_POLICY: &str = "hive-agent";
|
|
|
|
/// The template that stands for the agent's name in [`render_shared_agent`]:
|
|
/// the name of the login's identity entity, which swarm-controller sets to the
|
|
/// agent's name.
|
|
const ENTITY_NAME_TEMPLATE: &str = "{{identity.entity.name}}";
|
|
|
|
/// Render the policy [`SHARED_AGENT_POLICY`] holds: read on one agent's
|
|
/// credentials and list on their names (how `hive-matrix-daemon` finds its
|
|
/// linked accounts), with the agent's name taken from the identity entity of
|
|
/// whoever presents the token.
|
|
///
|
|
/// The text is the same for every agent, so a change to the rule is one write.
|
|
/// An entity whose name is not an agent's grants nothing: the controller names
|
|
/// each agent's entity after the agent, and a login that no entity of that
|
|
/// name owns reads `swarm/agents/<generated name>/`, where nothing is stored.
|
|
///
|
|
/// The one write is `create` on the agent's own [`backend_env`] leaf, without
|
|
/// `update`: the agent seeds it from the file it has today, once, and cannot
|
|
/// replace it. The stanza cannot constrain the content, and the agent's own
|
|
/// process holds the identity, so while the leaf is empty it can also create it
|
|
/// with any text, and the store's value then wins over the file. That is no
|
|
/// wider than the harness-dir file the agent already owns:
|
|
/// the container activation's `heal_own_dir` (`nix/agent-modules/user.nix`)
|
|
/// re-owns that dir to the agent recursively at every boot, so the agent
|
|
/// already controls the legacy file. Any other write would let an agent hand
|
|
/// itself an identity it was never issued. An agent has no business with
|
|
/// another agent's credentials, a hive's, a service's, or the controller's, so
|
|
/// none of [`render_shared_hives`]'s breadth applies. A hive still reads this
|
|
/// agent's secrets, and closing that is its own decision.
|
|
#[must_use]
|
|
pub fn render_shared_agent() -> String {
|
|
let kind = <&str>::from(Kind::Agent);
|
|
let read = read_stanza(&format!(
|
|
"{MOUNT}/data/{ROOT}/{kind}/{ENTITY_NAME_TEMPLATE}/*"
|
|
));
|
|
let list = list_stanza(&format!(
|
|
"{MOUNT}/metadata/{ROOT}/{kind}/{ENTITY_NAME_TEMPLATE}/*"
|
|
));
|
|
let seed = create_stanza(&format!(
|
|
"{MOUNT}/data/{ROOT}/{kind}/{ENTITY_NAME_TEMPLATE}/{}",
|
|
backend_env::LEAF
|
|
));
|
|
format!("{read}{list}{seed}")
|
|
}
|
|
|
|
/// What an agent's token grants under [`SHARED_AGENT_POLICY`]: the document
|
|
/// with the store's template filled in for `agent`. The oracle tests use to ask
|
|
/// whether the policy covers a path.
|
|
#[cfg(test)]
|
|
pub(crate) fn effective_agent(agent: &str) -> String {
|
|
checked_segment("agent", agent).expect("a legal agent name");
|
|
render_shared_agent().replace(ENTITY_NAME_TEMPLATE, agent)
|
|
}
|
|
|
|
/// What a hive's token grants under [`SHARED_HIVE_POLICY`]: the document with
|
|
/// the store's template filled in for `hive`.
|
|
#[cfg(test)]
|
|
pub(crate) fn effective_hive(hive: &str) -> String {
|
|
checked_segment("hive", hive).expect("a legal hive name");
|
|
render_shared_hives("auth_cert_test")
|
|
.expect("a legal accessor")
|
|
.replace("{{identity.entity.aliases.auth_cert_test.name}}", hive)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn the_shared_agent_document_reads_the_entity_name_where_the_agent_goes() {
|
|
assert_eq!(
|
|
render_shared_agent(),
|
|
"path \"secret/data/swarm/agents/{{identity.entity.name}}/*\" {\n capabilities = [\"read\"]\n}\n\
|
|
path \"secret/metadata/swarm/agents/{{identity.entity.name}}/*\" {\n capabilities = [\"list\"]\n}\n\
|
|
path \"secret/data/swarm/agents/{{identity.entity.name}}/backend-env\" {\n capabilities = [\"create\", \"read\"]\n}\n"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_shared_document_names_no_agent_and_grants_no_wider_than_one() {
|
|
let shared = render_shared_agent();
|
|
assert!(!shared.contains("atlas"));
|
|
assert!(!shared.contains("agents/*"), "{shared}");
|
|
assert!(!shared.contains("update"));
|
|
assert!(!shared.contains("delete"));
|
|
}
|
|
|
|
#[test]
|
|
fn the_shared_policy_name_is_writable_and_no_agents_or_hives() {
|
|
assert!(SHARED_AGENT_POLICY.starts_with(HIVE_PREFIX));
|
|
assert!(
|
|
agents_from_role_names(&[SHARED_AGENT_POLICY.to_owned()]).is_empty(),
|
|
"it must not be read back as an agent"
|
|
);
|
|
// The only hive name that would render it carries `agent`.
|
|
assert_eq!(
|
|
hive_object_name("agent").expect("legal as a segment"),
|
|
SHARED_AGENT_POLICY
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_document_grants_every_agent_and_this_hive_alone() {
|
|
assert_eq!(
|
|
effective_hive("pr1ma"),
|
|
"path \"secret/data/swarm/agents/*\" {\n capabilities = [\"read\"]\n}\n\
|
|
path \"secret/data/swarm/hives/pr1ma/*\" {\n capabilities = [\"read\"]\n}\n"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_shared_hive_document_takes_the_hive_from_the_alias_name() {
|
|
let shared = render_shared_hives("auth_cert_1a2b3c4d").expect("legal accessor");
|
|
assert_eq!(
|
|
shared,
|
|
"path \"secret/data/swarm/agents/*\" {\n capabilities = [\"read\"]\n}\n\
|
|
path \"secret/data/swarm/hives/{{identity.entity.aliases.auth_cert_1a2b3c4d.name}}/*\" {\n capabilities = [\"read\"]\n}\n"
|
|
);
|
|
// The template fills in for hives that differ in length, case and
|
|
// punctuation, and each then reads exactly its own path.
|
|
for hive in ["pr1ma", "a", "Mixed_Case-1", "x".repeat(63).as_str()] {
|
|
let own = effective_hive(hive);
|
|
assert!(own.contains(&format!("swarm/hives/{hive}/*")), "{own}");
|
|
assert!(!own.contains("{{"), "{own}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_shared_hive_document_names_no_hive_and_grants_no_wider_than_one() {
|
|
let shared = render_shared_hives("auth_cert_1a2b3c4d").expect("legal accessor");
|
|
assert!(!shared.contains("pr1ma"));
|
|
assert!(!shared.contains("hives/*"), "{shared}");
|
|
assert!(!shared.contains("create"));
|
|
assert!(!shared.contains("update"));
|
|
assert!(!shared.contains("delete"));
|
|
assert!(!shared.contains("list"));
|
|
// The control: the walk finds the stanzas that are there.
|
|
assert_eq!(stanza_paths(&shared).len(), 2, "{shared}");
|
|
assert!(
|
|
stanza_paths(&shared)
|
|
.iter()
|
|
.any(|g| g.starts_with("swarm/hives/{{"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_accessor_that_could_close_the_stanza_is_refused() {
|
|
for bad in ["", "a.b", "a}}b", "a\"b", "a/b", "a b"] {
|
|
assert!(
|
|
matches!(render_shared_hives(bad), Err(Error::PathSegment { .. })),
|
|
"{bad:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_shared_hive_policy_name_is_writable_and_no_agents() {
|
|
assert!(SHARED_HIVE_POLICY.starts_with(HIVE_PREFIX));
|
|
assert!(
|
|
agents_from_role_names(&[SHARED_HIVE_POLICY.to_owned()]).is_empty(),
|
|
"it must not be read back as an agent"
|
|
);
|
|
assert_ne!(SHARED_HIVE_POLICY, SHARED_AGENT_POLICY);
|
|
// The only hive name that would render it carries `hive`.
|
|
assert_eq!(
|
|
hive_object_name("hives").expect("legal as a segment"),
|
|
SHARED_HIVE_POLICY
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_hive_reaches_no_swarm_services_secret() {
|
|
// A swarm service's OIDC client secret is read under a certificate that
|
|
// is not a hive's. Checked by walking the stanzas, so a wider grant
|
|
// that happened to cover the tree (`swarm/*`, say) fails too.
|
|
let p = effective_hive("pr1ma");
|
|
assert!(!p.contains("swarm/services"), "{p}");
|
|
let service = "swarm/services/swarm-grafana/oidc/client";
|
|
assert!(
|
|
!stanza_paths(&p).iter().any(|g| covers(g, service)),
|
|
"a hive's document reaches {service}:\n{p}"
|
|
);
|
|
// The control: the walk does find a path the document grants.
|
|
assert!(
|
|
stanza_paths(&p)
|
|
.iter()
|
|
.any(|g| covers(g, "swarm/hives/pr1ma/oidc/client"))
|
|
);
|
|
}
|
|
|
|
/// 🩸 The per-hive matrix sender-token scoping, asserted on the rendered
|
|
/// document rather than on the path function alone: it is the policy text
|
|
/// that decides what a hive may fetch, so "the path is per-hive" only
|
|
/// means something if the stanza that reaches it is too.
|
|
#[test]
|
|
fn a_hive_reaches_its_own_matrix_sender_token_and_no_other_hives() {
|
|
let alpha = effective_hive("alpha");
|
|
let own = crate::matrix::sender_token_path("alpha").expect("legal");
|
|
let other = crate::matrix::sender_token_path("beta").expect("legal");
|
|
|
|
// Reachable: the hive's own stanza is the prefix of its own path.
|
|
assert!(
|
|
alpha.contains("path \"secret/data/swarm/hives/alpha/*\""),
|
|
"{alpha}"
|
|
);
|
|
assert!(own.starts_with("swarm/hives/alpha/"), "{own}");
|
|
|
|
// Unreachable: no stanza in alpha's document is a prefix of beta's
|
|
// path. Checked by walking the stanzas rather than by asserting the
|
|
// absence of the string "beta", so a future stanza that happened to
|
|
// cover it — `swarm/hives/*`, say — would fail this too.
|
|
assert!(
|
|
!stanza_paths(&alpha)
|
|
.iter()
|
|
.any(|granted| covers(granted, &other)),
|
|
"alpha's document reaches {other}:\n{alpha}"
|
|
);
|
|
}
|
|
|
|
/// Every `path "…"` a rendered document grants, with the `secret/data/`
|
|
/// ACL prefix stripped so it compares against a store path.
|
|
fn stanza_paths(document: &str) -> Vec<String> {
|
|
document
|
|
.lines()
|
|
.filter_map(|line| line.strip_prefix("path \""))
|
|
.filter_map(|rest| rest.split('"').next())
|
|
.filter_map(|p| p.strip_prefix("secret/data/"))
|
|
.map(str::to_owned)
|
|
.collect()
|
|
}
|
|
|
|
/// Does a granted policy path cover `path`? Only the trailing-`*` form
|
|
/// this module renders, which is the only form it has to understand.
|
|
fn covers(granted: &str, path: &str) -> bool {
|
|
match granted.strip_suffix('*') {
|
|
Some(prefix) => path.starts_with(prefix),
|
|
None => granted == path,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_grant_is_read_only() {
|
|
// A hive reads credentials; a hive that could write one could hand
|
|
// itself an agent's identity.
|
|
let p = effective_hive("pr1ma");
|
|
assert!(!p.contains("create"));
|
|
assert!(!p.contains("update"));
|
|
assert!(!p.contains("delete"));
|
|
assert!(!p.contains("list"));
|
|
}
|
|
|
|
#[test]
|
|
fn one_hives_document_does_not_reach_another_hives_path() {
|
|
let a = effective_hive("alpha");
|
|
assert!(a.contains("swarm/hives/alpha/*"));
|
|
assert!(!a.contains("beta"));
|
|
assert!(!a.contains("swarm/hives/*"), "the hive stanza stays narrow");
|
|
}
|
|
|
|
#[test]
|
|
fn a_name_that_could_close_the_stanza_is_refused() {
|
|
// The name reaches a role name and a certificate subject, so a name that
|
|
// could re-punctuate either is refused.
|
|
assert!(
|
|
hive_object_name("atlas/*\" { capabilities = [\"root\"] }").is_err(),
|
|
"the object NAME is a place a name can do damage too"
|
|
);
|
|
}
|
|
|
|
#[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 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));
|
|
}
|
|
|
|
/// What `effective_agent("atlas")` must be, byte for byte.
|
|
const ATLAS_DOCUMENT: &str = "path \"secret/data/swarm/agents/atlas/*\" {\n capabilities = [\"read\"]\n}\n\
|
|
path \"secret/metadata/swarm/agents/atlas/*\" {\n capabilities = [\"list\"]\n}\n\
|
|
path \"secret/data/swarm/agents/atlas/backend-env\" {\n capabilities = [\"create\", \"read\"]\n}\n";
|
|
|
|
#[test]
|
|
fn an_agents_document_is_its_stanzas_naming_that_agent() {
|
|
assert_eq!(effective_agent("atlas"), ATLAS_DOCUMENT);
|
|
}
|
|
|
|
#[test]
|
|
fn an_agents_document_does_not_grant_the_whole_agent_prefix() {
|
|
// `render`'s agent stanza is `agents/*` on purpose, and inheriting one
|
|
// character of that here would give every agent every other agent's
|
|
// credentials while the document still read as per-agent.
|
|
let p = effective_agent("atlas");
|
|
assert!(
|
|
!p.contains("swarm/agents/*"),
|
|
"the agent stanza must not widen to the kind: {p}"
|
|
);
|
|
assert!(p.contains("secret/data/swarm/agents/atlas/*"));
|
|
assert!(p.contains("secret/metadata/swarm/agents/atlas/*"));
|
|
}
|
|
|
|
#[test]
|
|
fn an_agents_document_reaches_nothing_but_that_agent() {
|
|
// Stated as an exhaustive check over the kinds rather than as a list of
|
|
// paths, so a kind added to `path::Kind` cannot be granted here by a
|
|
// renderer nobody re-read.
|
|
let p = effective_agent("atlas");
|
|
for kind in Kind::ALL {
|
|
let segment = <&str>::from(kind);
|
|
let expected = kind == Kind::Agent;
|
|
assert_eq!(
|
|
p.contains(&format!("swarm/{segment}/")),
|
|
expected,
|
|
"kind {kind:?} in an agent's document: {p}"
|
|
);
|
|
}
|
|
assert!(!p.contains("argus"), "no other principal is named");
|
|
assert_eq!(
|
|
p.matches("path \"").count(),
|
|
3,
|
|
"three stanzas, or the document grants something unaccounted for: {p}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_agents_document_is_exactly_its_own_stanzas() {
|
|
// Pinned byte for byte: an added stanza (a hive's queue credential, a
|
|
// second agent) is exactly what a presence check misses.
|
|
assert_eq!(effective_agent("atlas"), ATLAS_DOCUMENT);
|
|
// The control: the pin discriminates between agents.
|
|
assert!(!effective_agent("other").contains("agents/atlas/"));
|
|
}
|
|
|
|
/// The paths of the stanzas in `document` that grant `capability`.
|
|
fn paths_granting(document: &str, capability: &str) -> Vec<String> {
|
|
let mut paths = Vec::new();
|
|
let mut current = "";
|
|
for line in document.lines() {
|
|
if let Some(rest) = line.strip_prefix("path \"") {
|
|
current = rest.split_once('"').map_or("", |(path, _)| path);
|
|
} else if line.contains(&format!("\"{capability}\"")) {
|
|
paths.push(current.to_owned());
|
|
}
|
|
}
|
|
paths
|
|
}
|
|
|
|
#[test]
|
|
fn an_agents_only_write_is_creating_its_own_backend_env() {
|
|
// An agent that could write its own credentials could hand itself an
|
|
// identity it was never issued. The one leaf it may create is the one
|
|
// it seeds from its backend environment file, and `update` on it would
|
|
// let the agent replace a value the operator set.
|
|
let p = effective_agent("atlas");
|
|
let leaf = format!(
|
|
"secret/data/{}",
|
|
crate::backend_env::env_path("atlas").expect("legal")
|
|
);
|
|
// The control: the walk finds the stanzas that are there.
|
|
assert_eq!(
|
|
paths_granting(&p, "read"),
|
|
["secret/data/swarm/agents/atlas/*", leaf.as_str()]
|
|
);
|
|
assert_eq!(paths_granting(&p, "create"), [leaf.as_str()]);
|
|
for capability in ["update", "delete", "sudo", "patch"] {
|
|
assert!(
|
|
!p.contains(capability),
|
|
"an agent's document must not grant {capability}: {p}"
|
|
);
|
|
}
|
|
// The exact stanza overrides the glob for its path, so it carries
|
|
// `read` itself.
|
|
assert!(
|
|
p.contains("backend-env\" {\n capabilities = [\"create\", \"read\"]"),
|
|
"{p}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_agent_reads_values_and_lists_names_and_nothing_crosswise() {
|
|
// `read` on `data/` is the values; `list` on `metadata/` is the key
|
|
// names. `read` on `metadata/` (version history, deletion times) is not
|
|
// granted, nor is `list` anywhere but `metadata/`.
|
|
let p = effective_agent("atlas");
|
|
let stanzas: Vec<&str> = p.split_inclusive("}\n").collect();
|
|
assert_eq!(stanzas.len(), 3, "{p}");
|
|
// The third is the create stanza, pinned by the test above.
|
|
for stanza in &stanzas[..2] {
|
|
let on_data = stanza.starts_with("path \"secret/data/");
|
|
let on_metadata = stanza.starts_with("path \"secret/metadata/");
|
|
assert!(on_data != on_metadata, "{stanza}");
|
|
assert_eq!(
|
|
stanza.contains("capabilities = [\"read\"]"),
|
|
on_data,
|
|
"{stanza}"
|
|
);
|
|
assert_eq!(
|
|
stanza.contains("capabilities = [\"list\"]"),
|
|
on_metadata,
|
|
"{stanza}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn one_agents_document_does_not_reach_another_agents_path() {
|
|
let a = effective_agent("atlas");
|
|
assert!(a.contains("swarm/agents/atlas/*"));
|
|
assert!(!a.contains("argus"));
|
|
}
|
|
|
|
#[test]
|
|
fn an_agent_name_that_could_close_the_stanza_is_refused() {
|
|
// The name reaches a role name and a certificate subject, so a name that
|
|
// could re-punctuate either is refused.
|
|
assert!(agent_object_name("atlas/*\" { capabilities = [\"root\"] }").is_err());
|
|
assert!(agent_object_name("").is_err());
|
|
// The control: if everything were refused the arms above would pass for
|
|
// the wrong reason.
|
|
assert!(agent_object_name("a-b_C9").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn an_agents_object_name_sits_where_the_controller_can_write_it() {
|
|
// `hive-*` is the whole of what `controllerPolicyText` lets the
|
|
// controller create under `sys/policies/acl/` and `auth/cert/certs/`,
|
|
// and the controller is the only principal that learns an agent exists.
|
|
let n = agent_object_name("atlas").expect("legal");
|
|
assert_eq!(n, "hive-agent-atlas");
|
|
assert!(n.starts_with(HIVE_PREFIX));
|
|
assert!(n.starts_with(AGENT_PREFIX));
|
|
}
|
|
|
|
#[test]
|
|
fn an_agents_object_name_cannot_be_spelled_by_a_legal_hive_name() {
|
|
// The collision argument, as an assertion rather than as prose: the
|
|
// only hive name that renders an agent's object name is one carrying
|
|
// the substring `agent`, which `nix/reserved-hive-fragments.nix`
|
|
// forbids — so the guard this leans on is named in a test that fails if
|
|
// the prefix is ever changed to something that guard does not cover.
|
|
let agent = agent_object_name("atlas").expect("legal");
|
|
let colliding_hive = agent
|
|
.strip_prefix(HIVE_PREFIX)
|
|
.expect("an agent's name is inside the hive namespace");
|
|
assert_eq!(colliding_hive, "agent-atlas");
|
|
assert_eq!(
|
|
hive_object_name(colliding_hive).expect("legal as a path segment"),
|
|
agent,
|
|
"this is the hive name a collision would need"
|
|
);
|
|
assert!(
|
|
colliding_hive.contains("agent"),
|
|
"and it is unrepresentable only because a hive name may not contain `agent`"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_agents_document_is_not_the_hives() {
|
|
// A later edit that made one shared document delegate to the other
|
|
// would hand an agent the hive's `agents/*` stanza.
|
|
let agent = effective_agent("atlas");
|
|
let hive = effective_hive("atlas");
|
|
assert_ne!(agent, hive);
|
|
assert!(!agent.contains("swarm/services/"));
|
|
assert!(!agent.contains("swarm/hives/"));
|
|
// The control: the hive's document does carry that stanza.
|
|
assert!(hive.contains("path \"secret/data/swarm/agents/*\""));
|
|
}
|
|
|
|
#[test]
|
|
fn only_agent_roles_come_back_and_without_their_prefix() {
|
|
let roles: Vec<String> = [
|
|
"hive-agent-atlas",
|
|
"hive-pr1ma",
|
|
"swarm-controller",
|
|
"hive-agent-argus",
|
|
]
|
|
.map(str::to_owned)
|
|
.to_vec();
|
|
assert_eq!(agents_from_role_names(&roles), ["atlas", "argus"]);
|
|
}
|
|
|
|
#[test]
|
|
fn a_role_agent_object_name_could_not_have_produced_is_dropped() {
|
|
// An empty suffix and one carrying a dot are both names the prefix
|
|
// matches and no agent can have.
|
|
let roles: Vec<String> = ["hive-agent-", "hive-agent-a.b", "hive-agent-ok"]
|
|
.map(str::to_owned)
|
|
.to_vec();
|
|
assert_eq!(agents_from_role_names(&roles), ["ok"]);
|
|
}
|
|
|
|
#[test]
|
|
fn the_listing_inverts_agent_object_name() {
|
|
let name = agent_object_name("atlas").expect("legal");
|
|
assert_eq!(agents_from_role_names(&[name]), ["atlas"]);
|
|
}
|
|
}
|