Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
22 changes: 22 additions & 0 deletions terraform/modules/acm_certificate/data.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
locals {
hosted_zone_base_internal = "${var.platform.app}-${var.platform.env}.cmscloud.internal"
hosted_zone_base_zscaler = "${var.platform.app}-${var.platform.env}.cmscloud.local"
}

data "aws_ram_resource_share" "pace_ca" {
count = (var.enable_internal_endpoint || var.enable_zscaler_endpoint) ? 1 : 0
resource_owner = "OTHER-ACCOUNTS"
name = var.pca_ram_resource_share_name
}

data "aws_route53_zone" "internal" {
count = var.enable_internal_endpoint ? 1 : 0
name = local.hosted_zone_base_internal
private_zone = true
}

data "aws_route53_zone" "zscaler" {
count = var.enable_zscaler_endpoint ? 1 : 0
name = local.hosted_zone_base_zscaler
private_zone = true
}
126 changes: 126 additions & 0 deletions terraform/modules/acm_certificate/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
locals {
internal_domain = var.enable_internal_endpoint ? "${var.platform.service}.${trimsuffix(data.aws_route53_zone.internal[0].name, ".")}" : null
zscaler_domain = var.enable_zscaler_endpoint ? "${var.platform.service}.${trimsuffix(data.aws_route53_zone.zscaler[0].name, ".")}" : null

private_primary_domain = (
var.enable_internal_endpoint ? local.internal_domain :
var.enable_zscaler_endpoint ? local.zscaler_domain :
null
)

private_subject_alternative_names = (
var.enable_internal_endpoint && var.enable_zscaler_endpoint
) ? [local.zscaler_domain] : []
}

# -------------------------------------------------------
# PRIVATE: Issue from AWS Private CA
# -------------------------------------------------------
resource "aws_acm_certificate" "private" {
count = (var.enable_internal_endpoint || var.enable_zscaler_endpoint) ? 1 : 0

certificate_authority_arn = one(data.aws_ram_resource_share.pace_ca[0].resource_arns)
domain_name = local.private_primary_domain
subject_alternative_names = local.private_subject_alternative_names

tags = { Name = "${local.private_domain_name_prefix}-private-cert" }

lifecycle {
prevent_destroy = true
create_before_destroy = true
}
}

resource "aws_acm_certificate_validation" "private" {
count = (var.enable_internal_endpoint || var.enable_zscaler_endpoint) ? 1 : 0
certificate_arn = aws_acm_certificate.private[0].arn

timeouts {
create = "5m"
}
}

# -------------------------------------------------------
# PUBLIC PATH: Import CMS-signed cert (developer note: use SOPS encrypted values)
# -------------------------------------------------------

resource "tls_private_key" "this" {
for_each = var.public_domain_name != null ? var.public_certificate_versions : toset([])
algorithm = "RSA"
rsa_bits = 4096
}

resource "tls_cert_request" "this" {
for_each = var.public_domain_name != null ? var.public_certificate_versions : toset([])
private_key_pem = tls_private_key.this[each.key].private_key_pem

subject {
country = "US"
province = "MD"
locality = "Rockville"
organization = "US Dept of Health and Human Services"
organizational_unit = "Centers for Medicare and Medicaid Services"
common_name = var.public_domain_name
}
}

# -------------------------------------------------------
# Store private key in SSM — encrypted with platform KMS key
# It is read back by this module when importing the signed cert into ACM.
# -------------------------------------------------------

resource "aws_ssm_parameter" "private_key" {
for_each = var.public_domain_name != null ? var.public_certificate_versions : toset([])

name = "/${var.platform.app}/${var.platform.env}/${var.service_name}/tls/v${each.key}/private-key"
type = "SecureString"
value = tls_private_key.this[each.key].private_key_pem
key_id = var.platform.kms_alias_primary.target_key_arn

tags = { Name = "${var.public_domain_name}-private-key" }

lifecycle {
# Prevent Terraform from overwriting the key if it already exists.
# The private key must remain stable — replacing it invalidates signed certs.
ignore_changes = [value]
}
}

# -------------------------------------------------------
# Store CSR in SSM — plaintext is fine, CSRs are not sensitive
# Developers can retrieve this value and submit it to CMS for signing.
# -------------------------------------------------------

