Watch
0
0
Fork
You've already forked hyperhive
0

swarm-nats: require the responder's store identity, and drop its no-store mode

The responder reads the callout xkey before it connects, so a responder
with no secret store no longer starts, and its "deny every agent token,
serve OIDC" mode could not run. That mode is gone: `agent_token::Store`
is built from the settings the xkey load already read, and `authorize`
takes a source rather than an `Option` of one.

On the nix side, `deploy.nats.authBaoClientCertFile` and
`authBaoClientKeyFile` are now asserted at eval when the queue is
enabled. The units' no-identity branches, the copy unit's empty-file
fallback and its "denies agent tokens" message are removed; an absent
leaf file fails `swarm-nats-auth-secrets` like an absent seed does.
This commit is contained in:
atlas 2026-10-05 22:13:58 +02:00 • committed by mara
commit 52c931c3a7
5 changed files with 193 additions and 248 deletions

View file

@ -1,8 +1,8 @@
# Glue: point the queue's auth-callout responder at the bao leaf minted for it. # Glue: point the queue's auth-callout responder at the bao leaf minted for it.
# #
# ONE PAIRING PER FILE — the swarm-nats-auth principal ← bao, and nothing else. # ONE PAIRING PER FILE — the swarm-nats-auth principal ← bao, and nothing else.
# Deleting this leaves a responder with no store identity unless the operator # Deleting this leaves a queue with no responder identity unless the operator
# names one: every agent token is then denied, and OIDC clients are unaffected. # names one, which ./swarm-nats.nix refuses at eval.
# #
# ⚠️ The minting is NOT here. ./glue-bao-tls.nix holds the CA and signs the # ⚠️ The minting is NOT here. ./glue-bao-tls.nix holds the CA and signs the
# leaf. What belongs here is the pairing: which paths the responder presents. # leaf. What belongs here is the pairing: which paths the responder presents.

View file

