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 lambda_http::{RequestExt, request::RequestContext};
7use minco_http::Principal;
8use std::collections::{BTreeMap, BTreeSet};
9
10pub async fn run_router(router: Router) -> Result<()> {
11    lambda_http::run(router)
12        .await
13        .map_err(|error| anyhow::anyhow!("Lambda HTTP runtime failed: {error}"))
14}
15
16pub async fn inject_api_gateway_principal(mut request: Request, next: Next) -> Response {
17    if let Some(principal) = principal_from_request_context(request.request_context_ref()) {
18        request.extensions_mut().insert(principal);
19    }
20    next.run(request).await
21}
22
23#[must_use]
24pub fn principal_from_request_context(context: Option<&RequestContext>) -> Option<Principal> {
25    let RequestContext::ApiGatewayV2(context) = context? else {
26        return None;
27    };
28    let authorizer = context.authorizer.as_ref()?;
29    let value = serde_json::to_value(authorizer).ok()?;
30    let claims = value
31        .pointer("/jwt/claims")
32        .or_else(|| value.get("claims"))?
33        .as_object()?;
34    principal_from_claims(claims)
35}
36
37fn principal_from_claims(claims: &serde_json::Map<String, serde_json::Value>) -> Option<Principal> {
38    let subject = claims.get("sub")?.as_str()?.trim();
39    if subject.is_empty() {
40        return None;
41    }
42    let claims = claims
43        .iter()
44        .filter_map(|(key, value)| value.as_str().map(|value| (key.clone(), value.to_owned())))
45        .collect::<BTreeMap<_, _>>();
46    let mut permissions = BTreeSet::new();
47    for claim in ["scope", "permissions", "custom:permissions"] {
48        if let Some(value) = claims.get(claim) {
49            permissions.extend(
50                value
51                    .split([',', ' '])
52                    .map(str::trim)
53                    .filter(|value| !value.is_empty())
54                    .map(str::to_owned),
55            );
56        }
57    }
58    Some(Principal {
59        subject: subject.to_owned(),
60        permissions,
61        claims,
62    })
63}
64
65pub async fn load_secure_parameter(name: &str) -> Result<String> {
66    if name.trim().is_empty() {
67        anyhow::bail!("SSM parameter name is empty");
68    }
69    let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
70    let response = aws_sdk_ssm::Client::new(&config)
71        .get_parameter()
72        .name(name)
73        .with_decryption(true)
74        .send()
75        .await
76        .with_context(|| format!("failed to load SSM parameter {name}"))?;
77    response
78        .parameter
79        .and_then(|parameter| parameter.value)
80        .filter(|value| !value.is_empty())
81        .with_context(|| format!("SSM parameter {name} has no value"))
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn absent_gateway_context_is_anonymous() {
90        assert!(principal_from_request_context(None).is_none());
91    }
92
93    #[test]
94    fn maps_locked_cognito_permission_attributes() {
95        let claims = serde_json::json!({
96            "sub": "smoke-user",
97            "custom:permissions": "orders.create orders.read",
98            "aud": "client-id"
99        });
100        let principal =
101            principal_from_claims(claims.as_object().expect("claims")).expect("principal");
102        assert_eq!(principal.subject, "smoke-user");
103        assert!(principal.permissions.contains("orders.create"));
104        assert!(principal.permissions.contains("orders.read"));
105    }
106}