Empower Your Database Security: Creating a Postgres Read-Only User with Terraform

Introduction :-

In this guide, we will walk you through the process of creating a read-only user for a #PostgreSQL database using #Terraform. By following this step-by-step tutorial, you will be able to leverage Terraform’s #infrastructure-as-code capabilities to provision and manage your #PostgreSQL database resources, while ensuring the #security and access control of your data.

Prerequisites: To follow along with this tutorial, you will need the following:-

  • An AWS account with appropriate permissions to create resources like Ec2 Instances, RDS instances, and #Terraform access.

  • #Terraform installed on your local machine.

  • Basic knowledge of #PostgreSQL and #AWS RDS (Relational Database Service).

Step 1 :- Create a folder named postgres in your home directory and with in the postgres folder create two more folders with name postgres-db and postgres-role as shown below.

Step 2 :- Now create a RDS instance and Bastion-host instance within the postgre-db folder main.tf file using terraform.(Refer Below code)

resource "random_password" "db_master_password" {
  length  = 16
  special = false
}

resource "random_password" "db_read_only_password" {
  length  = 16
  special = false
}

resource "aws_secretsmanager_secret" "postgres_db" {
  name = var.secret_name
  recovery_window_in_days = 0
}

resource "aws_secretsmanager_secret_version" "postgres_db" {
  secret_id     = aws_secretsmanager_secret.postgres_db.id
  secret_string = <<EOT
{
  "master_username": "${var.db_username}",
  "master_password": "${random_password.db_master_password.result}",
  "read_only_username": "${var.db_username}_db",
  "read_only_password": "${random_password.db_read_only_password.result}",
}
EOT
}

resource "aws_vpc" "default" {
  cidr_block = var.vpc_cidr_block
  tags = {
    Name = "${var.vpc_name}"
  }
}

resource "aws_subnet" "public_subnet" {
  count                   = var.count_index
  vpc_id                  = aws_vpc.default.id
  cidr_block              = var.public_subnet_cidr_blocks[count.index]
  availability_zone       = var.azs[count.index]
  tags = {
    Name = "Public Subnet"
  }
}

resource "aws_subnet" "db_subnet" {
  count             = var.count_index
  vpc_id            = aws_vpc.default.id
  cidr_block        = var.db_subnets_cidr_blocks[count.index]
  availability_zone = var.azs[count.index]  # Replace with your desired availability zone
  tags = {
    Name = "Database Subnets"
  }
}

# Create Internet Gateway
resource "aws_internet_gateway" "igw" {
  vpc_id = aws_vpc.default.id

  tags = {
    Name = "postgres IGW"
  }
}

# Create Web layber route table
resource "aws_route_table" "web-rt" {
  vpc_id = aws_vpc.default.id
  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.igw.id
  }
  tags = {
    Name = "WebRT"
  }
}

resource "aws_security_group" "allow_all" {
  name   = "sg_for_rds"
  vpc_id = aws_vpc.default.id
  description = "RDS instance security group"
  ingress {
    description = "All Traffic"
    from_port   = 5432
    to_port     = 5432
    protocol    = "tcp"
    cidr_blocks = [var.public_subnet_cidr_blocks[0],
    var.public_subnet_cidr_blocks[1],
    var.public_subnet_cidr_blocks[2],
    var.db_subnets_cidr_blocks[0],
    var.db_subnets_cidr_blocks[1],
    var.db_subnets_cidr_blocks[2]]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
  tags = {
    Name        = "allow_all"
  }
}
resource "aws_db_instance" "postgres_rds" {
  allocated_storage    = var.allocated_storage
  identifier           = var.identifier
  storage_type         = var.storage_type
  engine               = var.engine
  engine_version       = var.engine_version
  instance_class       = var.instance_class
  db_name              = var.db_name
  username             = var.db_username
  password             = random_password.db_read_only_password.result
  parameter_group_name = var.parameter_group_name
  db_subnet_group_name = aws_db_subnet_group.db-subnet.name
  skip_final_snapshot = true
}

resource "aws_db_subnet_group" "db-subnet" {
  name = var.db_subnet_group_name
  subnet_ids  = [
   aws_subnet.db_subnet[0].id,
   aws_subnet.db_subnet[1].id,
   aws_subnet.db_subnet[2].id
  ]
}

data "http" "my_ip" {
  url = "http://checkip.amazonaws.com/"
}

resource "aws_iam_role" "bastion_role" {
  name               = "bastion-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Sid    = ""
        Principal = {
          Service = "ec2.amazonaws.com"
        }
      },
    ]
  })
}

