Coverage for gco / stacks / api_gateway_global_stack.py: 100.00%

223 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-09-14 22:07 +0000

1""" 

2Global API Gateway stack - Single authenticated entry point for all regions. 

3 

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 

13 

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 

22 

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 

29 

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. 

34 

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

40 

41import json 

42from dataclasses import dataclass 

43from typing import Any 

44 

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 

68 

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) 

82 

83# <pyflowchart-code-diagram> BEGIN - auto-inserted, do not edit 

84# Generated at (UTC): 2026-09-01T14:42:56Z 

85# Generated from Git commit: 89b000378ed5a912a38c06f4feab2b029936ebcc 

86# Flowchart(s) generated from this file: 

87# * ``GCOApiGatewayGlobalStack.__init__`` -> ``diagrams/code_diagrams/gco/stacks/api_gateway_global_stack.GCOApiGatewayGlobalStack___init__.html`` 

88# (PNG: ``diagrams/code_diagrams/gco/stacks/api_gateway_global_stack.GCOApiGatewayGlobalStack___init__.png``) 

89# Regenerate with ``SOURCE_DATE_EPOCH=<unix-seconds> GCO_DIAGRAM_SOURCE_COMMIT=<40-char-sha> python diagrams/generate.py --code-only``. 

90# <pyflowchart-code-diagram> END 

91 

92 

93@dataclass(frozen=True) 

94class AnalyticsApiConfig: 

95 """Configuration handed from ``GCOAnalyticsStack`` to ``GCOApiGatewayGlobalStack``. 

96 

97 When ``GCOApiGatewayGlobalStack`` is constructed (or mutated via 

98 :meth:`GCOApiGatewayGlobalStack.set_analytics_config`) with a non-``None`` 

99 instance of this dataclass, the stack wires a Cognito-authorized 

