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