Coverage for lambda / regional-api-proxy / handler.py: 100.00%
128 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"""Regional API proxy for authenticated access to one region's internal ALB.
3The Lambda runs in the regional VPC. API Gateway authenticates callers with
4IAM, then this function resolves the platform Ingress ALB, signs the exact
5backend request with a short-lived HMAC envelope, and forwards it privately.
7Environment variables:
8 SECRET_ARN: Secrets Manager ARN containing the backend HMAC signing key.
9 REGISTRY_REGION: Region containing the project ALB-hostname SSM registry.
10 TARGET_REGION: Region served by this regional API.
11 PROJECT_NAME: Deployment prefix used by the SSM path and EKS cluster name.
12 AWS_ACCOUNT_ID: Account that must own the resolved ALB.
13 AWS_URL_SUFFIX: CDK-resolved DNS suffix for the deployment partition.
14 ALB_ENDPOINT: Optional literal ALB DNS name for compatibility/isolated use.
15 REGIONAL_ENDPOINT_CACHE_TTL_SECONDS: Registry cache TTL, bounded to 0-300
16 seconds (default: 60; 0 disables caching).
17 PROXY_MAX_RETRIES: Max attempts per request (default: 3). Read-only
18 methods spend them on any retryable answer; mutating methods only on
19 the ALB's own 503 page, served before a target is selected.
20 PROXY_RETRY_BACKOFF_BASE: Base retry backoff in seconds (default: 0.3).
21 SECRET_CACHE_TTL_SECONDS: Signing-key cache TTL in seconds (default: 300).
22 BACKEND_TLS_SERVER_NAME: Private certificate identity asserted via SNI.
23 BACKEND_TLS_ROOT_CA_PARAMETER: SSM parameter containing public CA roots.
24 BACKEND_TLS_ROOT_CA_REGION: Region containing the public trust parameter.
25 BACKEND_TLS_CA_CACHE_TTL_SECONDS: Normal trust refresh interval.
26 BACKEND_TLS_CA_MAX_STALE_SECONDS: Maximum bounded stale-trust interval.
27"""
29import json
30import logging
31import os
32import re
33import time
34from typing import Any
36import boto3
37from botocore.exceptions import BotoCoreError, ClientError
38from proxy_utils import (
39 build_signed_headers,
40 build_target_url,
41 caller_identity_headers,
42 forward_request,
43 get_secret_token,
44 sanitize_request_headers,
45)
47# <pyflowchart-code-diagram> BEGIN - auto-inserted, do not edit
48# Generated at (UTC): 2026-08-30T12:00:00Z
49# Generated from Git commit: 8cb8e766461f5b7f35205e12db980988598cf6d5
50# Flowchart(s) generated from this file:
51# * ``lambda_handler`` -> ``diagrams/code_diagrams/lambda/regional-api-proxy/handler.lambda_handler.html``
52# (PNG: ``diagrams/code_diagrams/lambda/regional-api-proxy/handler.lambda_handler.png``)
53# Regenerate with ``SOURCE_DATE_EPOCH=<unix-seconds> GCO_DIAGRAM_SOURCE_COMMIT=<40-char-sha> python diagrams/generate.py --code-only``.
54# <pyflowchart-code-diagram> END
57_LOGGER = logging.getLogger(__name__)
58_MAX_FORWARD_REQUEST_SECONDS = 28.0
59_LAMBDA_RESPONSE_HEADROOM_SECONDS = 1.0
60_REGION_RE = re.compile(r"^[a-z]{2,4}(?:-[a-z0-9]+)+-[0-9]+$")
61_DNS_NAME_RE = re.compile(
62 r"(?=.{1,253}\Z)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+"
63 r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?",
64 re.IGNORECASE,
65)
66_REGIONAL_ENDPOINT_CACHE: dict[tuple[str, str, str, str], tuple[float, str]] = {}
67_BACKEND_AUTH_ERRORS = (KeyError, RuntimeError)
70def _regional_endpoint_cache_ttl() -> float:
71 """Return the bounded registry cache TTL; zero disables caching."""
72 try:
73 value = float(os.getenv("REGIONAL_ENDPOINT_CACHE_TTL_SECONDS", "60"))
74 except ValueError:
75 return 60.0
76 return value if 0 <= value <= 300 else 60.0
79def _aws_url_suffix() -> str:
80 """Return the CDK-resolved DNS suffix for this deployment partition."""
81 suffix = os.getenv("AWS_URL_SUFFIX", "").strip().lower()
82 if _DNS_NAME_RE.fullmatch(suffix) is None:
83 raise RuntimeError("The AWS URL suffix is not configured")
84 return suffix
87def _validated_dns_name(value: Any, *, region: str) -> str:
88 """Normalize an ELB DNS name and reject non-ELB hostnames."""
89 endpoint = str(value or "").strip().rstrip(".")
90 expected_suffix = f".elb.{_aws_url_suffix()}"
91 if _DNS_NAME_RE.fullmatch(endpoint) is None or not endpoint.lower().endswith(expected_suffix):
92 raise RuntimeError(f"The registered backend for {region} is invalid")
93 return endpoint
96def _validate_regional_endpoint_ownership(endpoint: str, region: str) -> None:
97 """Verify that the DNS name is this account's internal GCO platform ALB."""
98 expected_account = os.getenv("AWS_ACCOUNT_ID", "").strip()
99 project_name = os.getenv("PROJECT_NAME", "").strip()
100 if not expected_account or not project_name:
101 raise RuntimeError("Regional endpoint ownership validation is not configured")
103 client = boto3.client("elbv2", region_name=region)
104 marker: str | None = None
105 matched: dict[str, Any] | None = None
106 for _ in range(20):
107 kwargs = {"Marker": marker} if marker else {}
108 response = client.describe_load_balancers(**kwargs)
109 for load_balancer in response.get("LoadBalancers", []):
110 dns_name = str(load_balancer.get("DNSName", "")).rstrip(".")
111 if dns_name.lower() == endpoint.lower():
112 matched = load_balancer
113 break
114 if matched is not None:
115 break
116 marker = response.get("NextMarker")
117 if not marker:
118 break
119 if matched is None:
120 raise RuntimeError(f"The registered backend for {region} does not exist")
121 if matched.get("Type") != "application" or matched.get("Scheme") != "internal":
122 raise RuntimeError(f"The registered backend for {region} is not an internal ALB")
124 arn = str(matched.get("LoadBalancerArn", ""))
125 arn_parts = arn.split(":", 5)
126 if (
127 len(arn_parts) != 6
128 or arn_parts[2] != "elasticloadbalancing"
129 or arn_parts[3] != region
130 or arn_parts[4] != expected_account
131 ):
132 raise RuntimeError(f"The registered backend for {region} has invalid ownership")
134 tag_response = client.describe_tags(ResourceArns=[arn])
135 descriptions = tag_response.get("TagDescriptions", [])
136 tags = {
137 str(tag.get("Key")): str(tag.get("Value"))
138 for description in descriptions
139 for tag in description.get("Tags", [])
140 }
141 expected_cluster = f"{project_name}-{region}"
142 cluster_match = (
143 tags.get("eks:eks-cluster-name") == expected_cluster
144 or tags.get("elbv2.k8s.aws/cluster") == expected_cluster
145 )
146 if not cluster_match:
147 raise RuntimeError(f"The registered backend for {region} is not owned by the GCO cluster")
149 # Accept only the explicit Gateway ownership marker; a cluster tag alone
150 # is never sufficient.
151 platform_match = tags.get("gco.aws/gateway") == "gco-system/gco-gateway"
152 if not platform_match:
153 raise RuntimeError(f"The registered backend for {region} is not the GCO Gateway")
156def _resolve_registered_endpoint() -> str:
157 """Resolve and verify the regional ALB, retaining literal compatibility."""
158 target_region = os.getenv("TARGET_REGION", "").strip()
159 literal_endpoint = os.getenv("ALB_ENDPOINT", "").strip()
160 if literal_endpoint:
161 return _validated_dns_name(literal_endpoint, region=target_region or "configured region")
163 registry_region = os.getenv("REGISTRY_REGION", "").strip()
164 project_name = os.getenv("PROJECT_NAME", "").strip()
165 expected_account = os.getenv("AWS_ACCOUNT_ID", "").strip()
166 if (
167 _REGION_RE.fullmatch(registry_region) is None
168 or _REGION_RE.fullmatch(target_region) is None
169 or not project_name
170 or not expected_account
171 ):
172 raise RuntimeError("Regional endpoint registry is not configured")
174 cache_key = (registry_region, target_region, project_name, expected_account)
175 ttl = _regional_endpoint_cache_ttl()
176 now = time.monotonic()
177 cached = _REGIONAL_ENDPOINT_CACHE.get(cache_key)
178 if ttl > 0 and cached is not None and now - cached[0] < ttl:
179 return cached[1]
181 parameter_name = f"/{project_name}/alb-hostname-{target_region}"
182 try:
183 response = boto3.client("ssm", region_name=registry_region).get_parameter(
184 Name=parameter_name
185 )
186 endpoint = _validated_dns_name(
187 response.get("Parameter", {}).get("Value"), region=target_region
188 )
189 _validate_regional_endpoint_ownership(endpoint, target_region)
190 except (BotoCoreError, ClientError) as exc:
191 raise RuntimeError(
192 f"The registered backend for {target_region} could not be verified"
193 ) from exc
195 _REGIONAL_ENDPOINT_CACHE[cache_key] = (time.monotonic(), endpoint)
196 return endpoint
199def _error_response(status_code: int, message: str) -> dict[str, Any]:
200 """Build a bounded API Gateway error response."""
201 return {
202 "statusCode": status_code,
203 "headers": {"Content-Type": "application/json"},
204 "body": json.dumps({"error": message}),
205 }
208def _get_request_timeout_seconds(context: Any) -> float:
209 """Bound upstream work while retaining time to return a Lambda response."""
210 get_remaining_time = getattr(context, "get_remaining_time_in_millis", None)
211 if not callable(get_remaining_time):
212 return _MAX_FORWARD_REQUEST_SECONDS
214 remaining_seconds = float(get_remaining_time()) / 1000.0
215 available_seconds = remaining_seconds - _LAMBDA_RESPONSE_HEADROOM_SECONDS
216 return max(0.0, min(_MAX_FORWARD_REQUEST_SECONDS, available_seconds))
219def lambda_handler(event: dict[str, Any], context: Any) -> dict[str, Any]:
220 """Proxy one IAM-authenticated API Gateway request to the internal ALB."""
221 try:
222 signing_key = get_secret_token()
223 except _BACKEND_AUTH_ERRORS:
224 return _error_response(503, "Backend authentication is temporarily unavailable")
226 try:
227 alb_endpoint = _resolve_registered_endpoint()
228 except RuntimeError as exc:
229 _LOGGER.warning("Regional backend resolution failed: %s", exc)
230 return _error_response(502, "Regional backend is temporarily unavailable")
232 http_method = event["httpMethod"]
233 path = event["path"]
234 query_string = (
235 event.get("multiValueQueryStringParameters") or event.get("queryStringParameters") or {}
236 )
237 headers = dict(event.get("headers") or {})
238 body = event.get("body") or ""
239 if event.get("isBase64Encoded"):
240 return _error_response(415, "Base64-encoded request bodies are not supported")
242 target_url = build_target_url(alb_endpoint, path, query_string)
243 headers = sanitize_request_headers(headers)
244 headers.update(caller_identity_headers(event))
245 headers.update(build_signed_headers(signing_key, http_method, target_url, body))
247 return forward_request(
248 target_url,
249 http_method,
250 headers,
251 body,
252 timeout=_get_request_timeout_seconds(context),
253 )