def __init__(self, scope: Construct, construct_id: str, api_url: str, waf_acl_arn: str, **kwargs: Any) -> None:
"""Provision all frontend AWS resources.
Args:
scope: The CDK construct scope.
construct_id: The unique identifier for this stack.
api_url: The backend API Gateway URL, injected into config.json at deploy time.
waf_acl_arn: ARN of the WAF WebACL from HelloWorldWafStack (always in us-east-1).
**kwargs: Additional keyword arguments passed to the parent Stack.
"""
super().__init__(scope, construct_id, **kwargs)
apply_compliance_aspects(self)
# ── KMS key ──────────────────────────────────────────────────────────
# Used to encrypt the frontend S3 bucket and CloudWatch log group.
# CloudWatch Logs requires the Logs service principal in the key policy.
frontend_encryption_key = kms.Key(
self,
"FrontendEncryptionKey",
description=f"KMS key for {self.stack_name} S3 bucket and log groups",
enable_key_rotation=True,
# See HelloWorldApp.encryption_key for the rationale — automated
# rotation, no dependent redeploys, 90-day compliance baseline.
rotation_period=Duration.days(90),
removal_policy=RemovalPolicy.DESTROY,
)
# Confused-deputy guard on the CMK's CloudWatch Logs service grant.
# See ``grant_logs_service_to_key`` in ``nag_utils.py``.
grant_logs_service_to_key(
frontend_encryption_key,
region=self.region,
account=self.account,
partition=self.partition,
)
# ── S3 access logging bucket ─────────────────────────────────────────
# Receives both S3 server access logs and CloudFront standard access
# logs. Must use SSE-S3 (not SSE-KMS) because neither the S3 log
# delivery service nor CloudFront standard logging support KMS-encrypted
# target buckets. This bucket itself does not need access logging (that
# would be circular), versioning, or replication.
access_log_bucket = s3.Bucket(
self,
"FrontendAccessLogBucket",
block_public_access=s3.BlockPublicAccess.BLOCK_ALL,
encryption=s3.BucketEncryption.S3_MANAGED,
enforce_ssl=True,
# CloudFront standard logging requires ACL-based delivery — the bucket owner
# needs FULL_CONTROL on delivered log objects. BUCKET_OWNER_PREFERRED keeps
# Object Ownership set so ACLs remain usable for CloudFront log delivery.
object_ownership=s3.ObjectOwnership.BUCKET_OWNER_PREFERRED,
versioned=False,
# 7-day expiration cap on every prefix in this bucket (S3 access logs,
# CloudFront standard logs, Athena query results). Tunable: extend
# the duration, swap to a tiered transition (Standard-IA at 30d,
# Glacier Instant Retrieval at 90d, Glacier Deep Archive at 180d),
# or layer per-prefix rules if logs and Athena results need
# different retention.
lifecycle_rules=[
s3.LifecycleRule(
id="ExpireAfter7Days",
enabled=True,
expiration=Duration.days(7),
abort_incomplete_multipart_upload_after=Duration.days(1),
),
],
removal_policy=RemovalPolicy.DESTROY,
auto_delete_objects=True,
)
access_log_bucket_suppressions = [
("AwsSolutions-S1", "This IS the access log bucket — logging to itself would be circular"),
(
"NIST.800.53.R5-S3BucketLoggingEnabled",
"This IS the access log bucket — logging to itself would be circular",
),
(
"NIST.800.53.R5-S3DefaultEncryptionKMS",
"S3 log delivery service does not support KMS-encrypted target buckets; SSE-S3 is used instead",
),
(
"HIPAA.Security-S3DefaultEncryptionKMS",
"S3 log delivery service does not support KMS-encrypted target buckets; SSE-S3 is used instead",
),
(
"PCI.DSS.321-S3DefaultEncryptionKMS",
"S3 log delivery service does not support KMS-encrypted target buckets; SSE-S3 is used instead",
),
(
"NIST.800.53.R5-S3BucketVersioningEnabled",
"Versioning not needed for log bucket — logs are append-only and transient",
),
(
"HIPAA.Security-S3BucketVersioningEnabled",
"Versioning not needed for log bucket — logs are append-only and transient",
),
(
"PCI.DSS.321-S3BucketVersioningEnabled",
"Versioning not needed for log bucket — logs are append-only and transient",
),
("NIST.800.53.R5-S3BucketReplicationEnabled", "Replication not needed for log bucket in sample app"),
("HIPAA.Security-S3BucketReplicationEnabled", "Replication not needed for log bucket in sample app"),
("PCI.DSS.321-S3BucketReplicationEnabled", "Replication not needed for log bucket in sample app"),
]
NagSuppressions.add_resource_suppressions(
access_log_bucket,
[{"id": rule, "reason": reason} for rule, reason in access_log_bucket_suppressions],
)
# ── S3 bucket ────────────────────────────────────────────────────────
# Fully private — CloudFront OAC is the only allowed reader.
# KMS-encrypted with server access logging to access_log_bucket.
bucket = s3.Bucket(
self,
"FrontendBucket",
block_public_access=s3.BlockPublicAccess.BLOCK_ALL,
encryption=s3.BucketEncryption.KMS,
encryption_key=frontend_encryption_key,
enforce_ssl=True,
server_access_logs_bucket=access_log_bucket,
server_access_logs_prefix="s3-access-logs/",
versioned=False,
removal_policy=RemovalPolicy.DESTROY,
auto_delete_objects=True,
)
self._create_s3_audit_trail(audited_buckets=[bucket, access_log_bucket], encryption_key=frontend_encryption_key)
# ── CloudFront distribution ──────────────────────────────────────────
distribution = cloudfront.Distribution(
self,
"Distribution",
default_behavior=cloudfront.BehaviorOptions(
origin=origins.S3BucketOrigin.with_origin_access_control(bucket),
viewer_protocol_policy=cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
cache_policy=cloudfront.CachePolicy.CACHING_OPTIMIZED,
response_headers_policy=cloudfront.ResponseHeadersPolicy.SECURITY_HEADERS,
),
default_root_object="index.html",
error_responses=[
# Return index.html for 403/404 so SPA client-side routing works
cloudfront.ErrorResponse(
http_status=403,
response_http_status=200,
response_page_path="/index.html",
),
cloudfront.ErrorResponse(
http_status=404,
response_http_status=200,
response_page_path="/index.html",
),
],
minimum_protocol_version=cloudfront.SecurityPolicyProtocol.TLS_V1_2_2021,
web_acl_id=waf_acl_arn,
enable_logging=True,
log_bucket=access_log_bucket,
log_file_prefix="cloudfront/",
)
# ── CloudWatch RUM + X-Ray ───────────────────────────────────────────
# RUM collects browser telemetry (page loads, JS errors, fetch latency)
# and — with enable_x_ray — emits a client-side trace segment that joins
# the backend Lambda/API Gateway segments into a single X-Ray trace.
# Guest (unauthenticated) browsers authenticate via Cognito Identity
# Pool → STS AssumeRoleWithWebIdentity → scoped rum:PutRumEvents role.
# The monitor ARN is constructed from the known monitor name so the
# IAM role can reference it without a circular dependency on the
# CfnAppMonitor resource.
rum_identity_pool = cognito.CfnIdentityPool(
self,
"RumIdentityPool",
allow_unauthenticated_identities=True,
identity_pool_name=f"{self.stack_name}-rum",
)
rum_monitor_name = f"{self.stack_name}-rum"
rum_monitor_arn = f"arn:{self.partition}:rum:{self.region}:{self.account}:appmonitor/{rum_monitor_name}"
rum_unauth_role = iam.Role(
self,
"RumUnauthenticatedRole",
assumed_by=iam.FederatedPrincipal(
"cognito-identity.amazonaws.com",
conditions={
"StringEquals": {"cognito-identity.amazonaws.com:aud": rum_identity_pool.ref},
"ForAnyValue:StringLike": {"cognito-identity.amazonaws.com:amr": "unauthenticated"},
},
assume_role_action="sts:AssumeRoleWithWebIdentity",
),
description=f"Guest role assumed by browser RUM clients for {rum_monitor_name}",
inline_policies={
"AllowPutRumEvents": iam.PolicyDocument(
statements=[
iam.PolicyStatement(
actions=["rum:PutRumEvents"],
resources=[rum_monitor_arn],
)
]
)
},
)
cognito.CfnIdentityPoolRoleAttachment(
self,
"RumIdentityPoolRoleAttachment",
identity_pool_id=rum_identity_pool.ref,
roles={"unauthenticated": rum_unauth_role.role_arn},
)
rum_app_monitor = rum.CfnAppMonitor(
self,
"RumAppMonitor",
name=rum_monitor_name,
domain=distribution.distribution_domain_name,
cw_log_enabled=True,
# Enable custom events so the frontend can call cwr('recordEvent', ...)
# for domain telemetry. Without this set to ENABLED, custom event
# uploads are silently dropped at the data plane.
custom_events=rum.CfnAppMonitor.CustomEventsProperty(status="ENABLED"),
app_monitor_configuration=rum.CfnAppMonitor.AppMonitorConfigurationProperty(
allow_cookies=True,
enable_x_ray=True,
session_sample_rate=1.0,
# CloudFormation's schema only accepts ["errors", "performance", "http"] here —
# "interaction" is rejected as an invalid enum value despite being a real RUM
# plugin. This server-side list is metadata used by the AWS-generated snippet,
# not the live plugin loader. The actual plugin set is controlled by the
# client-side `telemetries` array in frontend/index.html, which DOES include
# "interaction" alongside the http tuple form. Keep these two lists divergent
# on purpose; do not "sync" them.
telemetries=["errors", "performance", "http"],
identity_pool_id=rum_identity_pool.ref,
guest_role_arn=rum_unauth_role.role_arn,
),
)
# CMK-encrypted log group for the BucketDeployment provider Lambda.
# Passing log_group= here (instead of log_retention=) avoids the legacy
# LogRetention singleton path and keeps every log group encrypted with
# this stack's CMK.
bucket_deployment_log_group = logs.LogGroup(
self,
"BucketDeploymentLogGroup",
encryption_key=frontend_encryption_key,
retention=logs.RetentionDays.ONE_WEEK,
removal_policy=RemovalPolicy.DESTROY,
)
# Shared CMK-encrypted log group for all AwsCustomResource singletons in
# this stack (RumMetricsDestination, RumExtendedMetrics, InvalidateCloudFrontCache).
# CDK reuses one provider Lambda across every AwsCustomResource in a stack,
# so a single log group serves all three.
custom_resource_log_group = logs.LogGroup(
self,
"AwsCustomResourceLogGroup",
encryption_key=frontend_encryption_key,
retention=logs.RetentionDays.ONE_WEEK,
removal_policy=RemovalPolicy.DESTROY,
)
rum_extended_metrics = self._wire_rum_metrics_extras(
rum_app_monitor, rum_monitor_name, rum_monitor_arn, custom_resource_log_group
)
self._wire_rum_log_group_cleanup(rum_app_monitor, rum_monitor_name, custom_resource_log_group)
# ── Deploy frontend assets ───────────────────────────────────────────
# Uploads frontend/ to S3 and generates config.json with the API URL
# and RUM client config injected at deploy time. Cache invalidation is
# handled by a separate AwsCustomResource below — the BucketDeployment's
# built-in `distribution=` parameter is intentionally not used because
# its delete-time invalidation races with CloudFront's own deletion on
# `cdk destroy` (aws/aws-cdk#15891).
bucket_deployment = s3deploy.BucketDeployment(
self,
"DeployFrontend",
sources=[
s3deploy.Source.asset("frontend"),
s3deploy.Source.json_data(
"config.json",
{
"apiUrl": api_url,
"rum": {
"appMonitorId": rum_app_monitor.attr_id,
"identityPoolId": rum_identity_pool.ref,
"region": self.region,
# Session attributes are attached to every RUM event
# in the session. Sourcing them from deploy-time
# config (rather than hardcoding in the HTML) lets
# multiple deploys feed the same dashboard while
# remaining filterable.
"sessionAttributes": {
"applicationName": self.stack_name,
},
},
},
),
],
destination_bucket=bucket,
log_group=bucket_deployment_log_group,
)
# Defer the slow asset deploy until after the RUM custom resources
# have succeeded. If RumExtendedMetrics fails (it depends on IAM
# propagation), the BucketDeployment never starts — saving the most
# expensive single resource from being repeated on every retry until
# the cheaper IAM dance settles.
bucket_deployment.node.add_dependency(rum_extended_metrics)
# CloudFront cache invalidation, decoupled from BucketDeployment.
# Defines on_create and on_update only — no on_delete — so CFN simply
# removes this resource from stack state during teardown without any
# CloudFront API call to race with the distribution's own deletion.
# This is the permanent fix for aws/aws-cdk#15891, replacing the
# BucketDeployment's built-in invalidation hook.
#
# CallerReference is gated on the BucketDeployment's content-hashed S3
# object key. Same assets → same key → CFN sees no change → no
# invalidation fires (correct: nothing to invalidate). Different assets
# → different key → CFN fires on_update → invalidation runs. Prevents
# backend-only deploys from burning the 1000/month free invalidation
# quota. See README "Design decisions" for the longer write-up.
cf_invalidation_call = cr.AwsSdkCall(
service="CloudFront",
action="createInvalidation",
parameters={
"DistributionId": distribution.distribution_id,
"InvalidationBatch": {
"Paths": {"Quantity": 1, "Items": ["/*"]},
# object_keys is a CDK list-token, not a Python list — use Fn.select.
"CallerReference": Fn.select(0, bucket_deployment.object_keys),
},
},
physical_resource_id=cr.PhysicalResourceId.of(f"{self.stack_name}-cf-invalidation"),
)
invalidate_cf_cache = cr.AwsCustomResource(
self,
"InvalidateCloudFrontCache",
on_create=cf_invalidation_call,
on_update=cf_invalidation_call,
policy=cr.AwsCustomResourcePolicy.from_statements(
[
iam.PolicyStatement(
actions=["cloudfront:CreateInvalidation"],
resources=[
f"arn:{Stack.of(self).partition}:cloudfront::{Stack.of(self).account}:distribution/{distribution.distribution_id}"
],
),
]
),
log_group=custom_resource_log_group,
)
invalidate_cf_cache.node.add_dependency(bucket_deployment)
# CDK generates an inline default policy on the AwsCustomResource's
# auto-created role. Same constraint as the RUM custom resources;
# apply the same IAMNoInlinePolicy suppressions.
cf_invalidation_inline_reason = (
"AwsCustomResource policy is a single least-privilege inline statement scoped to "
"cloudfront:CreateInvalidation on this stack's distribution ARN — managed-policy "
"reuse adds nothing"
)
NagSuppressions.add_resource_suppressions(
invalidate_cf_cache,
[
{"id": "NIST.800.53.R5-IAMNoInlinePolicy", "reason": cf_invalidation_inline_reason},
{"id": "HIPAA.Security-IAMNoInlinePolicy", "reason": cf_invalidation_inline_reason},
{"id": "PCI.DSS.321-IAMNoInlinePolicy", "reason": cf_invalidation_inline_reason},
],
apply_to_children=True,
)
CfnOutput(
self,
"CloudFrontDomainName",
description="CloudFront distribution domain name — use this as your frontend URL",
value=f"https://{distribution.distribution_domain_name}",
)
CfnOutput(
self,
"CloudFrontDistributionId",
description="CloudFront distribution ID — needed for manual cache invalidations",
value=distribution.distribution_id,
)
CfnOutput(
self,
"FrontendBucketName",
description="S3 bucket storing the frontend static assets",
value=bucket.bucket_name,
)
CfnOutput(
self,
"RumAppMonitorId",
description="CloudWatch RUM app monitor ID — used by the browser RUM client",
value=rum_app_monitor.attr_id,
)
CfnOutput(
self,
"RumIdentityPoolId",
description="Cognito Identity Pool ID — used by the browser RUM client for guest credentials",
value=rum_identity_pool.ref,
)
# ── RUM / Cognito cdk-nag suppressions ───────────────────────────────
# Unauthenticated identities are intentional — browsers have no prior
# identity and RUM's guest-credentials model is the standard pattern.
# The role's only permission is rum:PutRumEvents on this monitor.
NagSuppressions.add_resource_suppressions(
rum_identity_pool,
[
{
"id": "AwsSolutions-COG7",
"reason": "RUM requires unauthenticated guest credentials for anonymous browser telemetry",
},
],
)
# The guest role has a single least-privilege permission — rum:PutRumEvents
# on exactly one monitor ARN — tightly bound to this role's one purpose.
# A managed policy would add indirection without changing the security
# posture, since the policy is used by nothing else and is scoped to a
# resource that is itself one-to-one with the role.
inline_policy_reason = (
"Single least-privilege inline policy (rum:PutRumEvents on one monitor ARN) "
"is tightly bound to this role's sole purpose — anonymous browser telemetry upload"
)
NagSuppressions.add_resource_suppressions(
rum_unauth_role,
[
{"id": "NIST.800.53.R5-IAMNoInlinePolicy", "reason": inline_policy_reason},
{"id": "HIPAA.Security-IAMNoInlinePolicy", "reason": inline_policy_reason},
{"id": "PCI.DSS.321-IAMNoInlinePolicy", "reason": inline_policy_reason},
],
)
# ── Explicit log group for the CDK auto-delete Lambda ────────────────
# CDK creates a singleton Lambda to empty the bucket before deletion.
# It is a CloudFormation-managed Lambda, but its log group is created
# implicitly by Lambda and has no retention — it would dangle after
# cdk destroy. We find the provider via the construct tree and create
# an explicit log group so CloudFormation owns and deletes it.
# The lookup is type-checked at runtime instead of cast-asserted: if
# CDK ever swaps the provider out for a non-CustomResourceProvider type
# the explicit isinstance() returns None and the log-group block is
# skipped, rather than letting a stale cast() lie its way into a
# service_token attribute access that would crash at synth time.
auto_delete_provider_node = self.node.try_find_child("Custom::S3AutoDeleteObjectsCustomResourceProvider")
auto_delete_provider = (
auto_delete_provider_node if isinstance(auto_delete_provider_node, CustomResourceProvider) else None
)
if auto_delete_provider is not None:
# service_token is the Lambda ARN; index 6 of the colon-split is the function name
fn_name = Fn.select(6, Fn.split(":", auto_delete_provider.service_token))
logs.LogGroup(
self,
"AutoDeleteObjectsLogGroup",
log_group_name=Fn.join("", ["/aws/lambda/", fn_name]),
encryption_key=frontend_encryption_key,
retention=logs.RetentionDays.ONE_WEEK,
removal_policy=RemovalPolicy.DESTROY,
)
self._create_athena_glue_resources(access_log_bucket, frontend_encryption_key)
# ── Per-resource cdk-nag suppressions ──────────────────────────────────
# All Lambdas in this stack are CDK-managed singletons. Their construct
# IDs are stable (hashed from CDK's own source) but they are created as
# stack-level siblings of the construct that requested them, so we look
# them up with ``try_find_child`` rather than absolute path strings —
# this keeps the suppression working regardless of whether the stack is
# at the App root or nested inside a cdk.Stage.
#
# Stable singleton IDs:
# Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C — BucketDeployment provider
# Custom::S3AutoDeleteObjectsCustomResourceProvider — auto-delete provider
# AWS679f53fac002430cb0da5b7982bd2287 — AwsCustomResource provider Lambda
# (used by RumMetricsDestination, RumExtendedMetrics, InvalidateCloudFrontCache)
suppress_cdk_singletons(
self,
(
"Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C",
"AWS679f53fac002430cb0da5b7982bd2287",
),
)
# ── Async failure destination for the AwsCustomResource provider ────────
# See HelloWorldStack for the full rationale — CFN invokes the provider
# async, and without on_failure a crashed provider's payload is lost.
self.cr_provider_dlq = attach_async_failure_destination(
self,
"AWS679f53fac002430cb0da5b7982bd2287",
encryption_key=frontend_encryption_key,
queue_id="AwsCustomResourceProviderDlq",
)
# minimizePolicies restructures the BucketDeployment handler's inline
# policy into a separate resource under DeployFrontend/CustomResourceHandler.
deploy_frontend = self.node.try_find_child("DeployFrontend")
if deploy_frontend is not None:
suppress_cdk_singletons(deploy_frontend, ("CustomResourceHandler",))
if auto_delete_provider is not None:
NagSuppressions.add_resource_suppressions(
auto_delete_provider,
CDK_LAMBDA_SUPPRESSIONS,
apply_to_children=True,
)
# ── Stack-level cdk-nag suppressions (genuinely stack-wide) ─────────────
replication_reason = "S3 replication not needed for sample app — static assets are redeployable"
versioning_reason = "S3 versioning not needed for sample app — static assets are redeployable via cdk deploy"
stack_suppressions = [
("AwsSolutions-CFR1", "Geo restriction not required for sample app"),
("AwsSolutions-CFR4", "Using default CloudFront certificate — no custom domain for sample app"),
("NIST.800.53.R5-S3BucketReplicationEnabled", replication_reason),
("NIST.800.53.R5-S3BucketVersioningEnabled", versioning_reason),
("HIPAA.Security-S3BucketReplicationEnabled", replication_reason),
("HIPAA.Security-S3BucketVersioningEnabled", versioning_reason),
("PCI.DSS.321-S3BucketReplicationEnabled", replication_reason),
("PCI.DSS.321-S3BucketVersioningEnabled", versioning_reason),
]
NagSuppressions.add_stack_suppressions(
self,
[{"id": rule, "reason": reason} for rule, reason in stack_suppressions],
)