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

164 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-09-14 22:07 +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-09-12T06:04:03Z 

61# Generated from Git commit: e96e2c39c3626a5088651f43873dfade6a346850 

62# Flowchart(s) generated from this file: 

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

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

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

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

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

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

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

70# <pyflowchart-code-diagram> END 

71 

72 

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

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

75 

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

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

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

79 """ 

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

81 if normalized == "retain": 

82 return RemovalPolicy.RETAIN 

83 if normalized == "destroy": 

84 return RemovalPolicy.DESTROY 

85 raise ValueError( 

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

87 ) 

88 

89 

90class GCOAnalyticsStack(Stack): 

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

92 

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

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

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

96 a cross-region hop. 

97 """ 

98 

99 def __init__( 

100 self, 

101 scope: Construct, 

102 construct_id: str, 

103 *, 

104 config: ConfigLoader, 

105 api_gateway_secret_arn: str | None = None, 

106 **kwargs: Any, 

107 ) -> None: 

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

109 

110 self.config = config 

111 self.project_name = config.get_project_name() 

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

113 # accepted now so the constructor signature is stable. 

114 self.api_gateway_secret_arn = api_gateway_secret_arn 

115 

116 cfg = config.get_analytics_config() 

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

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

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

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

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

122 

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

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

125 self._create_kms_key() 

126 self._create_vpc_and_endpoints() 

127 self._create_access_logs_bucket() 

128 self._create_studio_only_bucket() 

129 self._create_studio_efs() 

130 self._create_execution_role_and_grants() 

131 self._grant_sagemaker_role_on_cluster_shared_bucket() 

132 self._create_studio_domain() 

133 self._create_emr_app() 

134 self._create_cognito_pool() 

135 self._create_presigned_url_lambda() 

136 self._apply_nag_suppressions() 

137 

138 # ================================================================== 

139 # KMS + VPC 

140 # ================================================================== 

141 

142 def _create_kms_key(self) -> None: 

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

144 

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

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

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

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

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

150 """ 

151 self.kms_key = kms.Key( 

152 self, 

153 "AnalyticsKmsKey", 

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

155 enable_key_rotation=True, 

156 pending_window=Duration.days(7), 

157 removal_policy=RemovalPolicy.DESTROY, 

158 ) 

159 

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

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

162 service_principals = [ 

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

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

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

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

167 ] 

168 for principal, region in service_principals: 

169 self.kms_key.add_to_resource_policy( 

170 iam.PolicyStatement( 

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

172 effect=iam.Effect.ALLOW, 

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

174 actions=[ 

175 "kms:Encrypt", 

176 "kms:Decrypt", 

177 "kms:ReEncrypt*", 

178 "kms:GenerateDataKey*", 

179 "kms:DescribeKey", 

180 ], 

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

182 ) 

183 ) 

184 

185 def _create_vpc_and_endpoints(self) -> None: 

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

187 

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

189 The nine interface endpoints plus the S3 gateway endpoint 

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

191 gateway provides internet egress so notebooks can pip install, 

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

193 """ 

194 self.vpc = ec2.Vpc( 

195 self, 

196 "AnalyticsVpc", 

197 max_azs=2, 

198 nat_gateways=1, 

199 subnet_configuration=[ 

200 ec2.SubnetConfiguration( 

201 name="AnalyticsPrivate", 

202 subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS, 

203 cidr_mask=24, 

204 ), 

205 ec2.SubnetConfiguration( 

206 name="AnalyticsPublic", 

207 subnet_type=ec2.SubnetType.PUBLIC, 

208 cidr_mask=28, 

209 ), 

210 ], 

211 ) 

212 

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

214 self.vpc.add_gateway_endpoint( 

215 "S3GatewayEndpoint", 

216 service=ec2.GatewayVpcEndpointAwsService.S3, 

217 ) 

218 

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

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

221 # VPC-endpoint security group. 

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

223 "SagemakerApiEndpoint": ec2.InterfaceVpcEndpointAwsService.SAGEMAKER_API, 

224 "SagemakerRuntimeEndpoint": ec2.InterfaceVpcEndpointAwsService.SAGEMAKER_RUNTIME, 

225 "SagemakerStudioEndpoint": ec2.InterfaceVpcEndpointAwsService.SAGEMAKER_STUDIO, 

226 "SagemakerNotebookEndpoint": ec2.InterfaceVpcEndpointAwsService.SAGEMAKER_NOTEBOOK, 

227 "StsEndpoint": ec2.InterfaceVpcEndpointAwsService.STS, 

228 "CloudWatchLogsEndpoint": ec2.InterfaceVpcEndpointAwsService.CLOUDWATCH_LOGS, 

229 "EcrEndpoint": ec2.InterfaceVpcEndpointAwsService.ECR, 

230 "EcrDockerEndpoint": ec2.InterfaceVpcEndpointAwsService.ECR_DOCKER, 

231 "EfsEndpoint": ec2.InterfaceVpcEndpointAwsService.ELASTIC_FILESYSTEM, 

