swarm: mint a per-agent queue credential beside the agent's store identity
Every agent on a hive authenticates to the swarm queue with the same hive-scoped secret today, so at the auth callout one agent is indistinguishable from its co-hived neighbours and no subject can be scoped to one of them. Mint a secret per agent instead, at swarm level, into secret/swarm/agents/<agent>/queue -- inside the stanza every agent's ACL document already grants, so no policy changes and no existing agent's document is rewritten. It is written by the same node that already mints the agent's certificate, and read back under the agent's own token before that node reports success. The secret is not derived from the agent's mTLS identity: the two credentials answer different questions and coupling their lifetimes would mean renewing either implied renewing the other. Nothing here rotates a queue secret -- a re-run keeps the existing value and only corrects the principal it names, because this function is re-run deliberately against agents that are already connected. Revoking one means deleting the path. Nothing reads the new credential yet; this is the minting half.
This commit is contained in:
parent
11097ed336
commit
ffd5018b18
6 changed files with 359 additions and 19 deletions
|
|
@ -174,6 +174,31 @@ impl SecretStore {
|
|||
Ok(vaultrs::kv2::read(&self.inner, MOUNT, path).await?)
|
||||
}
|
||||
|
||||
/// Read the object stored at `path`, or `None` when nothing is stored
|
||||
/// there.
|
||||
///
|
||||
/// The verb for a caller whose write must be a no-op if the path is
|
||||
/// already populated. [`read`][Self::read] cannot serve that: it collapses
|
||||
/// *absent*, *denied* and *undecodable* into one `Err`, so a caller
|
||||
/// treating every failure as absence would overwrite a live credential
|
||||
/// whenever the store was merely unreachable.
|
||||
///
|
||||
/// **Only a 404 is absence.** A denial is a 403 and stays an error — a
|
||||
/// principal whose grant does not cover the path must not conclude the
|
||||
/// path is empty, which is how a write ends up clobbering something the
|
||||
/// caller was never allowed to see.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::Vault`] for anything that is not a 404: a denial, an
|
||||
/// unreachable store, or an object that does not decode as `T`.
|
||||
pub async fn read_optional<T: DeserializeOwned>(&self, path: &str) -> Result<Option<T>, Error> {
|
||||
match vaultrs::kv2::read(&self.inner, MOUNT, path).await {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(vaultrs::error::ClientError::APIError { code: 404, .. }) => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `value` at `path`, creating a new version.
|
||||
///
|
||||
/// # Errors
|
||||
|
|
|
|||
|
|
@ -1,11 +1,27 @@
|
|||
//! The queue agreement: where a hive's agent-container credential lives in the
|
||||
//! store, and what the object at that path holds.
|
||||
//! The queue agreements: where a queue credential lives in the store, and what
|
||||
//! the object at that path holds.
|
||||
//!
|
||||
//! The sibling of [`crate::matrix`], and it differs from it in one way worth
|
||||
//! reading before using either: a matrix credential is keyed per **agent**,
|
||||
//! this one per **hive**. Agents are created at runtime, so the queue
|
||||
//! identity they present is minted once per hive at deploy time and says which
|
||||
//! hive an agent belongs to, never which agent.
|
||||
//! **Two kinds live here, and which is which is the thing to get right.**
|
||||
//!
|
||||
//! - [`agent_client_path`] / [`Credential`] are keyed per **hive**: one OIDC
|
||||
//! client minted per hive at deploy time, shared by every agent container on
|
||||
//! it. It says which hive a caller belongs to, never which agent.
|
||||
//! - [`agent_queue_path`] / [`AgentCredential`] are keyed per **agent**: a
|
||||
//! secret the swarm mints for one agent, at swarm level, with no hive in the
|
||||
//! chain. It is what makes one agent distinguishable from its co-hived
|
||||
//! neighbours.
|
||||
//!
|
||||
//! They are separate objects rather than one with a nullable field because they
|
||||
//! are minted by different principals on different events — the hive-scoped one
|
||||
//! by authelia at deploy time, the per-agent one by `swarm-controller` when an
|
||||
//! agent is created — and an object whose shape depends on who wrote it is a
|
||||
//! reader that has to guess.
|
||||
//!
|
||||
//! The per-agent secret is deliberately **not** derived from the agent's mTLS
|
||||
//! identity (the leaf at [`crate::mtls::identity_path`]). That leaf is for
|
||||
//! reaching the store and nothing else; deriving a queue identity from it would
|
||||
//! couple the two credentials' lifetimes, so that renewing one would mean
|
||||
//! renewing the other.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
|
@ -25,6 +41,48 @@ pub fn agent_client_path(hive: &str) -> Result<String, Error> {
|
|||
Ok(format!("{prefix}/queue/agent"))
|
||||
}
|
||||
|
||||
/// The path holding the secret **one agent** presents to the swarm queue.
|
||||
///
|
||||
/// Under the agent's own principal prefix, and that is the whole reason for
|
||||
/// this spelling rather than a new top-level one: every agent's ACL document
|
||||
/// already grants read on `swarm/agents/<agent>/*`
|
||||
/// ([`crate::policy::render_agent`]), so a credential here needs no new grant,
|
||||
/// no policy re-render, and no rewrite of any existing agent's document.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::PathSegment`] when `agent` contains anything but `[A-Za-z0-9_-]`,
|
||||
/// which is what keeps one agent's name from addressing another agent's secret.
|
||||
pub fn agent_queue_path(agent: &str) -> Result<String, Error> {
|
||||
let prefix = principal_prefix(Kind::Agent, agent)?;
|
||||
Ok(format!("{prefix}/queue"))
|
||||
}
|
||||
|
||||
/// What [`agent_queue_path`] holds: the secret, and the principal it proves.
|
||||
///
|
||||
/// Both names ride **in the object** rather than being parsed back out of a
|
||||
/// composite principal string. Hive and agent names draw from the same
|
||||
/// alphabet (`hive_types::Ident`, `[a-z0-9-]`), so a principal spelled
|
||||
/// `hive-<hive>-agent-<agent>` parses two ways for a name containing `-agent-`
|
||||
/// — and an ambiguous principal parse in an authorisation path is a caller that
|
||||
/// authenticates fine and is handed somebody else's grant.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AgentCredential {
|
||||
/// The secret itself. Named to match [`Credential::value`] and
|
||||
/// [`crate::matrix::Credential::value`] so a nix-side reader spells
|
||||
/// `bao kv get -field=value` for every kind.
|
||||
pub value: String,
|
||||
|
||||
/// The agent this secret authenticates.
|
||||
pub agent: String,
|
||||
|
||||
/// The hive that agent belongs to.
|
||||
///
|
||||
/// Here because the verifying end has no roster to look it up in, and
|
||||
/// because the subjects an agent is granted are hive-templated — without
|
||||
/// this field the verifier would know *who* is connecting and not *where*.
|
||||
pub hive: String,
|
||||
}
|
||||
|
||||
/// What the path holds: the client secret, plus the client id it belongs to.
|
||||
///
|
||||
/// The id rides with the secret for the same reason the homeserver rides with
|
||||
|
|
@ -97,4 +155,108 @@ mod tests {
|
|||
assert_eq!(json["value"], "s3cr3t");
|
||||
assert_eq!(json["client_id"], "hive-alpha-agent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agent_name_lands_under_its_own_principal_prefix() {
|
||||
assert_eq!(
|
||||
agent_queue_path("atlas").expect("a plain name is legal"),
|
||||
"swarm/agents/atlas/queue"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_traversal_in_the_agent_name_is_refused() {
|
||||
let e = agent_queue_path("../beta").expect_err("a traversal is not");
|
||||
assert!(matches!(e, Error::PathSegment { kind: "agent", .. }), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_agents_never_share_a_path() {
|
||||
assert_ne!(
|
||||
agent_queue_path("atlas").expect("legal"),
|
||||
agent_queue_path("argus").expect("legal")
|
||||
);
|
||||
}
|
||||
|
||||
/// The reason this path was chosen over a new top-level prefix: it is
|
||||
/// already inside the stanza every agent's own ACL document grants, so no
|
||||
/// policy has to change for the credential to be readable by the one agent
|
||||
/// it belongs to.
|
||||
#[test]
|
||||
fn the_agents_existing_policy_already_covers_its_queue_path() {
|
||||
let document = crate::policy::render_agent("atlas").expect("legal");
|
||||
let path = agent_queue_path("atlas").expect("legal");
|
||||
let stanza = path
|
||||
.rsplit_once('/')
|
||||
.map(|(prefix, _)| format!("secret/data/{prefix}/*"))
|
||||
.expect("the path has a parent");
|
||||
assert!(document.contains(&stanza), "{document}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_agent_object_round_trips_through_the_store_representation() {
|
||||
let c = AgentCredential {
|
||||
value: "s3cr3t".to_owned(),
|
||||
agent: "atlas".to_owned(),
|
||||
hive: "alpha".to_owned(),
|
||||
};
|
||||
let json = serde_json::to_string(&c).expect("serialises");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<AgentCredential>(&json).expect("deserialises"),
|
||||
c
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_agent_objects_field_names_are_the_ones_a_nix_reader_asks_for() {
|
||||
let json = serde_json::to_value(AgentCredential {
|
||||
value: "s3cr3t".to_owned(),
|
||||
agent: "atlas".to_owned(),
|
||||
hive: "alpha".to_owned(),
|
||||
})
|
||||
.expect("serialises");
|
||||
assert_eq!(json["value"], "s3cr3t");
|
||||
assert_eq!(json["agent"], "atlas");
|
||||
assert_eq!(json["hive"], "alpha");
|
||||
}
|
||||
|
||||
/// Neither name is optional. An object missing one is not a usable
|
||||
/// credential — a verifier holding `None` for the hive can only guess at
|
||||
/// the subjects to grant, and guessing is the failure this shape exists to
|
||||
/// prevent.
|
||||
#[test]
|
||||
fn an_agent_object_missing_a_principal_does_not_decode() {
|
||||
assert!(
|
||||
serde_json::from_str::<AgentCredential>(r#"{"value":"s","agent":"atlas"}"#).is_err()
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_str::<AgentCredential>(r#"{"value":"s","hive":"alpha"}"#).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
/// The two kinds are different objects at different paths, and neither
|
||||
/// decodes as the other — the property that keeps a reader from picking up
|
||||
/// a hive-shared credential where a per-agent one was meant.
|
||||
#[test]
|
||||
fn the_hive_credential_and_the_agent_credential_are_not_interchangeable() {
|
||||
let hive_json = serde_json::to_string(&Credential {
|
||||
value: "s".to_owned(),
|
||||
client_id: "hive-alpha-agent".to_owned(),
|
||||
})
|
||||
.expect("serialises");
|
||||
assert!(serde_json::from_str::<AgentCredential>(&hive_json).is_err());
|
||||
|
||||
let agent_json = serde_json::to_string(&AgentCredential {
|
||||
value: "s".to_owned(),
|
||||
agent: "atlas".to_owned(),
|
||||
hive: "alpha".to_owned(),
|
||||
})
|
||||
.expect("serialises");
|
||||
assert!(serde_json::from_str::<Credential>(&agent_json).is_err());
|
||||
|
||||
assert_ne!(
|
||||
agent_queue_path("atlas").expect("legal"),
|
||||
agent_client_path("atlas").expect("legal"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue