Skip to main content

av_harness/
workload.rs

1//! Agent identities for platform workloads: the RFC 7523 JWT-bearer grant
2//! on `/v1/token`.
3//!
4//! An app instance (usually the AgentVisor sidecar beside it) proves its
5//! Cloud Foundry instance identity with an assertion signed by the instance
6//! key (see `av_identity::workload`). The gateway maps the proven app,
7//! space, and organization to one configured `[[workload_identities]]`
8//! entry, which names the human principal the agent acts for, its charter,
9//! and its scopes, and returns a short-lived agent token signed with the
10//! gateway's exchange key. The gateway's own identity validator trusts that
11//! key and issuer, so the token works on every route like an identity
12//! provider's token: it expires, it can be revoked (by `jti`, or by the
13//! instance GUID in `instance_uid`), and its `sub` is the human.
14
15use crate::config::{HarnessConfig, WorkloadMapping};
16use crate::pipeline::AppState;
17use axum::body::Bytes;
18use axum::http::StatusCode;
19use axum::response::{IntoResponse, Response};
20use axum::Json;
21use serde_json::json;
22use std::sync::Arc;
23
24/// Issuer of gateway-issued agent tokens.
25pub fn issuer(config: &HarnessConfig) -> String {
26    format!("{}#workload", config.audience)
27}
28
29/// Make `validator` accept agent tokens this gateway issues: trust the
30/// exchange signing key under its key id and, when an issuer allowlist is
31/// configured, add the workload issuer to it. No-op without
32/// `[workload_identity]`.
33pub fn trust_gateway_tokens(
34    validator: &mut av_identity::IdentityValidator,
35    config: &HarnessConfig,
36) -> Result<(), String> {
37    if config.workload_identity.is_none() {
38        return Ok(());
39    }
40    let seed_path = config
41        .token_exchange_seed_file
42        .as_deref()
43        .ok_or_else(|| "[workload_identity] requires token_exchange_seed_file".to_owned())?;
44    let seed = crate::pipeline::load_exchange_seed(seed_path)?;
45    let signer = crate::authz::TokenSigner::from_seed(&seed);
46    validator
47        .add_key(
48            signer.kid(),
49            av_identity::KeyMaterial::Ed25519Jwk(signer.public_key_b64url()),
50        )
51        .map_err(|error| format!("trust the workload token key: {error}"))?;
52    if !config.identity_allowed_issuers.is_empty() {
53        let mut issuers = config.identity_allowed_issuers.clone();
54        issuers.push(issuer(config));
55        validator.allow_issuers(issuers);
56    }
57    Ok(())
58}
59
60/// Verifies workload assertions and maps them to configured workloads.
61pub struct WorkloadIssuer {
62    trust: av_identity::WorkloadTrust,
63    mappings: Vec<WorkloadMapping>,
64    token_ttl_s: u64,
65}
66
67impl WorkloadIssuer {
68    /// Load the trust bundle named by `[workload_identity].ca_file`.
69    pub fn from_config(config: &HarnessConfig) -> Result<Option<Self>, String> {
70        let Some(settings) = &config.workload_identity else {
71            return Ok(None);
72        };
73        let pem = std::fs::read(&settings.ca_file)
74            .map_err(|error| format!("read workload_identity.ca_file {:?}: {error}", settings.ca_file))?;
75        let trust = av_identity::WorkloadTrust::from_pem(&pem).map_err(|error| error.to_string())?;
76        Ok(Some(Self {
77            trust,
78            mappings: config.workload_identities.clone(),
79            token_ttl_s: settings.token_ttl_s,
80        }))
81    }
82
83    /// The single mapping that selects `identity`, if exactly one does.
84    fn mapping_for(
85        &self,
86        identity: &av_identity::CfInstanceIdentity,
87    ) -> Result<&WorkloadMapping, &'static str> {
88        let mut matches = self.mappings.iter().filter(|mapping| mapping.selects(identity));
89        match (matches.next(), matches.next()) {
90            (Some(mapping), None) => Ok(mapping),
91            (None, _) => Err("unregistered"),
92            (Some(_), Some(_)) => Err("ambiguous"),
93        }
94    }
95}
96
97fn oauth_error(status: StatusCode, error: &str, description: &str) -> Response {
98    let mut response = (
99        status,
100        Json(json!({"error": error, "error_description": description})),
101    )
102        .into_response();
103    response.headers_mut().insert(
104        axum::http::header::CACHE_CONTROL,
105        axum::http::HeaderValue::from_static("no-store"),
106    );
107    response
108}
109
110fn rejected(state: &AppState, reason: &str) {
111    state
112        .metrics
113        .counter(
114            &format!("av_workload_assertions_rejected_total{{reason=\"{reason}\"}}"),
115            "Workload identity assertions refused at /v1/token, by reason",
116        )
117        .inc();
118}
119
120#[derive(serde::Deserialize)]
121struct GrantForm {
122    #[allow(dead_code)]
123    grant_type: String,
124    assertion: String,
125    #[serde(default)]
126    scope: Option<String>,
127}
128
129/// Handle `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`.
130pub(crate) fn grant(state: &AppState, body: &Bytes) -> Response {
131    let Some(issuer_state) = state.workload.as_ref() else {
132        return oauth_error(
133            StatusCode::BAD_REQUEST,
134            "unsupported_grant_type",
135            "this gateway does not accept workload assertions",
136        );
137    };
138    let Ok(form) = serde_urlencoded::from_bytes::<GrantForm>(body) else {
139        return oauth_error(
140            StatusCode::BAD_REQUEST,
141            "invalid_request",
142            "the jwt-bearer grant needs an assertion",
143        );
144    };
145    let now_s = av_core::time::now_ms() / 1000;
146    let verified = match issuer_state
147        .trust
148        .verify(&form.assertion, &state.config.audience, now_s)
149    {
150        Ok(verified) => verified,
151        Err(error) => {
152            let reason = match &error {
153                av_identity::WorkloadError::Chain(_) => "chain",
154                av_identity::WorkloadError::Signature => "signature",
155                av_identity::WorkloadError::Replay => "replay",
156                av_identity::WorkloadError::Claims(_) => "claims",
157                av_identity::WorkloadError::Subject(_) => "subject",
158                _ => "malformed",
159            };
160            rejected(state, reason);
161            tracing::warn!(target: "agentvisor.workload", %error, "workload assertion refused");
162            // One opaque description: details name trust anchors and claims.
163            return oauth_error(
164                StatusCode::BAD_REQUEST,
165                "invalid_grant",
166                "the workload assertion was not accepted",
167            );
168        }
169    };
170    let mapping = match issuer_state.mapping_for(&verified.identity) {
171        Ok(mapping) => mapping,
172        Err(reason) => {
173            rejected(state, reason);
174            tracing::warn!(
175                target: "agentvisor.workload",
176                app = %verified.identity.app_guid,
177                space = %verified.identity.space_guid,
178                org = %verified.identity.org_guid,
179                reason,
180                "workload has no single [[workload_identities]] entry"
181            );
182            return oauth_error(
183                StatusCode::BAD_REQUEST,
184                "invalid_grant",
185                "this workload is not registered with the gateway",
186            );
187        }
188    };
189    let scopes = match form.scope.as_deref() {
190        None => mapping.scopes.clone(),
191        Some(requested) => {
192            let requested: Vec<String> = requested
193                .split(' ')
194                .filter(|s| !s.is_empty())
195                .map(str::to_owned)
196                .collect();
197            let granted = av_identity::scope_intersection(&requested, &mapping.scopes);
198            if granted.is_empty() {
199                rejected(state, "scope");
200                return oauth_error(
201                    StatusCode::BAD_REQUEST,
202                    "invalid_scope",
203                    "no requested scope is granted to this workload",
204                );
205            }
206            granted
207        }
208    };
209    let Some(signer) = state.token_signer.as_ref() else {
210        return oauth_error(
211            StatusCode::INTERNAL_SERVER_ERROR,
212            "server_error",
213            "workload identity is enabled but no signing key is configured",
214        );
215    };
216    let ttl = issuer_state.token_ttl_s.min(av_identity::MAX_TTL_SECS);
217    let claims = av_identity::NhiClaims {
218        sub: mapping.sub.clone(),
219        iss: issuer(&state.config),
220        aud: av_identity::Audience::Single(state.config.audience.clone()),
221        iat: now_s,
222        nbf: Some(now_s),
223        exp: now_s.saturating_add(ttl),
224        jti: av_core::ids::new_event_uid(),
225        azp: Some(format!("cf-app:{}", verified.identity.app_guid)),
226        act: None,
227        instance_uid: verified.identity.instance_guid.clone(),
228        charter: mapping.charter.clone(),
229        version: mapping.version.clone(),
230        scopes: scopes.clone(),
231        parent_token: None,
232    };
233    let access_token = match signer.sign(&claims) {
234        Ok(token) => token,
235        Err(error) => {
236            tracing::error!(%error, "workload token signing failed");
237            return oauth_error(
238                StatusCode::INTERNAL_SERVER_ERROR,
239                "server_error",
240                "token signing failed",
241            );
242        }
243    };
244    state
245        .metrics
246        .counter(
247            &format!("av_workload_tokens_issued_total{{workload=\"{}\"}}", mapping.name),
248            "Agent tokens issued to platform workloads, by workload",
249        )
250        .inc();
251    tracing::info!(
252        target: "agentvisor.workload",
253        workload = %mapping.name,
254        sub = %mapping.sub,
255        instance = %verified.identity.instance_guid,
256        app = %verified.identity.app_guid,
257        jti = %claims.jti,
258        "issued an agent token to a platform workload"
259    );
260    let mut response = Json(json!({
261        "access_token": access_token,
262        "token_type": "Bearer",
263        "issued_token_type": av_identity::exchange::JWT_TOKEN_TYPE,
264        "expires_in": ttl,
265        "scope": scopes.join(" "),
266    }))
267    .into_response();
268    for (name, value) in [("cache-control", "no-store"), ("pragma", "no-cache")] {
269        response.headers_mut().insert(
270            axum::http::HeaderName::from_static(name),
271            axum::http::HeaderValue::from_static(value),
272        );
273    }
274    response
275}
276
277/// Shared pointer type stored in the application state.
278pub type SharedWorkloadIssuer = Option<Arc<WorkloadIssuer>>;