232 } 

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

234 self.vpc.add_interface_endpoint( 

235 construct_id, 

236 service=service, 

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

238 ) 

239 

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

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

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

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

244 from gco.stacks.nag_suppressions import acknowledge_security_group_cidr_findings 

245 

246 acknowledge_security_group_cidr_findings( 

247 self.vpc, 

248 reason=( 

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

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

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

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

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

254 "endpoint access." 

255 ), 

256 ) 

257 

258 # ================================================================== 

259 # S3 buckets 

260 # ================================================================== 

261 

262 def _create_access_logs_bucket(self) -> None: 

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

264 

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

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

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

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

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

270 itself is scoped on the bucket construct by 

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

272 """ 

273 self.access_logs_bucket = s3.Bucket( 

274 self, 

275 "AnalyticsAccessLogsBucket", 

276 encryption=s3.BucketEncryption.S3_MANAGED, 

277 block_public_access=s3.BlockPublicAccess.BLOCK_ALL, 

278 enforce_ssl=True, 

279 versioned=True, 

280 removal_policy=RemovalPolicy.DESTROY, 

281 auto_delete_objects=True, 

282 lifecycle_rules=[ 

283 s3.LifecycleRule( 

284 id="ExpireAccessLogs", 

285 enabled=True, 

286 expiration=Duration.days(90), 

287 ) 

288 ], 

289 ) 

290 

291 def _create_studio_only_bucket(self) -> None: 

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

293 

294 The physical name is CloudFormation-generated 

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

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

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

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

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

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

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

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

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

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

305 — no other principal is granted access. 

306 """ 

307 self.studio_only_bucket = s3.Bucket( 

308 self, 

309 "StudioOnlyBucket", 

310 encryption=s3.BucketEncryption.KMS, 

311 encryption_key=self.kms_key, 

312 bucket_key_enabled=True, 

313 block_public_access=s3.BlockPublicAccess.BLOCK_ALL, 

314 enforce_ssl=True, 

315 versioned=True, 

316 removal_policy=RemovalPolicy.DESTROY, 

317 auto_delete_objects=True, 

318 server_access_logs_bucket=self.access_logs_bucket, 

319 server_access_logs_prefix="studio-only/", 

320 ) 

321 

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

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

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

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

326 self.studio_only_bucket.add_to_resource_policy( 

327 iam.PolicyStatement( 

328 sid="DenyInsecureTransport", 

329 effect=iam.Effect.DENY, 

330 principals=[iam.AnyPrincipal()], 

331 actions=["s3:*"], 

332 resources=[ 

333 self.studio_only_bucket.bucket_arn, 

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

335 ], 

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

337 ) 

338 ) 

339 

340 # ================================================================== 

341 # Studio EFS 

342 # ================================================================== 

343 

344 def _create_studio_efs(self) -> None: 

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

346 

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

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

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

350 until the Lambda materializes a per-user AP. 

351 

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

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

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

355 """ 

356 self.studio_efs_security_group = ec2.SecurityGroup( 

357 self, 

358 "StudioEfsSecurityGroup", 

359 vpc=self.vpc, 

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

361 allow_all_outbound=False, 

362 ) 

363 self.studio_efs_security_group.add_ingress_rule( 

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

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

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

367 ) 

368 

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

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

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

372 from gco.stacks.nag_suppressions import acknowledge_security_group_cidr_findings 

373 

374 acknowledge_security_group_cidr_findings( 

375 self.studio_efs_security_group, 

376 reason=( 

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

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

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

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

381 "subnet that mounts the file system." 

382 ), 

383 ) 

384 

385 self.studio_efs = efs.FileSystem( 

386 self, 

387 "StudioEfs", 

388 vpc=self.vpc, 

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

390 encrypted=True, 

391 kms_key=self.kms_key, 

392 enable_automatic_backups=True, 

393 removal_policy=self.efs_removal, 

394 security_group=self.studio_efs_security_group, 

395 ) 

396 

397 # ================================================================== 

398 # SageMaker execution role + grants 

399 # ================================================================== 

400 

401 def _create_execution_role_and_grants(self) -> None: 

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

403 

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

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

406 attached here: 

407 

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

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

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

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

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

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

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

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

416 scoped to the EFS ARN) 

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

418 * AWS-managed ``AmazonSageMakerCanvasFullAccess`` when 

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

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

421 whenever analytics is enabled. Covers the full SageMaker 

422 control-plane surface including MLflow Apps 

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

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

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

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

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

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

429 because the managed policy does not cover the 

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

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

432 managed policy replaces our previous enumerated 

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

434 

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

436 (:meth:`_grant_sagemaker_role_on_cluster_shared_bucket`) because the 

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

438 """ 

439 self.sagemaker_execution_role = iam.Role( 

440 self, 

441 "SagemakerExecutionRole", 

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

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

444 description=( 

445 "SageMaker_Execution_Role - assumed by notebooks in the Studio " 

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

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

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

449 "on the Cluster_Shared_Bucket metadata parameters." 

450 ), 

451 ) 

