swarm-nats-auth: verify an agent's own token against the store
An `auth_token` spelled `swarm-agent.<agent>.<secret>` is no longer sent
to introspection. The responder reads `swarm/agents/<agent>/queue` with
an identity of its own, checks that the stored object names the same
agent, compares the secret in constant time, and grants the subjects
`--agent-token-publish-subject` lists with `{agent}` expanded. Every
other outcome denies: a malformed token, no store identity, nothing
stored, a failed or slow lookup, a different secret. A token without
the prefix takes the OIDC path unchanged.
The journal's `auth request` line names such a caller `agent:<agent>`;
the hive-shared credential keeps `hive-<h>-agent`.
The new principal: a `swarm-nats-auth` cert-auth role and policy with
read on `secret/data/swarm/agents/+/queue` alone, a leaf signed by the
store's PKI glue, and `glue-nats-auth-bao-identity.nix` pairing the two.
The copy unit delivers the identity into the queue's container, and an
absent leaf is delivered empty so the responder still starts and only
agent tokens are refused.
The policy and role are written by `swarm-bao-nats-auth-policy`, logged in
as the bao granter: both names fall under its `swarm-*` globs, so the
deploy writes them with no operator step. module-eval counts it among the
granting units, so every generic granting-unit case covers it.
The secret compare uses `subtle`, already in the lock file through the
TLS stack; no workspace crate offered one directly.
This commit is contained in:
parent
fc97c237dc
commit
9bad58d86d
14 changed files with 846 additions and 51 deletions
|
|
@ -8,7 +8,8 @@
|
|||
//! It connects as the one callout-exempt user (by nkey, never by name — the
|
||||
//! server refuses to start if that entry carries a username), subscribes to
|
||||
//! `$SYS.REQ.USER.AUTH`, validates the presented bearer token against
|
||||
//! authelia's introspection endpoint, and answers with a NATS user JWT signed
|
||||
//! authelia's introspection endpoint (or, for an agent's own token, against the
|
||||
//! secret store: see `agent_token`), and answers with a NATS user JWT signed
|
||||
//! by the account key. A rejection is answered explicitly: silence is
|
||||
//! indistinguishable from the responder being down, and the queue is the
|
||||
//! swarm's control path.
|
||||
|
|
@ -27,6 +28,7 @@ use anyhow::Context;
|
|||
use clap::Parser;
|
||||
use futures_util::StreamExt;
|
||||
|
||||
mod agent_token;
|
||||
mod introspect;
|
||||
mod policy;
|
||||
mod request;
|
||||
|
|
@ -89,7 +91,7 @@ struct Args {
|
|||
/// config change plus a reload.
|
||||
///
|
||||
/// So this identity says which hive an agent belongs to and never which
|
||||
/// agent: two agents on one hive are indistinguishable to this responder.
|
||||
/// agent: two agents on one hive are indistinguishable under it.
|
||||
///
|
||||
/// A suffix on the hive's id rather than a prefix of its own, because
|
||||
/// `agent-<name>` reads as *the agent called `<name>`* — the one thing
|
||||
|
|
@ -127,6 +129,18 @@ struct Args {
|
|||
/// is refused, which is loud rather than silently over-broad.
|
||||
#[arg(long = "agent-publish-subject")]
|
||||
agent_publish_subjects: Vec<String>,
|
||||
|
||||
/// Subjects an agent that presented its own credential may publish to,
|
||||
/// with `{agent}` standing for its name. Repeatable, empty by default,
|
||||
/// which denies every agent token.
|
||||
#[arg(long = "agent-token-publish-subject")]
|
||||
agent_token_publish_subjects: Vec<String>,
|
||||
|
||||
/// 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_*`
|
||||
/// environment; with none, every agent token is denied.
|
||||
#[arg(long, default_value = "swarm-nats-auth")]
|
||||
store_cert_role: String,
|
||||
}
|
||||
|
||||
/// Read a secret file and strip surrounding whitespace.
|
||||
|
|
@ -177,7 +191,9 @@ async fn main() -> anyhow::Result<()> {
|
|||
args.reader_clients.clone(),
|
||||
args.hive_publish_subjects.clone(),
|
||||
args.agent_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 issuer = nkeys::KeyPair::from_seed(&read_secret(&args.issuer_seed_file)?)
|
||||
.context("parse the account signing seed")?;
|
||||
|
|
@ -210,44 +226,33 @@ async fn main() -> anyhow::Result<()> {
|
|||
};
|
||||
// No token is a denial, not an error: an anonymous connect is a
|
||||
// normal thing for a client to attempt and an abnormal thing to
|
||||
// grant. Introspection is only reached once something was presented.
|
||||
//
|
||||
// The caller is an identity or nothing — see `introspect`'s module
|
||||
// docs. There is no "admitted, identity unknown" branch to write here
|
||||
// because there is no such value to receive.
|
||||
let caller = match &req.connect_opts.auth_token {
|
||||
Some(token) => introspect::identify_caller(
|
||||
&http,
|
||||
&args.introspection_url,
|
||||
&args.client_id,
|
||||
&client_secret,
|
||||
token,
|
||||
)
|
||||
.await
|
||||
// An introspection that could not be *made* is a denial too. The
|
||||
// failure modes of an HTTP call are exactly the conditions under
|
||||
// which an attacker would most like this to fall open.
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!(error = ?e, "introspection failed; denying");
|
||||
None
|
||||
}),
|
||||
None => None,
|
||||
// grant. Neither the store nor introspection is reached until
|
||||
// something was presented.
|
||||
let (caller, permissions) = match req
|
||||
.connect_opts
|
||||
.auth_token
|
||||
.as_deref()
|
||||
.map(agent_token::classify)
|
||||
{
|
||||
// The caller is the name the token claims, logged whether or not
|
||||
// the secret proved it; `granted` says which.
|
||||
Some(agent_token::Presented::Agent(token)) => (
|
||||
Some(format!("agent:{}", token.agent)),
|
||||
agent_token::authorize(&policy, store.as_ref(), &token).await,
|
||||
),
|
||||
Some(agent_token::Presented::Malformed(e)) => {
|
||||
tracing::warn!(error = %e, "malformed agent token; denying");
|
||||
(None, None)
|
||||
}
|
||||
Some(agent_token::Presented::Bearer(token)) => {
|
||||
introspected(&policy, &http, &args, &client_secret, token).await
|
||||
}
|
||||
None => (None, None),
|
||||
};
|
||||
// Admission said who; the policy says what. A caller the `IdP`
|
||||
// vouches for but no rule matches is denied — see `policy`'s module
|
||||
// docs for why that is deny and not "connect with nothing".
|
||||
let permissions = caller.as_deref().and_then(|id| policy.permissions(id));
|
||||
if let (Some(id), None) = (caller.as_deref(), permissions.as_ref()) {
|
||||
// Loud, and the one case an operator has to be able to find: a
|
||||
// valid credential refused by our own policy. The alternative is
|
||||
// a client that authenticates fine and mysteriously cannot work.
|
||||
tracing::warn!(
|
||||
caller = %id,
|
||||
"authenticated client matches no policy rule; denying"
|
||||
);
|
||||
}
|
||||
// The client id is an identifier, not a credential, and it is the
|
||||
// only thing tying a connection in this log to a hive.
|
||||
// The caller is an identifier, not a credential: a client id, or
|
||||
// `agent:<name>` for an agent token. One line per auth request, a connect
|
||||
// or a reconnect, so `hive-<h>-agent` lines count requests made on a
|
||||
// hive's shared credential, not agents.
|
||||
tracing::info!(
|
||||
user_nkey = %req.user_nkey,
|
||||
server_id = %req.server_id.id,
|
||||
|
|
@ -281,6 +286,50 @@ async fn main() -> anyhow::Result<()> {
|
|||
anyhow::bail!("subscription to {AUTH_SUBJECT} ended")
|
||||
}
|
||||
|
||||
/// The OIDC path: who the `IdP` says presented `token`, and what the policy
|
||||
/// grants that client. `None` permissions is a denial.
|
||||
///
|
||||
/// The caller is an identity or nothing — see `introspect`'s module docs.
|
||||
/// There is no "admitted, identity unknown" branch to write here because
|
||||
/// there is no such value to receive.
|
||||
async fn introspected(
|
||||
policy: &policy::Policy,
|
||||
http: &reqwest::Client,
|
||||
args: &Args,
|
||||
client_secret: &str,
|
||||
token: &str,
|
||||
) -> (Option<String>, Option<policy::Permissions>) {
|
||||
let caller = introspect::identify_caller(
|
||||
http,
|
||||
&args.introspection_url,
|
||||
&args.client_id,
|
||||
client_secret,
|
||||
token,
|
||||
)
|
||||
.await
|
||||
// An introspection that could not be *made* is a denial too. The
|
||||
// failure modes of an HTTP call are exactly the conditions under
|
||||
// which an attacker would most like this to fall open.
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!(error = ?e, "introspection failed; denying");
|
||||
None
|
||||
});
|
||||
// Admission said who; the policy says what. A caller the `IdP`
|
||||
// vouches for but no rule matches is denied — see `policy`'s module
|
||||
// docs for why that is deny and not "connect with nothing".
|
||||
let permissions = caller.as_deref().and_then(|id| policy.permissions(id));
|
||||
if let (Some(id), None) = (caller.as_deref(), permissions.as_ref()) {
|
||||
// Loud, and the one case an operator has to be able to find: a
|
||||
// valid credential refused by our own policy. The alternative is
|
||||
// a client that authenticates fine and mysteriously cannot work.
|
||||
tracing::warn!(
|
||||
caller = %id,
|
||||
"authenticated client matches no policy rule; denying"
|
||||
);
|
||||
}
|
||||
(caller, permissions)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -306,9 +355,16 @@ mod tests {
|
|||
"hive-",
|
||||
"--agent-client-suffix",
|
||||
"-agent",
|
||||
"--agent-token-publish-subject",
|
||||
"$SWARM.term.{agent}",
|
||||
"--agent-token-publish-subject",
|
||||
"$SWARM.agent-state.{agent}",
|
||||
"--store-cert-role",
|
||||
"swarm-nats-auth",
|
||||
])
|
||||
.expect("the unit's own argument vector must parse");
|
||||
assert_eq!(args.agent_client_suffix, "-agent");
|
||||
assert_eq!(args.agent_token_publish_subjects.len(), 2);
|
||||
}
|
||||
|
||||
/// The control for the case above: an ordinary value parses through the
|
||||
|
|
|
|||
Loading…
Reference in a new issue