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

165 statements  

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

1"""Analytics stack for GCO - optional ML/analytics environment. 

2 

3Instantiated only when ``analytics_environment.enabled=true`` in ``cdk.json``. 

4When the toggle is ``false`` (the default), ``app.py`` skips creating it so 

5``cdk synth`` emits no SageMaker, EMR Serverless, or Cognito resources. 

6 

7Resources (wired in this order): 

8 

91. ``_create_kms_key`` — ``Analytics_KMS_Key`` 

102. ``_create_vpc_and_endpoints`` — private VPC + endpoints 

113. ``_create_access_logs_bucket`` — S3 access-logs bucket 

124. ``_create_studio_only_bucket`` — ``Studio_Only_Bucket`` 

135. ``_create_studio_efs`` — ``Studio_EFS`` 

146. ``_create_execution_role_and_grants`` — ``SageMaker_Execution_Role`` 

157. ``_grant_sagemaker_role_on_cluster_shared_bucket`` — cross-region IAM grant 

168. ``_create_studio_domain`` — ``sagemaker.CfnDomain`` 

179. ``_create_emr_app`` — ``emrserverless.CfnApplication`` 

1810. ``_create_cognito_pool`` — Cognito pool + client + domain 

1911. ``_create_presigned_url_lambda`` — ``Presigned_URL_Lambda`` 

2012. ``_apply_nag_suppressions`` — analytics-branch nag dispatch 

21 

22The API Gateway ``/studio/*`` wiring that consumes this Lambda lives in 

23``gco/stacks/api_gateway_global_stack.py``. 

24""" 

25 

26from __future__ import annotations 

27 

28from typing import Any 

29 

30from aws_cdk import ( 

31 CfnOutput, 

32 Duration, 

33 RemovalPolicy, 

34 Stack, 

35) 

36from aws_cdk import aws_cognito as cognito 

37from aws_cdk import aws_ec2 as ec2 

38from aws_cdk import aws_efs as efs 

39from aws_cdk import aws_emrserverless as emrserverless 

40from aws_cdk import aws_iam as iam 

41from aws_cdk import aws_kms as kms 

42from aws_cdk import aws_lambda as lambda_ 

43from aws_cdk import aws_logs as logs 

44from aws_cdk import aws_s3 as s3 

45from aws_cdk import aws_sagemaker as sagemaker 

46from aws_cdk import custom_resources as cr 

47from constructs import Construct 

48 

49from gco.config.config_loader import ConfigLoader 

50from gco.stacks.constants import ( 

51 EMR_SERVERLESS_RELEASE_LABEL, 

52 LAMBDA_PYTHON_RUNTIME, 

53 SAGEMAKER_ROLE_NAME_PREFIX, 

54 cluster_shared_ssm_parameter_prefix, 

55 cognito_domain_prefix_default, 

56) 

57from gco.stacks.lambda_assets import lambda_source_code 

58from gco.stacks.nag_suppressions import apply_all_suppressions 

59 

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

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

62# Generated from Git commit: 3458f76ef1541be718c00458b8ecc8f7c770c6ad 

63# Flowchart(s) generated from this file: 

64# * ``GCOAnalyticsStack.__init__`` -> ``diagrams/code_diagrams/gco/stacks/analytics_stack.GCOAnalyticsStack___init__.html`` 

65# (PNG: ``diagrams/code_diagrams/gco/stacks/analytics_stack.GCOAnalyticsStack___init__.png``) 

66# * ``GCOAnalyticsStack._create_execution_role_and_grants`` -> ``diagrams/code_diagrams/gco/stacks/analytics_stack.GCOAnalyticsStack__create_execution_role_and_grants.html`` 

67# (PNG: ``diagrams/code_diagrams/gco/stacks/analytics_stack.GCOAnalyticsStack__create_execution_role_and_grants.png``) 

68# * ``GCOAnalyticsStack._create_studio_domain`` -> ``diagrams/code_diagrams/gco/stacks/analytics_stack.GCOAnalyticsStack__create_studio_domain.html`` 

69# (PNG: ``diagrams/code_diagrams/gco/stacks/analytics_stack.GCOAnalyticsStack__create_studio_domain.png``) 

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

71# <pyflowchart-code-diagram> END 

72 

73 

74def _parse_removal(value: str) -> RemovalPolicy: 

75 """Map a cdk.json removal-policy string to ``aws_cdk.RemovalPolicy``. 

76 

77 Translates ``analytics_environment.{efs,cognito}.removal_policy`` into 

78 the matching enum member. Accepts ``"retain"`` / ``"destroy"`` 

79 (case-insensitive); raises ``ValueError`` on anything else. 

80 """ 

81 normalized = value.strip().lower() 

82 if normalized == "retain": 

83 return RemovalPolicy.RETAIN 

84 if normalized == "destroy": 

85 return RemovalPolicy.DESTROY 

86 raise ValueError( 

87 f"analytics_environment removal_policy must be 'retain' or 'destroy', got {value!r}" 

88 ) 

89 

90 

91class GCOAnalyticsStack(Stack): 

92 """Optional ML/analytics environment: SageMaker Studio, EMR Serverless, Cognito. 

93 

94 Only instantiated when ``analytics_environment.enabled=true``. Lives in 

95 the API gateway region so the presigned-URL Lambda can wire into the 

96 existing ``/studio/*`` routes on ``GCOApiGatewayGlobalStack`` without 

97 a cross-region hop. 

98 """ 

99 

100 def __init__( 

101 self, 

102 scope: Construct, 

103 construct_id: str, 

104 *, 

105 config: ConfigLoader, 

106 api_gateway_secret_arn: str | None = None, 

107 **kwargs: Any, 

108 ) -> None: 

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

110 

111 self.config = config 

112 self.project_name = config.get_project_name() 

113 # ``api_gateway_secret_arn`` is reserved for future auth wiring; 

114 # accepted now so the constructor signature is stable. 

115 self.api_gateway_secret_arn = api_gateway_secret_arn 

116 

117 cfg = config.get_analytics_config() 

118 self.hyperpod_enabled: bool = bool(cfg["hyperpod"]["enabled"]) 

119 self.canvas_enabled: bool = bool(cfg["canvas"]["enabled"]) 

120 self.efs_removal: RemovalPolicy = _parse_removal(cfg["efs"]["removal_policy"]) 

121 self.cognito_removal: RemovalPolicy = _parse_removal(cfg["cognito"]["removal_policy"]) 

122 self._cognito_domain_prefix_override: str | None = cfg["cognito"].get("domain_prefix") 

123 

124 # Wiring order is load-bearing — each helper consumes resources from 

125 # earlier helpers (EFS ARN → execution role → studio domain, etc.). 

126 self._create_kms_key() 

127 self._create_vpc_and_endpoints() 

128 self._create_access_logs_bucket() 

129 self._create_studio_only_bucket() 

130 self._create_studio_efs() 

131 self._create_execution_role_and_grants() 

132 self._grant_sagemaker_role_on_cluster_shared_bucket() 

133 self._create_studio_domain() 

134 self._create_emr_app() 

135 self._create_cognito_pool() 

136 self._create_presigned_url_lambda() 

137 self._apply_nag_suppressions() 

138 

139 # ================================================================== 

140 # KMS + VPC 

141 # ================================================================== 

142 

143 def _create_kms_key(self) -> None: 

144 """Create ``Analytics_KMS_Key`` with rotation + 7-day pending window. 

145 

146 Customer-managed so every analytics-owned bucket, the Studio EFS, 

147 and SageMaker-written artifacts share a single encryption boundary. 

148 ``removal_policy=DESTROY`` follows the iteration-loop posture 

149 — the 7-day pending window gives recovery headroom without retaining 

150 the key past a ``cdk destroy gco-analytics`` cycle. 

151 """ 

152 self.kms_key = kms.Key( 

153 self, 

154 "AnalyticsKmsKey", 

155 description="Analytics_KMS_Key - encrypts analytics S3 buckets, Studio EFS, SageMaker artifacts", 

156 enable_key_rotation=True, 

157 pending_window=Duration.days(7), 

158 removal_policy=RemovalPolicy.DESTROY, 

159 ) 

160 

161 # Grant encrypt/decrypt to service principals that need to operate 

162 # on analytics-owned resources encrypted by this key. 

163 service_principals = [ 

164 ("logs.amazonaws.com", self.region), 

165 ("sagemaker.amazonaws.com", self.region), 

166 ("s3.amazonaws.com", self.region), 

167 ("elasticfilesystem.amazonaws.com", self.region), 

168 ] 

169 for principal, region in service_principals: 

170 self.kms_key.add_to_resource_policy( 

171 iam.PolicyStatement( 

172 sid=f"Allow{principal.split('.')[0].capitalize()}Encrypt", 

173 effect=iam.Effect.ALLOW, 

174 principals=[iam.ServicePrincipal(principal, region=region)], 

175 actions=[ 

176 "kms:Encrypt", 

177 "kms:Decrypt", 

178 "kms:ReEncrypt*", 

179 "kms:GenerateDataKey*", 

180 "kms:DescribeKey", 

181 ], 

182 resources=["*"], # key-policy scope — always the key itself 

183 ) 

184 ) 

185 

186 def _create_vpc_and_endpoints(self) -> None: 

