//! 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 { 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> { 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 { 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(), ] ); } #[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(_) )); } }