@@ -52,12 +52,9 @@ resource "aws_kms_key" "site" {
5252 deletion_window_in_days = 7
5353 enable_key_rotation = true
5454 policy = jsonencode (yamldecode (templatefile (" ${ path . module } /templates/bucket-key-policy.yaml.tftpl" , {
55- account_id = data.aws_caller_identity.current.account_id
56- partition = data.aws_partition.current.partition
57- # CloudFront distribution ARN is needed here but the distribution is
58- # created in a later step. We use a wildcard for now and will tighten
59- # this once the distribution resource exists.
60- distribution_arn = " *"
55+ account_id = data.aws_caller_identity.current.account_id
56+ partition = data.aws_partition.current.partition
57+ distribution_arn = aws_cloudfront_distribution.site.arn
6158 })))
6259
6360 tags = {
@@ -159,3 +156,125 @@ resource "aws_acm_certificate_validation" "site" {
159156 certificate_arn = aws_acm_certificate. site . arn
160157 validation_record_fqdns = [for record in aws_route53_record . certificate_validation : record . fqdn ]
161158}
159+
160+ # ---------------------------------------------------------------------------
161+ # CloudFront distribution
162+ # ---------------------------------------------------------------------------
163+
164+ # Origin Access Control (OAC) lets CloudFront authenticate to S3 using
165+ # AWS SigV4 request signing. This replaces the older Origin Access Identity
166+ # (OAI) approach. With OAC, CloudFront signs every request to S3, and the
167+ # bucket policy checks the signature — so the bucket never needs to be public.
168+ resource "aws_cloudfront_origin_access_control" "site" {
169+ name = " ${ var . project } -${ var . state } -${ var . environment } -enrollment-checker"
170+ origin_access_control_origin_type = " s3"
171+ signing_behavior = " always"
172+ signing_protocol = " sigv4"
173+ }
174+
175+ # The CloudFront distribution serves the static site from S3 over HTTPS.
176+ # It acts as a CDN — caching files at edge locations close to users for
177+ # faster delivery — and handles TLS termination using our ACM certificate.
178+ resource "aws_cloudfront_distribution" "site" {
179+ aliases = [var . domain ]
180+ default_root_object = " index.html"
181+ enabled = true
182+ price_class = " PriceClass_100"
183+
184+ # S3 origin: CloudFront fetches files from our private bucket using OAC.
185+ origin {
186+ domain_name = aws_s3_bucket. site . bucket_regional_domain_name
187+ origin_id = " s3"
188+ origin_access_control_id = aws_cloudfront_origin_access_control. site . id
189+ }
190+
191+ # Default cache behavior: serve static files from S3.
192+ # GET and HEAD only — the static site has no server-side mutations.
193+ default_cache_behavior {
194+ allowed_methods = [" GET" , " HEAD" ]
195+ cached_methods = [" GET" , " HEAD" ]
196+ compress = true
197+ target_origin_id = " s3"
198+ viewer_protocol_policy = " redirect-to-https"
199+
200+ forwarded_values {
201+ query_string = false
202+
203+ cookies {
204+ forward = " none"
205+ }
206+ }
207+ }
208+
209+ # Handle client-side routing: when S3 returns a 404 (e.g. user navigates
210+ # to /check directly), serve index.html instead so the Next.js client
211+ # router can handle the path.
212+ custom_error_response {
213+ error_code = 403
214+ response_code = 200
215+ response_page_path = " /index.html"
216+ error_caching_min_ttl = 10
217+ }
218+
219+ custom_error_response {
220+ error_code = 404
221+ response_code = 200
222+ response_page_path = " /index.html"
223+ error_caching_min_ttl = 10
224+ }
225+
226+ # Send CloudFront access logs to the shared logging bucket.
227+ logging_config {
228+ bucket = var. logging_bucket_domain_name
229+ include_cookies = false
230+ prefix = " cloudfront/enrollment-checker/"
231+ }
232+
233+ restrictions {
234+ geo_restriction {
235+ restriction_type = " none"
236+ }
237+ }
238+
239+ # Use our ACM certificate for HTTPS. TLS 1.2 is the minimum — older
240+ # protocols have known vulnerabilities. SNI (Server Name Indication)
241+ # is the standard approach and avoids the cost of a dedicated IP.
242+ viewer_certificate {
243+ acm_certificate_arn = aws_acm_certificate_validation. site . certificate_arn
244+ minimum_protocol_version = " TLSv1.2_2021"
245+ ssl_support_method = " sni-only"
246+ }
247+
248+ tags = {
249+ service = " enrollment-checker"
250+ }
251+ }
252+
253+ # Bucket policy: deny non-SSL requests and allow only this CloudFront
254+ # distribution to read objects. Applied after the distribution is created
255+ # so we can reference its ARN.
256+ resource "aws_s3_bucket_policy" "site" {
257+ bucket = aws_s3_bucket. site . id
258+ policy = jsonencode (yamldecode (templatefile (" ${ path . module } /templates/bucket-policy.yaml.tftpl" , {
259+ bucket_arn = aws_s3_bucket.site.arn
260+ distribution_arn = aws_cloudfront_distribution.site.arn
261+ })))
262+
263+ depends_on = [aws_s3_bucket_public_access_block . site ]
264+ }
265+
266+ # Route53 A record pointing the enrollment checker domain at CloudFront.
267+ # This is an "alias" record — a Route53-specific feature that maps a
268+ # domain directly to an AWS resource without a CNAME. It works at the
269+ # zone apex and has no TTL (queries resolve instantly via Route53).
270+ resource "aws_route53_record" "site" {
271+ name = var. domain
272+ type = " A"
273+ zone_id = var. hosted_zone_id
274+
275+ alias {
276+ evaluate_target_health = false
277+ name = aws_cloudfront_distribution. site . domain_name
278+ zone_id = aws_cloudfront_distribution. site . hosted_zone_id
279+ }
280+ }
0 commit comments