187 """Create a private VPC plus every VPC endpoint Studio needs. 

188 

189 Notebooks never land on public subnets (the VPC has none). 

190 The nine interface endpoints plus the S3 gateway endpoint 

191 keep all Studio/EMR/EFS traffic on the private network. A NAT 

192 gateway provides internet egress so notebooks can pip install, 

193 git clone, and access external APIs (HuggingFace, PyPI, etc.). 

194 """ 

195 self.vpc = ec2.Vpc( 

196 self, 

197 "AnalyticsVpc", 

198 max_azs=2, 

199 nat_gateways=1, 

200 subnet_configuration=[ 

201 ec2.SubnetConfiguration( 

202 name="AnalyticsPrivate", 

203 subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS, 

204 cidr_mask=24, 

205 ), 

206 ec2.SubnetConfiguration( 

207 name="AnalyticsPublic", 

208 subnet_type=ec2.SubnetType.PUBLIC, 

209 cidr_mask=28, 

210 ), 

211 ], 

212 ) 

213 

214 # Gateway endpoint for S3 — route tables are wired up automatically. 

215 self.vpc.add_gateway_endpoint( 

216 "S3GatewayEndpoint", 

217 service=ec2.GatewayVpcEndpointAwsService.S3, 

218 ) 

219 

220 # Interface endpoints — one per AWS service required by Studio. Each 

221 # lands in the VPC's private subnets using the default 

222 # VPC-endpoint security group. 

223 interface_services: dict[str, ec2.InterfaceVpcEndpointAwsService] = { 

224 "SagemakerApiEndpoint": ec2.InterfaceVpcEndpointAwsService.SAGEMAKER_API, 

225 "SagemakerRuntimeEndpoint": ec2.InterfaceVpcEndpointAwsService.SAGEMAKER_RUNTIME, 

226 "SagemakerStudioEndpoint": ec2.InterfaceVpcEndpointAwsService.SAGEMAKER_STUDIO, 

227 "SagemakerNotebookEndpoint": ec2.InterfaceVpcEndpointAwsService.SAGEMAKER_NOTEBOOK, 

228 "StsEndpoint": ec2.InterfaceVpcEndpointAwsService.STS, 

229 "CloudWatchLogsEndpoint": ec2.InterfaceVpcEndpointAwsService.CLOUDWATCH_LOGS, 

230 "EcrEndpoint": ec2.InterfaceVpcEndpointAwsService.ECR, 

231 "EcrDockerEndpoint": ec2.InterfaceVpcEndpointAwsService.ECR_DOCKER, 

232 "EfsEndpoint": ec2.InterfaceVpcEndpointAwsService.ELASTIC_FILESYSTEM, 

233 } 

234 for construct_id, service in interface_services.items(): 

235 self.vpc.add_interface_endpoint( 

236 construct_id, 

237 service=service, 

238 subnets=ec2.SubnetSelection(subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS), 

239 ) 

240 

241 # Each interface endpoint's default security group allows 443 from the 

242 # VPC CIDR (an ``Fn::GetAtt`` token cdk-nag can't resolve), so the 

243 # SG-ingress rules throw. Scope the acknowledgment to the VPC construct 

244 # so it covers every endpoint SG under it without touching the stack. 

245 from gco.stacks.nag_suppressions import acknowledge_security_group_cidr_findings 

246 

247 acknowledge_security_group_cidr_findings( 

248 self.vpc, 

249 reason=( 

250 "The Studio VPC interface endpoints use their default security " 

251 "group, which allows HTTPS (443) ingress from the VPC CIDR " 

252 "only, referenced via an ``Fn::GetAtt`` token that cdk-nag " 

253 "cannot resolve at synth time. Ingress is restricted to " 

254 "intra-VPC traffic — the tightest source for private " 

255 "endpoint access." 

256 ), 

257 ) 

258 

259 # ================================================================== 

260 # S3 buckets 

261 # ================================================================== 

262 

263 def _create_access_logs_bucket(self) -> None: 

264 """Create the dedicated access-logs bucket for ``Studio_Only_Bucket``. 

265 

266 Server-side encryption uses S3-managed keys (SSE-S3) because S3 

267 server-access-log delivery does not support KMS-encrypted destinations 

268 without additional log-delivery role plumbing — the standard pattern 

269 is SSE-S3 for the log sink plus KMS for the bucket it logs. The 

270 resulting ``AwsSolutions-S1`` nag finding for the log sink targeting 

271 itself is scoped on the bucket construct by 

272 ``add_storage_suppressions`` via the analytics nag branch. 

273 """ 

274 self.access_logs_bucket = s3.Bucket( 

275 self, 

276 "AnalyticsAccessLogsBucket", 

277 encryption=s3.BucketEncryption.S3_MANAGED, 

278 block_public_access=s3.BlockPublicAccess.BLOCK_ALL, 

279 enforce_ssl=True, 

280 versioned=True, 

281 removal_policy=RemovalPolicy.DESTROY, 

282 auto_delete_objects=True, 

283 lifecycle_rules=[ 

284 s3.LifecycleRule( 

285 id="ExpireAccessLogs", 

286 enabled=True, 

287 expiration=Duration.days(90), 

288 ) 

289 ], 

290 ) 

291 

292 def _create_studio_only_bucket(self) -> None: 

293 """Create ``Studio_Only_Bucket`` for notebook-private scratch + outputs. 

294 

295 The physical name is CloudFormation-generated 

296 (``<stack>-studioonlybucket…``): S3 bucket names are a global namespace 

297 and a deleted name is not reliably reusable, so a fixed 

298 project/account/region name would make every destroy-and-redeploy a 

299 collision hazard. Nothing needs to reconstruct the name — the only 

300 grant is the ``SageMaker_Execution_Role`` grant on the construct's own 

301 ARN, and the CLI resolves the bucket from this stack's resources. The 

302 job-pod isolation property (``tests/test_analytics_bucket_isolation_ 

303 property.py``) classifies grants by construct token, so it does not 

304 depend on a name prefix either. KMS-encrypted with ``self.kms_key``; 

305 every access path goes through the ``SageMaker_Execution_Role`` grant 

306 — no other principal is granted access. 

307 """ 

308 self.studio_only_bucket = s3.Bucket( 

309 self, 

310 "StudioOnlyBucket", 

311 encryption=s3.BucketEncryption.KMS, 

312 encryption_key=self.kms_key, 

313 bucket_key_enabled=True, 

314 block_public_access=s3.BlockPublicAccess.BLOCK_ALL, 

315 enforce_ssl=True, 

316 versioned=True, 

317 removal_policy=RemovalPolicy.DESTROY, 

318 auto_delete_objects=True, 

319 server_access_logs_bucket=self.access_logs_bucket, 

320 server_access_logs_prefix="studio-only/", 

321 ) 

322 

323 # Belt-and-suspenders Deny for insecure transport, duplicating the 

324 # ``enforce_ssl=True`` semantics with a verifiable SID in the 

325 # synthesized template (mirrors the ``DenyInsecureTransport`` pattern 

326 # used by ``Cluster_Shared_Bucket`` in ``GCOGlobalStack``). 

327 self.studio_only_bucket.add_to_resource_policy( 

328 iam.PolicyStatement( 

329 sid="DenyInsecureTransport", 

330 effect=iam.Effect.DENY, 

331 principals=[iam.AnyPrincipal()], 

332 actions=["s3:*"], 

333 resources=[ 

334 self.studio_only_bucket.bucket_arn, 

335 f"{self.studio_only_bucket.bucket_arn}/*", 

336 ], 

337 conditions={"Bool": {"aws:SecureTransport": "false"}}, 

338 ) 

339 ) 

340 

341 # ================================================================== 

342 # Studio EFS 

343 # ================================================================== 

344 

345 def _create_studio_efs(self) -> None: 

346 """Create ``Studio_EFS`` with KMS encryption + TLS in transit. 

347 

348 Per-user access points are created lazily by the presigned-URL 

349 Lambda on first profile creation. No access points are defined 

350 here, so the file system's ``/`` root is effectively inaccessible 

351 until the Lambda materializes a per-user AP. 

352 

353 The dedicated security group only allows the VPC's private 

354 CIDR on TCP/2049 (NFS). SageMaker Studio mount traffic originates 

355 from the Studio compute subnet, which shares the VPC with this EFS. 

356 """ 

357 self.studio_efs_security_group = ec2.SecurityGroup( 

358 self, 

359 "StudioEfsSecurityGroup", 

360 vpc=self.vpc, 

361 description="SG for Studio_EFS - allows NFS from the analytics VPC only", 

362 allow_all_outbound=False, 

363 ) 

364 self.studio_efs_security_group.add_ingress_rule( 

365 peer=ec2.Peer.ipv4(self.vpc.vpc_cidr_block), 

366 connection=ec2.Port.tcp(2049), 

367 description="NFS from analytics VPC private subnets", 

368 ) 

369 

370 # The EFS SG ingress allows NFS (2049) from the VPC CIDR (an 

371 # ``Fn::GetAtt`` token cdk-nag can't resolve), so the SG-ingress rules 

372 # throw. Scope the acknowledgment to the EFS SG construct itself. 

373 from gco.stacks.nag_suppressions import acknowledge_security_group_cidr_findings 

374 