100 ``/studio/*`` route tree onto the existing REST API. When the value is 

101 ``None``, the stack is behaviorally identical to its pre-analytics shape 

102 — no ``/studio/*`` resources, no Cognito authorizer, no additional 

103 ``CfnOutput`` entries. 

104 

105 ``frozen=True`` makes the dataclass hashable and immutable so a single 

106 config object can be safely shared across constructs without the risk 

107 of accidental mutation after the synthesized template references its 

108 fields. 

109 

110 Attributes: 

111 user_pool_arn: Full ARN of the Cognito user pool that authenticates 

112 Studio logins. Shape: 

113 ``arn:aws:cognito-idp:<region>:<account>:userpool/<pool-id>``. 

114 user_pool_client_id: Client id of the Studio user-pool client 

115 (SRP auth). Used by the CLI's ``gco analytics studio login`` 

116 flow and surfaced to API Gateway outputs for discoverability. 

117 presigned_url_lambda: The ``analytics-presigned-url`` Lambda 

118 function created by ``GCOAnalyticsStack._create_presigned_url_lambda``. 

119 Consumed by the ``/studio/login`` ``LambdaIntegration``. 

120 studio_domain_name: SageMaker Studio domain name. Carried through 

121 as context for the Lambda integration; the Lambda itself also 

122 reads this value from its ``STUDIO_DOMAIN_NAME`` environment 

123 variable set by the analytics stack. 

124 callback_url: Concrete OAuth redirect target 

125 (``https://<api>/prod/studio/callback``) used when the 

126 Cognito hosted UI is enabled. The ``/studio/callback`` route 

127 is wired as a stub here so the URL is reachable immediately 

128 after deploy. 

129 """ 

130 

131 user_pool_arn: str 

132 user_pool_client_id: str 

133 presigned_url_lambda: lambda_.IFunction 

134 studio_domain_name: str 

135 callback_url: str 

136 

137 

138class GCOApiGatewayGlobalStack(Stack): 

139 """ 

140 Global API Gateway with IAM authentication. 

141 

142 This stack creates the single authenticated entry point for all GCO 

143 API requests. All requests must be signed with AWS credentials. 

144 

145 Attributes: 

146 secret: Secrets Manager secret containing the backend HMAC signing key 

147 proxy_lambda: Buffered Lambda proxy for the control-plane API 

148 inference_proxy_lambda: Response-streaming Lambda for `/inference/*` 

149 aggregator_lambda: Lambda function for cross-region aggregation 

150 api: REST API with IAM authentication 

151 """ 

152 

153 def __init__( 

154 self, 

155 scope: Construct, 

156 construct_id: str, 

157 global_accelerator_dns: str | None, 

158 regional_endpoints: dict[str, str] | None = None, 

159 analytics_config: AnalyticsApiConfig | None = None, 

160 project_name: str = "gco", 

161 api_gateway_config: dict[str, Any] | None = None, 

162 registry_region: str | None = None, 

163 certificate_regions: list[str] | None = None, 

164 backend_tls_config: dict[str, Any] | None = None, 

165 max_request_body_bytes: int = DEFAULT_MAX_REQUEST_BODY_BYTES, 

166 **kwargs: Any, 

167 ) -> None: 

168 super().__init__(scope, construct_id, **kwargs) 

169 

170 # ``project_name`` is the deployment's unique prefix. Every physical 

171 # resource name this stack owns (secret, WAF, log groups, CFN exports) 

172 # derives from it so two deployments can coexist in one account+region. 

173 # Defaults to ``"gco"`` so the rendered names are byte-for-byte 

174 # identical to the pre-#139 literals for the stock deployment. 

175 self.project_name = project_name 

176 self.ga_dns = str(global_accelerator_dns).strip() if global_accelerator_dns else None 

177 self.regional_endpoints = regional_endpoints or {} 

178 # Regional ALB hostnames and backend-TLS public metadata are registered 

179 # in the global stack's SSM region, which may differ from this stack. 

180 self.registry_region = registry_region or self.region 

181 self.certificate_regions = tuple( 

182 dict.fromkeys(certificate_regions if certificate_regions is not None else ["us-east-1"]) 

183 ) 

184 default_backend_tls_config: dict[str, int] = { 

185 "root_generation": 1, 

186 "root_validity_days": 3_650, 

187 "root_rotate_before_days": 180, 

188 "root_activation_delay_hours": 24, 

189 "root_overlap_days": 45, 

190 "leaf_validity_days": 30, 

191 "leaf_rotate_before_days": 10, 

192 "rotation_schedule_hours": 12, 

193 "trust_cache_ttl_seconds": 300, 

194 "trust_cache_max_stale_seconds": 3_600, 

195 } 

196 self.backend_tls_config = { 

197 **default_backend_tls_config, 

198 **(backend_tls_config or {}), 

199 } 

200 self.backend_tls_server_name = backend_tls_server_name(self.project_name) 

201 self.backend_tls_root_ca_parameter_name = backend_tls_root_ca_parameter_name( 

202 self.project_name 

203 ) 

204 self.backend_tls_certificate_parameter_prefix = backend_tls_certificate_parameter_prefix( 

205 self.project_name 

206 ) 

207 self.max_request_body_bytes = validated_request_body_limit(max_request_body_bytes) 

208 

209 default_api_gateway_config: dict[str, Any] = { 

210 "throttle_rate_limit": 1000, 

211 "throttle_burst_limit": 2000, 

212 "log_level": "INFO", 

213 "metrics_enabled": True, 

214 "tracing_enabled": True, 

215 } 

216 if api_gateway_config is not None: 

217 configured_api_gateway = api_gateway_config 

218 else: 

219 context_config = self.node.try_get_context("api_gateway") 

220 configured_api_gateway = context_config if isinstance(context_config, dict) else {} 

221 self.api_gateway_config = { 

222 **default_api_gateway_config, 

223 **configured_api_gateway, 

224 } 

225 # When analytics is disabled (the default) this stays ``None`` and 

226 # the stack synthesizes exactly as it did pre-analytics. When 

227 # non-``None``, ``_wire_studio_routes`` is invoked at the end of 

228 # the constructor, after the IAM-authorized ``/api/v1/*`` and 

229 # ``/inference/*`` methods are already attached — so Cognito and 

230 # IAM authorization coexist at the method level rather than at 

231 # the API level. 

232 self.analytics_config: AnalyticsApiConfig | None = analytics_config 

233 

234 # Create the deployment-local private PKI before any client Lambda. 

235 # The manager writes only public trust material and ACM ARNs to SSM; 

236 # its KMS-encrypted root private key is inaccessible to proxy roles. 

237 self._create_backend_tls() 

238 

239 # Create the shared backend HMAC signing key. 

240 self.secret = self._create_secret() 

241 

242 # Global Accelerator-backed proxy routes exist only where that global 

243 # service is available. In other partitions the global API retains its 

244 # aggregate routes while callers use the regional IAM APIs directly. 

245 self.proxy_lambda = self._create_proxy_lambda() if self.ga_dns is not None else None 

246 self.inference_proxy_lambda = ( 

247 self._create_inference_proxy_lambda() if self.ga_dns is not None else None 

248 ) 

249 

250 # Create cross-region aggregator Lambda 

251 self.aggregator_lambda = self._create_aggregator_lambda() 

252 

253 # Create API Gateway 

254 self.api = self._create_api_gateway() 

255 

256 # Create WAF WebACL and associate with API Gateway 

257 self._create_waf() 

258 

259 # Export API endpoint 

260 self._create_outputs() 

261 

262 # Wire /studio/* routes when analytics is explicitly enabled at 

263 # construction time. Most deployments take the mutator path 

264 # (:meth:`set_analytics_config`) because ``GCOAnalyticsStack`` is 

265 # built after this stack in ``app.py``. 

266 if self.analytics_config is not None: 

267 self._wire_studio_routes() 

268 

269 # Apply cdk-nag suppressions 

270 self._apply_nag_suppressions() 

271 

272 def _apply_nag_suppressions(self) -> None: 

273 """Apply cdk-nag suppressions for this stack.""" 

274 from gco.stacks.nag_suppressions import apply_all_suppressions 

275 

276 # This stack's proxy and certificate-manager roles read project-scoped 

277 # public SSM metadata from the global registry region. The aggregator 

278 # itself discovers regional bridges through CloudFormation, not SSM. 

279 apply_all_suppressions( 

280 self, 

281 stack_type="api_gateway", 

282 global_region=self.registry_region, 

283 project_name=self.project_name, 

284 ) 

285 

286 def _create_backend_tls(self) -> None: 

287 """Create the private root, regional ACM manager, schedule, and alarms.""" 

288 config = self.backend_tls_config 

289 project_name = self.project_name 

290 

291 self.backend_tls_key = kms.Key( 

292 self, 

293 "BackendTlsRootKey", 

294 alias=f"alias/{project_name}-backend-tls-root", 

295 description="Encrypts the GCO deployment-local backend TLS root private key", 

296 enable_key_rotation=True, 

297 removal_policy=RemovalPolicy.DESTROY, 

298 pending_window=Duration.days(7), 

299 ) 

300 self.backend_tls_root_secret = secretsmanager.Secret( 

301 self, 

302 "BackendTlsRootSecret", 

303 secret_name=backend_tls_root_secret_name(project_name), 

304 description=( 

305 "Deployment-local backend TLS root CA; private key access is restricted " 

306 "to the certificate manager Lambda" 

307 ), 

308 encryption_key=self.backend_tls_key, 

309 generate_secret_string=secretsmanager.SecretStringGenerator( 

310 secret_string_template=json.dumps({"state": "UNINITIALIZED"}), 

311 generate_string_key="bootstrap_nonce", 

312 exclude_punctuation=True, 

313 password_length=32, 

314 ), 

315 removal_policy=RemovalPolicy.DESTROY, 

316 ) 

317 

318 manager_role = iam.Role( 

319 self, 

320 "BackendTlsManagerRole", 

321 assumed_by=iam.ServicePrincipal("lambda.amazonaws.com"), 

322 managed_policies=[ 

323 iam.ManagedPolicy.from_aws_managed_policy_name( 

324 "service-role/AWSLambdaBasicExecutionRole" 

325 ) 

326 ], 

327 ) 

328 self.backend_tls_root_secret.grant_read(manager_role) 

329 self.backend_tls_root_secret.grant_write(manager_role) 

330 self.backend_tls_key.grant_encrypt_decrypt(manager_role) 

331 manager_role.add_to_policy( 

332 iam.PolicyStatement( 

333 actions=[ 

334 "acm:AddTagsToCertificate", 

335 "acm:ImportCertificate", 

336 "acm:ListCertificates", 

337 ], 

338 resources=["*"], 

339 ) 

340 ) 

341 manager_role.add_to_policy( 

342 iam.PolicyStatement( 

343 actions=[ 

344 "acm:DeleteCertificate", 

345 "acm:DescribeCertificate", 

346 "acm:GetCertificate", 

347 "acm:ListTagsForCertificate", 

348 ], 

349 resources=[f"arn:{self.partition}:acm:*:{self.account}:certificate/*"], 

350 ) 

351 ) 

352 manager_role.add_to_policy( 

353 iam.PolicyStatement( 

354 actions=[ 

355 "ssm:DeleteParameter", 

356 "ssm:GetParameter", 

357 "ssm:GetParametersByPath", 

358 "ssm:PutParameter", 

359 ], 

360 resources=[ 

361 f"arn:{self.partition}:ssm:{self.registry_region}:{self.account}:" 

362 f"parameter/{project_name}/backend-tls/*" 

363 ], 

364 ) 

365 ) 

366 manager_role.add_to_policy( 

367 iam.PolicyStatement( 

368 actions=["cloudwatch:PutMetricData"], 

369 resources=["*"], 

370 conditions={"StringEquals": {"cloudwatch:namespace": "GCO/BackendTLS"}}, 

371 ) 

372 ) 

373 

374 manager_log_group = logs.LogGroup( 

375 self, 

376 "BackendTlsManagerLogGroup", 

377 retention=logs.RetentionDays.ONE_MONTH, 

378 removal_policy=RemovalPolicy.DESTROY, 

379 ) 

380 manager_environment = { 

381 "ROOT_SECRET_ARN": self.backend_tls_root_secret.secret_arn, 

382 "AWS_PARTITION": self.partition, 

383 "AWS_ACCOUNT_ID": self.account, 

384 "PROJECT_NAME": project_name, 

385 "REGISTRY_REGION": self.registry_region, 

386 "CERTIFICATE_REGIONS": json.dumps(self.certificate_regions), 

387 "BACKEND_TLS_SERVER_NAME": self.backend_tls_server_name, 

388 "ROOT_CA_PARAMETER_NAME": self.backend_tls_root_ca_parameter_name, 

389 "CERTIFICATE_PARAMETER_PREFIX": self.backend_tls_certificate_parameter_prefix, 

390 "ROOT_GENERATION": str(config["root_generation"]), 

391 "ROOT_VALIDITY_DAYS": str(config["root_validity_days"]), 

392 "ROOT_ROTATE_BEFORE_DAYS": str(config["root_rotate_before_days"]), 

393 "ROOT_ACTIVATION_DELAY_HOURS": str(config["root_activation_delay_hours"]), 

394 "ROOT_OVERLAP_DAYS": str(config["root_overlap_days"]), 

395 "LEAF_VALIDITY_DAYS": str(config["leaf_validity_days"]), 

396 "LEAF_ROTATE_BEFORE_DAYS": str(config["leaf_rotate_before_days"]), 

397 } 

398 self.backend_tls_manager_lambda = lambda_.DockerImageFunction( 

399 self, 

400 "BackendTlsCertificateManager", 

401 function_name=f"{project_name}-backend-tls-manager", 

402 code=lambda_.DockerImageCode.from_image_asset( 

403 directory="lambda/tls-certificate-manager", 

404 platform=ecr_assets.Platform.LINUX_AMD64, 

405 ), 

406 architecture=lambda_.Architecture.X86_64, 

407 timeout=Duration.minutes(5), 

408 memory_size=512, 

409 reserved_concurrent_executions=1, 

410 role=manager_role, 

411 environment=manager_environment, 

412 log_group=manager_log_group, 

413 tracing=lambda_.Tracing.ACTIVE, 

414 description="Bootstraps and rotates GCO private-root regional ACM certificates", 

415 ) 

416 

417 provider_log_group = logs.LogGroup( 

418 self, 

419 "BackendTlsProviderLogGroup", 

420 retention=logs.RetentionDays.ONE_MONTH, 

421 removal_policy=RemovalPolicy.DESTROY, 

422 ) 

423 provider = cr.Provider( 

424 self, 

425 "BackendTlsProvider", 

426 on_event_handler=self.backend_tls_manager_lambda, 

427 log_group=provider_log_group, 

428 ) 

429 self.backend_tls_resource = CustomResource( 

430 self, 

431 "BackendTlsCertificates", 

432 service_token=provider.service_token, 

433 properties={ 

434 "ProjectName": project_name, 

435 "RegistryRegion": self.registry_region, 

436 "Regions": list(self.certificate_regions), 

437 "ServerName": self.backend_tls_server_name, 

438 "RootCaParameterName": self.backend_tls_root_ca_parameter_name, 

439 "CertificateParameterPrefix": self.backend_tls_certificate_parameter_prefix, 

440 "RootGeneration": config["root_generation"], 

441 "RootValidityDays": config["root_validity_days"], 

442 "RootRotateBeforeDays": config["root_rotate_before_days"], 

443 "RootActivationDelayHours": config["root_activation_delay_hours"], 

444 "RootOverlapDays": config["root_overlap_days"], 

445 "LeafValidityDays": config["leaf_validity_days"], 

446 "LeafRotateBeforeDays": config["leaf_rotate_before_days"], 

447 "PolicyVersion": "1", 

448 }, 

449 ) 

450 self.backend_tls_resource.node.add_dependency(self.backend_tls_root_secret) 

451 # CloudFormation reverses dependencies on delete, so the provider's 

452 # final invocation completes before its managed log group is removed. 

453 self.backend_tls_resource.node.add_dependency(provider_log_group) 

454 

455 self.backend_tls_rotation_dlq = sqs.Queue( 

456 self, 

457 "BackendTlsRotationDlq", 

458 queue_name=f"{project_name}-backend-tls-rotation-dlq", 

459 retention_period=Duration.days(14), 

460 encryption=sqs.QueueEncryption.SQS_MANAGED, 

461 enforce_ssl=True, 

462 removal_policy=RemovalPolicy.DESTROY, 

463 ) 

464 rotation_rule = events.Rule( 

465 self, 

466 "BackendTlsRotationSchedule", 

467 description="Reconcile GCO private roots and imported regional ACM leaves", 

468 schedule=events.Schedule.rate(Duration.hours(config["rotation_schedule_hours"])), 

469 ) 

470 rotation_rule.add_target( 

471 events_targets.LambdaFunction( 

472 self.backend_tls_manager_lambda, 

473 event=events.RuleTargetInput.from_object({"Action": "Rotate"}), 

474 dead_letter_queue=self.backend_tls_rotation_dlq, 

475 retry_attempts=2, 

476 max_event_age=Duration.hours(6), 

477 ) 

478 ) 

479 rotation_rule.node.add_dependency(self.backend_tls_resource) 

480 

481 self.backend_tls_manager_error_alarm = cloudwatch.Alarm( 

482 self, 

483 "BackendTlsManagerErrorAlarm", 

484 alarm_description="Backend TLS certificate bootstrap or rotation failed", 

485 metric=self.backend_tls_manager_lambda.metric_errors( 

486 period=Duration.minutes(15), statistic="Sum" 

487 ), 

488 threshold=1, 

489 evaluation_periods=1, 

490 comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, 

491 treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING, 

492 ) 

493 self.backend_tls_rotation_dlq_alarm = cloudwatch.Alarm( 

494 self, 

495 "BackendTlsRotationDlqAlarm", 

496 alarm_description="Backend TLS scheduled rotation exhausted its retries", 

497 metric=self.backend_tls_rotation_dlq.metric_approximate_number_of_messages_visible( 

498 period=Duration.minutes(5) 

499 ), 

500 threshold=1, 

501 evaluation_periods=1, 

502 comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, 

503 treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING, 

504 ) 

505 self.backend_tls_reconciliation_heartbeat_alarm = cloudwatch.Alarm( 

506 self, 

507 "BackendTlsReconciliationHeartbeatAlarm", 

508 alarm_description=( 

509 "Backend TLS reconciliation has not completed within two schedule intervals" 

510 ), 

511 metric=cloudwatch.Metric( 

512 namespace="GCO/BackendTLS", 

513 metric_name="ReconciliationSuccess", 

514 dimensions_map={"Project": project_name}, 

515 statistic="Sum", 

516 period=Duration.hours(config["rotation_schedule_hours"] * 2), 

517 ), 

518 threshold=1, 

519 evaluation_periods=1, 

520 comparison_operator=cloudwatch.ComparisonOperator.LESS_THAN_THRESHOLD, 

521 treat_missing_data=cloudwatch.TreatMissingData.BREACHING, 

522 ) 

523 self.backend_tls_root_expiry_alarm = cloudwatch.Alarm( 

524 self, 

525 "BackendTlsRootExpiryAlarm", 

526 alarm_description=( 

527 "Backend TLS root certificate is near expiry after its rotation window" 

528 ), 

529 metric=cloudwatch.Metric( 

530 namespace="GCO/BackendTLS", 

531 metric_name="RootCertificateDaysToExpiry", 

532 dimensions_map={"Project": project_name}, 

533 statistic="Minimum", 

534 period=Duration.hours(12), 

535 ), 

536 threshold=max(1, config["root_rotate_before_days"] // 2), 

537 evaluation_periods=2, 

538 comparison_operator=cloudwatch.ComparisonOperator.LESS_THAN_THRESHOLD, 

539 treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING, 

540 ) 

541 self.backend_tls_expiry_alarms: list[cloudwatch.Alarm] = [] 

542 expiry_alarm_threshold = max(1, config["leaf_rotate_before_days"] // 2) 

543 for region in self.certificate_regions: 

544 region_id = region.replace("-", "").title() 

545 alarm = cloudwatch.Alarm( 

546 self, 

547 f"BackendTlsLeafExpiryAlarm{region_id}", 

548 alarm_description=( 

549 f"Backend TLS certificate in {region} is near expiry after rotation window" 

550 ), 

551 metric=cloudwatch.Metric( 

552 namespace="GCO/BackendTLS", 

553 metric_name="LeafCertificateDaysToExpiry", 

554 dimensions_map={"Project": project_name, "Region": region}, 

555 statistic="Minimum", 

556 period=Duration.hours(12), 

557 ), 

558 threshold=expiry_alarm_threshold, 

559 evaluation_periods=2, 

560 comparison_operator=cloudwatch.ComparisonOperator.LESS_THAN_THRESHOLD, 

561 treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING, 

562 ) 

563 self.backend_tls_expiry_alarms.append(alarm) 

564 

565 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

566 

567 acknowledge_nag_findings( 

568 manager_role, 

569 [ 

570 { 

571 "id": "AwsSolutions-IAM5", 

572 "reason": ( 

573 "ACM ImportCertificate requires Resource: * when creating a new imported " 

574 "certificate because its ARN does not exist yet. Other ACM actions are " 

575 "scoped to this account's certificate ARNs; SSM is scoped to the exact " 

576 "project backend-tls namespace." 

577 ), 

578 "appliesTo": [ 

579 "Resource::*", 

580 "Action::kms:GenerateDataKey*", 

581 "Action::kms:ReEncrypt*", 

582 "Resource::arn:<AWS::Partition>:acm:*:<AWS::AccountId>:certificate/*", 

583 ( 

584 f"Resource::arn:<AWS::Partition>:ssm:{self.registry_region}:" 

585 f"<AWS::AccountId>:parameter/{project_name}/backend-tls/*" 

586 ), 

587 ], 

588 }, 

589 ], 

590 ) 

591 acknowledge_nag_findings( 

592 provider, 

593 [ 

594 { 

595 "id": "AwsSolutions-IAM5", 

596 "reason": ( 

597 "The CDK custom-resource provider invokes only versioned aliases of " 

598 "BackendTlsCertificateManager; the generated :* qualifier cannot be " 

599 "narrowed to a version that does not exist until deployment." 

600 ), 

601 "appliesTo": [ 

602 "Resource::<BackendTlsCertificateManager7EB9FC32.Arn>:*", 

603 ], 

604 } 

605 ], 

606 ) 

607 acknowledge_nag_findings( 

608 self.backend_tls_root_secret, 

609 [ 

610 { 

611 "id": "AwsSolutions-SMG4", 

612 "reason": ( 

613 "The long-lived private root is rotated by the serialized EventBridge " 

614 "certificate manager using a pending-root trust phase and overlap window; " 

615 "Secrets Manager's single-value rotation protocol cannot provide that " 

616 "multi-region certificate choreography." 

617 ), 

618 }, 

619 { 

620 "id": "HIPAA.Security-SecretsManagerRotationEnabled", 

621 "reason": "The EventBridge certificate manager performs staged root rotation.", 

622 }, 

623 { 

624 "id": "NIST.800.53.R5-SecretsManagerRotationEnabled", 

625 "reason": "The EventBridge certificate manager performs staged root rotation.", 

626 }, 

627 ], 

628 ) 

629 acknowledge_nag_findings( 

630 self.backend_tls_rotation_dlq, 

631 [ 

632 { 

633 "id": "AwsSolutions-SQS3", 

634 "reason": ( 

635 "This is itself EventBridge's terminal dead-letter queue; it is " 

636 "retained for 14 days and monitored by BackendTlsRotationDlqAlarm. " 

637 "Chaining another DLQ would only move the same terminal failure." 

638 ), 

639 }, 

640 { 

641 "id": "Serverless-SQSRedrivePolicy", 

642 "reason": ( 

643 "This queue is the terminal EventBridge dead-letter queue and is monitored " 

644 "by BackendTlsRotationDlqAlarm; redriving it into another queue would only " 

645 "move the terminal failure." 

646 ), 

647 }, 

648 ], 

649 ) 

650 for alarm in [ 

651 self.backend_tls_manager_error_alarm, 

652 self.backend_tls_rotation_dlq_alarm, 

653 self.backend_tls_reconciliation_heartbeat_alarm, 

654 self.backend_tls_root_expiry_alarm, 

655 *self.backend_tls_expiry_alarms, 

656 ]: 

657 acknowledge_nag_findings( 

658 alarm, 

659 [ 

660 { 

661 "id": "HIPAA.Security-CloudWatchAlarmAction", 

662 "reason": ( 

663 "Backend TLS alarms are retained as operator-visible stack alarms; " 

664 "notification routing is deployment-specific and can be attached to " 

665 "the exported alarms without granting the PKI manager publish access." 

666 ), 

667 }, 

668 { 

669 "id": "NIST.800.53.R5-CloudWatchAlarmAction", 

670 "reason": ( 

671 "Backend TLS alarms are operator-visible; notification destinations " 

672 "remain deployment-specific." 

673 ), 

674 }, 

675 ], 

676 ) 

677 

678 def _create_secret(self) -> secretsmanager.Secret: 

679 """Create the backend HMAC signing key and its daily rotation.""" 

680 secret = secretsmanager.Secret( 

681 self, 

682 "GCOAuthSecret", 

683 secret_name=api_gateway_auth_secret_name(self.project_name), # nosec B106 — this is the secret path, not a password 

684 description="HMAC signing key for API Gateway backend requests (auto-rotated)", 

685 generate_secret_string=secretsmanager.SecretStringGenerator( 

686 secret_string_template=json.dumps({"description": "GCO backend HMAC signing key"}), 

687 generate_string_key="token", 

688 exclude_punctuation=True, 

689 password_length=64, 

690 ), 

691 removal_policy=RemovalPolicy.DESTROY, 

692 ) 

693 

694 # Create rotation Lambda and store as instance attribute for monitoring 

695 self.rotation_lambda = self._create_rotation_lambda(secret) 

696 

697 # Enable automatic rotation (daily for enhanced security) 

698 secret.add_rotation_schedule( 

699 "RotationSchedule", 

700 automatically_after=Duration.days(1), 

701 rotation_lambda=self.rotation_lambda, 

702 ) 

703 

704 return secret 

705 

706 def _create_rotation_lambda(self, secret: secretsmanager.Secret) -> lambda_.Function: 

707 """Create Lambda function for secret rotation. 

708 

709 This Lambda implements the 4-step Secrets Manager rotation protocol: 

710 1. createSecret - Generate new random token 

711 2. setSecret - No-op (no external system) 

712 3. testSecret - Validate token structure 

713 4. finishSecret - Move AWSPENDING to AWSCURRENT 

714 """ 

715 # Create IAM role for rotation Lambda 

716 rotation_role = iam.Role( 

717 self, 

718 "RotationLambdaRole", 

719 assumed_by=iam.ServicePrincipal("lambda.amazonaws.com"), 

720 managed_policies=[ 

721 iam.ManagedPolicy.from_aws_managed_policy_name( 

722 "service-role/AWSLambdaBasicExecutionRole" 

723 ) 

724 ], 

725 ) 

726 

727 # Grant permissions to manage the secret 

728 secret.grant_read(rotation_role) 

729 secret.grant_write(rotation_role) 

730 

731 # Additional permissions for rotation 

732 rotation_role.add_to_policy( 

733 iam.PolicyStatement( 

734 actions=[ 

735 "secretsmanager:DescribeSecret", 

736 "secretsmanager:GetSecretValue", 

737 "secretsmanager:PutSecretValue", 

738 "secretsmanager:UpdateSecretVersionStage", 

739 ], 

740 resources=[secret.secret_arn], 

741 ) 

742 ) 

743 

744 # Create log group for rotation Lambda 

745 rotation_log_group = logs.LogGroup( 

746 self, 

747 "RotationLambdaLogGroup", 

748 retention=logs.RetentionDays.ONE_MONTH, 

749 removal_policy=RemovalPolicy.DESTROY, 

750 ) 

751 

752 # Create rotation Lambda 

753 rotation_lambda = lambda_.Function( 

754 self, 

755 "SecretRotationFunction", 

756 runtime=getattr(lambda_.Runtime, LAMBDA_PYTHON_RUNTIME), 

757 handler="handler.lambda_handler", 

758 code=lambda_.Code.from_asset("lambda/secret-rotation"), 

759 timeout=Duration.seconds(30), 

760 memory_size=128, 

761 role=rotation_role, 

762 log_group=rotation_log_group, 

763 description="Rotates the GCO backend HMAC signing key", 

764 tracing=lambda_.Tracing.ACTIVE, 

765 ) 

766 

767 # Grant Secrets Manager permission to invoke the rotation Lambda 

768 rotation_lambda.grant_invoke(iam.ServicePrincipal("secretsmanager.amazonaws.com")) 

769 

770 # cdk-nag suppression: CDK's grant methods generate Resource: * for 

771 # the rotation function's execution role. 

772 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

773 

774 acknowledge_nag_findings( 

775 rotation_role, 

776 [ 

777 { 

778 "id": "AwsSolutions-IAM5", 

779 "reason": ( 

780 "The secret rotation Lambda needs secretsmanager:GetSecretValue " 

781 "and PutSecretValue on the rotation secret. CDK's grant methods " 

782 "generate Resource: * for the rotation function's execution role " 

783 "because the secret ARN includes a random suffix not known at " 

784 "synth time." 

785 ), 

786 "appliesTo": ["Resource::*"], 

787 }, 

788 ], 

789 ) 

790 

791 return rotation_lambda 

792 

793 def _create_proxy_lambda(self) -> lambda_.Function: 

794 """Create the authenticated Global Accelerator backend proxy Lambda.""" 

795 if self.ga_dns is None: 

796 raise RuntimeError("The global proxy requires a Global Accelerator endpoint") 

797 

798 # Create IAM role 

799 lambda_role = iam.Role( 

800 self, 

801 "ProxyLambdaRole", 

802 assumed_by=iam.ServicePrincipal("lambda.amazonaws.com"), 

803 managed_policies=[ 

804 iam.ManagedPolicy.from_aws_managed_policy_name( 

805 "service-role/AWSLambdaBasicExecutionRole" 

806 ) 

807 ], 

808 ) 

809 

810 # Grant read access to secret 

811 self.secret.grant_read(lambda_role) 

812 

813 # The global proxy reaches regional ALBs only through Global 

814 # Accelerator. It needs the public root bundle but never the root 

815 # secret, certificate private keys, regional ALB registry, or ELB APIs. 

816 root_ca_parameter_arn = ( 

817 f"arn:{self.partition}:ssm:{self.registry_region}:{self.account}:" 

818 f"parameter/{self.backend_tls_root_ca_parameter_name.lstrip('/')}" 

819 ) 

820 lambda_role.add_to_policy( 

821 iam.PolicyStatement( 

822 effect=iam.Effect.ALLOW, 

823 actions=["ssm:GetParameter"], 

824 resources=[root_ca_parameter_arn], 

825 ) 

826 ) 

827 

828 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

829 

830 acknowledge_nag_findings( 

831 lambda_role, 

832 [ 

833 { 

834 "id": "AwsSolutions-IAM5", 

835 "reason": ( 

836 "Active X-Ray tracing requires xray:PutTraceSegments and " 

837 "xray:PutTelemetryRecords on Resource::* because those APIs do not " 

838 "support resource-level IAM constraints." 

839 ), 

840 "appliesTo": ["Resource::*"], 

841 } 

842 ], 

843 ) 

844 

845 # Create log group for Lambda 

846 proxy_lambda_log_group = logs.LogGroup( 

847 self, 

848 "ProxyLambdaLogGroup", 

849 retention=logs.RetentionDays.ONE_WEEK, 

850 removal_policy=RemovalPolicy.DESTROY, 

851 ) 

852 

853 # Create Lambda function 

854 proxy_lambda = lambda_.Function( 

855 self, 

856 "ApiGatewayProxyFunction", 

857 runtime=getattr(lambda_.Runtime, LAMBDA_PYTHON_RUNTIME), 

858 handler="handler.lambda_handler", 

859 code=lambda_.Code.from_asset("lambda/api-gateway-proxy"), 

860 timeout=Duration.seconds(29), 

861 memory_size=256, 

862 role=lambda_role, 

863 environment={ 

864 "GLOBAL_ACCELERATOR_ENDPOINT": self.ga_dns, 

865 "SECRET_ARN": self.secret.secret_arn, 

866 "BACKEND_TLS_SERVER_NAME": self.backend_tls_server_name, 

867 "BACKEND_TLS_ROOT_CA_PARAMETER": self.backend_tls_root_ca_parameter_name, 

868 "BACKEND_TLS_ROOT_CA_REGION": self.registry_region, 

869 "BACKEND_TLS_CA_CACHE_TTL_SECONDS": str( 

870 self.backend_tls_config["trust_cache_ttl_seconds"] 

871 ), 

872 "BACKEND_TLS_CA_MAX_STALE_SECONDS": str( 

873 self.backend_tls_config["trust_cache_max_stale_seconds"] 

874 ), 

875 }, 

876 log_group=proxy_lambda_log_group, 

877 tracing=lambda_.Tracing.ACTIVE, 

878 ) 

879 

880 return proxy_lambda 

881 

882 def _create_inference_proxy_lambda(self) -> lambda_.Function: 

883 """Create the inference-only Lambda response-streaming proxy.""" 

884 if self.ga_dns is None: 

885 raise RuntimeError("The global inference proxy requires Global Accelerator") 

886 role = iam.Role( 

887 self, 

888 "InferenceStreamingProxyRole", 

889 assumed_by=iam.ServicePrincipal("lambda.amazonaws.com"), 

890 managed_policies=[ 

891 iam.ManagedPolicy.from_aws_managed_policy_name( 

892 "service-role/AWSLambdaBasicExecutionRole" 

893 ) 

894 ], 

895 ) 

896 self.secret.grant_read(role) 

897 root_ca_parameter_arn = ( 

898 f"arn:{self.partition}:ssm:{self.registry_region}:{self.account}:" 

899 f"parameter/{self.backend_tls_root_ca_parameter_name.lstrip('/')}" 

900 ) 

901 role.add_to_policy( 

902 iam.PolicyStatement( 

903 effect=iam.Effect.ALLOW, 

904 actions=["ssm:GetParameter"], 

905 resources=[root_ca_parameter_arn], 

906 ) 

907 ) 

908 

909 log_group = logs.LogGroup( 

910 self, 

911 "InferenceStreamingProxyLogGroup", 

912 retention=logs.RetentionDays.ONE_WEEK, 

913 removal_policy=RemovalPolicy.DESTROY, 

914 ) 

915 function = lambda_.Function( 

916 self, 

917 "InferenceStreamingProxyFunction", 

918 runtime=getattr(lambda_.Runtime, LAMBDA_NODEJS_RUNTIME), 

919 handler="index.handler", 

920 code=lambda_.Code.from_asset("lambda/inference-streaming-proxy-build"), 

921 timeout=Duration.minutes(15), 

922 memory_size=256, 

923 role=role, 

924 environment={ 

925 "ROUTING_MODE": "global", 

926 "MAX_REQUEST_BODY_BYTES": str(self.max_request_body_bytes), 

927 "GLOBAL_ACCELERATOR_ENDPOINT": self.ga_dns, 

928 "SECRET_ARN": self.secret.secret_arn, 

929 "BACKEND_TLS_SERVER_NAME": self.backend_tls_server_name, 

930 "BACKEND_TLS_ROOT_CA_PARAMETER": self.backend_tls_root_ca_parameter_name, 

931 "BACKEND_TLS_ROOT_CA_REGION": self.registry_region, 

932 "BACKEND_TLS_CA_CACHE_TTL_SECONDS": str( 

933 self.backend_tls_config["trust_cache_ttl_seconds"] 

934 ), 

935 "BACKEND_TLS_CA_MAX_STALE_SECONDS": str( 

936 self.backend_tls_config["trust_cache_max_stale_seconds"] 

937 ), 

938 }, 

939 log_group=log_group, 

940 tracing=lambda_.Tracing.ACTIVE, 

941 description="Streams authenticated inference responses through Global Accelerator", 

942 ) 

943 

944 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

945 

946 acknowledge_nag_findings( 

947 role, 

948 [ 

949 { 

950 "id": "AwsSolutions-IAM5", 

951 "reason": ( 

952 "Active X-Ray tracing requires write APIs on Resource::*; secret and " 

953 "SSM reads remain scoped to this deployment's exact resources." 

954 ), 

955 "appliesTo": ["Resource::*"], 

956 } 

957 ], 

958 ) 

959 return function 

960 

961 def _create_aggregator_lambda(self) -> lambda_.Function: 

962 """Create the SigV4 regional-API aggregation Lambda. 

963 

964 A Lambda in the API Gateway region cannot join every regional VPC and 

965 therefore must not connect directly to internal ALBs. It discovers the 

966 deterministic regional API Gateway stacks through CloudFormation and 

967 invokes their account-restricted HTTPS endpoints with its execution-role 

968 credentials. Each regional API's VPC Lambda then performs the private 

969 authenticated-TLS hop to that region's ALB. 

970 """ 

971 # The exact role ARN is embedded in every regional API resource policy. 

972 # A project-scoped physical name keeps that ARN resolvable independently 

973 # in every region, avoiding an impossible cross-region CloudFormation 

974 # export while allowing multiple project deployments per account. 

975 aggregator_role = iam.Role( 

976 self, 

977 "AggregatorLambdaRole", 

978 assumed_by=iam.ServicePrincipal("lambda.amazonaws.com"), 

979 role_name=cross_region_aggregator_role_name(self.project_name), 

980 managed_policies=[ 

981 iam.ManagedPolicy.from_aws_managed_policy_name( 

982 "service-role/AWSLambdaBasicExecutionRole" 

983 ) 

984 ], 

985 ) 

986 self.aggregator_role = aggregator_role 

987 

988 regional_stack_arns = [ 

989 ( 

990 f"arn:{self.partition}:cloudformation:{region}:{self.account}:" 

991 f"stack/{self.project_name}-regional-api-{region}/*" 

992 ) 

993 for region in self.certificate_regions 

994 ] 

995 aggregator_role.add_to_policy( 

996 iam.PolicyStatement( 

997 effect=iam.Effect.ALLOW, 

998 actions=["cloudformation:DescribeStacks"], 

999 resources=regional_stack_arns, 

1000 ) 

1001 ) 

1002 aggregator_role.add_to_policy( 

1003 iam.PolicyStatement( 

1004 effect=iam.Effect.ALLOW, 

1005 actions=["execute-api:Invoke"], 

1006 resources=[ 

1007 ( 

1008 f"arn:{self.partition}:execute-api:{region}:{self.account}:" 

1009 f"*/*/{method}/{path}" 

