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

224 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-10-28 16:19 +0000

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) 

82from gco.stacks.lambda_assets import lambda_source_code 

83 

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

85# Generated at (UTC): 2026-10-16T16:57:50Z 

86# Generated from Git commit: 3458f76ef1541be718c00458b8ecc8f7c770c6ad 

87# Flowchart(s) generated from this file: 

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

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

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

91# <pyflowchart-code-diagram> END 

92 

93 

94@dataclass(frozen=True) 

95class AnalyticsApiConfig: 

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

97 

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

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

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

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

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

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

104 ``CfnOutput`` entries. 

105 

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

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

108 of accidental mutation after the synthesized template references its 

109 fields. 

110 

111 Attributes: 

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

113 Studio logins. Shape: 

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

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

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

117 flow and surfaced to API Gateway outputs for discoverability. 

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

119 function created by ``GCOAnalyticsStack._create_presigned_url_lambda``. 

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

121 studio_domain_name: SageMaker Studio domain name. Carried through 

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

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

124 variable set by the analytics stack. 

125 callback_url: Concrete OAuth redirect target 

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

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

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

129 after deploy. 

130 """ 

131 

132 user_pool_arn: str 

133 user_pool_client_id: str 

134 presigned_url_lambda: lambda_.IFunction 

135 studio_domain_name: str 

136 callback_url: str 

137 

138 

139class GCOApiGatewayGlobalStack(Stack): 

140 """ 

141 Global API Gateway with IAM authentication. 

142 

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

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

145 

146 Attributes: 

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

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

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

150 aggregator_lambda: Lambda function for cross-region aggregation 

151 api: REST API with IAM authentication 

