diff --git a/.terraform.lock.hcl b/.terraform.lock.hcl index 560e101..dac85a6 100644 --- a/.terraform.lock.hcl +++ b/.terraform.lock.hcl @@ -23,3 +23,22 @@ provider "registry.terraform.io/hashicorp/aws" { "zh:fb8c5ff7296d01bf60d983c64f45969ec664a40bdd768d90a35a6afe7df1aeb7", ] } + +provider "registry.terraform.io/hashicorp/random" { + version = "3.7.2" + hashes = [ + "h1:KG4NuIBl1mRWU0KD/BGfCi1YN/j3F7H4YgeeM7iSdNs=", + "zh:14829603a32e4bc4d05062f059e545a91e27ff033756b48afbae6b3c835f508f", + "zh:1527fb07d9fea400d70e9e6eb4a2b918d5060d604749b6f1c361518e7da546dc", + "zh:1e86bcd7ebec85ba336b423ba1db046aeaa3c0e5f921039b3f1a6fc2f978feab", + "zh:24536dec8bde66753f4b4030b8f3ef43c196d69cccbea1c382d01b222478c7a3", + "zh:29f1786486759fad9b0ce4fdfbbfece9343ad47cd50119045075e05afe49d212", + "zh:4d701e978c2dd8604ba1ce962b047607701e65c078cb22e97171513e9e57491f", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7b8434212eef0f8c83f5a90c6d76feaf850f6502b61b53c329e85b3b281cba34", + "zh:ac8a23c212258b7976e1621275e3af7099e7e4a3d4478cf8d5d2a27f3bc3e967", + "zh:b516ca74431f3df4c6cf90ddcdb4042c626e026317a33c53f0b445a3d93b720d", + "zh:dc76e4326aec2490c1600d6871a95e78f9050f9ce427c71707ea412a2f2f1a62", + "zh:eac7b63e86c749c7d48f527671c7aee5b4e26c10be6ad7232d6860167f99dbb0", + ] +} diff --git a/ec2.tf b/ec2.tf index 1207d2a..080d4e6 100644 --- a/ec2.tf +++ b/ec2.tf @@ -23,6 +23,7 @@ resource "aws_instance" "app" { instance_type = var.instance_type subnet_id = local.chosen_subnet_id vpc_security_group_ids = [aws_security_group.app_sg.id] + iam_instance_profile = aws_iam_instance_profile.app_ec2_profile.name # assign ssh key key_name = var.key_name != "" ? var.key_name : null @@ -40,6 +41,22 @@ resource "aws_instance" "app" { # ensure a public IP if your subnet doesn't auto-assign associate_public_ip_address = var.subnet_tier == "public" ? true : false + user_data = templatefile("${path.module}/scripts/user_data.sh", { + app_user = var.app_user + app_group = var.app_group + app_dir = var.app_dir + service_name = var.service_name + + db_host = aws_db_instance.db.address + db_port = aws_db_instance.db.port + db_name = var.db_name + db_username = var.db_username + db_password = var.db_password + + aws_region = var.region + s3_bucket = aws_s3_bucket.images.bucket + }) + tags = { Name = "${var.name_prefix}-ec2" Role = "webapp" diff --git a/iam_role.tf b/iam_role.tf new file mode 100644 index 0000000..661f53e --- /dev/null +++ b/iam_role.tf @@ -0,0 +1,84 @@ +resource "aws_iam_role" "app_ec2_role" { + name = "${var.name_prefix}-ec2-role" + + # Trust Policy + assume_role_policy = jsonencode({ + Version = "2012-10-17", + Statement = [{ + Effect = "Allow", + Principal = { Service = "ec2.amazonaws.com" }, # Who can assume this role. The 'Service' here represents the EC2 service itself. + Action = "sts:AssumeRole" + }] + }) +} +# Instance Profile is a container for the IAM Role. +# EC2 cannot directly attach an IAM Role — it must attach an Instance Profile instead. +# The profile allows EC2 to assume the role and get temporary credentials automatically. +resource "aws_iam_instance_profile" "app_ec2_profile" { + name = "${var.name_prefix}-ec2-profile" + role = aws_iam_role.app_ec2_role.name +} + +# ---------------------- +# Setup Least Privilage +# ---------------------- +locals { + # Retrieve the name and ARN of the S3 bucket created in Terraform. + bucket_name = aws_s3_bucket.images.bucket + bucket_arn = "arn:aws:s3:::${local.bucket_name}" + + # Define the object-level ARN (optionally scoped to a prefix). + objects_arn = "arn:aws:s3:::${local.bucket_name}/${var.s3_prefix}*" +} + +# Generate a least-privilege S3 access policy for the EC2 IAM Role. +data "aws_iam_policy_document" "s3_app_least" { + + # 1) Allow listing objects within the bucket. + statement { + sid = "ListBucket" + effect = "Allow" + actions = ["s3:ListBucket"] + resources = [local.bucket_arn] + + # If a prefix is specified, restrict the listing to that prefix only. + condition { + test = "StringLike" + variable = "s3:prefix" + values = [var.s3_prefix == "" ? "*" : "${var.s3_prefix}*"] + } + } + + # 2) Allow reading, uploading, and deleting objects. + statement { + sid = "ObjectRW" + effect = "Allow" + actions = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"] + resources = [local.objects_arn] + } + + # 3) Optional: Allow multipart upload operations for large files. + statement { + sid = "Multipart" + effect = "Allow" + actions = [ + "s3:AbortMultipartUpload", + "s3:ListMultipartUploadParts", + "s3:ListBucketMultipartUploads" + ] + resources = [local.bucket_arn, local.objects_arn] + } +} + +# Create the custom least-privilege S3 policy. +resource "aws_iam_policy" "s3_app_policy" { + name = "${var.name_prefix}-s3-app-policy" + policy = data.aws_iam_policy_document.s3_app_least.json +} + +# This tells the IAM role what it is allowed to do. +# In this case, Attach the custom least-privilege S3 policy to the EC2 role. +resource "aws_iam_role_policy_attachment" "s3_access" { + role = aws_iam_role.app_ec2_role.name + policy_arn = aws_iam_policy.s3_app_policy.arn +} diff --git a/outputs.tf b/outputs.tf index 9f0ee5f..19f4d6a 100644 --- a/outputs.tf +++ b/outputs.tf @@ -40,3 +40,12 @@ output "application_sg_id" { output "instance_id" { value = aws_instance.app.public_ip } + +output "rds_endpoint" { + description = "RDS endpoint hostname" + value = aws_db_instance.db.address +} + +output "rds_port" { + value = aws_db_instance.db.port +} diff --git a/providers.tf b/providers.tf index 060f4aa..d62e027 100644 --- a/providers.tf +++ b/providers.tf @@ -1,5 +1,4 @@ # Configure the AWS Provider provider "aws" { - region = var.region - profile = var.profile + region = var.region } diff --git a/rds.tf b/rds.tf new file mode 100644 index 0000000..8123b3d --- /dev/null +++ b/rds.tf @@ -0,0 +1,52 @@ +resource "aws_db_parameter_group" "postgres" { + name = "${var.name_prefix}-pg-param" + family = var.db_engine_family + description = "Custom parameter group for PostgreSQL ${var.db_engine_version}" + + parameter { + name = "log_min_duration_statement" + value = "500" + } +} + +resource "aws_db_subnet_group" "db_private" { + name = "${var.name_prefix}-db-subnet-group" + subnet_ids = [for s in aws_subnet.private : s.id] # multiple subnet (at least 2) + tags = { + Name = "${var.name_prefix}-db-subnet-group" + } +} + +# RDS Instance +resource "aws_db_instance" "db" { + identifier = "${var.name_prefix}-rds" + engine = "postgres" + engine_version = var.db_engine_version # e.g., "16.3" + instance_class = var.db_instance_class # e.g., "db.t3.micro" + allocated_storage = var.db_allocated_storage # e.g., 20 + storage_type = "gp3" + + db_name = var.db_name + username = var.db_username + password = var.db_password + + port = var.db_port # 5432 + multi_az = false + publicly_accessible = false + + vpc_security_group_ids = [aws_security_group.db_sg.id] + db_subnet_group_name = aws_db_subnet_group.db_private.name + parameter_group_name = aws_db_parameter_group.postgres.name + + skip_final_snapshot = true + + tags = { + Name = "${var.name_prefix}-rds-postgres" + } + + depends_on = [ + aws_db_subnet_group.db_private, + aws_db_parameter_group.postgres, + aws_security_group.db_sg + ] +} diff --git a/s3.tf b/s3.tf new file mode 100644 index 0000000..0fbd0de --- /dev/null +++ b/s3.tf @@ -0,0 +1,43 @@ +resource "aws_s3_bucket" "images" { + bucket = "${var.name_prefix}-${random_uuid.s3_suffix.result}" + force_destroy = true + + tags = { + Name = "${var.name_prefix}-s3" + } +} + +resource "random_uuid" "s3_suffix" {} + +resource "aws_s3_bucket_public_access_block" "images" { + bucket = aws_s3_bucket.images.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "images" { + bucket = aws_s3_bucket.images.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} + +resource "aws_s3_bucket_lifecycle_configuration" "images" { + bucket = aws_s3_bucket.images.id + + rule { + id = "transition-standard-to-ia" + status = "Enabled" + + transition { + days = 30 + storage_class = "STANDARD_IA" + } + } +} diff --git a/scripts/user_data.sh b/scripts/user_data.sh new file mode 100644 index 0000000..2012d99 --- /dev/null +++ b/scripts/user_data.sh @@ -0,0 +1,34 @@ +#!/bin/bash +set -euo pipefail + +ENV_FILE="${app_dir}/.env" +LOG_FILE="/var/log/user_data_setup.log" +exec > >(tee -a "$LOG_FILE") 2>&1 + +### ====== Configure log color ====== +log() { echo -e "\033[1;32m[OK]\033[0m $*"; } +info() { echo -e "\033[1;34m[INFO]\033[0m $*"; } +err() { echo -e "\033[1;31m[ERR]\033[0m $*" >&2; } + +info "=== Setting up environment for web app in ${app_dir} ===" + +mkdir -p "${app_dir}" # make sure the directory exist + +cat >> "$ENV_FILE" <