diff --git a/infrastructure/stacks/networking/README.md b/infrastructure/stacks/networking/README.md new file mode 100644 index 00000000..6e1995c3 --- /dev/null +++ b/infrastructure/stacks/networking/README.md @@ -0,0 +1,78 @@ +# Networking stack + +The networking stack contains the networking resources that are securing the Eligibility Signposting API application resources. + +The stack is documented on [this Confluence page](https://nhsd-confluence.digital.nhs.uk/spaces/Vacc/pages/1054575846/VPC+structure) + +## Traffic Flow Explanation + +### Public HTTPS Request Flow + +* External client makes HTTPS request → Internet Gateway +* Request routes to Load Balancer or API Gateway in public subnet +* Request forwards to Lambda (or other application) in private subnet +* Lambda processes the request and returns response +* Response returns to client through the same path + +### Outbound Internet Access + +* Lambda functions in private subnets can make outbound internet calls via NAT Gateways +* This allows Lambda to call external APIs, download packages, etc. +* No direct inbound access to Lambda from the internet + +### Internal-Only Traffic + +* Lambda functions access AWS services via VPC Endpoints: + * Gateway Endpoints: S3, DynamoDB + * Interface Endpoints: KMS, CloudWatch, SSM, Secrets Manager, Lambda, STS, SQS +* All traffic between Lambda and AWS services stays within the AWS network +* No internet transit required for AWS service access + +### Security Controls + +#### Network ACLs + +Public subnets: Allow HTTP(80), HTTPS(443), ephemeral ports +Private subnets: Allow VPC traffic and responses to outbound requests + +#### Security Groups + +Default security group: Deny all +VPC Endpoint security group: Allow HTTPS(443) from within VPC + +#### Route Tables + +Public subnets: Route to Internet Gateway for external access +Private subnets: Route to NAT Gateways for outbound-only access + +## Deployment to AWS Development Environment + +This stack should only ever be deployed once per account (e.g. the use of Terraform workspaces is explicitly not recommended beyond specifying `dev` as the environment). + +Deployment to the Development environment is done through use of `make` commands + +### Initialize Terraform and Plan + +Run the following command to initialize Terraform and generate a plan. Replace `` with the target environment: + +```bash +make terraform env=dev stack=networking tf-command=init workspace= +``` + +then + +```bash +make terraform env=dev stack=networking tf-command=plan workspace= +``` + +### 1.4 Apply Terraform Changes + +Deploy the Terraform configuration using the following command: + +```bash +make terraform env=dev stack=networking tf-command=apply workspace= +``` + +## Release Deployment to AWS (Int, Ref and Prod) + +Deployment to Int, Ref and Prod, as well as running the automated tests can be done via GitHub actions, when they are developed. diff --git a/infrastructure/stacks/networking/backends/dev.networking.tfbackend b/infrastructure/stacks/networking/backends/dev.networking.tfbackend new file mode 100644 index 00000000..7a4769a8 --- /dev/null +++ b/infrastructure/stacks/networking/backends/dev.networking.tfbackend @@ -0,0 +1,4 @@ +bucket = "eligibility-signposting-api-dev-tfstate" +key = "networking.tfstate" +region = "eu-west-2" +encrypt = true diff --git a/infrastructure/stacks/networking/data.tf b/infrastructure/stacks/networking/data.tf new file mode 100644 index 00000000..8fc4b38c --- /dev/null +++ b/infrastructure/stacks/networking/data.tf @@ -0,0 +1 @@ +data "aws_caller_identity" "current" {} diff --git a/infrastructure/stacks/networking/default_variables.tf b/infrastructure/stacks/networking/default_variables.tf new file mode 120000 index 00000000..062daf61 --- /dev/null +++ b/infrastructure/stacks/networking/default_variables.tf @@ -0,0 +1 @@ +../_shared/default_variables.tf \ No newline at end of file diff --git a/infrastructure/stacks/networking/internet_gateway.tf b/infrastructure/stacks/networking/internet_gateway.tf new file mode 100644 index 00000000..f3b2bc69 --- /dev/null +++ b/infrastructure/stacks/networking/internet_gateway.tf @@ -0,0 +1,7 @@ +resource "aws_internet_gateway" "vpc_external_access" { + vpc_id = aws_vpc.main.id + tags = { + Name = "internet-gateway", + Stack = local.stack_name + } +} diff --git a/infrastructure/stacks/networking/locals.tf b/infrastructure/stacks/networking/locals.tf new file mode 100644 index 00000000..e753f326 --- /dev/null +++ b/infrastructure/stacks/networking/locals.tf @@ -0,0 +1,34 @@ +locals { + any_ip_cidr = "0.0.0.0/0" + vpc_cidr_block = "10.0.0.0/16" + public_subnet_1_cidr = "10.0.3.0/24" + public_subnet_2_cidr = "10.0.4.0/24" + public_subnet_3_cidr = "10.0.5.0/24" + private_subnet_1_cidr = "10.0.6.0/24" + private_subnet_2_cidr = "10.0.7.0/24" + private_subnet_3_cidr = "10.0.8.0/24" + availability_zone_1 = "eu-west-2a" + availability_zone_2 = "eu-west-2b" + availability_zone_3 = "eu-west-2c" + default_port = 443 + + region = "eu-west-2" + stack_name = "Networking" + + # VPC Interface Endpoints + vpc_interface_endpoints = { + kms = "com.amazonaws.${local.region}.kms" + cloudwatch-logs = "com.amazonaws.${local.region}.logs" + ssm = "com.amazonaws.${local.region}.ssm" + secrets-manager = "com.amazonaws.${local.region}.secretsmanager" + lambda = "com.amazonaws.${local.region}.lambda" + sts = "com.amazonaws.${local.region}.sts" + sqs = "com.amazonaws.${local.region}.sqs" + } + + # VPC Gateway Endpoints + vpc_gateway_endpoints = { + dynamodb = "com.amazonaws.${local.region}.dynamodb" + s3 = "com.amazonaws.${local.region}.s3" + } +} diff --git a/infrastructure/stacks/networking/network_acls.tf b/infrastructure/stacks/networking/network_acls.tf new file mode 100644 index 00000000..56620d64 --- /dev/null +++ b/infrastructure/stacks/networking/network_acls.tf @@ -0,0 +1,109 @@ +# Network ACL for Private Subnets +resource "aws_network_acl" "private" { + vpc_id = aws_vpc.main.id + subnet_ids = [ + aws_subnet.private_1.id, + aws_subnet.private_2.id, + aws_subnet.private_3.id + ] + + # Allow all outbound traffic from private subnets + egress { + rule_no = 100 + action = "allow" + cidr_block = local.vpc_cidr_block + protocol = -1 + from_port = 0 + to_port = 0 + } + + # Allow inbound traffic from within the VPC + ingress { + rule_no = 100 + action = "allow" + cidr_block = local.vpc_cidr_block + protocol = -1 + from_port = 0 + to_port = 0 + } + + # Allow responses to outbound requests (ephemeral ports) + ingress { + rule_no = 200 + action = "allow" + cidr_block = "0.0.0.0/0" + protocol = "tcp" + from_port = 1024 + to_port = 65535 + } + + tags = { + Name = "private-nacl", + Stack = local.stack_name + } +} + +# Network ACL for Public Subnets +resource "aws_network_acl" "public" { + vpc_id = aws_vpc.main.id + subnet_ids = [ + aws_subnet.public_1.id, + aws_subnet.public_2.id, + aws_subnet.public_3.id + ] + + # Allow all outbound traffic from public subnets + egress { + rule_no = 100 + action = "allow" + cidr_block = "0.0.0.0/0" + protocol = -1 + from_port = 0 + to_port = 0 + } + + # Allow inbound HTTP + ingress { + rule_no = 100 + action = "allow" + cidr_block = "0.0.0.0/0" + protocol = "tcp" + from_port = 80 + to_port = 80 + } + + # Allow inbound HTTPS + ingress { + rule_no = 110 + action = "allow" + cidr_block = "0.0.0.0/0" + protocol = "tcp" + from_port = 443 + to_port = 443 + } + + # Allow responses to outbound requests (ephemeral ports) + ingress { + rule_no = 120 + action = "allow" + cidr_block = "0.0.0.0/0" + protocol = "tcp" + from_port = 1024 + to_port = 65535 + } + + # Allow inbound VPC traffic + ingress { + rule_no = 130 + action = "allow" + cidr_block = local.vpc_cidr_block + protocol = -1 + from_port = 0 + to_port = 0 + } + + tags = { + Name = "public-nacl", + Stack = local.stack_name + } +} diff --git a/infrastructure/stacks/networking/provider.tf b/infrastructure/stacks/networking/provider.tf new file mode 100644 index 00000000..b64be2af --- /dev/null +++ b/infrastructure/stacks/networking/provider.tf @@ -0,0 +1,3 @@ +provider "aws" { + region = "eu-west-2" +} diff --git a/infrastructure/stacks/networking/route_tables.tf b/infrastructure/stacks/networking/route_tables.tf new file mode 100644 index 00000000..34967398 --- /dev/null +++ b/infrastructure/stacks/networking/route_tables.tf @@ -0,0 +1,100 @@ +# Public Route Tables +resource "aws_route_table" "public_1" { + vpc_id = aws_vpc.main.id + tags = { + Name = "public-route-1", + Stack = local.stack_name + } +} + +resource "aws_route_table" "public_2" { + vpc_id = aws_vpc.main.id + tags = { + Name = "public-route-2", + Stack = local.stack_name + } +} + +resource "aws_route_table" "public_3" { + vpc_id = aws_vpc.main.id + tags = { + Name = "public-route-3", + Stack = local.stack_name + } +} + +# Associate Public Route Tables with Public Subnets +resource "aws_route_table_association" "public_1" { + subnet_id = aws_subnet.public_1.id + route_table_id = aws_route_table.public_1.id +} + +resource "aws_route_table_association" "public_2" { + subnet_id = aws_subnet.public_2.id + route_table_id = aws_route_table.public_2.id +} + +resource "aws_route_table_association" "public_3" { + subnet_id = aws_subnet.public_3.id + route_table_id = aws_route_table.public_3.id +} + +# Private Route Tables +resource "aws_route_table" "private_1" { + vpc_id = aws_vpc.main.id + tags = { + Name = "private-route-1", + Stack = local.stack_name + } +} + +resource "aws_route_table" "private_2" { + vpc_id = aws_vpc.main.id + tags = { + Name = "private-route-2", + Stack = local.stack_name + } +} + +resource "aws_route_table" "private_3" { + vpc_id = aws_vpc.main.id + tags = { + Name = "private-route-3", + Stack = local.stack_name + } +} + +# Associate Private Route Tables with Private Subnets +resource "aws_route_table_association" "private_association_1" { + subnet_id = aws_subnet.private_1.id + route_table_id = aws_route_table.private_1.id +} + +resource "aws_route_table_association" "private_association_2" { + subnet_id = aws_subnet.private_2.id + route_table_id = aws_route_table.private_2.id +} + +resource "aws_route_table_association" "private_association_3" { + subnet_id = aws_subnet.private_3.id + route_table_id = aws_route_table.private_3.id +} + +# Egress Internet Access +resource "aws_route" "public_internet_access" { + route_table_id = aws_route_table.public_1.id + destination_cidr_block = local.any_ip_cidr + gateway_id = aws_internet_gateway.vpc_external_access.id +} + +resource "aws_route" "public_internet_access_2" { + route_table_id = aws_route_table.public_2.id + destination_cidr_block = local.any_ip_cidr + gateway_id = aws_internet_gateway.vpc_external_access.id +} + +resource "aws_route" "public_internet_access_3" { + route_table_id = aws_route_table.public_3.id + destination_cidr_block = local.any_ip_cidr + gateway_id = aws_internet_gateway.vpc_external_access.id +} diff --git a/infrastructure/stacks/networking/shared_locals.tf b/infrastructure/stacks/networking/shared_locals.tf new file mode 120000 index 00000000..e360bc7f --- /dev/null +++ b/infrastructure/stacks/networking/shared_locals.tf @@ -0,0 +1 @@ +../_shared/locals.tf \ No newline at end of file diff --git a/infrastructure/stacks/networking/ssm.tf b/infrastructure/stacks/networking/ssm.tf new file mode 100644 index 00000000..f5fa3015 --- /dev/null +++ b/infrastructure/stacks/networking/ssm.tf @@ -0,0 +1,44 @@ +# resource "aws_ssm_parameter" "proxygen_private_key" { +# count = var.environment == "dev" ? 1 : 0 +# name = "/proxygen/private_key" +# type = "SecureString" +# value = var.PROXYGEN_PRIVATE_KEY +# tier = "Advanced" +# +# tags = { +# Stack = local.stack_name +# } +# } +# +# resource "aws_ssm_parameter" "mtls_api_ca_cert" { +# name = "/${var.environment}/mtls/api_ca_cert" +# type = "SecureString" +# key_id = aws_kms_key.networking_ssm_key.id +# value = var.API_CA_CERT +# tier = "Advanced" +# tags = { +# Stack = local.stack_name +# } +# } +# +# resource "aws_ssm_parameter" "mtls_api_client_cert" { +# name = "/${var.environment}/mtls/api_client_cert" +# type = "SecureString" +# key_id = aws_kms_key.networking_ssm_key.id +# value = var.API_CLIENT_CERT +# tier = "Advanced" +# tags = { +# Stack = local.stack_name +# } +# } +# +# resource "aws_ssm_parameter" "mtls_api_private_key_cert" { +# name = "/${var.environment}/mtls/api_private_key_cert" +# type = "SecureString" +# key_id = aws_kms_key.networking_ssm_key.id +# value = var.API_PRIVATE_KEY_CERT +# tier = "Advanced" +# tags = { +# Stack = local.stack_name +# } +# } diff --git a/infrastructure/stacks/networking/ssm_key.tf b/infrastructure/stacks/networking/ssm_key.tf new file mode 100644 index 00000000..7e091b77 --- /dev/null +++ b/infrastructure/stacks/networking/ssm_key.tf @@ -0,0 +1,49 @@ +data "aws_iam_policy_document" "networking_ssm_key" { + #checkov:skip=CKV_AWS_109: Ensure IAM policies does not allow permissions management / resource exposure without constraints + #checkov:skip=CKV_AWS_111: Ensure IAM policies does not allow write access without constraints + #checkov:skip=CKV_AWS_356: Ensure no IAM policies documents allow "*" as a statement's resource for restrictable actions + statement { + sid = "EnableIamUserPermissions" + effect = "Allow" + principals { + type = "AWS" + identifiers = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"] + } + actions = ["kms:*"] + resources = ["*"] + } + statement { + sid = "EncryptDecryptSsm" + effect = "Allow" + principals { + type = "Service" + identifiers = ["ssm.amazonaws.com"] + } + actions = [ + "kms:Encrypt*", + "kms:Decrypt*", + "kms:ReEncrypt*", + "kms:GenerateDataKey*", + "kms:Describe*" + ] + resources = ["*"] + } +} + +resource "aws_kms_key" "networking_ssm_key" { + description = "${var.environment} - KMS Key for Networking SSM Parameters" + deletion_window_in_days = 14 + enable_key_rotation = true + + policy = data.aws_iam_policy_document.networking_ssm_key.json + + tags = { + Name = "${var.environment}-${local.stack_name}-ssm-key" + Stack = local.stack_name + } +} + +resource "aws_kms_alias" "networking_ssm_key" { + name = "alias/${var.environment}-${local.stack_name}-ssm-parameters" + target_key_id = aws_kms_key.networking_ssm_key.key_id +} diff --git a/infrastructure/stacks/networking/state.tf b/infrastructure/stacks/networking/state.tf new file mode 100644 index 00000000..30b6dd2e --- /dev/null +++ b/infrastructure/stacks/networking/state.tf @@ -0,0 +1,16 @@ +terraform { + required_version = ">= 1.11.1" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.6, != 5.71.0" + } + } + backend "s3" { + bucket = "eligibility-signposting-api-dev-tfstate" + key = "tfstate/networking.tfstate" + region = "eu-west-2" + use_lockfile = true + } +} diff --git a/infrastructure/stacks/networking/subnets.tf b/infrastructure/stacks/networking/subnets.tf new file mode 100644 index 00000000..c0a0b3bb --- /dev/null +++ b/infrastructure/stacks/networking/subnets.tf @@ -0,0 +1,64 @@ +# Public Subnets +resource "aws_subnet" "public_1" { + vpc_id = aws_vpc.main.id + cidr_block = local.public_subnet_1_cidr + availability_zone = local.availability_zone_1 + map_public_ip_on_launch = false + tags = { + Name = "public-subnet-1", + Stack = local.stack_name + } +} + +resource "aws_subnet" "public_2" { + vpc_id = aws_vpc.main.id + cidr_block = local.public_subnet_2_cidr + availability_zone = local.availability_zone_2 + map_public_ip_on_launch = false + tags = { + Name = "public-subnet-2", + Stack = local.stack_name + } +} + +resource "aws_subnet" "public_3" { + vpc_id = aws_vpc.main.id + cidr_block = local.public_subnet_3_cidr + availability_zone = local.availability_zone_3 + map_public_ip_on_launch = false + tags = { + Name = "public-subnet-3", + Stack = local.stack_name + } +} + +# Private Subnets +resource "aws_subnet" "private_1" { + vpc_id = aws_vpc.main.id + cidr_block = local.private_subnet_1_cidr + availability_zone = local.availability_zone_1 + tags = { + Name = "private-subnet-1", + Stack = local.stack_name + } +} + +resource "aws_subnet" "private_2" { + vpc_id = aws_vpc.main.id + cidr_block = local.private_subnet_2_cidr + availability_zone = local.availability_zone_2 + tags = { + Name = "private-subnet-2", + Stack = local.stack_name + } +} + +resource "aws_subnet" "private_3" { + vpc_id = aws_vpc.main.id + cidr_block = local.private_subnet_3_cidr + availability_zone = local.availability_zone_3 + tags = { + Name = "private-subnet-3", + Stack = local.stack_name + } +} diff --git a/infrastructure/stacks/networking/variables.tf b/infrastructure/stacks/networking/variables.tf new file mode 100644 index 00000000..66a5ed0a --- /dev/null +++ b/infrastructure/stacks/networking/variables.tf @@ -0,0 +1,22 @@ +# variable "API_CA_CERT" { +# type = string +# description = "The Certificate Authority (CA) Root for the Client Certificate with Intermediate Certificate" +# sensitive = true +# default = "Test Value" +# +# } +# +# variable "API_CLIENT_CERT" { +# type = string +# description = "The signed Client Certificate" +# sensitive = true +# default = "Test Value" +# } +# +# variable "API_PRIVATE_KEY_CERT" { +# type = string +# description = "The private key for the signed Client Certificate" +# sensitive = true +# default = "Test Value" +# } +# diff --git a/infrastructure/stacks/networking/vpc.tf b/infrastructure/stacks/networking/vpc.tf new file mode 100644 index 00000000..a3ad8a04 --- /dev/null +++ b/infrastructure/stacks/networking/vpc.tf @@ -0,0 +1,23 @@ +resource "aws_vpc" "main" { + #checkov:skip=CKV2_AWS_11: "Ensure VPC flow logging is enabled in all VPCs" + cidr_block = local.vpc_cidr_block + enable_dns_support = true + enable_dns_hostnames = true + tags = { + Name = "main-vpc" + Stack = local.stack_name + } +} + +# Default Deny All Security Group +resource "aws_default_security_group" "default_vpc" { + vpc_id = aws_vpc.main.id + + tags = merge( + local.tags, + { + Name = "main-vpc" + Stack = local.stack_name + } + ) +} diff --git a/infrastructure/stacks/networking/vpc_endpoints.tf b/infrastructure/stacks/networking/vpc_endpoints.tf new file mode 100644 index 00000000..0e3fde0f --- /dev/null +++ b/infrastructure/stacks/networking/vpc_endpoints.tf @@ -0,0 +1,68 @@ +# Generate Security Group used by all VPC Endpoints: +resource "aws_security_group" "main" { + name = "main-security-group" + description = "Allows all internal to VPC resources access to the endpoints and the endpoints to access the AWS Services" + vpc_id = aws_vpc.main.id + tags = { + "Name" = "main-security-group", + Stack = local.stack_name + } +} + +resource "aws_security_group_rule" "main_https_in" { + description = "Allow all internal VPC resources access to all VPC Endpoints" + type = "ingress" + from_port = local.default_port + to_port = local.default_port + protocol = "tcp" + cidr_blocks = [local.vpc_cidr_block] + security_group_id = aws_security_group.main.id +} + +resource "aws_security_group_rule" "main_https_out" { + description = "Allow VPC Endpoint to access the actual AWS Service Endpoints" + type = "egress" + from_port = local.default_port + to_port = local.default_port + protocol = "tcp" + cidr_blocks = [local.any_ip_cidr] + security_group_id = aws_security_group.main.id +} + +# Generate the Interface VPC Endpoints +resource "aws_vpc_endpoint" "interfaces" { + vpc_id = aws_vpc.main.id + for_each = local.vpc_interface_endpoints + service_name = each.value + subnet_ids = [ + aws_subnet.private_1.id, + aws_subnet.private_2.id, + aws_subnet.private_3.id + ] + vpc_endpoint_type = "Interface" + private_dns_enabled = true + security_group_ids = [aws_security_group.main.id] + + tags = { + "Name" = "${each.key}-endpoint", + Stack = local.stack_name + } +} + +# Generate the Gateway VPC Endpoints +resource "aws_vpc_endpoint" "gateways" { + vpc_id = aws_vpc.main.id + for_each = local.vpc_gateway_endpoints + service_name = each.value + vpc_endpoint_type = "Gateway" + route_table_ids = [ + aws_route_table.private_1.id, + aws_route_table.private_2.id, + aws_route_table.private_3.id + ] + + tags = { + "Name" = "${each.key}-endpoint", + Stack = local.stack_name + } +} diff --git a/infrastructure/stacks/networking/vpc_flow_logs.tf b/infrastructure/stacks/networking/vpc_flow_logs.tf new file mode 100644 index 00000000..d6b0f299 --- /dev/null +++ b/infrastructure/stacks/networking/vpc_flow_logs.tf @@ -0,0 +1,65 @@ +# CloudWatch Log Group for VPC Flow Logs +resource "aws_cloudwatch_log_group" "flow_logs" { + name = "/aws/vpc/${aws_vpc.main.id}/flow-logs" + retention_in_days = 14 + + tags = { + Name = "vpc-flow-logs" + Stack = local.stack_name + } +} + +# IAM Role for VPC Flow Logs +resource "aws_iam_role" "flow_logs" { + name = "vpc-flow-logs-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "vpc-flow-logs.amazonaws.com" + } + }] + }) + + tags = { + Stack = local.stack_name + } +} + +# IAM Policy for VPC Flow Logs +resource "aws_iam_role_policy" "flow_logs" { + name = "vpc-flow-logs-policy" + role = aws_iam_role.flow_logs.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents", + "logs:DescribeLogGroups", + "logs:DescribeLogStreams" + ] + Effect = "Allow" + Resource = "${aws_cloudwatch_log_group.flow_logs.arn}:*" + }] + }) +} + +# VPC Flow Logs +resource "aws_flow_log" "vpc_flow_logs" { + log_destination = aws_cloudwatch_log_group.flow_logs.arn + log_destination_type = "cloud-watch-logs" + traffic_type = "ALL" + vpc_id = aws_vpc.main.id + iam_role_arn = aws_iam_role.flow_logs.arn + + tags = { + Name = "vpc-flow-logs" + Stack = local.stack_name + } +} diff --git a/scripts/config/vale/styles/config/vocabularies/words/accept.txt b/scripts/config/vale/styles/config/vocabularies/words/accept.txt index 9677cf4a..35e494e8 100644 --- a/scripts/config/vale/styles/config/vocabularies/words/accept.txt +++ b/scripts/config/vale/styles/config/vocabularies/words/accept.txt @@ -12,6 +12,7 @@ onboarding Podman Python sed +subnet Syft Terraform toolchain