hives read the forge through their own swarm-minted read account
swarm-controller now gives every hive in its directory a forge account, `hive-<hive>`, in a new `hives` team of `agent-configs` (read on `repo.code` + `repo.pulls`, every repo including later ones), and mints one `read:repository` token named `swarm-hive` into the store at `swarm/hives/<hive>/forge/token`. A five-minute pass queues a `MintHiveForgeToken` node for a hive whose token is missing or no longer matches the forge, whose account is missing, or whose team membership or team settings drifted. A hive whose store or forge read failed is never queued, so an outage cannot rotate every hive's token. The token decision reuses `agent_token`'s classifier and replace step, now parameterised by name and scopes. An agent may be called `hive-<x>`. The hive account is created with `hive-<hive>@hives.hyperhive.local` and the job refuses an account by that name with any other email before it locks it down, adds it to the team or mints for it; agent creation already refuses the hive's account by the same email check. The controller's bao policy gains the `swarm/hives/+/forge/token` leaf (create/read/update), pinned by the bao-grants module-eval assertion. The hive's own policy already reads `swarm/hives/<hive>/*`. hive-c0re reads that token through `forge::reader_token()`, falling back to the core token file when the store holds none, and uses it for: fetching a merged config rev (swarm deploy, `hivectl forge reconcile-config`), reseeding a purged `applied`, the meta-update cascade's fast-forward, seeding `proposed` on first deploy, and finding the PR that merged a refused rev. Those reads no longer require a local `hive-forge` container. The `/knowledge` clone moves from the forge host's boot sweep into the knowledge pull, so every hive clones it. Posting the deploy-failure comment on that PR stays on the core token. Refs #4851
This commit is contained in:
parent
c8472966e9
commit
21e3f626f4
17 changed files with 1222 additions and 138 deletions
|
|
@ -1,14 +1,17 @@
|
|||
//! The forge agreement: where an agent's forge tokens live in the store, and
|
||||
//! what the objects at those paths hold.
|
||||
//! The forge agreement: where agents' and hives' forge tokens live in the store,
|
||||
//! and what the objects at those paths hold.
|
||||
//!
|
||||
//! Two kinds. The agent's own token on the swarm's forge
|
||||
//! Three kinds. The agent's own token on the swarm's forge
|
||||
//! ([`agent_token_path`]): `swarm-controller` mints it with the forge's admin
|
||||
//! API, and `nix/agent-modules/forge-token.nix` reads it back under the agent's
|
||||
//! own certificate. And the agent's accounts on external forges
|
||||
//! ([`account_path`]): an operator hands the controller a URL and a token, the
|
||||
//! controller stores them there, and `nix/agent-modules/forge-accounts.nix`
|
||||
//! lists [`accounts_dir`] and reads each account. Neither end is senior, so
|
||||
//! both halves are stated once, here, beside [`crate::matrix`].
|
||||
//! lists [`accounts_dir`] and reads each account. A hive's read-only token
|
||||
//! ([`hive_token_path`]) is the third: `swarm-controller` mints it for the
|
||||
//! hive's own forge account, and hive-c0re reads it under the hive's
|
||||
//! certificate. Neither end is senior, so both halves are stated once, here,
|
||||
//! beside [`crate::matrix`].
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
|
@ -32,7 +35,34 @@ pub fn agent_token_path(agent: &str) -> Result<String, Error> {
|
|||
Ok(format!("{prefix}/forge-token"))
|
||||
}
|
||||
|
||||
/// What an agent's forge-token path holds.
|
||||
/// The forge account `hive` reads the forge as: `swarm-controller` creates it
|
||||
/// and mints [`hive_token_path`]'s token for it, and hive-c0re names it in the
|
||||
/// basic-auth header that token travels in.
|
||||
///
|
||||
/// The `hive-` prefix keeps the account reading as a hive's rather than an
|
||||
/// agent's, as [`crate::matrix::hive_localpart`] does on the homeserver. It
|
||||
/// does not make the name unique: an agent may be called `hive-<x>` too, and
|
||||
/// `swarm-controller` tells the two apart by the account's email.
|
||||
#[must_use]
|
||||
pub fn hive_login(hive: &str) -> String {
|
||||
format!("hive-{hive}")
|
||||
}
|
||||
|
||||
/// The path holding `hive`'s read-only token for the swarm's forge.
|
||||
///
|
||||
/// Under [`Kind::Hive`], so the grant that reaches it is the hive's own
|
||||
/// `swarm/hives/<this hive>/*` stanza: hive `alpha` reads its own token and
|
||||
/// gets a 403 on `beta`'s.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::PathSegment`] when `hive` contains anything but `[A-Za-z0-9_-]`,
|
||||
/// which is what keeps one hive's name from addressing another hive's secret.
|
||||
pub fn hive_token_path(hive: &str) -> Result<String, Error> {
|
||||
let prefix = principal_prefix(Kind::Hive, hive)?;
|
||||
Ok(format!("{prefix}/forge/token"))
|
||||
}
|
||||
|
||||
/// What an agent's forge-token path, and a hive's [`hive_token_path`], holds.
|
||||
///
|
||||
/// No `Debug` derive: `value` is a live forge credential, and a derived
|
||||
/// `Debug` is one `{:?}` away from a log line.
|
||||
|
|
@ -242,6 +272,44 @@ mod tests {
|
|||
assert!(!covered, "argus's policy must not reach atlas's token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hive_token_lands_under_the_hive_prefix() {
|
||||
// Spelled out: the controller's bao stanza names the same leaf.
|
||||
assert_eq!(
|
||||
hive_token_path("pr1ma").expect("a plain name is legal"),
|
||||
"swarm/hives/pr1ma/forge/token"
|
||||
);
|
||||
assert_eq!(hive_login("pr1ma"), "hive-pr1ma");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_traversal_in_the_hive_name_is_refused() {
|
||||
for bad in ["../beta", "a/b", "a.b", ""] {
|
||||
let e = hive_token_path(bad).expect_err("a traversal is not legal");
|
||||
assert!(matches!(e, Error::PathSegment { kind: "hive", .. }), "{e}");
|
||||
}
|
||||
// The control: the legal charset stays reachable.
|
||||
assert!(hive_token_path("a-b_C9").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hive_reaches_its_own_forge_token_and_no_other_hives() {
|
||||
let covered_by = |hive: &str, path: &str| {
|
||||
crate::policy::render(hive)
|
||||
.expect("legal")
|
||||
.lines()
|
||||
.any(|line| {
|
||||
line.strip_prefix("path \"")
|
||||
.and_then(|rest| rest.split_once("\" {"))
|
||||
.and_then(|(p, _)| p.strip_suffix('*'))
|
||||
.is_some_and(|prefix| format!("secret/data/{path}").starts_with(prefix))
|
||||
})
|
||||
};
|
||||
let alpha = hive_token_path("alpha").expect("legal");
|
||||
assert!(covered_by("alpha", &alpha), "alpha cannot read {alpha}");
|
||||
assert!(!covered_by("beta", &alpha), "beta reaches {alpha}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_value_field_matches_what_the_nix_reader_asks_for() {
|
||||
let json = serde_json::to_string(&Credential {
|
||||
|
|
|
|||
Loading…
Reference in a new issue