resource "aws_security_group" "sg-bastion-host" {
  name        = "sg_for_bastion-host"
  description = "security group for bastion host"
  vpc_id      = aws_vpc.default.id

ingress {
    description = "ssh"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["${chomp(data.http.my_ip.body)}/32"]
  }

  egress {
    from_port        = 0
    to_port          = 0
    protocol         = "-1"
    cidr_blocks      = ["0.0.0.0/0"]
  }

  tags = {
    Name          = "sg_for_bastion"

  }
}
resource "aws_iam_instance_profile" "iam_profile" {
  name = "bastion-profile"
  role = aws_iam_role.bastion_role.name
}

resource "aws_instance" "bastion_host" {
  ami                    = var.ami_id
  instance_type          = var.instance_type
  vpc_security_group_ids = [aws_security_group.sg-bastion-host.id]
  key_name               = aws_key_pair.bastion-key.key_name
  iam_instance_profile    = aws_iam_instance_profile.iam_profile.id
  subnet_id               = aws_subnet.public_subnet[0].id 
  tags = {
    Name = "bastion-host"
  }
}

resource "tls_private_key" "this" {
  algorithm = "RSA"
}

resource "aws_key_pair" "bastion-key" {
  key_name   = var.key_name
  public_key = tls_private_key.this.public_key_openssh

  provisioner "local-exec" { # Create a "myKey.pem" to your computer!!
    command = "echo '${tls_private_key.this.private_key_pem}' > ~/${var.key_name}.pem"
  }
}
variable "identifier" {
  default = "postgres-rds-db"
}
variable "allocated_storage" {
  default = 20
}
variable "storage_type" {
 default = "gp2" 
}
variable "engine" {
  default = "postgres"
}
variable "engine_version" {
  default = 14.7
}
variable "instance_class" {
  default = "db.t3.micro"
}
variable "db_name" {
  default = "postgres_db"
}
variable "parameter_group_name" {
  default = "default.postgres14"
}

variable "public_subnet_cidr_blocks" {
  type    = list(string)
  default = ["10.50.0.0/24", "10.50.1.0/24", "10.50.2.0/24"]
}

variable "db_subnets_cidr_blocks" {
  type    = list(string)
  default = ["10.50.16.0/24", "10.50.32.0/24", "10.50.64.0/24"]
}

variable "vpc_cidr_block" {
  description = "CIDR block for VPC"
  default     = "10.50.0.0/16"
}

variable "vpc_name" {
  description = "Name of the VPC"
  default     = "open-source_projects"
}

variable "azs" {
  type    = list(string)
  default = ["us-east-1a", "us-east-1b", "us-east-1c"]
}

variable "count_index" {
  type = number
  default = 3
}

variable "db_subnet_group_name" {
  type = string
  default = "db-subnet-group"
}

variable "secret_name" {
  type = string
  default = "Open-source"
}

variable "key_name" {
  type = string
  default = "postgres-key"
}

variable "ami_id" {
  type = string
  default = "ami-06ca3ca175f37dd66"
}

variable "instance_type" {
  type = string
  default = "t2.micro"
}

variable "db_username" {
  type = string
  default = "read_only_user"
}

variable "subnet_count" {
  type = number
  default = 1
}

Step 3:- Deploy the above code to create a postgre-db.

Step 4:- Next create a main.tf and variable.tf , provider.tf files in the postgres-role directory to create a db-read-only-user using terraform

Step 5:- Refer the below code to Create the postgres user and role .