@ -297,23 +297,21 @@ let
tlsKeyCredential = "tls-key"; tlsKeyCredential = "tls-key";
tlsKeyCredentialPath = "/run/credentials/nats.service/${tlsKeyCredential}"; tlsKeyCredentialPath = "/run/credentials/nats.service/${tlsKeyCredential}";
# The responder's own store identity, for reading agent queue credentials. # The responder's own store identity, for the callout xkey, agent queue
# Without it the responder denies every agent token; agents then fall back to # credentials and its OIDC client secret. Required: an assertion below refuses
# their hive's OIDC client. # a queue without it.
authStoreActive = authStoreActive =
deployCfg.nats.authBaoClientCertFile != null && deployCfg.nats.authBaoClientKeyFile != null; deployCfg.nats.authBaoClientCertFile != null && deployCfg.nats.authBaoClientKeyFile != null;
# The role ./swarm-bao.nix writes on the store's `cert` mount, by the same name. # The role ./swarm-bao.nix writes on the store's `cert` mount, by the same name.
authCertRole = "swarm-nats-auth"; authCertRole = "swarm-nats-auth";
# Store identity files the copy unit delivers, as credential id → host source. # Store identity files the copy unit delivers, as credential id → host source.
authStoreFiles = lib.optionalAttrs authStoreActive ( authStoreFiles = {
{ "bao-client.pem" = deployCfg.nats.authBaoClientCertFile;
"bao-client.pem" = deployCfg.nats.authBaoClientCertFile; "bao-client-key.pem" = deployCfg.nats.authBaoClientKeyFile;
"bao-client-key.pem" = deployCfg.nats.authBaoClientKeyFile; }
} // lib.optionalAttrs (baoDeploy.serverCaFile != null) {
// lib.optionalAttrs (baoDeploy.serverCaFile != null) { "bao-ca.pem" = baoDeploy.serverCaFile;
"bao-ca.pem" = baoDeploy.serverCaFile; };
}
);
authCredential = id: "/run/credentials/swarm-nats-auth.service/${id}"; authCredential = id: "/run/credentials/swarm-nats-auth.service/${id}";
atomicWriteSecret = import ./lib/atomic-write-secret.nix { }; atomicWriteSecret = import ./lib/atomic-write-secret.nix { };
@ -516,17 +514,19 @@ in
example = "/var/lib/swarm-bao-pki/nats-auth.pem"; example = "/var/lib/swarm-bao-pki/nats-auth.pem";
description = '' description = ''
Client certificate the auth-callout responder presents to the swarm's Client certificate the auth-callout responder presents to the swarm's
secret store to read agent queue credentials, and that secret store to read the callout xkey and agent queue credentials, and
`swarm-bao-nats-auth-oidc` presents to read the responder's own OIDC that `swarm-bao-nats-auth-oidc` and `swarm-bao-nats-callout-xkey`
client secret. Its subject must be present to read the responder's own OIDC client secret and the xkey's
public half. Its subject must be
{option}`services.hyperhive.deploy.bao.natsAuthCommonName`. {option}`services.hyperhive.deploy.bao.natsAuthCommonName`.
No default. ./glue-nats-auth-bao-identity.nix points it at the leaf Required when `deploy.nats.enable` is set; unset fails at eval.
./glue-nats-auth-bao-identity.nix points it at the leaf
./glue-bao-tls.nix mints, where this host mints one; on any other ./glue-bao-tls.nix mints, where this host mints one; on any other
queue host it is a file the operator places. Unset, or set to a file queue host it is a file the operator places. Set to a file that does
that does not exist, `swarm-bao-nats-auth-oidc` fails; a responder not exist, `swarm-nats-auth-secrets`, `swarm-bao-nats-auth-oidc` and
with no OIDC client secret on disk cannot start, and the queue then `swarm-bao-nats-callout-xkey` fail, and the queue's server does not
refuses every client. start without the xkey.
A path, never a value. A path, never a value.
''; '';
@ -620,6 +620,24 @@ in
to have this host mint all four. to have this host mint all four.
''; '';
} }
{
# The callout xkey and the responder's OIDC client secret are read
# from the store under this identity; without either the server or
# the responder does not start.
assertion = authStoreActive;
message = ''
services.hyperhive.deploy.nats.authBaoClientCertFile and
authBaoClientKeyFile must both be set.
The queue's callout xkey and its responder's OIDC client secret
are read from the swarm's secret store with this certificate, and
neither the server nor the responder starts without them. They
default to the leaf the store mints when it runs on this host;
elsewhere, issue a leaf whose CN is
services.hyperhive.deploy.bao.natsAuthCommonName and name it in
both options.
'';
}
]; ];
# Declared here rather than in the collector's module: an entry then # Declared here rather than in the collector's module: an entry then
@ -929,18 +947,16 @@ in
RestartSec = "5s"; RestartSec = "5s";
SyslogIdentifier = "swarm-nats-auth"; SyslogIdentifier = "swarm-nats-auth";
}; };
# The store the responder reads agent credentials from. Absent # The store the responder reads the callout xkey and agent
# without an identity, which the responder reads as "no store". # credentials from.
environment = lib.optionalAttrs authStoreActive ( environment = {
{ BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}";
BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}"; BAO_CLIENT_CERT = authCredential "bao-client.pem";
BAO_CLIENT_CERT = authCredential "bao-client.pem"; BAO_CLIENT_KEY = authCredential "bao-client-key.pem";
BAO_CLIENT_KEY = authCredential "bao-client-key.pem"; }
} // lib.optionalAttrs (authStoreFiles ? "bao-ca.pem") {
// lib.optionalAttrs (authStoreFiles ? "bao-ca.pem") { BAO_CACERT = authCredential "bao-ca.pem";
BAO_CACERT = authCredential "bao-ca.pem"; };
}
);
}; };
# The server binary, so an operator with a shell in here can # The server binary, so an operator with a shell in here can
@ -973,7 +989,7 @@ in
lib.optional deployCfg.nats.autoGenerateCallout "swarm-nats-callout-keys.service" lib.optional deployCfg.nats.autoGenerateCallout "swarm-nats-callout-keys.service"
# Where the store's PKI is minted on this host, the responder's leaf is # Where the store's PKI is minted on this host, the responder's leaf is
# one of its files. Ordering only: elsewhere the unit does not exist. # one of its files. Ordering only: elsewhere the unit does not exist.
++ lib.optional authStoreActive "swarm-bao-pki.service"; ++ [ "swarm-bao-pki.service" ];
requires = lib.optional deployCfg.nats.autoGenerateCallout "swarm-nats-callout-keys.service"; requires = lib.optional deployCfg.nats.autoGenerateCallout "swarm-nats-callout-keys.service";
serviceConfig = { serviceConfig = {
Type = "oneshot"; Type = "oneshot";
@ -989,17 +1005,9 @@ in
install -m 0400 ${lib.escapeShellArg issuerSeedFile} \ install -m 0400 ${lib.escapeShellArg issuerSeedFile} \
${lib.escapeShellArg (hostPath "issuer.seed")} ${lib.escapeShellArg (hostPath "issuer.seed")}
'' ''
# The store identity is optional to the responder's agent-token path, so
# an absent source is delivered as an empty file rather than failing this
# unit: a missing `LoadCredential` source stops the responder starting.
+ lib.concatStrings ( + lib.concatStrings (
lib.mapAttrsToList (id: source: '' lib.mapAttrsToList (id: source: ''
if [ -s ${lib.escapeShellArg source} ]; then install -m 0400 ${lib.escapeShellArg source} ${lib.escapeShellArg (hostPath id)}
install -m 0400 ${lib.escapeShellArg source} ${lib.escapeShellArg (hostPath id)}
else
echo "${source} is absent; the queue responder denies agent tokens until it exists" >&2
install -m 0400 /dev/null ${lib.escapeShellArg (hostPath id)}
fi
'') authStoreFiles '') authStoreFiles
); );
}; };
@ -1013,8 +1021,7 @@ in
# #
# ⚠️ Without the secret the responder does not start (its `LoadCredential` # ⚠️ Without the secret the responder does not start (its `LoadCredential`
# source is missing), and `auth_callout` with no responder refuses every # source is missing), and `auth_callout` with no responder refuses every
# client. A host with no responder leaf therefore fails here, naming the # client.
# options, rather than rendering no unit.
systemd.services.swarm-bao-nats-auth-oidc = { systemd.services.swarm-bao-nats-auth-oidc = {
description = "fetch the swarm queue responder's OIDC client secret from the swarm secret store"; description = "fetch the swarm queue responder's OIDC client secret from the swarm secret store";
# The store's units exist only where the store runs; `Requires=` on an # The store's units exist only where the store runs; `Requires=` on an
@ -1050,70 +1057,61 @@ in
}; };
environment = { environment = {
BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}"; BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}";
}
// lib.optionalAttrs authStoreActive {
BAO_CLIENT_CERT = deployCfg.nats.authBaoClientCertFile; BAO_CLIENT_CERT = deployCfg.nats.authBaoClientCertFile;
BAO_CLIENT_KEY = deployCfg.nats.authBaoClientKeyFile; BAO_CLIENT_KEY = deployCfg.nats.authBaoClientKeyFile;
} }
// lib.optionalAttrs (baoDeploy.serverCaFile != null) { // lib.optionalAttrs (baoDeploy.serverCaFile != null) {
BAO_CACERT = baoDeploy.serverCaFile; BAO_CACERT = baoDeploy.serverCaFile;
}; };
script = script = ''
if !authStoreActive then set -euo pipefail
''
echo "the swarm queue responder has no store identity, so it cannot read its OIDC client secret: set services.hyperhive.deploy.nats.authBaoClientCertFile and authBaoClientKeyFile" >&2
exit 1
''
else
''
set -euo pipefail
${atomicWriteSecret} ${atomicWriteSecret}
${refreshConsumer} ${refreshConsumer}
err="$(mktemp)" err="$(mktemp)"
trap 'rm -f "$err"' EXIT trap 'rm -f "$err"' EXIT
# `-token-only` answers on stdout and skips the token helper, a # `-token-only` answers on stdout and skips the token helper, a
# `sh` this unit's `path` does not carry. # `sh` this unit's `path` does not carry.
if ! BAO_TOKEN="$(bao login -method=cert -token-only 2>"$err")"; then if ! BAO_TOKEN="$(bao login -method=cert -token-only 2>"$err")"; then
echo "could not log in to swarm-bao with the queue responder's certificate; leaving its OIDC client secret as it is." >&2 echo "could not log in to swarm-bao with the queue responder's certificate; leaving its OIDC client secret as it is." >&2
if [ -s "$err" ]; then if [ -s "$err" ]; then
cat "$err" >&2 cat "$err" >&2
else else
echo "bao failed without writing a diagnostic." >&2 echo "bao failed without writing a diagnostic." >&2
fi fi
exit 1 exit 1
fi fi
export BAO_TOKEN export BAO_TOKEN
if ! secret="$(bao kv get -field=value ${lib.escapeShellArg storeSecretPath} 2>"$err")"; then if ! secret="$(bao kv get -field=value ${lib.escapeShellArg storeSecretPath} 2>"$err")"; then
echo "swarm-bao did not return ${storeSecretPath}; the queue responder has no OIDC client secret yet." >&2 echo "swarm-bao did not return ${storeSecretPath}; the queue responder has no OIDC client secret yet." >&2
if [ -s "$err" ]; then if [ -s "$err" ]; then
cat "$err" >&2 cat "$err" >&2
else else
echo "bao failed without writing a diagnostic." >&2 echo "bao failed without writing a diagnostic." >&2
fi fi
exit 1 exit 1
fi fi
if [ -z "$secret" ]; then if [ -z "$secret" ]; then
echo "swarm-bao returned an empty ${storeSecretPath}; the queue responder has no OIDC client secret yet." >&2 echo "swarm-bao returned an empty ${storeSecretPath}; the queue responder has no OIDC client secret yet." >&2
exit 1 exit 1
fi fi
# Root-owned 0400: `LoadCredential` reads it as root before the # Root-owned 0400: `LoadCredential` reads it as root before the
# responder's `DynamicUser` exists. `printf` inside # responder's `DynamicUser` exists. `printf` inside
# `atomic_write_secret` is a builtin, so the plaintext never # `atomic_write_secret` is a builtin, so the plaintext never
# becomes an argument in /proc. # becomes an argument in /proc.
install -d -m 0700 ${lib.escapeShellArg secretDir} install -d -m 0700 ${lib.escapeShellArg secretDir}
if secret_differs ${lib.escapeShellArg (hostPath "oidc-client.secret")} "$secret"; then if secret_differs ${lib.escapeShellArg (hostPath "oidc-client.secret")} "$secret"; then
atomic_write_secret 0400 "" ${lib.escapeShellArg (hostPath "oidc-client.secret")} "$secret" atomic_write_secret 0400 "" ${lib.escapeShellArg (hostPath "oidc-client.secret")} "$secret"
fi fi
# `LoadCredential` copies the file once, at the responder's start. # `LoadCredential` copies the file once, at the responder's start.
refresh_consumer ${lib.escapeShellArg machine} swarm-nats-auth.service ${lib.escapeShellArg (hostPath "oidc-client.secret")} refresh_consumer ${lib.escapeShellArg machine} swarm-nats-auth.service ${lib.escapeShellArg (hostPath "oidc-client.secret")}
''; '';
}; };
# The callout xkey's public half, from the store into the file # The callout xkey's public half, from the store into the file
@ -1157,57 +1155,48 @@ in
}; };
environment = { environment = {
BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}"; BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}";
}
// lib.optionalAttrs authStoreActive {
BAO_CLIENT_CERT = deployCfg.nats.authBaoClientCertFile; BAO_CLIENT_CERT = deployCfg.nats.authBaoClientCertFile;
BAO_CLIENT_KEY = deployCfg.nats.authBaoClientKeyFile; BAO_CLIENT_KEY = deployCfg.nats.authBaoClientKeyFile;
} }
// lib.optionalAttrs (baoDeploy.serverCaFile != null) { // lib.optionalAttrs (baoDeploy.serverCaFile != null) {
BAO_CACERT = baoDeploy.serverCaFile; BAO_CACERT = baoDeploy.serverCaFile;
}; };
script = script = ''
if !authStoreActive then set -euo pipefail
''
echo "the swarm queue responder has no store identity, so the callout xkey can be neither created nor fetched and the queue does not start: set services.hyperhive.deploy.nats.authBaoClientCertFile and authBaoClientKeyFile" >&2
exit 1
''
else
''
set -euo pipefail
${atomicWriteSecret} ${atomicWriteSecret}
${refreshConsumer} ${refreshConsumer}
swarm-nats-auth ensure-xkey --store-cert-role ${lib.escapeShellArg authCertRole} swarm-nats-auth ensure-xkey --store-cert-role ${lib.escapeShellArg authCertRole}
err="$(mktemp)" err="$(mktemp)"
trap 'rm -f "$err"' EXIT trap 'rm -f "$err"' EXIT
if ! BAO_TOKEN="$(bao login -method=cert -token-only 2>"$err")"; then if ! BAO_TOKEN="$(bao login -method=cert -token-only 2>"$err")"; then
echo "could not log in to swarm-bao with the queue responder's certificate; leaving the callout xkey file as it is." >&2 echo "could not log in to swarm-bao with the queue responder's certificate; leaving the callout xkey file as it is." >&2
cat "$err" >&2 cat "$err" >&2
exit 1 exit 1
fi fi
export BAO_TOKEN export BAO_TOKEN
if ! xkey="$(bao kv get -field=value ${lib.escapeShellArg xkeyPublicPath} 2>"$err")"; then if ! xkey="$(bao kv get -field=value ${lib.escapeShellArg xkeyPublicPath} 2>"$err")"; then
echo "swarm-bao did not return ${xkeyPublicPath}." >&2 echo "swarm-bao did not return ${xkeyPublicPath}." >&2
cat "$err" >&2 cat "$err" >&2
exit 1 exit 1
fi fi
if [ -z "$xkey" ]; then if [ -z "$xkey" ]; then
echo "swarm-bao returned an empty ${xkeyPublicPath}." >&2 echo "swarm-bao returned an empty ${xkeyPublicPath}." >&2
exit 1 exit 1
fi fi
install -d -m 0755 ${lib.escapeShellArg hostRuntimeDir} install -d -m 0755 ${lib.escapeShellArg hostRuntimeDir}
if secret_differs ${lib.escapeShellArg "${hostRuntimeDir}/${xkeyPublicFile}"} "$xkey"; then if secret_differs ${lib.escapeShellArg "${hostRuntimeDir}/${xkeyPublicFile}"} "$xkey"; then
atomic_write_secret 0444 "" ${lib.escapeShellArg "${hostRuntimeDir}/${xkeyPublicFile}"} "$xkey" atomic_write_secret 0444 "" ${lib.escapeShellArg "${hostRuntimeDir}/${xkeyPublicFile}"} "$xkey"
fi fi
# `renderConfig` reads the file as the server starts, and only then. # `renderConfig` reads the file as the server starts, and only then.
refresh_consumer ${lib.escapeShellArg machine} nats.service ${lib.escapeShellArg "${hostRuntimeDir}/${xkeyPublicFile}"} refresh_consumer ${lib.escapeShellArg machine} nats.service ${lib.escapeShellArg "${hostRuntimeDir}/${xkeyPublicFile}"}
''; '';
}; };
# ⚠️ Minted on the HOST, not in the container, because the responder is # ⚠️ Minted on the HOST, not in the container, because the responder is