152 """ 

153 

154 def __init__( 

155 self, 

156 scope: Construct, 

157 construct_id: str, 

158 global_accelerator_dns: str | None, 

159 analytics_config: AnalyticsApiConfig | None = None, 

160 project_name: str = "gco", 

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

162 registry_region: str | None = None, 

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

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

165 max_request_body_bytes: int = DEFAULT_MAX_REQUEST_BODY_BYTES, 

166 **kwargs: Any, 

167 ) -> None: 

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

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

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_source_code("lambda/secret-rotation"), 

758 timeout=Duration.seconds(30), 

759 memory_size=128, 

760 role=rotation_role, 

761 log_group=rotation_log_group, 

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

763 tracing=lambda_.Tracing.ACTIVE, 

764 ) 

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: 

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_source_code("lambda/api-gateway-proxy"), 

859 timeout=Duration.seconds(29), 

860 memory_size=256, 

861 role=lambda_role, 

862 environment={ 

863 "GLOBAL_ACCELERATOR_ENDPOINT": self.ga_dns, 

864 "SECRET_ARN": self.secret.secret_arn, 

865 "BACKEND_TLS_SERVER_NAME": self.backend_tls_server_name, 

866 "BACKEND_TLS_ROOT_CA_PARAMETER": self.backend_tls_root_ca_parameter_name, 

867 "BACKEND_TLS_ROOT_CA_REGION": self.registry_region, 

868 "BACKEND_TLS_CA_CACHE_TTL_SECONDS": str( 

869 self.backend_tls_config["trust_cache_ttl_seconds"] 

870 ), 

871 "BACKEND_TLS_CA_MAX_STALE_SECONDS": str( 

872 self.backend_tls_config["trust_cache_max_stale_seconds"] 

873 ), 

874 }, 

875 log_group=proxy_lambda_log_group, 

876 tracing=lambda_.Tracing.ACTIVE, 

877 ) 

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: 

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 # With zero workload Regions configured (a control-plane-only 

988 # deployment) there is no regional bridge to describe or invoke. An 

989 # identity policy statement must name at least one resource, so the 

990 # two grants are simply absent until a Region is added; the aggregator 

991 # then discovers nothing and answers with empty results. 

992 if self.certificate_regions: 

993 regional_stack_arns = [ 

994 ( 

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

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

997 ) 

998 for region in self.certificate_regions 

999 ] 

1000 aggregator_role.add_to_policy( 

1001 iam.PolicyStatement( 

1002 effect=iam.Effect.ALLOW, 

1003 actions=["cloudformation:DescribeStacks"], 

1004 resources=regional_stack_arns, 

1005 ) 

1006 ) 

1007 aggregator_role.add_to_policy( 

1008 iam.PolicyStatement( 

1009 effect=iam.Effect.ALLOW, 

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

1011 resources=[ 

1012 ( 

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

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

1015 ) 

1016 for region in self.certificate_regions 

1017 for method, path in AGGREGATOR_REGIONAL_API_ROUTES 

1018 ], 

1019 ) 

1020 ) 

1021 

1022 aggregator_log_group = logs.LogGroup( 

1023 self, 

1024 "AggregatorLambdaLogGroup", 

1025 retention=logs.RetentionDays.ONE_WEEK, 

1026 removal_policy=RemovalPolicy.DESTROY, 

1027 ) 

1028 

1029 aggregator_lambda = lambda_.Function( 

1030 self, 

1031 "CrossRegionAggregatorFunction", 

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

1033 handler="handler.lambda_handler", 

1034 code=lambda_source_code("lambda/cross-region-aggregator"), 

1035 timeout=Duration.seconds(29), 

1036 memory_size=512, 

1037 role=aggregator_role, 

1038 environment={ 

1039 "PROJECT_NAME": self.project_name, 

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

1041 "AWS_URL_SUFFIX": self.url_suffix, 

1042 }, 

1043 log_group=aggregator_log_group, 

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

1045 tracing=lambda_.Tracing.ACTIVE, 

1046 ) 

1047 

1048 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

1049 

1050 acknowledge_nag_findings( 

1051 aggregator_role, 

1052 [ 

1053 { 

1054 "id": "AwsSolutions-IAM5", 

1055 "reason": ( 

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

1057 "describes only deterministic project/region CloudFormation stack " 

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

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

1060 "unless operators explicitly enable direct regional access." 

1061 ), 

1062 "appliesTo": [ 

1063 "Resource::*", 

1064 *[ 

1065 ( 

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

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

1068 f"{region}/*" 

1069 ) 

1070 for region in self.certificate_regions 

1071 ], 

1072 *[ 

1073 ( 

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

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

1076 ) 

1077 for region in self.certificate_regions 

1078 for method, path in AGGREGATOR_REGIONAL_API_ROUTES 

1079 ], 

1080 ], 

1081 }, 

1082 ], 

1083 ) 

1084 

1085 return aggregator_lambda 

1086 

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

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

1089 

1090 # Create CloudWatch log group 

1091 api_log_group = logs.LogGroup( 

1092 self, 

1093 "ApiGatewayLogs", 

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

1095 retention=logs.RetentionDays.ONE_MONTH, 

1096 removal_policy=RemovalPolicy.DESTROY, 

1097 ) 

1098 

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

1100 logging_levels = { 

1101 "OFF": apigateway.MethodLoggingLevel.OFF, 

1102 "ERROR": apigateway.MethodLoggingLevel.ERROR, 

1103 "INFO": apigateway.MethodLoggingLevel.INFO, 

1104 } 

1105 if configured_log_level not in logging_levels: 

1106 raise ValueError( 

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

1108 f"got {configured_log_level!r}" 

1109 ) 

1110 

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

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

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

1114 endpoint_type = ( 

1115 apigateway.EndpointType.EDGE 

1116 if self.ga_dns is not None 

1117 else apigateway.EndpointType.REGIONAL 

1118 ) 

1119 api = apigateway.RestApi( 

1120 self, 

1121 "GCOGlobalApi", 

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

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

1124 endpoint_types=[endpoint_type], 

1125 deploy=True, 

1126 deploy_options=apigateway.StageOptions( 

1127 stage_name="prod", 

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

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

1130 logging_level=logging_levels[configured_log_level], 

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

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

1133 data_trace_enabled=False, 

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

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

1136 access_log_destination=apigateway.LogGroupLogDestination(api_log_group), 

1137 access_log_format=apigateway.AccessLogFormat.json_with_standard_fields( 

1138 caller=True, 

1139 http_method=True, 

1140 ip=True, 

1141 protocol=True, 

1142 request_time=True, 

1143 resource_path=True, 

1144 response_length=True, 

1145 status=True, 

1146 user=True, 

1147 ), 

1148 ), 

1149 cloud_watch_role=True, 

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

1151 cloud_watch_role_removal_policy=RemovalPolicy.DESTROY, 

1152 ) 

1153 

1154 # Add resource policy to restrict to account 

1155 api.add_to_resource_policy( 

1156 iam.PolicyStatement( 

1157 effect=iam.Effect.ALLOW, 

1158 principals=[iam.AnyPrincipal()], 

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

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

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

1162 ) 

1163 ) 

1164 

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

1166 # authorizer on the method handles authentication; the resource 

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

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

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

1170 api.add_to_resource_policy( 

1171 iam.PolicyStatement( 

1172 effect=iam.Effect.ALLOW, 

1173 principals=[iam.AnyPrincipal()], 

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

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

1176 ) 

1177 ) 

1178 

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

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

1181 # only when the global data path exists. 

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

1183 v1_resource = api_resource.add_resource("v1") 

1184 

1185 if self.proxy_lambda is not None: 

1186 lambda_integration = apigateway.LambdaIntegration( 

1187 self.proxy_lambda, 

1188 proxy=True, 

1189 timeout=Duration.seconds(29), 

1190 ) 

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

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

1193 proxy_resource.add_method( 

1194 method, 

1195 lambda_integration, 

1196 authorization_type=apigateway.AuthorizationType.IAM, 

1197 method_responses=[ 

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

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

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

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

1202 ], 

1203 ) 

1204 

1205 self._create_global_routes(api, v1_resource) 

1206 

1207 if self.inference_proxy_lambda is not None: 

1208 inference_integration = apigateway.LambdaIntegration( 

1209 self.inference_proxy_lambda, 

1210 proxy=True, 

1211 timeout=Duration.minutes(15), 

1212 response_transfer_mode=apigateway.ResponseTransferMode.STREAM, 

1213 ) 

1214 self._create_inference_routes(api, inference_integration) 

1215 

1216 return api 

1217 

1218 def _create_global_routes( 

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

1220 ) -> None: 

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

1222 

1223 Routes: 

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

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

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

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

1228 """ 

