mirror of
https://github.com/ghndrx/terraform-foundation.git
synced 2026-02-09 22:35:00 +00:00
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:
36
.checkov.yml
Normal file
36
.checkov.yml
Normal 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
219
.github/workflows/terraform.yml
vendored
Normal 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
44
.gitignore
vendored
Normal 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
90
.pre-commit-config.yaml
Normal 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
76
.terraform-docs.yml
Normal 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
138
.tflint.hcl
Normal 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
37
.trivy.yaml
Normal 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
41
.yamllint.yml
Normal 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
149
Makefile
Normal 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
592
README.md
Normal file
@@ -0,0 +1,592 @@
|
||||
# Terraform Foundation
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
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
69
REVIEW.md
Normal 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
212
docs/COST-OPTIMIZATION.md
Normal 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
170
docs/SECURITY.md
Normal 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
17
live/dev/env.hcl
Normal 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
17
live/prod/env.hcl
Normal 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
|
||||
}
|
||||
}
|
||||
143
live/shared/github-oidc/terragrunt.hcl
Normal file
143
live/shared/github-oidc/terragrunt.hcl
Normal 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
17
live/staging/env.hcl
Normal 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
130
scripts/apply-all.sh
Executable 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
92
scripts/new-tenant.sh
Executable 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
359
scripts/new-workload.sh
Executable 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 ""
|
||||
385
terraform/00-bootstrap/main.tf
Normal file
385
terraform/00-bootstrap/main.tf
Normal 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
|
||||
}
|
||||
6
terraform/00-bootstrap/terraform.tfvars.example
Normal file
6
terraform/00-bootstrap/terraform.tfvars.example
Normal 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"
|
||||
572
terraform/01-organization/main.tf
Normal file
572
terraform/01-organization/main.tf
Normal 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
|
||||
}
|
||||
}
|
||||
12
terraform/01-organization/terraform.tfvars.example
Normal file
12
terraform/01-organization/terraform.tfvars.example
Normal 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"
|
||||
305
terraform/02-network/main.tf
Normal file
305
terraform/02-network/main.tf
Normal 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
|
||||
}
|
||||
37
terraform/02-network/terraform.tfvars.example
Normal file
37
terraform/02-network/terraform.tfvars.example
Normal 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
|
||||
# }
|
||||
432
terraform/03-platform/main.tf
Normal file
432
terraform/03-platform/main.tf
Normal 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}"
|
||||
}
|
||||
15
terraform/03-platform/terraform.tfvars.example
Normal file
15
terraform/03-platform/terraform.tfvars.example
Normal 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
|
||||
466
terraform/04-tenants/_template/main.tf
Normal file
466
terraform/04-tenants/_template/main.tf
Normal 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
|
||||
}
|
||||
532
terraform/05-workloads/_template/api-gateway/main.tf
Normal file
532
terraform/05-workloads/_template/api-gateway/main.tf
Normal 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"
|
||||
}
|
||||
473
terraform/05-workloads/_template/aurora-serverless/main.tf
Normal file
473
terraform/05-workloads/_template/aurora-serverless/main.tf
Normal 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"
|
||||
}
|
||||
501
terraform/05-workloads/_template/cognito-auth/main.tf
Normal file
501
terraform/05-workloads/_template/cognito-auth/main.tf
Normal 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])}"
|
||||
}
|
||||
439
terraform/05-workloads/_template/dynamodb-table/main.tf
Normal file
439
terraform/05-workloads/_template/dynamodb-table/main.tf
Normal 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"
|
||||
}
|
||||
400
terraform/05-workloads/_template/ecr-repository/main.tf
Normal file
400
terraform/05-workloads/_template/ecr-repository/main.tf
Normal 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"
|
||||
}
|
||||
701
terraform/05-workloads/_template/ecs-fargate/main.tf
Normal file
701
terraform/05-workloads/_template/ecs-fargate/main.tf
Normal 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"
|
||||
}
|
||||
560
terraform/05-workloads/_template/ecs-service/main.tf
Normal file
560
terraform/05-workloads/_template/ecs-service/main.tf
Normal 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
|
||||
}
|
||||
968
terraform/05-workloads/_template/eks-cluster/main.tf
Normal file
968
terraform/05-workloads/_template/eks-cluster/main.tf
Normal 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
|
||||
}
|
||||
389
terraform/05-workloads/_template/elasticache-redis/main.tf
Normal file
389
terraform/05-workloads/_template/elasticache-redis/main.tf
Normal 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)"
|
||||
}
|
||||
385
terraform/05-workloads/_template/eventbridge-bus/main.tf
Normal file
385
terraform/05-workloads/_template/eventbridge-bus/main.tf
Normal 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"
|
||||
}
|
||||
422
terraform/05-workloads/_template/eventbridge-rules/main.tf
Normal file
422
terraform/05-workloads/_template/eventbridge-rules/main.tf
Normal 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"
|
||||
}
|
||||
409
terraform/05-workloads/_template/lambda-function/main.tf
Normal file
409
terraform/05-workloads/_template/lambda-function/main.tf
Normal 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
|
||||
}
|
||||
458
terraform/05-workloads/_template/opensearch/main.tf
Normal file
458
terraform/05-workloads/_template/opensearch/main.tf
Normal 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"
|
||||
}
|
||||
544
terraform/05-workloads/_template/rds-database/main.tf
Normal file
544
terraform/05-workloads/_template/rds-database/main.tf
Normal 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"
|
||||
}
|
||||
527
terraform/05-workloads/_template/s3-bucket/main.tf
Normal file
527
terraform/05-workloads/_template/s3-bucket/main.tf
Normal 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
|
||||
}
|
||||
427
terraform/05-workloads/_template/secrets-manager/main.tf
Normal file
427
terraform/05-workloads/_template/secrets-manager/main.tf
Normal 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"
|
||||
}
|
||||
524
terraform/05-workloads/_template/ses-email/main.tf
Normal file
524
terraform/05-workloads/_template/ses-email/main.tf
Normal 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
|
||||
}
|
||||
}
|
||||
383
terraform/05-workloads/_template/sns-topic/main.tf
Normal file
383
terraform/05-workloads/_template/sns-topic/main.tf
Normal 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"
|
||||
}
|
||||
379
terraform/05-workloads/_template/sqs-queue/main.tf
Normal file
379
terraform/05-workloads/_template/sqs-queue/main.tf
Normal 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
|
||||
}
|
||||
343
terraform/05-workloads/_template/ssm-parameters/main.tf
Normal file
343
terraform/05-workloads/_template/ssm-parameters/main.tf
Normal 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"
|
||||
}
|
||||
450
terraform/05-workloads/_template/static-site/main.tf
Normal file
450
terraform/05-workloads/_template/static-site/main.tf
Normal 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"
|
||||
}
|
||||
499
terraform/05-workloads/_template/step-function/main.tf
Normal file
499
terraform/05-workloads/_template/step-function/main.tf
Normal 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\"}'"
|
||||
}
|
||||
43
terraform/modules/account-baseline/README.md
Normal file
43
terraform/modules/account-baseline/README.md
Normal 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 |
|
||||
314
terraform/modules/account-baseline/main.tf
Normal file
314
terraform/modules/account-baseline/main.tf
Normal 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"
|
||||
}
|
||||
56
terraform/modules/account-baseline/outputs.tf
Normal file
56
terraform/modules/account-baseline/outputs.tf
Normal 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"
|
||||
}
|
||||
206
terraform/modules/account-baseline/variables.tf
Normal file
206
terraform/modules/account-baseline/variables.tf
Normal 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"
|
||||
}
|
||||
49
terraform/modules/acm-certificate/README.md
Normal file
49
terraform/modules/acm-certificate/README.md
Normal 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.
|
||||
163
terraform/modules/acm-certificate/main.tf
Normal file
163
terraform/modules/acm-certificate/main.tf
Normal 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"
|
||||
}
|
||||
68
terraform/modules/alb/README.md
Normal file
68
terraform/modules/alb/README.md
Normal 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.
|
||||
497
terraform/modules/alb/main.tf
Normal file
497
terraform/modules/alb/main.tf
Normal 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"
|
||||
}
|
||||
50
terraform/modules/alerting/README.md
Normal file
50
terraform/modules/alerting/README.md
Normal 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.
|
||||
429
terraform/modules/alerting/main.tf
Normal file
429
terraform/modules/alerting/main.tf
Normal 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
|
||||
}
|
||||
}
|
||||
36
terraform/modules/app-account/README.md
Normal file
36
terraform/modules/app-account/README.md
Normal 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
|
||||
}
|
||||
```
|
||||
222
terraform/modules/app-account/main.tf
Normal file
222
terraform/modules/app-account/main.tf
Normal 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]
|
||||
}
|
||||
49
terraform/modules/app-account/outputs.tf
Normal file
49
terraform/modules/app-account/outputs.tf
Normal 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"
|
||||
}
|
||||
131
terraform/modules/app-account/variables.tf
Normal file
131
terraform/modules/app-account/variables.tf
Normal 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"
|
||||
}
|
||||
54
terraform/modules/backup-plan/README.md
Normal file
54
terraform/modules/backup-plan/README.md
Normal 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.
|
||||
303
terraform/modules/backup-plan/main.tf
Normal file
303
terraform/modules/backup-plan/main.tf
Normal 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
|
||||
}
|
||||
54
terraform/modules/budget-alerts/README.md
Normal file
54
terraform/modules/budget-alerts/README.md
Normal 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.
|
||||
359
terraform/modules/budget-alerts/main.tf
Normal file
359
terraform/modules/budget-alerts/main.tf
Normal 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"
|
||||
}
|
||||
60
terraform/modules/cloudtrail/README.md
Normal file
60
terraform/modules/cloudtrail/README.md
Normal 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.
|
||||
506
terraform/modules/cloudtrail/main.tf
Normal file
506
terraform/modules/cloudtrail/main.tf
Normal 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"
|
||||
}
|
||||
49
terraform/modules/cloudwatch-dashboard/README.md
Normal file
49
terraform/modules/cloudwatch-dashboard/README.md
Normal 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.
|
||||
431
terraform/modules/cloudwatch-dashboard/main.tf
Normal file
431
terraform/modules/cloudwatch-dashboard/main.tf
Normal 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
|
||||
}
|
||||
51
terraform/modules/config-rules/README.md
Normal file
51
terraform/modules/config-rules/README.md
Normal 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.
|
||||
514
terraform/modules/config-rules/main.tf
Normal file
514
terraform/modules/config-rules/main.tf
Normal 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"
|
||||
}
|
||||
229
terraform/modules/github-oidc/README.md
Normal file
229
terraform/modules/github-oidc/README.md
Normal 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
|
||||
54
terraform/modules/github-oidc/examples/basic/main.tf
Normal file
54
terraform/modules/github-oidc/examples/basic/main.tf
Normal 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
|
||||
}
|
||||
126
terraform/modules/github-oidc/examples/multi-role/main.tf
Normal file
126
terraform/modules/github-oidc/examples/multi-role/main.tf
Normal 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
|
||||
}
|
||||
159
terraform/modules/github-oidc/examples/templates/main.tf
Normal file
159
terraform/modules/github-oidc/examples/templates/main.tf
Normal 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
|
||||
}
|
||||
673
terraform/modules/github-oidc/main.tf
Normal file
673
terraform/modules/github-oidc/main.tf
Normal 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
|
||||
}
|
||||
159
terraform/modules/github-oidc/outputs.tf
Normal file
159
terraform/modules/github-oidc/outputs.tf
Normal 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"
|
||||
}
|
||||
210
terraform/modules/github-oidc/tests/basic.tftest.hcl
Normal file
210
terraform/modules/github-oidc/tests/basic.tftest.hcl
Normal 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"
|
||||
}
|
||||
}
|
||||
203
terraform/modules/github-oidc/tests/security.tftest.hcl
Normal file
203
terraform/modules/github-oidc/tests/security.tftest.hcl
Normal 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"
|
||||
}
|
||||
}
|
||||
248
terraform/modules/github-oidc/variables.tf
Normal file
248
terraform/modules/github-oidc/variables.tf
Normal 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."
|
||||
}
|
||||
}
|
||||
18
terraform/modules/github-oidc/versions.tf
Normal file
18
terraform/modules/github-oidc/versions.tf
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
50
terraform/modules/iam-account-settings/README.md
Normal file
50
terraform/modules/iam-account-settings/README.md
Normal 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.
|
||||
338
terraform/modules/iam-account-settings/main.tf
Normal file
338
terraform/modules/iam-account-settings/main.tf
Normal 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)"
|
||||
}
|
||||
60
terraform/modules/iam-role/README.md
Normal file
60
terraform/modules/iam-role/README.md
Normal 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.
|
||||
352
terraform/modules/iam-role/main.tf
Normal file
352
terraform/modules/iam-role/main.tf
Normal 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"
|
||||
}
|
||||
40
terraform/modules/identity-center/README.md
Normal file
40
terraform/modules/identity-center/README.md
Normal 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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
145
terraform/modules/identity-center/main.tf
Normal file
145
terraform/modules/identity-center/main.tf
Normal 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"
|
||||
}
|
||||
28
terraform/modules/identity-center/outputs.tf
Normal file
28
terraform/modules/identity-center/outputs.tf
Normal 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"
|
||||
}
|
||||
36
terraform/modules/identity-center/variables.tf
Normal file
36
terraform/modules/identity-center/variables.tf
Normal 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"
|
||||
}
|
||||
54
terraform/modules/kms-key/README.md
Normal file
54
terraform/modules/kms-key/README.md
Normal 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.
|
||||
290
terraform/modules/kms-key/main.tf
Normal file
290
terraform/modules/kms-key/main.tf
Normal 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"
|
||||
}
|
||||
65
terraform/modules/lambda-function/README.md
Normal file
65
terraform/modules/lambda-function/README.md
Normal 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.
|
||||
501
terraform/modules/lambda-function/main.tf
Normal file
501
terraform/modules/lambda-function/main.tf
Normal 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"
|
||||
}
|
||||
34
terraform/modules/ram-share/README.md
Normal file
34
terraform/modules/ram-share/README.md
Normal 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
|
||||
}
|
||||
```
|
||||
83
terraform/modules/ram-share/main.tf
Normal file
83
terraform/modules/ram-share/main.tf
Normal 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
|
||||
}
|
||||
27
terraform/modules/ram-share/outputs.tf
Normal file
27
terraform/modules/ram-share/outputs.tf
Normal 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
Reference in New Issue
Block a user