View file

@ -82,6 +82,12 @@ let
responderOf = h: h.containers.swarm-nats.config.systemd.services.swarm-nats-auth; responderOf = h: h.containers.swarm-nats.config.systemd.services.swarm-nats-auth;
refusedWithoutIdentity =
m:
lib.any (
a: !a.assertion && lib.hasInfix "deploy.nats.authBaoClientCertFile and" a.message
) m.assertions;
# A hive running NOTHING of the swarm's own services — no IdP here, no # A hive running NOTHING of the swarm's own services — no IdP here, no
# `swarm.authelia.url` set by hand. The whole point of the fixture is what it # `swarm.authelia.url` set by hand. The whole point of the fixture is what it
# does *not* say: no IdP address is given and none is derived from # does *not* say: no IdP address is given and none is derived from
@ -91,6 +97,17 @@ let
autheliaColocated = hive { deploy.authelia.enable = true; }; autheliaColocated = hive { deploy.authelia.enable = true; };
cases = [ cases = [
{
# The xkey and the responder's OIDC client secret are read only from
# the store, so a queue with no responder leaf cannot start. Refused at
# eval; the controls are a leaf named by hand and the store host's
# minted one.
name = "a queue with no responder store identity is refused at eval, one with a leaf is not";
ok =
refusedWithoutIdentity natsOldPath
&& !(refusedWithoutIdentity natsWithStoreIdentity)
&& !(refusedWithoutIdentity natsOnStoreHost);
}
{ {
# Reads the RENDERED settings, not the option: `calloutBlocks {…} // { # Reads the RENDERED settings, not the option: `calloutBlocks {…} // {
# … }` is a shallow merge, and a future edit that dropped or shadowed # … }` is a shallow merge, and a future edit that dropped or shadowed
@ -182,17 +199,6 @@ let
&& !(lib.hasInfix "exit 0" s) && !(lib.hasInfix "exit 0" s)
&& u.serviceConfig.Restart == "on-failure"; && u.serviceConfig.Restart == "on-failure";
} }
{
name = "a queue host with no responder leaf renders an xkey fetch that fails naming the options";
ok =
let
u = natsOldPath.systemd.services.swarm-bao-nats-callout-xkey;
in
!(u.environment ? BAO_CLIENT_CERT)
&& lib.hasInfix "deploy.nats.authBaoClientCertFile" u.script
&& lib.hasInfix "exit 1" u.script
&& !(lib.hasInfix "ensure-xkey" u.script);
}
{ {
# Its creates are what the responder's role was granted; on the store's # Its creates are what the responder's role was granted; on the store's
# host the role's unit has to have run first. # host the role's unit has to have run first.
@ -281,16 +287,16 @@ let
&& lib.hasPrefix "https://" u.environment.BAO_ADDR; && lib.hasPrefix "https://" u.environment.BAO_ADDR;
} }
{ {
# An absent leaf must not stop the responder's agent-token path: it is # Copied as the seeds are, so an absent leaf fails the unit rather than
# delivered empty, beside the two seeds. # reaching the responder as a file it cannot log in with.
name = "the copy unit delivers the store identity, and an absent one as an empty file"; name = "the copy unit delivers the store identity beside the two seeds, and nothing in its place when absent";
ok = ok =
let let
s = natsWithStoreIdentity.systemd.services.swarm-nats-auth-secrets.script; s = natsWithStoreIdentity.systemd.services.swarm-nats-auth-secrets.script;
in in
lib.hasInfix "install -m 0400 /etc/pki/nats-auth.pem " s lib.hasInfix "install -m 0400 /etc/pki/nats-auth.pem " s
&& lib.hasInfix "install -m 0400 /etc/pki/nats-auth-key.pem " s && lib.hasInfix "install -m 0400 /etc/pki/nats-auth-key.pem " s
&& lib.hasInfix "install -m 0400 /dev/null " s && !(lib.hasInfix "/dev/null" s)
&& lib.hasInfix "callout-user.seed" s && lib.hasInfix "callout-user.seed" s
&& lib.hasInfix "issuer.seed" s; && lib.hasInfix "issuer.seed" s;
} }
@ -318,31 +324,6 @@ let
&& !(lib.hasInfix "oidc-client" copy) && !(lib.hasInfix "oidc-client" copy)
&& !(lib.hasInfix "authelia" copy); && !(lib.hasInfix "authelia" copy);
} }
{
# No responder leaf is a responder with no OIDC secret, which refuses
# every client. The unit still renders, and fails naming the options.
name = "a queue host with no responder leaf renders a secret fetch that fails naming the options";
ok =
let
fetch = natsOldPath.systemd.services.swarm-bao-nats-auth-oidc;
in
!(fetch.environment ? BAO_CLIENT_CERT)
&& lib.hasInfix "deploy.nats.authBaoClientCertFile" fetch.script
&& lib.hasInfix "exit 1" fetch.script
&& !(lib.hasInfix "bao login" fetch.script);
}
{
# The control: no identity, no store wiring, and the responder's
# credentials are exactly the three it cannot run without.
name = "a responder with no store identity is not pointed at a store";
ok =
let
u = responderOf natsOldPath;
in
!(u.environment ? BAO_ADDR)
&& !(u.environment ? BAO_CLIENT_CERT)
&& lib.length u.serviceConfig.LoadCredential == 3;
}
{ {
# On the store's host the fetch logs in under the responder's leaf, so it # On the store's host the fetch logs in under the responder's leaf, so it
# waits for the unit writing that role. # waits for the unit writing that role.

View file

@ -58,19 +58,11 @@ pub struct Store {
} }
impl Store { impl Store {
/// The store named by the `BAO_*` environment, or `None` when that is /// The store at `settings`, logged in to as `cert_role`.
/// unset: a responder with no store identity denies every agent token and pub fn new(settings: Settings, cert_role: String) -> Self {
/// serves the OIDC path unchanged. Self {
pub fn from_env(cert_role: String) -> Option<Self> { settings,
match Settings::from_env() { cert_role,
Ok(settings) => Some(Self {
settings,
cert_role,
}),
Err(e) => {
tracing::info!(reason = %e, "no secret store configured; agent tokens are denied");
None
}
} }
} }
} }
@ -149,20 +141,11 @@ async fn verify(source: &impl CredentialSource, token: &AgentToken<'_>) -> bool
} }
/// The grant for an agent token, or `None` for a denial. /// The grant for an agent token, or `None` for a denial.
/// pub async fn authorize(
/// `source` is `None` when this responder has no store identity, which denies.
pub async fn authorize<S: CredentialSource>(
policy: &Policy, policy: &Policy,
source: Option<&S>, source: &impl CredentialSource,
token: &AgentToken<'_>, token: &AgentToken<'_>,
) -> Option<Permissions> { ) -> Option<Permissions> {
let Some(source) = source else {
tracing::warn!(
agent = token.agent,
"agent token presented, but this responder has no secret store; denying"
);
return None;
};
if !verify(source, token).await { if !verify(source, token).await {
return None; return None;
} }
@ -237,7 +220,7 @@ mod tests {
.expect("valid") .expect("valid")
} }
async fn grant(source: Option<&Fake>, token: &str) -> Option<Permissions> { async fn grant(source: &Fake, token: &str) -> Option<Permissions> {
let Presented::Agent(token) = classify(token) else { let Presented::Agent(token) = classify(token) else {
panic!("{token:?} must classify as an agent token"); panic!("{token:?} must classify as an agent token");
}; };
@ -246,7 +229,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn a_valid_agent_token_is_granted_exactly_its_own_subjects() { async fn a_valid_agent_token_is_granted_exactly_its_own_subjects() {
let g = grant(Some(&atlas()), "swarm-agent.atlas.Ab9_-zSECRET") let g = grant(&atlas(), "swarm-agent.atlas.Ab9_-zSECRET")
.await .await
.expect("granted"); .expect("granted");
assert_eq!( assert_eq!(
@ -262,12 +245,12 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn a_wrong_secret_is_denied() { async fn a_wrong_secret_is_denied() {
assert!( assert!(
grant(Some(&atlas()), "swarm-agent.atlas.Ab9_-zSECREX") grant(&atlas(), "swarm-agent.atlas.Ab9_-zSECREX")
.await .await
.is_none() .is_none()
); );
assert!( assert!(
grant(Some(&atlas()), "swarm-agent.atlas.Ab9_-zSECRE") grant(&atlas(), "swarm-agent.atlas.Ab9_-zSECRE")
.await .await
.is_none() .is_none()
); );
@ -279,7 +262,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn another_agents_name_with_this_agents_secret_is_denied() { async fn another_agents_name_with_this_agents_secret_is_denied() {
assert!( assert!(
grant(Some(&atlas()), "swarm-agent.argus.Ab9_-zSECRET") grant(&atlas(), "swarm-agent.argus.Ab9_-zSECRET")
.await .await
.is_none() .is_none()
); );
@ -288,19 +271,16 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn an_agent_with_nothing_stored_is_denied() { async fn an_agent_with_nothing_stored_is_denied() {
assert!( assert!(
grant( grant(&stored("argus", "argus"), "swarm-agent.atlas.Ab9_-zSECRET")
Some(&stored("argus", "argus")), .await
"swarm-agent.atlas.Ab9_-zSECRET" .is_none()
)
.await
.is_none()
); );
} }
#[tokio::test] #[tokio::test]
async fn a_failed_lookup_is_denied() { async fn a_failed_lookup_is_denied() {
assert!( assert!(
grant(Some(&Fake::Fails), "swarm-agent.atlas.Ab9_-zSECRET") grant(&Fake::Fails, "swarm-agent.atlas.Ab9_-zSECRET")
.await .await
.is_none() .is_none()
); );
@ -313,7 +293,7 @@ mod tests {
let bound = crate::introspect::INTROSPECTION_TIMEOUT; let bound = crate::introspect::INTROSPECTION_TIMEOUT;
let started = std::time::Instant::now(); let started = std::time::Instant::now();
assert!( assert!(
grant(Some(&Fake::Hangs), "swarm-agent.atlas.Ab9_-zSECRET") grant(&Fake::Hangs, "swarm-agent.atlas.Ab9_-zSECRET")
.await .await
.is_none() .is_none()
); );
@ -325,24 +305,12 @@ mod tests {
); );
} }
#[tokio::test]
async fn no_store_is_denied() {
assert!(
grant(None, "swarm-agent.atlas.Ab9_-zSECRET")
.await
.is_none()
);
}
#[tokio::test] #[tokio::test]
async fn a_stored_object_naming_another_agent_is_denied() { async fn a_stored_object_naming_another_agent_is_denied() {
assert!( assert!(
grant( grant(&stored("argus", "atlas"), "swarm-agent.argus.Ab9_-zSECRET")
Some(&stored("argus", "atlas")), .await
"swarm-agent.argus.Ab9_-zSECRET" .is_none()
)
.await
.is_none()
); );
} }
@ -359,7 +327,7 @@ mod tests {
let Presented::Agent(token) = classify("swarm-agent.atlas.Ab9_-zSECRET") else { let Presented::Agent(token) = classify("swarm-agent.atlas.Ab9_-zSECRET") else {
panic!("an agent token"); panic!("an agent token");
}; };
assert!(authorize(&bare, Some(&atlas()), &token).await.is_none()); assert!(authorize(&bare, &atlas(), &token).await.is_none());
} }
/// Everything without the prefix reaches introspection as it arrived. /// Everything without the prefix reaches introspection as it arrived.

View file

@ -155,8 +155,9 @@ struct Args {
agent_token_publish_subjects: Vec<String>, agent_token_publish_subjects: Vec<String>,
/// Role on the secret store's `cert` auth mount this responder logs in /// Role on the secret store's `cert` auth mount this responder logs in
/// as to read agent credentials. The store is found through the `BAO_*` /// as to read the callout xkey and agent credentials. The store is found
/// environment; with none, every agent token is denied. /// through the `BAO_*` environment, which the responder does not start
/// without.
#[arg(long, default_value = "swarm-nats-auth")] #[arg(long, default_value = "swarm-nats-auth")]
store_cert_role: String, store_cert_role: String,
} }
@ -219,19 +220,25 @@ async fn serve(args: &Args) -> anyhow::Result<()> {
args.hive_publish_subjects.clone(), args.hive_publish_subjects.clone(),
)? )?
.with_agent_token_subjects(args.agent_token_publish_subjects.clone())?; .with_agent_token_subjects(args.agent_token_publish_subjects.clone())?;
let store = agent_token::Store::from_env(args.store_cert_role.clone());
let http = reqwest::Client::new(); let http = reqwest::Client::new();
let issuer = nkeys::KeyPair::from_seed(&read_secret(&args.issuer_seed_file)?) let issuer = nkeys::KeyPair::from_seed(&read_secret(&args.issuer_seed_file)?)
.context("parse the account signing seed")?; .context("parse the account signing seed")?;
let settings = swarm_secret_client::client::Settings::from_env()
.context("reading the secret store's address and this responder's identity")?;
// Before the connect: a responder that cannot open a sealed request must // Before the connect: a responder that cannot open a sealed request must
// not start answering. Read, never created here; see `xkey`. // not start answering. Read, never created here; see `xkey`.
let callout_xkey = xkey::load( let callout_xkey = xkey::load(
&swarm_secret_client::SecretStore::from_env(&args.store_cert_role) &swarm_secret_client::SecretStore::connect(
.await &settings,
.context("logging in to the secret store for the callout xkey")?, &args.store_cert_role,
swarm_secret_client::client::DEFAULT_CERT_MOUNT,
)
.await
.context("logging in to the secret store for the callout xkey")?,
) )
.await?; .await?;
tracing::info!(public_key = %callout_xkey.public_key(), "loaded the callout xkey"); tracing::info!(public_key = %callout_xkey.public_key(), "loaded the callout xkey");
let store = agent_token::Store::new(settings, args.store_cert_role.clone());
let user_seed = read_secret(&args.user_seed_file)?; let user_seed = read_secret(&args.user_seed_file)?;
let client = async_nats::ConnectOptions::with_nkey(user_seed) let client = async_nats::ConnectOptions::with_nkey(user_seed)
.name("swarm-nats-auth") .name("swarm-nats-auth")
@ -266,7 +273,7 @@ async fn serve(args: &Args) -> anyhow::Result<()> {
// the secret proved it; `granted` says which. // the secret proved it; `granted` says which.
Some(agent_token::Presented::Agent(token)) => ( Some(agent_token::Presented::Agent(token)) => (
Some(format!("agent:{}", token.agent)), Some(format!("agent:{}", token.agent)),
agent_token::authorize(&policy, store.as_ref(), &token).await, agent_token::authorize(&policy, &store, &token).await,
), ),
Some(agent_token::Presented::Malformed(e)) => { Some(agent_token::Presented::Malformed(e)) => {
tracing::warn!(error = %e, "malformed agent token; denying"); tracing::warn!(error = %e, "malformed agent token; denying");