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

164 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-30 21:22 +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.nag_suppressions import apply_all_suppressions 

58 

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

60# Generated at (UTC): 2026-07-18T01:03:40Z 

61# Flowchart(s) generated from this file: 

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

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

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

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

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

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

68# Regenerate with ``python diagrams/code_diagrams/generate.py``. 

69# <pyflowchart-code-diagram> END 

70 

71 

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

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

74 

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

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

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

78 """ 

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

80 if normalized == "retain": 

81 return RemovalPolicy.RETAIN 

82 if normalized == "destroy": 

83 return RemovalPolicy.DESTROY 

84 raise ValueError( 

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

86 ) 

87 

88 

89class GCOAnalyticsStack(Stack): 

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

91 

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

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

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

95 a cross-region hop. 

96 """ 

97 

98 def __init__( 

99 self, 

100 scope: Construct, 

101 construct_id: str, 

102 *, 

103 config: ConfigLoader, 

104 api_gateway_secret_arn: str | None = None, 

105 **kwargs: Any, 

106 ) -> None: 

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

108 

109 self.config = config 

110 self.project_name = config.get_project_name() 

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

112 # accepted now so the constructor signature is stable. 

113 self.api_gateway_secret_arn = api_gateway_secret_arn 

114 

115 cfg = config.get_analytics_config() 

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

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

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

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

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

121 

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

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

124 self._create_kms_key() 

125 self._create_vpc_and_endpoints() 

126 self._create_access_logs_bucket() 

127 self._create_studio_only_bucket() 

128 self._create_studio_efs() 

129 self._create_execution_role_and_grants() 

130 self._grant_sagemaker_role_on_cluster_shared_bucket() 

131 self._create_studio_domain() 

132 self._create_emr_app() 

133 self._create_cognito_pool() 

134 self._create_presigned_url_lambda() 

135 self._apply_nag_suppressions() 

136 

137 # ================================================================== 

138 # KMS + VPC 

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

140 

141 def _create_kms_key(self) -> None: 

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

143 

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

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

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

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

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

149 """ 

150 self.kms_key = kms.Key( 

151 self, 

152 "AnalyticsKmsKey", 

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

154 enable_key_rotation=True, 

155 pending_window=Duration.days(7), 

156 removal_policy=RemovalPolicy.DESTROY, 

157 ) 

158 

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

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

161 service_principals = [ 

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

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

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

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

166 ] 

167 for principal, region in service_principals: 

168 self.kms_key.add_to_resource_policy( 

169 iam.PolicyStatement( 

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

171 effect=iam.Effect.ALLOW, 

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

173 actions=[ 

174 "kms:Encrypt", 

175 "kms:Decrypt", 

176 "kms:ReEncrypt*", 

177 "kms:GenerateDataKey*", 

178 "kms:DescribeKey", 

179 ], 

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

181 ) 

182 ) 

183 

184 def _create_vpc_and_endpoints(self) -> None: 

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

186 

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

188 The nine interface endpoints plus the S3 gateway endpoint 

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

190 gateway provides internet egress so notebooks can pip install, 

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

192 """ 

193 self.vpc = ec2.Vpc( 

194 self, 

195 "AnalyticsVpc", 

196 max_azs=2, 

197 nat_gateways=1, 

198 subnet_configuration=[ 

199 ec2.SubnetConfiguration( 

200 name="AnalyticsPrivate", 

201 subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS, 

202 cidr_mask=24, 

203 ), 

204 ec2.SubnetConfiguration( 

205 name="AnalyticsPublic", 

206 subnet_type=ec2.SubnetType.PUBLIC, 

207 cidr_mask=28, 

208 ), 

209 ], 

210 ) 

211 

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

213 self.vpc.add_gateway_endpoint( 

214 "S3GatewayEndpoint", 

215 service=ec2.GatewayVpcEndpointAwsService.S3, 

216 ) 

217 

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

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

220 # VPC-endpoint security group. 

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

222 "SagemakerApiEndpoint": ec2.InterfaceVpcEndpointAwsService.SAGEMAKER_API, 

223 "SagemakerRuntimeEndpoint": ec2.InterfaceVpcEndpointAwsService.SAGEMAKER_RUNTIME, 

224 "SagemakerStudioEndpoint": ec2.InterfaceVpcEndpointAwsService.SAGEMAKER_STUDIO, 

225 "SagemakerNotebookEndpoint": ec2.InterfaceVpcEndpointAwsService.SAGEMAKER_NOTEBOOK, 

226 "StsEndpoint": ec2.InterfaceVpcEndpointAwsService.STS, 

227 "CloudWatchLogsEndpoint": ec2.InterfaceVpcEndpointAwsService.CLOUDWATCH_LOGS, 

228 "EcrEndpoint": ec2.InterfaceVpcEndpointAwsService.ECR, 

229 "EcrDockerEndpoint": ec2.InterfaceVpcEndpointAwsService.ECR_DOCKER, 

230 "EfsEndpoint": ec2.InterfaceVpcEndpointAwsService.ELASTIC_FILESYSTEM, 

231 } 

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

233 self.vpc.add_interface_endpoint( 

234 construct_id, 

235 service=service, 

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

237 ) 

238 

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

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

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

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

