Coverage for gco / stacks / analytics_stack.py: 100.00%
165 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
1"""Analytics stack for GCO - optional ML/analytics environment.
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.
7Resources (wired in this order):
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
22The API Gateway ``/studio/*`` wiring that consumes this Lambda lives in
23``gco/stacks/api_gateway_global_stack.py``.
24"""
26from __future__ import annotations
28from typing import Any
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
49from gco.config.config_loader import ConfigLoader
50from gco.stacks.constants import (
51 EMR_SERVERLESS_RELEASE_LABEL,
52 LAMBDA_PYTHON_RUNTIME,
53 SAGEMAKER_ROLE_NAME_PREFIX,
54 cluster_shared_ssm_parameter_prefix,
55 cognito_domain_prefix_default,
56)
57from gco.stacks.lambda_assets import lambda_source_code
58from gco.stacks.nag_suppressions import apply_all_suppressions
60# <pyflowchart-code-diagram> BEGIN - auto-inserted, do not edit
61# Generated at (UTC): 2026-10-16T16:57:50Z
62# Generated from Git commit: 3458f76ef1541be718c00458b8ecc8f7c770c6ad
63# Flowchart(s) generated from this file:
64# * ``GCOAnalyticsStack.__init__`` -> ``diagrams/code_diagrams/gco/stacks/analytics_stack.GCOAnalyticsStack___init__.html``
65# (PNG: ``diagrams/code_diagrams/gco/stacks/analytics_stack.GCOAnalyticsStack___init__.png``)
66# * ``GCOAnalyticsStack._create_execution_role_and_grants`` -> ``diagrams/code_diagrams/gco/stacks/analytics_stack.GCOAnalyticsStack__create_execution_role_and_grants.html``
67# (PNG: ``diagrams/code_diagrams/gco/stacks/analytics_stack.GCOAnalyticsStack__create_execution_role_and_grants.png``)
68# * ``GCOAnalyticsStack._create_studio_domain`` -> ``diagrams/code_diagrams/gco/stacks/analytics_stack.GCOAnalyticsStack__create_studio_domain.html``
69# (PNG: ``diagrams/code_diagrams/gco/stacks/analytics_stack.GCOAnalyticsStack__create_studio_domain.png``)
70# Regenerate with ``SOURCE_DATE_EPOCH=<unix-seconds> GCO_DIAGRAM_SOURCE_COMMIT=<40-char-sha> python diagrams/generate.py --code-only``.
71# <pyflowchart-code-diagram> END
74def _parse_removal(value: str) -> RemovalPolicy:
75 """Map a cdk.json removal-policy string to ``aws_cdk.RemovalPolicy``.
77 Translates ``analytics_environment.{efs,cognito}.removal_policy`` into
78 the matching enum member. Accepts ``"retain"`` / ``"destroy"``
79 (case-insensitive); raises ``ValueError`` on anything else.
80 """
81 normalized = value.strip().lower()
82 if normalized == "retain":
83 return RemovalPolicy.RETAIN
84 if normalized == "destroy":
85 return RemovalPolicy.DESTROY
86 raise ValueError(
87 f"analytics_environment removal_policy must be 'retain' or 'destroy', got {value!r}"
88 )
91class GCOAnalyticsStack(Stack):
92 """Optional ML/analytics environment: SageMaker Studio, EMR Serverless, Cognito.
94 Only instantiated when ``analytics_environment.enabled=true``. Lives in
95 the API gateway region so the presigned-URL Lambda can wire into the
96 existing ``/studio/*`` routes on ``GCOApiGatewayGlobalStack`` without
97 a cross-region hop.
98 """
100 def __init__(
101 self,
102 scope: Construct,
103 construct_id: str,
104 *,
105 config: ConfigLoader,
106 api_gateway_secret_arn: str | None = None,
107 **kwargs: Any,
108 ) -> None:
109 super().__init__(scope, construct_id, **kwargs)
111 self.config = config
112 self.project_name = config.get_project_name()
113 # ``api_gateway_secret_arn`` is reserved for future auth wiring;
114 # accepted now so the constructor signature is stable.
115 self.api_gateway_secret_arn = api_gateway_secret_arn
117 cfg = config.get_analytics_config()
118 self.hyperpod_enabled: bool = bool(cfg["hyperpod"]["enabled"])
119 self.canvas_enabled: bool = bool(cfg["canvas"]["enabled"])
120 self.efs_removal: RemovalPolicy = _parse_removal(cfg["efs"]["removal_policy"])
121 self.cognito_removal: RemovalPolicy = _parse_removal(cfg["cognito"]["removal_policy"])
122 self._cognito_domain_prefix_override: str | None = cfg["cognito"].get("domain_prefix")
124 # Wiring order is load-bearing — each helper consumes resources from
125 # earlier helpers (EFS ARN → execution role → studio domain, etc.).
126 self._create_kms_key()
127 self._create_vpc_and_endpoints()
128 self._create_access_logs_bucket()
129 self._create_studio_only_bucket()
130 self._create_studio_efs()
131 self._create_execution_role_and_grants()
132 self._grant_sagemaker_role_on_cluster_shared_bucket()
133 self._create_studio_domain()
134 self._create_emr_app()
135 self._create_cognito_pool()
136 self._create_presigned_url_lambda()
137 self._apply_nag_suppressions()
139 # ==================================================================
140 # KMS + VPC
141 # ==================================================================
143 def _create_kms_key(self) -> None:
144 """Create ``Analytics_KMS_Key`` with rotation + 7-day pending window.
146 Customer-managed so every analytics-owned bucket, the Studio EFS,
147 and SageMaker-written artifacts share a single encryption boundary.
148 ``removal_policy=DESTROY`` follows the iteration-loop posture
149 — the 7-day pending window gives recovery headroom without retaining
150 the key past a ``cdk destroy gco-analytics`` cycle.
151 """
152 self.kms_key = kms.Key(
153 self,
154 "AnalyticsKmsKey",
155 description="Analytics_KMS_Key - encrypts analytics S3 buckets, Studio EFS, SageMaker artifacts",
156 enable_key_rotation=True,
157 pending_window=Duration.days(7),
158 removal_policy=RemovalPolicy.DESTROY,
159 )
161 # Grant encrypt/decrypt to service principals that need to operate
162 # on analytics-owned resources encrypted by this key.
163 service_principals = [
164 ("logs.amazonaws.com", self.region),
165 ("sagemaker.amazonaws.com", self.region),
166 ("s3.amazonaws.com", self.region),
167 ("elasticfilesystem.amazonaws.com", self.region),
168 ]
169 for principal, region in service_principals:
170 self.kms_key.add_to_resource_policy(
171 iam.PolicyStatement(
172 sid=f"Allow{principal.split('.')[0].capitalize()}Encrypt",
173 effect=iam.Effect.ALLOW,
174 principals=[iam.ServicePrincipal(principal, region=region)],
175 actions=[
176 "kms:Encrypt",
177 "kms:Decrypt",
178 "kms:ReEncrypt*",
179 "kms:GenerateDataKey*",
180 "kms:DescribeKey",
181 ],
182 resources=["*"], # key-policy scope — always the key itself
183 )
184 )
186 def _create_vpc_and_endpoints(self) -> None:
187 """Create a private VPC plus every VPC endpoint Studio needs.
189 Notebooks never land on public subnets (the VPC has none).
190 The nine interface endpoints plus the S3 gateway endpoint
191 keep all Studio/EMR/EFS traffic on the private network. A NAT
192 gateway provides internet egress so notebooks can pip install,
193 git clone, and access external APIs (HuggingFace, PyPI, etc.).
194 """
195 self.vpc = ec2.Vpc(
196 self,
197 "AnalyticsVpc",
198 max_azs=2,
199 nat_gateways=1,
200 subnet_configuration=[
201 ec2.SubnetConfiguration(
202 name="AnalyticsPrivate",
203 subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS,
204 cidr_mask=24,
205 ),
206 ec2.SubnetConfiguration(
207 name="AnalyticsPublic",
208 subnet_type=ec2.SubnetType.PUBLIC,
209 cidr_mask=28,
210 ),
211 ],
212 )
214 # Gateway endpoint for S3 — route tables are wired up automatically.
215 self.vpc.add_gateway_endpoint(
216 "S3GatewayEndpoint",
217 service=ec2.GatewayVpcEndpointAwsService.S3,
218 )
220 # Interface endpoints — one per AWS service required by Studio. Each
221 # lands in the VPC's private subnets using the default
222 # VPC-endpoint security group.
223 interface_services: dict[str, ec2.InterfaceVpcEndpointAwsService] = {
224 "SagemakerApiEndpoint": ec2.InterfaceVpcEndpointAwsService.SAGEMAKER_API,
225 "SagemakerRuntimeEndpoint": ec2.InterfaceVpcEndpointAwsService.SAGEMAKER_RUNTIME,
226 "SagemakerStudioEndpoint": ec2.InterfaceVpcEndpointAwsService.SAGEMAKER_STUDIO,
227 "SagemakerNotebookEndpoint": ec2.InterfaceVpcEndpointAwsService.SAGEMAKER_NOTEBOOK,
228 "StsEndpoint": ec2.InterfaceVpcEndpointAwsService.STS,
229 "CloudWatchLogsEndpoint": ec2.InterfaceVpcEndpointAwsService.CLOUDWATCH_LOGS,
230 "EcrEndpoint": ec2.InterfaceVpcEndpointAwsService.ECR,
231 "EcrDockerEndpoint": ec2.InterfaceVpcEndpointAwsService.ECR_DOCKER,
232 "EfsEndpoint": ec2.InterfaceVpcEndpointAwsService.ELASTIC_FILESYSTEM,
233 }
234 for construct_id, service in interface_services.items():
235 self.vpc.add_interface_endpoint(
236 construct_id,
237 service=service,
238 subnets=ec2.SubnetSelection(subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS),
239 )
241 # Each interface endpoint's default security group allows 443 from the
242 # VPC CIDR (an ``Fn::GetAtt`` token cdk-nag can't resolve), so the
243 # SG-ingress rules throw. Scope the acknowledgment to the VPC construct
244 # so it covers every endpoint SG under it without touching the stack.
245 from gco.stacks.nag_suppressions import acknowledge_security_group_cidr_findings
247 acknowledge_security_group_cidr_findings(
248 self.vpc,
249 reason=(
250 "The Studio VPC interface endpoints use their default security "
251 "group, which allows HTTPS (443) ingress from the VPC CIDR "
252 "only, referenced via an ``Fn::GetAtt`` token that cdk-nag "
253 "cannot resolve at synth time. Ingress is restricted to "
254 "intra-VPC traffic — the tightest source for private "
255 "endpoint access."
256 ),
257 )
259 # ==================================================================
260 # S3 buckets
261 # ==================================================================
263 def _create_access_logs_bucket(self) -> None:
264 """Create the dedicated access-logs bucket for ``Studio_Only_Bucket``.
266 Server-side encryption uses S3-managed keys (SSE-S3) because S3
267 server-access-log delivery does not support KMS-encrypted destinations
268 without additional log-delivery role plumbing — the standard pattern
269 is SSE-S3 for the log sink plus KMS for the bucket it logs. The
270 resulting ``AwsSolutions-S1`` nag finding for the log sink targeting
271 itself is scoped on the bucket construct by
272 ``add_storage_suppressions`` via the analytics nag branch.
273 """
274 self.access_logs_bucket = s3.Bucket(
275 self,
276 "AnalyticsAccessLogsBucket",
277 encryption=s3.BucketEncryption.S3_MANAGED,
278 block_public_access=s3.BlockPublicAccess.BLOCK_ALL,
279 enforce_ssl=True,
280 versioned=True,
281 removal_policy=RemovalPolicy.DESTROY,
282 auto_delete_objects=True,
283 lifecycle_rules=[
284 s3.LifecycleRule(
285 id="ExpireAccessLogs",
286 enabled=True,
287 expiration=Duration.days(90),
288 )
289 ],
290 )
292 def _create_studio_only_bucket(self) -> None:
293 """Create ``Studio_Only_Bucket`` for notebook-private scratch + outputs.
295 The physical name is CloudFormation-generated
296 (``<stack>-studioonlybucket…``): S3 bucket names are a global namespace
297 and a deleted name is not reliably reusable, so a fixed
298 project/account/region name would make every destroy-and-redeploy a
299 collision hazard. Nothing needs to reconstruct the name — the only
300 grant is the ``SageMaker_Execution_Role`` grant on the construct's own
301 ARN, and the CLI resolves the bucket from this stack's resources. The
302 job-pod isolation property (``tests/test_analytics_bucket_isolation_
303 property.py``) classifies grants by construct token, so it does not
304 depend on a name prefix either. KMS-encrypted with ``self.kms_key``;
305 every access path goes through the ``SageMaker_Execution_Role`` grant
306 — no other principal is granted access.
307 """
308 self.studio_only_bucket = s3.Bucket(
309 self,
310 "StudioOnlyBucket",
311 encryption=s3.BucketEncryption.KMS,
312 encryption_key=self.kms_key,
313 bucket_key_enabled=True,
314 block_public_access=s3.BlockPublicAccess.BLOCK_ALL,
315 enforce_ssl=True,
316 versioned=True,
317 removal_policy=RemovalPolicy.DESTROY,
318 auto_delete_objects=True,
319 server_access_logs_bucket=self.access_logs_bucket,
320 server_access_logs_prefix="studio-only/",
321 )
323 # Belt-and-suspenders Deny for insecure transport, duplicating the
324 # ``enforce_ssl=True`` semantics with a verifiable SID in the
325 # synthesized template (mirrors the ``DenyInsecureTransport`` pattern
326 # used by ``Cluster_Shared_Bucket`` in ``GCOGlobalStack``).
327 self.studio_only_bucket.add_to_resource_policy(
328 iam.PolicyStatement(
329 sid="DenyInsecureTransport",
330 effect=iam.Effect.DENY,
331 principals=[iam.AnyPrincipal()],
332 actions=["s3:*"],
333 resources=[
334 self.studio_only_bucket.bucket_arn,
335 f"{self.studio_only_bucket.bucket_arn}/*",
336 ],
337 conditions={"Bool": {"aws:SecureTransport": "false"}},
338 )
339 )
341 # ==================================================================
342 # Studio EFS
343 # ==================================================================
345 def _create_studio_efs(self) -> None:
346 """Create ``Studio_EFS`` with KMS encryption + TLS in transit.
348 Per-user access points are created lazily by the presigned-URL
349 Lambda on first profile creation. No access points are defined
350 here, so the file system's ``/`` root is effectively inaccessible
351 until the Lambda materializes a per-user AP.
353 The dedicated security group only allows the VPC's private
354 CIDR on TCP/2049 (NFS). SageMaker Studio mount traffic originates
355 from the Studio compute subnet, which shares the VPC with this EFS.
356 """
357 self.studio_efs_security_group = ec2.SecurityGroup(
358 self,
359 "StudioEfsSecurityGroup",
360 vpc=self.vpc,
361 description="SG for Studio_EFS - allows NFS from the analytics VPC only",
362 allow_all_outbound=False,
363 )
364 self.studio_efs_security_group.add_ingress_rule(
365 peer=ec2.Peer.ipv4(self.vpc.vpc_cidr_block),
366 connection=ec2.Port.tcp(2049),
367 description="NFS from analytics VPC private subnets",
368 )
370 # The EFS SG ingress allows NFS (2049) from the VPC CIDR (an
371 # ``Fn::GetAtt`` token cdk-nag can't resolve), so the SG-ingress rules
372 # throw. Scope the acknowledgment to the EFS SG construct itself.
373 from gco.stacks.nag_suppressions import acknowledge_security_group_cidr_findings
375 acknowledge_security_group_cidr_findings(
376 self.studio_efs_security_group,
377 reason=(
378 "The Studio_EFS security group allows NFS (2049) ingress from "
379 "the VPC CIDR only, referenced via an ``Fn::GetAtt`` token "
380 "that cdk-nag cannot resolve at synth time. Ingress is "
381 "restricted to intra-VPC traffic from the Studio compute "
382 "subnet that mounts the file system."
383 ),
384 )
386 self.studio_efs = efs.FileSystem(
387 self,
388 "StudioEfs",
389 vpc=self.vpc,
390 vpc_subnets=ec2.SubnetSelection(subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS),
391 encrypted=True,
392 kms_key=self.kms_key,
393 enable_automatic_backups=True,
394 removal_policy=self.efs_removal,
395 security_group=self.studio_efs_security_group,
396 )
398 # ==================================================================
399 # SageMaker execution role + grants
400 # ==================================================================
402 def _create_execution_role_and_grants(self) -> None:
403 """Create ``SageMaker_Execution_Role`` and attach its (non-cluster-shared) grants.
405 Role name begins with ``AmazonSageMaker`` — SageMaker
406 requires this prefix for any role used by a Studio domain. Grants
407 attached here:
409 * RW on ``Studio_Only_Bucket`` + KMS on ``Analytics_KMS_Key``
410 * Read-only ``execute-api:Invoke`` on GCO API Gateway ``/api/v1/*`` GET routes
411 * ``sqs:SendMessage`` on regional job queues (wildcard ARN pattern)
412 * ``ssm:GetParameter`` on the ``Cluster_Shared_Bucket`` metadata
413 parameters in the global region — lets notebooks look up the
414 bucket name/arn/region at runtime without a per-user export step
415 * EFS mount actions on ``Studio_EFS`` (specific AP arn is added by
416 the presigned-URL Lambda at runtime; the role-level grant here is
417 scoped to the EFS ARN)
418 * HyperPod training-job actions when ``hyperpod.enabled=true``
419 * AWS-managed ``AmazonSageMakerCanvasFullAccess`` when
420 ``canvas.enabled=true`` (opt-in no-code ML app)
421 * AWS-managed ``AmazonSageMakerFullAccess`` — always attached
422 whenever analytics is enabled. Covers the full SageMaker
423 control-plane surface including MLflow Apps
424 (``CreateMlflowApp``/``ListMlflowApps``/``DescribeMlflowApp``),
425 MLflow Tracking Servers, Model Registry, Studio space/app
426 lifecycle, and adjacent services (S3, ECR, CloudWatch Logs,
427 etc.) that SageMaker needs to launch training jobs, create
428 apps, and render the Studio IDE. We pair the managed policy
429 with an inline ``sagemaker-mlflow:*`` statement (next block)
430 because the managed policy does not cover the
431 ``sagemaker-mlflow`` data-plane namespace the MLflow SDK
432 talks to. MLflow does not have its own sub-toggle — the
433 managed policy replaces our previous enumerated
434 ``sagemaker:*MlflowTrackingServer*`` inline grant.
436 The ``Cluster_Shared_Bucket`` grant lives in its own helper
437 (:meth:`_grant_sagemaker_role_on_cluster_shared_bucket`) because the
438 bucket ARN is resolved via a cross-region SSM read.
439 """
440 self.sagemaker_execution_role = iam.Role(
441 self,
442 "SagemakerExecutionRole",
443 role_name=f"{SAGEMAKER_ROLE_NAME_PREFIX}-{self.project_name}-analytics-exec-{self.region}",
444 assumed_by=iam.ServicePrincipal("sagemaker.amazonaws.com"),
445 description=(
446 "SageMaker_Execution_Role - assumed by notebooks in the Studio "
447 "domain. Grants RW on Studio_Only_Bucket and (via a separate "
448 "cross-region policy) Cluster_Shared_Bucket, plus read-only GCO "
449 "API access, SQS job submission, and cross-region ssm:GetParameter "
450 "on the Cluster_Shared_Bucket metadata parameters."
451 ),
452 )
454 # Bucket + KMS grants — studio-only scratch space. Analytics_KMS_Key
455 # already has encrypt/decrypt in its key policy for the sagemaker
456 # service principal, but role-level grants are still required for
457 # IAM-side authorization per the double-auth model.
458 self.studio_only_bucket.grant_read_write(self.sagemaker_execution_role)
459 self.kms_key.grant_encrypt_decrypt(self.sagemaker_execution_role)
461 # SageMaker needs CreateGrant on the KMS key to delegate encryption
462 # to EBS when creating space volumes. The grant is scoped to the
463 # key and conditioned on the grantee being an AWS service.
464 self.kms_key.grant(
465 self.sagemaker_execution_role,
466 "kms:CreateGrant",
467 "kms:DescribeKey",
468 )
470 # GCO API scope — notebooks need both read-only GET operations
471 # (list jobs, describe endpoints, fetch health) and job/inference
472 # submission actions (POST manifests, PUT template updates, DELETE
473 # jobs). Grant the full ``/api/v1/*`` method surface instead of
474 # GET-only so users can submit new jobs, manage templates, and
475 # tear things down from inside a notebook without bouncing
476 # through a service account.
477 #
478 # The exact API id is not known here (it lives in the api-gateway
479 # stack and is discovered through SSM or CfnOutput at synth time
480 # — see the api_gateway_global_stack wiring). Scope to the
481 # api-gateway region with any REST API id for now; tighter scope
482 # is applied once ``AnalyticsApiConfig`` is wired in.
483 api_gw_region = self.config.get_api_gateway_region()
484 self.sagemaker_execution_role.add_to_policy(
485 iam.PolicyStatement(
486 effect=iam.Effect.ALLOW,
487 actions=["execute-api:Invoke"],
488 resources=[
489 # ``*/prod/*/api/v1/*`` — any API id, any HTTP method
490 # (GET/POST/PUT/DELETE/PATCH), any path below
491 # /api/v1/. /studio/* is explicitly excluded; Canvas
492 # users go through their own Cognito-authorized
493 # ``/studio/login`` route.
494 f"arn:{self.partition}:execute-api:{api_gw_region}:{self.account}:"
495 "*/prod/*/api/v1/*",
496 # ``/inference/*`` proxies through to regional ALBs
497 # for in-cluster model endpoints — notebooks need
498 # the full method surface here too.
499 f"arn:{self.partition}:execute-api:{api_gw_region}:{self.account}:"
500 "*/prod/*/inference/*",
501 ],
502 )
503 )
505 # SQS job submission — scoped to the regional queue name pattern
506 # ``<project>-jobs-<region>`` written by
507 # ``GCORegionalStack._create_sqs_queue``. The exact region isn't
508 # known at synth time (queues live in regional stacks), so we use
509 # ``*`` in the region component with the project name fixed.
510 project_name = self.config.get_project_name()
511 self.sagemaker_execution_role.add_to_policy(
512 iam.PolicyStatement(
513 effect=iam.Effect.ALLOW,
514 actions=["sqs:SendMessage"],
515 resources=[
516 f"arn:{self.partition}:sqs:*:{self.account}:{project_name}-jobs-*",
517 ],
518 )
519 )
521 # ssm:GetParameter on the Cluster_Shared_Bucket metadata params. The
522 # three parameters (name/arn/region) live in the global region where
523 # GCOGlobalStack is deployed, not in the analytics region. Scoping
524 # to the cluster_shared_ssm_parameter_prefix(project_name) tree under
525 # the global region means a notebook can fetch the bucket name at
526 # runtime via
527 # boto3.client('ssm', region_name='<global-region>').get_parameter(
528 # Name='/<project_name>/cluster-shared-bucket/name')['Parameter']['Value']
529 # without any JupyterLab-terminal export step.
530 global_region = self.config.get_global_region()
531 self.sagemaker_execution_role.add_to_policy(
532 iam.PolicyStatement(
533 effect=iam.Effect.ALLOW,
534 actions=["ssm:GetParameter", "ssm:GetParameters"],
535 resources=[
536 f"arn:{self.partition}:ssm:{global_region}:{self.account}:parameter"
537 f"{cluster_shared_ssm_parameter_prefix(self.project_name)}/*",
538 ],
539 )
540 )
542 # EFS mount actions — scoped to the Studio EFS file-system ARN.
543 self.sagemaker_execution_role.add_to_policy(
544 iam.PolicyStatement(
545 effect=iam.Effect.ALLOW,
546 actions=[
547 "elasticfilesystem:ClientMount",
548 "elasticfilesystem:ClientWrite",
549 "elasticfilesystem:ClientRootAccess",
550 ],
551 resources=[self.studio_efs.file_system_arn],
552 )
553 )
555 # DescribeMountTargets does not support resource-level scoping —
556 # SageMaker calls it during user profile provisioning to validate
557 # the EFS mount configuration.
558 self.sagemaker_execution_role.add_to_policy(
559 iam.PolicyStatement(
560 effect=iam.Effect.ALLOW,
561 actions=[
562 "elasticfilesystem:DescribeMountTargets",
563 "elasticfilesystem:DescribeFileSystems",
564 ],
565 resources=["*"],
566 )
567 )
569 # SageMaker Studio UI actions — the execution role is assumed by
570 # the Studio notebook runtime and needs these to render the IDE,
571 # list spaces/apps, and manage its own lifecycle.
572 self.sagemaker_execution_role.add_to_policy(
573 iam.PolicyStatement(
574 effect=iam.Effect.ALLOW,
575 actions=[
576 "sagemaker:DescribeDomain",
577 "sagemaker:DescribeUserProfile",
578 "sagemaker:CreatePresignedDomainUrl",
579 "sagemaker:ListSpaces",
580 "sagemaker:ListApps",
581 "sagemaker:DescribeApp",
582 "sagemaker:DescribeSpace",
583 "sagemaker:CreateApp",
584 "sagemaker:DeleteApp",
585 "sagemaker:CreateSpace",
586 "sagemaker:DeleteSpace",
587 "sagemaker:UpdateSpace",
588 "sagemaker:ListTags",
589 "sagemaker:AddTags",
590 ],
591 resources=[
592 f"arn:{self.partition}:sagemaker:{self.region}:{self.account}:domain/*",
593 f"arn:{self.partition}:sagemaker:{self.region}:{self.account}:user-profile/*/*",
594 f"arn:{self.partition}:sagemaker:{self.region}:{self.account}:space/*/*",
595 f"arn:{self.partition}:sagemaker:{self.region}:{self.account}:app/*/*/*/*",
596 ],
597 )
598 )
600 # EMR Serverless — allow the execution role to discover, connect to,
601 # and manage the EMR Serverless application from Studio's Data panel.
602 self.sagemaker_execution_role.add_to_policy(
603 iam.PolicyStatement(
604 effect=iam.Effect.ALLOW,
605 actions=[
606 "emr-serverless:ListApplications",
607 "emr-serverless:GetApplication",
608 "emr-serverless:CreateApplication",
609 "emr-serverless:StartApplication",
610 "emr-serverless:StopApplication",
611 "emr-serverless:StartJobRun",
612 "emr-serverless:GetJobRun",
613 "emr-serverless:ListJobRuns",
614 "emr-serverless:CancelJobRun",
615 "emr-serverless:GetDashboardForJobRun",
616 "emr-serverless:AccessLivyEndpoints",
617 ],
618 resources=["*"],
619 )
620 )
622 # SageMaker-managed MLflow + Model Registry + MLflow Apps.
623 #
624 # We attach the AWS-managed ``AmazonSageMakerFullAccess`` policy
625 # for two reasons:
626 #
627 # 1. MLflow Apps (the newer Studio panel, separate from MLflow
628 # Tracking Servers) requires ``sagemaker:CreateMlflowApp``/
629 # ``ListMlflowApps``/``DescribeMlflowApp`` etc. The action
630 # surface is evolving quickly and the managed policy tracks
631 # it. Enumerating it inline would drift.
632 # 2. SageMaker Model Registry (``sagemaker:*ModelPackage*``),
633 # Studio space/app lifecycle, training-job submission, and
634 # the "related-services" helpers (S3, ECR, CloudWatch Logs)
635 # are already covered by the managed policy — keeping them
636 # inline duplicated the managed policy and kept us in a
637 # catch-up loop whenever SageMaker shipped a new feature.
638 #
639 # The managed policy is ``Resource: *`` by design; the trade-off
640 # (broader-than-least-privilege inside the role) is
641 # acknowledged with a nag suppression below. The inline
642 # ``sagemaker-mlflow:*`` statement that follows is still
643 # required because the managed policy does NOT cover the
644 # ``sagemaker-mlflow`` data-plane namespace — that's what the
645 # MLflow SDK talks to over SigV4 for ``log_metric``,
646 # ``log_artifact``, ``register_model``, etc.
647 from gco.stacks.nag_suppressions import suppress_managed_policy_opt_in
649 self.sagemaker_execution_role.add_managed_policy(
650 iam.ManagedPolicy.from_aws_managed_policy_name("AmazonSageMakerFullAccess")
651 )
652 suppress_managed_policy_opt_in(
653 self.sagemaker_execution_role,
654 managed_policy_name="AmazonSageMakerFullAccess",
655 reason=(
656 "AmazonSageMakerFullAccess is attached to "
657 "SageMaker_Execution_Role when analytics_environment.enabled=true. "
658 "The managed policy covers MLflow Apps, MLflow Tracking "
659 "Servers, SageMaker Model Registry, Studio space/app "
660 "lifecycle, training-job submission, and the cross-service "
661 "helpers (S3, ECR, CloudWatch Logs) SageMaker needs to "
662 "render the IDE and run jobs. Enumerating this surface "
663 "inline drifts out of date within weeks — tracking the "
664 "AWS-managed policy is the supported path. The inline "
665 "``sagemaker-mlflow:*`` statement that follows covers "
666 "the data-plane namespace the managed policy does not "
667 "include. Users who want a locked-down alternative can "
668 "disable the analytics environment."
669 ),
670 )
672 # MLflow SDK data-plane (``sagemaker-mlflow:*``) — required for
673 # ``mlflow.log_metric``, ``mlflow.log_artifact``,
674 # ``mlflow.register_model``, etc. to round-trip through the
675 # SageMaker-managed tracking server over SigV4. The managed
676 # policy above covers the ``sagemaker:*`` control-plane
677 # namespace but not ``sagemaker-mlflow:*`` (a separate service
678 # prefix), so we keep this inline and scope it to the
679 # api-gateway region where the tracking server and MLflow apps
680 # live.
681 self.sagemaker_execution_role.add_to_policy(
682 iam.PolicyStatement(
683 effect=iam.Effect.ALLOW,
684 actions=["sagemaker-mlflow:*"],
685 resources=[
686 f"arn:{self.partition}:sagemaker:{api_gw_region}:{self.account}:"
687 "mlflow-tracking-server/*",
688 f"arn:{self.partition}:sagemaker:{api_gw_region}:{self.account}:mlflow-app/*",
689 ],
690 )
691 )
693 # MLflow's SigV4 plug-in exchanges STS ``GetCallerIdentity`` on
694 # every request — the execution role needs that on ``*``.
695 # ``sts:GetCallerIdentity`` does not support resource-level
696 # scoping, so Resource: * is the only valid value.
697 self.sagemaker_execution_role.add_to_policy(
698 iam.PolicyStatement(
699 effect=iam.Effect.ALLOW,
700 actions=["sts:GetCallerIdentity"],
701 resources=["*"],
702 )
703 )
705 # HyperPod sub-toggle — additional SageMaker actions for training-job
706 # submission and cluster-instance lifecycle management.
707 # ``resources=["*"]`` is the documented scope; the HyperPod actions
708 # themselves encode the per-training-job authorization model.
709 if self.hyperpod_enabled:
710 self.sagemaker_execution_role.add_to_policy(
711 iam.PolicyStatement(
712 effect=iam.Effect.ALLOW,
713 actions=[
714 "sagemaker:CreateTrainingJob",
715 "sagemaker:DescribeTrainingJob",
716 "sagemaker:StopTrainingJob",
717 "sagemaker:ClusterInstance",
718 "sagemaker:ClusterInstanceGroup",
719 "sagemaker:DescribeClusterNode",
720 "sagemaker:ListClusterNodes",
721 ],
722 resources=["*"],
723 )
724 )
726 # Canvas sub-toggle — attach AWS-managed ``AmazonSageMakerCanvasFullAccess``
727 # to the execution role so users can launch the Canvas no-code ML
728 # app from inside Studio. The managed policy is used deliberately
729 # (rather than enumerating each action) because Canvas's per-feature
730 # permission surface — Bedrock for generative AI, Forecast for time
731 # series, Rekognition for image classification, S3 writes for
732 # datasets, Athena for SQL sources, etc. — is large and evolves with
733 # every Canvas release. Tracking AWS's managed policy means we pick
734 # up new Canvas capabilities automatically without shipping a CDK
735 # change. The trade-off (broader-than-least-privilege inside the
736 # role) is acknowledged with a dedicated nag suppression below.
737 #
738 # The matching ``CanvasAppSettings`` override on the Studio domain
739 # lives in ``_create_studio_domain`` so the Canvas tile shows up
740 # on the Studio landing page when the toggle is on.
741 if self.canvas_enabled:
742 self.sagemaker_execution_role.add_managed_policy(
743 iam.ManagedPolicy.from_aws_managed_policy_name("AmazonSageMakerCanvasFullAccess")
744 )
746 suppress_managed_policy_opt_in(
747 self.sagemaker_execution_role,
748 managed_policy_name="AmazonSageMakerCanvasFullAccess",
749 reason=(
750 "AmazonSageMakerCanvasFullAccess is attached to "
751 "SageMaker_Execution_Role when analytics_environment.canvas.enabled=true. "
752 "Canvas is an opt-in sub-toggle (off by default) and its managed "
753 "policy is preferred over an enumerated least-privilege policy "
754 "because Canvas's cross-service permission surface (Bedrock, "
755 "Forecast, Rekognition, Athena, S3 dataset writes, etc.) evolves "
756 "with every Canvas release — tracking the managed policy keeps "
757 "Canvas functional as AWS ships new features. Users who want a "
758 "locked-down alternative can keep the toggle off."
759 ),
760 )
762 # EFS file-system policies authorize NFS client access only. Keep
763 # control-plane Describe/Delete permissions on the identities that
764 # need them (the execution and cleanup roles), rather than exposing
765 # them through a wildcard resource principal. The Studio runtime
766 # mounts as its execution role and only through a VPC mount target.
767 self.studio_efs.add_to_resource_policy(
768 iam.PolicyStatement(
769 effect=iam.Effect.ALLOW,
770 principals=[self.sagemaker_execution_role],
771 actions=[
772 "elasticfilesystem:ClientMount",
773 "elasticfilesystem:ClientWrite",
774 "elasticfilesystem:ClientRootAccess",
775 ],
776 conditions={
777 "Bool": {"elasticfilesystem:AccessedViaMountTarget": "true"},
778 },
779 )
780 )
782 def _grant_sagemaker_role_on_cluster_shared_bucket(self) -> None:
783 """Attach RW + KMS on ``Cluster_Shared_Bucket`` to ``SageMaker_Execution_Role``.
785 The bucket lives in ``GCOGlobalStack`` in the global region. Its
786 ARN is resolved at synth time via an ``AwsCustomResource`` that
787 issues ``ssm:GetParameter`` against the global region — mirroring
788 the pattern used by ``GCORegionalStack._resolve_cluster_shared_bucket_from_ssm``.
790 Two statements attach to the role:
792 1. S3: ``GetObject``/``PutObject``/``DeleteObject``/``ListBucket``/
793 ``GetBucketLocation`` on ``<arn>`` + ``<arn>/*``.
794 2. KMS: ``Decrypt``/``GenerateDataKey`` with a
795 ``kms:ViaService=s3.<global-region>.<AWS::URLSuffix>`` condition.
797 This is a role-side policy — the bucket policy is owned
798 exclusively by ``GCOGlobalStack``.
799 """
800 from gco.stacks.nag_suppressions import acknowledge_nag_findings
802 global_region = self.config.get_global_region()
803 parameter_name = f"{cluster_shared_ssm_parameter_prefix(self.project_name)}/arn"
805 read_cr = cr.AwsCustomResource(
806 self,
807 "ReadClusterSharedBucketArn",
808 on_create=cr.AwsSdkCall(
809 service="SSM",
810 action="getParameter",
811 parameters={"Name": parameter_name},
812 region=global_region,
813 physical_resource_id=cr.PhysicalResourceId.of("analytics-cluster-shared-arn"),
814 ),
815 on_update=cr.AwsSdkCall(
816 service="SSM",
817 action="getParameter",
818 parameters={"Name": parameter_name},
819 region=global_region,
820 physical_resource_id=cr.PhysicalResourceId.of("analytics-cluster-shared-arn"),
821 ),
822 policy=cr.AwsCustomResourcePolicy.from_sdk_calls(
823 resources=cr.AwsCustomResourcePolicy.ANY_RESOURCE
824 ),
825 )
827 # Scoped suppression: same shape as
828 # ``GCORegionalStack._resolve_cluster_shared_bucket_from_ssm``. The
829 # CR policy is ``Resource::*`` because cross-region SSM does not
830 # support resource-level scoping cleanly; the action is a fixed
831 # ``ssm:GetParameter`` for a single literal parameter Name.
832 acknowledge_nag_findings(
833 read_cr,
834 [
835 {
836 "id": "AwsSolutions-IAM5",
837 "reason": (
838 "Cross-region ssm:GetParameter for "
839 f"{parameter_name} in the global region. The "
840 "AwsCustomResource SDK-call policy is scoped to a "
841 "single fixed action (ssm:GetParameter) with a "
842 "fixed parameter Name — the Resource: * is the "
843 "CDK-documented escape hatch because the parameter "
844 "ARN is not known to the calling principal's "
845 "region. Effective blast radius: one parameter."
846 ),
847 "appliesTo": ["Resource::*"],
848 },
849 ],
850 )
852 shared_arn = read_cr.get_response_field("Parameter.Value")
854 # Attach the two policy statements as an inline Policy on the role
855 # (policy on the role, not the bucket).
856 iam.Policy(
857 self,
858 "SagemakerClusterSharedBucketGrant",
859 roles=[self.sagemaker_execution_role],
860 statements=[
861 iam.PolicyStatement(
862 effect=iam.Effect.ALLOW,
863 actions=[
864 "s3:GetObject",
865 "s3:PutObject",
866 "s3:DeleteObject",
867 "s3:ListBucket",
868 "s3:GetBucketLocation",
869 ],
870 resources=[shared_arn, f"{shared_arn}/*"],
871 ),
872 iam.PolicyStatement(
873 effect=iam.Effect.ALLOW,
874 actions=["kms:Decrypt", "kms:GenerateDataKey"],
875 resources=["*"],
876 conditions={
877 "StringEquals": {
878 "kms:ViaService": f"s3.{global_region}.{self.url_suffix}",
879 }
880 },
881 ),
882 ],
883 )
885 # The S3 statement uses an <arn>/* object-key wildcard on the
886 # literal cluster-shared bucket ARN resolved from SSM — identical
887 # shape to the regional stack's analogous grant, with the same
888 # reason text (bucket-scoped RW).
889 acknowledge_nag_findings(
890 self.sagemaker_execution_role,
891 [
892 {
893 "id": "AwsSolutions-IAM5",
894 "reason": (
895 "The SageMaker RW grant on Cluster_Shared_Bucket "
896 "uses an <arn>/* object-key wildcard on the literal "
897 "ARN resolved from SSM. The wildcard covers object "
898 "keys within the single always-on cluster-shared "
899 "bucket (CloudFormation-generated name, published via SSM)."
900 ),
901 "appliesTo": [
902 "Resource::<ReadClusterSharedBucketArn4B0BD291.Parameter.Value>/*",
903 ],
904 },
905 ],
906 )
908 # ==================================================================
909 # SageMaker Studio domain
910 # ==================================================================
912 def _create_studio_domain(self) -> None:
913 """Create the SageMaker Studio domain bound to the private VPC.
915 ``auth_mode=IAM`` + ``app_network_access_type=VpcOnly`` keeps Studio
916 traffic on the private subnets.
917 ``DefaultUserSettings.ExecutionRole`` points at the role created in
918 :meth:`_create_execution_role_and_grants`. ``CustomImages`` is
919 intentionally left unset so Studio falls back to the stock AWS-
920 published Distribution images (a tested invariant).
922 ``CustomFileSystemConfigs`` mounts ``self.studio_efs`` at
923 ``/home/sagemaker-user`` — per-user ``/home/<username>`` isolation
924 is enforced by the access points that the presigned-URL Lambda
925 creates lazily on first login.
926 """
927 private_subnets = self.vpc.select_subnets(
928 subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS
929 ).subnets
931 efs_fs_config = sagemaker.CfnDomain.EFSFileSystemConfigProperty(
932 file_system_id=self.studio_efs.file_system_id,
933 file_system_path="/home/sagemaker-user",
934 )
935 efs_custom_fs = sagemaker.CfnDomain.CustomFileSystemConfigProperty(
936 efs_file_system_config=efs_fs_config,
937 )
939 # We also considered adding an ``S3FileSystemConfig`` custom file
940 # system that would mount the always-on ``Cluster_Shared_Bucket``
941 # under ``/mount/cluster-shared``. aws-cdk-lib exposes the
942 # property and CloudFormation synths it cleanly, but the
943 # SageMaker Studio service rejects the resource at create time
944 # with ``Invalid request provided: S3FileSystemConfig for
945 # SageMaker AI Studio is not supported yet.`` — so we ship
946 # without the mount. Notebooks access the cluster-shared
947 # bucket via ``boto3`` (the SageMaker execution role's
948 # cross-region RW grant in
949 # :meth:`_grant_sagemaker_role_on_cluster_shared_bucket` already
950 # authorizes that path). Revisit this block when SageMaker
951 # Studio lights up S3 custom file systems.
952 custom_file_systems: list[sagemaker.CfnDomain.CustomFileSystemConfigProperty] = [
953 efs_custom_fs,
954 ]
956 # Security group for Studio compute — allows all outbound so
957 # notebooks can reach the internet (pip, git, etc.) via the NAT
958 # gateway. SageMaker's default VpcOnly security group only permits
959 # NFS traffic, which blocks all internet access from notebooks.
960 self.studio_compute_sg = ec2.SecurityGroup(
961 self,
962 "StudioComputeSg",
963 vpc=self.vpc,
964 description="Allows outbound internet access from Studio notebooks",
965 allow_all_outbound=True,
966 )
968 default_user_settings = sagemaker.CfnDomain.UserSettingsProperty(
969 execution_role=self.sagemaker_execution_role.role_arn,
970 custom_file_system_configs=custom_file_systems,
971 security_groups=[self.studio_compute_sg.security_group_id],
972 # ``jupyter_lab_app_settings`` is deliberately omitted so
973 # ``CustomImages`` stays absent — the template contains no
974 # SageMaker image resources and no CustomImages
975 # key on the domain.
976 )
978 self.studio_domain = sagemaker.CfnDomain(
979 self,
980 "StudioDomain",
981 auth_mode="IAM",
982 app_network_access_type="VpcOnly",
983 domain_name=f"{self.project_name}-studio-{self.region}",
984 subnet_ids=[s.subnet_id for s in private_subnets],
985 vpc_id=self.vpc.vpc_id,
986 kms_key_id=self.kms_key.key_id,
987 default_user_settings=default_user_settings,
988 )
990 # Canvas sub-toggle (UI side): **IAM-only**. The
991 # ``AmazonSageMakerCanvasFullAccess`` managed policy attached to
992 # the SageMaker execution role in
993 # :meth:`_create_execution_role_and_grants` is sufficient to
994 # surface the Canvas tile on the Studio landing page — when a
995 # user with that policy opens Studio, SageMaker auto-discovers
996 # the entitlement and lights up the Canvas launcher.
997 #
998 # We intentionally do *not* inject a
999 # ``DefaultUserSettings.CanvasAppSettings`` block on the domain.
1000 # The CloudFormation ``AWS::SageMaker::Domain`` resource does
1001 # not accept that property (only ``AWS::SageMaker::UserProfile``
1002 # does), so a property override fails early validation with
1003 # ``Unsupported property [CanvasAppSettings]``. Canvas uses its
1004 # own default workspace artifact locations; operators who want
1005 # to pin per-user Canvas defaults can apply
1006 # ``CanvasAppSettings`` at the ``UserProfile`` level directly.
1008 # The domain validates that the EFS file system has mount targets in
1009 # every subnet before stabilizing. CDK doesn't infer this dependency
1010 # from the file_system_id reference alone, so we add it explicitly.
1011 self.studio_domain.node.add_dependency(self.studio_efs)
1013 CfnOutput(
1014 self,
1015 "StudioDomainName",
1016 value=self.studio_domain.domain_name or "",
1017 description="Name of the SageMaker Studio domain",
1018 )
1020 # Cleanup custom resource — on stack deletion, removes all user
1021 # profiles from the domain and all access points from the EFS so
1022 # CloudFormation can delete the domain and file system cleanly.
1023 from aws_cdk import CustomResource
1024 from aws_cdk import custom_resources as cr_provider
1026 cleanup_fn = lambda_.Function(
1027 self,
1028 "CleanupFunction",
1029 runtime=getattr(lambda_.Runtime, LAMBDA_PYTHON_RUNTIME),
1030 handler="handler.handler",
1031 code=lambda_source_code("lambda/analytics-cleanup"),
1032 # 15 minutes covers the worst case of multiple async drain
1033 # loops in series: apps (up to ~2 min), spaces (up to ~3 min),
1034 # user profiles (up to ~3 min), SageMaker-managed EFS mount
1035 # targets (up to ~2 min), plus incidental RPC latency and
1036 # security-group cleanup. In the common case (a handful of
1037 # users) this finishes in well under a minute.
1038 timeout=Duration.minutes(15),
1039 environment={
1040 "DOMAIN_ID": self.studio_domain.attr_domain_id,
1041 "EFS_ID": self.studio_efs.file_system_id,
1042 "REGION": self.region,
1043 "VPC_ID": self.vpc.vpc_id,
1044 },
1045 )
1047 # Use a customer-managed policy instead of an inline policy.
1048 # Inline policies (created by add_to_role_policy) are separate
1049 # CloudFormation resources that can be deleted before the custom
1050 # resource fires during stack deletion. A managed policy attached
1051 # via the role's managedPolicies property is part of the role
1052 # resource itself and persists until the role is deleted.
1053 cleanup_policy = iam.ManagedPolicy(
1054 self,
1055 "CleanupFunctionPolicy",
1056 statements=[
1057 iam.PolicyStatement(
1058 effect=iam.Effect.ALLOW,
1059 actions=[
1060 "sagemaker:ListApps",
1061 "sagemaker:DeleteApp",
1062 "sagemaker:ListSpaces",
1063 "sagemaker:DeleteSpace",
1064 "sagemaker:ListUserProfiles",
1065 "sagemaker:DeleteUserProfile",
1066 "sagemaker:DescribeDomain",
1067 "elasticfilesystem:DescribeAccessPoints",
1068 "elasticfilesystem:DeleteAccessPoint",
1069 "elasticfilesystem:DescribeFileSystems",
1070 "elasticfilesystem:DescribeMountTargets",
1071 "elasticfilesystem:DeleteMountTarget",
1072 "elasticfilesystem:DeleteFileSystem",
1073 "elasticfilesystem:DeleteFileSystemPolicy",
1074 "ec2:DescribeSecurityGroups",
1075 "ec2:DeleteSecurityGroup",
1076 "ec2:RevokeSecurityGroupIngress",
1077 "ec2:RevokeSecurityGroupEgress",
1078 ],
1079 resources=["*"],
1080 )
1081 ],
1082 )
1083 assert cleanup_fn.role is not None
1084 cleanup_fn.role.add_managed_policy(cleanup_policy)
1086 cleanup_provider = cr_provider.Provider(
1087 self,
1088 "CleanupProvider",
1089 on_event_handler=cleanup_fn,
1090 )
1092 cleanup_resource = CustomResource(
1093 self,
1094 "DomainCleanup",
1095 service_token=cleanup_provider.service_token,
1096 )
1098 # The managed policy must not be deleted until after the cleanup
1099 # custom resource completes. Adding a dependency ensures
1100 # CloudFormation keeps the policy alive during the Lambda execution.
1101 cleanup_resource.node.add_dependency(cleanup_policy)
1103 # Store reference so _create_presigned_url_lambda can add a
1104 # dependency after it creates the presigned-URL Lambda.
1105 self._cleanup_resource = cleanup_resource
1107 # Nag suppression for the cleanup Lambda — Resource::* is required
1108 # because ListUserProfiles/DeleteUserProfile and
1109 # DescribeAccessPoints/DeleteAccessPoint don't support resource-level
1110 # scoping (the domain ID and EFS ID are passed via env vars, not ARNs).
1111 from gco.stacks.nag_suppressions import acknowledge_nag_findings
1113 assert cleanup_fn.role is not None # always set for non-imported functions
1114 acknowledge_nag_findings(
1115 cleanup_fn.role,
1116 [
1117 {
1118 "id": "AwsSolutions-IAM5",
1119 "reason": (
1120 "Cleanup Lambda needs Resource::* for "
1121 "sagemaker:ListUserProfiles/DeleteUserProfile and "
1122 "efs:DescribeAccessPoints/DeleteAccessPoint. These "
1123 "APIs don't support resource-level scoping. The "
1124 "Lambda only runs on stack deletion and is scoped "
1125 "to the domain ID and EFS ID via environment variables."
1126 ),
1127 "appliesTo": ["Resource::*"],
1128 },
1129 {
1130 "id": "AwsSolutions-IAM4",
1131 "reason": (
1132 "Cleanup Lambda uses AWSLambdaBasicExecutionRole "
1133 "managed policy for CloudWatch Logs access."
1134 ),
1135 "appliesTo": [
1136 "Policy::arn:<AWS::Partition>:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole",
1137 ],
1138 },
1139 ],
1140 )
1141 acknowledge_nag_findings(
1142 cleanup_provider,
1143 [
1144 {
1145 "id": "AwsSolutions-IAM5",
1146 "reason": (
1147 "CDK Provider framework uses Resource::* for its "
1148 "internal Lambda invocation policy."
1149 ),
1150 "appliesTo": [
1151 "Resource::*",
1152 "Resource::<CleanupFunction1604930F.Arn>:*",
1153 ],
1154 },
1155 {
1156 "id": "AwsSolutions-IAM4",
1157 "reason": ("CDK Provider framework uses AWSLambdaBasicExecutionRole."),
1158 "appliesTo": [
1159 "Policy::arn:<AWS::Partition>:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole",
1160 ],
1161 },
1162 {
1163 "id": "AwsSolutions-L1",
1164 "reason": ("CDK Provider framework manages its own Lambda runtime version."),
1165 },
1166 ],
1167 )
1169 # ==================================================================
1170 # EMR Serverless application
1171 # ==================================================================
1173 def _create_emr_app(self) -> None:
1174 """Create an EMR Serverless Spark application on the private VPC.
1176 Pinned ``release_label`` lives in
1177 ``gco.stacks.constants.EMR_SERVERLESS_RELEASE_LABEL`` so analytics
1178 workloads get a reproducible Spark runtime across deployments. The
1179 application's network configuration uses the private
1180 subnets + a dedicated security group so Spark workers stay on the
1181 same network perimeter as the Studio notebooks.
1182 """
1183 private_subnet_ids = [
1184 s.subnet_id
1185 for s in self.vpc.select_subnets(subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS).subnets
1186 ]
1188 self.emr_security_group = ec2.SecurityGroup(
1189 self,
1190 "EmrServerlessSecurityGroup",
1191 vpc=self.vpc,
1192 description="SG for EMR Serverless Spark workers",
1193 allow_all_outbound=True,
1194 )
1196 self.emr_app = emrserverless.CfnApplication(
1197 self,
1198 "EmrServerlessApp",
1199 name=f"{self.project_name}-spark-{self.region}",
1200 release_label=EMR_SERVERLESS_RELEASE_LABEL,
1201 type="SPARK",
1202 network_configuration=emrserverless.CfnApplication.NetworkConfigurationProperty(
1203 subnet_ids=private_subnet_ids,
1204 security_group_ids=[self.emr_security_group.security_group_id],
1205 ),
1206 )
1208 # ==================================================================
1209 # Cognito pool + client + domain
1210 # ==================================================================
1212 def _create_cognito_pool(self) -> None:
1213 """Create the Cognito user pool that authenticates SageMaker Studio logins.
1215 Password policy, standard threat-protection mode, and self-sign-up-
1216 disabled flags are configured for SRP-backed Studio logins. The
1217 attached ``UserPoolClient`` runs SRP auth
1218 (used by ``gco analytics studio login``) with token revocation
1219 enabled. The ``UserPoolDomain`` uses the configurable prefix from
1220 ``analytics_environment.cognito.domain_prefix`` or defaults to
1221 ``gco-studio-<account>``.
1222 """
1223 self.cognito_pool = cognito.UserPool(
1224 self,
1225 "StudioUserPool",
1226 self_sign_up_enabled=False,
1227 password_policy=cognito.PasswordPolicy(
1228 min_length=12,
1229 require_digits=True,
1230 require_symbols=True,
1231 require_uppercase=True,
1232 require_lowercase=True,
1233 ),
1234 sign_in_aliases=cognito.SignInAliases(username=True),
1235 auto_verify=cognito.AutoVerifiedAttrs(email=True),
1236 # Replaces the deprecated ``advanced_security_mode`` kwarg
1237 # (aws-cdk-lib's AdvancedSecurityMode enum is gone as of the
1238 # Cognito November 2024 tier changes). Lite feature plan — the
1239 # default — does not support real threat protection, so we set
1240 # ``NO_ENFORCEMENT`` here to keep the synth warning-free.
1241 # TODO: operators who want real threat protection should opt
1242 # into the Essentials or Plus feature plan by also setting
1243 # ``feature_plan=cognito.FeaturePlan.ESSENTIALS`` (or
1244 # ``FeaturePlan.PLUS``) and flipping this to
1245 # ``StandardThreatProtectionMode.FULL_FUNCTION``. That path
1246 # changes the per-MAU price — see the Cognito pricing doc —
1247 # which is why the default stays on Lite+NO_ENFORCEMENT.
1248 standard_threat_protection_mode=(cognito.StandardThreatProtectionMode.NO_ENFORCEMENT),
1249 removal_policy=self.cognito_removal,
1250 )
1252 from gco.stacks.nag_suppressions import acknowledge_nag_findings
1254 # cdk-nag AwsSolutions-COG8 (new in cdk-nag 2.38.x): the Lite feature plan is intentional (cost); see UserPool above.
1255 acknowledge_nag_findings(
1256 self.cognito_pool,
1257 [
1258 {
1259 "id": "AwsSolutions-COG8",
1260 "reason": "Studio user pool intentionally uses the Lite feature plan with NO_ENFORCEMENT threat protection to avoid the Plus plan per-MAU cost; it only gates internal SageMaker Studio access. Operators who need threat protection can opt into the Essentials/Plus feature plan as documented on the UserPool definition.",
1261 }
1262 ],
1263 )
1264 self.cognito_client = self.cognito_pool.add_client(
1265 "StudioUserPoolClient",
1266 auth_flows=cognito.AuthFlow(
1267 user_srp=True,
1268 admin_user_password=True,
1269 ),
1270 prevent_user_existence_errors=True,
1271 enable_token_revocation=True,
1272 )
1274 # Domain prefix — default is ``<project_name>-studio-<account>`` (from
1275 # constants.cognito_domain_prefix_default(project_name) + account suffix).
1276 # The override in cdk.json is used verbatim when non-None, without
1277 # appending the account id, because operators who override the
1278 # prefix typically want a short memorable value.
1279 if self._cognito_domain_prefix_override:
1280 domain_prefix = self._cognito_domain_prefix_override
1281 else:
1282 domain_prefix = f"{cognito_domain_prefix_default(self.project_name)}-{self.account}"
1284 self.cognito_domain = self.cognito_pool.add_domain(
1285 "StudioUserPoolDomain",
1286 cognito_domain=cognito.CognitoDomainOptions(domain_prefix=domain_prefix),
1287 )
1289 CfnOutput(
1290 self,
1291 "CognitoUserPoolId",
1292 value=self.cognito_pool.user_pool_id,
1293 description="ID of the Cognito user pool that gates SageMaker Studio",
1294 )
1295 CfnOutput(
1296 self,
1297 "CognitoUserPoolArn",
1298 value=self.cognito_pool.user_pool_arn,
1299 description="ARN of the Cognito user pool",
1300 )
1301 CfnOutput(
1302 self,
1303 "CognitoUserPoolClientId",
1304 value=self.cognito_client.user_pool_client_id,
1305 description="Client ID used by the GCO CLI for SRP auth",
1306 )
1308 # ==================================================================
1309 # Presigned-URL Lambda
1310 # ==================================================================
1312 def _create_presigned_url_lambda(self) -> None:
1313 """Create the ``Presigned_URL_Lambda`` that mints Studio login URLs.
1315 Wired into API Gateway's ``/studio/login`` route from
1316 ``GCOApiGatewayGlobalStack``. The function lives on
1317 ``GCOAnalyticsStack`` (not the API gateway stack) so its IAM role
1318 can reference ``SageMaker_Execution_Role.role_arn`` on ``PassRole``
1319 and ``Studio_EFS.file_system_arn`` on the EFS access-point actions
1320 without a cross-stack import.
1322 Key configuration:
1324 * Runtime: ``LAMBDA_PYTHON_RUNTIME`` from ``gco.stacks.constants``.
1325 * Timeout: 29 s — API Gateway's maximum integration timeout is 29
1326 seconds, so matching it here lets the Lambda time out *before*
1327 API Gateway does, producing a clean HTTP 500 with our opaque
1328 error token rather than API Gateway's 504.
1329 * Tracing: ``ACTIVE`` so X-Ray captures the
1330 ``sagemaker:CreatePresignedDomainUrl`` call.
1331 * Log group retention: 1 month.
1333 IAM scoping:
1335 * ``sagemaker:ListDomains`` — no resource-level scoping available;
1336 scoped with a documented ``Resource::*`` nag suppression.
1337 * ``sagemaker:DescribeDomain`` + ``CreatePresignedDomainUrl`` +
1338 ``DescribeUserProfile`` + ``CreateUserProfile`` + ``ListTags`` +
1339 ``AddTags`` scoped to the domain and user-profile ARN families
1340 in this region+account. We cannot pin the ``DomainId`` at synth
1341 time because ``list_domains`` runs at invoke time, so the ARN
1342 shape includes a wildcard segment covering "any domain id".
1343 * ``iam:PassRole`` on ``SageMaker_Execution_Role.role_arn`` with a
1344 ``StringEquals iam:PassedToService=sagemaker.amazonaws.com``
1345 condition so the role can only ever be handed to SageMaker.
1346 * ``elasticfilesystem:DescribeAccessPoints`` +
1347 ``CreateAccessPoint`` on ``Studio_EFS.file_system_arn`` for the
1348 lazy per-user access-point creation path in the handler.
1349 * ``AWSLambdaBasicExecutionRole`` managed policy for the CloudWatch
1350 Logs + X-Ray write path.
1351 """
1352 from gco.stacks.nag_suppressions import acknowledge_nag_findings
1354 # Dedicated IAM role — narrow-scoped, no reuse across other
1355 # Lambdas. We attach the basic execution role as a managed policy
1356 # so the nag rule for ``AwsSolutions-IAM4`` is happy; everything
1357 # else is an inline policy we own entirely.
1358 self.presigned_url_lambda_role = iam.Role(
1359 self,
1360 "PresignedUrlLambdaRole",
1361 assumed_by=iam.ServicePrincipal("lambda.amazonaws.com"),
1362 description=(
1363 "Execution role for the analytics presigned-URL Lambda. "
1364 "Scoped to SageMaker domain + user-profile operations, "
1365 "PassRole on SageMaker_Execution_Role, and EFS access-"
1366 "point management on Studio_EFS."
1367 ),
1368 managed_policies=[
1369 iam.ManagedPolicy.from_aws_managed_policy_name(
1370 "service-role/AWSLambdaBasicExecutionRole"
1371 )
1372 ],
1373 )
1375 # ListDomains does not support resource-level scoping (AWS API
1376 # constraint). We use Resource::* and document the effective
1377 # blast radius in the nag suppression below — one list call per
1378 # invocation against the region's SageMaker control plane.
1379 self.presigned_url_lambda_role.add_to_policy(
1380 iam.PolicyStatement(
1381 effect=iam.Effect.ALLOW,
1382 actions=["sagemaker:ListDomains"],
1383 resources=["*"],
1384 )
1385 )
1387 # Domain + user-profile actions. At synth time we don't know the
1388 # DomainId (list_domains is an invoke-time call), so the ARN
1389 # wildcards cover "any domain in this region+account" and "any
1390 # user profile under any domain in this region+account". The
1391 # account is still pinned, so the blast radius is bounded to
1392 # this account's SageMaker Studio installation.
1393 domain_arn_prefix = f"arn:{self.partition}:sagemaker:{self.region}:{self.account}:domain/*"
1394 user_profile_arn_prefix = (
1395 f"arn:{self.partition}:sagemaker:{self.region}:{self.account}:user-profile/*/*"
1396 )
1397 self.presigned_url_lambda_role.add_to_policy(
1398 iam.PolicyStatement(
1399 effect=iam.Effect.ALLOW,
1400 actions=[
1401 "sagemaker:DescribeDomain",
1402 "sagemaker:CreatePresignedDomainUrl",
1403 "sagemaker:DescribeUserProfile",
1404 "sagemaker:CreateUserProfile",
1405 "sagemaker:ListTags",
1406 "sagemaker:AddTags",
1407 ],
1408 resources=[domain_arn_prefix, user_profile_arn_prefix],
1409 )
1410 )
1412 # iam:PassRole — only SageMaker_Execution_Role, only to
1413 # sagemaker.amazonaws.com. This is what CreateUserProfile passes
1414 # on the ``ExecutionRole`` field.
1415 self.presigned_url_lambda_role.add_to_policy(
1416 iam.PolicyStatement(
1417 effect=iam.Effect.ALLOW,
1418 actions=["iam:PassRole"],
1419 resources=[self.sagemaker_execution_role.role_arn],
1420 conditions={
1421 "StringEquals": {
1422 "iam:PassedToService": "sagemaker.amazonaws.com",
1423 }
1424 },
1425 )
1426 )
1428 # EFS access-point management — scoped to the Studio_EFS file
1429 # system. The Lambda creates one access point per Cognito user
1430 # at first login (lazy-in-Lambda approach).
1431 self.presigned_url_lambda_role.add_to_policy(
1432 iam.PolicyStatement(
1433 effect=iam.Effect.ALLOW,
1434 actions=[
1435 "elasticfilesystem:DescribeAccessPoints",
1436 "elasticfilesystem:CreateAccessPoint",
1437 "elasticfilesystem:TagResource",
1438 ],
1439 resources=[self.studio_efs.file_system_arn],
1440 )
1441 )
1443 # CloudWatch log group with 1-month retention. We own
1444 # the group explicitly (rather than letting Lambda auto-create
1445 # one) so the retention setting is captured in the template.
1446 presigned_url_log_group = logs.LogGroup(
1447 self,
1448 "PresignedUrlLambdaLogGroup",
1449 retention=logs.RetentionDays.ONE_MONTH,
1450 removal_policy=RemovalPolicy.DESTROY,
1451 )
1453 self.presigned_url_lambda = lambda_.Function(
1454 self,
1455 "PresignedUrlFunction",
1456 runtime=getattr(lambda_.Runtime, LAMBDA_PYTHON_RUNTIME),
1457 handler="handler.lambda_handler",
1458 code=lambda_source_code("lambda/analytics-presigned-url"),
1459 role=self.presigned_url_lambda_role,
1460 timeout=Duration.seconds(29),
1461 memory_size=256,
1462 tracing=lambda_.Tracing.ACTIVE,
1463 log_group=presigned_url_log_group,
1464 description=(
1465 "Exchanges a Cognito-authorized event for a presigned "
1466 "SageMaker Studio URL. Wired into /studio/login by "
1467 "GCOApiGatewayGlobalStack."
1468 ),
1469 environment={
1470 "STUDIO_DOMAIN_ID": self.studio_domain.attr_domain_id,
1471 "SAGEMAKER_EXECUTION_ROLE_ARN": self.sagemaker_execution_role.role_arn,
1472 "STUDIO_EFS_ID": self.studio_efs.file_system_id,
1473 "URL_EXPIRES_SECONDS": "300",
1474 "SESSION_EXPIRES_SECONDS": "43200",
1475 },
1476 )
1478 CfnOutput(
1479 self,
1480 "PresignedUrlLambdaArn",
1481 value=self.presigned_url_lambda.function_arn,
1482 description=(
1483 "ARN of the presigned-URL Lambda - consumed by the API "
1484 "Gateway stack's /studio/login integration."
1485 ),
1486 )
1488 # Nag suppressions. Each one carries a literal-ARN or documented
1489 # wildcard ``applies_to`` and a ``reason`` string explaining why
1490 # tighter scoping isn't possible.
1491 acknowledge_nag_findings(
1492 self.presigned_url_lambda_role,
1493 [
1494 {
1495 "id": "AwsSolutions-IAM5",
1496 "reason": (
1497 "sagemaker:ListDomains does not support resource-"
1498 "level scoping — the AWS API only accepts "
1499 "Resource: *. Effective blast radius: a single "
1500 "paginated list call per Lambda invocation "
1501 "against this account's SageMaker control plane "
1502 "in this region. The remaining SageMaker actions "
1503 "(DescribeDomain, CreatePresignedDomainUrl, "
1504 "DescribeUserProfile, CreateUserProfile, "
1505 "ListTags, AddTags) are scoped to the literal "
1506 "arn:<partition>:sagemaker:<region>:<account>:domain/* "
1507 "and arn:<partition>:sagemaker:<region>:<account>:"
1508 "user-profile/*/* ARN families, which is the "
1509 "tightest we can achieve at synth time because "
1510 "DomainId is only resolvable at invoke time."
1511 ),
1512 "appliesTo": [
1513 "Resource::*",
1514 (
1515 "Resource::arn:<AWS::Partition>:sagemaker:<AWS::Region>:<AWS::AccountId>:domain/*"
1516 ),
1517 (
1518 "Resource::arn:<AWS::Partition>:sagemaker:<AWS::Region>:"
1519 "<AWS::AccountId>:user-profile/*/*"
1520 ),
1521 ],
1522 },
1523 ],
1524 )
1526 # The cleanup custom resource must fire AFTER the presigned-URL
1527 # Lambda is deleted during stack destruction. Otherwise the Lambda
1528 # can recreate user profiles (via in-flight login requests) between
1529 # cleanup and domain deletion. Adding the dependency here (after
1530 # the Lambda is created) ensures correct deletion ordering.
1531 self._cleanup_resource.node.add_dependency(self.presigned_url_lambda)
1533 # ==================================================================
1534 # Nag suppressions
1535 # ==================================================================
1537 def _apply_nag_suppressions(self) -> None:
1538 """Dispatch to the analytics branch in ``gco/stacks/nag_suppressions.py``.
1540 The analytics branch calls ``add_sagemaker_suppressions``,
1541 ``add_cognito_suppressions``, ``add_emr_serverless_suppressions``,
1542 ``add_storage_suppressions`` (for ``Studio_Only_Bucket`` + access-
1543 logs bucket), ``add_lambda_suppressions`` (for the presigned-URL
1544 Lambda provider framework), and ``add_iam_suppressions`` (for
1545 cross-region SSM reads + CDK custom resources).
1546 """
1547 apply_all_suppressions(
1548 self,
1549 stack_type="analytics",
1550 regions=None,
1551 global_region=self.config.get_global_region(),
1552 api_gateway_region=self.config.get_api_gateway_region(),
1553 project_name=self.project_name,
1554 )