Coverage for gco / stacks / api_gateway_global_stack.py: 100.00%
223 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"""
2Global API Gateway stack - Single authenticated entry point for all regions.
4This stack creates the centralized API Gateway that serves as the authenticated
5entry point for all GCO API requests. It provides:
6- Edge-optimized endpoint in the commercial ``aws`` partition; regional endpoint elsewhere
7- IAM authentication (AWS SigV4) for all requests
8- Global Accelerator-backed HMAC proxy routes in ``aws`` only
9- SigV4-authenticated aggregation through regional API Gateway bridges
10- Secrets Manager signing key with automatic rotation
11- Multi-region replication for the signing key
12- CloudWatch logging for audit and debugging
14Security Flow in the commercial ``aws`` partition:
15 1. Client signs request with AWS credentials (SigV4)
16 2. CloudFront edge location receives request (managed by AWS)
17 3. API Gateway validates IAM permissions
18 4. Lambda proxy retrieves the signing key from Secrets Manager
19 5. Lambda signs the method, target, body digest, timestamp, and random nonce
20 6. Request is forwarded to Global Accelerator with the HMAC envelope
21 7. Backend middleware validates freshness, integrity, and replay protection
23Secret Rotation:
24 The signing key is automatically rotated daily. During rotation:
25 - A new key is generated and stored as AWSPENDING
26 - Backend services accept signatures from AWSCURRENT and AWSPENDING keys
27 - After validation, AWSPENDING becomes AWSCURRENT
28 - Multi-region replication ensures all regions receive the new key
30Outside ``aws``, the Global Accelerator proxy Lambdas and catch-all workload
31routes are omitted. The regional global API retains authenticated aggregate
32routes, while callers use IAM-authenticated regional bridges for workload
33control and inference.
35The HMAC envelope authenticates each request but does not encrypt its payload;
36transport confidentiality is a separate property of the network path. Direct
37requests to Global Accelerator (when present) or regional ALBs cannot mint a
38valid envelope.
39"""
41import json
42from dataclasses import dataclass
43from typing import Any
45from aws_cdk import (
46 CfnOutput,
47 CustomResource,
48 Duration,
49 Fn,
50 RemovalPolicy,
51 Stack,
52)
53from aws_cdk import aws_apigateway as apigateway
54from aws_cdk import aws_cloudwatch as cloudwatch
55from aws_cdk import aws_cognito as cognito
56from aws_cdk import aws_ecr_assets as ecr_assets
57from aws_cdk import aws_events as events
58from aws_cdk import aws_events_targets as events_targets
59from aws_cdk import aws_iam as iam
60from aws_cdk import aws_kms as kms
61from aws_cdk import aws_lambda as lambda_
62from aws_cdk import aws_logs as logs
63from aws_cdk import aws_secretsmanager as secretsmanager
64from aws_cdk import aws_sqs as sqs
65from aws_cdk import aws_wafv2 as wafv2
66from aws_cdk import custom_resources as cr
67from constructs import Construct
69from gco.stacks.constants import (
70 AGGREGATOR_REGIONAL_API_ROUTES,
71 DEFAULT_MAX_REQUEST_BODY_BYTES,
72 LAMBDA_NODEJS_RUNTIME,
73 LAMBDA_PYTHON_RUNTIME,
74 api_gateway_auth_secret_name,
75 backend_tls_certificate_parameter_prefix,
76 backend_tls_root_ca_parameter_name,
77 backend_tls_root_secret_name,
78 backend_tls_server_name,
79 cross_region_aggregator_role_name,
80 validated_request_body_limit,
81)
83# <pyflowchart-code-diagram> BEGIN - auto-inserted, do not edit
84# Generated at (UTC): 2026-09-01T14:42:56Z
85# Generated from Git commit: 89b000378ed5a912a38c06f4feab2b029936ebcc
86# Flowchart(s) generated from this file:
87# * ``GCOApiGatewayGlobalStack.__init__`` -> ``diagrams/code_diagrams/gco/stacks/api_gateway_global_stack.GCOApiGatewayGlobalStack___init__.html``
88# (PNG: ``diagrams/code_diagrams/gco/stacks/api_gateway_global_stack.GCOApiGatewayGlobalStack___init__.png``)
89# Regenerate with ``SOURCE_DATE_EPOCH=<unix-seconds> GCO_DIAGRAM_SOURCE_COMMIT=<40-char-sha> python diagrams/generate.py --code-only``.
90# <pyflowchart-code-diagram> END
93@dataclass(frozen=True)
94class AnalyticsApiConfig:
95 """Configuration handed from ``GCOAnalyticsStack`` to ``GCOApiGatewayGlobalStack``.
97 When ``GCOApiGatewayGlobalStack`` is constructed (or mutated via
98 :meth:`GCOApiGatewayGlobalStack.set_analytics_config`) with a non-``None``
99 instance of this dataclass, the stack wires a Cognito-authorized
100 ``/studio/*`` route tree onto the existing REST API. When the value is
101 ``None``, the stack is behaviorally identical to its pre-analytics shape
102 — no ``/studio/*`` resources, no Cognito authorizer, no additional
103 ``CfnOutput`` entries.
105 ``frozen=True`` makes the dataclass hashable and immutable so a single
106 config object can be safely shared across constructs without the risk
107 of accidental mutation after the synthesized template references its
108 fields.
110 Attributes:
111 user_pool_arn: Full ARN of the Cognito user pool that authenticates
112 Studio logins. Shape:
113 ``arn:aws:cognito-idp:<region>:<account>:userpool/<pool-id>``.
114 user_pool_client_id: Client id of the Studio user-pool client
115 (SRP auth). Used by the CLI's ``gco analytics studio login``
116 flow and surfaced to API Gateway outputs for discoverability.
117 presigned_url_lambda: The ``analytics-presigned-url`` Lambda
118 function created by ``GCOAnalyticsStack._create_presigned_url_lambda``.
119 Consumed by the ``/studio/login`` ``LambdaIntegration``.
120 studio_domain_name: SageMaker Studio domain name. Carried through
121 as context for the Lambda integration; the Lambda itself also
122 reads this value from its ``STUDIO_DOMAIN_NAME`` environment
123 variable set by the analytics stack.
124 callback_url: Concrete OAuth redirect target
125 (``https://<api>/prod/studio/callback``) used when the
126 Cognito hosted UI is enabled. The ``/studio/callback`` route
127 is wired as a stub here so the URL is reachable immediately
128 after deploy.
129 """
131 user_pool_arn: str
132 user_pool_client_id: str
133 presigned_url_lambda: lambda_.IFunction
134 studio_domain_name: str
135 callback_url: str
138class GCOApiGatewayGlobalStack(Stack):
139 """
140 Global API Gateway with IAM authentication.
142 This stack creates the single authenticated entry point for all GCO
143 API requests. All requests must be signed with AWS credentials.
145 Attributes:
146 secret: Secrets Manager secret containing the backend HMAC signing key
147 proxy_lambda: Buffered Lambda proxy for the control-plane API
148 inference_proxy_lambda: Response-streaming Lambda for `/inference/*`
149 aggregator_lambda: Lambda function for cross-region aggregation
150 api: REST API with IAM authentication
151 """
153 def __init__(
154 self,
155 scope: Construct,
156 construct_id: str,
157 global_accelerator_dns: str | None,
158 regional_endpoints: dict[str, str] | None = None,
159 analytics_config: AnalyticsApiConfig | None = None,
160 project_name: str = "gco",
161 api_gateway_config: dict[str, Any] | None = None,
162 registry_region: str | None = None,
163 certificate_regions: list[str] | None = None,
164 backend_tls_config: dict[str, Any] | None = None,
165 max_request_body_bytes: int = DEFAULT_MAX_REQUEST_BODY_BYTES,
166 **kwargs: Any,
167 ) -> None:
168 super().__init__(scope, construct_id, **kwargs)
170 # ``project_name`` is the deployment's unique prefix. Every physical
171 # resource name this stack owns (secret, WAF, log groups, CFN exports)
172 # derives from it so two deployments can coexist in one account+region.
173 # Defaults to ``"gco"`` so the rendered names are byte-for-byte
174 # identical to the pre-#139 literals for the stock deployment.
175 self.project_name = project_name
176 self.ga_dns = str(global_accelerator_dns).strip() if global_accelerator_dns else None
177 self.regional_endpoints = regional_endpoints or {}
178 # Regional ALB hostnames and backend-TLS public metadata are registered
179 # in the global stack's SSM region, which may differ from this stack.
180 self.registry_region = registry_region or self.region
181 self.certificate_regions = tuple(
182 dict.fromkeys(certificate_regions if certificate_regions is not None else ["us-east-1"])
183 )
184 default_backend_tls_config: dict[str, int] = {
185 "root_generation": 1,
186 "root_validity_days": 3_650,
187 "root_rotate_before_days": 180,
188 "root_activation_delay_hours": 24,
189 "root_overlap_days": 45,
190 "leaf_validity_days": 30,
191 "leaf_rotate_before_days": 10,
192 "rotation_schedule_hours": 12,
193 "trust_cache_ttl_seconds": 300,
194 "trust_cache_max_stale_seconds": 3_600,
195 }
196 self.backend_tls_config = {
197 **default_backend_tls_config,
198 **(backend_tls_config or {}),
199 }
200 self.backend_tls_server_name = backend_tls_server_name(self.project_name)
201 self.backend_tls_root_ca_parameter_name = backend_tls_root_ca_parameter_name(
202 self.project_name
203 )
204 self.backend_tls_certificate_parameter_prefix = backend_tls_certificate_parameter_prefix(
205 self.project_name
206 )
207 self.max_request_body_bytes = validated_request_body_limit(max_request_body_bytes)
209 default_api_gateway_config: dict[str, Any] = {
210 "throttle_rate_limit": 1000,
211 "throttle_burst_limit": 2000,
212 "log_level": "INFO",
213 "metrics_enabled": True,
214 "tracing_enabled": True,
215 }
216 if api_gateway_config is not None:
217 configured_api_gateway = api_gateway_config
218 else:
219 context_config = self.node.try_get_context("api_gateway")
220 configured_api_gateway = context_config if isinstance(context_config, dict) else {}
221 self.api_gateway_config = {
222 **default_api_gateway_config,
223 **configured_api_gateway,
224 }
225 # When analytics is disabled (the default) this stays ``None`` and
226 # the stack synthesizes exactly as it did pre-analytics. When
227 # non-``None``, ``_wire_studio_routes`` is invoked at the end of
228 # the constructor, after the IAM-authorized ``/api/v1/*`` and
229 # ``/inference/*`` methods are already attached — so Cognito and
230 # IAM authorization coexist at the method level rather than at
231 # the API level.
232 self.analytics_config: AnalyticsApiConfig | None = analytics_config
234 # Create the deployment-local private PKI before any client Lambda.
235 # The manager writes only public trust material and ACM ARNs to SSM;
236 # its KMS-encrypted root private key is inaccessible to proxy roles.
237 self._create_backend_tls()
239 # Create the shared backend HMAC signing key.
240 self.secret = self._create_secret()
242 # Global Accelerator-backed proxy routes exist only where that global
243 # service is available. In other partitions the global API retains its
244 # aggregate routes while callers use the regional IAM APIs directly.
245 self.proxy_lambda = self._create_proxy_lambda() if self.ga_dns is not None else None
246 self.inference_proxy_lambda = (
247 self._create_inference_proxy_lambda() if self.ga_dns is not None else None
248 )
250 # Create cross-region aggregator Lambda
251 self.aggregator_lambda = self._create_aggregator_lambda()
253 # Create API Gateway
254 self.api = self._create_api_gateway()
256 # Create WAF WebACL and associate with API Gateway
257 self._create_waf()
259 # Export API endpoint
260 self._create_outputs()
262 # Wire /studio/* routes when analytics is explicitly enabled at
263 # construction time. Most deployments take the mutator path
264 # (:meth:`set_analytics_config`) because ``GCOAnalyticsStack`` is
265 # built after this stack in ``app.py``.
266 if self.analytics_config is not None:
267 self._wire_studio_routes()
269 # Apply cdk-nag suppressions
270 self._apply_nag_suppressions()
272 def _apply_nag_suppressions(self) -> None:
273 """Apply cdk-nag suppressions for this stack."""
274 from gco.stacks.nag_suppressions import apply_all_suppressions
276 # This stack's proxy and certificate-manager roles read project-scoped
277 # public SSM metadata from the global registry region. The aggregator
278 # itself discovers regional bridges through CloudFormation, not SSM.
279 apply_all_suppressions(
280 self,
281 stack_type="api_gateway",
282 global_region=self.registry_region,
283 project_name=self.project_name,
284 )
286 def _create_backend_tls(self) -> None:
287 """Create the private root, regional ACM manager, schedule, and alarms."""
288 config = self.backend_tls_config
289 project_name = self.project_name
291 self.backend_tls_key = kms.Key(
292 self,
293 "BackendTlsRootKey",
294 alias=f"alias/{project_name}-backend-tls-root",
295 description="Encrypts the GCO deployment-local backend TLS root private key",
296 enable_key_rotation=True,
297 removal_policy=RemovalPolicy.DESTROY,
298 pending_window=Duration.days(7),
299 )
300 self.backend_tls_root_secret = secretsmanager.Secret(
301 self,
302 "BackendTlsRootSecret",
303 secret_name=backend_tls_root_secret_name(project_name),
304 description=(
305 "Deployment-local backend TLS root CA; private key access is restricted "
306 "to the certificate manager Lambda"
307 ),
308 encryption_key=self.backend_tls_key,
309 generate_secret_string=secretsmanager.SecretStringGenerator(
310 secret_string_template=json.dumps({"state": "UNINITIALIZED"}),
311 generate_string_key="bootstrap_nonce",
312 exclude_punctuation=True,
313 password_length=32,
314 ),
315 removal_policy=RemovalPolicy.DESTROY,
316 )
318 manager_role = iam.Role(
319 self,
320 "BackendTlsManagerRole",
321 assumed_by=iam.ServicePrincipal("lambda.amazonaws.com"),
322 managed_policies=[
323 iam.ManagedPolicy.from_aws_managed_policy_name(
324 "service-role/AWSLambdaBasicExecutionRole"
325 )
326 ],
327 )
328 self.backend_tls_root_secret.grant_read(manager_role)
329 self.backend_tls_root_secret.grant_write(manager_role)
330 self.backend_tls_key.grant_encrypt_decrypt(manager_role)
331 manager_role.add_to_policy(
332 iam.PolicyStatement(
333 actions=[
334 "acm:AddTagsToCertificate",
335 "acm:ImportCertificate",
336 "acm:ListCertificates",
337 ],
338 resources=["*"],
339 )
340 )
341 manager_role.add_to_policy(
342 iam.PolicyStatement(
343 actions=[
344 "acm:DeleteCertificate",
345 "acm:DescribeCertificate",
346 "acm:GetCertificate",
347 "acm:ListTagsForCertificate",
348 ],
349 resources=[f"arn:{self.partition}:acm:*:{self.account}:certificate/*"],
350 )
351 )
352 manager_role.add_to_policy(
353 iam.PolicyStatement(
354 actions=[
355 "ssm:DeleteParameter",
356 "ssm:GetParameter",
357 "ssm:GetParametersByPath",
358 "ssm:PutParameter",
359 ],
360 resources=[
361 f"arn:{self.partition}:ssm:{self.registry_region}:{self.account}:"
362 f"parameter/{project_name}/backend-tls/*"
363 ],
364 )
365 )
366 manager_role.add_to_policy(
367 iam.PolicyStatement(
368 actions=["cloudwatch:PutMetricData"],
369 resources=["*"],
370 conditions={"StringEquals": {"cloudwatch:namespace": "GCO/BackendTLS"}},
371 )
372 )
374 manager_log_group = logs.LogGroup(
375 self,
376 "BackendTlsManagerLogGroup",
377 retention=logs.RetentionDays.ONE_MONTH,
378 removal_policy=RemovalPolicy.DESTROY,
379 )
380 manager_environment = {
381 "ROOT_SECRET_ARN": self.backend_tls_root_secret.secret_arn,
382 "AWS_PARTITION": self.partition,
383 "AWS_ACCOUNT_ID": self.account,
384 "PROJECT_NAME": project_name,
385 "REGISTRY_REGION": self.registry_region,
386 "CERTIFICATE_REGIONS": json.dumps(self.certificate_regions),
387 "BACKEND_TLS_SERVER_NAME": self.backend_tls_server_name,
388 "ROOT_CA_PARAMETER_NAME": self.backend_tls_root_ca_parameter_name,
389 "CERTIFICATE_PARAMETER_PREFIX": self.backend_tls_certificate_parameter_prefix,
390 "ROOT_GENERATION": str(config["root_generation"]),
391 "ROOT_VALIDITY_DAYS": str(config["root_validity_days"]),
392 "ROOT_ROTATE_BEFORE_DAYS": str(config["root_rotate_before_days"]),
393 "ROOT_ACTIVATION_DELAY_HOURS": str(config["root_activation_delay_hours"]),
394 "ROOT_OVERLAP_DAYS": str(config["root_overlap_days"]),
395 "LEAF_VALIDITY_DAYS": str(config["leaf_validity_days"]),
396 "LEAF_ROTATE_BEFORE_DAYS": str(config["leaf_rotate_before_days"]),
397 }
398 self.backend_tls_manager_lambda = lambda_.DockerImageFunction(
399 self,
400 "BackendTlsCertificateManager",
401 function_name=f"{project_name}-backend-tls-manager",
402 code=lambda_.DockerImageCode.from_image_asset(
403 directory="lambda/tls-certificate-manager",
404 platform=ecr_assets.Platform.LINUX_AMD64,
405 ),
406 architecture=lambda_.Architecture.X86_64,
407 timeout=Duration.minutes(5),
408 memory_size=512,
409 reserved_concurrent_executions=1,
410 role=manager_role,
411 environment=manager_environment,
412 log_group=manager_log_group,
413 tracing=lambda_.Tracing.ACTIVE,
414 description="Bootstraps and rotates GCO private-root regional ACM certificates",
415 )
417 provider_log_group = logs.LogGroup(
418 self,
419 "BackendTlsProviderLogGroup",
420 retention=logs.RetentionDays.ONE_MONTH,
421 removal_policy=RemovalPolicy.DESTROY,
422 )
423 provider = cr.Provider(
424 self,
425 "BackendTlsProvider",
426 on_event_handler=self.backend_tls_manager_lambda,
427 log_group=provider_log_group,
428 )
429 self.backend_tls_resource = CustomResource(
430 self,
431 "BackendTlsCertificates",
432 service_token=provider.service_token,
433 properties={
434 "ProjectName": project_name,
435 "RegistryRegion": self.registry_region,
436 "Regions": list(self.certificate_regions),
437 "ServerName": self.backend_tls_server_name,
438 "RootCaParameterName": self.backend_tls_root_ca_parameter_name,
439 "CertificateParameterPrefix": self.backend_tls_certificate_parameter_prefix,
440 "RootGeneration": config["root_generation"],
441 "RootValidityDays": config["root_validity_days"],
442 "RootRotateBeforeDays": config["root_rotate_before_days"],
443 "RootActivationDelayHours": config["root_activation_delay_hours"],
444 "RootOverlapDays": config["root_overlap_days"],
445 "LeafValidityDays": config["leaf_validity_days"],
446 "LeafRotateBeforeDays": config["leaf_rotate_before_days"],
447 "PolicyVersion": "1",
448 },
449 )
450 self.backend_tls_resource.node.add_dependency(self.backend_tls_root_secret)
451 # CloudFormation reverses dependencies on delete, so the provider's
452 # final invocation completes before its managed log group is removed.
453 self.backend_tls_resource.node.add_dependency(provider_log_group)
455 self.backend_tls_rotation_dlq = sqs.Queue(
456 self,
457 "BackendTlsRotationDlq",
458 queue_name=f"{project_name}-backend-tls-rotation-dlq",
459 retention_period=Duration.days(14),
460 encryption=sqs.QueueEncryption.SQS_MANAGED,
461 enforce_ssl=True,
462 removal_policy=RemovalPolicy.DESTROY,
463 )
464 rotation_rule = events.Rule(
465 self,
466 "BackendTlsRotationSchedule",
467 description="Reconcile GCO private roots and imported regional ACM leaves",
468 schedule=events.Schedule.rate(Duration.hours(config["rotation_schedule_hours"])),
469 )
470 rotation_rule.add_target(
471 events_targets.LambdaFunction(
472 self.backend_tls_manager_lambda,
473 event=events.RuleTargetInput.from_object({"Action": "Rotate"}),
474 dead_letter_queue=self.backend_tls_rotation_dlq,
475 retry_attempts=2,
476 max_event_age=Duration.hours(6),
477 )
478 )
479 rotation_rule.node.add_dependency(self.backend_tls_resource)
481 self.backend_tls_manager_error_alarm = cloudwatch.Alarm(
482 self,
483 "BackendTlsManagerErrorAlarm",
484 alarm_description="Backend TLS certificate bootstrap or rotation failed",
485 metric=self.backend_tls_manager_lambda.metric_errors(
486 period=Duration.minutes(15), statistic="Sum"
487 ),
488 threshold=1,
489 evaluation_periods=1,
490 comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
491 treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING,
492 )
493 self.backend_tls_rotation_dlq_alarm = cloudwatch.Alarm(
494 self,
495 "BackendTlsRotationDlqAlarm",
496 alarm_description="Backend TLS scheduled rotation exhausted its retries",
497 metric=self.backend_tls_rotation_dlq.metric_approximate_number_of_messages_visible(
498 period=Duration.minutes(5)
499 ),
500 threshold=1,
501 evaluation_periods=1,
502 comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
503 treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING,
504 )
505 self.backend_tls_reconciliation_heartbeat_alarm = cloudwatch.Alarm(
506 self,
507 "BackendTlsReconciliationHeartbeatAlarm",
508 alarm_description=(
509 "Backend TLS reconciliation has not completed within two schedule intervals"
510 ),
511 metric=cloudwatch.Metric(
512 namespace="GCO/BackendTLS",
513 metric_name="ReconciliationSuccess",
514 dimensions_map={"Project": project_name},
515 statistic="Sum",
516 period=Duration.hours(config["rotation_schedule_hours"] * 2),
517 ),
518 threshold=1,
519 evaluation_periods=1,
520 comparison_operator=cloudwatch.ComparisonOperator.LESS_THAN_THRESHOLD,
521 treat_missing_data=cloudwatch.TreatMissingData.BREACHING,
522 )
523 self.backend_tls_root_expiry_alarm = cloudwatch.Alarm(
524 self,
525 "BackendTlsRootExpiryAlarm",
526 alarm_description=(
527 "Backend TLS root certificate is near expiry after its rotation window"
528 ),
529 metric=cloudwatch.Metric(
530 namespace="GCO/BackendTLS",
531 metric_name="RootCertificateDaysToExpiry",
532 dimensions_map={"Project": project_name},
533 statistic="Minimum",
534 period=Duration.hours(12),
535 ),
536 threshold=max(1, config["root_rotate_before_days"] // 2),
537 evaluation_periods=2,
538 comparison_operator=cloudwatch.ComparisonOperator.LESS_THAN_THRESHOLD,
539 treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING,
540 )
541 self.backend_tls_expiry_alarms: list[cloudwatch.Alarm] = []
542 expiry_alarm_threshold = max(1, config["leaf_rotate_before_days"] // 2)
543 for region in self.certificate_regions:
544 region_id = region.replace("-", "").title()
545 alarm = cloudwatch.Alarm(
546 self,
547 f"BackendTlsLeafExpiryAlarm{region_id}",
548 alarm_description=(
549 f"Backend TLS certificate in {region} is near expiry after rotation window"
550 ),
551 metric=cloudwatch.Metric(
552 namespace="GCO/BackendTLS",
553 metric_name="LeafCertificateDaysToExpiry",
554 dimensions_map={"Project": project_name, "Region": region},
555 statistic="Minimum",
556 period=Duration.hours(12),
557 ),
558 threshold=expiry_alarm_threshold,
559 evaluation_periods=2,
560 comparison_operator=cloudwatch.ComparisonOperator.LESS_THAN_THRESHOLD,
561 treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING,
562 )
563 self.backend_tls_expiry_alarms.append(alarm)
565 from gco.stacks.nag_suppressions import acknowledge_nag_findings
567 acknowledge_nag_findings(
568 manager_role,
569 [
570 {
571 "id": "AwsSolutions-IAM5",
572 "reason": (
573 "ACM ImportCertificate requires Resource: * when creating a new imported "
574 "certificate because its ARN does not exist yet. Other ACM actions are "
575 "scoped to this account's certificate ARNs; SSM is scoped to the exact "
576 "project backend-tls namespace."
577 ),
578 "appliesTo": [
579 "Resource::*",
580 "Action::kms:GenerateDataKey*",
581 "Action::kms:ReEncrypt*",
582 "Resource::arn:<AWS::Partition>:acm:*:<AWS::AccountId>:certificate/*",
583 (
584 f"Resource::arn:<AWS::Partition>:ssm:{self.registry_region}:"
585 f"<AWS::AccountId>:parameter/{project_name}/backend-tls/*"
586 ),
587 ],
588 },
589 ],
590 )
591 acknowledge_nag_findings(
592 provider,
593 [
594 {
595 "id": "AwsSolutions-IAM5",
596 "reason": (
597 "The CDK custom-resource provider invokes only versioned aliases of "
598 "BackendTlsCertificateManager; the generated :* qualifier cannot be "
599 "narrowed to a version that does not exist until deployment."
600 ),
601 "appliesTo": [
602 "Resource::<BackendTlsCertificateManager7EB9FC32.Arn>:*",
603 ],
604 }
605 ],
606 )
607 acknowledge_nag_findings(
608 self.backend_tls_root_secret,
609 [
610 {
611 "id": "AwsSolutions-SMG4",
612 "reason": (
613 "The long-lived private root is rotated by the serialized EventBridge "
614 "certificate manager using a pending-root trust phase and overlap window; "
615 "Secrets Manager's single-value rotation protocol cannot provide that "
616 "multi-region certificate choreography."
617 ),
618 },
619 {
620 "id": "HIPAA.Security-SecretsManagerRotationEnabled",
621 "reason": "The EventBridge certificate manager performs staged root rotation.",
622 },
623 {
624 "id": "NIST.800.53.R5-SecretsManagerRotationEnabled",
625 "reason": "The EventBridge certificate manager performs staged root rotation.",
626 },
627 ],
628 )
629 acknowledge_nag_findings(
630 self.backend_tls_rotation_dlq,
631 [
632 {
633 "id": "AwsSolutions-SQS3",
634 "reason": (
635 "This is itself EventBridge's terminal dead-letter queue; it is "
636 "retained for 14 days and monitored by BackendTlsRotationDlqAlarm. "
637 "Chaining another DLQ would only move the same terminal failure."
638 ),
639 },
640 {
641 "id": "Serverless-SQSRedrivePolicy",
642 "reason": (
643 "This queue is the terminal EventBridge dead-letter queue and is monitored "
644 "by BackendTlsRotationDlqAlarm; redriving it into another queue would only "
645 "move the terminal failure."
646 ),
647 },
648 ],
649 )
650 for alarm in [
651 self.backend_tls_manager_error_alarm,
652 self.backend_tls_rotation_dlq_alarm,
653 self.backend_tls_reconciliation_heartbeat_alarm,
654 self.backend_tls_root_expiry_alarm,
655 *self.backend_tls_expiry_alarms,
656 ]:
657 acknowledge_nag_findings(
658 alarm,
659 [
660 {
661 "id": "HIPAA.Security-CloudWatchAlarmAction",
662 "reason": (
663 "Backend TLS alarms are retained as operator-visible stack alarms; "
664 "notification routing is deployment-specific and can be attached to "
665 "the exported alarms without granting the PKI manager publish access."
666 ),
667 },
668 {
669 "id": "NIST.800.53.R5-CloudWatchAlarmAction",
670 "reason": (
671 "Backend TLS alarms are operator-visible; notification destinations "
672 "remain deployment-specific."
673 ),
674 },
675 ],
676 )
678 def _create_secret(self) -> secretsmanager.Secret:
679 """Create the backend HMAC signing key and its daily rotation."""
680 secret = secretsmanager.Secret(
681 self,
682 "GCOAuthSecret",
683 secret_name=api_gateway_auth_secret_name(self.project_name), # nosec B106 — this is the secret path, not a password
684 description="HMAC signing key for API Gateway backend requests (auto-rotated)",
685 generate_secret_string=secretsmanager.SecretStringGenerator(
686 secret_string_template=json.dumps({"description": "GCO backend HMAC signing key"}),
687 generate_string_key="token",
688 exclude_punctuation=True,
689 password_length=64,
690 ),
691 removal_policy=RemovalPolicy.DESTROY,
692 )
694 # Create rotation Lambda and store as instance attribute for monitoring
695 self.rotation_lambda = self._create_rotation_lambda(secret)
697 # Enable automatic rotation (daily for enhanced security)
698 secret.add_rotation_schedule(
699 "RotationSchedule",
700 automatically_after=Duration.days(1),
701 rotation_lambda=self.rotation_lambda,
702 )
704 return secret
706 def _create_rotation_lambda(self, secret: secretsmanager.Secret) -> lambda_.Function:
707 """Create Lambda function for secret rotation.
709 This Lambda implements the 4-step Secrets Manager rotation protocol:
710 1. createSecret - Generate new random token
711 2. setSecret - No-op (no external system)
712 3. testSecret - Validate token structure
713 4. finishSecret - Move AWSPENDING to AWSCURRENT
714 """
715 # Create IAM role for rotation Lambda
716 rotation_role = iam.Role(
717 self,
718 "RotationLambdaRole",
719 assumed_by=iam.ServicePrincipal("lambda.amazonaws.com"),
720 managed_policies=[
721 iam.ManagedPolicy.from_aws_managed_policy_name(
722 "service-role/AWSLambdaBasicExecutionRole"
723 )
724 ],
725 )
727 # Grant permissions to manage the secret
728 secret.grant_read(rotation_role)
729 secret.grant_write(rotation_role)
731 # Additional permissions for rotation
732 rotation_role.add_to_policy(
733 iam.PolicyStatement(
734 actions=[
735 "secretsmanager:DescribeSecret",
736 "secretsmanager:GetSecretValue",
737 "secretsmanager:PutSecretValue",
738 "secretsmanager:UpdateSecretVersionStage",
739 ],
740 resources=[secret.secret_arn],
741 )
742 )
744 # Create log group for rotation Lambda
745 rotation_log_group = logs.LogGroup(
746 self,
747 "RotationLambdaLogGroup",
748 retention=logs.RetentionDays.ONE_MONTH,
749 removal_policy=RemovalPolicy.DESTROY,
750 )
752 # Create rotation Lambda
753 rotation_lambda = lambda_.Function(
754 self,
755 "SecretRotationFunction",
756 runtime=getattr(lambda_.Runtime, LAMBDA_PYTHON_RUNTIME),
757 handler="handler.lambda_handler",
758 code=lambda_.Code.from_asset("lambda/secret-rotation"),
759 timeout=Duration.seconds(30),
760 memory_size=128,
761 role=rotation_role,
762 log_group=rotation_log_group,
763 description="Rotates the GCO backend HMAC signing key",
764 tracing=lambda_.Tracing.ACTIVE,
765 )
767 # Grant Secrets Manager permission to invoke the rotation Lambda
768 rotation_lambda.grant_invoke(iam.ServicePrincipal("secretsmanager.amazonaws.com"))
770 # cdk-nag suppression: CDK's grant methods generate Resource: * for
771 # the rotation function's execution role.
772 from gco.stacks.nag_suppressions import acknowledge_nag_findings
774 acknowledge_nag_findings(
775 rotation_role,
776 [
777 {
778 "id": "AwsSolutions-IAM5",
779 "reason": (
780 "The secret rotation Lambda needs secretsmanager:GetSecretValue "
781 "and PutSecretValue on the rotation secret. CDK's grant methods "
782 "generate Resource: * for the rotation function's execution role "
783 "because the secret ARN includes a random suffix not known at "
784 "synth time."
785 ),
786 "appliesTo": ["Resource::*"],
787 },
788 ],
789 )
791 return rotation_lambda
793 def _create_proxy_lambda(self) -> lambda_.Function:
794 """Create the authenticated Global Accelerator backend proxy Lambda."""
795 if self.ga_dns is None:
796 raise RuntimeError("The global proxy requires a Global Accelerator endpoint")
798 # Create IAM role
799 lambda_role = iam.Role(
800 self,
801 "ProxyLambdaRole",
802 assumed_by=iam.ServicePrincipal("lambda.amazonaws.com"),
803 managed_policies=[
804 iam.ManagedPolicy.from_aws_managed_policy_name(
805 "service-role/AWSLambdaBasicExecutionRole"
806 )
807 ],
808 )
810 # Grant read access to secret
811 self.secret.grant_read(lambda_role)
813 # The global proxy reaches regional ALBs only through Global
814 # Accelerator. It needs the public root bundle but never the root
815 # secret, certificate private keys, regional ALB registry, or ELB APIs.
816 root_ca_parameter_arn = (
817 f"arn:{self.partition}:ssm:{self.registry_region}:{self.account}:"
818 f"parameter/{self.backend_tls_root_ca_parameter_name.lstrip('/')}"
819 )
820 lambda_role.add_to_policy(
821 iam.PolicyStatement(
822 effect=iam.Effect.ALLOW,
823 actions=["ssm:GetParameter"],
824 resources=[root_ca_parameter_arn],
825 )
826 )
828 from gco.stacks.nag_suppressions import acknowledge_nag_findings
830 acknowledge_nag_findings(
831 lambda_role,
832 [
833 {
834 "id": "AwsSolutions-IAM5",
835 "reason": (
836 "Active X-Ray tracing requires xray:PutTraceSegments and "
837 "xray:PutTelemetryRecords on Resource::* because those APIs do not "
838 "support resource-level IAM constraints."
839 ),
840 "appliesTo": ["Resource::*"],
841 }
842 ],
843 )
845 # Create log group for Lambda
846 proxy_lambda_log_group = logs.LogGroup(
847 self,
848 "ProxyLambdaLogGroup",
849 retention=logs.RetentionDays.ONE_WEEK,
850 removal_policy=RemovalPolicy.DESTROY,
851 )
853 # Create Lambda function
854 proxy_lambda = lambda_.Function(
855 self,
856 "ApiGatewayProxyFunction",
857 runtime=getattr(lambda_.Runtime, LAMBDA_PYTHON_RUNTIME),
858 handler="handler.lambda_handler",
859 code=lambda_.Code.from_asset("lambda/api-gateway-proxy"),
860 timeout=Duration.seconds(29),
861 memory_size=256,
862 role=lambda_role,
863 environment={
864 "GLOBAL_ACCELERATOR_ENDPOINT": self.ga_dns,
865 "SECRET_ARN": self.secret.secret_arn,
866 "BACKEND_TLS_SERVER_NAME": self.backend_tls_server_name,
867 "BACKEND_TLS_ROOT_CA_PARAMETER": self.backend_tls_root_ca_parameter_name,
868 "BACKEND_TLS_ROOT_CA_REGION": self.registry_region,
869 "BACKEND_TLS_CA_CACHE_TTL_SECONDS": str(
870 self.backend_tls_config["trust_cache_ttl_seconds"]
871 ),
872 "BACKEND_TLS_CA_MAX_STALE_SECONDS": str(
873 self.backend_tls_config["trust_cache_max_stale_seconds"]
874 ),
875 },
876 log_group=proxy_lambda_log_group,
877 tracing=lambda_.Tracing.ACTIVE,
878 )
880 return proxy_lambda
882 def _create_inference_proxy_lambda(self) -> lambda_.Function:
883 """Create the inference-only Lambda response-streaming proxy."""
884 if self.ga_dns is None:
885 raise RuntimeError("The global inference proxy requires Global Accelerator")
886 role = iam.Role(
887 self,
888 "InferenceStreamingProxyRole",
889 assumed_by=iam.ServicePrincipal("lambda.amazonaws.com"),
890 managed_policies=[
891 iam.ManagedPolicy.from_aws_managed_policy_name(
892 "service-role/AWSLambdaBasicExecutionRole"
893 )
894 ],
895 )
896 self.secret.grant_read(role)
897 root_ca_parameter_arn = (
898 f"arn:{self.partition}:ssm:{self.registry_region}:{self.account}:"
899 f"parameter/{self.backend_tls_root_ca_parameter_name.lstrip('/')}"
900 )
901 role.add_to_policy(
902 iam.PolicyStatement(
903 effect=iam.Effect.ALLOW,
904 actions=["ssm:GetParameter"],
905 resources=[root_ca_parameter_arn],
906 )
907 )
909 log_group = logs.LogGroup(
910 self,
911 "InferenceStreamingProxyLogGroup",
912 retention=logs.RetentionDays.ONE_WEEK,
913 removal_policy=RemovalPolicy.DESTROY,
914 )
915 function = lambda_.Function(
916 self,
917 "InferenceStreamingProxyFunction",
918 runtime=getattr(lambda_.Runtime, LAMBDA_NODEJS_RUNTIME),
919 handler="index.handler",
920 code=lambda_.Code.from_asset("lambda/inference-streaming-proxy-build"),
921 timeout=Duration.minutes(15),
922 memory_size=256,
923 role=role,
924 environment={
925 "ROUTING_MODE": "global",
926 "MAX_REQUEST_BODY_BYTES": str(self.max_request_body_bytes),
927 "GLOBAL_ACCELERATOR_ENDPOINT": self.ga_dns,
928 "SECRET_ARN": self.secret.secret_arn,
929 "BACKEND_TLS_SERVER_NAME": self.backend_tls_server_name,
930 "BACKEND_TLS_ROOT_CA_PARAMETER": self.backend_tls_root_ca_parameter_name,
931 "BACKEND_TLS_ROOT_CA_REGION": self.registry_region,
932 "BACKEND_TLS_CA_CACHE_TTL_SECONDS": str(
933 self.backend_tls_config["trust_cache_ttl_seconds"]
934 ),
935 "BACKEND_TLS_CA_MAX_STALE_SECONDS": str(
936 self.backend_tls_config["trust_cache_max_stale_seconds"]
937 ),
938 },
939 log_group=log_group,
940 tracing=lambda_.Tracing.ACTIVE,
941 description="Streams authenticated inference responses through Global Accelerator",
942 )
944 from gco.stacks.nag_suppressions import acknowledge_nag_findings
946 acknowledge_nag_findings(
947 role,
948 [
949 {
950 "id": "AwsSolutions-IAM5",
951 "reason": (
952 "Active X-Ray tracing requires write APIs on Resource::*; secret and "
953 "SSM reads remain scoped to this deployment's exact resources."
954 ),
955 "appliesTo": ["Resource::*"],
956 }
957 ],
958 )
959 return function
961 def _create_aggregator_lambda(self) -> lambda_.Function:
962 """Create the SigV4 regional-API aggregation Lambda.
964 A Lambda in the API Gateway region cannot join every regional VPC and
965 therefore must not connect directly to internal ALBs. It discovers the
966 deterministic regional API Gateway stacks through CloudFormation and
967 invokes their account-restricted HTTPS endpoints with its execution-role
968 credentials. Each regional API's VPC Lambda then performs the private
969 authenticated-TLS hop to that region's ALB.
970 """
971 # The exact role ARN is embedded in every regional API resource policy.
972 # A project-scoped physical name keeps that ARN resolvable independently
973 # in every region, avoiding an impossible cross-region CloudFormation
974 # export while allowing multiple project deployments per account.
975 aggregator_role = iam.Role(
976 self,
977 "AggregatorLambdaRole",
978 assumed_by=iam.ServicePrincipal("lambda.amazonaws.com"),
979 role_name=cross_region_aggregator_role_name(self.project_name),
980 managed_policies=[
981 iam.ManagedPolicy.from_aws_managed_policy_name(
982 "service-role/AWSLambdaBasicExecutionRole"
983 )
984 ],
985 )
986 self.aggregator_role = aggregator_role
988 regional_stack_arns = [
989 (
990 f"arn:{self.partition}:cloudformation:{region}:{self.account}:"
991 f"stack/{self.project_name}-regional-api-{region}/*"
992 )
993 for region in self.certificate_regions
994 ]
995 aggregator_role.add_to_policy(
996 iam.PolicyStatement(
997 effect=iam.Effect.ALLOW,
998 actions=["cloudformation:DescribeStacks"],
999 resources=regional_stack_arns,
1000 )
1001 )
1002 aggregator_role.add_to_policy(
1003 iam.PolicyStatement(
1004 effect=iam.Effect.ALLOW,
1005 actions=["execute-api:Invoke"],
1006 resources=[
1007 (
1008 f"arn:{self.partition}:execute-api:{region}:{self.account}:"
1009 f"*/*/{method}/{path}"
1010 )
1011 for region in self.certificate_regions
1012 for method, path in AGGREGATOR_REGIONAL_API_ROUTES
1013 ],
1014 )
1015 )
1017 aggregator_log_group = logs.LogGroup(
1018 self,
1019 "AggregatorLambdaLogGroup",
1020 retention=logs.RetentionDays.ONE_WEEK,
1021 removal_policy=RemovalPolicy.DESTROY,
1022 )
1024 aggregator_lambda = lambda_.Function(
1025 self,
1026 "CrossRegionAggregatorFunction",
1027 runtime=getattr(lambda_.Runtime, LAMBDA_PYTHON_RUNTIME),
1028 handler="handler.lambda_handler",
1029 code=lambda_.Code.from_asset("lambda/cross-region-aggregator"),
1030 timeout=Duration.seconds(29),
1031 memory_size=512,
1032 role=aggregator_role,
1033 environment={
1034 "PROJECT_NAME": self.project_name,
1035 "TARGET_REGIONS": json.dumps(self.certificate_regions),
1036 "AWS_URL_SUFFIX": self.url_suffix,
1037 },
1038 log_group=aggregator_log_group,
1039 description="Aggregates data through SigV4-authenticated regional GCO APIs",
1040 tracing=lambda_.Tracing.ACTIVE,
1041 )
1043 from gco.stacks.nag_suppressions import acknowledge_nag_findings
1045 acknowledge_nag_findings(
1046 aggregator_role,
1047 [
1048 {
1049 "id": "AwsSolutions-IAM5",
1050 "reason": (
1051 "The aggregator uses X-Ray write APIs that require Resource::*, "
1052 "describes only deterministic project/region CloudFormation stack "
1053 "ARNs, and invokes only this account's generated regional API IDs "
1054 "under /api/v1. Regional API resource policies admit only this role "
1055 "unless operators explicitly enable direct regional access."
1056 ),
1057 "appliesTo": [
1058 "Resource::*",
1059 *[
1060 (
1061 f"Resource::arn:<AWS::Partition>:cloudformation:{region}:"
1062 f"<AWS::AccountId>:stack/{self.project_name}-regional-api-"
1063 f"{region}/*"
1064 )
1065 for region in self.certificate_regions
1066 ],
1067 *[
1068 (
1069 f"Resource::arn:<AWS::Partition>:execute-api:{region}:"
1070 f"<AWS::AccountId>:*/*/{method}/{path}"
1071 )
1072 for region in self.certificate_regions
1073 for method, path in AGGREGATOR_REGIONAL_API_ROUTES
1074 ],
1075 ],
1076 },
1077 ],
1078 )
1080 return aggregator_lambda
1082 def _create_api_gateway(self) -> apigateway.RestApi:
1083 """Create API Gateway with IAM authentication."""
1085 # Create CloudWatch log group
1086 api_log_group = logs.LogGroup(
1087 self,
1088 "ApiGatewayLogs",
1089 log_group_name=f"/aws/apigateway/{self.project_name}-global",
1090 retention=logs.RetentionDays.ONE_MONTH,
1091 removal_policy=RemovalPolicy.DESTROY,
1092 )
1094 configured_log_level = str(self.api_gateway_config["log_level"]).upper()
1095 logging_levels = {
1096 "OFF": apigateway.MethodLoggingLevel.OFF,
1097 "ERROR": apigateway.MethodLoggingLevel.ERROR,
1098 "INFO": apigateway.MethodLoggingLevel.INFO,
1099 }
1100 if configured_log_level not in logging_levels:
1101 raise ValueError(
1102 "api_gateway.log_level must be one of OFF, ERROR, or INFO; "
1103 f"got {configured_log_level!r}"
1104 )
1106 # Edge-optimized API Gateway endpoints are a commercial-partition
1107 # capability. Regional endpoints preserve the same IAM contract in
1108 # partitions where the Global Accelerator data path is unavailable.
1109 endpoint_type = (
1110 apigateway.EndpointType.EDGE
1111 if self.ga_dns is not None
1112 else apigateway.EndpointType.REGIONAL
1113 )
1114 api = apigateway.RestApi(
1115 self,
1116 "GCOGlobalApi",
1117 rest_api_name=f"{self.project_name}-global-api",
1118 description="Authenticated global aggregation API for GCO",
1119 endpoint_types=[endpoint_type],
1120 deploy=True,
1121 deploy_options=apigateway.StageOptions(
1122 stage_name="prod",
1123 throttling_rate_limit=self.api_gateway_config["throttle_rate_limit"],
1124 throttling_burst_limit=self.api_gateway_config["throttle_burst_limit"],
1125 logging_level=logging_levels[configured_log_level],
1126 # Never put inference prompts/responses (or other API bodies)
1127 # into execution logs. Standard access logs and metrics remain.
1128 data_trace_enabled=False,
1129 metrics_enabled=self.api_gateway_config["metrics_enabled"],
1130 tracing_enabled=self.api_gateway_config["tracing_enabled"],
1131 access_log_destination=apigateway.LogGroupLogDestination(api_log_group),
1132 access_log_format=apigateway.AccessLogFormat.json_with_standard_fields(
1133 caller=True,
1134 http_method=True,
1135 ip=True,
1136 protocol=True,
1137 request_time=True,
1138 resource_path=True,
1139 response_length=True,
1140 status=True,
1141 user=True,
1142 ),
1143 ),
1144 cloud_watch_role=True,
1145 # CDK otherwise retains the generated API Gateway account role.
1146 cloud_watch_role_removal_policy=RemovalPolicy.DESTROY,
1147 )
1149 # Add resource policy to restrict to account
1150 api.add_to_resource_policy(
1151 iam.PolicyStatement(
1152 effect=iam.Effect.ALLOW,
1153 principals=[iam.AnyPrincipal()],
1154 actions=["execute-api:Invoke"],
1155 resources=["execute-api:/*"],
1156 conditions={"StringEquals": {"aws:PrincipalAccount": self.account}},
1157 )
1158 )
1160 # Allow Cognito-authorized requests on /studio/* paths. The Cognito
1161 # authorizer on the method handles authentication; the resource
1162 # policy just needs to not block the request before it reaches the
1163 # authorizer. Cognito tokens don't carry aws:PrincipalAccount so
1164 # the account-scoped statement above would reject them.
1165 api.add_to_resource_policy(
1166 iam.PolicyStatement(
1167 effect=iam.Effect.ALLOW,
1168 principals=[iam.AnyPrincipal()],
1169 actions=["execute-api:Invoke"],
1170 resources=["execute-api:/*/GET/studio/*"],
1171 )
1172 )
1174 # Create /api/v1. Aggregate routes are available in every partition;
1175 # the GA-backed catch-all control-plane and inference routes are added
1176 # only when the global data path exists.
1177 api_resource = api.root.add_resource("api")
1178 v1_resource = api_resource.add_resource("v1")
1180 if self.proxy_lambda is not None:
1181 lambda_integration = apigateway.LambdaIntegration(
1182 self.proxy_lambda,
1183 proxy=True,
1184 timeout=Duration.seconds(29),
1185 )
1186 proxy_resource = v1_resource.add_resource("{proxy+}")
1187 for method in ["GET", "POST", "PUT", "DELETE", "PATCH"]:
1188 proxy_resource.add_method(
1189 method,
1190 lambda_integration,
1191 authorization_type=apigateway.AuthorizationType.IAM,
1192 method_responses=[
1193 apigateway.MethodResponse(status_code="200"),
1194 apigateway.MethodResponse(status_code="400"),
1195 apigateway.MethodResponse(status_code="403"),
1196 apigateway.MethodResponse(status_code="500"),
1197 ],
1198 )
1200 self._create_global_routes(api, v1_resource)
1202 if self.inference_proxy_lambda is not None:
1203 inference_integration = apigateway.LambdaIntegration(
1204 self.inference_proxy_lambda,
1205 proxy=True,
1206 timeout=Duration.minutes(15),
1207 response_transfer_mode=apigateway.ResponseTransferMode.STREAM,
1208 )
1209 self._create_inference_routes(api, inference_integration)
1211 return api
1213 def _create_global_routes(
1214 self, api: apigateway.RestApi, v1_resource: apigateway.Resource
1215 ) -> None:
1216 """Create routes for cross-region aggregation endpoints.
1218 Routes:
1219 GET /api/v1/global/jobs - List jobs across all regions
1220 DELETE /api/v1/global/jobs - Bulk delete across all regions
1221 GET /api/v1/global/health - Health status across all regions
1222 GET /api/v1/global/status - Cluster status across all regions
1223 """
1224 # Create Lambda integration for aggregator
1225 aggregator_integration = apigateway.LambdaIntegration(
1226 self.aggregator_lambda, proxy=True, timeout=Duration.seconds(29)
1227 )
1229 # Create /global resource
1230 global_resource = v1_resource.add_resource("global")
1232 # /global/jobs
1233 global_jobs = global_resource.add_resource("jobs")
1234 for method in ["GET", "DELETE"]:
1235 global_jobs.add_method(
1236 method,
1237 aggregator_integration,
1238 authorization_type=apigateway.AuthorizationType.IAM,
1239 method_responses=[
1240 apigateway.MethodResponse(status_code="200"),
1241 apigateway.MethodResponse(status_code="400"),
1242 apigateway.MethodResponse(status_code="500"),
1243 ],
1244 )
1246 # /global/health
1247 global_health = global_resource.add_resource("health")
1248 global_health.add_method(
1249 "GET",
1250 aggregator_integration,
1251 authorization_type=apigateway.AuthorizationType.IAM,
1252 method_responses=[
1253 apigateway.MethodResponse(status_code="200"),
1254 apigateway.MethodResponse(status_code="500"),
1255 ],
1256 )
1258 # /global/status
1259 global_status = global_resource.add_resource("status")
1260 global_status.add_method(
1261 "GET",
1262 aggregator_integration,
1263 authorization_type=apigateway.AuthorizationType.IAM,
1264 method_responses=[
1265 apigateway.MethodResponse(status_code="200"),
1266 apigateway.MethodResponse(status_code="500"),
1267 ],
1268 )
1270 def _create_inference_routes(
1271 self,
1272 api: apigateway.RestApi,
1273 lambda_integration: apigateway.LambdaIntegration,
1274 ) -> None:
1275 """Create proxy route for inference endpoints.
1277 Routes:
1278 GET|HEAD|POST /inference/{proxy+} → streaming Lambda → GA → ALB → inference proxy
1280 The dedicated in-cluster service enforces endpoint state and the serving-
1281 path allowlist before opening a streaming connection to a model server.
1282 """
1283 inference_resource = api.root.add_resource("inference")
1284 inference_proxy = inference_resource.add_resource("{proxy+}")
1286 for method in ["GET", "HEAD", "POST"]:
1287 inference_proxy.add_method(
1288 method,
1289 lambda_integration,
1290 authorization_type=apigateway.AuthorizationType.IAM,
1291 method_responses=[
1292 apigateway.MethodResponse(status_code="200"),
1293 apigateway.MethodResponse(status_code="400"),
1294 apigateway.MethodResponse(status_code="404"),
1295 apigateway.MethodResponse(status_code="500"),
1296 apigateway.MethodResponse(status_code="502"),
1297 ],
1298 )
1300 def _create_outputs(self) -> None:
1301 """Export API Gateway endpoint."""
1303 CfnOutput(
1304 self,
1305 "ApiEndpoint",
1306 value=self.api.url,
1307 description="Global API Gateway endpoint (IAM authenticated)",
1308 export_name=f"{self.project_name}-global-api-endpoint",
1309 )
1311 CfnOutput(
1312 self,
1313 "SecretArn",
1314 value=self.secret.secret_arn,
1315 description="Backend HMAC signing-key secret ARN",
1316 export_name=f"{self.project_name}-auth-secret-arn",
1317 )
1319 CfnOutput(
1320 self,
1321 "BackendTlsServerName",
1322 value=self.backend_tls_server_name,
1323 description="Private SNI identity verified on every proxy-to-ALB TLS connection",
1324 export_name=f"{self.project_name}-backend-tls-server-name",
1325 )
1327 CfnOutput(
1328 self,
1329 "BackendTlsRootCaParameter",
1330 value=self.backend_tls_root_ca_parameter_name,
1331 description="SSM parameter containing the public backend TLS root trust bundle",
1332 export_name=f"{self.project_name}-backend-tls-root-ca-parameter",
1333 )
1335 def set_analytics_config(self, config: AnalyticsApiConfig) -> None:
1336 """Attach a post-construction ``AnalyticsApiConfig`` and wire ``/studio/*`` routes.
1338 ``GCOAnalyticsStack`` is created *after* ``GCOApiGatewayGlobalStack``
1339 in ``app.py`` (the regional stacks already declare a dependency on
1340 the API gateway stack, so re-ordering the two global stacks would
1341 ripple through the entire stack graph). This mutator lets
1342 ``app.py`` defer attaching the analytics integration until after
1343 both stacks exist, without changing the constructor contract or
1344 the existing cross-stack dependency wiring.
1346 MUST be called **at most once**, and only before stack synthesis
1347 finishes. Calling it twice raises ``RuntimeError`` so the caller
1348 cannot accidentally double-wire the Cognito authorizer (which
1349 would produce two authorizers with overlapping identity sources
1350 on the same REST API).
1352 Args:
1353 config: The ``AnalyticsApiConfig`` built from the
1354 ``GCOAnalyticsStack`` attributes. Must be non-``None`` —
1355 pass ``None`` at construction time instead if analytics
1356 is disabled.
1358 Raises:
1359 RuntimeError: if the stack already has an attached
1360 ``analytics_config`` (from either constructor kwarg or
1361 a prior ``set_analytics_config`` call).
1362 """
1363 if self.analytics_config is not None:
1364 raise RuntimeError(
1365 "GCOApiGatewayGlobalStack.set_analytics_config may only be called "
1366 "once. The stack already has an analytics_config attached."
1367 )
1368 self.analytics_config = config
1369 self._wire_studio_routes()
1371 def _wire_studio_routes(self) -> None:
1372 """Attach the Cognito-authorized ``/studio/*`` route tree.
1374 Called from ``__init__`` when an ``AnalyticsApiConfig`` is passed
1375 to the constructor, or from :meth:`set_analytics_config` when the
1376 config is attached post-construction. Safe to skip entirely when
1377 analytics is disabled — the caller is responsible for gating on
1378 ``self.analytics_config is not None``.
1380 Wiring order matters: this runs *after* ``_create_api_gateway``
1381 has already attached the IAM-authorized ``/api/v1/*`` and
1382 ``/inference/*`` methods. The Cognito authorizer coexists with
1383 those methods at the method level (not at the REST API level),
1384 so the existing IAM-authorized methods are untouched — see the
1385 coexistence assertion in
1386 ``tests/test_api_gateway_analytics_config.py``.
1388 Resources added:
1390 * ``CognitoUserPoolsAuthorizer`` named ``StudioCognitoAuthorizer``
1391 referencing ``UserPool.from_user_pool_arn(...)``.
1392 * ``RequestValidator`` with ``validate_request_parameters=True``
1393 attached to the ``/studio/login`` method via
1394 ``request_validator_options``.
1395 * ``/studio`` + ``/studio/login`` + ``/studio/callback``
1396 resources.
1397 * ``GET /studio/login`` — Cognito-authorized,
1398 ``LambdaIntegration(presigned_url_lambda, proxy=True,
1399 timeout=Duration.seconds(29))``.
1400 * ``GET /studio/callback`` — unauthenticated stub MOCK
1401 integration returning a 200 with an empty body; serves as the
1402 OAuth redirect landing page when Cognito hosted UI is enabled.
1403 * ``CfnOutput`` ``CognitoAuthorizerId`` with the authorizer's
1404 ``authorizer_id``.
1405 * ``CfnOutput`` ``StudioLoginUrl`` — concrete
1406 ``https://<api-id>.execute-api.<region>.amazonaws.com/prod/studio/login``
1407 constructed at deploy time via ``Fn.sub`` because the REST API
1408 id is a deploy-time token.
1409 """
1410 assert self.analytics_config is not None, (
1411 "_wire_studio_routes called without an AnalyticsApiConfig attached."
1412 )
1413 analytics_config = self.analytics_config
1415 # Build the authorizer against the Cognito user pool that owns
1416 # Studio identities. ``from_user_pool_arn`` is an import — no
1417 # new Cognito resources are created in this stack.
1418 user_pool = cognito.UserPool.from_user_pool_arn(
1419 self,
1420 "StudioUserPoolRef",
1421 analytics_config.user_pool_arn,
1422 )
1423 authorizer = apigateway.CognitoUserPoolsAuthorizer(
1424 self,
1425 "StudioCognitoAuthorizer",
1426 cognito_user_pools=[user_pool],
1427 authorizer_name=f"{self.project_name}-studio-cognito-authorizer",
1428 )
1429 # The authorizer attaches itself to the RestApi automatically
1430 # the first time it is passed into ``add_method``. No explicit
1431 # attach call is needed (and the CDK API does not expose a
1432 # public one for ``CognitoUserPoolsAuthorizer``).
1434 # Request validator — validates query/path parameters are
1435 # present before the Lambda is invoked (the Cognito ID token
1436 # itself is validated by the authorizer, not this validator).
1437 studio_request_validator = apigateway.RequestValidator(
1438 self,
1439 "StudioRequestValidator",
1440 rest_api=self.api,
1441 request_validator_name=f"{self.project_name}-studio-request-validator",
1442 validate_request_parameters=True,
1443 )
1445 # /studio → /studio/login + /studio/callback
1446 studio_resource = self.api.root.add_resource("studio")
1447 login_resource = studio_resource.add_resource("login")
1448 callback_resource = studio_resource.add_resource("callback")
1450 # /studio/login — Cognito-authorized, proxies to the
1451 # presigned-URL Lambda. 29-second integration timeout matches
1452 # the Lambda timeout so the Lambda is the one that times out
1453 # on slow SageMaker API calls rather than API Gateway.
1454 login_integration = apigateway.LambdaIntegration(
1455 analytics_config.presigned_url_lambda,
1456 proxy=True,
1457 timeout=Duration.seconds(29),
1458 )
1459 login_resource.add_method(
1460 "GET",
1461 login_integration,
1462 authorization_type=apigateway.AuthorizationType.COGNITO,
1463 authorizer=authorizer,
1464 request_validator=studio_request_validator,
1465 method_responses=[
1466 apigateway.MethodResponse(status_code="200"),
1467 apigateway.MethodResponse(status_code="400"),
1468 apigateway.MethodResponse(status_code="401"),
1469 apigateway.MethodResponse(status_code="404"),
1470 apigateway.MethodResponse(status_code="500"),
1471 ],
1472 )
1474 # /studio/callback — stub 200 OK landing page for the Cognito
1475 # hosted UI OAuth redirect flow. Unauthenticated MOCK
1476 # integration so the page is reachable without a signed
1477 # request. The body is intentionally empty — the hosted UI
1478 # consumes the query-string code parameter, not the response
1479 # body.
1480 callback_integration = apigateway.MockIntegration(
1481 integration_responses=[
1482 apigateway.IntegrationResponse(
1483 status_code="200",
1484 response_templates={"application/json": ""},
1485 ),
1486 ],
1487 request_templates={"application/json": '{"statusCode": 200}'},
1488 )
1489 callback_method = callback_resource.add_method(
1490 "GET",
1491 callback_integration,
1492 authorization_type=apigateway.AuthorizationType.NONE,
1493 method_responses=[
1494 apigateway.MethodResponse(status_code="200"),
1495 ],
1496 )
1498 # /studio/callback is intentionally unauthenticated — it's the
1499 # Cognito hosted-UI OAuth redirect landing page where the
1500 # authorization ``code`` query-string parameter is consumed by
1501 # the client-side JavaScript. Adding IAM or Cognito authorization
1502 # here would break the OAuth flow because the browser redirect
1503 # from Cognito does not carry SigV4 or an id-token header.
1504 from gco.stacks.nag_suppressions import acknowledge_nag_findings
1506 acknowledge_nag_findings(
1507 callback_method,
1508 [
1509 {
1510 "id": "AwsSolutions-APIG4",
1511 "reason": (
1512 "/studio/callback is the Cognito hosted-UI OAuth "
1513 "redirect landing page. The browser redirect from "
1514 "Cognito carries the authorization code as a "
1515 "query-string parameter; it does NOT carry SigV4 "
1516 "or an id-token header. Adding IAM or Cognito "
1517 "authorization here would break the OAuth flow. "
1518 "The route is a MOCK integration that returns an "
1519 "empty 200 body; it does not expose any backend "
1520 "resources."
1521 ),
1522 },
1523 ],
1524 )
1526 # CfnOutputs — the CLI reads these for auto-discovery.
1527 CfnOutput(
1528 self,
1529 "CognitoAuthorizerId",
1530 value=authorizer.authorizer_id,
1531 description="API Gateway authorizer id for the Studio Cognito authorizer",
1532 export_name=f"{self.project_name}-studio-cognito-authorizer-id",
1533 )
1534 # ``self.api.url`` already resolves to the deploy-time URL, but
1535 # it points at the stage root. Use ``Fn.sub`` to append the
1536 # concrete ``studio/login`` suffix so operators get a copy-
1537 # pastable login URL in the stack outputs.
1538 studio_login_url = Fn.sub(
1539 "https://${ApiId}.execute-api.${AWS::Region}.${AWS::URLSuffix}/${Stage}/studio/login",
1540 {
1541 "ApiId": self.api.rest_api_id,
1542 "Stage": self.api.deployment_stage.stage_name,
1543 },
1544 )
1545 CfnOutput(
1546 self,
1547 "StudioLoginUrl",
1548 value=studio_login_url,
1549 description="Concrete URL for the /studio/login route (Cognito-authenticated)",
1550 export_name=f"{self.project_name}-studio-login-url",
1551 )
1553 def _create_waf(self) -> None:
1554 """Create WAF WebACL with AWS Managed Rules for API Gateway protection.
1556 This implements a comprehensive WAF setup using AWS Managed Rule Groups
1557 for protection against:
1558 - Common web exploits (OWASP Top 10)
1559 - Known bad inputs
1560 - SQL injection
1561 - Linux-specific attacks
1562 - IP reputation threats
1563 - Anonymous IP addresses (Tor, VPNs, proxies)
1565 The WebACL is associated with the API Gateway stage for edge protection.
1566 Logging is enabled to CloudWatch Logs for compliance (HIPAA, NIST, PCI-DSS).
1567 """
1568 # Create CloudWatch Log Group for WAF logs
1569 # WAF requires log group name to start with "aws-waf-logs-"
1570 waf_log_group = logs.LogGroup(
1571 self,
1572 "WafLogGroup",
1573 log_group_name=f"aws-waf-logs-{self.project_name}-api-gateway",
1574 retention=logs.RetentionDays.ONE_MONTH,
1575 removal_policy=RemovalPolicy.DESTROY,
1576 )
1578 # Create WAF WebACL with AWS Managed Rules
1579 # Note: For API Gateway (even edge-optimized), use REGIONAL scope
1580 # The WAF is associated with the API Gateway stage, not CloudFront directly
1581 #
1582 # Rule priority ordering:
1583 # 0 -> PerIPRateLimit (evaluated FIRST so abusive IPs are blocked
1584 # before expensive managed rule groups run)
1585 # 1 -> Preserve the CRS 8 KiB body limit outside /inference/*
1586 # 2-7 -> AWS Managed Rule Groups
1587 waf_config = self.node.try_get_context("waf") or {}
1588 per_ip_rate_limit = int(waf_config.get("per_ip_rate_limit", 100))
1590 self.web_acl = wafv2.CfnWebACL(
1591 self,
1592 "GCOWebAcl",
1593 name=f"{self.project_name}-api-gateway-waf",
1594 description="WAF WebACL for GCO API Gateway with AWS Managed Rules",
1595 scope="REGIONAL", # REGIONAL for API Gateway association
1596 default_action=wafv2.CfnWebACL.DefaultActionProperty(allow={}),
1597 visibility_config=wafv2.CfnWebACL.VisibilityConfigProperty(
1598 cloud_watch_metrics_enabled=True,
1599 metric_name="GCOApiGatewayWaf",
1600 sampled_requests_enabled=True,
1601 ),
1602 rules=[
1603 # Rule 0: Per-source-IP rate limiting (HIGHEST PRIORITY).
1604 # Evaluated before any AWS Managed Rule Group so that abusive
1605 # IPs are blocked immediately without consuming WCUs on the
1606 # heavier managed rule groups. Aggregates requests per source
1607 # IP over a rolling 5-minute window (AWS WAF fixed behavior
1608 # for rate-based statements).
1609 #
1610 # The limit is configurable via `cdk.json` context
1611 # `waf.per_ip_rate_limit` (default: 100 requests / 5 min).
1612 wafv2.CfnWebACL.RuleProperty(
1613 name="PerIPRateLimit",
1614 priority=0,
1615 action=wafv2.CfnWebACL.RuleActionProperty(block={}),
1616 statement=wafv2.CfnWebACL.StatementProperty(
1617 rate_based_statement=wafv2.CfnWebACL.RateBasedStatementProperty(
1618 limit=per_ip_rate_limit,
1619 aggregate_key_type="IP",
1620 )
1621 ),
1622 visibility_config=wafv2.CfnWebACL.VisibilityConfigProperty(
1623 cloud_watch_metrics_enabled=True,
1624 metric_name="PerIPRateLimit",
1625 sampled_requests_enabled=True,
1626 ),
1627 ),
1628 # Rule 1: Preserve the CRS 8 KiB body limit for every route
1629 # except the deployed /prod/inference/{proxy+} route. API Gateway
1630 # invoke URLs include the stage in the client URI that WAF inspects,
1631 # and the trailing slash is the route boundary: /prod/inference-extra
1632 # must remain subject to this limit. Inference accepts bodies up to
1633 # the backend's authoritative 1 MiB limit. MATCH fails closed on
1634 # oversized control-plane bodies beyond WAF's inspection window.
1635 wafv2.CfnWebACL.RuleProperty(
1636 name="NonInferenceBodySizeLimit",
1637 priority=1,
1638 action=wafv2.CfnWebACL.RuleActionProperty(block={}),
1639 statement=wafv2.CfnWebACL.StatementProperty(
1640 and_statement=wafv2.CfnWebACL.AndStatementProperty(
1641 statements=[
1642 wafv2.CfnWebACL.StatementProperty(
1643 size_constraint_statement=wafv2.CfnWebACL.SizeConstraintStatementProperty(
1644 comparison_operator="GT",
1645 field_to_match=wafv2.CfnWebACL.FieldToMatchProperty(
1646 body=wafv2.CfnWebACL.BodyProperty(
1647 oversize_handling="MATCH"
1648 )
1649 ),
1650 size=8_192,
1651 text_transformations=[
1652 wafv2.CfnWebACL.TextTransformationProperty(
1653 priority=0,
1654 type="NONE",
1655 )
1656 ],
1657 )
1658 ),
1659 wafv2.CfnWebACL.StatementProperty(
1660 not_statement=wafv2.CfnWebACL.NotStatementProperty(
1661 statement=wafv2.CfnWebACL.StatementProperty(
1662 byte_match_statement=wafv2.CfnWebACL.ByteMatchStatementProperty(
1663 field_to_match=wafv2.CfnWebACL.FieldToMatchProperty(
1664 uri_path={}
1665 ),
1666 positional_constraint="STARTS_WITH",
1667 search_string="/prod/inference/",
1668 text_transformations=[
1669 wafv2.CfnWebACL.TextTransformationProperty(
1670 priority=0,
1671 type="NONE",
1672 )
1673 ],
1674 )
1675 )
1676 )
1677 ),
1678 ]
1679 )
1680 ),
1681 visibility_config=wafv2.CfnWebACL.VisibilityConfigProperty(
1682 cloud_watch_metrics_enabled=True,
1683 metric_name="NonInferenceBodySizeLimit",
1684 sampled_requests_enabled=True,
1685 ),
1686 ),
1687 # Rule 2: AWS Managed Rules - Common Rule Set (OWASP Top 10).
1688 # Override only SizeRestrictions_BODY: every other CRS rule
1689 # continues to block normally, including body-content rules.
1690 wafv2.CfnWebACL.RuleProperty(
1691 name="AWSManagedRulesCommonRuleSet",
1692 priority=2,
1693 override_action=wafv2.CfnWebACL.OverrideActionProperty(none={}),
1694 statement=wafv2.CfnWebACL.StatementProperty(
1695 managed_rule_group_statement=wafv2.CfnWebACL.ManagedRuleGroupStatementProperty(
1696 vendor_name="AWS",
1697 name="AWSManagedRulesCommonRuleSet",
1698 rule_action_overrides=[
1699 wafv2.CfnWebACL.RuleActionOverrideProperty(
1700 name="SizeRestrictions_BODY",
1701 action_to_use=wafv2.CfnWebACL.RuleActionProperty(count={}),
1702 )
1703 ],
1704 )
1705 ),
1706 visibility_config=wafv2.CfnWebACL.VisibilityConfigProperty(
1707 cloud_watch_metrics_enabled=True,
1708 metric_name="AWSManagedRulesCommonRuleSet",
1709 sampled_requests_enabled=True,
1710 ),
1711 ),
1712 # Rule 3: AWS Managed Rules - Known Bad Inputs
1713 wafv2.CfnWebACL.RuleProperty(
1714 name="AWSManagedRulesKnownBadInputsRuleSet",
1715 priority=3,
1716 override_action=wafv2.CfnWebACL.OverrideActionProperty(none={}),
1717 statement=wafv2.CfnWebACL.StatementProperty(
1718 managed_rule_group_statement=wafv2.CfnWebACL.ManagedRuleGroupStatementProperty(
1719 vendor_name="AWS",
1720 name="AWSManagedRulesKnownBadInputsRuleSet",
1721 )
1722 ),
1723 visibility_config=wafv2.CfnWebACL.VisibilityConfigProperty(
1724 cloud_watch_metrics_enabled=True,
1725 metric_name="AWSManagedRulesKnownBadInputsRuleSet",
1726 sampled_requests_enabled=True,
1727 ),
1728 ),
1729 # Rule 4: AWS Managed Rules - SQL Injection
1730 wafv2.CfnWebACL.RuleProperty(
1731 name="AWSManagedRulesSQLiRuleSet",
1732 priority=4,
1733 override_action=wafv2.CfnWebACL.OverrideActionProperty(none={}),
1734 statement=wafv2.CfnWebACL.StatementProperty(
1735 managed_rule_group_statement=wafv2.CfnWebACL.ManagedRuleGroupStatementProperty(
1736 vendor_name="AWS",
1737 name="AWSManagedRulesSQLiRuleSet",
1738 )
1739 ),
1740 visibility_config=wafv2.CfnWebACL.VisibilityConfigProperty(
1741 cloud_watch_metrics_enabled=True,
1742 metric_name="AWSManagedRulesSQLiRuleSet",
1743 sampled_requests_enabled=True,
1744 ),
1745 ),
1746 # Rule 5: AWS Managed Rules - Linux OS (protects against Linux-specific attacks)
1747 wafv2.CfnWebACL.RuleProperty(
1748 name="AWSManagedRulesLinuxRuleSet",
1749 priority=5,
1750 override_action=wafv2.CfnWebACL.OverrideActionProperty(none={}),
1751 statement=wafv2.CfnWebACL.StatementProperty(
1752 managed_rule_group_statement=wafv2.CfnWebACL.ManagedRuleGroupStatementProperty(
1753 vendor_name="AWS",
1754 name="AWSManagedRulesLinuxRuleSet",
1755 )
1756 ),
1757 visibility_config=wafv2.CfnWebACL.VisibilityConfigProperty(
1758 cloud_watch_metrics_enabled=True,
1759 metric_name="AWSManagedRulesLinuxRuleSet",
1760 sampled_requests_enabled=True,
1761 ),
1762 ),
1763 # Rule 6: AWS Managed Rules - Amazon IP Reputation List
1764 wafv2.CfnWebACL.RuleProperty(
1765 name="AWSManagedRulesAmazonIpReputationList",
1766 priority=6,
1767 override_action=wafv2.CfnWebACL.OverrideActionProperty(none={}),
1768 statement=wafv2.CfnWebACL.StatementProperty(
1769 managed_rule_group_statement=wafv2.CfnWebACL.ManagedRuleGroupStatementProperty(
1770 vendor_name="AWS",
1771 name="AWSManagedRulesAmazonIpReputationList",
1772 )
1773 ),
1774 visibility_config=wafv2.CfnWebACL.VisibilityConfigProperty(
1775 cloud_watch_metrics_enabled=True,
1776 metric_name="AWSManagedRulesAmazonIpReputationList",
1777 sampled_requests_enabled=True,
1778 ),
1779 ),
1780 # Rule 7: AWS Managed Rules - Anonymous IP List (blocks Tor, VPNs, proxies)
1781 wafv2.CfnWebACL.RuleProperty(
1782 name="AWSManagedRulesAnonymousIpList",
1783 priority=7,
1784 override_action=wafv2.CfnWebACL.OverrideActionProperty(none={}),
1785 statement=wafv2.CfnWebACL.StatementProperty(
1786 managed_rule_group_statement=wafv2.CfnWebACL.ManagedRuleGroupStatementProperty(
1787 vendor_name="AWS",
1788 name="AWSManagedRulesAnonymousIpList",
1789 )
1790 ),
1791 visibility_config=wafv2.CfnWebACL.VisibilityConfigProperty(
1792 cloud_watch_metrics_enabled=True,
1793 metric_name="AWSManagedRulesAnonymousIpList",
1794 sampled_requests_enabled=True,
1795 ),
1796 ),
1797 ],
1798 )
1800 # Enable WAF logging to CloudWatch Logs
1801 # This is required for HIPAA, NIST 800-53, and PCI-DSS compliance
1802 wafv2.CfnLoggingConfiguration(
1803 self,
1804 "WafLoggingConfig",
1805 resource_arn=self.web_acl.attr_arn,
1806 log_destination_configs=[waf_log_group.log_group_arn],
1807 )
1809 # Associate WAF WebACL with API Gateway stage
1810 # For API Gateway, use the stage ARN format
1811 wafv2.CfnWebACLAssociation(
1812 self,
1813 "GCOWebAclAssociation",
1814 resource_arn=self.api.deployment_stage.stage_arn,
1815 web_acl_arn=self.web_acl.attr_arn,
1816 )
1818 # Output WAF WebACL ARN
1819 CfnOutput(
1820 self,
1821 "WebAclArn",
1822 value=self.web_acl.attr_arn,
1823 description="WAF WebACL ARN for API Gateway protection",
1824 export_name=f"{self.project_name}-waf-webacl-arn",
1825 )