Repository: communitiesuk/prsdb-infra
Pull request #41: Feat/prsd 898 add ssm bastion for database access
Author: login: AEPR
Created: 2025-03-10T09:32:23Z | Merged: 2025-03-11T10:13:35Z

First draft of ssm bastion terraform set up. This has not currently been tested as I want agreement from a reviewer before running it against integration!

I'm leaving a few comments on things that MUST change before this is merged - I'll raise a new PR for actually merging so we should focus more on function than form!


Diff:
diff --git a/ReadMe.md b/ReadMe.md
index 282b4bd..a19c209 100644
--- a/ReadMe.md
+++ b/ReadMe.md
@@ -28,6 +28,45 @@ Still in the environment directory, run `terraform init` to create a local terra
 
 You are now ready to start running terraform commands on the chosen environment.
 
+## Accessing deployed infrastructure
+
+### Connecting to the database
+The database is an RDS instance running on isolated subnets. This means there is no way to directly connect to it. 
+Instead, we use one bastion EC2 instance per availability zone acting as an 
+[SSM agent](https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager.html). 
+The instructions here assume that all infrastructure is deployed in the region eu-west-2 (London). 
+If the infrastructure is in a different region, use that instead in each link and command.
+
+Once you have followed the set up above, to connect to the database you will need:
+* The id of bastion you intend to use - [london ec2 instances](https://eu-west-2.console.aws.amazon.com/ec2/home#Instances)
+* The endpoint of the database - [london databases](https://eu-west-2.console.aws.amazon.com/rds#databases:)
+* The password for the database is stored in secrets - [london secrets](https://eu-west-2.console.aws.amazon.com/secretsmanager/listsecrets)
+
+First, start an `aws-vault exec` session for the profile corresponding to the environment you want to connect to,
+then run this command:
+
+```shell
+aws ssm start-session --region eu-west-2 --target <bastion id> --document-name AWS-StartPortForwardingSessionToRemoteHost --parameters host="<database endpoint>",portNumber="5432",localPortNumber="5432"
+```
+
+You should see something similar to:
+```shell 
+Starting session with SessionId: 12a3456bcdefghi789
+Port 5432 opened for sessionId 12a3456bcdefghi789.
+Waiting for connections...
+```
+
+Leave this terminal open and running, and then you should be able to connect to the database from your machine using 
+this connection string:
+```
+postgresql://postgres:<password>@localhost:5432/prsdb
+```
+
+When you start a connection, you will see a confirmation in the terminal window:
+```shell
+Connection accepted for session [12a3456bcdefghi789]
+```
+
 ## Setting up a new environment from scratch
 
 ### Bootstrapping the terraform backend
diff --git a/terraform/integration/main.tf b/terraform/integration/main.tf
index bbc0a3f..d6cc6a6 100644
--- a/terraform/integration/main.tf
+++ b/terraform/integration/main.tf
@@ -129,6 +129,15 @@ module "parameters" {
   environment_name = local.environment_name
 }
 
+module "bastion" {
+  source = "../modules/bastion"
+
+  bastion_subnet_ids = module.networking.private_subnets[*].id
+  environment_name   = local.environment_name
+  main_vpc_id        = module.networking.vpc.id
+  vpc_cidr_block     = module.networking.vpc.cidr_block
+}
+
 module "database" {
   source = "../modules/rds"
 
@@ -142,6 +151,7 @@ module "database" {
   multi_az                        = local.multi_az
   vpc_id                          = module.networking.vpc.id
   webapp_task_execution_role_name = module.ecr.webapp_ecs_task_role_name
+  bastion_group_id                = module.bastion.security_group_id
 }
 
 module "redis" {
diff --git a/terraform/modules/bastion/iam.tf b/terraform/modules/bastion/iam.tf
new file mode 100644
index 0000000..99d5d5f
--- /dev/null
+++ b/terraform/modules/bastion/iam.tf
@@ -0,0 +1,26 @@
+data "aws_iam_policy_document" "bastion_assume_role" {
+  statement {
+    effect  = "Allow"
+    actions = ["sts:AssumeRole"]
+
+    principals {
+      type        = "Service"
+      identifiers = ["ec2.amazonaws.com"]
+    }
+  }
+}
+
+resource "aws_iam_role" "ssm_bastion" {
+  name               = "${var.environment_name}-bastion-role"
+  assume_role_policy = data.aws_iam_policy_document.bastion_assume_role.json
+}
+
+resource "aws_iam_role_policy_attachment" "ssm_bastion_attachment" {
+  role       = aws_iam_role.ssm_bastion.name
+  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
+}
+
+resource "aws_iam_instance_profile" "ssm_bastion" {
+  name = "${var.environment_name}-bastion-instance-profile"
+  role = aws_iam_role.ssm_bastion.name
+}
\ No newline at end of file
diff --git a/terraform/modules/bastion/instance.tf b/terraform/modules/bastion/instance.tf
new file mode 100644
index 0000000..ff52dfb
--- /dev/null
+++ b/terraform/modules/bastion/instance.tf
@@ -0,0 +1,25 @@
+data "aws_ami" "amazon_linux_2" {
+  most_recent = true
+  owners      = ["amazon"]
+
+  filter {
+    name   = "name"
+    values = ["amzn2-ami-kernel-*-hvm-*-x86_64-gp2"]
+  }
+}
+
+resource "aws_instance" "bastion" {
+  count                  = length(var.bastion_subnet_ids)
+  ami                    = data.aws_ami.amazon_linux_2.id
+  instance_type          = "t2.micro"
+  subnet_id              = var.bastion_subnet_ids[count.index]
+  vpc_security_group_ids = [aws_security_group.bastion.id]
+  iam_instance_profile   = aws_iam_instance_profile.ssm_bastion.name
+
+  root_block_device {
+    encrypted = true
+  }
+  metadata_options {
+    http_tokens = "required"
+  }
+}
\ No newline at end of file
diff --git a/terraform/modules/bastion/main.tf b/terraform/modules/bastion/main.tf
new file mode 100644
index 0000000..ca66bd3
--- /dev/null
+++ b/terraform/modules/bastion/main.tf
@@ -0,0 +1,10 @@
+terraform {
+  required_version = "~>1.9.1"
+
+  required_providers {
+    aws = {
+      source  = "hashicorp/aws"
+      version = "~>5.0"
+    }
+  }
+}
\ No newline at end of file
diff --git a/terraform/modules/bastion/outputs.tf b/terraform/modules/bastion/outputs.tf
new file mode 100644
index 0000000..e16f37e
--- /dev/null
+++ b/terraform/modules/bastion/outputs.tf
@@ -0,0 +1,5 @@
+
+output "security_group_id" {
+  description = "Id of the bastion security group"
+  value       = aws_security_group.bastion.id
+}
\ No newline at end of file
diff --git a/terraform/modules/bastion/security_group.tf b/terraform/modules/bastion/security_group.tf
new file mode 100644
index 0000000..8376c3c
--- /dev/null
+++ b/terraform/modules/bastion/security_group.tf
@@ -0,0 +1,22 @@
+resource "aws_security_group" "bastion" {
+  name        = "${var.environment_name}-bastion"
+  description = "Security group for SSM bastion in ${var.environment_name}"
+
+  vpc_id = var.main_vpc_id
+
+  egress {
+    from_port   = 443
+    to_port     = 443
+    protocol    = "tcp"
+    cidr_blocks = [var.vpc_cidr_block]
+    description = "Allow bastion access to vpc endpoints to allow for SSM"
+  }
+
+  egress {
+    from_port   = 5432
+    to_port     = 5432
+    protocol    = "tcp"
+    cidr_blocks = [var.vpc_cidr_block]
+    description = "Allow bastion access to postgres port within the VPC"
+  }
+}
diff --git a/terraform/modules/bastion/variables.tf b/terraform/modules/bastion/variables.tf
new file mode 100644
index 0000000..1c645d7
--- /dev/null
+++ b/terraform/modules/bastion/variables.tf
@@ -0,0 +1,23 @@
+variable "environment_name" {
+  description = "must be one of: integration, test"
+  type        = string
+  validation {
+    condition     = contains(["integration", "test"], var.environment_name)
+    error_message = "Environment must be one of: integration, test"
+  }
+}
+
+variable "main_vpc_id" {
+  type        = string
+  description = "The id of the VPC."
+}
+
+variable "vpc_cidr_block" {
+  type        = string
+  description = "A collection of IP addresses to be allocated to VPC."
+}
+
+variable "bastion_subnet_ids" {
+  type        = list(string)
+  description = "The private subnets into which to deploy a bastion"
+}
\ No newline at end of file
diff --git a/terraform/modules/networking/data.tf b/terraform/modules/networking/data.tf
new file mode 100644
index 0000000..eca240f
--- /dev/null
+++ b/terraform/modules/networking/data.tf
@@ -0,0 +1,11 @@
+data "aws_vpc_endpoint_service" "ssm" {
+  service = "ssm"
+}
+
+data "aws_vpc_endpoint_service" "ssmmessages" {
+  service = "ssmmessages"
+}
+
+data "aws_vpc_endpoint_service" "ec2messages" {
+  service = "ec2messages"
+}
\ No newline at end of file
diff --git a/terraform/modules/networking/main.tf b/terraform/modules/networking/main.tf
index 0b24e64..ca66bd3 100644
--- a/terraform/modules/networking/main.tf
+++ b/terraform/modules/networking/main.tf
@@ -7,18 +7,4 @@ terraform {
       version = "~>5.0"
     }
   }
-}
-
-resource "aws_security_group" "aws_service_vpc_endpoints" {
-  name        = "vpc-endpoints-${var.environment_name}"
-  description = "VPC Endpoint security group"
-  vpc_id      = aws_vpc.main.id
-
-  ingress {
-    description = "Connections from VPC"
-    from_port   = 0
-    to_port     = 0
-    protocol    = "-1"
-    cidr_blocks = [aws_vpc.main.cidr_block]
-  }
 }
\ No newline at end of file
diff --git a/terraform/modules/networking/vpc_endpoints.tf b/terraform/modules/networking/vpc_endpoints.tf
new file mode 100644
index 0000000..c97acdb
--- /dev/null
+++ b/terraform/modules/networking/vpc_endpoints.tf
@@ -0,0 +1,40 @@
+resource "aws_security_group" "aws_service_vpc_endpoints" {
+  name        = "vpc-endpoints-${var.environment_name}"
+  description = "VPC Endpoint security group"
+  vpc_id      = aws_vpc.main.id
+
+  ingress {
+    description = "Connections from VPC"
+    from_port   = 0
+    to_port     = 0
+    protocol    = "-1"
+    cidr_blocks = [aws_vpc.main.cidr_block]
+  }
+}
+
+resource "aws_vpc_endpoint" "ssm" {
+  vpc_id              = aws_vpc.main.id
+  service_name        = data.aws_vpc_endpoint_service.ssm.service_name
+  vpc_endpoint_type   = "Interface"
+  subnet_ids          = aws_subnet.private_subnet[*].id
+  security_group_ids  = [aws_security_group.aws_service_vpc_endpoints.id]
+  private_dns_enabled = true
+}
+
+resource "aws_vpc_endpoint" "ssmmessages" {
+  vpc_id              = aws_vpc.main.id
+  service_name        = data.aws_vpc_endpoint_service.ssmmessages.service_name
+  vpc_endpoint_type   = "Interface"
+  subnet_ids          = aws_subnet.private_subnet[*].id
+  security_group_ids  = [aws_security_group.aws_service_vpc_endpoints.id]
+  private_dns_enabled = true
+}
+
+resource "aws_vpc_endpoint" "ec2messages" {
+  vpc_id              = aws_vpc.main.id
+  service_name        = data.aws_vpc_endpoint_service.ec2messages.service_name
+  vpc_endpoint_type   = "Interface"
+  subnet_ids          = aws_subnet.private_subnet[*].id
+  security_group_ids  = [aws_security_group.aws_service_vpc_endpoints.id]
+  private_dns_enabled = true
+}
\ No newline at end of file
diff --git a/terraform/modules/rds/security_groups.tf b/terraform/modules/rds/security_groups.tf
index 4caa3e0..c80a00f 100644
--- a/terraform/modules/rds/security_groups.tf
+++ b/terraform/modules/rds/security_groups.tf
@@ -6,4 +6,13 @@ resource "aws_security_group" "main" {
   lifecycle {
     create_before_destroy = true
   }
+}
+
+resource "aws_vpc_security_group_ingress_rule" "ingress_for_ssm_port_forwarding" {
+  description                  = "Allow ingress on port 5432 from the bastion"
+  ip_protocol                  = "tcp"
+  from_port                    = 5432
+  to_port                      = 5432
+  referenced_security_group_id = var.bastion_group_id
+  security_group_id            = aws_security_group.main.id
 }
\ No newline at end of file
diff --git a/terraform/modules/rds/variables.tf b/terraform/modules/rds/variables.tf
index 9b03450..9737d32 100644
--- a/terraform/modules/rds/variables.tf
+++ b/terraform/modules/rds/variables.tf
@@ -63,4 +63,8 @@ variable "vpc_id" {
 variable "webapp_task_execution_role_name" {
   description = "Name of the IAM role for the webapp ECS task execution"
   type        = string
+}
+variable "bastion_group_id" {
+  type        = string
+  description = "The id of the bastion security group"
 }
\ No newline at end of file


Reviews:
user: login: AEPR
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-10T09:37:55Z
user: login: Travis-Softwire
body: I think this looks good for 'let's test it out' purposes. Agree we'll want to switch to VPC endpoints, and at some point we'll want to add cloudwatch logging so that we capture any non-port forwarding connections to the bastion (but that can be done as part of the wider 'add cloudwatch logging' ticket in the backlog).
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-10T10:18:30Z
user: login: Travis-Softwire
body: A few more thoughts
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-10T14:32:22Z
user: login: AEPR
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-10T15:17:13Z
user: login: Travis-Softwire
body: Couple of minor thoughts, but LGTM 👍 
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-10T16:41:46Z
user: login: AEPR
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T09:42:02Z
user: login: Travis-Softwire
body: All LGTM 👍 
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-11T09:47:39Z

Review comments:
diff_hunk: @@ -129,6 +129,14 @@ module "parameters" {
   environment_name = local.environment_name
 }
 
+module "bastion" {
+  source = "../modules/bastion"
+
+  bastion_subnet_id = module.networking.private_subnets[0].id
path: terraform/integration/main.tf
user: login: AEPR
body: I'm a little unhappy that this is just living in a single private subnet on an indeterminate private subnet. I think this will move into an isolated subnet before we're done anyway, but we should think about which one!
created_at: 2025-03-10T09:34:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,59 @@
+terraform {
+  required_version = "~>1.9.1"
+
+  required_providers {
+    aws = {
+      source  = "hashicorp/aws"
+      version = "~>5.0"
+    }
+  }
+}
+
+resource "aws_security_group" "bastion" {
+  name        = "${var.environment_name}-bastion"
+  description = "Security group for SSM bastion in ${var.environment_name}"
+
+  vpc_id = var.main_vpc_id
+
+  egress {
+    from_port   = 0
+    to_port     = 0
+    protocol    = "-1"
+    cidr_blocks = ["0.0.0.0/0"]
+  }
path: terraform/modules/bastion/main.tf
user: login: AEPR
body: This is currently allowing 100% external access for the bastion, relying on firewall mediated internet access for SSM to function. In the final version of this PR, this will instead be managed by creating the necessary VPC endpoints so the SSM traffic does not go over the open internet.
created_at: 2025-03-10T09:37:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -129,6 +129,14 @@ module "parameters" {
   environment_name = local.environment_name
 }
 
+module "bastion" {
+  source = "../modules/bastion"
+
+  bastion_subnet_id = module.networking.private_subnets[0].id
path: terraform/integration/main.tf
user: login: Travis-Softwire
body: Annoyingly I've realised that we _do_ want the bastion host in the private subnet (and to give it egerss to the internet over TLS) to allow for software patching.

In terms of which subnet - if we're just having 1 instance then it's fine to just pick the first subnet as you have here. I think there is a Q about whether we should have multiple instances though, one in each AZ where our app & database are deployed, to allow for access when the first AZ goes down. Fine to leave it as a single instance for now though I think.
created_at: 2025-03-10T10:05:20Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,59 @@
+terraform {
+  required_version = "~>1.9.1"
+
+  required_providers {
+    aws = {
+      source  = "hashicorp/aws"
+      version = "~>5.0"
+    }
+  }
+}
+
+resource "aws_security_group" "bastion" {
+  name        = "${var.environment_name}-bastion"
+  description = "Security group for SSM bastion in ${var.environment_name}"
+
+  vpc_id = var.main_vpc_id
+
+  egress {
+    from_port   = 0
+    to_port     = 0
+    protocol    = "-1"
+    cidr_blocks = ["0.0.0.0/0"]
+  }
path: terraform/modules/bastion/main.tf
user: login: Travis-Softwire
body: Agree that this egress rule we'll want to limit to the VPC to allow for SSM endpoint access once that's set up. However I think we'll also need an egress rule to the internet on for TCP port 443 to allow the bastion host to patch itself - we have the equivalents for the ECS application as well
created_at: 2025-03-10T10:09:02Z
author_association: COLLABORATOR
diff_hunk: @@ -51,9 +52,33 @@ resource "aws_iam_instance_profile" "ssm_bastion" {
 }
 
 resource "aws_instance" "bastion" {
+  count           = length(var.bastion_subnet_ids)
   ami             = "ami-00710ab5544b60cf7"
path: terraform/modules/bastion/main.tf
user: login: Travis-Softwire
body: We should get the AMI through a `data` block rather than hard coding in a specific version, e.g. https://github.com/aws-samples/secured-bastion-host-terraform/blob/main/bastion-host/data.tf 
created_at: 2025-03-10T14:15:24Z
author_association: COLLABORATOR
diff_hunk: @@ -16,10 +16,11 @@ resource "aws_security_group" "bastion" {
   vpc_id = var.main_vpc_id
 
   egress {
-    from_port   = 0
-    to_port     = 0
-    protocol    = "-1"
-    cidr_blocks = ["0.0.0.0/0"]
+    from_port   = 443
+    to_port     = 443
+    protocol    = "tcp"
+    cidr_blocks = [var.vpc_cidr_block]
+    description = "Allow bastion access to vpc endpoints to allow for SSM"
path: terraform/modules/bastion/main.tf
user: login: Travis-Softwire
body: Can you add a ticket to consider options around patching the bastion host? One option is to allow TLS egress so we can just use `yum` or similar, but another option is to look at whether [SSM Patch manager](https://docs.aws.amazon.com/systems-manager/latest/userguide/patch-manager.html) does what we need
created_at: 2025-03-10T14:17:07Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,84 @@
+terraform {
+  required_version = "~>1.9.1"
+
+  required_providers {
+    aws = {
+      source  = "hashicorp/aws"
+      version = "~>5.0"
+    }
+  }
+}
+
+resource "aws_security_group" "bastion" {
+  name        = "${var.environment_name}-bastion"
+  description = "Security group for SSM bastion in ${var.environment_name}"
+
+  vpc_id = var.main_vpc_id
+
+  egress {
+    from_port   = 443
+    to_port     = 443
+    protocol    = "tcp"
+    cidr_blocks = [var.vpc_cidr_block]
+    description = "Allow bastion access to vpc endpoints to allow for SSM"
+  }
+}
+
+data "aws_iam_policy_document" "bastion_assume_role" {
+  statement {
+    effect  = "Allow"
+    actions = ["sts:AssumeRole"]
+
+    principals {
+      type        = "Service"
+      identifiers = ["ec2.amazonaws.com"]
+    }
+  }
+}
+
+resource "aws_iam_role" "ssm_bastion" {
+  name               = "${var.environment_name}-bastion-role"
+  assume_role_policy = data.aws_iam_policy_document.bastion_assume_role.json
+}
+
+resource "aws_iam_role_policy_attachment" "ssm_bastion_attachment" {
+  role       = aws_iam_role.ssm_bastion.name
+  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
+}
+
+resource "aws_iam_instance_profile" "ssm_bastion" {
+  name = "ec2_ssm_instance_profile"
+  role = aws_iam_role.ssm_bastion.name
+}
+
+resource "aws_instance" "bastion" {
+  count           = length(var.bastion_subnet_ids)
+  ami             = "ami-00710ab5544b60cf7"
+  instance_type   = "t2.micro"
+  subnet_id       = var.bastion_subnet_ids[count.index]
+  vpc_security_group_ids  = [aws_security_group.bastion.id]
+  iam_instance_profile    = aws_iam_instance_profile.ssm_bastion.name
+}
+
+data "aws_region" "current" {}
+
+resource "aws_vpc_endpoint" "ssm" {
path: terraform/modules/bastion/main.tf
user: login: Travis-Softwire
body: Minor, but I'd pop these in a different module (or add them to the networking module) as we might in the future want to use the SSM endpoints for things other than accessing the bastion host
created_at: 2025-03-10T14:24:16Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,84 @@
+terraform {
+  required_version = "~>1.9.1"
+
+  required_providers {
+    aws = {
+      source  = "hashicorp/aws"
+      version = "~>5.0"
+    }
+  }
+}
+
+resource "aws_security_group" "bastion" {
+  name        = "${var.environment_name}-bastion"
+  description = "Security group for SSM bastion in ${var.environment_name}"
+
+  vpc_id = var.main_vpc_id
+
+  egress {
+    from_port   = 443
+    to_port     = 443
+    protocol    = "tcp"
+    cidr_blocks = [var.vpc_cidr_block]
+    description = "Allow bastion access to vpc endpoints to allow for SSM"
+  }
+}
+
+data "aws_iam_policy_document" "bastion_assume_role" {
+  statement {
+    effect  = "Allow"
+    actions = ["sts:AssumeRole"]
+
+    principals {
+      type        = "Service"
+      identifiers = ["ec2.amazonaws.com"]
+    }
+  }
+}
+
+resource "aws_iam_role" "ssm_bastion" {
+  name               = "${var.environment_name}-bastion-role"
+  assume_role_policy = data.aws_iam_policy_document.bastion_assume_role.json
+}
+
+resource "aws_iam_role_policy_attachment" "ssm_bastion_attachment" {
+  role       = aws_iam_role.ssm_bastion.name
+  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
+}
+
+resource "aws_iam_instance_profile" "ssm_bastion" {
+  name = "ec2_ssm_instance_profile"
+  role = aws_iam_role.ssm_bastion.name
+}
+
+resource "aws_instance" "bastion" {
+  count           = length(var.bastion_subnet_ids)
+  ami             = "ami-00710ab5544b60cf7"
+  instance_type   = "t2.micro"
+  subnet_id       = var.bastion_subnet_ids[count.index]
+  vpc_security_group_ids  = [aws_security_group.bastion.id]
+  iam_instance_profile    = aws_iam_instance_profile.ssm_bastion.name
+}
+
+data "aws_region" "current" {}
+
+resource "aws_vpc_endpoint" "ssm" {
+  vpc_id            = var.main_vpc_id
+  service_name      = "com.amazonaws.${data.aws_region.current.name}.ssm"
path: terraform/modules/bastion/main.tf
user: login: Travis-Softwire
body: You can use a data block to access the service details directly to avoid relying on the format of the service name
created_at: 2025-03-10T14:26:05Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,84 @@
+terraform {
+  required_version = "~>1.9.1"
+
+  required_providers {
+    aws = {
+      source  = "hashicorp/aws"
+      version = "~>5.0"
+    }
+  }
+}
+
+resource "aws_security_group" "bastion" {
+  name        = "${var.environment_name}-bastion"
+  description = "Security group for SSM bastion in ${var.environment_name}"
+
+  vpc_id = var.main_vpc_id
+
+  egress {
+    from_port   = 443
+    to_port     = 443
+    protocol    = "tcp"
+    cidr_blocks = [var.vpc_cidr_block]
+    description = "Allow bastion access to vpc endpoints to allow for SSM"
+  }
+}
+
+data "aws_iam_policy_document" "bastion_assume_role" {
+  statement {
+    effect  = "Allow"
+    actions = ["sts:AssumeRole"]
+
+    principals {
+      type        = "Service"
+      identifiers = ["ec2.amazonaws.com"]
+    }
+  }
+}
+
+resource "aws_iam_role" "ssm_bastion" {
+  name               = "${var.environment_name}-bastion-role"
+  assume_role_policy = data.aws_iam_policy_document.bastion_assume_role.json
+}
+
+resource "aws_iam_role_policy_attachment" "ssm_bastion_attachment" {
+  role       = aws_iam_role.ssm_bastion.name
+  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
+}
+
+resource "aws_iam_instance_profile" "ssm_bastion" {
+  name = "ec2_ssm_instance_profile"
+  role = aws_iam_role.ssm_bastion.name
+}
+
+resource "aws_instance" "bastion" {
+  count           = length(var.bastion_subnet_ids)
+  ami             = "ami-00710ab5544b60cf7"
+  instance_type   = "t2.micro"
+  subnet_id       = var.bastion_subnet_ids[count.index]
+  vpc_security_group_ids  = [aws_security_group.bastion.id]
+  iam_instance_profile    = aws_iam_instance_profile.ssm_bastion.name
+}
+
+data "aws_region" "current" {}
+
+resource "aws_vpc_endpoint" "ssm" {
+  vpc_id            = var.main_vpc_id
+  service_name      = "com.amazonaws.${data.aws_region.current.name}.ssm"
+  vpc_endpoint_type = "Interface"
+  subnet_ids        = var.bastion_subnet_ids[*]
+}
path: terraform/modules/bastion/main.tf
user: login: Travis-Softwire
body: For all of these we should use the vpc endpoint security group that's created in the networking module (you'll need to add it as an output from that module if you don't move these into that module)
created_at: 2025-03-10T14:29:39Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,84 @@
+terraform {
+  required_version = "~>1.9.1"
+
+  required_providers {
+    aws = {
+      source  = "hashicorp/aws"
+      version = "~>5.0"
+    }
+  }
+}
+
+resource "aws_security_group" "bastion" {
+  name        = "${var.environment_name}-bastion"
+  description = "Security group for SSM bastion in ${var.environment_name}"
+
+  vpc_id = var.main_vpc_id
+
+  egress {
+    from_port   = 443
+    to_port     = 443
+    protocol    = "tcp"
+    cidr_blocks = [var.vpc_cidr_block]
+    description = "Allow bastion access to vpc endpoints to allow for SSM"
+  }
+}
+
+data "aws_iam_policy_document" "bastion_assume_role" {
+  statement {
+    effect  = "Allow"
+    actions = ["sts:AssumeRole"]
+
+    principals {
+      type        = "Service"
+      identifiers = ["ec2.amazonaws.com"]
+    }
+  }
+}
+
+resource "aws_iam_role" "ssm_bastion" {
+  name               = "${var.environment_name}-bastion-role"
+  assume_role_policy = data.aws_iam_policy_document.bastion_assume_role.json
+}
+
+resource "aws_iam_role_policy_attachment" "ssm_bastion_attachment" {
+  role       = aws_iam_role.ssm_bastion.name
+  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
+}
+
+resource "aws_iam_instance_profile" "ssm_bastion" {
+  name = "ec2_ssm_instance_profile"
+  role = aws_iam_role.ssm_bastion.name
+}
+
+resource "aws_instance" "bastion" {
+  count           = length(var.bastion_subnet_ids)
+  ami             = "ami-00710ab5544b60cf7"
+  instance_type   = "t2.micro"
+  subnet_id       = var.bastion_subnet_ids[count.index]
+  vpc_security_group_ids  = [aws_security_group.bastion.id]
+  iam_instance_profile    = aws_iam_instance_profile.ssm_bastion.name
+}
+
+data "aws_region" "current" {}
+
+resource "aws_vpc_endpoint" "ssm" {
+  vpc_id            = var.main_vpc_id
+  service_name      = "com.amazonaws.${data.aws_region.current.name}.ssm"
+  vpc_endpoint_type = "Interface"
+  subnet_ids        = var.bastion_subnet_ids[*]
+}
+
+resource "aws_vpc_endpoint" "ssmmessages" {
+  vpc_id            = var.main_vpc_id
+  service_name      = "com.amazonaws.${data.aws_region.current.name}.ssmmessages"
+  vpc_endpoint_type = "Interface"
+  subnet_ids        = var.bastion_subnet_ids[*]
path: terraform/modules/bastion/main.tf
user: login: Travis-Softwire
body: I think we also need private_dns_enabled set to true?
created_at: 2025-03-10T14:32:03Z
author_association: COLLABORATOR
diff_hunk: @@ -16,10 +16,11 @@ resource "aws_security_group" "bastion" {
   vpc_id = var.main_vpc_id
 
   egress {
-    from_port   = 0
-    to_port     = 0
-    protocol    = "-1"
-    cidr_blocks = ["0.0.0.0/0"]
+    from_port   = 443
+    to_port     = 443
+    protocol    = "tcp"
+    cidr_blocks = [var.vpc_cidr_block]
+    description = "Allow bastion access to vpc endpoints to allow for SSM"
path: terraform/modules/bastion/main.tf
user: login: AEPR
body: Created https://mhclgdigital.atlassian.net/browse/PRSD-984
created_at: 2025-03-10T15:17:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -7,18 +7,4 @@ terraform {
       version = "~>5.0"
     }
   }
-}
path: terraform/modules/networking/main.tf
user: login: Travis-Softwire
body: I'm fine w/ this, but worth checking in general when you move a resource what the knock-on impacts are, as terraform will destroy it and spin up a new one
created_at: 2025-03-10T16:34:52Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,86 @@
+terraform {
+  required_version = "~>1.9.1"
+
+  required_providers {
+    aws = {
+      source  = "hashicorp/aws"
+      version = "~>5.0"
+    }
+  }
+}
+
+resource "aws_security_group" "bastion" {
+  name        = "${var.environment_name}-bastion"
+  description = "Security group for SSM bastion in ${var.environment_name}"
+
+  vpc_id = var.main_vpc_id
+
+  egress {
+    from_port   = 443
+    to_port     = 443
+    protocol    = "tcp"
+    cidr_blocks = [var.vpc_cidr_block]
+    description = "Allow bastion access to vpc endpoints to allow for SSM"
+  }
+
+  egress {
+    from_port   = 5432
+    to_port     = 5432
+    protocol    = "tcp"
+    cidr_blocks = [var.vpc_cidr_block]
+    description = "Allow bastion access to postgres port within the VPC"
+  }
+}
+
+data "aws_iam_policy_document" "bastion_assume_role" {
+  statement {
+    effect  = "Allow"
+    actions = ["sts:AssumeRole"]
+
+    principals {
+      type        = "Service"
+      identifiers = ["ec2.amazonaws.com"]
+    }
+  }
+}
+
+resource "aws_iam_role" "ssm_bastion" {
path: terraform/modules/bastion/main.tf
user: login: Travis-Softwire
body: As you've said, would be tempted to break this up, although in this case would break it into an `iam.tf` file, a `security_group.tf` file, and an `instance .tf` file. 
created_at: 2025-03-10T16:40:45Z
author_association: COLLABORATOR
diff_hunk: @@ -7,18 +7,4 @@ terraform {
       version = "~>5.0"
     }
   }
-}
path: terraform/modules/networking/main.tf
user: login: AEPR
body: This didn't actually make any change to the plan - but if it did, this was an unused security group anyway!
created_at: 2025-03-11T09:42:02Z
author_association: CONTRIBUTOR

PR comments:
user: login: Travis-Softwire
created_at: 2025-03-10T13:58:02Z
body: Can you also make sure to update the environment templates (see parallel PR) with the final set of changes :)
author_association: COLLABORATOR
user: login: AEPR
created_at: 2025-03-10T16:09:49Z
body: OK, I'm going to make this as ready for review. I've addressed all your comments, and fixed the issues the pipeline was throwing up. It's probably worth taking a holistic look and making sure you're happy with file structure now - e.g. should bastion be split up into smaller files, maybe including a `data.tf` for the ami and the policy document.

Edit: Also, I've not actually added anything to the templates PR, but I will do as part of reviewing it!

Also, before we merge this in it probably makes sense for me to apply main and then let the pipeline have a truer history when it actually applies the update.
author_association: CONTRIBUTOR
user: login: Travis-Softwire
created_at: 2025-03-11T08:56:18Z
body: One other thought that occurred to me this morning was that we should update the ReadMe with instructions on how to connect to the DB via the bastion host :) 
author_association: COLLABORATOR