Skip to main content

minco_aws_lambda/
lib.rs

1//! Native Lambda HTTP runtime, API Gateway principal mapping and SSM configuration loading.
2#![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    // API Gateway's original `rawPath` is the routing source of truth. `lambda_http`
41    // may already have prepended the named stage while constructing the URI, including
42    // for raw paths that only resemble the stage prefix.
43    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}