1010 ) 

1011 for region in self.certificate_regions 

1012 for method, path in AGGREGATOR_REGIONAL_API_ROUTES 

1013 ], 

1014 ) 

1015 ) 

1016 

1017 aggregator_log_group = logs.LogGroup( 

1018 self, 

1019 "AggregatorLambdaLogGroup", 

1020 retention=logs.RetentionDays.ONE_WEEK, 

1021 removal_policy=RemovalPolicy.DESTROY, 

1022 ) 

1023 

1024 aggregator_lambda = lambda_.Function( 

1025 self, 

1026 "CrossRegionAggregatorFunction", 

1027 runtime=getattr(lambda_.Runtime, LAMBDA_PYTHON_RUNTIME), 

1028 handler="handler.lambda_handler", 

1029 code=lambda_.Code.from_asset("lambda/cross-region-aggregator"), 

1030 timeout=Duration.seconds(29), 

1031 memory_size=512, 

1032 role=aggregator_role, 

1033 environment={ 

1034 "PROJECT_NAME": self.project_name, 

1035 "TARGET_REGIONS": json.dumps(self.certificate_regions), 

1036 "AWS_URL_SUFFIX": self.url_suffix, 

1037 }, 

1038 log_group=aggregator_log_group, 

1039 description="Aggregates data through SigV4-authenticated regional GCO APIs", 

1040 tracing=lambda_.Tracing.ACTIVE, 

1041 ) 