243 from gco.stacks.nag_suppressions import acknowledge_security_group_cidr_findings 

244 

245 acknowledge_security_group_cidr_findings( 

246 self.vpc, 

247 reason=( 

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

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

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

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

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

253 "endpoint access." 

254 ), 

255 ) 

256 

257 # ================================================================== 

258 # S3 buckets 

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

260 

261 def _create_access_logs_bucket(self) -> None: 

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

263 

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

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

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

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

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

269 itself is scoped on the bucket construct by 

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

271 """ 

272 self.access_logs_bucket = s3.Bucket( 

273 self, 

274 "AnalyticsAccessLogsBucket", 

275 encryption=s3.BucketEncryption.S3_MANAGED, 

276 block_public_access=s3.BlockPublicAccess.BLOCK_ALL, 

277 enforce_ssl=True, 

278 versioned=True, 

279 removal_policy=RemovalPolicy.DESTROY, 

280 auto_delete_objects=True, 

281 lifecycle_rules=[ 

282 s3.LifecycleRule( 

283 id="ExpireAccessLogs", 

284 enabled=True, 

285 expiration=Duration.days(90), 

286 ) 

287 ], 

288 ) 

289 

290 def _create_studio_only_bucket(self) -> None: 

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

292 

293 Named ``<project_name>-analytics-studio-<account>-<region>`` so the 

294 cdk-nag deny-list assertion 

295 (``arn:<partition>:s3:::<project_name>-analytics-studio-*``) stays in 

296 lockstep, and two deployments in one account+region do not collide. KMS-encrypted with ``self.kms_key``; every access path goes 

297 through the ``SageMaker_Execution_Role`` grant — no other principal 

298 is granted access. 

299 """ 

300 self.studio_only_bucket = s3.Bucket( 

301 self, 

302 "StudioOnlyBucket", 

303 bucket_name=f"{self.project_name}-analytics-studio-{self.account}-{self.region}", 

304 encryption=s3.BucketEncryption.KMS, 

305 encryption_key=self.kms_key, 

306 bucket_key_enabled=True, 

307 block_public_access=s3.BlockPublicAccess.BLOCK_ALL, 

308 enforce_ssl=True, 

309 versioned=True, 

310 removal_policy=RemovalPolicy.DESTROY, 

311 auto_delete_objects=True, 

312 server_access_logs_bucket=self.access_logs_bucket, 

313 server_access_logs_prefix="studio-only/", 

314 ) 

315 

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

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

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

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

320 self.studio_only_bucket.add_to_resource_policy( 

321 iam.PolicyStatement( 

322 sid="DenyInsecureTransport", 

323 effect=iam.Effect.DENY, 

324 principals=[iam.AnyPrincipal()], 

325 actions=["s3:*"], 

326 resources=[ 

327 self.studio_only_bucket.bucket_arn, 

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

329 ], 

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

331 ) 

332 ) 

333 

334 # ================================================================== 

335 # Studio EFS 

336 # ================================================================== 

337 

338 def _create_studio_efs(self) -> None: 

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

340 

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

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

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

344 until the Lambda materializes a per-user AP. 

345 

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

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

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

349 """ 

350 self.studio_efs_security_group = ec2.SecurityGroup( 

351 self, 

352 "StudioEfsSecurityGroup", 

353 vpc=self.vpc, 

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

355 allow_all_outbound=False, 

356 ) 

357 self.studio_efs_security_group.add_ingress_rule( 

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

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

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

361 ) 

362 

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

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

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

366 from gco.stacks.nag_suppressions import acknowledge_security_group_cidr_findings 

367 

368 acknowledge_security_group_cidr_findings( 

369 self.studio_efs_security_group, 

370 reason=( 

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

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

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

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

375 "subnet that mounts the file system." 

376 ), 

377 ) 

378 

379 self.studio_efs = efs.FileSystem( 

380 self, 

381 "StudioEfs", 

382 vpc=self.vpc, 

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

384 encrypted=True, 

385 kms_key=self.kms_key, 

386 enable_automatic_backups=True, 

387 removal_policy=self.efs_removal, 

388 security_group=self.studio_efs_security_group, 

389 ) 

390 

391 # ================================================================== 

392 # SageMaker execution role + grants 

393 # ================================================================== 

394 

395 def _create_execution_role_and_grants(self) -> None: 

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

397 

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

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

400 attached here: 

401 

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

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

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

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

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

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

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

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

410 scoped to the EFS ARN) 

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

412 * AWS-managed ``AmazonSageMakerCanvasFullAccess`` when 

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

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

415 whenever analytics is enabled. Covers the full SageMaker 

416 control-plane surface including MLflow Apps 

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

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

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

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

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

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

423 because the managed policy does not cover the 

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

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

426 managed policy replaces our previous enumerated 

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

428 

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

430 (:meth:`_grant_sagemaker_role_on_cluster_shared_bucket`) because the 

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

432 """ 

433 self.sagemaker_execution_role = iam.Role( 

434 self, 

435 "SagemakerExecutionRole", 

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

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

438 description=( 

439 "SageMaker_Execution_Role - assumed by notebooks in the Studio " 

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

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

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

443 "on the Cluster_Shared_Bucket metadata parameters." 

444 ), 

445 ) 