452 

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

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

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

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

457 self.studio_only_bucket.grant_read_write(self.sagemaker_execution_role) 

458 self.kms_key.grant_encrypt_decrypt(self.sagemaker_execution_role) 

459 

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

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

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

463 self.kms_key.grant( 

464 self.sagemaker_execution_role, 

465 "kms:CreateGrant", 

466 "kms:DescribeKey", 

467 ) 

468 

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

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

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

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

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

474 # tear things down from inside a notebook without bouncing 

475 # through a service account. 

476 # 

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

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

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

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

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

482 api_gw_region = self.config.get_api_gateway_region() 

483 self.sagemaker_execution_role.add_to_policy( 

484 iam.PolicyStatement( 

485 effect=iam.Effect.ALLOW, 

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

487 resources=[ 

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

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

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

491 # users go through their own Cognito-authorized 

492 # ``/studio/login`` route. 

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

494 "*/prod/*/api/v1/*", 

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

496 # for in-cluster model endpoints — notebooks need 

497 # the full method surface here too. 

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

499 "*/prod/*/inference/*", 

500 ], 

501 ) 

502 ) 

503 

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

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

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

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

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

509 project_name = self.config.get_project_name() 

510 self.sagemaker_execution_role.add_to_policy( 

511 iam.PolicyStatement( 

512 effect=iam.Effect.ALLOW, 

513 actions=["sqs:SendMessage"], 

514 resources=[ 

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

516 ], 

517 ) 

518 ) 

519 

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

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

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

523 # to the cluster_shared_ssm_parameter_prefix(project_name) tree under 

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

525 # runtime via 

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

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

528 # without any JupyterLab-terminal export step. 

529 global_region = self.config.get_global_region() 

530 self.sagemaker_execution_role.add_to_policy( 

531 iam.PolicyStatement( 

532 effect=iam.Effect.ALLOW, 

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

534 resources=[ 

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

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

537 ], 

538 ) 

539 ) 

540 

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

542 self.sagemaker_execution_role.add_to_policy( 

543 iam.PolicyStatement( 

544 effect=iam.Effect.ALLOW, 

545 actions=[ 

546 "elasticfilesystem:ClientMount", 

547 "elasticfilesystem:ClientWrite", 

548 "elasticfilesystem:ClientRootAccess", 

549 ], 

550 resources=[self.studio_efs.file_system_arn], 

551 ) 

552 ) 

553 

554 # DescribeMountTargets does not support resource-level scoping — 

555 # SageMaker calls it during user profile provisioning to validate 

556 # the EFS mount configuration. 

557 self.sagemaker_execution_role.add_to_policy( 

558 iam.PolicyStatement( 

559 effect=iam.Effect.ALLOW, 

560 actions=[ 

561 "elasticfilesystem:DescribeMountTargets", 

562 "elasticfilesystem:DescribeFileSystems", 

563 ], 

564 resources=["*"], 

565 ) 

566 ) 

567 

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

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

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

571 self.sagemaker_execution_role.add_to_policy( 

572 iam.PolicyStatement( 

573 effect=iam.Effect.ALLOW, 

574 actions=[ 

575 "sagemaker:DescribeDomain", 

576 "sagemaker:DescribeUserProfile", 

577 "sagemaker:CreatePresignedDomainUrl", 

578 "sagemaker:ListSpaces", 

579 "sagemaker:ListApps", 

580 "sagemaker:DescribeApp", 

581 "sagemaker:DescribeSpace", 

582 "sagemaker:CreateApp", 

583 "sagemaker:DeleteApp", 

584 "sagemaker:CreateSpace", 

585 "sagemaker:DeleteSpace", 

586 "sagemaker:UpdateSpace", 

587 "sagemaker:ListTags", 

588 "sagemaker:AddTags", 

589 ], 

590 resources=[ 

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

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

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

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

595 ], 

596 ) 

597 ) 

598 

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

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

601 self.sagemaker_execution_role.add_to_policy( 

602 iam.PolicyStatement( 

603 effect=iam.Effect.ALLOW, 

604 actions=[ 

605 "emr-serverless:ListApplications", 

606 "emr-serverless:GetApplication", 

607 "emr-serverless:CreateApplication", 

608 "emr-serverless:StartApplication", 

609 "emr-serverless:StopApplication", 

610 "emr-serverless:StartJobRun", 

611 "emr-serverless:GetJobRun", 

612 "emr-serverless:ListJobRuns", 

613 "emr-serverless:CancelJobRun", 

614 "emr-serverless:GetDashboardForJobRun", 

615 "emr-serverless:AccessLivyEndpoints", 

616 ], 

617 resources=["*"], 

618 ) 

619 ) 

620 

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

622 # 

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

624 # for two reasons: 

625 # 

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

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

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

629 # surface is evolving quickly and the managed policy tracks 

630 # it. Enumerating it inline would drift. 

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

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

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

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

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

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

637 # 

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

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

640 # acknowledged with a nag suppression below. The inline 

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

