1#![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}