446 

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

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

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

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

451 self.studio_only_bucket.grant_read_write(self.sagemaker_execution_role) 

452 self.kms_key.grant_encrypt_decrypt(self.sagemaker_execution_role) 

453 

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

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

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

457 self.kms_key.grant( 

458 self.sagemaker_execution_role, 

459 "kms:CreateGrant", 

460 "kms:DescribeKey", 

461 ) 

462 

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

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

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

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

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

468 # tear things down from inside a notebook without bouncing 

469 # through a service account. 

470 # 

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

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

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

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

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

476 api_gw_region = self.config.get_api_gateway_region() 

477 self.sagemaker_execution_role.add_to_policy( 

478 iam.PolicyStatement( 

479 effect=iam.Effect.ALLOW, 

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

481 resources=[ 

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

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

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

485 # users go through their own Cognito-authorized 

486 # ``/studio/login`` route. 

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

488 "*/prod/*/api/v1/*", 

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

490 # for in-cluster model endpoints — notebooks need 

491 # the full method surface here too. 

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

493 "*/prod/*/inference/*", 

494 ], 

495 ) 

496 ) 

497 

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

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

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

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

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

503 project_name = self.config.get_project_name() 

504 self.sagemaker_execution_role.add_to_policy( 

505 iam.PolicyStatement( 

506 effect=iam.Effect.ALLOW, 

507 actions=["sqs:SendMessage"], 

508 resources=[ 

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

510 ], 

511 ) 

512 ) 

513 

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

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

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

517 # to the cluster_shared_ssm_parameter_prefix(project_name) tree under 

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

519 # runtime via 

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

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

522 # without any JupyterLab-terminal export step. 

523 global_region = self.config.get_global_region() 

524 self.sagemaker_execution_role.add_to_policy( 

525 iam.PolicyStatement( 

526 effect=iam.Effect.ALLOW, 

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

528 resources=[ 

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

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

531 ], 

532 ) 

533 ) 

534 

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

536 self.sagemaker_execution_role.add_to_policy( 

537 iam.PolicyStatement( 

538 effect=iam.Effect.ALLOW, 

539 actions=[ 

540 "elasticfilesystem:ClientMount", 

541 "elasticfilesystem:ClientWrite", 

542 "elasticfilesystem:ClientRootAccess", 

543 ], 

544 resources=[self.studio_efs.file_system_arn], 

545 ) 

546 ) 

547 

548 # DescribeMountTargets does not support resource-level scoping — 

549 # SageMaker calls it during user profile provisioning to validate 

550 # the EFS mount configuration. 

551 self.sagemaker_execution_role.add_to_policy( 

552 iam.PolicyStatement( 

553 effect=iam.Effect.ALLOW, 

554 actions=[ 

555 "elasticfilesystem:DescribeMountTargets", 

556 "elasticfilesystem:DescribeFileSystems", 

557 ], 

558 resources=["*"], 

559 ) 

560 ) 

561 

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

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

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

565 self.sagemaker_execution_role.add_to_policy( 

566 iam.PolicyStatement( 

567 effect=iam.Effect.ALLOW, 

568 actions=[ 

569 "sagemaker:DescribeDomain", 

570 "sagemaker:DescribeUserProfile", 

571 "sagemaker:CreatePresignedDomainUrl", 

572 "sagemaker:ListSpaces", 

573 "sagemaker:ListApps", 

574 "sagemaker:DescribeApp", 

575 "sagemaker:DescribeSpace", 

576 "sagemaker:CreateApp", 

577 "sagemaker:DeleteApp", 

578 "sagemaker:CreateSpace", 

579 "sagemaker:DeleteSpace", 

580 "sagemaker:UpdateSpace", 

581 "sagemaker:ListTags", 

582 "sagemaker:AddTags", 

583 ], 

584 resources=[ 

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

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

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

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

589 ], 

590 ) 

591 ) 

592 

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

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

595 self.sagemaker_execution_role.add_to_policy( 

596 iam.PolicyStatement( 

597 effect=iam.Effect.ALLOW, 

598 actions=[ 

599 "emr-serverless:ListApplications", 

600 "emr-serverless:GetApplication", 

601 "emr-serverless:CreateApplication", 

602 "emr-serverless:StartApplication", 

603 "emr-serverless:StopApplication", 

604 "emr-serverless:StartJobRun", 

605 "emr-serverless:GetJobRun", 

606 "emr-serverless:ListJobRuns", 

607 "emr-serverless:CancelJobRun", 

608 "emr-serverless:GetDashboardForJobRun", 

609 "emr-serverless:AccessLivyEndpoints", 

610 ], 

611 resources=["*"], 

612 ) 

613 ) 

614 

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

616 # 

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

618 # for two reasons: 

619 # 

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

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

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

623 # surface is evolving quickly and the managed policy tracks 

624 # it. Enumerating it inline would drift. 

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

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

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

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

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

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

631 # 

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

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

634 # acknowledged with a nag suppression below. The inline 

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

636 # required because the managed policy does NOT cover the 

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

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

639 # ``log_artifact``, ``register_model``, etc. 

640 from gco.stacks.nag_suppressions import suppress_managed_policy_opt_in 

641 

