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