---
name: terraform-aws-security
description: Project-specific security patterns for Agenda Systems modules. Use when adding security controls to any module or resource — Security Groups, RDS, ElastiCache, ECS IAM, Secrets Manager, KMS, S3.
---

# Security Patterns for Agenda Systems Modules

Apply these patterns when adding or reviewing security controls in any module.

---

## 1. Security Groups

One SG per tier: ALB, app, db, cache. Never `0.0.0.0/0` on ingress — scope to SG IDs or known CIDRs.

```hcl
/*
 * ALB Security Group
 * Accepts HTTPS from the internet; all other tiers reference this SG, not the CIDR.
 */
resource "aws_security_group" "alb" {
  count       = var.enabled ? 1 : 0
  name        = "${var.project}-${var.environment}-alb-sg"
  description = "ALB: inbound HTTPS from internet"
  vpc_id      = var.vpc_id

  ingress {
    description = "HTTPS from internet"
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"] # ALB is the only tier that accepts public traffic
  }

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

  tags = var.tags
}

/*
 * App (ECS) Security Group
 * Only accepts traffic from the ALB SG — never directly from the internet.
 */
resource "aws_security_group" "app" {
  count       = var.enabled ? 1 : 0
  name        = "${var.project}-${var.environment}-app-sg"
  description = "App: inbound from ALB SG only"
  vpc_id      = var.vpc_id

  ingress {
    description     = "From ALB"
    from_port       = var.app_port
    to_port         = var.app_port
    protocol        = "tcp"
    security_groups = [aws_security_group.alb[0].id]
  }

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

  tags = var.tags
}

/*
 * DB Security Group
 * Only accepts traffic from the app SG.
 */
resource "aws_security_group" "db" {
  count       = var.enabled ? 1 : 0
  name        = "${var.project}-${var.environment}-db-sg"
  description = "DB: inbound from app SG only"
  vpc_id      = var.vpc_id

  ingress {
    description     = "From app tier"
    from_port       = 5432
    to_port         = 5432
    protocol        = "tcp"
    security_groups = [aws_security_group.app[0].id]
  }

  tags = var.tags
}

/*
 * Cache Security Group
 * Only accepts traffic from the app SG.
 */
resource "aws_security_group" "cache" {
  count       = var.enabled ? 1 : 0
  name        = "${var.project}-${var.environment}-cache-sg"
  description = "Cache: inbound from app SG only"
  vpc_id      = var.vpc_id

  ingress {
    description     = "From app tier"
    from_port       = 6379
    to_port         = 6379
    protocol        = "tcp"
    security_groups = [aws_security_group.app[0].id]
  }

  tags = var.tags
}
```

---

## 2. RDS

```hcl
/*
 * RDS Instance
 * Not publicly accessible; encrypted at rest with a KMS key; deployed in private subnets.
 * Deletion protection and final snapshot are gated on environment to allow
 * sandbox teardown without manual intervention.
 */
resource "aws_db_instance" "this" {
  count = var.enabled ? 1 : 0

  identifier     = "${var.project}-${var.environment}"
  engine         = "postgres"
  engine_version = var.db_engine_version
  instance_class = var.db_instance_class

  allocated_storage = var.db_allocated_storage
  storage_type      = "gp3"

  # Security
  publicly_accessible    = false
  storage_encrypted      = true
  kms_key_id             = var.kms_key_arn
  deletion_protection    = var.environment == "live"
  skip_final_snapshot    = var.environment != "live"
  final_snapshot_identifier = var.environment == "live" ? "${var.project}-${var.environment}-final" : null

  # Credentials from Secrets Manager — never hardcoded
  username = jsondecode(data.aws_secretsmanager_secret_version.db_creds.secret_string)["username"]
  password = jsondecode(data.aws_secretsmanager_secret_version.db_creds.secret_string)["password"]

  # Network isolation
  db_subnet_group_name   = aws_db_subnet_group.this[0].name
  vpc_security_group_ids = [aws_security_group.db[0].id]

  tags = var.tags
}

resource "aws_db_subnet_group" "this" {
  count      = var.enabled ? 1 : 0
  name       = "${var.project}-${var.environment}-db-subnet-group"
  subnet_ids = var.private_subnet_ids
  tags       = var.tags
}
```

---

## 3. ElastiCache

```hcl
/*
 * ElastiCache Replication Group
 * Encryption at rest and in transit; auth token from Secrets Manager;
 * deployed in private subnets only.
 */
resource "aws_elasticache_replication_group" "this" {
  count = var.enabled ? 1 : 0

  replication_group_id = "${var.project}-${var.environment}"
  description          = "Redis cache for ${var.project} ${var.environment}"

  engine               = "redis"
  engine_version       = var.cache_engine_version
  node_type            = var.cache_node_type
  num_cache_clusters   = var.environment == "live" ? 2 : 1

  # Security
  at_rest_encryption_enabled  = true
  transit_encryption_enabled  = true
  auth_token                  = data.aws_secretsmanager_secret_version.cache_auth.secret_string
  kms_key_id                  = var.kms_key_arn

  # Network isolation
  subnet_group_name  = aws_elasticache_subnet_group.this[0].name
  security_group_ids = [aws_security_group.cache[0].id]

  tags = var.tags
}

resource "aws_elasticache_subnet_group" "this" {
  count      = var.enabled ? 1 : 0
  name       = "${var.project}-${var.environment}-cache-subnet-group"
  subnet_ids = var.private_subnet_ids
  tags       = var.tags
}
```