375 acknowledge_security_group_cidr_findings( 

376 self.studio_efs_security_group, 

377 reason=( 

378 "The Studio_EFS security group allows NFS (2049) ingress from " 

379 "the VPC CIDR only, referenced via an ``Fn::GetAtt`` token " 

380 "that cdk-nag cannot resolve at synth time. Ingress is " 

381 "restricted to intra-VPC traffic from the Studio compute " 

382 "subnet that mounts the file system." 

383 ), 

384 ) 

385 

386 self.studio_efs = efs.FileSystem( 

387 self, 

388 "StudioEfs", 

389 vpc=self.vpc, 

390 vpc_subnets=ec2.SubnetSelection(subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS), 

391 encrypted=True, 

392 kms_key=self.kms_key, 

393 enable_automatic_backups=True, 

394 removal_policy=self.efs_removal, 

395 security_group=self.studio_efs_security_group, 

396 ) 

397 

398 # ================================================================== 

399 # SageMaker execution role + grants 

400 # ================================================================== 

401 

402 def _create_execution_role_and_grants(self) -> None: 

403 """Create ``SageMaker_Execution_Role`` and attach its (non-cluster-shared) grants. 

404 

405 Role name begins with ``AmazonSageMaker`` — SageMaker 

406 requires this prefix for any role used by a Studio domain. Grants 

407 attached here: 

408 

409 * RW on ``Studio_Only_Bucket`` + KMS on ``Analytics_KMS_Key`` 

410 * Read-only ``execute-api:Invoke`` on GCO API Gateway ``/api/v1/*`` GET routes 

411 * ``sqs:SendMessage`` on regional job queues (wildcard ARN pattern) 

412 * ``ssm:GetParameter`` on the ``Cluster_Shared_Bucket`` metadata 

413 parameters in the global region — lets notebooks look up the 

414 bucket name/arn/region at runtime without a per-user export step 

415 * EFS mount actions on ``Studio_EFS`` (specific AP arn is added by 

416 the presigned-URL Lambda at runtime; the role-level grant here is 

417 scoped to the EFS ARN) 

418 * HyperPod training-job actions when ``hyperpod.enabled=true`` 

419 * AWS-managed ``AmazonSageMakerCanvasFullAccess`` when 

420 ``canvas.enabled=true`` (opt-in no-code ML app) 

421 * AWS-managed ``AmazonSageMakerFullAccess`` — always attached 

422 whenever analytics is enabled. Covers the full SageMaker 

423 control-plane surface including MLflow Apps 

424 (``CreateMlflowApp``/``ListMlflowApps``/``DescribeMlflowApp``), 

425 MLflow Tracking Servers, Model Registry, Studio space/app 

426 lifecycle, and adjacent services (S3, ECR, CloudWatch Logs, 

427 etc.) that SageMaker needs to launch training jobs, create 

428 apps, and render the Studio IDE. We pair the managed policy 

429 with an inline ``sagemaker-mlflow:*`` statement (next block) 

430 because the managed policy does not cover the 

431 ``sagemaker-mlflow`` data-plane namespace the MLflow SDK 

432 talks to. MLflow does not have its own sub-toggle — the 

433 managed policy replaces our previous enumerated 

434 ``sagemaker:*MlflowTrackingServer*`` inline grant. 

435 

436 The ``Cluster_Shared_Bucket`` grant lives in its own helper 

437 (:meth:`_grant_sagemaker_role_on_cluster_shared_bucket`) because the 

438 bucket ARN is resolved via a cross-region SSM read. 

439 """ 

440 self.sagemaker_execution_role = iam.Role( 

441 self, 

442 "SagemakerExecutionRole", 

443 role_name=f"{SAGEMAKER_ROLE_NAME_PREFIX}-{self.project_name}-analytics-exec-{self.region}", 

444 assumed_by=iam.ServicePrincipal("sagemaker.amazonaws.com"), 

445 description=( 

446 "SageMaker_Execution_Role - assumed by notebooks in the Studio " 

447 "domain. Grants RW on Studio_Only_Bucket and (via a separate " 

448 "cross-region policy) Cluster_Shared_Bucket, plus read-only GCO " 

449 "API access, SQS job submission, and cross-region ssm:GetParameter " 

450 "on the Cluster_Shared_Bucket metadata parameters." 

451 ), 

452 ) 

453 

454 # Bucket + KMS grants — studio-only scratch space. Analytics_KMS_Key 

455 # already has encrypt/decrypt in its key policy for the sagemaker 

456 # service principal, but role-level grants are still required for 

457 # IAM-side authorization per the double-auth model. 

458 self.studio_only_bucket.grant_read_write(self.sagemaker_execution_role) 

459 self.kms_key.grant_encrypt_decrypt(self.sagemaker_execution_role) 

460 

461 # SageMaker needs CreateGrant on the KMS key to delegate encryption 

462 # to EBS when creating space volumes. The grant is scoped to the 

463 # key and conditioned on the grantee being an AWS service. 

464 self.kms_key.grant( 

465 self.sagemaker_execution_role, 

466 "kms:CreateGrant", 

467 "kms:DescribeKey", 

468 ) 

469 

470 # GCO API scope — notebooks need both read-only GET operations 

471 # (list jobs, describe endpoints, fetch health) and job/inference 

472 # submission actions (POST manifests, PUT template updates, DELETE 

473 # jobs). Grant the full ``/api/v1/*`` method surface instead of 

474 # GET-only so users can submit new jobs, manage templates, and 

475 # tear things down from inside a notebook without bouncing 

476 # through a service account. 

477 # 

478 # The exact API id is not known here (it lives in the api-gateway 

479 # stack and is discovered through SSM or CfnOutput at synth time 

480 # — see the api_gateway_global_stack wiring). Scope to the 

481 # api-gateway region with any REST API id for now; tighter scope 

482 # is applied once ``AnalyticsApiConfig`` is wired in. 

483 api_gw_region = self.config.get_api_gateway_region() 

484 self.sagemaker_execution_role.add_to_policy( 

485 iam.PolicyStatement( 

486 effect=iam.Effect.ALLOW, 

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

488 resources=[ 

489 # ``*/prod/*/api/v1/*`` — any API id, any HTTP method 

490 # (GET/POST/PUT/DELETE/PATCH), any path below 

491 # /api/v1/. /studio/* is explicitly excluded; Canvas 

492 # users go through their own Cognito-authorized 

493 # ``/studio/login`` route. 

494 f"arn:{self.partition}:execute-api:{api_gw_region}:{self.account}:" 

495 "*/prod/*/api/v1/*", 

496 # ``/inference/*`` proxies through to regional ALBs 

497 # for in-cluster model endpoints — notebooks need 

498 # the full method surface here too. 

499 f"arn:{self.partition}:execute-api:{api_gw_region}:{self.account}:" 

500 "*/prod/*/inference/*", 

501 ], 

502 ) 

503 ) 

504 

505 # SQS job submission — scoped to the regional queue name pattern 

506 # ``<project>-jobs-<region>`` written by 

507 # ``GCORegionalStack._create_sqs_queue``. The exact region isn't 

508 # known at synth time (queues live in regional stacks), so we use 

509 # ``*`` in the region component with the project name fixed. 

510 project_name = self.config.get_project_name() 

511 self.sagemaker_execution_role.add_to_policy( 

512 iam.PolicyStatement( 

513 effect=iam.Effect.ALLOW, 

514 actions=["sqs:SendMessage"], 

515 resources=[ 

516 f"arn:{self.partition}:sqs:*:{self.account}:{project_name}-jobs-*", 

517 ], 

518 ) 

519 ) 

520 

521 # ssm:GetParameter on the Cluster_Shared_Bucket metadata params. The 

522 # three parameters (name/arn/region) live in the global region where 

523 # GCOGlobalStack is deployed, not in the analytics region. Scoping 

524 # to the cluster_shared_ssm_parameter_prefix(project_name) tree under 

525 # the global region means a notebook can fetch the bucket name at 

526 # runtime via 

527 # boto3.client('ssm', region_name='<global-region>').get_parameter( 

528 # Name='/<project_name>/cluster-shared-bucket/name')['Parameter']['Value'] 

529 # without any JupyterLab-terminal export step. 

530 global_region = self.config.get_global_region() 

531 self.sagemaker_execution_role.add_to_policy( 

532 iam.PolicyStatement( 

533 effect=iam.Effect.ALLOW, 

534 actions=["ssm:GetParameter", "ssm:GetParameters"], 

535 resources=[ 

536 f"arn:{self.partition}:ssm:{global_region}:{self.account}:parameter" 

537 f"{cluster_shared_ssm_parameter_prefix(self.project_name)}/*", 

538 ], 

539 ) 

540 ) 

541 

542 # EFS mount actions — scoped to the Studio EFS file-system ARN. 

543 self.sagemaker_execution_role.add_to_policy( 

544 iam.PolicyStatement( 

545 effect=iam.Effect.ALLOW, 

546 actions=[ 

547 "elasticfilesystem:ClientMount", 

548 "elasticfilesystem:ClientWrite", 

549 "elasticfilesystem:ClientRootAccess", 

550 ], 

551 resources=[self.studio_efs.file_system_arn], 

552 ) 

553 ) 

554 

555 # DescribeMountTargets does not support resource-level scoping — 

556 # SageMaker calls it during user profile provisioning to validate 

557 # the EFS mount configuration. 