642 # required because the managed policy does NOT cover the 

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

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

645 # ``log_artifact``, ``register_model``, etc. 

646 from gco.stacks.nag_suppressions import suppress_managed_policy_opt_in 

647 

648 self.sagemaker_execution_role.add_managed_policy( 

649 iam.ManagedPolicy.from_aws_managed_policy_name("AmazonSageMakerFullAccess") 

650 ) 

651 suppress_managed_policy_opt_in( 

652 self.sagemaker_execution_role, 

653 managed_policy_name="AmazonSageMakerFullAccess", 

654 reason=( 

655 "AmazonSageMakerFullAccess is attached to " 

656 "SageMaker_Execution_Role when analytics_environment.enabled=true. " 

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

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

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

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

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

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

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

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

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

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

667 "disable the analytics environment." 

668 ), 

669 ) 

670 

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

672 # ``mlflow.log_metric``, ``mlflow.log_artifact``, 

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

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

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

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

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

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

679 # live. 

680 self.sagemaker_execution_role.add_to_policy( 

681 iam.PolicyStatement( 

682 effect=iam.Effect.ALLOW, 

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

684 resources=[ 

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

686 "mlflow-tracking-server/*", 

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

688 ], 

689 ) 

690 ) 

691 

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

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

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

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

696 self.sagemaker_execution_role.add_to_policy( 

697 iam.PolicyStatement( 

698 effect=iam.Effect.ALLOW, 

699 actions=["sts:GetCallerIdentity"], 

700 resources=["*"], 

701 ) 

702 ) 

703 

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

705 # submission and cluster-instance lifecycle management. 

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

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

708 if self.hyperpod_enabled: 

709 self.sagemaker_execution_role.add_to_policy( 

710 iam.PolicyStatement( 

711 effect=iam.Effect.ALLOW, 

712 actions=[ 

713 "sagemaker:CreateTrainingJob", 

714 "sagemaker:DescribeTrainingJob", 

715 "sagemaker:StopTrainingJob", 

716 "sagemaker:ClusterInstance", 

717 "sagemaker:ClusterInstanceGroup", 

718 "sagemaker:DescribeClusterNode", 

719 "sagemaker:ListClusterNodes", 

720 ], 

721 resources=["*"], 

722 ) 

723 ) 

724 

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

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

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

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

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

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

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

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

733 # up new Canvas capabilities automatically without shipping a CDK 

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

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

736 # 

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

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

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

740 if self.canvas_enabled: 

741 self.sagemaker_execution_role.add_managed_policy( 

742 iam.ManagedPolicy.from_aws_managed_policy_name("AmazonSageMakerCanvasFullAccess") 

743 ) 

744 

745 suppress_managed_policy_opt_in( 

746 self.sagemaker_execution_role, 

747 managed_policy_name="AmazonSageMakerCanvasFullAccess", 

748 reason=( 

749 "AmazonSageMakerCanvasFullAccess is attached to " 

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

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

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

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

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

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

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

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

758 ), 

759 ) 

760 

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

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

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

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

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

766 self.studio_efs.add_to_resource_policy( 

767 iam.PolicyStatement( 

768 effect=iam.Effect.ALLOW, 

769 principals=[self.sagemaker_execution_role], 

770 actions=[ 

771 "elasticfilesystem:ClientMount", 

772 "elasticfilesystem:ClientWrite", 

773 "elasticfilesystem:ClientRootAccess", 

774 ], 

775 conditions={ 

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

777 }, 

778 ) 

779 ) 

780 

781 def _grant_sagemaker_role_on_cluster_shared_bucket(self) -> None: 

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

783 

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

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

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

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

788 

789 Two statements attach to the role: 

790 

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

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

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

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

795 

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

797 exclusively by ``GCOGlobalStack``. 