642 self.sagemaker_execution_role.add_managed_policy( 

643 iam.ManagedPolicy.from_aws_managed_policy_name("AmazonSageMakerFullAccess") 

644 ) 

645 suppress_managed_policy_opt_in( 

646 self.sagemaker_execution_role, 

647 managed_policy_name="AmazonSageMakerFullAccess", 

648 reason=( 

649 "AmazonSageMakerFullAccess is attached to " 

650 "SageMaker_Execution_Role when analytics_environment.enabled=true. " 

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

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

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

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

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

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

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

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

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

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

661 "disable the analytics environment." 

662 ), 

663 ) 

664 

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

666 # ``mlflow.log_metric``, ``mlflow.log_artifact``, 

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

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

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

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

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

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

673 # live. 

674 self.sagemaker_execution_role.add_to_policy( 

675 iam.PolicyStatement( 

676 effect=iam.Effect.ALLOW, 

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

678 resources=[ 

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

680 "mlflow-tracking-server/*", 

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

682 ], 

683 ) 

684 ) 

685 

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

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

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

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

690 self.sagemaker_execution_role.add_to_policy( 

691 iam.PolicyStatement( 

692 effect=iam.Effect.ALLOW, 

693 actions=["sts:GetCallerIdentity"], 

694 resources=["*"], 

695 ) 

696 ) 

697 

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

699 # submission and cluster-instance lifecycle management. 

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

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

702 if self.hyperpod_enabled: 

703 self.sagemaker_execution_role.add_to_policy( 

704 iam.PolicyStatement( 

705 effect=iam.Effect.ALLOW, 

706 actions=[ 

707 "sagemaker:CreateTrainingJob", 

708 "sagemaker:DescribeTrainingJob", 

709 "sagemaker:StopTrainingJob", 

710 "sagemaker:ClusterInstance", 

711 "sagemaker:ClusterInstanceGroup", 

712 "sagemaker:DescribeClusterNode", 

713 "sagemaker:ListClusterNodes", 

714 ], 

715 resources=["*"], 

716 ) 

717 ) 

718 

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

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

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

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

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

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

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

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

727 # up new Canvas capabilities automatically without shipping a CDK 

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

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

730 # 

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

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

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

734 if self.canvas_enabled: 

735 self.sagemaker_execution_role.add_managed_policy( 

736 iam.ManagedPolicy.from_aws_managed_policy_name("AmazonSageMakerCanvasFullAccess") 

737 ) 

738 

739 suppress_managed_policy_opt_in( 

740 self.sagemaker_execution_role, 

741 managed_policy_name="AmazonSageMakerCanvasFullAccess", 

742 reason=( 

743 "AmazonSageMakerCanvasFullAccess is attached to " 

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

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

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

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

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

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

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

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

752 ), 

753 ) 

754 

755 # EFS resource policy — must include DescribeMountTargets without 

756 # the AccessedViaMountTarget condition because SageMaker calls it 

757 # during user-profile provisioning. Using AnyPrincipal (AWS:*) 

758 # ensures all account roles (execution role, cleanup Lambda, and 

759 # the SageMaker service) are covered. Security is enforced by the 

760 # VPC security group (NFS traffic only from within the VPC) and 

761 # IAM policies on each role — the resource policy is permissive 

762 # by design to avoid the intersection-model blocking control-plane 

763 # calls. 

764 # Note: DescribeAccessPoints/DescribeFileSystems CANNOT be in EFS 

765 # resource policies (EFS rejects them). Those rely on IAM only. 

766 self.studio_efs.add_to_resource_policy( 

767 iam.PolicyStatement( 

768 effect=iam.Effect.ALLOW, 

769 principals=[iam.AnyPrincipal()], 

770 actions=[ 

771 "elasticfilesystem:ClientMount", 

772 "elasticfilesystem:ClientWrite", 

773 "elasticfilesystem:ClientRootAccess", 

774 "elasticfilesystem:DescribeMountTargets", 

775 "elasticfilesystem:DescribeFileSystems", 

776 "elasticfilesystem:DeleteAccessPoint", 

777 "elasticfilesystem:DeleteMountTarget", 

778 "elasticfilesystem:DeleteFileSystem", 

779 "elasticfilesystem:DeleteFileSystemPolicy", 

780 ], 

781 ) 

782 ) 

783 

784 def _grant_sagemaker_role_on_cluster_shared_bucket(self) -> None: 

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

786 

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

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

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

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

791 

792 Two statements attach to the role: 

793 

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

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

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

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

798 

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

800 exclusively by ``GCOGlobalStack``. 

