def __init__(
self,
scope: Construct,
construct_id: str,
*,
api_url: str,
api_id: str,
waf_acl_arn: str,
cf_waf_logs_location: str,
regional_waf_logs_location: 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.
api_id: The backend API Gateway REST API ID, used to pin the CSP connect-src
to the exact execute-api host instead of a region-wide wildcard.
waf_acl_arn: ARN of the WAF WebACL from WafStack (always in us-east-1).
cf_waf_logs_location: ``s3://…/`` prefix of the CloudFront WebACL's WAF logs,
for the Athena Glue table (computed by the Stage — see its docstring).
regional_waf_logs_location: ``s3://…/`` prefix of the regional (API Gateway)
WebACL's WAF logs, for the Athena Glue table.
**kwargs: Additional keyword arguments passed to the parent Stack.
"""
super().__init__(scope, construct_id, **kwargs)
self._api_id = api_id
self._cf_waf_logs_location = cf_waf_logs_location
self._regional_waf_logs_location = regional_waf_logs_location
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 BackendApp.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 S3 server access logs (asset bucket), CloudFront standard
# access logs, and Athena query results. SSE-S3 (not SSE-KMS) because
# neither S3 log delivery nor CloudFront standard logging support
# KMS-encrypted target buckets. Built via the shared log-sink helper;
# object_ownership=BUCKET_OWNER_PREFERRED keeps ACLs usable for CloudFront
# standard logging (which delivers via ACL). 7-day lifecycle is tunable —
# extend or tier to Glacier for longer retention (see "Audit stack and
# log retention" in the README).
access_log_bucket = create_sse_s3_log_bucket(
self,
"FrontendAccessLogBucket",
suppression_reason=(
"Access-log bucket — SSE-S3 (S3/CloudFront log delivery doesn't support "
"KMS-CMK target buckets), self-logging would be circular, no versioning/"
"replication for append-only transient logs"
),
expiration_days=7,
removal_policy=RemovalPolicy.DESTROY,
auto_delete=True,
object_ownership=s3.ObjectOwnership.BUCKET_OWNER_PREFERRED,
)
# ── 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,
)
# Expose the buckets for the audit stack to consume cross-stack — the
# CloudTrail trail recording object-level data events on them lives in
# AuditStack (a one-way dependency: audit -> frontend).
self.bucket = bucket
self.access_log_bucket = access_log_bucket
# ── CloudFront response headers ──────────────────────────────────────
# Custom ResponseHeadersPolicy (replaces the AWS-managed SECURITY_HEADERS)
# adding HSTS + CSP on top of the four headers that policy provided.
# See _build_response_headers_policy for the full rationale.
response_headers_policy = self._build_response_headers_policy()
# ── 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=response_headers_policy,
),
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",
)
# Pinned physical name (the IAM role below references the constructed
# ARN): a future replacement-forcing property change collides with the
# not-yet-deleted old monitor (CFN replacement is create-before-delete),
# so such a change must also change the name in the same commit — see
# the AppConfig profile note in backend_app.py.
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 is a CLIENT-SIDE knob: the RUM browser
# client reads it to decide what fraction of sessions to record.
# It does NOT bound the data plane — the public unauthenticated
# identity pool above lets anyone mint guest credentials and call
# rum:PutRumEvents directly, ignoring this rate entirely. So
# lowering it is a legitimate-traffic COST lever only; the
# adversarial-ingestion vector needs a Budgets / RUM-volume alarm,
# not a smaller sample rate (see TODO.md "CloudWatch RUM").
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.
#
# NOTE: CloudFront caps CallerReference at 128 characters, so we cannot fold
# the api_url (or other identifiers) into it — the content-hashed object key
# alone is already ~68 chars. If a backend-only API replacement changes
# config.json's contents, that path is covered because Source.json_data
# writes config.json as part of the BucketDeployment asset, so its hash —
# and thus this object key — changes when api_url changes.
#
# Rollback caveat (accepted): CloudFront treats a repeated CallerReference
# as an idempotent replay of the earlier invalidation, so rolling assets
# back to a previously deployed content hash reuses that deploy's
# CallerReference and NO new invalidation runs — viewers keep the
# rolled-back-FROM version cached until TTL expiry. A deploy-unique nonce
# would fix that but would also invalidate on every backend-only deploy,
# defeating the quota-saving design. After an asset rollback, run a manual
# invalidation (CloudFrontDistributionId output) if freshness matters.
# object_keys is a CDK list-token, not a Python list — use Fn.select.
cf_invalidation_call = cr.AwsSdkCall(
service="CloudFront",
action="createInvalidation",
parameters={
"DistributionId": distribution.distribution_id,
"InvalidationBatch": {
"Paths": {"Quantity": 1, "Items": ["/*"]},
"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"
)
acknowledge_rules(
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},
],
)
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.
acknowledge_rules(
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"
)
acknowledge_rules(
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 CMK log group + singleton nag suppressions for the CDK
# auto-delete-objects provider Lambda (see create_auto_delete_objects_log_group
# in nag_utils — it owns both, so there's no per-stack block here).
create_auto_delete_objects_log_group(self, frontend_encryption_key)
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,
(
BUCKET_DEPLOYMENT_PROVIDER_ID,
AWS_CUSTOM_RESOURCE_PROVIDER_ID,
),
)
# ── Async failure destinations for the CDK-managed provider Lambdas ─────
# See BackendStack for the full rationale — CFN invokes the providers
# async, and without on_failure a crashed provider's payload is lost.
# Both stack-level Function-based singletons get the same treatment: the
# AwsCustomResource provider AND the BucketDeployment handler. (The S3
# auto-delete provider is a CustomResourceProvider, not a Function — its
# async config is not reachable through the L2, which is exactly what
# its Serverless-LambdaDLQ suppression in CDK_LAMBDA_SUPPRESSIONS
# documents.) attach_async_failure_destination also emits a CfnOutput of
# each DLQ URL, so the returned queues are surfaced for operators.
self.cr_provider_dlq = attach_async_failure_destination(
self,
AWS_CUSTOM_RESOURCE_PROVIDER_ID,
encryption_key=frontend_encryption_key,
queue_id="AwsCustomResourceProviderDlq",
)
self.bucket_deployment_dlq = attach_async_failure_destination(
self,
BUCKET_DEPLOYMENT_PROVIDER_ID,
encryption_key=frontend_encryption_key,
queue_id="BucketDeploymentProviderDlq",
)
self._acknowledge_bucket_deployment_grants(bucket)
# 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",))
# ── 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),
]
acknowledge_rules(
self,
[{"id": rule, "reason": reason} for rule, reason in stack_suppressions],
)