558 self.sagemaker_execution_role.add_to_policy( 

559 iam.PolicyStatement( 

560 effect=iam.Effect.ALLOW, 

561 actions=[ 

562 "elasticfilesystem:DescribeMountTargets", 

563 "elasticfilesystem:DescribeFileSystems", 

564 ], 

565 resources=["*"], 

566 ) 

567 ) 

568 

569 # SageMaker Studio UI actions — the execution role is assumed by 

570 # the Studio notebook runtime and needs these to render the IDE, 

571 # list spaces/apps, and manage its own lifecycle. 

572 self.sagemaker_execution_role.add_to_policy( 

573 iam.PolicyStatement( 

574 effect=iam.Effect.ALLOW, 

575 actions=[ 

576 "sagemaker:DescribeDomain", 

577 "sagemaker:DescribeUserProfile", 

578 "sagemaker:CreatePresignedDomainUrl", 

579 "sagemaker:ListSpaces", 

580 "sagemaker:ListApps", 

581 "sagemaker:DescribeApp", 

582 "sagemaker:DescribeSpace", 

583 "sagemaker:CreateApp", 

584 "sagemaker:DeleteApp", 

585 "sagemaker:CreateSpace", 

586 "sagemaker:DeleteSpace", 

587 "sagemaker:UpdateSpace", 

588 "sagemaker:ListTags", 

589 "sagemaker:AddTags", 

590 ], 

591 resources=[ 

592 f"arn:{self.partition}:sagemaker:{self.region}:{self.account}:domain/*", 

593 f"arn:{self.partition}:sagemaker:{self.region}:{self.account}:user-profile/*/*", 

594 f"arn:{self.partition}:sagemaker:{self.region}:{self.account}:space/*/*", 

595 f"arn:{self.partition}:sagemaker:{self.region}:{self.account}:app/*/*/*/*", 

596 ], 

597 ) 

598 ) 

599 

600 # EMR Serverless — allow the execution role to discover, connect to, 

601 # and manage the EMR Serverless application from Studio's Data panel. 

602 self.sagemaker_execution_role.add_to_policy( 

603 iam.PolicyStatement( 

604 effect=iam.Effect.ALLOW, 

605 actions=[ 

606 "emr-serverless:ListApplications", 

607 "emr-serverless:GetApplication", 

608 "emr-serverless:CreateApplication", 

609 "emr-serverless:StartApplication", 

610 "emr-serverless:StopApplication", 

611 "emr-serverless:StartJobRun", 

612 "emr-serverless:GetJobRun", 

613 "emr-serverless:ListJobRuns", 

614 "emr-serverless:CancelJobRun", 

615 "emr-serverless:GetDashboardForJobRun", 

616 "emr-serverless:AccessLivyEndpoints", 

617 ], 

618 resources=["*"], 

619 ) 

620 ) 

621 

622 # SageMaker-managed MLflow + Model Registry + MLflow Apps. 

623 # 

624 # We attach the AWS-managed ``AmazonSageMakerFullAccess`` policy 

625 # for two reasons: 

626 # 

627 # 1. MLflow Apps (the newer Studio panel, separate from MLflow 

628 # Tracking Servers) requires ``sagemaker:CreateMlflowApp``/ 

629 # ``ListMlflowApps``/``DescribeMlflowApp`` etc. The action 

630 # surface is evolving quickly and the managed policy tracks 

631 # it. Enumerating it inline would drift. 

632 # 2. SageMaker Model Registry (``sagemaker:*ModelPackage*``), 

633 # Studio space/app lifecycle, training-job submission, and 

634 # the "related-services" helpers (S3, ECR, CloudWatch Logs) 

635 # are already covered by the managed policy — keeping them 

636 # inline duplicated the managed policy and kept us in a 

637 # catch-up loop whenever SageMaker shipped a new feature. 

638 # 

639 # The managed policy is ``Resource: *`` by design; the trade-off 

640 # (broader-than-least-privilege inside the role) is 

641 # acknowledged with a nag suppression below. The inline 

642 # ``sagemaker-mlflow:*`` statement that follows is still 

643 # required because the managed policy does NOT cover the 

644 # ``sagemaker-mlflow`` data-plane namespace — that's what the 

645 # MLflow SDK talks to over SigV4 for ``log_metric``, 

646 # ``log_artifact``, ``register_model``, etc. 

647 from gco.stacks.nag_suppressions import suppress_managed_policy_opt_in 

648 

649 self.sagemaker_execution_role.add_managed_policy( 

650 iam.ManagedPolicy.from_aws_managed_policy_name("AmazonSageMakerFullAccess") 

651 ) 

652 suppress_managed_policy_opt_in( 

653 self.sagemaker_execution_role, 

654 managed_policy_name="AmazonSageMakerFullAccess", 

655 reason=( 

656 "AmazonSageMakerFullAccess is attached to " 

657 "SageMaker_Execution_Role when analytics_environment.enabled=true. " 

658 "The managed policy covers MLflow Apps, MLflow Tracking " 

659 "Servers, SageMaker Model Registry, Studio space/app " 

660 "lifecycle, training-job submission, and the cross-service " 

661 "helpers (S3, ECR, CloudWatch Logs) SageMaker needs to " 

662 "render the IDE and run jobs. Enumerating this surface " 

663 "inline drifts out of date within weeks — tracking the " 

664 "AWS-managed policy is the supported path. The inline " 

665 "``sagemaker-mlflow:*`` statement that follows covers " 

666 "the data-plane namespace the managed policy does not " 

667 "include. Users who want a locked-down alternative can " 

668 "disable the analytics environment." 

669 ), 

670 ) 

671 

672 # MLflow SDK data-plane (``sagemaker-mlflow:*``) — required for 

673 # ``mlflow.log_metric``, ``mlflow.log_artifact``, 

674 # ``mlflow.register_model``, etc. to round-trip through the 

675 # SageMaker-managed tracking server over SigV4. The managed 

676 # policy above covers the ``sagemaker:*`` control-plane 

677 # namespace but not ``sagemaker-mlflow:*`` (a separate service 

678 # prefix), so we keep this inline and scope it to the 

679 # api-gateway region where the tracking server and MLflow apps 

680 # live. 

681 self.sagemaker_execution_role.add_to_policy( 

682 iam.PolicyStatement( 

683 effect=iam.Effect.ALLOW, 

684 actions=["sagemaker-mlflow:*"], 

685 resources=[ 

686 f"arn:{self.partition}:sagemaker:{api_gw_region}:{self.account}:" 

687 "mlflow-tracking-server/*", 

688 f"arn:{self.partition}:sagemaker:{api_gw_region}:{self.account}:mlflow-app/*", 

689 ], 

690 ) 

691 ) 

692 

693 # MLflow's SigV4 plug-in exchanges STS ``GetCallerIdentity`` on 

694 # every request — the execution role needs that on ``*``. 

695 # ``sts:GetCallerIdentity`` does not support resource-level 

696 # scoping, so Resource: * is the only valid value. 

697 self.sagemaker_execution_role.add_to_policy( 

698 iam.PolicyStatement( 

699 effect=iam.Effect.ALLOW, 

700 actions=["sts:GetCallerIdentity"], 

701 resources=["*"], 

702 ) 

703 ) 

704 

705 # HyperPod sub-toggle — additional SageMaker actions for training-job 

706 # submission and cluster-instance lifecycle management. 

707 # ``resources=["*"]`` is the documented scope; the HyperPod actions 

708 # themselves encode the per-training-job authorization model. 

709 if self.hyperpod_enabled: 

710 self.sagemaker_execution_role.add_to_policy( 

711 iam.PolicyStatement( 

712 effect=iam.Effect.ALLOW, 

713 actions=[ 

714 "sagemaker:CreateTrainingJob", 

715 "sagemaker:DescribeTrainingJob", 

716 "sagemaker:StopTrainingJob", 

717 "sagemaker:ClusterInstance", 

718 "sagemaker:ClusterInstanceGroup", 

719 "sagemaker:DescribeClusterNode", 

720 "sagemaker:ListClusterNodes", 

721 ], 

722 resources=["*"], 

723 ) 

724 ) 

725 

726 # Canvas sub-toggle — attach AWS-managed ``AmazonSageMakerCanvasFullAccess`` 

727 # to the execution role so users can launch the Canvas no-code ML 

728 # app from inside Studio. The managed policy is used deliberately 

729 # (rather than enumerating each action) because Canvas's per-feature 

730 # permission surface — Bedrock for generative AI, Forecast for time 

731 # series, Rekognition for image classification, S3 writes for 

732 # datasets, Athena for SQL sources, etc. — is large and evolves with 

733 # every Canvas release. Tracking AWS's managed policy means we pick 

734 # up new Canvas capabilities automatically without shipping a CDK 

735 # change. The trade-off (broader-than-least-privilege inside the 

736 # role) is acknowledged with a dedicated nag suppression below. 

737 # 

738 # The matching ``CanvasAppSettings`` override on the Studio domain 

739 # lives in ``_create_studio_domain`` so the Canvas tile shows up 

740 # on the Studio landing page when the toggle is on. 

741 if self.canvas_enabled: 

742 self.sagemaker_execution_role.add_managed_policy( 

743 iam.ManagedPolicy.from_aws_managed_policy_name("AmazonSageMakerCanvasFullAccess") 

744 ) 

745 

