Coverage for lambda / api-gateway-proxy / handler.py: 100.00%
43 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
1"""Global API Gateway proxy for the authenticated TLS backend path.
3API Gateway authenticates callers with IAM, this Lambda signs each exact
4request with the deployment HMAC key, and a strict private-root TLS transport
5forwards it through Global Accelerator to a healthy regional ALB. The reusable
6HMAC key and the root CA private key are never transmitted.
8``X-GCO-Target-Region`` is intentionally rejected here. This Lambda is not
9attached to regional VPCs and therefore cannot route directly to private ALBs;
10authorized callers that need region pinning must invoke that region's always-
11deployed API bridge directly. Direct caller access is an explicit opt-in even
12though the bridge itself is required for aggregation.
14Environment variables:
15 GLOBAL_ACCELERATOR_ENDPOINT: Global Accelerator DNS name.
16 SECRET_ARN: Secrets Manager ARN containing the HMAC signing key.
17 BACKEND_TLS_SERVER_NAME: Private certificate identity asserted via SNI.
18 BACKEND_TLS_ROOT_CA_PARAMETER: SSM parameter containing public CA roots.
19 BACKEND_TLS_ROOT_CA_REGION: Region containing the public trust parameter.
20 PROXY_MAX_RETRIES: Max attempts per request (default: 3). Read-only
21 methods spend them on any retryable answer; mutating methods only on
22 the ALB's own 503 page, served before a target is selected.
23 PROXY_RETRY_BACKOFF_BASE: Base retry backoff in seconds (default: 0.3).
24 SECRET_CACHE_TTL_SECONDS: Signing-key cache TTL in seconds (default: 300).
25"""
27import json
28import os
29from typing import Any
31from proxy_utils import (
32 build_signed_headers,
33 build_target_url,
34 caller_identity_headers,
35 forward_request,
36 get_secret_token,
37 sanitize_request_headers,
38)
40# <pyflowchart-code-diagram> BEGIN - auto-inserted, do not edit
41# Generated at (UTC): 2026-08-30T12:00:00Z
42# Generated from Git commit: 8cb8e766461f5b7f35205e12db980988598cf6d5
43# Flowchart(s) generated from this file:
44# * ``lambda_handler`` -> ``diagrams/code_diagrams/lambda/api-gateway-proxy/handler.lambda_handler.html``
45# (PNG: ``diagrams/code_diagrams/lambda/api-gateway-proxy/handler.lambda_handler.png``)
46# Regenerate with ``SOURCE_DATE_EPOCH=<unix-seconds> GCO_DIAGRAM_SOURCE_COMMIT=<40-char-sha> python diagrams/generate.py --code-only``.
47# <pyflowchart-code-diagram> END
50_MAX_FORWARD_REQUEST_SECONDS = 28.0
51_LAMBDA_RESPONSE_HEADROOM_SECONDS = 1.0
54def _pop_header(headers: dict[str, str], header_name: str) -> str | None:
55 """Remove and return one case-insensitive header value."""
56 for key in list(headers):
57 if key.lower() == header_name.lower():
58 value = headers.pop(key)
59 return str(value).strip() if value is not None else ""
60 return None
63def _error_response(status_code: int, message: str) -> dict[str, Any]:
64 """Build a bounded API Gateway error response."""
65 return {
66 "statusCode": status_code,
67 "headers": {"Content-Type": "application/json"},
68 "body": json.dumps({"error": message}),
69 }
72def _get_request_timeout_seconds(context: Any) -> float:
73 """Bound upstream work while retaining time to return a Lambda response."""
74 get_remaining_time = getattr(context, "get_remaining_time_in_millis", None)
75 if not callable(get_remaining_time):
76 return _MAX_FORWARD_REQUEST_SECONDS
78 remaining_seconds = float(get_remaining_time()) / 1000.0
79 available_seconds = remaining_seconds - _LAMBDA_RESPONSE_HEADROOM_SECONDS
80 return max(0.0, min(_MAX_FORWARD_REQUEST_SECONDS, available_seconds))
83def lambda_handler(event: dict[str, Any], context: Any) -> dict[str, Any]:
84 """Proxy one IAM-authenticated request through Global Accelerator."""
85 try:
86 signing_key = get_secret_token()
87 except KeyError, RuntimeError:
88 return _error_response(503, "Backend authentication is temporarily unavailable")
90 http_method = event["httpMethod"]
91 path = event["path"]
92 query_string = (
93 event.get("multiValueQueryStringParameters") or event.get("queryStringParameters") or {}
94 )
95 headers = dict(event.get("headers") or {})
97 # A non-VPC Lambda cannot reach any region's internal ALB directly. Never
98 # pretend region pinning succeeded or silently route the request elsewhere.
99 if _pop_header(headers, "X-GCO-Target-Region") is not None:
100 return _error_response(
101 400,
102 "X-GCO-Target-Region is not supported by the global endpoint; "
103 "use the target region's regional API endpoint if authorized for direct access",
104 )
106 body = event.get("body") or ""
107 if event.get("isBase64Encoded"):
108 return _error_response(415, "Base64-encoded request bodies are not supported")
110 try:
111 target_url = build_target_url(
112 os.environ["GLOBAL_ACCELERATOR_ENDPOINT"],
113 path,
114 query_string,
115 )
116 except KeyError, ValueError:
117 return _error_response(503, "Global backend routing is temporarily unavailable")
119 # Only a short-lived HMAC envelope is transmitted. TLS independently
120 # authenticates the ALB certificate and encrypts the complete request.
121 headers = sanitize_request_headers(headers)
122 headers.update(caller_identity_headers(event))
123 headers.update(build_signed_headers(signing_key, http_method, target_url, body))
125 return forward_request(
126 target_url,
127 http_method,
128 headers,
129 body,
130 timeout=_get_request_timeout_seconds(context),
131 )