801 """ 

802 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

803 

804 global_region = self.config.get_global_region() 

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

806 

807 read_cr = cr.AwsCustomResource( 

808 self, 

809 "ReadClusterSharedBucketArn", 

810 on_create=cr.AwsSdkCall( 

811 service="SSM", 

812 action="getParameter", 

813 parameters={"Name": parameter_name}, 

814 region=global_region, 

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

816 ), 

817 on_update=cr.AwsSdkCall( 

818 service="SSM", 

819 action="getParameter", 

820 parameters={"Name": parameter_name}, 

821 region=global_region, 

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

823 ), 

824 policy=cr.AwsCustomResourcePolicy.from_sdk_calls( 

825 resources=cr.AwsCustomResourcePolicy.ANY_RESOURCE 

826 ), 

827 ) 

828 

829 # Scoped suppression: same shape as 

830 # ``GCORegionalStack._resolve_cluster_shared_bucket_from_ssm``. The 

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

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

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

834 acknowledge_nag_findings( 

835 read_cr, 

836 [ 

837 { 

838 "id": "AwsSolutions-IAM5", 

839 "reason": ( 

840 "Cross-region ssm:GetParameter for " 

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

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

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

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

845 "CDK-documented escape hatch because the parameter " 

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

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

848 ), 

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

850 }, 

851 ], 

852 ) 

853 

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

855 

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

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

858 iam.Policy( 

859 self, 

860 "SagemakerClusterSharedBucketGrant", 

861 roles=[self.sagemaker_execution_role], 

862 statements=[ 

863 iam.PolicyStatement( 

864 effect=iam.Effect.ALLOW, 

865 actions=[ 

866 "s3:GetObject", 

867 "s3:PutObject", 

868 "s3:DeleteObject", 

869 "s3:ListBucket", 

870 "s3:GetBucketLocation", 

871 ], 

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

873 ), 

874 iam.PolicyStatement( 

875 effect=iam.Effect.ALLOW, 

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

877 resources=["*"], 

878 conditions={ 

879 "StringEquals": { 

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

881 } 

882 }, 

883 ), 

884 ], 

885 ) 

886 

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

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

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

890 # reason text (bucket-scoped RW). 

891 acknowledge_nag_findings( 

892 self.sagemaker_execution_role, 

893 [ 

894 { 

895 "id": "AwsSolutions-IAM5", 

896 "reason": ( 

897 "The SageMaker RW grant on Cluster_Shared_Bucket " 

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

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

900 "keys within the single always-on " 

901 "gco-cluster-shared-<account>-<region> bucket." 

902 ), 

903 "appliesTo": [ 

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

905 ], 

906 }, 

907 ], 

908 ) 

909 

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

911 # SageMaker Studio domain 

912 # ================================================================== 

913 

914 def _create_studio_domain(self) -> None: 

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

916 

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

918 traffic on the private subnets. 

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

920 :meth:`_create_execution_role_and_grants`. ``CustomImages`` is 

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

922 published Distribution images (a tested invariant). 

923 

924 ``CustomFileSystemConfigs`` mounts ``self.studio_efs`` at 

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

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

927 creates lazily on first login. 

928 """ 

929 private_subnets = self.vpc.select_subnets( 

930 subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS 

931 ).subnets 

932 

933 efs_fs_config = sagemaker.CfnDomain.EFSFileSystemConfigProperty( 

934 file_system_id=self.studio_efs.file_system_id, 

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

936 ) 

937 efs_custom_fs = sagemaker.CfnDomain.CustomFileSystemConfigProperty( 

938 efs_file_system_config=efs_fs_config, 

939 ) 

940 

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

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

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

944 # property and CloudFormation synths it cleanly, but the 

945 # SageMaker Studio service rejects the resource at create time 

946 # with ``Invalid request provided: S3FileSystemConfig for 

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

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

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

950 # cross-region RW grant in 

951 # :meth:`_grant_sagemaker_role_on_cluster_shared_bucket` already 

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

953 # Studio lights up S3 custom file systems. 

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

955 efs_custom_fs, 

956 ] 

957 

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

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

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

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

962 self.studio_compute_sg = ec2.SecurityGroup( 

963 self, 

964 "StudioComputeSg", 

965 vpc=self.vpc, 

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

967 allow_all_outbound=True, 

968 ) 

969 

970 default_user_settings = sagemaker.CfnDomain.UserSettingsProperty( 

971 execution_role=self.sagemaker_execution_role.role_arn, 

972 custom_file_system_configs=custom_file_systems, 

973 security_groups=[self.studio_compute_sg.security_group_id], 

974 # ``jupyter_lab_app_settings`` is deliberately omitted so 

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

976 # SageMaker image resources and no CustomImages 

977 # key on the domain. 

978 ) 

979 

980 self.studio_domain = sagemaker.CfnDomain( 

981 self, 

982 "StudioDomain", 

983 auth_mode="IAM", 

984 app_network_access_type="VpcOnly", 

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

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

987 vpc_id=self.vpc.vpc_id, 

988 kms_key_id=self.kms_key.key_id, 

989 default_user_settings=default_user_settings, 

990 ) 

991 

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

993 # ``AmazonSageMakerCanvasFullAccess`` managed policy attached to 

994 # the SageMaker execution role in 

995 # :meth:`_create_execution_role_and_grants` is sufficient to 

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

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

998 # the entitlement and lights up the Canvas launcher. 

999 # 

1000 # We intentionally do *not* inject a 

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

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

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

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

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

1006 # own default workspace artifact locations; operators who want 

1007 # to pin per-user Canvas defaults can apply 

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

1009 

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

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

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

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

1014 

1015 CfnOutput( 

1016 self, 

1017 "StudioDomainName", 

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

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

1020 ) 

1021 

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

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

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

1025 from aws_cdk import CustomResource 

1026 from aws_cdk import custom_resources as cr_provider 

