render_agent gains a second stanza: list on secret/metadata/swarm/agents/<agent>/*, next to the existing read on secret/data/swarm/agents/<agent>/*. An agent can now learn which credentials it holds by listing its own subtree. Metadata read, writes and every other principal's paths stay refused. An agent's policy was only written when it was minted, so existing agents would never get the new stanza. swarm-controller now rewrites every agent's policy at start (read_policy::ensure_agent_policies), with the same 30s / 24h retry as ensure_hive_access. The roster is the store's hive-agent-* cert-auth roles, listed with the controller's existing `list` on auth/cert/certs; the writes use its existing grant on sys/policies/acl/hive-*. Only the policy is written: mint_and_verify also reissues the certificate, so the pass does not call it. Refs #4348
560 lines
25 KiB
Rust
560 lines
25 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 document 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 **service** stanza is wide for the same shape of reason: a swarm
|
|
//! service's client is registered once per swarm, so its path names the service
|
|
//! and never the host, and which hive runs a service is a `deploy.*` fact with
|
|
//! no swarm-wide spelling to scope against. Cost: the same doc.
|
|
//!
|
|
//! 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 its neighbours — the asymmetry
|
|
//! is the point, and only holds if a one-per-hive credential is stored under
|
|
//! [`Kind::Hive`], not [`Kind::Service`] — see 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}"))
|
|
}
|
|
|
|
/// Namespace for an agent's own policy and cert-auth role.
|
|
///
|
|
/// 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
|
|
/// this policy grants 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`. That guard was written for the queue's `hive-<name>-agent`
|
|
/// client ids; this is a second identifier family leaning on it, which is why
|
|
/// 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 policy and cert-auth role name for `agent`, and the common name of the
|
|
/// certificate that authenticates it. One string, three objects: the role
|
|
/// attaches the policy and matches the subject by spelling all three 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`], the only shapes this module emits, so
|
|
/// "writes nothing" 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")
|
|
}
|
|
|
|
/// Render `hive`'s policy document: read on every agent's credentials, on this
|
|
/// hive's own, and on the swarm services'.
|
|
///
|
|
/// The name is the only input, and it is deploy-time — so the document is
|
|
/// still a deploy-time object rather than derived state with a re-emission to
|
|
/// get wrong. Nothing about which agents exist changes the text.
|
|
///
|
|
/// Read-only: the controller mints these and never reads one back.
|
|
///
|
|
/// ⚠️ The controller kind is deliberately absent: a hive has no business
|
|
/// reading the credentials of the thing that provisions it. Adding it is a
|
|
/// boundary decision, not a consequence of the namespace growing.
|
|
///
|
|
/// The **service** kind is granted, and that was such a decision rather than a
|
|
/// consequence: a service whose identity provider is on another host reads its
|
|
/// own OIDC client secret with the certificate of the hive it runs on, that
|
|
/// being the only identity such a host has — so every hive can read every
|
|
/// service's. Bought and paid for in `docs/trust-boundary/security.md`.
|
|
///
|
|
/// The hive's *own* kind is granted, and that is the decision the agent-only
|
|
/// version of this grant said had to be made rather than assumed: a hive holds
|
|
/// the queue credential its own agents authenticate with, so it has to read the
|
|
/// one principal named after itself — and only that one, which is why the path
|
|
/// interpolates the name instead of widening to the whole kind.
|
|
///
|
|
/// # Errors
|
|
/// [`Error::PathSegment`] when `hive` holds anything but `[A-Za-z0-9_-]` — the
|
|
/// name is interpolated into a policy path, so a name that could close the
|
|
/// stanza could grant itself anything.
|
|
pub fn render(hive: &str) -> Result<String, Error> {
|
|
checked_segment("hive", hive)?;
|
|
let agents = read_stanza(&format!(
|
|
"{MOUNT}/data/{ROOT}/{}/*",
|
|
<&str>::from(Kind::Agent)
|
|
));
|
|
let own = read_stanza(&format!(
|
|
"{MOUNT}/data/{ROOT}/{}/{hive}/*",
|
|
<&str>::from(Kind::Hive)
|
|
));
|
|
let services = read_stanza(&format!(
|
|
"{MOUNT}/data/{ROOT}/{}/*",
|
|
<&str>::from(Kind::Service)
|
|
));
|
|
Ok(format!("{agents}{own}{services}"))
|
|
}
|
|
|
|
/// Render `agent`'s policy document: read on that one agent's credentials, list
|
|
/// on their names (how `hive-matrix-daemon` finds its linked accounts), and
|
|
/// nothing else at all.
|
|
///
|
|
/// Two stanzas over one subtree, and the single interpolated name is the whole
|
|
/// document: an agent authenticating with its own certificate gets a token that
|
|
/// fetches `swarm/agents/<agent>/…`, lists the keys under it, and is refused
|
|
/// every other path in the store. That is the point — the reason to give an
|
|
/// agent an identity is that it then depends on its hive for one file (the
|
|
/// certificate) rather than for every credential it uses.
|
|
///
|
|
/// ⚠️ **None of [`render`]'s breadth is inherited.** A hive's document grants
|
|
/// read on `swarm/agents/*` — *every* agent's credentials, not the ones that
|
|
/// hive hosts — and on `swarm/services/*` the same way; the module header says
|
|
/// why each is a decision rather than an oversight. Neither reason transfers:
|
|
/// an agent's path names the agent, so scoping costs nothing and drifts nowhere
|
|
/// (the argument that keeps [`render`]'s hive stanza narrow), and an agent has
|
|
/// no business with a service's OIDC secret, another agent's credentials, a
|
|
/// hive's, or the controller's. Narrow here does **not** narrow the hive's: a
|
|
/// hive still reads this agent's secrets, and closing that is its own decision.
|
|
/// Nothing here writes, for the reason the hive's does not: an agent that could
|
|
/// write its own credentials could hand itself an identity it was never issued.
|
|
///
|
|
/// ⚠️ Not deploy-time: hive-c0re creates agents at runtime (`hive_c0re::meta`).
|
|
/// swarm-controller rewrites every agent's document at its own start, so an
|
|
/// edit here reaches existing agents with the next controller restart.
|
|
///
|
|
/// # Errors
|
|
/// [`Error::PathSegment`] when `agent` holds anything but `[A-Za-z0-9_-]`: the
|
|
/// name is interpolated into the policy text, where it could close the stanza.
|
|
pub fn render_agent(agent: &str) -> Result<String, Error> {
|
|
checked_segment("agent", agent)?;
|
|
let kind = <&str>::from(Kind::Agent);
|
|
let read = read_stanza(&format!("{MOUNT}/data/{ROOT}/{kind}/{agent}/*"));
|
|
let list = list_stanza(&format!("{MOUNT}/metadata/{ROOT}/{kind}/{agent}/*"));
|
|
Ok(format!("{read}{list}"))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn the_document_grants_two_whole_prefixes_and_this_hive_alone() {
|
|
assert_eq!(
|
|
render("pr1ma").expect("a plain name is legal"),
|
|
"path \"secret/data/swarm/agents/*\" {\n capabilities = [\"read\"]\n}\n\
|
|
path \"secret/data/swarm/hives/pr1ma/*\" {\n capabilities = [\"read\"]\n}\n\
|
|
path \"secret/data/swarm/services/*\" {\n capabilities = [\"read\"]\n}\n"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_service_stanza_covers_a_service_this_hive_was_never_named_beside() {
|
|
// The property the swarm-grafana delivery depends on: the host running a
|
|
// swarm service reads that service's client secret with its own hive
|
|
// certificate, and the path names the service rather than the host. A
|
|
// stanza narrowed to the reader's name would 403 every such read.
|
|
let p = render("pr1ma").expect("legal");
|
|
assert!(p.contains("path \"secret/data/swarm/services/*\""));
|
|
assert!(
|
|
!p.contains("services/pr1ma"),
|
|
"the service stanza is not scoped to the reader"
|
|
);
|
|
}
|
|
|
|
/// 🩸 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.
|
|
///
|
|
/// The `services/*` stanza above is deliberate and stays — it is how a
|
|
/// host reads the OIDC secret of a service it runs. The property here is
|
|
/// that the matrix sender token is no longer *inside* it.
|
|
#[test]
|
|
fn a_hive_reaches_its_own_matrix_sender_token_and_no_other_hives() {
|
|
let alpha = render("alpha").expect("legal");
|
|
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 = render("pr1ma").expect("legal");
|
|
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() {
|
|
// Replaces `every_hive_gets_a_byte_identical_document`: the hive stanza
|
|
// is per-reader now, so identical text is no longer the property. This
|
|
// is what that test was protecting — that a document says only what the
|
|
// deploy-time name puts in it.
|
|
let a = render("alpha").expect("legal");
|
|
assert!(a.contains("swarm/hives/alpha/*"));
|
|
assert!(!a.contains("beta"));
|
|
assert!(!a.contains("swarm/hives/*"), "the hive stanza stays narrow");
|
|
}
|
|
|
|
#[test]
|
|
fn the_same_name_still_renders_byte_identically() {
|
|
// The half of the old property that survives: the text is a function of
|
|
// the deploy-time name alone, so a re-emission cannot drift.
|
|
assert_eq!(
|
|
render("pr1ma").expect("legal"),
|
|
render("pr1ma").expect("legal")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_name_that_could_close_the_stanza_is_refused() {
|
|
// The name reaches the document now, which it did not before — so the
|
|
// injection case is live again in the policy TEXT, not just in the
|
|
// policy's identifier.
|
|
assert!(render("alpha/*\" { capabilities = [\"root\"] }").is_err());
|
|
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());
|
|
assert!(render("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 `render_agent("atlas")` must render, 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";
|
|
|
|
#[test]
|
|
fn an_agents_document_is_two_stanzas_naming_that_agent() {
|
|
assert_eq!(
|
|
render_agent("atlas").expect("a plain name is legal"),
|
|
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 = render_agent("atlas").expect("legal");
|
|
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 = render_agent("atlas").expect("legal");
|
|
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(),
|
|
2,
|
|
"two stanzas, or the document grants something unaccounted for: {p}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_agents_document_is_exactly_its_own_two_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!(render_agent("atlas").expect("legal"), ATLAS_DOCUMENT);
|
|
// The control: the pin discriminates between agents.
|
|
assert!(
|
|
!render_agent("other")
|
|
.expect("legal")
|
|
.contains("agents/atlas/")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_agents_grant_writes_nothing() {
|
|
// An agent that could write its own credentials could hand itself an
|
|
// identity it was never issued — and `create`/`update` on that path is
|
|
// exactly what the controller holds, so the wall is the capability.
|
|
let p = render_agent("atlas").expect("legal");
|
|
for capability in ["create", "update", "delete", "sudo", "patch"] {
|
|
assert!(
|
|
!p.contains(capability),
|
|
"an agent's document must not grant {capability}: {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 = render_agent("atlas").expect("legal");
|
|
let stanzas: Vec<&str> = p.split_inclusive("}\n").collect();
|
|
assert_eq!(stanzas.len(), 2, "{p}");
|
|
for stanza in stanzas {
|
|
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 = render_agent("atlas").expect("legal");
|
|
assert!(a.contains("swarm/agents/atlas/*"));
|
|
assert!(!a.contains("argus"));
|
|
assert_eq!(a, render_agent("atlas").expect("legal"));
|
|
}
|
|
|
|
#[test]
|
|
fn an_agent_name_that_could_close_the_stanza_is_refused() {
|
|
// Same live injection surface as the hive renderer's: the name reaches
|
|
// the document text, not just an identifier.
|
|
assert!(render_agent("atlas/*\" { capabilities = [\"root\"] }").is_err());
|
|
assert!(render_agent("").is_err());
|
|
assert!(agent_object_name("atlas/*\" { capabilities = [\"root\"] }").is_err());
|
|
// The control: if everything were refused the arms above would pass for
|
|
// the wrong reason.
|
|
assert!(render_agent("a-b_C9").is_ok());
|
|
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() {
|
|
// The pin the two renderers need against each other: a later edit that
|
|
// made `render_agent` delegate to `render`, or vice versa, would hand
|
|
// an agent the hive's two wide stanzas.
|
|
let agent = render_agent("atlas").expect("legal");
|
|
let hive = render("atlas").expect("legal");
|
|
assert_ne!(agent, hive);
|
|
assert!(!agent.contains("swarm/services/"));
|
|
assert!(!agent.contains("swarm/hives/"));
|
|
// And the hive's is untouched by this module's growth: still the three
|
|
// stanzas `the_document_grants_two_whole_prefixes_and_this_hive_alone`
|
|
// pins byte for byte, none of them narrowed to make the agent's look
|
|
// consistent with it.
|
|
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"]);
|
|
}
|
|
}
|