swarm-controller reads each member of authelia's operator group's forge profile every five minutes and keeps one key per operator in the new operator-profiles bucket. Every agent may list and read that bucket, and hive-agent renders it into the system prompt at harness start, falling back to today's prompt when the bucket is empty or doesn't answer within five seconds. Fixes #5383
191 lines
6.5 KiB
Rust
191 lines
6.5 KiB
Rust
//! Classifying a presented `auth_token`, and the grant for an agent's own.
|
|
//!
|
|
//! The spelling is `swarm_queue_client::agent_token`'s, shared with the agent
|
|
//! that presents it. Whether its secret is the stored one is
|
|
//! [`swarm_agent_verify::verify`]'s answer; nothing here grants on the name
|
|
//! alone.
|
|
//!
|
|
//! A token without the prefix is not this module's: it goes to introspection
|
|
//! unchanged.
|
|
|
|
use swarm_agent_verify::CredentialSource;
|
|
use swarm_queue_client::agent_token::{AgentToken, Malformed, parse_agent_token};
|
|
|
|
use crate::policy::{Permissions, Policy};
|
|
|
|
/// What a presented `auth_token` is.
|
|
pub enum Presented<'a> {
|
|
/// An agent's own credential, to be checked against the store.
|
|
Agent(AgentToken<'a>),
|
|
/// Carries the agent-token prefix but is not well-formed. Denied without a
|
|
/// lookup, and never handed to introspection: it holds a secret meant for
|
|
/// the store, not for the `IdP`.
|
|
Malformed(Malformed),
|
|
/// Anything else, which is an OIDC access token for introspection.
|
|
Bearer(&'a str),
|
|
}
|
|
|
|
/// Sort `token` onto the agent path or the introspection path.
|
|
pub fn classify(token: &str) -> Presented<'_> {
|
|
match parse_agent_token(token) {
|
|
Some(Ok(agent)) => Presented::Agent(agent),
|
|
Some(Err(e)) => Presented::Malformed(e),
|
|
None => Presented::Bearer(token),
|
|
}
|
|
}
|
|
|
|
/// The grant for an agent token, or `None` for a denial.
|
|
///
|
|
/// The store lookup is bounded by introspection's budget, under the server's
|
|
/// `authorization.timeout`. The callout loop answers one request at a time, so
|
|
/// a store that does not answer holds every request behind it, OIDC ones
|
|
/// included, for at most that long, and this then denies.
|
|
pub async fn authorize(
|
|
policy: &Policy,
|
|
source: &impl CredentialSource,
|
|
token: &AgentToken<'_>,
|
|
) -> Option<Permissions> {
|
|
if !swarm_agent_verify::verify(source, token, crate::introspect::INTROSPECTION_TIMEOUT).await {
|
|
return None;
|
|
}
|
|
let permissions = policy.agent_token_permissions(token.agent);
|
|
if permissions.is_none() {
|
|
tracing::warn!(
|
|
agent = token.agent,
|
|
"verified agent token, but no --agent-token-publish-subject is configured; denying"
|
|
);
|
|
}
|
|
permissions
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use swarm_secret_client::queue::AgentCredential;
|
|
|
|
/// A store holding atlas's credential, or one that never answers.
|
|
/// The store's own denials are `swarm_agent_verify`'s tests.
|
|
enum Fake {
|
|
Atlas,
|
|
Hangs,
|
|
}
|
|
|
|
impl CredentialSource for Fake {
|
|
async fn lookup(&self, agent: &str) -> anyhow::Result<Option<AgentCredential>> {
|
|
match self {
|
|
Self::Atlas => Ok((agent == "atlas").then(|| AgentCredential {
|
|
value: "Ab9_-zSECRET".to_owned(),
|
|
agent: "atlas".to_owned(),
|
|
minted_at: None,
|
|
})),
|
|
Self::Hangs => std::future::pending().await,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn policy() -> Policy {
|
|
Policy::new(
|
|
"hive-".to_owned(),
|
|
"-agent".to_owned(),
|
|
"hive-status".to_owned(),
|
|
vec!["swarm-controller".to_owned()],
|
|
vec![],
|
|
)
|
|
.expect("valid")
|
|
.with_agent_token_subjects(vec![
|
|
"$SWARM.term.{agent}".to_owned(),
|
|
"$SWARM.agent-state.{agent}".to_owned(),
|
|
"$KV.agent-icons.{agent}".to_owned(),
|
|
])
|
|
.expect("valid")
|
|
}
|
|
|
|
async fn grant(source: &Fake, token: &str) -> Option<Permissions> {
|
|
let Presented::Agent(token) = classify(token) else {
|
|
panic!("{token:?} must classify as an agent token");
|
|
};
|
|
authorize(&policy(), source, &token).await
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_valid_agent_token_is_granted_exactly_its_own_subjects() {
|
|
let g = grant(&Fake::Atlas, "swarm-agent.atlas.Ab9_-zSECRET")
|
|
.await
|
|
.expect("granted");
|
|
assert_eq!(
|
|
g.publish,
|
|
vec![
|
|
"$SWARM.term.atlas".to_owned(),
|
|
"$SWARM.agent-state.atlas".to_owned(),
|
|
"$KV.agent-icons.atlas".to_owned(),
|
|
"$JS.API.STREAM.INFO.KV_operator-profiles".to_owned(),
|
|
"$JS.API.CONSUMER.CREATE.KV_operator-profiles".to_owned(),
|
|
"$JS.API.CONSUMER.CREATE.KV_operator-profiles.>".to_owned(),
|
|
"$JS.API.DIRECT.GET.KV_operator-profiles.>".to_owned(),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_wrong_secret_is_denied() {
|
|
assert!(
|
|
grant(&Fake::Atlas, "swarm-agent.atlas.Ab9_-zSECREX")
|
|
.await
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
/// The callout loop answers one request at a time, so a store that never
|
|
/// answers must cost at most the bound, and then deny.
|
|
#[tokio::test]
|
|
async fn a_store_that_never_answers_is_denied_within_the_bound() {
|
|
let bound = crate::introspect::INTROSPECTION_TIMEOUT;
|
|
let started = std::time::Instant::now();
|
|
assert!(
|
|
grant(&Fake::Hangs, "swarm-agent.atlas.Ab9_-zSECRET")
|
|
.await
|
|
.is_none()
|
|
);
|
|
let took = started.elapsed();
|
|
assert!(took >= bound, "denied before the bound: {took:?}");
|
|
assert!(
|
|
took < bound + std::time::Duration::from_millis(250),
|
|
"denied well after the bound: {took:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_verified_agent_with_no_subjects_configured_is_denied() {
|
|
let bare = Policy::new(
|
|
"hive-".to_owned(),
|
|
"-agent".to_owned(),
|
|
"hive-status".to_owned(),
|
|
vec![],
|
|
vec![],
|
|
)
|
|
.expect("valid");
|
|
let Presented::Agent(token) = classify("swarm-agent.atlas.Ab9_-zSECRET") else {
|
|
panic!("an agent token");
|
|
};
|
|
assert!(authorize(&bare, &Fake::Atlas, &token).await.is_none());
|
|
}
|
|
|
|
/// Everything without the prefix reaches introspection as it arrived.
|
|
#[test]
|
|
fn a_token_without_the_prefix_goes_to_introspection_unchanged() {
|
|
for token in ["authelia_at_abc.def", "atlas.Ab9_-zSECRET"] {
|
|
let Presented::Bearer(t) = classify(token) else {
|
|
panic!("{token:?} is not an agent token");
|
|
};
|
|
assert_eq!(t, token);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_malformed_agent_token_goes_nowhere() {
|
|
assert!(matches!(
|
|
classify("swarm-agent.atlas"),
|
|
Presented::Malformed(_)
|
|
));
|
|
}
|
|
}
|