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

1"""Regional API proxy for authenticated access to one region's internal ALB. 

2 

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. 

6 

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""" 

28 

29import json 

30import logging 

31import os 

32import re 

33import time 

34from typing import Any 

35 

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) 

46 

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 

55 

56 

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) 

68 

69 

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 

77 

78 

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 

85 

86 

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 

94 

95 

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") 

102 

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") 

123 

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") 

133 

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") 

148 

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") 

154 

155 

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") 

162 

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") 

173 

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] 

180 

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 

194 

195 _REGIONAL_ENDPOINT_CACHE[cache_key] = (time.monotonic(), endpoint) 

196 return endpoint 

197 

198 

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 } 

206 

207 

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 

213 

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)) 

217 

218 

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") 

225 

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") 

231 

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") 

241 

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)) 

246 

247 return forward_request( 

248 target_url, 

249 http_method, 

250 headers, 

251 body, 

252 timeout=_get_request_timeout_seconds(context), 

253 )