Coverage for gco/stacks/api_gateway_global_stack.py: 95.98%
223 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-30 21:22 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-30 21:22 +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-07-18T01:03:40Z
85# Flowchart(s) generated from this file:
86# * ``GCOApiGatewayGlobalStack.__init__`` -> ``diagrams/code_diagrams/gco/stacks/api_gateway_global_stack.GCOApiGatewayGlobalStack___init__.html``
87# (PNG: ``diagrams/code_diagrams/gco/stacks/api_gateway_global_stack.GCOApiGatewayGlobalStack___init__.png``)
88# Regenerate with ``python diagrams/code_diagrams/generate.py``.
89# <pyflowchart-code-diagram> END
92@dataclass(frozen=True)
93class AnalyticsApiConfig:
94 """Configuration handed from ``GCOAnalyticsStack`` to ``GCOApiGatewayGlobalStack``.
96 When ``GCOApiGatewayGlobalStack`` is constructed (or mutated via
97 :meth:`GCOApiGatewayGlobalStack.set_analytics_config`) with a non-``None``
98 instance of this dataclass, the stack wires a Cognito-authorized
99 ``/studio/*`` route tree onto the existing REST API. When the value is
100 ``None``, the stack is behaviorally identical to its pre-analytics shape
101 — no ``/studio/*`` resources, no Cognito authorizer, no additional
102 ``CfnOutput`` entries.
104 ``frozen=True`` makes the dataclass hashable and immutable so a single
105 config object can be safely shared across constructs without the risk
106 of accidental mutation after the synthesized template references its
107 fields.
109 Attributes:
110 user_pool_arn: Full ARN of the Cognito user pool that authenticates
111 Studio logins. Shape:
112 ``arn:aws:cognito-idp:<region>:<account>:userpool/<pool-id>``.
113 user_pool_client_id: Client id of the Studio user-pool client
114 (SRP auth). Used by the CLI's ``gco analytics studio login``
115 flow and surfaced to API Gateway outputs for discoverability.
116 presigned_url_lambda: The ``analytics-presigned-url`` Lambda
117 function created by ``GCOAnalyticsStack._create_presigned_url_lambda``.
118 Consumed by the ``/studio/login`` ``LambdaIntegration``.
119 studio_domain_name: SageMaker Studio domain name. Carried through
120 as context for the Lambda integration; the Lambda itself also
121 reads this value from its ``STUDIO_DOMAIN_NAME`` environment
122 variable set by the analytics stack.
123 callback_url: Concrete OAuth redirect target
124 (``https://<api>/prod/studio/callback``) used when the
125 Cognito hosted UI is enabled. The ``/studio/callback`` route
126 is wired as a stub here so the URL is reachable immediately
127 after deploy.
128 """
130 user_pool_arn: str
131 user_pool_client_id: str
132 presigned_url_lambda: lambda_.IFunction
133 studio_domain_name: str
134 callback_url: str
137class GCOApiGatewayGlobalStack(Stack):
138 """
139 Global API Gateway with IAM authentication.
141 This stack creates the single authenticated entry point for all GCO
142 API requests. All requests must be signed with AWS credentials.
144 Attributes:
145 secret: Secrets Manager secret containing the backend HMAC signing key
146 proxy_lambda: Buffered Lambda proxy for the control-plane API
147 inference_proxy_lambda: Response-streaming Lambda for `/inference/*`
148 aggregator_lambda: Lambda function for cross-region aggregation
149 api: REST API with IAM authentication
150 """
152 def __init__(
153 self,
154 scope: Construct,
155 construct_id: str,
156 global_accelerator_dns: str | None,
157 regional_endpoints: dict[str, str] | None = None,
158 analytics_config: AnalyticsApiConfig | None = None,
159 project_name: str = "gco",
160 api_gateway_config: dict[str, Any] | None = None,
161 registry_region: str | None = None,
162 certificate_regions: list[str] | None = None,
163 backend_tls_config: dict[str, Any] | None = None,
164 max_request_body_bytes: int = DEFAULT_MAX_REQUEST_BODY_BYTES,
165 **kwargs: Any,
166 ) -> None:
167 super().__init__(scope, construct_id, **kwargs)
169 # ``project_name`` is the deployment's unique prefix. Every physical
170 # resource name this stack owns (secret, WAF, log groups, CFN exports)
171 # derives from it so two deployments can coexist in one account+region.
172 # Defaults to ``"gco"`` so the rendered names are byte-for-byte
173 # identical to the pre-#139 literals for the stock deployment.
174 self.project_name = project_name
175 self.ga_dns = str(global_accelerator_dns).strip() if global_accelerator_dns else None
176 self.regional_endpoints = regional_endpoints or {}
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: 265 ↛ 266line 265 didn't jump to line 266 because the condition on line 265 was never true
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_.Code.from_asset("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: 794 ↛ 795line 794 didn't jump to line 795 because the condition on line 794 was never true
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_.Code.from_asset("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: 883 ↛ 884line 883 didn't jump to line 884 because the condition on line 883 was never true
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 regional_stack_arns = [
988 (
989 f"arn:{self.partition}:cloudformation:{region}:{self.account}:"
990 f"stack/{self.project_name}-regional-api-{region}/*"
991 )
992 for region in self.certificate_regions
993 ]
994 aggregator_role.add_to_policy(
995 iam.PolicyStatement(
996 effect=iam.Effect.ALLOW,
997 actions=["cloudformation:DescribeStacks"],
998 resources=regional_stack_arns,
999 )
1000 )
1001 aggregator_role.add_to_policy(
1002 iam.PolicyStatement(
1003 effect=iam.Effect.ALLOW,
1004 actions=["execute-api:Invoke"],
1005 resources=[
1006 (
1007 f"arn:{self.partition}:execute-api:{region}:{self.account}:"
1008 f"*/*/{method}/{path}"
1009 )
1010 for region in self.certificate_regions
1011 for method, path in AGGREGATOR_REGIONAL_API_ROUTES
1012 ],
1013 )
1014 )
1016 aggregator_log_group = logs.LogGroup(
1017 self,
1018 "AggregatorLambdaLogGroup",
1019 retention=logs.RetentionDays.ONE_WEEK,
1020 removal_policy=RemovalPolicy.DESTROY,
1021 )
1023 aggregator_lambda = lambda_.Function(
1024 self,
1025 "CrossRegionAggregatorFunction",
1026 runtime=getattr(lambda_.Runtime, LAMBDA_PYTHON_RUNTIME),
1027 handler="handler.lambda_handler",
1028 code=lambda_.Code.from_asset("lambda/cross-region-aggregator"),
1029 timeout=Duration.seconds(29),
1030 memory_size=512,
1031 role=aggregator_role,
1032 environment={
1033 "PROJECT_NAME": self.project_name,
1034 "TARGET_REGIONS": json.dumps(self.certificate_regions),
1035 "AWS_URL_SUFFIX": self.url_suffix,
1036 },
1037 log_group=aggregator_log_group,
1038 description="Aggregates data through SigV4-authenticated regional GCO APIs",
1039 tracing=lambda_.Tracing.ACTIVE,
1040 )
1042 from gco.stacks.nag_suppressions import acknowledge_nag_findings
1044 acknowledge_nag_findings(
1045 aggregator_role,
1046 [
1047 {
1048 "id": "AwsSolutions-IAM5",
1049 "reason": (
1050 "The aggregator uses X-Ray write APIs that require Resource::*, "
1051 "describes only deterministic project/region CloudFormation stack "
1052 "ARNs, and invokes only this account's generated regional API IDs "
1053 "under /api/v1. Regional API resource policies admit only this role "
1054 "unless operators explicitly enable direct regional access."
1055 ),
1056 "appliesTo": [
1057 "Resource::*",
1058 *[
1059 (
1060 f"Resource::arn:<AWS::Partition>:cloudformation:{region}:"
1061 f"<AWS::AccountId>:stack/{self.project_name}-regional-api-"
1062 f"{region}/*"
1063 )
1064 for region in self.certificate_regions
1065 ],
1066 *[
1067 (
1068 f"Resource::arn:<AWS::Partition>:execute-api:{region}:"
1069 f"<AWS::AccountId>:*/*/{method}/{path}"
1070 )
1071 for region in self.certificate_regions
1072 for method, path in AGGREGATOR_REGIONAL_API_ROUTES
1073 ],
1074 ],
1075 },
1076 ],
1077 )
1079 return aggregator_lambda
1081 def _create_api_gateway(self) -> apigateway.RestApi:
1082 """Create API Gateway with IAM authentication."""
1084 # Create CloudWatch log group
1085 api_log_group = logs.LogGroup(
1086 self,
1087 "ApiGatewayLogs",
1088 log_group_name=f"/aws/apigateway/{self.project_name}-global",
1089 retention=logs.RetentionDays.ONE_MONTH,
1090 removal_policy=RemovalPolicy.DESTROY,
1091 )
1093 configured_log_level = str(self.api_gateway_config["log_level"]).upper()
1094 logging_levels = {
1095 "OFF": apigateway.MethodLoggingLevel.OFF,
1096 "ERROR": apigateway.MethodLoggingLevel.ERROR,
1097 "INFO": apigateway.MethodLoggingLevel.INFO,
1098 }
1099 if configured_log_level not in logging_levels: 1099 ↛ 1100line 1099 didn't jump to line 1100 because the condition on line 1099 was never true
1100 raise ValueError(
1101 "api_gateway.log_level must be one of OFF, ERROR, or INFO; "
1102 f"got {configured_log_level!r}"
1103 )
1105 # Edge-optimized API Gateway endpoints are a commercial-partition
1106 # capability. Regional endpoints preserve the same IAM contract in
1107 # partitions where the Global Accelerator data path is unavailable.
1108 endpoint_type = (
1109 apigateway.EndpointType.EDGE
1110 if self.ga_dns is not None
1111 else apigateway.EndpointType.REGIONAL
1112 )
1113 api = apigateway.RestApi(
1114 self,
1115 "GCOGlobalApi",
1116 rest_api_name=f"{self.project_name}-global-api",
1117 description="Authenticated global aggregation API for GCO",
1118 endpoint_types=[endpoint_type],
1119 deploy=True,
1120 deploy_options=apigateway.StageOptions(
1121 stage_name="prod",
1122 throttling_rate_limit=self.api_gateway_config["throttle_rate_limit"],
1123 throttling_burst_limit=self.api_gateway_config["throttle_burst_limit"],
1124 logging_level=logging_levels[configured_log_level],
1125 # Never put inference prompts/responses (or other API bodies)
1126 # into execution logs. Standard access logs and metrics remain.
1127 data_trace_enabled=False,
1128 metrics_enabled=self.api_gateway_config["metrics_enabled"],
1129 tracing_enabled=self.api_gateway_config["tracing_enabled"],
1130 access_log_destination=apigateway.LogGroupLogDestination(api_log_group),
1131 access_log_format=apigateway.AccessLogFormat.json_with_standard_fields(
1132 caller=True,
1133 http_method=True,
1134 ip=True,
1135 protocol=True,
1136 request_time=True,
1137 resource_path=True,
1138 response_length=True,
1139 status=True,
1140 user=True,
1141 ),
1142 ),
1143 cloud_watch_role=True,
1144 # CDK otherwise retains the generated API Gateway account role.
1145 cloud_watch_role_removal_policy=RemovalPolicy.DESTROY,
1146 )
1148 # Add resource policy to restrict to account
1149 api.add_to_resource_policy(
1150 iam.PolicyStatement(
1151 effect=iam.Effect.ALLOW,
1152 principals=[iam.AnyPrincipal()],
1153 actions=["execute-api:Invoke"],
1154 resources=["execute-api:/*"],
1155 conditions={"StringEquals": {"aws:PrincipalAccount": self.account}},
1156 )
1157 )
1159 # Allow Cognito-authorized requests on /studio/* paths. The Cognito
1160 # authorizer on the method handles authentication; the resource
1161 # policy just needs to not block the request before it reaches the
1162 # authorizer. Cognito tokens don't carry aws:PrincipalAccount so
1163 # the account-scoped statement above would reject them.
1164 api.add_to_resource_policy(
1165 iam.PolicyStatement(
1166 effect=iam.Effect.ALLOW,
1167 principals=[iam.AnyPrincipal()],
1168 actions=["execute-api:Invoke"],
1169 resources=["execute-api:/*/GET/studio/*"],
1170 )
1171 )
1173 # Create /api/v1. Aggregate routes are available in every partition;
1174 # the GA-backed catch-all control-plane and inference routes are added
1175 # only when the global data path exists.
1176 api_resource = api.root.add_resource("api")
1177 v1_resource = api_resource.add_resource("v1")
1179 if self.proxy_lambda is not None: 1179 ↛ 1199line 1179 didn't jump to line 1199 because the condition on line 1179 was always true
1180 lambda_integration = apigateway.LambdaIntegration(
1181 self.proxy_lambda,
1182 proxy=True,
1183 timeout=Duration.seconds(29),
1184 )
1185 proxy_resource = v1_resource.add_resource("{proxy+}")
1186 for method in ["GET", "POST", "PUT", "DELETE", "PATCH"]:
1187 proxy_resource.add_method(
1188 method,
1189 lambda_integration,
1190 authorization_type=apigateway.AuthorizationType.IAM,
1191 method_responses=[
1192 apigateway.MethodResponse(status_code="200"),
1193 apigateway.MethodResponse(status_code="400"),
1194 apigateway.MethodResponse(status_code="403"),
1195 apigateway.MethodResponse(status_code="500"),
1196 ],
1197 )
1199 self._create_global_routes(api, v1_resource)
1201 if self.inference_proxy_lambda is not None: 1201 ↛ 1210line 1201 didn't jump to line 1210 because the condition on line 1201 was always true
1202 inference_integration = apigateway.LambdaIntegration(
1203 self.inference_proxy_lambda,
1204 proxy=True,
1205 timeout=Duration.minutes(15),
1206 response_transfer_mode=apigateway.ResponseTransferMode.STREAM,
1207 )
1208 self._create_inference_routes(api, inference_integration)
1210 return api
1212 def _create_global_routes(
1213 self, api: apigateway.RestApi, v1_resource: apigateway.Resource
1214 ) -> None:
1215 """Create routes for cross-region aggregation endpoints.
1217 Routes:
1218 GET /api/v1/global/jobs - List jobs across all regions
1219 DELETE /api/v1/global/jobs - Bulk delete across all regions
1220 GET /api/v1/global/health - Health status across all regions
1221 GET /api/v1/global/status - Cluster status across all regions
1222 """
1223 # Create Lambda integration for aggregator
1224 aggregator_integration = apigateway.LambdaIntegration(
1225 self.aggregator_lambda, proxy=True, timeout=Duration.seconds(29)
1226 )
1228 # Create /global resource
1229 global_resource = v1_resource.add_resource("global")
1231 # /global/jobs
1232 global_jobs = global_resource.add_resource("jobs")
1233 for method in ["GET", "DELETE"]:
1234 global_jobs.add_method(
1235 method,
1236 aggregator_integration,
1237 authorization_type=apigateway.AuthorizationType.IAM,
1238 method_responses=[
1239 apigateway.MethodResponse(status_code="200"),
1240 apigateway.MethodResponse(status_code="400"),
1241 apigateway.MethodResponse(status_code="500"),
1242 ],
1243 )
1245 # /global/health
1246 global_health = global_resource.add_resource("health")
1247 global_health.add_method(
1248 "GET",
1249 aggregator_integration,
1250 authorization_type=apigateway.AuthorizationType.IAM,
1251 method_responses=[
1252 apigateway.MethodResponse(status_code="200"),
1253 apigateway.MethodResponse(status_code="500"),
1254 ],
1255 )
1257 # /global/status
1258 global_status = global_resource.add_resource("status")
1259 global_status.add_method(
1260 "GET",
1261 aggregator_integration,
1262 authorization_type=apigateway.AuthorizationType.IAM,
1263 method_responses=[
1264 apigateway.MethodResponse(status_code="200"),
1265 apigateway.MethodResponse(status_code="500"),
1266 ],
1267 )
1269 def _create_inference_routes(
1270 self,
1271 api: apigateway.RestApi,
1272 lambda_integration: apigateway.LambdaIntegration,
1273 ) -> None:
1274 """Create proxy route for inference endpoints.
1276 Routes:
1277 GET|HEAD|POST /inference/{proxy+} → streaming Lambda → GA → ALB → inference proxy
1279 The dedicated in-cluster service enforces endpoint state and the serving-
1280 path allowlist before opening a streaming connection to a model server.
1281 """
1282 inference_resource = api.root.add_resource("inference")
1283 inference_proxy = inference_resource.add_resource("{proxy+}")
1285 for method in ["GET", "HEAD", "POST"]:
1286 inference_proxy.add_method(
1287 method,
1288 lambda_integration,
1289 authorization_type=apigateway.AuthorizationType.IAM,
1290 method_responses=[
1291 apigateway.MethodResponse(status_code="200"),
1292 apigateway.MethodResponse(status_code="400"),
1293 apigateway.MethodResponse(status_code="404"),
1294 apigateway.MethodResponse(status_code="500"),
1295 apigateway.MethodResponse(status_code="502"),
1296 ],
1297 )
1299 def _create_outputs(self) -> None:
1300 """Export API Gateway endpoint."""
1302 CfnOutput(
1303 self,
1304 "ApiEndpoint",
1305 value=self.api.url,
1306 description="Global API Gateway endpoint (IAM authenticated)",
1307 export_name=f"{self.project_name}-global-api-endpoint",
1308 )
1310 CfnOutput(
1311 self,
1312 "SecretArn",
1313 value=self.secret.secret_arn,
1314 description="Backend HMAC signing-key secret ARN",
1315 export_name=f"{self.project_name}-auth-secret-arn",
1316 )
1318 CfnOutput(
1319 self,
1320 "BackendTlsServerName",
1321 value=self.backend_tls_server_name,
1322 description="Private SNI identity verified on every proxy-to-ALB TLS connection",
1323 export_name=f"{self.project_name}-backend-tls-server-name",
1324 )
1326 CfnOutput(
1327 self,
1328 "BackendTlsRootCaParameter",
1329 value=self.backend_tls_root_ca_parameter_name,
1330 description="SSM parameter containing the public backend TLS root trust bundle",
1331 export_name=f"{self.project_name}-backend-tls-root-ca-parameter",
1332 )
1334 def set_analytics_config(self, config: AnalyticsApiConfig) -> None:
1335 """Attach a post-construction ``AnalyticsApiConfig`` and wire ``/studio/*`` routes.
1337 ``GCOAnalyticsStack`` is created *after* ``GCOApiGatewayGlobalStack``
1338 in ``app.py`` (the regional stacks already declare a dependency on
1339 the API gateway stack, so re-ordering the two global stacks would
1340 ripple through the entire stack graph). This mutator lets
1341 ``app.py`` defer attaching the analytics integration until after
1342 both stacks exist, without changing the constructor contract or
1343 the existing cross-stack dependency wiring.
1345 MUST be called **at most once**, and only before stack synthesis
1346 finishes. Calling it twice raises ``RuntimeError`` so the caller
1347 cannot accidentally double-wire the Cognito authorizer (which
1348 would produce two authorizers with overlapping identity sources
1349 on the same REST API).
1351 Args:
1352 config: The ``AnalyticsApiConfig`` built from the
1353 ``GCOAnalyticsStack`` attributes. Must be non-``None`` —
1354 pass ``None`` at construction time instead if analytics
1355 is disabled.
1357 Raises:
1358 RuntimeError: if the stack already has an attached
1359 ``analytics_config`` (from either constructor kwarg or
1360 a prior ``set_analytics_config`` call).
1361 """
1362 if self.analytics_config is not None:
1363 raise RuntimeError(
1364 "GCOApiGatewayGlobalStack.set_analytics_config may only be called "
1365 "once. The stack already has an analytics_config attached."
1366 )
1367 self.analytics_config = config
1368 self._wire_studio_routes()
1370 def _wire_studio_routes(self) -> None:
1371 """Attach the Cognito-authorized ``/studio/*`` route tree.
1373 Called from ``__init__`` when an ``AnalyticsApiConfig`` is passed
1374 to the constructor, or from :meth:`set_analytics_config` when the
1375 config is attached post-construction. Safe to skip entirely when
1376 analytics is disabled — the caller is responsible for gating on
1377 ``self.analytics_config is not None``.
1379 Wiring order matters: this runs *after* ``_create_api_gateway``
1380 has already attached the IAM-authorized ``/api/v1/*`` and
1381 ``/inference/*`` methods. The Cognito authorizer coexists with
1382 those methods at the method level (not at the REST API level),
1383 so the existing IAM-authorized methods are untouched — see the
1384 coexistence assertion in
1385 ``tests/test_api_gateway_analytics_config.py``.
1387 Resources added:
1389 * ``CognitoUserPoolsAuthorizer`` named ``StudioCognitoAuthorizer``
1390 referencing ``UserPool.from_user_pool_arn(...)``.
1391 * ``RequestValidator`` with ``validate_request_parameters=True``
1392 attached to the ``/studio/login`` method via
1393 ``request_validator_options``.
1394 * ``/studio`` + ``/studio/login`` + ``/studio/callback``
1395 resources.
1396 * ``GET /studio/login`` — Cognito-authorized,
1397 ``LambdaIntegration(presigned_url_lambda, proxy=True,
1398 timeout=Duration.seconds(29))``.
1399 * ``GET /studio/callback`` — unauthenticated stub MOCK
1400 integration returning a 200 with an empty body; serves as the
1401 OAuth redirect landing page when Cognito hosted UI is enabled.
1402 * ``CfnOutput`` ``CognitoAuthorizerId`` with the authorizer's
1403 ``authorizer_id``.
1404 * ``CfnOutput`` ``StudioLoginUrl`` — concrete
1405 ``https://<api-id>.execute-api.<region>.amazonaws.com/prod/studio/login``
1406 constructed at deploy time via ``Fn.sub`` because the REST API
1407 id is a deploy-time token.
1408 """
1409 assert self.analytics_config is not None, (
1410 "_wire_studio_routes called without an AnalyticsApiConfig attached."
1411 )
1412 analytics_config = self.analytics_config
1414 # Build the authorizer against the Cognito user pool that owns
1415 # Studio identities. ``from_user_pool_arn`` is an import — no
1416 # new Cognito resources are created in this stack.
1417 user_pool = cognito.UserPool.from_user_pool_arn(
1418 self,
1419 "StudioUserPoolRef",
1420 analytics_config.user_pool_arn,
1421 )
1422 authorizer = apigateway.CognitoUserPoolsAuthorizer(
1423 self,
1424 "StudioCognitoAuthorizer",
1425 cognito_user_pools=[user_pool],
1426 authorizer_name=f"{self.project_name}-studio-cognito-authorizer",
1427 )
1428 # The authorizer attaches itself to the RestApi automatically
1429 # the first time it is passed into ``add_method``. No explicit
1430 # attach call is needed (and the CDK API does not expose a
1431 # public one for ``CognitoUserPoolsAuthorizer``).
1433 # Request validator — validates query/path parameters are
1434 # present before the Lambda is invoked (the Cognito ID token
1435 # itself is validated by the authorizer, not this validator).
1436 studio_request_validator = apigateway.RequestValidator(
1437 self,
1438 "StudioRequestValidator",
1439 rest_api=self.api,
1440 request_validator_name=f"{self.project_name}-studio-request-validator",
1441 validate_request_parameters=True,
1442 )
1444 # /studio → /studio/login + /studio/callback
1445 studio_resource = self.api.root.add_resource("studio")
1446 login_resource = studio_resource.add_resource("login")
1447 callback_resource = studio_resource.add_resource("callback")
1449 # /studio/login — Cognito-authorized, proxies to the
1450 # presigned-URL Lambda. 29-second integration timeout matches
1451 # the Lambda timeout so the Lambda is the one that times out
1452 # on slow SageMaker API calls rather than API Gateway.
1453 login_integration = apigateway.LambdaIntegration(
1454 analytics_config.presigned_url_lambda,
1455 proxy=True,
1456 timeout=Duration.seconds(29),
1457 )
1458 login_resource.add_method(
1459 "GET",
1460 login_integration,
1461 authorization_type=apigateway.AuthorizationType.COGNITO,
1462 authorizer=authorizer,
1463 request_validator=studio_request_validator,
1464 method_responses=[
1465 apigateway.MethodResponse(status_code="200"),
1466 apigateway.MethodResponse(status_code="400"),
1467 apigateway.MethodResponse(status_code="401"),
1468 apigateway.MethodResponse(status_code="404"),
1469 apigateway.MethodResponse(status_code="500"),
1470 ],
1471 )
1473 # /studio/callback — stub 200 OK landing page for the Cognito
1474 # hosted UI OAuth redirect flow. Unauthenticated MOCK
1475 # integration so the page is reachable without a signed
1476 # request. The body is intentionally empty — the hosted UI
1477 # consumes the query-string code parameter, not the response
1478 # body.
1479 callback_integration = apigateway.MockIntegration(
1480 integration_responses=[
1481 apigateway.IntegrationResponse(
1482 status_code="200",
1483 response_templates={"application/json": ""},
1484 ),
1485 ],
1486 request_templates={"application/json": '{"statusCode": 200}'},
1487 )
1488 callback_method = callback_resource.add_method(
1489 "GET",
1490 callback_integration,
1491 authorization_type=apigateway.AuthorizationType.NONE,
1492 method_responses=[
1493 apigateway.MethodResponse(status_code="200"),
1494 ],
1495 )
1497 # /studio/callback is intentionally unauthenticated — it's the
1498 # Cognito hosted-UI OAuth redirect landing page where the
1499 # authorization ``code`` query-string parameter is consumed by
1500 # the client-side JavaScript. Adding IAM or Cognito authorization
1501 # here would break the OAuth flow because the browser redirect
1502 # from Cognito does not carry SigV4 or an id-token header.
1503 from gco.stacks.nag_suppressions import acknowledge_nag_findings
1505 acknowledge_nag_findings(
1506 callback_method,
1507 [
1508 {
1509 "id": "AwsSolutions-APIG4",
1510 "reason": (
1511 "/studio/callback is the Cognito hosted-UI OAuth "
1512 "redirect landing page. The browser redirect from "
1513 "Cognito carries the authorization code as a "
1514 "query-string parameter; it does NOT carry SigV4 "
1515 "or an id-token header. Adding IAM or Cognito "
1516 "authorization here would break the OAuth flow. "
1517 "The route is a MOCK integration that returns an "
1518 "empty 200 body; it does not expose any backend "
1519 "resources."
1520 ),
1521 },
1522 ],
1523 )
1525 # CfnOutputs — the CLI reads these for auto-discovery.
1526 CfnOutput(
1527 self,
1528 "CognitoAuthorizerId",
1529 value=authorizer.authorizer_id,
1530 description="API Gateway authorizer id for the Studio Cognito authorizer",
1531 export_name=f"{self.project_name}-studio-cognito-authorizer-id",
1532 )
1533 # ``self.api.url`` already resolves to the deploy-time URL, but
1534 # it points at the stage root. Use ``Fn.sub`` to append the
1535 # concrete ``studio/login`` suffix so operators get a copy-
1536 # pastable login URL in the stack outputs.
1537 studio_login_url = Fn.sub(
1538 "https://${ApiId}.execute-api.${AWS::Region}.${AWS::URLSuffix}/${Stage}/studio/login",
1539 {
1540 "ApiId": self.api.rest_api_id,
1541 "Stage": self.api.deployment_stage.stage_name,
1542 },
1543 )
1544 CfnOutput(
1545 self,
1546 "StudioLoginUrl",
1547 value=studio_login_url,
1548 description="Concrete URL for the /studio/login route (Cognito-authenticated)",
1549 export_name=f"{self.project_name}-studio-login-url",
1550 )
1552 def _create_waf(self) -> None:
1553 """Create WAF WebACL with AWS Managed Rules for API Gateway protection.
1555 This implements a comprehensive WAF setup using AWS Managed Rule Groups
1556 for protection against:
1557 - Common web exploits (OWASP Top 10)
1558 - Known bad inputs
1559 - SQL injection
1560 - Linux-specific attacks
1561 - IP reputation threats
1562 - Anonymous IP addresses (Tor, VPNs, proxies)
1564 The WebACL is associated with the API Gateway stage for edge protection.
1565 Logging is enabled to CloudWatch Logs for compliance (HIPAA, NIST, PCI-DSS).
1566 """
1567 # Create CloudWatch Log Group for WAF logs
1568 # WAF requires log group name to start with "aws-waf-logs-"
1569 waf_log_group = logs.LogGroup(
1570 self,
1571 "WafLogGroup",
1572 log_group_name=f"aws-waf-logs-{self.project_name}-api-gateway",
1573 retention=logs.RetentionDays.ONE_MONTH,
1574 removal_policy=RemovalPolicy.DESTROY,
1575 )
1577 # Create WAF WebACL with AWS Managed Rules
1578 # Note: For API Gateway (even edge-optimized), use REGIONAL scope
1579 # The WAF is associated with the API Gateway stage, not CloudFront directly
1580 #
1581 # Rule priority ordering:
1582 # 0 -> PerIPRateLimit (evaluated FIRST so abusive IPs are blocked
1583 # before expensive managed rule groups run)
1584 # 1 -> Preserve the CRS 8 KiB body limit outside /inference/*
1585 # 2-7 -> AWS Managed Rule Groups
1586 waf_config = self.node.try_get_context("waf") or {}
1587 per_ip_rate_limit = int(waf_config.get("per_ip_rate_limit", 100))
1589 self.web_acl = wafv2.CfnWebACL(
1590 self,
1591 "GCOWebAcl",
1592 name=f"{self.project_name}-api-gateway-waf",
1593 description="WAF WebACL for GCO API Gateway with AWS Managed Rules",
1594 scope="REGIONAL", # REGIONAL for API Gateway association
1595 default_action=wafv2.CfnWebACL.DefaultActionProperty(allow={}),
1596 visibility_config=wafv2.CfnWebACL.VisibilityConfigProperty(
1597 cloud_watch_metrics_enabled=True,
1598 metric_name="GCOApiGatewayWaf",
1599 sampled_requests_enabled=True,
1600 ),
1601 rules=[
1602 # Rule 0: Per-source-IP rate limiting (HIGHEST PRIORITY).
1603 # Evaluated before any AWS Managed Rule Group so that abusive
1604 # IPs are blocked immediately without consuming WCUs on the
1605 # heavier managed rule groups. Aggregates requests per source
1606 # IP over a rolling 5-minute window (AWS WAF fixed behavior
1607 # for rate-based statements).
1608 #
1609 # The limit is configurable via `cdk.json` context
1610 # `waf.per_ip_rate_limit` (default: 100 requests / 5 min).
1611 wafv2.CfnWebACL.RuleProperty(
1612 name="PerIPRateLimit",
1613 priority=0,
1614 action=wafv2.CfnWebACL.RuleActionProperty(block={}),
1615 statement=wafv2.CfnWebACL.StatementProperty(
1616 rate_based_statement=wafv2.CfnWebACL.RateBasedStatementProperty(
1617 limit=per_ip_rate_limit,
1618 aggregate_key_type="IP",
1619 )
1620 ),
1621 visibility_config=wafv2.CfnWebACL.VisibilityConfigProperty(
1622 cloud_watch_metrics_enabled=True,
1623 metric_name="PerIPRateLimit",
1624 sampled_requests_enabled=True,
1625 ),
1626 ),
1627 # Rule 1: Preserve the CRS 8 KiB body limit for every route
1628 # except the deployed /prod/inference/{proxy+} route. API Gateway
1629 # invoke URLs include the stage in the client URI that WAF inspects,
1630 # and the trailing slash is the route boundary: /prod/inference-extra
1631 # must remain subject to this limit. Inference accepts bodies up to
1632 # the backend's authoritative 1 MiB limit. MATCH fails closed on
1633 # oversized control-plane bodies beyond WAF's inspection window.
1634 wafv2.CfnWebACL.RuleProperty(
1635 name="NonInferenceBodySizeLimit",
1636 priority=1,
1637 action=wafv2.CfnWebACL.RuleActionProperty(block={}),
1638 statement=wafv2.CfnWebACL.StatementProperty(
1639 and_statement=wafv2.CfnWebACL.AndStatementProperty(
1640 statements=[
1641 wafv2.CfnWebACL.StatementProperty(
1642 size_constraint_statement=wafv2.CfnWebACL.SizeConstraintStatementProperty(
1643 comparison_operator="GT",
1644 field_to_match=wafv2.CfnWebACL.FieldToMatchProperty(
1645 body=wafv2.CfnWebACL.BodyProperty(
1646 oversize_handling="MATCH"
1647 )
1648 ),
1649 size=8_192,
1650 text_transformations=[
1651 wafv2.CfnWebACL.TextTransformationProperty(
1652 priority=0,
1653 type="NONE",
1654 )
1655 ],
1656 )
1657 ),
1658 wafv2.CfnWebACL.StatementProperty(
1659 not_statement=wafv2.CfnWebACL.NotStatementProperty(
1660 statement=wafv2.CfnWebACL.StatementProperty(
1661 byte_match_statement=wafv2.CfnWebACL.ByteMatchStatementProperty(
1662 field_to_match=wafv2.CfnWebACL.FieldToMatchProperty(
1663 uri_path={}
1664 ),
1665 positional_constraint="STARTS_WITH",
1666 search_string="/prod/inference/",
1667 text_transformations=[
1668 wafv2.CfnWebACL.TextTransformationProperty(
1669 priority=0,
1670 type="NONE",
1671 )
1672 ],
1673 )
1674 )
1675 )
1676 ),
1677 ]
1678 )
1679 ),
1680 visibility_config=wafv2.CfnWebACL.VisibilityConfigProperty(
1681 cloud_watch_metrics_enabled=True,
1682 metric_name="NonInferenceBodySizeLimit",
1683 sampled_requests_enabled=True,
1684 ),
1685 ),
1686 # Rule 2: AWS Managed Rules - Common Rule Set (OWASP Top 10).
1687 # Override only SizeRestrictions_BODY: every other CRS rule
1688 # continues to block normally, including body-content rules.
1689 wafv2.CfnWebACL.RuleProperty(
1690 name="AWSManagedRulesCommonRuleSet",
1691 priority=2,
1692 override_action=wafv2.CfnWebACL.OverrideActionProperty(none={}),
1693 statement=wafv2.CfnWebACL.StatementProperty(
1694 managed_rule_group_statement=wafv2.CfnWebACL.ManagedRuleGroupStatementProperty(
1695 vendor_name="AWS",
1696 name="AWSManagedRulesCommonRuleSet",
1697 rule_action_overrides=[
1698 wafv2.CfnWebACL.RuleActionOverrideProperty(
1699 name="SizeRestrictions_BODY",
1700 action_to_use=wafv2.CfnWebACL.RuleActionProperty(count={}),
1701 )
1702 ],
1703 )
1704 ),
1705 visibility_config=wafv2.CfnWebACL.VisibilityConfigProperty(
1706 cloud_watch_metrics_enabled=True,
1707 metric_name="AWSManagedRulesCommonRuleSet",
1708 sampled_requests_enabled=True,
1709 ),
1710 ),
1711 # Rule 3: AWS Managed Rules - Known Bad Inputs
1712 wafv2.CfnWebACL.RuleProperty(
1713 name="AWSManagedRulesKnownBadInputsRuleSet",
1714 priority=3,
1715 override_action=wafv2.CfnWebACL.OverrideActionProperty(none={}),
1716 statement=wafv2.CfnWebACL.StatementProperty(
1717 managed_rule_group_statement=wafv2.CfnWebACL.ManagedRuleGroupStatementProperty(
1718 vendor_name="AWS",
1719 name="AWSManagedRulesKnownBadInputsRuleSet",
1720 )
1721 ),
1722 visibility_config=wafv2.CfnWebACL.VisibilityConfigProperty(
1723 cloud_watch_metrics_enabled=True,
1724 metric_name="AWSManagedRulesKnownBadInputsRuleSet",
1725 sampled_requests_enabled=True,
1726 ),
1727 ),
1728 # Rule 4: AWS Managed Rules - SQL Injection
1729 wafv2.CfnWebACL.RuleProperty(
1730 name="AWSManagedRulesSQLiRuleSet",
1731 priority=4,
1732 override_action=wafv2.CfnWebACL.OverrideActionProperty(none={}),
1733 statement=wafv2.CfnWebACL.StatementProperty(
1734 managed_rule_group_statement=wafv2.CfnWebACL.ManagedRuleGroupStatementProperty(
1735 vendor_name="AWS",
1736 name="AWSManagedRulesSQLiRuleSet",
1737 )
1738 ),
1739 visibility_config=wafv2.CfnWebACL.VisibilityConfigProperty(
1740 cloud_watch_metrics_enabled=True,
1741 metric_name="AWSManagedRulesSQLiRuleSet",
1742 sampled_requests_enabled=True,
1743 ),
1744 ),
1745 # Rule 5: AWS Managed Rules - Linux OS (protects against Linux-specific attacks)
1746 wafv2.CfnWebACL.RuleProperty(
1747 name="AWSManagedRulesLinuxRuleSet",
1748 priority=5,
1749 override_action=wafv2.CfnWebACL.OverrideActionProperty(none={}),
1750 statement=wafv2.CfnWebACL.StatementProperty(
1751 managed_rule_group_statement=wafv2.CfnWebACL.ManagedRuleGroupStatementProperty(
1752 vendor_name="AWS",
1753 name="AWSManagedRulesLinuxRuleSet",
1754 )
1755 ),
1756 visibility_config=wafv2.CfnWebACL.VisibilityConfigProperty(
1757 cloud_watch_metrics_enabled=True,
1758 metric_name="AWSManagedRulesLinuxRuleSet",
1759 sampled_requests_enabled=True,
1760 ),
1761 ),
1762 # Rule 6: AWS Managed Rules - Amazon IP Reputation List
1763 wafv2.CfnWebACL.RuleProperty(
1764 name="AWSManagedRulesAmazonIpReputationList",
1765 priority=6,
1766 override_action=wafv2.CfnWebACL.OverrideActionProperty(none={}),
1767 statement=wafv2.CfnWebACL.StatementProperty(
1768 managed_rule_group_statement=wafv2.CfnWebACL.ManagedRuleGroupStatementProperty(
1769 vendor_name="AWS",
1770 name="AWSManagedRulesAmazonIpReputationList",
1771 )
1772 ),
1773 visibility_config=wafv2.CfnWebACL.VisibilityConfigProperty(
1774 cloud_watch_metrics_enabled=True,
1775 metric_name="AWSManagedRulesAmazonIpReputationList",
1776 sampled_requests_enabled=True,
1777 ),
1778 ),
1779 # Rule 7: AWS Managed Rules - Anonymous IP List (blocks Tor, VPNs, proxies)
1780 wafv2.CfnWebACL.RuleProperty(
1781 name="AWSManagedRulesAnonymousIpList",
1782 priority=7,
1783 override_action=wafv2.CfnWebACL.OverrideActionProperty(none={}),
1784 statement=wafv2.CfnWebACL.StatementProperty(
1785 managed_rule_group_statement=wafv2.CfnWebACL.ManagedRuleGroupStatementProperty(
1786 vendor_name="AWS",
1787 name="AWSManagedRulesAnonymousIpList",
1788 )
1789 ),
1790 visibility_config=wafv2.CfnWebACL.VisibilityConfigProperty(
1791 cloud_watch_metrics_enabled=True,
1792 metric_name="AWSManagedRulesAnonymousIpList",
1793 sampled_requests_enabled=True,
1794 ),
1795 ),
1796 ],
1797 )
1799 # Enable WAF logging to CloudWatch Logs
1800 # This is required for HIPAA, NIST 800-53, and PCI-DSS compliance
1801 wafv2.CfnLoggingConfiguration(
1802 self,
1803 "WafLoggingConfig",
1804 resource_arn=self.web_acl.attr_arn,
1805 log_destination_configs=[waf_log_group.log_group_arn],
1806 )
1808 # Associate WAF WebACL with API Gateway stage
1809 # For API Gateway, use the stage ARN format
1810 wafv2.CfnWebACLAssociation(
1811 self,
1812 "GCOWebAclAssociation",
1813 resource_arn=self.api.deployment_stage.stage_arn,
1814 web_acl_arn=self.web_acl.attr_arn,
1815 )
1817 # Output WAF WebACL ARN
1818 CfnOutput(
1819 self,
1820 "WebAclArn",
1821 value=self.web_acl.attr_arn,
1822 description="WAF WebACL ARN for API Gateway protection",
1823 export_name=f"{self.project_name}-waf-webacl-arn",
1824 )