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}
36
37impl Authz {
38 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
57pub 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 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
92enum Decision {
94 Allow(Vec<(HeaderName, HeaderValue)>),
96 Deny(Response),
98}
99
100fn 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
139fn 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 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
174const DENIED: &str = "forbidden by authorization policy";
176
177fn apply_headers(dst: &mut HeaderMap, headers: Vec<(HeaderName, HeaderValue)>) {
180 for (name, value) in headers {
181 dst.append(name, value);
182 }
183}
184
185fn 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
193fn denied_to_response(denied: DeniedHttpResponse) -> Response {
195 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 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 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 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, ..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}