resource "aws_ssm_parameter" "csr" {
for_each = var.public_domain_name != null ? var.public_certificate_versions : toset([])

name = "/${var.platform.app}/${var.platform.env}/${var.service_name}/tls/v${each.key}/csr"
description = "Certificate Signing Request for ${var.public_domain_name}. Submit this to CMS for signing."
type = "String"
value = tls_cert_request.this[each.key].cert_request_pem

lifecycle {
ignore_changes = [value]
}
}


# Once cert information is provided via SOPS path, this will be set
resource "aws_acm_certificate" "public" {
count = (
var.public_domain_name != null &&
var.public_certificate != null &&
var.public_private_key != null
) ? 1 : 0

certificate = var.public_certificate
private_key = var.public_private_key
certificate_chain = var.public_certificate_chain

tags = { Name = var.public_domain_name }

lifecycle {
prevent_destroy = true
create_before_destroy = true
}
}
44 changes: 44 additions & 0 deletions terraform/modules/acm_certificate/outputs.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
locals {
_latest_cert_version = var.public_domain_name != null ? max(var.public_certificate_versions...) : null

csr_instructions = var.public_domain_name != null ? join("\n", [
"Latest CSR version: ${local._latest_cert_version}",
"",
"SSM path:",
" /${var.platform.app}/${var.platform.env}/${var.service_name}/tls/v${local._latest_cert_version}/csr",
"",
"To retrieve and zip for CMS submission:",
" aws ssm get-parameter \\",
" --name \"/${var.platform.app}/${var.platform.env}/${var.service_name}/tls/v${local._latest_cert_version}/csr\" \\",
" --query \"Parameter.Value\" \\",
" --output text > ${var.public_domain_name}.csr",
" zip ${var.public_domain_name}-v${local._latest_cert_version}-csr.zip ${var.public_domain_name}.csr",
"",
"After CMS signs the certificate, store the values via SOPS and re-apply.",
]) : null
}

output "private_certificate_arn" {
description = "ARN of the PCA-issued certificate covering the internal and/or zscaler domains. Use as the primary cert on the ALB HTTPS listener."
value = (var.enable_internal_endpoint || var.enable_zscaler_endpoint) ? aws_acm_certificate_validation.private[0].certificate_arn : null
sensitive = true
}

output "internal_domain" {
value = var.enable_internal_endpoint ? local.internal_domain : null
}

output "zscaler_domain" {
value = var.enable_zscaler_endpoint ? local.zscaler_domain : null
}

output "public_certificate_arn" {
description = "ARN of the imported CMS-signed public certificate. Null if cert values have not yet been provided."
value = (var.public_domain_name != null && var.public_certificate != null && var.public_private_key != null) ? aws_acm_certificate.public[0].arn : null
sensitive = true
}

output "csr_retrieval_instructions" {
description = "Instructions for retrieving the latest CSR and submitting to CMS."
value = local.csr_instructions
}
113 changes: 113 additions & 0 deletions terraform/modules/acm_certificate/variables.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
variable "platform" {
description = "Object representing the CDAP platform module."
type = object({
app = string
env = string
primary_region = object({ name = string })
service = string
kms_alias_primary = object({
target_key_arn = string
})
})
}

# -------------------------------------------------------
# Internal endpoint (VPC-only, cmscloud.internal)
# -------------------------------------------------------
variable "enable_internal_endpoint" {
type = bool
default = false
description = <<-EOT
Issue a PCA-backed certificate for the VPC-internal endpoint.
Domain: <app>-<env>-<service>.internal
Use for Lambda/ECS-to-ECS calls that do not need Zscaler or public access.
Route 53 is NOT managed here — DNS for .internal is handled by CMS.
EOT
}

# -------------------------------------------------------
# Zscaler endpoint (cmscloud.local)
# -------------------------------------------------------
variable "enable_zscaler_endpoint" {
type = bool
default = false
description = <<-EOT
Issue a PCA-backed certificate for the Zscaler-accessible endpoint.
Domain: <app>-<env>-<service>.cmscloud.local
Route 53 is NOT managed here — DNS for cmscloud.local is handled by CMS.

-------------------------------------------------------------------------
CMS DOMAIN REGISTRATION — ACTION REQUIRED AFTER APPLY
-------------------------------------------------------------------------
After applying this module, submit a request to CMS to register:
<app>-<env>-<service>.cmscloud.local
and point it at the ALB DNS name from the alb module output.
Use the zscaler_domain output from this module for the request.
-------------------------------------------------------------------------
EOT
}