798 """ 

799 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

800 

801 global_region = self.config.get_global_region() 

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

803 

804 read_cr = cr.AwsCustomResource( 

805 self, 

806 "ReadClusterSharedBucketArn", 

807 on_create=cr.AwsSdkCall( 

808 service="SSM", 

809 action="getParameter", 

810 parameters={"Name": parameter_name}, 

811 region=global_region, 

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

813 ), 

814 on_update=cr.AwsSdkCall( 

815 service="SSM", 

816 action="getParameter", 

817 parameters={"Name": parameter_name}, 

818 region=global_region, 

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

820 ), 

821 policy=cr.AwsCustomResourcePolicy.from_sdk_calls( 

822 resources=cr.AwsCustomResourcePolicy.ANY_RESOURCE 

823 ), 

824 ) 

825 

826 # Scoped suppression: same shape as 

827 # ``GCORegionalStack._resolve_cluster_shared_bucket_from_ssm``. The 

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

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

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

831 acknowledge_nag_findings( 

832 read_cr, 

833 [ 

834 { 

835 "id": "AwsSolutions-IAM5", 

836 "reason": ( 

837 "Cross-region ssm:GetParameter for " 

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

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

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

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

842 "CDK-documented escape hatch because the parameter " 

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

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

845 ), 

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

847 }, 

848 ], 

849 ) 

850 

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

852 

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

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

855 iam.Policy( 

856 self, 

857 "SagemakerClusterSharedBucketGrant", 

858 roles=[self.sagemaker_execution_role], 

859 statements=[ 

860 iam.PolicyStatement( 

861 effect=iam.Effect.ALLOW, 

862 actions=[ 

863 "s3:GetObject", 

864 "s3:PutObject", 

865 "s3:DeleteObject", 

866 "s3:ListBucket", 

867 "s3:GetBucketLocation", 

868 ], 

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

870 ), 

871 iam.PolicyStatement( 

872 effect=iam.Effect.ALLOW, 

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

874 resources=["*"], 

875 conditions={ 

876 "StringEquals": { 

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

878 } 

879 }, 

880 ), 

881 ], 

882 ) 

883 

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

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

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

887 # reason text (bucket-scoped RW). 

888 acknowledge_nag_findings( 

889 self.sagemaker_execution_role, 

890 [ 

891 { 

892 "id": "AwsSolutions-IAM5", 

893 "reason": ( 

894 "The SageMaker RW grant on Cluster_Shared_Bucket " 

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

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

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

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

899 ), 

900 "appliesTo": [ 

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

902 ], 

903 }, 

904 ], 

905 ) 

906 

907 # ================================================================== 

908 # SageMaker Studio domain 

909 # ================================================================== 

910 

911 def _create_studio_domain(self) -> None: 

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

913 

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

915 traffic on the private subnets. 

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

917 :meth:`_create_execution_role_and_grants`. ``CustomImages`` is 

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

919 published Distribution images (a tested invariant). 

920 

921 ``CustomFileSystemConfigs`` mounts ``self.studio_efs`` at 

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

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

924 creates lazily on first login. 

925 """ 

926 private_subnets = self.vpc.select_subnets( 

927 subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS 

928 ).subnets 

929 

930 efs_fs_config = sagemaker.CfnDomain.EFSFileSystemConfigProperty( 

931 file_system_id=self.studio_efs.file_system_id, 

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

933 ) 

934 efs_custom_fs = sagemaker.CfnDomain.CustomFileSystemConfigProperty( 

935 efs_file_system_config=efs_fs_config, 

936 ) 

937 

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

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

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

941 # property and CloudFormation synths it cleanly, but the 

942 # SageMaker Studio service rejects the resource at create time 

943 # with ``Invalid request provided: S3FileSystemConfig for 

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

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

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

947 # cross-region RW grant in 

948 # :meth:`_grant_sagemaker_role_on_cluster_shared_bucket` already 

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

950 # Studio lights up S3 custom file systems. 

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

952 efs_custom_fs, 

953 ] 

954 

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

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

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

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

959 self.studio_compute_sg = ec2.SecurityGroup( 

960 self, 

961 "StudioComputeSg", 

962 vpc=self.vpc, 

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

964 allow_all_outbound=True, 

965 ) 

966 

967 default_user_settings = sagemaker.CfnDomain.UserSettingsProperty( 

968 execution_role=self.sagemaker_execution_role.role_arn, 

969 custom_file_system_configs=custom_file_systems, 

970 security_groups=[self.studio_compute_sg.security_group_id], 

971 # ``jupyter_lab_app_settings`` is deliberately omitted so 

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

973 # SageMaker image resources and no CustomImages 

974 # key on the domain. 

975 ) 

976 

977 self.studio_domain = sagemaker.CfnDomain( 

978 self, 

979 "StudioDomain", 

980 auth_mode="IAM", 

981 app_network_access_type="VpcOnly", 

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

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

984 vpc_id=self.vpc.vpc_id, 

985 kms_key_id=self.kms_key.key_id, 

986 default_user_settings=default_user_settings, 

987 ) 

988 

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

990 # ``AmazonSageMakerCanvasFullAccess`` managed policy attached to 

991 # the SageMaker execution role in 

992 # :meth:`_create_execution_role_and_grants` is sufficient to 

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

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

995 # the entitlement and lights up the Canvas launcher. 

996 # 

997 # We intentionally do *not* inject a 

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

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

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

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

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

1003 # own default workspace artifact locations; operators who want 

1004 # to pin per-user Canvas defaults can apply 

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

1006 

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

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

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

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

1011 

1012 CfnOutput( 

1013 self, 

1014 "StudioDomainName", 

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

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

1017 ) 

1018 

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

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

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

1022 from aws_cdk import CustomResource 

1023 from aws_cdk import custom_resources as cr_provider 

1024 

1025 cleanup_fn = lambda_.Function( 

1026 self, 

1027 "CleanupFunction", 

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

1029 handler="handler.handler", 

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

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

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

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

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

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

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

1037 timeout=Duration.minutes(15), 

1038 environment={ 

1039 "DOMAIN_ID": self.studio_domain.attr_domain_id, 

1040 "EFS_ID": self.studio_efs.file_system_id, 

1041 "REGION": self.region, 

1042 "VPC_ID": self.vpc.vpc_id, 

1043 }, 

1044 ) 

