Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 55 additions & 2 deletions terraform/environments/aws/compute.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
}
4 changes: 1 addition & 3 deletions terraform/environments/aws/dev.tfvars.example
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
6 changes: 6 additions & 0 deletions terraform/environments/aws/frontend.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 7 additions & 6 deletions terraform/environments/aws/github-dev.tfvars
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 9 additions & 7 deletions terraform/environments/aws/github-prod.tfvars
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
13 changes: 7 additions & 6 deletions terraform/environments/aws/github-staging.tfvars
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
29 changes: 6 additions & 23 deletions terraform/environments/aws/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 2 additions & 4 deletions terraform/modules/compute/aws/lambda/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion terraform/modules/compute/aws/lambda/outputs.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down
23 changes: 16 additions & 7 deletions terraform/modules/compute/aws/lambda/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down
21 changes: 21 additions & 0 deletions terraform/modules/frontend/aws/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
14 changes: 14 additions & 0 deletions terraform/modules/frontend/aws/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 1 addition & 2 deletions terraform/profiles/aws/dev.tfvars.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions terraform/profiles/aws/prod.tfvars.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading