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

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

26 

27import json 

28import logging 

29import os 

30import re 

31import time 

32from typing import Any 

33 

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) 

43 

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 

52 

53 

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) 

65 

66 

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 

74 

75 

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 

82 

83 

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 

91 

92 

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

99 

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

120 

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

130 

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

145 

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

151 

152 

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

159 

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

170 

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] 

177 

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 

191 

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

193 return endpoint 

194 

195 

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 } 

203 

204 

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 

210 

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

214 

215 

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

222 

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

228 

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

238 

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

242 

243 return forward_request( 

244 target_url, 

245 http_method, 

246 headers, 

247 body, 

248 timeout=_get_request_timeout_seconds(context), 

249 )