agent: fetch this agent's own swarm-queue credential from the store

Every agent on a hive authenticates to the swarm queue with the same
hive-scoped OIDC client, so at the auth callout one agent is
indistinguishable from its co-hived neighbours. The commit before this
one mints a secret per agent at swarm level into
secret/swarm/agents/<agent>/queue; nothing read it.

Read it here, and read it from the container itself. A hive courier in
the path would be the hive vouching for which agent this is, which is
the property a per-agent credential exists to remove -- so the agent
logs in to the store with the certificate hive-agent-bao-identity
already proves it can log in with, and reads its own path. The store
certificate is for reaching the store and nothing else: what the new
unit writes to /run is the secret it read back, and nothing hands a
BAO_CLIENT_* path to anything queue-shaped.

The read needs no policy change. render_agent grants read on
secret/data/swarm/agents/<agent>/*, which covers this path and the
bao-mtls one beside it alike -- which is also why this unit degrades
where the identity check fails. A refusal this unit sees and that check
did not cannot be a policy that drifted; it is an object not yet minted,
the ordinary state of every agent created before its swarm knew to mint
one.

The harness resolves the path and reports which credential this agent
can present. It does not yet present it: the auth-callout responder
still verifies only the hive-scoped token, and an agent offering a
credential nothing on the other end reads back would simply be refused.
Teaching swarm-nats-auth to read the same path is the next slice.
This commit is contained in:
atlas 2026-09-21 20:06:09 +02:00 committed by mara
commit afdfce67ec
4 changed files with 416 additions and 2 deletions

View file

@ -13,8 +13,21 @@
//! has already spawned threads, and this one has. So the rule is restated
//! here over the inputs this consumer actually has, and [`decide`] is the
//! single place it lives.
//!
//! Beside all four sits a fifth coordinate, resolved by
//! [`decide_agent_secret`] and not part of their group. The four are the
//! *hive's* — one OIDC client shared by every container on it — so at the
//! queue's auth callout they say which hive is connecting and never which
//! agent. The fifth is this agent's own, minted per agent at swarm level and
//! fetched by the container itself (`nix/agent-modules/queue-identity.nix`).
//!
//! It is reported here but not yet *presented*: the queue's auth-callout
//! responder (`swarm-nats-auth`) validates only the hive-scoped token, and an
//! agent offering a credential nothing on the other end reads back would be
//! refused. Until that responder learns the same path, the connect path below
//! is unchanged and this is the fetching half.
use std::path::Path;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use tokio::sync::OnceCell;
@ -49,6 +62,13 @@ struct QueueEnv {
/// `hive_c0re::meta` embeds at build time — so it is here for a
/// deployment that needs a different one, not for ours.
ca_file: Option<String>,
/// Where `nix/agent-modules/queue-identity.nix` fetched this agent's own
/// per-agent secret to. Outside the all-or-none group above because it is
/// governed by a different switch entirely — that unit is generated by
/// the agent having a *store* address, not by its hive having queue
/// coordinates — so an agent can legally have this and none of the four,
/// or the four and not this.
agent_secret_file: Option<String>,
}
impl QueueEnv {
@ -60,6 +80,7 @@ impl QueueEnv {
client_id_file: var("OIDC_CLIENT_ID_FILE"),
client_secret_file: var("OIDC_CLIENT_SECRET_FILE"),
ca_file: var("OIDC_CA_FILE"),
agent_secret_file: var("QUEUE_AGENT_SECRET_FILE"),
}
}
}
@ -96,6 +117,36 @@ fn read_client_id(path: &Path) -> Option<String> {
}
}
/// Decide whether this agent has its own per-agent queue secret, given the
/// path the fetch unit was told to write it to.
///
/// Three states collapse to two answers. No variable means no store address
/// for this container, so no fetch unit was generated at all. A variable
/// naming a file that is missing or empty means the unit ran and found
/// nothing minted — the ordinary state of an agent created before its swarm
/// knew to mint one, which that unit reports and survives. Only a non-empty
/// file is a credential.
///
/// The file is not read. Its *contents* are the secret and belong nowhere but
/// the moment of use; what a caller needs from here is whether there is one
/// and where, which `metadata` answers without opening it.
fn decide_agent_secret(path: Option<&str>) -> Option<PathBuf> {
let path = PathBuf::from(path?);
match std::fs::metadata(&path) {
Ok(m) if m.len() > 0 => Some(path),
Ok(_) => None,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"checking for this agent's own queue credential failed"
);
None
}
}
}
/// Decide what this agent's queue configuration is, given the environment and
/// whatever the client-id file held.
///
@ -163,6 +214,28 @@ pub fn init() {
}
};
let _ = CONFIG.set(resolved);
// Independent of everything above: this agent may hold its own secret on
// a hive with no queue coordinates, or hold the coordinates and no secret
// of its own yet. Reported either way, because "which credential is this
// agent able to present" is a question only this process can answer, and
// it is the one the next slice's rollout will be asked repeatedly.
//
// The answer is only logged here. Presenting it needs the queue's
// auth-callout responder to verify it, which is the next slice — see this
// module's header.
if let Some(path) = decide_agent_secret(env.agent_secret_file.as_deref()) {
// The path, never the bytes: the file holds the secret itself.
tracing::info!(
path = %path.display(),
"this agent has its own swarm queue credential"
);
} else {
tracing::info!(
"no per-agent swarm queue credential; this agent is known to the queue \
by its hive's shared client"
);
}
}
/// What [`init`] resolved, or `None` when this agent has no queue.
@ -213,7 +286,7 @@ async fn connect_once() -> Option<async_nats::Client> {
#[cfg(test)]
mod tests {
use super::{QueueEnv, Resolution, decide, read_client_id};
use super::{QueueEnv, Resolution, decide, decide_agent_secret, read_client_id};
fn env(parts: [Option<&str>; 4]) -> QueueEnv {
let [nats_url, token_endpoint, client_id_file, client_secret_file] = parts;
@ -223,6 +296,7 @@ mod tests {
client_id_file: client_id_file.map(str::to_owned),
client_secret_file: client_secret_file.map(str::to_owned),
ca_file: None,
agent_secret_file: None,
}
}
@ -313,4 +387,52 @@ mod tests {
);
}
}
/// The whole point of the fetch: a non-empty file is this agent's own
/// credential, and the answer is the path rather than what is in it.
#[test]
fn a_fetched_secret_resolves_to_its_path() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("secret");
std::fs::write(&path, "s3cr3t").expect("write");
assert_eq!(
decide_agent_secret(path.to_str()).as_deref(),
Some(path.as_path())
);
}
/// The rollout state, and the one this must not confuse with a
/// credential: the fetch unit ran, found nothing minted for this agent,
/// and left no file. Treating that as a secret would have the harness
/// present zero bytes to the queue.
#[test]
fn a_missing_or_empty_fetched_secret_is_no_credential() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("secret");
assert_eq!(decide_agent_secret(path.to_str()), None);
std::fs::write(&path, "").expect("write");
assert_eq!(decide_agent_secret(path.to_str()), None);
}
/// No variable at all: this container was given no store address, so no
/// fetch unit exists to have written anything.
#[test]
fn no_fetch_path_is_no_credential() {
assert_eq!(decide_agent_secret(None), None);
}
/// The two credentials are resolved by separate switches, and this is the
/// asymmetry that makes keeping them apart worth it: an agent whose hive
/// has no queue can still hold its own swarm-minted secret, because that
/// one is minted with no hive in the chain.
#[test]
fn the_per_agent_secret_is_independent_of_the_hive_coordinates() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("secret");
std::fs::write(&path, "s3cr3t").expect("write");
let e = env([None, None, None, None]);
assert!(matches!(decide(&e, None), Resolution::Absent(_)));
assert!(decide_agent_secret(path.to_str()).is_some());
}
}