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
« 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.
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.nag_suppressions import apply_all_suppressions
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
73def _parse_removal(value: str) -> RemovalPolicy:
74 """Map a cdk.json removal-policy string to ``aws_cdk.RemovalPolicy``.
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 )
90class GCOAnalyticsStack(Stack):
91 """Optional ML/analytics environment: SageMaker Studio, EMR Serverless, Cognito.
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 """
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)
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
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")
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()
138 # ==================================================================
139 # KMS + VPC
140 # ==================================================================
142 def _create_kms_key(self) -> None:
143 """Create ``Analytics_KMS_Key`` with rotation + 7-day pending window.
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 )
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 )
185 def _create_vpc_and_endpoints(self) -> None:
186 """Create a private VPC plus every VPC endpoint Studio needs.
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 )
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 )
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 )
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
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 )
258 # ==================================================================
259 # S3 buckets
260 # ==================================================================
262 def _create_access_logs_bucket(self) -> None:
263 """Create the dedicated access-logs bucket for ``Studio_Only_Bucket``.
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 )
291 def _create_studio_only_bucket(self) -> None:
292 """Create ``Studio_Only_Bucket`` for notebook-private scratch + outputs.
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 )
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 )
340 # ==================================================================
341 # Studio EFS
342 # ==================================================================
344 def _create_studio_efs(self) -> None:
345 """Create ``Studio_EFS`` with KMS encryption + TLS in transit.
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.
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 )
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
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 )
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 )
397 # ==================================================================
398 # SageMaker execution role + grants
399 # ==================================================================
401 def _create_execution_role_and_grants(self) -> None:
402 """Create ``SageMaker_Execution_Role`` and attach its (non-cluster-shared) grants.
404 Role name begins with ``AmazonSageMaker`` — SageMaker
405 requires this prefix for any role used by a Studio domain. Grants
406 attached here:
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.
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 )
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)
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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
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 )
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 )
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 )
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 )
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 )
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 )
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 )
781 def _grant_sagemaker_role_on_cluster_shared_bucket(self) -> None:
782 """Attach RW + KMS on ``Cluster_Shared_Bucket`` to ``SageMaker_Execution_Role``.
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``.
789 Two statements attach to the role:
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.
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
801 global_region = self.config.get_global_region()
802 parameter_name = f"{cluster_shared_ssm_parameter_prefix(self.project_name)}/arn"
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 )
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 )
851 shared_arn = read_cr.get_response_field("Parameter.Value")
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 )
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 )
907 # ==================================================================
908 # SageMaker Studio domain
909 # ==================================================================
911 def _create_studio_domain(self) -> None:
912 """Create the SageMaker Studio domain bound to the private VPC.
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).
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
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 )
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 ]
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 )
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 )
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 )
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.
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)
1012 CfnOutput(
1013 self,
1014 "StudioDomainName",
1015 value=self.studio_domain.domain_name or "",
1016 description="Name of the SageMaker Studio domain",
1017 )
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
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 )
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)
1085 cleanup_provider = cr_provider.Provider(
1086 self,
1087 "CleanupProvider",
1088 on_event_handler=cleanup_fn,
1089 )
1091 cleanup_resource = CustomResource(
1092 self,
1093 "DomainCleanup",
1094 service_token=cleanup_provider.service_token,
1095 )
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)
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
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
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 )
1168 # ==================================================================
1169 # EMR Serverless application
1170 # ==================================================================
1172 def _create_emr_app(self) -> None:
1173 """Create an EMR Serverless Spark application on the private VPC.
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 ]
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 )
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 )
1207 # ==================================================================
1208 # Cognito pool + client + domain
1209 # ==================================================================
1211 def _create_cognito_pool(self) -> None:
1212 """Create the Cognito user pool that authenticates SageMaker Studio logins.
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 )
1251 from gco.stacks.nag_suppressions import acknowledge_nag_findings
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 )
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}"
1283 self.cognito_domain = self.cognito_pool.add_domain(
1284 "StudioUserPoolDomain",
1285 cognito_domain=cognito.CognitoDomainOptions(domain_prefix=domain_prefix),
1286 )
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 )
1307 # ==================================================================
1308 # Presigned-URL Lambda
1309 # ==================================================================
1311 def _create_presigned_url_lambda(self) -> None:
1312 """Create the ``Presigned_URL_Lambda`` that mints Studio login URLs.
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.
1321 Key configuration:
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.
1332 IAM scoping:
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
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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)
1532 # ==================================================================
1533 # Nag suppressions
1534 # ==================================================================
1536 def _apply_nag_suppressions(self) -> None:
1537 """Dispatch to the analytics branch in ``gco/stacks/nag_suppressions.py``.
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 )