# -------------------------------------------------------
# Public endpoint (*.cms.gov)
# -------------------------------------------------------

variable "public_certificate_versions" {
type = set(number)
default = [1]
description = <<-EOT
Set of active certificate versions. Add a new version number to generate a new
key and CSR for renewal without deleting the previous version's parameters.
Example: [1] → initial; [1, 2] → renewal in progress; [2] → old version cleaned up.
EOT
}

variable "public_domain_name" {
type = string
default = null
description = <<-EOT
Domain name for the public endpoint. Must end in .cms.gov.
-------------------------------------------------------------------------
PUBLIC CERTIFICATE PROCESS — ACTION REQUIRED BEFORE CERT IS ACTIVE
-------------------------------------------------------------------------
1. Run this module once without public_certificate or public_private_key defined.
2. Follow output instructions to provide CMS with CSR in a zip file.
3. Once returned from CMS signed, encrypt the certificate, private key, and chain via SOPS.
4. Pass the sensitive values via SOPS into public_certificate, public_private_key,
and public_certificate_chain at module instantiation.
5. Re-apply — the module imports the cert into ACM automatically.

EOT
validation {
condition = var.public_domain_name == null || endswith(var.public_domain_name, ".cms.gov")
error_message = "public_domain_name must end in .cms.gov."
}
}

# Cert values passed in directly — populated from platform module SOPS outputs at instantiation.
# Set to null to defer cert creation while awaiting CMS issuance.
variable "public_certificate" {
type = string
default = null
sensitive = true
description = "PEM-encoded CMS-signed public certificate. Include via SOPS if provided by CMS. Set null to defer import while awaiting CMS signing."
}

variable "public_private_key" {
type = string
default = null
sensitive = true
description = "PEM-encoded private key for the public certificate. Include via SOPS if provided by CMS. Set null to defer."
}

variable "public_certificate_chain" {
type = string
default = null
sensitive = true
description = "PEM-encoded certificate chain. Optional — include via SOPS if provided by CMS with the signed certificate."
}

variable "pca_ram_resource_share_name" {
type = string
default = "pace-ca-g1"
description = "Name of the AWS RAM resource share providing access to the shared Private CA. Required when enable_internal_endpoint or enable_zscaler_endpoint is true."
}
14 changes: 14 additions & 0 deletions terraform/modules/acm_certificate/versions.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
terraform {
required_version = ">= 1.6.0"

required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
tls = {
source = "hashicorp/tls"
version = ">= 4.0"
}
}
}
Empty file added terraform/modules/alb/README.md
Empty file.
64 changes: 64 additions & 0 deletions terraform/modules/alb/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
locals {
alb_name = var.name_override != null ? var.name_override : "${var.platform.app}-${var.platform.env}-${var.platform.service}-alb"

# Use explicitly provided subnets, or fall back to the platform's private subnets
subnet_ids = var.subnet_ids != null ? var.subnet_ids : [for s in var.platform.private_subnets : s.id]
}

# -------------------------------------------------------
# Application Load Balancer
# -------------------------------------------------------
resource "aws_lb" "this" {
name = local.alb_name
internal = var.internal
load_balancer_type = "application"
subnets = local.subnet_ids
security_groups = var.security_group_ids

tags = merge(
{ Name = local.alb_name },
var.platform.tags
)
}

# -------------------------------------------------------
# HTTPS Listener (port 443)
# Default action is a 404 fixed response — apps can attach
# their own listener rules with path/host conditions.
# -------------------------------------------------------
resource "aws_lb_listener" "https" {
load_balancer_arn = aws_lb.this.arn
port = 443
protocol = "HTTPS"
ssl_policy = var.ssl_policy
certificate_arn = var.acm_certificate_arn

default_action {
type = "fixed-response"
fixed_response {
content_type = "text/plain"
message_body = "Not Found"
status_code = "404"
}
}
}

# -------------------------------------------------------
# HTTP Listener (port 80) redirect
# -------------------------------------------------------
resource "aws_lb_listener" "http_redirect" {
count = var.enable_http_redirect ? 1 : 0

load_balancer_arn = aws_lb.this.arn
port = 80
protocol = "HTTP"

default_action {
type = "redirect"
redirect {
port = "443"
protocol = "HTTPS"
status_code = "HTTP_301"
}
}
}
Loading