feat: Terraform Foundation - AWS Landing Zone

Enterprise-grade multi-tenant AWS cloud foundation.

Modules:
- GitHub OIDC for keyless CI/CD authentication
- IAM account settings and security baseline
- AWS Config Rules for compliance
- ABAC (Attribute-Based Access Control)
- SCPs (Service Control Policies)

Features:
- Multi-account architecture
- Cost optimization patterns
- Security best practices
- Comprehensive documentation

Tech: Terraform, AWS Organizations, IAM Identity Center
This commit is contained in:
2026-02-01 20:06:28 +00:00
commit 6136cde9bb
145 changed files with 30832 additions and 0 deletions

36
.checkov.yml Normal file
View File

@@ -0,0 +1,36 @@
# Checkov Configuration
# Security and compliance scanning for Terraform
# https://www.checkov.io/
# Framework to scan
framework:
- terraform
# Directories to scan
directory:
- terraform/modules
- live
# Skip specific checks with justification
skip-check:
# These are intentionally broad for template/example modules
- CKV_AWS_111 # IAM policy allows * in resource - needed for flexible templates
- CKV_AWS_355 # IAM policy document allows * - same reason
- CKV2_AWS_62 # S3 event notifications - not always needed
# Soft fail for CI (don't block, just warn)
soft-fail: false
# Output format
output:
- cli
- junitxml
# Compact output
compact: true
# Download external modules
download-external-modules: false
# Evaluate variables
evaluate-variables: true

219
.github/workflows/terraform.yml vendored Normal file
View File

@@ -0,0 +1,219 @@
name: Terraform
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
AWS_REGION: us-east-1
TF_VERSION: 1.7.0
TG_VERSION: 0.55.0
permissions:
id-token: write
contents: read
pull-requests: write
security-events: write # For SARIF upload
jobs:
# Security scanning
security:
name: Security Scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run tfsec
uses: aquasecurity/tfsec-action@v1.0.3
with:
soft_fail: true
sarif_file: tfsec.sarif
- name: Run Checkov
uses: bridgecrewio/checkov-action@v12
with:
directory: terraform/modules
framework: terraform
output_format: sarif
output_file_path: checkov.sarif
soft_fail: true
config_file: .checkov.yml
- name: Run Trivy
uses: aquasecurity/trivy-action@master
with:
scan-type: 'config'
scan-ref: 'terraform/modules'
format: 'sarif'
output: 'trivy.sarif'
exit-code: '0'
severity: 'CRITICAL,HIGH'
- name: Upload SARIF results
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: '.'
category: security-scan
# Linting
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Terraform Format Check
run: terraform fmt -check -recursive terraform/modules
- name: Setup TFLint
uses: terraform-linters/setup-tflint@v4
with:
tflint_version: v0.50.0
- name: Init TFLint
run: tflint --init --config=.tflint.hcl
- name: Run TFLint
run: |
for module in terraform/modules/*/; do
echo "Linting $module..."
tflint --config=.tflint.hcl --chdir="$module" || true
done
# Validate on PRs
validate:
name: Validate
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
needs: [security, lint]
steps:
- uses: actions/checkout@v4
- name: Configure AWS Credentials (Read-Only)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.AWS_ROLE_VALIDATE }}
aws-region: ${{ env.AWS_REGION }}
role-session-name: tf-validate-${{ github.run_id }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Setup Terragrunt
run: |
wget -q https://github.com/gruntwork-io/terragrunt/releases/download/v${{ env.TG_VERSION }}/terragrunt_linux_amd64
chmod +x terragrunt_linux_amd64
sudo mv terragrunt_linux_amd64 /usr/local/bin/terragrunt
- name: Validate Modules
run: |
for module in terraform/modules/*/; do
echo "Validating $module..."
cd "$module"
terraform init -backend=false
terraform validate
cd -
done
- name: Terragrunt Plan
run: |
cd live
terragrunt run-all plan --terragrunt-non-interactive -out=tfplan
continue-on-error: true
- name: Post Plan to PR
uses: actions/github-script@v7
if: github.event_name == 'pull_request'
with:
script: |
const output = `#### Terraform Validation ✅
*Pushed by: @${{ github.actor }}, Action: \`${{ github.event_name }}\`*`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: output
})
# Deploy on merge to main
deploy:
name: Deploy
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
environment: production
needs: [security, lint]
steps:
- uses: actions/checkout@v4
- name: Configure AWS Credentials (Deploy)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.AWS_ROLE_DEPLOY }}
aws-region: ${{ env.AWS_REGION }}
role-session-name: tf-deploy-${{ github.run_id }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Setup Terragrunt
run: |
wget -q https://github.com/gruntwork-io/terragrunt/releases/download/v${{ env.TG_VERSION }}/terragrunt_linux_amd64
chmod +x terragrunt_linux_amd64
sudo mv terragrunt_linux_amd64 /usr/local/bin/terragrunt
- name: Terragrunt Apply
run: |
cd live
terragrunt run-all apply --terragrunt-non-interactive -auto-approve
# Module tests
test-modules:
name: Test Modules
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
strategy:
matrix:
module:
- github-oidc
- vpc-lite
- iam-role
fail-fast: false
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Terraform Init
run: terraform init -backend=false
working-directory: terraform/modules/${{ matrix.module }}
- name: Terraform Validate
run: terraform validate
working-directory: terraform/modules/${{ matrix.module }}
- name: Check Documentation
run: |
if [ ! -f README.md ]; then
echo "Missing README.md in ${{ matrix.module }}"
exit 1
fi
working-directory: terraform/modules/${{ matrix.module }}

44
.gitignore vendored Normal file
View File

@@ -0,0 +1,44 @@
# Terraform
**/.terraform/
**/.terraform.lock.hcl
*.tfstate
*.tfstate.*
*.tfplan
tfplan
crash.log
crash.*.log
override.tf
override.tf.json
*_override.tf
*_override.tf.json
# Secrets & credentials
*.tfvars
!*.tfvars.example
backend.hcl
.env
.env.*
# Local artifacts
lambda.zip
*.zip
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Infracost
.infracost/
# OpenCode
.opencode/
# MCP
.mcp.json

90
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,90 @@
# Pre-commit hooks for Terraform
# Install: pip install pre-commit && pre-commit install
#
# Tools:
# - terraform fmt/validate
# - tflint (with AWS plugin)
# - tfsec (security scanner)
# - checkov (policy-as-code)
# - terraform-docs (auto-generate docs)
# - trivy (vulnerability scanner)
repos:
# Terraform formatting and validation
- repo: https://github.com/antonbabenko/pre-commit-terraform
rev: v1.86.0
hooks:
- id: terraform_fmt
- id: terraform_validate
args:
- --hook-config=--retry-once-with-cleanup=true
- id: terraform_tflint
args:
- --args=--config=__GIT_WORKING_DIR__/.tflint.hcl
- id: terraform_docs
args:
- --args=--config=.terraform-docs.yml
- id: terraform_tfsec
args:
- --args=--soft-fail
- --args=--exclude-downloaded-modules
- id: terraform_checkov
args:
- --args=--config-file=__GIT_WORKING_DIR__/.checkov.yml
- --args=--framework=terraform
- --args=--download-external-modules=false
# Trivy security scanner
- repo: https://github.com/aquasecurity/trivy
rev: v0.48.0
hooks:
- id: trivy
args:
- --config=.trivy.yaml
- --exit-code=0 # Warn only
files: \.tf$
# General file checks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
args: ['--unsafe']
- id: check-json
- id: check-merge-conflict
- id: detect-private-key
- id: no-commit-to-branch
args: ['--branch', 'main']
stages: [commit]
# Security scanning for secrets
- repo: https://github.com/Yelp/detect-secrets
rev: v1.4.0
hooks:
- id: detect-secrets
args: ['--baseline', '.secrets.baseline']
exclude: '\.terraform/.*|\.terraform\.lock\.hcl'
# Markdown linting
- repo: https://github.com/igorshubovych/markdownlint-cli
rev: v0.39.0
hooks:
- id: markdownlint
args: ['--fix', '--disable', 'MD013', 'MD033', 'MD041']
# YAML linting
- repo: https://github.com/adrienverge/yamllint
rev: v1.33.0
hooks:
- id: yamllint
args: ['-c', '.yamllint.yml']
exclude: '\.terraform/.*'
# Shell script linting
- repo: https://github.com/shellcheck-py/shellcheck-py
rev: v0.9.0.6
hooks:
- id: shellcheck
args: ['--severity=warning']

76
.terraform-docs.yml Normal file
View File

@@ -0,0 +1,76 @@
# terraform-docs configuration
# https://terraform-docs.io/user-guide/configuration/
formatter: markdown table
version: ">= 0.16.0"
header-from: ""
footer-from: ""
recursive:
enabled: false
sections:
hide: []
show: []
content: |-
{{ .Header }}
## Requirements
{{ .Requirements }}
## Providers
{{ .Providers }}
## Modules
{{ .Modules }}
## Resources
{{ .Resources }}
## Inputs
{{ .Inputs }}
## Outputs
{{ .Outputs }}
{{ .Footer }}
output:
file: README.md
mode: inject
template: |-
<!-- BEGIN_TF_DOCS -->
{{ .Content }}
<!-- END_TF_DOCS -->
output-values:
enabled: false
from: ""
sort:
enabled: true
by: required
settings:
anchor: true
color: true
default: true
description: true
escape: true
hide-empty: false
html: true
indent: 2
lockfile: true
read-comments: true
required: true
sensitive: true
type: true

138
.tflint.hcl Normal file
View File

@@ -0,0 +1,138 @@
# TFLint Configuration
# Terraform linting with AWS best practices
# https://github.com/terraform-linters/tflint
config {
module = true
force = false
}
# AWS Provider Plugin
plugin "aws" {
enabled = true
version = "0.29.0"
source = "github.com/terraform-linters/tflint-ruleset-aws"
# Deep checking (requires AWS credentials)
deep_check = false
}
################################################################################
# Terraform Core Rules
################################################################################
# Enforce snake_case naming
rule "terraform_naming_convention" {
enabled = true
format = "snake_case"
}
# Require descriptions
rule "terraform_documented_variables" {
enabled = true
}
rule "terraform_documented_outputs" {
enabled = true
}
# Standard module structure
rule "terraform_standard_module_structure" {
enabled = true
}
# Deprecated syntax
rule "terraform_deprecated_interpolation" {
enabled = true
}
rule "terraform_deprecated_index" {
enabled = true
}
# Comment formatting
rule "terraform_comment_syntax" {
enabled = true
}
# Require type declarations
rule "terraform_typed_variables" {
enabled = true
}
# Workspace usage (discouraged with Terragrunt)
rule "terraform_workspace_remote" {
enabled = true
}
################################################################################
# AWS Security Rules
################################################################################
# Invalid instance types
rule "aws_instance_invalid_type" {
enabled = true
}
# Invalid AMIs
rule "aws_instance_invalid_ami" {
enabled = true
}
# Resource tagging
rule "aws_resource_missing_tags" {
enabled = true
tags = ["Name", "Environment", "ManagedBy"]
}
# IAM Policy best practices
rule "aws_iam_policy_document_gov_friendly_arns" {
enabled = true
}
rule "aws_iam_policy_too_long_policy" {
enabled = true
}
# S3 bucket configuration
rule "aws_s3_bucket_invalid_acl" {
enabled = true
}
# Security group rules
rule "aws_security_group_invalid_protocol" {
enabled = true
}
# DB instance sizing
rule "aws_db_instance_invalid_type" {
enabled = true
}
rule "aws_db_instance_invalid_db_subnet_group" {
enabled = true
}
# ElastiCache
rule "aws_elasticache_cluster_invalid_type" {
enabled = true
}
# Lambda
rule "aws_lambda_function_invalid_runtime" {
enabled = true
}
################################################################################
# Disabled Rules
################################################################################
# Too strict for template modules with dynamic configs
rule "terraform_unused_declarations" {
enabled = false
}
# Allow empty defaults for optional objects
rule "terraform_required_providers" {
enabled = false
}

37
.trivy.yaml Normal file
View File

@@ -0,0 +1,37 @@
# Trivy Configuration
# Security and misconfiguration scanning
# https://aquasecurity.github.io/trivy/
# Scan severity levels
severity:
- CRITICAL
- HIGH
- MEDIUM
# Exit code (0 = warn only, 1 = fail on findings)
exit-code: 0
# Output format
format: table
# Ignore unfixed vulnerabilities
ignore-unfixed: true
# Skip directories
skip-dirs:
- .terraform
- .git
- node_modules
# Custom policy paths
# policy:
# - ./policies
# Misconfiguration scanning
misconfiguration:
# Terraform-specific checks
terraform:
exclude-downloaded-modules: true
# Ignore specific checks
ignorefile: .trivyignore

41
.yamllint.yml Normal file
View File

@@ -0,0 +1,41 @@
# yamllint configuration
# https://yamllint.readthedocs.io/
extends: default
rules:
# Allow long lines (common in Terraform/CI configs)
line-length:
max: 200
level: warning
# Allow inline comments
comments:
min-spaces-from-content: 1
# Relaxed indentation for readability
indentation:
spaces: 2
indent-sequences: consistent
# Allow document start without ---
document-start: disable
# Truthy values (allow yes/no/on/off)
truthy:
allowed-values: ['true', 'false', 'yes', 'no', 'on', 'off']
# Braces spacing
braces:
min-spaces-inside: 0
max-spaces-inside: 1
# Brackets spacing
brackets:
min-spaces-inside: 0
max-spaces-inside: 1
ignore: |
.terraform/
**/.terraform/
node_modules/

149
Makefile Normal file
View File

@@ -0,0 +1,149 @@
# Terraform Foundation - Makefile
# Common commands for managing the infrastructure
.PHONY: help init fmt validate plan apply destroy docs clean
# Default target
help:
@echo "Terraform Foundation - Available Commands"
@echo ""
@echo " make init Initialize all Terraform layers"
@echo " make fmt Format all Terraform files"
@echo " make validate Validate all configurations"
@echo " make plan Plan all layers (dry run)"
@echo " make apply Apply all layers"
@echo " make docs Generate documentation"
@echo " make clean Clean up local artifacts"
@echo ""
@echo "Layer-specific commands:"
@echo " make plan-bootstrap"
@echo " make plan-org"
@echo " make plan-network"
@echo " make plan-platform"
@echo ""
@echo "Tenant commands:"
@echo " make new-tenant NAME=acme"
@echo " make plan-tenant NAME=acme"
@echo " make apply-tenant NAME=acme"
# Configuration
TF_DIR := terraform
STATE_BUCKET ?= $(shell cat $(TF_DIR)/00-bootstrap/backend.hcl 2>/dev/null | grep bucket | cut -d'"' -f2)
REGION ?= us-east-1
# Initialize all layers
init:
@echo "Initializing Terraform layers..."
@cd $(TF_DIR)/00-bootstrap && terraform init
@if [ -n "$(STATE_BUCKET)" ]; then \
for dir in 01-organization 02-network 03-platform; do \
if [ -f "$(TF_DIR)/$$dir/main.tf" ]; then \
echo "Initializing $$dir..."; \
cd $(TF_DIR)/$$dir && terraform init -backend-config=../00-bootstrap/backend.hcl; \
cd - > /dev/null; \
fi; \
done; \
else \
echo "Note: Run 'make apply-bootstrap' first to configure remote state"; \
fi
# Format all Terraform files
fmt:
@echo "Formatting Terraform files..."
@terraform fmt -recursive $(TF_DIR)
# Validate all configurations
validate: fmt
@echo "Validating Terraform configurations..."
@for dir in $(TF_DIR)/00-bootstrap $(TF_DIR)/01-organization $(TF_DIR)/02-network $(TF_DIR)/03-platform; do \
if [ -f "$$dir/main.tf" ]; then \
echo "Validating $$dir..."; \
cd $$dir && terraform init -backend=false -input=false >/dev/null 2>&1 && terraform validate && cd - > /dev/null; \
fi; \
done
@echo "✓ All configurations valid"
# Plan all layers
plan:
@./scripts/apply-all.sh plan
# Apply all layers
apply:
@./scripts/apply-all.sh apply
# Destroy (use with caution!)
destroy:
@echo "⚠️ This will destroy ALL infrastructure!"
@read -p "Type 'destroy' to confirm: " confirm && [ "$$confirm" = "destroy" ]
@./scripts/apply-all.sh destroy
# Layer-specific targets
plan-bootstrap:
@cd $(TF_DIR)/00-bootstrap && terraform plan
apply-bootstrap:
@cd $(TF_DIR)/00-bootstrap && terraform apply
plan-org:
@cd $(TF_DIR)/01-organization && terraform plan
apply-org:
@cd $(TF_DIR)/01-organization && terraform apply
plan-network:
@cd $(TF_DIR)/02-network && terraform plan -var="state_bucket=$(STATE_BUCKET)"
apply-network:
@cd $(TF_DIR)/02-network && terraform apply -var="state_bucket=$(STATE_BUCKET)"
plan-platform:
@cd $(TF_DIR)/03-platform && terraform plan -var="state_bucket=$(STATE_BUCKET)" -var="project_name=$(PROJECT_NAME)"
apply-platform:
@cd $(TF_DIR)/03-platform && terraform apply -var="state_bucket=$(STATE_BUCKET)" -var="project_name=$(PROJECT_NAME)"
# Tenant commands
new-tenant:
@if [ -z "$(NAME)" ]; then echo "Usage: make new-tenant NAME=<tenant>"; exit 1; fi
@./scripts/new-tenant.sh $(NAME)
plan-tenant:
@if [ -z "$(NAME)" ]; then echo "Usage: make plan-tenant NAME=<tenant>"; exit 1; fi
@cd $(TF_DIR)/04-tenants/$(NAME) && terraform plan -var="state_bucket=$(STATE_BUCKET)"
apply-tenant:
@if [ -z "$(NAME)" ]; then echo "Usage: make apply-tenant NAME=<tenant>"; exit 1; fi
@cd $(TF_DIR)/04-tenants/$(NAME) && terraform apply -var="state_bucket=$(STATE_BUCKET)"
# Generate documentation
docs:
@echo "Generating documentation..."
@which terraform-docs > /dev/null 2>&1 || (echo "Install terraform-docs: brew install terraform-docs" && exit 1)
@for dir in $(TF_DIR)/modules/*; do \
if [ -d "$$dir" ]; then \
terraform-docs markdown table $$dir > $$dir/README.md 2>/dev/null || true; \
fi; \
done
@echo "✓ Documentation generated"
# Clean up local artifacts
clean:
@echo "Cleaning up..."
@find $(TF_DIR) -name ".terraform" -type d -exec rm -rf {} + 2>/dev/null || true
@find $(TF_DIR) -name "*.tfstate*" -type f -delete 2>/dev/null || true
@find $(TF_DIR) -name ".terraform.lock.hcl" -type f -delete 2>/dev/null || true
@find $(TF_DIR) -name "tfplan" -type f -delete 2>/dev/null || true
@find $(TF_DIR) -name "lambda.zip" -type f -delete 2>/dev/null || true
@echo "✓ Cleanup complete"
# Security scan
security:
@echo "Running security scan..."
@which tfsec > /dev/null 2>&1 || (echo "Install tfsec: brew install tfsec" && exit 1)
@tfsec $(TF_DIR)
# Cost estimate (requires Infracost)
cost:
@echo "Estimating costs..."
@which infracost > /dev/null 2>&1 || (echo "Install infracost: brew install infracost" && exit 1)
@infracost breakdown --path $(TF_DIR)

592
README.md Normal file
View File

@@ -0,0 +1,592 @@
# Terraform Foundation
![Terraform](https://img.shields.io/badge/Terraform-1.5+-7B42BC?style=flat&logo=terraform)
![AWS](https://img.shields.io/badge/Cloud-AWS-FF9900?style=flat&logo=amazon-aws)
![License](https://img.shields.io/badge/License-MIT-blue)
Enterprise-grade cloud foundation with multi-tenancy, designed to scale from startup to enterprise.
## Features
- 🏢 **Multi-tenancy** - Logical tenant isolation via tags & ABAC
- 💰 **Cost optimized** - Single shared VPC, one NAT Gateway
- 🔒 **Security** - SCPs, tag enforcement, tenant-scoped IAM
- 📊 **Billing** - Per-tenant and per-app budget alerts
- 🎚️ **Flexible** - Single-account or multi-account deployment
- 🚀 **CI/CD Ready** - GitHub Actions workflow included
- 📦 **Workload Templates** - ECS, Lambda, RDS ready to deploy
## Deployment Modes
| Mode | Accounts | Best For | Cost |
|------|----------|----------|------|
| **single-account** | 1 | Startups, POCs, small teams | $ |
| **multi-account** | 1 per env (prod/staging/dev) | Growing companies, compliance | $$ |
Both modes use the same tenant isolation pattern (tags + ABAC + security groups).
```
┌─────────────────────────────────────────────────────────────────────┐
│ Shared VPC │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Tenant A │ │ Tenant B │ │ Tenant C │ │
│ │ SG: A-* │ │ SG: B-* │ │ SG: C-* │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
│ Isolation: Security Groups + Tags (ABAC) + IAM │
│ Cost: Single NAT Gateway (~$32/mo vs $288 for 3 separate VPCs) │
└─────────────────────────────────────────────────────────────────────┘
```
## Quick Start
### Prerequisites
- Terraform >= 1.5
- AWS CLI configured with appropriate permissions
- Make (optional, for convenience commands)
### Single-Account Mode (Fastest)
```bash
# 1. Bootstrap
cd terraform/00-bootstrap
terraform init
terraform apply -var="project_name=myproject" -var="deployment_mode=single-account"
# 2. Network (skip 01-organization in single-account mode)
cd ../02-network
terraform init -backend-config=../00-bootstrap/backend.hcl
terraform apply -var="state_bucket=myproject-terraform-state"
# 3. Platform (ECR, CI/CD)
cd ../03-platform
terraform init -backend-config=../00-bootstrap/backend.hcl
terraform apply -var="state_bucket=myproject-terraform-state" -var="project_name=myproject"
# 4. Add a tenant
./scripts/new-tenant.sh acme
cd terraform/04-tenants/acme
# Edit main.tf (apps, budgets, emails)
terraform init -backend-config=../../00-bootstrap/backend.hcl
terraform apply -var="state_bucket=myproject-terraform-state"
# 5. Deploy a workload
./scripts/new-workload.sh ecs acme api
cd terraform/05-workloads/acme-api
# Edit main.tf (container image, ports, scaling)
terraform init -backend-config=../../00-bootstrap/backend.hcl
terraform apply -var="state_bucket=myproject-terraform-state"
```
### Multi-Account Mode (Enterprise)
```bash
# 1. Bootstrap
cd terraform/00-bootstrap
terraform init
terraform apply -var="project_name=myorg" -var="deployment_mode=multi-account"
# 2. Organization (creates AWS Org, OUs, core accounts)
cd ../01-organization
terraform init -backend-config=../00-bootstrap/backend.hcl
terraform apply
# 3. Network (VPC in dedicated network account)
cd ../02-network
terraform init -backend-config=../00-bootstrap/backend.hcl
terraform apply -var="state_bucket=myorg-terraform-state" -var="deployment_mode=multi-account"
# 4. Platform & tenants as above
```
### Using Make
```bash
make help # Show all commands
make init # Initialize all layers
make plan # Plan all layers
make apply # Apply all layers
make new-tenant NAME=acme
make plan-tenant NAME=acme
```
## Layered Structure
Apply in order — each layer depends on the previous:
```
terraform/
├── 00-bootstrap/ # State bucket, locks, KMS (FIRST)
├── 01-organization/ # AWS Org, OUs, SCPs (multi-account only)
├── 02-network/ # Shared VPC, NAT, subnets
├── 03-platform/ # Shared services: ECR, CodeBuild
├── 04-tenants/ # Per-tenant: SGs, IAM, budgets
│ ├── _template/ # Copy for new tenants
│ ├── acme/
│ └── globex/
├── 05-workloads/ # Actual resources
│ ├── _template/
│ │ ├── ecs-service/
│ │ ├── eks-cluster/
│ │ ├── elasticache-redis/
│ │ ├── lambda-function/
│ │ ├── rds-database/
│ │ ├── sqs-queue/
│ │ └── static-site/
│ ├── acme-api/
│ └── acme-db/
└── modules/ # Reusable modules
├── backup-plan/ # AWS Backup automation
├── vpc-endpoints/ # PrivateLink endpoints
└── ...
```
## Tenant Isolation
### Security Groups
Each tenant gets isolated SGs that **only allow intra-tenant traffic**:
```
acme-prod-base-sg → Self-referencing (acme can talk to acme)
acme-prod-web-sg → 443/80 from internet
acme-prod-app-sg → 8080 from acme-base only
acme-prod-db-sg → 5432 from acme-base only
❌ globex-* cannot reach acme-* (no SG rules allow it)
```
### ABAC (Attribute-Based Access Control)
IAM roles are scoped to tenant by tag:
```hcl
# acme-admin can ONLY touch resources tagged Tenant=acme
Condition = {
StringEquals = {
"aws:ResourceTag/Tenant" = "acme"
}
}
# Must tag new resources correctly
Condition = {
StringEquals = {
"aws:RequestTag/Tenant" = "acme"
}
}
```
### Budgets
- **Tenant budget**: Total spend for all apps
- **App budgets**: Per-app limits
- **Alerts**: 50%, 80%, 100% thresholds → email
## Workload Templates
### ECS Fargate Service
Full container orchestration with:
- ECS Cluster with Fargate/Fargate Spot
- Application Load Balancer with access logging
- Auto-scaling (CPU/Memory based)
- CloudWatch logging
```bash
./scripts/new-workload.sh ecs <tenant> <app-name>
```
### EKS Kubernetes Cluster
Production-ready Kubernetes with:
- EKS managed node groups (On-Demand & Spot)
- IRSA (IAM Roles for Service Accounts)
- Core addons (VPC CNI, CoreDNS, kube-proxy, EBS CSI)
- IMDSv2 enforced, encrypted EBS volumes
- Cluster Autoscaler & LB Controller ready
```bash
./scripts/new-workload.sh eks <tenant> <cluster-name>
```
### Lambda Function
Serverless functions with:
- API Gateway HTTP API (optional)
- VPC access for database connectivity
- EventBridge scheduled execution
- X-Ray tracing
```bash
./scripts/new-workload.sh lambda <tenant> <function-name>
```
### RDS Database
Managed databases with:
- PostgreSQL, MySQL, or Aurora
- KMS encryption, IAM authentication
- Secrets Manager for credentials
- Enhanced monitoring, Performance Insights
```bash
./scripts/new-workload.sh rds <tenant> <db-name>
```
### ElastiCache Redis
In-memory caching with:
- Redis 7.x replication group
- Encryption at rest and in transit
- Automatic failover (Multi-AZ)
- Auth token in Secrets Manager
```bash
./scripts/new-workload.sh redis <tenant> <cache-name>
```
### SQS Queue
Message queuing with:
- Main queue + dead letter queue
- KMS encryption
- CloudWatch alarms (depth, age, DLQ)
- FIFO support optional
```bash
./scripts/new-workload.sh sqs <tenant> <queue-name>
```
### DynamoDB Table
NoSQL database with:
- On-demand or provisioned capacity
- KMS encryption, point-in-time recovery
- GSI/LSI support, TTL
- Auto-scaling (provisioned mode)
```bash
./scripts/new-workload.sh dynamodb <tenant> <table-name>
```
### EventBridge Event Bus
Event-driven architecture with:
- Custom event bus for tenant isolation
- Event rules with pattern matching
- Dead letter queue, event archiving
- Schema discovery
```bash
./scripts/new-workload.sh eventbus <tenant> <bus-name>
```
### Step Functions Workflow
Serverless orchestration with:
- Standard or Express workflows
- IAM permissions per service
- CloudWatch logging, X-Ray tracing
- API Gateway or EventBridge triggers
```bash
./scripts/new-workload.sh stepfn <tenant> <workflow-name>
```
### Static Site (S3 + CloudFront)
CDN-backed static hosting with:
- S3 bucket (private, OAC access)
- CloudFront with HTTPS
- Security headers (CSP, HSTS, etc.)
- Optional custom domain + ACM
```bash
./scripts/new-workload.sh static <tenant> <site-name>
```
### ECR Repository
Container registry with:
- Lifecycle policies (auto-cleanup old images)
- Cross-account pull/push access
- Multi-region replication
- Image scanning on push
```bash
./scripts/new-workload.sh ecr <tenant> <repo-name>
```
### SNS Topic
Pub/sub messaging with:
- Multiple subscription types (Lambda, SQS, HTTP, Email)
- Message filtering policies
- Dead letter queue for failed deliveries
- FIFO topics for ordered delivery
```bash
./scripts/new-workload.sh sns <tenant> <topic-name>
```
### SSM Parameters
Configuration store with:
- Hierarchical parameter paths
- SecureString for secrets (KMS encrypted)
- Free tier (cheaper than Secrets Manager)
- IAM policies for read/write access
```bash
./scripts/new-workload.sh params <tenant> <config-name>
```
### EventBridge Rules
Event-driven automation with:
- Scheduled rules (cron/rate expressions)
- Event pattern matching (AWS service events)
- Input transformations
- Multiple targets (Lambda, SQS, SNS, Step Functions)
```bash
./scripts/new-workload.sh events <tenant> <rules-name>
```
### Cognito User Pool
Authentication with:
- User signup/signin
- Social identity providers
- MFA (TOTP, SMS)
- Custom UI branding
- App clients for web/mobile
```bash
./scripts/new-workload.sh cognito <tenant> <auth-name>
```
### SES Email
Transactional email with:
- Domain identity verification
- DKIM/SPF/DMARC
- Email templates
- Reputation monitoring
- Bounce/complaint handling
```bash
./scripts/new-workload.sh ses <tenant> <email-name>
```
### API Gateway
REST API with:
- Lambda integration
- Request validation
- Usage plans and API keys
- Custom domain support
- CloudWatch logging
```bash
./scripts/new-workload.sh apigw <tenant> <api-name>
```
## Platform Services (03-platform)
The platform layer provides shared infrastructure:
- **ECR Repositories**: Container registry with lifecycle policies
- **CodeBuild**: Shared build project with VPC access
- **S3 Artifacts**: CI/CD artifact storage with lifecycle rules
- **SSM Parameters**: Centralized configuration store
## Cost Savings
| Setup | NAT Gateways | Est. Monthly |
|-------|--------------|--------------|
| VPC per tenant (3 tenants, 3 AZ) | 9 | ~$288 |
| **Shared VPC (1 NAT)** | 1 | ~$32 |
| **Savings** | | **~$256/mo** |
## Scripts
```bash
# Create new tenant
./scripts/new-tenant.sh <name>
# Create new workload
./scripts/new-workload.sh <ecs|eks|lambda|rds> <tenant> <name>
# Apply all layers in order
./scripts/apply-all.sh plan # Preview
./scripts/apply-all.sh apply # Deploy
```
## CI/CD
GitHub Actions workflow included (`.github/workflows/terraform.yml`):
- **On PR**: Format check, validate, security scan, plan (comments on PR)
- **On merge**: Auto-apply (requires `production` environment approval)
Setup:
1. Create an IAM role for GitHub OIDC
2. Add `AWS_ROLE_ARN` to repository secrets
3. Create `production` environment with required reviewers
## Requirements
- Terraform >= 1.5
- AWS CLI configured
- Sufficient IAM permissions (Organizations, IAM, EC2, RDS, etc.)
### Optional Tools
- [tfsec](https://github.com/aquasecurity/tfsec) - Security scanning
- [terraform-docs](https://github.com/terraform-docs/terraform-docs) - Documentation generation
- [infracost](https://www.infracost.io/) - Cost estimation
## Security Controls
Built-in security controls (see [docs/SECURITY.md](docs/SECURITY.md)):
| Control | Implementation |
|---------|----------------|
| **Encryption at rest** | KMS for RDS, EBS, S3, SQS, ElastiCache |
| **Encryption in transit** | TLS enforced on all services |
| **Network isolation** | VPC Flow Logs, private subnets, SG-based tenant isolation |
| **Access logging** | ALB, CloudFront, S3, VPC flow logs → centralized bucket |
| **IMDSv2** | Enforced on all EC2/EKS nodes via SCP + launch template |
| **Tag enforcement** | SCP requires Tenant + Environment tags |
| **Audit protection** | SCP prevents disabling CloudTrail, GuardDuty, Config |
## Reusable Modules
| Module | Purpose |
|--------|---------|
| **alerting** | SNS topics (critical/warning/info), Slack/PagerDuty integration |
| **backup-plan** | AWS Backup with daily/weekly/monthly, cross-region DR |
| **budget-alerts** | Cost budgets with anomaly detection |
| **cloudtrail** | Audit logging with S3, CloudWatch, KMS |
| **cloudwatch-dashboard** | Pre-built metric dashboards |
| **github-oidc** | Secure CI/CD without long-lived credentials |
| **iam-role** | Service, cross-account, and OIDC roles |
| **kms-key** | Customer-managed encryption keys |
| **route53-zone** | Hosted zones with health checks |
| **security-baseline** | GuardDuty, Security Hub, AWS Config, IAM Access Analyzer |
| **vpc-endpoints** | Gateway (S3, DynamoDB) + Interface endpoints |
| **vpc-lite** | Cost-optimized VPC ($0-$32/mo NAT options) |
| **waf-alb** | AWS WAF with managed rules, rate limiting, geo-blocking |
## Terragrunt Support
For DRY multi-environment configuration:
```bash
live/
├── terragrunt.hcl # Root config
├── prod/
│ ├── env.hcl # Environment variables
│ └── network/
│ └── terragrunt.hcl
├── staging/
│ └── env.hcl
└── dev/
└── env.hcl
```
Copy `terragrunt.hcl` to your `live/` directory and customize `env.hcl` per environment.
## Documentation
- [Security Architecture](docs/SECURITY.md) — Encryption, access control, audit logging
- [Cost Optimization](docs/COST-OPTIMIZATION.md) — Savings strategies, right-sizing guide
## Roadmap
- [x] ~~Add 03-platform (shared ECR, CI/CD)~~
- [x] ~~Add 05-workloads templates (ECS, Lambda, RDS, EKS)~~
- [x] ~~Security hardening (KMS, VPC Flow Logs, IMDSv2)~~
- [x] ~~Terragrunt support~~
- [x] ~~Event-driven templates (EventBridge, Step Functions)~~
- [x] ~~Security baseline (GuardDuty, Security Hub, Config)~~
- [x] ~~WAF module for ALB protection~~
- [x] ~~Alerting module (SNS, Slack, PagerDuty)~~
- [ ] GCP/Azure modules (future)
- [ ] Service mesh (AWS App Mesh)
- [ ] Prometheus/Grafana on EKS
## License
MIT
## AI-Assisted Development (MCP Servers)
This repository includes MCP (Model Context Protocol) server configurations for AI-assisted infrastructure development.
### Available MCP Servers
| Server | Purpose |
|--------|---------|
| `terraform` | HashiCorp Terraform Registry integration |
| `awslabs.terraform-mcp-server` | AWS-specific Terraform resources |
| `awslabs.aws-documentation-mcp-server` | Real-time AWS documentation |
| `awslabs.cdk-mcp-server` | AWS CDK best practices |
| `awslabs.core-mcp-server` | Core AWS utilities |
| `awslabs.cost-analysis-mcp-server` | Cost Explorer analysis |
| `awslabs.cloudformation-mcp-server` | CloudFormation operations |
### Setup
1. **Install prerequisites**:
```bash
# Node.js (for HashiCorp MCP server)
npm install -g npx
# Python uv (for AWS Labs servers)
pip install uv
# or
curl -LsSf https://astral.sh/uv/install.sh | sh
```
2. **Configure your MCP client**:
For **Claude Code**:
```bash
# Already configured in .mcp.json
claude-code .
```
For **Cursor**:
```bash
cp .mcp.json .cursor/mcp.json
```
For **VS Code**:
```bash
cp .mcp.json .vscode/mcp.json
```
### Usage Examples
With MCP servers enabled, your AI assistant can:
- **Generate Terraform configurations** using latest provider schemas
- **Look up AWS documentation** for service configurations
- **Apply best practices** from AWS Well-Architected Framework
- **Analyze costs** and suggest optimizations
- **Validate security** against AWS security guidelines
```
# Example prompts with MCP
"Create a Terraform module for an ECS service with Fargate"
"What are the latest IAM best practices for GitHub OIDC?"
"Analyze the cost impact of this RDS configuration"
```
### Security Note
MCP servers with AWS credentials (`cost-analysis`, `cloudformation`) are disabled by default. Enable them only when needed and ensure proper IAM permissions.

69
REVIEW.md Normal file
View File

@@ -0,0 +1,69 @@
# Terraform Foundation - Review Status
**Last Updated**: 2026-02-01
**Status**: Partially Implemented
---
## Completed Actions ✅
### 1. Removed Empty Modules (10 modules)
- ~~account-baseline~~
- ~~app-account~~
- ~~identity-center~~
- ~~ram-share~~
- ~~scps~~
- ~~security-groups~~
- ~~tenant-baseline~~
- ~~tenant-budget~~
- ~~tenant-iam~~
- ~~tenant-vpc~~
### 2. Added README.md to All Modules
All 21 remaining modules now have documentation:
- Usage examples
- Input variables table
- Outputs table
---
## Remaining Work
### Medium Priority
| Task | Status |
|------|--------|
| Split variables.tf/outputs.tf | Not started |
| Add versions.tf | Not started |
| Add examples/ directories | Not started |
| Add Terraform tests | Not started |
### Low Priority
| Task | Status |
|------|--------|
| Standardize count→for_each | Not started |
| Add consistent tagging | Not started |
| Generate provider lock files | Not started |
---
## Current Module Status
| Module | Structure | Docs | Ready |
|--------|-----------|------|-------|
| github-oidc | ✅ | ✅ | ✅ |
| Other modules (20) | 🟡 | ✅ | 🟡 |
Legend:
- ✅ Complete
- 🟡 Partial (works but not AWS IA compliant)
- ❌ Not ready
---
## Validation Status
All modules pass `terraform validate` with warnings:
- Deprecation warning: `aws_region.name` (use `.id`)
- Deprecation warning: GuardDuty `datasources` block
These are cosmetic and do not affect functionality.

212
docs/COST-OPTIMIZATION.md Normal file
View File

@@ -0,0 +1,212 @@
# Cost Optimization Guide
This document outlines cost-saving strategies implemented in this foundation and recommendations for further optimization.
## Built-In Cost Savings
### 1. Shared VPC Architecture
**Savings: ~$256/month per 3 tenants**
| Approach | NAT Gateways | Monthly Cost |
|----------|--------------|--------------|
| VPC per tenant (3 tenants, 2 AZ) | 6 | ~$192 |
| **Shared VPC (single NAT)** | 1 | ~$32 |
The shared VPC with tenant isolation via security groups provides the same logical separation at a fraction of the cost.
### 2. Single NAT Gateway
For non-production or cost-sensitive workloads:
```hcl
# terraform/02-network/main.tf
variable "enable_nat" {
default = true # Set to false to save ~$32/mo (no private subnet egress)
}
```
**Alternative**: NAT Instance (~$3/mo for t4g.nano) for dev environments.
### 3. GP3 Storage (Default)
All EBS and RDS storage uses GP3:
- 20% cheaper than GP2
- 3,000 IOPS included (vs 100 IOPS/GB for GP2)
- Configurable IOPS and throughput
### 4. Fargate Spot (ECS)
```hcl
# Configured in ECS template
default_capacity_provider_strategy {
base = 1 # 1 On-Demand for availability
weight = 100
capacity_provider = "FARGATE" # Change to FARGATE_SPOT for 70% savings
}
```
**Savings**: Up to 70% on Fargate compute.
### 5. EKS Spot Instances
```hcl
# Uncomment in EKS template
node_groups = {
spot = {
instance_types = ["t3.medium", "t3.large", "t3a.medium"] # Diversify!
capacity_type = "SPOT"
# ...
}
}
```
**Savings**: Up to 90% on EC2 compute.
### 6. S3 Intelligent-Tiering
For logs bucket (already configured):
```hcl
lifecycle_configuration {
rule {
transition {
days = 90
storage_class = "GLACIER"
}
expiration {
days = 2555 # 7 years
}
}
}
```
### 7. CloudWatch Log Retention
All log groups configured with retention (default 30 days):
```hcl
retention_in_days = 30 # Adjust based on compliance needs
```
**Cost**: ~$0.03/GB/month for ingestion + storage.
## Recommendations
### Compute Right-Sizing
1. **Start Small**: Use `t3.micro` or `t3.small` for non-prod
2. **Monitor**: Use CloudWatch Container Insights / Compute Optimizer
3. **Scale Down**: Reduce replica counts in dev/staging
### Reserved Capacity
| Resource | Savings | Commitment |
|----------|---------|------------|
| EC2 Reserved | 30-72% | 1-3 years |
| RDS Reserved | 30-60% | 1-3 years |
| Savings Plans (Compute) | 20-66% | 1-3 years |
| ElastiCache Reserved | 30-55% | 1-3 years |
**Recommendation**: After 3 months of stable usage, purchase Compute Savings Plans.
### Database Optimization
1. **Aurora Serverless v2**: For variable workloads (scales to 0.5 ACU)
2. **RDS Proxy**: Pool connections, reduce instance size
3. **Read Replicas**: Only for read-heavy workloads
4. **Stop Dev Databases**: Use Lambda to stop/start on schedule
```hcl
# Example: Smaller dev database
locals {
instance_class = local.env == "prod" ? "db.r6g.large" : "db.t3.micro"
}
```
### Networking
1. **VPC Endpoints**: For S3, ECR, Secrets Manager (~$7/mo each, but saves NAT costs)
2. **PrivateLink**: For high-volume AWS service access
3. **CloudFront**: Cache static content, reduce origin load
### Monitoring Cost Control
```hcl
# Reduce metric granularity in non-prod
enhanced_monitoring_interval = local.env == "prod" ? 60 : 0
# Disable Performance Insights in dev
performance_insights = local.env != "dev"
```
### EKS Specific
1. **Karpenter**: Better bin-packing than Cluster Autoscaler
2. **Bottlerocket OS**: Smaller footprint, faster boot
3. **Fargate for Batch**: No idle nodes
## Cost Monitoring
### AWS Tools
1. **Cost Explorer**: Built-in, tag-based analysis
2. **Budgets**: Already configured per-tenant
3. **Cost Anomaly Detection**: ML-based alerts
### Third-Party
1. **Infracost**: PR-level cost estimation (in Makefile)
2. **Kubecost**: Kubernetes cost allocation
3. **Spot.io**: Spot instance management
## Environment-Based Defaults
```hcl
locals {
# Automatically scale down non-prod
instance_class = {
prod = "db.r6g.large"
staging = "db.t3.small"
dev = "db.t3.micro"
}[local.env]
desired_count = {
prod = 3
staging = 2
dev = 1
}[local.env]
multi_az = local.env == "prod"
}
```
## Estimated Monthly Costs
### Minimal Setup (Dev/POC)
| Resource | Spec | Est. Cost |
|----------|------|-----------|
| NAT Gateway | 1 | $32 |
| RDS | db.t3.micro | $13 |
| ECS Fargate | 0.25 vCPU, 0.5GB x 2 | $15 |
| ALB | 1 | $16 |
| S3 + CloudWatch | Minimal | $5 |
| **Total** | | **~$80/mo** |
### Production (Small)
| Resource | Spec | Est. Cost |
|----------|------|-----------|
| NAT Gateway | 1 | $32 |
| RDS | db.r6g.large, Multi-AZ | $350 |
| ECS Fargate | 1 vCPU, 2GB x 4 | $120 |
| ALB | 1 | $25 |
| EKS | Control plane | $73 |
| EKS Nodes | 2x t3.medium | $60 |
| S3 + CloudWatch | Moderate | $30 |
| **Total** | | **~$690/mo** |
### Production (With Savings Plans)
Same as above with 1-year Compute Savings Plan: **~$480/mo** (30% savings)

170
docs/SECURITY.md Normal file
View File

@@ -0,0 +1,170 @@
# Security Architecture
This document outlines the security controls implemented in this Terraform foundation. These controls align with common compliance frameworks (HIPAA, SOC 2, ISO 27001, HITRUST) without being prescriptive to any specific framework.
## Encryption
### At Rest
| Resource | Encryption | Key Management |
|----------|------------|----------------|
| S3 Buckets | SSE-KMS | Customer-managed KMS keys |
| RDS/Aurora | AES-256 | Customer-managed KMS keys |
| EBS Volumes | AES-256 | Customer-managed KMS keys |
| DynamoDB | AES-256 | Customer-managed KMS keys |
| EKS Secrets | Envelope encryption | Customer-managed KMS keys |
| Secrets Manager | AES-256 | AWS-managed or customer KMS |
### In Transit
| Resource | Protocol | Enforcement |
|----------|----------|-------------|
| S3 | TLS 1.2+ | Bucket policy denies non-HTTPS |
| RDS | TLS 1.2+ | `ca_cert_identifier` configured |
| ALB | TLS 1.2+ | HTTPS listeners with modern policy |
| EKS API | TLS 1.2+ | AWS-managed certificates |
## Access Control
### Network Isolation
```
┌─────────────────────────────────────────────────────────────┐
│ Shared VPC │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Public Subnet │ │ Public Subnet │ ← ALB only │
│ │ (AZ-a) │ │ (AZ-b) │ │
│ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │
│ ┌────────▼────────┐ ┌────────▼────────┐ │
│ │ Private Subnet │ │ Private Subnet │ ← Workloads │
│ │ (AZ-a) │ │ (AZ-b) │ (no public IP) │
│ └─────────────────┘ └─────────────────┘ │
│ │
│ Default SG: DENY ALL (no rules) │
└─────────────────────────────────────────────────────────────┘
```
### Tenant Isolation
1. **Security Groups**: Each tenant has isolated SGs; cross-tenant traffic is denied by default
2. **ABAC (Attribute-Based Access Control)**: IAM policies require `Tenant` tag match
3. **Resource Tagging**: All resources tagged with `Tenant`, `App`, `Environment`
### Identity & Authentication
| Component | Authentication Method |
|-----------|----------------------|
| AWS Console | IAM + MFA (configure separately) |
| EKS Cluster | OIDC + IAM Roles for Service Accounts |
| RDS | Password + IAM Database Authentication |
| Secrets | Secrets Manager with rotation support |
## Audit & Logging
### Log Sources
| Source | Destination | Retention |
|--------|-------------|-----------|
| VPC Flow Logs | CloudWatch Logs | 90 days |
| ALB Access Logs | S3 (logs bucket) | 7 years |
| RDS Audit Logs | CloudWatch Logs | 30 days |
| EKS Control Plane | CloudWatch Logs | 30 days |
| CloudTrail | S3 (configure separately) | 7 years recommended |
### Log Protection
- S3 logs bucket: Versioning enabled, lifecycle to Glacier at 90 days
- CloudWatch Logs: Configurable KMS encryption
- Immutable: S3 Object Lock available (enable for compliance)
## Compute Security
### EKS Nodes
- **IMDSv2 Enforced**: Prevents SSRF-based credential theft
- **Hop Limit = 1**: Containers cannot access node metadata
- **Encrypted EBS**: All node volumes encrypted
- **Private Subnets**: No public IPs on worker nodes
### ECS/Fargate
- **No EC2 Management**: Fargate abstracts host security
- **Task IAM Roles**: Least-privilege per service
- **awsvpc Network Mode**: Each task gets own ENI
### Lambda
- **VPC Optional**: Deploy in VPC for database access
- **X-Ray Tracing**: Request tracking enabled
- **Reserved Concurrency**: Prevent noisy-neighbor DoS
## Data Protection
### Secrets Management
```hcl
# Secrets Manager with automatic rotation
resource "aws_secretsmanager_secret" "db" {
recovery_window_in_days = 30 # Prod: prevent accidental deletion
}
```
### Database Security
- **No Public Access**: `publicly_accessible = false`
- **Security Group**: Only allows traffic from tenant base SG
- **TLS Required**: Certificate validation enforced
- **IAM Auth**: Token-based authentication available
## Vulnerability Management
### Recommendations
1. **ECR Image Scanning**: Enabled by default (`scan_on_push = true`)
2. **Dependency Scanning**: Use Dependabot or Snyk in CI/CD
3. **tfsec**: Security scanning in GitHub Actions workflow
4. **AWS Inspector**: Enable for EC2/EKS vulnerability assessment
## Incident Response
### Recommendations
1. **GuardDuty**: Enable for threat detection
2. **Security Hub**: Aggregate findings across services
3. **CloudWatch Alarms**: CPU, connections, storage alerts configured
4. **SNS Topics**: Wire alarms to PagerDuty/Slack
## Compliance Mapping
| Control | HIPAA | SOC 2 | ISO 27001 | HITRUST |
|---------|-------|-------|-----------|---------|
| Encryption at rest | ✓ | ✓ | ✓ | ✓ |
| Encryption in transit | ✓ | ✓ | ✓ | ✓ |
| Access logging | ✓ | ✓ | ✓ | ✓ |
| Network isolation | ✓ | ✓ | ✓ | ✓ |
| Least privilege IAM | ✓ | ✓ | ✓ | ✓ |
| Key management | ✓ | ✓ | ✓ | ✓ |
## What's NOT Included (Configure Separately)
- CloudTrail (account-level, usually in audit account)
- AWS Config Rules
- GuardDuty
- Security Hub
- AWS WAF (per-application decision)
- MFA enforcement (IAM policy)
- Password policies (IAM)
- Backup policies (AWS Backup)
## Cost Considerations
Security features with cost impact:
| Feature | Cost Impact | Recommendation |
|---------|-------------|----------------|
| KMS keys | ~$1/mo per key | Use for production |
| VPC Flow Logs | ~$0.50/GB | Enable for compliance |
| Enhanced Monitoring | ~$0.10/instance/mo | Production only |
| Performance Insights | Free (7 days) | Always enable |
| S3 Glacier | ~$0.004/GB/mo | Use for log archival |

17
live/dev/env.hcl Normal file
View File

@@ -0,0 +1,17 @@
# Development environment configuration
locals {
environment = "dev"
aws_region = "us-east-1"
project_name = "myproject" # Update this
# Environment-specific settings
settings = {
multi_az = false
deletion_protection = false
backup_retention = 1
instance_class = "db.t3.micro"
node_type = "cache.t3.micro"
min_capacity = 1
max_capacity = 2
}
}

17
live/prod/env.hcl Normal file
View File

@@ -0,0 +1,17 @@
# Production environment configuration
locals {
environment = "prod"
aws_region = "us-east-1"
project_name = "myproject" # Update this
# Environment-specific settings
settings = {
multi_az = true
deletion_protection = true
backup_retention = 35
instance_class = "db.r6g.large"
node_type = "cache.r6g.large"
min_capacity = 2
max_capacity = 20
}
}

View File

@@ -0,0 +1,143 @@
# GitHub OIDC Configuration
# Implements AWS/Terraform/Security best practices
#
# Security features enabled:
# - Explicit repository restrictions (no wildcards)
# - Branch/environment protection
# - Session duration limits
# - Least-privilege policies
# - CloudTrail monitoring
terraform {
source = "../../../terraform/modules/github-oidc"
}
include "root" {
path = find_in_parent_folders("terragrunt.hcl")
}
inputs = {
# GitHub organization
github_org = "ghndrx" # Update to your org
name_prefix = "github"
# Security settings
path = "/github-actions/" # Isolated IAM path
max_session_hours_limit = 2 # Cap all sessions at 2 hours
deny_wildcard_repos = true # No * repos allowed
require_permissions_boundary = false # Enable in production
# permissions_boundary = "arn:aws:iam::ACCOUNT:policy/GitHubActionsBoundary"
# Monitoring (requires CloudTrail)
enable_cloudtrail_logging = false # Set true when CloudTrail is configured
# alarm_sns_topic_arn = "arn:aws:sns:us-east-1:ACCOUNT:security-alerts"
# Custom roles with explicit restrictions
roles = {
# Infrastructure deployment - main branch only
infra = {
repos = ["terraform-foundation", "infrastructure"]
branches = ["main"]
environments = ["production"]
policy_statements = [
{
sid = "ReadOnly"
actions = ["ec2:Describe*", "s3:List*", "s3:Get*", "iam:Get*", "iam:List*"]
resources = ["*"]
}
]
max_session_hours = 1
}
# PR validation - read-only
validate = {
repos = ["terraform-foundation"]
pull_request = true
policy_statements = [
{
sid = "ReadOnlyValidation"
effect = "Allow"
actions = ["ec2:Describe*", "s3:List*", "iam:Get*", "iam:List*"]
resources = ["*"]
}
]
max_session_hours = 1
}
# Release automation - tag-based
release = {
repos = ["terraform-foundation"]
tags = ["v*"]
branches = [] # Only tags, not branches
policy_statements = [
{
sid = "ReleaseArtifacts"
actions = ["s3:PutObject"]
resources = ["arn:aws:s3:::release-artifacts/*"]
}
]
}
}
# Terraform deployment with least privilege
terraform_deploy_role = {
enabled = true
repos = ["terraform-foundation"]
branches = ["main"]
environments = ["production"]
state_bucket = "your-terraform-state-bucket" # Update
state_bucket_key_prefix = "terraform/*" # Limit to specific paths
dynamodb_table = "terraform-locks"
allowed_services = [
"ec2", "s3", "iam", "lambda", "apigateway",
"cloudwatch", "logs", "route53", "acm"
]
denied_actions = [
"iam:CreateUser",
"iam:CreateAccessKey",
"iam:DeleteAccountPasswordPolicy",
"organizations:*",
"account:*",
"sts:AssumeRole" # Prevent role chaining
]
}
# ECR with explicit repos
ecr_push_role = {
enabled = true
repos = ["backend-api", "frontend-app"]
branches = ["main", "develop"]
ecr_repos = ["backend-api", "frontend-app"] # Explicit ECR repos
allow_create = false
allow_delete = false
}
# S3 static sites
s3_deploy_role = {
enabled = true
repos = ["website", "docs"]
branches = ["main"]
bucket_arns = ["arn:aws:s3:::www.example.com"] # Update
allowed_prefixes = ["assets/*", "*.html", "*.js", "*.css"]
cloudfront_arns = [] # Add if using CloudFront
}
# Lambda deployments
lambda_deploy_role = {
enabled = true
repos = ["serverless-api"]
branches = ["main"]
function_arns = [
"arn:aws:lambda:us-east-1:*:function:api-*" # Update
]
allow_create = false
allow_logs = true
}
tags = {
Environment = "shared"
ManagedBy = "terraform"
Component = "github-oidc"
CostCenter = "platform"
}
}

17
live/staging/env.hcl Normal file
View File

@@ -0,0 +1,17 @@
# Staging environment configuration
locals {
environment = "staging"
aws_region = "us-east-1"
project_name = "myproject" # Update this
# Environment-specific settings
settings = {
multi_az = false
deletion_protection = false
backup_retention = 7
instance_class = "db.t3.small"
node_type = "cache.t3.small"
min_capacity = 1
max_capacity = 5
}
}

130
scripts/apply-all.sh Executable file
View File

@@ -0,0 +1,130 @@
#!/bin/bash
################################################################################
# Apply all Terraform layers in order
# Usage: ./scripts/apply-all.sh [plan|apply|destroy]
################################################################################
set -e
ACTION="${1:-plan}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TF_DIR="$(dirname "$SCRIPT_DIR")/terraform"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Validate action
if [[ ! "$ACTION" =~ ^(plan|apply|destroy)$ ]]; then
echo -e "${RED}Usage: $0 [plan|apply|destroy]${NC}"
exit 1
fi
# Check if bootstrap has been run
if [ ! -f "$TF_DIR/00-bootstrap/backend.hcl" ]; then
echo -e "${YELLOW}Warning: backend.hcl not found. Run bootstrap first:${NC}"
echo " cd terraform/00-bootstrap && terraform init && terraform apply"
if [ "$ACTION" != "plan" ]; then
exit 1
fi
fi
# Read config from bootstrap if available
if [ -f "$TF_DIR/00-bootstrap/backend.hcl" ]; then
STATE_BUCKET=$(grep 'bucket' "$TF_DIR/00-bootstrap/backend.hcl" | cut -d'"' -f2)
REGION=$(grep 'region' "$TF_DIR/00-bootstrap/backend.hcl" | cut -d'"' -f2)
fi
# Determine deployment mode (check if we have organization state)
DEPLOYMENT_MODE="single-account"
if [ -f "$TF_DIR/01-organization/.terraform/terraform.tfstate" ]; then
DEPLOYMENT_MODE="multi-account"
fi
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN}Terraform Foundation - ${ACTION}${NC}"
echo -e "${GREEN}Mode: ${DEPLOYMENT_MODE}${NC}"
echo -e "${GREEN}========================================${NC}"
# Define layers based on deployment mode
if [ "$DEPLOYMENT_MODE" = "multi-account" ]; then
LAYERS=("00-bootstrap" "01-organization" "02-network" "03-platform")
else
LAYERS=("00-bootstrap" "02-network" "03-platform")
fi
# Reverse for destroy
if [ "$ACTION" = "destroy" ]; then
echo -e "${RED}⚠️ DESTROYING infrastructure in reverse order${NC}"
LAYERS=($(printf '%s\n' "${LAYERS[@]}" | tac))
fi
# Process each layer
for layer in "${LAYERS[@]}"; do
layer_dir="$TF_DIR/$layer"
# Skip if main.tf doesn't exist
if [ ! -f "$layer_dir/main.tf" ]; then
echo -e "${YELLOW}Skipping $layer (no main.tf)${NC}"
continue
fi
echo ""
echo -e "${GREEN}>>> Layer: $layer${NC}"
cd "$layer_dir"
# Initialize
if [ "$layer" = "00-bootstrap" ]; then
terraform init -input=false
else
terraform init -input=false -backend-config=../00-bootstrap/backend.hcl 2>/dev/null || terraform init -input=false -backend=false
fi
# Build var args
VAR_ARGS=""
if [ -n "$STATE_BUCKET" ] && [ "$layer" != "00-bootstrap" ]; then
VAR_ARGS="-var=state_bucket=$STATE_BUCKET"
fi
# Add project_name for platform layer if we can detect it
if [ "$layer" = "03-platform" ] && [ -n "$STATE_BUCKET" ]; then
PROJECT_NAME=$(echo "$STATE_BUCKET" | sed 's/-terraform-state$//')
VAR_ARGS="$VAR_ARGS -var=project_name=$PROJECT_NAME"
fi
# Execute action
case $ACTION in
plan)
terraform plan $VAR_ARGS
;;
apply)
terraform apply $VAR_ARGS -auto-approve
;;
destroy)
terraform destroy $VAR_ARGS -auto-approve
;;
esac
cd - > /dev/null
done
echo ""
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN}Complete!${NC}"
echo -e "${GREEN}========================================${NC}"
# Process tenants if applying
if [ "$ACTION" = "apply" ]; then
TENANT_DIRS=$(find "$TF_DIR/04-tenants" -maxdepth 1 -type d ! -name "_template" ! -name "04-tenants" 2>/dev/null)
if [ -n "$TENANT_DIRS" ]; then
echo ""
echo -e "${YELLOW}Tenant directories found. Apply separately:${NC}"
for tenant_dir in $TENANT_DIRS; do
tenant=$(basename "$tenant_dir")
echo " cd terraform/04-tenants/$tenant && terraform apply -var=\"state_bucket=$STATE_BUCKET\""
done
fi
fi

92
scripts/new-tenant.sh Executable file
View File

@@ -0,0 +1,92 @@
#!/bin/bash
################################################################################
# Create a new tenant from template
# Usage: ./scripts/new-tenant.sh <tenant-name>
################################################################################
set -e
TENANT_NAME="$1"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
TF_DIR="$PROJECT_DIR/terraform"
TEMPLATE_DIR="$TF_DIR/04-tenants/_template"
TENANT_DIR="$TF_DIR/04-tenants/$TENANT_NAME"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
# Validate input
if [ -z "$TENANT_NAME" ]; then
echo -e "${RED}Usage: $0 <tenant-name>${NC}"
echo ""
echo "Tenant name requirements:"
echo " - Lowercase letters, numbers, and hyphens only"
echo " - 3-20 characters"
echo " - Must start with a letter"
exit 1
fi
# Validate tenant name format
if ! [[ "$TENANT_NAME" =~ ^[a-z][a-z0-9-]{2,19}$ ]]; then
echo -e "${RED}Invalid tenant name: $TENANT_NAME${NC}"
echo "Must be 3-20 chars, start with letter, contain only lowercase letters, numbers, hyphens"
exit 1
fi
# Check if tenant already exists
if [ -d "$TENANT_DIR" ]; then
echo -e "${RED}Tenant '$TENANT_NAME' already exists at: $TENANT_DIR${NC}"
exit 1
fi
# Check template exists
if [ ! -f "$TEMPLATE_DIR/main.tf" ]; then
echo -e "${RED}Template not found at: $TEMPLATE_DIR${NC}"
exit 1
fi
echo -e "${GREEN}Creating tenant: $TENANT_NAME${NC}"
# Copy template
cp -r "$TEMPLATE_DIR" "$TENANT_DIR"
# Replace placeholders in all files
find "$TENANT_DIR" -type f -name "*.tf" -exec sed -i "s/<TENANT_NAME>/$TENANT_NAME/g" {} \;
echo -e "${GREEN}✓ Created tenant directory: $TENANT_DIR${NC}"
# Show next steps
echo ""
echo -e "${YELLOW}Next steps:${NC}"
echo ""
echo "1. Edit the configuration:"
echo " ${GREEN}vim $TENANT_DIR/main.tf${NC}"
echo ""
echo " Update these values:"
echo " - tenant (should be '$TENANT_NAME')"
echo " - env (prod, staging, dev)"
echo " - apps (name, port, budget, owner)"
echo " - budget (monthly total)"
echo " - alert_emails"
echo ""
echo "2. Initialize and apply:"
echo " ${GREEN}cd $TENANT_DIR${NC}"
echo " ${GREEN}terraform init -backend-config=../../00-bootstrap/backend.hcl${NC}"
echo " ${GREEN}terraform plan -var=\"state_bucket=YOUR_BUCKET\"${NC}"
echo " ${GREEN}terraform apply -var=\"state_bucket=YOUR_BUCKET\"${NC}"
echo ""
echo "3. (Optional) Create workloads for this tenant:"
echo ""
echo " ECS Service:"
echo " ${GREEN}cp -r $TF_DIR/05-workloads/_template/ecs-service $TF_DIR/05-workloads/${TENANT_NAME}-api${NC}"
echo ""
echo " Lambda Function:"
echo " ${GREEN}cp -r $TF_DIR/05-workloads/_template/lambda-function $TF_DIR/05-workloads/${TENANT_NAME}-worker${NC}"
echo ""
echo " RDS Database:"
echo " ${GREEN}cp -r $TF_DIR/05-workloads/_template/rds-database $TF_DIR/05-workloads/${TENANT_NAME}-db${NC}"
echo ""

359
scripts/new-workload.sh Executable file
View File

@@ -0,0 +1,359 @@
#!/bin/bash
################################################################################
# Create a new workload from template
# Usage: ./scripts/new-workload.sh <type> <tenant> <name>
#
# Types: ecs, lambda, rds
################################################################################
set -e
TYPE="$1"
TENANT="$2"
NAME="$3"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
TF_DIR="$PROJECT_DIR/terraform"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
# Input validation - prevent command injection
# Only allow lowercase letters, numbers, and hyphens
validate_name() {
local value="$1"
local field="$2"
if [[ ! "$value" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]]; then
echo -e "${RED}Error: $field must contain only lowercase letters, numbers, and hyphens${NC}"
echo " Must start and end with a letter or number"
echo " Got: '$value'"
exit 1
fi
if [[ ${#value} -gt 63 ]]; then
echo -e "${RED}Error: $field must be 63 characters or less${NC}"
exit 1
fi
}
# Show usage
usage() {
echo "Usage: $0 <type> <tenant> <name>"
echo ""
echo "Compute:"
echo " ecs - ECS Fargate service with ALB"
echo " eks - EKS Kubernetes cluster"
echo " lambda - Lambda function with API Gateway"
echo ""
echo "Data:"
echo " rds - RDS database (PostgreSQL/MySQL)"
echo " aurora - Aurora Serverless v2 (auto-scaling)"
echo " dynamodb - DynamoDB NoSQL table"
echo " redis - ElastiCache Redis cluster"
echo " opensearch - OpenSearch (Elasticsearch)"
echo " s3 - S3 bucket (data lake, backups, media)"
echo " ecr - ECR container registry"
echo ""
echo "API & Messaging:"
echo " apigw - API Gateway REST API"
echo " sqs - SQS queue with DLQ"
echo " sns - SNS topic (pub/sub)"
echo " eventbus - EventBridge custom event bus"
echo " events - EventBridge rules (scheduled/pattern)"
echo " stepfn - Step Functions workflow"
echo ""
echo "Auth & Email:"
echo " cognito - Cognito User Pool (auth)"
echo " ses - SES email (transactional/marketing)"
echo ""
echo "Config & Security:"
echo " secrets - Secrets Manager (credentials, API keys)"
echo " params - SSM Parameter Store (config, cheaper)"
echo ""
echo "Web:"
echo " static - Static site (S3 + CloudFront)"
echo ""
echo "Examples:"
echo " $0 ecs acme api"
echo " $0 rds acme main"
echo " $0 dynamodb acme orders"
echo " $0 eventbus acme events"
echo " $0 stepfn acme order-processor"
exit 1
}
# Validate input
if [ -z "$TYPE" ] || [ -z "$TENANT" ] || [ -z "$NAME" ]; then
usage
fi
# Validate tenant and name format (security: prevent command injection)
validate_name "$TENANT" "tenant"
validate_name "$NAME" "name"
# Map type to template directory
case $TYPE in
ecs)
TEMPLATE_DIR="$TF_DIR/05-workloads/_template/ecs-fargate"
;;
events)
TEMPLATE_DIR="$TF_DIR/05-workloads/_template/eventbridge-rules"
;;
eks)
TEMPLATE_DIR="$TF_DIR/05-workloads/_template/eks-cluster"
;;
lambda)
TEMPLATE_DIR="$TF_DIR/05-workloads/_template/lambda-function"
;;
rds)
TEMPLATE_DIR="$TF_DIR/05-workloads/_template/rds-database"
;;
aurora)
TEMPLATE_DIR="$TF_DIR/05-workloads/_template/aurora-serverless"
;;
dynamodb)
TEMPLATE_DIR="$TF_DIR/05-workloads/_template/dynamodb-table"
;;
redis)
TEMPLATE_DIR="$TF_DIR/05-workloads/_template/elasticache-redis"
;;
opensearch)
TEMPLATE_DIR="$TF_DIR/05-workloads/_template/opensearch"
;;
s3)
TEMPLATE_DIR="$TF_DIR/05-workloads/_template/s3-bucket"
;;
ecr)
TEMPLATE_DIR="$TF_DIR/05-workloads/_template/ecr-repository"
;;
sns)
TEMPLATE_DIR="$TF_DIR/05-workloads/_template/sns-topic"
;;
params)
TEMPLATE_DIR="$TF_DIR/05-workloads/_template/ssm-parameters"
;;
cognito)
TEMPLATE_DIR="$TF_DIR/05-workloads/_template/cognito-auth"
;;
ses)
TEMPLATE_DIR="$TF_DIR/05-workloads/_template/ses-email"
;;
secrets)
TEMPLATE_DIR="$TF_DIR/05-workloads/_template/secrets-manager"
;;
apigw)
TEMPLATE_DIR="$TF_DIR/05-workloads/_template/api-gateway"
;;
sqs)
TEMPLATE_DIR="$TF_DIR/05-workloads/_template/sqs-queue"
;;
eventbus)
TEMPLATE_DIR="$TF_DIR/05-workloads/_template/eventbridge-bus"
;;
stepfn)
TEMPLATE_DIR="$TF_DIR/05-workloads/_template/step-function"
;;
static)
TEMPLATE_DIR="$TF_DIR/05-workloads/_template/static-site"
;;
*)
echo -e "${RED}Unknown type: $TYPE${NC}"
usage
;;
esac
WORKLOAD_NAME="${TENANT}-${NAME}"
WORKLOAD_DIR="$TF_DIR/05-workloads/$WORKLOAD_NAME"
# Check if workload already exists
if [ -d "$WORKLOAD_DIR" ]; then
echo -e "${RED}Workload '$WORKLOAD_NAME' already exists at: $WORKLOAD_DIR${NC}"
exit 1
fi
# Check template exists
if [ ! -f "$TEMPLATE_DIR/main.tf" ]; then
echo -e "${RED}Template not found at: $TEMPLATE_DIR${NC}"
exit 1
fi
# Check tenant exists
if [ ! -d "$TF_DIR/04-tenants/$TENANT" ] && [ "$TENANT" != "_template" ]; then
echo -e "${YELLOW}Warning: Tenant '$TENANT' doesn't exist yet.${NC}"
echo "Create it first: ./scripts/new-tenant.sh $TENANT"
echo ""
read -p "Continue anyway? [y/N] " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
exit 1
fi
fi
echo -e "${GREEN}Creating workload: $WORKLOAD_NAME (type: $TYPE)${NC}"
# Copy template
cp -r "$TEMPLATE_DIR" "$WORKLOAD_DIR"
# Replace placeholders
find "$WORKLOAD_DIR" -type f -name "*.tf" -exec sed -i "s/<TENANT>/$TENANT/g" {} \;
find "$WORKLOAD_DIR" -type f -name "*.tf" -exec sed -i "s/<APP>/$NAME/g" {} \;
echo -e "${GREEN}✓ Created workload directory: $WORKLOAD_DIR${NC}"
# Type-specific instructions
echo ""
echo -e "${YELLOW}Next steps:${NC}"
echo ""
echo "1. Edit the configuration:"
echo " ${GREEN}vim $WORKLOAD_DIR/main.tf${NC}"
echo ""
case $TYPE in
ecs)
echo " Update these values:"
echo " - container_image (ECR URL)"
echo " - container_port"
echo " - cpu, memory"
echo " - desired_count, min_count, max_count"
echo " - environment variables"
echo " - secrets (ARNs)"
;;
eks)
echo " Update these values:"
echo " - cluster_version (1.29, 1.30, etc.)"
echo " - node_groups (instance types, scaling config)"
echo " - enable_fargate (for serverless pods)"
echo " - admin_arns (IAM principals for cluster access)"
echo " - cluster_endpoint_public (false for private-only)"
echo ""
echo " After apply, configure kubectl:"
echo " aws eks update-kubeconfig --name ${TENANT}-prod"
;;
lambda)
echo " Update these values:"
echo " - runtime (python3.12, nodejs20.x, etc.)"
echo " - handler"
echo " - source_dir OR s3_bucket/s3_key OR image_uri"
echo " - enable_vpc (true for database access)"
echo " - enable_api (true for HTTP endpoint)"
echo " - schedule_expression (for cron jobs)"
;;
rds)
echo " Update these values:"
echo " - engine (postgres, mysql, aurora-postgresql)"
echo " - engine_version"
echo " - instance_class"
echo " - storage_gb"
echo " - multi_az (true for prod)"
;;
redis)
echo " Update these values:"
echo " - engine_version (7.1, 7.0, etc.)"
echo " - node_type (cache.t3.micro, cache.r6g.large)"
echo " - num_cache_clusters (2 for Multi-AZ)"
echo " - maxmemory_policy (volatile-lru, allkeys-lru)"
;;
s3)
echo " Update these values:"
echo " - lifecycle_rules (tiering, expiration)"
echo " - enable_replication (cross-region DR)"
echo " - lambda_notifications (event triggers)"
echo " - cors_enabled (for web access)"
;;
ecr)
echo " Update these values:"
echo " - repositories (map of repo names)"
echo " - lifecycle_policy (cleanup rules)"
echo " - pull_access_accounts (cross-account)"
echo " - replication_regions (multi-region)"
;;
sns)
echo " Update these values:"
echo " - subscriptions (Lambda, SQS, Email, HTTP)"
echo " - filter_policy (message filtering)"
echo " - fifo_topic (ordered delivery)"
echo " - aws_service_principals (EventBridge, S3)"
;;
params)
echo " Update these values:"
echo " - parameters map (path -> value)"
echo " - SecureString for sensitive values"
echo " - Free for standard tier (4KB limit)"
echo " - Cheaper than Secrets Manager"
;;
cognito)
echo " Update these values:"
echo " - app_clients (web, mobile, m2m)"
echo " - password policy, MFA settings"
echo " - social_providers (Google, Facebook)"
echo " - custom_domain, lambda_triggers"
;;
ses)
echo " Update these values:"
echo " - domain, hosted_zone_id"
echo " - email_identities (sender addresses)"
echo " - tracking_options (open/click tracking)"
echo " - DMARC policy"
;;
secrets)
echo " Update these values:"
echo " - secrets map (name -> config)"
echo " - generate_password for auto-generated creds"
echo " - rotation settings for RDS"
echo " - allowed_accounts for cross-account"
;;
apigw)
echo " Update these values:"
echo " - lambda_integrations (path -> Lambda ARN)"
echo " - domain_name, hosted_zone_id (custom domain)"
echo " - usage_plans (quota/throttle)"
echo " - cors_origins (CORS allowed origins)"
;;
sqs)
echo " Update these values:"
echo " - fifo_queue (true for exactly-once processing)"
echo " - visibility_timeout_seconds"
echo " - max_receive_count (DLQ threshold)"
echo " - message_retention_seconds"
;;
dynamodb)
echo " Update these values:"
echo " - hash_key, range_key (primary key)"
echo " - billing_mode (PAY_PER_REQUEST or PROVISIONED)"
echo " - global_secondary_indexes"
echo " - ttl_attribute (for auto-expiry)"
;;
eventbus)
echo " Update these values:"
echo " - event_rules (pattern matching and targets)"
echo " - enable_archive (for event replay)"
echo " - allowed_source_accounts (cross-account)"
;;
stepfn)
echo " Update these values:"
echo " - state_machine_definition (workflow JSON)"
echo " - type (STANDARD or EXPRESS)"
echo " - lambda_arns, dynamodb_arns, etc. (permissions)"
echo " - schedule_expression (for scheduled runs)"
;;
static)
echo " Update these values:"
echo " - domain_name (e.g., www.example.com)"
echo " - hosted_zone_id (Route53 zone)"
echo " - price_class (PriceClass_100 cheapest)"
echo ""
echo " Deploy content:"
echo " aws s3 sync ./dist s3://BUCKET --delete"
;;
esac
echo ""
echo "2. Initialize and apply:"
echo " ${GREEN}cd $WORKLOAD_DIR${NC}"
echo " ${GREEN}terraform init -backend-config=../../00-bootstrap/backend.hcl${NC}"
echo " ${GREEN}terraform plan -var=\"state_bucket=YOUR_BUCKET\"${NC}"
echo " ${GREEN}terraform apply -var=\"state_bucket=YOUR_BUCKET\"${NC}"
echo ""

View File

@@ -0,0 +1,385 @@
################################################################################
# Layer 00: Bootstrap
#
# First layer - creates foundational resources needed by all other layers:
# - Terraform state bucket
# - DynamoDB lock table
# - KMS key for encryption
#
# Supports two deployment modes:
# - single-account: Everything in one account (small scale / startup)
# - multi-account: Separate accounts per environment (enterprise)
#
# Run: terraform init && terraform apply
# Next: 01-organization (multi-account) or 02-network (single-account)
################################################################################
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
# First run uses local state, then migrate to S3
# backend "s3" {
# bucket = "your-org-terraform-state"
# key = "00-bootstrap/terraform.tfstate"
# region = "us-east-1"
# dynamodb_table = "terraform-locks"
# encrypt = true
# }
}
provider "aws" {
region = var.region
default_tags {
tags = {
Layer = "00-bootstrap"
ManagedBy = "terraform"
Project = var.project_name
}
}
}
################################################################################
# Variables
################################################################################
variable "region" {
description = "AWS region"
type = string
default = "us-east-1"
}
variable "project_name" {
description = "Project name (used for naming resources)"
type = string
}
variable "deployment_mode" {
description = "Deployment mode: 'single-account' or 'multi-account'"
type = string
default = "single-account"
validation {
condition = contains(["single-account", "multi-account"], var.deployment_mode)
error_message = "deployment_mode must be 'single-account' or 'multi-account'"
}
}
################################################################################
# S3 Bucket for Terraform State
################################################################################
resource "aws_s3_bucket" "terraform_state" {
bucket = "${var.project_name}-terraform-state"
lifecycle {
prevent_destroy = true
}
tags = {
Name = "Terraform State"
}
}
resource "aws_s3_bucket_versioning" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = aws_kms_key.terraform.arn
}
}
}
resource "aws_s3_bucket_public_access_block" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
################################################################################
# S3 Bucket for Access Logs (Audit Trail)
################################################################################
resource "aws_s3_bucket" "logs" {
bucket = "${var.project_name}-logs-${data.aws_caller_identity.current.account_id}"
tags = {
Name = "Access Logs"
}
}
resource "aws_s3_bucket_versioning" "logs" {
bucket = aws_s3_bucket.logs.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "logs" {
bucket = aws_s3_bucket.logs.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256" # S3-managed keys for log delivery compatibility
}
}
}
resource "aws_s3_bucket_public_access_block" "logs" {
bucket = aws_s3_bucket.logs.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_lifecycle_configuration" "logs" {
bucket = aws_s3_bucket.logs.id
rule {
id = "transition-to-glacier"
status = "Enabled"
transition {
days = 90
storage_class = "GLACIER"
}
expiration {
days = 2555 # 7 years for compliance
}
}
}
# Policy to allow various AWS services to write logs
resource "aws_s3_bucket_policy" "logs" {
bucket = aws_s3_bucket.logs.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowSSLRequestsOnly"
Effect = "Deny"
Principal = "*"
Action = "s3:*"
Resource = [
aws_s3_bucket.logs.arn,
"${aws_s3_bucket.logs.arn}/*"
]
Condition = {
Bool = {
"aws:SecureTransport" = "false"
}
}
},
{
Sid = "AWSLogDeliveryWrite"
Effect = "Allow"
Principal = {
Service = "delivery.logs.amazonaws.com"
}
Action = "s3:PutObject"
Resource = "${aws_s3_bucket.logs.arn}/*"
Condition = {
StringEquals = {
"s3:x-amz-acl" = "bucket-owner-full-control"
"aws:SourceAccount" = data.aws_caller_identity.current.account_id
}
}
},
{
Sid = "AWSLogDeliveryCheck"
Effect = "Allow"
Principal = {
Service = "delivery.logs.amazonaws.com"
}
Action = ["s3:GetBucketAcl", "s3:ListBucket"]
Resource = aws_s3_bucket.logs.arn
Condition = {
StringEquals = {
"aws:SourceAccount" = data.aws_caller_identity.current.account_id
}
}
},
{
Sid = "ELBLogDelivery"
Effect = "Allow"
Principal = {
AWS = "arn:aws:iam::${local.elb_account_id}:root"
}
Action = "s3:PutObject"
Resource = "${aws_s3_bucket.logs.arn}/alb/*"
}
]
})
}
# ELB account IDs by region (for ALB access logging)
locals {
elb_account_ids = {
us-east-1 = "127311923021"
us-east-2 = "033677994240"
us-west-1 = "027434742980"
us-west-2 = "797873946194"
eu-west-1 = "156460612806"
eu-west-2 = "652711504416"
eu-central-1 = "054676820928"
ap-southeast-1 = "114774131450"
ap-southeast-2 = "783225319266"
ap-northeast-1 = "582318560864"
}
elb_account_id = lookup(local.elb_account_ids, var.region, "127311923021")
}
data "aws_caller_identity" "current" {}
################################################################################
# DynamoDB Table for State Locking
################################################################################
resource "aws_dynamodb_table" "terraform_locks" {
name = "${var.project_name}-terraform-locks"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
# Encryption at rest with AWS managed key (free) or use KMS for compliance
server_side_encryption {
enabled = true
kms_key_arn = aws_kms_key.terraform.arn
}
# Point-in-time recovery for compliance
point_in_time_recovery {
enabled = true
}
tags = {
Name = "Terraform Lock Table"
}
}
################################################################################
# KMS Key for State Encryption
################################################################################
resource "aws_kms_key" "terraform" {
description = "KMS key for Terraform state encryption"
deletion_window_in_days = 30
enable_key_rotation = true
tags = {
Name = "Terraform State Key"
}
}
resource "aws_kms_alias" "terraform" {
name = "alias/${var.project_name}-terraform"
target_key_id = aws_kms_key.terraform.key_id
}
################################################################################
# Outputs
################################################################################
output "state_bucket" {
value = aws_s3_bucket.terraform_state.id
}
output "lock_table" {
value = aws_dynamodb_table.terraform_locks.id
}
output "kms_key_arn" {
value = aws_kms_key.terraform.arn
}
output "region" {
value = var.region
}
output "project_name" {
value = var.project_name
}
output "deployment_mode" {
value = var.deployment_mode
}
output "logs_bucket" {
value = aws_s3_bucket.logs.id
}
output "logs_bucket_arn" {
value = aws_s3_bucket.logs.arn
}
################################################################################
# Backend Config Generator
################################################################################
resource "local_file" "backend_config" {
filename = "${path.module}/backend.hcl"
content = <<-EOT
bucket = "${aws_s3_bucket.terraform_state.id}"
region = "${var.region}"
dynamodb_table = "${aws_dynamodb_table.terraform_locks.id}"
encrypt = true
EOT
}
################################################################################
# Next Steps
################################################################################
output "next_steps" {
value = var.deployment_mode == "single-account" ? <<-EOT
Single-Account Mode Selected
============================
Skip 01-organization, go directly to:
cd ../02-network
terraform init -backend-config=../00-bootstrap/backend.hcl
terraform apply -var="state_bucket=${aws_s3_bucket.terraform_state.id}" -var="deployment_mode=single-account"
EOT
: <<-EOT
Multi-Account Mode Selected
===========================
Next step:
cd ../01-organization
terraform init -backend-config=../00-bootstrap/backend.hcl
terraform apply
EOT
}

View File

@@ -0,0 +1,6 @@
# Example variables for 00-bootstrap layer
# Copy to terraform.tfvars and update values
project_name = "myproject"
deployment_mode = "single-account" # or "multi-account"
region = "us-east-1"

View File

@@ -0,0 +1,572 @@
################################################################################
# Layer 01: Organization (Multi-Account Mode Only)
#
# Creates:
# - AWS Organization with SCPs and Tag Policies
# - OUs: Security, Infrastructure, Platform, Workloads, Sandbox
# - Core accounts: audit, log-archive, network
# - Service Control Policies
#
# Depends on: 00-bootstrap
################################################################################
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
backend "s3" {
key = "01-organization/terraform.tfstate"
}
}
provider "aws" {
region = var.region
default_tags {
tags = {
Layer = "01-organization"
ManagedBy = "terraform"
}
}
}
################################################################################
# Variables
################################################################################
variable "region" {
type = string
default = "us-east-1"
}
variable "email_domain" {
description = "Domain for account emails (e.g., example.com)"
type = string
}
variable "email_prefix" {
description = "Prefix for account emails"
type = string
default = "aws"
}
variable "allowed_regions" {
type = list(string)
default = ["us-east-1", "us-west-2"]
}
################################################################################
# Organization
################################################################################
resource "aws_organizations_organization" "main" {
feature_set = "ALL"
enabled_policy_types = [
"SERVICE_CONTROL_POLICY",
"TAG_POLICY",
]
aws_service_access_principals = [
"cloudtrail.amazonaws.com",
"config.amazonaws.com",
"guardduty.amazonaws.com",
"ram.amazonaws.com",
"sso.amazonaws.com",
]
}
################################################################################
# Organizational Units
################################################################################
resource "aws_organizations_organizational_unit" "security" {
name = "Security"
parent_id = aws_organizations_organization.main.roots[0].id
}
resource "aws_organizations_organizational_unit" "infrastructure" {
name = "Infrastructure"
parent_id = aws_organizations_organization.main.roots[0].id
}
resource "aws_organizations_organizational_unit" "platform" {
name = "Platform"
parent_id = aws_organizations_organization.main.roots[0].id
}
resource "aws_organizations_organizational_unit" "workloads" {
name = "Workloads"
parent_id = aws_organizations_organization.main.roots[0].id
}
resource "aws_organizations_organizational_unit" "prod" {
name = "Production"
parent_id = aws_organizations_organizational_unit.workloads.id
}
resource "aws_organizations_organizational_unit" "nonprod" {
name = "Non-Production"
parent_id = aws_organizations_organizational_unit.workloads.id
}
resource "aws_organizations_organizational_unit" "sandbox" {
name = "Sandbox"
parent_id = aws_organizations_organization.main.roots[0].id
}
################################################################################
# Core Accounts
################################################################################
resource "aws_organizations_account" "audit" {
name = "audit"
email = "${var.email_prefix}+audit@${var.email_domain}"
parent_id = aws_organizations_organizational_unit.security.id
role_name = "OrganizationAccountAccessRole"
lifecycle { ignore_changes = [role_name] }
}
resource "aws_organizations_account" "log_archive" {
name = "log-archive"
email = "${var.email_prefix}+logs@${var.email_domain}"
parent_id = aws_organizations_organizational_unit.security.id
role_name = "OrganizationAccountAccessRole"
lifecycle { ignore_changes = [role_name] }
}
resource "aws_organizations_account" "network" {
name = "network"
email = "${var.email_prefix}+network@${var.email_domain}"
parent_id = aws_organizations_organizational_unit.infrastructure.id
role_name = "OrganizationAccountAccessRole"
lifecycle { ignore_changes = [role_name] }
}
################################################################################
# SCPs - Security Baseline
################################################################################
# Deny root user access in member accounts
resource "aws_organizations_policy" "deny_root" {
name = "deny-root"
description = "Deny all actions by the root user in member accounts"
type = "SERVICE_CONTROL_POLICY"
content = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "DenyRoot"
Effect = "Deny"
Action = "*"
Resource = "*"
Condition = { StringLike = { "aws:PrincipalArn" = "arn:aws:iam::*:root" } }
}]
})
}
resource "aws_organizations_policy_attachment" "deny_root" {
policy_id = aws_organizations_policy.deny_root.id
target_id = aws_organizations_organizational_unit.workloads.id
}
# Restrict to approved regions
resource "aws_organizations_policy" "restrict_regions" {
name = "restrict-regions"
description = "Restrict resource creation to approved regions"
type = "SERVICE_CONTROL_POLICY"
content = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "DenyOtherRegions"
Effect = "Deny"
NotAction = [
"iam:*",
"organizations:*",
"support:*",
"sts:*",
"cloudfront:*",
"route53:*",
"route53domains:*",
"budgets:*",
"ce:*",
"waf:*",
"wafv2:*",
"health:*",
"globalaccelerator:*",
"importexport:*",
"pricing:*",
"trustedadvisor:*"
]
Resource = "*"
Condition = { StringNotEquals = { "aws:RequestedRegion" = var.allowed_regions } }
}]
})
}
resource "aws_organizations_policy_attachment" "restrict_regions" {
policy_id = aws_organizations_policy.restrict_regions.id
target_id = aws_organizations_organizational_unit.workloads.id
}
# Require tags on resource creation
resource "aws_organizations_policy" "require_tags" {
name = "require-tags"
description = "Require Tenant and Environment tags on resource creation"
type = "SERVICE_CONTROL_POLICY"
content = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "RequireTags"
Effect = "Deny"
Action = [
"ec2:RunInstances",
"ec2:CreateVolume",
"ec2:CreateSecurityGroup",
"rds:CreateDBInstance",
"rds:CreateDBCluster",
"s3:CreateBucket",
"lambda:CreateFunction",
"ecs:CreateCluster",
"eks:CreateCluster",
"elasticache:CreateCacheCluster",
"sqs:CreateQueue",
"sns:CreateTopic"
]
Resource = "*"
Condition = {
Null = {
"aws:RequestTag/Tenant" = "true"
"aws:RequestTag/Environment" = "true"
}
}
}]
})
}
resource "aws_organizations_policy_attachment" "require_tags" {
policy_id = aws_organizations_policy.require_tags.id
target_id = aws_organizations_organizational_unit.workloads.id
}
################################################################################
# SCPs - Data Protection
################################################################################
# Require encryption on S3 buckets
resource "aws_organizations_policy" "require_s3_encryption" {
name = "require-s3-encryption"
description = "Deny unencrypted S3 object uploads"
type = "SERVICE_CONTROL_POLICY"
content = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "DenyUnencryptedUploads"
Effect = "Deny"
Action = "s3:PutObject"
Resource = "*"
Condition = {
Null = {
"s3:x-amz-server-side-encryption" = "true"
}
}
},
{
Sid = "DenyNonAESEncryption"
Effect = "Deny"
Action = "s3:PutObject"
Resource = "*"
Condition = {
StringNotEqualsIfExists = {
"s3:x-amz-server-side-encryption" = ["AES256", "aws:kms"]
}
}
}
]
})
}
resource "aws_organizations_policy_attachment" "require_s3_encryption" {
policy_id = aws_organizations_policy.require_s3_encryption.id
target_id = aws_organizations_organizational_unit.workloads.id
}
# Prevent disabling of encryption
resource "aws_organizations_policy" "protect_encryption" {
name = "protect-encryption"
description = "Prevent disabling encryption on critical services"
type = "SERVICE_CONTROL_POLICY"
content = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "DenyUnencryptedRDS"
Effect = "Deny"
Action = [
"rds:CreateDBInstance",
"rds:CreateDBCluster"
]
Resource = "*"
Condition = {
Bool = {
"rds:StorageEncrypted" = "false"
}
}
},
{
Sid = "DenyUnencryptedEBS"
Effect = "Deny"
Action = [
"ec2:CreateVolume",
"ec2:RunInstances"
]
Resource = "arn:aws:ec2:*:*:volume/*"
Condition = {
Bool = {
"ec2:Encrypted" = "false"
}
}
}
]
})
}
resource "aws_organizations_policy_attachment" "protect_encryption" {
policy_id = aws_organizations_policy.protect_encryption.id
target_id = aws_organizations_organizational_unit.workloads.id
}
################################################################################
# SCPs - Network Security
################################################################################
# Prevent public access
resource "aws_organizations_policy" "deny_public_access" {
name = "deny-public-access"
description = "Prevent creation of publicly accessible resources"
type = "SERVICE_CONTROL_POLICY"
content = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "DenyPublicRDS"
Effect = "Deny"
Action = [
"rds:CreateDBInstance",
"rds:ModifyDBInstance"
]
Resource = "*"
Condition = {
Bool = {
"rds:PubliclyAccessible" = "true"
}
}
},
{
Sid = "DenyPublicS3"
Effect = "Deny"
Action = [
"s3:PutBucketPublicAccessBlock",
"s3:DeleteBucketPublicAccessBlock"
]
Resource = "*"
Condition = {
StringNotEquals = {
"s3:BlockPublicAcls" = "true"
"s3:BlockPublicPolicy" = "true"
"s3:IgnorePublicAcls" = "true"
"s3:RestrictPublicBuckets" = "true"
}
}
}
]
})
}
resource "aws_organizations_policy_attachment" "deny_public_access" {
policy_id = aws_organizations_policy.deny_public_access.id
target_id = aws_organizations_organizational_unit.workloads.id
}
# Require IMDSv2
resource "aws_organizations_policy" "require_imdsv2" {
name = "require-imdsv2"
description = "Require IMDSv2 for EC2 instances"
type = "SERVICE_CONTROL_POLICY"
content = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "RequireIMDSv2"
Effect = "Deny"
Action = "ec2:RunInstances"
Resource = "arn:aws:ec2:*:*:instance/*"
Condition = {
StringNotEquals = {
"ec2:MetadataHttpTokens" = "required"
}
}
}]
})
}
resource "aws_organizations_policy_attachment" "require_imdsv2" {
policy_id = aws_organizations_policy.require_imdsv2.id
target_id = aws_organizations_organizational_unit.workloads.id
}
################################################################################
# SCPs - Audit Protection
################################################################################
# Protect CloudTrail and GuardDuty
resource "aws_organizations_policy" "protect_security_services" {
name = "protect-security-services"
description = "Prevent disabling of security monitoring services"
type = "SERVICE_CONTROL_POLICY"
content = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "ProtectCloudTrail"
Effect = "Deny"
Action = [
"cloudtrail:DeleteTrail",
"cloudtrail:StopLogging",
"cloudtrail:UpdateTrail",
"cloudtrail:PutEventSelectors"
]
Resource = "*"
},
{
Sid = "ProtectGuardDuty"
Effect = "Deny"
Action = [
"guardduty:DeleteDetector",
"guardduty:DisassociateFromMasterAccount",
"guardduty:DeleteMembers",
"guardduty:StopMonitoringMembers"
]
Resource = "*"
},
{
Sid = "ProtectConfig"
Effect = "Deny"
Action = [
"config:DeleteConfigRule",
"config:DeleteConfigurationRecorder",
"config:DeleteDeliveryChannel",
"config:StopConfigurationRecorder"
]
Resource = "*"
},
{
Sid = "ProtectSecurityHub"
Effect = "Deny"
Action = [
"securityhub:DisableSecurityHub",
"securityhub:DeleteMembers",
"securityhub:DisassociateFromMasterAccount"
]
Resource = "*"
}
]
})
}
resource "aws_organizations_policy_attachment" "protect_security_services" {
policy_id = aws_organizations_policy.protect_security_services.id
target_id = aws_organizations_organization.main.roots[0].id
}
################################################################################
# SCPs - Sandbox (Relaxed Controls)
################################################################################
# More permissive policy for sandbox accounts
resource "aws_organizations_policy" "sandbox_controls" {
name = "sandbox-controls"
description = "Relaxed controls for sandbox experimentation"
type = "SERVICE_CONTROL_POLICY"
content = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "AllowAll"
Effect = "Allow"
Action = "*"
Resource = "*"
}]
})
}
resource "aws_organizations_policy_attachment" "sandbox_controls" {
policy_id = aws_organizations_policy.sandbox_controls.id
target_id = aws_organizations_organizational_unit.sandbox.id
}
################################################################################
# Tag Policy
################################################################################
resource "aws_organizations_policy" "tags" {
name = "mandatory-tags"
type = "TAG_POLICY"
content = jsonencode({
tags = {
Tenant = { tag_key = { "@@assign" = "Tenant" } }
Environment = { tag_key = { "@@assign" = "Environment" }, tag_value = { "@@assign" = ["prod", "staging", "dev", "sandbox"] } }
App = { tag_key = { "@@assign" = "App" } }
}
})
}
resource "aws_organizations_policy_attachment" "tags" {
policy_id = aws_organizations_policy.tags.id
target_id = aws_organizations_organization.main.roots[0].id
}
################################################################################
# Outputs
################################################################################
output "organization_id" {
value = aws_organizations_organization.main.id
}
output "ou_ids" {
value = {
security = aws_organizations_organizational_unit.security.id
infrastructure = aws_organizations_organizational_unit.infrastructure.id
platform = aws_organizations_organizational_unit.platform.id
workloads = aws_organizations_organizational_unit.workloads.id
production = aws_organizations_organizational_unit.prod.id
nonproduction = aws_organizations_organizational_unit.nonprod.id
sandbox = aws_organizations_organizational_unit.sandbox.id
}
}
output "account_ids" {
value = {
audit = aws_organizations_account.audit.id
log_archive = aws_organizations_account.log_archive.id
network = aws_organizations_account.network.id
}
}

View File

@@ -0,0 +1,12 @@
# Copy to terraform.tfvars and customize
# Email domain for AWS account emails
# Accounts will be: aws+audit@domain.com, aws+logs@domain.com, etc.
email_domain = "example.com"
email_prefix = "aws"
# Allowed AWS regions (enforced by SCP)
allowed_regions = ["us-east-1", "us-west-2"]
# AWS Region
region = "us-east-1"

View File

@@ -0,0 +1,305 @@
################################################################################
# Layer 02: Network
#
# Creates shared VPC:
# - Single VPC (cost optimized)
# - Public/Private subnets
# - Single NAT Gateway
# - AWS RAM sharing (multi-account only)
#
# Depends on: 00-bootstrap (single) or 01-organization (multi)
################################################################################
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
backend "s3" {
key = "02-network/terraform.tfstate"
}
}
################################################################################
# Variables
################################################################################
variable "region" {
type = string
default = "us-east-1"
}
variable "state_bucket" {
type = string
}
variable "deployment_mode" {
type = string
default = "single-account"
validation {
condition = contains(["single-account", "multi-account"], var.deployment_mode)
error_message = "Must be single-account or multi-account"
}
}
variable "vpc_cidr" {
type = string
default = "10.0.0.0/16"
}
variable "azs" {
type = list(string)
default = ["us-east-1a", "us-east-1b"]
}
variable "enable_nat" {
type = bool
default = true
}
################################################################################
# Data Sources
################################################################################
data "terraform_remote_state" "org" {
count = var.deployment_mode == "multi-account" ? 1 : 0
backend = "s3"
config = {
bucket = var.state_bucket
key = "01-organization/terraform.tfstate"
region = var.region
}
}
################################################################################
# Provider
################################################################################
provider "aws" {
region = var.region
default_tags {
tags = {
Layer = "02-network"
ManagedBy = "terraform"
}
}
}
################################################################################
# VPC
################################################################################
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = { Name = "shared-vpc" }
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = { Name = "shared-igw" }
}
################################################################################
# Subnets
################################################################################
resource "aws_subnet" "public" {
count = length(var.azs)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 4, count.index)
availability_zone = var.azs[count.index]
map_public_ip_on_launch = true
tags = { Name = "public-${var.azs[count.index]}", Type = "public" }
}
resource "aws_subnet" "private" {
count = length(var.azs)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 4, count.index + 4)
availability_zone = var.azs[count.index]
tags = { Name = "private-${var.azs[count.index]}", Type = "private" }
}
################################################################################
# NAT Gateway
################################################################################
resource "aws_eip" "nat" {
count = var.enable_nat ? 1 : 0
domain = "vpc"
tags = { Name = "nat-eip" }
}
resource "aws_nat_gateway" "main" {
count = var.enable_nat ? 1 : 0
allocation_id = aws_eip.nat[0].id
subnet_id = aws_subnet.public[0].id
tags = { Name = "shared-nat" }
depends_on = [aws_internet_gateway.main]
}
################################################################################
# Route Tables
################################################################################
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = { Name = "public-rt" }
}
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
dynamic "route" {
for_each = var.enable_nat ? [1] : []
content {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main[0].id
}
}
tags = { Name = "private-rt" }
}
resource "aws_route_table_association" "public" {
count = length(var.azs)
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "private" {
count = length(var.azs)
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private.id
}
################################################################################
# Default SG - Deny All
################################################################################
resource "aws_default_security_group" "default" {
vpc_id = aws_vpc.main.id
tags = { Name = "default-deny-all" }
}
################################################################################
# VPC Flow Logs (Audit Trail)
################################################################################
resource "aws_flow_log" "main" {
vpc_id = aws_vpc.main.id
traffic_type = "ALL"
log_destination_type = "cloud-watch-logs"
log_destination = aws_cloudwatch_log_group.flow_logs.arn
iam_role_arn = aws_iam_role.flow_logs.arn
max_aggregation_interval = 60 # 1 minute for better visibility
tags = { Name = "vpc-flow-logs" }
}
resource "aws_cloudwatch_log_group" "flow_logs" {
name = "/aws/vpc/flow-logs"
retention_in_days = 90
tags = { Name = "vpc-flow-logs" }
}
resource "aws_iam_role" "flow_logs" {
name = "vpc-flow-logs"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "vpc-flow-logs.amazonaws.com" }
}]
})
tags = { Name = "vpc-flow-logs" }
}
resource "aws_iam_role_policy" "flow_logs" {
name = "vpc-flow-logs"
role = aws_iam_role.flow_logs.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents",
"logs:DescribeLogGroups",
"logs:DescribeLogStreams"
]
Resource = "*"
}]
})
}
################################################################################
# RAM Sharing (multi-account only)
################################################################################
resource "aws_ram_resource_share" "subnets" {
count = var.deployment_mode == "multi-account" ? 1 : 0
name = "shared-subnets"
allow_external_principals = false
}
resource "aws_ram_resource_association" "private" {
count = var.deployment_mode == "multi-account" ? length(var.azs) : 0
resource_arn = aws_subnet.private[count.index].arn
resource_share_arn = aws_ram_resource_share.subnets[0].arn
}
resource "aws_ram_principal_association" "workloads" {
count = var.deployment_mode == "multi-account" ? 1 : 0
principal = data.terraform_remote_state.org[0].outputs.ou_ids.workloads
resource_share_arn = aws_ram_resource_share.subnets[0].arn
}
################################################################################
# Outputs
################################################################################
output "vpc_id" {
value = aws_vpc.main.id
}
output "vpc_cidr" {
value = aws_vpc.main.cidr_block
}
output "public_subnet_ids" {
value = aws_subnet.public[*].id
}
output "private_subnet_ids" {
value = aws_subnet.private[*].id
}
output "nat_ip" {
value = var.enable_nat ? aws_eip.nat[0].public_ip : null
}

View File

@@ -0,0 +1,37 @@
# Example variables for 02-network layer
# Copy to terraform.tfvars and update values
state_bucket = "myproject-terraform-state"
deployment_mode = "single-account"
region = "us-east-1"
vpc_cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b"]
# NAT Gateway - set to false for cost savings (~$32/mo)
enable_nat = true
################################################################################
# Cost Optimization Options
################################################################################
#
# Option 1: Standard (enable_nat = true)
# - NAT Gateway: ~$32/mo + $0.045/GB data transfer
# - Recommended for: Production, compliance requirements
#
# Option 2: No NAT (enable_nat = false)
# - Cost: $0/mo for NAT
# - Private subnets can't reach internet
# - Use VPC endpoints for AWS services (S3, DynamoDB, ECR, etc.)
# - Recommended for: Dev/test, serverless-heavy, small accounts
#
# Option 3: Use vpc-lite module instead
# - nat_mode = "none" → $0/mo (VPC endpoints only)
# - nat_mode = "instance" → ~$3/mo (t4g.nano NAT, single-AZ)
# - nat_mode = "gateway" → ~$32/mo (standard)
#
# Example vpc-lite usage:
# module "vpc" {
# source = "../modules/vpc-lite"
# name = "dev-vpc"
# nat_mode = "none" # or "instance" for cheap NAT
# }

View File

@@ -0,0 +1,432 @@
################################################################################
# Layer 03: Platform
#
# Shared platform services for all tenants:
# - ECR repositories for container images
# - CodePipeline/CodeBuild for CI/CD
# - Secrets Manager baseline
# - SSM Parameter Store hierarchy
#
# Depends on: 00-bootstrap, 02-network
################################################################################
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
backend "s3" {
key = "03-platform/terraform.tfstate"
}
}
################################################################################
# Variables
################################################################################
variable "region" {
type = string
default = "us-east-1"
}
variable "state_bucket" {
type = string
}
variable "project_name" {
type = string
description = "Project name for resource naming"
}
variable "enable_cicd" {
type = bool
default = true
description = "Enable CI/CD resources (CodeBuild, S3 artifacts)"
}
variable "ecr_repos" {
type = list(string)
default = ["base", "app"]
description = "List of shared ECR repositories to create"
}
variable "ecr_image_retention_count" {
type = number
default = 30
description = "Number of images to retain per repository"
}
################################################################################
# Provider
################################################################################
provider "aws" {
region = var.region
default_tags {
tags = {
Layer = "03-platform"
ManagedBy = "terraform"
Project = var.project_name
}
}
}
################################################################################
# Data Sources
################################################################################
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = var.state_bucket
key = "02-network/terraform.tfstate"
region = var.region
}
}
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
################################################################################
# ECR Repositories
################################################################################
resource "aws_ecr_repository" "shared" {
for_each = toset(var.ecr_repos)
name = "${var.project_name}/${each.key}"
image_tag_mutability = "MUTABLE"
image_scanning_configuration {
scan_on_push = true
}
encryption_configuration {
encryption_type = "AES256"
}
tags = {
Name = "${var.project_name}-${each.key}"
}
}
resource "aws_ecr_lifecycle_policy" "shared" {
for_each = aws_ecr_repository.shared
repository = each.value.name
policy = jsonencode({
rules = [
{
rulePriority = 1
description = "Keep last ${var.ecr_image_retention_count} images"
selection = {
tagStatus = "any"
countType = "imageCountMoreThan"
countNumber = var.ecr_image_retention_count
}
action = { type = "expire" }
}
]
})
}
################################################################################
# CI/CD - Artifact Bucket
################################################################################
resource "aws_s3_bucket" "artifacts" {
count = var.enable_cicd ? 1 : 0
bucket = "${var.project_name}-cicd-artifacts-${data.aws_caller_identity.current.account_id}"
tags = { Name = "CI/CD Artifacts" }
}
resource "aws_s3_bucket_versioning" "artifacts" {
count = var.enable_cicd ? 1 : 0
bucket = aws_s3_bucket.artifacts[0].id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_lifecycle_configuration" "artifacts" {
count = var.enable_cicd ? 1 : 0
bucket = aws_s3_bucket.artifacts[0].id
rule {
id = "cleanup-old-artifacts"
status = "Enabled"
expiration {
days = 90
}
noncurrent_version_expiration {
noncurrent_days = 30
}
}
}
resource "aws_s3_bucket_public_access_block" "artifacts" {
count = var.enable_cicd ? 1 : 0
bucket = aws_s3_bucket.artifacts[0].id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
################################################################################
# CI/CD - CodeBuild Role
################################################################################
resource "aws_iam_role" "codebuild" {
count = var.enable_cicd ? 1 : 0
name = "${var.project_name}-codebuild"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "codebuild.amazonaws.com" }
}]
})
tags = { Name = "${var.project_name}-codebuild" }
}
resource "aws_iam_role_policy" "codebuild" {
count = var.enable_cicd ? 1 : 0
name = "codebuild-policy"
role = aws_iam_role.codebuild[0].id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "CloudWatchLogs"
Effect = "Allow"
Action = ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"]
Resource = [
"arn:aws:logs:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:log-group:/aws/codebuild/${var.project_name}-*",
"arn:aws:logs:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:log-group:/aws/codebuild/${var.project_name}-*:*"
]
},
{
Sid = "S3Artifacts"
Effect = "Allow"
Action = ["s3:GetObject", "s3:PutObject", "s3:GetObjectVersion"]
Resource = "${aws_s3_bucket.artifacts[0].arn}/*"
},
{
Sid = "ECRAuth"
Effect = "Allow"
Action = ["ecr:GetAuthorizationToken"]
Resource = "*"
},
{
Sid = "ECRPush"
Effect = "Allow"
Action = [
"ecr:BatchCheckLayerAvailability",
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage",
"ecr:PutImage",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload"
]
Resource = "arn:aws:ecr:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:repository/${var.project_name}/*"
},
{
Sid = "SSMParams"
Effect = "Allow"
Action = ["ssm:GetParameter", "ssm:GetParameters", "ssm:GetParametersByPath"]
Resource = "arn:aws:ssm:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:parameter/${var.project_name}/*"
},
{
Sid = "SecretsManager"
Effect = "Allow"
Action = ["secretsmanager:GetSecretValue"]
Resource = "arn:aws:secretsmanager:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:secret:${var.project_name}/*"
},
{
Sid = "VPC"
Effect = "Allow"
Action = [
"ec2:CreateNetworkInterface",
"ec2:DescribeNetworkInterfaces",
"ec2:DeleteNetworkInterface",
"ec2:DescribeSubnets",
"ec2:DescribeSecurityGroups",
"ec2:DescribeVpcs"
]
Resource = "*"
}
]
})
}
################################################################################
# CodeBuild - Shared Build Project
################################################################################
resource "aws_codebuild_project" "build" {
count = var.enable_cicd ? 1 : 0
name = "${var.project_name}-build"
description = "Shared build project for ${var.project_name}"
build_timeout = 30
service_role = aws_iam_role.codebuild[0].arn
artifacts {
type = "S3"
location = aws_s3_bucket.artifacts[0].bucket
packaging = "ZIP"
}
environment {
compute_type = "BUILD_GENERAL1_SMALL"
image = "aws/codebuild/amazonlinux2-x86_64-standard:5.0"
type = "LINUX_CONTAINER"
image_pull_credentials_type = "CODEBUILD"
privileged_mode = true # Required for Docker
environment_variable {
name = "AWS_DEFAULT_REGION"
value = data.aws_region.current.name
}
environment_variable {
name = "AWS_ACCOUNT_ID"
value = data.aws_caller_identity.current.account_id
}
environment_variable {
name = "ECR_REGISTRY"
value = "${data.aws_caller_identity.current.account_id}.dkr.ecr.${data.aws_region.current.name}.amazonaws.com"
}
}
source {
type = "NO_SOURCE"
buildspec = <<-YAML
version: 0.2
phases:
pre_build:
commands:
- echo "Override this buildspec in your project"
build:
commands:
- echo "Build phase"
post_build:
commands:
- echo "Post-build phase"
YAML
}
vpc_config {
vpc_id = data.terraform_remote_state.network.outputs.vpc_id
subnets = data.terraform_remote_state.network.outputs.private_subnet_ids
security_group_ids = [aws_security_group.codebuild[0].id]
}
logs_config {
cloudwatch_logs {
group_name = "/aws/codebuild/${var.project_name}"
stream_name = "build"
}
}
tags = { Name = "${var.project_name}-build" }
}
################################################################################
# CodeBuild Security Group
################################################################################
resource "aws_security_group" "codebuild" {
count = var.enable_cicd ? 1 : 0
name = "${var.project_name}-codebuild"
description = "Security group for CodeBuild"
vpc_id = data.terraform_remote_state.network.outputs.vpc_id
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
description = "Allow all outbound"
}
tags = { Name = "${var.project_name}-codebuild" }
}
################################################################################
# SSM Parameter Store - Hierarchy Base
################################################################################
resource "aws_ssm_parameter" "platform_config" {
name = "/${var.project_name}/platform/region"
type = "String"
value = data.aws_region.current.name
tags = { Name = "Platform Region" }
}
resource "aws_ssm_parameter" "vpc_id" {
name = "/${var.project_name}/platform/vpc_id"
type = "String"
value = data.terraform_remote_state.network.outputs.vpc_id
tags = { Name = "VPC ID" }
}
resource "aws_ssm_parameter" "private_subnets" {
name = "/${var.project_name}/platform/private_subnet_ids"
type = "StringList"
value = join(",", data.terraform_remote_state.network.outputs.private_subnet_ids)
tags = { Name = "Private Subnet IDs" }
}
################################################################################
# Outputs
################################################################################
output "ecr_repositories" {
value = {
for k, v in aws_ecr_repository.shared : k => {
arn = v.arn
url = v.repository_url
}
}
}
output "artifacts_bucket" {
value = var.enable_cicd ? aws_s3_bucket.artifacts[0].id : null
}
output "codebuild_project" {
value = var.enable_cicd ? aws_codebuild_project.build[0].name : null
}
output "codebuild_role_arn" {
value = var.enable_cicd ? aws_iam_role.codebuild[0].arn : null
}
output "codebuild_security_group" {
value = var.enable_cicd ? aws_security_group.codebuild[0].id : null
}
output "ssm_prefix" {
value = "/${var.project_name}"
}

View File

@@ -0,0 +1,15 @@
# Example variables for 03-platform layer
# Copy to terraform.tfvars and update values
state_bucket = "myproject-terraform-state"
project_name = "myproject"
region = "us-east-1"
# Enable/disable CI/CD resources
enable_cicd = true
# ECR repositories to create
ecr_repos = ["base", "app", "worker"]
# Image retention (number of images to keep per repo)
ecr_image_retention_count = 30

View File

@@ -0,0 +1,466 @@
################################################################################
# Layer 04: Tenant - <TENANT_NAME>
#
# Creates tenant-specific resources:
# - Security Groups (tenant-scoped, blocks cross-tenant traffic)
# - IAM Roles with ABAC (can only access Tenant=X resources)
# - Budgets with alerts
#
# Usage:
# ./scripts/new-tenant.sh acme
# cd terraform/04-tenants/acme
# # Edit locals below
# terraform init -backend-config=../../00-bootstrap/backend.hcl
# terraform apply -var="state_bucket=YOUR_BUCKET"
################################################################################
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
backend "s3" {
key = "04-tenants/<TENANT_NAME>/terraform.tfstate"
}
}
################################################################################
# Configuration - UPDATE THESE
################################################################################
locals {
# Tenant name (max 20 chars, lowercase, alphanumeric + hyphen)
tenant = "<TENANT_NAME>"
# Environment
env = "prod" # prod, staging, dev
# Short prefix for resources (tenant-env, max 28 chars total)
prefix = "${local.tenant}-${local.env}"
# Apps with ports and budgets
apps = {
api = {
port = 8080
budget = 200
owner = "team@example.com"
}
web = {
port = 3000
budget = 100
owner = "team@example.com"
}
}
# Budget
budget = 500
alert_emails = ["ops@example.com"]
}
################################################################################
# Variables
################################################################################
variable "region" {
type = string
default = "us-east-1"
}
variable "state_bucket" {
type = string
}
################################################################################
# Provider
################################################################################
provider "aws" {
region = var.region
default_tags {
tags = {
Tenant = local.tenant
Environment = local.env
ManagedBy = "terraform"
}
}
}
################################################################################
# Data Sources
################################################################################
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = var.state_bucket
key = "02-network/terraform.tfstate"
region = var.region
}
}
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
################################################################################
# Security Group - Base (intra-tenant)
################################################################################
resource "aws_security_group" "base" {
name = "${local.prefix}-base"
description = "Base SG for ${local.tenant} - intra-tenant only"
vpc_id = data.terraform_remote_state.network.outputs.vpc_id
ingress {
description = "Self"
from_port = 0
to_port = 0
protocol = "-1"
self = true
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "${local.prefix}-base" }
}
################################################################################
# Security Group - Web (public)
################################################################################
resource "aws_security_group" "web" {
name = "${local.prefix}-web"
description = "Web SG for ${local.tenant}"
vpc_id = data.terraform_remote_state.network.outputs.vpc_id
ingress {
description = "HTTPS"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
description = "HTTP"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "${local.prefix}-web" }
}
################################################################################
# Security Group - Database
################################################################################
resource "aws_security_group" "db" {
name = "${local.prefix}-db"
description = "DB SG for ${local.tenant}"
vpc_id = data.terraform_remote_state.network.outputs.vpc_id
ingress {
description = "PostgreSQL"
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.base.id]
}
ingress {
description = "MySQL"
from_port = 3306
to_port = 3306
protocol = "tcp"
security_groups = [aws_security_group.base.id]
}
ingress {
description = "Redis"
from_port = 6379
to_port = 6379
protocol = "tcp"
security_groups = [aws_security_group.base.id]
}
tags = { Name = "${local.prefix}-db" }
}
################################################################################
# Security Groups - Per App
################################################################################
resource "aws_security_group" "app" {
for_each = { for k, v in local.apps : k => v if v.port > 0 }
name = "${local.prefix}-${each.key}"
description = "SG for ${local.tenant} ${each.key}"
vpc_id = data.terraform_remote_state.network.outputs.vpc_id
ingress {
description = "App port"
from_port = each.value.port
to_port = each.value.port
protocol = "tcp"
security_groups = [aws_security_group.base.id]
}
ingress {
description = "Self"
from_port = 0
to_port = 0
protocol = "-1"
self = true
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${local.prefix}-${each.key}"
App = each.key
}
}
################################################################################
# IAM Role - Admin (ABAC)
################################################################################
resource "aws_iam_role" "admin" {
name = "${local.prefix}-admin"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root" }
}]
})
tags = { Name = "${local.prefix}-admin" }
}
resource "aws_iam_role_policy" "admin" {
name = "abac"
role = aws_iam_role.admin.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowTagged"
Effect = "Allow"
Action = ["ec2:*", "ecs:*", "ecr:*", "lambda:*", "rds:*", "s3:*", "dynamodb:*", "logs:*", "cloudwatch:*", "ssm:*", "secretsmanager:*", "elasticloadbalancing:*"]
Resource = "*"
Condition = { StringEquals = { "aws:ResourceTag/Tenant" = local.tenant } }
},
{
Sid = "AllowDescribe"
Effect = "Allow"
Action = ["ec2:Describe*", "ecs:Describe*", "ecs:List*", "rds:Describe*", "s3:ListAllMyBuckets", "lambda:List*", "logs:Describe*", "elasticloadbalancing:Describe*"]
Resource = "*"
},
{
Sid = "AllowCreateTagged"
Effect = "Allow"
Action = ["ec2:RunInstances", "ec2:CreateVolume", "rds:CreateDBInstance", "s3:CreateBucket", "lambda:CreateFunction", "ecs:CreateCluster"]
Resource = "*"
Condition = { StringEquals = { "aws:RequestTag/Tenant" = local.tenant } }
},
{
Sid = "AllowTagging"
Effect = "Allow"
Action = ["ec2:CreateTags", "rds:AddTagsToResource", "s3:PutBucketTagging", "lambda:TagResource"]
Resource = "*"
Condition = { StringEquals = { "aws:RequestTag/Tenant" = local.tenant } }
}
]
})
}
################################################################################
# IAM Role - Developer (limited)
################################################################################
resource "aws_iam_role" "developer" {
name = "${local.prefix}-dev"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root" }
}]
})
tags = { Name = "${local.prefix}-dev" }
}
resource "aws_iam_role_policy" "developer" {
name = "dev-access"
role = aws_iam_role.developer.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "ReadOnly"
Effect = "Allow"
Action = ["ec2:Describe*", "ecs:Describe*", "ecs:List*", "logs:*", "cloudwatch:Get*", "cloudwatch:List*", "ssm:GetParameter*"]
Resource = "*"
},
{
Sid = "DeployLambda"
Effect = "Allow"
Action = ["lambda:UpdateFunctionCode", "lambda:UpdateFunctionConfiguration"]
Resource = "arn:aws:lambda:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:function:${local.tenant}-*"
}
]
})
}
################################################################################
# IAM Role - ReadOnly
################################################################################
resource "aws_iam_role" "readonly" {
name = "${local.prefix}-ro"
managed_policy_arns = ["arn:aws:iam::aws:policy/ReadOnlyAccess"]
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root" }
}]
})
tags = { Name = "${local.prefix}-ro" }
}
################################################################################
# Budget - Tenant Total
################################################################################
resource "aws_budgets_budget" "tenant" {
name = "${local.prefix}-total"
budget_type = "COST"
limit_amount = tostring(local.budget)
limit_unit = "USD"
time_unit = "MONTHLY"
cost_filter {
name = "TagKeyValue"
values = ["Tenant$${local.tenant}"]
}
notification {
comparison_operator = "GREATER_THAN"
notification_type = "ACTUAL"
threshold = 50
threshold_type = "PERCENTAGE"
subscriber_email_addresses = local.alert_emails
}
notification {
comparison_operator = "GREATER_THAN"
notification_type = "ACTUAL"
threshold = 80
threshold_type = "PERCENTAGE"
subscriber_email_addresses = local.alert_emails
}
notification {
comparison_operator = "GREATER_THAN"
notification_type = "FORECASTED"
threshold = 100
threshold_type = "PERCENTAGE"
subscriber_email_addresses = local.alert_emails
}
}
################################################################################
# Budget - Per App
################################################################################
resource "aws_budgets_budget" "app" {
for_each = local.apps
name = "${local.prefix}-${each.key}"
budget_type = "COST"
limit_amount = tostring(each.value.budget)
limit_unit = "USD"
time_unit = "MONTHLY"
cost_filter {
name = "TagKeyValue"
values = ["App$${each.key}"]
}
notification {
comparison_operator = "GREATER_THAN"
notification_type = "ACTUAL"
threshold = 90
threshold_type = "PERCENTAGE"
subscriber_email_addresses = [each.value.owner]
}
}
################################################################################
# Outputs
################################################################################
output "tenant" {
value = local.tenant
}
output "security_groups" {
value = {
base = aws_security_group.base.id
web = aws_security_group.web.id
db = aws_security_group.db.id
apps = { for k, v in aws_security_group.app : k => v.id }
}
}
output "iam_roles" {
value = {
admin = aws_iam_role.admin.arn
developer = aws_iam_role.developer.arn
readonly = aws_iam_role.readonly.arn
}
}
output "subnets" {
value = data.terraform_remote_state.network.outputs.private_subnet_ids
}
output "vpc_id" {
value = data.terraform_remote_state.network.outputs.vpc_id
}

View File

@@ -0,0 +1,532 @@
################################################################################
# Workload: API Gateway REST API
#
# Deploys a REST API with:
# - API Gateway with stages
# - Lambda or HTTP backend integrations
# - Custom domain with ACM
# - WAF integration (optional)
# - CloudWatch logging
# - Usage plans and API keys
#
# Usage:
# Copy this folder to 05-workloads/<tenant>-<api-name>/
# Update locals
# terraform init -backend-config=../../00-bootstrap/backend.hcl
# terraform apply
################################################################################
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
backend "s3" {
key = "05-workloads/<TENANT>-<NAME>-api/terraform.tfstate"
}
}
################################################################################
# Configuration - UPDATE THESE
################################################################################
locals {
# Naming
tenant = "<TENANT>"
name = "<NAME>"
env = "prod"
api_name = "${local.tenant}-${local.name}-${local.env}"
# API Type: REST or HTTP
api_type = "REST" # REST for full features, HTTP for simpler/cheaper
# Custom domain (set to null to skip)
domain_name = null # e.g., "api.example.com"
hosted_zone_id = null # Route53 zone ID
# WAF (requires waf-alb module deployed)
waf_acl_arn = null
# Stages
stages = ["prod", "staging"]
# Throttling defaults
throttle_burst_limit = 100
throttle_rate_limit = 50
# CloudWatch logging
logging_level = "INFO" # OFF, ERROR, INFO
# Lambda integrations (map of path -> lambda ARN)
lambda_integrations = {
# "GET /users" = "arn:aws:lambda:us-east-1:123456789012:function:get-users"
# "POST /users" = "arn:aws:lambda:us-east-1:123456789012:function:create-user"
# "GET /users/{id}" = "arn:aws:lambda:us-east-1:123456789012:function:get-user"
}
# HTTP proxy integrations (map of path -> HTTP endpoint)
http_integrations = {
# "GET /health" = "https://backend.example.com/health"
}
# Mock integrations for static responses
mock_integrations = {
"GET /health" = {
status_code = "200"
response = jsonencode({ status = "healthy" })
}
}
# CORS configuration
cors_enabled = true
cors_origins = ["*"]
cors_methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
cors_headers = ["Content-Type", "Authorization", "X-Api-Key"]
# API Keys and Usage Plans
create_api_key = true
usage_plans = {
basic = {
quota_limit = 1000
quota_period = "MONTH"
throttle_burst = 10
throttle_rate = 5
}
premium = {
quota_limit = 100000
quota_period = "MONTH"
throttle_burst = 100
throttle_rate = 50
}
}
}
################################################################################
# Variables
################################################################################
variable "region" {
type = string
default = "us-east-1"
}
variable "state_bucket" {
type = string
}
################################################################################
# Provider
################################################################################
provider "aws" {
region = var.region
default_tags {
tags = {
Tenant = local.tenant
App = local.name
Environment = local.env
ManagedBy = "terraform"
}
}
}
################################################################################
# Data Sources
################################################################################
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
################################################################################
# REST API
################################################################################
resource "aws_api_gateway_rest_api" "main" {
name = local.api_name
description = "REST API for ${local.tenant} ${local.name}"
endpoint_configuration {
types = ["REGIONAL"]
}
tags = { Name = local.api_name }
}
################################################################################
# CloudWatch Logging
################################################################################
resource "aws_cloudwatch_log_group" "api" {
name = "/aws/api-gateway/${local.api_name}"
retention_in_days = 30
tags = { Name = local.api_name }
}
resource "aws_api_gateway_account" "main" {
cloudwatch_role_arn = aws_iam_role.api_logging.arn
}
resource "aws_iam_role" "api_logging" {
name = "${local.api_name}-api-logging"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "apigateway.amazonaws.com" }
}]
})
tags = { Name = "${local.api_name}-api-logging" }
}
resource "aws_iam_role_policy_attachment" "api_logging" {
role = aws_iam_role.api_logging.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs"
}
################################################################################
# Mock Integration - Health Check
################################################################################
resource "aws_api_gateway_resource" "health" {
rest_api_id = aws_api_gateway_rest_api.main.id
parent_id = aws_api_gateway_rest_api.main.root_resource_id
path_part = "health"
}
resource "aws_api_gateway_method" "health_get" {
rest_api_id = aws_api_gateway_rest_api.main.id
resource_id = aws_api_gateway_resource.health.id
http_method = "GET"
authorization = "NONE"
}
resource "aws_api_gateway_integration" "health_get" {
rest_api_id = aws_api_gateway_rest_api.main.id
resource_id = aws_api_gateway_resource.health.id
http_method = aws_api_gateway_method.health_get.http_method
type = "MOCK"
request_templates = {
"application/json" = jsonencode({ statusCode = 200 })
}
}
resource "aws_api_gateway_method_response" "health_get" {
rest_api_id = aws_api_gateway_rest_api.main.id
resource_id = aws_api_gateway_resource.health.id
http_method = aws_api_gateway_method.health_get.http_method
status_code = "200"
response_models = {
"application/json" = "Empty"
}
response_parameters = {
"method.response.header.Access-Control-Allow-Origin" = true
}
}
resource "aws_api_gateway_integration_response" "health_get" {
rest_api_id = aws_api_gateway_rest_api.main.id
resource_id = aws_api_gateway_resource.health.id
http_method = aws_api_gateway_method.health_get.http_method
status_code = aws_api_gateway_method_response.health_get.status_code
response_templates = {
"application/json" = jsonencode({
status = "healthy"
timestamp = "$context.requestTime"
})
}
response_parameters = {
"method.response.header.Access-Control-Allow-Origin" = "'*'"
}
depends_on = [aws_api_gateway_integration.health_get]
}
################################################################################
# CORS - OPTIONS method for health
################################################################################
resource "aws_api_gateway_method" "health_options" {
count = local.cors_enabled ? 1 : 0
rest_api_id = aws_api_gateway_rest_api.main.id
resource_id = aws_api_gateway_resource.health.id
http_method = "OPTIONS"
authorization = "NONE"
}
resource "aws_api_gateway_integration" "health_options" {
count = local.cors_enabled ? 1 : 0
rest_api_id = aws_api_gateway_rest_api.main.id
resource_id = aws_api_gateway_resource.health.id
http_method = aws_api_gateway_method.health_options[0].http_method
type = "MOCK"
request_templates = {
"application/json" = jsonencode({ statusCode = 200 })
}
}
resource "aws_api_gateway_method_response" "health_options" {
count = local.cors_enabled ? 1 : 0
rest_api_id = aws_api_gateway_rest_api.main.id
resource_id = aws_api_gateway_resource.health.id
http_method = aws_api_gateway_method.health_options[0].http_method
status_code = "200"
response_parameters = {
"method.response.header.Access-Control-Allow-Headers" = true
"method.response.header.Access-Control-Allow-Methods" = true
"method.response.header.Access-Control-Allow-Origin" = true
}
}
resource "aws_api_gateway_integration_response" "health_options" {
count = local.cors_enabled ? 1 : 0
rest_api_id = aws_api_gateway_rest_api.main.id
resource_id = aws_api_gateway_resource.health.id
http_method = aws_api_gateway_method.health_options[0].http_method
status_code = "200"
response_parameters = {
"method.response.header.Access-Control-Allow-Headers" = "'${join(",", local.cors_headers)}'"
"method.response.header.Access-Control-Allow-Methods" = "'${join(",", local.cors_methods)}'"
"method.response.header.Access-Control-Allow-Origin" = "'${join(",", local.cors_origins)}'"
}
depends_on = [aws_api_gateway_integration.health_options]
}
################################################################################
# Deployment & Stages
################################################################################
resource "aws_api_gateway_deployment" "main" {
rest_api_id = aws_api_gateway_rest_api.main.id
triggers = {
redeployment = sha1(jsonencode([
aws_api_gateway_resource.health.id,
aws_api_gateway_method.health_get.id,
aws_api_gateway_integration.health_get.id,
]))
}
lifecycle {
create_before_destroy = true
}
depends_on = [
aws_api_gateway_integration.health_get,
aws_api_gateway_integration_response.health_get,
]
}
resource "aws_api_gateway_stage" "stages" {
for_each = toset(local.stages)
deployment_id = aws_api_gateway_deployment.main.id
rest_api_id = aws_api_gateway_rest_api.main.id
stage_name = each.value
access_log_settings {
destination_arn = aws_cloudwatch_log_group.api.arn
format = jsonencode({
requestId = "$context.requestId"
ip = "$context.identity.sourceIp"
caller = "$context.identity.caller"
user = "$context.identity.user"
requestTime = "$context.requestTime"
httpMethod = "$context.httpMethod"
resourcePath = "$context.resourcePath"
status = "$context.status"
protocol = "$context.protocol"
responseLength = "$context.responseLength"
integrationLatency = "$context.integrationLatency"
})
}
tags = { Name = "${local.api_name}-${each.value}" }
}
resource "aws_api_gateway_method_settings" "stages" {
for_each = toset(local.stages)
rest_api_id = aws_api_gateway_rest_api.main.id
stage_name = aws_api_gateway_stage.stages[each.value].stage_name
method_path = "*/*"
settings {
logging_level = local.logging_level
data_trace_enabled = local.logging_level != "OFF"
metrics_enabled = true
throttling_burst_limit = local.throttle_burst_limit
throttling_rate_limit = local.throttle_rate_limit
}
}
################################################################################
# WAF Association (Optional)
################################################################################
resource "aws_wafv2_web_acl_association" "api" {
count = local.waf_acl_arn != null ? length(local.stages) : 0
resource_arn = aws_api_gateway_stage.stages[local.stages[count.index]].arn
web_acl_arn = local.waf_acl_arn
}
################################################################################
# Custom Domain (Optional)
################################################################################
resource "aws_acm_certificate" "api" {
count = local.domain_name != null ? 1 : 0
domain_name = local.domain_name
validation_method = "DNS"
lifecycle {
create_before_destroy = true
}
tags = { Name = local.domain_name }
}
resource "aws_route53_record" "cert_validation" {
for_each = local.domain_name != null ? {
for dvo in aws_acm_certificate.api[0].domain_validation_options : dvo.domain_name => {
name = dvo.resource_record_name
record = dvo.resource_record_value
type = dvo.resource_record_type
}
} : {}
zone_id = local.hosted_zone_id
name = each.value.name
type = each.value.type
records = [each.value.record]
ttl = 60
}
resource "aws_acm_certificate_validation" "api" {
count = local.domain_name != null ? 1 : 0
certificate_arn = aws_acm_certificate.api[0].arn
validation_record_fqdns = [for record in aws_route53_record.cert_validation : record.fqdn]
}
resource "aws_api_gateway_domain_name" "main" {
count = local.domain_name != null ? 1 : 0
domain_name = local.domain_name
certificate_arn = aws_acm_certificate_validation.api[0].certificate_arn
endpoint_configuration {
types = ["REGIONAL"]
}
tags = { Name = local.domain_name }
}
resource "aws_api_gateway_base_path_mapping" "main" {
count = local.domain_name != null ? 1 : 0
api_id = aws_api_gateway_rest_api.main.id
stage_name = aws_api_gateway_stage.stages["prod"].stage_name
domain_name = aws_api_gateway_domain_name.main[0].domain_name
}
resource "aws_route53_record" "api" {
count = local.domain_name != null ? 1 : 0
zone_id = local.hosted_zone_id
name = local.domain_name
type = "A"
alias {
name = aws_api_gateway_domain_name.main[0].regional_domain_name
zone_id = aws_api_gateway_domain_name.main[0].regional_zone_id
evaluate_target_health = false
}
}
################################################################################
# API Keys & Usage Plans
################################################################################
resource "aws_api_gateway_api_key" "main" {
count = local.create_api_key ? 1 : 0
name = "${local.api_name}-key"
enabled = true
tags = { Name = "${local.api_name}-key" }
}
resource "aws_api_gateway_usage_plan" "plans" {
for_each = local.usage_plans
name = "${local.api_name}-${each.key}"
api_stages {
api_id = aws_api_gateway_rest_api.main.id
stage = aws_api_gateway_stage.stages["prod"].stage_name
}
quota_settings {
limit = each.value.quota_limit
period = each.value.quota_period
}
throttle_settings {
burst_limit = each.value.throttle_burst
rate_limit = each.value.throttle_rate
}
tags = { Name = "${local.api_name}-${each.key}" }
}
resource "aws_api_gateway_usage_plan_key" "main" {
count = local.create_api_key ? 1 : 0
key_id = aws_api_gateway_api_key.main[0].id
key_type = "API_KEY"
usage_plan_id = aws_api_gateway_usage_plan.plans["basic"].id
}
################################################################################
# Outputs
################################################################################
output "api_id" {
value = aws_api_gateway_rest_api.main.id
}
output "api_name" {
value = aws_api_gateway_rest_api.main.name
}
output "stage_urls" {
value = { for stage in local.stages : stage => aws_api_gateway_stage.stages[stage].invoke_url }
}
output "custom_domain_url" {
value = local.domain_name != null ? "https://${local.domain_name}" : null
}
output "api_key" {
value = local.create_api_key ? aws_api_gateway_api_key.main[0].value : null
sensitive = true
}
output "health_endpoint" {
value = "${aws_api_gateway_stage.stages["prod"].invoke_url}health"
}

View File

@@ -0,0 +1,473 @@
################################################################################
# Workload: Aurora Serverless v2
#
# Auto-scaling PostgreSQL/MySQL with:
# - Scale to zero (cost savings for dev)
# - Instant scaling (0.5 ACU increments)
# - Multi-AZ by default
# - IAM authentication
# - Data API (HTTP queries)
# - Secrets Manager integration
#
# Cost: ~$0.12/ACU-hour (scales 0.5-128 ACUs)
# Use cases: Variable workloads, dev/test, bursty traffic
################################################################################
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
random = {
source = "hashicorp/random"
version = ">= 3.0"
}
}
backend "s3" {
key = "05-workloads/<TENANT>-<NAME>-aurora/terraform.tfstate"
}
}
################################################################################
# Configuration - UPDATE THESE
################################################################################
locals {
# Naming
tenant = "<TENANT>"
name = "<NAME>"
env = "prod"
cluster_name = "${local.tenant}-${local.name}-${local.env}"
# Engine
engine = "aurora-postgresql" # aurora-postgresql or aurora-mysql
engine_version = "15.4" # PostgreSQL 15.4 / MySQL 8.0
# Serverless v2 capacity
min_capacity = 0.5 # Minimum ACUs (0.5 = scale to near-zero)
max_capacity = 16 # Maximum ACUs (adjust based on needs)
# For true scale-to-zero (pauses after idle):
# Note: Only available in some regions
enable_pause = false
pause_after_seconds = 300 # 5 minutes idle
# Database
database_name = replace(local.name, "-", "_")
port = local.engine == "aurora-postgresql" ? 5432 : 3306
# Master credentials (stored in Secrets Manager)
master_username = "admin"
# Network (get from remote state or hardcode)
vpc_id = "" # data.terraform_remote_state.network.outputs.vpc_id
private_subnet_ids = [] # data.terraform_remote_state.network.outputs.private_subnet_ids
# Features
enable_iam_auth = true
enable_data_api = true # HTTP Data API (for Lambda/serverless)
enable_performance_insights = true
performance_insights_retention = 7 # days (7 = free tier)
# Backup
backup_retention_period = 7
preferred_backup_window = "03:00-04:00"
# Maintenance
preferred_maintenance_window = "sun:04:00-sun:05:00"
auto_minor_version_upgrade = true
# Deletion protection (enable for production)
deletion_protection = local.env == "prod"
skip_final_snapshot = local.env != "prod"
}
################################################################################
# Variables
################################################################################
variable "region" {
type = string
default = "us-east-1"
}
variable "state_bucket" {
type = string
}
################################################################################
# Provider
################################################################################
provider "aws" {
region = var.region
default_tags {
tags = {
Tenant = local.tenant
App = local.name
Environment = local.env
ManagedBy = "terraform"
}
}
}
################################################################################
# Data Sources
################################################################################
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
################################################################################
# Random Password
################################################################################
resource "random_password" "master" {
length = 32
special = false # Aurora has special char restrictions
}
################################################################################
# Secrets Manager
################################################################################
resource "aws_secretsmanager_secret" "db" {
name = "${local.tenant}/${local.env}/${local.name}/aurora"
description = "Aurora Serverless credentials for ${local.cluster_name}"
tags = { Name = "${local.cluster_name}-credentials" }
}
resource "aws_secretsmanager_secret_version" "db" {
secret_id = aws_secretsmanager_secret.db.id
secret_string = jsonencode({
username = local.master_username
password = random_password.master.result
engine = local.engine
host = aws_rds_cluster.main.endpoint
port = local.port
dbname = local.database_name
dbClusterIdentifier = aws_rds_cluster.main.id
})
}
################################################################################
# Security Group
################################################################################
resource "aws_security_group" "aurora" {
count = length(local.vpc_id) > 0 ? 1 : 0
name = "${local.cluster_name}-aurora"
vpc_id = local.vpc_id
ingress {
description = "Database from VPC"
from_port = local.port
to_port = local.port
protocol = "tcp"
cidr_blocks = ["10.0.0.0/8"] # Adjust to your VPC CIDR
}
egress {
description = "All outbound"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "${local.cluster_name}-aurora" }
}
################################################################################
# DB Subnet Group
################################################################################
resource "aws_db_subnet_group" "main" {
count = length(local.private_subnet_ids) > 0 ? 1 : 0
name = local.cluster_name
subnet_ids = local.private_subnet_ids
tags = { Name = local.cluster_name }
}
################################################################################
# Aurora Serverless v2 Cluster
################################################################################
resource "aws_rds_cluster" "main" {
cluster_identifier = local.cluster_name
engine = local.engine
engine_mode = "provisioned" # Required for Serverless v2
engine_version = local.engine_version
database_name = local.database_name
master_username = local.master_username
master_password = random_password.master.result
port = local.port
# Serverless v2 scaling
serverlessv2_scaling_configuration {
min_capacity = local.min_capacity
max_capacity = local.max_capacity
}
# Network
db_subnet_group_name = length(aws_db_subnet_group.main) > 0 ? aws_db_subnet_group.main[0].name : null
vpc_security_group_ids = length(aws_security_group.aurora) > 0 ? [aws_security_group.aurora[0].id] : []
# Storage
storage_encrypted = true
kms_key_id = null # Uses AWS managed key
# Features
enable_http_endpoint = local.enable_data_api
iam_database_authentication_enabled = local.enable_iam_auth
# Backup
backup_retention_period = local.backup_retention_period
preferred_backup_window = local.preferred_backup_window
copy_tags_to_snapshot = true
skip_final_snapshot = local.skip_final_snapshot
final_snapshot_identifier = local.skip_final_snapshot ? null : "${local.cluster_name}-final"
# Maintenance
preferred_maintenance_window = local.preferred_maintenance_window
apply_immediately = false
# Protection
deletion_protection = local.deletion_protection
tags = { Name = local.cluster_name }
lifecycle {
ignore_changes = [
master_password, # Managed in Secrets Manager
]
}
}
################################################################################
# Aurora Serverless v2 Instance
################################################################################
resource "aws_rds_cluster_instance" "main" {
count = 1 # Add more for read replicas
identifier = "${local.cluster_name}-${count.index + 1}"
cluster_identifier = aws_rds_cluster.main.id
instance_class = "db.serverless" # Required for Serverless v2
engine = local.engine
engine_version = local.engine_version
# Performance Insights
performance_insights_enabled = local.enable_performance_insights
performance_insights_retention_period = local.enable_performance_insights ? local.performance_insights_retention : null
# Maintenance
auto_minor_version_upgrade = local.auto_minor_version_upgrade
tags = { Name = "${local.cluster_name}-${count.index + 1}" }
}
################################################################################
# IAM Role for IAM Authentication
################################################################################
resource "aws_iam_policy" "db_connect" {
count = local.enable_iam_auth ? 1 : 0
name = "${local.cluster_name}-db-connect"
description = "IAM authentication to ${local.cluster_name}"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowDBConnect"
Effect = "Allow"
Action = "rds-db:connect"
Resource = "arn:aws:rds-db:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:dbuser:${aws_rds_cluster.main.cluster_resource_id}/*"
}
]
})
tags = { Name = "${local.cluster_name}-db-connect" }
}
################################################################################
# Data API Access Policy
################################################################################
resource "aws_iam_policy" "data_api" {
count = local.enable_data_api ? 1 : 0
name = "${local.cluster_name}-data-api"
description = "Data API access to ${local.cluster_name}"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "ExecuteStatement"
Effect = "Allow"
Action = [
"rds-data:ExecuteStatement",
"rds-data:BatchExecuteStatement",
"rds-data:BeginTransaction",
"rds-data:CommitTransaction",
"rds-data:RollbackTransaction"
]
Resource = aws_rds_cluster.main.arn
},
{
Sid = "GetSecret"
Effect = "Allow"
Action = "secretsmanager:GetSecretValue"
Resource = aws_secretsmanager_secret.db.arn
}
]
})
tags = { Name = "${local.cluster_name}-data-api" }
}
################################################################################
# CloudWatch Alarms
################################################################################
resource "aws_cloudwatch_metric_alarm" "cpu" {
alarm_name = "${local.cluster_name}-cpu-high"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
metric_name = "CPUUtilization"
namespace = "AWS/RDS"
period = 300
statistic = "Average"
threshold = 80
alarm_description = "Aurora CPU > 80%"
dimensions = {
DBClusterIdentifier = aws_rds_cluster.main.id
}
tags = { Name = "${local.cluster_name}-cpu-high" }
}
resource "aws_cloudwatch_metric_alarm" "connections" {
alarm_name = "${local.cluster_name}-connections-high"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
metric_name = "DatabaseConnections"
namespace = "AWS/RDS"
period = 300
statistic = "Average"
threshold = 100
alarm_description = "Aurora connections > 100"
dimensions = {
DBClusterIdentifier = aws_rds_cluster.main.id
}
tags = { Name = "${local.cluster_name}-connections-high" }
}
resource "aws_cloudwatch_metric_alarm" "capacity" {
alarm_name = "${local.cluster_name}-acu-high"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 3
metric_name = "ServerlessDatabaseCapacity"
namespace = "AWS/RDS"
period = 300
statistic = "Average"
threshold = local.max_capacity * 0.8
alarm_description = "Aurora ACU > 80% of max"
dimensions = {
DBClusterIdentifier = aws_rds_cluster.main.id
}
tags = { Name = "${local.cluster_name}-acu-high" }
}
################################################################################
# Outputs
################################################################################
output "cluster_endpoint" {
value = aws_rds_cluster.main.endpoint
description = "Writer endpoint"
}
output "reader_endpoint" {
value = aws_rds_cluster.main.reader_endpoint
description = "Reader endpoint"
}
output "cluster_arn" {
value = aws_rds_cluster.main.arn
description = "Cluster ARN"
}
output "cluster_id" {
value = aws_rds_cluster.main.id
description = "Cluster identifier"
}
output "port" {
value = local.port
description = "Database port"
}
output "database_name" {
value = local.database_name
description = "Database name"
}
output "secret_arn" {
value = aws_secretsmanager_secret.db.arn
description = "Secrets Manager ARN"
}
output "iam_auth_policy_arn" {
value = length(aws_iam_policy.db_connect) > 0 ? aws_iam_policy.db_connect[0].arn : null
description = "IAM policy for database authentication"
}
output "data_api_policy_arn" {
value = length(aws_iam_policy.data_api) > 0 ? aws_iam_policy.data_api[0].arn : null
description = "IAM policy for Data API access"
}
output "connection_string" {
value = "${local.engine == "aurora-postgresql" ? "postgresql" : "mysql"}://${local.master_username}:****@${aws_rds_cluster.main.endpoint}:${local.port}/${local.database_name}"
description = "Connection string template (password in Secrets Manager)"
sensitive = false
}
output "data_api_example" {
value = local.enable_data_api ? <<-EOF
aws rds-data execute-statement \
--resource-arn '${aws_rds_cluster.main.arn}' \
--secret-arn '${aws_secretsmanager_secret.db.arn}' \
--database '${local.database_name}' \
--sql 'SELECT NOW()'
EOF
: null
description = "Data API example command"
}
output "cost_estimate" {
value = {
acu_hour = "$0.12/ACU-hour"
min_idle = "$${local.min_capacity * 0.12 * 24 * 30}/month (${local.min_capacity} ACU 24/7)"
storage = "$0.10/GB-month"
io = "$0.20/million requests"
data_api = "$0.35/million Data API requests"
}
description = "Cost breakdown"
}

View File

@@ -0,0 +1,501 @@
################################################################################
# Workload: Cognito User Pool
#
# User authentication infrastructure:
# - User Pool with customizable password policy
# - App clients (web, mobile, machine-to-machine)
# - Identity Pool for AWS credential federation
# - Social/SAML/OIDC identity providers
# - Custom domain
# - Lambda triggers (pre/post auth, migration)
#
# Use cases: Web/mobile auth, B2C apps, admin portals
################################################################################
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
backend "s3" {
key = "05-workloads/<TENANT>-<NAME>-auth/terraform.tfstate"
}
}
################################################################################
# Configuration - UPDATE THESE
################################################################################
locals {
# Naming
tenant = "<TENANT>"
name = "<NAME>"
env = "prod"
pool_name = "${local.tenant}-${local.name}-${local.env}"
# Email configuration
email_sending_account = "COGNITO_DEFAULT" # COGNITO_DEFAULT or DEVELOPER
ses_email_from = null # Required if DEVELOPER
# Password policy
password_minimum_length = 12
password_require_lowercase = true
password_require_numbers = true
password_require_symbols = true
password_require_uppercase = true
temporary_password_validity_days = 7
# MFA
mfa_configuration = "OPTIONAL" # OFF, ON, OPTIONAL
mfa_methods = ["SOFTWARE_TOKEN_MFA"] # SOFTWARE_TOKEN_MFA, SMS_MFA
# Account recovery
recovery_mechanisms = [
{ name = "verified_email", priority = 1 },
{ name = "verified_phone_number", priority = 2 }
]
# User attributes
auto_verified_attributes = ["email"]
username_attributes = ["email"] # email, phone_number
alias_attributes = [] # email, phone_number, preferred_username
# Custom attributes
custom_attributes = {
# "tenant_id" = {
# type = "String"
# mutable = false
# min_length = 1
# max_length = 50
# }
}
# App clients
app_clients = {
web = {
generate_secret = false
explicit_auth_flows = ["ALLOW_USER_SRP_AUTH", "ALLOW_REFRESH_TOKEN_AUTH"]
supported_identity_providers = ["COGNITO"]
callback_urls = ["https://example.com/callback"]
logout_urls = ["https://example.com/logout"]
allowed_oauth_flows = ["code"]
allowed_oauth_scopes = ["email", "openid", "profile"]
allowed_oauth_flows_user_pool_client = true
access_token_validity = 60 # minutes
id_token_validity = 60
refresh_token_validity = 30 # days
}
mobile = {
generate_secret = false
explicit_auth_flows = ["ALLOW_USER_SRP_AUTH", "ALLOW_REFRESH_TOKEN_AUTH"]
supported_identity_providers = ["COGNITO"]
callback_urls = ["myapp://callback"]
logout_urls = ["myapp://logout"]
allowed_oauth_flows = ["code"]
allowed_oauth_scopes = ["email", "openid", "profile"]
allowed_oauth_flows_user_pool_client = true
access_token_validity = 60
id_token_validity = 60
refresh_token_validity = 30
}
# m2m = {
# generate_secret = true
# explicit_auth_flows = ["ALLOW_ADMIN_USER_PASSWORD_AUTH"]
# supported_identity_providers = ["COGNITO"]
# allowed_oauth_flows = ["client_credentials"]
# allowed_oauth_scopes = ["api/read", "api/write"]
# allowed_oauth_flows_user_pool_client = true
# }
}
# Custom domain (requires ACM cert in us-east-1 for CloudFront)
custom_domain = null # e.g., "auth.example.com"
custom_domain_cert = null # ACM certificate ARN
hosted_zone_id = null
# Identity Pool (for AWS credential federation)
enable_identity_pool = false
# Lambda triggers
lambda_triggers = {
# pre_sign_up = "arn:aws:lambda:..."
# post_confirmation = "arn:aws:lambda:..."
# pre_authentication = "arn:aws:lambda:..."
# post_authentication = "arn:aws:lambda:..."
# pre_token_generation = "arn:aws:lambda:..."
# user_migration = "arn:aws:lambda:..."
# custom_message = "arn:aws:lambda:..."
}
# Social identity providers
social_providers = {
# google = {
# client_id = "..."
# client_secret = "..."
# scopes = ["email", "profile", "openid"]
# }
# facebook = {
# client_id = "..."
# client_secret = "..."
# scopes = ["email", "public_profile"]
# }
}
}
################################################################################
# Variables
################################################################################
variable "region" {
type = string
default = "us-east-1"
}
variable "state_bucket" {
type = string
}
################################################################################
# Provider
################################################################################
provider "aws" {
region = var.region
default_tags {
tags = {
Tenant = local.tenant
App = local.name
Environment = local.env
ManagedBy = "terraform"
}
}
}
################################################################################
# Data Sources
################################################################################
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
################################################################################
# Cognito User Pool
################################################################################
resource "aws_cognito_user_pool" "main" {
name = local.pool_name
# Username configuration
username_attributes = local.username_attributes
alias_attributes = length(local.alias_attributes) > 0 ? local.alias_attributes : null
auto_verified_attributes = local.auto_verified_attributes
# Password policy
password_policy {
minimum_length = local.password_minimum_length
require_lowercase = local.password_require_lowercase
require_numbers = local.password_require_numbers
require_symbols = local.password_require_symbols
require_uppercase = local.password_require_uppercase
temporary_password_validity_days = local.temporary_password_validity_days
}
# MFA
mfa_configuration = local.mfa_configuration
dynamic "software_token_mfa_configuration" {
for_each = contains(local.mfa_methods, "SOFTWARE_TOKEN_MFA") && local.mfa_configuration != "OFF" ? [1] : []
content {
enabled = true
}
}
# Account recovery
account_recovery_setting {
dynamic "recovery_mechanism" {
for_each = local.recovery_mechanisms
content {
name = recovery_mechanism.value.name
priority = recovery_mechanism.value.priority
}
}
}
# Email configuration
email_configuration {
email_sending_account = local.email_sending_account
source_arn = local.email_sending_account == "DEVELOPER" ? local.ses_email_from : null
}
# User attribute verification
user_attribute_update_settings {
attributes_require_verification_before_update = ["email"]
}
# Admin create user config
admin_create_user_config {
allow_admin_create_user_only = false
invite_message_template {
email_subject = "Your ${local.pool_name} account"
email_message = "Your username is {username} and temporary password is {####}"
sms_message = "Your username is {username} and temporary password is {####}"
}
}
# Verification message
verification_message_template {
default_email_option = "CONFIRM_WITH_CODE"
email_subject = "Verify your email for ${local.pool_name}"
email_message = "Your verification code is {####}"
}
# Schema (custom attributes)
dynamic "schema" {
for_each = local.custom_attributes
content {
name = schema.key
attribute_data_type = schema.value.type
mutable = schema.value.mutable
required = false
developer_only_attribute = false
dynamic "string_attribute_constraints" {
for_each = schema.value.type == "String" ? [1] : []
content {
min_length = lookup(schema.value, "min_length", 0)
max_length = lookup(schema.value, "max_length", 2048)
}
}
dynamic "number_attribute_constraints" {
for_each = schema.value.type == "Number" ? [1] : []
content {
min_value = lookup(schema.value, "min_value", null)
max_value = lookup(schema.value, "max_value", null)
}
}
}
}
# Lambda triggers
lambda_config {
pre_sign_up = lookup(local.lambda_triggers, "pre_sign_up", null)
post_confirmation = lookup(local.lambda_triggers, "post_confirmation", null)
pre_authentication = lookup(local.lambda_triggers, "pre_authentication", null)
post_authentication = lookup(local.lambda_triggers, "post_authentication", null)
pre_token_generation = lookup(local.lambda_triggers, "pre_token_generation", null)
user_migration = lookup(local.lambda_triggers, "user_migration", null)
custom_message = lookup(local.lambda_triggers, "custom_message", null)
}
tags = { Name = local.pool_name }
}
################################################################################
# User Pool Domain
################################################################################
resource "aws_cognito_user_pool_domain" "main" {
domain = local.custom_domain != null ? local.custom_domain : local.pool_name
user_pool_id = aws_cognito_user_pool.main.id
certificate_arn = local.custom_domain_cert
}
# Route53 record for custom domain
resource "aws_route53_record" "cognito" {
count = local.custom_domain != null ? 1 : 0
zone_id = local.hosted_zone_id
name = local.custom_domain
type = "A"
alias {
name = aws_cognito_user_pool_domain.main.cloudfront_distribution_arn
zone_id = "Z2FDTNDATAQYW2" # CloudFront zone ID
evaluate_target_health = false
}
}
################################################################################
# App Clients
################################################################################
resource "aws_cognito_user_pool_client" "clients" {
for_each = local.app_clients
name = "${local.pool_name}-${each.key}"
user_pool_id = aws_cognito_user_pool.main.id
generate_secret = each.value.generate_secret
explicit_auth_flows = each.value.explicit_auth_flows
supported_identity_providers = each.value.supported_identity_providers
callback_urls = lookup(each.value, "callback_urls", null)
logout_urls = lookup(each.value, "logout_urls", null)
allowed_oauth_flows = lookup(each.value, "allowed_oauth_flows", null)
allowed_oauth_scopes = lookup(each.value, "allowed_oauth_scopes", null)
allowed_oauth_flows_user_pool_client = lookup(each.value, "allowed_oauth_flows_user_pool_client", false)
access_token_validity = lookup(each.value, "access_token_validity", 60)
id_token_validity = lookup(each.value, "id_token_validity", 60)
refresh_token_validity = lookup(each.value, "refresh_token_validity", 30)
token_validity_units {
access_token = "minutes"
id_token = "minutes"
refresh_token = "days"
}
prevent_user_existence_errors = "ENABLED"
enable_token_revocation = true
}
################################################################################
# Social Identity Providers
################################################################################
resource "aws_cognito_identity_provider" "google" {
count = contains(keys(local.social_providers), "google") ? 1 : 0
user_pool_id = aws_cognito_user_pool.main.id
provider_name = "Google"
provider_type = "Google"
provider_details = {
client_id = local.social_providers.google.client_id
client_secret = local.social_providers.google.client_secret
authorize_scopes = join(" ", local.social_providers.google.scopes)
}
attribute_mapping = {
email = "email"
username = "sub"
}
}
resource "aws_cognito_identity_provider" "facebook" {
count = contains(keys(local.social_providers), "facebook") ? 1 : 0
user_pool_id = aws_cognito_user_pool.main.id
provider_name = "Facebook"
provider_type = "Facebook"
provider_details = {
client_id = local.social_providers.facebook.client_id
client_secret = local.social_providers.facebook.client_secret
authorize_scopes = join(",", local.social_providers.facebook.scopes)
}
attribute_mapping = {
email = "email"
username = "id"
}
}
################################################################################
# Identity Pool (Optional)
################################################################################
resource "aws_cognito_identity_pool" "main" {
count = local.enable_identity_pool ? 1 : 0
identity_pool_name = replace(local.pool_name, "-", "_")
allow_unauthenticated_identities = false
cognito_identity_providers {
client_id = aws_cognito_user_pool_client.clients["web"].id
provider_name = aws_cognito_user_pool.main.endpoint
server_side_token_check = true
}
tags = { Name = local.pool_name }
}
resource "aws_iam_role" "authenticated" {
count = local.enable_identity_pool ? 1 : 0
name = "${local.pool_name}-authenticated"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
Federated = "cognito-identity.amazonaws.com"
}
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"cognito-identity.amazonaws.com:aud" = aws_cognito_identity_pool.main[0].id
}
"ForAnyValue:StringLike" = {
"cognito-identity.amazonaws.com:amr" = "authenticated"
}
}
}]
})
tags = { Name = "${local.pool_name}-authenticated" }
}
resource "aws_iam_role_policy" "authenticated" {
count = local.enable_identity_pool ? 1 : 0
name = "authenticated-policy"
role = aws_iam_role.authenticated[0].id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = [
"mobileanalytics:PutEvents",
"cognito-sync:*",
"cognito-identity:*"
]
Resource = "*"
}]
})
}
resource "aws_cognito_identity_pool_roles_attachment" "main" {
count = local.enable_identity_pool ? 1 : 0
identity_pool_id = aws_cognito_identity_pool.main[0].id
roles = {
authenticated = aws_iam_role.authenticated[0].arn
}
}
################################################################################
# Outputs
################################################################################
output "user_pool_id" {
value = aws_cognito_user_pool.main.id
}
output "user_pool_arn" {
value = aws_cognito_user_pool.main.arn
}
output "user_pool_endpoint" {
value = aws_cognito_user_pool.main.endpoint
}
output "user_pool_domain" {
value = local.custom_domain != null ? "https://${local.custom_domain}" : "https://${aws_cognito_user_pool_domain.main.domain}.auth.${data.aws_region.current.name}.amazoncognito.com"
}
output "client_ids" {
value = { for k, v in aws_cognito_user_pool_client.clients : k => v.id }
}
output "identity_pool_id" {
value = local.enable_identity_pool ? aws_cognito_identity_pool.main[0].id : null
}
output "hosted_ui_url" {
value = "${local.custom_domain != null ? "https://${local.custom_domain}" : "https://${aws_cognito_user_pool_domain.main.domain}.auth.${data.aws_region.current.name}.amazoncognito.com"}/login?client_id=${aws_cognito_user_pool_client.clients["web"].id}&response_type=code&redirect_uri=${urlencode(local.app_clients.web.callback_urls[0])}"
}

View File

@@ -0,0 +1,439 @@
################################################################################
# Workload: DynamoDB Table
#
# Deploys a NoSQL database table:
# - On-demand or provisioned capacity
# - Encryption at rest with KMS
# - Point-in-time recovery
# - TTL support
# - Global Secondary Indexes
# - Streams for event-driven patterns
#
# Usage:
# Copy this folder to 05-workloads/<tenant>-<table-name>/
# Update locals and variables
# terraform init -backend-config=../../00-bootstrap/backend.hcl
# terraform apply
################################################################################
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
backend "s3" {
key = "05-workloads/<TENANT>-<NAME>-table/terraform.tfstate"
}
}
################################################################################
# Configuration - UPDATE THESE
################################################################################
locals {
# Naming
tenant = "<TENANT>"
name = "<NAME>"
env = "prod"
table_name = "${local.tenant}-${local.name}-${local.env}"
# Capacity mode: "PAY_PER_REQUEST" (on-demand) or "PROVISIONED"
billing_mode = "PAY_PER_REQUEST"
# Provisioned capacity (only used if billing_mode = "PROVISIONED")
read_capacity = 5
write_capacity = 5
# Auto-scaling for provisioned mode
enable_autoscaling = local.billing_mode == "PROVISIONED"
autoscaling_min_read = 5
autoscaling_max_read = 100
autoscaling_min_write = 5
autoscaling_max_write = 100
autoscaling_target_utilization = 70
# Primary key
hash_key = "pk" # Partition key
hash_key_type = "S" # S = String, N = Number, B = Binary
range_key = "sk" # Sort key (optional, set to null to disable)
range_key_type = "S"
# TTL (set to null to disable)
ttl_attribute = "ttl"
# Streams (set to null to disable)
# Options: KEYS_ONLY, NEW_IMAGE, OLD_IMAGE, NEW_AND_OLD_IMAGES
stream_view_type = null
# Point-in-time recovery
point_in_time_recovery = true
# Global Secondary Indexes (GSI)
global_secondary_indexes = [
# {
# name = "gsi1"
# hash_key = "gsi1pk"
# range_key = "gsi1sk"
# projection_type = "ALL" # ALL, KEYS_ONLY, or INCLUDE
# non_key_attributes = [] # Only for INCLUDE
# }
]
# Local Secondary Indexes (LSI) - must be defined at table creation
local_secondary_indexes = [
# {
# name = "lsi1"
# range_key = "lsi1sk"
# projection_type = "ALL"
# non_key_attributes = []
# }
]
# Table class: STANDARD or STANDARD_INFREQUENT_ACCESS
table_class = "STANDARD"
}
################################################################################
# Variables
################################################################################
variable "region" {
type = string
default = "us-east-1"
}
variable "state_bucket" {
type = string
}
################################################################################
# Provider
################################################################################
provider "aws" {
region = var.region
default_tags {
tags = {
Tenant = local.tenant
App = local.name
Environment = local.env
ManagedBy = "terraform"
}
}
}
################################################################################
# Data Sources
################################################################################
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
################################################################################
# KMS Key
################################################################################
resource "aws_kms_key" "table" {
description = "KMS key for ${local.table_name} DynamoDB encryption"
deletion_window_in_days = 7
enable_key_rotation = true
tags = { Name = "${local.table_name}-dynamodb" }
}
resource "aws_kms_alias" "table" {
name = "alias/${local.table_name}-dynamodb"
target_key_id = aws_kms_key.table.key_id
}
################################################################################
# DynamoDB Table
################################################################################
resource "aws_dynamodb_table" "main" {
name = local.table_name
billing_mode = local.billing_mode
table_class = local.table_class
# Capacity (only for PROVISIONED)
read_capacity = local.billing_mode == "PROVISIONED" ? local.read_capacity : null
write_capacity = local.billing_mode == "PROVISIONED" ? local.write_capacity : null
# Primary key
hash_key = local.hash_key
range_key = local.range_key
# Key schema
attribute {
name = local.hash_key
type = local.hash_key_type
}
dynamic "attribute" {
for_each = local.range_key != null ? [1] : []
content {
name = local.range_key
type = local.range_key_type
}
}
# GSI attributes
dynamic "attribute" {
for_each = local.global_secondary_indexes
content {
name = attribute.value.hash_key
type = "S"
}
}
dynamic "attribute" {
for_each = [for gsi in local.global_secondary_indexes : gsi if gsi.range_key != null]
content {
name = attribute.value.range_key
type = "S"
}
}
# LSI attributes
dynamic "attribute" {
for_each = local.local_secondary_indexes
content {
name = attribute.value.range_key
type = "S"
}
}
# Global Secondary Indexes
dynamic "global_secondary_index" {
for_each = local.global_secondary_indexes
content {
name = global_secondary_index.value.name
hash_key = global_secondary_index.value.hash_key
range_key = lookup(global_secondary_index.value, "range_key", null)
projection_type = global_secondary_index.value.projection_type
non_key_attributes = global_secondary_index.value.projection_type == "INCLUDE" ? global_secondary_index.value.non_key_attributes : null
# Capacity for provisioned mode
read_capacity = local.billing_mode == "PROVISIONED" ? local.read_capacity : null
write_capacity = local.billing_mode == "PROVISIONED" ? local.write_capacity : null
}
}
# Local Secondary Indexes
dynamic "local_secondary_index" {
for_each = local.local_secondary_indexes
content {
name = local_secondary_index.value.name
range_key = local_secondary_index.value.range_key
projection_type = local_secondary_index.value.projection_type
non_key_attributes = local_secondary_index.value.projection_type == "INCLUDE" ? local_secondary_index.value.non_key_attributes : null
}
}
# TTL
dynamic "ttl" {
for_each = local.ttl_attribute != null ? [1] : []
content {
attribute_name = local.ttl_attribute
enabled = true
}
}
# Streams
stream_enabled = local.stream_view_type != null
stream_view_type = local.stream_view_type
# Encryption
server_side_encryption {
enabled = true
kms_key_arn = aws_kms_key.table.arn
}
# Point-in-time recovery
point_in_time_recovery {
enabled = local.point_in_time_recovery
}
# Deletion protection for prod
deletion_protection_enabled = local.env == "prod"
tags = {
Name = local.table_name
Backup = "true"
}
lifecycle {
prevent_destroy = false # Set to true for production
}
}
################################################################################
# Auto Scaling (Provisioned Mode Only)
################################################################################
resource "aws_appautoscaling_target" "read" {
count = local.enable_autoscaling ? 1 : 0
max_capacity = local.autoscaling_max_read
min_capacity = local.autoscaling_min_read
resource_id = "table/${aws_dynamodb_table.main.name}"
scalable_dimension = "dynamodb:table:ReadCapacityUnits"
service_namespace = "dynamodb"
}
resource "aws_appautoscaling_policy" "read" {
count = local.enable_autoscaling ? 1 : 0
name = "${local.table_name}-read-autoscaling"
policy_type = "TargetTrackingScaling"
resource_id = aws_appautoscaling_target.read[0].resource_id
scalable_dimension = aws_appautoscaling_target.read[0].scalable_dimension
service_namespace = aws_appautoscaling_target.read[0].service_namespace
target_tracking_scaling_policy_configuration {
predefined_metric_specification {
predefined_metric_type = "DynamoDBReadCapacityUtilization"
}
target_value = local.autoscaling_target_utilization
}
}
resource "aws_appautoscaling_target" "write" {
count = local.enable_autoscaling ? 1 : 0
max_capacity = local.autoscaling_max_write
min_capacity = local.autoscaling_min_write
resource_id = "table/${aws_dynamodb_table.main.name}"
scalable_dimension = "dynamodb:table:WriteCapacityUnits"
service_namespace = "dynamodb"
}
resource "aws_appautoscaling_policy" "write" {
count = local.enable_autoscaling ? 1 : 0
name = "${local.table_name}-write-autoscaling"
policy_type = "TargetTrackingScaling"
resource_id = aws_appautoscaling_target.write[0].resource_id
scalable_dimension = aws_appautoscaling_target.write[0].scalable_dimension
service_namespace = aws_appautoscaling_target.write[0].service_namespace
target_tracking_scaling_policy_configuration {
predefined_metric_specification {
predefined_metric_type = "DynamoDBWriteCapacityUtilization"
}
target_value = local.autoscaling_target_utilization
}
}
################################################################################
# CloudWatch Alarms
################################################################################
resource "aws_cloudwatch_metric_alarm" "throttled_requests" {
alarm_name = "${local.table_name}-throttled-requests"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
metric_name = "ThrottledRequests"
namespace = "AWS/DynamoDB"
period = 300
statistic = "Sum"
threshold = 0
alarm_description = "DynamoDB throttled requests detected"
dimensions = {
TableName = aws_dynamodb_table.main.name
}
tags = { Name = "${local.table_name}-throttled" }
}
resource "aws_cloudwatch_metric_alarm" "system_errors" {
alarm_name = "${local.table_name}-system-errors"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
metric_name = "SystemErrors"
namespace = "AWS/DynamoDB"
period = 300
statistic = "Sum"
threshold = 0
alarm_description = "DynamoDB system errors detected"
dimensions = {
TableName = aws_dynamodb_table.main.name
}
tags = { Name = "${local.table_name}-errors" }
}
################################################################################
# IAM Policy Document (for application access)
################################################################################
data "aws_iam_policy_document" "table_access" {
statement {
sid = "AllowTableOperations"
effect = "Allow"
actions = [
"dynamodb:BatchGetItem",
"dynamodb:BatchWriteItem",
"dynamodb:DeleteItem",
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:Query",
"dynamodb:Scan",
"dynamodb:UpdateItem",
"dynamodb:DescribeTable",
]
resources = [
aws_dynamodb_table.main.arn,
"${aws_dynamodb_table.main.arn}/index/*",
]
}
statement {
sid = "AllowKMSDecrypt"
effect = "Allow"
actions = [
"kms:Decrypt",
"kms:Encrypt",
"kms:GenerateDataKey",
]
resources = [aws_kms_key.table.arn]
}
}
################################################################################
# Outputs
################################################################################
output "table_name" {
value = aws_dynamodb_table.main.name
}
output "table_arn" {
value = aws_dynamodb_table.main.arn
}
output "table_id" {
value = aws_dynamodb_table.main.id
}
output "stream_arn" {
value = aws_dynamodb_table.main.stream_arn
}
output "kms_key_arn" {
value = aws_kms_key.table.arn
}
output "access_policy_json" {
value = data.aws_iam_policy_document.table_access.json
description = "IAM policy document for application access to this table"
}

View File

@@ -0,0 +1,400 @@
################################################################################
# Workload: ECR Repository
#
# Container registry with:
# - Image scanning on push
# - Lifecycle policies (cleanup old images)
# - Cross-account access
# - Replication to other regions
# - Immutable tags (optional)
#
# Use cases: Docker images, Lambda container images
################################################################################
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
backend "s3" {
key = "05-workloads/<TENANT>-<NAME>-ecr/terraform.tfstate"
}
}
################################################################################
# Configuration - UPDATE THESE
################################################################################
locals {
# Naming
tenant = "<TENANT>"
name = "<NAME>"
env = "prod"
# Multiple repositories can be created
repositories = {
api = {
description = "API service container"
}
worker = {
description = "Background worker container"
}
# Add more as needed
}
# Image scanning
scan_on_push = true
# Tag immutability (prevents overwriting tags)
image_tag_mutability = "MUTABLE" # MUTABLE or IMMUTABLE
# Encryption
encryption_type = "AES256" # AES256 or KMS
kms_key_arn = null # Set if using KMS
# Lifecycle policy - cleanup old images
lifecycle_policy = {
# Keep last N tagged images
keep_tagged_count = 30
# Delete untagged images older than N days
untagged_expiry_days = 7
# Keep images with these tag prefixes forever
keep_tag_prefixes = ["release-", "v"]
}
# Cross-account access (account IDs that can pull)
pull_access_accounts = [
# "123456789012", # Dev account
# "234567890123", # Staging account
]
# Cross-account push access
push_access_accounts = [
# "345678901234", # CI/CD account
]
# IAM principals with pull access
pull_access_principals = [
# "arn:aws:iam::123456789012:role/ecs-task-role",
]
# Replication to other regions
replication_regions = [
# "us-west-2",
# "eu-west-1",
]
}
################################################################################
# Variables
################################################################################
variable "region" {
type = string
default = "us-east-1"
}
variable "state_bucket" {
type = string
}
################################################################################
# Provider
################################################################################
provider "aws" {
region = var.region
default_tags {
tags = {
Tenant = local.tenant
App = local.name
Environment = local.env
ManagedBy = "terraform"
}
}
}
################################################################################
# Data Sources
################################################################################
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
################################################################################
# ECR Repositories
################################################################################
resource "aws_ecr_repository" "repos" {
for_each = local.repositories
name = "${local.tenant}/${local.name}/${each.key}"
image_tag_mutability = local.image_tag_mutability
image_scanning_configuration {
scan_on_push = local.scan_on_push
}
encryption_configuration {
encryption_type = local.encryption_type
kms_key = local.kms_key_arn
}
tags = {
Name = "${local.tenant}/${local.name}/${each.key}"
Description = each.value.description
}
}
################################################################################
# Lifecycle Policies
################################################################################
resource "aws_ecr_lifecycle_policy" "repos" {
for_each = local.repositories
repository = aws_ecr_repository.repos[each.key].name
policy = jsonencode({
rules = [
# Keep tagged images with specific prefixes
{
rulePriority = 1
description = "Keep release images"
selection = {
tagStatus = "tagged"
tagPrefixList = local.lifecycle_policy.keep_tag_prefixes
countType = "imageCountMoreThan"
countNumber = 9999
}
action = {
type = "expire"
}
},
# Keep last N tagged images
{
rulePriority = 10
description = "Keep last ${local.lifecycle_policy.keep_tagged_count} tagged images"
selection = {
tagStatus = "tagged"
tagPrefixList = [""]
countType = "imageCountMoreThan"
countNumber = local.lifecycle_policy.keep_tagged_count
}
action = {
type = "expire"
}
},
# Delete old untagged images
{
rulePriority = 20
description = "Delete untagged images older than ${local.lifecycle_policy.untagged_expiry_days} days"
selection = {
tagStatus = "untagged"
countType = "sinceImagePushed"
countUnit = "days"
countNumber = local.lifecycle_policy.untagged_expiry_days
}
action = {
type = "expire"
}
}
]
})
}
################################################################################
# Repository Policies (Cross-Account Access)
################################################################################
resource "aws_ecr_repository_policy" "repos" {
for_each = length(local.pull_access_accounts) > 0 || length(local.push_access_accounts) > 0 || length(local.pull_access_principals) > 0 ? local.repositories : {}
repository = aws_ecr_repository.repos[each.key].name
policy = jsonencode({
Version = "2012-10-17"
Statement = concat(
# Cross-account pull access
length(local.pull_access_accounts) > 0 ? [{
Sid = "CrossAccountPull"
Effect = "Allow"
Principal = {
AWS = [for acct in local.pull_access_accounts : "arn:aws:iam::${acct}:root"]
}
Action = [
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage",
"ecr:BatchCheckLayerAvailability"
]
}] : [],
# Cross-account push access
length(local.push_access_accounts) > 0 ? [{
Sid = "CrossAccountPush"
Effect = "Allow"
Principal = {
AWS = [for acct in local.push_access_accounts : "arn:aws:iam::${acct}:root"]
}
Action = [
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage",
"ecr:BatchCheckLayerAvailability",
"ecr:PutImage",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload"
]
}] : [],
# Principal-based pull access
length(local.pull_access_principals) > 0 ? [{
Sid = "PrincipalPull"
Effect = "Allow"
Principal = {
AWS = local.pull_access_principals
}
Action = [
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage",
"ecr:BatchCheckLayerAvailability"
]
}] : []
)
})
}
################################################################################
# Replication Configuration
################################################################################
resource "aws_ecr_replication_configuration" "main" {
count = length(local.replication_regions) > 0 ? 1 : 0
replication_configuration {
rule {
dynamic "destination" {
for_each = local.replication_regions
content {
region = destination.value
registry_id = data.aws_caller_identity.current.account_id
}
}
repository_filter {
filter = "${local.tenant}/${local.name}/"
filter_type = "PREFIX_MATCH"
}
}
}
}
################################################################################
# IAM Policy for CI/CD
################################################################################
resource "aws_iam_policy" "push" {
name = "${local.tenant}-${local.name}-ecr-push"
description = "Push access to ${local.tenant}/${local.name} ECR repositories"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "GetAuthToken"
Effect = "Allow"
Action = "ecr:GetAuthorizationToken"
Resource = "*"
},
{
Sid = "PushImages"
Effect = "Allow"
Action = [
"ecr:BatchCheckLayerAvailability",
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage",
"ecr:PutImage",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload"
]
Resource = [for repo in aws_ecr_repository.repos : repo.arn]
}
]
})
tags = { Name = "${local.tenant}-${local.name}-ecr-push" }
}
resource "aws_iam_policy" "pull" {
name = "${local.tenant}-${local.name}-ecr-pull"
description = "Pull access to ${local.tenant}/${local.name} ECR repositories"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "GetAuthToken"
Effect = "Allow"
Action = "ecr:GetAuthorizationToken"
Resource = "*"
},
{
Sid = "PullImages"
Effect = "Allow"
Action = [
"ecr:BatchCheckLayerAvailability",
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage"
]
Resource = [for repo in aws_ecr_repository.repos : repo.arn]
}
]
})
tags = { Name = "${local.tenant}-${local.name}-ecr-pull" }
}
################################################################################
# Outputs
################################################################################
output "repository_urls" {
value = { for k, v in aws_ecr_repository.repos : k => v.repository_url }
description = "Repository URLs for docker push/pull"
}
output "repository_arns" {
value = { for k, v in aws_ecr_repository.repos : k => v.arn }
description = "Repository ARNs"
}
output "push_policy_arn" {
value = aws_iam_policy.push.arn
description = "IAM policy ARN for push access"
}
output "pull_policy_arn" {
value = aws_iam_policy.pull.arn
description = "IAM policy ARN for pull access"
}
output "docker_login_command" {
value = "aws ecr get-login-password --region ${data.aws_region.current.name} | docker login --username AWS --password-stdin ${data.aws_caller_identity.current.account_id}.dkr.ecr.${data.aws_region.current.name}.amazonaws.com"
description = "Command to authenticate Docker with ECR"
}
output "push_commands" {
value = { for k, v in aws_ecr_repository.repos : k => <<-EOF
docker build -t ${v.repository_url}:latest .
docker push ${v.repository_url}:latest
EOF
}
description = "Docker build and push commands for each repository"
}

View File

@@ -0,0 +1,701 @@
################################################################################
# Workload: ECS Fargate Service
#
# Container service with:
# - Fargate (serverless containers)
# - Auto-scaling
# - ALB integration
# - Service discovery
# - Secrets/SSM integration
# - CloudWatch logging
# - X-Ray tracing
#
# Use cases: Web services, APIs, microservices
################################################################################
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
backend "s3" {
key = "05-workloads/<TENANT>-<NAME>-ecs/terraform.tfstate"
}
}
################################################################################
# Configuration - UPDATE THESE
################################################################################
locals {
# Naming
tenant = "<TENANT>"
name = "<NAME>"
env = "prod"
prefix = "${local.tenant}-${local.name}-${local.env}"
# Container configuration
container = {
image = "nginx:latest" # Update to your ECR image
port = 80
protocol = "HTTP"
# Resources (Fargate valid combinations)
# CPU: 256, 512, 1024, 2048, 4096
# Memory depends on CPU
cpu = 256
memory = 512
# Health check
health_check_path = "/health"
health_check_interval = 30
# Environment variables
environment = {
LOG_LEVEL = "info"
NODE_ENV = local.env
}
# Secrets from SSM Parameter Store
secrets_ssm = {
# DATABASE_URL = "/${local.tenant}/${local.env}/${local.name}/database/url"
}
# Secrets from Secrets Manager
secrets_sm = {
# API_KEY = "myapp/api-key"
}
}
# Service configuration
service = {
desired_count = 2
min_count = 1
max_count = 10
# Deployment
deployment_max_percent = 200
deployment_min_healthy_percent = 100
# Enable execute command (for debugging)
enable_execute_command = true
}
# Network (get from remote state or hardcode)
vpc_id = "" # data.terraform_remote_state.network.outputs.vpc_id
private_subnet_ids = [] # data.terraform_remote_state.network.outputs.private_subnet_ids
public_subnet_ids = [] # data.terraform_remote_state.network.outputs.public_subnet_ids
# Load balancer
alb = {
enabled = true
internal = false
certificate_arn = "" # ACM certificate ARN for HTTPS
health_check_path = local.container.health_check_path
}
# Auto-scaling
autoscaling = {
enabled = true
# CPU-based scaling
cpu_target = 70
# Request count scaling (if ALB)
requests_target = 1000 # requests per target per minute
# Scale-in cooldown
scale_in_cooldown = 300
scale_out_cooldown = 60
}
# Service discovery (Cloud Map)
service_discovery = {
enabled = false
namespace_id = "" # Cloud Map namespace ID
dns_ttl = 10
}
# Logging
log_retention_days = 30
# X-Ray tracing
enable_xray = false
}
################################################################################
# Variables
################################################################################
variable "region" {
type = string
default = "us-east-1"
}
variable "state_bucket" {
type = string
}
################################################################################
# Provider
################################################################################
provider "aws" {
region = var.region
default_tags {
tags = {
Tenant = local.tenant
App = local.name
Environment = local.env
ManagedBy = "terraform"
}
}
}
################################################################################
# Data Sources
################################################################################
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
################################################################################
# ECS Cluster
################################################################################
resource "aws_ecs_cluster" "main" {
name = local.prefix
setting {
name = "containerInsights"
value = "enabled"
}
tags = { Name = local.prefix }
}
resource "aws_ecs_cluster_capacity_providers" "main" {
cluster_name = aws_ecs_cluster.main.name
capacity_providers = ["FARGATE", "FARGATE_SPOT"]
default_capacity_provider_strategy {
base = 1
weight = 100
capacity_provider = "FARGATE"
}
}
################################################################################
# CloudWatch Logs
################################################################################
resource "aws_cloudwatch_log_group" "app" {
name = "/ecs/${local.prefix}"
retention_in_days = local.log_retention_days
tags = { Name = local.prefix }
}
################################################################################
# IAM Roles
################################################################################
# Task execution role (ECS agent)
resource "aws_iam_role" "execution" {
name = "${local.prefix}-execution"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "ecs-tasks.amazonaws.com" }
}]
})
tags = { Name = "${local.prefix}-execution" }
}
resource "aws_iam_role_policy_attachment" "execution" {
role = aws_iam_role.execution.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
resource "aws_iam_role_policy" "execution_secrets" {
name = "secrets-access"
role = aws_iam_role.execution.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "SSMParameters"
Effect = "Allow"
Action = [
"ssm:GetParameters",
"ssm:GetParameter"
]
Resource = "arn:aws:ssm:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:parameter/${local.tenant}/*"
},
{
Sid = "SecretsManager"
Effect = "Allow"
Action = "secretsmanager:GetSecretValue"
Resource = "arn:aws:secretsmanager:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:secret:${local.tenant}/*"
},
{
Sid = "KMSDecrypt"
Effect = "Allow"
Action = "kms:Decrypt"
Resource = "*"
}
]
})
}
# Task role (application)
resource "aws_iam_role" "task" {
name = "${local.prefix}-task"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "ecs-tasks.amazonaws.com" }
}]
})
tags = { Name = "${local.prefix}-task" }
}
# Allow ECS exec
resource "aws_iam_role_policy" "task_exec" {
count = local.service.enable_execute_command ? 1 : 0
name = "ecs-exec"
role = aws_iam_role.task.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = [
"ssmmessages:CreateControlChannel",
"ssmmessages:CreateDataChannel",
"ssmmessages:OpenControlChannel",
"ssmmessages:OpenDataChannel"
]
Resource = "*"
}]
})
}
# X-Ray tracing
resource "aws_iam_role_policy" "task_xray" {
count = local.enable_xray ? 1 : 0
name = "xray"
role = aws_iam_role.task.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = [
"xray:PutTraceSegments",
"xray:PutTelemetryRecords",
"xray:GetSamplingRules",
"xray:GetSamplingTargets",
"xray:GetSamplingStatisticSummaries"
]
Resource = "*"
}]
})
}
################################################################################
# Security Groups
################################################################################
resource "aws_security_group" "service" {
count = length(local.vpc_id) > 0 ? 1 : 0
name = "${local.prefix}-service"
vpc_id = local.vpc_id
ingress {
description = "From ALB"
from_port = local.container.port
to_port = local.container.port
protocol = "tcp"
security_groups = local.alb.enabled ? [aws_security_group.alb[0].id] : []
cidr_blocks = local.alb.enabled ? [] : ["0.0.0.0/0"]
}
egress {
description = "All outbound"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "${local.prefix}-service" }
}
resource "aws_security_group" "alb" {
count = local.alb.enabled && length(local.vpc_id) > 0 ? 1 : 0
name = "${local.prefix}-alb"
vpc_id = local.vpc_id
ingress {
description = "HTTPS"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
description = "HTTP redirect"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
description = "To service"
from_port = local.container.port
to_port = local.container.port
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "${local.prefix}-alb" }
}
################################################################################
# Application Load Balancer
################################################################################
resource "aws_lb" "main" {
count = local.alb.enabled && length(local.public_subnet_ids) > 0 ? 1 : 0
name = local.prefix
internal = local.alb.internal
load_balancer_type = "application"
security_groups = [aws_security_group.alb[0].id]
subnets = local.alb.internal ? local.private_subnet_ids : local.public_subnet_ids
enable_deletion_protection = local.env == "prod"
tags = { Name = local.prefix }
}
resource "aws_lb_target_group" "main" {
count = local.alb.enabled && length(local.vpc_id) > 0 ? 1 : 0
name = local.prefix
port = local.container.port
protocol = "HTTP"
vpc_id = local.vpc_id
target_type = "ip"
health_check {
enabled = true
healthy_threshold = 2
unhealthy_threshold = 3
timeout = 5
interval = local.container.health_check_interval
path = local.alb.health_check_path
matcher = "200-299"
}
tags = { Name = local.prefix }
}
resource "aws_lb_listener" "https" {
count = local.alb.enabled && length(local.alb.certificate_arn) > 0 && length(local.public_subnet_ids) > 0 ? 1 : 0
load_balancer_arn = aws_lb.main[0].arn
port = 443
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
certificate_arn = local.alb.certificate_arn
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.main[0].arn
}
}
resource "aws_lb_listener" "http_redirect" {
count = local.alb.enabled && length(local.public_subnet_ids) > 0 ? 1 : 0
load_balancer_arn = aws_lb.main[0].arn
port = 80
protocol = "HTTP"
default_action {
type = length(local.alb.certificate_arn) > 0 ? "redirect" : "forward"
dynamic "redirect" {
for_each = length(local.alb.certificate_arn) > 0 ? [1] : []
content {
port = "443"
protocol = "HTTPS"
status_code = "HTTP_301"
}
}
target_group_arn = length(local.alb.certificate_arn) > 0 ? null : aws_lb_target_group.main[0].arn
}
}
################################################################################
# Task Definition
################################################################################
resource "aws_ecs_task_definition" "main" {
family = local.prefix
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
cpu = local.container.cpu
memory = local.container.memory
execution_role_arn = aws_iam_role.execution.arn
task_role_arn = aws_iam_role.task.arn
container_definitions = jsonencode(concat(
[{
name = "app"
image = local.container.image
essential = true
portMappings = [{
containerPort = local.container.port
protocol = "tcp"
}]
environment = [
for k, v in local.container.environment : { name = k, value = v }
]
secrets = concat(
[for k, v in local.container.secrets_ssm : {
name = k
valueFrom = "arn:aws:ssm:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:parameter${v}"
}],
[for k, v in local.container.secrets_sm : {
name = k
valueFrom = "arn:aws:secretsmanager:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:secret:${v}"
}]
)
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.app.name
"awslogs-region" = data.aws_region.current.name
"awslogs-stream-prefix" = "app"
}
}
healthCheck = {
command = ["CMD-SHELL", "curl -f http://localhost:${local.container.port}${local.container.health_check_path} || exit 1"]
interval = 30
timeout = 5
retries = 3
startPeriod = 60
}
}],
local.enable_xray ? [{
name = "xray-daemon"
image = "amazon/aws-xray-daemon:latest"
essential = false
cpu = 32
memory = 256
portMappings = [{
containerPort = 2000
protocol = "udp"
}]
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.app.name
"awslogs-region" = data.aws_region.current.name
"awslogs-stream-prefix" = "xray"
}
}
}] : []
))
tags = { Name = local.prefix }
}
################################################################################
# ECS Service
################################################################################
resource "aws_ecs_service" "main" {
count = length(local.vpc_id) > 0 && length(local.private_subnet_ids) > 0 ? 1 : 0
name = local.prefix
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.main.arn
desired_count = local.service.desired_count
launch_type = "FARGATE"
deployment_maximum_percent = local.service.deployment_max_percent
deployment_minimum_healthy_percent = local.service.deployment_min_healthy_percent
enable_execute_command = local.service.enable_execute_command
network_configuration {
subnets = local.private_subnet_ids
security_groups = [aws_security_group.service[0].id]
assign_public_ip = false
}
dynamic "load_balancer" {
for_each = local.alb.enabled ? [1] : []
content {
target_group_arn = aws_lb_target_group.main[0].arn
container_name = "app"
container_port = local.container.port
}
}
dynamic "service_registries" {
for_each = local.service_discovery.enabled ? [1] : []
content {
registry_arn = aws_service_discovery_service.main[0].arn
}
}
tags = { Name = local.prefix }
lifecycle {
ignore_changes = [desired_count]
}
}
################################################################################
# Auto Scaling
################################################################################
resource "aws_appautoscaling_target" "main" {
count = local.autoscaling.enabled && length(local.vpc_id) > 0 ? 1 : 0
max_capacity = local.service.max_count
min_capacity = local.service.min_count
resource_id = "service/${aws_ecs_cluster.main.name}/${aws_ecs_service.main[0].name}"
scalable_dimension = "ecs:service:DesiredCount"
service_namespace = "ecs"
}
resource "aws_appautoscaling_policy" "cpu" {
count = local.autoscaling.enabled && length(local.vpc_id) > 0 ? 1 : 0
name = "${local.prefix}-cpu"
policy_type = "TargetTrackingScaling"
resource_id = aws_appautoscaling_target.main[0].resource_id
scalable_dimension = aws_appautoscaling_target.main[0].scalable_dimension
service_namespace = aws_appautoscaling_target.main[0].service_namespace
target_tracking_scaling_policy_configuration {
predefined_metric_specification {
predefined_metric_type = "ECSServiceAverageCPUUtilization"
}
target_value = local.autoscaling.cpu_target
scale_in_cooldown = local.autoscaling.scale_in_cooldown
scale_out_cooldown = local.autoscaling.scale_out_cooldown
}
}
resource "aws_appautoscaling_policy" "requests" {
count = local.autoscaling.enabled && local.alb.enabled && length(local.vpc_id) > 0 ? 1 : 0
name = "${local.prefix}-requests"
policy_type = "TargetTrackingScaling"
resource_id = aws_appautoscaling_target.main[0].resource_id
scalable_dimension = aws_appautoscaling_target.main[0].scalable_dimension
service_namespace = aws_appautoscaling_target.main[0].service_namespace
target_tracking_scaling_policy_configuration {
predefined_metric_specification {
predefined_metric_type = "ALBRequestCountPerTarget"
resource_label = "${aws_lb.main[0].arn_suffix}/${aws_lb_target_group.main[0].arn_suffix}"
}
target_value = local.autoscaling.requests_target
scale_in_cooldown = local.autoscaling.scale_in_cooldown
scale_out_cooldown = local.autoscaling.scale_out_cooldown
}
}
################################################################################
# Service Discovery
################################################################################
resource "aws_service_discovery_service" "main" {
count = local.service_discovery.enabled ? 1 : 0
name = local.name
dns_config {
namespace_id = local.service_discovery.namespace_id
dns_records {
ttl = local.service_discovery.dns_ttl
type = "A"
}
routing_policy = "MULTIVALUE"
}
health_check_custom_config {
failure_threshold = 1
}
tags = { Name = local.prefix }
}
################################################################################
# Outputs
################################################################################
output "cluster_name" {
value = aws_ecs_cluster.main.name
description = "ECS cluster name"
}
output "service_name" {
value = length(aws_ecs_service.main) > 0 ? aws_ecs_service.main[0].name : null
description = "ECS service name"
}
output "alb_dns_name" {
value = length(aws_lb.main) > 0 ? aws_lb.main[0].dns_name : null
description = "ALB DNS name"
}
output "alb_zone_id" {
value = length(aws_lb.main) > 0 ? aws_lb.main[0].zone_id : null
description = "ALB hosted zone ID (for Route53 alias)"
}
output "task_definition_arn" {
value = aws_ecs_task_definition.main.arn
description = "Task definition ARN"
}
output "log_group" {
value = aws_cloudwatch_log_group.app.name
description = "CloudWatch log group"
}
output "exec_command" {
value = length(aws_ecs_service.main) > 0 ? "aws ecs execute-command --cluster ${aws_ecs_cluster.main.name} --task <task-id> --container app --interactive --command '/bin/sh'" : null
description = "ECS exec command for debugging"
}
output "update_command" {
value = length(aws_ecs_service.main) > 0 ? "aws ecs update-service --cluster ${aws_ecs_cluster.main.name} --service ${aws_ecs_service.main[0].name} --force-new-deployment" : null
description = "Force new deployment command"
}

View File

@@ -0,0 +1,560 @@
################################################################################
# Workload: ECS Fargate Service
#
# Deploys a containerized application on ECS Fargate:
# - ECS Service with Fargate launch type
# - Application Load Balancer (optional)
# - Auto-scaling based on CPU/Memory
# - CloudWatch logging
#
# Usage:
# Copy this folder to 05-workloads/<tenant>-<app>/
# Update locals and variables
# terraform init -backend-config=../../00-bootstrap/backend.hcl
# terraform apply
################################################################################
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
backend "s3" {
key = "05-workloads/<TENANT>-<APP>/terraform.tfstate"
}
}
################################################################################
# Configuration - UPDATE THESE
################################################################################
locals {
# Naming
tenant = "<TENANT>"
app = "<APP>"
env = "prod" # prod, staging, dev
name = "${local.tenant}-${local.app}-${local.env}"
# Short name for resources with strict limits (ALB: 32 chars, TG: 32 chars)
# Uses first 10 chars of tenant + first 10 of app + env suffix
short_name = "${substr(local.tenant, 0, min(10, length(local.tenant)))}-${substr(local.app, 0, min(10, length(local.app)))}-${substr(local.env, 0, 4)}"
# Container config
container_image = "nginx:latest" # Replace with your ECR image
container_port = 8080
cpu = 256 # 0.25 vCPU
memory = 512 # MB
# Scaling
desired_count = 2
min_count = 1
max_count = 10
# Load balancer
enable_alb = true
health_check_path = "/health"
# Environment variables (non-sensitive)
environment = {
APP_ENV = local.env
LOG_LEVEL = "info"
}
# Secrets from SSM/Secrets Manager (ARNs)
secrets = {
# DATABASE_URL = "arn:aws:secretsmanager:us-east-1:123456789:secret:mydb-xxx"
}
}
################################################################################
# Variables
################################################################################
variable "region" {
type = string
default = "us-east-1"
}
variable "state_bucket" {
type = string
}
################################################################################
# Provider
################################################################################
provider "aws" {
region = var.region
default_tags {
tags = {
Tenant = local.tenant
App = local.app
Environment = local.env
ManagedBy = "terraform"
}
}
}
################################################################################
# Data Sources
################################################################################
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = var.state_bucket
key = "02-network/terraform.tfstate"
region = var.region
}
}
data "terraform_remote_state" "tenant" {
backend = "s3"
config = {
bucket = var.state_bucket
key = "04-tenants/${local.tenant}/terraform.tfstate"
region = var.region
}
}
data "terraform_remote_state" "bootstrap" {
backend = "s3"
config = {
bucket = var.state_bucket
key = "00-bootstrap/terraform.tfstate"
region = var.region
}
}
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
################################################################################
# ECS Cluster
################################################################################
resource "aws_ecs_cluster" "main" {
name = local.name
setting {
name = "containerInsights"
value = "enabled"
}
tags = { Name = local.name }
}
resource "aws_ecs_cluster_capacity_providers" "main" {
cluster_name = aws_ecs_cluster.main.name
capacity_providers = ["FARGATE", "FARGATE_SPOT"]
default_capacity_provider_strategy {
base = 1
weight = 100
capacity_provider = "FARGATE"
}
}
################################################################################
# CloudWatch Log Group
################################################################################
resource "aws_cloudwatch_log_group" "main" {
name = "/ecs/${local.name}"
retention_in_days = 30
tags = { Name = local.name }
}
################################################################################
# IAM - Task Execution Role
################################################################################
resource "aws_iam_role" "execution" {
name = "${local.name}-execution"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "ecs-tasks.amazonaws.com" }
}]
})
tags = { Name = "${local.name}-execution" }
}
resource "aws_iam_role_policy_attachment" "execution" {
role = aws_iam_role.execution.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
resource "aws_iam_role_policy" "execution_secrets" {
count = length(local.secrets) > 0 ? 1 : 0
name = "secrets-access"
role = aws_iam_role.execution.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = ["secretsmanager:GetSecretValue"]
Resource = values(local.secrets)
},
{
Effect = "Allow"
Action = ["ssm:GetParameters"]
Resource = values(local.secrets)
}
]
})
}
################################################################################
# IAM - Task Role (app permissions)
################################################################################
resource "aws_iam_role" "task" {
name = "${local.name}-task"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "ecs-tasks.amazonaws.com" }
}]
})
tags = { Name = "${local.name}-task" }
}
# Add app-specific permissions here
resource "aws_iam_role_policy" "task" {
name = "app-permissions"
role = aws_iam_role.task.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowTaggedResources"
Effect = "Allow"
Action = ["s3:GetObject", "s3:PutObject", "dynamodb:*"]
Resource = "*"
Condition = { StringEquals = { "aws:ResourceTag/Tenant" = local.tenant } }
}
]
})
}
################################################################################
# Task Definition
################################################################################
resource "aws_ecs_task_definition" "main" {
family = local.name
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
cpu = local.cpu
memory = local.memory
execution_role_arn = aws_iam_role.execution.arn
task_role_arn = aws_iam_role.task.arn
container_definitions = jsonencode([
{
name = local.app
image = local.container_image
essential = true
portMappings = [{
containerPort = local.container_port
protocol = "tcp"
}]
environment = [
for k, v in local.environment : { name = k, value = v }
]
secrets = [
for k, v in local.secrets : { name = k, valueFrom = v }
]
logConfiguration = {
logDriver = "awslogs"
options = {
awslogs-group = aws_cloudwatch_log_group.main.name
awslogs-region = data.aws_region.current.name
awslogs-stream-prefix = "ecs"
}
}
healthCheck = {
command = ["CMD-SHELL", "curl -f http://localhost:${local.container_port}${local.health_check_path} || exit 1"]
interval = 30
timeout = 5
retries = 3
startPeriod = 60
}
}
])
tags = { Name = local.name }
}
################################################################################
# Security Group - Service
################################################################################
resource "aws_security_group" "service" {
name = "${local.name}-service"
description = "ECS service for ${local.name}"
vpc_id = data.terraform_remote_state.network.outputs.vpc_id
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
description = "Allow all outbound traffic"
}
tags = { Name = "${local.name}-service" }
}
# Separate ingress rules to handle conditional ALB
resource "aws_security_group_rule" "service_from_alb" {
count = local.enable_alb ? 1 : 0
type = "ingress"
from_port = local.container_port
to_port = local.container_port
protocol = "tcp"
source_security_group_id = aws_security_group.alb[0].id
security_group_id = aws_security_group.service.id
description = "From ALB"
}
resource "aws_security_group_rule" "service_self" {
count = local.enable_alb ? 0 : 1
type = "ingress"
from_port = local.container_port
to_port = local.container_port
protocol = "tcp"
self = true
security_group_id = aws_security_group.service.id
description = "Self-referencing for service mesh"
}
################################################################################
# ALB
################################################################################
resource "aws_security_group" "alb" {
count = local.enable_alb ? 1 : 0
name = "${local.name}-alb"
description = "ALB for ${local.name}"
vpc_id = data.terraform_remote_state.network.outputs.vpc_id
ingress {
description = "HTTPS"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
description = "HTTP"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "${local.name}-alb" }
}
resource "aws_lb" "main" {
count = local.enable_alb ? 1 : 0
name = local.short_name # ALB names max 32 chars
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.alb[0].id]
subnets = data.terraform_remote_state.network.outputs.public_subnet_ids
# Security: Drop invalid headers
drop_invalid_header_fields = true
# Access logging for audit trail
access_logs {
bucket = data.terraform_remote_state.bootstrap.outputs.logs_bucket
prefix = "alb/${local.name}"
enabled = true
}
tags = { Name = local.name }
}
resource "aws_lb_target_group" "main" {
count = local.enable_alb ? 1 : 0
name = local.short_name # Target group names max 32 chars
port = local.container_port
protocol = "HTTP"
vpc_id = data.terraform_remote_state.network.outputs.vpc_id
target_type = "ip"
health_check {
enabled = true
healthy_threshold = 2
interval = 30
matcher = "200"
path = local.health_check_path
port = "traffic-port"
timeout = 5
unhealthy_threshold = 3
}
# Enable stickiness for stateful apps (disabled by default)
stickiness {
type = "lb_cookie"
enabled = false
cookie_duration = 86400
}
tags = { Name = local.name }
}
resource "aws_lb_listener" "http" {
count = local.enable_alb ? 1 : 0
load_balancer_arn = aws_lb.main[0].arn
port = "80"
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.main[0].arn
}
}
################################################################################
# ECS Service
################################################################################
resource "aws_ecs_service" "main" {
name = local.app
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.main.arn
desired_count = local.desired_count
launch_type = "FARGATE"
network_configuration {
subnets = data.terraform_remote_state.network.outputs.private_subnet_ids
security_groups = [aws_security_group.service.id, data.terraform_remote_state.tenant.outputs.security_groups.base]
assign_public_ip = false
}
dynamic "load_balancer" {
for_each = local.enable_alb ? [1] : []
content {
target_group_arn = aws_lb_target_group.main[0].arn
container_name = local.app
container_port = local.container_port
}
}
lifecycle {
ignore_changes = [desired_count] # Managed by auto-scaling
}
tags = { Name = local.name }
}
################################################################################
# Auto Scaling
################################################################################
resource "aws_appautoscaling_target" "ecs" {
max_capacity = local.max_count
min_capacity = local.min_count
resource_id = "service/${aws_ecs_cluster.main.name}/${aws_ecs_service.main.name}"
scalable_dimension = "ecs:service:DesiredCount"
service_namespace = "ecs"
}
resource "aws_appautoscaling_policy" "cpu" {
name = "${local.name}-cpu"
policy_type = "TargetTrackingScaling"
resource_id = aws_appautoscaling_target.ecs.resource_id
scalable_dimension = aws_appautoscaling_target.ecs.scalable_dimension
service_namespace = aws_appautoscaling_target.ecs.service_namespace
target_tracking_scaling_policy_configuration {
predefined_metric_specification {
predefined_metric_type = "ECSServiceAverageCPUUtilization"
}
target_value = 70
scale_in_cooldown = 300
scale_out_cooldown = 60
}
}
resource "aws_appautoscaling_policy" "memory" {
name = "${local.name}-memory"
policy_type = "TargetTrackingScaling"
resource_id = aws_appautoscaling_target.ecs.resource_id
scalable_dimension = aws_appautoscaling_target.ecs.scalable_dimension
service_namespace = aws_appautoscaling_target.ecs.service_namespace
target_tracking_scaling_policy_configuration {
predefined_metric_specification {
predefined_metric_type = "ECSServiceAverageMemoryUtilization"
}
target_value = 80
scale_in_cooldown = 300
scale_out_cooldown = 60
}
}
################################################################################
# Outputs
################################################################################
output "cluster_name" {
value = aws_ecs_cluster.main.name
}
output "service_name" {
value = aws_ecs_service.main.name
}
output "alb_dns_name" {
value = local.enable_alb ? aws_lb.main[0].dns_name : null
}
output "alb_zone_id" {
value = local.enable_alb ? aws_lb.main[0].zone_id : null
}
output "log_group" {
value = aws_cloudwatch_log_group.main.name
}
output "task_role_arn" {
value = aws_iam_role.task.arn
}

View File

@@ -0,0 +1,968 @@
################################################################################
# Workload: EKS Cluster
#
# Deploys a managed Kubernetes cluster:
# - EKS cluster with managed node groups
# - Core addons (VPC CNI, CoreDNS, kube-proxy)
# - IRSA (IAM Roles for Service Accounts)
# - Cluster Autoscaler ready
# - AWS Load Balancer Controller ready
# - Optional Fargate profiles
#
# Usage:
# Copy this folder to 05-workloads/<tenant>-eks/
# Update locals and variables
# terraform init -backend-config=../../00-bootstrap/backend.hcl
# terraform apply
################################################################################
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
tls = {
source = "hashicorp/tls"
version = ">= 4.0"
}
}
backend "s3" {
key = "05-workloads/<TENANT>-eks/terraform.tfstate"
}
}
################################################################################
# Configuration - UPDATE THESE
################################################################################
locals {
# Naming
tenant = "<TENANT>"
env = "prod" # prod, staging, dev
name = "${local.tenant}-${local.env}"
# EKS Version
cluster_version = "1.29"
# Node Groups
node_groups = {
general = {
instance_types = ["t3.medium"]
capacity_type = "ON_DEMAND" # ON_DEMAND or SPOT
min_size = 2
max_size = 10
desired_size = 2
disk_size = 50
labels = {
role = "general"
}
taints = []
}
# Uncomment for spot instances
# spot = {
# instance_types = ["t3.medium", "t3.large", "t3a.medium"]
# capacity_type = "SPOT"
# min_size = 0
# max_size = 20
# desired_size = 0
# disk_size = 50
# labels = {
# role = "spot"
# }
# taints = [{
# key = "spot"
# value = "true"
# effect = "NO_SCHEDULE"
# }]
# }
}
# Fargate (for serverless pods)
enable_fargate = false
fargate_namespaces = ["serverless"] # Namespaces to run on Fargate
# Addons
enable_cluster_autoscaler = true
enable_aws_lb_controller = true
enable_ebs_csi_driver = true
enable_metrics_server = true
# Logging
cluster_log_types = ["api", "audit", "authenticator", "controllerManager", "scheduler"]
log_retention_days = 30
# Access
cluster_endpoint_public = true
cluster_endpoint_private = true
public_access_cidrs = ["0.0.0.0/0"] # Restrict in production!
# Admin access (IAM ARNs that can access cluster)
admin_arns = [
# "arn:aws:iam::123456789012:role/Admin",
# "arn:aws:iam::123456789012:user/admin",
]
}
################################################################################
# Variables
################################################################################
variable "region" {
type = string
default = "us-east-1"
}
variable "state_bucket" {
type = string
}
################################################################################
# Provider
################################################################################
provider "aws" {
region = var.region
default_tags {
tags = {
Tenant = local.tenant
Environment = local.env
ManagedBy = "terraform"
}
}
}
################################################################################
# Data Sources
################################################################################
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = var.state_bucket
key = "02-network/terraform.tfstate"
region = var.region
}
}
data "terraform_remote_state" "tenant" {
backend = "s3"
config = {
bucket = var.state_bucket
key = "04-tenants/${local.tenant}/terraform.tfstate"
region = var.region
}
}
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
data "aws_partition" "current" {}
################################################################################
# KMS Key for Secrets Encryption
################################################################################
resource "aws_kms_key" "eks" {
description = "EKS Secret Encryption Key for ${local.name}"
deletion_window_in_days = 7
enable_key_rotation = true
tags = { Name = "${local.name}-eks" }
}
resource "aws_kms_alias" "eks" {
name = "alias/${local.name}-eks"
target_key_id = aws_kms_key.eks.key_id
}
################################################################################
# CloudWatch Log Group
################################################################################
resource "aws_cloudwatch_log_group" "eks" {
name = "/aws/eks/${local.name}/cluster"
retention_in_days = local.log_retention_days
tags = { Name = "${local.name}-eks" }
}
################################################################################
# IAM - Cluster Role
################################################################################
resource "aws_iam_role" "cluster" {
name = "${local.name}-eks-cluster"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "eks.amazonaws.com" }
}]
})
tags = { Name = "${local.name}-eks-cluster" }
}
resource "aws_iam_role_policy_attachment" "cluster_policy" {
role = aws_iam_role.cluster.name
policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/AmazonEKSClusterPolicy"
}
resource "aws_iam_role_policy_attachment" "cluster_vpc_policy" {
role = aws_iam_role.cluster.name
policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/AmazonEKSVPCResourceController"
}
################################################################################
# IAM - Node Role
################################################################################
resource "aws_iam_role" "node" {
name = "${local.name}-eks-node"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "ec2.amazonaws.com" }
}]
})
tags = { Name = "${local.name}-eks-node" }
}
resource "aws_iam_role_policy_attachment" "node_policy" {
role = aws_iam_role.node.name
policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/AmazonEKSWorkerNodePolicy"
}
resource "aws_iam_role_policy_attachment" "node_cni_policy" {
role = aws_iam_role.node.name
policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/AmazonEKS_CNI_Policy"
}
resource "aws_iam_role_policy_attachment" "node_ecr_policy" {
role = aws_iam_role.node.name
policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"
}
resource "aws_iam_role_policy_attachment" "node_ssm_policy" {
role = aws_iam_role.node.name
policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
################################################################################
# IAM - Fargate Role
################################################################################
resource "aws_iam_role" "fargate" {
count = local.enable_fargate ? 1 : 0
name = "${local.name}-eks-fargate"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "eks-fargate-pods.amazonaws.com" }
}]
})
tags = { Name = "${local.name}-eks-fargate" }
}
resource "aws_iam_role_policy_attachment" "fargate_policy" {
count = local.enable_fargate ? 1 : 0
role = aws_iam_role.fargate[0].name
policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/AmazonEKSFargatePodExecutionRolePolicy"
}
################################################################################
# Security Groups
################################################################################
resource "aws_security_group" "cluster" {
name = "${local.name}-eks-cluster"
description = "EKS cluster security group"
vpc_id = data.terraform_remote_state.network.outputs.vpc_id
tags = { Name = "${local.name}-eks-cluster" }
}
resource "aws_security_group_rule" "cluster_egress" {
type = "egress"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
security_group_id = aws_security_group.cluster.id
description = "Allow all outbound"
}
resource "aws_security_group" "node" {
name = "${local.name}-eks-node"
description = "EKS node security group"
vpc_id = data.terraform_remote_state.network.outputs.vpc_id
tags = {
Name = "${local.name}-eks-node"
"kubernetes.io/cluster/${local.name}" = "owned"
}
}
resource "aws_security_group_rule" "node_egress" {
type = "egress"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
security_group_id = aws_security_group.node.id
description = "Allow all outbound"
}
resource "aws_security_group_rule" "node_ingress_self" {
type = "ingress"
from_port = 0
to_port = 65535
protocol = "-1"
source_security_group_id = aws_security_group.node.id
security_group_id = aws_security_group.node.id
description = "Node to node"
}
resource "aws_security_group_rule" "node_ingress_cluster" {
type = "ingress"
from_port = 443
to_port = 443
protocol = "tcp"
source_security_group_id = aws_security_group.cluster.id
security_group_id = aws_security_group.node.id
description = "Cluster to node (webhooks)"
}
resource "aws_security_group_rule" "node_ingress_cluster_kubelet" {
type = "ingress"
from_port = 10250
to_port = 10250
protocol = "tcp"
source_security_group_id = aws_security_group.cluster.id
security_group_id = aws_security_group.node.id
description = "Cluster to node (kubelet)"
}
resource "aws_security_group_rule" "cluster_ingress_node" {
type = "ingress"
from_port = 443
to_port = 443
protocol = "tcp"
source_security_group_id = aws_security_group.node.id
security_group_id = aws_security_group.cluster.id
description = "Node to cluster API"
}
################################################################################
# EKS Cluster
################################################################################
resource "aws_eks_cluster" "main" {
name = local.name
version = local.cluster_version
role_arn = aws_iam_role.cluster.arn
vpc_config {
subnet_ids = data.terraform_remote_state.network.outputs.private_subnet_ids
endpoint_private_access = local.cluster_endpoint_private
endpoint_public_access = local.cluster_endpoint_public
public_access_cidrs = local.public_access_cidrs
security_group_ids = [aws_security_group.cluster.id]
}
encryption_config {
provider {
key_arn = aws_kms_key.eks.arn
}
resources = ["secrets"]
}
enabled_cluster_log_types = local.cluster_log_types
depends_on = [
aws_iam_role_policy_attachment.cluster_policy,
aws_iam_role_policy_attachment.cluster_vpc_policy,
aws_cloudwatch_log_group.eks,
]
tags = { Name = local.name }
}
################################################################################
# EKS Addons
################################################################################
resource "aws_eks_addon" "vpc_cni" {
cluster_name = aws_eks_cluster.main.name
addon_name = "vpc-cni"
resolve_conflicts_on_create = "OVERWRITE"
resolve_conflicts_on_update = "OVERWRITE"
tags = { Name = "${local.name}-vpc-cni" }
}
resource "aws_eks_addon" "coredns" {
cluster_name = aws_eks_cluster.main.name
addon_name = "coredns"
resolve_conflicts_on_create = "OVERWRITE"
resolve_conflicts_on_update = "OVERWRITE"
depends_on = [aws_eks_node_group.main]
tags = { Name = "${local.name}-coredns" }
}
resource "aws_eks_addon" "kube_proxy" {
cluster_name = aws_eks_cluster.main.name
addon_name = "kube-proxy"
resolve_conflicts_on_create = "OVERWRITE"
resolve_conflicts_on_update = "OVERWRITE"
tags = { Name = "${local.name}-kube-proxy" }
}
resource "aws_eks_addon" "ebs_csi" {
count = local.enable_ebs_csi_driver ? 1 : 0
cluster_name = aws_eks_cluster.main.name
addon_name = "aws-ebs-csi-driver"
service_account_role_arn = aws_iam_role.ebs_csi[0].arn
resolve_conflicts_on_create = "OVERWRITE"
resolve_conflicts_on_update = "OVERWRITE"
depends_on = [aws_eks_node_group.main]
tags = { Name = "${local.name}-ebs-csi" }
}
################################################################################
# Node Groups
################################################################################
resource "aws_eks_node_group" "main" {
for_each = local.node_groups
cluster_name = aws_eks_cluster.main.name
node_group_name = "${local.name}-${each.key}"
node_role_arn = aws_iam_role.node.arn
subnet_ids = data.terraform_remote_state.network.outputs.private_subnet_ids
instance_types = each.value.instance_types
capacity_type = each.value.capacity_type
disk_size = each.value.disk_size
scaling_config {
min_size = each.value.min_size
max_size = each.value.max_size
desired_size = each.value.desired_size
}
update_config {
max_unavailable = 1
}
labels = merge(each.value.labels, {
Tenant = local.tenant
})
dynamic "taint" {
for_each = each.value.taints
content {
key = taint.value.key
value = taint.value.value
effect = taint.value.effect
}
}
# Launch template for security hardening
launch_template {
id = aws_launch_template.node[each.key].id
version = aws_launch_template.node[each.key].latest_version
}
depends_on = [
aws_iam_role_policy_attachment.node_policy,
aws_iam_role_policy_attachment.node_cni_policy,
aws_iam_role_policy_attachment.node_ecr_policy,
]
lifecycle {
ignore_changes = [scaling_config[0].desired_size]
}
tags = { Name = "${local.name}-${each.key}" }
}
################################################################################
# Launch Template for Node Security Hardening
################################################################################
resource "aws_launch_template" "node" {
for_each = local.node_groups
name = "${local.name}-${each.key}"
# IMDSv2 enforcement - critical security control
metadata_options {
http_endpoint = "enabled"
http_tokens = "required" # Enforces IMDSv2
http_put_response_hop_limit = 1 # Prevent container credential theft
instance_metadata_tags = "enabled"
}
# EBS encryption
block_device_mappings {
device_name = "/dev/xvda"
ebs {
volume_size = each.value.disk_size
volume_type = "gp3"
encrypted = true
delete_on_termination = true
}
}
# Monitoring
monitoring {
enabled = true
}
tag_specifications {
resource_type = "instance"
tags = {
Name = "${local.name}-${each.key}"
Tenant = local.tenant
}
}
tags = { Name = "${local.name}-${each.key}" }
}
################################################################################
# Fargate Profiles
################################################################################
resource "aws_eks_fargate_profile" "main" {
for_each = local.enable_fargate ? toset(local.fargate_namespaces) : []
cluster_name = aws_eks_cluster.main.name
fargate_profile_name = "${local.name}-${each.key}"
pod_execution_role_arn = aws_iam_role.fargate[0].arn
subnet_ids = data.terraform_remote_state.network.outputs.private_subnet_ids
selector {
namespace = each.key
labels = {
Tenant = local.tenant
}
}
tags = { Name = "${local.name}-${each.key}" }
}
################################################################################
# OIDC Provider for IRSA
################################################################################
data "tls_certificate" "eks" {
url = aws_eks_cluster.main.identity[0].oidc[0].issuer
}
resource "aws_iam_openid_connect_provider" "eks" {
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = [data.tls_certificate.eks.certificates[0].sha1_fingerprint]
url = aws_eks_cluster.main.identity[0].oidc[0].issuer
tags = { Name = "${local.name}-eks-oidc" }
}
################################################################################
# IRSA - EBS CSI Driver
################################################################################
resource "aws_iam_role" "ebs_csi" {
count = local.enable_ebs_csi_driver ? 1 : 0
name = "${local.name}-ebs-csi"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRoleWithWebIdentity"
Principal = {
Federated = aws_iam_openid_connect_provider.eks.arn
}
Condition = {
StringEquals = {
"${replace(aws_iam_openid_connect_provider.eks.url, "https://", "")}:sub" = "system:serviceaccount:kube-system:ebs-csi-controller-sa"
"${replace(aws_iam_openid_connect_provider.eks.url, "https://", "")}:aud" = "sts.amazonaws.com"
}
}
}]
})
tags = { Name = "${local.name}-ebs-csi" }
}
resource "aws_iam_role_policy_attachment" "ebs_csi" {
count = local.enable_ebs_csi_driver ? 1 : 0
role = aws_iam_role.ebs_csi[0].name
policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/service-role/AmazonEBSCSIDriverPolicy"
}
################################################################################
# IRSA - Cluster Autoscaler
################################################################################
resource "aws_iam_role" "cluster_autoscaler" {
count = local.enable_cluster_autoscaler ? 1 : 0
name = "${local.name}-cluster-autoscaler"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRoleWithWebIdentity"
Principal = {
Federated = aws_iam_openid_connect_provider.eks.arn
}
Condition = {
StringEquals = {
"${replace(aws_iam_openid_connect_provider.eks.url, "https://", "")}:sub" = "system:serviceaccount:kube-system:cluster-autoscaler"
"${replace(aws_iam_openid_connect_provider.eks.url, "https://", "")}:aud" = "sts.amazonaws.com"
}
}
}]
})
tags = { Name = "${local.name}-cluster-autoscaler" }
}
resource "aws_iam_role_policy" "cluster_autoscaler" {
count = local.enable_cluster_autoscaler ? 1 : 0
name = "cluster-autoscaler"
role = aws_iam_role.cluster_autoscaler[0].id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"autoscaling:DescribeAutoScalingGroups",
"autoscaling:DescribeAutoScalingInstances",
"autoscaling:DescribeLaunchConfigurations",
"autoscaling:DescribeScalingActivities",
"autoscaling:DescribeTags",
"ec2:DescribeInstanceTypes",
"ec2:DescribeLaunchTemplateVersions",
"ec2:DescribeImages",
"ec2:GetInstanceTypesFromInstanceRequirements",
"eks:DescribeNodegroup"
]
Resource = "*"
},
{
Effect = "Allow"
Action = [
"autoscaling:SetDesiredCapacity",
"autoscaling:TerminateInstanceInAutoScalingGroup"
]
Resource = "*"
Condition = {
StringEquals = {
"autoscaling:ResourceTag/k8s.io/cluster-autoscaler/${local.name}" = "owned"
}
}
}
]
})
}
################################################################################
# IRSA - AWS Load Balancer Controller
################################################################################
resource "aws_iam_role" "lb_controller" {
count = local.enable_aws_lb_controller ? 1 : 0
name = "${local.name}-aws-lb-controller"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRoleWithWebIdentity"
Principal = {
Federated = aws_iam_openid_connect_provider.eks.arn
}
Condition = {
StringEquals = {
"${replace(aws_iam_openid_connect_provider.eks.url, "https://", "")}:sub" = "system:serviceaccount:kube-system:aws-load-balancer-controller"
"${replace(aws_iam_openid_connect_provider.eks.url, "https://", "")}:aud" = "sts.amazonaws.com"
}
}
}]
})
tags = { Name = "${local.name}-aws-lb-controller" }
}
resource "aws_iam_role_policy" "lb_controller" {
count = local.enable_aws_lb_controller ? 1 : 0
name = "aws-lb-controller"
role = aws_iam_role.lb_controller[0].id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = ["iam:CreateServiceLinkedRole"]
Resource = "*"
Condition = {
StringEquals = {
"iam:AWSServiceName" = "elasticloadbalancing.amazonaws.com"
}
}
},
{
Effect = "Allow"
Action = [
"ec2:DescribeAccountAttributes",
"ec2:DescribeAddresses",
"ec2:DescribeAvailabilityZones",
"ec2:DescribeInternetGateways",
"ec2:DescribeVpcs",
"ec2:DescribeVpcPeeringConnections",
"ec2:DescribeSubnets",
"ec2:DescribeSecurityGroups",
"ec2:DescribeInstances",
"ec2:DescribeNetworkInterfaces",
"ec2:DescribeTags",
"ec2:GetCoipPoolUsage",
"ec2:DescribeCoipPools",
"elasticloadbalancing:DescribeLoadBalancers",
"elasticloadbalancing:DescribeLoadBalancerAttributes",
"elasticloadbalancing:DescribeListeners",
"elasticloadbalancing:DescribeListenerCertificates",
"elasticloadbalancing:DescribeSSLPolicies",
"elasticloadbalancing:DescribeRules",
"elasticloadbalancing:DescribeTargetGroups",
"elasticloadbalancing:DescribeTargetGroupAttributes",
"elasticloadbalancing:DescribeTargetHealth",
"elasticloadbalancing:DescribeTags"
]
Resource = "*"
},
{
Effect = "Allow"
Action = [
"cognito-idp:DescribeUserPoolClient",
"acm:ListCertificates",
"acm:DescribeCertificate",
"iam:ListServerCertificates",
"iam:GetServerCertificate",
"waf-regional:GetWebACL",
"waf-regional:GetWebACLForResource",
"waf-regional:AssociateWebACL",
"waf-regional:DisassociateWebACL",
"wafv2:GetWebACL",
"wafv2:GetWebACLForResource",
"wafv2:AssociateWebACL",
"wafv2:DisassociateWebACL",
"shield:GetSubscriptionState",
"shield:DescribeProtection",
"shield:CreateProtection",
"shield:DeleteProtection"
]
Resource = "*"
},
{
Effect = "Allow"
Action = [
"ec2:AuthorizeSecurityGroupIngress",
"ec2:RevokeSecurityGroupIngress",
"ec2:CreateSecurityGroup",
"ec2:CreateTags",
"ec2:DeleteTags",
"ec2:DeleteSecurityGroup"
]
Resource = "*"
},
{
Effect = "Allow"
Action = [
"elasticloadbalancing:CreateLoadBalancer",
"elasticloadbalancing:CreateTargetGroup"
]
Resource = "*"
Condition = {
Null = {
"aws:RequestTag/elbv2.k8s.aws/cluster" = "false"
}
}
},
{
Effect = "Allow"
Action = [
"elasticloadbalancing:AddTags",
"elasticloadbalancing:RemoveTags"
]
Resource = [
"arn:${data.aws_partition.current.partition}:elasticloadbalancing:*:*:targetgroup/*/*",
"arn:${data.aws_partition.current.partition}:elasticloadbalancing:*:*:loadbalancer/net/*/*",
"arn:${data.aws_partition.current.partition}:elasticloadbalancing:*:*:loadbalancer/app/*/*"
]
},
{
Effect = "Allow"
Action = [
"elasticloadbalancing:ModifyLoadBalancerAttributes",
"elasticloadbalancing:SetIpAddressType",
"elasticloadbalancing:SetSecurityGroups",
"elasticloadbalancing:SetSubnets",
"elasticloadbalancing:DeleteLoadBalancer",
"elasticloadbalancing:ModifyTargetGroup",
"elasticloadbalancing:ModifyTargetGroupAttributes",
"elasticloadbalancing:DeleteTargetGroup",
"elasticloadbalancing:RegisterTargets",
"elasticloadbalancing:DeregisterTargets",
"elasticloadbalancing:CreateListener",
"elasticloadbalancing:DeleteListener",
"elasticloadbalancing:CreateRule",
"elasticloadbalancing:ModifyRule",
"elasticloadbalancing:DeleteRule",
"elasticloadbalancing:SetWebAcl",
"elasticloadbalancing:ModifyListener",
"elasticloadbalancing:AddListenerCertificates",
"elasticloadbalancing:RemoveListenerCertificates"
]
Resource = "*"
}
]
})
}
################################################################################
# EKS Access Entries (K8s 1.29+)
################################################################################
resource "aws_eks_access_entry" "admins" {
for_each = toset(local.admin_arns)
cluster_name = aws_eks_cluster.main.name
principal_arn = each.value
type = "STANDARD"
}
resource "aws_eks_access_policy_association" "admins" {
for_each = toset(local.admin_arns)
cluster_name = aws_eks_cluster.main.name
principal_arn = each.value
policy_arn = "arn:${data.aws_partition.current.partition}:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy"
access_scope {
type = "cluster"
}
depends_on = [aws_eks_access_entry.admins]
}
################################################################################
# Outputs
################################################################################
output "cluster_name" {
value = aws_eks_cluster.main.name
}
output "cluster_endpoint" {
value = aws_eks_cluster.main.endpoint
}
output "cluster_ca_certificate" {
value = aws_eks_cluster.main.certificate_authority[0].data
sensitive = true
}
output "cluster_version" {
value = aws_eks_cluster.main.version
}
output "cluster_security_group_id" {
value = aws_security_group.cluster.id
}
output "node_security_group_id" {
value = aws_security_group.node.id
}
output "oidc_provider_arn" {
value = aws_iam_openid_connect_provider.eks.arn
}
output "oidc_provider_url" {
value = aws_iam_openid_connect_provider.eks.url
}
output "cluster_autoscaler_role_arn" {
value = local.enable_cluster_autoscaler ? aws_iam_role.cluster_autoscaler[0].arn : null
}
output "lb_controller_role_arn" {
value = local.enable_aws_lb_controller ? aws_iam_role.lb_controller[0].arn : null
}
output "kubeconfig_command" {
value = "aws eks update-kubeconfig --region ${data.aws_region.current.name} --name ${aws_eks_cluster.main.name}"
}
output "next_steps" {
value = <<-EOT
EKS Cluster Created: ${aws_eks_cluster.main.name}
=============================================
1. Configure kubectl:
${local.enable_cluster_autoscaler ? "aws eks update-kubeconfig --region ${data.aws_region.current.name} --name ${aws_eks_cluster.main.name}" : ""}
2. Install Cluster Autoscaler (if enabled):
helm repo add autoscaler https://kubernetes.github.io/autoscaler
helm install cluster-autoscaler autoscaler/cluster-autoscaler \
--namespace kube-system \
--set autoDiscovery.clusterName=${aws_eks_cluster.main.name} \
--set awsRegion=${data.aws_region.current.name} \
--set rbac.serviceAccount.create=true \
--set rbac.serviceAccount.name=cluster-autoscaler \
--set rbac.serviceAccount.annotations."eks\.amazonaws\.com/role-arn"=${local.enable_cluster_autoscaler ? aws_iam_role.cluster_autoscaler[0].arn : "N/A"}
3. Install AWS Load Balancer Controller (if enabled):
helm repo add eks https://aws.github.io/eks-charts
helm install aws-load-balancer-controller eks/aws-load-balancer-controller \
--namespace kube-system \
--set clusterName=${aws_eks_cluster.main.name} \
--set serviceAccount.create=true \
--set serviceAccount.name=aws-load-balancer-controller \
--set serviceAccount.annotations."eks\.amazonaws\.com/role-arn"=${local.enable_aws_lb_controller ? aws_iam_role.lb_controller[0].arn : "N/A"}
EOT
}

View File

@@ -0,0 +1,389 @@
################################################################################
# Workload: ElastiCache Redis
#
# Deploys a managed Redis cluster:
# - Redis cluster or replication group
# - Encryption at rest and in transit
# - Automatic failover (Multi-AZ)
# - CloudWatch alarms
#
# Usage:
# Copy this folder to 05-workloads/<tenant>-cache/
# Update locals and variables
# terraform init -backend-config=../../00-bootstrap/backend.hcl
# terraform apply
################################################################################
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
backend "s3" {
key = "05-workloads/<TENANT>-cache/terraform.tfstate"
}
}
################################################################################
# Configuration - UPDATE THESE
################################################################################
locals {
# Naming
tenant = "<TENANT>"
app = "cache"
env = "prod" # prod, staging, dev
name = "${local.tenant}-${local.app}-${local.env}"
# Redis version
engine_version = "7.1"
# Node sizing
# cache.t3.micro - Dev/test ($0.017/hr)
# cache.t3.small - Small prod ($0.034/hr)
# cache.r6g.large - Production ($0.158/hr)
node_type = "cache.t3.micro"
# Cluster configuration
num_cache_clusters = local.env == "prod" ? 2 : 1 # 2 for Multi-AZ
automatic_failover = local.env == "prod"
multi_az_enabled = local.env == "prod"
# Memory management
maxmemory_policy = "volatile-lru" # Evict keys with TTL when memory full
# Maintenance
maintenance_window = "sun:05:00-sun:06:00"
snapshot_window = "04:00-05:00"
snapshot_retention = local.env == "prod" ? 7 : 1
# Port
port = 6379
}
################################################################################
# Variables
################################################################################
variable "region" {
type = string
default = "us-east-1"
}
variable "state_bucket" {
type = string
}
################################################################################
# Provider
################################################################################
provider "aws" {
region = var.region
default_tags {
tags = {
Tenant = local.tenant
App = local.app
Environment = local.env
ManagedBy = "terraform"
}
}
}
################################################################################
# Data Sources
################################################################################
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = var.state_bucket
key = "02-network/terraform.tfstate"
region = var.region
}
}
data "terraform_remote_state" "tenant" {
backend = "s3"
config = {
bucket = var.state_bucket
key = "04-tenants/${local.tenant}/terraform.tfstate"
region = var.region
}
}
data "aws_caller_identity" "current" {}
################################################################################
# KMS Key
################################################################################
resource "aws_kms_key" "redis" {
description = "KMS key for ${local.name} Redis encryption"
deletion_window_in_days = 7
enable_key_rotation = true
tags = { Name = "${local.name}-redis" }
}
resource "aws_kms_alias" "redis" {
name = "alias/${local.name}-redis"
target_key_id = aws_kms_key.redis.key_id
}
################################################################################
# Subnet Group
################################################################################
resource "aws_elasticache_subnet_group" "main" {
name = local.name
description = "Subnet group for ${local.name}"
subnet_ids = data.terraform_remote_state.network.outputs.private_subnet_ids
tags = { Name = local.name }
}
################################################################################
# Parameter Group
################################################################################
resource "aws_elasticache_parameter_group" "main" {
name = local.name
family = "redis7"
description = "Parameter group for ${local.name}"
parameter {
name = "maxmemory-policy"
value = local.maxmemory_policy
}
# Cluster mode disabled settings
parameter {
name = "cluster-enabled"
value = "no"
}
# Slow log for debugging
parameter {
name = "slowlog-log-slower-than"
value = "10000" # 10ms
}
parameter {
name = "slowlog-max-len"
value = "128"
}
tags = { Name = local.name }
}
################################################################################
# Security Group
################################################################################
resource "aws_security_group" "redis" {
name = "${local.name}-redis"
description = "Redis cluster ${local.name}"
vpc_id = data.terraform_remote_state.network.outputs.vpc_id
ingress {
description = "Redis from tenant"
from_port = local.port
to_port = local.port
protocol = "tcp"
security_groups = [data.terraform_remote_state.tenant.outputs.security_groups.base]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
description = "Allow all outbound"
}
tags = { Name = "${local.name}-redis" }
}
################################################################################
# Replication Group (Redis Cluster)
################################################################################
resource "aws_elasticache_replication_group" "main" {
replication_group_id = local.name
description = "Redis cluster for ${local.name}"
engine = "redis"
engine_version = local.engine_version
node_type = local.node_type
port = local.port
parameter_group_name = aws_elasticache_parameter_group.main.name
# Cluster configuration
num_cache_clusters = local.num_cache_clusters
automatic_failover_enabled = local.automatic_failover
multi_az_enabled = local.multi_az_enabled
# Network
subnet_group_name = aws_elasticache_subnet_group.main.name
security_group_ids = [aws_security_group.redis.id]
# Encryption
at_rest_encryption_enabled = true
kms_key_id = aws_kms_key.redis.arn
transit_encryption_enabled = true
auth_token = random_password.auth.result
# Maintenance
maintenance_window = local.maintenance_window
snapshot_window = local.snapshot_window
snapshot_retention_limit = local.snapshot_retention
auto_minor_version_upgrade = true
# Notifications
notification_topic_arn = aws_sns_topic.redis.arn
# Apply changes immediately in non-prod, during maintenance in prod
apply_immediately = local.env != "prod"
tags = {
Name = local.name
Backup = "true"
}
}
################################################################################
# Auth Token (Password)
################################################################################
resource "random_password" "auth" {
length = 64
special = false # Redis auth token doesn't support all special chars
}
resource "aws_secretsmanager_secret" "redis" {
name = "${local.name}-redis-auth"
description = "Redis auth token for ${local.name}"
recovery_window_in_days = local.env == "prod" ? 30 : 0
tags = { Name = "${local.name}-redis-auth" }
}
resource "aws_secretsmanager_secret_version" "redis" {
secret_id = aws_secretsmanager_secret.redis.id
secret_string = jsonencode({
auth_token = random_password.auth.result
host = aws_elasticache_replication_group.main.primary_endpoint_address
port = local.port
url = "rediss://:${random_password.auth.result}@${aws_elasticache_replication_group.main.primary_endpoint_address}:${local.port}"
})
}
################################################################################
# SNS Topic for Notifications
################################################################################
resource "aws_sns_topic" "redis" {
name = "${local.name}-redis-events"
tags = { Name = "${local.name}-redis-events" }
}
################################################################################
# CloudWatch Alarms
################################################################################
resource "aws_cloudwatch_metric_alarm" "cpu" {
alarm_name = "${local.name}-redis-cpu"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 3
metric_name = "CPUUtilization"
namespace = "AWS/ElastiCache"
period = 300
statistic = "Average"
threshold = 75
alarm_description = "Redis CPU utilization high"
dimensions = {
CacheClusterId = "${aws_elasticache_replication_group.main.id}-001"
}
alarm_actions = [aws_sns_topic.redis.arn]
tags = { Name = "${local.name}-redis-cpu" }
}
resource "aws_cloudwatch_metric_alarm" "memory" {
alarm_name = "${local.name}-redis-memory"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
metric_name = "DatabaseMemoryUsagePercentage"
namespace = "AWS/ElastiCache"
period = 300
statistic = "Average"
threshold = 80
alarm_description = "Redis memory usage high"
dimensions = {
CacheClusterId = "${aws_elasticache_replication_group.main.id}-001"
}
alarm_actions = [aws_sns_topic.redis.arn]
tags = { Name = "${local.name}-redis-memory" }
}
resource "aws_cloudwatch_metric_alarm" "connections" {
alarm_name = "${local.name}-redis-connections"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
metric_name = "CurrConnections"
namespace = "AWS/ElastiCache"
period = 300
statistic = "Average"
threshold = 1000
alarm_description = "Redis connections high"
dimensions = {
CacheClusterId = "${aws_elasticache_replication_group.main.id}-001"
}
alarm_actions = [aws_sns_topic.redis.arn]
tags = { Name = "${local.name}-redis-connections" }
}
################################################################################
# Outputs
################################################################################
output "primary_endpoint" {
value = aws_elasticache_replication_group.main.primary_endpoint_address
}
output "reader_endpoint" {
value = aws_elasticache_replication_group.main.reader_endpoint_address
}
output "port" {
value = local.port
}
output "secret_arn" {
value = aws_secretsmanager_secret.redis.arn
}
output "security_group_id" {
value = aws_security_group.redis.id
}
output "connection_command" {
value = "redis-cli -h ${aws_elasticache_replication_group.main.primary_endpoint_address} -p ${local.port} --tls --askpass"
description = "Command to connect (retrieve password from Secrets Manager)"
}

View File

@@ -0,0 +1,385 @@
################################################################################
# Workload: EventBridge Event Bus
#
# Deploys an event-driven architecture component:
# - Custom event bus for tenant isolation
# - Event rules with pattern matching
# - Multiple targets (Lambda, SQS, Step Functions)
# - Dead letter queue for failed events
# - Event archiving for replay
#
# Usage:
# Copy this folder to 05-workloads/<tenant>-events/
# Update locals and rules
# terraform init -backend-config=../../00-bootstrap/backend.hcl
# terraform apply
################################################################################
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
backend "s3" {
key = "05-workloads/<TENANT>-events/terraform.tfstate"
}
}
################################################################################
# Configuration - UPDATE THESE
################################################################################
locals {
# Naming
tenant = "<TENANT>"
name = "events"
env = "prod"
bus_name = "${local.tenant}-${local.name}-${local.env}"
# Event archiving (for replay capability)
enable_archive = true
archive_retention_days = 30
# Dead letter queue for failed event delivery
enable_dlq = true
# Schema discovery (for event schema registry)
enable_schema_discovery = false
# Event rules - define your event routing here
event_rules = {
# Example: Route order events to SQS
# order-created = {
# description = "Route order.created events to processing queue"
# event_pattern = {
# source = ["${local.tenant}.orders"]
# detail-type = ["order.created"]
# }
# targets = {
# sqs = {
# type = "sqs"
# arn = "arn:aws:sqs:us-east-1:123456789012:order-processing"
# }
# }
# }
# Example: Route all events to CloudWatch Logs for debugging
all-events-log = {
description = "Log all events for debugging"
event_pattern = {
source = [{ prefix = "${local.tenant}." }]
}
targets = {
logs = {
type = "cloudwatch"
}
}
}
}
# Cross-account event sources (account IDs that can put events)
allowed_source_accounts = []
# Cross-account event targets (account IDs that can receive events)
allowed_target_accounts = []
}
################################################################################
# Variables
################################################################################
variable "region" {
type = string
default = "us-east-1"
}
variable "state_bucket" {
type = string
}
################################################################################
# Provider
################################################################################
provider "aws" {
region = var.region
default_tags {
tags = {
Tenant = local.tenant
App = local.name
Environment = local.env
ManagedBy = "terraform"
}
}
}
################################################################################
# Data Sources
################################################################################
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
################################################################################
# Event Bus
################################################################################
resource "aws_cloudwatch_event_bus" "main" {
name = local.bus_name
tags = { Name = local.bus_name }
}
################################################################################
# Event Bus Policy
################################################################################
resource "aws_cloudwatch_event_bus_policy" "main" {
count = length(local.allowed_source_accounts) > 0 ? 1 : 0
event_bus_name = aws_cloudwatch_event_bus.main.name
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowCrossAccountPutEvents"
Effect = "Allow"
Principal = {
AWS = [for account in local.allowed_source_accounts : "arn:aws:iam::${account}:root"]
}
Action = "events:PutEvents"
Resource = aws_cloudwatch_event_bus.main.arn
}
]
})
}
################################################################################
# Event Archive
################################################################################
resource "aws_cloudwatch_event_archive" "main" {
count = local.enable_archive ? 1 : 0
name = local.bus_name
description = "Archive for ${local.bus_name}"
event_source_arn = aws_cloudwatch_event_bus.main.arn
retention_days = local.archive_retention_days
# Archive all events (can be filtered with event_pattern)
}
################################################################################
# Dead Letter Queue
################################################################################
resource "aws_sqs_queue" "dlq" {
count = local.enable_dlq ? 1 : 0
name = "${local.bus_name}-dlq"
message_retention_seconds = 1209600 # 14 days
kms_master_key_id = "alias/aws/sqs"
tags = { Name = "${local.bus_name}-dlq" }
}
resource "aws_sqs_queue_policy" "dlq" {
count = local.enable_dlq ? 1 : 0
queue_url = aws_sqs_queue.dlq[0].id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "AllowEventBridge"
Effect = "Allow"
Principal = {
Service = "events.amazonaws.com"
}
Action = "sqs:SendMessage"
Resource = aws_sqs_queue.dlq[0].arn
}]
})
}
################################################################################
# CloudWatch Log Group for Event Logging
################################################################################
resource "aws_cloudwatch_log_group" "events" {
name = "/aws/events/${local.bus_name}"
retention_in_days = 30
tags = { Name = local.bus_name }
}
# Resource policy to allow EventBridge to write logs
resource "aws_cloudwatch_log_resource_policy" "events" {
policy_name = "${local.bus_name}-events"
policy_document = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
Service = ["events.amazonaws.com", "delivery.logs.amazonaws.com"]
}
Action = ["logs:CreateLogStream", "logs:PutLogEvents"]
Resource = "${aws_cloudwatch_log_group.events.arn}:*"
}]
})
}
################################################################################
# IAM Role for EventBridge Targets
################################################################################
resource "aws_iam_role" "eventbridge" {
name = "${local.bus_name}-eventbridge"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "events.amazonaws.com" }
}]
})
tags = { Name = "${local.bus_name}-eventbridge" }
}
################################################################################
# Event Rules and Targets
################################################################################
resource "aws_cloudwatch_event_rule" "rules" {
for_each = local.event_rules
name = "${local.bus_name}-${each.key}"
description = each.value.description
event_bus_name = aws_cloudwatch_event_bus.main.name
event_pattern = jsonencode(each.value.event_pattern)
state = "ENABLED"
tags = { Name = "${local.bus_name}-${each.key}" }
}
# CloudWatch Logs targets
resource "aws_cloudwatch_event_target" "logs" {
for_each = {
for k, v in local.event_rules : k => v
if contains(keys(v.targets), "logs") && v.targets.logs.type == "cloudwatch"
}
rule = aws_cloudwatch_event_rule.rules[each.key].name
event_bus_name = aws_cloudwatch_event_bus.main.name
target_id = "cloudwatch-logs"
arn = aws_cloudwatch_log_group.events.arn
dead_letter_config {
arn = local.enable_dlq ? aws_sqs_queue.dlq[0].arn : null
}
}
################################################################################
# Schema Registry (Optional)
################################################################################
resource "aws_schemas_discoverer" "main" {
count = local.enable_schema_discovery ? 1 : 0
source_arn = aws_cloudwatch_event_bus.main.arn
description = "Schema discoverer for ${local.bus_name}"
tags = { Name = local.bus_name }
}
################################################################################
# CloudWatch Alarms
################################################################################
resource "aws_cloudwatch_metric_alarm" "dlq_messages" {
count = local.enable_dlq ? 1 : 0
alarm_name = "${local.bus_name}-dlq-messages"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 1
metric_name = "ApproximateNumberOfMessagesVisible"
namespace = "AWS/SQS"
period = 300
statistic = "Sum"
threshold = 0
alarm_description = "Events failing to deliver to targets"
dimensions = {
QueueName = aws_sqs_queue.dlq[0].name
}
tags = { Name = "${local.bus_name}-dlq-alarm" }
}
resource "aws_cloudwatch_metric_alarm" "failed_invocations" {
alarm_name = "${local.bus_name}-failed-invocations"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
metric_name = "FailedInvocations"
namespace = "AWS/Events"
period = 300
statistic = "Sum"
threshold = 0
alarm_description = "EventBridge rule invocations failing"
dimensions = {
EventBusName = aws_cloudwatch_event_bus.main.name
}
tags = { Name = "${local.bus_name}-failed-invocations" }
}
################################################################################
# Outputs
################################################################################
output "event_bus_name" {
value = aws_cloudwatch_event_bus.main.name
}
output "event_bus_arn" {
value = aws_cloudwatch_event_bus.main.arn
}
output "archive_arn" {
value = local.enable_archive ? aws_cloudwatch_event_archive.main[0].arn : null
}
output "dlq_url" {
value = local.enable_dlq ? aws_sqs_queue.dlq[0].url : null
}
output "dlq_arn" {
value = local.enable_dlq ? aws_sqs_queue.dlq[0].arn : null
}
output "log_group" {
value = aws_cloudwatch_log_group.events.name
}
output "rule_arns" {
value = { for k, v in aws_cloudwatch_event_rule.rules : k => v.arn }
}
output "put_event_example" {
value = <<-EOF
aws events put-events --entries '[{
"EventBusName": "${aws_cloudwatch_event_bus.main.name}",
"Source": "${local.tenant}.myservice",
"DetailType": "order.created",
"Detail": "{\"orderId\": \"12345\", \"amount\": 99.99}"
}]'
EOF
description = "Example command to put an event"
}

View File

@@ -0,0 +1,422 @@
################################################################################
# Workload: EventBridge Rules
#
# Event-driven automation with:
# - Scheduled rules (cron/rate)
# - Event pattern rules (AWS service events)
# - Multiple targets (Lambda, SQS, SNS, Step Functions)
# - Dead letter queues
# - Input transformations
#
# Use cases: Scheduled jobs, event routing, service integration
################################################################################
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
backend "s3" {
key = "05-workloads/<TENANT>-<NAME>-events/terraform.tfstate"
}
}
################################################################################
# Configuration - UPDATE THESE
################################################################################
locals {
# Naming
tenant = "<TENANT>"
name = "<NAME>"
env = "prod"
prefix = "${local.tenant}-${local.name}"
# Use custom event bus (null = default bus)
event_bus_name = null
# Scheduled rules
scheduled_rules = {
# Daily report at 9 AM UTC
daily-report = {
description = "Generate daily report"
schedule_expression = "cron(0 9 * * ? *)"
enabled = true
target_type = "lambda"
target_arn = "" # Lambda function ARN
input = jsonencode({
report_type = "daily"
format = "pdf"
})
}
# Every 5 minutes health check
health-check = {
description = "Periodic health check"
schedule_expression = "rate(5 minutes)"
enabled = true
target_type = "lambda"
target_arn = "" # Lambda function ARN
}
# Monthly cleanup (1st of month at midnight)
monthly-cleanup = {
description = "Monthly data cleanup"
schedule_expression = "cron(0 0 1 * ? *)"
enabled = true
target_type = "step-function"
target_arn = "" # State machine ARN
input = jsonencode({
retention_days = 90
})
}
}
# Event pattern rules (react to AWS events)
event_pattern_rules = {
# EC2 instance state changes
ec2-state-change = {
description = "EC2 instance state changes"
enabled = true
event_pattern = jsonencode({
source = ["aws.ec2"]
detail-type = ["EC2 Instance State-change Notification"]
detail = {
state = ["stopped", "terminated"]
}
})
target_type = "sns"
target_arn = "" # SNS topic ARN
}
# S3 object created
s3-upload = {
description = "S3 object created in uploads bucket"
enabled = true
event_pattern = jsonencode({
source = ["aws.s3"]
detail-type = ["Object Created"]
detail = {
bucket = {
name = ["my-uploads-bucket"]
}
}
})
target_type = "lambda"
target_arn = "" # Lambda function ARN
input_transformer = {
input_paths = {
bucket = "$.detail.bucket.name"
key = "$.detail.object.key"
size = "$.detail.object.size"
}
input_template = <<-EOF
{
"bucket": <bucket>,
"key": <key>,
"size": <size>,
"timestamp": "<aws.events.event.ingestion-time>"
}
EOF
}
}
# CodePipeline state change
pipeline-failed = {
description = "CodePipeline execution failed"
enabled = true
event_pattern = jsonencode({
source = ["aws.codepipeline"]
detail-type = ["CodePipeline Pipeline Execution State Change"]
detail = {
state = ["FAILED"]
}
})
target_type = "sns"
target_arn = "" # SNS topic ARN
}
# GuardDuty findings
security-findings = {
description = "GuardDuty security findings"
enabled = false # Enable when GuardDuty is active
event_pattern = jsonencode({
source = ["aws.guardduty"]
detail-type = ["GuardDuty Finding"]
detail = {
severity = [{ numeric = [">=", 7] }] # High severity
}
})
target_type = "sns"
target_arn = "" # SNS topic ARN
}
}
# Enable DLQ for failed deliveries
enable_dlq = true
}
################################################################################
# Variables
################################################################################
variable "region" {
type = string
default = "us-east-1"
}
variable "state_bucket" {
type = string
}
################################################################################
# Provider
################################################################################
provider "aws" {
region = var.region
default_tags {
tags = {
Tenant = local.tenant
App = local.name
Environment = local.env
ManagedBy = "terraform"
}
}
}
################################################################################
# Data Sources
################################################################################
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
################################################################################
# Dead Letter Queue
################################################################################
resource "aws_sqs_queue" "dlq" {
count = local.enable_dlq ? 1 : 0
name = "${local.prefix}-events-dlq"
message_retention_seconds = 1209600 # 14 days
kms_master_key_id = "alias/aws/sqs"
tags = { Name = "${local.prefix}-events-dlq" }
}
resource "aws_sqs_queue_policy" "dlq" {
count = local.enable_dlq ? 1 : 0
queue_url = aws_sqs_queue.dlq[0].id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "AllowEventBridge"
Effect = "Allow"
Principal = {
Service = "events.amazonaws.com"
}
Action = "sqs:SendMessage"
Resource = aws_sqs_queue.dlq[0].arn
}]
})
}
################################################################################
# IAM Role for EventBridge
################################################################################
resource "aws_iam_role" "eventbridge" {
name = "${local.prefix}-eventbridge"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "events.amazonaws.com" }
}]
})
tags = { Name = "${local.prefix}-eventbridge" }
}
resource "aws_iam_role_policy" "eventbridge" {
name = "invoke-targets"
role = aws_iam_role.eventbridge.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "InvokeStepFunctions"
Effect = "Allow"
Action = "states:StartExecution"
Resource = "*"
},
{
Sid = "InvokeLambda"
Effect = "Allow"
Action = "lambda:InvokeFunction"
Resource = "*"
},
{
Sid = "SendToSQS"
Effect = "Allow"
Action = "sqs:SendMessage"
Resource = "*"
},
{
Sid = "PublishToSNS"
Effect = "Allow"
Action = "sns:Publish"
Resource = "*"
}
]
})
}
################################################################################
# Scheduled Rules
################################################################################
resource "aws_cloudwatch_event_rule" "scheduled" {
for_each = { for k, v in local.scheduled_rules : k => v if v.target_arn != "" }
name = "${local.prefix}-${each.key}"
description = lookup(each.value, "description", "Scheduled rule ${each.key}")
schedule_expression = each.value.schedule_expression
event_bus_name = local.event_bus_name
state = each.value.enabled ? "ENABLED" : "DISABLED"
tags = { Name = "${local.prefix}-${each.key}" }
}
resource "aws_cloudwatch_event_target" "scheduled" {
for_each = { for k, v in local.scheduled_rules : k => v if v.target_arn != "" }
rule = aws_cloudwatch_event_rule.scheduled[each.key].name
event_bus_name = local.event_bus_name
target_id = each.key
arn = each.value.target_arn
role_arn = each.value.target_type == "step-function" ? aws_iam_role.eventbridge.arn : null
input = lookup(each.value, "input", null)
dynamic "dead_letter_config" {
for_each = local.enable_dlq ? [1] : []
content {
arn = aws_sqs_queue.dlq[0].arn
}
}
}
################################################################################
# Event Pattern Rules
################################################################################
resource "aws_cloudwatch_event_rule" "pattern" {
for_each = { for k, v in local.event_pattern_rules : k => v if v.target_arn != "" }
name = "${local.prefix}-${each.key}"
description = lookup(each.value, "description", "Event pattern rule ${each.key}")
event_pattern = each.value.event_pattern
event_bus_name = local.event_bus_name
state = each.value.enabled ? "ENABLED" : "DISABLED"
tags = { Name = "${local.prefix}-${each.key}" }
}
resource "aws_cloudwatch_event_target" "pattern" {
for_each = { for k, v in local.event_pattern_rules : k => v if v.target_arn != "" }
rule = aws_cloudwatch_event_rule.pattern[each.key].name
event_bus_name = local.event_bus_name
target_id = each.key
arn = each.value.target_arn
role_arn = each.value.target_type == "step-function" ? aws_iam_role.eventbridge.arn : null
input = lookup(each.value, "input", null)
dynamic "input_transformer" {
for_each = lookup(each.value, "input_transformer", null) != null ? [each.value.input_transformer] : []
content {
input_paths = input_transformer.value.input_paths
input_template = input_transformer.value.input_template
}
}
dynamic "dead_letter_config" {
for_each = local.enable_dlq ? [1] : []
content {
arn = aws_sqs_queue.dlq[0].arn
}
}
}
################################################################################
# Lambda Permissions
################################################################################
resource "aws_lambda_permission" "scheduled" {
for_each = { for k, v in local.scheduled_rules : k => v if v.target_arn != "" && v.target_type == "lambda" }
statement_id = "AllowEventBridge-${each.key}"
action = "lambda:InvokeFunction"
function_name = regex("function:([^:]+)", each.value.target_arn)[0]
principal = "events.amazonaws.com"
source_arn = aws_cloudwatch_event_rule.scheduled[each.key].arn
}
resource "aws_lambda_permission" "pattern" {
for_each = { for k, v in local.event_pattern_rules : k => v if v.target_arn != "" && v.target_type == "lambda" }
statement_id = "AllowEventBridge-${each.key}"
action = "lambda:InvokeFunction"
function_name = regex("function:([^:]+)", each.value.target_arn)[0]
principal = "events.amazonaws.com"
source_arn = aws_cloudwatch_event_rule.pattern[each.key].arn
}
################################################################################
# Outputs
################################################################################
output "scheduled_rule_arns" {
value = { for k, v in aws_cloudwatch_event_rule.scheduled : k => v.arn }
description = "Scheduled rule ARNs"
}
output "pattern_rule_arns" {
value = { for k, v in aws_cloudwatch_event_rule.pattern : k => v.arn }
description = "Event pattern rule ARNs"
}
output "dlq_arn" {
value = local.enable_dlq ? aws_sqs_queue.dlq[0].arn : null
description = "Dead letter queue ARN"
}
output "eventbridge_role_arn" {
value = aws_iam_role.eventbridge.arn
description = "EventBridge execution role ARN"
}
output "cron_examples" {
value = {
every_5_min = "rate(5 minutes)"
every_hour = "rate(1 hour)"
daily_9am_utc = "cron(0 9 * * ? *)"
weekdays_8am = "cron(0 8 ? * MON-FRI *)"
monthly_1st = "cron(0 0 1 * ? *)"
every_monday = "cron(0 12 ? * MON *)"
}
description = "Cron expression examples"
}

View File

@@ -0,0 +1,409 @@
################################################################################
# Workload: Lambda Function
#
# Deploys a serverless function:
# - Lambda function with VPC access (optional)
# - API Gateway HTTP API (optional)
# - CloudWatch logging & X-Ray tracing
# - EventBridge rules for scheduled invocation (optional)
#
# Usage:
# Copy this folder to 05-workloads/<tenant>-<app>/
# Update locals and variables
# terraform init -backend-config=../../00-bootstrap/backend.hcl
# terraform apply
################################################################################
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
backend "s3" {
key = "05-workloads/<TENANT>-<APP>/terraform.tfstate"
}
}
################################################################################
# Configuration - UPDATE THESE
################################################################################
locals {
# Naming
tenant = "<TENANT>"
app = "<APP>"
env = "prod" # prod, staging, dev
name = "${local.tenant}-${local.app}-${local.env}"
# Lambda config
runtime = "python3.12" # python3.12, nodejs20.x, go1.x, etc.
handler = "main.handler"
memory_size = 256
timeout = 30
# Source - provide ONE of these
source_dir = null # Path to source directory (will be zipped)
s3_bucket = null # S3 bucket containing deployment package
s3_key = null # S3 key for deployment package
image_uri = null # Container image URI
# VPC - set to true for database access
enable_vpc = false
# API Gateway
enable_api = true
api_path = "/{proxy+}"
# Scheduled execution (cron or rate expression)
schedule_expression = null # "rate(5 minutes)" or "cron(0 12 * * ? *)"
# Environment variables
environment = {
APP_ENV = local.env
LOG_LEVEL = "INFO"
}
# Secrets (ARNs to SSM/Secrets Manager)
secrets = {}
}
################################################################################
# Variables
################################################################################
variable "region" {
type = string
default = "us-east-1"
}
variable "state_bucket" {
type = string
}
################################################################################
# Provider
################################################################################
provider "aws" {
region = var.region
default_tags {
tags = {
Tenant = local.tenant
App = local.app
Environment = local.env
ManagedBy = "terraform"
}
}
}
################################################################################
# Data Sources
################################################################################
data "terraform_remote_state" "network" {
count = local.enable_vpc ? 1 : 0
backend = "s3"
config = {
bucket = var.state_bucket
key = "02-network/terraform.tfstate"
region = var.region
}
}
data "terraform_remote_state" "tenant" {
count = local.enable_vpc ? 1 : 0
backend = "s3"
config = {
bucket = var.state_bucket
key = "04-tenants/${local.tenant}/terraform.tfstate"
region = var.region
}
}
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
################################################################################
# CloudWatch Log Group
################################################################################
resource "aws_cloudwatch_log_group" "main" {
name = "/aws/lambda/${local.name}"
retention_in_days = 30
tags = { Name = local.name }
}
################################################################################
# IAM Role
################################################################################
resource "aws_iam_role" "lambda" {
name = "${local.name}-lambda"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "lambda.amazonaws.com" }
}]
})
tags = { Name = "${local.name}-lambda" }
}
resource "aws_iam_role_policy_attachment" "lambda_basic" {
role = aws_iam_role.lambda.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
resource "aws_iam_role_policy_attachment" "lambda_vpc" {
count = local.enable_vpc ? 1 : 0
role = aws_iam_role.lambda.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
}
resource "aws_iam_role_policy_attachment" "lambda_xray" {
role = aws_iam_role.lambda.name
policy_arn = "arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess"
}
resource "aws_iam_role_policy" "lambda_app" {
name = "app-permissions"
role = aws_iam_role.lambda.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowTaggedResources"
Effect = "Allow"
Action = ["s3:GetObject", "s3:PutObject", "dynamodb:*", "sqs:*", "sns:Publish"]
Resource = "*"
Condition = { StringEquals = { "aws:ResourceTag/Tenant" = local.tenant } }
},
{
Sid = "SecretsAccess"
Effect = "Allow"
Action = ["secretsmanager:GetSecretValue", "ssm:GetParameter", "ssm:GetParameters"]
Resource = length(local.secrets) > 0 ? values(local.secrets) : ["arn:aws:ssm:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:parameter/${local.tenant}/*"]
}
]
})
}
################################################################################
# Security Group (VPC mode)
################################################################################
resource "aws_security_group" "lambda" {
count = local.enable_vpc ? 1 : 0
name = "${local.name}-lambda"
description = "Lambda function ${local.name}"
vpc_id = data.terraform_remote_state.network[0].outputs.vpc_id
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "${local.name}-lambda" }
}
################################################################################
# Lambda Function
################################################################################
# Create zip from source directory if provided
data "archive_file" "lambda" {
count = local.source_dir != null ? 1 : 0
type = "zip"
source_dir = local.source_dir
output_path = "${path.module}/lambda.zip"
}
resource "aws_lambda_function" "main" {
function_name = local.name
description = "${local.tenant} ${local.app} function"
role = aws_iam_role.lambda.arn
# Source - exactly one must be specified
filename = local.source_dir != null ? data.archive_file.lambda[0].output_path : null
source_code_hash = local.source_dir != null ? data.archive_file.lambda[0].output_base64sha256 : null
s3_bucket = local.s3_bucket
s3_key = local.s3_key
image_uri = local.image_uri
package_type = local.image_uri != null ? "Image" : "Zip"
# Only for Zip packages
runtime = local.image_uri == null ? local.runtime : null
handler = local.image_uri == null ? local.handler : null
memory_size = local.memory_size
timeout = local.timeout
environment {
variables = merge(local.environment, {
for k, v in local.secrets : k => v
})
}
dynamic "vpc_config" {
for_each = local.enable_vpc ? [1] : []
content {
subnet_ids = data.terraform_remote_state.network[0].outputs.private_subnet_ids
security_group_ids = [
aws_security_group.lambda[0].id,
data.terraform_remote_state.tenant[0].outputs.security_groups.base
]
}
}
tracing_config {
mode = "Active"
}
depends_on = [aws_cloudwatch_log_group.main]
tags = { Name = local.name }
}
################################################################################
# API Gateway HTTP API
################################################################################
resource "aws_apigatewayv2_api" "main" {
count = local.enable_api ? 1 : 0
name = local.name
protocol_type = "HTTP"
cors_configuration {
allow_origins = ["*"]
allow_methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
allow_headers = ["Content-Type", "Authorization"]
max_age = 300
}
tags = { Name = local.name }
}
resource "aws_apigatewayv2_stage" "main" {
count = local.enable_api ? 1 : 0
api_id = aws_apigatewayv2_api.main[0].id
name = "$default"
auto_deploy = true
access_log_settings {
destination_arn = aws_cloudwatch_log_group.api[0].arn
format = jsonencode({
requestId = "$context.requestId"
ip = "$context.identity.sourceIp"
requestTime = "$context.requestTime"
httpMethod = "$context.httpMethod"
routeKey = "$context.routeKey"
status = "$context.status"
responseLength = "$context.responseLength"
integrationError = "$context.integrationErrorMessage"
})
}
tags = { Name = local.name }
}
resource "aws_cloudwatch_log_group" "api" {
count = local.enable_api ? 1 : 0
name = "/aws/apigateway/${local.name}"
retention_in_days = 30
tags = { Name = "${local.name}-api" }
}
resource "aws_apigatewayv2_integration" "main" {
count = local.enable_api ? 1 : 0
api_id = aws_apigatewayv2_api.main[0].id
integration_type = "AWS_PROXY"
integration_uri = aws_lambda_function.main.invoke_arn
payload_format_version = "2.0"
}
resource "aws_apigatewayv2_route" "main" {
count = local.enable_api ? 1 : 0
api_id = aws_apigatewayv2_api.main[0].id
route_key = "ANY ${local.api_path}"
target = "integrations/${aws_apigatewayv2_integration.main[0].id}"
}
resource "aws_lambda_permission" "api" {
count = local.enable_api ? 1 : 0
statement_id = "AllowAPIGateway"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.main.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_apigatewayv2_api.main[0].execution_arn}/*/*"
}
################################################################################
# EventBridge Schedule
################################################################################
resource "aws_cloudwatch_event_rule" "schedule" {
count = local.schedule_expression != null ? 1 : 0
name = "${local.name}-schedule"
description = "Schedule for ${local.name}"
schedule_expression = local.schedule_expression
tags = { Name = "${local.name}-schedule" }
}
resource "aws_cloudwatch_event_target" "schedule" {
count = local.schedule_expression != null ? 1 : 0
rule = aws_cloudwatch_event_rule.schedule[0].name
target_id = "lambda"
arn = aws_lambda_function.main.arn
}
resource "aws_lambda_permission" "schedule" {
count = local.schedule_expression != null ? 1 : 0
statement_id = "AllowEventBridge"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.main.function_name
principal = "events.amazonaws.com"
source_arn = aws_cloudwatch_event_rule.schedule[0].arn
}
################################################################################
# Outputs
################################################################################
output "function_name" {
value = aws_lambda_function.main.function_name
}
output "function_arn" {
value = aws_lambda_function.main.arn
}
output "invoke_arn" {
value = aws_lambda_function.main.invoke_arn
}
output "api_endpoint" {
value = local.enable_api ? aws_apigatewayv2_api.main[0].api_endpoint : null
}
output "log_group" {
value = aws_cloudwatch_log_group.main.name
}
output "role_arn" {
value = aws_iam_role.lambda.arn
}

View File

@@ -0,0 +1,458 @@
################################################################################
# Workload: OpenSearch (Elasticsearch)
#
# Search and analytics with:
# - Serverless or provisioned clusters
# - Fine-grained access control
# - VPC or public access
# - Cognito authentication
# - UltraWarm for cost-effective storage
# - Cross-cluster search
#
# Use cases: Log analytics, full-text search, observability
################################################################################
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
backend "s3" {
key = "05-workloads/<TENANT>-<NAME>-opensearch/terraform.tfstate"
}
}
################################################################################
# Configuration - UPDATE THESE
################################################################################
locals {
# Naming
tenant = "<TENANT>"
name = "<NAME>"
env = "prod"
domain_name = "${local.tenant}-${local.name}-${local.env}"
# Engine
engine_version = "OpenSearch_2.11"
# Cluster sizing
cluster = {
# Data nodes
instance_type = "t3.medium.search" # t3.small.search for dev
instance_count = 2
# Dedicated master nodes (recommended for production)
dedicated_master_enabled = local.env == "prod"
dedicated_master_type = "t3.medium.search"
dedicated_master_count = 3
# Multi-AZ
zone_awareness_enabled = local.env == "prod"
availability_zone_count = local.env == "prod" ? 2 : 1
}
# Storage
storage = {
type = "gp3"
size_gb = 100
iops = 3000
throughput = 125
}
# UltraWarm (cost-effective warm storage)
ultrawarm = {
enabled = false
type = "ultrawarm1.medium.search"
count = 2
}
# Network
# Option 1: VPC (private, more secure)
vpc_enabled = true
vpc_id = "" # data.terraform_remote_state.network.outputs.vpc_id
private_subnet_ids = [] # data.terraform_remote_state.network.outputs.private_subnet_ids
# Option 2: Public (set vpc_enabled = false)
# Uses IP-based access policy
# Access control
enable_fine_grained_access = true
master_user_name = "admin"
# Cognito authentication (optional, for Dashboards)
cognito = {
enabled = false
user_pool_id = ""
identity_pool_id = ""
role_arn = ""
}
# Encryption
encrypt_at_rest = true
node_to_node_encryption = true
# Logging
log_types = ["INDEX_SLOW_LOGS", "SEARCH_SLOW_LOGS", "ES_APPLICATION_LOGS"]
# Auto-tune
auto_tune_enabled = true
}
################################################################################
# Variables
################################################################################
variable "region" {
type = string
default = "us-east-1"
}
variable "state_bucket" {
type = string
}
################################################################################
# Provider
################################################################################
provider "aws" {
region = var.region
default_tags {
tags = {
Tenant = local.tenant
App = local.name
Environment = local.env
ManagedBy = "terraform"
}
}
}
################################################################################
# Data Sources
################################################################################
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
################################################################################
# Random Password for Master User
################################################################################
resource "random_password" "master" {
count = local.enable_fine_grained_access ? 1 : 0
length = 24
special = true
override_special = "!#$%&*()-_=+[]{}<>:?"
}
################################################################################
# Secrets Manager
################################################################################
resource "aws_secretsmanager_secret" "opensearch" {
count = local.enable_fine_grained_access ? 1 : 0
name = "${local.tenant}/${local.env}/${local.name}/opensearch"
description = "OpenSearch master credentials"
tags = { Name = "${local.domain_name}-credentials" }
}
resource "aws_secretsmanager_secret_version" "opensearch" {
count = local.enable_fine_grained_access ? 1 : 0
secret_id = aws_secretsmanager_secret.opensearch[0].id
secret_string = jsonencode({
username = local.master_user_name
password = random_password.master[0].result
endpoint = aws_opensearch_domain.main.endpoint
})
}
################################################################################
# CloudWatch Log Groups
################################################################################
resource "aws_cloudwatch_log_group" "opensearch" {
for_each = toset(local.log_types)
name = "/aws/opensearch/${local.domain_name}/${lower(each.key)}"
retention_in_days = 30
tags = { Name = "${local.domain_name}-${lower(each.key)}" }
}
resource "aws_cloudwatch_log_resource_policy" "opensearch" {
policy_name = "${local.domain_name}-logs"
policy_document = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
Service = "es.amazonaws.com"
}
Action = [
"logs:PutLogEvents",
"logs:CreateLogStream"
]
Resource = "arn:aws:logs:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:log-group:/aws/opensearch/${local.domain_name}/*"
}]
})
}
################################################################################
# Security Group (VPC mode)
################################################################################
resource "aws_security_group" "opensearch" {
count = local.vpc_enabled && length(local.vpc_id) > 0 ? 1 : 0
name = "${local.domain_name}-opensearch"
vpc_id = local.vpc_id
ingress {
description = "HTTPS from VPC"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["10.0.0.0/8"]
}
egress {
description = "All outbound"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "${local.domain_name}-opensearch" }
}
################################################################################
# IAM Service-Linked Role
################################################################################
resource "aws_iam_service_linked_role" "opensearch" {
count = local.vpc_enabled ? 1 : 0
aws_service_name = "opensearchservice.amazonaws.com"
}
################################################################################
# OpenSearch Domain
################################################################################
resource "aws_opensearch_domain" "main" {
domain_name = local.domain_name
engine_version = local.engine_version
# Cluster configuration
cluster_config {
instance_type = local.cluster.instance_type
instance_count = local.cluster.instance_count
dedicated_master_enabled = local.cluster.dedicated_master_enabled
dedicated_master_type = local.cluster.dedicated_master_enabled ? local.cluster.dedicated_master_type : null
dedicated_master_count = local.cluster.dedicated_master_enabled ? local.cluster.dedicated_master_count : null
zone_awareness_enabled = local.cluster.zone_awareness_enabled
dynamic "zone_awareness_config" {
for_each = local.cluster.zone_awareness_enabled ? [1] : []
content {
availability_zone_count = local.cluster.availability_zone_count
}
}
# UltraWarm
warm_enabled = local.ultrawarm.enabled
warm_type = local.ultrawarm.enabled ? local.ultrawarm.type : null
warm_count = local.ultrawarm.enabled ? local.ultrawarm.count : null
}
# Storage
ebs_options {
ebs_enabled = true
volume_type = local.storage.type
volume_size = local.storage.size_gb
iops = local.storage.type == "gp3" ? local.storage.iops : null
throughput = local.storage.type == "gp3" ? local.storage.throughput : null
}
# VPC configuration
dynamic "vpc_options" {
for_each = local.vpc_enabled && length(local.private_subnet_ids) > 0 ? [1] : []
content {
subnet_ids = slice(local.private_subnet_ids, 0, local.cluster.availability_zone_count)
security_group_ids = [aws_security_group.opensearch[0].id]
}
}
# Encryption
encrypt_at_rest {
enabled = local.encrypt_at_rest
}
node_to_node_encryption {
enabled = local.node_to_node_encryption
}
domain_endpoint_options {
enforce_https = true
tls_security_policy = "Policy-Min-TLS-1-2-2019-07"
}
# Fine-grained access control
advanced_security_options {
enabled = local.enable_fine_grained_access
internal_user_database_enabled = local.enable_fine_grained_access
dynamic "master_user_options" {
for_each = local.enable_fine_grained_access ? [1] : []
content {
master_user_name = local.master_user_name
master_user_password = random_password.master[0].result
}
}
}
# Cognito authentication
dynamic "cognito_options" {
for_each = local.cognito.enabled ? [1] : []
content {
enabled = true
user_pool_id = local.cognito.user_pool_id
identity_pool_id = local.cognito.identity_pool_id
role_arn = local.cognito.role_arn
}
}
# Logging
dynamic "log_publishing_options" {
for_each = local.log_types
content {
cloudwatch_log_group_arn = aws_cloudwatch_log_group.opensearch[log_publishing_options.value].arn
log_type = log_publishing_options.value
}
}
# Auto-tune
auto_tune_options {
desired_state = local.auto_tune_enabled ? "ENABLED" : "DISABLED"
rollback_on_disable = "NO_ROLLBACK"
}
# Access policy (for non-VPC or fine-grained access)
access_policies = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
AWS = "*"
}
Action = "es:*"
Resource = "arn:aws:es:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:domain/${local.domain_name}/*"
Condition = local.vpc_enabled ? {} : {
IpAddress = {
"aws:SourceIp" = ["0.0.0.0/0"] # Restrict in production!
}
}
}
]
})
tags = { Name = local.domain_name }
depends_on = [
aws_iam_service_linked_role.opensearch,
aws_cloudwatch_log_resource_policy.opensearch
]
}
################################################################################
# IAM Policy for Application Access
################################################################################
resource "aws_iam_policy" "opensearch_access" {
name = "${local.domain_name}-access"
description = "Access to ${local.domain_name} OpenSearch domain"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "OpenSearchAccess"
Effect = "Allow"
Action = [
"es:ESHttpGet",
"es:ESHttpHead",
"es:ESHttpPost",
"es:ESHttpPut",
"es:ESHttpDelete"
]
Resource = "${aws_opensearch_domain.main.arn}/*"
}
]
})
tags = { Name = "${local.domain_name}-access" }
}
################################################################################
# Outputs
################################################################################
output "domain_endpoint" {
value = aws_opensearch_domain.main.endpoint
description = "OpenSearch domain endpoint"
}
output "dashboard_endpoint" {
value = aws_opensearch_domain.main.dashboard_endpoint
description = "OpenSearch Dashboards endpoint"
}
output "domain_arn" {
value = aws_opensearch_domain.main.arn
description = "Domain ARN"
}
output "domain_id" {
value = aws_opensearch_domain.main.domain_id
description = "Domain ID"
}
output "secret_arn" {
value = length(aws_secretsmanager_secret.opensearch) > 0 ? aws_secretsmanager_secret.opensearch[0].arn : null
description = "Secrets Manager ARN for credentials"
}
output "access_policy_arn" {
value = aws_iam_policy.opensearch_access.arn
description = "IAM policy for application access"
}
output "kibana_url" {
value = "https://${aws_opensearch_domain.main.dashboard_endpoint}/_dashboards"
description = "OpenSearch Dashboards URL"
}
output "curl_example" {
value = local.enable_fine_grained_access ? <<-EOF
# Get credentials from Secrets Manager
SECRET=$(aws secretsmanager get-secret-value --secret-id ${aws_secretsmanager_secret.opensearch[0].arn} --query SecretString --output text)
USER=$(echo $SECRET | jq -r .username)
PASS=$(echo $SECRET | jq -r .password)
# Query cluster health
curl -u "$USER:$PASS" "https://${aws_opensearch_domain.main.endpoint}/_cluster/health?pretty"
EOF
: null
description = "Example curl commands"
}

View File

@@ -0,0 +1,544 @@
################################################################################
# Workload: RDS Database
#
# Deploys a managed database:
# - RDS PostgreSQL/MySQL instance or Aurora cluster
# - Subnet group and security group
# - Parameter group with optimized settings
# - Secrets Manager for credentials
# - Optional read replica
#
# Usage:
# Copy this folder to 05-workloads/<tenant>-<app>-db/
# Update locals and variables
# terraform init -backend-config=../../00-bootstrap/backend.hcl
# terraform apply
################################################################################
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
random = {
source = "hashicorp/random"
version = ">= 3.0"
}
}
backend "s3" {
key = "05-workloads/<TENANT>-<APP>-db/terraform.tfstate"
}
}
################################################################################
# Configuration - UPDATE THESE
################################################################################
locals {
# Naming
tenant = "<TENANT>"
app = "<APP>"
env = "prod" # prod, staging, dev
name = "${local.tenant}-${local.app}-${local.env}"
# Engine - "postgres", "mysql", "aurora-postgresql", "aurora-mysql"
engine = "postgres"
engine_version = "16.3"
# Instance sizing
instance_class = "db.t3.micro" # db.t3.micro, db.t3.small, db.r6g.large, etc.
storage_gb = 20
max_storage_gb = 100 # Auto-scaling max (set to storage_gb to disable)
# High availability
multi_az = false # true for prod
read_replica = false # Create read replica
# Database config
database_name = "app"
port = 5432 # 5432 for postgres, 3306 for mysql
# Backup
backup_retention_days = 7
backup_window = "03:00-04:00" # UTC
maintenance_window = "sun:04:00-sun:05:00"
# Deletion protection (disable for dev/test)
deletion_protection = local.env == "prod"
skip_final_snapshot = local.env != "prod"
# Performance Insights (free for 7 days retention)
performance_insights = true
# IAM database authentication (recommended for apps)
iam_auth_enabled = true
# Enhanced Monitoring interval (0 to disable, 1/5/10/15/30/60 seconds)
monitoring_interval = local.env == "prod" ? 60 : 0
# Is this an Aurora cluster?
is_aurora = startswith(local.engine, "aurora-")
}
################################################################################
# Variables
################################################################################
variable "region" {
type = string
default = "us-east-1"
}
variable "state_bucket" {
type = string
}
################################################################################
# Provider
################################################################################
provider "aws" {
region = var.region
default_tags {
tags = {
Tenant = local.tenant
App = local.app
Environment = local.env
ManagedBy = "terraform"
}
}
}
################################################################################
# Data Sources
################################################################################
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = var.state_bucket
key = "02-network/terraform.tfstate"
region = var.region
}
}
data "terraform_remote_state" "tenant" {
backend = "s3"
config = {
bucket = var.state_bucket
key = "04-tenants/${local.tenant}/terraform.tfstate"
region = var.region
}
}
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
################################################################################
# KMS Key for Encryption
################################################################################
resource "aws_kms_key" "db" {
description = "KMS key for ${local.name} database encryption"
deletion_window_in_days = 7
enable_key_rotation = true
tags = { Name = "${local.name}-db" }
}
resource "aws_kms_alias" "db" {
name = "alias/${local.name}-db"
target_key_id = aws_kms_key.db.key_id
}
################################################################################
# Random Password
################################################################################
resource "random_password" "master" {
length = 32
special = true
override_special = "!#$%&*()-_=+[]{}<>:?"
}
################################################################################
# Secrets Manager
################################################################################
resource "aws_secretsmanager_secret" "db" {
name = "${local.name}-db-credentials"
description = "Database credentials for ${local.name}"
recovery_window_in_days = local.env == "prod" ? 30 : 0
tags = { Name = "${local.name}-db-credentials" }
}
resource "aws_secretsmanager_secret_version" "db" {
secret_id = aws_secretsmanager_secret.db.id
secret_string = jsonencode({
username = "dbadmin"
password = random_password.master.result
engine = local.engine
host = local.is_aurora ? aws_rds_cluster.main[0].endpoint : aws_db_instance.main[0].address
port = local.port
database = local.database_name
url = local.is_aurora ? "postgresql://dbadmin:${random_password.master.result}@${aws_rds_cluster.main[0].endpoint}:${local.port}/${local.database_name}" : "postgresql://dbadmin:${random_password.master.result}@${aws_db_instance.main[0].address}:${local.port}/${local.database_name}"
})
depends_on = [aws_db_instance.main, aws_rds_cluster.main]
}
################################################################################
# Subnet Group
################################################################################
resource "aws_db_subnet_group" "main" {
name = local.name
description = "Subnet group for ${local.name}"
subnet_ids = data.terraform_remote_state.network.outputs.private_subnet_ids
tags = { Name = local.name }
}
################################################################################
# Security Group
################################################################################
resource "aws_security_group" "db" {
name = "${local.name}-db"
description = "Database ${local.name}"
vpc_id = data.terraform_remote_state.network.outputs.vpc_id
ingress {
description = "Database port from tenant"
from_port = local.port
to_port = local.port
protocol = "tcp"
security_groups = [data.terraform_remote_state.tenant.outputs.security_groups.base]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "${local.name}-db" }
}
################################################################################
# Parameter Group
################################################################################
resource "aws_db_parameter_group" "main" {
count = local.is_aurora ? 0 : 1
name = local.name
family = "${local.engine}${split(".", local.engine_version)[0]}"
dynamic "parameter" {
for_each = local.engine == "postgres" ? [
{ name = "log_statement", value = "ddl" },
{ name = "log_min_duration_statement", value = "1000" },
{ name = "shared_preload_libraries", value = "pg_stat_statements", apply = "pending-reboot" }
] : [
{ name = "slow_query_log", value = "1" },
{ name = "long_query_time", value = "1" }
]
content {
name = parameter.value.name
value = parameter.value.value
apply_method = lookup(parameter.value, "apply", "immediate")
}
}
tags = { Name = local.name }
}
resource "aws_rds_cluster_parameter_group" "main" {
count = local.is_aurora ? 1 : 0
name = local.name
family = local.engine == "aurora-postgresql" ? "aurora-postgresql16" : "aurora-mysql8.0"
dynamic "parameter" {
for_each = local.engine == "aurora-postgresql" ? [
{ name = "log_statement", value = "ddl" },
{ name = "log_min_duration_statement", value = "1000" }
] : [
{ name = "slow_query_log", value = "1" },
{ name = "long_query_time", value = "1" }
]
content {
name = parameter.value.name
value = parameter.value.value
apply_method = lookup(parameter.value, "apply", "immediate")
}
}
tags = { Name = local.name }
}
################################################################################
# RDS Instance (non-Aurora)
################################################################################
resource "aws_db_instance" "main" {
count = local.is_aurora ? 0 : 1
identifier = local.name
engine = local.engine
engine_version = local.engine_version
instance_class = local.instance_class
allocated_storage = local.storage_gb
max_allocated_storage = local.max_storage_gb
storage_type = "gp3"
storage_encrypted = true
kms_key_id = aws_kms_key.db.arn
db_name = local.database_name
username = "dbadmin"
password = random_password.master.result
port = local.port
multi_az = local.multi_az
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [aws_security_group.db.id]
parameter_group_name = aws_db_parameter_group.main[0].name
publicly_accessible = false
# IAM authentication for better security
iam_database_authentication_enabled = local.iam_auth_enabled
backup_retention_period = local.backup_retention_days
backup_window = local.backup_window
maintenance_window = local.maintenance_window
copy_tags_to_snapshot = true
performance_insights_enabled = local.performance_insights
performance_insights_retention_period = local.performance_insights ? 7 : null
performance_insights_kms_key_id = local.performance_insights ? aws_kms_key.db.arn : null
# Enhanced monitoring
monitoring_interval = local.monitoring_interval
monitoring_role_arn = local.monitoring_interval > 0 ? aws_iam_role.rds_monitoring[0].arn : null
deletion_protection = local.deletion_protection
skip_final_snapshot = local.skip_final_snapshot
final_snapshot_identifier = local.skip_final_snapshot ? null : "${local.name}-final"
enabled_cloudwatch_logs_exports = local.engine == "postgres" ? ["postgresql", "upgrade"] : ["error", "slowquery"]
# Require TLS connections
ca_cert_identifier = "rds-ca-rsa2048-g1"
tags = { Name = local.name }
}
################################################################################
# RDS Read Replica (non-Aurora)
################################################################################
resource "aws_db_instance" "replica" {
count = !local.is_aurora && local.read_replica ? 1 : 0
identifier = "${local.name}-replica"
replicate_source_db = aws_db_instance.main[0].identifier
instance_class = local.instance_class
vpc_security_group_ids = [aws_security_group.db.id]
parameter_group_name = aws_db_parameter_group.main[0].name
publicly_accessible = false
performance_insights_enabled = local.performance_insights
performance_insights_retention_period = local.performance_insights ? 7 : null
skip_final_snapshot = true
tags = { Name = "${local.name}-replica" }
}
################################################################################
# Aurora Cluster
################################################################################
resource "aws_rds_cluster" "main" {
count = local.is_aurora ? 1 : 0
cluster_identifier = local.name
engine = local.engine
engine_version = local.engine_version
database_name = local.database_name
master_username = "dbadmin"
master_password = random_password.master.result
port = local.port
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [aws_security_group.db.id]
db_cluster_parameter_group_name = aws_rds_cluster_parameter_group.main[0].name
storage_encrypted = true
kms_key_id = aws_kms_key.db.arn
# IAM authentication
iam_database_authentication_enabled = local.iam_auth_enabled
backup_retention_period = local.backup_retention_days
preferred_backup_window = local.backup_window
preferred_maintenance_window = local.maintenance_window
copy_tags_to_snapshot = true
deletion_protection = local.deletion_protection
skip_final_snapshot = local.skip_final_snapshot
final_snapshot_identifier = local.skip_final_snapshot ? null : "${local.name}-final"
enabled_cloudwatch_logs_exports = local.engine == "aurora-postgresql" ? ["postgresql"] : ["error", "slowquery"]
tags = { Name = local.name }
}
resource "aws_rds_cluster_instance" "main" {
count = local.is_aurora ? (local.multi_az ? 2 : 1) : 0
identifier = "${local.name}-${count.index}"
cluster_identifier = aws_rds_cluster.main[0].id
engine = aws_rds_cluster.main[0].engine
engine_version = aws_rds_cluster.main[0].engine_version
instance_class = local.instance_class
publicly_accessible = false
performance_insights_enabled = local.performance_insights
performance_insights_retention_period = local.performance_insights ? 7 : null
tags = { Name = "${local.name}-${count.index}" }
}
################################################################################
# IAM Role for Enhanced Monitoring
################################################################################
resource "aws_iam_role" "rds_monitoring" {
count = local.monitoring_interval > 0 ? 1 : 0
name = "${local.name}-rds-monitoring"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "monitoring.rds.amazonaws.com" }
}]
})
tags = { Name = "${local.name}-rds-monitoring" }
}
resource "aws_iam_role_policy_attachment" "rds_monitoring" {
count = local.monitoring_interval > 0 ? 1 : 0
role = aws_iam_role.rds_monitoring[0].name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonRDSEnhancedMonitoringRole"
}
################################################################################
# CloudWatch Alarms
################################################################################
resource "aws_cloudwatch_metric_alarm" "cpu" {
alarm_name = "${local.name}-cpu-high"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 3
metric_name = "CPUUtilization"
namespace = "AWS/RDS"
period = 300
statistic = "Average"
threshold = 80
alarm_description = "Database CPU utilization high"
dimensions = {
DBInstanceIdentifier = local.is_aurora ? aws_rds_cluster_instance.main[0].identifier : aws_db_instance.main[0].identifier
}
tags = { Name = "${local.name}-cpu-high" }
}
resource "aws_cloudwatch_metric_alarm" "storage" {
count = local.is_aurora ? 0 : 1
alarm_name = "${local.name}-storage-low"
comparison_operator = "LessThanThreshold"
evaluation_periods = 1
metric_name = "FreeStorageSpace"
namespace = "AWS/RDS"
period = 300
statistic = "Average"
threshold = 5368709120 # 5 GB
alarm_description = "Database free storage space low"
dimensions = {
DBInstanceIdentifier = aws_db_instance.main[0].identifier
}
tags = { Name = "${local.name}-storage-low" }
}
resource "aws_cloudwatch_metric_alarm" "connections" {
alarm_name = "${local.name}-connections-high"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
metric_name = "DatabaseConnections"
namespace = "AWS/RDS"
period = 300
statistic = "Average"
threshold = 50 # Adjust based on instance class
alarm_description = "Database connections high"
dimensions = {
DBInstanceIdentifier = local.is_aurora ? aws_rds_cluster_instance.main[0].identifier : aws_db_instance.main[0].identifier
}
tags = { Name = "${local.name}-connections-high" }
}
################################################################################
# Outputs
################################################################################
output "endpoint" {
value = local.is_aurora ? aws_rds_cluster.main[0].endpoint : aws_db_instance.main[0].address
}
output "reader_endpoint" {
value = local.is_aurora ? aws_rds_cluster.main[0].reader_endpoint : (local.read_replica ? aws_db_instance.replica[0].address : null)
}
output "port" {
value = local.port
}
output "database_name" {
value = local.database_name
}
output "secret_arn" {
value = aws_secretsmanager_secret.db.arn
}
output "security_group_id" {
value = aws_security_group.db.id
}
output "connection_string_ssm" {
value = "Retrieve from: aws secretsmanager get-secret-value --secret-id ${aws_secretsmanager_secret.db.name}"
description = "Command to retrieve connection string"
}

View File

@@ -0,0 +1,527 @@
################################################################################
# Workload: S3 Bucket
#
# Multi-purpose S3 bucket with:
# - Versioning, encryption (KMS or S3)
# - Lifecycle rules (tiering, expiration)
# - Replication (cross-region DR)
# - Access logging
# - Event notifications (Lambda, SQS, SNS)
# - Object Lock (compliance/governance)
#
# Use cases: Data lake, backups, artifacts, logs, media storage
################################################################################
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
backend "s3" {
key = "05-workloads/<TENANT>-<NAME>-bucket/terraform.tfstate"
}
}
################################################################################
# Configuration - UPDATE THESE
################################################################################
locals {
# Naming
tenant = "<TENANT>"
name = "<NAME>"
env = "prod"
bucket_name = "${local.tenant}-${local.name}-${local.env}-${data.aws_caller_identity.current.account_id}"
# Versioning
versioning_enabled = true
# Encryption
encryption_type = "SSE-S3" # SSE-S3, SSE-KMS, or KMS ARN
kms_key_arn = null # Set if using SSE-KMS
# Public access (always blocked by default)
block_public_access = true
# Access logging
enable_logging = true
logging_bucket = null # Set to existing logging bucket, or creates one
logging_prefix = "s3-access-logs/${local.bucket_name}/"
# Lifecycle rules
lifecycle_rules = {
transition-to-ia = {
enabled = true
filter = {
prefix = ""
}
transitions = [
{
days = 30
storage_class = "STANDARD_IA"
},
{
days = 90
storage_class = "GLACIER"
}
]
expiration_days = 365
noncurrent_version_expiration_days = 90
}
}
# Cross-region replication
enable_replication = false
replication_region = "us-west-2"
replication_bucket = null # Will create if null
# Event notifications
lambda_notifications = {
# "object-created" = {
# lambda_arn = "arn:aws:lambda:..."
# events = ["s3:ObjectCreated:*"]
# prefix = "uploads/"
# suffix = ".jpg"
# }
}
sqs_notifications = {
# "new-files" = {
# queue_arn = "arn:aws:sqs:..."
# events = ["s3:ObjectCreated:*"]
# }
}
# Object Lock (for compliance - cannot be disabled once enabled)
object_lock_enabled = false
object_lock_mode = "GOVERNANCE" # GOVERNANCE or COMPLIANCE
object_lock_days = 30
# CORS (for web access)
cors_enabled = false
cors_rules = [
{
allowed_headers = ["*"]
allowed_methods = ["GET", "HEAD"]
allowed_origins = ["*"]
max_age_seconds = 3600
}
]
# Intelligent tiering
intelligent_tiering_enabled = false
}
################################################################################
# Variables
################################################################################
variable "region" {
type = string
default = "us-east-1"
}
variable "state_bucket" {
type = string
}
################################################################################
# Provider
################################################################################
provider "aws" {
region = var.region
default_tags {
tags = {
Tenant = local.tenant
App = local.name
Environment = local.env
ManagedBy = "terraform"
}
}
}
provider "aws" {
alias = "replication"
region = local.replication_region
}
################################################################################
# Data Sources
################################################################################
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
################################################################################
# S3 Bucket
################################################################################
resource "aws_s3_bucket" "main" {
bucket = local.bucket_name
dynamic "object_lock_configuration" {
for_each = local.object_lock_enabled ? [1] : []
content {
object_lock_enabled = "Enabled"
}
}
tags = { Name = local.bucket_name }
}
################################################################################
# Versioning
################################################################################
resource "aws_s3_bucket_versioning" "main" {
bucket = aws_s3_bucket.main.id
versioning_configuration {
status = local.versioning_enabled ? "Enabled" : "Suspended"
}
}
################################################################################
# Encryption
################################################################################
resource "aws_s3_bucket_server_side_encryption_configuration" "main" {
bucket = aws_s3_bucket.main.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = local.encryption_type == "SSE-S3" ? "AES256" : "aws:kms"
kms_master_key_id = local.encryption_type != "SSE-S3" ? (local.kms_key_arn != null ? local.kms_key_arn : null) : null
}
bucket_key_enabled = local.encryption_type != "SSE-S3"
}
}
################################################################################
# Public Access Block
################################################################################
resource "aws_s3_bucket_public_access_block" "main" {
bucket = aws_s3_bucket.main.id
block_public_acls = local.block_public_access
block_public_policy = local.block_public_access
ignore_public_acls = local.block_public_access
restrict_public_buckets = local.block_public_access
}
################################################################################
# Access Logging
################################################################################
resource "aws_s3_bucket" "logs" {
count = local.enable_logging && local.logging_bucket == null ? 1 : 0
bucket = "${local.bucket_name}-logs"
tags = { Name = "${local.bucket_name}-logs" }
}
resource "aws_s3_bucket_versioning" "logs" {
count = local.enable_logging && local.logging_bucket == null ? 1 : 0
bucket = aws_s3_bucket.logs[0].id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "logs" {
count = local.enable_logging && local.logging_bucket == null ? 1 : 0
bucket = aws_s3_bucket.logs[0].id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_public_access_block" "logs" {
count = local.enable_logging && local.logging_bucket == null ? 1 : 0
bucket = aws_s3_bucket.logs[0].id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_lifecycle_configuration" "logs" {
count = local.enable_logging && local.logging_bucket == null ? 1 : 0
bucket = aws_s3_bucket.logs[0].id
rule {
id = "expire-logs"
status = "Enabled"
expiration {
days = 90
}
}
}
resource "aws_s3_bucket_logging" "main" {
count = local.enable_logging ? 1 : 0
bucket = aws_s3_bucket.main.id
target_bucket = local.logging_bucket != null ? local.logging_bucket : aws_s3_bucket.logs[0].id
target_prefix = local.logging_prefix
}
################################################################################
# Lifecycle Rules
################################################################################
resource "aws_s3_bucket_lifecycle_configuration" "main" {
count = length(local.lifecycle_rules) > 0 ? 1 : 0
bucket = aws_s3_bucket.main.id
dynamic "rule" {
for_each = local.lifecycle_rules
content {
id = rule.key
status = rule.value.enabled ? "Enabled" : "Disabled"
filter {
prefix = lookup(rule.value.filter, "prefix", "")
}
dynamic "transition" {
for_each = lookup(rule.value, "transitions", [])
content {
days = transition.value.days
storage_class = transition.value.storage_class
}
}
dynamic "expiration" {
for_each = lookup(rule.value, "expiration_days", null) != null ? [1] : []
content {
days = rule.value.expiration_days
}
}
dynamic "noncurrent_version_expiration" {
for_each = lookup(rule.value, "noncurrent_version_expiration_days", null) != null ? [1] : []
content {
noncurrent_days = rule.value.noncurrent_version_expiration_days
}
}
}
}
depends_on = [aws_s3_bucket_versioning.main]
}
################################################################################
# Intelligent Tiering
################################################################################
resource "aws_s3_bucket_intelligent_tiering_configuration" "main" {
count = local.intelligent_tiering_enabled ? 1 : 0
bucket = aws_s3_bucket.main.id
name = "EntireBucket"
tiering {
access_tier = "DEEP_ARCHIVE_ACCESS"
days = 180
}
tiering {
access_tier = "ARCHIVE_ACCESS"
days = 90
}
}
################################################################################
# CORS
################################################################################
resource "aws_s3_bucket_cors_configuration" "main" {
count = local.cors_enabled ? 1 : 0
bucket = aws_s3_bucket.main.id
dynamic "cors_rule" {
for_each = local.cors_rules
content {
allowed_headers = cors_rule.value.allowed_headers
allowed_methods = cors_rule.value.allowed_methods
allowed_origins = cors_rule.value.allowed_origins
max_age_seconds = cors_rule.value.max_age_seconds
}
}
}
################################################################################
# Object Lock
################################################################################
resource "aws_s3_bucket_object_lock_configuration" "main" {
count = local.object_lock_enabled ? 1 : 0
bucket = aws_s3_bucket.main.id
rule {
default_retention {
mode = local.object_lock_mode
days = local.object_lock_days
}
}
}
################################################################################
# Event Notifications
################################################################################
resource "aws_s3_bucket_notification" "main" {
count = length(local.lambda_notifications) > 0 || length(local.sqs_notifications) > 0 ? 1 : 0
bucket = aws_s3_bucket.main.id
dynamic "lambda_function" {
for_each = local.lambda_notifications
content {
lambda_function_arn = lambda_function.value.lambda_arn
events = lambda_function.value.events
filter_prefix = lookup(lambda_function.value, "prefix", null)
filter_suffix = lookup(lambda_function.value, "suffix", null)
}
}
dynamic "queue" {
for_each = local.sqs_notifications
content {
queue_arn = queue.value.queue_arn
events = queue.value.events
filter_prefix = lookup(queue.value, "prefix", null)
filter_suffix = lookup(queue.value, "suffix", null)
}
}
}
################################################################################
# Replication
################################################################################
resource "aws_s3_bucket" "replica" {
count = local.enable_replication && local.replication_bucket == null ? 1 : 0
provider = aws.replication
bucket = "${local.bucket_name}-replica"
tags = { Name = "${local.bucket_name}-replica" }
}
resource "aws_s3_bucket_versioning" "replica" {
count = local.enable_replication && local.replication_bucket == null ? 1 : 0
provider = aws.replication
bucket = aws_s3_bucket.replica[0].id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_iam_role" "replication" {
count = local.enable_replication ? 1 : 0
name = "${local.bucket_name}-replication"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "s3.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy" "replication" {
count = local.enable_replication ? 1 : 0
name = "replication"
role = aws_iam_role.replication[0].id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"s3:GetReplicationConfiguration",
"s3:ListBucket"
]
Resource = aws_s3_bucket.main.arn
},
{
Effect = "Allow"
Action = [
"s3:GetObjectVersionForReplication",
"s3:GetObjectVersionAcl",
"s3:GetObjectVersionTagging"
]
Resource = "${aws_s3_bucket.main.arn}/*"
},
{
Effect = "Allow"
Action = [
"s3:ReplicateObject",
"s3:ReplicateDelete",
"s3:ReplicateTags"
]
Resource = "${local.replication_bucket != null ? local.replication_bucket : aws_s3_bucket.replica[0].arn}/*"
}
]
})
}
resource "aws_s3_bucket_replication_configuration" "main" {
count = local.enable_replication ? 1 : 0
bucket = aws_s3_bucket.main.id
role = aws_iam_role.replication[0].arn
rule {
id = "replicate-all"
status = "Enabled"
destination {
bucket = local.replication_bucket != null ? local.replication_bucket : aws_s3_bucket.replica[0].arn
storage_class = "STANDARD"
}
}
depends_on = [aws_s3_bucket_versioning.main]
}
################################################################################
# Outputs
################################################################################
output "bucket_name" {
value = aws_s3_bucket.main.id
}
output "bucket_arn" {
value = aws_s3_bucket.main.arn
}
output "bucket_domain_name" {
value = aws_s3_bucket.main.bucket_regional_domain_name
}
output "replica_bucket" {
value = local.enable_replication && local.replication_bucket == null ? aws_s3_bucket.replica[0].id : local.replication_bucket
}
output "logging_bucket" {
value = local.enable_logging && local.logging_bucket == null ? aws_s3_bucket.logs[0].id : local.logging_bucket
}

View File

@@ -0,0 +1,427 @@
################################################################################
# Workload: Secrets Manager
#
# Secure secret storage:
# - KMS encryption
# - Automatic rotation (RDS, Redshift, DocumentDB, custom Lambda)
# - Cross-account access policies
# - Versioning and recovery
# - Replication to other regions
#
# Use cases: DB credentials, API keys, certificates, config
################################################################################
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
random = {
source = "hashicorp/random"
version = ">= 3.0"
}
}
backend "s3" {
key = "05-workloads/<TENANT>-<NAME>-secrets/terraform.tfstate"
}
}
################################################################################
# Configuration - UPDATE THESE
################################################################################
locals {
# Naming
tenant = "<TENANT>"
name = "<NAME>"
env = "prod"
prefix = "${local.tenant}/${local.env}"
# KMS encryption (null uses AWS managed key)
kms_key_arn = null
# Recovery window (days) - 0 for immediate deletion
recovery_window_days = 30
# Secrets to create
secrets = {
# Database credentials (auto-generated)
"db/main" = {
description = "Main database credentials"
generate_password = true
password_length = 32
exclude_characters = "\"@/\\"
secret_string_template = jsonencode({
username = "admin"
engine = "postgres"
host = "db.example.internal"
port = 5432
dbname = "main"
})
# RDS rotation
rotation = {
enabled = false
# lambda_arn = "arn:aws:lambda:..." # Rotation Lambda
# days = 30
}
}
# API keys - Update via console or CLI after deployment:
# aws secretsmanager put-secret-value --secret-id <arn> --secret-string '{"publishable_key":"pk_live_xxx","secret_key":"sk_live_xxx"}'
"api/stripe" = {
description = "Stripe API keys"
secret_string = jsonencode({
publishable_key = "pk_live_xxxxxxxxxxxx"
secret_key = "sk_live_xxxxxxxxxxxx"
})
}
# Generic config
"config/app" = {
description = "Application configuration"
secret_string = jsonencode({
feature_flags = {
new_checkout = true
beta_features = false
}
limits = {
max_upload_mb = 100
rate_limit_rpm = 1000
}
})
}
}
# Cross-account access
allowed_accounts = [
# "123456789012", # Dev account
# "234567890123", # Staging account
]
# IAM principals allowed to read secrets
allowed_principals = [
# "arn:aws:iam::123456789012:role/app-role",
]
# Replication to other regions
replica_regions = [
# "us-west-2",
# "eu-west-1",
]
}
################################################################################
# Variables
################################################################################
variable "region" {
type = string
default = "us-east-1"
}
variable "state_bucket" {
type = string
}
################################################################################
# Provider
################################################################################
provider "aws" {
region = var.region
default_tags {
tags = {
Tenant = local.tenant
App = local.name
Environment = local.env
ManagedBy = "terraform"
}
}
}
################################################################################
# Data Sources
################################################################################
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
################################################################################
# KMS Key (optional - for customer managed encryption)
################################################################################
resource "aws_kms_key" "secrets" {
count = local.kms_key_arn == null ? 1 : 0
description = "KMS key for ${local.prefix} secrets"
deletion_window_in_days = 30
enable_key_rotation = true
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "Enable IAM policies"
Effect = "Allow"
Principal = {
AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"
}
Action = "kms:*"
Resource = "*"
},
{
Sid = "Allow Secrets Manager"
Effect = "Allow"
Principal = {
Service = "secretsmanager.amazonaws.com"
}
Action = [
"kms:Encrypt",
"kms:Decrypt",
"kms:ReEncrypt*",
"kms:GenerateDataKey*",
"kms:DescribeKey"
]
Resource = "*"
}
]
})
tags = { Name = "${local.prefix}-secrets" }
}
resource "aws_kms_alias" "secrets" {
count = local.kms_key_arn == null ? 1 : 0
name = "alias/${replace(local.prefix, "/", "-")}-secrets"
target_key_id = aws_kms_key.secrets[0].key_id
}
locals {
effective_kms_key = local.kms_key_arn != null ? local.kms_key_arn : aws_kms_key.secrets[0].arn
}
################################################################################
# Random Passwords
################################################################################
resource "random_password" "secrets" {
for_each = { for k, v in local.secrets : k => v if lookup(v, "generate_password", false) }
length = lookup(each.value, "password_length", 32)
special = true
override_special = lookup(each.value, "override_special", "!#$%&*()-_=+[]{}<>:?")
# Exclude problematic characters
min_lower = 1
min_upper = 1
min_numeric = 1
min_special = 1
}
################################################################################
# Secrets
################################################################################
resource "aws_secretsmanager_secret" "secrets" {
for_each = local.secrets
name = "${local.prefix}/${each.key}"
description = lookup(each.value, "description", "Secret for ${each.key}")
kms_key_id = local.effective_kms_key
recovery_window_in_days = local.recovery_window_days
# Replication
dynamic "replica" {
for_each = local.replica_regions
content {
region = replica.value
kms_key_id = null # Use default key in replica region
}
}
tags = { Name = "${local.prefix}/${each.key}" }
}
################################################################################
# Secret Values
################################################################################
resource "aws_secretsmanager_secret_version" "secrets" {
for_each = local.secrets
secret_id = aws_secretsmanager_secret.secrets[each.key].id
secret_string = lookup(each.value, "generate_password", false) ? jsonencode(merge(
jsondecode(lookup(each.value, "secret_string_template", "{}")),
{ password = random_password.secrets[each.key].result }
)) : lookup(each.value, "secret_string", "{}")
}
################################################################################
# Secret Rotation
################################################################################
resource "aws_secretsmanager_secret_rotation" "secrets" {
for_each = { for k, v in local.secrets : k => v if lookup(lookup(v, "rotation", {}), "enabled", false) }
secret_id = aws_secretsmanager_secret.secrets[each.key].id
rotation_lambda_arn = each.value.rotation.lambda_arn
rotation_rules {
automatically_after_days = lookup(each.value.rotation, "days", 30)
}
}
################################################################################
# Resource Policy (Cross-Account Access)
################################################################################
resource "aws_secretsmanager_secret_policy" "cross_account" {
for_each = length(local.allowed_accounts) > 0 || length(local.allowed_principals) > 0 ? local.secrets : {}
secret_arn = aws_secretsmanager_secret.secrets[each.key].arn
policy = jsonencode({
Version = "2012-10-17"
Statement = concat(
length(local.allowed_accounts) > 0 ? [{
Sid = "AllowCrossAccountAccess"
Effect = "Allow"
Principal = {
AWS = [for acct in local.allowed_accounts : "arn:aws:iam::${acct}:root"]
}
Action = [
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret"
]
Resource = "*"
}] : [],
length(local.allowed_principals) > 0 ? [{
Sid = "AllowPrincipalAccess"
Effect = "Allow"
Principal = {
AWS = local.allowed_principals
}
Action = [
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret"
]
Resource = "*"
}] : []
)
})
}
################################################################################
# IAM Policy for Reading Secrets
################################################################################
resource "aws_iam_policy" "read_secrets" {
name = "${replace(local.prefix, "/", "-")}-secrets-read"
description = "Read access to ${local.prefix} secrets"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "GetSecrets"
Effect = "Allow"
Action = [
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret"
]
Resource = [for s in aws_secretsmanager_secret.secrets : s.arn]
},
{
Sid = "DecryptSecrets"
Effect = "Allow"
Action = [
"kms:Decrypt",
"kms:DescribeKey"
]
Resource = [local.effective_kms_key]
}
]
})
tags = { Name = "${local.prefix}-secrets-read" }
}
resource "aws_iam_policy" "write_secrets" {
name = "${replace(local.prefix, "/", "-")}-secrets-write"
description = "Write access to ${local.prefix} secrets"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "ManageSecrets"
Effect = "Allow"
Action = [
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret",
"secretsmanager:PutSecretValue",
"secretsmanager:UpdateSecret"
]
Resource = [for s in aws_secretsmanager_secret.secrets : s.arn]
},
{
Sid = "EncryptDecryptSecrets"
Effect = "Allow"
Action = [
"kms:Encrypt",
"kms:Decrypt",
"kms:GenerateDataKey",
"kms:DescribeKey"
]
Resource = [local.effective_kms_key]
}
]
})
tags = { Name = "${local.prefix}-secrets-write" }
}
################################################################################
# Outputs
################################################################################
output "secret_arns" {
value = { for k, v in aws_secretsmanager_secret.secrets : k => v.arn }
description = "Secret ARNs"
}
output "secret_names" {
value = { for k, v in aws_secretsmanager_secret.secrets : k => v.name }
description = "Secret names"
}
output "kms_key_arn" {
value = local.effective_kms_key
description = "KMS key ARN used for encryption"
}
output "read_policy_arn" {
value = aws_iam_policy.read_secrets.arn
description = "IAM policy ARN for reading secrets"
}
output "write_policy_arn" {
value = aws_iam_policy.write_secrets.arn
description = "IAM policy ARN for writing secrets"
}
output "secret_retrieval_commands" {
value = {
for k, v in aws_secretsmanager_secret.secrets : k =>
"aws secretsmanager get-secret-value --secret-id ${v.name} --query SecretString --output text | jq ."
}
description = "CLI commands to retrieve each secret"
}

View File

@@ -0,0 +1,524 @@
################################################################################
# Workload: SES Email Configuration
#
# Email sending infrastructure:
# - Domain identity with DKIM
# - Email identities for sending
# - Configuration sets with tracking
# - Event destinations (CloudWatch, SNS, Kinesis)
# - Dedicated IP pools (optional)
# - Suppression list management
#
# Use cases: Transactional email, marketing campaigns, notifications
################################################################################
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
backend "s3" {
key = "05-workloads/<TENANT>-<NAME>-email/terraform.tfstate"
}
}
################################################################################
# Configuration - UPDATE THESE
################################################################################
locals {
# Naming
tenant = "<TENANT>"
name = "<NAME>"
env = "prod"
config_name = "${local.tenant}-${local.name}-${local.env}"
# Domain to verify (required)
domain = "example.com"
hosted_zone_id = null # Route53 zone ID for automatic DNS verification
# Additional email identities
email_identities = [
# "noreply@example.com",
# "support@example.com",
]
# MAIL FROM domain (optional custom subdomain)
mail_from_subdomain = "mail" # Results in mail.example.com
# DMARC record
enable_dmarc = true
dmarc_policy = "none" # none, quarantine, reject
dmarc_rua = null # Aggregate report email, e.g., "mailto:dmarc@example.com"
# Configuration set (for tracking)
enable_config_set = true
# Event tracking
tracking_options = {
click = true
open = true
bounce = true
complaint = true
delivery = true
reject = true
send = true
}
# Event destinations
cloudwatch_destination = true
sns_destination = true
# Reputation metrics
reputation_metrics_enabled = true
# Sending quotas (request increase via AWS support)
# These are informational - actual limits set by AWS
# Suppression list
suppression_list_reasons = ["BOUNCE", "COMPLAINT"]
# Dedicated IPs (additional cost)
enable_dedicated_ips = false
dedicated_ip_count = 0
# IAM policy for sending
create_sending_role = true
sending_role_name = "${local.config_name}-ses-sender"
}
################################################################################
# Variables
################################################################################
variable "region" {
type = string
default = "us-east-1"
}
variable "state_bucket" {
type = string
}
################################################################################
# Provider
################################################################################
provider "aws" {
region = var.region
default_tags {
tags = {
Tenant = local.tenant
App = local.name
Environment = local.env
ManagedBy = "terraform"
}
}
}
################################################################################
# Data Sources
################################################################################
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
################################################################################
# Domain Identity
################################################################################
resource "aws_ses_domain_identity" "main" {
domain = local.domain
}
resource "aws_ses_domain_dkim" "main" {
domain = aws_ses_domain_identity.main.domain
}
################################################################################
# DNS Records (if hosted zone provided)
################################################################################
# Domain verification
resource "aws_route53_record" "ses_verification" {
count = local.hosted_zone_id != null ? 1 : 0
zone_id = local.hosted_zone_id
name = "_amazonses.${local.domain}"
type = "TXT"
ttl = 600
records = [aws_ses_domain_identity.main.verification_token]
}
# DKIM records
resource "aws_route53_record" "dkim" {
count = local.hosted_zone_id != null ? 3 : 0
zone_id = local.hosted_zone_id
name = "${aws_ses_domain_dkim.main.dkim_tokens[count.index]}._domainkey.${local.domain}"
type = "CNAME"
ttl = 600
records = ["${aws_ses_domain_dkim.main.dkim_tokens[count.index]}.dkim.amazonses.com"]
}
# MAIL FROM domain
resource "aws_ses_domain_mail_from" "main" {
domain = aws_ses_domain_identity.main.domain
mail_from_domain = "${local.mail_from_subdomain}.${local.domain}"
}
resource "aws_route53_record" "mail_from_mx" {
count = local.hosted_zone_id != null ? 1 : 0
zone_id = local.hosted_zone_id
name = "${local.mail_from_subdomain}.${local.domain}"
type = "MX"
ttl = 600
records = ["10 feedback-smtp.${data.aws_region.current.name}.amazonses.com"]
}
resource "aws_route53_record" "mail_from_spf" {
count = local.hosted_zone_id != null ? 1 : 0
zone_id = local.hosted_zone_id
name = "${local.mail_from_subdomain}.${local.domain}"
type = "TXT"
ttl = 600
records = ["v=spf1 include:amazonses.com ~all"]
}
# DMARC record
resource "aws_route53_record" "dmarc" {
count = local.hosted_zone_id != null && local.enable_dmarc ? 1 : 0
zone_id = local.hosted_zone_id
name = "_dmarc.${local.domain}"
type = "TXT"
ttl = 600
records = [
local.dmarc_rua != null
? "v=DMARC1; p=${local.dmarc_policy}; rua=${local.dmarc_rua}"
: "v=DMARC1; p=${local.dmarc_policy}"
]
}
################################################################################
# Email Identities
################################################################################
resource "aws_ses_email_identity" "identities" {
for_each = toset(local.email_identities)
email = each.value
}
################################################################################
# Configuration Set
################################################################################
resource "aws_ses_configuration_set" "main" {
count = local.enable_config_set ? 1 : 0
name = local.config_name
reputation_metrics_enabled = local.reputation_metrics_enabled
delivery_options {
tls_policy = "REQUIRE"
}
tracking_options {
custom_redirect_domain = null
}
}
################################################################################
# SNS Topic for Events
################################################################################
resource "aws_sns_topic" "ses_events" {
count = local.sns_destination ? 1 : 0
name = "${local.config_name}-ses-events"
kms_master_key_id = "alias/aws/sns"
tags = { Name = "${local.config_name}-ses-events" }
}
resource "aws_sns_topic_policy" "ses_events" {
count = local.sns_destination ? 1 : 0
arn = aws_sns_topic.ses_events[0].arn
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "AllowSES"
Effect = "Allow"
Principal = {
Service = "ses.amazonaws.com"
}
Action = "sns:Publish"
Resource = aws_sns_topic.ses_events[0].arn
Condition = {
StringEquals = {
"AWS:SourceAccount" = data.aws_caller_identity.current.account_id
}
}
}]
})
}
################################################################################
# Event Destinations
################################################################################
resource "aws_ses_event_destination" "cloudwatch" {
count = local.enable_config_set && local.cloudwatch_destination ? 1 : 0
name = "cloudwatch"
configuration_set_name = aws_ses_configuration_set.main[0].name
enabled = true
matching_types = compact([
local.tracking_options.bounce ? "bounce" : "",
local.tracking_options.complaint ? "complaint" : "",
local.tracking_options.delivery ? "delivery" : "",
local.tracking_options.send ? "send" : "",
local.tracking_options.reject ? "reject" : "",
local.tracking_options.open ? "open" : "",
local.tracking_options.click ? "click" : "",
])
cloudwatch_destination {
default_value = "default"
dimension_name = "ses:source-ip"
value_source = "messageTag"
}
}
resource "aws_ses_event_destination" "sns" {
count = local.enable_config_set && local.sns_destination ? 1 : 0
name = "sns"
configuration_set_name = aws_ses_configuration_set.main[0].name
enabled = true
matching_types = ["bounce", "complaint"]
sns_destination {
topic_arn = aws_sns_topic.ses_events[0].arn
}
}
################################################################################
# IAM Role for Sending
################################################################################
resource "aws_iam_role" "sending" {
count = local.create_sending_role ? 1 : 0
name = local.sending_role_name
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
Service = [
"lambda.amazonaws.com",
"ecs-tasks.amazonaws.com",
"ec2.amazonaws.com"
]
}
Action = "sts:AssumeRole"
}]
})
tags = { Name = local.sending_role_name }
}
resource "aws_iam_role_policy" "sending" {
count = local.create_sending_role ? 1 : 0
name = "ses-sending"
role = aws_iam_role.sending[0].id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "SendEmail"
Effect = "Allow"
Action = [
"ses:SendEmail",
"ses:SendRawEmail",
"ses:SendTemplatedEmail",
"ses:SendBulkTemplatedEmail"
]
Resource = "*"
Condition = {
StringEquals = {
"ses:FromAddress" = [for e in local.email_identities : e]
}
}
},
{
Sid = "UseConfigSet"
Effect = "Allow"
Action = ["ses:SendEmail", "ses:SendRawEmail"]
Resource = local.enable_config_set ? aws_ses_configuration_set.main[0].arn : "*"
}
]
})
}
################################################################################
# SMTP Credentials (for apps that use SMTP)
################################################################################
resource "aws_iam_user" "smtp" {
name = "${local.config_name}-smtp"
tags = { Name = "${local.config_name}-smtp" }
}
resource "aws_iam_user_policy" "smtp" {
name = "ses-smtp"
user = aws_iam_user.smtp.name
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "ses:SendRawEmail"
Resource = "*"
}]
})
}
resource "aws_iam_access_key" "smtp" {
user = aws_iam_user.smtp.name
}
################################################################################
# Email Templates (Examples)
################################################################################
resource "aws_ses_template" "welcome" {
name = "${local.config_name}-welcome"
subject = "Welcome to {{company_name}}!"
html = <<-HTML
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body>
<h1>Welcome, {{name}}!</h1>
<p>Thank you for signing up for {{company_name}}.</p>
<p>Click <a href="{{verification_link}}">here</a> to verify your email.</p>
</body>
</html>
HTML
text = <<-TEXT
Welcome, {{name}}!
Thank you for signing up for {{company_name}}.
Click the link below to verify your email:
{{verification_link}}
TEXT
}
resource "aws_ses_template" "password_reset" {
name = "${local.config_name}-password-reset"
subject = "Reset your {{company_name}} password"
html = <<-HTML
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body>
<h1>Password Reset Request</h1>
<p>Hi {{name}},</p>
<p>Click <a href="{{reset_link}}">here</a> to reset your password.</p>
<p>This link expires in {{expiry_hours}} hours.</p>
<p>If you didn't request this, please ignore this email.</p>
</body>
</html>
HTML
text = <<-TEXT
Password Reset Request
Hi {{name}},
Click the link below to reset your password:
{{reset_link}}
This link expires in {{expiry_hours}} hours.
If you didn't request this, please ignore this email.
TEXT
}
################################################################################
# Outputs
################################################################################
output "domain_identity_arn" {
value = aws_ses_domain_identity.main.arn
}
output "domain_verification_token" {
value = aws_ses_domain_identity.main.verification_token
}
output "dkim_tokens" {
value = aws_ses_domain_dkim.main.dkim_tokens
}
output "configuration_set" {
value = local.enable_config_set ? aws_ses_configuration_set.main[0].name : null
}
output "sns_topic_arn" {
value = local.sns_destination ? aws_sns_topic.ses_events[0].arn : null
}
output "sending_role_arn" {
value = local.create_sending_role ? aws_iam_role.sending[0].arn : null
}
output "smtp_credentials" {
value = {
username = aws_iam_access_key.smtp.id
password = aws_iam_access_key.smtp.ses_smtp_password_v4
endpoint = "email-smtp.${data.aws_region.current.name}.amazonaws.com"
port = 587
}
sensitive = true
}
output "dns_records_required" {
value = local.hosted_zone_id == null ? {
verification = {
name = "_amazonses.${local.domain}"
type = "TXT"
value = aws_ses_domain_identity.main.verification_token
}
dkim = [
for i, token in aws_ses_domain_dkim.main.dkim_tokens : {
name = "${token}._domainkey.${local.domain}"
type = "CNAME"
value = "${token}.dkim.amazonses.com"
}
]
mail_from_mx = {
name = "${local.mail_from_subdomain}.${local.domain}"
type = "MX"
value = "10 feedback-smtp.${data.aws_region.current.name}.amazonses.com"
}
mail_from_spf = {
name = "${local.mail_from_subdomain}.${local.domain}"
type = "TXT"
value = "v=spf1 include:amazonses.com ~all"
}
} : "DNS records created automatically"
}
output "templates" {
value = {
welcome = aws_ses_template.welcome.name
password_reset = aws_ses_template.password_reset.name
}
}

View File

@@ -0,0 +1,383 @@
################################################################################
# Workload: SNS Topic
#
# Pub/Sub messaging with:
# - Multiple subscription types (Lambda, SQS, HTTP, Email, SMS)
# - Message filtering
# - Dead letter queue
# - KMS encryption
# - Cross-account publishing
# - FIFO topics (ordered, exactly-once)
#
# Use cases: Event fan-out, notifications, decoupling services
################################################################################
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
backend "s3" {
key = "05-workloads/<TENANT>-<NAME>-sns/terraform.tfstate"
}
}
################################################################################
# Configuration - UPDATE THESE
################################################################################
locals {
# Naming
tenant = "<TENANT>"
name = "<NAME>"
env = "prod"
topic_name = "${local.tenant}-${local.name}-${local.env}"
# FIFO topic (ordered, exactly-once delivery)
fifo_topic = false
content_based_deduplication = false
# Encryption
kms_key_arn = null # null = AWS managed key
# Message delivery settings
delivery_policy = {
http = {
defaultHealthyRetryPolicy = {
minDelayTarget = 20
maxDelayTarget = 20
numRetries = 3
numMaxDelayRetries = 0
numNoDelayRetries = 0
numMinDelayRetries = 0
backoffFunction = "linear"
}
disableSubscriptionOverrides = false
}
}
# Subscriptions
subscriptions = {
# Lambda subscription
# "process-events" = {
# protocol = "lambda"
# endpoint = "arn:aws:lambda:us-east-1:123456789012:function:process-events"
# filter_policy = {
# event_type = ["order.created", "order.updated"]
# }
# }
# SQS subscription
# "event-queue" = {
# protocol = "sqs"
# endpoint = "arn:aws:sqs:us-east-1:123456789012:event-queue"
# raw_message_delivery = true
# }
# Email subscription
# "alerts" = {
# protocol = "email"
# endpoint = "alerts@example.com"
# }
# HTTP/HTTPS subscription
# "webhook" = {
# protocol = "https"
# endpoint = "https://api.example.com/webhook"
# filter_policy = {
# severity = ["high", "critical"]
# }
# }
}
# Cross-account publish access
publish_accounts = [
# "123456789012",
]
# Cross-account subscribe access
subscribe_accounts = [
# "234567890123",
]
# AWS service publish access
aws_service_principals = [
# "events.amazonaws.com", # EventBridge
# "cloudwatch.amazonaws.com", # CloudWatch Alarms
# "s3.amazonaws.com", # S3 Event Notifications
# "ses.amazonaws.com", # SES Notifications
]
# Dead letter queue for failed deliveries
enable_dlq = true
}
################################################################################
# Variables
################################################################################
variable "region" {
type = string
default = "us-east-1"
}
variable "state_bucket" {
type = string
}
################################################################################
# Provider
################################################################################
provider "aws" {
region = var.region
default_tags {
tags = {
Tenant = local.tenant
App = local.name
Environment = local.env
ManagedBy = "terraform"
}
}
}
################################################################################
# Data Sources
################################################################################
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
################################################################################
# SNS Topic
################################################################################
resource "aws_sns_topic" "main" {
name = local.fifo_topic ? "${local.topic_name}.fifo" : local.topic_name
fifo_topic = local.fifo_topic
content_based_deduplication = local.fifo_topic ? local.content_based_deduplication : null
kms_master_key_id = local.kms_key_arn != null ? local.kms_key_arn : "alias/aws/sns"
delivery_policy = jsonencode(local.delivery_policy)
tags = { Name = local.topic_name }
}
################################################################################
# Dead Letter Queue
################################################################################
resource "aws_sqs_queue" "dlq" {
count = local.enable_dlq ? 1 : 0
name = local.fifo_topic ? "${local.topic_name}-dlq.fifo" : "${local.topic_name}-dlq"
fifo_queue = local.fifo_topic
content_based_deduplication = local.fifo_topic
message_retention_seconds = 1209600 # 14 days
kms_master_key_id = "alias/aws/sqs"
tags = { Name = "${local.topic_name}-dlq" }
}
resource "aws_sqs_queue_policy" "dlq" {
count = local.enable_dlq ? 1 : 0
queue_url = aws_sqs_queue.dlq[0].id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "AllowSNS"
Effect = "Allow"
Principal = {
Service = "sns.amazonaws.com"
}
Action = "sqs:SendMessage"
Resource = aws_sqs_queue.dlq[0].arn
Condition = {
ArnEquals = {
"aws:SourceArn" = aws_sns_topic.main.arn
}
}
}]
})
}
################################################################################
# Topic Policy
################################################################################
resource "aws_sns_topic_policy" "main" {
arn = aws_sns_topic.main.arn
policy = jsonencode({
Version = "2012-10-17"
Statement = concat(
# Allow account root
[{
Sid = "DefaultPolicy"
Effect = "Allow"
Principal = {
AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"
}
Action = [
"sns:Publish",
"sns:Subscribe",
"sns:Receive",
"sns:ListSubscriptionsByTopic",
"sns:GetTopicAttributes"
]
Resource = aws_sns_topic.main.arn
}],
# Cross-account publish
length(local.publish_accounts) > 0 ? [{
Sid = "CrossAccountPublish"
Effect = "Allow"
Principal = {
AWS = [for acct in local.publish_accounts : "arn:aws:iam::${acct}:root"]
}
Action = "sns:Publish"
Resource = aws_sns_topic.main.arn
}] : [],
# Cross-account subscribe
length(local.subscribe_accounts) > 0 ? [{
Sid = "CrossAccountSubscribe"
Effect = "Allow"
Principal = {
AWS = [for acct in local.subscribe_accounts : "arn:aws:iam::${acct}:root"]
}
Action = "sns:Subscribe"
Resource = aws_sns_topic.main.arn
}] : [],
# AWS service access
length(local.aws_service_principals) > 0 ? [{
Sid = "AWSServicePublish"
Effect = "Allow"
Principal = {
Service = local.aws_service_principals
}
Action = "sns:Publish"
Resource = aws_sns_topic.main.arn
Condition = {
StringEquals = {
"aws:SourceAccount" = data.aws_caller_identity.current.account_id
}
}
}] : []
)
})
}
################################################################################
# Subscriptions
################################################################################
resource "aws_sns_topic_subscription" "subscriptions" {
for_each = local.subscriptions
topic_arn = aws_sns_topic.main.arn
protocol = each.value.protocol
endpoint = each.value.endpoint
filter_policy = lookup(each.value, "filter_policy", null) != null ? jsonencode(each.value.filter_policy) : null
filter_policy_scope = lookup(each.value, "filter_policy", null) != null ? "MessageAttributes" : null
raw_message_delivery = lookup(each.value, "raw_message_delivery", false)
redrive_policy = local.enable_dlq ? jsonencode({
deadLetterTargetArn = aws_sqs_queue.dlq[0].arn
}) : null
}
# Lambda permissions for SNS to invoke
resource "aws_lambda_permission" "sns" {
for_each = { for k, v in local.subscriptions : k => v if v.protocol == "lambda" }
statement_id = "AllowSNS-${each.key}"
action = "lambda:InvokeFunction"
function_name = regex("function:([^:]+)$", each.value.endpoint)[0]
principal = "sns.amazonaws.com"
source_arn = aws_sns_topic.main.arn
}
################################################################################
# IAM Policies
################################################################################
resource "aws_iam_policy" "publish" {
name = "${local.topic_name}-sns-publish"
description = "Publish to ${local.topic_name} SNS topic"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "PublishToTopic"
Effect = "Allow"
Action = "sns:Publish"
Resource = aws_sns_topic.main.arn
},
{
Sid = "DecryptKMS"
Effect = "Allow"
Action = [
"kms:Decrypt",
"kms:GenerateDataKey"
]
Resource = local.kms_key_arn != null ? [local.kms_key_arn] : ["arn:aws:kms:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:alias/aws/sns"]
}
]
})
tags = { Name = "${local.topic_name}-publish" }
}
################################################################################
# Outputs
################################################################################
output "topic_arn" {
value = aws_sns_topic.main.arn
description = "SNS topic ARN"
}
output "topic_name" {
value = aws_sns_topic.main.name
description = "SNS topic name"
}
output "dlq_arn" {
value = local.enable_dlq ? aws_sqs_queue.dlq[0].arn : null
description = "Dead letter queue ARN"
}
output "dlq_url" {
value = local.enable_dlq ? aws_sqs_queue.dlq[0].url : null
description = "Dead letter queue URL"
}
output "publish_policy_arn" {
value = aws_iam_policy.publish.arn
description = "IAM policy ARN for publishing"
}
output "subscription_arns" {
value = { for k, v in aws_sns_topic_subscription.subscriptions : k => v.arn }
description = "Subscription ARNs"
}
output "publish_example" {
value = "aws sns publish --topic-arn ${aws_sns_topic.main.arn} --message '{\"event\": \"test\"}' --message-attributes '{\"event_type\": {\"DataType\": \"String\", \"StringValue\": \"test\"}}'"
description = "Example publish command"
}

View File

@@ -0,0 +1,379 @@
################################################################################
# Workload: SQS Queue
#
# Deploys a managed message queue:
# - Main queue with DLQ (dead letter queue)
# - Server-side encryption
# - CloudWatch alarms
# - Optional FIFO support
#
# Usage:
# Copy this folder to 05-workloads/<tenant>-<queue-name>-queue/
# Update locals and variables
# terraform init -backend-config=../../00-bootstrap/backend.hcl
# terraform apply
################################################################################
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
backend "s3" {
key = "05-workloads/<TENANT>-<NAME>-queue/terraform.tfstate"
}
}
################################################################################
# Configuration - UPDATE THESE
################################################################################
locals {
# Naming
tenant = "<TENANT>"
name = "<NAME>"
env = "prod" # prod, staging, dev
# Queue name (will add .fifo suffix if FIFO enabled)
queue_name = "${local.tenant}-${local.name}-${local.env}"
# FIFO queue (exactly-once processing, ordered)
fifo_queue = false
content_based_deduplication = false # Only for FIFO
# Message settings
message_retention_seconds = 1209600 # 14 days (max)
max_message_size = 262144 # 256 KB (max)
delay_seconds = 0 # Delay before message becomes visible
receive_wait_time_seconds = 20 # Long polling (cost efficient)
# Visibility timeout (should be > consumer processing time)
visibility_timeout_seconds = 300 # 5 minutes
# Dead letter queue settings
max_receive_count = 3 # Messages go to DLQ after this many failed receives
dlq_retention_days = 14
# Alarm thresholds
alarm_age_threshold = 300 # 5 minutes - message age alarm
alarm_messages_threshold = 1000 # Queue depth alarm
}
################################################################################
# Variables
################################################################################
variable "region" {
type = string
default = "us-east-1"
}
variable "state_bucket" {
type = string
}
################################################################################
# Provider
################################################################################
provider "aws" {
region = var.region
default_tags {
tags = {
Tenant = local.tenant
App = local.name
Environment = local.env
ManagedBy = "terraform"
}
}
}
################################################################################
# Data Sources
################################################################################
data "terraform_remote_state" "tenant" {
backend = "s3"
config = {
bucket = var.state_bucket
key = "04-tenants/${local.tenant}/terraform.tfstate"
region = var.region
}
}
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
################################################################################
# KMS Key
################################################################################
resource "aws_kms_key" "sqs" {
description = "KMS key for ${local.queue_name} SQS encryption"
deletion_window_in_days = 7
enable_key_rotation = true
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "Enable IAM User Permissions"
Effect = "Allow"
Principal = {
AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"
}
Action = "kms:*"
Resource = "*"
},
{
Sid = "Allow SQS Service"
Effect = "Allow"
Principal = {
Service = "sqs.amazonaws.com"
}
Action = [
"kms:Decrypt",
"kms:GenerateDataKey"
]
Resource = "*"
},
{
Sid = "Allow SNS to use this key"
Effect = "Allow"
Principal = {
Service = "sns.amazonaws.com"
}
Action = [
"kms:Decrypt",
"kms:GenerateDataKey"
]
Resource = "*"
}
]
})
tags = { Name = "${local.queue_name}-sqs" }
}
resource "aws_kms_alias" "sqs" {
name = "alias/${local.queue_name}-sqs"
target_key_id = aws_kms_key.sqs.key_id
}
################################################################################
# Dead Letter Queue
################################################################################
resource "aws_sqs_queue" "dlq" {
name = local.fifo_queue ? "${local.queue_name}-dlq.fifo" : "${local.queue_name}-dlq"
fifo_queue = local.fifo_queue
message_retention_seconds = local.dlq_retention_days * 86400
kms_master_key_id = aws_kms_key.sqs.id
kms_data_key_reuse_period_seconds = 86400 # 24 hours
tags = { Name = "${local.queue_name}-dlq" }
}
################################################################################
# Main Queue
################################################################################
resource "aws_sqs_queue" "main" {
name = local.fifo_queue ? "${local.queue_name}.fifo" : local.queue_name
fifo_queue = local.fifo_queue
content_based_deduplication = local.fifo_queue ? local.content_based_deduplication : null
message_retention_seconds = local.message_retention_seconds
max_message_size = local.max_message_size
delay_seconds = local.delay_seconds
receive_message_wait_time_seconds = local.receive_wait_time_seconds
visibility_timeout_seconds = local.visibility_timeout_seconds
# Encryption
kms_master_key_id = aws_kms_key.sqs.id
kms_data_key_reuse_period_seconds = 86400 # 24 hours
# Dead letter queue
redrive_policy = jsonencode({
deadLetterTargetArn = aws_sqs_queue.dlq.arn
maxReceiveCount = local.max_receive_count
})
tags = { Name = local.queue_name }
}
# Allow DLQ redrive
resource "aws_sqs_queue_redrive_allow_policy" "dlq" {
queue_url = aws_sqs_queue.dlq.id
redrive_allow_policy = jsonencode({
redrivePermission = "byQueue"
sourceQueueArns = [aws_sqs_queue.main.arn]
})
}
################################################################################
# Queue Policy
################################################################################
resource "aws_sqs_queue_policy" "main" {
queue_url = aws_sqs_queue.main.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowTenantAccess"
Effect = "Allow"
Principal = {
AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"
}
Action = [
"sqs:SendMessage",
"sqs:ReceiveMessage",
"sqs:DeleteMessage",
"sqs:GetQueueAttributes",
"sqs:GetQueueUrl"
]
Resource = aws_sqs_queue.main.arn
Condition = {
StringEquals = {
"aws:PrincipalTag/Tenant" = local.tenant
}
}
},
{
Sid = "DenyInsecureTransport"
Effect = "Deny"
Principal = "*"
Action = "sqs:*"
Resource = aws_sqs_queue.main.arn
Condition = {
Bool = {
"aws:SecureTransport" = "false"
}
}
}
]
})
}
################################################################################
# SNS Topic for Alarms
################################################################################
resource "aws_sns_topic" "alarms" {
name = "${local.queue_name}-alarms"
tags = { Name = "${local.queue_name}-alarms" }
}
################################################################################
# CloudWatch Alarms
################################################################################
# Queue depth alarm
resource "aws_cloudwatch_metric_alarm" "depth" {
alarm_name = "${local.queue_name}-depth"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
metric_name = "ApproximateNumberOfMessagesVisible"
namespace = "AWS/SQS"
period = 300
statistic = "Average"
threshold = local.alarm_messages_threshold
alarm_description = "Queue depth high - messages may be backing up"
dimensions = {
QueueName = aws_sqs_queue.main.name
}
alarm_actions = [aws_sns_topic.alarms.arn]
ok_actions = [aws_sns_topic.alarms.arn]
tags = { Name = "${local.queue_name}-depth" }
}
# Message age alarm
resource "aws_cloudwatch_metric_alarm" "age" {
alarm_name = "${local.queue_name}-age"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
metric_name = "ApproximateAgeOfOldestMessage"
namespace = "AWS/SQS"
period = 300
statistic = "Maximum"
threshold = local.alarm_age_threshold
alarm_description = "Oldest message age high - consumers may be failing"
dimensions = {
QueueName = aws_sqs_queue.main.name
}
alarm_actions = [aws_sns_topic.alarms.arn]
ok_actions = [aws_sns_topic.alarms.arn]
tags = { Name = "${local.queue_name}-age" }
}
# DLQ messages alarm (critical - messages are failing)
resource "aws_cloudwatch_metric_alarm" "dlq" {
alarm_name = "${local.queue_name}-dlq"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 1
metric_name = "ApproximateNumberOfMessagesVisible"
namespace = "AWS/SQS"
period = 300
statistic = "Sum"
threshold = 0
alarm_description = "Messages in DLQ - processing failures detected"
dimensions = {
QueueName = aws_sqs_queue.dlq.name
}
alarm_actions = [aws_sns_topic.alarms.arn]
tags = { Name = "${local.queue_name}-dlq" }
}
################################################################################
# Outputs
################################################################################
output "queue_url" {
value = aws_sqs_queue.main.url
}
output "queue_arn" {
value = aws_sqs_queue.main.arn
}
output "queue_name" {
value = aws_sqs_queue.main.name
}
output "dlq_url" {
value = aws_sqs_queue.dlq.url
}
output "dlq_arn" {
value = aws_sqs_queue.dlq.arn
}
output "kms_key_arn" {
value = aws_kms_key.sqs.arn
}
output "alarm_topic_arn" {
value = aws_sns_topic.alarms.arn
}

View File

@@ -0,0 +1,343 @@
################################################################################
# Workload: SSM Parameter Store
#
# Configuration management (cheaper than Secrets Manager for non-secrets):
# - String, StringList, SecureString parameters
# - Hierarchical paths for organization
# - KMS encryption for SecureString
# - Parameter policies (expiration, notification)
# - Cross-account access
#
# Cost: Free for standard parameters, $0.05/10K API calls for advanced
# Use Secrets Manager for: rotation, cross-region replication, RDS integration
################################################################################
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
backend "s3" {
key = "05-workloads/<TENANT>-<NAME>-params/terraform.tfstate"
}
}
################################################################################
# Configuration - UPDATE THESE
################################################################################
locals {
# Naming
tenant = "<TENANT>"
name = "<NAME>"
env = "prod"
prefix = "/${local.tenant}/${local.env}/${local.name}"
# KMS key for SecureString (null = AWS managed key)
kms_key_arn = null
# Parameter tier: Standard (free, 4KB) or Advanced ($0.05/param/mo, 8KB)
tier = "Standard"
# Parameters to create
parameters = {
# Application config
"config/app_name" = {
type = "String"
value = local.name
description = "Application name"
}
"config/environment" = {
type = "String"
value = local.env
description = "Environment name"
}
"config/log_level" = {
type = "String"
value = "INFO"
description = "Application log level"
}
"config/feature_flags" = {
type = "String"
value = jsonencode({
new_checkout = true
dark_mode = false
beta_features = false
})
description = "Feature flags JSON"
}
# Database config (non-secret parts)
"database/host" = {
type = "String"
value = "db.example.internal"
description = "Database hostname"
}
"database/port" = {
type = "String"
value = "5432"
description = "Database port"
}
"database/name" = {
type = "String"
value = "myapp"
description = "Database name"
}
# Secure values (encrypted with KMS)
# Note: Update this value after deployment via CLI:
# aws ssm put-parameter --name "/<tenant>/<env>/<app>/secrets/api_key" --value "real-secret" --type SecureString --overwrite
"secrets/api_key" = {
type = "SecureString"
value = "initial-value-update-after-deploy"
description = "External API key"
}
# List example
"config/allowed_origins" = {
type = "StringList"
value = "https://example.com,https://app.example.com"
description = "CORS allowed origins"
}
}
# Parameters with expiration policies (Advanced tier only)
expiring_parameters = {
# "tokens/temp_token" = {
# type = "SecureString"
# value = "temp-value"
# description = "Temporary token"
# expiration = "2024-12-31T23:59:59Z"
# }
}
}
################################################################################
# Variables
################################################################################
variable "region" {
type = string
default = "us-east-1"
}
variable "state_bucket" {
type = string
}
################################################################################
# Provider
################################################################################
provider "aws" {
region = var.region
default_tags {
tags = {
Tenant = local.tenant
App = local.name
Environment = local.env
ManagedBy = "terraform"
}
}
}
################################################################################
# Data Sources
################################################################################
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
################################################################################
# SSM Parameters
################################################################################
resource "aws_ssm_parameter" "params" {
for_each = local.parameters
name = "${local.prefix}/${each.key}"
description = lookup(each.value, "description", "Parameter ${each.key}")
type = each.value.type
value = each.value.value
tier = local.tier
key_id = each.value.type == "SecureString" ? local.kms_key_arn : null
tags = {
Name = "${local.prefix}/${each.key}"
Type = each.value.type
}
# Uncomment to prevent Terraform from updating SecureString values
# (useful when managing secrets externally via CLI/console)
# lifecycle {
# ignore_changes = [value]
# }
}
################################################################################
# Parameters with Expiration (Advanced Tier)
################################################################################
resource "aws_ssm_parameter" "expiring" {
for_each = local.expiring_parameters
name = "${local.prefix}/${each.key}"
description = lookup(each.value, "description", "Parameter ${each.key}")
type = each.value.type
value = each.value.value
tier = "Advanced" # Required for policies
overwrite = true # Allow updates to existing parameters
key_id = each.value.type == "SecureString" ? local.kms_key_arn : null
# Note: Parameter policies (expiration, notification) require AWS SDK/CLI
# Use aws ssm put-parameter with --policies flag for expiration:
# aws ssm put-parameter --name "/path/param" --policies '[{"Type":"Expiration","Version":"1.0","Attributes":{"Timestamp":"2024-12-31T23:59:59.000Z"}}]'
tags = {
Name = "${local.prefix}/${each.key}"
Type = each.value.type
Expiration = lookup(each.value, "expiration", "none")
}
}
################################################################################
# IAM Policy for Reading Parameters
################################################################################
resource "aws_iam_policy" "read" {
name = "${local.tenant}-${local.name}-ssm-read"
description = "Read access to ${local.prefix} parameters"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "DescribeParameters"
Effect = "Allow"
Action = [
"ssm:DescribeParameters"
]
Resource = "*"
},
{
Sid = "GetParameters"
Effect = "Allow"
Action = [
"ssm:GetParameter",
"ssm:GetParameters",
"ssm:GetParametersByPath"
]
Resource = "arn:aws:ssm:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:parameter${local.prefix}/*"
},
{
Sid = "DecryptSecureStrings"
Effect = "Allow"
Action = [
"kms:Decrypt"
]
Resource = local.kms_key_arn != null ? [local.kms_key_arn] : ["arn:aws:kms:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:alias/aws/ssm"]
}
]
})
tags = { Name = "${local.tenant}-${local.name}-ssm-read" }
}
resource "aws_iam_policy" "write" {
name = "${local.tenant}-${local.name}-ssm-write"
description = "Write access to ${local.prefix} parameters"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "ManageParameters"
Effect = "Allow"
Action = [
"ssm:PutParameter",
"ssm:DeleteParameter",
"ssm:GetParameter",
"ssm:GetParameters",
"ssm:GetParametersByPath",
"ssm:DescribeParameters"
]
Resource = "arn:aws:ssm:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:parameter${local.prefix}/*"
},
{
Sid = "EncryptDecrypt"
Effect = "Allow"
Action = [
"kms:Encrypt",
"kms:Decrypt",
"kms:GenerateDataKey"
]
Resource = local.kms_key_arn != null ? [local.kms_key_arn] : ["arn:aws:kms:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:alias/aws/ssm"]
}
]
})
tags = { Name = "${local.tenant}-${local.name}-ssm-write" }
}
################################################################################
# Outputs
################################################################################
output "parameter_arns" {
value = { for k, v in aws_ssm_parameter.params : k => v.arn }
description = "Parameter ARNs"
}
output "parameter_names" {
value = { for k, v in aws_ssm_parameter.params : k => v.name }
description = "Full parameter names (paths)"
}
output "prefix" {
value = local.prefix
description = "Parameter path prefix"
}
output "read_policy_arn" {
value = aws_iam_policy.read.arn
description = "IAM policy ARN for reading parameters"
}
output "write_policy_arn" {
value = aws_iam_policy.write.arn
description = "IAM policy ARN for writing parameters"
}
output "sdk_examples" {
value = {
get_single = "aws ssm get-parameter --name '${local.prefix}/config/app_name' --query Parameter.Value --output text"
get_secure = "aws ssm get-parameter --name '${local.prefix}/secrets/api_key' --with-decryption --query Parameter.Value --output text"
get_path = "aws ssm get-parameters-by-path --path '${local.prefix}/config' --recursive --query 'Parameters[*].[Name,Value]' --output table"
put_param = "aws ssm put-parameter --name '${local.prefix}/config/new_param' --value 'my-value' --type String --overwrite"
}
description = "Example CLI commands"
}
output "cost_estimate" {
value = {
standard_params = "Free (up to 10,000 parameters)"
advanced_params = "$0.05/parameter/month"
api_calls = "Free for standard, $0.05 per 10,000 for advanced"
note = "SecureString encryption uses KMS (may have additional costs)"
}
description = "Cost information"
}

View File

@@ -0,0 +1,450 @@
################################################################################
# Workload: Static Site (S3 + CloudFront)
#
# Deploys a static website:
# - S3 bucket for content (private, OAC access only)
# - CloudFront distribution with HTTPS
# - ACM certificate (DNS validation)
# - WAF integration (optional)
#
# Usage:
# Copy this folder to 05-workloads/<tenant>-<site-name>/
# Update locals and variables
# terraform init -backend-config=../../00-bootstrap/backend.hcl
# terraform apply
################################################################################
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
backend "s3" {
key = "05-workloads/<TENANT>-<NAME>/terraform.tfstate"
}
}
################################################################################
# Configuration - UPDATE THESE
################################################################################
locals {
# Naming
tenant = "<TENANT>"
name = "<NAME>"
env = "prod"
# Domain (leave empty for CloudFront default domain)
domain_name = "" # e.g., "www.example.com"
hosted_zone_id = "" # Route53 hosted zone ID
create_certificate = local.domain_name != ""
# Content settings
default_root_object = "index.html"
error_page_path = "/error.html"
# Caching
default_ttl = 86400 # 1 day
min_ttl = 0
max_ttl = 31536000 # 1 year
# Price class
# PriceClass_100 = US, Canada, Europe (cheapest)
# PriceClass_200 = Above + Asia, Africa, Middle East
# PriceClass_All = All edge locations
price_class = "PriceClass_100"
# WAF (set to WAF web ACL ARN to enable)
waf_web_acl_arn = ""
# Logging
enable_logging = true
}
################################################################################
# Variables
################################################################################
variable "region" {
type = string
default = "us-east-1"
}
variable "state_bucket" {
type = string
}
################################################################################
# Provider
################################################################################
provider "aws" {
region = var.region
default_tags {
tags = {
Tenant = local.tenant
App = local.name
Environment = local.env
ManagedBy = "terraform"
}
}
}
# ACM certificates must be in us-east-1 for CloudFront
provider "aws" {
alias = "us_east_1"
region = "us-east-1"
}
################################################################################
# Data Sources
################################################################################
data "terraform_remote_state" "bootstrap" {
backend = "s3"
config = {
bucket = var.state_bucket
key = "00-bootstrap/terraform.tfstate"
region = var.region
}
}
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
################################################################################
# S3 Bucket
################################################################################
resource "aws_s3_bucket" "site" {
bucket = "${local.tenant}-${local.name}-${local.env}-${data.aws_caller_identity.current.account_id}"
tags = { Name = "${local.tenant}-${local.name}" }
}
resource "aws_s3_bucket_versioning" "site" {
bucket = aws_s3_bucket.site.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "site" {
bucket = aws_s3_bucket.site.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_public_access_block" "site" {
bucket = aws_s3_bucket.site.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# Bucket policy for CloudFront OAC
resource "aws_s3_bucket_policy" "site" {
bucket = aws_s3_bucket.site.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowCloudFrontOAC"
Effect = "Allow"
Principal = {
Service = "cloudfront.amazonaws.com"
}
Action = "s3:GetObject"
Resource = "${aws_s3_bucket.site.arn}/*"
Condition = {
StringEquals = {
"AWS:SourceArn" = aws_cloudfront_distribution.site.arn
}
}
}
]
})
}
################################################################################
# CloudFront Origin Access Control
################################################################################
resource "aws_cloudfront_origin_access_control" "site" {
name = "${local.tenant}-${local.name}"
description = "OAC for ${local.tenant}-${local.name}"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
################################################################################
# ACM Certificate (if custom domain)
################################################################################
resource "aws_acm_certificate" "site" {
count = local.create_certificate ? 1 : 0
provider = aws.us_east_1
domain_name = local.domain_name
validation_method = "DNS"
lifecycle {
create_before_destroy = true
}
tags = { Name = "${local.tenant}-${local.name}" }
}
resource "aws_route53_record" "cert_validation" {
for_each = local.create_certificate ? {
for dvo in aws_acm_certificate.site[0].domain_validation_options : dvo.domain_name => {
name = dvo.resource_record_name
record = dvo.resource_record_value
type = dvo.resource_record_type
}
} : {}
allow_overwrite = true
name = each.value.name
records = [each.value.record]
ttl = 60
type = each.value.type
zone_id = local.hosted_zone_id
}
resource "aws_acm_certificate_validation" "site" {
count = local.create_certificate ? 1 : 0
provider = aws.us_east_1
certificate_arn = aws_acm_certificate.site[0].arn
validation_record_fqdns = [for record in aws_route53_record.cert_validation : record.fqdn]
}
################################################################################
# CloudFront Logging Bucket
################################################################################
resource "aws_s3_bucket" "logs" {
count = local.enable_logging ? 1 : 0
bucket = "${local.tenant}-${local.name}-logs-${data.aws_caller_identity.current.account_id}"
tags = { Name = "${local.tenant}-${local.name}-logs" }
}
resource "aws_s3_bucket_ownership_controls" "logs" {
count = local.enable_logging ? 1 : 0
bucket = aws_s3_bucket.logs[0].id
rule {
object_ownership = "BucketOwnerPreferred"
}
}
resource "aws_s3_bucket_acl" "logs" {
count = local.enable_logging ? 1 : 0
depends_on = [aws_s3_bucket_ownership_controls.logs]
bucket = aws_s3_bucket.logs[0].id
acl = "private"
}
resource "aws_s3_bucket_lifecycle_configuration" "logs" {
count = local.enable_logging ? 1 : 0
bucket = aws_s3_bucket.logs[0].id
rule {
id = "cleanup"
status = "Enabled"
expiration {
days = 90
}
}
}
################################################################################
# CloudFront Distribution
################################################################################
resource "aws_cloudfront_distribution" "site" {
enabled = true
is_ipv6_enabled = true
default_root_object = local.default_root_object
price_class = local.price_class
comment = "${local.tenant} ${local.name} static site"
aliases = local.create_certificate ? [local.domain_name] : []
origin {
domain_name = aws_s3_bucket.site.bucket_regional_domain_name
origin_id = "S3-${aws_s3_bucket.site.id}"
origin_access_control_id = aws_cloudfront_origin_access_control.site.id
}
default_cache_behavior {
allowed_methods = ["GET", "HEAD", "OPTIONS"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "S3-${aws_s3_bucket.site.id}"
viewer_protocol_policy = "redirect-to-https"
compress = true
min_ttl = local.min_ttl
default_ttl = local.default_ttl
max_ttl = local.max_ttl
forwarded_values {
query_string = false
cookies {
forward = "none"
}
}
# Security headers
response_headers_policy_id = aws_cloudfront_response_headers_policy.security.id
}
# Custom error pages
custom_error_response {
error_code = 404
response_code = 404
response_page_path = local.error_page_path
error_caching_min_ttl = 60
}
custom_error_response {
error_code = 403
response_code = 404
response_page_path = local.error_page_path
error_caching_min_ttl = 60
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
viewer_certificate {
acm_certificate_arn = local.create_certificate ? aws_acm_certificate.site[0].arn : null
ssl_support_method = local.create_certificate ? "sni-only" : null
minimum_protocol_version = local.create_certificate ? "TLSv1.2_2021" : null
cloudfront_default_certificate = !local.create_certificate
}
dynamic "logging_config" {
for_each = local.enable_logging ? [1] : []
content {
bucket = aws_s3_bucket.logs[0].bucket_domain_name
include_cookies = false
prefix = "cloudfront/"
}
}
web_acl_id = local.waf_web_acl_arn != "" ? local.waf_web_acl_arn : null
tags = { Name = "${local.tenant}-${local.name}" }
}
################################################################################
# Security Headers Policy
################################################################################
resource "aws_cloudfront_response_headers_policy" "security" {
name = "${local.tenant}-${local.name}-security"
comment = "Security headers for ${local.tenant}-${local.name}"
security_headers_config {
content_type_options {
override = true
}
frame_options {
frame_option = "DENY"
override = true
}
referrer_policy {
referrer_policy = "strict-origin-when-cross-origin"
override = true
}
strict_transport_security {
access_control_max_age_sec = 31536000 # 1 year
include_subdomains = true
preload = true
override = true
}
xss_protection {
mode_block = true
protection = true
override = true
}
content_security_policy {
content_security_policy = "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'"
override = true
}
}
}
################################################################################
# Route53 Record (if custom domain)
################################################################################
resource "aws_route53_record" "site" {
count = local.create_certificate ? 1 : 0
zone_id = local.hosted_zone_id
name = local.domain_name
type = "A"
alias {
name = aws_cloudfront_distribution.site.domain_name
zone_id = aws_cloudfront_distribution.site.hosted_zone_id
evaluate_target_health = false
}
}
################################################################################
# Outputs
################################################################################
output "bucket_name" {
value = aws_s3_bucket.site.id
}
output "bucket_arn" {
value = aws_s3_bucket.site.arn
}
output "distribution_id" {
value = aws_cloudfront_distribution.site.id
}
output "distribution_domain" {
value = aws_cloudfront_distribution.site.domain_name
}
output "site_url" {
value = local.create_certificate ? "https://${local.domain_name}" : "https://${aws_cloudfront_distribution.site.domain_name}"
}
output "deploy_command" {
value = "aws s3 sync ./dist s3://${aws_s3_bucket.site.id} --delete && aws cloudfront create-invalidation --distribution-id ${aws_cloudfront_distribution.site.id} --paths '/*'"
description = "Command to deploy content"
}

View File

@@ -0,0 +1,499 @@
################################################################################
# Workload: Step Functions State Machine
#
# Deploys a serverless workflow:
# - Step Functions state machine
# - IAM role with least-privilege
# - CloudWatch logging
# - X-Ray tracing
# - EventBridge trigger (optional)
# - API Gateway trigger (optional)
#
# Usage:
# Copy this folder to 05-workloads/<tenant>-<workflow-name>/
# Update the state machine definition in definition.json
# terraform init -backend-config=../../00-bootstrap/backend.hcl
# terraform apply
################################################################################
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
backend "s3" {
key = "05-workloads/<TENANT>-<NAME>-workflow/terraform.tfstate"
}
}
################################################################################
# Configuration - UPDATE THESE
################################################################################
locals {
# Naming
tenant = "<TENANT>"
name = "<NAME>"
env = "prod"
state_machine_name = "${local.tenant}-${local.name}-${local.env}"
# State machine type: STANDARD or EXPRESS
# STANDARD: Long-running (up to 1 year), exactly-once execution
# EXPRESS: Short-duration (up to 5 min), at-least-once, cheaper
type = "STANDARD"
# Logging level: OFF, ALL, ERROR, FATAL
logging_level = "ERROR"
# X-Ray tracing
tracing_enabled = true
# EventBridge trigger (set to null to disable)
schedule_expression = null # e.g., "rate(1 hour)" or "cron(0 12 * * ? *)"
# API Gateway trigger
enable_api_trigger = false
# Lambda functions this workflow can invoke (ARNs)
lambda_arns = [
# "arn:aws:lambda:us-east-1:123456789012:function:my-function",
]
# DynamoDB tables this workflow can access (ARNs)
dynamodb_arns = [
# "arn:aws:dynamodb:us-east-1:123456789012:table/my-table",
]
# SQS queues this workflow can send to (ARNs)
sqs_arns = [
# "arn:aws:sqs:us-east-1:123456789012:my-queue",
]
# SNS topics this workflow can publish to (ARNs)
sns_arns = [
# "arn:aws:sns:us-east-1:123456789012:my-topic",
]
# S3 buckets this workflow can access (ARNs)
s3_arns = [
# "arn:aws:s3:::my-bucket/*",
]
}
################################################################################
# Variables
################################################################################
variable "region" {
type = string
default = "us-east-1"
}
variable "state_bucket" {
type = string
}
################################################################################
# Provider
################################################################################
provider "aws" {
region = var.region
default_tags {
tags = {
Tenant = local.tenant
App = local.name
Environment = local.env
ManagedBy = "terraform"
}
}
}
################################################################################
# Data Sources
################################################################################
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
################################################################################
# CloudWatch Log Group
################################################################################
resource "aws_cloudwatch_log_group" "main" {
name = "/aws/states/${local.state_machine_name}"
retention_in_days = 30
tags = { Name = local.state_machine_name }
}
################################################################################
# IAM Role
################################################################################
resource "aws_iam_role" "state_machine" {
name = "${local.state_machine_name}-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "states.amazonaws.com" }
}]
})
tags = { Name = "${local.state_machine_name}-role" }
}
# CloudWatch Logs permissions
resource "aws_iam_role_policy" "logs" {
name = "cloudwatch-logs"
role = aws_iam_role.state_machine.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = [
"logs:CreateLogDelivery",
"logs:CreateLogStream",
"logs:GetLogDelivery",
"logs:UpdateLogDelivery",
"logs:DeleteLogDelivery",
"logs:ListLogDeliveries",
"logs:PutLogEvents",
"logs:PutResourcePolicy",
"logs:DescribeResourcePolicies",
"logs:DescribeLogGroups"
]
Resource = "*"
}]
})
}
# X-Ray permissions
resource "aws_iam_role_policy" "xray" {
count = local.tracing_enabled ? 1 : 0
name = "xray"
role = aws_iam_role.state_machine.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = [
"xray:PutTraceSegments",
"xray:PutTelemetryRecords",
"xray:GetSamplingRules",
"xray:GetSamplingTargets"
]
Resource = "*"
}]
})
}
# Lambda invocation permissions
resource "aws_iam_role_policy" "lambda" {
count = length(local.lambda_arns) > 0 ? 1 : 0
name = "lambda"
role = aws_iam_role.state_machine.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "lambda:InvokeFunction"
Resource = local.lambda_arns
}]
})
}
# DynamoDB permissions
resource "aws_iam_role_policy" "dynamodb" {
count = length(local.dynamodb_arns) > 0 ? 1 : 0
name = "dynamodb"
role = aws_iam_role.state_machine.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem",
"dynamodb:Query",
"dynamodb:Scan"
]
Resource = local.dynamodb_arns
}]
})
}
# SQS permissions
resource "aws_iam_role_policy" "sqs" {
count = length(local.sqs_arns) > 0 ? 1 : 0
name = "sqs"
role = aws_iam_role.state_machine.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = [
"sqs:SendMessage",
"sqs:GetQueueUrl"
]
Resource = local.sqs_arns
}]
})
}
# SNS permissions
resource "aws_iam_role_policy" "sns" {
count = length(local.sns_arns) > 0 ? 1 : 0
name = "sns"
role = aws_iam_role.state_machine.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sns:Publish"
Resource = local.sns_arns
}]
})
}
# S3 permissions
resource "aws_iam_role_policy" "s3" {
count = length(local.s3_arns) > 0 ? 1 : 0
name = "s3"
role = aws_iam_role.state_machine.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject"
]
Resource = local.s3_arns
}]
})
}
################################################################################
# State Machine Definition
################################################################################
# Simple example - replace with your actual workflow
locals {
state_machine_definition = jsonencode({
Comment = "Example workflow for ${local.tenant} ${local.name}"
StartAt = "ProcessInput"
States = {
ProcessInput = {
Type = "Pass"
Parameters = {
"input.$" = "$"
"timestamp" = "$$.State.EnteredTime"
}
Next = "Success"
}
Success = {
Type = "Succeed"
}
}
})
}
################################################################################
# Step Functions State Machine
################################################################################
resource "aws_sfn_state_machine" "main" {
name = local.state_machine_name
role_arn = aws_iam_role.state_machine.arn
type = local.type
definition = local.state_machine_definition
logging_configuration {
log_destination = "${aws_cloudwatch_log_group.main.arn}:*"
include_execution_data = true
level = local.logging_level
}
tracing_configuration {
enabled = local.tracing_enabled
}
tags = { Name = local.state_machine_name }
}
################################################################################
# EventBridge Schedule Trigger
################################################################################
resource "aws_cloudwatch_event_rule" "schedule" {
count = local.schedule_expression != null ? 1 : 0
name = "${local.state_machine_name}-schedule"
description = "Trigger ${local.state_machine_name} on schedule"
schedule_expression = local.schedule_expression
tags = { Name = "${local.state_machine_name}-schedule" }
}
resource "aws_cloudwatch_event_target" "schedule" {
count = local.schedule_expression != null ? 1 : 0
rule = aws_cloudwatch_event_rule.schedule[0].name
target_id = "StepFunctions"
arn = aws_sfn_state_machine.main.arn
role_arn = aws_iam_role.eventbridge[0].arn
input = jsonencode({
source = "scheduled"
timestamp = "$.time"
})
}
resource "aws_iam_role" "eventbridge" {
count = local.schedule_expression != null ? 1 : 0
name = "${local.state_machine_name}-eventbridge"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "events.amazonaws.com" }
}]
})
tags = { Name = "${local.state_machine_name}-eventbridge" }
}
resource "aws_iam_role_policy" "eventbridge" {
count = local.schedule_expression != null ? 1 : 0
name = "start-execution"
role = aws_iam_role.eventbridge[0].id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "states:StartExecution"
Resource = aws_sfn_state_machine.main.arn
}]
})
}
################################################################################
# API Gateway Trigger
################################################################################
resource "aws_apigatewayv2_api" "main" {
count = local.enable_api_trigger ? 1 : 0
name = local.state_machine_name
protocol_type = "HTTP"
tags = { Name = local.state_machine_name }
}
resource "aws_apigatewayv2_stage" "main" {
count = local.enable_api_trigger ? 1 : 0
api_id = aws_apigatewayv2_api.main[0].id
name = "$default"
auto_deploy = true
}
resource "aws_apigatewayv2_integration" "main" {
count = local.enable_api_trigger ? 1 : 0
api_id = aws_apigatewayv2_api.main[0].id
integration_type = "AWS_PROXY"
integration_subtype = "StepFunctions-StartExecution"
credentials_arn = aws_iam_role.api[0].arn
request_parameters = {
StateMachineArn = aws_sfn_state_machine.main.arn
Input = "$request.body"
}
}
resource "aws_apigatewayv2_route" "main" {
count = local.enable_api_trigger ? 1 : 0
api_id = aws_apigatewayv2_api.main[0].id
route_key = "POST /execute"
target = "integrations/${aws_apigatewayv2_integration.main[0].id}"
}
resource "aws_iam_role" "api" {
count = local.enable_api_trigger ? 1 : 0
name = "${local.state_machine_name}-api"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "apigateway.amazonaws.com" }
}]
})
tags = { Name = "${local.state_machine_name}-api" }
}
resource "aws_iam_role_policy" "api" {
count = local.enable_api_trigger ? 1 : 0
name = "start-execution"
role = aws_iam_role.api[0].id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "states:StartExecution"
Resource = aws_sfn_state_machine.main.arn
}]
})
}
################################################################################
# Outputs
################################################################################
output "state_machine_arn" {
value = aws_sfn_state_machine.main.arn
}
output "state_machine_name" {
value = aws_sfn_state_machine.main.name
}
output "role_arn" {
value = aws_iam_role.state_machine.arn
}
output "log_group" {
value = aws_cloudwatch_log_group.main.name
}
output "api_endpoint" {
value = local.enable_api_trigger ? "${aws_apigatewayv2_api.main[0].api_endpoint}/execute" : null
}
output "execution_command" {
value = "aws stepfunctions start-execution --state-machine-arn ${aws_sfn_state_machine.main.arn} --input '{\"key\": \"value\"}'"
}

View File

@@ -0,0 +1,43 @@
# account-baseline
Terraform module for AWS landing zone pattern.
Apply baseline security configuration to AWS accounts in a landing zone.
## Planned Features
- [ ] CloudTrail configuration (or org trail delegation)
- [ ] AWS Config (or org aggregator delegation)
- [ ] GuardDuty member enrollment
- [ ] Security Hub member enrollment
- [ ] IAM password policy
- [ ] Standard IAM roles (admin, readonly, billing)
- [ ] EBS default encryption
- [ ] S3 public access block
## Planned Usage
```hcl
module "baseline" {
source = "../modules/account-baseline"
account_name = "workload-prod"
# Delegate to org-level services
enable_cloudtrail = false
enable_config = false
# Enroll in delegated admin services
enable_guardduty = true
enable_securityhub = true
tags = local.tags
}
```
## Requirements
| Name | Version |
|------|---------|
| terraform | >= 1.5.0 |
| aws | >= 5.0 |

View File

@@ -0,0 +1,314 @@
################################################################################
# Account Baseline Module
#
# Applies baseline security configuration to AWS accounts:
# - EBS default encryption
# - S3 account public access block
# - IAM account password policy
# - IAM Access Analyzer
# - Security Hub enrollment (optional)
# - GuardDuty enrollment (optional)
################################################################################
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
locals {
account_id = data.aws_caller_identity.current.account_id
region = data.aws_region.current.id
}
################################################################################
# EBS Default Encryption
################################################################################
resource "aws_ebs_encryption_by_default" "this" {
count = var.enable_ebs_encryption ? 1 : 0
enabled = true
}
resource "aws_ebs_default_kms_key" "this" {
count = var.enable_ebs_encryption && var.ebs_kms_key_arn != null ? 1 : 0
key_arn = var.ebs_kms_key_arn
}
################################################################################
# S3 Account Public Access Block
################################################################################
resource "aws_s3_account_public_access_block" "this" {
count = var.enable_s3_block_public ? 1 : 0
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
################################################################################
# IAM Password Policy
################################################################################
resource "aws_iam_account_password_policy" "this" {
count = var.enable_password_policy ? 1 : 0
minimum_password_length = var.password_policy.minimum_length
require_lowercase_characters = var.password_policy.require_lowercase
require_numbers = var.password_policy.require_numbers
require_uppercase_characters = var.password_policy.require_uppercase
require_symbols = var.password_policy.require_symbols
allow_users_to_change_password = var.password_policy.allow_users_to_change
max_password_age = var.password_policy.max_age_days
password_reuse_prevention = var.password_policy.reuse_prevention_count
hard_expiry = var.password_policy.hard_expiry
}
################################################################################
# IAM Access Analyzer
################################################################################
resource "aws_accessanalyzer_analyzer" "this" {
count = var.enable_access_analyzer ? 1 : 0
analyzer_name = "${var.name}-access-analyzer"
type = var.access_analyzer_type
tags = merge(var.tags, {
Name = "${var.name}-access-analyzer"
})
}
################################################################################
# Security Hub
################################################################################
resource "aws_securityhub_account" "this" {
count = var.enable_securityhub ? 1 : 0
enable_default_standards = var.securityhub_enable_default_standards
auto_enable_controls = var.securityhub_auto_enable_controls
control_finding_generator = "SECURITY_CONTROL"
}
resource "aws_securityhub_standards_subscription" "this" {
for_each = var.enable_securityhub ? toset(var.securityhub_standards) : []
standards_arn = each.value
depends_on = [aws_securityhub_account.this]
}
################################################################################
# GuardDuty
################################################################################
resource "aws_guardduty_detector" "this" {
count = var.enable_guardduty ? 1 : 0
enable = true
finding_publishing_frequency = var.guardduty_finding_frequency
datasources {
s3_logs {
enable = true
}
kubernetes {
audit_logs {
enable = var.guardduty_kubernetes_audit
}
}
malware_protection {
scan_ec2_instance_with_findings {
ebs_volumes {
enable = var.guardduty_malware_protection
}
}
}
}
tags = merge(var.tags, {
Name = "${var.name}-guardduty"
})
}
################################################################################
# AWS Config
################################################################################
resource "aws_config_configuration_recorder" "this" {
count = var.enable_config ? 1 : 0
name = "${var.name}-config-recorder"
role_arn = aws_iam_role.config[0].arn
recording_group {
all_supported = true
include_global_resource_types = var.config_include_global_resources
}
}
resource "aws_config_delivery_channel" "this" {
count = var.enable_config ? 1 : 0
name = "${var.name}-config-delivery"
s3_bucket_name = var.config_s3_bucket
s3_key_prefix = var.config_s3_prefix
sns_topic_arn = var.config_sns_topic_arn
snapshot_delivery_properties {
delivery_frequency = var.config_snapshot_frequency
}
depends_on = [aws_config_configuration_recorder.this]
}
resource "aws_config_configuration_recorder_status" "this" {
count = var.enable_config ? 1 : 0
name = aws_config_configuration_recorder.this[0].name
is_enabled = true
depends_on = [aws_config_delivery_channel.this]
}
resource "aws_iam_role" "config" {
count = var.enable_config ? 1 : 0
name = "${var.name}-config-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "config.amazonaws.com"
}
}]
})
tags = merge(var.tags, {
Name = "${var.name}-config-role"
})
}
resource "aws_iam_role_policy_attachment" "config" {
count = var.enable_config ? 1 : 0
role = aws_iam_role.config[0].name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWS_ConfigRole"
}
resource "aws_iam_role_policy" "config_s3" {
count = var.enable_config ? 1 : 0
name = "config-s3-access"
role = aws_iam_role.config[0].id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"s3:PutObject",
"s3:PutObjectAcl"
]
Resource = "arn:aws:s3:::${var.config_s3_bucket}/${var.config_s3_prefix}/*"
Condition = {
StringEquals = {
"s3:x-amz-acl" = "bucket-owner-full-control"
}
}
},
{
Effect = "Allow"
Action = "s3:GetBucketAcl"
Resource = "arn:aws:s3:::${var.config_s3_bucket}"
}
]
})
}
################################################################################
# Standard IAM Roles
################################################################################
resource "aws_iam_role" "admin" {
count = var.create_admin_role ? 1 : 0
name = "${var.name}-admin"
path = var.iam_role_path
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
AWS = var.trusted_admin_principals
}
Condition = var.require_mfa ? {
Bool = {
"aws:MultiFactorAuthPresent" = "true"
}
} : {}
}]
})
max_session_duration = var.admin_session_duration
tags = merge(var.tags, {
Name = "${var.name}-admin"
Role = "admin"
})
}
resource "aws_iam_role_policy_attachment" "admin" {
count = var.create_admin_role ? 1 : 0
role = aws_iam_role.admin[0].name
policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess"
}
resource "aws_iam_role" "readonly" {
count = var.create_readonly_role ? 1 : 0
name = "${var.name}-readonly"
path = var.iam_role_path
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
AWS = var.trusted_readonly_principals
}
}]
})
max_session_duration = var.readonly_session_duration
tags = merge(var.tags, {
Name = "${var.name}-readonly"
Role = "readonly"
})
}
resource "aws_iam_role_policy_attachment" "readonly" {
count = var.create_readonly_role ? 1 : 0
role = aws_iam_role.readonly[0].name
policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess"
}

View File

@@ -0,0 +1,56 @@
################################################################################
# Account Baseline - Outputs
################################################################################
output "ebs_encryption_enabled" {
value = var.enable_ebs_encryption
description = "Whether EBS encryption is enabled"
}
output "s3_block_public_enabled" {
value = var.enable_s3_block_public
description = "Whether S3 public block is enabled"
}
output "access_analyzer_arn" {
value = try(aws_accessanalyzer_analyzer.this[0].arn, null)
description = "Access Analyzer ARN"
}
output "securityhub_enabled" {
value = var.enable_securityhub
description = "Whether Security Hub is enabled"
}
output "guardduty_detector_id" {
value = try(aws_guardduty_detector.this[0].id, null)
description = "GuardDuty detector ID"
}
output "config_recorder_id" {
value = try(aws_config_configuration_recorder.this[0].id, null)
description = "Config recorder ID"
}
output "admin_role_arn" {
value = try(aws_iam_role.admin[0].arn, null)
description = "Admin IAM role ARN"
}
output "readonly_role_arn" {
value = try(aws_iam_role.readonly[0].arn, null)
description = "Readonly IAM role ARN"
}
output "baseline_status" {
value = {
ebs_encryption = var.enable_ebs_encryption
s3_block_public = var.enable_s3_block_public
password_policy = var.enable_password_policy
access_analyzer = var.enable_access_analyzer
securityhub = var.enable_securityhub
guardduty = var.enable_guardduty
config = var.enable_config
}
description = "Summary of baseline status"
}

View File

@@ -0,0 +1,206 @@
################################################################################
# Account Baseline - Input Variables
################################################################################
variable "name" {
type = string
description = "Name prefix for resources"
}
# EBS Encryption
variable "enable_ebs_encryption" {
type = bool
default = true
description = "Enable EBS encryption by default"
}
variable "ebs_kms_key_arn" {
type = string
default = null
description = "KMS key ARN for EBS encryption (null = AWS managed)"
}
# S3 Public Access
variable "enable_s3_block_public" {
type = bool
default = true
description = "Block public access to S3 at account level"
}
# Password Policy
variable "enable_password_policy" {
type = bool
default = true
description = "Configure IAM password policy"
}
variable "password_policy" {
type = object({
minimum_length = optional(number, 14)
require_lowercase = optional(bool, true)
require_uppercase = optional(bool, true)
require_numbers = optional(bool, true)
require_symbols = optional(bool, true)
allow_users_to_change = optional(bool, true)
max_age_days = optional(number, 90)
reuse_prevention_count = optional(number, 24)
hard_expiry = optional(bool, false)
})
default = {}
description = "IAM password policy settings"
}
# Access Analyzer
variable "enable_access_analyzer" {
type = bool
default = true
description = "Enable IAM Access Analyzer"
}
variable "access_analyzer_type" {
type = string
default = "ACCOUNT"
description = "Access Analyzer type (ACCOUNT or ORGANIZATION)"
}
# Security Hub
variable "enable_securityhub" {
type = bool
default = false
description = "Enable Security Hub (set false if using delegated admin)"
}
variable "securityhub_enable_default_standards" {
type = bool
default = false
description = "Enable default Security Hub standards"
}
variable "securityhub_auto_enable_controls" {
type = bool
default = true
description = "Auto-enable new controls"
}
variable "securityhub_standards" {
type = list(string)
default = []
description = "Security Hub standard ARNs to enable"
}
# GuardDuty
variable "enable_guardduty" {
type = bool
default = false
description = "Enable GuardDuty (set false if using delegated admin)"
}
variable "guardduty_finding_frequency" {
type = string
default = "FIFTEEN_MINUTES"
description = "GuardDuty finding publishing frequency"
}
variable "guardduty_kubernetes_audit" {
type = bool
default = true
description = "Enable GuardDuty Kubernetes audit logs"
}
variable "guardduty_malware_protection" {
type = bool
default = true
description = "Enable GuardDuty malware protection"
}
# AWS Config
variable "enable_config" {
type = bool
default = false
description = "Enable AWS Config (set false if using org aggregator)"
}
variable "config_s3_bucket" {
type = string
default = ""
description = "S3 bucket for Config recordings"
}
variable "config_s3_prefix" {
type = string
default = "config"
description = "S3 key prefix for Config recordings"
}
variable "config_sns_topic_arn" {
type = string
default = null
description = "SNS topic for Config notifications"
}
variable "config_snapshot_frequency" {
type = string
default = "TwentyFour_Hours"
description = "Config snapshot delivery frequency"
}
variable "config_include_global_resources" {
type = bool
default = true
description = "Include global resources in Config"
}
# IAM Roles
variable "create_admin_role" {
type = bool
default = false
description = "Create admin IAM role"
}
variable "create_readonly_role" {
type = bool
default = false
description = "Create readonly IAM role"
}
variable "iam_role_path" {
type = string
default = "/"
description = "IAM role path"
}
variable "trusted_admin_principals" {
type = list(string)
default = []
description = "ARNs allowed to assume admin role"
}
variable "trusted_readonly_principals" {
type = list(string)
default = []
description = "ARNs allowed to assume readonly role"
}
variable "require_mfa" {
type = bool
default = true
description = "Require MFA for admin role assumption"
}
variable "admin_session_duration" {
type = number
default = 3600
description = "Admin role session duration in seconds"
}
variable "readonly_session_duration" {
type = number
default = 3600
description = "Readonly role session duration in seconds"
}
variable "tags" {
type = map(string)
default = {}
description = "Tags to apply to resources"
}

View File

@@ -0,0 +1,49 @@
# acm-certificate
ACM Certificate Module
## Usage
```hcl
module "acm_certificate" {
source = "../modules/acm-certificate"
# Required variables
domain_name = ""
# Optional: see variables.tf for all options
}
```
## Requirements
| Name | Version |
|------|---------|
| terraform | >= 1.5.0 |
| aws | >= 5.0 |
## Inputs
| Name | Description | Type | Required |
|------|-------------|------|----------|
| domain_name | Primary domain name for the certificate | `string` | yes |
| subject_alternative_names | Additional domain names (SANs) for the certificate | `list(string)` | no |
| zone_id | Route53 zone ID for DNS validation (null for email validatio... | `string` | no |
| validation_method | Validation method: DNS or EMAIL | `string` | no |
| wait_for_validation | Wait for certificate validation to complete | `bool` | no |
| validation_timeout | Timeout for certificate validation | `string` | no |
| tags | | `map(string)` | no |
## Outputs
| Name | Description |
|------|-------------|
| certificate_arn | ARN of the certificate |
| certificate_domain_name | Primary domain name |
| certificate_status | Certificate status |
| validation_records | |
| validated_certificate_arn | ARN of the validated certificate |
## License
Apache 2.0 - See LICENSE for details.

View File

@@ -0,0 +1,163 @@
################################################################################
# ACM Certificate Module
#
# SSL/TLS certificates with:
# - DNS or email validation
# - Automatic Route53 validation records
# - SAN (Subject Alternative Names) support
# - Wildcard certificates
#
# Usage:
# module "cert" {
# source = "../modules/acm-certificate"
#
# domain_name = "example.com"
# zone_id = "Z1234567890"
#
# subject_alternative_names = [
# "*.example.com",
# "api.example.com"
# ]
# }
################################################################################
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
variable "domain_name" {
type = string
description = "Primary domain name for the certificate"
}
variable "subject_alternative_names" {
type = list(string)
default = []
description = "Additional domain names (SANs) for the certificate"
}
variable "zone_id" {
type = string
default = null
description = "Route53 zone ID for DNS validation (null for email validation)"
}
variable "validation_method" {
type = string
default = "DNS"
description = "Validation method: DNS or EMAIL"
validation {
condition = contains(["DNS", "EMAIL"], var.validation_method)
error_message = "Must be DNS or EMAIL"
}
}
variable "wait_for_validation" {
type = bool
default = true
description = "Wait for certificate validation to complete"
}
variable "validation_timeout" {
type = string
default = "45m"
description = "Timeout for certificate validation"
}
variable "tags" {
type = map(string)
default = {}
}
################################################################################
# ACM Certificate
################################################################################
resource "aws_acm_certificate" "main" {
domain_name = var.domain_name
subject_alternative_names = var.subject_alternative_names
validation_method = var.validation_method
lifecycle {
create_before_destroy = true
}
tags = merge(var.tags, { Name = var.domain_name })
}
################################################################################
# DNS Validation Records
################################################################################
resource "aws_route53_record" "validation" {
for_each = var.validation_method == "DNS" && var.zone_id != null ? {
for dvo in aws_acm_certificate.main.domain_validation_options : dvo.domain_name => {
name = dvo.resource_record_name
record = dvo.resource_record_value
type = dvo.resource_record_type
}
} : {}
allow_overwrite = true
name = each.value.name
records = [each.value.record]
ttl = 60
type = each.value.type
zone_id = var.zone_id
}
################################################################################
# Certificate Validation
################################################################################
resource "aws_acm_certificate_validation" "main" {
count = var.wait_for_validation ? 1 : 0
certificate_arn = aws_acm_certificate.main.arn
validation_record_fqdns = var.validation_method == "DNS" && var.zone_id != null ? [for record in aws_route53_record.validation : record.fqdn] : null
timeouts {
create = var.validation_timeout
}
}
################################################################################
# Outputs
################################################################################
output "certificate_arn" {
value = aws_acm_certificate.main.arn
description = "ARN of the certificate"
}
output "certificate_domain_name" {
value = aws_acm_certificate.main.domain_name
description = "Primary domain name"
}
output "certificate_status" {
value = aws_acm_certificate.main.status
description = "Certificate status"
}
output "validation_records" {
value = var.validation_method == "DNS" ? {
for dvo in aws_acm_certificate.main.domain_validation_options : dvo.domain_name => {
name = dvo.resource_record_name
type = dvo.resource_record_type
value = dvo.resource_record_value
}
} : null
description = "DNS validation records (if using DNS validation without auto Route53)"
}
output "validated_certificate_arn" {
value = var.wait_for_validation ? aws_acm_certificate_validation.main[0].certificate_arn : aws_acm_certificate.main.arn
description = "ARN of the validated certificate"
}

View File

@@ -0,0 +1,68 @@
# alb
Application Load Balancer Module
## Usage
```hcl
module "alb" {
source = "../modules/alb"
# Required variables
name = ""
vpc_id = ""
subnet_ids = ""
access_logs = ""
target_groups = ""
listener_rules = ""
# Optional: see variables.tf for all options
}
```
## Requirements
| Name | Version |
|------|---------|
| terraform | >= 1.5.0 |
| aws | >= 5.0 |
## Inputs
| Name | Description | Type | Required |
|------|-------------|------|----------|
| name | ALB name | `string` | yes |
| vpc_id | VPC ID | `string` | yes |
| subnet_ids | Subnet IDs (public for internet-facing, private for internal... | `list(string)` | yes |
| internal | Internal ALB (no public IP) | `bool` | no |
| certificate_arn | ACM certificate ARN for HTTPS | `string` | no |
| additional_certificates | Additional certificate ARNs for SNI | `list(string)` | no |
| ssl_policy | SSL policy for HTTPS listeners | `string` | no |
| enable_deletion_protection | Prevent accidental deletion | `bool` | no |
| enable_http2 | Enable HTTP/2 | `bool` | no |
| idle_timeout | Idle timeout in seconds | `number` | no |
| drop_invalid_header_fields | Drop requests with invalid headers | `bool` | no |
| access_logs | | `object({` | yes |
| target_groups | | `map(object({` | yes |
| listener_rules | | `map(object({` | yes |
| waf_arn | WAF Web ACL ARN to associate | `string` | no |
*...and 3 more variables. See `variables.tf` for complete list.*
## Outputs
| Name | Description |
|------|-------------|
| arn | ALB ARN |
| arn_suffix | ALB ARN suffix (for CloudWatch metrics) |
| dns_name | ALB DNS name |
| zone_id | ALB hosted zone ID |
| security_group_id | ALB security group ID |
| target_group_arns | |
| target_group_arn_suffixes | |
| https_listener_arn | HTTPS listener ARN |
| http_listener_arn | HTTP listener ARN |
## License
Apache 2.0 - See LICENSE for details.

View File

@@ -0,0 +1,497 @@
################################################################################
# Application Load Balancer Module
#
# Full-featured ALB with:
# - HTTPS with ACM certificate
# - HTTP to HTTPS redirect
# - Access logging to S3
# - WAF integration (optional)
# - Multiple target groups
# - Host/path-based routing
# - Health checks
#
# Usage:
# module "alb" {
# source = "../modules/alb"
#
# name = "web-alb"
# vpc_id = module.vpc.vpc_id
# subnet_ids = module.vpc.public_subnet_ids
#
# certificate_arn = module.acm.certificate_arn
#
# target_groups = {
# api = {
# port = 8080
# protocol = "HTTP"
# target_type = "ip"
# health_check_path = "/health"
# }
# }
# }
################################################################################
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
variable "name" {
type = string
description = "ALB name"
}
variable "vpc_id" {
type = string
description = "VPC ID"
}
variable "subnet_ids" {
type = list(string)
description = "Subnet IDs (public for internet-facing, private for internal)"
}
variable "internal" {
type = bool
default = false
description = "Internal ALB (no public IP)"
}
variable "certificate_arn" {
type = string
default = ""
description = "ACM certificate ARN for HTTPS"
}
variable "additional_certificates" {
type = list(string)
default = []
description = "Additional certificate ARNs for SNI"
}
variable "ssl_policy" {
type = string
default = "ELBSecurityPolicy-TLS13-1-2-2021-06"
description = "SSL policy for HTTPS listeners"
}
variable "enable_deletion_protection" {
type = bool
default = true
description = "Prevent accidental deletion"
}
variable "enable_http2" {
type = bool
default = true
description = "Enable HTTP/2"
}
variable "idle_timeout" {
type = number
default = 60
description = "Idle timeout in seconds"
}
variable "drop_invalid_header_fields" {
type = bool
default = true
description = "Drop requests with invalid headers"
}
variable "access_logs" {
type = object({
enabled = bool
bucket = string
prefix = optional(string, "")
})
default = {
enabled = false
bucket = ""
}
description = "Access logging configuration"
}
variable "target_groups" {
type = map(object({
port = number
protocol = optional(string, "HTTP")
target_type = optional(string, "ip")
deregistration_delay = optional(number, 30)
slow_start = optional(number, 0)
health_check_path = optional(string, "/")
health_check_port = optional(string, "traffic-port")
health_check_protocol = optional(string, "HTTP")
health_check_interval = optional(number, 30)
health_check_timeout = optional(number, 5)
healthy_threshold = optional(number, 2)
unhealthy_threshold = optional(number, 3)
health_check_matcher = optional(string, "200-299")
stickiness_enabled = optional(bool, false)
stickiness_duration = optional(number, 86400)
}))
default = {}
description = "Target group configurations"
}
variable "listener_rules" {
type = map(object({
priority = number
target_group_key = string
# Conditions (at least one required)
host_headers = optional(list(string), [])
path_patterns = optional(list(string), [])
http_headers = optional(map(list(string)), {})
query_strings = optional(map(string), {})
source_ips = optional(list(string), [])
}))
default = {}
description = "HTTPS listener rules for routing"
}
variable "waf_arn" {
type = string
default = ""
description = "WAF Web ACL ARN to associate"
}
variable "security_group_ids" {
type = list(string)
default = []
description = "Additional security group IDs"
}
variable "ingress_cidr_blocks" {
type = list(string)
default = ["0.0.0.0/0"]
description = "CIDR blocks for ingress (HTTP/HTTPS)"
}
variable "tags" {
type = map(string)
default = {}
}
################################################################################
# Security Group
################################################################################
resource "aws_security_group" "alb" {
name = "${var.name}-alb"
description = "ALB security group"
vpc_id = var.vpc_id
ingress {
description = "HTTPS"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = var.ingress_cidr_blocks
}
ingress {
description = "HTTP (redirect)"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = var.ingress_cidr_blocks
}
egress {
description = "All outbound"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = merge(var.tags, { Name = "${var.name}-alb" })
}
################################################################################
# Application Load Balancer
################################################################################
resource "aws_lb" "main" {
name = var.name
internal = var.internal
load_balancer_type = "application"
security_groups = concat([aws_security_group.alb.id], var.security_group_ids)
subnets = var.subnet_ids
enable_deletion_protection = var.enable_deletion_protection
enable_http2 = var.enable_http2
idle_timeout = var.idle_timeout
drop_invalid_header_fields = var.drop_invalid_header_fields
dynamic "access_logs" {
for_each = var.access_logs.enabled ? [1] : []
content {
bucket = var.access_logs.bucket
prefix = var.access_logs.prefix
enabled = true
}
}
tags = merge(var.tags, { Name = var.name })
}
################################################################################
# Target Groups
################################################################################
resource "aws_lb_target_group" "main" {
for_each = var.target_groups
name = "${var.name}-${each.key}"
port = each.value.port
protocol = each.value.protocol
vpc_id = var.vpc_id
target_type = each.value.target_type
deregistration_delay = each.value.deregistration_delay
slow_start = each.value.slow_start
health_check {
enabled = true
path = each.value.health_check_path
port = each.value.health_check_port
protocol = each.value.health_check_protocol
interval = each.value.health_check_interval
timeout = each.value.health_check_timeout
healthy_threshold = each.value.healthy_threshold
unhealthy_threshold = each.value.unhealthy_threshold
matcher = each.value.health_check_matcher
}
dynamic "stickiness" {
for_each = each.value.stickiness_enabled ? [1] : []
content {
type = "lb_cookie"
cookie_duration = each.value.stickiness_duration
enabled = true
}
}
tags = merge(var.tags, { Name = "${var.name}-${each.key}" })
lifecycle {
create_before_destroy = true
}
}
################################################################################
# HTTPS Listener
################################################################################
resource "aws_lb_listener" "https" {
count = var.certificate_arn != "" ? 1 : 0
load_balancer_arn = aws_lb.main.arn
port = 443
protocol = "HTTPS"
ssl_policy = var.ssl_policy
certificate_arn = var.certificate_arn
default_action {
type = length(var.target_groups) > 0 ? "forward" : "fixed-response"
dynamic "forward" {
for_each = length(var.target_groups) > 0 ? [1] : []
content {
target_group {
arn = aws_lb_target_group.main[keys(var.target_groups)[0]].arn
}
}
}
dynamic "fixed_response" {
for_each = length(var.target_groups) == 0 ? [1] : []
content {
content_type = "text/plain"
message_body = "No backend configured"
status_code = "503"
}
}
}
tags = merge(var.tags, { Name = "${var.name}-https" })
}
# Additional certificates (SNI)
resource "aws_lb_listener_certificate" "additional" {
for_each = toset(var.additional_certificates)
listener_arn = aws_lb_listener.https[0].arn
certificate_arn = each.value
}
################################################################################
# HTTP Listener (Redirect to HTTPS)
################################################################################
resource "aws_lb_listener" "http" {
load_balancer_arn = aws_lb.main.arn
port = 80
protocol = "HTTP"
default_action {
type = var.certificate_arn != "" ? "redirect" : "forward"
dynamic "redirect" {
for_each = var.certificate_arn != "" ? [1] : []
content {
port = "443"
protocol = "HTTPS"
status_code = "HTTP_301"
}
}
dynamic "forward" {
for_each = var.certificate_arn == "" && length(var.target_groups) > 0 ? [1] : []
content {
target_group {
arn = aws_lb_target_group.main[keys(var.target_groups)[0]].arn
}
}
}
}
tags = merge(var.tags, { Name = "${var.name}-http" })
}
################################################################################
# Listener Rules
################################################################################
resource "aws_lb_listener_rule" "main" {
for_each = var.certificate_arn != "" ? var.listener_rules : {}
listener_arn = aws_lb_listener.https[0].arn
priority = each.value.priority
action {
type = "forward"
target_group_arn = aws_lb_target_group.main[each.value.target_group_key].arn
}
# Host header condition
dynamic "condition" {
for_each = length(each.value.host_headers) > 0 ? [1] : []
content {
host_header {
values = each.value.host_headers
}
}
}
# Path pattern condition
dynamic "condition" {
for_each = length(each.value.path_patterns) > 0 ? [1] : []
content {
path_pattern {
values = each.value.path_patterns
}
}
}
# HTTP header conditions
dynamic "condition" {
for_each = each.value.http_headers
content {
http_header {
http_header_name = condition.key
values = condition.value
}
}
}
# Query string conditions
dynamic "condition" {
for_each = each.value.query_strings
content {
query_string {
key = condition.key
value = condition.value
}
}
}
# Source IP condition
dynamic "condition" {
for_each = length(each.value.source_ips) > 0 ? [1] : []
content {
source_ip {
values = each.value.source_ips
}
}
}
tags = merge(var.tags, { Name = "${var.name}-${each.key}" })
}
################################################################################
# WAF Association
################################################################################
resource "aws_wafv2_web_acl_association" "main" {
count = var.waf_arn != "" ? 1 : 0
resource_arn = aws_lb.main.arn
web_acl_arn = var.waf_arn
}
################################################################################
# Outputs
################################################################################
output "arn" {
value = aws_lb.main.arn
description = "ALB ARN"
}
output "arn_suffix" {
value = aws_lb.main.arn_suffix
description = "ALB ARN suffix (for CloudWatch metrics)"
}
output "dns_name" {
value = aws_lb.main.dns_name
description = "ALB DNS name"
}
output "zone_id" {
value = aws_lb.main.zone_id
description = "ALB hosted zone ID"
}
output "security_group_id" {
value = aws_security_group.alb.id
description = "ALB security group ID"
}
output "target_group_arns" {
value = { for k, v in aws_lb_target_group.main : k => v.arn }
description = "Target group ARNs"
}
output "target_group_arn_suffixes" {
value = { for k, v in aws_lb_target_group.main : k => v.arn_suffix }
description = "Target group ARN suffixes"
}
output "https_listener_arn" {
value = length(aws_lb_listener.https) > 0 ? aws_lb_listener.https[0].arn : null
description = "HTTPS listener ARN"
}
output "http_listener_arn" {
value = aws_lb_listener.http.arn
description = "HTTP listener ARN"
}

View File

@@ -0,0 +1,50 @@
# alerting
Alerting Module
## Usage
```hcl
module "alerting" {
source = "../modules/alerting"
# Required variables
name = ""
# Optional: see variables.tf for all options
}
```
## Requirements
| Name | Version |
|------|---------|
| terraform | >= 1.5.0 |
| aws | >= 5.0 |
## Inputs
| Name | Description | Type | Required |
|------|-------------|------|----------|
| name | Name prefix for alerting resources | `string` | yes |
| email_endpoints | Email addresses to receive alerts | `list(string)` | no |
| email_endpoints_critical | Email addresses for critical alerts only (uses email_endpoin... | `list(string)` | no |
| slack_webhook_url | Slack webhook URL for notifications | `string` | no |
| pagerduty_endpoint | PagerDuty Events API endpoint | `string` | no |
| enable_aws_health_events | | `bool` | no |
| enable_guardduty_events | | `bool` | no |
| enable_securityhub_events | | `bool` | no |
| tags | | `map(string)` | no |
## Outputs
| Name | Description |
|------|-------------|
| critical_topic_arn | SNS topic for critical alerts |
| warning_topic_arn | SNS topic for warning alerts |
| info_topic_arn | SNS topic for info alerts |
| topics | |
## License
Apache 2.0 - See LICENSE for details.

View File

@@ -0,0 +1,429 @@
################################################################################
# Alerting Module
#
# Centralized alerting infrastructure:
# - SNS topics by severity (critical, warning, info)
# - Subscriptions (email, Slack, PagerDuty)
# - CloudWatch composite alarms
# - EventBridge rules for AWS events
#
# Usage:
# module "alerting" {
# source = "../modules/alerting"
# name = "myproject-prod"
#
# email_endpoints = ["ops@example.com"]
# slack_webhook_url = "https://hooks.slack.com/..."
# }
################################################################################
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
variable "name" {
type = string
description = "Name prefix for alerting resources"
}
variable "email_endpoints" {
type = list(string)
default = []
description = "Email addresses to receive alerts"
}
variable "email_endpoints_critical" {
type = list(string)
default = []
description = "Email addresses for critical alerts only (uses email_endpoints if empty)"
}
variable "slack_webhook_url" {
type = string
default = ""
description = "Slack webhook URL for notifications"
sensitive = true
}
variable "pagerduty_endpoint" {
type = string
default = ""
description = "PagerDuty Events API endpoint"
sensitive = true
}
variable "enable_aws_health_events" {
type = bool
default = true
}
variable "enable_guardduty_events" {
type = bool
default = true
}
variable "enable_securityhub_events" {
type = bool
default = true
}
variable "tags" {
type = map(string)
default = {}
}
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
################################################################################
# SNS Topics by Severity
################################################################################
resource "aws_sns_topic" "critical" {
name = "${var.name}-alerts-critical"
kms_master_key_id = "alias/aws/sns"
tags = merge(var.tags, { Name = "${var.name}-critical", Severity = "critical" })
}
resource "aws_sns_topic" "warning" {
name = "${var.name}-alerts-warning"
kms_master_key_id = "alias/aws/sns"
tags = merge(var.tags, { Name = "${var.name}-warning", Severity = "warning" })
}
resource "aws_sns_topic" "info" {
name = "${var.name}-alerts-info"
kms_master_key_id = "alias/aws/sns"
tags = merge(var.tags, { Name = "${var.name}-info", Severity = "info" })
}
################################################################################
# SNS Topic Policies
################################################################################
data "aws_iam_policy_document" "sns_policy" {
statement {
sid = "AllowCloudWatchAlarms"
effect = "Allow"
principals {
type = "Service"
identifiers = ["cloudwatch.amazonaws.com"]
}
actions = ["sns:Publish"]
resources = ["*"]
condition {
test = "ArnLike"
variable = "aws:SourceArn"
values = ["arn:aws:cloudwatch:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:alarm:*"]
}
}
statement {
sid = "AllowEventBridge"
effect = "Allow"
principals {
type = "Service"
identifiers = ["events.amazonaws.com"]
}
actions = ["sns:Publish"]
resources = ["*"]
}
}
resource "aws_sns_topic_policy" "critical" {
arn = aws_sns_topic.critical.arn
policy = data.aws_iam_policy_document.sns_policy.json
}
resource "aws_sns_topic_policy" "warning" {
arn = aws_sns_topic.warning.arn
policy = data.aws_iam_policy_document.sns_policy.json
}
resource "aws_sns_topic_policy" "info" {
arn = aws_sns_topic.info.arn
policy = data.aws_iam_policy_document.sns_policy.json
}
################################################################################
# Email Subscriptions
################################################################################
resource "aws_sns_topic_subscription" "critical_email" {
for_each = toset(length(var.email_endpoints_critical) > 0 ? var.email_endpoints_critical : var.email_endpoints)
topic_arn = aws_sns_topic.critical.arn
protocol = "email"
endpoint = each.value
}
resource "aws_sns_topic_subscription" "warning_email" {
for_each = toset(var.email_endpoints)
topic_arn = aws_sns_topic.warning.arn
protocol = "email"
endpoint = each.value
}
################################################################################
# Slack Integration (via Lambda)
################################################################################
data "archive_file" "slack_notifier" {
count = var.slack_webhook_url != "" ? 1 : 0
type = "zip"
output_path = "${path.module}/slack_notifier.zip"
source {
content = <<-PYTHON
import json
import urllib.request
import os
def handler(event, context):
webhook_url = os.environ['SLACK_WEBHOOK_URL']
for record in event.get('Records', []):
message = json.loads(record['Sns']['Message'])
# Parse CloudWatch Alarm
if 'AlarmName' in message:
color = '#FF0000' if message['NewStateValue'] == 'ALARM' else '#36a64f'
text = f"*{message['AlarmName']}*\n{message['AlarmDescription']}\n\nState: {message['NewStateValue']}\nReason: {message['NewStateReason']}"
else:
text = json.dumps(message, indent=2)
color = '#FFA500'
payload = {
'attachments': [{
'color': color,
'text': text,
'footer': f"AWS | {message.get('Region', 'Unknown Region')}",
}]
}
req = urllib.request.Request(
webhook_url,
data=json.dumps(payload).encode('utf-8'),
headers={'Content-Type': 'application/json'}
)
urllib.request.urlopen(req)
return {'statusCode': 200}
PYTHON
filename = "lambda_function.py"
}
}
resource "aws_lambda_function" "slack_notifier" {
count = var.slack_webhook_url != "" ? 1 : 0
filename = data.archive_file.slack_notifier[0].output_path
source_code_hash = data.archive_file.slack_notifier[0].output_base64sha256
function_name = "${var.name}-slack-notifier"
role = aws_iam_role.slack_notifier[0].arn
handler = "lambda_function.handler"
runtime = "python3.12"
timeout = 30
environment {
variables = {
SLACK_WEBHOOK_URL = var.slack_webhook_url
}
}
tags = merge(var.tags, { Name = "${var.name}-slack-notifier" })
}
resource "aws_iam_role" "slack_notifier" {
count = var.slack_webhook_url != "" ? 1 : 0
name = "${var.name}-slack-notifier"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "lambda.amazonaws.com" }
}]
})
tags = merge(var.tags, { Name = "${var.name}-slack-notifier" })
}
resource "aws_iam_role_policy_attachment" "slack_notifier" {
count = var.slack_webhook_url != "" ? 1 : 0
role = aws_iam_role.slack_notifier[0].name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
resource "aws_lambda_permission" "slack_critical" {
count = var.slack_webhook_url != "" ? 1 : 0
statement_id = "AllowSNSCritical"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.slack_notifier[0].function_name
principal = "sns.amazonaws.com"
source_arn = aws_sns_topic.critical.arn
}
resource "aws_lambda_permission" "slack_warning" {
count = var.slack_webhook_url != "" ? 1 : 0
statement_id = "AllowSNSWarning"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.slack_notifier[0].function_name
principal = "sns.amazonaws.com"
source_arn = aws_sns_topic.warning.arn
}
resource "aws_sns_topic_subscription" "slack_critical" {
count = var.slack_webhook_url != "" ? 1 : 0
topic_arn = aws_sns_topic.critical.arn
protocol = "lambda"
endpoint = aws_lambda_function.slack_notifier[0].arn
}
resource "aws_sns_topic_subscription" "slack_warning" {
count = var.slack_webhook_url != "" ? 1 : 0
topic_arn = aws_sns_topic.warning.arn
protocol = "lambda"
endpoint = aws_lambda_function.slack_notifier[0].arn
}
################################################################################
# EventBridge Rules - AWS Health Events
################################################################################
resource "aws_cloudwatch_event_rule" "health" {
count = var.enable_aws_health_events ? 1 : 0
name = "${var.name}-health-events"
description = "Capture AWS Health events"
event_pattern = jsonencode({
source = ["aws.health"]
detail-type = ["AWS Health Event"]
})
tags = merge(var.tags, { Name = "${var.name}-health" })
}
resource "aws_cloudwatch_event_target" "health" {
count = var.enable_aws_health_events ? 1 : 0
rule = aws_cloudwatch_event_rule.health[0].name
target_id = "SendToSNS"
arn = aws_sns_topic.warning.arn
}
################################################################################
# EventBridge Rules - GuardDuty Findings
################################################################################
resource "aws_cloudwatch_event_rule" "guardduty" {
count = var.enable_guardduty_events ? 1 : 0
name = "${var.name}-guardduty-findings"
description = "Capture GuardDuty findings"
event_pattern = jsonencode({
source = ["aws.guardduty"]
detail-type = ["GuardDuty Finding"]
detail = {
severity = [{ numeric = [">=", 4] }] # Medium and above
}
})
tags = merge(var.tags, { Name = "${var.name}-guardduty" })
}
resource "aws_cloudwatch_event_target" "guardduty_critical" {
count = var.enable_guardduty_events ? 1 : 0
rule = aws_cloudwatch_event_rule.guardduty[0].name
target_id = "SendToSNSCritical"
arn = aws_sns_topic.critical.arn
input_transformer {
input_paths = {
severity = "$.detail.severity"
title = "$.detail.title"
type = "$.detail.type"
region = "$.region"
}
input_template = <<-EOF
{
"AlarmName": "GuardDuty Finding",
"AlarmDescription": "<title>",
"NewStateValue": "ALARM",
"NewStateReason": "Type: <type>, Severity: <severity>",
"Region": "<region>"
}
EOF
}
}
################################################################################
# EventBridge Rules - Security Hub
################################################################################
resource "aws_cloudwatch_event_rule" "securityhub" {
count = var.enable_securityhub_events ? 1 : 0
name = "${var.name}-securityhub-findings"
description = "Capture Security Hub findings"
event_pattern = jsonencode({
source = ["aws.securityhub"]
detail-type = ["Security Hub Findings - Imported"]
detail = {
findings = {
Severity = {
Label = ["CRITICAL", "HIGH"]
}
}
}
})
tags = merge(var.tags, { Name = "${var.name}-securityhub" })
}
resource "aws_cloudwatch_event_target" "securityhub" {
count = var.enable_securityhub_events ? 1 : 0
rule = aws_cloudwatch_event_rule.securityhub[0].name
target_id = "SendToSNSCritical"
arn = aws_sns_topic.critical.arn
}
################################################################################
# Outputs
################################################################################
output "critical_topic_arn" {
value = aws_sns_topic.critical.arn
description = "SNS topic for critical alerts"
}
output "warning_topic_arn" {
value = aws_sns_topic.warning.arn
description = "SNS topic for warning alerts"
}
output "info_topic_arn" {
value = aws_sns_topic.info.arn
description = "SNS topic for info alerts"
}
output "topics" {
value = {
critical = aws_sns_topic.critical.arn
warning = aws_sns_topic.warning.arn
info = aws_sns_topic.info.arn
}
}

View File

@@ -0,0 +1,36 @@
# app-account
Terraform module for AWS landing zone pattern.
Provision new application/workload AWS accounts with account vending pattern.
## Planned Features
- [ ] Create account via AWS Organizations
- [ ] Place in appropriate OU
- [ ] Apply account baseline module
- [ ] Configure VPC (shared or dedicated)
- [ ] Create cross-account IAM roles
- [ ] Set up budget alerts
- [ ] Apply standard tags
## Planned Usage
```hcl
module "app_account" {
source = "../modules/app-account"
account_name = "myapp-prod"
account_email = "aws+myapp-prod@company.com"
environment = "prod"
owner = "platform-team"
vpc_config = {
mode = "shared" # Use shared VPC from network account
}
budget_limit = 500
tags = local.tags
}
```

View File

@@ -0,0 +1,222 @@
################################################################################
# App Account Module
#
# Account vending machine for provisioning new workload accounts:
# - Creates AWS account via Organizations
# - Applies account baseline
# - Sets up cross-account IAM roles
# - Configures budget alerts
################################################################################
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
data "aws_organizations_organization" "this" {}
locals {
# Generate account email if not provided
account_email = var.account_email != "" ? var.account_email : "${var.email_prefix}+${var.account_name}@${var.email_domain}"
# Standard account tags
account_tags = {
AccountName = var.account_name
Environment = var.environment
Owner = var.owner
CostCenter = var.cost_center
OrganizationUnit = var.organizational_unit
ManagedBy = "terraform"
}
}
################################################################################
# AWS Account
################################################################################
resource "aws_organizations_account" "this" {
name = var.account_name
email = local.account_email
parent_id = var.organizational_unit_id
# IAM user access to billing (usually disabled)
iam_user_access_to_billing = var.iam_user_access_to_billing ? "ALLOW" : "DENY"
# Role name for cross-account access from management account
role_name = var.admin_role_name
# Don't close account on destroy (safety)
close_on_deletion = var.close_on_deletion
tags = merge(var.tags, local.account_tags)
lifecycle {
# Prevent accidental deletion
prevent_destroy = false # Set to true in production
# Email cannot be changed
ignore_changes = [email, role_name]
}
}
################################################################################
# Cross-Account IAM Role (in new account)
# Note: This creates a role that can be assumed from the management account
################################################################################
# Provider for the new account (assumes role created during account creation)
provider "aws" {
alias = "new_account"
region = var.region
assume_role {
role_arn = "arn:aws:iam::${aws_organizations_account.this.id}:role/${var.admin_role_name}"
session_name = "terraform-account-setup"
}
}
# Readonly role for cross-account access
resource "aws_iam_role" "cross_account_readonly" {
provider = aws.new_account
count = var.create_cross_account_roles ? 1 : 0
name = "cross-account-readonly"
path = "/cross-account/"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
AWS = var.readonly_trusted_principals
}
}]
})
tags = merge(var.tags, {
Name = "cross-account-readonly"
})
depends_on = [aws_organizations_account.this]
}
resource "aws_iam_role_policy_attachment" "cross_account_readonly" {
provider = aws.new_account
count = var.create_cross_account_roles ? 1 : 0
role = aws_iam_role.cross_account_readonly[0].name
policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess"
}
# Admin role for cross-account access (requires MFA)
resource "aws_iam_role" "cross_account_admin" {
provider = aws.new_account
count = var.create_cross_account_roles ? 1 : 0
name = "cross-account-admin"
path = "/cross-account/"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
AWS = var.admin_trusted_principals
}
Condition = {
Bool = {
"aws:MultiFactorAuthPresent" = "true"
}
}
}]
})
max_session_duration = 3600
tags = merge(var.tags, {
Name = "cross-account-admin"
})
depends_on = [aws_organizations_account.this]
}
resource "aws_iam_role_policy_attachment" "cross_account_admin" {
provider = aws.new_account
count = var.create_cross_account_roles ? 1 : 0
role = aws_iam_role.cross_account_admin[0].name
policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess"
}
################################################################################
# Account Baseline (in new account)
################################################################################
module "account_baseline" {
source = "../account-baseline"
count = var.apply_baseline ? 1 : 0
providers = {
aws = aws.new_account
}
name = var.account_name
enable_ebs_encryption = true
enable_s3_block_public = true
enable_password_policy = true
enable_access_analyzer = true
# Security services typically managed by delegated admin
enable_securityhub = false
enable_guardduty = false
enable_config = false
tags = merge(var.tags, local.account_tags)
depends_on = [aws_organizations_account.this]
}
################################################################################
# Budget (in new account)
################################################################################
resource "aws_budgets_budget" "this" {
provider = aws.new_account
count = var.budget_limit > 0 ? 1 : 0
name = "${var.account_name}-monthly-budget"
budget_type = "COST"
limit_amount = tostring(var.budget_limit)
limit_unit = "USD"
time_unit = "MONTHLY"
notification {
comparison_operator = "GREATER_THAN"
threshold = 80
threshold_type = "PERCENTAGE"
notification_type = "ACTUAL"
subscriber_email_addresses = [var.owner_email != "" ? var.owner_email : local.account_email]
}
notification {
comparison_operator = "GREATER_THAN"
threshold = 100
threshold_type = "PERCENTAGE"
notification_type = "ACTUAL"
subscriber_email_addresses = [var.owner_email != "" ? var.owner_email : local.account_email]
}
tags = merge(var.tags, {
Name = "${var.account_name}-monthly-budget"
})
depends_on = [aws_organizations_account.this]
}

View File

@@ -0,0 +1,49 @@
################################################################################
# App Account - Outputs
################################################################################
output "account_id" {
value = aws_organizations_account.this.id
description = "AWS account ID"
}
output "account_arn" {
value = aws_organizations_account.this.arn
description = "AWS account ARN"
}
output "account_name" {
value = aws_organizations_account.this.name
description = "Account name"
}
output "account_email" {
value = aws_organizations_account.this.email
description = "Account root email"
sensitive = true
}
output "admin_role_arn" {
value = "arn:aws:iam::${aws_organizations_account.this.id}:role/${var.admin_role_name}"
description = "Admin role ARN for cross-account access"
}
output "cross_account_readonly_role_arn" {
value = var.create_cross_account_roles ? aws_iam_role.cross_account_readonly[0].arn : null
description = "Cross-account readonly role ARN"
}
output "cross_account_admin_role_arn" {
value = var.create_cross_account_roles ? aws_iam_role.cross_account_admin[0].arn : null
description = "Cross-account admin role ARN"
}
output "budget_id" {
value = var.budget_limit > 0 ? aws_budgets_budget.this[0].id : null
description = "Budget ID"
}
output "account_tags" {
value = local.account_tags
description = "Account tags"
}

View File

@@ -0,0 +1,131 @@
################################################################################
# App Account - Input Variables
################################################################################
# Account Identity
variable "account_name" {
type = string
description = "Name for the new account"
}
variable "account_email" {
type = string
default = ""
description = "Root email for the account (auto-generated if empty)"
}
variable "email_prefix" {
type = string
default = "aws"
description = "Email prefix for auto-generated email"
}
variable "email_domain" {
type = string
default = "example.com"
description = "Email domain for auto-generated email"
}
# Organization Placement
variable "organizational_unit" {
type = string
default = "Workloads"
description = "OU name (for tagging)"
}
variable "organizational_unit_id" {
type = string
description = "OU ID to place the account in"
}
# Account Metadata
variable "environment" {
type = string
description = "Environment type (dev, staging, prod)"
validation {
condition = contains(["dev", "staging", "prod", "sandbox"], var.environment)
error_message = "Must be dev, staging, prod, or sandbox"
}
}
variable "cost_center" {
type = string
default = ""
description = "Cost center for billing"
}
variable "owner" {
type = string
description = "Team/person responsible for this account"
}
variable "owner_email" {
type = string
default = ""
description = "Owner email for notifications"
}
variable "region" {
type = string
default = "us-east-1"
description = "Primary region for the account"
}
# IAM Configuration
variable "admin_role_name" {
type = string
default = "OrganizationAccountAccessRole"
description = "Name of admin role created in new account"
}
variable "iam_user_access_to_billing" {
type = bool
default = false
description = "Allow IAM users to access billing"
}
variable "create_cross_account_roles" {
type = bool
default = true
description = "Create cross-account IAM roles"
}
variable "admin_trusted_principals" {
type = list(string)
default = []
description = "ARNs allowed to assume admin role"
}
variable "readonly_trusted_principals" {
type = list(string)
default = []
description = "ARNs allowed to assume readonly role"
}
# Baseline Configuration
variable "apply_baseline" {
type = bool
default = true
description = "Apply account baseline configuration"
}
# Budget
variable "budget_limit" {
type = number
default = 100
description = "Monthly budget limit in USD (0 = no budget)"
}
# Safety
variable "close_on_deletion" {
type = bool
default = false
description = "Close account when Terraform resource is deleted"
}
variable "tags" {
type = map(string)
default = {}
description = "Additional tags"
}

View File

@@ -0,0 +1,54 @@
# backup-plan
AWS Backup Module
## Usage
```hcl
module "backup_plan" {
source = "../modules/backup-plan"
# Required variables
name = ""
# Optional: see variables.tf for all options
}
```
## Requirements
| Name | Version |
|------|---------|
| terraform | >= 1.5.0 |
| aws | >= 5.0 |
## Inputs
| Name | Description | Type | Required |
|------|-------------|------|----------|
| name | Backup plan name | `string` | yes |
| tenant | Tenant name for resource selection | `string` | no |
| backup_tag_key | Tag key to select resources for backup | `string` | no |
| backup_tag_value | Tag value to select resources for backup | `string` | no |
| daily_retention_days | | `number` | no |
| weekly_retention_days | | `number` | no |
| monthly_retention_days | | `number` | no |
| enable_continuous_backup | Enable continuous backup for point-in-time recovery (RDS, S3... | `bool` | no |
| enable_cross_region_copy | | `bool` | no |
| dr_region | DR region for cross-region backup copy | `string` | no |
| dr_retention_days | | `number` | no |
| kms_key_arn | KMS key ARN for backup encryption (uses AWS managed key if n... | `string` | no |
## Outputs
| Name | Description |
|------|-------------|
| vault_arn | |
| vault_name | |
| plan_id | |
| plan_arn | |
| role_arn | |
## License
Apache 2.0 - See LICENSE for details.

View File

@@ -0,0 +1,303 @@
################################################################################
# AWS Backup Module
#
# Centralized backup management:
# - Daily backups with configurable retention
# - Cross-region copy for DR (optional)
# - Tag-based resource selection
#
# Compliance: Meets HIPAA, SOC 2 backup requirements
#
# Note: Cross-region DR requires passing a provider alias for the DR region:
#
# provider "aws" {
# alias = "dr"
# region = "us-west-2"
# }
#
# module "backup" {
# source = "../modules/backup-plan"
# providers = {
# aws = aws
# aws.dr = aws.dr
# }
# enable_cross_region_copy = true
# dr_region = "us-west-2"
# ...
# }
################################################################################
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
configuration_aliases = [aws.dr]
}
}
}
variable "name" {
type = string
description = "Backup plan name"
}
variable "tenant" {
type = string
description = "Tenant name for resource selection"
default = null
}
variable "backup_tag_key" {
type = string
default = "Backup"
description = "Tag key to select resources for backup"
}
variable "backup_tag_value" {
type = string
default = "true"
description = "Tag value to select resources for backup"
}
# Retention settings
variable "daily_retention_days" {
type = number
default = 35 # 5 weeks
}
variable "weekly_retention_days" {
type = number
default = 90 # ~3 months
}
variable "monthly_retention_days" {
type = number
default = 365 # 1 year
}
variable "enable_continuous_backup" {
type = bool
default = false
description = "Enable continuous backup for point-in-time recovery (RDS, S3)"
}
# Cross-region DR
variable "enable_cross_region_copy" {
type = bool
default = false
}
variable "dr_region" {
type = string
default = "us-west-2"
description = "DR region for cross-region backup copy"
}
variable "dr_retention_days" {
type = number
default = 30
}
# KMS
variable "kms_key_arn" {
type = string
default = null
description = "KMS key ARN for backup encryption (uses AWS managed key if null)"
}
################################################################################
# Backup Vault
################################################################################
resource "aws_backup_vault" "main" {
name = var.name
kms_key_arn = var.kms_key_arn
tags = { Name = var.name }
}
# Vault lock for compliance (prevents deletion)
resource "aws_backup_vault_lock_configuration" "main" {
backup_vault_name = aws_backup_vault.main.name
min_retention_days = 7
max_retention_days = 365
changeable_for_days = 3 # Grace period before lock becomes immutable
}
################################################################################
# DR Vault (Cross-Region)
################################################################################
resource "aws_backup_vault" "dr" {
count = var.enable_cross_region_copy ? 1 : 0
provider = aws.dr
name = "${var.name}-dr"
kms_key_arn = var.kms_key_arn
tags = { Name = "${var.name}-dr" }
}
################################################################################
# IAM Role
################################################################################
resource "aws_iam_role" "backup" {
name = "${var.name}-backup"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "backup.amazonaws.com" }
}]
})
tags = { Name = "${var.name}-backup" }
}
resource "aws_iam_role_policy_attachment" "backup" {
role = aws_iam_role.backup.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSBackupServiceRolePolicyForBackup"
}
resource "aws_iam_role_policy_attachment" "restore" {
role = aws_iam_role.backup.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSBackupServiceRolePolicyForRestores"
}
resource "aws_iam_role_policy_attachment" "s3_backup" {
role = aws_iam_role.backup.name
policy_arn = "arn:aws:iam::aws:policy/AWSBackupServiceRolePolicyForS3Backup"
}
resource "aws_iam_role_policy_attachment" "s3_restore" {
role = aws_iam_role.backup.name
policy_arn = "arn:aws:iam::aws:policy/AWSBackupServiceRolePolicyForS3Restore"
}
################################################################################
# Backup Plan
################################################################################
resource "aws_backup_plan" "main" {
name = var.name
# Daily backup at 3 AM UTC
rule {
rule_name = "daily"
target_vault_name = aws_backup_vault.main.name
schedule = "cron(0 3 * * ? *)"
start_window = 60 # 1 hour
completion_window = 180 # 3 hours
lifecycle {
delete_after = var.daily_retention_days
}
dynamic "copy_action" {
for_each = var.enable_cross_region_copy ? [1] : []
content {
destination_vault_arn = aws_backup_vault.dr[0].arn
lifecycle {
delete_after = var.dr_retention_days
}
}
}
}
# Weekly backup (Sunday 2 AM UTC)
rule {
rule_name = "weekly"
target_vault_name = aws_backup_vault.main.name
schedule = "cron(0 2 ? * SUN *)"
start_window = 60
completion_window = 180
lifecycle {
delete_after = var.weekly_retention_days
}
}
# Monthly backup (1st of month, 1 AM UTC)
rule {
rule_name = "monthly"
target_vault_name = aws_backup_vault.main.name
schedule = "cron(0 1 1 * ? *)"
start_window = 60
completion_window = 180
lifecycle {
delete_after = var.monthly_retention_days
cold_storage_after = 90 # Move to cold storage after 90 days
}
}
# Continuous backup (point-in-time recovery)
dynamic "rule" {
for_each = var.enable_continuous_backup ? [1] : []
content {
rule_name = "continuous"
target_vault_name = aws_backup_vault.main.name
enable_continuous_backup = true
lifecycle {
delete_after = 35 # Max for continuous backup
}
}
}
tags = { Name = var.name }
}
################################################################################
# Resource Selection
################################################################################
resource "aws_backup_selection" "tagged" {
name = "${var.name}-tagged"
plan_id = aws_backup_plan.main.id
iam_role_arn = aws_iam_role.backup.arn
selection_tag {
type = "STRINGEQUALS"
key = var.backup_tag_key
value = var.backup_tag_value
}
# If tenant is specified, also match tenant tag
dynamic "selection_tag" {
for_each = var.tenant != null ? [1] : []
content {
type = "STRINGEQUALS"
key = "Tenant"
value = var.tenant
}
}
}
################################################################################
# Outputs
################################################################################
output "vault_arn" {
value = aws_backup_vault.main.arn
}
output "vault_name" {
value = aws_backup_vault.main.name
}
output "plan_id" {
value = aws_backup_plan.main.id
}
output "plan_arn" {
value = aws_backup_plan.main.arn
}
output "role_arn" {
value = aws_iam_role.backup.arn
}

View File

@@ -0,0 +1,54 @@
# budget-alerts
Budget Alerts Module
## Usage
```hcl
module "budget_alerts" {
source = "../modules/budget-alerts"
# Required variables
monthly_budget = ""
# Optional: see variables.tf for all options
}
```
## Requirements
| Name | Version |
|------|---------|
| terraform | >= 1.5.0 |
| aws | >= 5.0 |
## Inputs
| Name | Description | Type | Required |
|------|-------------|------|----------|
| name_prefix | Prefix for budget names | `string` | no |
| monthly_budget | Monthly budget amount in USD | `number` | yes |
| currency | Budget currency | `string` | no |
| alert_emails | Email addresses for budget alerts | `list(string)` | no |
| alert_sns_topic_arn | SNS topic ARN for alerts (creates one if empty) | `string` | no |
| alert_thresholds | Alert thresholds as percentage of budget | `list(number)` | no |
| forecast_alert_threshold | Alert when forecasted spend exceeds this percentage | `number` | no |
| service_budgets | | `map(number)` | no |
| enable_anomaly_detection | Enable AWS Cost Anomaly Detection | `bool` | no |
| anomaly_threshold_percentage | Anomaly alert threshold as percentage above expected | `number` | no |
| anomaly_threshold_absolute | Minimum absolute dollar amount for anomaly alerts | `number` | no |
| tags | | `map(string)` | no |
## Outputs
| Name | Description |
|------|-------------|
| monthly_budget_id | Monthly budget ID |
| service_budget_ids | |
| sns_topic_arn | SNS topic ARN for alerts |
| anomaly_monitor_arn | Cost Anomaly Monitor ARN |
| budget_summary | |
## License
Apache 2.0 - See LICENSE for details.

View File

@@ -0,0 +1,359 @@
################################################################################
# Budget Alerts Module
#
# AWS Budgets for cost monitoring:
# - Monthly spend budgets
# - Service-specific budgets
# - Forecasted spend alerts
# - Cost anomaly detection
# - SNS/email notifications
#
# Usage:
# module "budgets" {
# source = "../modules/budget-alerts"
#
# monthly_budget = 1000
# alert_emails = ["finance@example.com"]
#
# service_budgets = {
# ec2 = 500
# rds = 200
# }
# }
################################################################################
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
variable "name_prefix" {
type = string
default = "account"
description = "Prefix for budget names"
}
variable "monthly_budget" {
type = number
description = "Monthly budget amount in USD"
}
variable "currency" {
type = string
default = "USD"
description = "Budget currency"
}
variable "alert_emails" {
type = list(string)
default = []
description = "Email addresses for budget alerts"
}
variable "alert_sns_topic_arn" {
type = string
default = ""
description = "SNS topic ARN for alerts (creates one if empty)"
}
variable "alert_thresholds" {
type = list(number)
default = [50, 75, 90, 100, 110]
description = "Alert thresholds as percentage of budget"
}
variable "forecast_alert_threshold" {
type = number
default = 100
description = "Alert when forecasted spend exceeds this percentage"
}
variable "service_budgets" {
type = map(number)
default = {}
description = "Per-service budgets (service name -> monthly amount)"
}
variable "enable_anomaly_detection" {
type = bool
default = true
description = "Enable AWS Cost Anomaly Detection"
}
variable "anomaly_threshold_percentage" {
type = number
default = 10
description = "Anomaly alert threshold as percentage above expected"
}
variable "anomaly_threshold_absolute" {
type = number
default = 100
description = "Minimum absolute dollar amount for anomaly alerts"
}
variable "tags" {
type = map(string)
default = {}
}
################################################################################
# Data Sources
################################################################################
data "aws_caller_identity" "current" {}
################################################################################
# SNS Topic for Alerts
################################################################################
resource "aws_sns_topic" "budget_alerts" {
count = var.alert_sns_topic_arn == "" ? 1 : 0
name = "${var.name_prefix}-budget-alerts"
tags = merge(var.tags, { Name = "${var.name_prefix}-budget-alerts" })
}
resource "aws_sns_topic_policy" "budget_alerts" {
count = var.alert_sns_topic_arn == "" ? 1 : 0
arn = aws_sns_topic.budget_alerts[0].arn
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowBudgets"
Effect = "Allow"
Principal = {
Service = "budgets.amazonaws.com"
}
Action = "sns:Publish"
Resource = aws_sns_topic.budget_alerts[0].arn
Condition = {
StringEquals = {
"aws:SourceAccount" = data.aws_caller_identity.current.account_id
}
}
},
{
Sid = "AllowCostAnomaly"
Effect = "Allow"
Principal = {
Service = "costalerts.amazonaws.com"
}
Action = "sns:Publish"
Resource = aws_sns_topic.budget_alerts[0].arn
}
]
})
}
resource "aws_sns_topic_subscription" "email" {
for_each = var.alert_sns_topic_arn == "" ? toset(var.alert_emails) : []
topic_arn = aws_sns_topic.budget_alerts[0].arn
protocol = "email"
endpoint = each.value
}
locals {
sns_topic_arn = var.alert_sns_topic_arn != "" ? var.alert_sns_topic_arn : aws_sns_topic.budget_alerts[0].arn
}
################################################################################
# Monthly Account Budget
################################################################################
resource "aws_budgets_budget" "monthly" {
name = "${var.name_prefix}-monthly-budget"
budget_type = "COST"
limit_amount = var.monthly_budget
limit_unit = var.currency
time_unit = "MONTHLY"
time_period_start = formatdate("YYYY-MM-01_00:00", timestamp())
cost_filter {
name = "LinkedAccount"
values = [data.aws_caller_identity.current.account_id]
}
# Actual spend alerts
dynamic "notification" {
for_each = var.alert_thresholds
content {
comparison_operator = "GREATER_THAN"
threshold = notification.value
threshold_type = "PERCENTAGE"
notification_type = "ACTUAL"
subscriber_sns_topic_arns = [local.sns_topic_arn]
subscriber_email_addresses = var.alert_sns_topic_arn != "" ? var.alert_emails : []
}
}
# Forecasted spend alert
notification {
comparison_operator = "GREATER_THAN"
threshold = var.forecast_alert_threshold
threshold_type = "PERCENTAGE"
notification_type = "FORECASTED"
subscriber_sns_topic_arns = [local.sns_topic_arn]
subscriber_email_addresses = var.alert_sns_topic_arn != "" ? var.alert_emails : []
}
tags = merge(var.tags, { Name = "${var.name_prefix}-monthly" })
lifecycle {
ignore_changes = [time_period_start]
}
}
################################################################################
# Service-Specific Budgets
################################################################################
locals {
service_filters = {
ec2 = "Amazon Elastic Compute Cloud - Compute"
rds = "Amazon Relational Database Service"
s3 = "Amazon Simple Storage Service"
lambda = "AWS Lambda"
dynamodb = "Amazon DynamoDB"
cloudfront = "Amazon CloudFront"
elasticache = "Amazon ElastiCache"
eks = "Amazon Elastic Kubernetes Service"
ecs = "Amazon Elastic Container Service"
nat = "EC2 - Other" # NAT Gateway charges
data = "AWS Data Transfer"
}
}
resource "aws_budgets_budget" "services" {
for_each = var.service_budgets
name = "${var.name_prefix}-${each.key}-budget"
budget_type = "COST"
limit_amount = each.value
limit_unit = var.currency
time_unit = "MONTHLY"
time_period_start = formatdate("YYYY-MM-01_00:00", timestamp())
cost_filter {
name = "Service"
values = [lookup(local.service_filters, each.key, each.key)]
}
notification {
comparison_operator = "GREATER_THAN"
threshold = 80
threshold_type = "PERCENTAGE"
notification_type = "ACTUAL"
subscriber_sns_topic_arns = [local.sns_topic_arn]
subscriber_email_addresses = var.alert_sns_topic_arn != "" ? var.alert_emails : []
}
notification {
comparison_operator = "GREATER_THAN"
threshold = 100
threshold_type = "PERCENTAGE"
notification_type = "ACTUAL"
subscriber_sns_topic_arns = [local.sns_topic_arn]
subscriber_email_addresses = var.alert_sns_topic_arn != "" ? var.alert_emails : []
}
tags = merge(var.tags, { Name = "${var.name_prefix}-${each.key}" })
lifecycle {
ignore_changes = [time_period_start]
}
}
################################################################################
# Cost Anomaly Detection
################################################################################
resource "aws_ce_anomaly_monitor" "main" {
count = var.enable_anomaly_detection ? 1 : 0
name = "${var.name_prefix}-anomaly-monitor"
monitor_type = "DIMENSIONAL"
monitor_dimension = "SERVICE"
tags = merge(var.tags, { Name = "${var.name_prefix}-anomaly-monitor" })
}
resource "aws_ce_anomaly_subscription" "main" {
count = var.enable_anomaly_detection ? 1 : 0
name = "${var.name_prefix}-anomaly-alerts"
frequency = "IMMEDIATE"
monitor_arn_list = [aws_ce_anomaly_monitor.main[0].arn]
subscriber {
type = "SNS"
address = local.sns_topic_arn
}
dynamic "subscriber" {
for_each = var.alert_sns_topic_arn != "" ? var.alert_emails : []
content {
type = "EMAIL"
address = subscriber.value
}
}
threshold_expression {
and {
dimension {
key = "ANOMALY_TOTAL_IMPACT_PERCENTAGE"
match_options = ["GREATER_THAN_OR_EQUAL"]
values = [tostring(var.anomaly_threshold_percentage)]
}
}
and {
dimension {
key = "ANOMALY_TOTAL_IMPACT_ABSOLUTE"
match_options = ["GREATER_THAN_OR_EQUAL"]
values = [tostring(var.anomaly_threshold_absolute)]
}
}
}
tags = merge(var.tags, { Name = "${var.name_prefix}-anomaly-alerts" })
}
################################################################################
# Outputs
################################################################################
output "monthly_budget_id" {
value = aws_budgets_budget.monthly.id
description = "Monthly budget ID"
}
output "service_budget_ids" {
value = { for k, v in aws_budgets_budget.services : k => v.id }
description = "Service budget IDs"
}
output "sns_topic_arn" {
value = local.sns_topic_arn
description = "SNS topic ARN for alerts"
}
output "anomaly_monitor_arn" {
value = var.enable_anomaly_detection ? aws_ce_anomaly_monitor.main[0].arn : null
description = "Cost Anomaly Monitor ARN"
}
output "budget_summary" {
value = {
monthly_limit = "$${var.monthly_budget}/month"
alert_thresholds = [for t in var.alert_thresholds : "${t}%"]
service_limits = { for k, v in var.service_budgets : k => "$${v}/month" }
anomaly_detection = var.enable_anomaly_detection ? "Enabled (>${var.anomaly_threshold_percentage}% and >$${var.anomaly_threshold_absolute})" : "Disabled"
}
description = "Budget configuration summary"
}

View File

@@ -0,0 +1,60 @@
# cloudtrail
CloudTrail Module
## Usage
```hcl
module "cloudtrail" {
source = "../modules/cloudtrail"
# Required variables
name = ""
# Optional: see variables.tf for all options
}
```
## Requirements
| Name | Version |
|------|---------|
| terraform | >= 1.5.0 |
| aws | >= 5.0 |
## Inputs
| Name | Description | Type | Required |
|------|-------------|------|----------|
| name | Trail name | `string` | yes |
| s3_bucket_name | S3 bucket for logs (created if empty) | `string` | no |
| is_multi_region | Enable multi-region trail | `bool` | no |
| is_organization_trail | Organization-wide trail (requires org management account) | `bool` | no |
| enable_log_file_validation | Enable log file integrity validation | `bool` | no |
| include_global_service_events | Include global service events (IAM, STS, CloudFront) | `bool` | no |
| enable_cloudwatch_logs | Send logs to CloudWatch Logs | `bool` | no |
| cloudwatch_log_retention_days | CloudWatch log retention in days | `number` | no |
| enable_insights | Enable CloudTrail Insights (additional cost) | `bool` | no |
| insight_selectors | Insight types to enable | `list(string)` | no |
| enable_data_events | Enable data events logging | `bool` | no |
| data_event_s3_buckets | S3 bucket ARNs for data events (empty = all buckets) | `list(string)` | no |
| data_event_lambda_functions | Lambda function ARNs for data events (empty = all functions) | `list(string)` | no |
| data_event_dynamodb_tables | DynamoDB table ARNs for data events | `list(string)` | no |
| kms_key_arn | KMS key ARN for encryption (created if empty) | `string` | no |
*...and 3 more variables. See `variables.tf` for complete list.*
## Outputs
| Name | Description |
|------|-------------|
| trail_arn | CloudTrail ARN |
| trail_name | CloudTrail name |
| s3_bucket | S3 bucket for CloudTrail logs |
| kms_key_arn | KMS key ARN for encryption |
| cloudwatch_log_group | CloudWatch Logs group |
| home_region | Trail home region |
## License
Apache 2.0 - See LICENSE for details.

View File

@@ -0,0 +1,506 @@
################################################################################
# CloudTrail Module
#
# Audit logging for AWS API activity:
# - Management events (console, CLI, SDK)
# - Data events (S3, Lambda, DynamoDB)
# - Insights events (anomaly detection)
# - Multi-region trail
# - KMS encryption
# - CloudWatch Logs integration
# - S3 bucket with lifecycle
#
# Usage:
# module "cloudtrail" {
# source = "../modules/cloudtrail"
# name = "org-trail"
#
# enable_data_events = true
# data_event_buckets = ["my-bucket"]
# }
################################################################################
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
variable "name" {
type = string
description = "Trail name"
}
variable "s3_bucket_name" {
type = string
default = ""
description = "S3 bucket for logs (created if empty)"
}
variable "is_multi_region" {
type = bool
default = true
description = "Enable multi-region trail"
}
variable "is_organization_trail" {
type = bool
default = false
description = "Organization-wide trail (requires org management account)"
}
variable "enable_log_file_validation" {
type = bool
default = true
description = "Enable log file integrity validation"
}
variable "include_global_service_events" {
type = bool
default = true
description = "Include global service events (IAM, STS, CloudFront)"
}
variable "enable_cloudwatch_logs" {
type = bool
default = true
description = "Send logs to CloudWatch Logs"
}
variable "cloudwatch_log_retention_days" {
type = number
default = 90
description = "CloudWatch log retention in days"
}
variable "enable_insights" {
type = bool
default = false
description = "Enable CloudTrail Insights (additional cost)"
}
variable "insight_selectors" {
type = list(string)
default = ["ApiCallRateInsight", "ApiErrorRateInsight"]
description = "Insight types to enable"
}
variable "enable_data_events" {
type = bool
default = false
description = "Enable data events logging"
}
variable "data_event_s3_buckets" {
type = list(string)
default = []
description = "S3 bucket ARNs for data events (empty = all buckets)"
}
variable "data_event_lambda_functions" {
type = list(string)
default = []
description = "Lambda function ARNs for data events (empty = all functions)"
}
variable "data_event_dynamodb_tables" {
type = list(string)
default = []
description = "DynamoDB table ARNs for data events"
}
variable "kms_key_arn" {
type = string
default = ""
description = "KMS key ARN for encryption (created if empty)"
}
variable "s3_log_retention_days" {
type = number
default = 365
description = "S3 log retention in days"
}
variable "s3_transition_to_glacier_days" {
type = number
default = 90
description = "Days before transitioning logs to Glacier"
}
variable "tags" {
type = map(string)
default = {}
}
################################################################################
# Data Sources
################################################################################
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
data "aws_partition" "current" {}
locals {
bucket_name = var.s3_bucket_name != "" ? var.s3_bucket_name : "${var.name}-cloudtrail-${data.aws_caller_identity.current.account_id}"
create_bucket = var.s3_bucket_name == ""
create_kms = var.kms_key_arn == ""
}
################################################################################
# KMS Key
################################################################################
resource "aws_kms_key" "cloudtrail" {
count = local.create_kms ? 1 : 0
description = "CloudTrail encryption key for ${var.name}"
deletion_window_in_days = 30
enable_key_rotation = true
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "Enable IAM policies"
Effect = "Allow"
Principal = {
AWS = "arn:${data.aws_partition.current.partition}:iam::${data.aws_caller_identity.current.account_id}:root"
}
Action = "kms:*"
Resource = "*"
},
{
Sid = "Allow CloudTrail to encrypt logs"
Effect = "Allow"
Principal = {
Service = "cloudtrail.amazonaws.com"
}
Action = [
"kms:GenerateDataKey*",
"kms:DescribeKey"
]
Resource = "*"
Condition = {
StringEquals = {
"aws:SourceArn" = "arn:${data.aws_partition.current.partition}:cloudtrail:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:trail/${var.name}"
}
StringLike = {
"kms:EncryptionContext:aws:cloudtrail:arn" = "arn:${data.aws_partition.current.partition}:cloudtrail:*:${data.aws_caller_identity.current.account_id}:trail/*"
}
}
},
{
Sid = "Allow CloudTrail to describe key"
Effect = "Allow"
Principal = {
Service = "cloudtrail.amazonaws.com"
}
Action = "kms:DescribeKey"
Resource = "*"
},
{
Sid = "Allow log decryption"
Effect = "Allow"
Principal = {
AWS = "arn:${data.aws_partition.current.partition}:iam::${data.aws_caller_identity.current.account_id}:root"
}
Action = [
"kms:Decrypt",
"kms:ReEncryptFrom"
]
Resource = "*"
Condition = {
StringEquals = {
"kms:CallerAccount" = data.aws_caller_identity.current.account_id
}
StringLike = {
"kms:EncryptionContext:aws:cloudtrail:arn" = "arn:${data.aws_partition.current.partition}:cloudtrail:*:${data.aws_caller_identity.current.account_id}:trail/*"
}
}
}
]
})
tags = merge(var.tags, { Name = "${var.name}-cloudtrail" })
}
resource "aws_kms_alias" "cloudtrail" {
count = local.create_kms ? 1 : 0
name = "alias/${var.name}-cloudtrail"
target_key_id = aws_kms_key.cloudtrail[0].key_id
}
locals {
kms_key_arn = local.create_kms ? aws_kms_key.cloudtrail[0].arn : var.kms_key_arn
}
################################################################################
# S3 Bucket
################################################################################
resource "aws_s3_bucket" "cloudtrail" {
count = local.create_bucket ? 1 : 0
bucket = local.bucket_name
tags = merge(var.tags, { Name = local.bucket_name })
}
resource "aws_s3_bucket_versioning" "cloudtrail" {
count = local.create_bucket ? 1 : 0
bucket = aws_s3_bucket.cloudtrail[0].id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "cloudtrail" {
count = local.create_bucket ? 1 : 0
bucket = aws_s3_bucket.cloudtrail[0].id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = local.kms_key_arn
}
bucket_key_enabled = true
}
}
resource "aws_s3_bucket_public_access_block" "cloudtrail" {
count = local.create_bucket ? 1 : 0
bucket = aws_s3_bucket.cloudtrail[0].id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_lifecycle_configuration" "cloudtrail" {
count = local.create_bucket ? 1 : 0
bucket = aws_s3_bucket.cloudtrail[0].id
rule {
id = "archive-and-expire"
status = "Enabled"
transition {
days = var.s3_transition_to_glacier_days
storage_class = "GLACIER"
}
expiration {
days = var.s3_log_retention_days
}
noncurrent_version_expiration {
noncurrent_days = 30
}
}
}
resource "aws_s3_bucket_policy" "cloudtrail" {
count = local.create_bucket ? 1 : 0
bucket = aws_s3_bucket.cloudtrail[0].id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AWSCloudTrailAclCheck"
Effect = "Allow"
Principal = {
Service = "cloudtrail.amazonaws.com"
}
Action = "s3:GetBucketAcl"
Resource = aws_s3_bucket.cloudtrail[0].arn
Condition = {
StringEquals = {
"aws:SourceArn" = "arn:${data.aws_partition.current.partition}:cloudtrail:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:trail/${var.name}"
}
}
},
{
Sid = "AWSCloudTrailWrite"
Effect = "Allow"
Principal = {
Service = "cloudtrail.amazonaws.com"
}
Action = "s3:PutObject"
Resource = "${aws_s3_bucket.cloudtrail[0].arn}/*"
Condition = {
StringEquals = {
"s3:x-amz-acl" = "bucket-owner-full-control"
"aws:SourceArn" = "arn:${data.aws_partition.current.partition}:cloudtrail:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:trail/${var.name}"
}
}
},
{
Sid = "DenyInsecureTransport"
Effect = "Deny"
Principal = "*"
Action = "s3:*"
Resource = [
aws_s3_bucket.cloudtrail[0].arn,
"${aws_s3_bucket.cloudtrail[0].arn}/*"
]
Condition = {
Bool = {
"aws:SecureTransport" = "false"
}
}
}
]
})
}
################################################################################
# CloudWatch Logs
################################################################################
resource "aws_cloudwatch_log_group" "cloudtrail" {
count = var.enable_cloudwatch_logs ? 1 : 0
name = "/aws/cloudtrail/${var.name}"
retention_in_days = var.cloudwatch_log_retention_days
tags = merge(var.tags, { Name = var.name })
}
resource "aws_iam_role" "cloudtrail_cloudwatch" {
count = var.enable_cloudwatch_logs ? 1 : 0
name = "${var.name}-cloudtrail-cloudwatch"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "cloudtrail.amazonaws.com" }
}]
})
tags = merge(var.tags, { Name = "${var.name}-cloudtrail-cloudwatch" })
}
resource "aws_iam_role_policy" "cloudtrail_cloudwatch" {
count = var.enable_cloudwatch_logs ? 1 : 0
name = "cloudwatch-logs"
role = aws_iam_role.cloudtrail_cloudwatch[0].id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = [
"logs:CreateLogStream",
"logs:PutLogEvents"
]
Resource = "${aws_cloudwatch_log_group.cloudtrail[0].arn}:*"
}]
})
}
################################################################################
# CloudTrail
################################################################################
resource "aws_cloudtrail" "main" {
name = var.name
s3_bucket_name = local.create_bucket ? aws_s3_bucket.cloudtrail[0].id : var.s3_bucket_name
include_global_service_events = var.include_global_service_events
is_multi_region_trail = var.is_multi_region
is_organization_trail = var.is_organization_trail
enable_log_file_validation = var.enable_log_file_validation
kms_key_id = local.kms_key_arn
cloud_watch_logs_group_arn = var.enable_cloudwatch_logs ? "${aws_cloudwatch_log_group.cloudtrail[0].arn}:*" : null
cloud_watch_logs_role_arn = var.enable_cloudwatch_logs ? aws_iam_role.cloudtrail_cloudwatch[0].arn : null
# Insights
dynamic "insight_selector" {
for_each = var.enable_insights ? var.insight_selectors : []
content {
insight_type = insight_selector.value
}
}
# Data events
dynamic "event_selector" {
for_each = var.enable_data_events ? [1] : []
content {
read_write_type = "All"
include_management_events = true
# S3 data events
dynamic "data_resource" {
for_each = length(var.data_event_s3_buckets) > 0 ? [1] : (var.enable_data_events ? [1] : [])
content {
type = "AWS::S3::Object"
values = length(var.data_event_s3_buckets) > 0 ? var.data_event_s3_buckets : ["arn:aws:s3"]
}
}
# Lambda data events
dynamic "data_resource" {
for_each = length(var.data_event_lambda_functions) > 0 ? [1] : []
content {
type = "AWS::Lambda::Function"
values = var.data_event_lambda_functions
}
}
# DynamoDB data events
dynamic "data_resource" {
for_each = length(var.data_event_dynamodb_tables) > 0 ? [1] : []
content {
type = "AWS::DynamoDB::Table"
values = var.data_event_dynamodb_tables
}
}
}
}
tags = merge(var.tags, { Name = var.name })
depends_on = [
aws_s3_bucket_policy.cloudtrail,
]
}
################################################################################
# Outputs
################################################################################
output "trail_arn" {
value = aws_cloudtrail.main.arn
description = "CloudTrail ARN"
}
output "trail_name" {
value = aws_cloudtrail.main.name
description = "CloudTrail name"
}
output "s3_bucket" {
value = local.create_bucket ? aws_s3_bucket.cloudtrail[0].id : var.s3_bucket_name
description = "S3 bucket for CloudTrail logs"
}
output "kms_key_arn" {
value = local.kms_key_arn
description = "KMS key ARN for encryption"
}
output "cloudwatch_log_group" {
value = var.enable_cloudwatch_logs ? aws_cloudwatch_log_group.cloudtrail[0].name : null
description = "CloudWatch Logs group"
}
output "home_region" {
value = aws_cloudtrail.main.home_region
description = "Trail home region"
}

View File

@@ -0,0 +1,49 @@
# cloudwatch-dashboard
CloudWatch Dashboard Module
## Usage
```hcl
module "cloudwatch_dashboard" {
source = "../modules/cloudwatch-dashboard"
# Required variables
name = ""
# Optional: see variables.tf for all options
}
```
## Requirements
| Name | Version |
|------|---------|
| terraform | >= 1.5.0 |
| aws | >= 5.0 |
## Inputs
| Name | Description | Type | Required |
|------|-------------|------|----------|
| name | Dashboard name | `string` | yes |
| ecs_clusters | ECS cluster names to monitor | `list(string)` | no |
| ecs_services | ECS service names to monitor | `list(string)` | no |
| rds_instances | RDS instance identifiers | `list(string)` | no |
| lambda_functions | Lambda function names | `list(string)` | no |
| alb_arns | ALB ARN suffixes (app/name/id) | `list(string)` | no |
| api_gateway_apis | API Gateway API IDs | `list(string)` | no |
| sqs_queues | SQS queue names | `list(string)` | no |
| dynamodb_tables | DynamoDB table names | `list(string)` | no |
| tags | | `map(string)` | no |
## Outputs
| Name | Description |
|------|-------------|
| dashboard_name | |
| dashboard_arn | |
## License
Apache 2.0 - See LICENSE for details.

View File

@@ -0,0 +1,431 @@
################################################################################
# CloudWatch Dashboard Module
#
# Creates CloudWatch dashboards for common AWS services:
# - ECS services
# - RDS databases
# - Lambda functions
# - ALB/NLB
# - API Gateway
#
# Usage:
# module "dashboard" {
# source = "../modules/cloudwatch-dashboard"
# name = "myapp-prod"
#
# ecs_clusters = ["prod-cluster"]
# ecs_services = ["myapp-api"]
# rds_instances = ["myapp-db"]
# lambda_functions = ["myapp-worker"]
# alb_arns = ["arn:aws:elasticloadbalancing:..."]
# }
################################################################################
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
variable "name" {
type = string
description = "Dashboard name"
}
variable "ecs_clusters" {
type = list(string)
default = []
description = "ECS cluster names to monitor"
}
variable "ecs_services" {
type = list(string)
default = []
description = "ECS service names to monitor"
}
variable "rds_instances" {
type = list(string)
default = []
description = "RDS instance identifiers"
}
variable "lambda_functions" {
type = list(string)
default = []
description = "Lambda function names"
}
variable "alb_arns" {
type = list(string)
default = []
description = "ALB ARN suffixes (app/name/id)"
}
variable "api_gateway_apis" {
type = list(string)
default = []
description = "API Gateway API IDs"
}
variable "sqs_queues" {
type = list(string)
default = []
description = "SQS queue names"
}
variable "dynamodb_tables" {
type = list(string)
default = []
description = "DynamoDB table names"
}
variable "tags" {
type = map(string)
default = {}
}
data "aws_region" "current" {}
locals {
region = data.aws_region.current.name
# ECS widgets
ecs_widgets = length(var.ecs_clusters) > 0 ? [
{
type = "metric"
x = 0
y = 0
width = 12
height = 6
properties = {
title = "ECS CPU Utilization"
region = local.region
metrics = [
for i, cluster in var.ecs_clusters : [
"AWS/ECS", "CPUUtilization",
"ClusterName", cluster,
"ServiceName", try(var.ecs_services[i], cluster)
]
]
stat = "Average"
period = 300
}
},
{
type = "metric"
x = 12
y = 0
width = 12
height = 6
properties = {
title = "ECS Memory Utilization"
region = local.region
metrics = [
for i, cluster in var.ecs_clusters : [
"AWS/ECS", "MemoryUtilization",
"ClusterName", cluster,
"ServiceName", try(var.ecs_services[i], cluster)
]
]
stat = "Average"
period = 300
}
}
] : []
# RDS widgets
rds_widgets = length(var.rds_instances) > 0 ? [
{
type = "metric"
x = 0
y = 6
width = 8
height = 6
properties = {
title = "RDS CPU Utilization"
region = local.region
metrics = [
for db in var.rds_instances : [
"AWS/RDS", "CPUUtilization",
"DBInstanceIdentifier", db
]
]
stat = "Average"
period = 300
}
},
{
type = "metric"
x = 8
y = 6
width = 8
height = 6
properties = {
title = "RDS Database Connections"
region = local.region
metrics = [
for db in var.rds_instances : [
"AWS/RDS", "DatabaseConnections",
"DBInstanceIdentifier", db
]
]
stat = "Average"
period = 300
}
},
{
type = "metric"
x = 16
y = 6
width = 8
height = 6
properties = {
title = "RDS Free Storage"
region = local.region
metrics = [
for db in var.rds_instances : [
"AWS/RDS", "FreeStorageSpace",
"DBInstanceIdentifier", db
]
]
stat = "Average"
period = 300
}
}
] : []
# Lambda widgets
lambda_widgets = length(var.lambda_functions) > 0 ? [
{
type = "metric"
x = 0
y = 12
width = 8
height = 6
properties = {
title = "Lambda Invocations"
region = local.region
metrics = [
for fn in var.lambda_functions : [
"AWS/Lambda", "Invocations",
"FunctionName", fn
]
]
stat = "Sum"
period = 300
}
},
{
type = "metric"
x = 8
y = 12
width = 8
height = 6
properties = {
title = "Lambda Errors"
region = local.region
metrics = [
for fn in var.lambda_functions : [
"AWS/Lambda", "Errors",
"FunctionName", fn
]
]
stat = "Sum"
period = 300
}
},
{
type = "metric"
x = 16
y = 12
width = 8
height = 6
properties = {
title = "Lambda Duration"
region = local.region
metrics = [
for fn in var.lambda_functions : [
"AWS/Lambda", "Duration",
"FunctionName", fn
]
]
stat = "Average"
period = 300
}
}
] : []
# ALB widgets
alb_widgets = length(var.alb_arns) > 0 ? [
{
type = "metric"
x = 0
y = 18
width = 8
height = 6
properties = {
title = "ALB Request Count"
region = local.region
metrics = [
for alb in var.alb_arns : [
"AWS/ApplicationELB", "RequestCount",
"LoadBalancer", alb
]
]
stat = "Sum"
period = 300
}
},
{
type = "metric"
x = 8
y = 18
width = 8
height = 6
properties = {
title = "ALB 5xx Errors"
region = local.region
metrics = [
for alb in var.alb_arns : [
"AWS/ApplicationELB", "HTTPCode_ELB_5XX_Count",
"LoadBalancer", alb
]
]
stat = "Sum"
period = 300
}
},
{
type = "metric"
x = 16
y = 18
width = 8
height = 6
properties = {
title = "ALB Response Time"
region = local.region
metrics = [
for alb in var.alb_arns : [
"AWS/ApplicationELB", "TargetResponseTime",
"LoadBalancer", alb
]
]
stat = "Average"
period = 300
}
}
] : []
# SQS widgets
sqs_widgets = length(var.sqs_queues) > 0 ? [
{
type = "metric"
x = 0
y = 24
width = 12
height = 6
properties = {
title = "SQS Messages Visible"
region = local.region
metrics = [
for q in var.sqs_queues : [
"AWS/SQS", "ApproximateNumberOfMessagesVisible",
"QueueName", q
]
]
stat = "Average"
period = 300
}
},
{
type = "metric"
x = 12
y = 24
width = 12
height = 6
properties = {
title = "SQS Age of Oldest Message"
region = local.region
metrics = [
for q in var.sqs_queues : [
"AWS/SQS", "ApproximateAgeOfOldestMessage",
"QueueName", q
]
]
stat = "Maximum"
period = 300
}
}
] : []
# DynamoDB widgets
dynamodb_widgets = length(var.dynamodb_tables) > 0 ? [
{
type = "metric"
x = 0
y = 30
width = 12
height = 6
properties = {
title = "DynamoDB Read Capacity"
region = local.region
metrics = [
for t in var.dynamodb_tables : [
"AWS/DynamoDB", "ConsumedReadCapacityUnits",
"TableName", t
]
]
stat = "Sum"
period = 300
}
},
{
type = "metric"
x = 12
y = 30
width = 12
height = 6
properties = {
title = "DynamoDB Write Capacity"
region = local.region
metrics = [
for t in var.dynamodb_tables : [
"AWS/DynamoDB", "ConsumedWriteCapacityUnits",
"TableName", t
]
]
stat = "Sum"
period = 300
}
}
] : []
all_widgets = concat(
local.ecs_widgets,
local.rds_widgets,
local.lambda_widgets,
local.alb_widgets,
local.sqs_widgets,
local.dynamodb_widgets
)
}
resource "aws_cloudwatch_dashboard" "main" {
dashboard_name = var.name
dashboard_body = jsonencode({
widgets = local.all_widgets
})
}
output "dashboard_name" {
value = aws_cloudwatch_dashboard.main.dashboard_name
}
output "dashboard_arn" {
value = aws_cloudwatch_dashboard.main.dashboard_arn
}

View File

@@ -0,0 +1,51 @@
# config-rules
AWS Config Rules Module
## Usage
```hcl
module "config_rules" {
source = "../modules/config-rules"
# Required variables
# Optional: see variables.tf for all options
}
```
## Requirements
| Name | Version |
|------|---------|
| terraform | >= 1.5.0 |
| aws | >= 5.0 |
## Inputs
| Name | Description | Type | Required |
|------|-------------|------|----------|
| enable_aws_config | Enable AWS Config (required for rules) | `bool` | no |
| config_bucket | S3 bucket for Config snapshots (created if empty) | `string` | no |
| config_sns_topic_arn | SNS topic for Config notifications | `string` | no |
| delivery_frequency | Config snapshot delivery frequency | `string` | no |
| enable_cis_benchmark | Enable CIS AWS Foundations Benchmark rules | `bool` | no |
| enable_security_best_practices | Enable AWS Security Best Practices rules | `bool` | no |
| enable_pci_dss | Enable PCI DSS compliance rules | `bool` | no |
| enable_hipaa | Enable HIPAA compliance rules | `bool` | no |
| rules | | `object({` | no |
| auto_remediation | Enable auto-remediation for supported rules | `bool` | no |
| tags | | `map(string)` | no |
## Outputs
| Name | Description |
|------|-------------|
| config_recorder_id | Config recorder ID |
| config_bucket | S3 bucket for Config snapshots |
| enabled_rules | |
| compliance_packs | |
## License
Apache 2.0 - See LICENSE for details.

View File

@@ -0,0 +1,514 @@
################################################################################
# AWS Config Rules Module
#
# Compliance monitoring with managed rules:
# - CIS AWS Foundations Benchmark
# - PCI DSS
# - HIPAA
# - Custom rules
# - Auto-remediation (optional)
#
# Usage:
# module "config_rules" {
# source = "../modules/config-rules"
#
# enable_cis_benchmark = true
# enable_security_best_practices = true
#
# # Or pick individual rules
# rules = {
# s3-bucket-ssl = true
# ec2-imdsv2 = true
# }
# }
################################################################################
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
variable "enable_aws_config" {
type = bool
default = true
description = "Enable AWS Config (required for rules)"
}
variable "config_bucket" {
type = string
default = ""
description = "S3 bucket for Config snapshots (created if empty)"
}
variable "config_sns_topic_arn" {
type = string
default = ""
description = "SNS topic for Config notifications"
}
variable "delivery_frequency" {
type = string
default = "TwentyFour_Hours"
description = "Config snapshot delivery frequency"
}
# Compliance Packs
variable "enable_cis_benchmark" {
type = bool
default = false
description = "Enable CIS AWS Foundations Benchmark rules"
}
variable "enable_security_best_practices" {
type = bool
default = true
description = "Enable AWS Security Best Practices rules"
}
variable "enable_pci_dss" {
type = bool
default = false
description = "Enable PCI DSS compliance rules"
}
variable "enable_hipaa" {
type = bool
default = false
description = "Enable HIPAA compliance rules"
}
# Individual Rules (all optional)
variable "rules" {
type = object({
# S3 Security
s3_bucket_public_read_prohibited = optional(bool, true)
s3_bucket_public_write_prohibited = optional(bool, true)
s3_bucket_ssl_requests_only = optional(bool, true)
s3_bucket_logging_enabled = optional(bool, false)
s3_bucket_versioning_enabled = optional(bool, false)
s3_default_encryption_kms = optional(bool, false)
# EC2 Security
ec2_imdsv2_check = optional(bool, true)
ec2_instance_no_public_ip = optional(bool, false)
ec2_ebs_encryption_by_default = optional(bool, true)
ec2_security_group_attached_to_eni = optional(bool, false)
restricted_ssh = optional(bool, true)
restricted_rdp = optional(bool, true)
# IAM Security
iam_root_access_key_check = optional(bool, true)
iam_user_mfa_enabled = optional(bool, true)
iam_user_no_policies_check = optional(bool, true)
iam_password_policy = optional(bool, true)
access_keys_rotated = optional(bool, true)
access_keys_rotated_days = optional(number, 90)
# RDS Security
rds_instance_public_access_check = optional(bool, true)
rds_storage_encrypted = optional(bool, true)
rds_multi_az_support = optional(bool, false)
rds_snapshot_encrypted = optional(bool, true)
# Network Security
vpc_flow_logs_enabled = optional(bool, true)
vpc_default_security_group_closed = optional(bool, true)
# Encryption
kms_cmk_not_scheduled_for_deletion = optional(bool, true)
encrypted_volumes = optional(bool, true)
# Logging & Monitoring
cloudtrail_enabled = optional(bool, true)
cloudwatch_alarm_action_check = optional(bool, false)
cw_loggroup_retention_period_check = optional(bool, false)
guardduty_enabled_centralized = optional(bool, false)
# Lambda
lambda_function_public_access_prohibited = optional(bool, true)
lambda_inside_vpc = optional(bool, false)
})
default = {}
description = "Individual Config rules to enable"
}
variable "auto_remediation" {
type = bool
default = false
description = "Enable auto-remediation for supported rules"
}
variable "tags" {
type = map(string)
default = {}
}
################################################################################
# Data Sources
################################################################################
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
################################################################################
# S3 Bucket for Config
################################################################################
resource "aws_s3_bucket" "config" {
count = var.enable_aws_config && var.config_bucket == "" ? 1 : 0
bucket = "aws-config-${data.aws_caller_identity.current.account_id}-${data.aws_region.current.name}"
tags = merge(var.tags, { Name = "aws-config" })
}
resource "aws_s3_bucket_versioning" "config" {
count = var.enable_aws_config && var.config_bucket == "" ? 1 : 0
bucket = aws_s3_bucket.config[0].id
versioning_configuration { status = "Enabled" }
}
resource "aws_s3_bucket_server_side_encryption_configuration" "config" {
count = var.enable_aws_config && var.config_bucket == "" ? 1 : 0
bucket = aws_s3_bucket.config[0].id
rule {
apply_server_side_encryption_by_default { sse_algorithm = "AES256" }
}
}
resource "aws_s3_bucket_public_access_block" "config" {
count = var.enable_aws_config && var.config_bucket == "" ? 1 : 0
bucket = aws_s3_bucket.config[0].id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
locals {
config_bucket = var.config_bucket != "" ? var.config_bucket : (var.enable_aws_config ? aws_s3_bucket.config[0].id : "")
}
################################################################################
# IAM Role for Config
################################################################################
resource "aws_iam_role" "config" {
count = var.enable_aws_config ? 1 : 0
name = "AWSConfigRole"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "config.amazonaws.com" }
}]
})
tags = merge(var.tags, { Name = "AWSConfigRole" })
}
resource "aws_iam_role_policy_attachment" "config" {
count = var.enable_aws_config ? 1 : 0
role = aws_iam_role.config[0].name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWS_ConfigRole"
}
resource "aws_iam_role_policy" "config_s3" {
count = var.enable_aws_config ? 1 : 0
name = "s3-delivery"
role = aws_iam_role.config[0].id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = ["s3:PutObject", "s3:PutObjectAcl"]
Resource = "arn:aws:s3:::${local.config_bucket}/*"
Condition = {
StringLike = { "s3:x-amz-acl" = "bucket-owner-full-control" }
}
},
{
Effect = "Allow"
Action = "s3:GetBucketAcl"
Resource = "arn:aws:s3:::${local.config_bucket}"
}
]
})
}
################################################################################
# AWS Config Recorder
################################################################################
resource "aws_config_configuration_recorder" "main" {
count = var.enable_aws_config ? 1 : 0
name = "default"
role_arn = aws_iam_role.config[0].arn
recording_group {
all_supported = true
include_global_resource_types = true
}
}
resource "aws_config_delivery_channel" "main" {
count = var.enable_aws_config ? 1 : 0
name = "default"
s3_bucket_name = local.config_bucket
sns_topic_arn = var.config_sns_topic_arn != "" ? var.config_sns_topic_arn : null
snapshot_delivery_properties {
delivery_frequency = var.delivery_frequency
}
depends_on = [aws_config_configuration_recorder.main]
}
resource "aws_config_configuration_recorder_status" "main" {
count = var.enable_aws_config ? 1 : 0
name = aws_config_configuration_recorder.main[0].name
is_enabled = true
depends_on = [aws_config_delivery_channel.main]
}
################################################################################
# Security Best Practices Rules
################################################################################
# S3 Rules
resource "aws_config_config_rule" "s3_bucket_public_read_prohibited" {
count = var.enable_aws_config && (var.rules.s3_bucket_public_read_prohibited || var.enable_security_best_practices) ? 1 : 0
name = "s3-bucket-public-read-prohibited"
source {
owner = "AWS"
source_identifier = "S3_BUCKET_PUBLIC_READ_PROHIBITED"
}
depends_on = [aws_config_configuration_recorder.main]
tags = var.tags
}
resource "aws_config_config_rule" "s3_bucket_public_write_prohibited" {
count = var.enable_aws_config && (var.rules.s3_bucket_public_write_prohibited || var.enable_security_best_practices) ? 1 : 0
name = "s3-bucket-public-write-prohibited"
source {
owner = "AWS"
source_identifier = "S3_BUCKET_PUBLIC_WRITE_PROHIBITED"
}
depends_on = [aws_config_configuration_recorder.main]
tags = var.tags
}
resource "aws_config_config_rule" "s3_bucket_ssl_requests_only" {
count = var.enable_aws_config && (var.rules.s3_bucket_ssl_requests_only || var.enable_security_best_practices) ? 1 : 0
name = "s3-bucket-ssl-requests-only"
source {
owner = "AWS"
source_identifier = "S3_BUCKET_SSL_REQUESTS_ONLY"
}
depends_on = [aws_config_configuration_recorder.main]
tags = var.tags
}
# EC2 Rules
resource "aws_config_config_rule" "ec2_imdsv2_check" {
count = var.enable_aws_config && (var.rules.ec2_imdsv2_check || var.enable_security_best_practices) ? 1 : 0
name = "ec2-imdsv2-check"
source {
owner = "AWS"
source_identifier = "EC2_IMDSV2_CHECK"
}
depends_on = [aws_config_configuration_recorder.main]
tags = var.tags
}
resource "aws_config_config_rule" "ebs_encryption_by_default" {
count = var.enable_aws_config && (var.rules.ec2_ebs_encryption_by_default || var.enable_security_best_practices) ? 1 : 0
name = "ec2-ebs-encryption-by-default-check"
source {
owner = "AWS"
source_identifier = "EC2_EBS_ENCRYPTION_BY_DEFAULT"
}
depends_on = [aws_config_configuration_recorder.main]
tags = var.tags
}
resource "aws_config_config_rule" "restricted_ssh" {
count = var.enable_aws_config && (var.rules.restricted_ssh || var.enable_security_best_practices) ? 1 : 0
name = "restricted-ssh"
source {
owner = "AWS"
source_identifier = "INCOMING_SSH_DISABLED"
}
depends_on = [aws_config_configuration_recorder.main]
tags = var.tags
}
# IAM Rules
resource "aws_config_config_rule" "iam_root_access_key_check" {
count = var.enable_aws_config && (var.rules.iam_root_access_key_check || var.enable_security_best_practices) ? 1 : 0
name = "iam-root-access-key-check"
source {
owner = "AWS"
source_identifier = "IAM_ROOT_ACCESS_KEY_CHECK"
}
depends_on = [aws_config_configuration_recorder.main]
tags = var.tags
}
resource "aws_config_config_rule" "iam_user_mfa_enabled" {
count = var.enable_aws_config && (var.rules.iam_user_mfa_enabled || var.enable_security_best_practices) ? 1 : 0
name = "iam-user-mfa-enabled"
source {
owner = "AWS"
source_identifier = "IAM_USER_MFA_ENABLED"
}
depends_on = [aws_config_configuration_recorder.main]
tags = var.tags
}
resource "aws_config_config_rule" "access_keys_rotated" {
count = var.enable_aws_config && (var.rules.access_keys_rotated || var.enable_security_best_practices) ? 1 : 0
name = "access-keys-rotated"
source {
owner = "AWS"
source_identifier = "ACCESS_KEYS_ROTATED"
}
input_parameters = jsonencode({
maxAccessKeyAge = var.rules.access_keys_rotated_days
})
depends_on = [aws_config_configuration_recorder.main]
tags = var.tags
}
# RDS Rules
resource "aws_config_config_rule" "rds_instance_public_access_check" {
count = var.enable_aws_config && (var.rules.rds_instance_public_access_check || var.enable_security_best_practices) ? 1 : 0
name = "rds-instance-public-access-check"
source {
owner = "AWS"
source_identifier = "RDS_INSTANCE_PUBLIC_ACCESS_CHECK"
}
depends_on = [aws_config_configuration_recorder.main]
tags = var.tags
}
resource "aws_config_config_rule" "rds_storage_encrypted" {
count = var.enable_aws_config && (var.rules.rds_storage_encrypted || var.enable_security_best_practices) ? 1 : 0
name = "rds-storage-encrypted"
source {
owner = "AWS"
source_identifier = "RDS_STORAGE_ENCRYPTED"
}
depends_on = [aws_config_configuration_recorder.main]
tags = var.tags
}
# Network Rules
resource "aws_config_config_rule" "vpc_flow_logs_enabled" {
count = var.enable_aws_config && (var.rules.vpc_flow_logs_enabled || var.enable_security_best_practices) ? 1 : 0
name = "vpc-flow-logs-enabled"
source {
owner = "AWS"
source_identifier = "VPC_FLOW_LOGS_ENABLED"
}
depends_on = [aws_config_configuration_recorder.main]
tags = var.tags
}
resource "aws_config_config_rule" "vpc_default_security_group_closed" {
count = var.enable_aws_config && (var.rules.vpc_default_security_group_closed || var.enable_security_best_practices) ? 1 : 0
name = "vpc-default-security-group-closed"
source {
owner = "AWS"
source_identifier = "VPC_DEFAULT_SECURITY_GROUP_CLOSED"
}
depends_on = [aws_config_configuration_recorder.main]
tags = var.tags
}
# CloudTrail Rule
resource "aws_config_config_rule" "cloudtrail_enabled" {
count = var.enable_aws_config && (var.rules.cloudtrail_enabled || var.enable_security_best_practices) ? 1 : 0
name = "cloudtrail-enabled"
source {
owner = "AWS"
source_identifier = "CLOUD_TRAIL_ENABLED"
}
depends_on = [aws_config_configuration_recorder.main]
tags = var.tags
}
# Encryption Rules
resource "aws_config_config_rule" "encrypted_volumes" {
count = var.enable_aws_config && (var.rules.encrypted_volumes || var.enable_security_best_practices) ? 1 : 0
name = "encrypted-volumes"
source {
owner = "AWS"
source_identifier = "ENCRYPTED_VOLUMES"
}
depends_on = [aws_config_configuration_recorder.main]
tags = var.tags
}
# Lambda Rules
resource "aws_config_config_rule" "lambda_function_public_access_prohibited" {
count = var.enable_aws_config && (var.rules.lambda_function_public_access_prohibited || var.enable_security_best_practices) ? 1 : 0
name = "lambda-function-public-access-prohibited"
source {
owner = "AWS"
source_identifier = "LAMBDA_FUNCTION_PUBLIC_ACCESS_PROHIBITED"
}
depends_on = [aws_config_configuration_recorder.main]
tags = var.tags
}
################################################################################
# Outputs
################################################################################
output "config_recorder_id" {
value = var.enable_aws_config ? aws_config_configuration_recorder.main[0].id : null
description = "Config recorder ID"
}
output "config_bucket" {
value = local.config_bucket
description = "S3 bucket for Config snapshots"
}
output "enabled_rules" {
value = var.enable_aws_config ? {
s3_public_read = var.rules.s3_bucket_public_read_prohibited || var.enable_security_best_practices
s3_public_write = var.rules.s3_bucket_public_write_prohibited || var.enable_security_best_practices
s3_ssl_only = var.rules.s3_bucket_ssl_requests_only || var.enable_security_best_practices
ec2_imdsv2 = var.rules.ec2_imdsv2_check || var.enable_security_best_practices
ebs_encryption = var.rules.ec2_ebs_encryption_by_default || var.enable_security_best_practices
restricted_ssh = var.rules.restricted_ssh || var.enable_security_best_practices
iam_root_key = var.rules.iam_root_access_key_check || var.enable_security_best_practices
iam_mfa = var.rules.iam_user_mfa_enabled || var.enable_security_best_practices
access_key_rotation = var.rules.access_keys_rotated || var.enable_security_best_practices
rds_public = var.rules.rds_instance_public_access_check || var.enable_security_best_practices
rds_encrypted = var.rules.rds_storage_encrypted || var.enable_security_best_practices
vpc_flow_logs = var.rules.vpc_flow_logs_enabled || var.enable_security_best_practices
cloudtrail = var.rules.cloudtrail_enabled || var.enable_security_best_practices
} : null
description = "List of enabled Config rules"
}
output "compliance_packs" {
value = {
cis_benchmark = var.enable_cis_benchmark
security_best = var.enable_security_best_practices
pci_dss = var.enable_pci_dss
hipaa = var.enable_hipaa
}
description = "Enabled compliance packs"
}

View File

@@ -0,0 +1,229 @@
# GitHub OIDC Module
Secure CI/CD access from GitHub Actions to AWS without long-lived credentials.
## Features
- 🔐 **OIDC Provider** - Automatic setup of GitHub OIDC trust
- 🎯 **Fine-grained access** - Restrict by repo, branch, tag, environment
- 📦 **Pre-built templates** - Common patterns for Terraform, ECR, S3, Lambda
- 🔧 **Custom roles** - Full flexibility for any use case
- 📝 **Policy generation** - Build policies from simple statements
## Quick Start
```hcl
module "github_oidc" {
source = "../modules/github-oidc"
github_org = "myorg"
# Custom role
roles = {
deploy = {
repos = ["myrepo"]
branches = ["main"]
policy_arns = ["arn:aws:iam::aws:policy/PowerUserAccess"]
}
}
}
```
## Pre-built Templates
### Terraform Deployments
```hcl
module "github_oidc" {
source = "../modules/github-oidc"
github_org = "myorg"
terraform_deploy_role = {
enabled = true
repos = ["infrastructure"]
branches = ["main"]
environments = ["production"]
state_bucket = "myorg-tf-state"
dynamodb_table = "terraform-locks"
}
}
```
### ECR Push
```hcl
module "github_oidc" {
source = "../modules/github-oidc"
github_org = "myorg"
ecr_push_role = {
enabled = true
repos = ["backend", "frontend"]
branches = ["main", "develop"]
ecr_repos = ["backend", "frontend"]
allow_create = false
}
}
```
### S3 Static Site Deploy
```hcl
module "github_oidc" {
source = "../modules/github-oidc"
github_org = "myorg"
s3_deploy_role = {
enabled = true
repos = ["website"]
branches = ["main"]
bucket_arns = ["arn:aws:s3:::mysite.com"]
cloudfront_arns = ["arn:aws:cloudfront::123456789012:distribution/EXAMPLE"]
}
}
```
### Lambda Deploy
```hcl
module "github_oidc" {
source = "../modules/github-oidc"
github_org = "myorg"
lambda_deploy_role = {
enabled = true
repos = ["serverless-api"]
branches = ["main"]
function_arns = ["arn:aws:lambda:us-east-1:123456789012:function:my-api"]
}
}
```
## Advanced Usage
### Multiple Custom Roles
```hcl
module "github_oidc" {
source = "../modules/github-oidc"
github_org = "myorg"
roles = {
# Read-only for PRs
preview = {
repos = ["webapp"]
pull_request = true
policy_arns = ["arn:aws:iam::aws:policy/ReadOnlyAccess"]
}
# Full deploy for main
deploy = {
repos = ["webapp"]
branches = ["main"]
policy_arns = ["arn:aws:iam::aws:policy/PowerUserAccess"]
}
# Tag-based releases
release = {
repos = ["webapp"]
tags = ["v*"]
policy_statements = [{
actions = ["s3:PutObject", "cloudfront:CreateInvalidation"]
resources = ["*"]
}]
}
}
}
```
### Reusable Workflow Restriction
```hcl
roles = {
deploy = {
repos = ["*"] # Any repo in org
workflow_ref = "myorg/workflows/.github/workflows/deploy.yml@main"
policy_arns = ["arn:aws:iam::aws:policy/PowerUserAccess"]
}
}
```
### Custom Trust Conditions
```hcl
roles = {
restricted = {
repos = ["myrepo"]
branches = ["main"]
extra_conditions = {
StringEquals = {
"token.actions.githubusercontent.com:actor" = ["trusted-user"]
}
}
policy_arns = ["arn:aws:iam::aws:policy/AdministratorAccess"]
}
}
```
## GitHub Actions Workflow
```yaml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write # Required for OIDC
contents: read
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-deploy
aws-region: us-east-1
- run: aws sts get-caller-identity
```
## Inputs
| Name | Description | Type | Default |
|------|-------------|------|---------|
| `create_provider` | Create OIDC provider | `bool` | `true` |
| `provider_arn` | Existing provider ARN | `string` | `""` |
| `github_org` | GitHub organization | `string` | `""` |
| `name_prefix` | Role name prefix | `string` | `"github"` |
| `roles` | Custom role configs | `map(object)` | `{}` |
| `terraform_deploy_role` | Terraform template | `object` | `{}` |
| `ecr_push_role` | ECR template | `object` | `{}` |
| `s3_deploy_role` | S3 template | `object` | `{}` |
| `lambda_deploy_role` | Lambda template | `object` | `{}` |
## Outputs
| Name | Description |
|------|-------------|
| `provider_arn` | OIDC provider ARN |
| `role_arns` | Map of custom role ARNs |
| `all_role_arns` | All role ARNs (custom + templates) |
| `terraform_role_arn` | Terraform role ARN |
| `ecr_role_arn` | ECR role ARN |
| `workflow_examples` | Example workflow snippets |
## Security Considerations
1. **Principle of least privilege** - Use specific repos/branches, not wildcards
2. **Environment protection** - Use GitHub environments for production
3. **Permissions boundary** - Consider attaching a boundary for defense-in-depth
4. **Audit** - CloudTrail logs all AssumeRoleWithWebIdentity calls

View File

@@ -0,0 +1,54 @@
################################################################################
# GitHub OIDC - Basic Example
#
# Single role with branch restriction
################################################################################
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
module "github_oidc" {
source = "../../"
github_org = "example-org"
name_prefix = "github"
roles = {
deploy = {
repos = ["my-app"]
branches = ["main"]
policy_statements = [
{
sid = "S3Access"
actions = ["s3:GetObject", "s3:PutObject"]
resources = ["arn:aws:s3:::my-bucket/*"]
}
]
}
}
tags = {
Environment = "production"
Project = "my-app"
}
}
output "role_arn" {
value = module.github_oidc.role_arns["deploy"]
}
output "provider_arn" {
value = module.github_oidc.provider_arn
}

View File

@@ -0,0 +1,126 @@
################################################################################
# GitHub OIDC - Multi-Role Example
#
# Multiple roles with different permission levels
################################################################################
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
# Permissions boundary for defense-in-depth
resource "aws_iam_policy" "github_boundary" {
name = "GitHubActionsBoundary"
description = "Permissions boundary for GitHub Actions roles"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowedServices"
Effect = "Allow"
Action = ["s3:*", "ecr:*", "lambda:*", "logs:*", "cloudwatch:*"]
Resource = "*"
},
{
Sid = "DenyDangerous"
Effect = "Deny"
Action = [
"iam:CreateUser",
"iam:CreateAccessKey",
"organizations:*",
"account:*"
]
Resource = "*"
}
]
})
}
module "github_oidc" {
source = "../../"
github_org = "example-org"
name_prefix = "github"
permissions_boundary = aws_iam_policy.github_boundary.arn
# Security settings
max_session_hours_limit = 2
deny_wildcard_repos = true
roles = {
# Read-only for PR validation
validate = {
repos = ["infrastructure", "application"]
pull_request = true
policy_arns = ["arn:aws:iam::aws:policy/ReadOnlyAccess"]
max_session_hours = 1
}
# Deploy from main branch only
deploy = {
repos = ["infrastructure"]
branches = ["main"]
environments = ["production"]
policy_statements = [
{
sid = "DeployAccess"
actions = ["s3:*", "cloudfront:*", "lambda:*"]
resources = ["*"]
}
]
max_session_hours = 2
}
# Release automation from tags
release = {
repos = ["application"]
tags = ["v*", "release-*"]
branches = [] # Only tags
policy_statements = [
{
sid = "ECRPush"
actions = ["ecr:*"]
resources = ["arn:aws:ecr:*:*:repository/application"]
}
]
}
# Reusable workflow restriction
shared = {
repos = ["*"] # Any repo
workflow_ref = "example-org/shared-workflows/.github/workflows/deploy.yml@main"
policy_statements = [
{
sid = "SharedDeploy"
actions = ["s3:PutObject"]
resources = ["arn:aws:s3:::artifacts-bucket/*"]
}
]
}
}
tags = {
Environment = "production"
CostCenter = "platform"
}
}
output "all_roles" {
value = module.github_oidc.all_role_arns
}
output "security_status" {
value = module.github_oidc.security_recommendations
}

View File

@@ -0,0 +1,159 @@
################################################################################
# GitHub OIDC - Pre-built Templates Example
#
# Using pre-built role templates for common patterns
################################################################################
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
data "aws_caller_identity" "current" {}
# Prerequisites - S3 bucket for Terraform state
resource "aws_s3_bucket" "terraform_state" {
bucket_prefix = "terraform-state-"
force_destroy = true # For example only - remove in production
tags = {
Purpose = "terraform-state"
}
}
resource "aws_s3_bucket_versioning" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_dynamodb_table" "terraform_locks" {
name = "terraform-locks"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
tags = {
Purpose = "terraform-locks"
}
}
# ECR repository for container builds
resource "aws_ecr_repository" "app" {
name = "my-application"
image_tag_mutability = "IMMUTABLE"
image_scanning_configuration {
scan_on_push = true
}
tags = {
Purpose = "container-registry"
}
}
# GitHub OIDC with all templates enabled
module "github_oidc" {
source = "../../"
github_org = "example-org"
name_prefix = "github"
# Terraform deployment role
terraform_deploy_role = {
enabled = true
repos = ["infrastructure"]
branches = ["main"]
environments = ["production"]
state_bucket = aws_s3_bucket.terraform_state.id
state_bucket_key_prefix = "live/*"
dynamodb_table = aws_dynamodb_table.terraform_locks.name
allowed_services = ["ec2", "s3", "iam", "lambda", "rds", "vpc"]
denied_actions = [
"iam:CreateUser",
"iam:CreateAccessKey",
"organizations:*"
]
}
# ECR push role for container builds
ecr_push_role = {
enabled = true
repos = ["my-application", "backend-api"]
branches = ["main", "develop"]
ecr_repos = [aws_ecr_repository.app.name]
allow_create = false
allow_delete = false
}
# S3 deploy role for static sites
s3_deploy_role = {
enabled = true
repos = ["frontend"]
branches = ["main"]
bucket_arns = ["arn:aws:s3:::www.example.com"]
allowed_prefixes = ["*"]
cloudfront_arns = [] # Add CloudFront distribution ARN if needed
}
# Lambda deploy role for serverless
lambda_deploy_role = {
enabled = true
repos = ["serverless-api"]
branches = ["main"]
function_arns = ["arn:aws:lambda:us-east-1:${data.aws_caller_identity.current.account_id}:function:api-*"]
allow_create = false
allow_logs = true
}
tags = {
Environment = "production"
ManagedBy = "terraform"
}
}
# Outputs
output "terraform_role_arn" {
description = "Role ARN for Terraform deployments"
value = module.github_oidc.terraform_role_arn
}
output "ecr_role_arn" {
description = "Role ARN for ECR push operations"
value = module.github_oidc.ecr_role_arn
}
output "s3_deploy_role_arn" {
description = "Role ARN for S3 static site deployments"
value = module.github_oidc.s3_deploy_role_arn
}
output "lambda_deploy_role_arn" {
description = "Role ARN for Lambda deployments"
value = module.github_oidc.lambda_deploy_role_arn
}
output "all_roles" {
description = "All created role ARNs"
value = module.github_oidc.all_role_arns
}
output "workflow_examples" {
description = "Example workflow snippets"
value = module.github_oidc.workflow_examples
}

View File

@@ -0,0 +1,673 @@
################################################################################
# GitHub OIDC Module
#
# AWS/Terraform/Security Best Practices:
# - Least privilege IAM policies
# - Input validation
# - Explicit denies for dangerous actions
# - Session duration limits
# - CloudTrail monitoring integration
# - Permissions boundary support
# - No wildcard repos by default
#
# Security scanning: tfsec, checkov, tflint-aws
################################################################################
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
data "aws_partition" "current" {}
################################################################################
# Local Variables & Validation
################################################################################
locals {
account_id = data.aws_caller_identity.current.account_id
region = data.aws_region.current.id
partition = data.aws_partition.current.partition
# Validate permissions boundary requirement
boundary_check = var.require_permissions_boundary && var.permissions_boundary == null ? tobool("Permissions boundary is required but not set") : true
# Normalize repo names with org prefix
normalize_repo = { for k, v in var.roles : k => merge(v, {
repos = [for repo in v.repos :
!contains(split("/", repo), "/") && var.github_org != ""
? "${var.github_org}/${repo}"
: repo
]
# Cap session duration at limit
max_session_hours = min(v.max_session_hours, var.max_session_hours_limit)
})}
# Validate no wildcard repos unless workflow_ref is set
wildcard_check = var.deny_wildcard_repos ? alltrue([
for k, v in var.roles : !contains(v.repos, "*") || v.workflow_ref != ""
]) : true
_ = local.wildcard_check ? true : tobool("Wildcard repos (*) require workflow_ref restriction or deny_wildcard_repos=false")
# Common tags
common_tags = merge(var.tags, {
ManagedBy = "terraform"
Module = "github-oidc"
})
}
################################################################################
# OIDC Provider
################################################################################
data "tls_certificate" "github" {
count = var.create_provider ? 1 : 0
url = "https://token.actions.githubusercontent.com"
}
resource "aws_iam_openid_connect_provider" "github" {
count = var.create_provider ? 1 : 0
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = [data.tls_certificate.github[0].certificates[0].sha1_fingerprint]
tags = merge(local.common_tags, {
Name = "github-actions-oidc"
Description = "GitHub Actions OIDC Identity Provider"
})
}
locals {
provider_arn = var.create_provider ? aws_iam_openid_connect_provider.github[0].arn : var.provider_arn
}
################################################################################
# Custom Roles
################################################################################
resource "aws_iam_role" "github" {
for_each = local.normalize_repo
name = "${var.name_prefix}-${each.key}"
path = var.path
description = "GitHub Actions: ${join(", ", each.value.repos)}"
max_session_duration = each.value.max_session_hours * 3600
permissions_boundary = var.permissions_boundary
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "GitHubActionsOIDC"
Effect = "Allow"
Action = "sts:AssumeRoleWithWebIdentity"
Principal = {
Federated = local.provider_arn
}
Condition = merge(
{
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
StringLike = {
"token.actions.githubusercontent.com:sub" = distinct(compact(concat(
# Branch-based subjects
flatten([for repo in each.value.repos :
length(each.value.branches) > 0
? [for branch in each.value.branches : "repo:${repo}:ref:refs/heads/${branch}"]
: length(each.value.tags) == 0 && length(each.value.environments) == 0 && !each.value.pull_request
? ["repo:${repo}:*"]
: []
]),
# Tag-based subjects
flatten([for repo in each.value.repos :
[for tag in each.value.tags : "repo:${repo}:ref:refs/tags/${tag}"]
]),
# Environment-based subjects
flatten([for repo in each.value.repos :
[for env in each.value.environments : "repo:${repo}:environment:${env}"]
]),
# Pull request subjects
each.value.pull_request
? [for repo in each.value.repos : "repo:${repo}:pull_request"]
: []
)))
}
},
# Workflow ref condition (for reusable workflows)
each.value.workflow_ref != "" ? {
StringEquals = merge(
{ "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" },
{ "token.actions.githubusercontent.com:job_workflow_ref" = each.value.workflow_ref }
)
} : {},
# Extra conditions
each.value.extra_conditions
)
}]
})
tags = merge(local.common_tags, {
Name = "${var.name_prefix}-${each.key}"
GitHubRepos = join(",", slice(each.value.repos, 0, min(5, length(each.value.repos))))
Purpose = "github-actions-oidc"
})
}
# Managed policy attachments
resource "aws_iam_role_policy_attachment" "github" {
for_each = {
for pair in flatten([
for role_name, role in local.normalize_repo : [
for policy_arn in role.policy_arns : {
role_name = role_name
policy_arn = policy_arn
}
]
]) : "${pair.role_name}-${md5(pair.policy_arn)}" => pair
}
role = aws_iam_role.github[each.value.role_name].name
policy_arn = each.value.policy_arn
}
# Inline policies (raw JSON)
resource "aws_iam_role_policy" "github_inline" {
for_each = { for k, v in local.normalize_repo : k => v if v.inline_policy != "" }
name = "inline"
role = aws_iam_role.github[each.key].id
policy = each.value.inline_policy
}
# Generated policies from statements
resource "aws_iam_role_policy" "github_generated" {
for_each = { for k, v in local.normalize_repo : k => v if length(v.policy_statements) > 0 }
name = "generated"
role = aws_iam_role.github[each.key].id
policy = jsonencode({
Version = "2012-10-17"
Statement = [for stmt in each.value.policy_statements : {
Sid = stmt.sid != "" ? stmt.sid : null
Effect = stmt.effect
Action = stmt.actions
Resource = stmt.resources
Condition = length(stmt.conditions) > 0 ? {
for cond in stmt.conditions : cond.test => {
"${cond.variable}" = cond.values
}
} : null
}]
})
}
################################################################################
# Terraform Deploy Role (Template)
################################################################################
locals {
tf_role_enabled = try(var.terraform_deploy_role.enabled, false)
tf_repos = try(var.terraform_deploy_role.repos, [])
tf_repos_normalized = [for repo in local.tf_repos :
!contains(split("/", repo), "/") && var.github_org != ""
? "${var.github_org}/${repo}"
: repo
]
}
resource "aws_iam_role" "terraform" {
count = local.tf_role_enabled ? 1 : 0
name = "${var.name_prefix}-terraform"
path = var.path
description = "GitHub Actions - Terraform deployment"
max_session_duration = min(2, var.max_session_hours_limit) * 3600
permissions_boundary = var.permissions_boundary
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "GitHubActionsTerraform"
Effect = "Allow"
Action = "sts:AssumeRoleWithWebIdentity"
Principal = { Federated = local.provider_arn }
Condition = {
StringEquals = { "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" }
StringLike = {
"token.actions.githubusercontent.com:sub" = concat(
flatten([for repo in local.tf_repos_normalized :
length(try(var.terraform_deploy_role.branches, [])) > 0
? [for branch in var.terraform_deploy_role.branches : "repo:${repo}:ref:refs/heads/${branch}"]
: ["repo:${repo}:*"]
]),
flatten([for repo in local.tf_repos_normalized :
[for env in try(var.terraform_deploy_role.environments, []) : "repo:${repo}:environment:${env}"]
])
)
}
}
}]
})
tags = merge(local.common_tags, {
Name = "${var.name_prefix}-terraform"
Purpose = "terraform-deployment"
})
}
resource "aws_iam_role_policy" "terraform_state" {
count = local.tf_role_enabled && try(var.terraform_deploy_role.state_bucket, "") != "" ? 1 : 0
name = "terraform-state"
role = aws_iam_role.terraform[0].id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "TerraformStateBucket"
Effect = "Allow"
Action = [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject"
]
Resource = "arn:${local.partition}:s3:::${var.terraform_deploy_role.state_bucket}/${try(var.terraform_deploy_role.state_bucket_key_prefix, "*")}"
},
{
Sid = "TerraformStateBucketList"
Effect = "Allow"
Action = ["s3:ListBucket"]
Resource = "arn:${local.partition}:s3:::${var.terraform_deploy_role.state_bucket}"
Condition = {
StringLike = {
"s3:prefix" = [try(var.terraform_deploy_role.state_bucket_key_prefix, "*")]
}
}
}
]
})
}
resource "aws_iam_role_policy" "terraform_lock" {
count = local.tf_role_enabled && try(var.terraform_deploy_role.dynamodb_table, "") != "" ? 1 : 0
name = "terraform-lock"
role = aws_iam_role.terraform[0].id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "TerraformLockTable"
Effect = "Allow"
Action = [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:DeleteItem"
]
Resource = "arn:${local.partition}:dynamodb:*:${local.account_id}:table/${var.terraform_deploy_role.dynamodb_table}"
}]
})
}
# Service-specific permissions (least privilege approach)
resource "aws_iam_role_policy" "terraform_services" {
count = local.tf_role_enabled && length(try(var.terraform_deploy_role.allowed_services, [])) > 0 ? 1 : 0
name = "terraform-services"
role = aws_iam_role.terraform[0].id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "AllowedServices"
Effect = "Allow"
Action = flatten([for svc in var.terraform_deploy_role.allowed_services : "${svc}:*"])
Resource = "*"
}]
})
}
# Explicit denies for dangerous actions
resource "aws_iam_role_policy" "terraform_deny" {
count = local.tf_role_enabled && length(try(var.terraform_deploy_role.denied_actions, [])) > 0 ? 1 : 0
name = "terraform-deny"
role = aws_iam_role.terraform[0].id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "ExplicitDeny"
Effect = "Deny"
Action = var.terraform_deploy_role.denied_actions
Resource = "*"
}]
})
}
################################################################################
# ECR Push Role (Template)
################################################################################
locals {
ecr_role_enabled = try(var.ecr_push_role.enabled, false)
ecr_repos_gh = try(var.ecr_push_role.repos, [])
ecr_repos_normalized = [for repo in local.ecr_repos_gh :
!contains(split("/", repo), "/") && var.github_org != ""
? "${var.github_org}/${repo}"
: repo
]
}
resource "aws_iam_role" "ecr" {
count = local.ecr_role_enabled ? 1 : 0
name = "${var.name_prefix}-ecr-push"
path = var.path
description = "GitHub Actions - ECR push"
max_session_duration = 3600
permissions_boundary = var.permissions_boundary
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "GitHubActionsECR"
Effect = "Allow"
Action = "sts:AssumeRoleWithWebIdentity"
Principal = { Federated = local.provider_arn }
Condition = {
StringEquals = { "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" }
StringLike = {
"token.actions.githubusercontent.com:sub" = flatten([for repo in local.ecr_repos_normalized :
length(try(var.ecr_push_role.branches, [])) > 0
? [for branch in var.ecr_push_role.branches : "repo:${repo}:ref:refs/heads/${branch}"]
: ["repo:${repo}:*"]
])
}
}
}]
})
tags = merge(local.common_tags, {
Name = "${var.name_prefix}-ecr-push"
Purpose = "ecr-push"
})
}
resource "aws_iam_role_policy" "ecr" {
count = local.ecr_role_enabled ? 1 : 0
name = "ecr-push"
role = aws_iam_role.ecr[0].id
policy = jsonencode({
Version = "2012-10-17"
Statement = concat(
[{
Sid = "ECRAuth"
Effect = "Allow"
Action = "ecr:GetAuthorizationToken"
Resource = "*" # Required - GetAuthorizationToken doesn't support resource constraints
}],
[{
Sid = "ECRPush"
Effect = "Allow"
Action = [
"ecr:BatchCheckLayerAvailability",
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage",
"ecr:PutImage",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload",
"ecr:DescribeRepositories",
"ecr:DescribeImages"
]
Resource = [for repo in try(var.ecr_push_role.ecr_repos, []) :
"arn:${local.partition}:ecr:*:${local.account_id}:repository/${repo}"
]
}],
try(var.ecr_push_role.allow_create, false) ? [{
Sid = "ECRCreate"
Effect = "Allow"
Action = ["ecr:CreateRepository", "ecr:TagResource"]
Resource = "arn:${local.partition}:ecr:*:${local.account_id}:repository/*"
}] : [],
try(var.ecr_push_role.allow_delete, false) ? [{
Sid = "ECRDelete"
Effect = "Allow"
Action = ["ecr:DeleteRepository", "ecr:BatchDeleteImage"]
Resource = [for repo in try(var.ecr_push_role.ecr_repos, []) :
"arn:${local.partition}:ecr:*:${local.account_id}:repository/${repo}"
]
}] : []
)
})
}
################################################################################
# S3 Deploy Role (Template)
################################################################################
locals {
s3_role_enabled = try(var.s3_deploy_role.enabled, false)
s3_repos = try(var.s3_deploy_role.repos, [])
s3_repos_normalized = [for repo in local.s3_repos :
!contains(split("/", repo), "/") && var.github_org != ""
? "${var.github_org}/${repo}"
: repo
]
}
resource "aws_iam_role" "s3_deploy" {
count = local.s3_role_enabled ? 1 : 0
name = "${var.name_prefix}-s3-deploy"
path = var.path
description = "GitHub Actions - S3 deployment"
max_session_duration = 3600
permissions_boundary = var.permissions_boundary
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "GitHubActionsS3Deploy"
Effect = "Allow"
Action = "sts:AssumeRoleWithWebIdentity"
Principal = { Federated = local.provider_arn }
Condition = {
StringEquals = { "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" }
StringLike = {
"token.actions.githubusercontent.com:sub" = flatten([for repo in local.s3_repos_normalized :
length(try(var.s3_deploy_role.branches, [])) > 0
? [for branch in var.s3_deploy_role.branches : "repo:${repo}:ref:refs/heads/${branch}"]
: ["repo:${repo}:*"]
])
}
}
}]
})
tags = merge(local.common_tags, {
Name = "${var.name_prefix}-s3-deploy"
Purpose = "s3-static-site"
})
}
resource "aws_iam_role_policy" "s3_deploy" {
count = local.s3_role_enabled ? 1 : 0
name = "s3-deploy"
role = aws_iam_role.s3_deploy[0].id
policy = jsonencode({
Version = "2012-10-17"
Statement = concat(
[{
Sid = "S3Deploy"
Effect = "Allow"
Action = [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject",
"s3:GetObjectAcl",
"s3:PutObjectAcl"
]
Resource = flatten([for bucket in try(var.s3_deploy_role.bucket_arns, []) : [
for prefix in try(var.s3_deploy_role.allowed_prefixes, ["*"]) :
"${bucket}/${prefix}"
]])
}],
[{
Sid = "S3List"
Effect = "Allow"
Action = ["s3:ListBucket", "s3:GetBucketLocation"]
Resource = try(var.s3_deploy_role.bucket_arns, [])
}],
length(try(var.s3_deploy_role.cloudfront_arns, [])) > 0 ? [{
Sid = "CloudFrontInvalidate"
Effect = "Allow"
Action = "cloudfront:CreateInvalidation"
Resource = var.s3_deploy_role.cloudfront_arns
}] : []
)
})
}
################################################################################
# Lambda Deploy Role (Template)
################################################################################
locals {
lambda_role_enabled = try(var.lambda_deploy_role.enabled, false)
lambda_repos = try(var.lambda_deploy_role.repos, [])
lambda_repos_normalized = [for repo in local.lambda_repos :
!contains(split("/", repo), "/") && var.github_org != ""
? "${var.github_org}/${repo}"
: repo
]
}
resource "aws_iam_role" "lambda_deploy" {
count = local.lambda_role_enabled ? 1 : 0
name = "${var.name_prefix}-lambda-deploy"
path = var.path
description = "GitHub Actions - Lambda deployment"
max_session_duration = 3600
permissions_boundary = var.permissions_boundary
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "GitHubActionsLambda"
Effect = "Allow"
Action = "sts:AssumeRoleWithWebIdentity"
Principal = { Federated = local.provider_arn }
Condition = {
StringEquals = { "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" }
StringLike = {
"token.actions.githubusercontent.com:sub" = flatten([for repo in local.lambda_repos_normalized :
length(try(var.lambda_deploy_role.branches, [])) > 0
? [for branch in var.lambda_deploy_role.branches : "repo:${repo}:ref:refs/heads/${branch}"]
: ["repo:${repo}:*"]
])
}
}
}]
})
tags = merge(local.common_tags, {
Name = "${var.name_prefix}-lambda-deploy"
Purpose = "lambda-deployment"
})
}
resource "aws_iam_role_policy" "lambda_deploy" {
count = local.lambda_role_enabled ? 1 : 0
name = "lambda-deploy"
role = aws_iam_role.lambda_deploy[0].id
policy = jsonencode({
Version = "2012-10-17"
Statement = concat(
[{
Sid = "LambdaDeploy"
Effect = "Allow"
Action = [
"lambda:UpdateFunctionCode",
"lambda:UpdateFunctionConfiguration",
"lambda:GetFunction",
"lambda:GetFunctionConfiguration",
"lambda:PublishVersion",
"lambda:ListVersionsByFunction"
]
Resource = try(var.lambda_deploy_role.function_arns, [])
}],
try(var.lambda_deploy_role.allow_create, false) ? [{
Sid = "LambdaCreate"
Effect = "Allow"
Action = [
"lambda:CreateFunction",
"lambda:DeleteFunction",
"lambda:TagResource",
"lambda:AddPermission",
"lambda:RemovePermission"
]
Resource = "arn:${local.partition}:lambda:*:${local.account_id}:function:*"
}] : [],
try(var.lambda_deploy_role.allow_create, false) ? [{
Sid = "IAMPassRole"
Effect = "Allow"
Action = "iam:PassRole"
Resource = "arn:${local.partition}:iam::${local.account_id}:role/*"
Condition = {
StringEquals = {
"iam:PassedToService" = "lambda.amazonaws.com"
}
}
}] : [],
try(var.lambda_deploy_role.allow_logs, true) ? [{
Sid = "CloudWatchLogs"
Effect = "Allow"
Action = [
"logs:DescribeLogGroups",
"logs:DescribeLogStreams",
"logs:GetLogEvents"
]
Resource = "arn:${local.partition}:logs:*:${local.account_id}:log-group:/aws/lambda/*"
}] : []
)
})
}
################################################################################
# Security Monitoring (Optional)
################################################################################
resource "aws_cloudwatch_log_metric_filter" "oidc_assume_role" {
count = var.enable_cloudtrail_logging && var.alarm_sns_topic_arn != "" ? 1 : 0
name = "github-oidc-assume-role"
pattern = "{ ($.eventName = AssumeRoleWithWebIdentity) && ($.requestParameters.roleArn = \"*${var.name_prefix}*\") }"
log_group_name = "aws-cloudtrail-logs" # Adjust to your CloudTrail log group
metric_transformation {
name = "GitHubOIDCAssumeRole"
namespace = "Security/OIDC"
value = "1"
}
}
resource "aws_cloudwatch_metric_alarm" "oidc_high_usage" {
count = var.enable_cloudtrail_logging && var.alarm_sns_topic_arn != "" ? 1 : 0
alarm_name = "github-oidc-high-usage"
alarm_description = "High number of GitHub OIDC role assumptions"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 1
metric_name = "GitHubOIDCAssumeRole"
namespace = "Security/OIDC"
period = 300
statistic = "Sum"
threshold = 100
treat_missing_data = "notBreaching"
alarm_actions = [var.alarm_sns_topic_arn]
tags = local.common_tags
}

View File

@@ -0,0 +1,159 @@
################################################################################
# GitHub OIDC Module - Outputs
################################################################################
output "provider_arn" {
value = local.provider_arn
description = "GitHub OIDC provider ARN"
}
output "provider_url" {
value = "https://token.actions.githubusercontent.com"
description = "GitHub OIDC provider URL"
}
# Custom roles
output "role_arns" {
value = { for k, v in aws_iam_role.github : k => v.arn }
description = "Map of custom role names to ARNs"
}
output "role_names" {
value = { for k, v in aws_iam_role.github : k => v.name }
description = "Map of custom role key to IAM role names"
}
# Template roles
output "terraform_role_arn" {
value = local.tf_role_enabled ? aws_iam_role.terraform[0].arn : null
description = "Terraform deploy role ARN"
}
output "terraform_role_name" {
value = local.tf_role_enabled ? aws_iam_role.terraform[0].name : null
description = "Terraform deploy role name"
}
output "ecr_role_arn" {
value = local.ecr_role_enabled ? aws_iam_role.ecr[0].arn : null
description = "ECR push role ARN"
}
output "ecr_role_name" {
value = local.ecr_role_enabled ? aws_iam_role.ecr[0].name : null
description = "ECR push role name"
}
output "s3_deploy_role_arn" {
value = local.s3_role_enabled ? aws_iam_role.s3_deploy[0].arn : null
description = "S3 deploy role ARN"
}
output "s3_deploy_role_name" {
value = local.s3_role_enabled ? aws_iam_role.s3_deploy[0].name : null
description = "S3 deploy role name"
}
output "lambda_deploy_role_arn" {
value = local.lambda_role_enabled ? aws_iam_role.lambda_deploy[0].arn : null
description = "Lambda deploy role ARN"
}
output "lambda_deploy_role_name" {
value = local.lambda_role_enabled ? aws_iam_role.lambda_deploy[0].name : null
description = "Lambda deploy role name"
}
# All role ARNs combined
output "all_role_arns" {
value = merge(
{ for k, v in aws_iam_role.github : k => v.arn },
local.tf_role_enabled ? { terraform = aws_iam_role.terraform[0].arn } : {},
local.ecr_role_enabled ? { ecr = aws_iam_role.ecr[0].arn } : {},
local.s3_role_enabled ? { s3_deploy = aws_iam_role.s3_deploy[0].arn } : {},
local.lambda_role_enabled ? { lambda_deploy = aws_iam_role.lambda_deploy[0].arn } : {}
)
description = "All role ARNs (custom + templates)"
}
# Security outputs
output "iam_path" {
value = var.path
description = "IAM path used for roles (useful for permissions boundaries)"
}
output "security_recommendations" {
value = {
permissions_boundary_set = var.permissions_boundary != null
max_session_limited = var.max_session_hours_limit < 12
wildcard_repos_denied = var.deny_wildcard_repos
cloudtrail_monitoring = var.enable_cloudtrail_logging
}
description = "Security configuration status"
}
# Workflow configuration helper
output "github_actions_config" {
value = {
aws_region = local.region
roles = merge(
{ for k, v in aws_iam_role.github : k => {
arn = v.arn
name = v.name
}},
local.tf_role_enabled ? { terraform = {
arn = aws_iam_role.terraform[0].arn
name = aws_iam_role.terraform[0].name
}} : {},
local.ecr_role_enabled ? { ecr = {
arn = aws_iam_role.ecr[0].arn
name = aws_iam_role.ecr[0].name
}} : {},
local.s3_role_enabled ? { s3_deploy = {
arn = aws_iam_role.s3_deploy[0].arn
name = aws_iam_role.s3_deploy[0].name
}} : {},
local.lambda_role_enabled ? { lambda_deploy = {
arn = aws_iam_role.lambda_deploy[0].arn
name = aws_iam_role.lambda_deploy[0].name
}} : {}
)
}
description = "Configuration for GitHub Actions workflows"
}
# Example workflow snippets
output "workflow_examples" {
value = {
basic = <<-EOF
# .github/workflows/deploy.yml
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: <ROLE_ARN>
aws-region: ${local.region}
role-session-name: github-actions-${"$"}{{ github.run_id }}
EOF
with_environment = <<-EOF
# .github/workflows/deploy.yml
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # Requires approval if configured
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: <ROLE_ARN>
aws-region: ${local.region}
EOF
}
description = "Example GitHub Actions workflow snippets"
}

View File

@@ -0,0 +1,210 @@
################################################################################
# GitHub OIDC Module - Basic Tests
# Uses Terraform native testing framework
################################################################################
# Mock AWS provider for unit tests
mock_provider "aws" {
mock_data "aws_caller_identity" {
defaults = {
account_id = "123456789012"
arn = "arn:aws:iam::123456789012:user/test"
user_id = "AIDATEST123456789"
}
}
mock_data "aws_region" {
defaults = {
name = "us-east-1"
}
}
mock_data "aws_partition" {
defaults = {
partition = "aws"
dns_suffix = "amazonaws.com"
}
}
}
# Test: Basic role creation
run "basic_role_creation" {
command = plan
variables {
github_org = "test-org"
roles = {
deploy = {
repos = ["test-repo"]
branches = ["main"]
policy_statements = [{
sid = "TestAccess"
actions = ["s3:GetObject"]
resources = ["arn:aws:s3:::test-bucket/*"]
}]
}
}
tags = {
Environment = "test"
}
}
# Verify OIDC provider is created
assert {
condition = aws_iam_openid_connect_provider.github[0].url == "https://token.actions.githubusercontent.com"
error_message = "OIDC provider URL is incorrect"
}
# Verify role is created with correct name
assert {
condition = aws_iam_role.github["deploy"].name == "github-deploy"
error_message = "Role name should be github-deploy"
}
# Verify IAM path is set correctly
assert {
condition = aws_iam_role.github["deploy"].path == "/github-actions/"
error_message = "Role path should be /github-actions/"
}
}
# Test: Repository normalization with org prefix
run "repo_normalization" {
command = plan
variables {
github_org = "my-org"
roles = {
test = {
repos = ["repo-without-org"] # Should become my-org/repo-without-org
branches = ["main"]
}
}
}
# Role should be created (validates normalization works)
assert {
condition = aws_iam_role.github["test"].name == "github-test"
error_message = "Role should be created with normalized repo"
}
}
# Test: Multiple roles with different configurations
run "multiple_roles" {
command = plan
variables {
github_org = "test-org"
roles = {
validate = {
repos = ["app"]
pull_request = true
max_session_hours = 1
}
deploy = {
repos = ["app"]
branches = ["main"]
max_session_hours = 2
}
release = {
repos = ["app"]
tags = ["v*"]
}
}
}
# Verify all roles are created
assert {
condition = length(aws_iam_role.github) == 3
error_message = "Should create 3 roles"
}
}
# Test: Terraform deploy template role
run "terraform_template_role" {
command = plan
variables {
github_org = "test-org"
terraform_deploy_role = {
enabled = true
repos = ["infra"]
branches = ["main"]
state_bucket = "my-tf-state"
dynamodb_table = "terraform-locks"
}
}
# Verify Terraform role is created
assert {
condition = aws_iam_role.terraform[0].name == "github-terraform"
error_message = "Terraform role should be created"
}
}
# Test: ECR push template role
run "ecr_template_role" {
command = plan
variables {
github_org = "test-org"
ecr_push_role = {
enabled = true
repos = ["app"]
branches = ["main"]
ecr_repos = ["my-ecr-repo"]
}
}
# Verify ECR role is created
assert {
condition = aws_iam_role.ecr[0].name == "github-ecr-push"
error_message = "ECR role should be created"
}
}
# Test: Session duration capping
run "session_duration_capping" {
command = plan
variables {
github_org = "test-org"
max_session_hours_limit = 2
roles = {
test = {
repos = ["app"]
branches = ["main"]
max_session_hours = 4 # Should be capped to 2
}
}
}
# Verify session duration is capped (2 hours = 7200 seconds)
assert {
condition = aws_iam_role.github["test"].max_session_duration == 7200
error_message = "Session duration should be capped at 2 hours (7200 seconds)"
}
}
# Test: Existing provider ARN (no provider creation)
run "existing_provider" {
command = plan
variables {
create_provider = false
provider_arn = "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
github_org = "test-org"
roles = {
test = {
repos = ["app"]
branches = ["main"]
}
}
}
# Verify no provider is created
assert {
condition = length(aws_iam_openid_connect_provider.github) == 0
error_message = "Should not create provider when create_provider=false"
}
}

View File

@@ -0,0 +1,203 @@
################################################################################
# GitHub OIDC Module - Security Tests
# Validates security best practices are enforced
################################################################################
mock_provider "aws" {
mock_data "aws_caller_identity" {
defaults = {
account_id = "123456789012"
}
}
mock_data "aws_region" {
defaults = {
name = "us-east-1"
}
}
mock_data "aws_partition" {
defaults = {
partition = "aws"
dns_suffix = "amazonaws.com"
}
}
}
# Test: Wildcard repos denied by default
run "wildcard_repos_denied" {
command = plan
variables {
github_org = "test-org"
deny_wildcard_repos = true
roles = {
test = {
repos = ["*"] # Wildcard - should fail without workflow_ref
branches = ["main"]
}
}
}
expect_failures = [
# This should fail validation because wildcard repos require workflow_ref
var.roles
]
}
# Test: Wildcard repos allowed with workflow_ref
run "wildcard_repos_with_workflow_ref" {
command = plan
variables {
github_org = "test-org"
deny_wildcard_repos = true
roles = {
test = {
repos = ["*"]
workflow_ref = "test-org/workflows/.github/workflows/deploy.yml@main"
}
}
}
# Should succeed because workflow_ref is specified
assert {
condition = aws_iam_role.github["test"].name == "github-test"
error_message = "Should allow wildcard with workflow_ref"
}
}
# Test: IAM path isolation
run "iam_path_isolation" {
command = plan
variables {
github_org = "test-org"
path = "/github-actions/"
roles = {
test = {
repos = ["app"]
branches = ["main"]
}
}
}
# Verify path is set for role isolation
assert {
condition = aws_iam_role.github["test"].path == "/github-actions/"
error_message = "Role should use isolated IAM path"
}
}
# Test: Permissions boundary is applied
run "permissions_boundary_applied" {
command = plan
variables {
github_org = "test-org"
permissions_boundary = "arn:aws:iam::123456789012:policy/TestBoundary"
roles = {
test = {
repos = ["app"]
branches = ["main"]
}
}
}
# Verify permissions boundary is set
assert {
condition = aws_iam_role.github["test"].permissions_boundary == "arn:aws:iam::123456789012:policy/TestBoundary"
error_message = "Permissions boundary should be applied to role"
}
}
# Test: Terraform role has explicit denies
run "terraform_role_explicit_denies" {
command = plan
variables {
github_org = "test-org"
terraform_deploy_role = {
enabled = true
repos = ["infra"]
branches = ["main"]
denied_actions = ["iam:CreateUser", "organizations:*"]
}
}
# Verify deny policy is created
assert {
condition = aws_iam_role_policy.terraform_deny[0].name == "terraform-deny"
error_message = "Terraform deny policy should be created"
}
}
# Test: ECR role requires explicit repos
run "ecr_explicit_repos_required" {
command = plan
variables {
github_org = "test-org"
ecr_push_role = {
enabled = true
repos = ["app"]
ecr_repos = ["my-ecr-repo"] # Explicit ECR repo required
}
}
# Should succeed with explicit ECR repos
assert {
condition = aws_iam_role.ecr[0].name == "github-ecr-push"
error_message = "ECR role should be created with explicit repos"
}
}
# Test: Role tags include security metadata
run "security_tags" {
command = plan
variables {
github_org = "test-org"
roles = {
test = {
repos = ["app"]
branches = ["main"]
}
}
tags = {
Environment = "production"
}
}
# Verify tags include ManagedBy and Module
assert {
condition = aws_iam_role.github["test"].tags["ManagedBy"] == "terraform"
error_message = "Role should have ManagedBy tag"
}
assert {
condition = aws_iam_role.github["test"].tags["Module"] == "github-oidc"
error_message = "Role should have Module tag"
}
}
# Test: Trust policy uses StringLike for subject claims
run "trust_policy_string_like" {
command = plan
variables {
github_org = "test-org"
roles = {
test = {
repos = ["app"]
branches = ["main", "develop"] # Multiple branches
}
}
}
# Role should be created with proper trust policy
assert {
condition = aws_iam_role.github["test"].assume_role_policy != ""
error_message = "Trust policy should be set"
}
}

View File

@@ -0,0 +1,248 @@
################################################################################
# GitHub OIDC Module - Variables
# With AWS/Terraform/Security Best Practices Validation
################################################################################
variable "create_provider" {
type = bool
default = true
description = "Create the OIDC provider. Set false if already exists in account."
}
variable "provider_arn" {
type = string
default = ""
description = "Existing OIDC provider ARN (required if create_provider = false)"
validation {
condition = var.provider_arn == "" || can(regex("^arn:aws:iam::[0-9]{12}:oidc-provider/", var.provider_arn))
error_message = "Provider ARN must be a valid IAM OIDC provider ARN."
}
}
variable "github_org" {
type = string
default = ""
description = "GitHub organization. If set, prepended to repos that don't include org."
validation {
condition = var.github_org == "" || can(regex("^[a-zA-Z0-9][a-zA-Z0-9-]*$", var.github_org))
error_message = "GitHub org must be alphanumeric with hyphens (no leading hyphen)."
}
}
variable "name_prefix" {
type = string
default = "github"
description = "Prefix for IAM role names"
validation {
condition = can(regex("^[a-zA-Z][a-zA-Z0-9-_]*$", var.name_prefix))
error_message = "Name prefix must start with letter, contain only alphanumeric, hyphens, underscores."
}
}
variable "path" {
type = string
default = "/github-actions/"
description = "IAM path for roles (enables easier permission boundaries)"
validation {
condition = can(regex("^/[a-zA-Z0-9/_-]*/$", var.path))
error_message = "IAM path must start and end with /, contain only alphanumeric, /, -, _."
}
}
variable "permissions_boundary" {
type = string
default = null
description = "ARN of permissions boundary to attach to roles (RECOMMENDED for defense-in-depth)"
validation {
condition = var.permissions_boundary == null || can(regex("^arn:aws:iam::[0-9]{12}:policy/", var.permissions_boundary))
error_message = "Permissions boundary must be a valid IAM policy ARN."
}
}
variable "require_permissions_boundary" {
type = bool
default = false
description = "Require a permissions boundary to be set (security guardrail)"
}
variable "max_session_hours_limit" {
type = number
default = 4
description = "Maximum allowed session duration in hours (caps role max_session_hours)"
validation {
condition = var.max_session_hours_limit >= 1 && var.max_session_hours_limit <= 12
error_message = "Max session hours must be between 1 and 12."
}
}
variable "deny_wildcard_repos" {
type = bool
default = true
description = "Deny roles that allow all repos (*). Set false only if using workflow_ref restriction."
}
variable "roles" {
type = map(object({
# Repository configuration
repos = list(string) # GitHub repos (owner/repo or just repo if github_org set)
branches = optional(list(string), []) # Branch restrictions (empty = all branches)
tags = optional(list(string), []) # Tag restrictions (e.g., ["v*", "release-*"])
environments = optional(list(string), []) # GitHub environment restrictions
# Event type restrictions
pull_request = optional(bool, false) # Allow from pull_request events
workflow_ref = optional(string, "") # Restrict to specific reusable workflow
# IAM configuration
policy_arns = optional(list(string), []) # Managed policy ARNs to attach
inline_policy = optional(string, "") # Inline policy JSON
policy_statements = optional(list(object({ # Policy statements to generate
sid = optional(string, "")
effect = optional(string, "Allow")
actions = list(string)
resources = list(string)
conditions = optional(list(object({
test = string
variable = string
values = list(string)
})), [])
})), [])
# Session configuration
max_session_hours = optional(number, 1) # Maximum session duration (1-12)
# Extra trust conditions
extra_conditions = optional(map(map(list(string))), {}) # Additional assume role conditions
}))
default = {}
description = "Map of role configurations for GitHub Actions"
validation {
condition = alltrue([
for k, v in var.roles : length(v.repos) > 0
])
error_message = "Each role must specify at least one repository."
}
validation {
condition = alltrue([
for k, v in var.roles : v.max_session_hours >= 1 && v.max_session_hours <= 12
])
error_message = "Role max_session_hours must be between 1 and 12."
}
validation {
condition = alltrue([
for k, v in var.roles : alltrue([
for repo in v.repos : can(regex("^[a-zA-Z0-9][a-zA-Z0-9-_.]*/[a-zA-Z0-9][a-zA-Z0-9-_.]*$|^[a-zA-Z0-9][a-zA-Z0-9-_.]*$|^\\*$", repo))
])
])
error_message = "Repository names must be valid GitHub repo format (owner/repo or repo)."
}
}
# Pre-built role templates
variable "terraform_deploy_role" {
type = object({
enabled = optional(bool, false)
repos = optional(list(string), [])
branches = optional(list(string), ["main"])
environments = optional(list(string), [])
state_bucket = optional(string, "")
state_bucket_key_prefix = optional(string, "*") # Limit to specific paths
dynamodb_table = optional(string, "")
allowed_services = optional(list(string), []) # Limit to specific AWS services
denied_actions = optional(list(string), [ # Explicit denies for safety
"iam:CreateUser",
"iam:CreateAccessKey",
"iam:DeleteAccountPasswordPolicy",
"organizations:*",
"account:*"
])
})
default = {}
description = "Pre-configured role for Terraform deployments"
}
variable "ecr_push_role" {
type = object({
enabled = optional(bool, false)
repos = optional(list(string), [])
branches = optional(list(string), ["main"])
ecr_repos = optional(list(string), []) # Specific ECR repos (no default wildcard)
allow_create = optional(bool, false)
allow_delete = optional(bool, false) # Explicit opt-in for delete
})
default = {}
description = "Pre-configured role for ECR push operations"
validation {
condition = !try(var.ecr_push_role.enabled, false) || length(try(var.ecr_push_role.ecr_repos, [])) > 0
error_message = "ECR push role requires explicit ecr_repos list (no wildcards for security)."
}
}
variable "s3_deploy_role" {
type = object({
enabled = optional(bool, false)
repos = optional(list(string), [])
branches = optional(list(string), ["main"])
bucket_arns = optional(list(string), [])
allowed_prefixes = optional(list(string), ["*"]) # Limit to specific paths
cloudfront_arns = optional(list(string), [])
})
default = {}
description = "Pre-configured role for S3 static site deployments"
validation {
condition = !try(var.s3_deploy_role.enabled, false) || length(try(var.s3_deploy_role.bucket_arns, [])) > 0
error_message = "S3 deploy role requires explicit bucket_arns list."
}
}
variable "lambda_deploy_role" {
type = object({
enabled = optional(bool, false)
repos = optional(list(string), [])
branches = optional(list(string), ["main"])
function_arns = optional(list(string), [])
allow_create = optional(bool, false)
allow_logs = optional(bool, true) # Allow CloudWatch Logs access
})
default = {}
description = "Pre-configured role for Lambda deployments"
validation {
condition = !try(var.lambda_deploy_role.enabled, false) || length(try(var.lambda_deploy_role.function_arns, [])) > 0
error_message = "Lambda deploy role requires explicit function_arns list."
}
}
variable "enable_cloudtrail_logging" {
type = bool
default = true
description = "Create CloudWatch metric alarms for OIDC role assumptions"
}
variable "alarm_sns_topic_arn" {
type = string
default = ""
description = "SNS topic ARN for security alarms"
}
variable "tags" {
type = map(string)
default = {}
description = "Tags to apply to all resources"
validation {
condition = !contains(keys(var.tags), "Name")
error_message = "Name tag is auto-generated, do not specify in tags variable."
}
}

View File

@@ -0,0 +1,18 @@
################################################################################
# GitHub OIDC Module - Versions
################################################################################
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
tls = {
source = "hashicorp/tls"
version = ">= 4.0"
}
}
}

View File

@@ -0,0 +1,50 @@
# iam-account-settings
IAM Account Settings Module
## Usage
```hcl
module "iam_account_settings" {
source = "../modules/iam-account-settings"
# Required variables
password_policy = ""
# Optional: see variables.tf for all options
}
```
## Requirements
| Name | Version |
|------|---------|
| terraform | >= 1.5.0 |
| aws | >= 5.0 |
## Inputs
| Name | Description | Type | Required |
|------|-------------|------|----------|
| account_alias | AWS account alias (appears in sign-in URL) | `string` | no |
| password_policy | | `object({` | yes |
| enable_password_policy | Enable custom password policy | `bool` | no |
| enforce_mfa | Create IAM policy to enforce MFA for all actions | `bool` | no |
| mfa_grace_period_days | Days new users have before MFA is required (0 = immediate) | `number` | no |
| mfa_exempt_roles | Role names exempt from MFA requirement | `list(string)` | no |
| tags | | `map(string)` | no |
## Outputs
| Name | Description |
|------|-------------|
| account_alias | AWS account alias |
| signin_url | |
| password_policy | |
| mfa_enforcement_policy_arn | MFA enforcement policy ARN |
| mfa_required_group | Group name for users requiring MFA |
| mfa_scp_template_policy | Template policy for MFA SCP (copy to Organizations) |
## License
Apache 2.0 - See LICENSE for details.

View File

@@ -0,0 +1,338 @@
################################################################################
# IAM Account Settings Module
#
# Account-level IAM security settings:
# - Password policy (complexity, rotation, reuse)
# - MFA enforcement via SCP/IAM policy
# - Account alias
# - SAML providers
#
# Usage:
# module "iam_settings" {
# source = "../modules/iam-account-settings"
#
# account_alias = "mycompany-prod"
#
# password_policy = {
# minimum_length = 14
# require_symbols = true
# require_numbers = true
# require_uppercase = true
# require_lowercase = true
# max_age_days = 90
# password_reuse_prevention = 24
# allow_users_to_change = true
# }
#
# enforce_mfa = true
# }
################################################################################
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
variable "account_alias" {
type = string
default = ""
description = "AWS account alias (appears in sign-in URL)"
}
variable "password_policy" {
type = object({
minimum_length = optional(number, 14)
require_symbols = optional(bool, true)
require_numbers = optional(bool, true)
require_uppercase_characters = optional(bool, true)
require_lowercase_characters = optional(bool, true)
allow_users_to_change_password = optional(bool, true)
max_password_age = optional(number, 90)
password_reuse_prevention = optional(number, 24)
hard_expiry = optional(bool, false)
})
default = {}
description = "Password policy settings"
}
variable "enable_password_policy" {
type = bool
default = true
description = "Enable custom password policy"
}
variable "enforce_mfa" {
type = bool
default = false
description = "Create IAM policy to enforce MFA for all actions"
}
variable "mfa_grace_period_days" {
type = number
default = 0
description = "Days new users have before MFA is required (0 = immediate)"
}
variable "mfa_exempt_roles" {
type = list(string)
default = []
description = "Role names exempt from MFA requirement"
}
variable "tags" {
type = map(string)
default = {}
}
################################################################################
# Account Alias
################################################################################
resource "aws_iam_account_alias" "main" {
count = var.account_alias != "" ? 1 : 0
account_alias = var.account_alias
}
################################################################################
# Password Policy
################################################################################
resource "aws_iam_account_password_policy" "main" {
count = var.enable_password_policy ? 1 : 0
minimum_password_length = var.password_policy.minimum_length
require_symbols = var.password_policy.require_symbols
require_numbers = var.password_policy.require_numbers
require_uppercase_characters = var.password_policy.require_uppercase_characters
require_lowercase_characters = var.password_policy.require_lowercase_characters
allow_users_to_change_password = var.password_policy.allow_users_to_change_password
max_password_age = var.password_policy.max_password_age
password_reuse_prevention = var.password_policy.password_reuse_prevention
hard_expiry = var.password_policy.hard_expiry
}
################################################################################
# MFA Enforcement Policy
################################################################################
# This policy denies all actions (except MFA setup) if MFA is not present
resource "aws_iam_policy" "enforce_mfa" {
count = var.enforce_mfa ? 1 : 0
name = "EnforceMFA"
description = "Denies all actions except MFA setup when MFA is not enabled"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowViewAccountInfo"
Effect = "Allow"
Action = [
"iam:GetAccountPasswordPolicy",
"iam:ListVirtualMFADevices"
]
Resource = "*"
},
{
Sid = "AllowManageOwnPasswords"
Effect = "Allow"
Action = [
"iam:ChangePassword",
"iam:GetUser"
]
Resource = "arn:aws:iam::*:user/$${aws:username}"
},
{
Sid = "AllowManageOwnAccessKeys"
Effect = "Allow"
Action = [
"iam:CreateAccessKey",
"iam:DeleteAccessKey",
"iam:ListAccessKeys",
"iam:UpdateAccessKey",
"iam:GetAccessKeyLastUsed"
]
Resource = "arn:aws:iam::*:user/$${aws:username}"
},
{
Sid = "AllowManageOwnSigningCertificates"
Effect = "Allow"
Action = [
"iam:DeleteSigningCertificate",
"iam:ListSigningCertificates",
"iam:UpdateSigningCertificate",
"iam:UploadSigningCertificate"
]
Resource = "arn:aws:iam::*:user/$${aws:username}"
},
{
Sid = "AllowManageOwnSSHPublicKeys"
Effect = "Allow"
Action = [
"iam:DeleteSSHPublicKey",
"iam:GetSSHPublicKey",
"iam:ListSSHPublicKeys",
"iam:UpdateSSHPublicKey",
"iam:UploadSSHPublicKey"
]
Resource = "arn:aws:iam::*:user/$${aws:username}"
},
{
Sid = "AllowManageOwnGitCredentials"
Effect = "Allow"
Action = [
"iam:CreateServiceSpecificCredential",
"iam:DeleteServiceSpecificCredential",
"iam:ListServiceSpecificCredentials",
"iam:ResetServiceSpecificCredential",
"iam:UpdateServiceSpecificCredential"
]
Resource = "arn:aws:iam::*:user/$${aws:username}"
},
{
Sid = "AllowManageOwnVirtualMFADevice"
Effect = "Allow"
Action = [
"iam:CreateVirtualMFADevice",
"iam:DeleteVirtualMFADevice"
]
Resource = "arn:aws:iam::*:mfa/*"
},
{
Sid = "AllowManageOwnUserMFA"
Effect = "Allow"
Action = [
"iam:DeactivateMFADevice",
"iam:EnableMFADevice",
"iam:ListMFADevices",
"iam:ResyncMFADevice"
]
Resource = "arn:aws:iam::*:user/$${aws:username}"
},
{
Sid = "DenyAllExceptListedIfNoMFA"
Effect = "Deny"
NotAction = [
"iam:CreateVirtualMFADevice",
"iam:EnableMFADevice",
"iam:GetUser",
"iam:GetMFADevice",
"iam:ListMFADevices",
"iam:ListVirtualMFADevices",
"iam:ResyncMFADevice",
"sts:GetSessionToken",
"iam:ChangePassword",
"iam:GetAccountPasswordPolicy"
]
Resource = "*"
Condition = {
BoolIfExists = {
"aws:MultiFactorAuthPresent" = "false"
}
}
}
]
})
tags = merge(var.tags, { Name = "EnforceMFA" })
}
# Group for users who must have MFA
resource "aws_iam_group" "mfa_required" {
count = var.enforce_mfa ? 1 : 0
name = "MFARequired"
}
resource "aws_iam_group_policy_attachment" "mfa_required" {
count = var.enforce_mfa ? 1 : 0
group = aws_iam_group.mfa_required[0].name
policy_arn = aws_iam_policy.enforce_mfa[0].arn
}
################################################################################
# MFA Enforcement SCP (for Organizations)
################################################################################
# This can be attached at the OU level for organization-wide enforcement
resource "aws_iam_policy" "mfa_scp_template" {
count = var.enforce_mfa ? 1 : 0
name = "MFA-SCP-Template"
description = "Template SCP for MFA enforcement (apply via aws_organizations_policy)"
# Note: This is an IAM policy format - for SCP, use this as a template
# SCPs don't support aws:MultiFactorAuthPresent for all actions
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "DenyStopAndTerminateWithoutMFA"
Effect = "Deny"
Action = [
"ec2:StopInstances",
"ec2:TerminateInstances",
"rds:DeleteDBInstance",
"rds:DeleteDBCluster",
"s3:DeleteBucket",
"iam:DeleteUser",
"iam:DeleteRole"
]
Resource = "*"
Condition = {
BoolIfExists = {
"aws:MultiFactorAuthPresent" = "false"
}
}
}
]
})
tags = merge(var.tags, { Name = "MFA-SCP-Template" })
}
################################################################################
# Outputs
################################################################################
output "account_alias" {
value = var.account_alias != "" ? var.account_alias : null
description = "AWS account alias"
}
output "signin_url" {
value = var.account_alias != "" ? "https://${var.account_alias}.signin.aws.amazon.com/console" : null
description = "AWS console sign-in URL"
}
output "password_policy" {
value = var.enable_password_policy ? {
minimum_length = var.password_policy.minimum_length
require_symbols = var.password_policy.require_symbols
require_numbers = var.password_policy.require_numbers
require_uppercase = var.password_policy.require_uppercase_characters
require_lowercase = var.password_policy.require_lowercase_characters
max_age_days = var.password_policy.max_password_age
reuse_prevention = var.password_policy.password_reuse_prevention
} : null
description = "Password policy settings"
}
output "mfa_enforcement_policy_arn" {
value = var.enforce_mfa ? aws_iam_policy.enforce_mfa[0].arn : null
description = "MFA enforcement policy ARN"
}
output "mfa_required_group" {
value = var.enforce_mfa ? aws_iam_group.mfa_required[0].name : null
description = "Group name for users requiring MFA"
}
output "mfa_scp_template_policy" {
value = var.enforce_mfa ? aws_iam_policy.mfa_scp_template[0].policy : null
description = "Template policy for MFA SCP (copy to Organizations)"
}

View File

@@ -0,0 +1,60 @@
# iam-role
IAM Role Module
## Usage
```hcl
module "iam_role" {
source = "../modules/iam-role"
# Required variables
name = ""
# Optional: see variables.tf for all options
}
```
## Requirements
| Name | Version |
|------|---------|
| terraform | >= 1.5.0 |
| aws | >= 5.0 |
## Inputs
| Name | Description | Type | Required |
|------|-------------|------|----------|
| name | Role name | `string` | yes |
| role_type | Type: service, cross-account, oidc | `string` | no |
| description | Role description | `string` | no |
| path | IAM path | `string` | no |
| max_session_duration | Maximum session duration in seconds (1-12 hours) | `number` | no |
| service | AWS service principal (e.g., lambda.amazonaws.com) | `string` | no |
| services | Multiple service principals | `list(string)` | no |
| trusted_account_ids | Account IDs that can assume this role | `list(string)` | no |
| trusted_role_arns | Specific role ARNs that can assume this role | `list(string)` | no |
| require_mfa | Require MFA for cross-account assumption | `bool` | no |
| require_external_id | External ID required for assumption | `string` | no |
| oidc_provider_arn | OIDC provider ARN | `string` | no |
| oidc_subjects | Allowed OIDC subjects (e.g., repo:org/repo:*) | `list(string)` | no |
| oidc_audiences | OIDC audiences | `list(string)` | no |
| managed_policies | List of managed policy ARNs to attach | `list(string)` | no |
*...and 4 more variables. See `variables.tf` for complete list.*
## Outputs
| Name | Description |
|------|-------------|
| role_arn | Role ARN |
| role_name | Role name |
| role_id | Role unique ID |
| instance_profile_arn | Instance profile ARN |
| instance_profile_name | Instance profile name |
| assume_role_command | |
## License
Apache 2.0 - See LICENSE for details.

View File

@@ -0,0 +1,352 @@
################################################################################
# IAM Role Module
#
# Common IAM role patterns:
# - Service roles (EC2, Lambda, ECS, etc.)
# - Cross-account roles (OrganizationAccountAccessRole pattern)
# - OIDC roles (GitHub Actions, EKS service accounts)
# - Instance profiles
#
# Usage:
# # Lambda execution role
# module "lambda_role" {
# source = "../modules/iam-role"
#
# name = "my-lambda"
# role_type = "service"
# service = "lambda.amazonaws.com"
# managed_policies = ["arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"]
# }
#
# # GitHub Actions OIDC
# module "github_role" {
# source = "../modules/iam-role"
#
# name = "github-deploy"
# role_type = "oidc"
# oidc_provider_arn = aws_iam_openid_connect_provider.github.arn
# oidc_subjects = ["repo:myorg/myrepo:*"]
# }
################################################################################
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
variable "name" {
type = string
description = "Role name"
}
variable "role_type" {
type = string
default = "service"
description = "Type: service, cross-account, oidc"
validation {
condition = contains(["service", "cross-account", "oidc"], var.role_type)
error_message = "Must be service, cross-account, or oidc"
}
}
variable "description" {
type = string
default = ""
description = "Role description"
}
variable "path" {
type = string
default = "/"
description = "IAM path"
}
variable "max_session_duration" {
type = number
default = 3600
description = "Maximum session duration in seconds (1-12 hours)"
}
# Service role settings
variable "service" {
type = string
default = ""
description = "AWS service principal (e.g., lambda.amazonaws.com)"
}
variable "services" {
type = list(string)
default = []
description = "Multiple service principals"
}
# Cross-account settings
variable "trusted_account_ids" {
type = list(string)
default = []
description = "Account IDs that can assume this role"
}
variable "trusted_role_arns" {
type = list(string)
default = []
description = "Specific role ARNs that can assume this role"
}
variable "require_mfa" {
type = bool
default = false
description = "Require MFA for cross-account assumption"
}
variable "require_external_id" {
type = string
default = ""
description = "External ID required for assumption"
}
# OIDC settings
variable "oidc_provider_arn" {
type = string
default = ""
description = "OIDC provider ARN"
}
variable "oidc_subjects" {
type = list(string)
default = []
description = "Allowed OIDC subjects (e.g., repo:org/repo:*)"
}
variable "oidc_audiences" {
type = list(string)
default = ["sts.amazonaws.com"]
description = "OIDC audiences"
}
# Policies
variable "managed_policies" {
type = list(string)
default = []
description = "List of managed policy ARNs to attach"
}
variable "inline_policies" {
type = map(string)
default = {}
description = "Map of inline policy name -> JSON policy document"
}
# Instance profile
variable "create_instance_profile" {
type = bool
default = false
description = "Create an instance profile (for EC2)"
}
# Permissions boundary
variable "permissions_boundary" {
type = string
default = ""
description = "Permissions boundary ARN"
}
variable "tags" {
type = map(string)
default = {}
}
################################################################################
# Data Sources
################################################################################
data "aws_caller_identity" "current" {}
locals {
service_principals = var.service != "" ? [var.service] : var.services
description = var.description != "" ? var.description : (
var.role_type == "service" ? "Service role for ${join(", ", local.service_principals)}" :
var.role_type == "cross-account" ? "Cross-account role" :
"OIDC role"
)
}
################################################################################
# Assume Role Policy
################################################################################
data "aws_iam_policy_document" "assume_role" {
# Service role trust
dynamic "statement" {
for_each = var.role_type == "service" && length(local.service_principals) > 0 ? [1] : []
content {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = local.service_principals
}
}
}
# Cross-account trust (account IDs)
dynamic "statement" {
for_each = var.role_type == "cross-account" && length(var.trusted_account_ids) > 0 ? [1] : []
content {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "AWS"
identifiers = [for id in var.trusted_account_ids : "arn:aws:iam::${id}:root"]
}
dynamic "condition" {
for_each = var.require_mfa ? [1] : []
content {
test = "Bool"
variable = "aws:MultiFactorAuthPresent"
values = ["true"]
}
}
dynamic "condition" {
for_each = var.require_external_id != "" ? [1] : []
content {
test = "StringEquals"
variable = "sts:ExternalId"
values = [var.require_external_id]
}
}
}
}
# Cross-account trust (specific roles)
dynamic "statement" {
for_each = var.role_type == "cross-account" && length(var.trusted_role_arns) > 0 ? [1] : []
content {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "AWS"
identifiers = var.trusted_role_arns
}
}
}
# OIDC trust
dynamic "statement" {
for_each = var.role_type == "oidc" && var.oidc_provider_arn != "" ? [1] : []
content {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [var.oidc_provider_arn]
}
dynamic "condition" {
for_each = length(var.oidc_subjects) > 0 ? [1] : []
content {
test = "StringLike"
variable = "${replace(var.oidc_provider_arn, "/.*oidc-provider\\//", "")}:sub"
values = var.oidc_subjects
}
}
condition {
test = "StringEquals"
variable = "${replace(var.oidc_provider_arn, "/.*oidc-provider\\//", "")}:aud"
values = var.oidc_audiences
}
}
}
}
################################################################################
# IAM Role
################################################################################
resource "aws_iam_role" "main" {
name = var.name
description = local.description
path = var.path
max_session_duration = var.max_session_duration
assume_role_policy = data.aws_iam_policy_document.assume_role.json
permissions_boundary = var.permissions_boundary != "" ? var.permissions_boundary : null
tags = merge(var.tags, { Name = var.name })
}
################################################################################
# Managed Policies
################################################################################
resource "aws_iam_role_policy_attachment" "managed" {
for_each = toset(var.managed_policies)
role = aws_iam_role.main.name
policy_arn = each.value
}
################################################################################
# Inline Policies
################################################################################
resource "aws_iam_role_policy" "inline" {
for_each = var.inline_policies
name = each.key
role = aws_iam_role.main.id
policy = each.value
}
################################################################################
# Instance Profile
################################################################################
resource "aws_iam_instance_profile" "main" {
count = var.create_instance_profile ? 1 : 0
name = var.name
role = aws_iam_role.main.name
tags = merge(var.tags, { Name = var.name })
}
################################################################################
# Outputs
################################################################################
output "role_arn" {
value = aws_iam_role.main.arn
description = "Role ARN"
}
output "role_name" {
value = aws_iam_role.main.name
description = "Role name"
}
output "role_id" {
value = aws_iam_role.main.unique_id
description = "Role unique ID"
}
output "instance_profile_arn" {
value = var.create_instance_profile ? aws_iam_instance_profile.main[0].arn : null
description = "Instance profile ARN"
}
output "instance_profile_name" {
value = var.create_instance_profile ? aws_iam_instance_profile.main[0].name : null
description = "Instance profile name"
}
output "assume_role_command" {
value = var.role_type == "cross-account" ? "aws sts assume-role --role-arn ${aws_iam_role.main.arn} --role-session-name my-session" : null
description = "CLI command to assume the role"
}

View File

@@ -0,0 +1,40 @@
# identity-center
Terraform module for AWS landing zone pattern.
Configure AWS IAM Identity Center (formerly AWS SSO).
## Planned Features
- [ ] Default permission sets (Admin, PowerUser, ReadOnly, Billing)
- [ ] Custom permission sets with managed + inline policies
- [ ] Group-to-account assignments
- [ ] SCIM provisioning setup
- [ ] MFA enforcement
- [ ] Session duration policies
## Planned Usage
```hcl
module "identity_center" {
source = "../modules/identity-center"
default_permission_sets = true
permission_sets = {
DatabaseAdmin = {
description = "Database administration access"
session_duration = "PT8H"
managed_policies = ["arn:aws:iam::aws:policy/AmazonRDSFullAccess"]
}
}
group_assignments = {
admins_prod = {
group_name = "AWS-Admins"
permission_set = "AdministratorAccess"
account_ids = ["111111111111", "222222222222"]
}
}
}
```

View File

@@ -0,0 +1,145 @@
################################################################################
# Identity Center Module
#
# Configures AWS IAM Identity Center (formerly AWS SSO):
# - Permission sets with managed and inline policies
# - Account assignments for groups
# - Default permission sets (Admin, PowerUser, ReadOnly, Billing)
################################################################################
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
data "aws_ssoadmin_instances" "this" {}
locals {
instance_arn = tolist(data.aws_ssoadmin_instances.this.arns)[0]
identity_store_id = tolist(data.aws_ssoadmin_instances.this.identity_store_ids)[0]
# Default permission sets
default_permission_sets = var.create_default_permission_sets ? {
AdministratorAccess = {
description = "Full administrator access"
session_duration = "PT4H"
managed_policies = ["arn:aws:iam::aws:policy/AdministratorAccess"]
inline_policy = ""
}
PowerUserAccess = {
description = "Power user access (no IAM)"
session_duration = "PT4H"
managed_policies = ["arn:aws:iam::aws:policy/PowerUserAccess"]
inline_policy = ""
}
ReadOnlyAccess = {
description = "Read-only access"
session_duration = "PT8H"
managed_policies = ["arn:aws:iam::aws:policy/ReadOnlyAccess"]
inline_policy = ""
}
Billing = {
description = "Billing access"
session_duration = "PT4H"
managed_policies = ["arn:aws:iam::aws:policy/job-function/Billing"]
inline_policy = ""
}
ViewOnlyAccess = {
description = "View-only access (no data access)"
session_duration = "PT8H"
managed_policies = ["arn:aws:iam::aws:policy/job-function/ViewOnlyAccess"]
inline_policy = ""
}
} : {}
# Merge default and custom permission sets
all_permission_sets = merge(local.default_permission_sets, var.permission_sets)
}
################################################################################
# Permission Sets
################################################################################
resource "aws_ssoadmin_permission_set" "this" {
for_each = local.all_permission_sets
instance_arn = local.instance_arn
name = each.key
description = each.value.description
session_duration = each.value.session_duration
tags = merge(var.tags, {
Name = each.key
})
}
# Attach managed policies
resource "aws_ssoadmin_managed_policy_attachment" "this" {
for_each = {
for pair in flatten([
for ps_name, ps in local.all_permission_sets : [
for policy in ps.managed_policies : {
key = "${ps_name}-${replace(policy, "/.*//", "")}"
ps_name = ps_name
policy_arn = policy
}
]
]) : pair.key => pair
}
instance_arn = local.instance_arn
permission_set_arn = aws_ssoadmin_permission_set.this[each.value.ps_name].arn
managed_policy_arn = each.value.policy_arn
}
# Attach inline policies
resource "aws_ssoadmin_permission_set_inline_policy" "this" {
for_each = {
for name, ps in local.all_permission_sets : name => ps
if ps.inline_policy != ""
}
instance_arn = local.instance_arn
permission_set_arn = aws_ssoadmin_permission_set.this[each.key].arn
inline_policy = each.value.inline_policy
}
################################################################################
# Account Assignments
################################################################################
# Look up groups from Identity Store
data "aws_identitystore_group" "this" {
for_each = toset([for a in var.account_assignments : a.group_name])
identity_store_id = local.identity_store_id
alternate_identifier {
unique_attribute {
attribute_path = "DisplayName"
attribute_value = each.value
}
}
}
# Create account assignments
resource "aws_ssoadmin_account_assignment" "this" {
for_each = {
for a in var.account_assignments :
"${a.group_name}-${a.permission_set}-${a.account_id}" => a
}
instance_arn = local.instance_arn
permission_set_arn = aws_ssoadmin_permission_set.this[each.value.permission_set].arn
principal_id = data.aws_identitystore_group.this[each.value.group_name].group_id
principal_type = "GROUP"
target_id = each.value.account_id
target_type = "AWS_ACCOUNT"
}

View File

@@ -0,0 +1,28 @@
################################################################################
# Identity Center - Outputs
################################################################################
output "instance_arn" {
value = local.instance_arn
description = "Identity Center instance ARN"
}
output "identity_store_id" {
value = local.identity_store_id
description = "Identity Store ID"
}
output "permission_set_arns" {
value = { for k, v in aws_ssoadmin_permission_set.this : k => v.arn }
description = "Map of permission set names to ARNs"
}
output "sso_start_url" {
value = "https://${local.identity_store_id}.awsapps.com/start"
description = "SSO portal start URL"
}
output "assignment_count" {
value = length(aws_ssoadmin_account_assignment.this)
description = "Number of account assignments created"
}

View File

@@ -0,0 +1,36 @@
################################################################################
# Identity Center - Input Variables
################################################################################
variable "create_default_permission_sets" {
type = bool
default = true
description = "Create default permission sets (Admin, PowerUser, ReadOnly, Billing)"
}
variable "permission_sets" {
type = map(object({
description = string
session_duration = optional(string, "PT4H")
managed_policies = optional(list(string), [])
inline_policy = optional(string, "")
}))
default = {}
description = "Custom permission sets to create"
}
variable "account_assignments" {
type = list(object({
group_name = string
permission_set = string
account_id = string
}))
default = []
description = "Group to account/permission assignments"
}
variable "tags" {
type = map(string)
default = {}
description = "Tags to apply to resources"
}

View File

@@ -0,0 +1,54 @@
# kms-key
KMS Key Module
## Usage
```hcl
module "kms_key" {
source = "../modules/kms-key"
# Required variables
name = ""
# Optional: see variables.tf for all options
}
```
## Requirements
| Name | Version |
|------|---------|
| terraform | >= 1.5.0 |
| aws | >= 5.0 |
## Inputs
| Name | Description | Type | Required |
|------|-------------|------|----------|
| name | Key name (used for alias) | `string` | yes |
| description | Key description | `string` | no |
| deletion_window_in_days | Waiting period before key deletion (7-30 days) | `number` | no |
| enable_key_rotation | Enable automatic key rotation (annual) | `bool` | no |
| multi_region | Create a multi-region key | `bool` | no |
| key_usage | Key usage: ENCRYPT_DECRYPT or SIGN_VERIFY | `string` | no |
| key_spec | Key spec (SYMMETRIC_DEFAULT, RSA_2048, ECC_NIST_P256, etc.) | `string` | no |
| admin_principals | IAM ARNs with full admin access to the key | `list(string)` | no |
| user_principals | IAM ARNs with encrypt/decrypt access | `list(string)` | no |
| service_principals | AWS service principals that can use the key (e.g., logs.amaz... | `list(string)` | no |
| grant_accounts | Account IDs with cross-account access | `list(string)` | no |
| tags | | `map(string)` | no |
## Outputs
| Name | Description |
|------|-------------|
| key_id | KMS key ID |
| key_arn | KMS key ARN |
| alias_arn | KMS alias ARN |
| alias_name | KMS alias name |
| key_policy | Key policy document |
## License
Apache 2.0 - See LICENSE for details.

View File

@@ -0,0 +1,290 @@
################################################################################
# KMS Key Module
#
# Customer-managed KMS keys for encryption:
# - Automatic key rotation
# - Cross-account access
# - Service-specific grants
# - Alias management
# - Key policies
#
# Usage:
# module "kms" {
# source = "../modules/kms-key"
#
# name = "myapp-encryption"
# description = "Encryption key for myapp"
#
# service_principals = ["logs.amazonaws.com", "s3.amazonaws.com"]
# }
################################################################################
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
variable "name" {
type = string
description = "Key name (used for alias)"
}
variable "description" {
type = string
default = ""
description = "Key description"
}
variable "deletion_window_in_days" {
type = number
default = 30
description = "Waiting period before key deletion (7-30 days)"
validation {
condition = var.deletion_window_in_days >= 7 && var.deletion_window_in_days <= 30
error_message = "Must be between 7 and 30 days"
}
}
variable "enable_key_rotation" {
type = bool
default = true
description = "Enable automatic key rotation (annual)"
}
variable "multi_region" {
type = bool
default = false
description = "Create a multi-region key"
}
variable "key_usage" {
type = string
default = "ENCRYPT_DECRYPT"
description = "Key usage: ENCRYPT_DECRYPT or SIGN_VERIFY"
validation {
condition = contains(["ENCRYPT_DECRYPT", "SIGN_VERIFY", "GENERATE_VERIFY_MAC"], var.key_usage)
error_message = "Must be ENCRYPT_DECRYPT, SIGN_VERIFY, or GENERATE_VERIFY_MAC"
}
}
variable "key_spec" {
type = string
default = "SYMMETRIC_DEFAULT"
description = "Key spec (SYMMETRIC_DEFAULT, RSA_2048, ECC_NIST_P256, etc.)"
}
variable "admin_principals" {
type = list(string)
default = []
description = "IAM ARNs with full admin access to the key"
}
variable "user_principals" {
type = list(string)
default = []
description = "IAM ARNs with encrypt/decrypt access"
}
variable "service_principals" {
type = list(string)
default = []
description = "AWS service principals that can use the key (e.g., logs.amazonaws.com)"
}
variable "grant_accounts" {
type = list(string)
default = []
description = "Account IDs with cross-account access"
}
variable "tags" {
type = map(string)
default = {}
}
################################################################################
# Data Sources
################################################################################
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
################################################################################
# KMS Key
################################################################################
resource "aws_kms_key" "main" {
description = var.description != "" ? var.description : "KMS key for ${var.name}"
deletion_window_in_days = var.deletion_window_in_days
enable_key_rotation = var.key_spec == "SYMMETRIC_DEFAULT" ? var.enable_key_rotation : false
multi_region = var.multi_region
key_usage = var.key_usage
customer_master_key_spec = var.key_spec
policy = jsonencode({
Version = "2012-10-17"
Statement = concat(
# Root account access (required)
[{
Sid = "EnableRootPermissions"
Effect = "Allow"
Principal = {
AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"
}
Action = "kms:*"
Resource = "*"
}],
# Admin principals
length(var.admin_principals) > 0 ? [{
Sid = "KeyAdministrators"
Effect = "Allow"
Principal = {
AWS = var.admin_principals
}
Action = [
"kms:Create*",
"kms:Describe*",
"kms:Enable*",
"kms:List*",
"kms:Put*",
"kms:Update*",
"kms:Revoke*",
"kms:Disable*",
"kms:Get*",
"kms:Delete*",
"kms:TagResource",
"kms:UntagResource",
"kms:ScheduleKeyDeletion",
"kms:CancelKeyDeletion"
]
Resource = "*"
}] : [],
# User principals
length(var.user_principals) > 0 ? [{
Sid = "KeyUsers"
Effect = "Allow"
Principal = {
AWS = var.user_principals
}
Action = [
"kms:Encrypt",
"kms:Decrypt",
"kms:ReEncrypt*",
"kms:GenerateDataKey*",
"kms:DescribeKey"
]
Resource = "*"
}] : [],
# Service principals
length(var.service_principals) > 0 ? [{
Sid = "AllowServices"
Effect = "Allow"
Principal = {
Service = var.service_principals
}
Action = [
"kms:Encrypt",
"kms:Decrypt",
"kms:ReEncrypt*",
"kms:GenerateDataKey*",
"kms:DescribeKey"
]
Resource = "*"
Condition = {
StringEquals = {
"aws:SourceAccount" = data.aws_caller_identity.current.account_id
}
}
}] : [],
# Cross-account access
length(var.grant_accounts) > 0 ? [{
Sid = "CrossAccountAccess"
Effect = "Allow"
Principal = {
AWS = [for acct in var.grant_accounts : "arn:aws:iam::${acct}:root"]
}
Action = [
"kms:Encrypt",
"kms:Decrypt",
"kms:ReEncrypt*",
"kms:GenerateDataKey*",
"kms:DescribeKey"
]
Resource = "*"
}] : [],
# Allow grants (needed for some AWS services)
[{
Sid = "AllowGrants"
Effect = "Allow"
Principal = {
AWS = concat(
["arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"],
var.user_principals
)
}
Action = [
"kms:CreateGrant",
"kms:ListGrants",
"kms:RevokeGrant"
]
Resource = "*"
Condition = {
Bool = {
"kms:GrantIsForAWSResource" = "true"
}
}
}]
)
})
tags = merge(var.tags, { Name = var.name })
}
################################################################################
# Alias
################################################################################
resource "aws_kms_alias" "main" {
name = "alias/${var.name}"
target_key_id = aws_kms_key.main.key_id
}
################################################################################
# Outputs
################################################################################
output "key_id" {
value = aws_kms_key.main.key_id
description = "KMS key ID"
}
output "key_arn" {
value = aws_kms_key.main.arn
description = "KMS key ARN"
}
output "alias_arn" {
value = aws_kms_alias.main.arn
description = "KMS alias ARN"
}
output "alias_name" {
value = aws_kms_alias.main.name
description = "KMS alias name"
}
output "key_policy" {
value = aws_kms_key.main.policy
description = "Key policy document"
}

View File

@@ -0,0 +1,65 @@
# lambda-function
Lambda Function Module
## Usage
```hcl
module "lambda_function" {
source = "../modules/lambda-function"
# Required variables
name = ""
vpc_config = ""
function_url = ""
# Optional: see variables.tf for all options
}
```
## Requirements
| Name | Version |
|------|---------|
| terraform | >= 1.5.0 |
| aws | >= 5.0 |
## Inputs
| Name | Description | Type | Required |
|------|-------------|------|----------|
| name | Function name | `string` | yes |
| description | Function description | `string` | no |
| runtime | Lambda runtime | `string` | no |
| handler | Function handler | `string` | no |
| architectures | CPU architecture (arm64 or x86_64) | `list(string)` | no |
| memory_size | Memory in MB (128-10240) | `number` | no |
| timeout | Timeout in seconds (max 900) | `number` | no |
| reserved_concurrent_executions | Reserved concurrency (-1 = unreserved) | `number` | no |
| source_dir | Local source directory to zip | `string` | no |
| source_file | Single source file to deploy | `string` | no |
| s3_bucket | S3 bucket containing deployment package | `string` | no |
| s3_key | S3 key for deployment package | `string` | no |
| image_uri | Container image URI | `string` | no |
| vpc_config | | `object({` | yes |
| environment | | `map(string)` | no |
*...and 12 more variables. See `variables.tf` for complete list.*
## Outputs
| Name | Description |
|------|-------------|
| function_name | Function name |
| function_arn | Function ARN |
| invoke_arn | Invoke ARN (for API Gateway) |
| qualified_arn | Qualified ARN (includes version) |
| role_arn | IAM role ARN |
| role_name | IAM role name |
| log_group_name | CloudWatch log group name |
| function_url | Function URL |
| version | Published version |
## License
Apache 2.0 - See LICENSE for details.

View File

@@ -0,0 +1,501 @@
################################################################################
# Lambda Function Module
#
# Reusable Lambda deployment with:
# - S3 or local zip deployment
# - VPC access (optional)
# - Environment variables
# - Secrets Manager integration
# - CloudWatch logs
# - X-Ray tracing
# - Provisioned concurrency
# - Function URL (optional)
#
# Usage:
# module "api_lambda" {
# source = "../modules/lambda-function"
#
# name = "my-api"
# runtime = "nodejs20.x"
# handler = "index.handler"
#
# source_dir = "${path.module}/src"
#
# environment = {
# LOG_LEVEL = "info"
# }
# }
################################################################################
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
archive = {
source = "hashicorp/archive"
version = ">= 2.0"
}
}
}
variable "name" {
type = string
description = "Function name"
}
variable "description" {
type = string
default = ""
description = "Function description"
}
variable "runtime" {
type = string
default = "nodejs20.x"
description = "Lambda runtime"
}
variable "handler" {
type = string
default = "index.handler"
description = "Function handler"
}
variable "architectures" {
type = list(string)
default = ["arm64"]
description = "CPU architecture (arm64 or x86_64)"
}
variable "memory_size" {
type = number
default = 256
description = "Memory in MB (128-10240)"
}
variable "timeout" {
type = number
default = 30
description = "Timeout in seconds (max 900)"
}
variable "reserved_concurrent_executions" {
type = number
default = -1
description = "Reserved concurrency (-1 = unreserved)"
}
# Deployment options
variable "source_dir" {
type = string
default = ""
description = "Local source directory to zip"
}
variable "source_file" {
type = string
default = ""
description = "Single source file to deploy"
}
variable "s3_bucket" {
type = string
default = ""
description = "S3 bucket containing deployment package"
}
variable "s3_key" {
type = string
default = ""
description = "S3 key for deployment package"
}
variable "image_uri" {
type = string
default = ""
description = "Container image URI"
}
# VPC configuration
variable "vpc_config" {
type = object({
subnet_ids = list(string)
security_group_ids = list(string)
})
default = null
description = "VPC configuration for Lambda"
}
# Environment
variable "environment" {
type = map(string)
default = {}
description = "Environment variables"
}
variable "secrets" {
type = map(string)
default = {}
description = "Secrets Manager ARNs (name -> ARN)"
}
variable "ssm_parameters" {
type = map(string)
default = {}
description = "SSM parameter ARNs (name -> ARN)"
}
# Layers
variable "layers" {
type = list(string)
default = []
description = "Lambda layer ARNs"
}
# Tracing
variable "tracing_mode" {
type = string
default = "Active"
description = "X-Ray tracing mode (Active, PassThrough, or empty)"
}
# Logging
variable "log_retention_days" {
type = number
default = 14
description = "CloudWatch log retention in days"
}
variable "log_format" {
type = string
default = "Text"
description = "Log format: Text or JSON"
}
# Function URL
variable "function_url" {
type = object({
enabled = bool
auth_type = optional(string, "NONE")
cors_origins = optional(list(string), ["*"])
cors_methods = optional(list(string), ["*"])
cors_headers = optional(list(string), ["*"])
invoke_mode = optional(string, "BUFFERED")
})
default = {
enabled = false
}
description = "Lambda function URL configuration"
}
# Provisioned concurrency
variable "provisioned_concurrency" {
type = number
default = 0
description = "Provisioned concurrency (0 = disabled)"
}
# Additional IAM policies
variable "policy_arns" {
type = list(string)
default = []
description = "Additional IAM policy ARNs to attach"
}
variable "inline_policy" {
type = string
default = ""
description = "Inline IAM policy JSON"
}
# Dead letter queue
variable "dead_letter_arn" {
type = string
default = ""
description = "SQS queue or SNS topic ARN for failed invocations"
}
variable "tags" {
type = map(string)
default = {}
}
################################################################################
# Data Sources
################################################################################
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
################################################################################
# Archive (if using source_dir)
################################################################################
data "archive_file" "lambda" {
count = var.source_dir != "" ? 1 : (var.source_file != "" ? 1 : 0)
type = "zip"
output_path = "${path.module}/.terraform/${var.name}.zip"
source_dir = var.source_dir != "" ? var.source_dir : null
source_file = var.source_file != "" ? var.source_file : null
}
################################################################################
# IAM Role
################################################################################
resource "aws_iam_role" "lambda" {
name = "${var.name}-lambda"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "lambda.amazonaws.com" }
}]
})
tags = merge(var.tags, { Name = "${var.name}-lambda" })
}
# Basic execution role
resource "aws_iam_role_policy_attachment" "basic" {
role = aws_iam_role.lambda.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
# VPC access
resource "aws_iam_role_policy_attachment" "vpc" {
count = var.vpc_config != null ? 1 : 0
role = aws_iam_role.lambda.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
}
# X-Ray
resource "aws_iam_role_policy_attachment" "xray" {
count = var.tracing_mode != "" ? 1 : 0
role = aws_iam_role.lambda.name
policy_arn = "arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess"
}
# Secrets Manager access
resource "aws_iam_role_policy" "secrets" {
count = length(var.secrets) > 0 ? 1 : 0
name = "secrets-access"
role = aws_iam_role.lambda.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "secretsmanager:GetSecretValue"
Resource = values(var.secrets)
}]
})
}
# SSM Parameter access
resource "aws_iam_role_policy" "ssm" {
count = length(var.ssm_parameters) > 0 ? 1 : 0
name = "ssm-access"
role = aws_iam_role.lambda.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["ssm:GetParameter", "ssm:GetParameters"]
Resource = values(var.ssm_parameters)
}]
})
}
# Additional policies
resource "aws_iam_role_policy_attachment" "additional" {
for_each = toset(var.policy_arns)
role = aws_iam_role.lambda.name
policy_arn = each.value
}
# Inline policy
resource "aws_iam_role_policy" "inline" {
count = var.inline_policy != "" ? 1 : 0
name = "inline"
role = aws_iam_role.lambda.id
policy = var.inline_policy
}
################################################################################
# CloudWatch Log Group
################################################################################
resource "aws_cloudwatch_log_group" "lambda" {
name = "/aws/lambda/${var.name}"
retention_in_days = var.log_retention_days
tags = merge(var.tags, { Name = var.name })
}
################################################################################
# Lambda Function
################################################################################
resource "aws_lambda_function" "main" {
function_name = var.name
description = var.description != "" ? var.description : "Lambda function ${var.name}"
role = aws_iam_role.lambda.arn
# Deployment package
filename = var.source_dir != "" || var.source_file != "" ? data.archive_file.lambda[0].output_path : null
source_code_hash = var.source_dir != "" || var.source_file != "" ? data.archive_file.lambda[0].output_base64sha256 : null
s3_bucket = var.s3_bucket != "" ? var.s3_bucket : null
s3_key = var.s3_key != "" ? var.s3_key : null
image_uri = var.image_uri != "" ? var.image_uri : null
package_type = var.image_uri != "" ? "Image" : "Zip"
# Runtime config (not for container images)
runtime = var.image_uri == "" ? var.runtime : null
handler = var.image_uri == "" ? var.handler : null
architectures = var.architectures
layers = var.image_uri == "" ? var.layers : null
# Resources
memory_size = var.memory_size
timeout = var.timeout
reserved_concurrent_executions = var.reserved_concurrent_executions
# Environment
dynamic "environment" {
for_each = length(var.environment) > 0 ? [1] : []
content {
variables = var.environment
}
}
# VPC
dynamic "vpc_config" {
for_each = var.vpc_config != null ? [var.vpc_config] : []
content {
subnet_ids = vpc_config.value.subnet_ids
security_group_ids = vpc_config.value.security_group_ids
}
}
# Tracing
dynamic "tracing_config" {
for_each = var.tracing_mode != "" ? [1] : []
content {
mode = var.tracing_mode
}
}
# Dead letter queue
dynamic "dead_letter_config" {
for_each = var.dead_letter_arn != "" ? [1] : []
content {
target_arn = var.dead_letter_arn
}
}
# Logging
logging_config {
log_format = var.log_format
log_group = aws_cloudwatch_log_group.lambda.name
}
tags = merge(var.tags, { Name = var.name })
depends_on = [aws_cloudwatch_log_group.lambda]
}
################################################################################
# Function URL
################################################################################
resource "aws_lambda_function_url" "main" {
count = var.function_url.enabled ? 1 : 0
function_name = aws_lambda_function.main.function_name
authorization_type = var.function_url.auth_type
invoke_mode = var.function_url.invoke_mode
cors {
allow_origins = var.function_url.cors_origins
allow_methods = var.function_url.cors_methods
allow_headers = var.function_url.cors_headers
max_age = 86400
}
}
################################################################################
# Provisioned Concurrency
################################################################################
resource "aws_lambda_alias" "live" {
count = var.provisioned_concurrency > 0 ? 1 : 0
name = "live"
function_name = aws_lambda_function.main.function_name
function_version = aws_lambda_function.main.version
}
resource "aws_lambda_provisioned_concurrency_config" "main" {
count = var.provisioned_concurrency > 0 ? 1 : 0
function_name = aws_lambda_function.main.function_name
provisioned_concurrent_executions = var.provisioned_concurrency
qualifier = aws_lambda_alias.live[0].name
}
################################################################################
# Outputs
################################################################################
output "function_name" {
value = aws_lambda_function.main.function_name
description = "Function name"
}
output "function_arn" {
value = aws_lambda_function.main.arn
description = "Function ARN"
}
output "invoke_arn" {
value = aws_lambda_function.main.invoke_arn
description = "Invoke ARN (for API Gateway)"
}
output "qualified_arn" {
value = aws_lambda_function.main.qualified_arn
description = "Qualified ARN (includes version)"
}
output "role_arn" {
value = aws_iam_role.lambda.arn
description = "IAM role ARN"
}
output "role_name" {
value = aws_iam_role.lambda.name
description = "IAM role name"
}
output "log_group_name" {
value = aws_cloudwatch_log_group.lambda.name
description = "CloudWatch log group name"
}
output "function_url" {
value = var.function_url.enabled ? aws_lambda_function_url.main[0].function_url : null
description = "Function URL"
}
output "version" {
value = aws_lambda_function.main.version
description = "Published version"
}

View File

@@ -0,0 +1,34 @@
# ram-share
Terraform module for AWS landing zone pattern.
Share resources across accounts via AWS Resource Access Manager.
## Planned Features
- [ ] VPC subnet sharing
- [ ] Transit Gateway sharing
- [ ] Route53 Resolver rule sharing
- [ ] Organization-wide sharing option
- [ ] OU-level sharing
## Planned Usage
```hcl
module "vpc_share" {
source = "../modules/ram-share"
name = "shared-vpc-subnets"
resources = [
aws_subnet.private_a.arn,
aws_subnet.private_b.arn,
]
# Share with specific accounts
principals = ["111111111111", "222222222222"]
# Or share with entire org
# allow_organization = true
}
```

View File

@@ -0,0 +1,83 @@
################################################################################
# RAM Share Module
#
# Shares resources across accounts via AWS Resource Access Manager:
# - VPC subnets
# - Transit Gateway
# - Route53 Resolver rules
# - Any RAM-supported resource
################################################################################
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
data "aws_organizations_organization" "this" {
count = var.share_with_organization ? 1 : 0
}
locals {
# Organization ARN for org-wide sharing
org_arn = var.share_with_organization ? data.aws_organizations_organization.this[0].arn : null
}
################################################################################
# Resource Share
################################################################################
resource "aws_ram_resource_share" "this" {
name = var.name
allow_external_principals = var.allow_external_principals
# Enable org sharing if specified
permission_arns = var.permission_arns
tags = merge(var.tags, {
Name = var.name
})
}
################################################################################
# Resource Associations
################################################################################
resource "aws_ram_resource_association" "this" {
for_each = toset(var.resource_arns)
resource_arn = each.value
resource_share_arn = aws_ram_resource_share.this.arn
}
################################################################################
# Principal Associations
################################################################################
# Share with organization
resource "aws_ram_principal_association" "organization" {
count = var.share_with_organization ? 1 : 0
principal = local.org_arn
resource_share_arn = aws_ram_resource_share.this.arn
}
# Share with specific OUs
resource "aws_ram_principal_association" "ous" {
for_each = toset(var.principal_ous)
principal = each.value
resource_share_arn = aws_ram_resource_share.this.arn
}
# Share with specific accounts
resource "aws_ram_principal_association" "accounts" {
for_each = toset(var.principal_accounts)
principal = each.value
resource_share_arn = aws_ram_resource_share.this.arn
}

View File

@@ -0,0 +1,27 @@
################################################################################
# RAM Share - Outputs
################################################################################
output "share_arn" {
value = aws_ram_resource_share.this.arn
description = "Resource share ARN"
}
output "share_id" {
value = aws_ram_resource_share.this.id
description = "Resource share ID"
}
output "resource_associations" {
value = { for k, v in aws_ram_resource_association.this : k => v.id }
description = "Map of resource associations"
}
output "principal_count" {
value = (
(var.share_with_organization ? 1 : 0) +
length(var.principal_ous) +
length(var.principal_accounts)
)
description = "Number of principals shared with"
}

Some files were not shown because too many files have changed in this diff Show More