1229 # Create Lambda integration for aggregator 

1230 aggregator_integration = apigateway.LambdaIntegration( 

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

1232 ) 

1233 

1234 # Create /global resource 

1235 global_resource = v1_resource.add_resource("global") 

1236 

1237 # /global/jobs 

1238 global_jobs = global_resource.add_resource("jobs") 

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

1240 global_jobs.add_method( 

1241 method, 

1242 aggregator_integration, 

1243 authorization_type=apigateway.AuthorizationType.IAM, 

1244 method_responses=[ 

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

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

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

1248 ], 

1249 ) 

1250 

1251 # /global/health 

1252 global_health = global_resource.add_resource("health") 

1253 global_health.add_method( 

1254 "GET", 

1255 aggregator_integration, 

1256 authorization_type=apigateway.AuthorizationType.IAM, 

1257 method_responses=[ 

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

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

1260 ], 

1261 ) 

1262 

1263 # /global/status 

1264 global_status = global_resource.add_resource("status") 

1265 global_status.add_method( 

1266 "GET", 

1267 aggregator_integration, 

1268 authorization_type=apigateway.AuthorizationType.IAM, 

1269 method_responses=[ 

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

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

1272 ], 

1273 ) 

1274 

1275 def _create_inference_routes( 

1276 self, 

1277 api: apigateway.RestApi, 

1278 lambda_integration: apigateway.LambdaIntegration, 

1279 ) -> None: 

1280 """Create proxy route for inference endpoints. 

1281 

1282 Routes: 

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

1284 

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

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

1287 """ 

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

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

1290 

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

1292 inference_proxy.add_method( 

1293 method, 

1294 lambda_integration, 

1295 authorization_type=apigateway.AuthorizationType.IAM, 

1296 method_responses=[ 

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

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

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

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

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

1302 ], 

1303 ) 

1304 

1305 def _create_outputs(self) -> None: 

1306 """Export API Gateway endpoint.""" 

1307 