1045 

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

1047 # Inline policies (created by add_to_role_policy) are separate 

1048 # CloudFormation resources that can be deleted before the custom 

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

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

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

1052 cleanup_policy = iam.ManagedPolicy( 

1053 self, 

1054 "CleanupFunctionPolicy", 

1055 statements=[ 

1056 iam.PolicyStatement( 

1057 effect=iam.Effect.ALLOW, 

1058 actions=[ 

1059 "sagemaker:ListApps", 

1060 "sagemaker:DeleteApp", 

1061 "sagemaker:ListSpaces", 

1062 "sagemaker:DeleteSpace", 

1063 "sagemaker:ListUserProfiles", 

1064 "sagemaker:DeleteUserProfile", 

1065 "sagemaker:DescribeDomain", 

1066 "elasticfilesystem:DescribeAccessPoints", 

1067 "elasticfilesystem:DeleteAccessPoint", 

1068 "elasticfilesystem:DescribeFileSystems", 

1069 "elasticfilesystem:DescribeMountTargets", 

1070 "elasticfilesystem:DeleteMountTarget", 

1071 "elasticfilesystem:DeleteFileSystem", 

1072 "elasticfilesystem:DeleteFileSystemPolicy", 

1073 "ec2:DescribeSecurityGroups", 

1074 "ec2:DeleteSecurityGroup", 

1075 "ec2:RevokeSecurityGroupIngress", 

1076 "ec2:RevokeSecurityGroupEgress", 

1077 ], 

1078 resources=["*"], 

1079 ) 

1080 ], 

1081 ) 

1082 assert cleanup_fn.role is not None 

1083 cleanup_fn.role.add_managed_policy(cleanup_policy) 

1084 

1085 cleanup_provider = cr_provider.Provider( 

1086 self, 

1087 "CleanupProvider", 

1088 on_event_handler=cleanup_fn, 

1089 ) 

1090 

1091 cleanup_resource = CustomResource( 

1092 self, 

1093 "DomainCleanup", 

1094 service_token=cleanup_provider.service_token, 

1095 ) 

1096 

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

1098 # custom resource completes. Adding a dependency ensures 

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

1100 cleanup_resource.node.add_dependency(cleanup_policy) 

1101 

1102 # Store reference so _create_presigned_url_lambda can add a 

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

1104 self._cleanup_resource = cleanup_resource 

1105 

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

1107 # because ListUserProfiles/DeleteUserProfile and 

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

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

1110 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

1111 

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

1113 acknowledge_nag_findings( 

1114 cleanup_fn.role, 

1115 [ 

1116 { 

1117 "id": "AwsSolutions-IAM5", 

1118 "reason": ( 

1119 "Cleanup Lambda needs Resource::* for " 

1120 "sagemaker:ListUserProfiles/DeleteUserProfile and " 

1121 "efs:DescribeAccessPoints/DeleteAccessPoint. These " 

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

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

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

1125 ), 

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

1127 }, 

1128 { 

1129 "id": "AwsSolutions-IAM4", 

1130 "reason": ( 

1131 "Cleanup Lambda uses AWSLambdaBasicExecutionRole " 

1132 "managed policy for CloudWatch Logs access." 

1133 ), 

1134 "appliesTo": [ 

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

1136 ], 

1137 }, 

1138 ], 

1139 ) 

1140 acknowledge_nag_findings( 

1141 cleanup_provider, 

1142 [ 

1143 { 

1144 "id": "AwsSolutions-IAM5", 

1145 "reason": ( 

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

1147 "internal Lambda invocation policy." 

1148 ), 

1149 "appliesTo": [ 

1150 "Resource::*", 

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

1152 ], 

1153 }, 

1154 { 

1155 "id": "AwsSolutions-IAM4", 

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

1157 "appliesTo": [ 

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

1159 ], 

1160 }, 

1161 { 

1162 "id": "AwsSolutions-L1", 

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

1164 }, 

1165 ], 

1166 ) 

1167 

1168 # ================================================================== 

1169 # EMR Serverless application 

1170 # ================================================================== 

1171 

1172 def _create_emr_app(self) -> None: 

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

1174 

1175 Pinned ``release_label`` lives in 

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

1177 workloads get a reproducible Spark runtime across deployments. The 

1178 application's network configuration uses the private 

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

1180 same network perimeter as the Studio notebooks. 

