Files
windmill/backend/windmill-common/src/agent_workload_identity.rs
T
be23abb360 feat: let agent workers authenticate with a workload identity token (#11858)
* feat: let agent workers authenticate with a workload identity token

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: throttle failed workload identity key fetches per issuer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: keep stale workload identity keys through an issuer outage

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: refuse workload identity rules with an empty claim value

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: validate workload identity issuers in declarative sync and share the JWKS cache

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: say workload identity issuers are optional in agent worker settings

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: update ee-repo-ref to 96e8e2aa15bdc5968a1dae10f4e81d47d7ea49e0

This commit updates the EE repository reference after PR #897 was merged in windmill-ee-private.

Previous ee-repo-ref: 0e98880edb8db269d53120ca606025c81f320f5c

New ee-repo-ref: 96e8e2aa15bdc5968a1dae10f4e81d47d7ea49e0

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-10-18 10:44:50 +02:00

476 lines
18 KiB
Rust

//! Agent worker authentication with a workload identity token: a JWT the platform the agent
//! runs on issues to it (a Kubernetes projected service account token, a SPIFFE JWT-SVID, ...),
//! verified against an issuer the superadmin trusts in `agent_workload_identity_issuers`.
//! The worker group and tags come from the first rule whose claims the token matches, never
//! from the token's own say, so the tag checks the static agent token path applies hold as is.
use std::collections::HashMap;
use std::sync::Arc;
use jsonwebtoken::{jwk::Jwk, Algorithm, DecodingKey, Validation};
use serde_json::Value;
use crate::error::Result;
use crate::guest_jwt::{cached_jwks_keys, jwk_algorithms, parse_jwks_keys, JwksSource};
use crate::instance_config::{AgentWorkloadIdentityIssuer, AgentWorkloadIdentityRule};
/// A projected service account token is about 1 KB; refused past this before any work.
const MAX_TOKEN_LEN: usize = 16 * 1024;
/// Why a token was refused. `issuer` is set once the token named a trusted one. The reason
/// goes to the server log and the audit log, never to the client.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Refusal {
pub issuer: Option<String>,
/// The token's `sub`, set only once its signature verified, so a caller cannot vary it.
pub verified_subject: Option<String>,
pub reason: String,
}
impl Refusal {
fn new(issuer: Option<&str>, reason: impl Into<String>) -> Self {
Self { issuer: issuer.map(str::to_string), verified_subject: None, reason: reason.into() }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkloadIdentity {
pub issuer: String,
pub subject: Option<String>,
pub worker_group: String,
pub tags: Vec<String>,
pub exp: u64,
}
/// The setting's raw value, as both the settings API and the declarative sync receive it.
pub fn validate_issuers_value(value: &Value) -> std::result::Result<(), String> {
let issuers: Vec<AgentWorkloadIdentityIssuer> =
serde_json::from_value(value.clone()).map_err(|e| e.to_string())?;
validate_issuers(&issuers)
}
/// Rejects what would make a rule set ambiguous or a rule match more than intended. Runtime
/// matching skips a rule without claims too, so a setting planted around this check cannot
/// admit every token of an issuer.
pub fn validate_issuers(
issuers: &[AgentWorkloadIdentityIssuer],
) -> std::result::Result<(), String> {
let mut seen = std::collections::HashSet::new();
for (i, cfg) in issuers.iter().enumerate() {
let at = format!("issuer #{} ({})", i + 1, cfg.issuer);
if cfg.issuer.trim().is_empty() {
return Err(format!("issuer #{}: issuer is required", i + 1));
}
if !seen.insert(cfg.issuer.as_str()) {
return Err(format!("{at}: listed more than once"));
}
if cfg.audience.trim().is_empty() {
return Err(format!("{at}: audience is required"));
}
match (&cfg.jwks, &cfg.jwks_uri) {
(Some(jwks), _) if !jwks.trim().is_empty() => {
parse_jwks_keys(jwks.as_bytes()).map_err(|e| format!("{at}: inline JWKS: {e}"))?;
}
(_, Some(uri)) if !uri.trim().is_empty() => {
if !(uri.starts_with("https://") || uri.starts_with("http://")) {
return Err(format!("{at}: jwks_uri must be an http(s) URL"));
}
}
_ => {
if !(cfg.issuer.starts_with("https://") || cfg.issuer.starts_with("http://")) {
return Err(format!(
"{at}: without jwks_uri or an inline JWKS, the issuer must be an http(s) URL \
serving OIDC discovery"
));
}
}
}
if cfg.rules.is_empty() {
return Err(format!("{at}: at least one rule is required"));
}
for (j, rule) in cfg.rules.iter().enumerate() {
let at = format!("{at}, rule #{}", j + 1);
if rule.claims.is_empty()
|| rule
.claims
.iter()
.any(|(k, v)| k.trim().is_empty() || v.trim().is_empty())
{
return Err(format!(
"{at}: at least one claim to match is required, each with a path and a value"
));
}
if rule.worker_group.trim().is_empty()
|| rule.worker_group.contains(char::is_whitespace)
{
return Err(format!("{at}: worker group is required and has no spaces"));
}
if rule.tags.is_empty() || rule.tags.iter().any(|t| t.trim().is_empty()) {
return Err(format!("{at}: at least one non-empty tag is required"));
}
}
}
Ok(())
}
/// The value at a dot separated claim path. At each level the longest key that prefixes the
/// remaining path is tried first, so `kubernetes.io.namespace` reaches `namespace` under the
/// `kubernetes.io` key.
fn claim_at<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
let obj = value.as_object()?;
if let Some(v) = obj.get(path) {
return Some(v);
}
path.match_indices('.')
.rev()
.find_map(|(i, _)| claim_at(obj.get(&path[..i])?, &path[i + 1..]))
}
fn claim_matches(claims: &Value, path: &str, expected: &str) -> bool {
match claim_at(claims, path) {
Some(Value::String(s)) => s == expected,
Some(v @ (Value::Number(_) | Value::Bool(_))) => v.to_string() == expected,
_ => false,
}
}
pub fn matching_rule<'a>(
rules: &'a [AgentWorkloadIdentityRule],
claims: &Value,
) -> Option<&'a AgentWorkloadIdentityRule> {
rules.iter().find(|rule| {
!rule.claims.is_empty()
&& rule
.claims
.iter()
.all(|(path, expected)| claim_matches(claims, path, expected))
})
}
/// The header's `alg`, read raw: `jsonwebtoken` cannot parse `none`, and a refusal should say
/// which algorithm was offered rather than that the header is malformed.
fn header_alg(token: &str) -> std::result::Result<Algorithm, String> {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
let header = token.split('.').next().unwrap_or_default();
let header: Value = URL_SAFE_NO_PAD
.decode(header.trim_end_matches('='))
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.ok_or("malformed JWT header")?;
let alg = header.get("alg").and_then(Value::as_str).unwrap_or("");
match alg.parse::<Algorithm>() {
Ok(
a @ (Algorithm::RS256
| Algorithm::RS384
| Algorithm::RS512
| Algorithm::PS256
| Algorithm::PS384
| Algorithm::PS512
| Algorithm::ES256
| Algorithm::ES384),
) => Ok(a),
_ => Err(format!(
"alg {alg:?} is not accepted, only RS*, PS* and ES*"
)),
}
}
/// Verify `token` against the trusted `issuers` and map it to a worker group and tags.
pub async fn verify(
issuers: &[AgentWorkloadIdentityIssuer],
token: &str,
) -> std::result::Result<WorkloadIdentity, Refusal> {
if token.len() > MAX_TOKEN_LEN {
return Err(Refusal::new(None, "token is too long"));
}
let alg = header_alg(token).map_err(|r| Refusal::new(None, r))?;
let kid = jsonwebtoken::decode_header(token)
.map_err(|e| Refusal::new(None, format!("malformed JWT header: {e}")))?
.kid;
let unverified: Value = crate::jwt::decode_without_verify(token)
.map_err(|e| Refusal::new(None, format!("malformed JWT payload: {e}")))?;
let iss = unverified.get("iss").and_then(Value::as_str).unwrap_or("");
let cfg = issuers
.iter()
.find(|c| c.issuer == iss)
.ok_or_else(|| Refusal::new(None, format!("issuer {iss:?} is not trusted")))?;
let refuse = |reason: String| Refusal::new(Some(&cfg.issuer), reason);
let keys = issuer_keys(cfg)
.await
.map_err(|e| refuse(format!("signing keys unavailable: {e}")))?;
let candidates: Vec<&Jwk> = match &kid {
Some(kid) => keys.get(kid).into_iter().collect(),
None => keys.values().collect(),
};
let candidates: Vec<&Jwk> = candidates
.into_iter()
.filter(|jwk| jwk_algorithms(jwk).is_some_and(|algs| algs.contains(&alg)))
.collect();
if candidates.is_empty() {
return Err(refuse(match &kid {
Some(kid) => format!("no {alg:?} signing key with kid {kid:?} in the issuer's JWKS"),
None => format!("no {alg:?} signing key in the issuer's JWKS"),
}));
}
let mut validation = Validation::new(alg);
validation.set_audience(&[&cfg.audience]);
validation.set_issuer(&[&cfg.issuer]);
validation.set_required_spec_claims(&["exp", "iss", "aud"]);
validation.validate_nbf = true;
let mut last_err = None;
let mut claims = None;
for jwk in candidates {
let key = match DecodingKey::from_jwk(jwk) {
Ok(key) => key,
Err(e) => {
last_err = Some(e.to_string());
continue;
}
};
match jsonwebtoken::decode::<Value>(token, &key, &validation) {
Ok(data) => {
claims = Some(data.claims);
break;
}
Err(e) => last_err = Some(describe_jwt_error(&e, &cfg.audience)),
}
}
let claims = claims.ok_or_else(|| refuse(last_err.unwrap_or_default()))?;
let subject = claims
.get("sub")
.and_then(Value::as_str)
.map(str::to_string);
let rule = matching_rule(&cfg.rules, &claims).ok_or_else(|| Refusal {
verified_subject: subject.clone(),
..refuse(format!(
"no rule matches the token (sub {})",
subject.as_deref().unwrap_or("<none>")
))
})?;
Ok(WorkloadIdentity {
issuer: cfg.issuer.clone(),
subject,
worker_group: rule.worker_group.clone(),
tags: rule.tags.clone(),
exp: claims.get("exp").and_then(Value::as_u64).unwrap_or(0),
})
}
fn describe_jwt_error(e: &jsonwebtoken::errors::Error, audience: &str) -> String {
use jsonwebtoken::errors::ErrorKind;
match e.kind() {
ErrorKind::InvalidSignature => "signature does not verify".to_string(),
ErrorKind::ExpiredSignature => "token is expired".to_string(),
ErrorKind::ImmatureSignature => "token is not valid yet (nbf)".to_string(),
ErrorKind::InvalidAudience => format!("aud does not include {audience:?}"),
ErrorKind::InvalidIssuer => "iss does not match".to_string(),
ErrorKind::MissingRequiredClaim(c) => format!("required claim {c} is missing"),
_ => format!("token refused: {e}"),
}
}
async fn issuer_keys(cfg: &AgentWorkloadIdentityIssuer) -> Result<Arc<HashMap<String, Jwk>>> {
if let Some(jwks) = cfg.jwks.as_deref().filter(|j| !j.trim().is_empty()) {
return Ok(Arc::new(parse_jwks_keys(jwks.as_bytes())?));
}
cached_jwks_keys(&JwksSource::TrustedIssuer {
issuer: cfg.issuer.clone(),
jwks_uri: cfg.jwks_uri.clone(),
})
.await
}
#[cfg(test)]
mod tests {
use super::*;
use jsonwebtoken::{EncodingKey, Header};
use serde_json::json;
use std::collections::BTreeMap;
// Throwaway P-256 keys, used nowhere else. `JWKS` publishes the first one.
const PRIV: &str = "-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgu7FqG4gEcV8logVI
B6/Lc5aFA8jxKmix7Jdcr/acid2hRANCAATV+8XhZlMNXkRAy7FZ4nbQkBOechNv
a2U/COEBOOauFRMd0NB5bi2DAqO1r/h7eN7fLMGYLXVPlSvNdJuYL4F8
-----END PRIVATE KEY-----";
const OTHER_PRIV: &str = "-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcU92tN6g4nOPoxQm
6KLBG0mtRL4gZ6/BNcrNPXiRammhRANCAASu64yo/1u80XgfncT4r9QMORMHZjDM
Sa6vBkm9YSs4MMV6K5vCIyow+i712HgUL/FNZ55GJVwXF77JffommMAk
-----END PRIVATE KEY-----";
const JWKS: &str = r#"{"keys":[{"kty":"EC","crv":"P-256","alg":"ES256","use":"sig","kid":"k1",
"x":"1fvF4WZTDV5EQMuxWeJ20JATnnITb2tlPwjhATjmrhU",
"y":"Ex3Q0HluLYMCo7Wv-Ht43t8swZgtdU-VK810m5gvgXw"}]}"#;
const ISS: &str = "https://kubernetes.default.svc.cluster.local";
fn issuer() -> AgentWorkloadIdentityIssuer {
AgentWorkloadIdentityIssuer {
issuer: ISS.to_string(),
jwks_uri: None,
jwks: Some(JWKS.to_string()),
audience: "windmill".to_string(),
rules: vec![
AgentWorkloadIdentityRule {
claims: BTreeMap::from([(
"sub".to_string(),
"system:serviceaccount:agents:gpu".to_string(),
)]),
worker_group: "gpu".to_string(),
tags: vec!["gpu".to_string()],
},
AgentWorkloadIdentityRule {
claims: BTreeMap::from([(
"kubernetes.io.namespace".to_string(),
"agents".to_string(),
)]),
worker_group: "agents".to_string(),
tags: vec!["dbt".to_string(), "etl".to_string()],
},
],
}
}
fn claims(sub: &str, ns: &str) -> Value {
let now = jsonwebtoken::get_current_timestamp();
json!({
"iss": ISS, "aud": ["windmill"], "sub": sub, "exp": now + 600, "iat": now, "nbf": now,
"kubernetes.io": {"namespace": ns, "serviceaccount": {"name": "x"}},
})
}
fn sign(claims: &Value, pem: &str, alg: Algorithm) -> String {
let mut header = Header::new(alg);
header.kid = Some("k1".to_string());
jsonwebtoken::encode(
&header,
claims,
&EncodingKey::from_ec_pem(pem.as_bytes()).unwrap(),
)
.unwrap()
}
async fn reason(token: &str) -> String {
verify(&[issuer()], token).await.unwrap_err().reason
}
#[tokio::test]
async fn first_matching_rule_decides_group_and_tags() {
let id = verify(
&[issuer()],
&sign(
&claims("system:serviceaccount:agents:gpu", "agents"),
PRIV,
Algorithm::ES256,
),
)
.await
.unwrap();
assert_eq!(
(id.worker_group.as_str(), id.tags),
("gpu", vec!["gpu".to_string()])
);
// A dotted key (`kubernetes.io`) is reached through a nested path.
let id = verify(
&[issuer()],
&sign(
&claims("system:serviceaccount:agents:etl", "agents"),
PRIV,
Algorithm::ES256,
),
)
.await
.unwrap();
assert_eq!(id.worker_group, "agents");
assert!(reason(&sign(
&claims("system:serviceaccount:other:etl", "other"),
PRIV,
Algorithm::ES256
))
.await
.starts_with("no rule matches"));
}
#[tokio::test]
async fn refuses_wrong_iss_aud_exp_key_and_alg() {
let ok = claims("system:serviceaccount:agents:gpu", "agents");
let with = |k: &str, v: Value| {
let mut c = ok.clone();
c[k] = v;
sign(&c, PRIV, Algorithm::ES256)
};
assert!(reason(&with("iss", json!("https://evil")))
.await
.contains("not trusted"));
assert!(reason(&with("aud", json!("other"))).await.contains("aud"));
assert!(reason(&with("exp", json!(1_000_000)))
.await
.contains("expired"));
assert!(reason(&sign(&ok, OTHER_PRIV, Algorithm::ES256))
.await
.contains("signature"));
let hs = jsonwebtoken::encode(
&Header::new(Algorithm::HS256),
&ok,
&EncodingKey::from_secret(b"s"),
)
.unwrap();
assert!(reason(&hs).await.contains("not accepted"));
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
let none = format!(
"{}.{}.",
URL_SAFE_NO_PAD.encode(br#"{"alg":"none","kid":"k1"}"#),
URL_SAFE_NO_PAD.encode(ok.to_string())
);
assert!(reason(&none).await.contains("not accepted"));
}
#[tokio::test]
async fn failed_key_fetches_are_throttled() {
use std::sync::atomic::{AtomicUsize, Ordering};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let hits = Arc::new(AtomicUsize::new(0));
let counted = hits.clone();
tokio::spawn(async move {
while let Ok((conn, _)) = listener.accept().await {
counted.fetch_add(1, Ordering::SeqCst);
drop(conn);
}
});
let mut cfg = issuer();
cfg.issuer = format!("https://throttle-test-{}", addr.port());
cfg.jwks = None;
cfg.jwks_uri = Some(format!("http://{addr}/jwks"));
let mut c = claims("system:serviceaccount:agents:gpu", "agents");
c["iss"] = json!(cfg.issuer);
for _ in 0..3 {
let refusal = verify(&[cfg.clone()], &sign(&c, PRIV, Algorithm::ES256))
.await
.unwrap_err();
assert!(
refusal.reason.contains("signing keys unavailable"),
"{}",
refusal.reason
);
}
assert_eq!(hits.load(Ordering::SeqCst), 1);
}
#[test]
fn rule_without_claims_never_matches() {
let rules = vec![AgentWorkloadIdentityRule {
claims: BTreeMap::new(),
worker_group: "g".to_string(),
tags: vec!["t".to_string()],
}];
assert!(matching_rule(&rules, &json!({"sub": "x"})).is_none());
let mut cfg = issuer();
cfg.rules = rules;
assert!(validate_issuers(&[cfg]).is_err());
}
}