Coverage for lambda / regional-api-proxy / handler.py: 100.00%
127 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +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 for safe read-only methods (default: 3).
18 PROXY_RETRY_BACKOFF_BASE: Base retry backoff in seconds (default: 0.3).
19 SECRET_CACHE_TTL_SECONDS: Signing-key cache TTL in seconds (default: 300).
20 BACKEND_TLS_SERVER_NAME: Private certificate identity asserted via SNI.
21 BACKEND_TLS_ROOT_CA_PARAMETER: SSM parameter containing public CA roots.
22 BACKEND_TLS_ROOT_CA_REGION: Region containing the public trust parameter.
23 BACKEND_TLS_CA_CACHE_TTL_SECONDS: Normal trust refresh interval.
24 BACKEND_TLS_CA_MAX_STALE_SECONDS: Maximum bounded stale-trust interval.
25"""
27import json
28import logging
29import os
30import re
31import time
32from typing import Any
34import boto3
35from botocore.exceptions import BotoCoreError, ClientError
36from proxy_utils import (
37 build_signed_headers,
38 build_target_url,
39 forward_request,
40 get_secret_token,
41 sanitize_request_headers,
42)
44# <pyflowchart-code-diagram> BEGIN - auto-inserted, do not edit
45# Generated at (UTC): 2026-09-01T14:42:56Z
46# Generated from Git commit: 89b000378ed5a912a38c06f4feab2b029936ebcc
47# Flowchart(s) generated from this file:
48# * ``lambda_handler`` -> ``diagrams/code_diagrams/lambda/regional-api-proxy/handler.lambda_handler.html``
49# (PNG: ``diagrams/code_diagrams/lambda/regional-api-proxy/handler.lambda_handler.png``)
50# Regenerate with ``SOURCE_DATE_EPOCH=<unix-seconds> GCO_DIAGRAM_SOURCE_COMMIT=<40-char-sha> python diagrams/generate.py --code-only``.
51# <pyflowchart-code-diagram> END
54_LOGGER = logging.getLogger(__name__)
55_MAX_FORWARD_REQUEST_SECONDS = 28.0
56_LAMBDA_RESPONSE_HEADROOM_SECONDS = 1.0
57_REGION_RE = re.compile(r"^[a-z]{2,4}(?:-[a-z0-9]+)+-[0-9]+$")
58_DNS_NAME_RE = re.compile(
59 r"(?=.{1,253}\Z)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+"
60 r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?",
61 re.IGNORECASE,
62)
63_REGIONAL_ENDPOINT_CACHE: dict[tuple[str, str, str, str], tuple[float, str]] = {}
64_BACKEND_AUTH_ERRORS = (KeyError, RuntimeError)
67def _regional_endpoint_cache_ttl() -> float:
68 """Return the bounded registry cache TTL; zero disables caching."""
69 try:
70 value = float(os.getenv("REGIONAL_ENDPOINT_CACHE_TTL_SECONDS", "60"))
71 except ValueError:
72 return 60.0
73 return value if 0 <= value <= 300 else 60.0
76def _aws_url_suffix() -> str:
77 """Return the CDK-resolved DNS suffix for this deployment partition."""
78 suffix = os.getenv("AWS_URL_SUFFIX", "").strip().lower()
79 if _DNS_NAME_RE.fullmatch(suffix) is None:
80 raise RuntimeError("The AWS URL suffix is not configured")
81 return suffix
84def _validated_dns_name(value: Any, *, region: str) -> str:
85 """Normalize an ELB DNS name and reject non-ELB hostnames."""
86 endpoint = str(value or "").strip().rstrip(".")
87 expected_suffix = f".elb.{_aws_url_suffix()}"
88 if _DNS_NAME_RE.fullmatch(endpoint) is None or not endpoint.lower().endswith(expected_suffix):
89 raise RuntimeError(f"The registered backend for {region} is invalid")
90 return endpoint
93def _validate_regional_endpoint_ownership(endpoint: str, region: str) -> None:
94 """Verify that the DNS name is this account's internal GCO platform ALB."""
95 expected_account = os.getenv("AWS_ACCOUNT_ID", "").strip()
96 project_name = os.getenv("PROJECT_NAME", "").strip()
97 if not expected_account or not project_name:
98 raise RuntimeError("Regional endpoint ownership validation is not configured")
100 client = boto3.client("elbv2", region_name=region)
101 marker: str | None = None
102 matched: dict[str, Any] | None = None
103 for _ in range(20):
104 kwargs = {"Marker": marker} if marker else {}
105 response = client.describe_load_balancers(**kwargs)
106 for load_balancer in response.get("LoadBalancers", []):
107 dns_name = str(load_balancer.get("DNSName", "")).rstrip(".")
108 if dns_name.lower() == endpoint.lower():
109 matched = load_balancer
110 break
111 if matched is not None:
112 break
113 marker = response.get("NextMarker")
114 if not marker:
115 break
116 if matched is None:
117 raise RuntimeError(f"The registered backend for {region} does not exist")
118 if matched.get("Type") != "application" or matched.get("Scheme") != "internal":
119 raise RuntimeError(f"The registered backend for {region} is not an internal ALB")
121 arn = str(matched.get("LoadBalancerArn", ""))
122 arn_parts = arn.split(":", 5)
123 if (
124 len(arn_parts) != 6
125 or arn_parts[2] != "elasticloadbalancing"
126 or arn_parts[3] != region
127 or arn_parts[4] != expected_account
128 ):
129 raise RuntimeError(f"The registered backend for {region} has invalid ownership")
131 tag_response = client.describe_tags(ResourceArns=[arn])
132 descriptions = tag_response.get("TagDescriptions", [])
133 tags = {
134 str(tag.get("Key")): str(tag.get("Value"))
135 for description in descriptions
136 for tag in description.get("Tags", [])
137 }
138 expected_cluster = f"{project_name}-{region}"
139 cluster_match = (
140 tags.get("eks:eks-cluster-name") == expected_cluster
141 or tags.get("elbv2.k8s.aws/cluster") == expected_cluster
142 )
143 if not cluster_match:
144 raise RuntimeError(f"The registered backend for {region} is not owned by the GCO cluster")
146 # Accept only the explicit Gateway ownership marker; a cluster tag alone
147 # is never sufficient.
148 platform_match = tags.get("gco.aws/gateway") == "gco-system/gco-gateway"
149 if not platform_match:
150 raise RuntimeError(f"The registered backend for {region} is not the GCO Gateway")
153def _resolve_registered_endpoint() -> str:
154 """Resolve and verify the regional ALB, retaining literal compatibility."""
155 target_region = os.getenv("TARGET_REGION", "").strip()
156 literal_endpoint = os.getenv("ALB_ENDPOINT", "").strip()
157 if literal_endpoint:
158 return _validated_dns_name(literal_endpoint, region=target_region or "configured region")
160 registry_region = os.getenv("REGISTRY_REGION", "").strip()
161 project_name = os.getenv("PROJECT_NAME", "").strip()
162 expected_account = os.getenv("AWS_ACCOUNT_ID", "").strip()
163 if (
164 _REGION_RE.fullmatch(registry_region) is None
165 or _REGION_RE.fullmatch(target_region) is None
166 or not project_name
167 or not expected_account
168 ):
169 raise RuntimeError("Regional endpoint registry is not configured")
171 cache_key = (registry_region, target_region, project_name, expected_account)
172 ttl = _regional_endpoint_cache_ttl()
173 now = time.monotonic()
174 cached = _REGIONAL_ENDPOINT_CACHE.get(cache_key)
175 if ttl > 0 and cached is not None and now - cached[0] < ttl:
176 return cached[1]
178 parameter_name = f"/{project_name}/alb-hostname-{target_region}"
179 try:
180 response = boto3.client("ssm", region_name=registry_region).get_parameter(
181 Name=parameter_name
182 )
183 endpoint = _validated_dns_name(
184 response.get("Parameter", {}).get("Value"), region=target_region
185 )
186 _validate_regional_endpoint_ownership(endpoint, target_region)
187 except (BotoCoreError, ClientError) as exc:
188 raise RuntimeError(
189 f"The registered backend for {target_region} could not be verified"
190 ) from exc
192 _REGIONAL_ENDPOINT_CACHE[cache_key] = (time.monotonic(), endpoint)
193 return endpoint
196def _error_response(status_code: int, message: str) -> dict[str, Any]:
197 """Build a bounded API Gateway error response."""
198 return {
199 "statusCode": status_code,
200 "headers": {"Content-Type": "application/json"},
201 "body": json.dumps({"error": message}),
202 }
205def _get_request_timeout_seconds(context: Any) -> float:
206 """Bound upstream work while retaining time to return a Lambda response."""
207 get_remaining_time = getattr(context, "get_remaining_time_in_millis", None)
208 if not callable(get_remaining_time):
209 return _MAX_FORWARD_REQUEST_SECONDS
211 remaining_seconds = float(get_remaining_time()) / 1000.0
212 available_seconds = remaining_seconds - _LAMBDA_RESPONSE_HEADROOM_SECONDS
213 return max(0.0, min(_MAX_FORWARD_REQUEST_SECONDS, available_seconds))
216def lambda_handler(event: dict[str, Any], context: Any) -> dict[str, Any]:
217 """Proxy one IAM-authenticated API Gateway request to the internal ALB."""
218 try:
219 signing_key = get_secret_token()
220 except _BACKEND_AUTH_ERRORS:
221 return _error_response(503, "Backend authentication is temporarily unavailable")
223 try:
224 alb_endpoint = _resolve_registered_endpoint()
225 except RuntimeError as exc:
226 _LOGGER.warning("Regional backend resolution failed: %s", exc)
227 return _error_response(502, "Regional backend is temporarily unavailable")
229 http_method = event["httpMethod"]
230 path = event["path"]
231 query_string = (
232 event.get("multiValueQueryStringParameters") or event.get("queryStringParameters") or {}
233 )
234 headers = dict(event.get("headers") or {})
235 body = event.get("body") or ""
236 if event.get("isBase64Encoded"):
237 return _error_response(415, "Base64-encoded request bodies are not supported")
239 target_url = build_target_url(alb_endpoint, path, query_string)
240 headers = sanitize_request_headers(headers)
241 headers.update(build_signed_headers(signing_key, http_method, target_url, body))
243 return forward_request(
244 target_url,
245 http_method,
246 headers,
247 body,
248 timeout=_get_request_timeout_seconds(context),
249 )