diff --git a/infra/api/app-config/main.tf b/infra/api/app-config/main.tf index 69bb12af5..d749d84b4 100644 --- a/infra/api/app-config/main.tf +++ b/infra/api/app-config/main.tf @@ -7,8 +7,9 @@ locals { has_incident_management_service = false environment_configs = { - dev = module.dev_config - prod = module.prod_config + dev = module.dev_config + staging = module.staging_config + prod = module.prod_config } build_repository_config = { @@ -44,9 +45,10 @@ locals { # prod = "prod" # } account_names_by_environment = { - shared = "simpler-grants-gov" - dev = "simpler-grants-gov" - prod = "simpler-grants-gov" + shared = "simpler-grants-gov" + dev = "simpler-grants-gov" + staging = "simpler-grants-gov" + prod = "simpler-grants-gov" } } diff --git a/infra/api/app-config/staging.tf b/infra/api/app-config/staging.tf new file mode 100644 index 000000000..4f3e94191 --- /dev/null +++ b/infra/api/app-config/staging.tf @@ -0,0 +1,8 @@ +module "staging_config" { + source = "./env-config" + app_name = local.app_name + default_region = module.project_config.default_region + environment = "staging" + has_database = local.has_database + has_incident_management_service = local.has_incident_management_service +} diff --git a/infra/api/database/staging.s3.tfbackend b/infra/api/database/staging.s3.tfbackend new file mode 100644 index 000000000..c94da0d3f --- /dev/null +++ b/infra/api/database/staging.s3.tfbackend @@ -0,0 +1,4 @@ +bucket = "simpler-grants-gov-315341936575-us-east-1-tf" +key = "infra/api/database/staging.tfstate" +dynamodb_table = "simpler-grants-gov-315341936575-us-east-1-tf-state-locks" +region = "us-east-1" diff --git a/infra/api/service/main.tf b/infra/api/service/main.tf index def913636..b12ffce8b 100644 --- a/infra/api/service/main.tf +++ b/infra/api/service/main.tf @@ -1,16 +1,24 @@ # TODO(https://github.com/navapbc/template-infra/issues/152) use non-default VPC +# docs: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc data "aws_vpc" "default" { default = true } -# TODO(https://github.com/navapbc/template-infra/issues/152) use private subnets -data "aws_subnets" "default" { +# docs: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/subnet +data "aws_subnets" "private" { filter { - name = "default-for-az" - values = [true] + name = "tag:subnet_type" + values = ["private"] } } +# docs: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/subnet +data "aws_subnets" "public" { + filter { + name = "tag:subnet_type" + values = ["public"] + } +} locals { # The prefix key/value pair is used for Terraform Workspaces, which is useful for projects with multiple infrastructure developers. @@ -95,7 +103,8 @@ module "service" { image_repository_name = module.app_config.image_repository_name image_tag = local.image_tag vpc_id = data.aws_vpc.default.id - subnet_ids = data.aws_subnets.default.ids + public_subnet_ids = data.aws_subnets.public.ids + private_subnet_ids = data.aws_subnets.private.ids cpu = 1024 memory = 2048 diff --git a/infra/api/service/staging.s3.tfbackend b/infra/api/service/staging.s3.tfbackend new file mode 100644 index 000000000..3bd263b17 --- /dev/null +++ b/infra/api/service/staging.s3.tfbackend @@ -0,0 +1,4 @@ +bucket = "simpler-grants-gov-315341936575-us-east-1-tf" +key = "infra/api/service/staging.tfstate" +dynamodb_table = "simpler-grants-gov-315341936575-us-east-1-tf-state-locks" +region = "us-east-1" diff --git a/infra/frontend/app-config/main.tf b/infra/frontend/app-config/main.tf index 159a7d283..9f0414f80 100644 --- a/infra/frontend/app-config/main.tf +++ b/infra/frontend/app-config/main.tf @@ -1,6 +1,6 @@ locals { app_name = "frontend" - environments = ["dev", "prod"] + environments = ["dev", "staging", "prod"] project_name = module.project_config.project_name image_repository_name = "${local.project_name}-${local.app_name}" has_database = false @@ -13,8 +13,9 @@ locals { } environment_configs = { - dev = module.dev_config - prod = module.prod_config + dev = module.dev_config + staging = module.staging_config + prod = module.prod_config } # Map from environment name to the account name for the AWS account that # contains the resources for that environment. Resources that are shared @@ -46,9 +47,10 @@ locals { # prod = "prod" # } account_names_by_environment = { - shared = "simpler-grants-gov" - dev = "simpler-grants-gov" - prod = "simpler-grants-gov" + shared = "simpler-grants-gov" + dev = "simpler-grants-gov" + staging = "simpler-grants-gov" + prod = "simpler-grants-gov" } } diff --git a/infra/frontend/app-config/staging.tf b/infra/frontend/app-config/staging.tf new file mode 100644 index 000000000..96265bf85 --- /dev/null +++ b/infra/frontend/app-config/staging.tf @@ -0,0 +1,12 @@ +module "staging_config" { + source = "./env-config" + app_name = local.app_name + default_region = module.project_config.default_region + environment = "staging" + has_database = local.has_database + has_incident_management_service = local.has_incident_management_service + domain = "beta.grants.gov" + sendy_api_key = "/${local.app_name}/staging/sendy-api-key" + sendy_api_url = "/${local.app_name}/staging/sendy-api-url" + sendy_list_id = "/${local.app_name}/staging/sendy-list-id" +} diff --git a/infra/frontend/service/main.tf b/infra/frontend/service/main.tf index 199eff25c..2b12724bf 100644 --- a/infra/frontend/service/main.tf +++ b/infra/frontend/service/main.tf @@ -1,16 +1,24 @@ # TODO(https://github.com/navapbc/template-infra/issues/152) use non-default VPC +# docs: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc data "aws_vpc" "default" { default = true } -# TODO(https://github.com/navapbc/template-infra/issues/152) use private subnets -data "aws_subnets" "default" { +# docs: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/subnet +data "aws_subnets" "private" { filter { - name = "default-for-az" - values = [true] + name = "tag:subnet_type" + values = ["private"] } } +# docs: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/subnet +data "aws_subnets" "public" { + filter { + name = "tag:subnet_type" + values = ["public"] + } +} locals { # The prefix key/value pair is used for Terraform Workspaces, which is useful for projects with multiple infrastructure developers. @@ -111,7 +119,8 @@ module "service" { image_repository_name = module.app_config.image_repository_name image_tag = local.image_tag vpc_id = data.aws_vpc.default.id - subnet_ids = data.aws_subnets.default.ids + public_subnet_ids = data.aws_subnets.public.ids + private_subnet_ids = data.aws_subnets.private.ids enable_autoscaling = module.app_config.enable_autoscaling cert_arn = terraform.workspace == "default" ? data.aws_acm_certificate.cert[0].arn : null hostname = module.app_config.hostname diff --git a/infra/frontend/service/staging.s3.tfbackend b/infra/frontend/service/staging.s3.tfbackend new file mode 100644 index 000000000..a19f6284a --- /dev/null +++ b/infra/frontend/service/staging.s3.tfbackend @@ -0,0 +1,4 @@ +bucket = "simpler-grants-gov-315341936575-us-east-1-tf" +key = "infra/frontend/service/staging.tfstate" +dynamodb_table = "simpler-grants-gov-315341936575-us-east-1-tf-state-locks" +region = "us-east-1" diff --git a/infra/modules/database/role_manager/role_manager.py b/infra/modules/database/role_manager/role_manager.py index a435e65bd..a0544cf44 100644 --- a/infra/modules/database/role_manager/role_manager.py +++ b/infra/modules/database/role_manager/role_manager.py @@ -2,9 +2,14 @@ import itertools from operator import itemgetter import os +import json import logging from pg8000.native import Connection, identifier +logging.basicConfig() +logging.getLogger('botocore').setLevel(logging.DEBUG) +logging.getLogger('boto3').setLevel(logging.DEBUG) + logger = logging.getLogger() logger.setLevel(logging.INFO) @@ -115,9 +120,9 @@ def connect_using_iam(user: str) -> Connection: return Connection(user=user, host=host, port=port, database=database, password=token, ssl_context=True) def get_password() -> str: - ssm = boto3.client("ssm") + ssm = boto3.client("ssm",region_name=os.environ["AWS_REGION"]) param_name = os.environ["DB_PASSWORD_PARAM_NAME"] - logger.info("Fetching password from parameter store") + logger.info("Fetching password from parameter store:\n%s"%param_name) result = json.loads(ssm.get_parameter( Name=param_name, WithDecryption=True, diff --git a/infra/modules/service/access-control.tf b/infra/modules/service/access-control.tf index 0b295088f..c7566c93f 100644 --- a/infra/modules/service/access-control.tf +++ b/infra/modules/service/access-control.tf @@ -69,3 +69,11 @@ resource "aws_iam_role_policy" "task_executor" { role = aws_iam_role.task_executor.id policy = data.aws_iam_policy_document.task_executor.json } + + +resource "aws_iam_role_policy_attachment" "extra_policies" { + for_each = var.extra_policies + + role = aws_iam_role.app_service.name + policy_arn = each.value +} diff --git a/infra/modules/service/load-balancer.tf b/infra/modules/service/load-balancer.tf index 631c77db1..08876b068 100644 --- a/infra/modules/service/load-balancer.tf +++ b/infra/modules/service/load-balancer.tf @@ -9,7 +9,7 @@ resource "aws_lb" "alb" { idle_timeout = "120" internal = false security_groups = [aws_security_group.alb.id] - subnets = var.subnet_ids + subnets = var.public_subnet_ids # TODO(https://github.com/navapbc/template-infra/issues/163) Implement HTTPS # checkov:skip=CKV2_AWS_20:Redirect HTTP to HTTPS as part of implementing HTTPS support diff --git a/infra/modules/service/main.tf b/infra/modules/service/main.tf index 07a6f887a..4484e52db 100644 --- a/infra/modules/service/main.tf +++ b/infra/modules/service/main.tf @@ -28,7 +28,7 @@ locals { { name : "DB_NAME", value : var.db_vars.connection_info.db_name }, { name : "DB_SCHEMA", value : var.db_vars.connection_info.schema_name }, ] - environment_variables = concat(local.base_environment_variables, local.db_environment_variables) + environment_variables = concat(local.base_environment_variables, local.db_environment_variables, var.extra_environment_variables) } #------------------- @@ -49,10 +49,8 @@ resource "aws_ecs_service" "app" { } network_configuration { - # TODO(https://github.com/navapbc/template-infra/issues/152) set assign_public_ip = false after using private subnets - # checkov:skip=CKV_AWS_333:Switch to using private subnets - assign_public_ip = true - subnets = var.subnet_ids + assign_public_ip = false + subnets = var.private_subnet_ids security_groups = [aws_security_group.app.id] } diff --git a/infra/modules/service/variables.tf b/infra/modules/service/variables.tf index c3f5b86dc..4b33931d3 100644 --- a/infra/modules/service/variables.tf +++ b/infra/modules/service/variables.tf @@ -70,9 +70,20 @@ variable "vpc_id" { description = "Uniquely identifies the VPC." } -variable "subnet_ids" { +variable "public_subnet_ids" { type = list(any) - description = "Private subnet id from vpc module" + description = "Public subnet ids in VPC" +} + +variable "private_subnet_ids" { + type = list(any) + description = "Private subnet ids in VPC" +} + +variable "extra_environment_variables" { + type = list(object({ name = string, value = string })) + description = "Additional environment variables to pass to the service container" + default = [] } variable "db_vars" { @@ -92,6 +103,12 @@ variable "db_vars" { default = null } +variable "extra_policies" { + description = "Map of extra IAM policies to attach to the service's task role. The map's keys define the resource name in terraform." + type = map(string) + default = {} +} + variable "cert_arn" { description = "The ARN for the TLS certificate passed in from the app service layer" type = string @@ -120,4 +137,4 @@ variable "api_auth_token" { type = string default = null description = "Auth token for connecting to the API" -} \ No newline at end of file +} diff --git a/infra/networks/main.tf b/infra/networks/main.tf index 140573d8c..9f5ac2057 100644 --- a/infra/networks/main.tf +++ b/infra/networks/main.tf @@ -17,9 +17,17 @@ locals { # see https://docs.aws.amazon.com/vpc/latest/privatelink/aws-services-privatelink-support.html # # The database module requires VPC access from private networks to SSM, KMS, and RDS - aws_service_integrations = toset( - module.app_config.has_database ? ["ssm", "kms"] : [] + aws_service_integrations = setunion( + # AWS services used by ECS Fargate: ECR to fetch images, S3 for image layers, and CloudWatch for logs + ["ecr.api", "ecr.dkr", "s3", "logs"], + + # AWS services used by the database's role manager + var.has_database ? ["ssm", "kms", "secretsmanager"] : [], ) + + # S3 and DynamoDB use Gateway VPC endpoints. All other services use Interface VPC endpoints + interface_vpc_endpoints = toset([for aws_service in local.aws_service_integrations : aws_service if !contains(["s3", "dynamodb"], aws_service)]) + gateway_vpc_endpoints = toset([for aws_service in local.aws_service_integrations : aws_service if contains(["s3", "dynamodb"], aws_service)]) } terraform { @@ -75,6 +83,7 @@ data "aws_subnets" "default" { # See https://repost.aws/knowledge-center/lambda-vpc-parameter-store # See https://docs.aws.amazon.com/vpc/latest/privatelink/create-interface-endpoint.html#create-interface-endpoint +# docs: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group resource "aws_security_group" "aws_services" { count = length(local.aws_service_integrations) > 0 ? 1 : 0 @@ -83,6 +92,19 @@ resource "aws_security_group" "aws_services" { vpc_id = data.aws_vpc.default.id } +# docs: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_security_group_ingress_rule +resource "aws_vpc_security_group_ingress_rule" "aws_services" { + count = length(local.aws_service_integrations) > 0 ? 1 : 0 + + security_group_id = aws_security_group.aws_services[0].id + description = "Allow all traffic from the VPCs CIDR block to the VPC endpoint security group" + from_port = 443 + to_port = 443 + ip_protocol = "tcp" + cidr_ipv4 = data.aws_vpc.default.cidr_block +} + +# docs: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_endpoint resource "aws_vpc_endpoint" "aws_service" { for_each = local.aws_service_integrations @@ -90,10 +112,20 @@ resource "aws_vpc_endpoint" "aws_service" { service_name = "com.amazonaws.${data.aws_region.current.name}.${each.key}" vpc_endpoint_type = "Interface" security_group_ids = [aws_security_group.aws_services[0].id] - subnet_ids = data.aws_subnets.default.ids + subnet_ids = [for subnet in aws_subnet.backfill_private : subnet.id] private_dns_enabled = true } +# docs: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_endpoint +resource "aws_vpc_endpoint" "gateway" { + for_each = local.gateway_vpc_endpoints + + vpc_id = data.aws_vpc.default.id + service_name = "com.amazonaws.${data.aws_region.current.name}.${each.key}" + vpc_endpoint_type = "Gateway" + route_table_ids = [for table in aws_route_table.backfill_private : table.id] +} + # VPC Configuration for DMS # ---------------------------------------- diff --git a/infra/networks/subnet-backfill.tf b/infra/networks/subnet-backfill.tf new file mode 100644 index 000000000..7009f0457 --- /dev/null +++ b/infra/networks/subnet-backfill.tf @@ -0,0 +1,92 @@ +# The purpose of this file is to backfill the default VPC with 3 private subnets. + +locals { + backfill_subnet_cidrs = { + # The CIDRs were chosen to be within `172.31.0.0/16` but not overlap with the nearest + # CIDRs already being used in the VPC. + # + # You can can confirm the ranges with a tool like: + # https://www.ipaddressguide.com/cidr + # + # The `/20` CIDR block was chosen because most of the existing subnets are `/20`. + "us-east-1a" = "172.31.144.0/20", # /20 = 4096 IPs, last address is 172.31.159.255 + "us-east-1b" = "172.31.160.0/20", # /20 = 4096 IPs, last address is 172.31.175.255 + "us-east-1c" = "172.31.176.0/20", # /20 = 4096 IPs, last address is 172.31.191.255 + } +} + +# ------- # +# SUBNETS # +# ------- # + +# docs: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/subnet +resource "aws_subnet" "backfill_private" { + for_each = local.backfill_subnet_cidrs + vpc_id = data.aws_vpc.default.id + availability_zone = each.key + cidr_block = each.value + map_public_ip_on_launch = false + tags = { + Name = "backfill-private-${each.key}" + subnet_type = "private" + } +} + +# ----------- # +# NAT GATEWAY # +# ----------- # + +# docs: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/eip +# +# purpose: All external traffic from the private subnets will be routed through this EIP +# via means of a NAT gateway. +resource "aws_eip" "backfill_private" { + # checkov:skip=CKV2_AWS_19: These EIPs are attached to NAT gateways + for_each = local.backfill_subnet_cidrs + domain = "vpc" + tags = { + Name = "backfill-private-${each.key}" + } +} + +# docs: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/nat_gateway +resource "aws_nat_gateway" "backfill_private" { + for_each = local.backfill_subnet_cidrs + allocation_id = aws_eip.backfill_private[each.key].allocation_id + subnet_id = aws_subnet.backfill_private[each.key].id + tags = { + Name = "backfill-private-${each.key}" + } +} + +# ------------ # +# ROUTE TABLES # +# ------------ # + +# docs: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route_table +resource "aws_route_table" "backfill_private" { + for_each = local.backfill_subnet_cidrs + vpc_id = data.aws_vpc.default.id + tags = { + Name = "backfill-private-${each.key}" + } +} + +# docs: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route_table_association +# +# purpose: Associate the private subnets with the private route table. +resource "aws_route_table_association" "backfill_private" { + for_each = local.backfill_subnet_cidrs + subnet_id = aws_subnet.backfill_private[each.key].id + route_table_id = aws_route_table.backfill_private[each.key].id +} + +# docs: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route +# +# purpose: Route external traffic through the NAT gateway. +resource "aws_route" "backfill_private" { + for_each = local.backfill_subnet_cidrs + route_table_id = aws_route_table.backfill_private[each.key].id + destination_cidr_block = "0.0.0.0/0" + nat_gateway_id = aws_nat_gateway.backfill_private[each.key].id +} diff --git a/infra/networks/variables.tf b/infra/networks/variables.tf new file mode 100644 index 000000000..f45967381 --- /dev/null +++ b/infra/networks/variables.tf @@ -0,0 +1,5 @@ +variable "has_database" { + type = bool + description = "whether the application has a database" + default = true +}