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.
257 lines
11 KiB
Nix
257 lines
11 KiB
Nix
# `checks.module-eval-agent-queue-bao` — see ./lib.nix for the shared
|
|
# rationale (why this suite exists, naming convention, "evaluates
|
|
# not executes").
|
|
{
|
|
pkgs,
|
|
lib,
|
|
self,
|
|
nixosSystem,
|
|
}:
|
|
let
|
|
inherit
|
|
(import ./lib.nix {
|
|
inherit
|
|
pkgs
|
|
lib
|
|
self
|
|
nixosSystem
|
|
;
|
|
})
|
|
agent
|
|
agentWith
|
|
runGroup
|
|
agentHarness
|
|
;
|
|
|
|
# The agent side of the swarm queue. Both coordinates set is the only state
|
|
# in which the harness unit declares a credential at all, so the pair and
|
|
# the empty fixture beside it are the two arms worth having.
|
|
agentQueue = agent {
|
|
queue.natsUrl = "nats://10.42.0.1:4222";
|
|
queue.tokenEndpoint = "https://auth.t.local/api/oidc/token";
|
|
};
|
|
|
|
agentNoQueue = agent { };
|
|
|
|
# The agent side of the swarm secret store. The address is the whole switch —
|
|
# it is both what generates the login check and what that check points at —
|
|
# so it and the empty fixture beside it are the two arms worth having.
|
|
agentBao = agentWith { services.hyperhive.agent.bao.addr = "https://bao.t.local:8200"; };
|
|
|
|
agentNoBao = agentWith { };
|
|
|
|
agentBaoIdentity = machine: machine.systemd.services.hive-agent-bao-identity;
|
|
agentQueueCredential = machine: machine.systemd.services.hive-agent-queue-credential;
|
|
cases = [
|
|
{
|
|
# Both ids or neither: the secret authenticates nobody without the id it
|
|
# belongs to, and the harness refuses to treat one of the two as a queue.
|
|
name = "an agent with queue coordinates imports both halves of its credential";
|
|
ok =
|
|
let
|
|
c = (agentHarness agentQueue).serviceConfig.LoadCredential;
|
|
in
|
|
builtins.elem "hive-queue-agent-secret" c && builtins.elem "hive-queue-agent-client-id" c;
|
|
}
|
|
{
|
|
# `%d` and not a path under the state dir: the host file is `0600`
|
|
# root-owned, so the only copy this unprivileged unit can open is the
|
|
# one systemd puts in its own credentials directory.
|
|
name = "the harness reads its queue credential out of the credentials directory";
|
|
ok =
|
|
let
|
|
e = (agentHarness agentQueue).environment;
|
|
in
|
|
e.HIVE_AGENT_OIDC_CLIENT_SECRET_FILE == "%d/hive-queue-agent-secret"
|
|
&& e.HIVE_AGENT_OIDC_CLIENT_ID_FILE == "%d/hive-queue-agent-client-id";
|
|
}
|
|
{
|
|
# An agent built before its hive was handed the queue's address. It
|
|
# must declare nothing rather than name a credential that never
|
|
# arrives — and the harness then reports "no queue coordinates"
|
|
# instead of a half-set environment.
|
|
name = "an agent with no queue coordinates declares no credential";
|
|
ok =
|
|
let
|
|
u = agentHarness agentNoQueue;
|
|
in
|
|
!(u.serviceConfig ? LoadCredential)
|
|
&& !(u.environment ? HIVE_AGENT_OIDC_CLIENT_SECRET_FILE)
|
|
&& !(u.environment ? HIVE_AGENT_OIDC_CLIENT_ID_FILE);
|
|
}
|
|
{
|
|
# The three ids `hive_c0re::lifecycle::agent_identity` forwards under.
|
|
# Neither end can discover the other's spelling, and a mismatch is a
|
|
# credential that is simply not there — which this unit then reports as
|
|
# a hive that delivered nothing.
|
|
name = "an agent with the store enabled imports every half of its identity";
|
|
ok =
|
|
let
|
|
c = (agentBaoIdentity agentBao).serviceConfig.LoadCredential;
|
|
in
|
|
builtins.elem "hive-agent-bao-cert" c
|
|
&& builtins.elem "hive-agent-bao-key" c
|
|
&& builtins.elem "hive-agent-bao-server-ca" c;
|
|
}
|
|
{
|
|
# `%d` and not a path under the agent's state dir, for the reason the
|
|
# queue arm above gives: the host file is `0600` to the hive daemon, so
|
|
# the only copy this unprivileged unit can open is the one systemd puts
|
|
# in its own credentials directory. The address is the option's value
|
|
# rather than a literal that agrees with it today.
|
|
name = "the identity check presents its certificate out of the credentials directory";
|
|
ok =
|
|
let
|
|
e = (agentBaoIdentity agentBao).environment;
|
|
in
|
|
e.BAO_CLIENT_CERT == "%d/hive-agent-bao-cert"
|
|
&& e.BAO_CLIENT_KEY == "%d/hive-agent-bao-key"
|
|
&& e.BAO_ADDR == agentBao.services.hyperhive.agent.bao.addr;
|
|
}
|
|
{
|
|
# Same 403-not-a-miss reason as the hive-side readers: the path
|
|
# `swarm_secret_client::mtls::identity_path` builds is the one this
|
|
# agent's own policy stanza covers, and a path outside it is refused
|
|
# however correct it looks. Built from the agent's own name rather than
|
|
# from a literal, because the name is what makes it this agent's path
|
|
# and not some other agent's.
|
|
name = "the identity check reads the agent's own path";
|
|
ok =
|
|
let
|
|
m = agentBao;
|
|
name = m.services.hyperhive.agent.user.name;
|
|
in
|
|
lib.hasInfix "secret/swarm/agents/${name}/bao-mtls" (agentBaoIdentity m).script;
|
|
}
|
|
{
|
|
# The whole point of the unit, and the thing a quieter default would
|
|
# undo: every arm of the check ends the unit non-zero, so an agent that
|
|
# cannot authenticate as itself says so at boot instead of at whichever
|
|
# pull needed the store first.
|
|
name = "the identity check fails the unit rather than degrading";
|
|
ok =
|
|
let
|
|
u = agentBaoIdentity agentBao;
|
|
in
|
|
lib.hasInfix "exit 1" u.script
|
|
&& !(lib.hasInfix "exit 0" u.script)
|
|
&& u.serviceConfig.Restart == "on-failure";
|
|
}
|
|
{
|
|
# Nothing about the identity may be printed, and the read-back is where
|
|
# that could slip: `bao kv get` on this path answers with certificate
|
|
# material, and the object beside it is a private key. The check needs
|
|
# only whether the read succeeded.
|
|
#
|
|
# The path goes through `lib.escapeShellArg` here for the same reason the
|
|
# module passes it through one — that helper decides whether an argument
|
|
# needs quotes at all, and this one (only `[a-z0-9/-]`) comes back bare.
|
|
# Spelling the quotes in by hand asserts a rendering nixpkgs chooses
|
|
# rather than the redirect this property is about.
|
|
name = "the identity check discards what it reads back";
|
|
ok =
|
|
let
|
|
m = agentBao;
|
|
name = m.services.hyperhive.agent.user.name;
|
|
arg = lib.escapeShellArg "secret/swarm/agents/${name}/bao-mtls";
|
|
in
|
|
lib.hasInfix "bao kv get -field=cert ${arg} >/dev/null" (agentBaoIdentity m).script;
|
|
}
|
|
{
|
|
# The absence arm, and what makes the four above able to fail. An agent
|
|
# whose swarm never minted an identity has nothing to log in with, and a
|
|
# failed unit at every boot would be the loudest possible statement
|
|
# about a deployment that never asked for one.
|
|
name = "an agent told no store address runs no identity check";
|
|
ok = !(agentNoBao.systemd.services ? hive-agent-bao-identity);
|
|
}
|
|
{
|
|
# The per-agent credential's fetch rides on the same store identity the
|
|
# check above proves, because there is one identity per agent. A fetch
|
|
# unit loading different ids would be a second certificate nothing
|
|
# mints.
|
|
name = "the queue-credential fetch presents the agent's own store identity";
|
|
ok =
|
|
let
|
|
u = agentQueueCredential agentBao;
|
|
in
|
|
builtins.elem "hive-agent-bao-cert" u.serviceConfig.LoadCredential
|
|
&& u.environment.BAO_CLIENT_CERT == "%d/hive-agent-bao-cert"
|
|
&& u.environment.BAO_CLIENT_KEY == "%d/hive-agent-bao-key";
|
|
}
|
|
{
|
|
# Same 403-not-a-miss reason as every other reader here: the path
|
|
# `swarm_secret_client::queue::agent_queue_path` builds is the one this
|
|
# agent's own policy stanza covers. Built from the agent's own name,
|
|
# because the name is what makes it this agent's credential and not a
|
|
# neighbour's — which is the entire point of minting one per agent.
|
|
name = "the queue-credential fetch reads the agent's own per-agent path";
|
|
ok =
|
|
let
|
|
m = agentBao;
|
|
name = m.services.hyperhive.agent.user.name;
|
|
in
|
|
lib.hasInfix "secret/swarm/agents/${name}/queue" (agentQueueCredential m).script;
|
|
}
|
|
{
|
|
# ⛔ The store certificate authenticates against the store and nothing
|
|
# else. It must never reach the queue, so nothing in this unit may hand
|
|
# a `BAO_CLIENT_*` path to anything queue-shaped.
|
|
name = "the queue-credential fetch never points the queue at the store certificate";
|
|
ok =
|
|
let
|
|
u = agentQueueCredential agentBao;
|
|
harness = agentHarness agentBao;
|
|
in
|
|
!(lib.hasInfix "nats" u.script)
|
|
&& !(lib.any (lib.hasPrefix "BAO_") (builtins.attrNames harness.environment));
|
|
}
|
|
{
|
|
# A secret fetched at boot has no business surviving one, and the
|
|
# directory has to be the unit's own so the file is owned by the agent
|
|
# rather than needing a mode change. `RemainAfterExit` is what keeps
|
|
# systemd from removing it out from under the harness.
|
|
name = "the fetched credential lands in a runtime directory the unit keeps alive";
|
|
ok =
|
|
let
|
|
u = agentQueueCredential agentBao;
|
|
c = u.serviceConfig;
|
|
in
|
|
c.RuntimeDirectory == "hive-agent-queue-credential"
|
|
&& c.RemainAfterExit
|
|
&& lib.hasInfix "/run/hive-agent-queue-credential/secret" u.script;
|
|
}
|
|
{
|
|
# 🩸 Degrades where the identity check fails, and the reason is that
|
|
# both reads are governed by one policy stanza: a refusal this unit
|
|
# sees and that check did not is an object not yet minted, not a policy
|
|
# that drifted. An agent created before its swarm minted one is a
|
|
# deployment doing nothing wrong.
|
|
name = "a per-agent credential that was never minted does not fail the unit";
|
|
ok = lib.hasInfix "exit 0" (agentQueueCredential agentBao).script;
|
|
}
|
|
{
|
|
# The harness reads a path and never a value — the same discipline
|
|
# ../agent-modules/queue.nix keeps for the hive-scoped secret. Not
|
|
# `%d`: this one is not a systemd credential, it is a file the
|
|
# container fetched for itself.
|
|
name = "the harness is handed the fetched credential as a path";
|
|
ok =
|
|
let
|
|
m = agentBao;
|
|
in
|
|
(agentHarness m).environment.HIVE_AGENT_QUEUE_AGENT_SECRET_FILE
|
|
== m.services.hyperhive.agent.queue.agentSecretFile;
|
|
}
|
|
{
|
|
# The absence arm. An agent whose swarm gave it no store has nothing to
|
|
# log in with, so there is nothing to fetch with either — and the
|
|
# harness is then told no path rather than one that never fills.
|
|
name = "an agent told no store address fetches no queue credential";
|
|
ok =
|
|
!(agentNoBao.systemd.services ? hive-agent-queue-credential)
|
|
&& !((agentHarness agentNoBao).environment ? HIVE_AGENT_QUEUE_AGENT_SECRET_FILE);
|
|
}
|
|
];
|
|
in
|
|
runGroup "agent-queue-bao" cases
|