diff --git a/terraform/environments/aws/compute.tf b/terraform/environments/aws/compute.tf index 51c2683f..680517c1 100644 --- a/terraform/environments/aws/compute.tf +++ b/terraform/environments/aws/compute.tf @@ -2,6 +2,29 @@ # Compute Platform: Lambda (default) # ============================================== +# Lambda Function URL auth type is fully derived from enable_cdn. There is no +# operator-facing variable for it: the two valid combinations are +# +# enable_cdn = false -> auth_type = "NONE" +# The SPA hits the Function URL directly. Browsers cannot SigV4-sign, so +# the URL must be unauthenticated at the AWS-IAM layer; auth is enforced +# at the application layer (login session, CSRF, API keys). +# +# enable_cdn = true -> auth_type = "AWS_IAM" +# CloudFront fronts the URL with an OAC that SigV4-signs every request +# (see frontend module's aws_cloudfront_origin_access_control.lambda). +# The Function URL rejects anything not signed by CloudFront's OAC, +# closing the public-internet exposure described in #424. +# +# Tying the two flags together prevents two specific mis-configurations: +# (a) enable_cdn = true with auth_type = "NONE" -> public Lambda URL behind +# a pointless CloudFront (security goal of #424 defeated). +# (b) enable_cdn = false with auth_type = "AWS_IAM" -> direct browser hits +# to Function URL return 403 (no way to sign), site is unreachable. +locals { + lambda_function_url_auth_type = var.enable_cdn ? "AWS_IAM" : "NONE" +} + module "compute_lambda" { source = "../../modules/compute/aws/lambda" count = var.compute_platform == "lambda" ? 1 : 0 @@ -37,8 +60,7 @@ module "compute_lambda" { } # Function URL - enable_function_url = var.lambda_enable_function_url - function_url_auth_type = var.lambda_function_url_auth_type + function_url_auth_type = local.lambda_function_url_auth_type allowed_origins = var.lambda_allowed_origins # Concurrency @@ -204,3 +226,34 @@ module "compute_fargate" { # Build dependency must be explicit — image_uri is computed before docker push completes depends_on = [module.networking, module.database, module.secrets, module.build] } + +# ============================================== +# Lambda Function URL: CloudFront OAC permission +# ============================================== +# +# This permission lives at the environment layer (not inside the lambda module) +# because both module.compute_lambda and module.frontend must be fully resolved +# before the permission can reference both the Lambda function name and the +# CloudFront distribution ARN. Placing it inside either module would create a +# dependency cycle (Lambda URL is module.frontend's origin; distribution ARN +# would need to feed back into the lambda module). +# +# Created only when: +# - compute platform is lambda (function URL is active) +# - enable_cdn is true (CloudFront distribution is deployed and provides the +# source ARN); when enable_cdn = true the local also flips auth_type to +# AWS_IAM so the IAM gate is enforced on the Function URL. +# +# When enable_cdn = false the Function URL stays NONE (no IAM gate) and this +# resource is count = 0; the SPA hits the Function URL directly and auth is +# enforced at the application layer. +resource "aws_lambda_permission" "function_url_cloudfront" { + count = var.compute_platform == "lambda" && var.enable_cdn ? 1 : 0 + + statement_id = "FunctionURLAllowCloudFront" + action = "lambda:InvokeFunctionUrl" + function_name = module.compute_lambda[0].function_name + principal = "cloudfront.amazonaws.com" + source_arn = module.frontend[0].cloudfront_distribution_arn + function_url_auth_type = "AWS_IAM" +} diff --git a/terraform/environments/aws/dev.tfvars.example b/terraform/environments/aws/dev.tfvars.example index b7b2e0c6..6457ebf9 100644 --- a/terraform/environments/aws/dev.tfvars.example +++ b/terraform/environments/aws/dev.tfvars.example @@ -30,9 +30,7 @@ lambda_timeout = 60 # Seconds lambda_reserved_concurrency = -1 # -1 = no limit lambda_log_retention_days = 30 -# Lambda Function URL -lambda_enable_function_url = true -lambda_function_url_auth_type = "NONE" +# Lambda Function URL (auth_type is derived from enable_cdn — see compute.tf) lambda_allowed_origins = ["*"] # Fargate settings (when compute_platform = "fargate") diff --git a/terraform/environments/aws/frontend.tf b/terraform/environments/aws/frontend.tf index fa09f511..9a97c974 100644 --- a/terraform/environments/aws/frontend.tf +++ b/terraform/environments/aws/frontend.tf @@ -30,6 +30,12 @@ module "frontend" { # Don't let module create DNS record if we're managing it in dns_records.tf route53_zone_id = var.subdomain_zone_name != "" ? null : var.frontend_route53_zone_id + # Enable OAC whenever Lambda is fronted by CloudFront (enable_cdn = true, + # which also flips the Function URL auth_type to AWS_IAM via the local in + # compute.tf, so the OAC's SigV4 signing is the auth the Function URL + # expects). + enable_oac = var.compute_platform == "lambda" && var.enable_cdn + # CloudFront configuration price_class = var.frontend_price_class diff --git a/terraform/environments/aws/github-dev.tfvars b/terraform/environments/aws/github-dev.tfvars index 7a201826..bef90501 100644 --- a/terraform/environments/aws/github-dev.tfvars +++ b/terraform/environments/aws/github-dev.tfvars @@ -18,12 +18,13 @@ compute_platform = "lambda" enable_docker_build = true # Build and push image via terraform apply on the runner # Lambda Configuration -lambda_memory_size = 2048 -lambda_timeout = 300 -lambda_reserved_concurrency = -1 -lambda_log_retention_days = 7 -lambda_enable_function_url = true -lambda_function_url_auth_type = "NONE" +lambda_memory_size = 2048 +lambda_timeout = 300 +lambda_reserved_concurrency = -1 +lambda_log_retention_days = 7 +# Function URL auth_type is derived from enable_cdn (local in compute.tf): +# enable_cdn = false -> NONE (direct browser hits, app-layer auth) +# enable_cdn = true -> AWS_IAM (CloudFront OAC signs every request) # Current deployed dev origin (Lambda Function URL) + local Webpack dev server. # Wildcard is rejected by the module (allow_credentials=true + * = any-origin CSRF). # Update the Lambda Function URL entry when the dev environment is redeployed. diff --git a/terraform/environments/aws/github-prod.tfvars b/terraform/environments/aws/github-prod.tfvars index 3af0e89c..99470778 100644 --- a/terraform/environments/aws/github-prod.tfvars +++ b/terraform/environments/aws/github-prod.tfvars @@ -18,15 +18,17 @@ compute_platform = "lambda" enable_docker_build = true # Build image via Terraform build module (no separate CI build step) # Lambda Configuration -lambda_memory_size = 1024 -lambda_timeout = 300 -lambda_reserved_concurrency = -1 -lambda_log_retention_days = 30 -lambda_enable_function_url = true -lambda_function_url_auth_type = "NONE" +lambda_memory_size = 1024 +lambda_timeout = 300 +lambda_reserved_concurrency = -1 +lambda_log_retention_days = 30 +# Function URL auth_type is derived from enable_cdn (local in compute.tf): +# enable_cdn = false -> NONE (direct browser hits, app-layer auth) +# enable_cdn = true -> AWS_IAM (CloudFront OAC signs every request) # TODO(env-not-deployed): prod environment is not yet provisioned. Update this # to the actual prod origin (e.g. the customer-facing dashboard domain) when -# the env exists. .invalid placeholder fails fast on accidental apply. +# the env exists. .invalid placeholder ensures any accidental apply fails fast +# on hostname resolution. lambda_allowed_origins = ["https://prod-not-yet-deployed.invalid"] # Fargate Configuration (when compute_platform = "fargate") diff --git a/terraform/environments/aws/github-staging.tfvars b/terraform/environments/aws/github-staging.tfvars index bb3cabad..7e38b86d 100644 --- a/terraform/environments/aws/github-staging.tfvars +++ b/terraform/environments/aws/github-staging.tfvars @@ -18,12 +18,13 @@ compute_platform = "lambda" enable_docker_build = true # Build image via Terraform build module (no separate CI build step) # Lambda Configuration -lambda_memory_size = 1024 -lambda_timeout = 300 -lambda_reserved_concurrency = -1 -lambda_log_retention_days = 14 -lambda_enable_function_url = true -lambda_function_url_auth_type = "NONE" +lambda_memory_size = 1024 +lambda_timeout = 300 +lambda_reserved_concurrency = -1 +lambda_log_retention_days = 14 +# Function URL auth_type is derived from enable_cdn (local in compute.tf): +# enable_cdn = false -> NONE (direct browser hits, app-layer auth) +# enable_cdn = true -> AWS_IAM (CloudFront OAC signs every request) # TODO(env-not-deployed): staging environment is not yet provisioned. Update this # to the actual staging origin when the env exists. Until then the .invalid TLD # ensures any accidental terraform apply fails fast on hostname resolution. diff --git a/terraform/environments/aws/variables.tf b/terraform/environments/aws/variables.tf index 82310274..a69171c4 100644 --- a/terraform/environments/aws/variables.tf +++ b/terraform/environments/aws/variables.tf @@ -234,30 +234,13 @@ variable "lambda_timeout" { default = 30 } -variable "lambda_enable_function_url" { - description = "Enable Lambda Function URL" - type = bool - default = true -} - -variable "lambda_function_url_auth_type" { - description = <<-EOT - Lambda Function URL auth type — must be "NONE" or "AWS_IAM". Default is - "NONE" because CUDly is a browser-served SPA that talks directly to the - Function URL; the browser cannot SigV4-sign requests. Auth is enforced at - the application layer (login session, JWT, API keys, CSRF) — see - internal/api/handler_login.go and middleware in router.go. Override to - AWS_IAM only when fronting the URL with a signed-request gateway - (CloudFront with Lambda@Edge, API Gateway, etc.). - EOT - type = string - default = "NONE" - validation { - condition = contains(["AWS_IAM", "NONE"], var.lambda_function_url_auth_type) - error_message = "lambda_function_url_auth_type must be either \"AWS_IAM\" or \"NONE\"." - } -} +# Lambda Function URL auth type is derived from var.enable_cdn (see +# local.lambda_function_url_auth_type in compute.tf). When enable_cdn = true +# the Function URL is fronted by CloudFront with OAC + SigV4, so auth is +# AWS_IAM; when enable_cdn = false the browser hits the Function URL directly +# and the SPA cannot SigV4-sign, so auth is NONE and the application layer +# (login session, CSRF, API keys) is the gate. variable "lambda_allowed_origins" { description = "CORS allowed origins" diff --git a/terraform/modules/compute/aws/lambda/main.tf b/terraform/modules/compute/aws/lambda/main.tf index 6700a924..21ab8877 100644 --- a/terraform/modules/compute/aws/lambda/main.tf +++ b/terraform/modules/compute/aws/lambda/main.tf @@ -115,8 +115,6 @@ resource "aws_lambda_function" "main" { # ============================================== resource "aws_lambda_function_url" "main" { - count = var.enable_function_url ? 1 : 0 - function_name = aws_lambda_function.main.function_name authorization_type = var.function_url_auth_type @@ -132,9 +130,9 @@ resource "aws_lambda_function_url" "main" { # Resource-based policy grant for the Function URL. Without this, every request # is rejected at the Lambda edge with HTTP 403 AccessDeniedException, even when # authorization_type = "NONE". The AWS provider used to create this implicitly -# but stopped doing so in recent versions — it must be declared explicitly. +# but stopped doing so in recent versions -- it must be declared explicitly. resource "aws_lambda_permission" "function_url" { - count = var.enable_function_url && var.function_url_auth_type == "NONE" ? 1 : 0 + count = var.function_url_auth_type == "NONE" ? 1 : 0 statement_id = "FunctionURLAllowPublicAccess" action = "lambda:InvokeFunctionUrl" diff --git a/terraform/modules/compute/aws/lambda/outputs.tf b/terraform/modules/compute/aws/lambda/outputs.tf index 97b3f7ac..94e7ff63 100644 --- a/terraform/modules/compute/aws/lambda/outputs.tf +++ b/terraform/modules/compute/aws/lambda/outputs.tf @@ -10,7 +10,7 @@ output "function_arn" { output "function_url" { description = "Lambda Function URL" - value = var.enable_function_url ? aws_lambda_function_url.main[0].function_url : null + value = aws_lambda_function_url.main.function_url } output "function_invoke_arn" { diff --git a/terraform/modules/compute/aws/lambda/variables.tf b/terraform/modules/compute/aws/lambda/variables.tf index 131c9c67..6c062a44 100644 --- a/terraform/modules/compute/aws/lambda/variables.tf +++ b/terraform/modules/compute/aws/lambda/variables.tf @@ -83,16 +83,25 @@ variable "vpc_config" { default = null } -variable "enable_function_url" { - description = "Enable Lambda Function URL" - type = bool - default = true -} variable "function_url_auth_type" { - description = "Function URL authorization type (NONE or AWS_IAM)" + description = <<-EOT + Function URL authorization type. Use "AWS_IAM" (default, recommended) when a + CloudFront distribution with an OAC is deployed in front of the Function URL — + AWS then enforces SigV4 signing on every request so the Function URL is + unreachable without a valid AWS identity. + Use "NONE" only in environments where direct access without CloudFront is + acceptable (e.g. local dev, or environments not yet provisioned with CloudFront). + Pair with cloudfront_distribution_arn when switching to AWS_IAM so the module can + create the necessary resource-based policy granting CloudFront invocation rights. + EOT type = string - default = "NONE" + default = "AWS_IAM" + + validation { + condition = contains(["NONE", "AWS_IAM"], var.function_url_auth_type) + error_message = "function_url_auth_type must be \"NONE\" or \"AWS_IAM\"." + } } variable "allowed_origins" { diff --git a/terraform/modules/frontend/aws/main.tf b/terraform/modules/frontend/aws/main.tf index 6e93f4fd..14689fd5 100644 --- a/terraform/modules/frontend/aws/main.tf +++ b/terraform/modules/frontend/aws/main.tf @@ -12,6 +12,22 @@ terraform { } } +# Origin Access Control for Lambda Function URL +# Instructs CloudFront to SigV4-sign every forwarded request so the Lambda +# Function URL (authorization_type = "AWS_IAM") only accepts traffic that +# originates from this distribution. The signing happens at the CloudFront edge +# -- the browser never sees an AWS credential. +# Only created when enable_oac = true (Lambda + AWS_IAM deployments). +resource "aws_cloudfront_origin_access_control" "lambda" { + count = var.enable_oac ? 1 : 0 + + name = "${var.project_name}-${var.environment}-lambda-oac" + description = "OAC for ${var.project_name} Lambda Function URL (${var.environment})" + origin_access_control_origin_type = "lambda" + signing_behavior = "always" + signing_protocol = "sigv4" +} + # CloudFront distribution with single compute origin resource "aws_cloudfront_distribution" "frontend" { enabled = true @@ -26,6 +42,11 @@ resource "aws_cloudfront_distribution" "frontend" { domain_name = var.origin_domain_name origin_id = "compute" + # Attach OAC when Lambda Function URL uses authorization_type = "AWS_IAM". + # CloudFront will SigV4-sign every forwarded request. Null when enable_oac = false + # (Fargate ALB or Lambda with auth_type = "NONE"). + origin_access_control_id = var.enable_oac ? aws_cloudfront_origin_access_control.lambda[0].id : null + custom_origin_config { http_port = 80 https_port = 443 diff --git a/terraform/modules/frontend/aws/variables.tf b/terraform/modules/frontend/aws/variables.tf index 093f2df6..5b64391a 100644 --- a/terraform/modules/frontend/aws/variables.tf +++ b/terraform/modules/frontend/aws/variables.tf @@ -69,6 +69,20 @@ variable "alarm_sns_topic_arn" { default = "" } +variable "enable_oac" { + description = <<-EOT + When true, create a CloudFront Origin Access Control (OAC) of type "lambda" + and attach it to the compute origin in the distribution. Required when the + Lambda Function URL uses authorization_type = "AWS_IAM": CloudFront will + SigV4-sign every forwarded request so the Function URL only accepts traffic + from this distribution. + Set to false when the origin is a Fargate ALB or when the Lambda uses + authorization_type = "NONE". + EOT + type = bool + default = false +} + variable "tags" { description = "Additional tags for resources" type = map(string) diff --git a/terraform/profiles/aws/dev.tfvars.example b/terraform/profiles/aws/dev.tfvars.example index 642ad8e5..3e90b835 100644 --- a/terraform/profiles/aws/dev.tfvars.example +++ b/terraform/profiles/aws/dev.tfvars.example @@ -18,8 +18,7 @@ lambda_memory_size = 512 lambda_timeout = 60 lambda_reserved_concurrency = -1 lambda_log_retention_days = 7 -lambda_enable_function_url = true -lambda_function_url_auth_type = "NONE" +# Function URL auth_type is derived from enable_cdn — see compute.tf local lambda_allowed_origins = ["*"] # Docker build settings diff --git a/terraform/profiles/aws/prod.tfvars.example b/terraform/profiles/aws/prod.tfvars.example index 9df5cb66..a61adacf 100644 --- a/terraform/profiles/aws/prod.tfvars.example +++ b/terraform/profiles/aws/prod.tfvars.example @@ -18,8 +18,7 @@ lambda_memory_size = 1024 # More memory for production lambda_timeout = 60 lambda_reserved_concurrency = -1 lambda_log_retention_days = 30 -lambda_enable_function_url = true -lambda_function_url_auth_type = "NONE" +# Function URL auth_type is derived from enable_cdn — see compute.tf local lambda_allowed_origins = ["*"] # TODO: restrict to production domain # Docker build settings