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:
parent
9a241a1df1
commit
52c931c3a7
5 changed files with 193 additions and 248 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue