1#![forbid(unsafe_code)]
3
4use anyhow::{Context, Result};
5use axum::{Router, extract::Request, middleware::Next, response::Response};
6use http::Uri;
7use lambda_http::{RequestExt, request::RequestContext};
8use minco_http::Principal;
9use std::collections::{BTreeMap, BTreeSet};
10use tower::ServiceExt;
11
12pub async fn run_router(router: Router) -> Result<()> {
13 let service = lambda_http::service_fn(move |request: lambda_http::Request| {
14 route_request(router.clone(), request)
15 });
16 lambda_http::run(service)
17 .await
18 .map_err(|error| anyhow::anyhow!("Lambda HTTP runtime failed: {error}"))
19}
20
21async fn route_request(
22 router: Router,
23 mut request: lambda_http::Request,
24) -> std::result::Result<Response, std::convert::Infallible> {
25 strip_api_gateway_stage_from_uri(&mut request);
26 router.oneshot(request).await
27}
28
29fn strip_api_gateway_stage_from_uri(request: &mut lambda_http::Request) -> bool {
30 let Some(RequestContext::ApiGatewayV2(context)) = request.request_context_ref() else {
31 return false;
32 };
33 let Some(stage) = context.stage.as_deref() else {
34 return false;
35 };
36 if stage.is_empty() || stage == "$default" {
37 return false;
38 }
39
40 let raw_path = request.raw_http_path().to_owned();
44 let source_path = if raw_path.is_empty() {
45 request.uri().path()
46 } else {
47 &raw_path
48 };
49 let prefix = format!("/{stage}");
50 let normalized_path = if source_path == prefix {
51 "/"
52 } else if let Some(suffix) = source_path.strip_prefix(&prefix) {
53 if suffix.starts_with('/') {
54 suffix
55 } else {
56 source_path
57 }
58 } else {
59 source_path
60 };
61
62 if normalized_path == request.uri().path() {
63 return false;
64 }
65 let normalized_path_and_query = match request.uri().query() {
66 Some(query) => format!("{normalized_path}?{query}"),
67 None => normalized_path.to_owned(),
68 };
69 let Ok(path_and_query) = normalized_path_and_query.parse() else {
70 return false;
71 };
72 let mut parts = request.uri().clone().into_parts();
73 parts.path_and_query = Some(path_and_query);
74 let Ok(uri) = Uri::from_parts(parts) else {
75 return false;
76 };
77 *request.uri_mut() = uri;
78 true
79}
80
81pub async fn inject_api_gateway_principal(mut request: Request, next: Next) -> Response {
82 if let Some(principal) = principal_from_request_context(request.request_context_ref()) {
83 request.extensions_mut().insert(principal);
84 }
85 next.run(request).await
86}
87
88#[must_use]
89pub fn principal_from_request_context(context: Option<&RequestContext>) -> Option<Principal> {
90 let RequestContext::ApiGatewayV2(context) = context? else {
91 return None;
92 };
93 let authorizer = context.authorizer.as_ref()?;
94 let value = serde_json::to_value(authorizer).ok()?;
95 let claims = value
96 .pointer("/jwt/claims")
97 .or_else(|| value.get("claims"))?
98 .as_object()?;
99 principal_from_claims(claims)
100}
101
102fn principal_from_claims(claims: &serde_json::Map<String, serde_json::Value>) -> Option<Principal> {
103 let subject = claims.get("sub")?.as_str()?.trim();
104 if subject.is_empty() {
105 return None;
106 }
107 let claims = claims
108 .iter()
109 .filter_map(|(key, value)| value.as_str().map(|value| (key.clone(), value.to_owned())))
110 .collect::<BTreeMap<_, _>>();
111 let mut permissions = BTreeSet::new();
112 for claim in ["scope", "permissions", "custom:permissions"] {
113 if let Some(value) = claims.get(claim) {
114 permissions.extend(
115 value
116 .split([',', ' '])
117 .map(str::trim)
118 .filter(|value| !value.is_empty())
119 .map(str::to_owned),
120 );
121 }
122 }
123 Some(Principal {
124 subject: subject.to_owned(),
125 permissions,
126 claims,
127 })
128}
129
130pub async fn load_secure_parameter(name: &str) -> Result<String> {
131 if name.trim().is_empty() {
132 anyhow::bail!("SSM parameter name is empty");
133 }
134 let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
135 let response = aws_sdk_ssm::Client::new(&config)
136 .get_parameter()
137 .name(name)
138 .with_decryption(true)
139 .send()
140 .await
141 .with_context(|| format!("failed to load SSM parameter {name}"))?;
142 response
143 .parameter
144 .and_then(|parameter| parameter.value)
145 .filter(|value| !value.is_empty())
146 .with_context(|| format!("SSM parameter {name} has no value"))
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152 use axum::{http::StatusCode, routing::get};
153
154 fn gateway_request(uri: &str, stage: Option<&str>) -> lambda_http::Request {
155 let uri = uri.parse::<Uri>().expect("request URI is valid");
156 let path = uri.path();
157 let query = uri.query().unwrap_or_default();
158 let event = serde_json::json!({
159 "version": "2.0",
160 "routeKey": "GET /health/live",
161 "rawPath": path,
162 "rawQueryString": query,
163 "headers": {
164 "host": "example.execute-api.invalid"
165 },
166 "requestContext": {
167 "accountId": "123456789012",
168 "apiId": "api-id",
169 "domainName": "example.execute-api.invalid",
170 "domainPrefix": "example",
171 "http": {
172 "method": "GET",
173 "path": path,
174 "protocol": "HTTP/1.1",
175 "sourceIp": "127.0.0.1",
176 "userAgent": "minco-test"
177 },
178 "requestId": "request-id",
179 "routeKey": "GET /health/live",
180 "stage": stage,
181 "time": "30/Jul/2026:09:06:25 +0000",
182 "timeEpoch": 1_785_402_385_000_u64
183 },
184 "isBase64Encoded": false
185 });
186 lambda_http::request::from_str(&event.to_string()).expect("API Gateway v2 event is valid")
187 }
188
189 #[test]
190 fn absent_gateway_context_is_anonymous() {
191 assert!(principal_from_request_context(None).is_none());
192 }
193
194 #[test]
195 fn non_gateway_requests_are_not_rewritten() {
196 let mut request = http::Request::builder()
197 .uri("/candidate/health/live")
198 .body(lambda_http::Body::Empty)
199 .expect("request is valid");
200
201 assert!(!strip_api_gateway_stage_from_uri(&mut request));
202 assert_eq!(request.uri().path(), "/candidate/health/live");
203 }
204
205 #[test]
206 fn maps_locked_cognito_permission_attributes() {
207 let claims = serde_json::json!({
208 "sub": "smoke-user",
209 "custom:permissions": "orders.create orders.read",
210 "aud": "client-id"
211 });
212 let principal =
213 principal_from_claims(claims.as_object().expect("claims")).expect("principal");
214 assert_eq!(principal.subject, "smoke-user");
215 assert!(principal.permissions.contains("orders.create"));
216 assert!(principal.permissions.contains("orders.read"));
217 }
218
219 #[test]
220 fn strips_the_exact_named_stage_before_axum_routing() {
221 let mut request = gateway_request(
222 "https://example.execute-api.invalid/candidate/health/live?probe=1",
223 Some("candidate"),
224 );
225
226 assert!(strip_api_gateway_stage_from_uri(&mut request));
227 assert_eq!(request.uri().path(), "/health/live");
228 assert_eq!(request.uri().query(), Some("probe=1"));
229 assert_eq!(
230 request.uri().authority().map(http::uri::Authority::as_str),
231 Some("example.execute-api.invalid")
232 );
233 }
234
235 #[test]
236 fn named_stage_normalization_is_boundary_safe() {
237 let mut root = gateway_request("/candidate?probe=1", Some("candidate"));
238 assert!(strip_api_gateway_stage_from_uri(&mut root));
239 assert_eq!(
240 root.uri()
241 .path_and_query()
242 .map(http::uri::PathAndQuery::as_str),
243 Some("/?probe=1")
244 );
245
246 let mut different_prefix = gateway_request("/candidate-v2/health/live", Some("candidate"));
247 assert_eq!(
248 different_prefix.uri().path(),
249 "/candidate/candidate-v2/health/live"
250 );
251 assert!(strip_api_gateway_stage_from_uri(&mut different_prefix));
252 assert_eq!(different_prefix.uri().path(), "/candidate-v2/health/live");
253
254 let mut unprefixed = gateway_request("/health/live", Some("candidate"));
255 assert_eq!(unprefixed.uri().path(), "/candidate/health/live");
256 assert!(strip_api_gateway_stage_from_uri(&mut unprefixed));
257 assert_eq!(unprefixed.uri().path(), "/health/live");
258
259 let mut default_stage = gateway_request("/health/live", Some("$default"));
260 assert!(!strip_api_gateway_stage_from_uri(&mut default_stage));
261 assert_eq!(default_stage.uri().path(), "/health/live");
262 }
263
264 #[tokio::test]
265 async fn named_stage_is_removed_before_axum_route_matching() {
266 let router = Router::new().route("/health/live", get(|| async { StatusCode::NO_CONTENT }));
267 let request = gateway_request("/candidate/health/live", Some("candidate"));
268 assert_eq!(request.uri().path(), "/candidate/health/live");
269
270 let response = route_request(router, request)
271 .await
272 .expect("router service is infallible");
273
274 assert_eq!(response.status(), StatusCode::NO_CONTENT);
275 }
276}