1027 

1028 cleanup_fn = lambda_.Function( 

1029 self, 

1030 "CleanupFunction", 

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

1032 handler="handler.handler", 

1033 code=lambda_.Code.from_asset("lambda/analytics-cleanup"), 

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

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

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

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

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

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

1040 timeout=Duration.minutes(15), 

1041 environment={ 

1042 "DOMAIN_ID": self.studio_domain.attr_domain_id, 

1043 "EFS_ID": self.studio_efs.file_system_id, 

1044 "REGION": self.region, 

1045 "VPC_ID": self.vpc.vpc_id, 

1046 }, 

1047 ) 

1048 

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

1050 # Inline policies (created by add_to_role_policy) are separate 

1051 # CloudFormation resources that can be deleted before the custom 

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

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

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

1055 cleanup_policy = iam.ManagedPolicy( 

1056 self, 

1057 "CleanupFunctionPolicy", 

1058 statements=[ 

1059 iam.PolicyStatement( 

1060 effect=iam.Effect.ALLOW, 

1061 actions=[ 

1062 "sagemaker:ListApps", 

1063 "sagemaker:DeleteApp", 

1064 "sagemaker:ListSpaces", 

1065 "sagemaker:DeleteSpace", 

1066 "sagemaker:ListUserProfiles", 

1067 "sagemaker:DeleteUserProfile", 

1068 "sagemaker:DescribeDomain", 

1069 "elasticfilesystem:DescribeAccessPoints", 

1070 "elasticfilesystem:DeleteAccessPoint", 

1071 "elasticfilesystem:DescribeFileSystems", 

1072 "elasticfilesystem:DescribeMountTargets", 

1073 "elasticfilesystem:DeleteMountTarget", 

1074 "elasticfilesystem:DeleteFileSystem", 

1075 "elasticfilesystem:DeleteFileSystemPolicy", 

1076 "ec2:DescribeSecurityGroups", 

1077 "ec2:DeleteSecurityGroup", 

1078 "ec2:RevokeSecurityGroupIngress", 

1079 "ec2:RevokeSecurityGroupEgress", 

1080 ], 

1081 resources=["*"], 

1082 ) 

1083 ], 

1084 ) 

1085 assert cleanup_fn.role is not None 

1086 cleanup_fn.role.add_managed_policy(cleanup_policy) 

1087 

1088 cleanup_provider = cr_provider.Provider( 

1089 self, 

1090 "CleanupProvider", 

1091 on_event_handler=cleanup_fn, 

1092 ) 

1093 

1094 cleanup_resource = CustomResource( 

1095 self, 

1096 "DomainCleanup", 

1097 service_token=cleanup_provider.service_token, 

1098 ) 

1099 

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

1101 # custom resource completes. Adding a dependency ensures 

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

1103 cleanup_resource.node.add_dependency(cleanup_policy) 

1104 

1105 # Store reference so _create_presigned_url_lambda can add a 

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

1107 self._cleanup_resource = cleanup_resource 

1108 

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

1110 # because ListUserProfiles/DeleteUserProfile and 

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

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

1113 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

1114 

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

1116 acknowledge_nag_findings( 

1117 cleanup_fn.role, 

1118 [ 

1119 { 

1120 "id": "AwsSolutions-IAM5", 

1121 "reason": ( 

1122 "Cleanup Lambda needs Resource::* for " 

1123 "sagemaker:ListUserProfiles/DeleteUserProfile and " 

1124 "efs:DescribeAccessPoints/DeleteAccessPoint. These " 

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

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

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

1128 ), 

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

1130 }, 

1131 { 

1132 "id": "AwsSolutions-IAM4", 

1133 "reason": ( 

1134 "Cleanup Lambda uses AWSLambdaBasicExecutionRole " 

1135 "managed policy for CloudWatch Logs access." 

1136 ), 

1137 "appliesTo": [ 

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

1139 ], 

1140 }, 

1141 ], 

1142 ) 

1143 acknowledge_nag_findings( 

1144 cleanup_provider, 

1145 [ 

1146 { 

1147 "id": "AwsSolutions-IAM5", 

1148 "reason": ( 

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

1150 "internal Lambda invocation policy." 

1151 ), 

1152 "appliesTo": [ 

1153 "Resource::*", 

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

1155 ], 

1156 }, 

1157 { 

1158 "id": "AwsSolutions-IAM4", 

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

1160 "appliesTo": [ 

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

1162 ], 

1163 }, 

1164 { 

1165 "id": "AwsSolutions-L1", 

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

1167 }, 

1168 ], 

1169 ) 

1170 

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

1172 # EMR Serverless application 

1173 # ================================================================== 

1174 

1175 def _create_emr_app(self) -> None: 

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

1177 

1178 Pinned ``release_label`` lives in 

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

1180 workloads get a reproducible Spark runtime across deployments. The 

1181 application's network configuration uses the private 

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

1183 same network perimeter as the Studio notebooks. 

