1use 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
30pub struct Authz {
32 channel: Channel,
33 timeout: Duration,
34 failure_mode_allow: bool,
35 reserved: Arc<[HeaderName]>,
38}
39
40impl Authz {
41 pub fn build(config: &AuthzConfig) -> Result<Option<Arc<Self>>, String> {
46 Self::build_reserving(config, Arc::from([]))
47 }
48
49 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
69pub 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 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
104enum Decision {
106 Allow(Vec<(HeaderName, HeaderValue)>),
108 Deny(Response),
110}
111
112fn 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
151fn 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 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
186const DENIED: &str = "forbidden by authorization policy";
188
189fn 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
208fn 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
216fn denied_to_response(denied: DeniedHttpResponse) -> Response {
218 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 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 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 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 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 ("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 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, ..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}