1042 

1043 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

1044 

1045 acknowledge_nag_findings( 

1046 aggregator_role, 

1047 [ 

1048 { 

1049 "id": "AwsSolutions-IAM5", 

1050 "reason": ( 

1051 "The aggregator uses X-Ray write APIs that require Resource::*, " 

1052 "describes only deterministic project/region CloudFormation stack " 

1053 "ARNs, and invokes only this account's generated regional API IDs " 

1054 "under /api/v1. Regional API resource policies admit only this role " 

1055 "unless operators explicitly enable direct regional access." 

1056 ), 

1057 "appliesTo": [ 

1058 "Resource::*", 

1059 *[ 

1060 ( 

1061 f"Resource::arn:<AWS::Partition>:cloudformation:{region}:" 

1062 f"<AWS::AccountId>:stack/{self.project_name}-regional-api-" 

1063 f"{region}/*" 

1064 ) 

1065 for region in self.certificate_regions 

1066 ], 

1067 *[ 

1068 ( 

1069 f"Resource::arn:<AWS::Partition>:execute-api:{region}:" 

1070 f"<AWS::AccountId>:*/*/{method}/{path}" 

1071 ) 

1072 for region in self.certificate_regions 

1073 for method, path in AGGREGATOR_REGIONAL_API_ROUTES 

1074 ], 

1075 ], 

1076 }, 

1077 ], 

1078 ) 

