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