document_redaction / cdk /cdk_cloudfront_distribution.py
seanpedrickcase's picture
Sync: redaction boxes should now remain visible on page changes
54b0fbc
Raw
History Blame Contribute Delete
11.3 kB
"""CloudFront distribution for doc_redaction (RedactionStack; optional WAF)."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, List, Optional, Sequence
from urllib.parse import urlparse
from aws_cdk import CfnOutput
from aws_cdk import aws_cloudfront as cloudfront
from aws_cdk import aws_cloudfront_origins as origins
from aws_cdk import aws_elasticloadbalancingv2 as elbv2
from cdk_cloudfront_auth import (
MagicLinkAuthResources,
create_forwarded_host_function,
create_magic_link_auth,
emit_magic_link_outputs,
magic_link_function_association,
)
from cdk_cloudfront_headers import (
create_secure_cloudfront_response_headers_policy,
resolve_cloudfront_csp_urls,
)
from cdk_functions import create_web_acl_with_common_rules
from constructs import Construct
def express_endpoint_hostname(endpoint: str) -> str:
"""Hostname from ExpressServiceEndpoint / attr_endpoint (URL or host)."""
value = (endpoint or "").strip()
if "://" in value:
parsed = urlparse(value)
return (parsed.hostname or value).strip()
return value.split("/")[0].strip()
def parse_geo_restriction_locations(raw: str) -> Optional[List[str]]:
locations = [
part.strip().upper() for part in (raw or "").split(",") if part.strip()
]
return locations or None
def _behavior_options(
origin: cloudfront.IOrigin,
*,
response_headers_policy: Optional[cloudfront.IResponseHeadersPolicy],
function_associations: Optional[Sequence[cloudfront.FunctionAssociation]] = None,
origin_request_policy: Optional[cloudfront.IOriginRequestPolicy] = None,
) -> cloudfront.BehaviorOptions:
return cloudfront.BehaviorOptions(
origin=origin,
viewer_protocol_policy=cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
allowed_methods=cloudfront.AllowedMethods.ALLOW_ALL,
cache_policy=cloudfront.CachePolicy.CACHING_DISABLED,
origin_request_policy=(
origin_request_policy or cloudfront.OriginRequestPolicy.ALL_VIEWER
),
response_headers_policy=response_headers_policy,
function_associations=list(function_associations or ()),
)
@dataclass(frozen=True)
class RedactionCloudFrontResources:
distribution: cloudfront.Distribution
magic_link: Optional[MagicLinkAuthResources] = None
def create_redaction_cloudfront_distribution(
scope: Construct,
construct_id: str,
*,
distribution_comment: str,
cognito_redirection_url: str,
cloudfront_domain: str,
cognito_user_pool_domain_prefix: str,
aws_region: str,
cognito_user_pool_login_url: str,
ssl_certificate_domain: str,
enable_secure_response_headers: bool,
attach_secure_response_headers: bool = False,
geo_restriction_raw: str,
enable_cloudfront_waf: bool,
web_acl_name: str,
auth_mode: str,
magic_link_cookie_name: str,
magic_link_cookie_max_age_sec: int,
custom_header_name: str,
custom_header_value: str,
cdk_prefix: str,
resource_removal_policy,
# Origin mode: exactly one of alb or main express endpoint required.
alb: Optional[elbv2.IApplicationLoadBalancer] = None,
main_express_endpoint: str = "",
agentic_express_endpoint: str = "",
agentic_path_prefix: str = "/agent",
) -> RedactionCloudFrontResources:
"""Create CloudFront distribution in RedactionStack (no WAF unless opted in)."""
geo_locations = parse_geo_restriction_locations(geo_restriction_raw)
geo_restrict = (
cloudfront.GeoRestriction.allowlist(*geo_locations) if geo_locations else None
)
# The secure response headers policy is created as a standalone resource (so it
# appears in the account's CloudFront policy list and can be attached manually), but
# it is only wired onto the distribution's behaviors when ``attach_secure_response_
# headers`` is set. Attaching the CSP/security headers tends to break demonstration
# mode (e.g. Cognito redirect flows and mixed Express origins), so it is created but
# left detached by default.
response_headers_policy = None
if enable_secure_response_headers:
app_origin, cognito_login_url = resolve_cloudfront_csp_urls(
cognito_redirection_url=cognito_redirection_url,
cloudfront_domain=cloudfront_domain,
cognito_user_pool_domain_prefix=cognito_user_pool_domain_prefix,
aws_region=aws_region,
cognito_user_pool_login_url=cognito_user_pool_login_url,
ssl_certificate_domain=ssl_certificate_domain,
)
policy_name = f"{cdk_prefix}SecureResponseHeaders"[:128]
response_headers_policy = create_secure_cloudfront_response_headers_policy(
scope,
f"{construct_id}SecureResponseHeadersPolicy",
policy_name=policy_name,
app_origin=app_origin,
cognito_login_url=cognito_login_url,
)
attached_response_headers_policy = (
response_headers_policy if attach_secure_response_headers else None
)
magic_link: Optional[MagicLinkAuthResources] = None
function_associations: List[cloudfront.FunctionAssociation] = []
if auth_mode == "magic-link":
magic_link = create_magic_link_auth(
scope,
f"{construct_id}MagicLink",
cookie_name=magic_link_cookie_name,
cookie_max_age_sec=magic_link_cookie_max_age_sec,
)
# The magic-link viewer-request function also forwards viewer host/proto.
function_associations.append(
magic_link_function_association(magic_link.auth_function)
)
else:
# No magic-link function on this behavior, so attach a lightweight
# viewer-request function that forwards the viewer host/proto to the
# origin. Without it, CloudFront (ALL_VIEWER_EXCEPT_HOST_HEADER) presents
# the origin's own *.ecs.on.aws host and Gradio emits absolute origin URLs
# for its assets — which the browser can't reach once the origin SG is
# locked to CloudFront-only.
forwarded_host_function = create_forwarded_host_function(
scope, f"{construct_id}Fwd"
)
function_associations.append(
magic_link_function_association(forwarded_host_function)
)
custom_headers: Dict[str, str] = {}
if custom_header_name and custom_header_value:
custom_headers[custom_header_name] = custom_header_value
# Supply the viewer protocol to the origin as a static header. CloudFront edge
# functions are not allowed to set x-forwarded-proto (doing so returns HTTP 502),
# so it is injected here instead. Viewers always reach CloudFront over HTTPS
# (REDIRECT_TO_HTTPS), so a constant "https" is correct and lets Gradio emit
# https:// asset URLs (see cdk_cloudfront_auth._FORWARDED_HOST_INJECTION_JS).
custom_headers["X-Forwarded-Proto"] = "https"
additional_behaviors: Dict[str, cloudfront.BehaviorOptions] = {}
# Path prefix at which the agent (Pi) app is fronted by this distribution, when a
# dedicated agentic behavior is created below. Used to emit the agent app URL output.
agentic_behavior_prefix: Optional[str] = None
if alb is not None:
origin = origins.LoadBalancerV2Origin(
alb,
custom_headers=custom_headers or None,
origin_shield_enabled=False,
protocol_policy=cloudfront.OriginProtocolPolicy.HTTP_ONLY,
)
default_origin = origin
# Legacy ALB listener rule matches the CloudFront distribution domain, so the
# viewer Host header must be forwarded to the origin unchanged.
origin_request_policy: cloudfront.IOriginRequestPolicy = (
cloudfront.OriginRequestPolicy.ALL_VIEWER
)
else:
main_host = express_endpoint_hostname(main_express_endpoint)
if not main_host:
raise ValueError(
"main_express_endpoint is required for Express CloudFront origin."
)
# ECS Express managed ALB listener rules route on the per-service *.on.aws host
# header. CloudFront must present each origin's own hostname (not the viewer's
# CloudFront domain), so strip the viewer Host and let CloudFront set the origin
# domain as Host instead.
origin_request_policy = (
cloudfront.OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER
)
default_origin = origins.HttpOrigin(
main_host,
protocol_policy=cloudfront.OriginProtocolPolicy.HTTPS_ONLY,
custom_headers=custom_headers or None,
)
agentic_host = express_endpoint_hostname(agentic_express_endpoint)
prefix = (agentic_path_prefix or "/agent").strip()
if not prefix.startswith("/"):
prefix = f"/{prefix}"
if agentic_host and agentic_host != main_host:
agentic_origin = origins.HttpOrigin(
agentic_host,
protocol_policy=cloudfront.OriginProtocolPolicy.HTTPS_ONLY,
custom_headers=custom_headers or None,
)
additional_behaviors[prefix] = _behavior_options(
agentic_origin,
response_headers_policy=attached_response_headers_policy,
function_associations=function_associations,
origin_request_policy=origin_request_policy,
)
additional_behaviors[f"{prefix}*"] = _behavior_options(
agentic_origin,
response_headers_policy=attached_response_headers_policy,
function_associations=function_associations,
origin_request_policy=origin_request_policy,
)
agentic_behavior_prefix = prefix
default_behavior = _behavior_options(
default_origin,
response_headers_policy=attached_response_headers_policy,
function_associations=function_associations,
origin_request_policy=origin_request_policy,
)
web_acl_id = None
if enable_cloudfront_waf:
web_acl = create_web_acl_with_common_rules(scope, web_acl_name)
web_acl_id = web_acl.attr_arn
distribution_kwargs = {
"comment": distribution_comment,
"geo_restriction": geo_restrict,
"default_behavior": default_behavior,
"additional_behaviors": additional_behaviors or None,
}
if web_acl_id:
distribution_kwargs["web_acl_id"] = web_acl_id
distribution = cloudfront.Distribution(
scope,
f"{construct_id}Distribution",
**distribution_kwargs,
)
distribution.apply_removal_policy(resource_removal_policy)
CfnOutput(
scope,
"CloudFrontDistributionURL",
value=distribution.domain_name,
description="CloudFront distribution domain name",
)
if magic_link is not None:
emit_magic_link_outputs(
scope,
distribution_domain_name=distribution.domain_name,
auth_token=magic_link.auth_token,
agentic_path_prefix=agentic_behavior_prefix,
)
return RedactionCloudFrontResources(
distribution=distribution, magic_link=magic_link
)