1079 

1080 return aggregator_lambda 

1081 

1082 def _create_api_gateway(self) -> apigateway.RestApi: 

1083 """Create API Gateway with IAM authentication.""" 

1084 

1085 # Create CloudWatch log group 

1086 api_log_group = logs.LogGroup( 

1087 self, 

1088 "ApiGatewayLogs", 

1089 log_group_name=f"/aws/apigateway/{self.project_name}-global", 

1090 retention=logs.RetentionDays.ONE_MONTH, 

1091 removal_policy=RemovalPolicy.DESTROY, 

1092 ) 

1093 

1094 configured_log_level = str(self.api_gateway_config["log_level"]).upper() 

1095 logging_levels = { 

1096 "OFF": apigateway.MethodLoggingLevel.OFF, 

1097 "ERROR": apigateway.MethodLoggingLevel.ERROR, 

1098 "INFO": apigateway.MethodLoggingLevel.INFO, 

1099 } 

1100 if configured_log_level not in logging_levels: 

1101 raise ValueError( 

1102 "api_gateway.log_level must be one of OFF, ERROR, or INFO; " 

1103 f"got {configured_log_level!r}" 

1104 ) 

1105 

1106 # Edge-optimized API Gateway endpoints are a commercial-partition 

1107 # capability. Regional endpoints preserve the same IAM contract in 

1108 # partitions where the Global Accelerator data path is unavailable. 

1109 endpoint_type = ( 

1110 apigateway.EndpointType.EDGE 

1111 if self.ga_dns is not None 

1112 else apigateway.EndpointType.REGIONAL 

1113 ) 

1114 api = apigateway.RestApi( 

1115 self, 

1116 "GCOGlobalApi", 

1117 rest_api_name=f"{self.project_name}-global-api", 

1118 description="Authenticated global aggregation API for GCO", 

1119 endpoint_types=[endpoint_type], 

1120 deploy=True, 

1121 deploy_options=apigateway.StageOptions( 

1122 stage_name="prod", 

1123 throttling_rate_limit=self.api_gateway_config["throttle_rate_limit"], 

1124 throttling_burst_limit=self.api_gateway_config["throttle_burst_limit"], 

1125 logging_level=logging_levels[configured_log_level], 

1126 # Never put inference prompts/responses (or other API bodies) 

1127 # into execution logs. Standard access logs and metrics remain. 

1128 data_trace_enabled=False, 

1129 metrics_enabled=self.api_gateway_config["metrics_enabled"], 

1130 tracing_enabled=self.api_gateway_config["tracing_enabled"], 

1131 access_log_destination=apigateway.LogGroupLogDestination(api_log_group), 

1132 access_log_format=apigateway.AccessLogFormat.json_with_standard_fields( 

1133 caller=True, 

1134 http_method=True, 

1135 ip=True, 

1136 protocol=True, 

1137 request_time=True, 

1138 resource_path=True, 

1139 response_length=True, 

1140 status=True, 

1141 user=True, 

1142 ), 

1143 ), 

1144 cloud_watch_role=True, 

1145 # CDK otherwise retains the generated API Gateway account role. 

1146 cloud_watch_role_removal_policy=RemovalPolicy.DESTROY, 

1147 ) 

1148 

1149 # Add resource policy to restrict to account 

1150 api.add_to_resource_policy( 

1151 iam.PolicyStatement( 

1152 effect=iam.Effect.ALLOW, 

1153 principals=[iam.AnyPrincipal()], 

1154 actions=["execute-api:Invoke"], 

1155 resources=["execute-api:/*"], 

1156 conditions={"StringEquals": {"aws:PrincipalAccount": self.account}}, 

1157 ) 

1158 ) 

1159 

1160 # Allow Cognito-authorized requests on /studio/* paths. The Cognito 

1161 # authorizer on the method handles authentication; the resource 

1162 # policy just needs to not block the request before it reaches the 

1163 # authorizer. Cognito tokens don't carry aws:PrincipalAccount so 

1164 # the account-scoped statement above would reject them. 

1165 api.add_to_resource_policy( 

1166 iam.PolicyStatement( 

1167 effect=iam.Effect.ALLOW, 

1168 principals=[iam.AnyPrincipal()], 

1169 actions=["execute-api:Invoke"], 

1170 resources=["execute-api:/*/GET/studio/*"], 

1171 ) 

1172 ) 

1173 

1174 # Create /api/v1. Aggregate routes are available in every partition; 

1175 # the GA-backed catch-all control-plane and inference routes are added 

1176 # only when the global data path exists. 

1177 api_resource = api.root.add_resource("api") 

1178 v1_resource = api_resource.add_resource("v1") 

1179 

1180 if self.proxy_lambda is not None: 

1181 lambda_integration = apigateway.LambdaIntegration( 

1182 self.proxy_lambda, 

1183 proxy=True, 

1184 timeout=Duration.seconds(29), 

1185 ) 

1186 proxy_resource = v1_resource.add_resource("{proxy+}") 

1187 for method in ["GET", "POST", "PUT", "DELETE", "PATCH"]: 

1188 proxy_resource.add_method( 

1189 method, 

1190 lambda_integration, 

1191 authorization_type=apigateway.AuthorizationType.IAM, 

1192 method_responses=[ 

1193 apigateway.MethodResponse(status_code="200"), 

1194 apigateway.MethodResponse(status_code="400"), 

1195 apigateway.MethodResponse(status_code="403"), 

1196 apigateway.MethodResponse(status_code="500"), 

1197 ], 

1198 ) 

