Skip to main content

structured_proxy/auth/
authz.rs

1//! External authorization via the Envoy ext_authz gRPC contract.
2//!
3//! Before a transcoded request is forwarded upstream, the proxy calls the
4//! configured ext_authz server's `envoy.service.auth.v3.Authorization/Check`
5//! with the request's HTTP attributes. An OK status allows the request (and may
6//! inject response headers); anything else denies it. This is the same contract
7//! OPA's Envoy plugin and any ext_authz server implement.
8
9use std::sync::Arc;
10use std::time::Duration;
11
12use axum::extract::State;
13use axum::http::header::{HeaderName, HeaderValue, HOST};
14use axum::http::{HeaderMap, StatusCode, Uri};
15use axum::middleware::Next;
16use axum::response::{IntoResponse, Response};
17use tonic::transport::Channel;
18
19use envoy_types::pb::envoy::config::core::v3::HeaderValueOption;
20use envoy_types::pb::envoy::service::auth::v3::{
21    attribute_context::{HttpRequest, Request as AttrRequest},
22    authorization_client::AuthorizationClient,
23    check_response::HttpResponse as EnvoyHttpResponse,
24    AttributeContext, CheckRequest, CheckResponse, DeniedHttpResponse,
25};
26
27use crate::config::AuthzConfig;
28use crate::guard::{http_to_grpc_code, mark_rejection, reject};
29
30/// A configured ext_authz client.
31pub struct Authz {
32    channel: Channel,
33    timeout: Duration,
34    failure_mode_allow: bool,
35    /// Headers an allowed check may not set beyond the client-address ones:
36    /// those the proxy's client-address forwarding writes.
37    reserved: Arc<[HeaderName]>,
38}
39
40impl Authz {
41    /// Build the authz client, or `None` when authz is disabled.
42    ///
43    /// # Errors
44    /// Returns an error when `endpoint` is not a valid gRPC URL.
45    pub fn build(config: &AuthzConfig) -> Result<Option<Arc<Self>>, String> {
46        Self::build_reserving(config, Arc::from([]))
47    }
48
49    /// [`build`](Self::build), keeping the check's OK headers off `reserved`.
50    pub(crate) fn build_reserving(
51        config: &AuthzConfig,
52        reserved: Arc<[HeaderName]>,
53    ) -> Result<Option<Arc<Self>>, String> {
54        if !config.enabled {
55            return Ok(None);
56        }
57        let channel = Channel::from_shared(config.endpoint.clone())
58            .map_err(|e| format!("invalid authz endpoint: {e}"))?
59            .connect_lazy();
60        Ok(Some(Arc::new(Self {
61            channel,
62            timeout: Duration::from_millis(config.timeout_ms),
63            failure_mode_allow: config.failure_mode_allow,
64            reserved,
65        })))
66    }
67}
68
69/// Axum middleware gating proxied requests through the ext_authz server.
70pub async fn middleware(
71    State(authz): State<Arc<Authz>>,
72    mut request: axum::extract::Request,
73    next: Next,
74) -> Response {
75    let check = build_check_request(request.headers(), request.method().as_str(), request.uri());
76    let mut client = AuthorizationClient::new(authz.channel.clone());
77    let mut grpc_req = tonic::Request::new(check);
78    grpc_req.set_timeout(authz.timeout);
79
80    match client.check(grpc_req).await {
81        Ok(resp) => match evaluate(resp.into_inner()) {
82            Decision::Allow(headers) => {
83                apply_headers(request.headers_mut(), headers, &authz.reserved);
84                next.run(request).await
85            }
86            Decision::Deny(response) => response,
87        },
88        // The authz call itself failed (unreachable / timeout): fail open or
89        // closed per config. Fail-closed is a 503, distinct from a policy 403.
90        Err(status) if authz.failure_mode_allow => {
91            tracing::warn!(error = %status, "authz check failed; failing open");
92            next.run(request).await
93        }
94        Err(status) => {
95            tracing::warn!(error = %status, "authz check failed; failing closed");
96            reject(
97                tonic::Code::Unavailable,
98                "authorization service unavailable",
99            )
100        }
101    }
102}
103
104/// The result of an authorization check.
105enum Decision {
106    /// Allowed; add these headers (from the OK response) to the upstream request.
107    Allow(Vec<(HeaderName, HeaderValue)>),
108    /// Denied; return this response to the client.
109    Deny(Response),
110}
111
112/// Build a `CheckRequest` from the incoming request's HTTP attributes.
113fn build_check_request(headers: &HeaderMap, method: &str, uri: &Uri) -> CheckRequest {
114    let mut header_map = std::collections::HashMap::new();
115    for (name, value) in headers {
116        if let Ok(v) = value.to_str() {
117            header_map.insert(name.as_str().to_string(), v.to_string());
118        }
119    }
120    let host = headers
121        .get(HOST)
122        .and_then(|v| v.to_str().ok())
123        .unwrap_or_default()
124        .to_string();
125    let scheme = headers
126        .get("x-forwarded-proto")
127        .and_then(|v| v.to_str().ok())
128        .unwrap_or("http")
129        .to_string();
130
131    let http = HttpRequest {
132        method: method.to_string(),
133        path: uri.path().to_string(),
134        query: uri.query().unwrap_or_default().to_string(),
135        host,
136        scheme,
137        headers: header_map,
138        ..Default::default()
139    };
140    CheckRequest {
141        attributes: Some(AttributeContext {
142            request: Some(AttrRequest {
143                http: Some(http),
144                ..Default::default()
145            }),
146            ..Default::default()
147        }),
148    }
149}
150
151/// Interpret a `CheckResponse`: an OK `rpc.Status` (code 0) allows the request.
152fn evaluate(resp: CheckResponse) -> Decision {
153    let allowed = resp.status.as_ref().map(|s| s.code == 0).unwrap_or(false);
154    if allowed {
155        let headers = match resp.http_response {
156            Some(EnvoyHttpResponse::OkResponse(ok)) => {
157                ok.headers.into_iter().filter_map(header_kv).collect()
158            }
159            _ => Vec::new(),
160        };
161        Decision::Allow(headers)
162    } else {
163        let response = match resp.http_response {
164            Some(EnvoyHttpResponse::DeniedResponse(denied))
165            | Some(EnvoyHttpResponse::ErrorResponse(denied)) => {
166                let response = denied_to_response(denied);
167                // A gRPC caller gets the Check's own status, the decision in
168                // gRPC terms; a Check that sets no code maps the HTTP one.
169                let code = match resp.status.as_ref().map(|s| s.code) {
170                    Some(code) if code != 0 => tonic::Code::from_i32(code),
171                    _ => http_to_grpc_code(response.status()),
172                };
173                let message = resp
174                    .status
175                    .map(|s| s.message)
176                    .filter(|m| !m.is_empty())
177                    .unwrap_or_else(|| DENIED.to_string());
178                mark_rejection(response, code, message)
179            }
180            _ => reject(tonic::Code::PermissionDenied, DENIED),
181        };
182        Decision::Deny(response)
183    }
184}
185
186/// The message of a denial that carries none of its own.
187const DENIED: &str = "forbidden by authorization policy";
188
189/// Append the headers an allowed check adds to the upstream request,
190/// preserving multiple values for the same name (e.g. several `Set-Cookie`)
191/// instead of overwriting all but the last. A client-address header, or one
192/// in `reserved` the client-address forwarding writes, is not applied: the
193/// proxy sets those from the resolved address alone.
194fn apply_headers(
195    dst: &mut HeaderMap,
196    headers: Vec<(HeaderName, HeaderValue)>,
197    reserved: &[HeaderName],
198) {
199    for (name, value) in headers {
200        if crate::client_address::owns(name.as_str()) || reserved.contains(&name) {
201            tracing::warn!(header = %name, "ext_authz header ignored: the proxy sets it from the client address");
202            continue;
203        }
204        dst.append(name, value);
205    }
206}
207
208/// Convert an Envoy `HeaderValueOption` into an axum header pair.
209fn header_kv(opt: HeaderValueOption) -> Option<(HeaderName, HeaderValue)> {
210    let header = opt.header?;
211    let name = HeaderName::try_from(header.key).ok()?;
212    let value = HeaderValue::try_from(header.value).ok()?;
213    Some((name, value))
214}
215
216/// Render a denied ext_authz response as an HTTP response (default 403).
217fn denied_to_response(denied: DeniedHttpResponse) -> Response {
218    // Envoy's HttpStatus.code carries the numeric HTTP status directly.
219    let status = denied
220        .status
221        .and_then(|s| u16::try_from(s.code).ok())
222        .and_then(|c| StatusCode::from_u16(c).ok())
223        .unwrap_or(StatusCode::FORBIDDEN);
224    // The denial's own headers go to the client as they are.
225    let mut headers = HeaderMap::new();
226    for (name, value) in denied.headers.into_iter().filter_map(header_kv) {
227        headers.append(name, value);
228    }
229    (status, headers, denied.body).into_response()
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use envoy_types::pb::envoy::config::core::v3::HeaderValue as EnvoyHeaderValue;
236    use envoy_types::pb::envoy::r#type::v3::HttpStatus;
237    use envoy_types::pb::envoy::service::auth::v3::{CheckResponse, OkHttpResponse};
238    use envoy_types::pb::google::rpc::Status as RpcStatus;
239
240    fn hvo(key: &str, value: &str) -> HeaderValueOption {
241        HeaderValueOption {
242            header: Some(EnvoyHeaderValue {
243                key: key.to_string(),
244                value: value.to_string(),
245                ..Default::default()
246            }),
247            ..Default::default()
248        }
249    }
250
251    #[test]
252    fn build_check_request_maps_http_attributes() {
253        let mut headers = HeaderMap::new();
254        headers.insert(HOST, HeaderValue::from_static("api.example.com"));
255        headers.insert("x-forwarded-proto", HeaderValue::from_static("https"));
256        headers.insert("x-forwarded-user", HeaderValue::from_static("alice"));
257        let uri: Uri = "/v1/things?page=2".parse().unwrap();
258
259        let check = build_check_request(&headers, "POST", &uri);
260        let http = check.attributes.unwrap().request.unwrap().http.unwrap();
261        assert_eq!(http.method, "POST");
262        assert_eq!(http.path, "/v1/things");
263        assert_eq!(http.query, "page=2");
264        assert_eq!(http.host, "api.example.com");
265        assert_eq!(http.scheme, "https");
266        // The verified identity header is forwarded for the policy to use.
267        assert_eq!(http.headers.get("x-forwarded-user").unwrap(), "alice");
268    }
269
270    #[test]
271    fn scheme_defaults_to_http() {
272        let headers = HeaderMap::new();
273        let uri: Uri = "/x".parse().unwrap();
274        let check = build_check_request(&headers, "GET", &uri);
275        let http = check.attributes.unwrap().request.unwrap().http.unwrap();
276        assert_eq!(http.scheme, "http");
277    }
278
279    #[test]
280    fn ok_status_allows_and_collects_headers() {
281        let resp = CheckResponse {
282            status: Some(RpcStatus {
283                code: 0,
284                ..Default::default()
285            }),
286            http_response: Some(EnvoyHttpResponse::OkResponse(OkHttpResponse {
287                headers: vec![hvo("x-authz-decision", "allow")],
288                ..Default::default()
289            })),
290            ..Default::default()
291        };
292        match evaluate(resp) {
293            Decision::Allow(headers) => {
294                assert_eq!(headers.len(), 1);
295                assert_eq!(headers[0].0.as_str(), "x-authz-decision");
296                assert_eq!(headers[0].1, "allow");
297            }
298            Decision::Deny(_) => panic!("expected allow"),
299        }
300    }
301
302    #[test]
303    fn apply_headers_preserves_duplicate_names() {
304        // Multiple authz headers with the same name (e.g. Set-Cookie) must all
305        // survive, not collapse to the last one.
306        let mut dst = HeaderMap::new();
307        apply_headers(
308            &mut dst,
309            vec![
310                (
311                    HeaderName::from_static("set-cookie"),
312                    HeaderValue::from_static("a=1"),
313                ),
314                (
315                    HeaderName::from_static("set-cookie"),
316                    HeaderValue::from_static("b=2"),
317                ),
318            ],
319            &[],
320        );
321        let values: Vec<_> = dst
322            .get_all("set-cookie")
323            .iter()
324            .map(|v| v.to_str().unwrap())
325            .collect();
326        assert_eq!(values, vec!["a=1", "b=2"]);
327    }
328
329    #[test]
330    fn an_allowed_check_cannot_set_the_client_address_headers() {
331        // The proxy writes these from the resolved client address: an
332        // ext_authz server's OK headers must not replace it upstream.
333        let mut dst = HeaderMap::new();
334        dst.insert("x-forwarded-for", HeaderValue::from_static("203.0.113.7"));
335        dst.insert("cf-connecting-ip", HeaderValue::from_static("203.0.113.7"));
336        apply_headers(
337            &mut dst,
338            [
339                ("x-forwarded-for", "198.51.100.1"),
340                ("X-Real-IP", "198.51.100.2"),
341                ("forwarded", "for=198.51.100.3"),
342                // A header the configured forwarding writes.
343                ("cf-connecting-ip", "198.51.100.4"),
344                ("x-authz-decision", "allow"),
345            ]
346            .into_iter()
347            .map(|(n, v)| {
348                (
349                    HeaderName::from_bytes(n.as_bytes()).unwrap(),
350                    HeaderValue::from_static(v),
351                )
352            })
353            .collect(),
354            &[HeaderName::from_static("cf-connecting-ip")],
355        );
356        assert_eq!(dst["cf-connecting-ip"], "203.0.113.7");
357        assert_eq!(
358            dst.get_all("x-forwarded-for").iter().collect::<Vec<_>>(),
359            ["203.0.113.7"]
360        );
361        assert!(dst.get("x-real-ip").is_none());
362        assert!(dst.get("forwarded").is_none());
363        assert_eq!(dst["x-authz-decision"], "allow");
364    }
365
366    #[test]
367    fn missing_status_denies() {
368        // An empty CheckResponse (no OK status) must deny, never leak through.
369        let resp = CheckResponse::default();
370        match evaluate(resp) {
371            Decision::Deny(response) => assert_eq!(response.status(), StatusCode::FORBIDDEN),
372            Decision::Allow(_) => panic!("expected deny"),
373        }
374    }
375
376    #[test]
377    fn denied_response_uses_its_status() {
378        let resp = CheckResponse {
379            status: Some(RpcStatus {
380                code: 7, // PermissionDenied
381                ..Default::default()
382            }),
383            http_response: Some(EnvoyHttpResponse::DeniedResponse(DeniedHttpResponse {
384                status: Some(HttpStatus { code: 401 }),
385                body: "nope".to_string(),
386                ..Default::default()
387            })),
388            ..Default::default()
389        };
390        match evaluate(resp) {
391            Decision::Deny(response) => assert_eq!(response.status(), StatusCode::UNAUTHORIZED),
392            Decision::Allow(_) => panic!("expected deny"),
393        }
394    }
395
396    #[tokio::test]
397    async fn fail_closed_returns_503_when_authz_unreachable() {
398        use axum::routing::get;
399        use axum::Router;
400        use tower::ServiceExt;
401
402        let authz = Authz::build(&AuthzConfig {
403            enabled: true,
404            endpoint: "http://127.0.0.1:1".into(),
405            timeout_ms: 100,
406            failure_mode_allow: false,
407            scope: None,
408        })
409        .unwrap()
410        .unwrap();
411        let app: Router = Router::new()
412            .route("/x", get(|| async { "upstream" }))
413            .layer(axum::middleware::from_fn_with_state(authz, middleware));
414        let resp = app
415            .oneshot(
416                axum::http::Request::get("/x")
417                    .body(axum::body::Body::empty())
418                    .unwrap(),
419            )
420            .await
421            .unwrap();
422        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
423    }
424
425    #[tokio::test]
426    async fn fail_open_passes_through_when_authz_unreachable() {
427        use axum::routing::get;
428        use axum::Router;
429        use tower::ServiceExt;
430
431        let authz = Authz::build(&AuthzConfig {
432            enabled: true,
433            endpoint: "http://127.0.0.1:1".into(),
434            timeout_ms: 100,
435            failure_mode_allow: true,
436            scope: None,
437        })
438        .unwrap()
439        .unwrap();
440        let app: Router = Router::new()
441            .route("/x", get(|| async { "upstream" }))
442            .layer(axum::middleware::from_fn_with_state(authz, middleware));
443        let resp = app
444            .oneshot(
445                axum::http::Request::get("/x")
446                    .body(axum::body::Body::empty())
447                    .unwrap(),
448            )
449            .await
450            .unwrap();
451        assert_eq!(resp.status(), StatusCode::OK);
452    }
453}