# main.tf
provider "postgresql" {
  host            = "127.0.0.1"
  port            = "5434"
  database        = "postgres"
  username        = "${var.db_username}"
  password        = "${var.db_master_password}"
  sslmode         = "require"
  connect_timeout = 15
  superuser = false
}

resource "postgresql_role" "readonly" {                                                                                                                                                      
  name = "readonly"                                                                                                                                                               
}                                                                                                                                                                                            

resource postgresql_grant "readonly_public" {                                                                                                                                                
  database    = "postgres"                                                                                                                                                                    
  role        = postgresql_role.readonly.name                                                                                                                                                
  schema      = "public"                                                                                                                                                                     
  object_type = "table"                                                                                                                                                                      
  privileges  = ["SELECT"]                                                                                                                                                              
}   

resource "postgresql_role" "readonly_user" {                                                                                                                                                 
  name     = "${var.db_username}-db-role"                                                                                                                                                                 
  password = "${var.db_read_only_password}"                                                                                                                                                             
  login    = true                                                                                                                                                                            
  roles = [postgresql_role.readonly.name]                                                                                                                                               
}
# variables.tf
variable "db_username" {
  type = string
  default = "read_only_user"
}

variable "secret_name" {
  type = string
  default = "Open-source"
}

# Note:- Login to aws management console, then navigate to secrets manager service and find the Open-source secret. Retrieve the secret values in Open-source secret to copy the master_password & db_read_only_password.
variable "db_master_password" {
  type = string
  description = "The master pasword of the postgres-db"
  default = "GJpdsI2IhnAaMWaZ"
}

variable "db_read_only_password" {
  type = string
  description = "read only password of the postgres-db"
  default = "myn1JubEHNTeZ9ju"
}
# provider.tf
terraform {
  required_providers {
    postgresql = {
      source = "cyrilgdn/postgresql"
      version = "1.19.0"
    }
    aws = {
      source = "hashicorp/aws"
      version = "5.8.0"
    }
  }
}

Step 6:- First we need to run the tunneling command to #deploy the above postgres read only user creation code. Tunneling command will looks like below.

Step 7 :- Replace the below command values with your postgres-db and instance values. After replacing the values Open a terminal window and run the command.

ssh -i <.pem> -f -N -L 5434:<db-endponit>:5432 ec2-user@<bastion-host-public ip> -v

Step 8:- Keep the tunneling running on and open another terminal to deploy the postgres read only user code.

Step 9:- Once the code deploy’s successfully. Connect to your #pgadmin4 and check the user and role is being created or not. To connect to your pgadmin refer the below figure

Step 9 :- As u can see the read-only-user and role is being created in postgres db (or) else u can verify by downloading the pgadmin4 and connecting to the db

Step 10 :- once u have connected with Desktop #PGadmin4. First register u r credentials in pgadmin4. as shown like below

Step 11 :- after filling the db details, click on save to login to the #PgAdmin4.

Step 12 :- Once u registered the postgresdb. You are able to see the read-only-user in the postgresdb, as shown in below .

Conclusion :-

In this guide, we have demonstrated how to create a read-only user for a #PostgreSQL database using #Terraform. By following the step-by-step tutorial, users can leverage Terraform’s #infrastructure-as-code capabilities to provision and manage PostgreSQL database resources while ensuring data #security and access control.

The process involves creating an RDS instance, a bastion host, and the necessary security groups. Then, we use Terraform to create a read-only user, a role, and grant the necessary privileges to the user on the public schema of the PostgreSQL database.

By using #infrastructure as code, we can easily reproduce this setup across multiple environments with consistent configuration and #security. #Terraform allows us to version-control our infrastructure code, making it easier to track changes and #collaborate with a team.

0
Subscribe to my newsletter

Read articles from Mahira Technology Private Limited directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Mahira Technology Private Limited
Mahira Technology Private Limited

A leading tech consulting firm specializing in innovative solutions. Experts in cloud, DevOps, automation, data analytics & more. Trusted technology partner.