1308 CfnOutput( 

1309 self, 

1310 "ApiEndpoint", 

1311 value=self.api.url, 

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

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

1314 ) 

1315 

1316 CfnOutput( 

1317 self, 

1318 "SecretArn", 

1319 value=self.secret.secret_arn, 

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

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

1322 ) 

1323 

1324 CfnOutput( 

1325 self, 

1326 "BackendTlsServerName", 

1327 value=self.backend_tls_server_name, 

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

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

1330 ) 

1331 

1332 CfnOutput( 

1333 self, 

1334 "BackendTlsRootCaParameter", 

1335 value=self.backend_tls_root_ca_parameter_name, 

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

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

1338 ) 

1339 

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

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

1342 

1343 ``GCOAnalyticsStack`` is created *after* ``GCOApiGatewayGlobalStack`` 

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

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

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

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

1348 both stacks exist, without changing the constructor contract or 

1349 the existing cross-stack dependency wiring. 

1350 

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

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

1353 cannot accidentally double-wire the Cognito authorizer (which 

1354 would produce two authorizers with overlapping identity sources 

1355 on the same REST API). 

1356 

1357 Args: 

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

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

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

1361 is disabled. 

1362 

1363 Raises: 

1364 RuntimeError: if the stack already has an attached 

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

1366 a prior ``set_analytics_config`` call). 

1367 """ 

1368 if self.analytics_config is not None: 

1369 raise RuntimeError( 

1370 "GCOApiGatewayGlobalStack.set_analytics_config may only be called " 

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

1372 ) 

1373 self.analytics_config = config 

1374 self._wire_studio_routes() 

1375 

1376 def _wire_studio_routes(self) -> None: 

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

1378 

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

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

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

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

1383 ``self.analytics_config is not None``. 

1384 

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

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

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

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

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

1390 coexistence assertion in 

1391 ``tests/test_api_gateway_analytics_config.py``. 

1392 

1393 Resources added: 

1394 

1395 * ``CognitoUserPoolsAuthorizer`` named ``StudioCognitoAuthorizer`` 

1396 referencing ``UserPool.from_user_pool_arn(...)``. 

1397 * ``RequestValidator`` with ``validate_request_parameters=True`` 

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

1399 ``request_validator_options``. 

1400 * ``/studio`` + ``/studio/login`` + ``/studio/callback`` 

1401 resources. 

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

1403 ``LambdaIntegration(presigned_url_lambda, proxy=True, 

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

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

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

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

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

1409 ``authorizer_id``. 

1410 * ``CfnOutput`` ``StudioLoginUrl`` — concrete 

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

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

1413 id is a deploy-time token. 

1414 """ 

1415 assert self.analytics_config is not None, ( 

1416 "_wire_studio_routes called without an AnalyticsApiConfig attached." 

1417 ) 

1418 analytics_config = self.analytics_config 

1419 

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

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

1422 # new Cognito resources are created in this stack. 

1423 user_pool = cognito.UserPool.from_user_pool_arn( 

1424 self, 

1425 "StudioUserPoolRef", 

1426 analytics_config.user_pool_arn, 

1427 ) 

1428 authorizer = apigateway.CognitoUserPoolsAuthorizer( 

1429 self, 

1430 "StudioCognitoAuthorizer", 

1431 cognito_user_pools=[user_pool], 

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

1433 ) 

1434 # The authorizer attaches itself to the RestApi automatically 

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

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

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

1438 

1439 # Request validator — validates query/path parameters are 

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

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

1442 studio_request_validator = apigateway.RequestValidator( 

1443 self, 

1444 "StudioRequestValidator", 

1445 rest_api=self.api, 

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

1447 validate_request_parameters=True, 

1448 ) 

1449 

1450 # /studio → /studio/login + /studio/callback 

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

1452 login_resource = studio_resource.add_resource("login") 

1453 callback_resource = studio_resource.add_resource("callback") 

1454 

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

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

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

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

1459 login_integration = apigateway.LambdaIntegration( 

1460 analytics_config.presigned_url_lambda, 

1461 proxy=True, 

1462 timeout=Duration.seconds(29), 

1463 ) 