746 suppress_managed_policy_opt_in( 

747 self.sagemaker_execution_role, 

748 managed_policy_name="AmazonSageMakerCanvasFullAccess", 

749 reason=( 

750 "AmazonSageMakerCanvasFullAccess is attached to " 

751 "SageMaker_Execution_Role when analytics_environment.canvas.enabled=true. " 

752 "Canvas is an opt-in sub-toggle (off by default) and its managed " 

753 "policy is preferred over an enumerated least-privilege policy " 

754 "because Canvas's cross-service permission surface (Bedrock, " 

755 "Forecast, Rekognition, Athena, S3 dataset writes, etc.) evolves " 

756 "with every Canvas release — tracking the managed policy keeps " 

757 "Canvas functional as AWS ships new features. Users who want a " 

758 "locked-down alternative can keep the toggle off." 

759 ), 

760 ) 

761 

762 # EFS file-system policies authorize NFS client access only. Keep 

763 # control-plane Describe/Delete permissions on the identities that 

764 # need them (the execution and cleanup roles), rather than exposing 

765 # them through a wildcard resource principal. The Studio runtime 

766 # mounts as its execution role and only through a VPC mount target. 

767 self.studio_efs.add_to_resource_policy( 

768 iam.PolicyStatement( 

769 effect=iam.Effect.ALLOW, 

770 principals=[self.sagemaker_execution_role], 

771 actions=[ 

772 "elasticfilesystem:ClientMount", 

773 "elasticfilesystem:ClientWrite", 

774 "elasticfilesystem:ClientRootAccess", 

775 ], 

776 conditions={ 

777 "Bool": {"elasticfilesystem:AccessedViaMountTarget": "true"}, 

778 }, 

779 ) 

780 ) 

781 

782 def _grant_sagemaker_role_on_cluster_shared_bucket(self) -> None: 

783 """Attach RW + KMS on ``Cluster_Shared_Bucket`` to ``SageMaker_Execution_Role``. 

784 

785 The bucket lives in ``GCOGlobalStack`` in the global region. Its 

786 ARN is resolved at synth time via an ``AwsCustomResource`` that 

787 issues ``ssm:GetParameter`` against the global region — mirroring 

788 the pattern used by ``GCORegionalStack._resolve_cluster_shared_bucket_from_ssm``. 

789 

790 Two statements attach to the role: 

791 

792 1. S3: ``GetObject``/``PutObject``/``DeleteObject``/``ListBucket``/ 

793 ``GetBucketLocation`` on ``<arn>`` + ``<arn>/*``. 

794 2. KMS: ``Decrypt``/``GenerateDataKey`` with a 

795 ``kms:ViaService=s3.<global-region>.<AWS::URLSuffix>`` condition. 

796 

797 This is a role-side policy — the bucket policy is owned 

798 exclusively by ``GCOGlobalStack``. 

799 """ 

800 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

801 

802 global_region = self.config.get_global_region() 

803 parameter_name = f"{cluster_shared_ssm_parameter_prefix(self.project_name)}/arn" 

804 

805 read_cr = cr.AwsCustomResource( 

806 self, 

807 "ReadClusterSharedBucketArn", 

808 on_create=cr.AwsSdkCall( 

809 service="SSM", 

810 action="getParameter", 

811 parameters={"Name": parameter_name}, 

812 region=global_region, 

813 physical_resource_id=cr.PhysicalResourceId.of("analytics-cluster-shared-arn"), 

814 ), 

815 on_update=cr.AwsSdkCall( 

816 service="SSM", 

817 action="getParameter", 

818 parameters={"Name": parameter_name}, 

819 region=global_region, 

820 physical_resource_id=cr.PhysicalResourceId.of("analytics-cluster-shared-arn"), 

821 ), 

822 policy=cr.AwsCustomResourcePolicy.from_sdk_calls( 

823 resources=cr.AwsCustomResourcePolicy.ANY_RESOURCE 

824 ), 

825 ) 

826 

827 # Scoped suppression: same shape as 

828 # ``GCORegionalStack._resolve_cluster_shared_bucket_from_ssm``. The 

829 # CR policy is ``Resource::*`` because cross-region SSM does not 

830 # support resource-level scoping cleanly; the action is a fixed 

831 # ``ssm:GetParameter`` for a single literal parameter Name. 

832 acknowledge_nag_findings( 

833 read_cr, 

834 [ 

835 { 

836 "id": "AwsSolutions-IAM5", 

837 "reason": ( 

838 "Cross-region ssm:GetParameter for " 

839 f"{parameter_name} in the global region. The " 

840 "AwsCustomResource SDK-call policy is scoped to a " 

841 "single fixed action (ssm:GetParameter) with a " 

842 "fixed parameter Name — the Resource: * is the " 

843 "CDK-documented escape hatch because the parameter " 

844 "ARN is not known to the calling principal's " 

845 "region. Effective blast radius: one parameter." 

846 ), 

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

848 }, 

849 ], 

850 ) 

851 

852 shared_arn = read_cr.get_response_field("Parameter.Value") 

853 

854 # Attach the two policy statements as an inline Policy on the role 

855 # (policy on the role, not the bucket). 

856 iam.Policy( 

857 self, 

858 "SagemakerClusterSharedBucketGrant", 

859 roles=[self.sagemaker_execution_role], 

860 statements=[ 

861 iam.PolicyStatement( 

862 effect=iam.Effect.ALLOW, 

863 actions=[ 

864 "s3:GetObject", 

865 "s3:PutObject", 

866 "s3:DeleteObject", 

867 "s3:ListBucket", 

868 "s3:GetBucketLocation", 

869 ], 

870 resources=[shared_arn, f"{shared_arn}/*"], 

871 ), 

872 iam.PolicyStatement( 

873 effect=iam.Effect.ALLOW, 

874 actions=["kms:Decrypt", "kms:GenerateDataKey"], 

875 resources=["*"], 

876 conditions={ 

877 "StringEquals": { 

878 "kms:ViaService": f"s3.{global_region}.{self.url_suffix}", 

879 } 

880 }, 

881 ), 

882 ], 

883 ) 

884 

885 # The S3 statement uses an <arn>/* object-key wildcard on the 

886 # literal cluster-shared bucket ARN resolved from SSM — identical 

887 # shape to the regional stack's analogous grant, with the same 

888 # reason text (bucket-scoped RW). 

889 acknowledge_nag_findings( 

890 self.sagemaker_execution_role, 

891 [ 

892 { 

893 "id": "AwsSolutions-IAM5", 

894 "reason": ( 

895 "The SageMaker RW grant on Cluster_Shared_Bucket " 

896 "uses an <arn>/* object-key wildcard on the literal " 

897 "ARN resolved from SSM. The wildcard covers object " 

898 "keys within the single always-on cluster-shared " 

899 "bucket (CloudFormation-generated name, published via SSM)." 

900 ), 

901 "appliesTo": [ 

902 "Resource::<ReadClusterSharedBucketArn4B0BD291.Parameter.Value>/*", 

903 ], 

904 }, 

905 ], 

906 ) 

907 

908 # ================================================================== 

909 # SageMaker Studio domain 

910 # ================================================================== 

911 

912 def _create_studio_domain(self) -> None: 

913 """Create the SageMaker Studio domain bound to the private VPC. 

914 

915 ``auth_mode=IAM`` + ``app_network_access_type=VpcOnly`` keeps Studio 

916 traffic on the private subnets. 

917 ``DefaultUserSettings.ExecutionRole`` points at the role created in 

918 :meth:`_create_execution_role_and_grants`. ``CustomImages`` is 

919 intentionally left unset so Studio falls back to the stock AWS- 

920 published Distribution images (a tested invariant). 

921 

922 ``CustomFileSystemConfigs`` mounts ``self.studio_efs`` at 

923 ``/home/sagemaker-user`` — per-user ``/home/<username>`` isolation 

924 is enforced by the access points that the presigned-URL Lambda 

925 creates lazily on first login. 

926 """ 

927 private_subnets = self.vpc.select_subnets( 

928 subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS 

929 ).subnets 

930 

931 efs_fs_config = sagemaker.CfnDomain.EFSFileSystemConfigProperty( 

932 file_system_id=self.studio_efs.file_system_id, 

933 file_system_path="/home/sagemaker-user", 

934 ) 

935 efs_custom_fs = sagemaker.CfnDomain.CustomFileSystemConfigProperty( 

936 efs_file_system_config=efs_fs_config, 

937 ) 

938 

939 # We also considered adding an ``S3FileSystemConfig`` custom file 

940 # system that would mount the always-on ``Cluster_Shared_Bucket`` 

941 # under ``/mount/cluster-shared``. aws-cdk-lib exposes the 

942 # property and CloudFormation synths it cleanly, but the 

943 # SageMaker Studio service rejects the resource at create time 

944 # with ``Invalid request provided: S3FileSystemConfig for 

945 # SageMaker AI Studio is not supported yet.`` — so we ship 

946 # without the mount. Notebooks access the cluster-shared 

947 # bucket via ``boto3`` (the SageMaker execution role's 

948 # cross-region RW grant in 

949 # :meth:`_grant_sagemaker_role_on_cluster_shared_bucket` already 

950 # authorizes that path). Revisit this block when SageMaker 

951 # Studio lights up S3 custom file systems. 

952 custom_file_systems: list[sagemaker.CfnDomain.CustomFileSystemConfigProperty] = [ 

953 efs_custom_fs, 

954 ] 

955 

956 # Security group for Studio compute — allows all outbound so 