1181 """ 

1182 private_subnet_ids = [ 

1183 s.subnet_id 

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

1185 ] 

1186 

1187 self.emr_security_group = ec2.SecurityGroup( 

1188 self, 

1189 "EmrServerlessSecurityGroup", 

1190 vpc=self.vpc, 

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

1192 allow_all_outbound=True, 

1193 ) 

1194 

1195 self.emr_app = emrserverless.CfnApplication( 

1196 self, 

1197 "EmrServerlessApp", 

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

1199 release_label=EMR_SERVERLESS_RELEASE_LABEL, 

1200 type="SPARK", 

1201 network_configuration=emrserverless.CfnApplication.NetworkConfigurationProperty( 

1202 subnet_ids=private_subnet_ids, 

1203 security_group_ids=[self.emr_security_group.security_group_id], 

1204 ), 

1205 ) 

1206 

1207 # ================================================================== 

1208 # Cognito pool + client + domain 

1209 # ================================================================== 

1210 

1211 def _create_cognito_pool(self) -> None: 

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

1213 

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

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

1216 attached ``UserPoolClient`` runs SRP auth 

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

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

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

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

1221 """ 

1222 self.cognito_pool = cognito.UserPool( 

1223 self, 

1224 "StudioUserPool", 

1225 self_sign_up_enabled=False, 

1226 password_policy=cognito.PasswordPolicy( 

1227 min_length=12, 

1228 require_digits=True, 

1229 require_symbols=True, 

1230 require_uppercase=True, 

1231 require_lowercase=True, 

1232 ), 

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

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

1235 # Replaces the deprecated ``advanced_security_mode`` kwarg 

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

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

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

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

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

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

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

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

1244 # ``StandardThreatProtectionMode.FULL_FUNCTION``. That path 

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

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

1247 standard_threat_protection_mode=(cognito.StandardThreatProtectionMode.NO_ENFORCEMENT), 

1248 removal_policy=self.cognito_removal, 

1249 ) 

1250 

1251 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

1252 

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

1254 acknowledge_nag_findings( 

1255 self.cognito_pool, 

1256 [ 

1257 { 

1258 "id": "AwsSolutions-COG8", 

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

1260 } 

1261 ], 

1262 ) 

1263 self.cognito_client = self.cognito_pool.add_client( 

1264 "StudioUserPoolClient", 

1265 auth_flows=cognito.AuthFlow( 

1266 user_srp=True, 

1267 admin_user_password=True, 

1268 ), 

1269 prevent_user_existence_errors=True, 

1270 enable_token_revocation=True, 

1271 ) 

1272 

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

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

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

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

1277 # prefix typically want a short memorable value. 

1278 if self._cognito_domain_prefix_override: 

1279 domain_prefix = self._cognito_domain_prefix_override 

1280 else: 

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

1282 

1283 self.cognito_domain = self.cognito_pool.add_domain( 

1284 "StudioUserPoolDomain", 

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

1286 ) 

1287 

1288 CfnOutput( 

1289 self, 

1290 "CognitoUserPoolId", 

1291 value=self.cognito_pool.user_pool_id, 

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

1293 ) 

1294 CfnOutput( 

1295 self, 

1296 "CognitoUserPoolArn", 

1297 value=self.cognito_pool.user_pool_arn, 

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

1299 ) 

1300 CfnOutput( 

1301 self, 

1302 "CognitoUserPoolClientId", 

1303 value=self.cognito_client.user_pool_client_id, 

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

1305 ) 

1306 

1307 # ================================================================== 

1308 # Presigned-URL Lambda 

1309 # ================================================================== 

1310 

1311 def _create_presigned_url_lambda(self) -> None: 

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

1313 

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

1315 ``GCOApiGatewayGlobalStack``. The function lives on 

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

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

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

1319 without a cross-stack import. 

1320 

1321 Key configuration: 

1322 

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

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

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

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

1327 error token rather than API Gateway's 504. 

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

1329 ``sagemaker:CreatePresignedDomainUrl`` call. 

1330 * Log group retention: 1 month. 

1331 

1332 IAM scoping: 

1333 

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

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

1336 * ``sagemaker:DescribeDomain`` + ``CreatePresignedDomainUrl`` + 

1337 ``DescribeUserProfile`` + ``CreateUserProfile`` + ``ListTags`` + 

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

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

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

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

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

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

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

1345 * ``elasticfilesystem:DescribeAccessPoints`` + 

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

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

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

1349 Logs + X-Ray write path. 