1464 login_resource.add_method( 

1465 "GET", 

1466 login_integration, 

1467 authorization_type=apigateway.AuthorizationType.COGNITO, 

1468 authorizer=authorizer, 

1469 request_validator=studio_request_validator, 

1470 method_responses=[ 

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

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

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

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

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

1476 ], 

1477 ) 

1478 

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

1480 # hosted UI OAuth redirect flow. Unauthenticated MOCK 

1481 # integration so the page is reachable without a signed 

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

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

1484 # body. 

1485 callback_integration = apigateway.MockIntegration( 

1486 integration_responses=[ 

1487 apigateway.IntegrationResponse( 

1488 status_code="200", 

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

1490 ), 

1491 ], 

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

1493 ) 

1494 callback_method = callback_resource.add_method( 

1495 "GET", 

1496 callback_integration, 

1497 authorization_type=apigateway.AuthorizationType.NONE, 

1498 method_responses=[ 

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

1500 ], 

1501 ) 

1502 

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

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

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

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

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

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

1509 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

1510 

1511 acknowledge_nag_findings( 

1512 callback_method, 

1513 [ 

1514 { 

1515 "id": "AwsSolutions-APIG4", 

1516 "reason": ( 

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

1518 "redirect landing page. The browser redirect from " 

1519 "Cognito carries the authorization code as a " 

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

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

1522 "authorization here would break the OAuth flow. " 

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

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

1525 "resources." 

1526 ), 

1527 }, 

1528 ], 

1529 ) 

1530 

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

1532 CfnOutput( 

1533 self, 

1534 "CognitoAuthorizerId", 

1535 value=authorizer.authorizer_id, 

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

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

1538 ) 

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

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

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

1542 # pastable login URL in the stack outputs. 

1543 studio_login_url = Fn.sub( 

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

1545 { 

1546 "ApiId": self.api.rest_api_id, 

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

1548 }, 

1549 ) 

1550 CfnOutput( 

1551 self, 

1552 "StudioLoginUrl", 

1553 value=studio_login_url, 

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

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

1556 ) 

1557 

1558 def _create_waf(self) -> None: 

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

1560 

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

1562 for protection against: 

1563 - Common web exploits (OWASP Top 10) 

1564 - Known bad inputs 

1565 - SQL injection 

1566 - Linux-specific attacks 

1567 - IP reputation threats 

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

1569 

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

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