957 # notebooks can reach the internet (pip, git, etc.) via the NAT 

958 # gateway. SageMaker's default VpcOnly security group only permits 

959 # NFS traffic, which blocks all internet access from notebooks. 

960 self.studio_compute_sg = ec2.SecurityGroup( 

961 self, 

962 "StudioComputeSg", 

963 vpc=self.vpc, 

964 description="Allows outbound internet access from Studio notebooks", 

965 allow_all_outbound=True, 

966 ) 

967 

968 default_user_settings = sagemaker.CfnDomain.UserSettingsProperty( 

969 execution_role=self.sagemaker_execution_role.role_arn, 

970 custom_file_system_configs=custom_file_systems, 

971 security_groups=[self.studio_compute_sg.security_group_id], 

972 # ``jupyter_lab_app_settings`` is deliberately omitted so 

973 # ``CustomImages`` stays absent — the template contains no 

974 # SageMaker image resources and no CustomImages 

975 # key on the domain. 

976 ) 

977 

978 self.studio_domain = sagemaker.CfnDomain( 

979 self, 

980 "StudioDomain", 

981 auth_mode="IAM", 

982 app_network_access_type="VpcOnly", 

983 domain_name=f"{self.project_name}-studio-{self.region}", 

984 subnet_ids=[s.subnet_id for s in private_subnets], 

985 vpc_id=self.vpc.vpc_id, 

986 kms_key_id=self.kms_key.key_id, 

987 default_user_settings=default_user_settings, 

988 ) 

989 

990 # Canvas sub-toggle (UI side): **IAM-only**. The 

991 # ``AmazonSageMakerCanvasFullAccess`` managed policy attached to 

992 # the SageMaker execution role in 

993 # :meth:`_create_execution_role_and_grants` is sufficient to 

994 # surface the Canvas tile on the Studio landing page — when a 

995 # user with that policy opens Studio, SageMaker auto-discovers 

996 # the entitlement and lights up the Canvas launcher. 

997 # 

998 # We intentionally do *not* inject a 

999 # ``DefaultUserSettings.CanvasAppSettings`` block on the domain. 

1000 # The CloudFormation ``AWS::SageMaker::Domain`` resource does 

1001 # not accept that property (only ``AWS::SageMaker::UserProfile`` 

1002 # does), so a property override fails early validation with 

1003 # ``Unsupported property [CanvasAppSettings]``. Canvas uses its 

1004 # own default workspace artifact locations; operators who want 

1005 # to pin per-user Canvas defaults can apply 

1006 # ``CanvasAppSettings`` at the ``UserProfile`` level directly. 

1007 

1008 # The domain validates that the EFS file system has mount targets in 

1009 # every subnet before stabilizing. CDK doesn't infer this dependency 

1010 # from the file_system_id reference alone, so we add it explicitly. 

1011 self.studio_domain.node.add_dependency(self.studio_efs) 

1012 

1013 CfnOutput( 

1014 self, 

1015 "StudioDomainName", 

1016 value=self.studio_domain.domain_name or "", 

1017 description="Name of the SageMaker Studio domain", 

1018 ) 

1019 

1020 # Cleanup custom resource — on stack deletion, removes all user 

1021 # profiles from the domain and all access points from the EFS so 

1022 # CloudFormation can delete the domain and file system cleanly. 

1023 from aws_cdk import CustomResource 

1024 from aws_cdk import custom_resources as cr_provider 

1025 

1026 cleanup_fn = lambda_.Function( 

1027 self, 

1028 "CleanupFunction", 

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

1030 handler="handler.handler", 

1031 code=lambda_source_code("lambda/analytics-cleanup"), 

1032 # 15 minutes covers the worst case of multiple async drain 

1033 # loops in series: apps (up to ~2 min), spaces (up to ~3 min), 

1034 # user profiles (up to ~3 min), SageMaker-managed EFS mount 

1035 # targets (up to ~2 min), plus incidental RPC latency and 

1036 # security-group cleanup. In the common case (a handful of 

1037 # users) this finishes in well under a minute. 

1038 timeout=Duration.minutes(15), 

1039 environment={ 

1040 "DOMAIN_ID": self.studio_domain.attr_domain_id, 

1041 "EFS_ID": self.studio_efs.file_system_id, 

1042 "REGION": self.region, 

1043 "VPC_ID": self.vpc.vpc_id, 

1044 }, 

1045 ) 

1046 

1047 # Use a customer-managed policy instead of an inline policy. 

1048 # Inline policies (created by add_to_role_policy) are separate 

1049 # CloudFormation resources that can be deleted before the custom 

1050 # resource fires during stack deletion. A managed policy attached 

1051 # via the role's managedPolicies property is part of the role 

1052 # resource itself and persists until the role is deleted. 

1053 cleanup_policy = iam.ManagedPolicy( 

1054 self, 

1055 "CleanupFunctionPolicy", 

1056 statements=[ 

1057 iam.PolicyStatement( 

1058 effect=iam.Effect.ALLOW, 

1059 actions=[ 

1060 "sagemaker:ListApps", 

1061 "sagemaker:DeleteApp", 

1062 "sagemaker:ListSpaces", 

1063 "sagemaker:DeleteSpace", 

1064 "sagemaker:ListUserProfiles", 

1065 "sagemaker:DeleteUserProfile", 

1066 "sagemaker:DescribeDomain", 

1067 "elasticfilesystem:DescribeAccessPoints", 

1068 "elasticfilesystem:DeleteAccessPoint", 

1069 "elasticfilesystem:DescribeFileSystems", 

1070 "elasticfilesystem:DescribeMountTargets", 

1071 "elasticfilesystem:DeleteMountTarget", 

1072 "elasticfilesystem:DeleteFileSystem", 

1073 "elasticfilesystem:DeleteFileSystemPolicy", 

1074 "ec2:DescribeSecurityGroups", 

1075 "ec2:DeleteSecurityGroup", 

1076 "ec2:RevokeSecurityGroupIngress", 

1077 "ec2:RevokeSecurityGroupEgress", 

1078 ], 

1079 resources=["*"], 

1080 ) 

1081 ], 

1082 ) 

1083 assert cleanup_fn.role is not None 

1084 cleanup_fn.role.add_managed_policy(cleanup_policy) 

1085 

1086 cleanup_provider = cr_provider.Provider( 

1087 self, 

1088 "CleanupProvider", 

1089 on_event_handler=cleanup_fn, 

1090 ) 

1091 

1092 cleanup_resource = CustomResource( 

1093 self, 

1094 "DomainCleanup", 

1095 service_token=cleanup_provider.service_token, 

1096 ) 

1097 

1098 # The managed policy must not be deleted until after the cleanup 

1099 # custom resource completes. Adding a dependency ensures 

1100 # CloudFormation keeps the policy alive during the Lambda execution. 

1101 cleanup_resource.node.add_dependency(cleanup_policy) 

1102 

1103 # Store reference so _create_presigned_url_lambda can add a 

1104 # dependency after it creates the presigned-URL Lambda. 

1105 self._cleanup_resource = cleanup_resource 

1106 

1107 # Nag suppression for the cleanup Lambda — Resource::* is required 

1108 # because ListUserProfiles/DeleteUserProfile and 

1109 # DescribeAccessPoints/DeleteAccessPoint don't support resource-level 

1110 # scoping (the domain ID and EFS ID are passed via env vars, not ARNs). 

1111 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

1112 

1113 assert cleanup_fn.role is not None # always set for non-imported functions 

1114 acknowledge_nag_findings( 

1115 cleanup_fn.role, 

1116 [ 

1117 { 

1118 "id": "AwsSolutions-IAM5", 

1119 "reason": ( 

1120 "Cleanup Lambda needs Resource::* for " 

1121 "sagemaker:ListUserProfiles/DeleteUserProfile and " 

1122 "efs:DescribeAccessPoints/DeleteAccessPoint. These " 

1123 "APIs don't support resource-level scoping. The " 

1124 "Lambda only runs on stack deletion and is scoped " 

1125 "to the domain ID and EFS ID via environment variables." 

1126 ), 

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

1128 }, 

1129 { 

1130 "id": "AwsSolutions-IAM4", 

1131 "reason": ( 

1132 "Cleanup Lambda uses AWSLambdaBasicExecutionRole " 

1133 "managed policy for CloudWatch Logs access." 

1134 ), 

1135 "appliesTo": [ 

1136 "Policy::arn:<AWS::Partition>:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", 

1137 ], 

1138 }, 

1139 ], 

1140 ) 

1141 acknowledge_nag_findings( 

1142 cleanup_provider, 

1143 [ 

1144 { 

1145 "id": "AwsSolutions-IAM5", 

1146 "reason": ( 

1147 "CDK Provider framework uses Resource::* for its " 

1148 "internal Lambda invocation policy." 

1149 ), 

1150 "appliesTo": [ 

1151 "Resource::*", 

1152 "Resource::<CleanupFunction1604930F.Arn>:*", 

1153 ], 

1154 }, 

1155 { 

1156 "id": "AwsSolutions-IAM4", 

1157 "reason": ("CDK Provider framework uses AWSLambdaBasicExecutionRole."), 

1158 "appliesTo": [ 

1159 "Policy::arn:<AWS::Partition>:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", 

1160 ], 

1161 }, 

1162 { 

1163 "id": "AwsSolutions-L1", 

1164 "reason": ("CDK Provider framework manages its own Lambda runtime version."), 

1165 }, 

1166 ], 

1167 ) 