1199 

1200 self._create_global_routes(api, v1_resource) 

1201 

1202 if self.inference_proxy_lambda is not None: 

1203 inference_integration = apigateway.LambdaIntegration( 

1204 self.inference_proxy_lambda, 

1205 proxy=True, 

1206 timeout=Duration.minutes(15), 

1207 response_transfer_mode=apigateway.ResponseTransferMode.STREAM, 

1208 ) 

1209 self._create_inference_routes(api, inference_integration) 

1210 

1211 return api 

1212 

1213 def _create_global_routes( 

1214 self, api: apigateway.RestApi, v1_resource: apigateway.Resource 

1215 ) -> None: 

1216 """Create routes for cross-region aggregation endpoints. 

1217 

1218 Routes: 

1219 GET /api/v1/global/jobs - List jobs across all regions 

1220 DELETE /api/v1/global/jobs - Bulk delete across all regions 

1221 GET /api/v1/global/health - Health status across all regions 

1222 GET /api/v1/global/status - Cluster status across all regions 

1223 """ 

1224 # Create Lambda integration for aggregator 

1225 aggregator_integration = apigateway.LambdaIntegration( 

1226 self.aggregator_lambda, proxy=True, timeout=Duration.seconds(29) 

1227 ) 

1228 

1229 # Create /global resource 

1230 global_resource = v1_resource.add_resource("global") 

1231 

1232 # /global/jobs 

1233 global_jobs = global_resource.add_resource("jobs") 

1234 for method in ["GET", "DELETE"]: 

1235 global_jobs.add_method( 

1236 method, 

1237 aggregator_integration, 

1238 authorization_type=apigateway.AuthorizationType.IAM, 

1239 method_responses=[ 

1240 apigateway.MethodResponse(status_code="200"), 

1241 apigateway.MethodResponse(status_code="400"), 

1242 apigateway.MethodResponse(status_code="500"), 

1243 ], 

1244 ) 

1245 

1246 # /global/health 

1247 global_health = global_resource.add_resource("health") 

1248 global_health.add_method( 

1249 "GET", 

1250 aggregator_integration, 

1251 authorization_type=apigateway.AuthorizationType.IAM, 

1252 method_responses=[ 

1253 apigateway.MethodResponse(status_code="200"), 

1254 apigateway.MethodResponse(status_code="500"), 

1255 ], 

1256 ) 

1257 

1258 # /global/status 

1259 global_status = global_resource.add_resource("status") 

1260 global_status.add_method( 

1261 "GET", 

1262 aggregator_integration, 

1263 authorization_type=apigateway.AuthorizationType.IAM, 

1264 method_responses=[ 

1265 apigateway.MethodResponse(status_code="200"), 

1266 apigateway.MethodResponse(status_code="500"), 

1267 ], 

1268 ) 

1269 

1270 def _create_inference_routes( 

1271 self, 

1272 api: apigateway.RestApi, 

1273 lambda_integration: apigateway.LambdaIntegration, 

1274 ) -> None: 

1275 """Create proxy route for inference endpoints. 

1276 

1277 Routes: 

1278 GET|HEAD|POST /inference/{proxy+} → streaming Lambda → GA → ALB → inference proxy 

1279 

1280 The dedicated in-cluster service enforces endpoint state and the serving- 

1281 path allowlist before opening a streaming connection to a model server. 

1282 """ 

1283 inference_resource = api.root.add_resource("inference") 

1284 inference_proxy = inference_resource.add_resource("{proxy+}") 

1285 

1286 for method in ["GET", "HEAD", "POST"]: 

1287 inference_proxy.add_method( 

1288 method, 

1289 lambda_integration, 

1290 authorization_type=apigateway.AuthorizationType.IAM, 

1291 method_responses=[ 

1292 apigateway.MethodResponse(status_code="200"), 

1293 apigateway.MethodResponse(status_code="400"), 

1294 apigateway.MethodResponse(status_code="404"), 

1295 apigateway.MethodResponse(status_code="500"), 

1296 apigateway.MethodResponse(status_code="502"), 

1297 ], 

1298 ) 

1299 

1300 def _create_outputs(self) -> None: 

1301 """Export API Gateway endpoint.""" 

1302 

1303 CfnOutput( 

1304 self, 

1305 "ApiEndpoint", 

1306 value=self.api.url, 

1307 description="Global API Gateway endpoint (IAM authenticated)", 

1308 export_name=f"{self.project_name}-global-api-endpoint", 

1309 ) 

1310 

1311 CfnOutput( 

1312 self, 

1313 "SecretArn", 

1314 value=self.secret.secret_arn, 

1315 description="Backend HMAC signing-key secret ARN", 

1316 export_name=f"{self.project_name}-auth-secret-arn", 

1317 ) 

1318 

1319 CfnOutput( 

1320 self, 

1321 "BackendTlsServerName", 

1322 value=self.backend_tls_server_name, 

1323 description="Private SNI identity verified on every proxy-to-ALB TLS connection", 

1324 export_name=f"{self.project_name}-backend-tls-server-name", 

1325 ) 

1326 

1327 CfnOutput( 

1328 self, 

1329 "BackendTlsRootCaParameter", 

1330 value=self.backend_tls_root_ca_parameter_name, 

1331 description="SSM parameter containing the public backend TLS root trust bundle", 

1332 export_name=f"{self.project_name}-backend-tls-root-ca-parameter", 

1333 ) 

1334 

1335 def set_analytics_config(self, config: AnalyticsApiConfig) -> None: 

1336 """Attach a post-construction ``AnalyticsApiConfig`` and wire ``/studio/*`` routes. 

1337 

1338 ``GCOAnalyticsStack`` is created *after* ``GCOApiGatewayGlobalStack`` 

1339 in ``app.py`` (the regional stacks already declare a dependency on 

1340 the API gateway stack, so re-ordering the two global stacks would 

1341 ripple through the entire stack graph). This mutator lets 

1342 ``app.py`` defer attaching the analytics integration until after 

1343 both stacks exist, without changing the constructor contract or 

1344 the existing cross-stack dependency wiring. 

1345 

1346 MUST be called **at most once**, and only before stack synthesis 

1347 finishes. Calling it twice raises ``RuntimeError`` so the caller 

1348 cannot accidentally double-wire the Cognito authorizer (which 

1349 would produce two authorizers with overlapping identity sources 

1350 on the same REST API). 

1351 

1352 Args: 

1353 config: The ``AnalyticsApiConfig`` built from the 

1354 ``GCOAnalyticsStack`` attributes. Must be non-``None`` — 

1355 pass ``None`` at construction time instead if analytics 

1356 is disabled. 

1357 

1358 Raises: 

1359 RuntimeError: if the stack already has an attached 

1360 ``analytics_config`` (from either constructor kwarg or 

1361 a prior ``set_analytics_config`` call). 

1362 """ 

1363 if self.analytics_config is not None: 

1364 raise RuntimeError( 

1365 "GCOApiGatewayGlobalStack.set_analytics_config may only be called " 

1366 "once. The stack already has an analytics_config attached." 

1367 ) 

1368 self.analytics_config = config 

1369 self._wire_studio_routes() 

1370 

1371 def _wire_studio_routes(self) -> None: 

1372 """Attach the Cognito-authorized ``/studio/*`` route tree. 

1373 

1374 Called from ``__init__`` when an ``AnalyticsApiConfig`` is passed 

1375 to the constructor, or from :meth:`set_analytics_config` when the 

1376 config is attached post-construction. Safe to skip entirely when 

1377 analytics is disabled — the caller is responsible for gating on 

1378 ``self.analytics_config is not None``. 

1379 

1380 Wiring order matters: this runs *after* ``_create_api_gateway`` 

1381 has already attached the IAM-authorized ``/api/v1/*`` and 

1382 ``/inference/*`` methods. The Cognito authorizer coexists with 

1383 those methods at the method level (not at the REST API level), 

1384 so the existing IAM-authorized methods are untouched — see the 

1385 coexistence assertion in 

1386 ``tests/test_api_gateway_analytics_config.py``. 

1387 

1388 Resources added: 

1389 

1390 * ``CognitoUserPoolsAuthorizer`` named ``StudioCognitoAuthorizer`` 

1391 referencing ``UserPool.from_user_pool_arn(...)``. 

1392 * ``RequestValidator`` with ``validate_request_parameters=True`` 

1393 attached to the ``/studio/login`` method via 

1394 ``request_validator_options``. 

1395 * ``/studio`` + ``/studio/login`` + ``/studio/callback`` 

1396 resources. 

1397 * ``GET /studio/login`` — Cognito-authorized, 

1398 ``LambdaIntegration(presigned_url_lambda, proxy=True, 

1399 timeout=Duration.seconds(29))``. 

1400 * ``GET /studio/callback`` — unauthenticated stub MOCK 

1401 integration returning a 200 with an empty body; serves as the 

1402 OAuth redirect landing page when Cognito hosted UI is enabled. 

1403 * ``CfnOutput`` ``CognitoAuthorizerId`` with the authorizer's 

1404 ``authorizer_id``. 

1405 * ``CfnOutput`` ``StudioLoginUrl`` — concrete 

1406 ``https://<api-id>.execute-api.<region>.amazonaws.com/prod/studio/login`` 

1407 constructed at deploy time via ``Fn.sub`` because the REST API 

1408 id is a deploy-time token. 

1409 """ 

1410 assert self.analytics_config is not None, ( 

1411 "_wire_studio_routes called without an AnalyticsApiConfig attached." 

1412 ) 

1413 analytics_config = self.analytics_config 

1414 

1415 # Build the authorizer against the Cognito user pool that owns 

1416 # Studio identities. ``from_user_pool_arn`` is an import — no 

1417 # new Cognito resources are created in this stack. 

1418 user_pool = cognito.UserPool.from_user_pool_arn( 

1419 self, 

1420 "StudioUserPoolRef", 

1421 analytics_config.user_pool_arn, 

1422 ) 