1572 """ 

1573 # Create CloudWatch Log Group for WAF logs 

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

1575 waf_log_group = logs.LogGroup( 

1576 self, 

1577 "WafLogGroup", 

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

1579 retention=logs.RetentionDays.ONE_MONTH, 

1580 removal_policy=RemovalPolicy.DESTROY, 

1581 ) 

1582 

1583 # Create WAF WebACL with AWS Managed Rules 

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

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

1586 # 

1587 # Rule priority ordering: 

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

1589 # before expensive managed rule groups run) 

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

1591 # 2-7 -> AWS Managed Rule Groups 

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

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

1594 

1595 self.web_acl = wafv2.CfnWebACL( 

1596 self, 

1597 "GCOWebAcl", 

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

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

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

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

1602 visibility_config=wafv2.CfnWebACL.VisibilityConfigProperty( 

1603 cloud_watch_metrics_enabled=True, 

1604 metric_name="GCOApiGatewayWaf", 

1605 sampled_requests_enabled=True, 

1606 ), 

1607 rules=[ 

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

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

1610 # IPs are blocked immediately without consuming WCUs on the 

1611 # heavier managed rule groups. Aggregates requests per source 

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

1613 # for rate-based statements). 

1614 # 

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

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

1617 wafv2.CfnWebACL.RuleProperty( 

1618 name="PerIPRateLimit", 

1619 priority=0, 

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

1621 statement=wafv2.CfnWebACL.StatementProperty( 

1622 rate_based_statement=wafv2.CfnWebACL.RateBasedStatementProperty( 

1623 limit=per_ip_rate_limit, 

1624 aggregate_key_type="IP", 

1625 ) 

1626 ), 

1627 visibility_config=wafv2.CfnWebACL.VisibilityConfigProperty( 

1628 cloud_watch_metrics_enabled=True, 

1629 metric_name="PerIPRateLimit", 

1630 sampled_requests_enabled=True, 

1631 ), 

1632 ), 

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

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

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

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

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

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

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

1640 wafv2.CfnWebACL.RuleProperty( 

1641 name="NonInferenceBodySizeLimit", 

1642 priority=1, 

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

1644 statement=wafv2.CfnWebACL.StatementProperty( 

1645 and_statement=wafv2.CfnWebACL.AndStatementProperty( 

1646 statements=[ 

1647 wafv2.CfnWebACL.StatementProperty( 

1648 size_constraint_statement=wafv2.CfnWebACL.SizeConstraintStatementProperty( 

1649 comparison_operator="GT", 

1650 field_to_match=wafv2.CfnWebACL.FieldToMatchProperty( 

1651 body=wafv2.CfnWebACL.BodyProperty( 

1652 oversize_handling="MATCH" 

1653 ) 

1654 ), 

1655 size=8_192, 

1656 text_transformations=[ 

1657 wafv2.CfnWebACL.TextTransformationProperty( 

1658 priority=0, 

1659 type="NONE", 

1660 ) 

1661 ], 

1662 ) 

1663 ), 

1664 wafv2.CfnWebACL.StatementProperty( 

1665 not_statement=wafv2.CfnWebACL.NotStatementProperty( 

1666 statement=wafv2.CfnWebACL.StatementProperty( 

1667 byte_match_statement=wafv2.CfnWebACL.ByteMatchStatementProperty( 

1668 field_to_match=wafv2.CfnWebACL.FieldToMatchProperty( 

1669 uri_path={} 

1670 ), 

1671 positional_constraint="STARTS_WITH", 

1672 search_string="/prod/inference/", 

1673 text_transformations=[ 

1674 wafv2.CfnWebACL.TextTransformationProperty( 

1675 priority=0, 

1676 type="NONE", 

1677 ) 

1678 ], 

1679 ) 

1680 ) 

1681 ) 

1682 ), 

1683 ] 

1684 ) 

1685 ), 

1686 visibility_config=wafv2.CfnWebACL.VisibilityConfigProperty( 

1687 cloud_watch_metrics_enabled=True, 

1688 metric_name="NonInferenceBodySizeLimit", 

1689 sampled_requests_enabled=True, 

1690 ), 

1691 ), 

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

1693 # Override only SizeRestrictions_BODY: every other CRS rule 

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

1695 wafv2.CfnWebACL.RuleProperty( 

1696 name="AWSManagedRulesCommonRuleSet", 

1697 priority=2, 

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

1699 statement=wafv2.CfnWebACL.StatementProperty( 

1700 managed_rule_group_statement=wafv2.CfnWebACL.ManagedRuleGroupStatementProperty( 

1701 vendor_name="AWS", 

1702 name="AWSManagedRulesCommonRuleSet", 

1703 rule_action_overrides=[ 

1704 wafv2.CfnWebACL.RuleActionOverrideProperty( 

1705 name="SizeRestrictions_BODY", 

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

1707 ) 

1708 ], 

1709 ) 

1710 ), 

1711 visibility_config=wafv2.CfnWebACL.VisibilityConfigProperty( 

1712 cloud_watch_metrics_enabled=True, 

1713 metric_name="AWSManagedRulesCommonRuleSet", 

1714 sampled_requests_enabled=True, 

1715 ), 

1716 ), 

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

1718 wafv2.CfnWebACL.RuleProperty( 

1719 name="AWSManagedRulesKnownBadInputsRuleSet", 

1720 priority=3, 

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

1722 statement=wafv2.CfnWebACL.StatementProperty( 

1723 managed_rule_group_statement=wafv2.CfnWebACL.ManagedRuleGroupStatementProperty( 

1724 vendor_name="AWS", 

1725 name="AWSManagedRulesKnownBadInputsRuleSet", 

1726 ) 

1727 ), 

1728 visibility_config=wafv2.CfnWebACL.VisibilityConfigProperty( 

1729 cloud_watch_metrics_enabled=True, 

1730 metric_name="AWSManagedRulesKnownBadInputsRuleSet", 

1731 sampled_requests_enabled=True, 

1732 ), 

1733 ), 

1734 # Rule 4: AWS Managed Rules - SQL Injection 

1735 wafv2.CfnWebACL.RuleProperty( 

1736 name="AWSManagedRulesSQLiRuleSet", 

1737 priority=4, 

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

1739 statement=wafv2.CfnWebACL.StatementProperty( 

1740 managed_rule_group_statement=wafv2.CfnWebACL.ManagedRuleGroupStatementProperty( 

1741 vendor_name="AWS", 

1742 name="AWSManagedRulesSQLiRuleSet", 

1743 ) 

1744 ), 

1745 visibility_config=wafv2.CfnWebACL.VisibilityConfigProperty( 

1746 cloud_watch_metrics_enabled=True, 

1747 metric_name="AWSManagedRulesSQLiRuleSet", 

1748 sampled_requests_enabled=True, 

1749 ), 

1750 ), 

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

1752 wafv2.CfnWebACL.RuleProperty( 

1753 name="AWSManagedRulesLinuxRuleSet", 

1754 priority=5, 

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

1756 statement=wafv2.CfnWebACL.StatementProperty( 

1757 managed_rule_group_statement=wafv2.CfnWebACL.ManagedRuleGroupStatementProperty( 

1758 vendor_name="AWS", 

1759 name="AWSManagedRulesLinuxRuleSet", 

1760 ) 

1761 ), 

1762 visibility_config=wafv2.CfnWebACL.VisibilityConfigProperty( 

1763 cloud_watch_metrics_enabled=True, 

1764 metric_name="AWSManagedRulesLinuxRuleSet", 

1765 sampled_requests_enabled=True, 

1766 ), 

1767 ), 

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

1769 wafv2.CfnWebACL.RuleProperty( 

1770 name="AWSManagedRulesAmazonIpReputationList", 

1771 priority=6, 

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

1773 statement=wafv2.CfnWebACL.StatementProperty( 

1774 managed_rule_group_statement=wafv2.CfnWebACL.ManagedRuleGroupStatementProperty( 

1775 vendor_name="AWS", 

1776 name="AWSManagedRulesAmazonIpReputationList", 

1777 ) 

1778 ), 

1779 visibility_config=wafv2.CfnWebACL.VisibilityConfigProperty( 

1780 cloud_watch_metrics_enabled=True, 

1781 metric_name="AWSManagedRulesAmazonIpReputationList", 

1782 sampled_requests_enabled=True, 

1783 ), 

1784 ), 

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

1786 wafv2.CfnWebACL.RuleProperty( 

1787 name="AWSManagedRulesAnonymousIpList", 

1788 priority=7, 

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

1790 statement=wafv2.CfnWebACL.StatementProperty( 

1791 managed_rule_group_statement=wafv2.CfnWebACL.ManagedRuleGroupStatementProperty( 

1792 vendor_name="AWS", 

1793 name="AWSManagedRulesAnonymousIpList", 

1794 ) 

1795 ), 

1796 visibility_config=wafv2.CfnWebACL.VisibilityConfigProperty( 

1797 cloud_watch_metrics_enabled=True, 

1798 metric_name="AWSManagedRulesAnonymousIpList", 

1799 sampled_requests_enabled=True, 

1800 ), 

1801 ), 

1802 ], 

1803 ) 

1804 

1805 # Enable WAF logging to CloudWatch Logs 

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

1807 wafv2.CfnLoggingConfiguration( 

1808 self, 

1809 "WafLoggingConfig", 

1810 resource_arn=self.web_acl.attr_arn, 

1811 log_destination_configs=[waf_log_group.log_group_arn], 

1812 ) 

1813 

1814 # Associate WAF WebACL with API Gateway stage 

1815 # For API Gateway, use the stage ARN format 

1816 wafv2.CfnWebACLAssociation( 

1817 self, 

1818 "GCOWebAclAssociation", 

1819 resource_arn=self.api.deployment_stage.stage_arn, 

1820 web_acl_arn=self.web_acl.attr_arn, 

1821 ) 

1822 

1823 # Output WAF WebACL ARN 

1824 CfnOutput( 

1825 self, 

1826 "WebAclArn", 

1827 value=self.web_acl.attr_arn, 

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

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

1830 )