A swarm runs one homeserver and every hive on it logged in as the same `@hive:` localpart, holding the same access token out of one swarm-wide store path. That is one matrix identity for N hives: the homeserver cannot attribute an action to the hive that took it, and revoking one hive's standing revokes every hive's. Three changes, and the third is the one that makes the other two real: - **The localpart carries the hive's name** (`hive-<hive>`), derived in one place, `swarm_secret_client::matrix::hive_localpart`. `hive-matrix.nix` renders the same string as the appservice registration's `sender_localpart`, so the shared account stops being created rather than merely stops being used. - **The store path is templated by hive**, not a constant. The "a swarm runs one homeserver, so this is a constant rather than a parameter" rationale went with it; it stopped holding the moment two hives shared the homeserver it describes. - **The path moved out from under the grant every hive has.** It sat at `swarm/services/matrix/sender-token`, inside the `secret/data/swarm/services/*` read stanza `policy::render` gives every hive. It now sits under that hive's own stanza, `secret/data/swarm/hives/<hive>/*`, which interpolates the reader's name — so a hive reads its own token and is refused another's. The policy renderer itself is unchanged: narrowing the `services/*` grant would break the OIDC-secret read it exists for, and moving the credential is what this needed instead. A policy test walks the rendered stanzas and asserts none of hive alpha's covers hive beta's sender token, so a later stanza that widened it fails here. `swarm-matrix-ctl` takes a new required `MATRIX_MINT_HIVE` and writes that hive's path; its store grant in `swarm-bao.nix` follows, scoped to one hive's leaf via the new `deploy.bao.matrixCtlHiveName` (defaulting to this host's `hiveName`) rather than a `hives/*` wildcard, which would hand the matrix container every hive's token back. Migration: no outage at deploy. `ensure_hive_user` short-circuits on the local token file, so a hive keeps running on what it has; with no such file it reads the new per-hive path, finds nothing, and falls through to the existing register-or-appservice-login ladder against its own localpart — which needs only the per-hive `as_token` on local disk. The old shared object is read by nothing afterwards. Rooms do not follow the identity, and that is the one operator step; both ways out are written into `docs/integrations/matrix.md`. No admin standing is granted to the per-hive accounts: `admin_execute` stays empty and the assertion pinning it is untouched.
598 lines
27 KiB
Rust
598 lines
27 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.
|
|
///
|
|
/// ⚠️ The controller's and publisher's subjects are *not* covered by that: their
|
|
/// policy names are literals outside `hive-`, but their **common names** are
|
|
/// operator-set options (`deploy.bao.controllerCommonName`,
|
|
/// `secretPublisherCommonName`) that nothing here can see, and an operator may
|
|
/// spell one `hive-agent-atlas`. Whichever change first mints an agent leaf
|
|
/// owes the assertion that neither starts with this prefix — `swarm.nix`'s
|
|
/// `certAuthCns` is where the mirror-image check for hive names lives.
|
|
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 — and agent
|
|
/// names are one swarm-wide namespace (an agent's path is
|
|
/// `swarm/agents/<agent>`, with no hive segment to disambiguate two that
|
|
/// matched), so two agents cannot land on one name.
|
|
///
|
|
/// # 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}"))
|
|
}
|
|
|
|
/// One read stanza. The only shape this module emits, so "read-only" is a
|
|
/// property of the renderer rather than of each call site.
|
|
fn read_stanza(path: &str) -> String {
|
|
format!("path \"{path}\" {{\n capabilities = [\"read\"]\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, and
|
|
/// on nothing else at all.
|
|
///
|
|
/// One stanza, and the single interpolated name in it is the whole document: an
|
|
/// agent authenticating with its own certificate gets a token that fetches
|
|
/// `swarm/agents/<agent>/…` 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.
|
|
/// Read-only, for the reason the hive's is: an agent that could write its own
|
|
/// credentials could hand itself an identity it was never issued.
|
|
///
|
|
/// ⚠️ Unlike [`render`]'s, this name is not deploy-time at every layer: nothing
|
|
/// in `nix/host-modules/` knows which agents exist — hive-c0re creates them at
|
|
/// runtime into its own meta flake (`hive_c0re::meta`).
|
|
///
|
|
/// # Errors
|
|
/// [`Error::PathSegment`] when `agent` holds anything but `[A-Za-z0-9_-]` — it
|
|
/// is interpolated into a policy path, so a name that could close the stanza
|
|
/// could grant itself anything.
|
|
pub fn render_agent(agent: &str) -> Result<String, Error> {
|
|
checked_segment("agent", agent)?;
|
|
Ok(read_stanza(&format!(
|
|
"{MOUNT}/data/{ROOT}/{}/{agent}/*",
|
|
<&str>::from(Kind::Agent)
|
|
)))
|
|
}
|
|
|
|
/// Render `agent`'s policy document: read on that one agent's credentials and
|
|
/// on the hive's shared queue credential.
|
|
///
|
|
/// Extends [`render_agent`] with a second stanza granting read on
|
|
/// `swarm/hives/<hive>/queue/agent`. The queue credential is **hive-shared,
|
|
/// not per-agent** — every agent in a hive authenticates to the queue with the
|
|
/// same client secret (`queue.rs:1-8`), so a policy scoped strictly to
|
|
/// `agents/<agent>/*` cannot read it and an in-container pull would fail. That
|
|
/// hive-shared credential is already handed to every agent container on that
|
|
/// hive by the host today, so this grant adds no new authority — it merely
|
|
/// makes the existing capability reachable through the agent's own token
|
|
/// instead of requiring the credential to be delivered out of band.
|
|
///
|
|
/// ⚠️ **Every agent in a hive can read that hive's queue credential.** This is
|
|
/// not new authority (the host already provides this exact value to all agents
|
|
/// on the hive), but it is a documented property: an agent policy grants read
|
|
/// on a path shared across every agent on its hive, not on a path unique to
|
|
/// that agent alone.
|
|
///
|
|
/// Read-only, for the same reason [`render_agent`]'s is: an agent that could
|
|
/// write credentials could hand itself an identity it was never issued.
|
|
///
|
|
/// # Errors
|
|
/// [`Error::PathSegment`] when `agent` or `hive` holds anything but
|
|
/// `[A-Za-z0-9_-]` — both are interpolated into policy paths, so a name that
|
|
/// could close a stanza could grant itself anything.
|
|
pub fn render_agent_with_queue(agent: &str, hive: &str) -> Result<String, Error> {
|
|
checked_segment("agent", agent)?;
|
|
let agent_stanza = read_stanza(&format!(
|
|
"{MOUNT}/data/{ROOT}/{}/{agent}/*",
|
|
<&str>::from(Kind::Agent)
|
|
));
|
|
let queue_path = crate::queue::agent_client_path(hive)?;
|
|
let queue_stanza = read_stanza(&format!("{MOUNT}/data/{queue_path}"));
|
|
Ok(format!("{agent_stanza}{queue_stanza}"))
|
|
}
|
|
|
|
#[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));
|
|
}
|
|
|
|
#[test]
|
|
fn an_agents_document_is_one_stanza_naming_that_agent() {
|
|
assert_eq!(
|
|
render_agent("atlas").expect("a plain name is legal"),
|
|
"path \"secret/data/swarm/agents/atlas/*\" {\n capabilities = [\"read\"]\n}\n"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_agents_document_does_not_grant_the_whole_agent_prefix() {
|
|
// The arm the whole change exists for. `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("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(),
|
|
1,
|
|
"one stanza, or the document grants something unaccounted for: {p}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_agents_grant_is_read_only() {
|
|
// 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", "list", "sudo", "patch"] {
|
|
assert!(
|
|
!p.contains(capability),
|
|
"an agent's document must not grant {capability}: {p}"
|
|
);
|
|
}
|
|
assert!(p.contains("capabilities = [\"read\"]"));
|
|
}
|
|
|
|
#[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 an_agents_document_with_queue_grants_both_paths() {
|
|
// The happy path: the document grants read on the agent's own namespace
|
|
// and on the hive's queue credential.
|
|
let p = render_agent_with_queue("atlas", "pr1ma").expect("legal");
|
|
assert!(
|
|
p.contains("path \"secret/data/swarm/agents/atlas/*\""),
|
|
"must grant the agent's own path: {p}"
|
|
);
|
|
let expected_queue_path = format!(
|
|
"path \"{MOUNT}/data/{}\"",
|
|
crate::queue::agent_client_path("pr1ma").expect("legal")
|
|
);
|
|
assert!(
|
|
p.contains(&expected_queue_path),
|
|
"must grant the hive's queue credential: {p}"
|
|
);
|
|
assert_eq!(
|
|
p.matches("path \"").count(),
|
|
2,
|
|
"two stanzas, one for the agent and one for the queue: {p}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_agents_document_with_queue_is_read_only() {
|
|
// An agent that could write the queue credential could hand every agent
|
|
// on its hive an identity they were never issued.
|
|
let p = render_agent_with_queue("atlas", "pr1ma").expect("legal");
|
|
for capability in ["create", "update", "delete", "list", "sudo", "patch"] {
|
|
assert!(!p.contains(capability), "must not grant {capability}: {p}");
|
|
}
|
|
assert!(p.contains("capabilities = [\"read\"]"));
|
|
}
|
|
|
|
#[test]
|
|
fn an_agent_name_with_traversal_in_the_queue_variant_is_refused() {
|
|
// The agent parameter is an injection surface in both renderers, so
|
|
// refusing a traversal here proves the new one validates it.
|
|
assert!(
|
|
render_agent_with_queue("atlas/*\" { capabilities = [\"root\"] }", "pr1ma").is_err()
|
|
);
|
|
assert!(render_agent_with_queue("", "pr1ma").is_err());
|
|
// The control: legal names still work.
|
|
assert!(render_agent_with_queue("a-b_C9", "pr1ma").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn a_hive_name_with_traversal_in_the_queue_variant_is_refused() {
|
|
// The hive parameter is a second injection surface that only the queue
|
|
// variant introduces, so this test proves that new parameter is
|
|
// validated. A name that could close the stanza could grant the agent
|
|
// anything.
|
|
assert!(
|
|
render_agent_with_queue("atlas", "pr1ma/*\" { capabilities = [\"root\"] }").is_err()
|
|
);
|
|
assert!(render_agent_with_queue("atlas", "").is_err());
|
|
assert!(
|
|
render_agent_with_queue("atlas", "../services/swarm-grafana").is_err(),
|
|
"a path traversal that could reach a different kind"
|
|
);
|
|
// The control: legal names still work.
|
|
assert!(render_agent_with_queue("atlas", "a-b_C9").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn the_queue_variant_does_not_widen_the_agent_stanza() {
|
|
// The queue grant must not cause the agent stanza to widen from
|
|
// `agents/<agent>/*` to `agents/*` — that would give every agent every
|
|
// other agent's credentials.
|
|
let p = render_agent_with_queue("atlas", "pr1ma").expect("legal");
|
|
assert!(
|
|
!p.contains("swarm/agents/*"),
|
|
"must not grant the whole agent prefix: {p}"
|
|
);
|
|
assert!(p.contains("swarm/agents/atlas/*"));
|
|
}
|
|
|
|
#[test]
|
|
fn the_queue_variant_does_not_grant_the_whole_hive_prefix() {
|
|
// The queue stanza must grant only the queue credential path, not
|
|
// `hives/<hive>/*` — the latter would give the agent read on every
|
|
// secret of the hive that hosts it.
|
|
let p = render_agent_with_queue("atlas", "pr1ma").expect("legal");
|
|
assert!(
|
|
!p.contains("swarm/hives/*"),
|
|
"must not grant the whole hive prefix: {p}"
|
|
);
|
|
assert!(
|
|
!p.contains("swarm/hives/pr1ma/*"),
|
|
"must not grant the hive's whole path: {p}"
|
|
);
|
|
let expected_queue_path = crate::queue::agent_client_path("pr1ma").expect("legal");
|
|
assert!(p.contains(&expected_queue_path));
|
|
}
|
|
}
|