1168 

1169 # ================================================================== 

1170 # EMR Serverless application 

1171 # ================================================================== 

1172 

1173 def _create_emr_app(self) -> None: 

1174 """Create an EMR Serverless Spark application on the private VPC. 

1175 

1176 Pinned ``release_label`` lives in 

1177 ``gco.stacks.constants.EMR_SERVERLESS_RELEASE_LABEL`` so analytics 

1178 workloads get a reproducible Spark runtime across deployments. The 

1179 application's network configuration uses the private 

1180 subnets + a dedicated security group so Spark workers stay on the 

1181 same network perimeter as the Studio notebooks. 

1182 """ 

1183 private_subnet_ids = [ 

1184 s.subnet_id 

1185 for s in self.vpc.select_subnets(subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS).subnets 

1186 ] 

1187 

1188 self.emr_security_group = ec2.SecurityGroup( 

1189 self, 

1190 "EmrServerlessSecurityGroup", 

1191 vpc=self.vpc, 

1192 description="SG for EMR Serverless Spark workers", 

1193 allow_all_outbound=True, 

1194 ) 

1195 

1196 self.emr_app = emrserverless.CfnApplication( 

1197 self, 

1198 "EmrServerlessApp", 

1199 name=f"{self.project_name}-spark-{self.region}", 

1200 release_label=EMR_SERVERLESS_RELEASE_LABEL, 

1201 type="SPARK", 

1202 network_configuration=emrserverless.CfnApplication.NetworkConfigurationProperty( 

1203 subnet_ids=private_subnet_ids, 

1204 security_group_ids=[self.emr_security_group.security_group_id], 

1205 ), 

1206 ) 

1207 

1208 # ================================================================== 

1209 # Cognito pool + client + domain 

1210 # ================================================================== 

1211 

1212 def _create_cognito_pool(self) -> None: 

1213 """Create the Cognito user pool that authenticates SageMaker Studio logins. 

1214 

1215 Password policy, standard threat-protection mode, and self-sign-up- 

1216 disabled flags are configured for SRP-backed Studio logins. The 

1217 attached ``UserPoolClient`` runs SRP auth 

1218 (used by ``gco analytics studio login``) with token revocation 

1219 enabled. The ``UserPoolDomain`` uses the configurable prefix from 

1220 ``analytics_environment.cognito.domain_prefix`` or defaults to 

1221 ``gco-studio-<account>``. 

1222 """ 

1223 self.cognito_pool = cognito.UserPool( 

1224 self, 

1225 "StudioUserPool", 

1226 self_sign_up_enabled=False, 

1227 password_policy=cognito.PasswordPolicy( 

1228 min_length=12, 

1229 require_digits=True, 

1230 require_symbols=True, 

1231 require_uppercase=True, 

1232 require_lowercase=True, 

1233 ), 

1234 sign_in_aliases=cognito.SignInAliases(username=True), 

1235 auto_verify=cognito.AutoVerifiedAttrs(email=True), 

1236 # Replaces the deprecated ``advanced_security_mode`` kwarg 

1237 # (aws-cdk-lib's AdvancedSecurityMode enum is gone as of the 

1238 # Cognito November 2024 tier changes). Lite feature plan — the 

1239 # default — does not support real threat protection, so we set 

1240 # ``NO_ENFORCEMENT`` here to keep the synth warning-free. 

1241 # TODO: operators who want real threat protection should opt 

1242 # into the Essentials or Plus feature plan by also setting 

1243 # ``feature_plan=cognito.FeaturePlan.ESSENTIALS`` (or 

1244 # ``FeaturePlan.PLUS``) and flipping this to 

1245 # ``StandardThreatProtectionMode.FULL_FUNCTION``. That path 

1246 # changes the per-MAU price — see the Cognito pricing doc — 

1247 # which is why the default stays on Lite+NO_ENFORCEMENT. 

1248 standard_threat_protection_mode=(cognito.StandardThreatProtectionMode.NO_ENFORCEMENT), 

1249 removal_policy=self.cognito_removal, 

1250 ) 

1251 

1252 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

1253 

1254 # cdk-nag AwsSolutions-COG8 (new in cdk-nag 2.38.x): the Lite feature plan is intentional (cost); see UserPool above. 

1255 acknowledge_nag_findings( 

1256 self.cognito_pool, 

1257 [ 

1258 { 

1259 "id": "AwsSolutions-COG8", 

1260 "reason": "Studio user pool intentionally uses the Lite feature plan with NO_ENFORCEMENT threat protection to avoid the Plus plan per-MAU cost; it only gates internal SageMaker Studio access. Operators who need threat protection can opt into the Essentials/Plus feature plan as documented on the UserPool definition.", 

1261 } 

1262 ], 

1263 ) 

1264 self.cognito_client = self.cognito_pool.add_client( 

1265 "StudioUserPoolClient", 

1266 auth_flows=cognito.AuthFlow( 

1267 user_srp=True, 

1268 admin_user_password=True, 

1269 ), 

1270 prevent_user_existence_errors=True, 

1271 enable_token_revocation=True, 

1272 ) 

1273 

1274 # Domain prefix — default is ``<project_name>-studio-<account>`` (from 

1275 # constants.cognito_domain_prefix_default(project_name) + account suffix). 

1276 # The override in cdk.json is used verbatim when non-None, without 

1277 # appending the account id, because operators who override the 

1278 # prefix typically want a short memorable value. 

1279 if self._cognito_domain_prefix_override: 

1280 domain_prefix = self._cognito_domain_prefix_override 

1281 else: 

1282 domain_prefix = f"{cognito_domain_prefix_default(self.project_name)}-{self.account}" 

1283 

1284 self.cognito_domain = self.cognito_pool.add_domain( 

1285 "StudioUserPoolDomain", 

1286 cognito_domain=cognito.CognitoDomainOptions(domain_prefix=domain_prefix), 

1287 ) 

1288 

1289 CfnOutput( 

1290 self, 

1291 "CognitoUserPoolId", 

1292 value=self.cognito_pool.user_pool_id, 

1293 description="ID of the Cognito user pool that gates SageMaker Studio", 

1294 ) 

1295 CfnOutput( 

1296 self, 

1297 "CognitoUserPoolArn", 

1298 value=self.cognito_pool.user_pool_arn, 

1299 description="ARN of the Cognito user pool", 

1300 ) 

1301 CfnOutput( 

1302 self, 

1303 "CognitoUserPoolClientId", 

1304 value=self.cognito_client.user_pool_client_id, 

1305 description="Client ID used by the GCO CLI for SRP auth", 

1306 ) 

1307 

1308 # ================================================================== 

1309 # Presigned-URL Lambda 

1310 # ================================================================== 

1311 

1312 def _create_presigned_url_lambda(self) -> None: 

1313 """Create the ``Presigned_URL_Lambda`` that mints Studio login URLs. 

1314 

1315 Wired into API Gateway's ``/studio/login`` route from 

1316 ``GCOApiGatewayGlobalStack``. The function lives on 

1317 ``GCOAnalyticsStack`` (not the API gateway stack) so its IAM role 

1318 can reference ``SageMaker_Execution_Role.role_arn`` on ``PassRole`` 

1319 and ``Studio_EFS.file_system_arn`` on the EFS access-point actions 

1320 without a cross-stack import. 

1321 

1322 Key configuration: 

1323 

1324 * Runtime: ``LAMBDA_PYTHON_RUNTIME`` from ``gco.stacks.constants``. 

1325 * Timeout: 29 s — API Gateway's maximum integration timeout is 29 

1326 seconds, so matching it here lets the Lambda time out *before* 

1327 API Gateway does, producing a clean HTTP 500 with our opaque 

1328 error token rather than API Gateway's 504. 

1329 * Tracing: ``ACTIVE`` so X-Ray captures the 

1330 ``sagemaker:CreatePresignedDomainUrl`` call. 

1331 * Log group retention: 1 month. 

1332 

1333 IAM scoping: 

1334 

1335 * ``sagemaker:ListDomains`` — no resource-level scoping available; 

1336 scoped with a documented ``Resource::*`` nag suppression. 

1337 * ``sagemaker:DescribeDomain`` + ``CreatePresignedDomainUrl`` + 

1338 ``DescribeUserProfile`` + ``CreateUserProfile`` + ``ListTags`` + 

1339 ``AddTags`` scoped to the domain and user-profile ARN families 

1340 in this region+account. We cannot pin the ``DomainId`` at synth 

1341 time because ``list_domains`` runs at invoke time, so the ARN 

1342 shape includes a wildcard segment covering "any domain id". 

1343 * ``iam:PassRole`` on ``SageMaker_Execution_Role.role_arn`` with a 

1344 ``StringEquals iam:PassedToService=sagemaker.amazonaws.com`` 

1345 condition so the role can only ever be handed to SageMaker. 

1346 * ``elasticfilesystem:DescribeAccessPoints`` + 

1347 ``CreateAccessPoint`` on ``Studio_EFS.file_system_arn`` for the 

1348 lazy per-user access-point creation path in the handler. 

1349 * ``AWSLambdaBasicExecutionRole`` managed policy for the CloudWatch 

1350 Logs + X-Ray write path. 

1351 """ 

1352 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