1423 authorizer = apigateway.CognitoUserPoolsAuthorizer( 

1424 self, 

1425 "StudioCognitoAuthorizer", 

1426 cognito_user_pools=[user_pool], 

1427 authorizer_name=f"{self.project_name}-studio-cognito-authorizer", 

1428 ) 

1429 # The authorizer attaches itself to the RestApi automatically 

1430 # the first time it is passed into ``add_method``. No explicit 

1431 # attach call is needed (and the CDK API does not expose a 

1432 # public one for ``CognitoUserPoolsAuthorizer``). 

1433 

1434 # Request validator — validates query/path parameters are 

1435 # present before the Lambda is invoked (the Cognito ID token 

1436 # itself is validated by the authorizer, not this validator). 

1437 studio_request_validator = apigateway.RequestValidator( 

1438 self, 

1439 "StudioRequestValidator", 

1440 rest_api=self.api, 

1441 request_validator_name=f"{self.project_name}-studio-request-validator", 

1442 validate_request_parameters=True, 

1443 ) 

1444 

1445 # /studio → /studio/login + /studio/callback 

1446 studio_resource = self.api.root.add_resource("studio") 

1447 login_resource = studio_resource.add_resource("login") 

1448 callback_resource = studio_resource.add_resource("callback") 

1449 

1450 # /studio/login — Cognito-authorized, proxies to the 

1451 # presigned-URL Lambda. 29-second integration timeout matches 

1452 # the Lambda timeout so the Lambda is the one that times out 

1453 # on slow SageMaker API calls rather than API Gateway. 

1454 login_integration = apigateway.LambdaIntegration( 

1455 analytics_config.presigned_url_lambda, 

1456 proxy=True, 

1457 timeout=Duration.seconds(29), 

1458 ) 

1459 login_resource.add_method( 

1460 "GET", 

1461 login_integration, 

1462 authorization_type=apigateway.AuthorizationType.COGNITO, 

1463 authorizer=authorizer, 

1464 request_validator=studio_request_validator, 

1465 method_responses=[ 

1466 apigateway.MethodResponse(status_code="200"), 

1467 apigateway.MethodResponse(status_code="400"), 

1468 apigateway.MethodResponse(status_code="401"), 

1469 apigateway.MethodResponse(status_code="404"), 

1470 apigateway.MethodResponse(status_code="500"), 

1471 ], 

1472 ) 

1473 

1474 # /studio/callback — stub 200 OK landing page for the Cognito 

1475 # hosted UI OAuth redirect flow. Unauthenticated MOCK 

1476 # integration so the page is reachable without a signed 

1477 # request. The body is intentionally empty — the hosted UI 

1478 # consumes the query-string code parameter, not the response 

1479 # body. 

1480 callback_integration = apigateway.MockIntegration( 

1481 integration_responses=[ 

1482 apigateway.IntegrationResponse( 

1483 status_code="200", 

1484 response_templates={"application/json": ""}, 

1485 ), 

1486 ], 

1487 request_templates={"application/json": '{"statusCode": 200}'}, 

1488 ) 

1489 callback_method = callback_resource.add_method( 

1490 "GET", 

1491 callback_integration, 

1492 authorization_type=apigateway.AuthorizationType.NONE, 

1493 method_responses=[ 

1494 apigateway.MethodResponse(status_code="200"), 

1495 ], 

1496 ) 

1497 

1498 # /studio/callback is intentionally unauthenticated — it's the 

1499 # Cognito hosted-UI OAuth redirect landing page where the 

1500 # authorization ``code`` query-string parameter is consumed by 

1501 # the client-side JavaScript. Adding IAM or Cognito authorization 

1502 # here would break the OAuth flow because the browser redirect 

1503 # from Cognito does not carry SigV4 or an id-token header. 

1504 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

1505 

1506 acknowledge_nag_findings( 

1507 callback_method, 

1508 [ 

1509 { 

1510 "id": "AwsSolutions-APIG4", 

1511 "reason": ( 

1512 "/studio/callback is the Cognito hosted-UI OAuth " 

1513 "redirect landing page. The browser redirect from " 

1514 "Cognito carries the authorization code as a " 

1515 "query-string parameter; it does NOT carry SigV4 " 

1516 "or an id-token header. Adding IAM or Cognito " 

1517 "authorization here would break the OAuth flow. " 

1518 "The route is a MOCK integration that returns an " 

1519 "empty 200 body; it does not expose any backend " 

1520 "resources." 

1521 ), 

1522 }, 

1523 ], 

1524 ) 

1525 

1526 # CfnOutputs — the CLI reads these for auto-discovery. 

1527 CfnOutput( 

1528 self, 

1529 "CognitoAuthorizerId", 

1530 value=authorizer.authorizer_id, 

1531 description="API Gateway authorizer id for the Studio Cognito authorizer", 

1532 export_name=f"{self.project_name}-studio-cognito-authorizer-id", 

1533 ) 

1534 # ``self.api.url`` already resolves to the deploy-time URL, but 

1535 # it points at the stage root. Use ``Fn.sub`` to append the 

1536 # concrete ``studio/login`` suffix so operators get a copy- 

1537 # pastable login URL in the stack outputs. 

1538 studio_login_url = Fn.sub( 

1539 "https://${ApiId}.execute-api.${AWS::Region}.${AWS::URLSuffix}/${Stage}/studio/login", 

1540 { 

1541 "ApiId": self.api.rest_api_id, 

1542 "Stage": self.api.deployment_stage.stage_name, 

1543 }, 

1544 ) 

1545 CfnOutput( 

1546 self, 

1547 "StudioLoginUrl", 

1548 value=studio_login_url, 

1549 description="Concrete URL for the /studio/login route (Cognito-authenticated)", 

1550 export_name=f"{self.project_name}-studio-login-url", 

1551 ) 

1552 

1553 def _create_waf(self) -> None: 

1554 """Create WAF WebACL with AWS Managed Rules for API Gateway protection. 

1555 

1556 This implements a comprehensive WAF setup using AWS Managed Rule Groups 

1557 for protection against: 

1558 - Common web exploits (OWASP Top 10) 

1559 - Known bad inputs 

1560 - SQL injection 

1561 - Linux-specific attacks 

1562 - IP reputation threats 

1563 - Anonymous IP addresses (Tor, VPNs, proxies) 

1564 

1565 The WebACL is associated with the API Gateway stage for edge protection. 

1566 Logging is enabled to CloudWatch Logs for compliance (HIPAA, NIST, PCI-DSS). 

1567 """ 

1568 # Create CloudWatch Log Group for WAF logs 

1569 # WAF requires log group name to start with "aws-waf-logs-" 

1570 waf_log_group = logs.LogGroup( 

1571 self, 

1572 "WafLogGroup", 

1573 log_group_name=f"aws-waf-logs-{self.project_name}-api-gateway", 

1574 retention=logs.RetentionDays.ONE_MONTH, 

1575 removal_policy=RemovalPolicy.DESTROY, 

1576 ) 

1577 

1578 # Create WAF WebACL with AWS Managed Rules 

1579 # Note: For API Gateway (even edge-optimized), use REGIONAL scope 

1580 # The WAF is associated with the API Gateway stage, not CloudFront directly 

1581 # 

1582 # Rule priority ordering: 

1583 # 0 -> PerIPRateLimit (evaluated FIRST so abusive IPs are blocked 

1584 # before expensive managed rule groups run) 

1585 # 1 -> Preserve the CRS 8 KiB body limit outside /inference/* 

1586 # 2-7 -> AWS Managed Rule Groups 

1587 waf_config = self.node.try_get_context("waf") or {} 

1588 per_ip_rate_limit = int(waf_config.get("per_ip_rate_limit", 100)) 

1589 