1350 """ 

1351 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

1352 

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

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

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

1356 # else is an inline policy we own entirely. 

1357 self.presigned_url_lambda_role = iam.Role( 

1358 self, 

1359 "PresignedUrlLambdaRole", 

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

1361 description=( 

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

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

1364 "PassRole on SageMaker_Execution_Role, and EFS access-" 

1365 "point management on Studio_EFS." 

1366 ), 

1367 managed_policies=[ 

1368 iam.ManagedPolicy.from_aws_managed_policy_name( 

1369 "service-role/AWSLambdaBasicExecutionRole" 

1370 ) 

1371 ], 

1372 ) 

1373 

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

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

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

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

1378 self.presigned_url_lambda_role.add_to_policy( 

1379 iam.PolicyStatement( 

1380 effect=iam.Effect.ALLOW, 

1381 actions=["sagemaker:ListDomains"], 

1382 resources=["*"], 

1383 ) 

1384 ) 

1385 

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

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

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

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

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

1391 # this account's SageMaker Studio installation. 

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

1393 user_profile_arn_prefix = ( 

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

1395 ) 

1396 self.presigned_url_lambda_role.add_to_policy( 

1397 iam.PolicyStatement( 

1398 effect=iam.Effect.ALLOW, 

1399 actions=[ 

1400 "sagemaker:DescribeDomain", 

1401 "sagemaker:CreatePresignedDomainUrl", 

1402 "sagemaker:DescribeUserProfile", 

1403 "sagemaker:CreateUserProfile", 

1404 "sagemaker:ListTags", 

1405 "sagemaker:AddTags", 

1406 ], 

1407 resources=[domain_arn_prefix, user_profile_arn_prefix], 

1408 ) 

1409 ) 

1410 

1411 # iam:PassRole — only SageMaker_Execution_Role, only to 

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

1413 # on the ``ExecutionRole`` field. 

1414 self.presigned_url_lambda_role.add_to_policy( 

1415 iam.PolicyStatement( 

1416 effect=iam.Effect.ALLOW, 

1417 actions=["iam:PassRole"], 

1418 resources=[self.sagemaker_execution_role.role_arn], 

1419 conditions={ 

1420 "StringEquals": { 

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

1422 } 

1423 }, 

1424 ) 

1425 ) 

1426 

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

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

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

1430 self.presigned_url_lambda_role.add_to_policy( 

1431 iam.PolicyStatement( 

1432 effect=iam.Effect.ALLOW, 

1433 actions=[ 

1434 "elasticfilesystem:DescribeAccessPoints", 

1435 "elasticfilesystem:CreateAccessPoint", 

1436 "elasticfilesystem:TagResource", 

1437 ], 

1438 resources=[self.studio_efs.file_system_arn], 

1439 ) 

1440 ) 

1441 

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

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

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

1445 presigned_url_log_group = logs.LogGroup( 

1446 self, 

1447 "PresignedUrlLambdaLogGroup", 

1448 retention=logs.RetentionDays.ONE_MONTH, 

1449 removal_policy=RemovalPolicy.DESTROY, 

1450 ) 

1451 

1452 self.presigned_url_lambda = lambda_.Function( 

1453 self, 

1454 "PresignedUrlFunction", 

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

1456 handler="handler.lambda_handler", 

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

1458 role=self.presigned_url_lambda_role, 

1459 timeout=Duration.seconds(29), 

1460 memory_size=256, 

1461 tracing=lambda_.Tracing.ACTIVE, 

1462 log_group=presigned_url_log_group, 

1463 description=( 

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

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

1466 "GCOApiGatewayGlobalStack." 

1467 ), 

1468 environment={ 

1469 "STUDIO_DOMAIN_ID": self.studio_domain.attr_domain_id, 

1470 "SAGEMAKER_EXECUTION_ROLE_ARN": self.sagemaker_execution_role.role_arn, 

1471 "STUDIO_EFS_ID": self.studio_efs.file_system_id, 

1472 "URL_EXPIRES_SECONDS": "300", 

1473 "SESSION_EXPIRES_SECONDS": "43200", 

1474 }, 

1475 ) 

1476 

1477 CfnOutput( 

1478 self, 

1479 "PresignedUrlLambdaArn", 

1480 value=self.presigned_url_lambda.function_arn, 

1481 description=( 

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

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

1484 ), 

1485 ) 

1486 

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

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

1489 # tighter scoping isn't possible. 

1490 acknowledge_nag_findings( 

1491 self.presigned_url_lambda_role, 

1492 [ 

1493 { 

1494 "id": "AwsSolutions-IAM5", 

1495 "reason": ( 

1496 "sagemaker:ListDomains does not support resource-" 

1497 "level scoping — the AWS API only accepts " 

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

1499 "paginated list call per Lambda invocation " 

1500 "against this account's SageMaker control plane " 

1501 "in this region. The remaining SageMaker actions " 

1502 "(DescribeDomain, CreatePresignedDomainUrl, " 

1503 "DescribeUserProfile, CreateUserProfile, " 

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

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

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

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

1508 "tightest we can achieve at synth time because " 

1509 "DomainId is only resolvable at invoke time." 

1510 ), 

1511 "appliesTo": [ 

1512 "Resource::*", 

1513 ( 

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

1515 ), 

1516 ( 

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

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

1519 ), 

1520 ], 

1521 }, 

1522 ], 

1523 ) 

1524 

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

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

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

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

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

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

1531 

1532 # ================================================================== 

1533 # Nag suppressions 

1534 # ================================================================== 

1535 

1536 def _apply_nag_suppressions(self) -> None: 

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

1538 

1539 The analytics branch calls ``add_sagemaker_suppressions``, 

1540 ``add_cognito_suppressions``, ``add_emr_serverless_suppressions``, 

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

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

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

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

1545 """ 

1546 apply_all_suppressions( 

1547 self, 

1548 stack_type="analytics", 

1549 regions=None, 

1550 global_region=self.config.get_global_region(), 

1551 api_gateway_region=self.config.get_api_gateway_region(), 

1552 project_name=self.project_name, 

1553 )