1353 

1354 # Dedicated IAM role — narrow-scoped, no reuse across other 

1355 # Lambdas. We attach the basic execution role as a managed policy 

1356 # so the nag rule for ``AwsSolutions-IAM4`` is happy; everything 

1357 # else is an inline policy we own entirely. 

1358 self.presigned_url_lambda_role = iam.Role( 

1359 self, 

1360 "PresignedUrlLambdaRole", 

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

1362 description=( 

1363 "Execution role for the analytics presigned-URL Lambda. " 

1364 "Scoped to SageMaker domain + user-profile operations, " 

1365 "PassRole on SageMaker_Execution_Role, and EFS access-" 

1366 "point management on Studio_EFS." 

1367 ), 

1368 managed_policies=[ 

1369 iam.ManagedPolicy.from_aws_managed_policy_name( 

1370 "service-role/AWSLambdaBasicExecutionRole" 

1371 ) 

1372 ], 

1373 ) 

1374 

1375 # ListDomains does not support resource-level scoping (AWS API 

1376 # constraint). We use Resource::* and document the effective 

1377 # blast radius in the nag suppression below — one list call per 

1378 # invocation against the region's SageMaker control plane. 

1379 self.presigned_url_lambda_role.add_to_policy( 

1380 iam.PolicyStatement( 

1381 effect=iam.Effect.ALLOW, 

1382 actions=["sagemaker:ListDomains"], 

1383 resources=["*"], 

1384 ) 

1385 ) 

1386 

1387 # Domain + user-profile actions. At synth time we don't know the 

1388 # DomainId (list_domains is an invoke-time call), so the ARN 

1389 # wildcards cover "any domain in this region+account" and "any 

1390 # user profile under any domain in this region+account". The 

1391 # account is still pinned, so the blast radius is bounded to 

1392 # this account's SageMaker Studio installation. 

1393 domain_arn_prefix = f"arn:{self.partition}:sagemaker:{self.region}:{self.account}:domain/*" 

1394 user_profile_arn_prefix = ( 

1395 f"arn:{self.partition}:sagemaker:{self.region}:{self.account}:user-profile/*/*" 

1396 ) 

1397 self.presigned_url_lambda_role.add_to_policy( 

1398 iam.PolicyStatement( 

1399 effect=iam.Effect.ALLOW, 

1400 actions=[ 

1401 "sagemaker:DescribeDomain", 

1402 "sagemaker:CreatePresignedDomainUrl", 

1403 "sagemaker:DescribeUserProfile", 

1404 "sagemaker:CreateUserProfile", 

1405 "sagemaker:ListTags", 

1406 "sagemaker:AddTags", 

1407 ], 

1408 resources=[domain_arn_prefix, user_profile_arn_prefix], 

1409 ) 

1410 ) 

1411 

1412 # iam:PassRole — only SageMaker_Execution_Role, only to 

1413 # sagemaker.amazonaws.com. This is what CreateUserProfile passes 

1414 # on the ``ExecutionRole`` field. 

1415 self.presigned_url_lambda_role.add_to_policy( 

1416 iam.PolicyStatement( 

1417 effect=iam.Effect.ALLOW, 

1418 actions=["iam:PassRole"], 

1419 resources=[self.sagemaker_execution_role.role_arn], 

1420 conditions={ 

1421 "StringEquals": { 

1422 "iam:PassedToService": "sagemaker.amazonaws.com", 

1423 } 

1424 }, 

1425 ) 

1426 ) 

1427 

1428 # EFS access-point management — scoped to the Studio_EFS file 

1429 # system. The Lambda creates one access point per Cognito user 

1430 # at first login (lazy-in-Lambda approach). 

1431 self.presigned_url_lambda_role.add_to_policy( 

1432 iam.PolicyStatement( 

1433 effect=iam.Effect.ALLOW, 

1434 actions=[ 

1435 "elasticfilesystem:DescribeAccessPoints", 

1436 "elasticfilesystem:CreateAccessPoint", 

1437 "elasticfilesystem:TagResource", 

1438 ], 

1439 resources=[self.studio_efs.file_system_arn], 

1440 ) 

1441 ) 

1442 

1443 # CloudWatch log group with 1-month retention. We own 

1444 # the group explicitly (rather than letting Lambda auto-create 

1445 # one) so the retention setting is captured in the template. 

1446 presigned_url_log_group = logs.LogGroup( 

1447 self, 

1448 "PresignedUrlLambdaLogGroup", 

1449 retention=logs.RetentionDays.ONE_MONTH, 

1450 removal_policy=RemovalPolicy.DESTROY, 

1451 ) 

1452 

1453 self.presigned_url_lambda = lambda_.Function( 

1454 self, 

1455 "PresignedUrlFunction", 

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

1457 handler="handler.lambda_handler", 

1458 code=lambda_source_code("lambda/analytics-presigned-url"), 

1459 role=self.presigned_url_lambda_role, 

1460 timeout=Duration.seconds(29), 

1461 memory_size=256, 

1462 tracing=lambda_.Tracing.ACTIVE, 

1463 log_group=presigned_url_log_group, 

1464 description=( 

1465 "Exchanges a Cognito-authorized event for a presigned " 

1466 "SageMaker Studio URL. Wired into /studio/login by " 

1467 "GCOApiGatewayGlobalStack." 

1468 ), 

1469 environment={ 

1470 "STUDIO_DOMAIN_ID": self.studio_domain.attr_domain_id, 

1471 "SAGEMAKER_EXECUTION_ROLE_ARN": self.sagemaker_execution_role.role_arn, 

1472 "STUDIO_EFS_ID": self.studio_efs.file_system_id, 

1473 "URL_EXPIRES_SECONDS": "300", 

1474 "SESSION_EXPIRES_SECONDS": "43200", 

1475 }, 

1476 ) 

1477 

1478 CfnOutput( 

1479 self, 

1480 "PresignedUrlLambdaArn", 

1481 value=self.presigned_url_lambda.function_arn, 

1482 description=( 

1483 "ARN of the presigned-URL Lambda - consumed by the API " 

1484 "Gateway stack's /studio/login integration." 

1485 ), 

1486 ) 

1487 

1488 # Nag suppressions. Each one carries a literal-ARN or documented 

1489 # wildcard ``applies_to`` and a ``reason`` string explaining why 

1490 # tighter scoping isn't possible. 

1491 acknowledge_nag_findings( 

1492 self.presigned_url_lambda_role, 

1493 [ 

1494 { 

1495 "id": "AwsSolutions-IAM5", 

1496 "reason": ( 

1497 "sagemaker:ListDomains does not support resource-" 

1498 "level scoping — the AWS API only accepts " 

1499 "Resource: *. Effective blast radius: a single " 

1500 "paginated list call per Lambda invocation " 

1501 "against this account's SageMaker control plane " 

1502 "in this region. The remaining SageMaker actions " 

1503 "(DescribeDomain, CreatePresignedDomainUrl, " 

1504 "DescribeUserProfile, CreateUserProfile, " 

1505 "ListTags, AddTags) are scoped to the literal " 

1506 "arn:<partition>:sagemaker:<region>:<account>:domain/* " 

1507 "and arn:<partition>:sagemaker:<region>:<account>:" 

1508 "user-profile/*/* ARN families, which is the " 

1509 "tightest we can achieve at synth time because " 

1510 "DomainId is only resolvable at invoke time." 

1511 ), 

1512 "appliesTo": [ 

1513 "Resource::*", 

1514 ( 

1515 "Resource::arn:<AWS::Partition>:sagemaker:<AWS::Region>:<AWS::AccountId>:domain/*" 

1516 ), 

1517 ( 

1518 "Resource::arn:<AWS::Partition>:sagemaker:<AWS::Region>:" 

1519 "<AWS::AccountId>:user-profile/*/*" 

1520 ), 

1521 ], 

1522 }, 

1523 ], 

1524 ) 

1525 

1526 # The cleanup custom resource must fire AFTER the presigned-URL 

1527 # Lambda is deleted during stack destruction. Otherwise the Lambda 

1528 # can recreate user profiles (via in-flight login requests) between 

1529 # cleanup and domain deletion. Adding the dependency here (after 

1530 # the Lambda is created) ensures correct deletion ordering. 

1531 self._cleanup_resource.node.add_dependency(self.presigned_url_lambda) 

1532 

1533 # ================================================================== 

1534 # Nag suppressions 

1535 # ================================================================== 

1536 

1537 def _apply_nag_suppressions(self) -> None: 

1538 """Dispatch to the analytics branch in ``gco/stacks/nag_suppressions.py``. 

1539 

1540 The analytics branch calls ``add_sagemaker_suppressions``, 

1541 ``add_cognito_suppressions``, ``add_emr_serverless_suppressions``, 

1542 ``add_storage_suppressions`` (for ``Studio_Only_Bucket`` + access- 

1543 logs bucket), ``add_lambda_suppressions`` (for the presigned-URL 

1544 Lambda provider framework), and ``add_iam_suppressions`` (for 

1545 cross-region SSM reads + CDK custom resources). 

1546 """ 

1547 apply_all_suppressions( 

1548 self, 

1549 stack_type="analytics", 

1550 regions=None, 

1551 global_region=self.config.get_global_region(), 

1552 api_gateway_region=self.config.get_api_gateway_region(), 

1553 project_name=self.project_name, 

1554 )