1184 """ 

1185 private_subnet_ids = [ 

1186 s.subnet_id 

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

1188 ] 

1189 

1190 self.emr_security_group = ec2.SecurityGroup( 

1191 self, 

1192 "EmrServerlessSecurityGroup", 

1193 vpc=self.vpc, 

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

1195 allow_all_outbound=True, 

1196 ) 

1197 

1198 self.emr_app = emrserverless.CfnApplication( 

1199 self, 

1200 "EmrServerlessApp", 

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

1202 release_label=EMR_SERVERLESS_RELEASE_LABEL, 

1203 type="SPARK", 

1204 network_configuration=emrserverless.CfnApplication.NetworkConfigurationProperty( 

1205 subnet_ids=private_subnet_ids, 

1206 security_group_ids=[self.emr_security_group.security_group_id], 

1207 ), 

1208 ) 

1209 

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

1211 # Cognito pool + client + domain 

1212 # ================================================================== 

1213 

1214 def _create_cognito_pool(self) -> None: 

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

1216 

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

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

1219 attached ``UserPoolClient`` runs SRP auth 

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

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

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

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

1224 """ 

1225 self.cognito_pool = cognito.UserPool( 

1226 self, 

1227 "StudioUserPool", 

1228 self_sign_up_enabled=False, 

1229 password_policy=cognito.PasswordPolicy( 

1230 min_length=12, 

1231 require_digits=True, 

1232 require_symbols=True, 

1233 require_uppercase=True, 

1234 require_lowercase=True, 

1235 ), 

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

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

1238 # Replaces the deprecated ``advanced_security_mode`` kwarg 

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

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

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

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

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

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

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

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

1247 # ``StandardThreatProtectionMode.FULL_FUNCTION``. That path 

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

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

1250 standard_threat_protection_mode=(cognito.StandardThreatProtectionMode.NO_ENFORCEMENT), 

1251 removal_policy=self.cognito_removal, 

1252 ) 

1253 

1254 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

1255 

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

1257 acknowledge_nag_findings( 

1258 self.cognito_pool, 

1259 [ 

1260 { 

1261 "id": "AwsSolutions-COG8", 

1262 "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.", 

1263 } 

1264 ], 

1265 ) 

1266 self.cognito_client = self.cognito_pool.add_client( 

1267 "StudioUserPoolClient", 

1268 auth_flows=cognito.AuthFlow( 

1269 user_srp=True, 

1270 admin_user_password=True, 

1271 ), 

1272 prevent_user_existence_errors=True, 

1273 enable_token_revocation=True, 

1274 ) 

1275 

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

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

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

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

1280 # prefix typically want a short memorable value. 

1281 if self._cognito_domain_prefix_override: 1281 ↛ 1282line 1281 didn't jump to line 1282 because the condition on line 1281 was never true

1282 domain_prefix = self._cognito_domain_prefix_override 

1283 else: 

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

1285 

1286 self.cognito_domain = self.cognito_pool.add_domain( 

1287 "StudioUserPoolDomain", 

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

1289 ) 

1290 

1291 CfnOutput( 

1292 self, 

1293 "CognitoUserPoolId", 

1294 value=self.cognito_pool.user_pool_id, 

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

1296 ) 

1297 CfnOutput( 

1298 self, 

1299 "CognitoUserPoolArn", 

1300 value=self.cognito_pool.user_pool_arn, 

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

1302 ) 

1303 CfnOutput( 

1304 self, 

1305 "CognitoUserPoolClientId", 

1306 value=self.cognito_client.user_pool_client_id, 

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

1308 ) 

1309 

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

1311 # Presigned-URL Lambda 

1312 # ================================================================== 

1313 

1314 def _create_presigned_url_lambda(self) -> None: 

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

1316 

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

1318 ``GCOApiGatewayGlobalStack``. The function lives on 

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

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

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

1322 without a cross-stack import. 

1323 

1324 Key configuration: 

1325 

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

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

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

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

1330 error token rather than API Gateway's 504. 

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

1332 ``sagemaker:CreatePresignedDomainUrl`` call. 

1333 * Log group retention: 1 month. 

1334 

1335 IAM scoping: 

1336 

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

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

1339 * ``sagemaker:DescribeDomain`` + ``CreatePresignedDomainUrl`` + 

1340 ``DescribeUserProfile`` + ``CreateUserProfile`` + ``ListTags`` + 

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

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

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

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

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

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

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

1348 * ``elasticfilesystem:DescribeAccessPoints`` + 

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

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

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

1352 Logs + X-Ray write path. 

