Coverage for gco / stacks / regional_api_gateway_stack.py: 100.00%
95 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"""
2Regional API Gateway bridge for authenticated access to private regional ALBs.
4Every deployment creates this regional bridge so the centralized aggregator has
5a reachable, IAM-authenticated path into each regional VPC. In the commercial
6``aws`` partition, ``api_gateway.regional_api_enabled`` optionally admits other
7same-account principals. In every other AWS partition, Global Accelerator is
8omitted and this regional IAM path is enabled for same-account callers
9regardless of that setting.
11Architecture:
12 Aggregator → Regional API Gateway → buffered VPC Lambda → Internal ALB → EKS pods
13 User (optional in ``aws``; required elsewhere) ────────┤
14 └→ streaming VPC Lambda → inference proxy
16Security:
17 - API Gateway uses AWS-managed TLS and IAM authentication (SigV4)
18 - The resource policy always admits only the aggregator role by default
19 - Optional direct mode additionally admits IAM-authorized account principals
20 - Lambda runs inside the VPC with access to the internal ALB
21 - Lambda verifies the deployment-local ALB certificate with explicit SNI
22 - Lambda adds a short-lived per-request HMAC envelope to the ALB request
23 - No public exposure of the ALB or EKS API
25Configuration:
26 In the commercial ``aws`` partition, set
27 ``api_gateway.regional_api_enabled`` to ``true`` when callers need direct
28 region-pinned access. Outside that partition, the regional API is the
29 supported workload ingress and same-account access is forced on. Global
30 aggregation always uses its dedicated role in every partition.
31"""
33from typing import Any
35from aws_cdk import (
36 CfnOutput,
37 Duration,
38 RemovalPolicy,
39 Stack,
40)
41from aws_cdk import aws_apigateway as apigateway
42from aws_cdk import aws_ec2 as ec2
43from aws_cdk import aws_iam as iam
44from aws_cdk import aws_lambda as lambda_
45from aws_cdk import aws_logs as logs
46from constructs import Construct
48from gco.config.config_loader import ConfigLoader
49from gco.stacks.constants import (
50 AGGREGATOR_REGIONAL_API_ROUTES,
51 DEFAULT_MAX_REQUEST_BODY_BYTES,
52 LAMBDA_NODEJS_RUNTIME,
53 LAMBDA_PYTHON_RUNTIME,
54 backend_tls_root_ca_parameter_name,
55 backend_tls_server_name,
56 validated_request_body_limit,
57)
59# <pyflowchart-code-diagram> BEGIN - auto-inserted, do not edit
60# Generated at (UTC): 2026-09-01T14:42:56Z
61# Generated from Git commit: 89b000378ed5a912a38c06f4feab2b029936ebcc
62# Flowchart(s) generated from this file:
63# * ``GCORegionalApiGatewayStack.__init__`` -> ``diagrams/code_diagrams/gco/stacks/regional_api_gateway_stack.GCORegionalApiGatewayStack___init__.html``
64# (PNG: ``diagrams/code_diagrams/gco/stacks/regional_api_gateway_stack.GCORegionalApiGatewayStack___init__.png``)
65# Regenerate with ``SOURCE_DATE_EPOCH=<unix-seconds> GCO_DIAGRAM_SOURCE_COMMIT=<40-char-sha> python diagrams/generate.py --code-only``.
66# <pyflowchart-code-diagram> END
69class GCORegionalApiGatewayStack(Stack):
70 """Regional aggregation bridge with optional direct caller access.
72 The VPC Lambda gives the global aggregator a reachable path to one internal
73 regional ALB. Direct region-pinned access for other IAM-authorized account
74 principals is optional in the commercial ``aws`` partition and mandatory
75 in partitions where Global Accelerator is unavailable.
77 Attributes:
78 api: Regional REST API with IAM authentication.
79 proxy_lambda: Buffered VPC Lambda for ``/api/v1/*`` requests.
80 inference_proxy_lambda: Response-streaming VPC Lambda for ``/inference/*``.
81 """
83 def __init__(
84 self,
85 scope: Construct,
86 construct_id: str,
87 config: ConfigLoader,
88 region: str,
89 vpc: ec2.IVpc,
90 auth_secret_arn: str,
91 aggregator_role_arn: str,
92 alb_dns_name: str | None = None,
93 **kwargs: Any,
94 ) -> None:
95 super().__init__(scope, construct_id, **kwargs)
97 self.config = config
98 self.deployment_region = region
99 self.vpc = vpc
100 self.alb_dns_name = alb_dns_name
101 supports_global_accelerator = getattr(config, "supports_global_accelerator", None)
102 self.global_accelerator_enabled = (
103 bool(supports_global_accelerator()) if callable(supports_global_accelerator) else True
104 )
105 self.auth_secret_arn = auth_secret_arn
106 self.aggregator_role_arn = aggregator_role_arn
108 # Keep control-plane calls on the established buffered Python proxy and
109 # give inference a separate Node.js response-streaming runtime.
110 self.proxy_lambda = self._create_vpc_proxy_lambda()
111 self.inference_proxy_lambda = self._create_inference_proxy_lambda()
113 # Create regional API Gateway
114 self.api = self._create_api_gateway()
116 # Export outputs
117 self._create_outputs()
119 # Apply cdk-nag suppressions
120 self._apply_nag_suppressions()
122 def _apply_nag_suppressions(self) -> None:
123 """Apply cdk-nag suppressions for this stack."""
124 from gco.stacks.nag_suppressions import apply_all_suppressions
126 apply_all_suppressions(
127 self,
128 stack_type="regional_api_gateway",
129 global_region=self.config.get_global_region(),
130 project_name=self.config.get_project_name(),
131 )
133 def _create_vpc_proxy_lambda(self) -> lambda_.Function:
134 """Create VPC Lambda that proxies requests to internal ALB."""
135 project_name = self.config.get_project_name()
136 backend_tls_config = self.config.get_backend_tls_config()
137 root_ca_parameter_name = backend_tls_root_ca_parameter_name(project_name)
139 # Create security group for Lambda
140 lambda_sg = ec2.SecurityGroup(
141 self,
142 "ProxyLambdaSg",
143 vpc=self.vpc,
144 description="Security group for regional API proxy Lambdas",
145 allow_all_outbound=True,
146 )
147 self._proxy_lambda_security_group = lambda_sg
149 # Create IAM role for Lambda
150 # role_name intentionally omitted - let CDK generate unique name
151 lambda_role = iam.Role(
152 self,
153 "ProxyLambdaRole",
154 assumed_by=iam.ServicePrincipal("lambda.amazonaws.com"),
155 managed_policies=[
156 iam.ManagedPolicy.from_aws_managed_policy_name(
157 "service-role/AWSLambdaVPCAccessExecutionRole"
158 )
159 ],
160 )
162 # Grant read access to auth secret.
163 lambda_role.add_to_policy(
164 iam.PolicyStatement(
165 effect=iam.Effect.ALLOW,
166 actions=[
167 "secretsmanager:GetSecretValue",
168 "secretsmanager:DescribeSecret",
169 ],
170 resources=[f"{self.auth_secret_arn}*"],
171 )
172 )
174 # The Ingress-created ALB does not exist during CDK synthesis. Resolve
175 # its current hostname from the project-scoped SSM registry at request
176 # time, then verify that the hostname belongs to this account, region,
177 # EKS cluster, and platform Ingress before forwarding any request.
178 registry_region = self.config.get_global_region()
179 registry_parameter_arn = (
180 f"arn:{self.partition}:ssm:{registry_region}:{self.account}:"
181 f"parameter/{project_name}/alb-hostname-{self.deployment_region}"
182 )
183 root_ca_parameter_arn = (
184 f"arn:{self.partition}:ssm:{registry_region}:{self.account}:"
185 f"parameter/{root_ca_parameter_name.lstrip('/')}"
186 )
187 lambda_role.add_to_policy(
188 iam.PolicyStatement(
189 effect=iam.Effect.ALLOW,
190 actions=["ssm:GetParameter"],
191 resources=[registry_parameter_arn, root_ca_parameter_arn],
192 )
193 )
194 lambda_role.add_to_policy(
195 iam.PolicyStatement(
196 effect=iam.Effect.ALLOW,
197 actions=[
198 "elasticloadbalancing:DescribeLoadBalancers",
199 "elasticloadbalancing:DescribeTags",
200 ],
201 resources=["*"],
202 )
203 )
205 from gco.stacks.nag_suppressions import acknowledge_nag_findings
207 acknowledge_nag_findings(
208 lambda_role,
209 [
210 {
211 "id": "AwsSolutions-IAM5",
212 "reason": (
213 "ELB DescribeLoadBalancers and DescribeTags do not support "
214 "resource-level scoping. They are read-only and are used only "
215 "to verify that the SSM-registered hostname belongs to this "
216 "account's exact regional GCO cluster and platform Ingress."
217 ),
218 "appliesTo": ["Resource::*"],
219 }
220 ],
221 )
223 # Create log group
224 # log_group_name intentionally omitted - let CDK generate unique name
225 log_group = logs.LogGroup(
226 self,
227 "ProxyLambdaLogGroup",
228 retention=logs.RetentionDays.ONE_WEEK,
229 removal_policy=RemovalPolicy.DESTROY,
230 )
232 # A literal endpoint remains available for isolated stack synthesis and
233 # compatibility callers. Production app wiring omits it so replacements
234 # are discovered from SSM without requiring an ALB at deploy time.
235 environment = {
236 "SECRET_ARN": self.auth_secret_arn,
237 "REGISTRY_REGION": registry_region,
238 "TARGET_REGION": self.deployment_region,
239 "PROJECT_NAME": project_name,
240 "AWS_ACCOUNT_ID": self.account,
241 "AWS_URL_SUFFIX": self.url_suffix,
242 "BACKEND_TLS_SERVER_NAME": backend_tls_server_name(project_name),
243 "BACKEND_TLS_ROOT_CA_PARAMETER": root_ca_parameter_name,
244 "BACKEND_TLS_ROOT_CA_REGION": registry_region,
245 "BACKEND_TLS_CA_CACHE_TTL_SECONDS": str(backend_tls_config["trust_cache_ttl_seconds"]),
246 "BACKEND_TLS_CA_MAX_STALE_SECONDS": str(
247 backend_tls_config["trust_cache_max_stale_seconds"]
248 ),
249 }
250 if self.alb_dns_name:
251 environment["ALB_ENDPOINT"] = self.alb_dns_name
253 # Create Lambda function in VPC
254 proxy_lambda = lambda_.Function(
255 self,
256 "RegionalProxyFunction",
257 function_name=f"{project_name}-regional-proxy-{self.deployment_region}",
258 runtime=getattr(lambda_.Runtime, LAMBDA_PYTHON_RUNTIME),
259 handler="handler.lambda_handler",
260 code=lambda_.Code.from_asset("lambda/regional-api-proxy"),
261 timeout=Duration.seconds(29),
262 memory_size=256,
263 role=lambda_role,
264 vpc=self.vpc,
265 vpc_subnets=ec2.SubnetSelection(subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS),
266 security_groups=[lambda_sg],
267 environment=environment,
268 log_group=log_group,
269 description=f"Regional API proxy for {self.deployment_region} (VPC Lambda)",
270 tracing=lambda_.Tracing.ACTIVE,
271 )
273 return proxy_lambda
275 def _create_inference_proxy_lambda(self) -> lambda_.Function:
276 """Create the VPC Lambda that streams inference responses from the ALB."""
277 project_name = self.config.get_project_name()
278 backend_tls_config = self.config.get_backend_tls_config()
279 max_request_body_bytes = validated_request_body_limit(
280 self.config.get_manifest_processor_config().get(
281 "max_request_body_bytes", DEFAULT_MAX_REQUEST_BODY_BYTES
282 )
283 )
284 registry_region = self.config.get_global_region()
285 root_ca_parameter_name = backend_tls_root_ca_parameter_name(project_name)
286 registry_parameter_arn = (
287 f"arn:{self.partition}:ssm:{registry_region}:{self.account}:"
288 f"parameter/{project_name}/alb-hostname-{self.deployment_region}"
289 )
290 root_ca_parameter_arn = (
291 f"arn:{self.partition}:ssm:{registry_region}:{self.account}:"
292 f"parameter/{root_ca_parameter_name.lstrip('/')}"
293 )
295 role = iam.Role(
296 self,
297 "InferenceStreamingProxyRole",
298 assumed_by=iam.ServicePrincipal("lambda.amazonaws.com"),
299 managed_policies=[
300 iam.ManagedPolicy.from_aws_managed_policy_name(
301 "service-role/AWSLambdaVPCAccessExecutionRole"
302 )
303 ],
304 )
305 role.add_to_policy(
306 iam.PolicyStatement(
307 effect=iam.Effect.ALLOW,
308 actions=[
309 "secretsmanager:GetSecretValue",
310 "secretsmanager:DescribeSecret",
311 ],
312 resources=[f"{self.auth_secret_arn}*"],
313 )
314 )
315 role.add_to_policy(
316 iam.PolicyStatement(
317 effect=iam.Effect.ALLOW,
318 actions=["ssm:GetParameter"],
319 resources=[registry_parameter_arn, root_ca_parameter_arn],
320 )
321 )
322 role.add_to_policy(
323 iam.PolicyStatement(
324 effect=iam.Effect.ALLOW,
325 actions=[
326 "elasticloadbalancing:DescribeLoadBalancers",
327 "elasticloadbalancing:DescribeTags",
328 ],
329 resources=["*"],
330 )
331 )
333 from gco.stacks.nag_suppressions import acknowledge_nag_findings
335 acknowledge_nag_findings(
336 role,
337 [
338 {
339 "id": "AwsSolutions-IAM5",
340 "reason": (
341 "ELB ownership verification and the Lambda VPC/X-Ray APIs do not "
342 "support resource-level scoping. Secret and SSM reads remain "
343 "scoped to this deployment's exact resources."
344 ),
345 "appliesTo": ["Resource::*"],
346 }
347 ],
348 )
350 log_group = logs.LogGroup(
351 self,
352 "InferenceStreamingProxyLogGroup",
353 retention=logs.RetentionDays.ONE_WEEK,
354 removal_policy=RemovalPolicy.DESTROY,
355 )
356 return lambda_.Function(
357 self,
358 "InferenceStreamingProxyFunction",
359 function_name=(f"{project_name}-regional-inference-proxy-{self.deployment_region}"),
360 runtime=getattr(lambda_.Runtime, LAMBDA_NODEJS_RUNTIME),
361 handler="index.handler",
362 code=lambda_.Code.from_asset("lambda/inference-streaming-proxy-build"),
363 timeout=Duration.minutes(15),
364 memory_size=256,
365 role=role,
366 vpc=self.vpc,
367 vpc_subnets=ec2.SubnetSelection(subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS),
368 security_groups=[self._proxy_lambda_security_group],
369 environment={
370 "ROUTING_MODE": "regional",
371 "MAX_REQUEST_BODY_BYTES": str(max_request_body_bytes),
372 "SECRET_ARN": self.auth_secret_arn,
373 "REGISTRY_REGION": registry_region,
374 "TARGET_REGION": self.deployment_region,
375 "PROJECT_NAME": project_name,
376 "AWS_ACCOUNT_ID": self.account,
377 "AWS_URL_SUFFIX": self.url_suffix,
378 "BACKEND_TLS_SERVER_NAME": backend_tls_server_name(project_name),
379 "BACKEND_TLS_ROOT_CA_PARAMETER": root_ca_parameter_name,
380 "BACKEND_TLS_ROOT_CA_REGION": registry_region,
381 "BACKEND_TLS_CA_CACHE_TTL_SECONDS": str(
382 backend_tls_config["trust_cache_ttl_seconds"]
383 ),
384 "BACKEND_TLS_CA_MAX_STALE_SECONDS": str(
385 backend_tls_config["trust_cache_max_stale_seconds"]
386 ),
387 },
388 log_group=log_group,
389 description=(
390 f"Regional inference response-streaming proxy for {self.deployment_region}"
391 ),
392 tracing=lambda_.Tracing.ACTIVE,
393 )
395 def _create_api_gateway(self) -> apigateway.RestApi:
396 """Create regional API Gateway with IAM authentication."""
397 project_name = self.config.get_project_name()
399 # Create CloudWatch log group
400 # log_group_name intentionally omitted - let CDK generate unique name
401 api_log_group = logs.LogGroup(
402 self,
403 "ApiGatewayLogs",
404 retention=logs.RetentionDays.ONE_MONTH,
405 removal_policy=RemovalPolicy.DESTROY,
406 )
408 api_config = self.config.get_api_gateway_config()
409 configured_log_level = str(api_config["log_level"]).upper()
410 logging_levels = {
411 "OFF": apigateway.MethodLoggingLevel.OFF,
412 "ERROR": apigateway.MethodLoggingLevel.ERROR,
413 "INFO": apigateway.MethodLoggingLevel.INFO,
414 }
415 if configured_log_level not in logging_levels:
416 raise ValueError(
417 "api_gateway.log_level must be one of OFF, ERROR, or INFO; "
418 f"got {configured_log_level!r}"
419 )
421 # Create regional REST API
422 api = apigateway.RestApi(
423 self,
424 "RegionalApi",
425 rest_api_name=f"{project_name}-regional-api-{self.deployment_region}",
426 description=f"Direct regional API for {project_name} in {self.deployment_region}",
427 endpoint_types=[apigateway.EndpointType.REGIONAL],
428 deploy=True,
429 deploy_options=apigateway.StageOptions(
430 stage_name="prod",
431 throttling_rate_limit=api_config["throttle_rate_limit"],
432 throttling_burst_limit=api_config["throttle_burst_limit"],
433 logging_level=logging_levels[configured_log_level],
434 # Never put inference prompts/responses (or other API bodies)
435 # into execution logs. Standard access logs and metrics remain.
436 data_trace_enabled=False,
437 metrics_enabled=api_config["metrics_enabled"],
438 tracing_enabled=api_config["tracing_enabled"],
439 access_log_destination=apigateway.LogGroupLogDestination(api_log_group),
440 access_log_format=apigateway.AccessLogFormat.json_with_standard_fields(
441 caller=True,
442 http_method=True,
443 ip=True,
444 protocol=True,
445 request_time=True,
446 resource_path=True,
447 response_length=True,
448 status=True,
449 user=True,
450 ),
451 ),
452 cloud_watch_role=True,
453 # CDK otherwise retains the generated API Gateway account role.
454 cloud_watch_role_removal_policy=RemovalPolicy.DESTROY,
455 )
457 # The bridge is private at the authorization layer by default: only
458 # the aggregator execution role is named in the API resource policy.
459 api.add_to_resource_policy(
460 iam.PolicyStatement(
461 effect=iam.Effect.ALLOW,
462 principals=[iam.ArnPrincipal(self.aggregator_role_arn)],
463 actions=["execute-api:Invoke"],
464 resources=[
465 f"execute-api:/*/{method}/{path}"
466 for method, path in AGGREGATOR_REGIONAL_API_ROUTES
467 ],
468 )
469 )
471 # Direct regional mode is an explicit opt-in in the commercial
472 # partition. It becomes the required supported ingress in partitions
473 # where Global Accelerator does not exist. Methods still require SigV4
474 # and callers still need identity-policy permission to invoke.
475 if (
476 self.config.get_api_gateway_config()["regional_api_enabled"]
477 or not self.global_accelerator_enabled
478 ):
479 api.add_to_resource_policy(
480 iam.PolicyStatement(
481 effect=iam.Effect.ALLOW,
482 principals=[iam.AnyPrincipal()],
483 actions=["execute-api:Invoke"],
484 resources=["execute-api:/*"],
485 conditions={
486 "StringEquals": {"aws:PrincipalAccount": self.account},
487 "ArnNotEquals": {"aws:PrincipalArn": self.aggregator_role_arn},
488 },
489 )
490 )
492 # Keep control-plane integration semantics unchanged. Inference uses
493 # InvokeWithResponseStream and may remain open for API Gateway's full
494 # 15-minute streaming integration window; request bodies are buffered.
495 control_plane_integration = apigateway.LambdaIntegration(
496 self.proxy_lambda, proxy=True, timeout=Duration.seconds(29)
497 )
498 inference_integration = apigateway.LambdaIntegration(
499 self.inference_proxy_lambda,
500 proxy=True,
501 timeout=Duration.minutes(15),
502 response_transfer_mode=apigateway.ResponseTransferMode.STREAM,
503 )
505 # API Gateway greedy resources do not cross a root segment, so
506 # /api/v1/{proxy+} cannot match /inference/{endpoint}/....
507 api_resource = api.root.add_resource("api")
508 v1_resource = api_resource.add_resource("v1")
509 api_proxy_resource = v1_resource.add_resource("{proxy+}")
510 inference_resource = api.root.add_resource("inference")
511 inference_proxy_resource = inference_resource.add_resource("{proxy+}")
513 for method in ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]:
514 api_proxy_resource.add_method(
515 method,
516 control_plane_integration,
517 authorization_type=apigateway.AuthorizationType.IAM,
518 method_responses=[
519 apigateway.MethodResponse(status_code="200"),
520 apigateway.MethodResponse(status_code="400"),
521 apigateway.MethodResponse(status_code="403"),
522 apigateway.MethodResponse(status_code="500"),
523 ],
524 )
526 for method in ["GET", "HEAD", "POST"]:
527 inference_proxy_resource.add_method(
528 method,
529 inference_integration,
530 authorization_type=apigateway.AuthorizationType.IAM,
531 method_responses=[
532 apigateway.MethodResponse(status_code="200"),
533 apigateway.MethodResponse(status_code="400"),
534 apigateway.MethodResponse(status_code="404"),
535 apigateway.MethodResponse(status_code="500"),
536 apigateway.MethodResponse(status_code="502"),
537 ],
538 )
540 from gco.stacks.nag_suppressions import acknowledge_nag_findings
542 acknowledge_nag_findings(
543 api.deployment_stage,
544 [
545 {
546 "id": "AwsSolutions-APIG3",
547 "reason": (
548 "This regional bridge is not a general public API: every method "
549 "requires SigV4 and its resource policy admits only the exact "
550 "aggregator role unless account-local direct access is explicitly "
551 "enabled. A separate WAF would duplicate those identity controls."
552 ),
553 },
554 {
555 "id": "NIST.800.53.R5-APIGWAssociatedWithWAF",
556 "reason": (
557 "The IAM-authenticated regional bridge has an aggregator-only resource "
558 "policy by default; unauthorized traffic is rejected before integration."
559 ),
560 },
561 {
562 "id": "PCI.DSS.321-APIGWAssociatedWithWAF",
563 "reason": (
564 "The IAM-authenticated regional bridge has an aggregator-only resource "
565 "policy by default and carries no payment-card-specific public surface."
566 ),
567 },
568 ],
569 )
571 return api
573 def _create_outputs(self) -> None:
574 """Export regional API Gateway endpoint."""
575 project_name = self.config.get_project_name()
577 CfnOutput(
578 self,
579 "RegionalApiEndpoint",
580 value=self.api.url,
581 description=f"Regional API Gateway endpoint for {self.deployment_region}",
582 export_name=f"{project_name}-regional-api-endpoint-{self.deployment_region}",
583 )