1590 self.web_acl = wafv2.CfnWebACL( 

1591 self, 

1592 "GCOWebAcl", 

1593 name=f"{self.project_name}-api-gateway-waf", 

1594 description="WAF WebACL for GCO API Gateway with AWS Managed Rules", 

1595 scope="REGIONAL", # REGIONAL for API Gateway association 

1596 default_action=wafv2.CfnWebACL.DefaultActionProperty(allow={}), 

1597 visibility_config=wafv2.CfnWebACL.VisibilityConfigProperty( 

1598 cloud_watch_metrics_enabled=True, 

1599 metric_name="GCOApiGatewayWaf", 

1600 sampled_requests_enabled=True, 

1601 ), 

1602 rules=[ 

1603 # Rule 0: Per-source-IP rate limiting (HIGHEST PRIORITY). 

1604 # Evaluated before any AWS Managed Rule Group so that abusive 

1605 # IPs are blocked immediately without consuming WCUs on the 

1606 # heavier managed rule groups. Aggregates requests per source 

1607 # IP over a rolling 5-minute window (AWS WAF fixed behavior 

1608 # for rate-based statements). 

1609 # 

1610 # The limit is configurable via `cdk.json` context 

1611 # `waf.per_ip_rate_limit` (default: 100 requests / 5 min). 

1612 wafv2.CfnWebACL.RuleProperty( 

1613 name="PerIPRateLimit", 

1614 priority=0, 

1615 action=wafv2.CfnWebACL.RuleActionProperty(block={}), 

1616 statement=wafv2.CfnWebACL.StatementProperty( 

1617 rate_based_statement=wafv2.CfnWebACL.RateBasedStatementProperty( 

1618 limit=per_ip_rate_limit, 

1619 aggregate_key_type="IP", 

1620 ) 

1621 ), 

1622 visibility_config=wafv2.CfnWebACL.VisibilityConfigProperty( 

1623 cloud_watch_metrics_enabled=True, 

1624 metric_name="PerIPRateLimit", 

1625 sampled_requests_enabled=True, 

1626 ), 

1627 ), 

1628 # Rule 1: Preserve the CRS 8 KiB body limit for every route 

1629 # except the deployed /prod/inference/{proxy+} route. API Gateway 

1630 # invoke URLs include the stage in the client URI that WAF inspects, 

1631 # and the trailing slash is the route boundary: /prod/inference-extra 

1632 # must remain subject to this limit. Inference accepts bodies up to 

1633 # the backend's authoritative 1 MiB limit. MATCH fails closed on 

1634 # oversized control-plane bodies beyond WAF's inspection window. 

1635 wafv2.CfnWebACL.RuleProperty( 

1636 name="NonInferenceBodySizeLimit", 

1637 priority=1, 

1638 action=wafv2.CfnWebACL.RuleActionProperty(block={}), 

1639 statement=wafv2.CfnWebACL.StatementProperty( 

1640 and_statement=wafv2.CfnWebACL.AndStatementProperty( 

1641 statements=[ 

1642 wafv2.CfnWebACL.StatementProperty( 

1643 size_constraint_statement=wafv2.CfnWebACL.SizeConstraintStatementProperty( 

1644 comparison_operator="GT", 

1645 field_to_match=wafv2.CfnWebACL.FieldToMatchProperty( 

1646 body=wafv2.CfnWebACL.BodyProperty( 

1647 oversize_handling="MATCH" 

1648 ) 

1649 ), 

1650 size=8_192, 

1651 text_transformations=[ 

1652 wafv2.CfnWebACL.TextTransformationProperty( 

1653 priority=0, 

1654 type="NONE", 

1655 ) 

1656 ], 

1657 ) 

1658 ), 

1659 wafv2.CfnWebACL.StatementProperty( 

1660 not_statement=wafv2.CfnWebACL.NotStatementProperty( 

1661 statement=wafv2.CfnWebACL.StatementProperty( 

1662 byte_match_statement=wafv2.CfnWebACL.ByteMatchStatementProperty( 

1663 field_to_match=wafv2.CfnWebACL.FieldToMatchProperty( 

1664 uri_path={} 

1665 ), 

1666 positional_constraint="STARTS_WITH", 

1667 search_string="/prod/inference/", 

1668 text_transformations=[ 

1669 wafv2.CfnWebACL.TextTransformationProperty( 

1670 priority=0, 

1671 type="NONE", 

1672 ) 

1673 ], 

1674 ) 

1675 ) 

1676 ) 

1677 ), 

1678 ] 

1679 ) 

1680 ), 

1681 visibility_config=wafv2.CfnWebACL.VisibilityConfigProperty( 

1682 cloud_watch_metrics_enabled=True, 

1683 metric_name="NonInferenceBodySizeLimit", 

1684 sampled_requests_enabled=True, 

1685 ), 

1686 ), 

1687 # Rule 2: AWS Managed Rules - Common Rule Set (OWASP Top 10). 

1688 # Override only SizeRestrictions_BODY: every other CRS rule 

1689 # continues to block normally, including body-content rules. 

1690 wafv2.CfnWebACL.RuleProperty( 

1691 name="AWSManagedRulesCommonRuleSet", 

1692 priority=2, 

1693 override_action=wafv2.CfnWebACL.OverrideActionProperty(none={}), 

1694 statement=wafv2.CfnWebACL.StatementProperty( 

1695 managed_rule_group_statement=wafv2.CfnWebACL.ManagedRuleGroupStatementProperty( 

1696 vendor_name="AWS", 

1697 name="AWSManagedRulesCommonRuleSet", 

1698 rule_action_overrides=[ 

1699 wafv2.CfnWebACL.RuleActionOverrideProperty( 

1700 name="SizeRestrictions_BODY", 

1701 action_to_use=wafv2.CfnWebACL.RuleActionProperty(count={}), 

1702 ) 

1703 ], 

1704 ) 

1705 ), 

1706 visibility_config=wafv2.CfnWebACL.VisibilityConfigProperty( 

1707 cloud_watch_metrics_enabled=True, 

1708 metric_name="AWSManagedRulesCommonRuleSet", 

1709 sampled_requests_enabled=True, 

1710 ), 

1711 ), 

1712 # Rule 3: AWS Managed Rules - Known Bad Inputs 

1713 wafv2.CfnWebACL.RuleProperty( 

1714 name="AWSManagedRulesKnownBadInputsRuleSet", 

1715 priority=3, 

1716 override_action=wafv2.CfnWebACL.OverrideActionProperty(none={}), 

1717 statement=wafv2.CfnWebACL.StatementProperty( 

1718 managed_rule_group_statement=wafv2.CfnWebACL.ManagedRuleGroupStatementProperty( 

1719 vendor_name="AWS", 

1720 name="AWSManagedRulesKnownBadInputsRuleSet", 

1721 ) 

1722 ), 

1723 visibility_config=wafv2.CfnWebACL.VisibilityConfigProperty( 

1724 cloud_watch_metrics_enabled=True, 

1725 metric_name="AWSManagedRulesKnownBadInputsRuleSet", 

1726 sampled_requests_enabled=True, 

1727 ), 

1728 ), 

1729 # Rule 4: AWS Managed Rules - SQL Injection 

1730 wafv2.CfnWebACL.RuleProperty( 

1731 name="AWSManagedRulesSQLiRuleSet", 

1732 priority=4, 

1733 override_action=wafv2.CfnWebACL.OverrideActionProperty(none={}), 

1734 statement=wafv2.CfnWebACL.StatementProperty( 

1735 managed_rule_group_statement=wafv2.CfnWebACL.ManagedRuleGroupStatementProperty( 

1736 vendor_name="AWS", 

1737 name="AWSManagedRulesSQLiRuleSet", 

1738 ) 

1739 ), 

1740 visibility_config=wafv2.CfnWebACL.VisibilityConfigProperty( 

1741 cloud_watch_metrics_enabled=True, 

1742 metric_name="AWSManagedRulesSQLiRuleSet", 

1743 sampled_requests_enabled=True, 

1744 ), 

1745 ), 

1746 # Rule 5: AWS Managed Rules - Linux OS (protects against Linux-specific attacks) 

1747 wafv2.CfnWebACL.RuleProperty( 

1748 name="AWSManagedRulesLinuxRuleSet", 

1749 priority=5, 

1750 override_action=wafv2.CfnWebACL.OverrideActionProperty(none={}), 

1751 statement=wafv2.CfnWebACL.StatementProperty( 

1752 managed_rule_group_statement=wafv2.CfnWebACL.ManagedRuleGroupStatementProperty( 

1753 vendor_name="AWS", 

1754 name="AWSManagedRulesLinuxRuleSet", 

1755 ) 

1756 ), 

1757 visibility_config=wafv2.CfnWebACL.VisibilityConfigProperty( 

1758 cloud_watch_metrics_enabled=True, 

1759 metric_name="AWSManagedRulesLinuxRuleSet", 

1760 sampled_requests_enabled=True, 

1761 ), 

1762 ), 

1763 # Rule 6: AWS Managed Rules - Amazon IP Reputation List 

1764 wafv2.CfnWebACL.RuleProperty( 

1765 name="AWSManagedRulesAmazonIpReputationList", 

1766 priority=6, 

1767 override_action=wafv2.CfnWebACL.OverrideActionProperty(none={}), 

1768 statement=wafv2.CfnWebACL.StatementProperty( 

1769 managed_rule_group_statement=wafv2.CfnWebACL.ManagedRuleGroupStatementProperty( 

1770 vendor_name="AWS", 

1771 name="AWSManagedRulesAmazonIpReputationList", 

1772 ) 

1773 ), 

1774 visibility_config=wafv2.CfnWebACL.VisibilityConfigProperty( 

1775 cloud_watch_metrics_enabled=True, 

1776 metric_name="AWSManagedRulesAmazonIpReputationList", 

1777 sampled_requests_enabled=True, 

1778 ), 

1779 ), 

1780 # Rule 7: AWS Managed Rules - Anonymous IP List (blocks Tor, VPNs, proxies) 

1781 wafv2.CfnWebACL.RuleProperty( 

1782 name="AWSManagedRulesAnonymousIpList", 

1783 priority=7, 

1784 override_action=wafv2.CfnWebACL.OverrideActionProperty(none={}), 

1785 statement=wafv2.CfnWebACL.StatementProperty( 

1786 managed_rule_group_statement=wafv2.CfnWebACL.ManagedRuleGroupStatementProperty( 

1787 vendor_name="AWS", 

1788 name="AWSManagedRulesAnonymousIpList", 

1789 ) 

1790 ), 

1791 visibility_config=wafv2.CfnWebACL.VisibilityConfigProperty( 

1792 cloud_watch_metrics_enabled=True, 

1793 metric_name="AWSManagedRulesAnonymousIpList", 

1794 sampled_requests_enabled=True, 

1795 ), 

1796 ), 

1797 ], 

1798 ) 

1799 

1800 # Enable WAF logging to CloudWatch Logs 

1801 # This is required for HIPAA, NIST 800-53, and PCI-DSS compliance 

1802 wafv2.CfnLoggingConfiguration( 

1803 self, 

1804 "WafLoggingConfig", 

1805 resource_arn=self.web_acl.attr_arn, 

1806 log_destination_configs=[waf_log_group.log_group_arn], 

1807 ) 

1808 

1809 # Associate WAF WebACL with API Gateway stage 

1810 # For API Gateway, use the stage ARN format 

1811 wafv2.CfnWebACLAssociation( 

1812 self, 

1813 "GCOWebAclAssociation", 

1814 resource_arn=self.api.deployment_stage.stage_arn, 

1815 web_acl_arn=self.web_acl.attr_arn, 

1816 ) 

1817 

1818 # Output WAF WebACL ARN 

1819 CfnOutput( 

1820 self, 

1821 "WebAclArn", 

1822 value=self.web_acl.attr_arn, 

1823 description="WAF WebACL ARN for API Gateway protection", 

1824 export_name=f"{self.project_name}-waf-webacl-arn", 

1825 )