---

## 4. ECS Task IAM

Two separate roles: execution role (ECR pull + Secrets Manager) and task role (app permissions only).

```hcl
/*
 * ECS Execution Role
 * Used by the ECS agent to pull images and fetch secrets before the container starts.
 * Scoped to specific secret ARNs — not a wildcard.
 */
resource "aws_iam_role" "ecs_execution" {
  count = var.enabled ? 1 : 0
  name  = "${var.project}-${var.environment}-ecs-execution"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "ecs-tasks.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })

  tags = var.tags
}

resource "aws_iam_role_policy" "ecs_execution_secrets" {
  count = var.enabled ? 1 : 0
  name  = "secrets-access"
  role  = aws_iam_role.ecs_execution[0].id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = ["secretsmanager:GetSecretValue"]
      Resource = var.secret_arns # explicit list, never "*"
    }]
  })
}

resource "aws_iam_role_policy_attachment" "ecs_execution_managed" {
  count      = var.enabled ? 1 : 0
  role       = aws_iam_role.ecs_execution[0].name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}

/*
 * ECS Task Role
 * Assumed by the running container. Grant only what the application needs.
 */
resource "aws_iam_role" "ecs_task" {
  count = var.enabled ? 1 : 0
  name  = "${var.project}-${var.environment}-ecs-task"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "ecs-tasks.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })

  tags = var.tags
}
```

Inject secrets via the `secrets` block in the container definition — never pass plaintext values through environment variables:

```hcl
/*
 * Container Definition — secrets block pulls values from Secrets Manager at runtime.
 * The ECS agent decrypts and injects them as env vars; they never appear in the task definition JSON.
 */
container_definitions = jsonencode([{
  name  = var.container_name
  image = var.container_image

  secrets = [
    {
      name      = "DB_PASSWORD"
      valueFrom = data.aws_secretsmanager_secret.db_password.arn
    },
    {
      name      = "CACHE_AUTH_TOKEN"
      valueFrom = data.aws_secretsmanager_secret.cache_auth.arn
    }
  ]
}])
```

---

## 5. Secrets Manager

```hcl
/*
 * Secret placeholder — actual value set outside Terraform (console or CI/CD).
 * Terraform manages the secret resource lifecycle, not the secret value.
 */
resource "aws_secretsmanager_secret" "db_creds" {
  count                   = var.enabled ? 1 : 0
  name                    = "${var.project}/${var.environment}/db/creds"
  kms_key_id              = var.kms_key_arn
  recovery_window_in_days = var.environment == "live" ? 30 : 0

  tags = var.tags
}
```

Variable and output discipline:

```hcl
variable "db_password" {
  type      = string
  sensitive = true
}

output "db_endpoint" {
  value     = aws_db_instance.this[0].endpoint
  sensitive = true
}
```

---

## 6. KMS

```hcl
/*
 * KMS Key — one per environment.
 * Key rotation enabled to limit exposure window;
 * 30-day deletion window prevents accidental permanent loss.
 */
resource "aws_kms_key" "this" {
  count                   = var.enabled ? 1 : 0
  description             = "${var.project} ${var.environment} encryption key"
  enable_key_rotation     = true
  deletion_window_in_days = 30

  tags = var.tags
}

resource "aws_kms_alias" "this" {
  count         = var.enabled ? 1 : 0
  name          = "alias/${var.project}-${var.environment}"
  target_key_id = aws_kms_key.this[0].key_id
}
```

---

## 7. S3

```hcl
/*
 * S3 Bucket — encryption, public access block, and versioning are always on.
 * Mirrors the state bucket pattern already used in this project.
 */
resource "aws_s3_bucket" "this" {
  count  = var.enabled ? 1 : 0
  bucket = "${var.project}-${var.environment}-${var.bucket_suffix}"
  tags   = var.tags
}

resource "aws_s3_bucket_server_side_encryption_configuration" "this" {
  count  = var.enabled ? 1 : 0
  bucket = aws_s3_bucket.this[0].id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm     = "aws:kms"
      kms_master_key_id = var.kms_key_arn
    }
    bucket_key_enabled = true
  }
}

resource "aws_s3_bucket_public_access_block" "this" {
  count                   = var.enabled ? 1 : 0
  bucket                  = aws_s3_bucket.this[0].id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_s3_bucket_versioning" "this" {
  count  = var.enabled ? 1 : 0
  bucket = aws_s3_bucket.this[0].id
  versioning_configuration {
    status = "Enabled"
  }
}
```

---

## Conventions

- **Toggle pattern:** `count = var.enabled ? 1 : 0` on every resource; reference as `resource.this[0]`
- **Sandbox vs Live:**
  - `deletion_protection = var.environment == "live"`
  - `skip_final_snapshot = var.environment != "live"`
  - `recovery_window_in_days = var.environment == "live" ? 30 : 0`
- **No `0.0.0.0/0` ingress** except on the ALB; all other tiers reference SGs by ID
- **Sensitive flag:** all variables/outputs containing credentials or tokens use `sensitive = true`
- **Secrets by reference:** secrets are fetched from Secrets Manager via `data` sources or ECS `secrets` block — never hardcoded or passed as plaintext env vars
- **`.gitignore`:** `*.tfvars` must be ignored; commit only `example.tfvars` with placeholder values
- **Format:** run `terraform fmt -recursive` after every change