1353 """ 

1354 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

1355 

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

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

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

1359 # else is an inline policy we own entirely. 

1360 self.presigned_url_lambda_role = iam.Role( 

1361 self, 

1362 "PresignedUrlLambdaRole", 

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

1364 description=( 

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

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

1367 "PassRole on SageMaker_Execution_Role, and EFS access-" 

1368 "point management on Studio_EFS." 

1369 ), 

1370 managed_policies=[ 

1371 iam.ManagedPolicy.from_aws_managed_policy_name( 

1372 "service-role/AWSLambdaBasicExecutionRole" 

1373 ) 

1374 ], 

1375 ) 

1376 

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

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

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

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

1381 self.presigned_url_lambda_role.add_to_policy( 

1382 iam.PolicyStatement( 

1383 effect=iam.Effect.ALLOW, 

1384 actions=["sagemaker:ListDomains"], 

1385 resources=["*"], 

1386 ) 

1387 ) 

1388 

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

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

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

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

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

1394 # this account's SageMaker Studio installation. 

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

1396 user_profile_arn_prefix = ( 

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

1398 ) 

1399 self.presigned_url_lambda_role.add_to_policy( 

1400 iam.PolicyStatement( 

1401 effect=iam.Effect.ALLOW, 

1402 actions=[ 

1403 "sagemaker:DescribeDomain", 

1404 "sagemaker:CreatePresignedDomainUrl", 

1405 "sagemaker:DescribeUserProfile", 

1406 "sagemaker:CreateUserProfile", 

1407 "sagemaker:ListTags", 

1408 "sagemaker:AddTags", 

1409 ], 

1410 resources=[domain_arn_prefix, user_profile_arn_prefix], 

1411 ) 

1412 ) 

1413 

1414 # iam:PassRole — only SageMaker_Execution_Role, only to 

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

1416 # on the ``ExecutionRole`` field. 

1417 self.presigned_url_lambda_role.add_to_policy( 

1418 iam.PolicyStatement( 

1419 effect=iam.Effect.ALLOW, 

1420 actions=["iam:PassRole"], 

1421 resources=[self.sagemaker_execution_role.role_arn], 

1422 conditions={ 

1423 "StringEquals": { 

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

1425 } 

1426 }, 

1427 ) 

1428 ) 

1429 

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

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

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

1433 self.presigned_url_lambda_role.add_to_policy( 

1434 iam.PolicyStatement( 

1435 effect=iam.Effect.ALLOW, 

1436 actions=[ 

1437 "elasticfilesystem:DescribeAccessPoints", 

1438 "elasticfilesystem:CreateAccessPoint", 

1439 "elasticfilesystem:TagResource", 

1440 ], 

1441 resources=[self.studio_efs.file_system_arn], 

1442 ) 

1443 ) 

1444 

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

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

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

1448 presigned_url_log_group = logs.LogGroup( 

1449 self, 

1450 "PresignedUrlLambdaLogGroup", 

1451 retention=logs.RetentionDays.ONE_MONTH, 

1452 removal_policy=RemovalPolicy.DESTROY, 

1453 ) 

1454 

1455 self.presigned_url_lambda = lambda_.Function( 

1456 self, 

1457 "PresignedUrlFunction", 

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

1459 handler="handler.lambda_handler", 

1460 code=lambda_.Code.from_asset("lambda/analytics-presigned-url"), 

1461 role=self.presigned_url_lambda_role, 

1462 timeout=Duration.seconds(29), 

1463 memory_size=256, 

1464 tracing=lambda_.Tracing.ACTIVE, 

1465 log_group=presigned_url_log_group, 

1466 description=( 

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

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

1469 "GCOApiGatewayGlobalStack." 

1470 ), 

1471 environment={ 

1472 "STUDIO_DOMAIN_ID": self.studio_domain.attr_domain_id, 

1473 "SAGEMAKER_EXECUTION_ROLE_ARN": self.sagemaker_execution_role.role_arn, 

1474 "STUDIO_EFS_ID": self.studio_efs.file_system_id, 

1475 "URL_EXPIRES_SECONDS": "300", 

1476 "SESSION_EXPIRES_SECONDS": "43200", 

1477 }, 

1478 ) 

1479 

1480 CfnOutput( 

1481 self, 

1482 "PresignedUrlLambdaArn", 

1483 value=self.presigned_url_lambda.function_arn, 

1484 description=( 

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

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

1487 ), 

1488 ) 

1489 

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

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

1492 # tighter scoping isn't possible. 

1493 acknowledge_nag_findings( 

1494 self.presigned_url_lambda_role, 

1495 [ 

1496 { 

1497 "id": "AwsSolutions-IAM5", 

1498 "reason": ( 

1499 "sagemaker:ListDomains does not support resource-" 

1500 "level scoping — the AWS API only accepts " 

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

1502 "paginated list call per Lambda invocation " 

1503 "against this account's SageMaker control plane " 

1504 "in this region. The remaining SageMaker actions " 

1505 "(DescribeDomain, CreatePresignedDomainUrl, " 

1506 "DescribeUserProfile, CreateUserProfile, " 

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

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

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

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

1511 "tightest we can achieve at synth time because " 

1512 "DomainId is only resolvable at invoke time." 

1513 ), 

1514 "appliesTo": [ 

1515 "Resource::*", 

1516 ( 

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

1518 ), 

1519 ( 

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

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

1522 ), 

1523 ], 

1524 }, 

1525 ], 

1526 ) 

1527 

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

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

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

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

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

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

1534 

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

1536 # Nag suppressions 

1537 # ================================================================== 

1538 

1539 def _apply_nag_suppressions(self) -> None: 

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

1541 

1542 The analytics branch calls ``add_sagemaker_suppressions``, 

1543 ``add_cognito_suppressions``, ``add_emr_serverless_suppressions``, 

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

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

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

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

1548 """ 

1549 apply_all_suppressions( 

1550 self, 

1551 stack_type="analytics", 

1552 regions=None, 

1553 global_region=self.config.get_global_region(), 

1554 api_gateway_region=self.config.get_api_gateway_region(), 

1555 project_name=self.project_name, 

1556 )