File size: 11,284 Bytes
517919c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
"""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
    )