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

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

90 

91 

92@dataclass(frozen=True) 

93class AnalyticsApiConfig: 

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

95 

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. 

103 

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. 

108 

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

129 

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 

135 

136 

137class GCOApiGatewayGlobalStack(Stack): 

138 """ 

139 Global API Gateway with IAM authentication. 

140 

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

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

143 

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

151 

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) 

168 

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) 

207 

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 

232 

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() 

237 

238 # Create the shared backend HMAC signing key. 

239 self.secret = self._create_secret() 

240 

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 ) 

248 

249 # Create cross-region aggregator Lambda 

250 self.aggregator_lambda = self._create_aggregator_lambda() 

251 

252 # Create API Gateway 

253 self.api = self._create_api_gateway() 

254 

255 # Create WAF WebACL and associate with API Gateway 

256 self._create_waf() 

257 

258 # Export API endpoint 

259 self._create_outputs() 

260 

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() 

267 

268 # Apply cdk-nag suppressions 

269 self._apply_nag_suppressions() 

270 

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 

274 

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 ) 

284 

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 

289 

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 ) 

316 

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 ) 

372 

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 ) 

415 

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) 

453 

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) 

479 

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) 

563 

564 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

565 

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 ) 

676 

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 ) 

692 

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

694 self.rotation_lambda = self._create_rotation_lambda(secret) 

695 

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 ) 

702 

703 return secret 

704 

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

706 """Create Lambda function for secret rotation. 

707 

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 ) 

725 

726 # Grant permissions to manage the secret 

727 secret.grant_read(rotation_role) 

728 secret.grant_write(rotation_role) 

729 

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 ) 

742 

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 ) 

750 

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 ) 

765 

766 # Grant Secrets Manager permission to invoke the rotation Lambda 

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

768 

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 

772 

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 ) 

789 

790 return rotation_lambda 

791 

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

796 

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 ) 

808 

809 # Grant read access to secret 

810 self.secret.grant_read(lambda_role) 

811 

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 ) 

826 

827 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

828 

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 ) 

843 

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 ) 

851 

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 ) 

878 

879 return proxy_lambda 

880 

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 ) 

907 

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 ) 

942 

943 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

944 

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 

959 

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

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

962 

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 

986 

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 ) 

1015 

1016 aggregator_log_group = logs.LogGroup( 

1017 self, 

1018 "AggregatorLambdaLogGroup", 

1019 retention=logs.RetentionDays.ONE_WEEK, 

1020 removal_policy=RemovalPolicy.DESTROY, 

1021 ) 

1022 

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 ) 

1041 

1042 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

1043 

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 ) 

1078 

1079 return aggregator_lambda 

1080 

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

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

1083 

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 ) 

1092 

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 ) 

1104 

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 ) 

1147 

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 ) 

1158 

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 ) 

1172 

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

1178 

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 ) 

1198 

1199 self._create_global_routes(api, v1_resource) 

1200 

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) 

1209 

1210 return api 

1211 

1212 def _create_global_routes( 

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

1214 ) -> None: 

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

1216 

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 ) 

1227 

1228 # Create /global resource 

1229 global_resource = v1_resource.add_resource("global") 

1230 

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 ) 

1244 

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 ) 

1256 

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 ) 

1268 

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. 

1275 

1276 Routes: 

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

1278 

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+}") 

1284 

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 ) 

1298 

1299 def _create_outputs(self) -> None: 

1300 """Export API Gateway endpoint.""" 

1301 

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 ) 

1309 

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 ) 

1317 

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 ) 

1325 

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 ) 

1333 

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

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

1336 

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. 

1344 

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). 

1350 

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. 

1356 

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() 

1369 

1370 def _wire_studio_routes(self) -> None: 

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

1372 

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``. 

1378 

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``. 

1386 

1387 Resources added: 

1388 

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 

1413 

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``). 

1432 

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 ) 

1443 

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

1448 

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 ) 

1472 

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 ) 

1496 

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 

1504 

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 ) 

1524 

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 ) 

1551 

1552 def _create_waf(self) -> None: 

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

1554 

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) 

1563 

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 ) 

1576 

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

1588 

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 ) 

1798 

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 ) 

1807 

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 ) 

1816 

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 )