feat: Terraform Foundation - AWS Landing Zone

Enterprise-grade multi-tenant AWS cloud foundation.

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,49 @@
################################################################################
# RAM Share - Input Variables
################################################################################
variable "name" {
type = string
description = "Name of the resource share"
}
variable "resource_arns" {
type = list(string)
description = "List of resource ARNs to share"
}
variable "share_with_organization" {
type = bool
default = false
description = "Share with entire organization"
}
variable "principal_ous" {
type = list(string)
default = []
description = "OU ARNs to share with"
}
variable "principal_accounts" {
type = list(string)
default = []
description = "Account IDs to share with"
}
variable "allow_external_principals" {
type = bool
default = false
description = "Allow sharing with external accounts"
}
variable "permission_arns" {
type = list(string)
default = null
description = "Custom RAM permission ARNs"
}
variable "tags" {
type = map(string)
default = {}
description = "Tags to apply to resources"
}

View File

@@ -0,0 +1,57 @@
# route53-zone
Route53 Zone Module
## Usage
```hcl
module "route53_zone" {
source = "../modules/route53-zone"
# Required variables
domain_name = ""
records = ""
alias_records = ""
mx_records = ""
# Optional: see variables.tf for all options
}
```
## Requirements
| Name | Version |
|------|---------|
| terraform | >= 1.5.0 |
| aws | >= 5.0 |
## Inputs
| Name | Description | Type | Required |
|------|-------------|------|----------|
| domain_name | Domain name for the hosted zone | `string` | yes |
| comment | Comment for the hosted zone | `string` | no |
| private_zone | Create a private hosted zone | `bool` | no |
| vpc_ids | VPC IDs to associate with private zone | `list(string)` | no |
| enable_dnssec | Enable DNSSEC signing | `bool` | no |
| enable_query_logging | Enable query logging to CloudWatch | `bool` | no |
| query_log_retention_days | Query log retention in days | `number` | no |
| records | | `map(object({` | yes |
| alias_records | | `map(object({` | yes |
| mx_records | | `list(object({` | yes |
| txt_records | | `map(string)` | no |
| tags | | `map(string)` | no |
## Outputs
| Name | Description |
|------|-------------|
| zone_id | Hosted zone ID |
| zone_arn | Hosted zone ARN |
| name_servers | Name servers for the zone (update at registrar) |
| domain_name | Domain name |
| dnssec_ds_record | DS record for DNSSEC (add to parent zone/registrar) |
## License
Apache 2.0 - See LICENSE for details.

View File

@@ -0,0 +1,418 @@
################################################################################
# Route53 Zone Module
#
# DNS zone management:
# - Public or private hosted zones
# - Common record types (A, AAAA, CNAME, MX, TXT)
# - Alias records (CloudFront, ALB, S3, API Gateway)
# - DNSSEC signing
# - Query logging
# - Health checks
#
# Usage:
# module "dns" {
# source = "../modules/route53-zone"
#
# domain_name = "example.com"
#
# records = {
# "www" = {
# type = "CNAME"
# ttl = 300
# records = ["example.com"]
# }
# "mail" = {
# type = "MX"
# ttl = 300
# records = ["10 mail.example.com"]
# }
# }
# }
################################################################################
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
variable "domain_name" {
type = string
description = "Domain name for the hosted zone"
}
variable "comment" {
type = string
default = ""
description = "Comment for the hosted zone"
}
variable "private_zone" {
type = bool
default = false
description = "Create a private hosted zone"
}
variable "vpc_ids" {
type = list(string)
default = []
description = "VPC IDs to associate with private zone"
}
variable "enable_dnssec" {
type = bool
default = false
description = "Enable DNSSEC signing"
}
variable "enable_query_logging" {
type = bool
default = false
description = "Enable query logging to CloudWatch"
}
variable "query_log_retention_days" {
type = number
default = 30
description = "Query log retention in days"
}
variable "records" {
type = map(object({
type = string
ttl = optional(number, 300)
records = optional(list(string))
alias = optional(object({
name = string
zone_id = string
evaluate_target_health = optional(bool, false)
}))
health_check_id = optional(string)
set_identifier = optional(string)
weight = optional(number)
latency_routing_region = optional(string)
geolocation = optional(object({
continent = optional(string)
country = optional(string)
subdivision = optional(string)
}))
failover = optional(string)
}))
default = {}
description = "DNS records to create"
}
variable "alias_records" {
type = map(object({
type = optional(string, "A")
target_dns_name = string
target_zone_id = string
evaluate_target_health = optional(bool, false)
}))
default = {}
description = "Alias records (simplified syntax for CloudFront, ALB, etc.)"
}
variable "mx_records" {
type = list(object({
priority = number
server = string
}))
default = []
description = "MX records for email"
}
variable "txt_records" {
type = map(string)
default = {}
description = "TXT records (name -> value)"
}
variable "tags" {
type = map(string)
default = {}
}
data "aws_region" "current" {}
################################################################################
# Hosted Zone
################################################################################
resource "aws_route53_zone" "main" {
name = var.domain_name
comment = var.comment != "" ? var.comment : "Managed by Terraform"
dynamic "vpc" {
for_each = var.private_zone ? var.vpc_ids : []
content {
vpc_id = vpc.value
}
}
tags = merge(var.tags, { Name = var.domain_name })
}
################################################################################
# Standard Records
################################################################################
resource "aws_route53_record" "records" {
for_each = var.records
zone_id = aws_route53_zone.main.zone_id
name = each.key == "@" ? var.domain_name : "${each.key}.${var.domain_name}"
type = each.value.type
# Standard records
ttl = each.value.alias == null ? each.value.ttl : null
records = each.value.alias == null ? each.value.records : null
# Alias records
dynamic "alias" {
for_each = each.value.alias != null ? [each.value.alias] : []
content {
name = alias.value.name
zone_id = alias.value.zone_id
evaluate_target_health = alias.value.evaluate_target_health
}
}
# Routing policies
health_check_id = each.value.health_check_id
set_identifier = each.value.set_identifier
dynamic "weighted_routing_policy" {
for_each = each.value.weight != null ? [1] : []
content {
weight = each.value.weight
}
}
dynamic "latency_routing_policy" {
for_each = each.value.latency_routing_region != null ? [1] : []
content {
region = each.value.latency_routing_region
}
}
dynamic "geolocation_routing_policy" {
for_each = each.value.geolocation != null ? [each.value.geolocation] : []
content {
continent = geolocation_routing_policy.value.continent
country = geolocation_routing_policy.value.country
subdivision = geolocation_routing_policy.value.subdivision
}
}
dynamic "failover_routing_policy" {
for_each = each.value.failover != null ? [1] : []
content {
type = each.value.failover
}
}
}
################################################################################
# Simplified Alias Records
################################################################################
resource "aws_route53_record" "alias" {
for_each = var.alias_records
zone_id = aws_route53_zone.main.zone_id
name = each.key == "@" ? var.domain_name : "${each.key}.${var.domain_name}"
type = each.value.type
alias {
name = each.value.target_dns_name
zone_id = each.value.target_zone_id
evaluate_target_health = each.value.evaluate_target_health
}
}
################################################################################
# MX Records
################################################################################
resource "aws_route53_record" "mx" {
count = length(var.mx_records) > 0 ? 1 : 0
zone_id = aws_route53_zone.main.zone_id
name = var.domain_name
type = "MX"
ttl = 300
records = [for mx in var.mx_records : "${mx.priority} ${mx.server}"]
}
################################################################################
# TXT Records
################################################################################
resource "aws_route53_record" "txt" {
for_each = var.txt_records
zone_id = aws_route53_zone.main.zone_id
name = each.key == "@" ? var.domain_name : "${each.key}.${var.domain_name}"
type = "TXT"
ttl = 300
records = [each.value]
}
################################################################################
# DNSSEC
################################################################################
resource "aws_route53_key_signing_key" "main" {
count = var.enable_dnssec && !var.private_zone ? 1 : 0
hosted_zone_id = aws_route53_zone.main.id
key_management_service_arn = aws_kms_key.dnssec[0].arn
name = "${replace(var.domain_name, ".", "-")}-ksk"
}
resource "aws_kms_key" "dnssec" {
count = var.enable_dnssec && !var.private_zone ? 1 : 0
customer_master_key_spec = "ECC_NIST_P256"
deletion_window_in_days = 7
key_usage = "SIGN_VERIFY"
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 Route 53 DNSSEC Service"
Effect = "Allow"
Principal = {
Service = "dnssec-route53.amazonaws.com"
}
Action = [
"kms:DescribeKey",
"kms:GetPublicKey",
"kms:Sign"
]
Resource = "*"
Condition = {
StringEquals = {
"aws:SourceAccount" = data.aws_caller_identity.current.account_id
}
ArnLike = {
"aws:SourceArn" = "arn:aws:route53:::hostedzone/*"
}
}
},
{
Sid = "Allow Route 53 DNSSEC to CreateGrant"
Effect = "Allow"
Principal = {
Service = "dnssec-route53.amazonaws.com"
}
Action = "kms:CreateGrant"
Resource = "*"
Condition = {
Bool = {
"kms:GrantIsForAWSResource" = "true"
}
}
}
]
})
tags = merge(var.tags, { Name = "${var.domain_name}-dnssec" })
}
data "aws_caller_identity" "current" {}
resource "aws_route53_hosted_zone_dnssec" "main" {
count = var.enable_dnssec && !var.private_zone ? 1 : 0
hosted_zone_id = aws_route53_zone.main.id
depends_on = [aws_route53_key_signing_key.main]
}
################################################################################
# Query Logging
################################################################################
resource "aws_cloudwatch_log_group" "query_log" {
count = var.enable_query_logging && !var.private_zone ? 1 : 0
name = "/aws/route53/${var.domain_name}"
retention_in_days = var.query_log_retention_days
tags = merge(var.tags, { Name = var.domain_name })
}
resource "aws_cloudwatch_log_resource_policy" "query_log" {
count = var.enable_query_logging && !var.private_zone ? 1 : 0
policy_name = "route53-query-logging-${replace(var.domain_name, ".", "-")}"
policy_document = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
Service = "route53.amazonaws.com"
}
Action = [
"logs:CreateLogStream",
"logs:PutLogEvents"
]
Resource = "${aws_cloudwatch_log_group.query_log[0].arn}:*"
}]
})
}
resource "aws_route53_query_log" "main" {
count = var.enable_query_logging && !var.private_zone ? 1 : 0
cloudwatch_log_group_arn = aws_cloudwatch_log_group.query_log[0].arn
zone_id = aws_route53_zone.main.zone_id
depends_on = [aws_cloudwatch_log_resource_policy.query_log]
}
################################################################################
# Outputs
################################################################################
output "zone_id" {
value = aws_route53_zone.main.zone_id
description = "Hosted zone ID"
}
output "zone_arn" {
value = aws_route53_zone.main.arn
description = "Hosted zone ARN"
}
output "name_servers" {
value = aws_route53_zone.main.name_servers
description = "Name servers for the zone (update at registrar)"
}
output "domain_name" {
value = var.domain_name
description = "Domain name"
}
output "dnssec_ds_record" {
value = var.enable_dnssec && !var.private_zone ? aws_route53_key_signing_key.main[0].ds_record : null
description = "DS record for DNSSEC (add to parent zone/registrar)"
}

View File

@@ -0,0 +1,97 @@
# scps
AWS Organizations Service Control Policies for security guardrails.
## Features
- **Deny leaving organization** - Prevent accounts from leaving
- **Require IMDSv2** - Block EC2 instances without IMDSv2
- **Deny root actions** - Block most root user operations
- **Region restrictions** - Limit operations to allowed regions
- **Protect security services** - Prevent disabling GuardDuty, Security Hub, Config
- **Protect CloudTrail** - Prevent trail modification
- **Require S3 encryption** - Block unencrypted S3 objects
- **Require EBS encryption** - Block unencrypted volumes
## Usage
```hcl
module "scps" {
source = "../modules/scps"
name_prefix = "org"
# Enable all security guardrails
enable_deny_leave_org = true
enable_require_imdsv2 = true
enable_deny_root_actions = true
protect_security_services = true
protect_cloudtrail = true
require_s3_encryption = true
require_ebs_encryption = true
# Optional: Region restriction
allowed_regions = ["us-east-1", "us-west-2", "eu-west-1"]
# Attach to OUs
target_ous = [
"ou-xxxx-workloads",
"ou-xxxx-sandbox"
]
tags = {
Environment = "org"
ManagedBy = "terraform"
}
}
```
## Requirements
| Name | Version |
|------|---------|
| terraform | >= 1.5.0 |
| aws | >= 5.0 |
## Providers
Must be run from the **AWS Organizations management account**.
## Inputs
| Name | Description | Type | Default | Required |
|------|-------------|------|---------|----------|
| name_prefix | Prefix for SCP names | `string` | `"scp"` | no |
| enable_deny_leave_org | Prevent accounts from leaving | `bool` | `true` | no |
| enable_require_imdsv2 | Require IMDSv2 for EC2 | `bool` | `true` | no |
| enable_deny_root_actions | Deny root user actions | `bool` | `true` | no |
| allowed_regions | Allowed AWS regions | `list(string)` | `[]` | no |
| protect_security_services | Protect security services | `bool` | `true` | no |
| protect_cloudtrail | Protect CloudTrail | `bool` | `true` | no |
| require_s3_encryption | Require S3 encryption | `bool` | `true` | no |
| require_ebs_encryption | Require EBS encryption | `bool` | `true` | no |
| target_ous | OU IDs to attach SCPs | `list(string)` | `[]` | no |
| target_accounts | Account IDs to attach SCPs | `list(string)` | `[]` | no |
| tags | Resource tags | `map(string)` | `{}` | no |
## Outputs
| Name | Description |
|------|-------------|
| policy_ids | Map of SCP names to policy IDs |
| policy_arns | Map of SCP names to policy ARNs |
| enabled_policies | List of enabled SCP names |
| attachment_count | Count of attachments |
## Security Best Practices
These SCPs implement:
- CIS AWS Foundations Benchmark
- AWS Security Reference Architecture
- Well-Architected Framework Security Pillar
## Notes
- SCPs only affect member accounts, not the management account
- Test SCPs in sandbox OU before applying to production
- Global services (IAM, Route53, etc.) are exempt from region restrictions

View File

@@ -0,0 +1,388 @@
################################################################################
# Service Control Policies Module
#
# Implements AWS Organizations SCPs for security guardrails:
# - Deny leaving organization
# - Require IMDSv2
# - Deny root user actions
# - Region restrictions
# - Protect security services
# - Protect CloudTrail
# - Require encryption
#
# References:
# - AWS SRA: https://docs.aws.amazon.com/prescriptive-guidance/latest/security-reference-architecture
# - CIS Benchmark: https://www.cisecurity.org/benchmark/amazon_web_services
################################################################################
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
locals {
# Build list of SCPs to create based on enabled flags
scps = merge(
var.enable_deny_leave_org ? {
deny_leave_org = {
name = "${var.name_prefix}-deny-leave-org"
description = "Prevent accounts from leaving the organization"
policy = data.aws_iam_policy_document.deny_leave_org.json
}
} : {},
var.enable_require_imdsv2 ? {
require_imdsv2 = {
name = "${var.name_prefix}-require-imdsv2"
description = "Require IMDSv2 for EC2 instances"
policy = data.aws_iam_policy_document.require_imdsv2.json
}
} : {},
var.enable_deny_root_actions ? {
deny_root = {
name = "${var.name_prefix}-deny-root-actions"
description = "Deny most actions by root user"
policy = data.aws_iam_policy_document.deny_root.json
}
} : {},
length(var.allowed_regions) > 0 ? {
region_restriction = {
name = "${var.name_prefix}-region-restriction"
description = "Restrict operations to allowed regions"
policy = data.aws_iam_policy_document.region_restriction.json
}
} : {},
var.protect_security_services ? {
protect_security = {
name = "${var.name_prefix}-protect-security-services"
description = "Prevent disabling security services"
policy = data.aws_iam_policy_document.protect_security.json
}
} : {},
var.protect_cloudtrail ? {
protect_cloudtrail = {
name = "${var.name_prefix}-protect-cloudtrail"
description = "Prevent CloudTrail modification"
policy = data.aws_iam_policy_document.protect_cloudtrail.json
}
} : {},
var.require_s3_encryption ? {
require_s3_encryption = {
name = "${var.name_prefix}-require-s3-encryption"
description = "Require S3 bucket encryption"
policy = data.aws_iam_policy_document.require_s3_encryption.json
}
} : {},
var.require_ebs_encryption ? {
require_ebs_encryption = {
name = "${var.name_prefix}-require-ebs-encryption"
description = "Require EBS volume encryption"
policy = data.aws_iam_policy_document.require_ebs_encryption.json
}
} : {},
)
# Global services that shouldn't be region-restricted
global_services = [
"iam",
"organizations",
"sts",
"support",
"budgets",
"cloudfront",
"route53",
"waf",
"waf-regional",
"health",
"trustedadvisor",
]
}
################################################################################
# Policy Documents
################################################################################
data "aws_iam_policy_document" "deny_leave_org" {
statement {
sid = "DenyLeaveOrganization"
effect = "Deny"
actions = ["organizations:LeaveOrganization"]
resources = ["*"]
}
}
data "aws_iam_policy_document" "require_imdsv2" {
statement {
sid = "RequireIMDSv2"
effect = "Deny"
actions = [
"ec2:RunInstances"
]
resources = ["arn:aws:ec2:*:*:instance/*"]
condition {
test = "StringNotEquals"
variable = "ec2:MetadataHttpTokens"
values = ["required"]
}
}
statement {
sid = "DenyIMDSv1Modification"
effect = "Deny"
actions = [
"ec2:ModifyInstanceMetadataOptions"
]
resources = ["*"]
condition {
test = "StringNotEquals"
variable = "ec2:MetadataHttpTokens"
values = ["required"]
}
}
}
data "aws_iam_policy_document" "deny_root" {
statement {
sid = "DenyRootActions"
effect = "Deny"
not_actions = [
# Allow essential root-only actions
"iam:CreateVirtualMFADevice",
"iam:EnableMFADevice",
"iam:GetAccountPasswordPolicy",
"iam:GetAccountSummary",
"iam:ListVirtualMFADevices",
"sts:GetSessionToken",
"support:*",
]
resources = ["*"]
condition {
test = "StringLike"
variable = "aws:PrincipalArn"
values = ["arn:aws:iam::*:root"]
}
}
}
data "aws_iam_policy_document" "region_restriction" {
statement {
sid = "DenyNonAllowedRegions"
effect = "Deny"
not_actions = [
# Global services - always allow
"iam:*",
"organizations:*",
"sts:*",
"support:*",
"budgets:*",
"cloudfront:*",
"route53:*",
"route53domains:*",
"waf:*",
"wafv2:*",
"waf-regional:*",
"health:*",
"trustedadvisor:*",
"globalaccelerator:*",
"shield:*",
"chime:*",
"aws-portal:*",
]
resources = ["*"]
condition {
test = "StringNotEquals"
variable = "aws:RequestedRegion"
values = var.allowed_regions
}
}
}
data "aws_iam_policy_document" "protect_security" {
statement {
sid = "ProtectGuardDuty"
effect = "Deny"
actions = [
"guardduty:DeleteDetector",
"guardduty:DeleteMembers",
"guardduty:DisassociateFromMasterAccount",
"guardduty:StopMonitoringMembers",
"guardduty:UpdateDetector",
]
resources = ["*"]
}
statement {
sid = "ProtectSecurityHub"
effect = "Deny"
actions = [
"securityhub:DisableSecurityHub",
"securityhub:DeleteMembers",
"securityhub:DisassociateFromMasterAccount",
]
resources = ["*"]
}
statement {
sid = "ProtectConfig"
effect = "Deny"
actions = [
"config:DeleteConfigRule",
"config:DeleteConfigurationRecorder",
"config:DeleteDeliveryChannel",
"config:StopConfigurationRecorder",
]
resources = ["*"]
}
statement {
sid = "ProtectAccessAnalyzer"
effect = "Deny"
actions = [
"access-analyzer:DeleteAnalyzer",
]
resources = ["*"]
}
}
data "aws_iam_policy_document" "protect_cloudtrail" {
statement {
sid = "ProtectCloudTrail"
effect = "Deny"
actions = [
"cloudtrail:DeleteTrail",
"cloudtrail:StopLogging",
"cloudtrail:UpdateTrail",
"cloudtrail:PutEventSelectors",
]
resources = ["*"]
# Allow org management account to manage org trail
condition {
test = "StringNotEquals"
variable = "aws:PrincipalOrgMasterAccountId"
values = ["${data.aws_caller_identity.current.account_id}"]
}
}
}
data "aws_iam_policy_document" "require_s3_encryption" {
statement {
sid = "DenyUnencryptedS3PutObject"
effect = "Deny"
actions = [
"s3:PutObject"
]
resources = ["*"]
condition {
test = "Null"
variable = "s3:x-amz-server-side-encryption"
values = ["true"]
}
}
statement {
sid = "DenyWrongEncryptionType"
effect = "Deny"
actions = [
"s3:PutObject"
]
resources = ["*"]
condition {
test = "StringNotEqualsIfExists"
variable = "s3:x-amz-server-side-encryption"
values = ["AES256", "aws:kms"]
}
}
}
data "aws_iam_policy_document" "require_ebs_encryption" {
statement {
sid = "DenyUnencryptedVolume"
effect = "Deny"
actions = [
"ec2:CreateVolume"
]
resources = ["*"]
condition {
test = "Bool"
variable = "ec2:Encrypted"
values = ["false"]
}
}
statement {
sid = "DenyUnencryptedSnapshot"
effect = "Deny"
actions = [
"ec2:RunInstances"
]
resources = ["arn:aws:ec2:*::snapshot/*"]
condition {
test = "Bool"
variable = "ec2:Encrypted"
values = ["false"]
}
}
}
################################################################################
# Data Sources
################################################################################
data "aws_caller_identity" "current" {}
data "aws_organizations_organization" "current" {}
################################################################################
# SCP Resources
################################################################################
resource "aws_organizations_policy" "this" {
for_each = local.scps
name = each.value.name
description = each.value.description
type = "SERVICE_CONTROL_POLICY"
content = each.value.policy
tags = merge(var.tags, {
Name = each.value.name
})
}
# Attach SCPs to specified OUs
resource "aws_organizations_policy_attachment" "ou" {
for_each = {
for pair in setproduct(keys(local.scps), var.target_ous) : "${pair[0]}-${pair[1]}" => {
policy_key = pair[0]
target_id = pair[1]
}
}
policy_id = aws_organizations_policy.this[each.value.policy_key].id
target_id = each.value.target_id
}
# Attach SCPs to specified accounts
resource "aws_organizations_policy_attachment" "account" {
for_each = {
for pair in setproduct(keys(local.scps), var.target_accounts) : "${pair[0]}-${pair[1]}" => {
policy_key = pair[0]
target_id = pair[1]
}
}
policy_id = aws_organizations_policy.this[each.value.policy_key].id
target_id = each.value.target_id
}

View File

@@ -0,0 +1,26 @@
################################################################################
# SCPs - Outputs
################################################################################
output "policy_ids" {
value = { for k, v in aws_organizations_policy.this : k => v.id }
description = "Map of SCP names to policy IDs"
}
output "policy_arns" {
value = { for k, v in aws_organizations_policy.this : k => v.arn }
description = "Map of SCP names to policy ARNs"
}
output "enabled_policies" {
value = keys(local.scps)
description = "List of enabled SCP policy names"
}
output "attachment_count" {
value = {
ous = length(var.target_ous)
accounts = length(var.target_accounts)
}
description = "Count of SCP attachments"
}

View File

@@ -0,0 +1,75 @@
################################################################################
# SCPs - Input Variables
################################################################################
variable "name_prefix" {
type = string
default = "scp"
description = "Prefix for SCP names"
}
variable "enable_deny_leave_org" {
type = bool
default = true
description = "Prevent accounts from leaving organization"
}
variable "enable_require_imdsv2" {
type = bool
default = true
description = "Require IMDSv2 for EC2 instances"
}
variable "enable_deny_root_actions" {
type = bool
default = true
description = "Deny most actions by root user"
}
variable "allowed_regions" {
type = list(string)
default = []
description = "Allowed regions (empty = all regions allowed)"
}
variable "protect_security_services" {
type = bool
default = true
description = "Prevent disabling GuardDuty, Security Hub, Config, Access Analyzer"
}
variable "protect_cloudtrail" {
type = bool
default = true
description = "Prevent CloudTrail modification"
}
variable "require_s3_encryption" {
type = bool
default = true
description = "Require S3 bucket encryption"
}
variable "require_ebs_encryption" {
type = bool
default = true
description = "Require EBS volume encryption"
}
variable "target_ous" {
type = list(string)
default = []
description = "OU IDs to attach SCPs to"
}
variable "target_accounts" {
type = list(string)
default = []
description = "Account IDs to attach SCPs to"
}
variable "tags" {
type = map(string)
default = {}
description = "Tags to apply to SCP resources"
}

View File

@@ -0,0 +1,54 @@
# security-baseline
Security Baseline Module
## Usage
```hcl
module "security_baseline" {
source = "../modules/security-baseline"
# Required variables
name = ""
config_bucket_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 resources | `string` | yes |
| enable_guardduty | | `bool` | no |
| enable_securityhub | | `bool` | no |
| enable_config | | `bool` | no |
| enable_access_analyzer | | `bool` | no |
| enable_macie | Macie for S3 data classification (additional cost) | `bool` | no |
| config_bucket_name | S3 bucket for AWS Config recordings | `string` | yes |
| guardduty_finding_publishing_frequency | | `string` | no |
| securityhub_standards | Security Hub standards to enable | `list(string)` | no |
| config_rules | Additional AWS Config managed rule identifiers to enable | `list(string)` | no |
| tags | | `map(string)` | no |
## Outputs
| Name | Description |
|------|-------------|
| guardduty_detector_id | |
| securityhub_account_id | |
| config_recorder_id | |
| access_analyzer_arn | |
| enabled_services | |
## License
Apache 2.0 - See LICENSE for details.

View File

@@ -0,0 +1,334 @@
################################################################################
# Security Baseline Module
#
# Enables core AWS security services:
# - GuardDuty (threat detection)
# - Security Hub (security posture)
# - AWS Config (configuration compliance)
# - IAM Access Analyzer
#
# For multi-account: Deploy in management account, then enable delegated admin
################################################################################
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
variable "name" {
type = string
description = "Name prefix for resources"
}
variable "enable_guardduty" {
type = bool
default = true
}
variable "enable_securityhub" {
type = bool
default = true
}
variable "enable_config" {
type = bool
default = true
}
variable "enable_access_analyzer" {
type = bool
default = true
}
variable "enable_macie" {
type = bool
default = false
description = "Macie for S3 data classification (additional cost)"
}
variable "config_bucket_name" {
type = string
description = "S3 bucket for AWS Config recordings"
}
variable "guardduty_finding_publishing_frequency" {
type = string
default = "FIFTEEN_MINUTES"
validation {
condition = contains(["FIFTEEN_MINUTES", "ONE_HOUR", "SIX_HOURS"], var.guardduty_finding_publishing_frequency)
error_message = "Must be FIFTEEN_MINUTES, ONE_HOUR, or SIX_HOURS"
}
}
variable "securityhub_standards" {
type = list(string)
default = [
"aws-foundational-security-best-practices/v/1.0.0",
"cis-aws-foundations-benchmark/v/1.4.0",
]
description = "Security Hub standards to enable"
}
variable "config_rules" {
type = list(string)
default = []
description = "Additional AWS Config managed rule identifiers to enable"
}
variable "tags" {
type = map(string)
default = {}
}
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
################################################################################
# GuardDuty
################################################################################
resource "aws_guardduty_detector" "main" {
count = var.enable_guardduty ? 1 : 0
enable = true
finding_publishing_frequency = var.guardduty_finding_publishing_frequency
datasources {
s3_logs {
enable = true
}
kubernetes {
audit_logs {
enable = true
}
}
malware_protection {
scan_ec2_instance_with_findings {
ebs_volumes {
enable = true
}
}
}
}
tags = merge(var.tags, { Name = "${var.name}-guardduty" })
}
################################################################################
# Security Hub
################################################################################
resource "aws_securityhub_account" "main" {
count = var.enable_securityhub ? 1 : 0
enable_default_standards = false
auto_enable_controls = true
depends_on = [aws_guardduty_detector.main]
}
resource "aws_securityhub_standards_subscription" "standards" {
for_each = var.enable_securityhub ? toset(var.securityhub_standards) : []
standards_arn = "arn:aws:securityhub:${data.aws_region.current.name}::standards/${each.value}"
depends_on = [aws_securityhub_account.main]
}
################################################################################
# AWS Config
################################################################################
resource "aws_config_configuration_recorder" "main" {
count = var.enable_config ? 1 : 0
name = var.name
role_arn = aws_iam_role.config[0].arn
recording_group {
all_supported = true
include_global_resource_types = true
}
recording_mode {
recording_frequency = "CONTINUOUS"
}
}
resource "aws_config_delivery_channel" "main" {
count = var.enable_config ? 1 : 0
name = var.name
s3_bucket_name = var.config_bucket_name
s3_key_prefix = "config"
snapshot_delivery_properties {
delivery_frequency = "TwentyFour_Hours"
}
depends_on = [aws_config_configuration_recorder.main]
}
resource "aws_config_configuration_recorder_status" "main" {
count = var.enable_config ? 1 : 0
name = aws_config_configuration_recorder.main[0].name
is_enabled = true
depends_on = [aws_config_delivery_channel.main]
}
# IAM Role for Config
resource "aws_iam_role" "config" {
count = var.enable_config ? 1 : 0
name = "${var.name}-config"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "config.amazonaws.com" }
}]
})
tags = merge(var.tags, { Name = "${var.name}-config" })
}
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 = "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:::${var.config_bucket_name}/config/*"
Condition = {
StringEquals = {
"s3:x-amz-acl" = "bucket-owner-full-control"
}
}
}, {
Effect = "Allow"
Action = ["s3:GetBucketAcl"]
Resource = "arn:aws:s3:::${var.config_bucket_name}"
}]
})
}
################################################################################
# AWS Config Rules - Security Best Practices
################################################################################
locals {
default_config_rules = [
"ENCRYPTED_VOLUMES",
"RDS_STORAGE_ENCRYPTED",
"S3_BUCKET_SERVER_SIDE_ENCRYPTION_ENABLED",
"S3_BUCKET_SSL_REQUESTS_ONLY",
"S3_BUCKET_PUBLIC_READ_PROHIBITED",
"S3_BUCKET_PUBLIC_WRITE_PROHIBITED",
"RESTRICTED_SSH",
"VPC_DEFAULT_SECURITY_GROUP_CLOSED",
"VPC_FLOW_LOGS_ENABLED",
"CLOUD_TRAIL_ENABLED",
"CLOUD_TRAIL_ENCRYPTION_ENABLED",
"CLOUD_TRAIL_LOG_FILE_VALIDATION_ENABLED",
"IAM_ROOT_ACCESS_KEY_CHECK",
"IAM_USER_MFA_ENABLED",
"MFA_ENABLED_FOR_IAM_CONSOLE_ACCESS",
"ROOT_ACCOUNT_MFA_ENABLED",
"RDS_INSTANCE_PUBLIC_ACCESS_CHECK",
"GUARDDUTY_ENABLED_CENTRALIZED",
"SECURITYHUB_ENABLED",
"EBS_OPTIMIZED_INSTANCE",
"EC2_IMDSV2_CHECK",
"EKS_SECRETS_ENCRYPTED",
"LAMBDA_FUNCTION_PUBLIC_ACCESS_PROHIBITED",
"LAMBDA_INSIDE_VPC",
]
all_config_rules = distinct(concat(local.default_config_rules, var.config_rules))
}
resource "aws_config_config_rule" "rules" {
for_each = var.enable_config ? toset(local.all_config_rules) : []
name = lower(replace(each.value, "_", "-"))
source {
owner = "AWS"
source_identifier = each.value
}
depends_on = [aws_config_configuration_recorder_status.main]
tags = merge(var.tags, { Name = lower(replace(each.value, "_", "-")) })
}
################################################################################
# IAM Access Analyzer
################################################################################
resource "aws_accessanalyzer_analyzer" "main" {
count = var.enable_access_analyzer ? 1 : 0
analyzer_name = var.name
type = "ACCOUNT"
tags = merge(var.tags, { Name = "${var.name}-access-analyzer" })
}
################################################################################
# Macie (Optional)
################################################################################
resource "aws_macie2_account" "main" {
count = var.enable_macie ? 1 : 0
finding_publishing_frequency = "FIFTEEN_MINUTES"
status = "ENABLED"
}
################################################################################
# Outputs
################################################################################
output "guardduty_detector_id" {
value = var.enable_guardduty ? aws_guardduty_detector.main[0].id : null
}
output "securityhub_account_id" {
value = var.enable_securityhub ? aws_securityhub_account.main[0].id : null
}
output "config_recorder_id" {
value = var.enable_config ? aws_config_configuration_recorder.main[0].id : null
}
output "access_analyzer_arn" {
value = var.enable_access_analyzer ? aws_accessanalyzer_analyzer.main[0].arn : null
}
output "enabled_services" {
value = {
guardduty = var.enable_guardduty
securityhub = var.enable_securityhub
config = var.enable_config
access_analyzer = var.enable_access_analyzer
macie = var.enable_macie
}
}

View File

@@ -0,0 +1,34 @@
# security-groups
Terraform module for AWS landing zone pattern.
Create common security group patterns for multi-tier architectures.
## Planned Features
- [ ] Web tier (HTTP/HTTPS from ALB)
- [ ] App tier (from web tier only)
- [ ] Database tier (from app tier only)
- [ ] Bastion host (SSH from allowed CIDRs)
- [ ] VPC endpoints (HTTPS from VPC)
- [ ] EKS patterns (cluster, nodes, pods)
## Planned Usage
```hcl
module "security_groups" {
source = "../modules/security-groups"
vpc_id = module.vpc.vpc_id
name_prefix = "myapp"
create_web_tier = true
create_app_tier = true
create_db_tier = true
create_bastion = true
allowed_ssh_cidrs = ["10.0.0.0/8"]
tags = local.tags
}
```

View File

@@ -0,0 +1,395 @@
################################################################################
# Security Groups Module
#
# Creates common security group patterns for multi-tier architectures:
# - Web tier (HTTP/HTTPS from ALB or internet)
# - App tier (from web tier only)
# - Database tier (from app tier only)
# - Bastion host (SSH from allowed CIDRs)
# - VPC endpoints (HTTPS from VPC)
# - EKS patterns (cluster, nodes)
################################################################################
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
data "aws_vpc" "selected" {
id = var.vpc_id
}
locals {
vpc_cidr = data.aws_vpc.selected.cidr_block
}
################################################################################
# Web Tier Security Group
################################################################################
resource "aws_security_group" "web" {
count = var.create_web_tier ? 1 : 0
name_prefix = "${var.name_prefix}-web-"
description = "Web tier - HTTP/HTTPS access"
vpc_id = var.vpc_id
tags = merge(var.tags, {
Name = "${var.name_prefix}-web"
Tier = "web"
})
lifecycle {
create_before_destroy = true
}
}
resource "aws_vpc_security_group_ingress_rule" "web_http" {
count = var.create_web_tier ? 1 : 0
security_group_id = aws_security_group.web[0].id
description = "HTTP from allowed sources"
from_port = 80
to_port = 80
ip_protocol = "tcp"
cidr_ipv4 = var.web_ingress_cidr
tags = { Name = "http-ingress" }
}
resource "aws_vpc_security_group_ingress_rule" "web_https" {
count = var.create_web_tier ? 1 : 0
security_group_id = aws_security_group.web[0].id
description = "HTTPS from allowed sources"
from_port = 443
to_port = 443
ip_protocol = "tcp"
cidr_ipv4 = var.web_ingress_cidr
tags = { Name = "https-ingress" }
}
resource "aws_vpc_security_group_egress_rule" "web_all" {
count = var.create_web_tier ? 1 : 0
security_group_id = aws_security_group.web[0].id
description = "Allow all outbound"
ip_protocol = "-1"
cidr_ipv4 = "0.0.0.0/0"
tags = { Name = "all-egress" }
}
################################################################################
# App Tier Security Group
################################################################################
resource "aws_security_group" "app" {
count = var.create_app_tier ? 1 : 0
name_prefix = "${var.name_prefix}-app-"
description = "App tier - access from web tier"
vpc_id = var.vpc_id
tags = merge(var.tags, {
Name = "${var.name_prefix}-app"
Tier = "app"
})
lifecycle {
create_before_destroy = true
}
}
resource "aws_vpc_security_group_ingress_rule" "app_from_web" {
count = var.create_app_tier && var.create_web_tier ? 1 : 0
security_group_id = aws_security_group.app[0].id
description = "App port from web tier"
from_port = var.app_port
to_port = var.app_port
ip_protocol = "tcp"
referenced_security_group_id = aws_security_group.web[0].id
tags = { Name = "from-web-tier" }
}
resource "aws_vpc_security_group_ingress_rule" "app_from_cidr" {
count = var.create_app_tier && !var.create_web_tier ? 1 : 0
security_group_id = aws_security_group.app[0].id
description = "App port from VPC"
from_port = var.app_port
to_port = var.app_port
ip_protocol = "tcp"
cidr_ipv4 = local.vpc_cidr
tags = { Name = "from-vpc" }
}
resource "aws_vpc_security_group_egress_rule" "app_all" {
count = var.create_app_tier ? 1 : 0
security_group_id = aws_security_group.app[0].id
description = "Allow all outbound"
ip_protocol = "-1"
cidr_ipv4 = "0.0.0.0/0"
tags = { Name = "all-egress" }
}
################################################################################
# Database Tier Security Group
################################################################################
resource "aws_security_group" "db" {
count = var.create_db_tier ? 1 : 0
name_prefix = "${var.name_prefix}-db-"
description = "Database tier - access from app tier"
vpc_id = var.vpc_id
tags = merge(var.tags, {
Name = "${var.name_prefix}-db"
Tier = "database"
})
lifecycle {
create_before_destroy = true
}
}
resource "aws_vpc_security_group_ingress_rule" "db_from_app" {
count = var.create_db_tier && var.create_app_tier ? 1 : 0
security_group_id = aws_security_group.db[0].id
description = "Database port from app tier"
from_port = var.db_port
to_port = var.db_port
ip_protocol = "tcp"
referenced_security_group_id = aws_security_group.app[0].id
tags = { Name = "from-app-tier" }
}
resource "aws_vpc_security_group_ingress_rule" "db_from_cidr" {
count = var.create_db_tier && !var.create_app_tier ? 1 : 0
security_group_id = aws_security_group.db[0].id
description = "Database port from VPC"
from_port = var.db_port
to_port = var.db_port
ip_protocol = "tcp"
cidr_ipv4 = local.vpc_cidr
tags = { Name = "from-vpc" }
}
resource "aws_vpc_security_group_egress_rule" "db_all" {
count = var.create_db_tier ? 1 : 0
security_group_id = aws_security_group.db[0].id
description = "Allow all outbound"
ip_protocol = "-1"
cidr_ipv4 = "0.0.0.0/0"
tags = { Name = "all-egress" }
}
################################################################################
# Bastion Security Group
################################################################################
resource "aws_security_group" "bastion" {
count = var.create_bastion ? 1 : 0
name_prefix = "${var.name_prefix}-bastion-"
description = "Bastion host - SSH from allowed CIDRs"
vpc_id = var.vpc_id
tags = merge(var.tags, {
Name = "${var.name_prefix}-bastion"
Tier = "bastion"
})
lifecycle {
create_before_destroy = true
}
}
resource "aws_vpc_security_group_ingress_rule" "bastion_ssh" {
for_each = var.create_bastion ? toset(var.allowed_ssh_cidrs) : []
security_group_id = aws_security_group.bastion[0].id
description = "SSH from ${each.value}"
from_port = 22
to_port = 22
ip_protocol = "tcp"
cidr_ipv4 = each.value
tags = { Name = "ssh-from-${replace(each.value, "/", "-")}" }
}
resource "aws_vpc_security_group_egress_rule" "bastion_all" {
count = var.create_bastion ? 1 : 0
security_group_id = aws_security_group.bastion[0].id
description = "Allow all outbound"
ip_protocol = "-1"
cidr_ipv4 = "0.0.0.0/0"
tags = { Name = "all-egress" }
}
################################################################################
# VPC Endpoints Security Group
################################################################################
resource "aws_security_group" "endpoints" {
count = var.create_endpoints ? 1 : 0
name_prefix = "${var.name_prefix}-endpoints-"
description = "VPC Endpoints - HTTPS from VPC"
vpc_id = var.vpc_id
tags = merge(var.tags, {
Name = "${var.name_prefix}-endpoints"
Tier = "endpoints"
})
lifecycle {
create_before_destroy = true
}
}
resource "aws_vpc_security_group_ingress_rule" "endpoints_https" {
count = var.create_endpoints ? 1 : 0
security_group_id = aws_security_group.endpoints[0].id
description = "HTTPS from VPC"
from_port = 443
to_port = 443
ip_protocol = "tcp"
cidr_ipv4 = local.vpc_cidr
tags = { Name = "https-from-vpc" }
}
resource "aws_vpc_security_group_egress_rule" "endpoints_all" {
count = var.create_endpoints ? 1 : 0
security_group_id = aws_security_group.endpoints[0].id
description = "Allow all outbound"
ip_protocol = "-1"
cidr_ipv4 = "0.0.0.0/0"
tags = { Name = "all-egress" }
}
################################################################################
# EKS Cluster Security Group
################################################################################
resource "aws_security_group" "eks_cluster" {
count = var.create_eks ? 1 : 0
name_prefix = "${var.name_prefix}-eks-cluster-"
description = "EKS cluster control plane"
vpc_id = var.vpc_id
tags = merge(var.tags, {
Name = "${var.name_prefix}-eks-cluster"
Tier = "eks"
})
lifecycle {
create_before_destroy = true
}
}
resource "aws_vpc_security_group_ingress_rule" "eks_cluster_https" {
count = var.create_eks ? 1 : 0
security_group_id = aws_security_group.eks_cluster[0].id
description = "HTTPS from VPC (kubectl)"
from_port = 443
to_port = 443
ip_protocol = "tcp"
cidr_ipv4 = local.vpc_cidr
tags = { Name = "https-from-vpc" }
}
resource "aws_vpc_security_group_egress_rule" "eks_cluster_all" {
count = var.create_eks ? 1 : 0
security_group_id = aws_security_group.eks_cluster[0].id
description = "Allow all outbound"
ip_protocol = "-1"
cidr_ipv4 = "0.0.0.0/0"
tags = { Name = "all-egress" }
}
################################################################################
# EKS Nodes Security Group
################################################################################
resource "aws_security_group" "eks_nodes" {
count = var.create_eks ? 1 : 0
name_prefix = "${var.name_prefix}-eks-nodes-"
description = "EKS worker nodes"
vpc_id = var.vpc_id
tags = merge(var.tags, {
Name = "${var.name_prefix}-eks-nodes"
Tier = "eks"
"kubernetes.io/cluster/${var.name_prefix}" = "owned"
})
lifecycle {
create_before_destroy = true
}
}
resource "aws_vpc_security_group_ingress_rule" "eks_nodes_self" {
count = var.create_eks ? 1 : 0
security_group_id = aws_security_group.eks_nodes[0].id
description = "Node to node communication"
ip_protocol = "-1"
referenced_security_group_id = aws_security_group.eks_nodes[0].id
tags = { Name = "node-to-node" }
}
resource "aws_vpc_security_group_ingress_rule" "eks_nodes_cluster" {
count = var.create_eks ? 1 : 0
security_group_id = aws_security_group.eks_nodes[0].id
description = "From cluster control plane"
from_port = 1025
to_port = 65535
ip_protocol = "tcp"
referenced_security_group_id = aws_security_group.eks_cluster[0].id
tags = { Name = "from-cluster" }
}
resource "aws_vpc_security_group_egress_rule" "eks_nodes_all" {
count = var.create_eks ? 1 : 0
security_group_id = aws_security_group.eks_nodes[0].id
description = "Allow all outbound"
ip_protocol = "-1"
cidr_ipv4 = "0.0.0.0/0"
tags = { Name = "all-egress" }
}

View File

@@ -0,0 +1,51 @@
################################################################################
# Security Groups - Outputs
################################################################################
output "web_tier_sg_id" {
value = try(aws_security_group.web[0].id, null)
description = "Web tier security group ID"
}
output "app_tier_sg_id" {
value = try(aws_security_group.app[0].id, null)
description = "App tier security group ID"
}
output "db_tier_sg_id" {
value = try(aws_security_group.db[0].id, null)
description = "Database tier security group ID"
}
output "bastion_sg_id" {
value = try(aws_security_group.bastion[0].id, null)
description = "Bastion security group ID"
}
output "endpoints_sg_id" {
value = try(aws_security_group.endpoints[0].id, null)
description = "VPC endpoints security group ID"
}
output "eks_cluster_sg_id" {
value = try(aws_security_group.eks_cluster[0].id, null)
description = "EKS cluster security group ID"
}
output "eks_nodes_sg_id" {
value = try(aws_security_group.eks_nodes[0].id, null)
description = "EKS nodes security group ID"
}
output "all_sg_ids" {
value = {
web = try(aws_security_group.web[0].id, null)
app = try(aws_security_group.app[0].id, null)
db = try(aws_security_group.db[0].id, null)
bastion = try(aws_security_group.bastion[0].id, null)
endpoints = try(aws_security_group.endpoints[0].id, null)
eks_cluster = try(aws_security_group.eks_cluster[0].id, null)
eks_nodes = try(aws_security_group.eks_nodes[0].id, null)
}
description = "Map of all security group IDs"
}

View File

@@ -0,0 +1,79 @@
################################################################################
# Security Groups - Input Variables
################################################################################
variable "vpc_id" {
type = string
description = "VPC ID to create security groups in"
}
variable "name_prefix" {
type = string
description = "Prefix for security group names"
}
variable "create_web_tier" {
type = bool
default = false
description = "Create web tier security group"
}
variable "create_app_tier" {
type = bool
default = false
description = "Create application tier security group"
}
variable "create_db_tier" {
type = bool
default = false
description = "Create database tier security group"
}
variable "create_bastion" {
type = bool
default = false
description = "Create bastion host security group"
}
variable "create_endpoints" {
type = bool
default = false
description = "Create VPC endpoints security group"
}
variable "create_eks" {
type = bool
default = false
description = "Create EKS cluster and node security groups"
}
variable "web_ingress_cidr" {
type = string
default = "0.0.0.0/0"
description = "CIDR for web tier ingress (use ALB SG for production)"
}
variable "app_port" {
type = number
default = 8080
description = "Application port for app tier"
}
variable "db_port" {
type = number
default = 5432
description = "Database port (5432=PostgreSQL, 3306=MySQL)"
}
variable "allowed_ssh_cidrs" {
type = list(string)
default = []
description = "CIDRs allowed SSH access to bastion"
}
variable "tags" {
type = map(string)
default = {}
description = "Tags to apply to security groups"
}

View File

@@ -0,0 +1,51 @@
# shared-vpc
Shared VPC Module Single VPC shared across all tenants via AWS RAM Isolation via: Security Groups, ABAC (tags), optional subnet segmentation
## Usage
```hcl
module "shared_vpc" {
source = "../modules/shared-vpc"
# Required variables
workloads_ou_arn = ""
# Optional: see variables.tf for all options
}
```
## Requirements
| Name | Version |
|------|---------|
| terraform | >= 1.5.0 |
| aws | >= 5.0 |
## Inputs
| Name | Description | Type | Required |
|------|-------------|------|----------|
| vpc_cidr | CIDR block for the shared VPC | `string` | no |
| tenant_subnet_cidr | CIDR block for tenant-specific subnets (if enabled) | `string` | no |
| availability_zones | List of availability zones | `list(string)` | no |
| enable_nat_gateway | Enable NAT Gateway for private subnet internet access | `bool` | no |
| tenants | List of tenant names (for per-tenant subnets) | `list(string)` | no |
| create_tenant_subnets | Create separate subnets per tenant (stricter isolation) | `bool` | no |
| workloads_ou_arn | ARN of the Workloads OU to share subnets with | `string` | yes |
## Outputs
| Name | Description |
|------|-------------|
| vpc_id | |
| vpc_cidr | |
| public_subnet_ids | |
| private_shared_subnet_ids | |
| private_tenant_subnet_ids | |
| nat_gateway_ip | |
| ram_share_arn | |
## License
Apache 2.0 - See LICENSE for details.

View File

@@ -0,0 +1,267 @@
################################################################################
# Shared VPC Module
# Single VPC shared across all tenants via AWS RAM
# Isolation via: Security Groups, ABAC (tags), optional subnet segmentation
################################################################################
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
################################################################################
# VPC
################################################################################
resource "aws_vpc" "shared" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "shared-vpc"
Environment = "shared"
ManagedBy = "terraform"
}
}
################################################################################
# Internet Gateway
################################################################################
resource "aws_internet_gateway" "shared" {
vpc_id = aws_vpc.shared.id
tags = {
Name = "shared-igw"
}
}
################################################################################
# NAT Gateway (Single for cost savings)
################################################################################
resource "aws_eip" "nat" {
count = var.enable_nat_gateway ? 1 : 0
domain = "vpc"
tags = {
Name = "shared-nat-eip"
}
}
resource "aws_nat_gateway" "shared" {
count = var.enable_nat_gateway ? 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.shared]
}
################################################################################
# Subnets - Public (shared)
################################################################################
resource "aws_subnet" "public" {
count = length(var.availability_zones)
vpc_id = aws_vpc.shared.id
cidr_block = cidrsubnet(var.vpc_cidr, 4, count.index)
availability_zone = var.availability_zones[count.index]
map_public_ip_on_launch = true
tags = {
Name = "shared-public-${var.availability_zones[count.index]}"
Type = "public"
Environment = "shared"
}
}
################################################################################
# Subnets - Private (shared across tenants)
################################################################################
resource "aws_subnet" "private_shared" {
count = length(var.availability_zones)
vpc_id = aws_vpc.shared.id
cidr_block = cidrsubnet(var.vpc_cidr, 4, count.index + length(var.availability_zones))
availability_zone = var.availability_zones[count.index]
tags = {
Name = "shared-private-${var.availability_zones[count.index]}"
Type = "private"
Environment = "shared"
}
}
################################################################################
# Subnets - Per-Tenant Private (optional, for stricter isolation)
################################################################################
resource "aws_subnet" "private_tenant" {
for_each = var.create_tenant_subnets ? {
for combo in setproduct(var.tenants, range(length(var.availability_zones))) :
"${combo[0]}-${combo[1]}" => {
tenant = combo[0]
az_idx = combo[1]
}
} : {}
vpc_id = aws_vpc.shared.id
cidr_block = cidrsubnet(var.tenant_subnet_cidr, 4, index(var.tenants, each.value.tenant) * length(var.availability_zones) + each.value.az_idx)
availability_zone = var.availability_zones[each.value.az_idx]
tags = {
Name = "tenant-${each.value.tenant}-private-${var.availability_zones[each.value.az_idx]}"
Type = "private"
Tenant = each.value.tenant
Environment = "shared"
}
}
################################################################################
# Route Tables
################################################################################
resource "aws_route_table" "public" {
vpc_id = aws_vpc.shared.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.shared.id
}
tags = {
Name = "shared-public-rt"
}
}
resource "aws_route_table" "private" {
vpc_id = aws_vpc.shared.id
dynamic "route" {
for_each = var.enable_nat_gateway ? [1] : []
content {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.shared[0].id
}
}
tags = {
Name = "shared-private-rt"
}
}
resource "aws_route_table_association" "public" {
count = length(var.availability_zones)
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "private_shared" {
count = length(var.availability_zones)
subnet_id = aws_subnet.private_shared[count.index].id
route_table_id = aws_route_table.private.id
}
resource "aws_route_table_association" "private_tenant" {
for_each = aws_subnet.private_tenant
subnet_id = each.value.id
route_table_id = aws_route_table.private.id
}
################################################################################
# AWS RAM - Share VPC Subnets with Organization
################################################################################
resource "aws_ram_resource_share" "vpc_subnets" {
name = "shared-vpc-subnets"
allow_external_principals = false
tags = {
Name = "Shared VPC Subnets"
}
}
# Share private subnets with the organization
resource "aws_ram_resource_association" "private_shared" {
count = length(var.availability_zones)
resource_arn = aws_subnet.private_shared[count.index].arn
resource_share_arn = aws_ram_resource_share.vpc_subnets.arn
}
# Share tenant-specific subnets (if created)
resource "aws_ram_resource_association" "private_tenant" {
for_each = aws_subnet.private_tenant
resource_arn = each.value.arn
resource_share_arn = aws_ram_resource_share.vpc_subnets.arn
}
# Share with specific OUs or entire org
resource "aws_ram_principal_association" "workloads_ou" {
principal = var.workloads_ou_arn
resource_share_arn = aws_ram_resource_share.vpc_subnets.arn
}
################################################################################
# Default Security Group - Deny All (force explicit SGs)
################################################################################
resource "aws_default_security_group" "default" {
vpc_id = aws_vpc.shared.id
# No ingress or egress rules = deny all
tags = {
Name = "default-deny-all"
Description = "Default SG - no access, use tenant-specific SGs"
}
}
################################################################################
# Outputs
################################################################################
output "vpc_id" {
value = aws_vpc.shared.id
}
output "vpc_cidr" {
value = aws_vpc.shared.cidr_block
}
output "public_subnet_ids" {
value = aws_subnet.public[*].id
}
output "private_shared_subnet_ids" {
value = aws_subnet.private_shared[*].id
}
output "private_tenant_subnet_ids" {
value = {
for k, v in aws_subnet.private_tenant : k => v.id
}
}
output "nat_gateway_ip" {
value = var.enable_nat_gateway ? aws_eip.nat[0].public_ip : null
}
output "ram_share_arn" {
value = aws_ram_resource_share.vpc_subnets.arn
}

View File

@@ -0,0 +1,40 @@
variable "vpc_cidr" {
description = "CIDR block for the shared VPC"
type = string
default = "10.0.0.0/16"
}
variable "tenant_subnet_cidr" {
description = "CIDR block for tenant-specific subnets (if enabled)"
type = string
default = "10.1.0.0/16"
}
variable "availability_zones" {
description = "List of availability zones"
type = list(string)
default = ["us-east-1a", "us-east-1b", "us-east-1c"]
}
variable "enable_nat_gateway" {
description = "Enable NAT Gateway for private subnet internet access"
type = bool
default = true
}
variable "tenants" {
description = "List of tenant names (for per-tenant subnets)"
type = list(string)
default = []
}
variable "create_tenant_subnets" {
description = "Create separate subnets per tenant (stricter isolation)"
type = bool
default = false
}
variable "workloads_ou_arn" {
description = "ARN of the Workloads OU to share subnets with"
type = string
}

View File

@@ -0,0 +1,38 @@
# tenant-baseline
Terraform module for AWS landing zone pattern.
Apply tenant-specific baseline for multi-tenant architectures.
## Planned Features
- [ ] Tenant-specific IAM roles with boundaries
- [ ] Tenant budget alerts
- [ ] Tenant tagging enforcement
- [ ] Dedicated or shared VPC networking
- [ ] Cost allocation tag setup
## Planned Usage
```hcl
module "tenant" {
source = "../modules/tenant-baseline"
tenant_name = "acme-corp"
tenant_id = "acme"
environment = "prod"
cost_center = "CC-12345"
owner_email = "admin@acme.com"
budget_limit = 500
# Dedicated VPC (optional)
vpc_config = {
cidr = "10.100.0.0/16"
azs = ["us-east-1a", "us-east-1b"]
private_subnets = ["10.100.1.0/24", "10.100.2.0/24"]
public_subnets = ["10.100.101.0/24", "10.100.102.0/24"]
}
tags = local.tags
}
```

View File

@@ -0,0 +1,102 @@
################################################################################
# Tenant Baseline Module
#
# Composite module that provisions a complete tenant environment:
# - Tenant IAM roles with permissions boundary
# - Tenant budget alerts
# - Tenant VPC (optional)
# - Standard tagging
################################################################################
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
data "aws_caller_identity" "current" {}
locals {
account_id = data.aws_caller_identity.current.account_id
# Standard tenant tags
tenant_tags = merge(var.tags, {
Tenant = var.tenant_name
TenantId = var.tenant_id
Environment = var.environment
CostCenter = var.cost_center
Owner = var.owner_email
ManagedBy = "terraform"
})
}
################################################################################
# Tenant IAM
################################################################################
module "tenant_iam" {
source = "../tenant-iam"
tenant_name = var.tenant_name
tenant_id = var.tenant_id
create_permissions_boundary = var.create_permissions_boundary
create_admin_role = var.create_admin_role
create_developer_role = var.create_developer_role
create_readonly_role = var.create_readonly_role
trusted_principals = var.trusted_principals
allowed_services = var.allowed_services
require_mfa = var.require_mfa
tags = local.tenant_tags
}
################################################################################
# Tenant Budget
################################################################################
module "tenant_budget" {
source = "../tenant-budget"
name = var.tenant_name
budget_limit = var.budget_limit
alert_thresholds = var.budget_alert_thresholds
enable_forecasted_alerts = var.enable_forecasted_alerts
notification_emails = var.budget_notification_emails
cost_filter_tags = {
Tenant = var.tenant_name
}
tags = local.tenant_tags
}
################################################################################
# Tenant VPC (Optional)
################################################################################
module "tenant_vpc" {
source = "../tenant-vpc"
count = var.create_vpc ? 1 : 0
tenant_name = var.tenant_name
cidr = var.vpc_cidr
azs = var.vpc_azs
public_subnets = var.vpc_public_subnets
private_subnets = var.vpc_private_subnets
enable_nat = var.vpc_enable_nat
nat_mode = var.vpc_nat_mode
transit_gateway_id = var.transit_gateway_id
enable_flow_logs = var.enable_flow_logs
tags = local.tenant_tags
}

View File

@@ -0,0 +1,72 @@
################################################################################
# Tenant Baseline - Outputs
################################################################################
# IAM Outputs
output "permissions_boundary_arn" {
value = module.tenant_iam.permissions_boundary_arn
description = "Permissions boundary ARN"
}
output "admin_role_arn" {
value = module.tenant_iam.admin_role_arn
description = "Tenant admin role ARN"
}
output "developer_role_arn" {
value = module.tenant_iam.developer_role_arn
description = "Tenant developer role ARN"
}
output "readonly_role_arn" {
value = module.tenant_iam.readonly_role_arn
description = "Tenant readonly role ARN"
}
output "all_role_arns" {
value = module.tenant_iam.all_role_arns
description = "Map of all tenant role ARNs"
}
# Budget Outputs
output "budget_id" {
value = module.tenant_budget.budget_id
description = "Budget ID"
}
output "budget_sns_topic_arn" {
value = module.tenant_budget.sns_topic_arn
description = "Budget alerts SNS topic ARN"
}
# VPC Outputs
output "vpc_id" {
value = var.create_vpc ? module.tenant_vpc[0].vpc_id : null
description = "VPC ID (if created)"
}
output "vpc_cidr" {
value = var.create_vpc ? module.tenant_vpc[0].vpc_cidr : null
description = "VPC CIDR (if created)"
}
output "private_subnet_ids" {
value = var.create_vpc ? module.tenant_vpc[0].private_subnet_ids : []
description = "Private subnet IDs"
}
output "public_subnet_ids" {
value = var.create_vpc ? module.tenant_vpc[0].public_subnet_ids : []
description = "Public subnet IDs"
}
# Summary
output "tenant_tags" {
value = local.tenant_tags
description = "Standard tenant tags"
}
output "resource_prefix" {
value = module.tenant_iam.resource_prefix
description = "Tenant resource prefix"
}

View File

@@ -0,0 +1,158 @@
################################################################################
# Tenant Baseline - Input Variables
################################################################################
# Core tenant info
variable "tenant_name" {
type = string
description = "Tenant name (human readable)"
}
variable "tenant_id" {
type = string
description = "Short tenant ID for resource naming"
}
variable "environment" {
type = string
description = "Environment (dev, staging, prod)"
}
variable "cost_center" {
type = string
description = "Cost center for billing"
}
variable "owner_email" {
type = string
description = "Tenant owner email for notifications"
}
# IAM Configuration
variable "create_permissions_boundary" {
type = bool
default = true
description = "Create permissions boundary"
}
variable "create_admin_role" {
type = bool
default = true
description = "Create tenant admin role"
}
variable "create_developer_role" {
type = bool
default = true
description = "Create tenant developer role"
}
variable "create_readonly_role" {
type = bool
default = true
description = "Create tenant readonly role"
}
variable "trusted_principals" {
type = list(string)
default = []
description = "ARNs allowed to assume tenant roles"
}
variable "allowed_services" {
type = list(string)
default = ["ec2", "s3", "lambda", "dynamodb", "rds", "ecs", "ecr"]
description = "AWS services the tenant can use"
}
variable "require_mfa" {
type = bool
default = true
description = "Require MFA for admin role"
}
# Budget Configuration
variable "budget_limit" {
type = number
default = 100
description = "Monthly budget limit in USD"
}
variable "budget_alert_thresholds" {
type = list(number)
default = [50, 80, 100]
description = "Budget alert thresholds"
}
variable "enable_forecasted_alerts" {
type = bool
default = true
description = "Enable forecasted spend alerts"
}
variable "budget_notification_emails" {
type = list(string)
default = []
description = "Email addresses for budget alerts"
}
# VPC Configuration
variable "create_vpc" {
type = bool
default = false
description = "Create dedicated tenant VPC"
}
variable "vpc_cidr" {
type = string
default = "10.0.0.0/16"
description = "VPC CIDR block"
}
variable "vpc_azs" {
type = list(string)
default = []
description = "Availability zones"
}
variable "vpc_public_subnets" {
type = list(string)
default = []
description = "Public subnet CIDRs"
}
variable "vpc_private_subnets" {
type = list(string)
default = []
description = "Private subnet CIDRs"
}
variable "vpc_enable_nat" {
type = bool
default = true
description = "Enable NAT for VPC"
}
variable "vpc_nat_mode" {
type = string
default = "instance"
description = "NAT mode: gateway or instance"
}
variable "transit_gateway_id" {
type = string
default = ""
description = "Transit Gateway ID for attachment"
}
variable "enable_flow_logs" {
type = bool
default = true
description = "Enable VPC flow logs"
}
variable "tags" {
type = map(string)
default = {}
description = "Additional tags"
}

View File

@@ -0,0 +1,38 @@
# tenant-budget
Terraform module for AWS landing zone pattern.
Create tenant-specific AWS budget alerts.
## Planned Features
- [ ] Monthly budget with configurable limit
- [ ] Multi-threshold alerts (50%, 80%, 100%, 120%)
- [ ] Cost allocation tag filtering
- [ ] SNS and email notifications
- [ ] Forecasted spend alerts
- [ ] Auto-actions at budget limits (optional)
## Planned Usage
```hcl
module "tenant_budget" {
source = "../modules/tenant-budget"
tenant_name = "acme-corp"
budget_limit = 500
alert_thresholds = [50, 80, 100]
notification_emails = [
"billing@acme.com",
"admin@acme.com"
]
cost_filter_tags = {
Tenant = "acme-corp"
}
enable_forecasted_alerts = true
}
```

View File

@@ -0,0 +1,144 @@
################################################################################
# Tenant Budget Module
#
# Creates tenant-specific budget with alerts:
# - Monthly budget with configurable limit
# - Multi-threshold alerts
# - Cost allocation tag filtering
# - SNS and email notifications
################################################################################
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
data "aws_caller_identity" "current" {}
locals {
account_id = data.aws_caller_identity.current.account_id
# Build cost filters from tags
cost_filters = length(var.cost_filter_tags) > 0 ? {
TagKeyValue = [for k, v in var.cost_filter_tags : "user:${k}$${v}"]
} : {}
}
################################################################################
# SNS Topic for Budget Alerts
################################################################################
resource "aws_sns_topic" "budget" {
count = var.create_sns_topic ? 1 : 0
name = "${var.name}-budget-alerts"
tags = merge(var.tags, {
Name = "${var.name}-budget-alerts"
})
}
resource "aws_sns_topic_policy" "budget" {
count = var.create_sns_topic ? 1 : 0
arn = aws_sns_topic.budget[0].arn
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowBudgetsPublish"
Effect = "Allow"
Principal = {
Service = "budgets.amazonaws.com"
}
Action = "sns:Publish"
Resource = aws_sns_topic.budget[0].arn
Condition = {
StringEquals = {
"aws:SourceAccount" = local.account_id
}
}
}
]
})
}
resource "aws_sns_topic_subscription" "email" {
for_each = var.create_sns_topic ? toset(var.notification_emails) : []
topic_arn = aws_sns_topic.budget[0].arn
protocol = "email"
endpoint = each.value
}
################################################################################
# Budget
################################################################################
resource "aws_budgets_budget" "this" {
name = "${var.name}-monthly-budget"
budget_type = "COST"
limit_amount = tostring(var.budget_limit)
limit_unit = "USD"
time_unit = "MONTHLY"
# Optional: Filter by cost allocation tags
dynamic "cost_filter" {
for_each = local.cost_filters
content {
name = cost_filter.key
values = cost_filter.value
}
}
cost_types {
include_credit = false
include_discount = true
include_other_subscription = true
include_recurring = true
include_refund = false
include_subscription = true
include_support = true
include_tax = true
include_upfront = true
use_amortized = false
use_blended = false
}
# Actual spend notifications
dynamic "notification" {
for_each = var.alert_thresholds
content {
comparison_operator = "GREATER_THAN"
threshold = notification.value
threshold_type = "PERCENTAGE"
notification_type = "ACTUAL"
subscriber_email_addresses = var.create_sns_topic ? [] : var.notification_emails
subscriber_sns_topic_arns = var.create_sns_topic ? [aws_sns_topic.budget[0].arn] : (var.sns_topic_arn != null ? [var.sns_topic_arn] : [])
}
}
# Forecasted spend notifications
dynamic "notification" {
for_each = var.enable_forecasted_alerts ? var.forecasted_thresholds : []
content {
comparison_operator = "GREATER_THAN"
threshold = notification.value
threshold_type = "PERCENTAGE"
notification_type = "FORECASTED"
subscriber_email_addresses = var.create_sns_topic ? [] : var.notification_emails
subscriber_sns_topic_arns = var.create_sns_topic ? [aws_sns_topic.budget[0].arn] : (var.sns_topic_arn != null ? [var.sns_topic_arn] : [])
}
}
tags = merge(var.tags, {
Name = "${var.name}-monthly-budget"
Tenant = var.name
})
}

View File

@@ -0,0 +1,28 @@
################################################################################
# Tenant Budget - Outputs
################################################################################
output "budget_id" {
value = aws_budgets_budget.this.id
description = "Budget ID"
}
output "budget_name" {
value = aws_budgets_budget.this.name
description = "Budget name"
}
output "budget_limit" {
value = var.budget_limit
description = "Budget limit in USD"
}
output "sns_topic_arn" {
value = var.create_sns_topic ? aws_sns_topic.budget[0].arn : var.sns_topic_arn
description = "SNS topic ARN for budget alerts"
}
output "alert_thresholds" {
value = var.alert_thresholds
description = "Configured alert thresholds"
}

View File

@@ -0,0 +1,61 @@
################################################################################
# Tenant Budget - Input Variables
################################################################################
variable "name" {
type = string
description = "Tenant/budget name"
}
variable "budget_limit" {
type = number
description = "Monthly budget limit in USD"
}
variable "alert_thresholds" {
type = list(number)
default = [50, 80, 100]
description = "Percentage thresholds for actual spend alerts"
}
variable "enable_forecasted_alerts" {
type = bool
default = true
description = "Enable forecasted spend alerts"
}
variable "forecasted_thresholds" {
type = list(number)
default = [100]
description = "Percentage thresholds for forecasted spend alerts"
}
variable "notification_emails" {
type = list(string)
default = []
description = "Email addresses for budget alerts"
}
variable "create_sns_topic" {
type = bool
default = true
description = "Create SNS topic for alerts"
}
variable "sns_topic_arn" {
type = string
default = null
description = "Existing SNS topic ARN (if not creating)"
}
variable "cost_filter_tags" {
type = map(string)
default = {}
description = "Cost allocation tags to filter by"
}
variable "tags" {
type = map(string)
default = {}
description = "Tags to apply to resources"
}

View File

@@ -0,0 +1,45 @@
# tenant-iam
Terraform module for AWS landing zone pattern.
Create tenant-specific IAM roles with proper isolation.
## Planned Features
- [ ] Tenant admin role (full tenant access)
- [ ] Tenant developer role (limited write)
- [ ] Tenant readonly role (view only)
- [ ] Permissions boundary enforcement
- [ ] Resource-based isolation (tenant prefix)
- [ ] Cross-account trust configuration
## Planned Usage
```hcl
module "tenant_iam" {
source = "../modules/tenant-iam"
tenant_name = "acme-corp"
tenant_id = "acme"
create_admin_role = true
create_developer_role = true
create_readonly_role = true
trusted_principals = [
"arn:aws:iam::111111111111:root" # Identity account
]
allowed_services = ["ec2", "s3", "lambda", "rds"]
resource_prefix = "acme-"
permissions_boundary = aws_iam_policy.tenant_boundary.arn
}
```
## Security
All tenant roles are created with permissions boundaries to prevent:
- Creating IAM users/roles without boundaries
- Accessing other tenants' resources
- Modifying security services

View File

@@ -0,0 +1,279 @@
################################################################################
# Tenant IAM Module
#
# Creates tenant-specific IAM roles with isolation:
# - Tenant admin role with permissions boundary
# - Tenant developer role
# - Tenant readonly role
# - Permissions boundary for tenant isolation
################################################################################
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
data "aws_caller_identity" "current" {}
data "aws_partition" "current" {}
locals {
account_id = data.aws_caller_identity.current.account_id
partition = data.aws_partition.current.partition
# Resource prefix for tenant isolation
resource_prefix = var.resource_prefix != "" ? var.resource_prefix : "${var.tenant_id}-"
}
################################################################################
# Permissions Boundary
################################################################################
resource "aws_iam_policy" "boundary" {
count = var.create_permissions_boundary ? 1 : 0
name = "${var.tenant_id}-permissions-boundary"
path = var.iam_path
description = "Permissions boundary for ${var.tenant_name} tenant"
policy = jsonencode({
Version = "2012-10-17"
Statement = concat(
# Allow specified services
[{
Sid = "AllowedServices"
Effect = "Allow"
Action = [for svc in var.allowed_services : "${svc}:*"]
Resource = "*"
Condition = {
StringLikeIfExists = {
"aws:ResourceTag/Tenant" = [var.tenant_id, var.tenant_name]
}
}
}],
# Restrict to tenant-prefixed resources where possible
[{
Sid = "RestrictToTenantResources"
Effect = "Allow"
Action = [
"s3:*",
"dynamodb:*",
"lambda:*",
"sqs:*",
"sns:*"
]
Resource = [
"arn:${local.partition}:s3:::${local.resource_prefix}*",
"arn:${local.partition}:dynamodb:*:${local.account_id}:table/${local.resource_prefix}*",
"arn:${local.partition}:lambda:*:${local.account_id}:function:${local.resource_prefix}*",
"arn:${local.partition}:sqs:*:${local.account_id}:${local.resource_prefix}*",
"arn:${local.partition}:sns:*:${local.account_id}:${local.resource_prefix}*"
]
}],
# Deny modifying boundary or escalating privileges
[{
Sid = "DenyBoundaryModification"
Effect = "Deny"
Action = [
"iam:DeletePolicy",
"iam:DeletePolicyVersion",
"iam:CreatePolicyVersion",
"iam:SetDefaultPolicyVersion"
]
Resource = "arn:${local.partition}:iam::${local.account_id}:policy/${var.tenant_id}-permissions-boundary"
}],
# Deny creating roles/users without boundary
[{
Sid = "DenyCreatingRolesWithoutBoundary"
Effect = "Deny"
Action = [
"iam:CreateRole",
"iam:CreateUser"
]
Resource = "*"
Condition = {
StringNotEquals = {
"iam:PermissionsBoundary" = "arn:${local.partition}:iam::${local.account_id}:policy/${var.tenant_id}-permissions-boundary"
}
}
}],
# Deny modifying other tenants' resources
[{
Sid = "DenyAccessToOtherTenants"
Effect = "Deny"
Action = "*"
Resource = "*"
Condition = {
StringNotLike = {
"aws:ResourceTag/Tenant" = [var.tenant_id, var.tenant_name, ""]
}
Null = {
"aws:ResourceTag/Tenant" = "false"
}
}
}],
# Deny disabling security services
[{
Sid = "DenySecurityServiceModification"
Effect = "Deny"
Action = [
"guardduty:DeleteDetector",
"guardduty:DisassociateFromMasterAccount",
"securityhub:DisableSecurityHub",
"config:DeleteConfigurationRecorder",
"config:StopConfigurationRecorder",
"cloudtrail:DeleteTrail",
"cloudtrail:StopLogging"
]
Resource = "*"
}]
)
})
tags = merge(var.tags, {
Name = "${var.tenant_id}-permissions-boundary"
Tenant = var.tenant_name
})
}
################################################################################
# Admin Role
################################################################################
resource "aws_iam_role" "admin" {
count = var.create_admin_role ? 1 : 0
name = "${var.tenant_id}-admin"
path = var.iam_path
permissions_boundary = var.create_permissions_boundary ? aws_iam_policy.boundary[0].arn : var.permissions_boundary_arn
max_session_duration = var.admin_session_duration
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
AWS = var.trusted_principals
}
Condition = var.require_mfa ? {
Bool = {
"aws:MultiFactorAuthPresent" = "true"
}
} : {}
}]
})
tags = merge(var.tags, {
Name = "${var.tenant_id}-admin"
Tenant = var.tenant_name
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:${local.partition}:iam::aws:policy/PowerUserAccess"
}
################################################################################
# Developer Role
################################################################################
resource "aws_iam_role" "developer" {
count = var.create_developer_role ? 1 : 0
name = "${var.tenant_id}-developer"
path = var.iam_path
permissions_boundary = var.create_permissions_boundary ? aws_iam_policy.boundary[0].arn : var.permissions_boundary_arn
max_session_duration = var.developer_session_duration
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
AWS = var.trusted_principals
}
}]
})
tags = merge(var.tags, {
Name = "${var.tenant_id}-developer"
Tenant = var.tenant_name
Role = "developer"
})
}
resource "aws_iam_role_policy" "developer" {
count = var.create_developer_role ? 1 : 0
name = "developer-access"
role = aws_iam_role.developer[0].id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "DeveloperAccess"
Effect = "Allow"
Action = [for svc in var.allowed_services : "${svc}:*"]
Resource = "*"
},
{
Sid = "DenyAdmin"
Effect = "Deny"
Action = [
"iam:*",
"organizations:*",
"account:*"
]
Resource = "*"
}
]
})
}
################################################################################
# Readonly Role
################################################################################
resource "aws_iam_role" "readonly" {
count = var.create_readonly_role ? 1 : 0
name = "${var.tenant_id}-readonly"
path = var.iam_path
permissions_boundary = var.create_permissions_boundary ? aws_iam_policy.boundary[0].arn : var.permissions_boundary_arn
max_session_duration = var.readonly_session_duration
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
AWS = var.trusted_principals
}
}]
})
tags = merge(var.tags, {
Name = "${var.tenant_id}-readonly"
Tenant = var.tenant_name
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:${local.partition}:iam::aws:policy/ReadOnlyAccess"
}

View File

@@ -0,0 +1,52 @@
################################################################################
# Tenant IAM - Outputs
################################################################################
output "permissions_boundary_arn" {
value = var.create_permissions_boundary ? aws_iam_policy.boundary[0].arn : var.permissions_boundary_arn
description = "Permissions boundary policy ARN"
}
output "admin_role_arn" {
value = try(aws_iam_role.admin[0].arn, null)
description = "Tenant admin role ARN"
}
output "admin_role_name" {
value = try(aws_iam_role.admin[0].name, null)
description = "Tenant admin role name"
}
output "developer_role_arn" {
value = try(aws_iam_role.developer[0].arn, null)
description = "Tenant developer role ARN"
}
output "developer_role_name" {
value = try(aws_iam_role.developer[0].name, null)
description = "Tenant developer role name"
}
output "readonly_role_arn" {
value = try(aws_iam_role.readonly[0].arn, null)
description = "Tenant readonly role ARN"
}
output "readonly_role_name" {
value = try(aws_iam_role.readonly[0].name, null)
description = "Tenant readonly role name"
}
output "all_role_arns" {
value = {
admin = try(aws_iam_role.admin[0].arn, null)
developer = try(aws_iam_role.developer[0].arn, null)
readonly = try(aws_iam_role.readonly[0].arn, null)
}
description = "Map of all tenant role ARNs"
}
output "resource_prefix" {
value = local.resource_prefix
description = "Resource prefix for tenant naming"
}

View File

@@ -0,0 +1,97 @@
################################################################################
# Tenant IAM - Input Variables
################################################################################
variable "tenant_name" {
type = string
description = "Tenant name (human readable)"
}
variable "tenant_id" {
type = string
description = "Short tenant ID for resource naming"
}
variable "create_permissions_boundary" {
type = bool
default = true
description = "Create permissions boundary policy"
}
variable "permissions_boundary_arn" {
type = string
default = null
description = "Existing permissions boundary ARN (if not creating)"
}
variable "create_admin_role" {
type = bool
default = true
description = "Create tenant admin role"
}
variable "create_developer_role" {
type = bool
default = true
description = "Create tenant developer role"
}
variable "create_readonly_role" {
type = bool
default = true
description = "Create tenant readonly role"
}
variable "trusted_principals" {
type = list(string)
default = []
description = "ARNs allowed to assume tenant roles"
}
variable "allowed_services" {
type = list(string)
default = ["ec2", "s3", "lambda", "dynamodb", "rds", "ecs", "ecr", "logs", "cloudwatch", "events", "sqs", "sns"]
description = "AWS services the tenant can use"
}
variable "resource_prefix" {
type = string
default = ""
description = "Resource naming prefix (defaults to tenant_id-)"
}
variable "iam_path" {
type = string
default = "/tenants/"
description = "IAM path for roles and policies"
}
variable "require_mfa" {
type = bool
default = true
description = "Require MFA for admin role"
}
variable "admin_session_duration" {
type = number
default = 3600
description = "Admin role session duration in seconds"
}
variable "developer_session_duration" {
type = number
default = 14400
description = "Developer role session duration in seconds"
}
variable "readonly_session_duration" {
type = number
default = 14400
description = "Readonly role session duration in seconds"
}
variable "tags" {
type = map(string)
default = {}
description = "Tags to apply to resources"
}

View File

@@ -0,0 +1,60 @@
# tenant-onboard
Tenant Onboarding Module Creates: Tenant OUs, App Accounts, IAM Groups, Budgets }
## Usage
```hcl
module "tenant_onboard" {
source = "../modules/tenant-onboard"
# Required variables
tenant = ""
email_domain = ""
production_ou_id = ""
nonproduction_ou_id = ""
apps = ""
alert_emails = ""
permission_set_admin_arn = ""
permission_set_developer_arn = ""
permission_set_readonly_arn = ""
# Optional: see variables.tf for all options
}
```
## Requirements
| Name | Version |
|------|---------|
| terraform | >= 1.5.0 |
| aws | >= 5.0 |
## Inputs
| Name | Description | Type | Required |
|------|-------------|------|----------|
| tenant | Tenant identifier (lowercase, no spaces) | `string` | yes |
| email_domain | Domain for AWS account emails | `string` | yes |
| email_prefix | Email prefix before + sign | `string` | no |
| production_ou_id | ID of the Production OU | `string` | yes |
| nonproduction_ou_id | ID of the Non-Production OU | `string` | yes |
| environments | Environments to create for each app | `list(string)` | no |
| apps | Map of applications for this tenant | `map(object({` | yes |
| monthly_budget | Total monthly budget for tenant | `number` | no |
| alert_emails | Emails to receive budget alerts | `list(string)` | yes |
| permission_set_admin_arn | ARN of the TenantAdmin permission set | `string` | yes |
| permission_set_developer_arn | ARN of the TenantDeveloper permission set | `string` | yes |
| permission_set_readonly_arn | ARN of the TenantReadOnly permission set | `string` | yes |
## Outputs
| Name | Description |
|------|-------------|
| tenant_ou_ids | |
| account_ids | |
| group_ids | |
## License
Apache 2.0 - See LICENSE for details.

View File

@@ -0,0 +1,243 @@
################################################################################
# Tenant Onboarding Module
# Creates: Tenant OUs, App Accounts, IAM Groups, Budgets
################################################################################
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
################################################################################
# Data Sources
################################################################################
data "aws_organizations_organization" "main" {}
data "aws_ssoadmin_instances" "main" {}
################################################################################
# Tenant OUs
################################################################################
resource "aws_organizations_organizational_unit" "tenant_prod" {
name = "tenant-${var.tenant}-prod"
parent_id = var.production_ou_id
}
resource "aws_organizations_organizational_unit" "tenant_nonprod" {
name = "tenant-${var.tenant}-nonprod"
parent_id = var.nonproduction_ou_id
}
################################################################################
# App Accounts
################################################################################
locals {
# Generate all app/environment combinations
app_accounts = {
for combo in setproduct(keys(var.apps), var.environments) :
"${combo[0]}-${combo[1]}" => {
app = combo[0]
env = combo[1]
}
}
}
resource "aws_organizations_account" "app" {
for_each = local.app_accounts
name = "${var.tenant}-${each.value.app}-${each.value.env}"
email = "${var.email_prefix}+${var.tenant}-${each.value.app}-${each.value.env}@${var.email_domain}"
role_name = "OrganizationAccountAccessRole"
parent_id = each.value.env == "prod" ? (
aws_organizations_organizational_unit.tenant_prod.id
) : (
aws_organizations_organizational_unit.tenant_nonprod.id
)
tags = {
Tenant = var.tenant
App = each.value.app
Environment = each.value.env
ManagedBy = "terraform"
}
lifecycle {
ignore_changes = [role_name]
}
}
################################################################################
# IAM Identity Center - Tenant Group
################################################################################
resource "aws_identitystore_group" "tenant" {
identity_store_id = tolist(data.aws_ssoadmin_instances.main.identity_store_ids)[0]
display_name = "Tenant-${var.tenant}"
description = "All users for tenant ${var.tenant}"
}
# Create role-specific groups
resource "aws_identitystore_group" "tenant_admins" {
identity_store_id = tolist(data.aws_ssoadmin_instances.main.identity_store_ids)[0]
display_name = "Tenant-${var.tenant}-Admins"
description = "Admins for tenant ${var.tenant}"
}
resource "aws_identitystore_group" "tenant_developers" {
identity_store_id = tolist(data.aws_ssoadmin_instances.main.identity_store_ids)[0]
display_name = "Tenant-${var.tenant}-Developers"
description = "Developers for tenant ${var.tenant}"
}
resource "aws_identitystore_group" "tenant_readonly" {
identity_store_id = tolist(data.aws_ssoadmin_instances.main.identity_store_ids)[0]
display_name = "Tenant-${var.tenant}-ReadOnly"
description = "Read-only users for tenant ${var.tenant}"
}
################################################################################
# Account Assignments - Admin access to all tenant accounts
################################################################################
resource "aws_ssoadmin_account_assignment" "admin" {
for_each = aws_organizations_account.app
instance_arn = tolist(data.aws_ssoadmin_instances.main.arns)[0]
permission_set_arn = var.permission_set_admin_arn
principal_id = aws_identitystore_group.tenant_admins.group_id
principal_type = "GROUP"
target_id = each.value.id
target_type = "AWS_ACCOUNT"
}
# Developer access to non-prod only
resource "aws_ssoadmin_account_assignment" "developer" {
for_each = {
for k, v in aws_organizations_account.app : k => v
if local.app_accounts[k].env != "prod"
}
instance_arn = tolist(data.aws_ssoadmin_instances.main.arns)[0]
permission_set_arn = var.permission_set_developer_arn
principal_id = aws_identitystore_group.tenant_developers.group_id
principal_type = "GROUP"
target_id = each.value.id
target_type = "AWS_ACCOUNT"
}
# Read-only access to all accounts
resource "aws_ssoadmin_account_assignment" "readonly" {
for_each = aws_organizations_account.app
instance_arn = tolist(data.aws_ssoadmin_instances.main.arns)[0]
permission_set_arn = var.permission_set_readonly_arn
principal_id = aws_identitystore_group.tenant_readonly.group_id
principal_type = "GROUP"
target_id = each.value.id
target_type = "AWS_ACCOUNT"
}
################################################################################
# Budgets
################################################################################
resource "aws_budgets_budget" "tenant" {
name = "${var.tenant}-monthly-budget"
budget_type = "COST"
limit_amount = var.monthly_budget
limit_unit = "USD"
time_unit = "MONTHLY"
cost_filter {
name = "TagKeyValue"
values = ["Tenant$${var.tenant}"]
}
notification {
comparison_operator = "GREATER_THAN"
notification_type = "ACTUAL"
threshold = 50
threshold_type = "PERCENTAGE"
subscriber_email_addresses = var.alert_emails
}
notification {
comparison_operator = "GREATER_THAN"
notification_type = "ACTUAL"
threshold = 80
threshold_type = "PERCENTAGE"
subscriber_email_addresses = var.alert_emails
}
notification {
comparison_operator = "GREATER_THAN"
notification_type = "FORECASTED"
threshold = 100
threshold_type = "PERCENTAGE"
subscriber_email_addresses = var.alert_emails
}
}
# Per-app budgets
resource "aws_budgets_budget" "app" {
for_each = var.apps
name = "${var.tenant}-${each.key}-budget"
budget_type = "COST"
limit_amount = each.value.monthly_budget
limit_unit = "USD"
time_unit = "MONTHLY"
cost_filter {
name = "TagKeyValue"
values = ["App$${each.key}"]
}
notification {
comparison_operator = "GREATER_THAN"
notification_type = "ACTUAL"
threshold = 80
threshold_type = "PERCENTAGE"
subscriber_email_addresses = [each.value.owner_email]
}
}
################################################################################
# Outputs
################################################################################
output "tenant_ou_ids" {
value = {
prod = aws_organizations_organizational_unit.tenant_prod.id
nonprod = aws_organizations_organizational_unit.tenant_nonprod.id
}
}
output "account_ids" {
value = {
for k, v in aws_organizations_account.app : k => v.id
}
}
output "group_ids" {
value = {
all = aws_identitystore_group.tenant.group_id
admins = aws_identitystore_group.tenant_admins.group_id
developers = aws_identitystore_group.tenant_developers.group_id
readonly = aws_identitystore_group.tenant_readonly.group_id
}
}

View File

@@ -0,0 +1,70 @@
variable "tenant" {
description = "Tenant identifier (lowercase, no spaces)"
type = string
validation {
condition = can(regex("^[a-z0-9-]+$", var.tenant))
error_message = "Tenant must be lowercase alphanumeric with hyphens only."
}
}
variable "email_domain" {
description = "Domain for AWS account emails"
type = string
}
variable "email_prefix" {
description = "Email prefix before + sign"
type = string
default = "aws"
}
variable "production_ou_id" {
description = "ID of the Production OU"
type = string
}
variable "nonproduction_ou_id" {
description = "ID of the Non-Production OU"
type = string
}
variable "environments" {
description = "Environments to create for each app"
type = list(string)
default = ["prod", "staging", "dev"]
}
variable "apps" {
description = "Map of applications for this tenant"
type = map(object({
monthly_budget = number
owner_email = string
}))
}
variable "monthly_budget" {
description = "Total monthly budget for tenant"
type = number
default = 1000
}
variable "alert_emails" {
description = "Emails to receive budget alerts"
type = list(string)
}
variable "permission_set_admin_arn" {
description = "ARN of the TenantAdmin permission set"
type = string
}
variable "permission_set_developer_arn" {
description = "ARN of the TenantDeveloper permission set"
type = string
}
variable "permission_set_readonly_arn" {
description = "ARN of the TenantReadOnly permission set"
type = string
}

View File

@@ -0,0 +1,51 @@
# tenant-security-group
Tenant Security Group Module Creates isolated security groups for tenant workloads in shared VPC }
## Usage
```hcl
module "tenant_security_group" {
source = "../modules/tenant-security-group"
# Required variables
tenant = ""
environment = ""
vpc_id = ""
# Optional: see variables.tf for all options
}
```
## Requirements
| Name | Version |
|------|---------|
| terraform | >= 1.5.0 |
| aws | >= 5.0 |
## Inputs
| Name | Description | Type | Required |
|------|-------------|------|----------|
| tenant | Tenant identifier | `string` | yes |
| environment | Environment (prod, staging, dev) | `string` | yes |
| vpc_id | VPC ID for the security groups | `string` | yes |
| create_web_sg | Create web tier security group | `bool` | no |
| create_app_sg | Create app tier security group | `bool` | no |
| create_db_sg | Create database tier security group | `bool` | no |
| app_port | Application port | `number` | no |
| db_port | Database port | `number` | no |
## Outputs
| Name | Description |
|------|-------------|
| base_sg_id | |
| web_sg_id | |
| app_sg_id | |
| db_sg_id | |
## License
Apache 2.0 - See LICENSE for details.

View File

@@ -0,0 +1,201 @@
################################################################################
# Tenant Security Group Module
# Creates isolated security groups for tenant workloads in shared VPC
################################################################################
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
################################################################################
# Base Tenant Security Group
################################################################################
resource "aws_security_group" "tenant_base" {
name = "${var.tenant}-base-sg"
description = "Base security group for tenant ${var.tenant}"
vpc_id = var.vpc_id
# Allow all traffic within same tenant (same SG)
ingress {
description = "Allow intra-tenant traffic"
from_port = 0
to_port = 0
protocol = "-1"
self = true
}
# Allow outbound internet
egress {
description = "Allow all outbound"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.tenant}-base-sg"
Tenant = var.tenant
Environment = var.environment
}
}
################################################################################
# Web Tier Security Group
################################################################################
resource "aws_security_group" "tenant_web" {
count = var.create_web_sg ? 1 : 0
name = "${var.tenant}-web-sg"
description = "Web tier security group for tenant ${var.tenant}"
vpc_id = var.vpc_id
# HTTPS from anywhere
ingress {
description = "HTTPS from anywhere"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
# HTTP from anywhere (redirect to HTTPS)
ingress {
description = "HTTP from anywhere"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
# Allow from tenant base SG
ingress {
description = "Allow from tenant base"
from_port = 0
to_port = 0
protocol = "-1"
security_groups = [aws_security_group.tenant_base.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.tenant}-web-sg"
Tenant = var.tenant
Environment = var.environment
Tier = "web"
}
}
################################################################################
# App Tier Security Group
################################################################################
resource "aws_security_group" "tenant_app" {
count = var.create_app_sg ? 1 : 0
name = "${var.tenant}-app-sg"
description = "App tier security group for tenant ${var.tenant}"
vpc_id = var.vpc_id
# Allow from web tier
ingress {
description = "Allow from web tier"
from_port = var.app_port
to_port = var.app_port
protocol = "tcp"
security_groups = var.create_web_sg ? [aws_security_group.tenant_web[0].id] : []
}
# Allow from tenant base SG
ingress {
description = "Allow from tenant base"
from_port = 0
to_port = 0
protocol = "-1"
security_groups = [aws_security_group.tenant_base.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.tenant}-app-sg"
Tenant = var.tenant
Environment = var.environment
Tier = "app"
}
}
################################################################################
# Database Tier Security Group
################################################################################
resource "aws_security_group" "tenant_db" {
count = var.create_db_sg ? 1 : 0
name = "${var.tenant}-db-sg"
description = "Database tier security group for tenant ${var.tenant}"
vpc_id = var.vpc_id
# Allow from app tier only
ingress {
description = "Allow from app tier"
from_port = var.db_port
to_port = var.db_port
protocol = "tcp"
security_groups = var.create_app_sg ? [aws_security_group.tenant_app[0].id] : [aws_security_group.tenant_base.id]
}
# No direct outbound (DB shouldn't initiate connections)
egress {
description = "Allow response to app tier"
from_port = 0
to_port = 0
protocol = "-1"
security_groups = var.create_app_sg ? [aws_security_group.tenant_app[0].id] : [aws_security_group.tenant_base.id]
}
tags = {
Name = "${var.tenant}-db-sg"
Tenant = var.tenant
Environment = var.environment
Tier = "database"
}
}
################################################################################
# Outputs
################################################################################
output "base_sg_id" {
value = aws_security_group.tenant_base.id
}
output "web_sg_id" {
value = var.create_web_sg ? aws_security_group.tenant_web[0].id : null
}
output "app_sg_id" {
value = var.create_app_sg ? aws_security_group.tenant_app[0].id : null
}
output "db_sg_id" {
value = var.create_db_sg ? aws_security_group.tenant_db[0].id : null
}

View File

@@ -0,0 +1,44 @@
variable "tenant" {
description = "Tenant identifier"
type = string
}
variable "environment" {
description = "Environment (prod, staging, dev)"
type = string
}
variable "vpc_id" {
description = "VPC ID for the security groups"
type = string
}
variable "create_web_sg" {
description = "Create web tier security group"
type = bool
default = true
}
variable "create_app_sg" {
description = "Create app tier security group"
type = bool
default = true
}
variable "create_db_sg" {
description = "Create database tier security group"
type = bool
default = true
}
variable "app_port" {
description = "Application port"
type = number
default = 8080
}
variable "db_port" {
description = "Database port"
type = number
default = 5432
}

View File

@@ -0,0 +1,36 @@
# tenant-vpc
Terraform module for AWS landing zone pattern.
Create tenant-isolated VPC with standard networking.
## Planned Features
- [ ] Dedicated CIDR block
- [ ] Public/private subnets across AZs
- [ ] NAT Gateway or cost-optimized NAT Instance
- [ ] VPC Flow Logs to CloudWatch
- [ ] Transit Gateway attachment
- [ ] Routes to shared services VPC
## Planned Usage
```hcl
module "tenant_vpc" {
source = "../modules/tenant-vpc"
tenant_name = "acme-corp"
cidr = "10.100.0.0/16"
azs = ["us-east-1a", "us-east-1b"]
private_subnets = ["10.100.1.0/24", "10.100.2.0/24"]
public_subnets = ["10.100.101.0/24", "10.100.102.0/24"]
enable_nat = true
nat_mode = "instance" # Cost-optimized
transit_gateway_id = data.aws_ec2_transit_gateway.main.id
tags = local.tags
}
```

View File

@@ -0,0 +1,333 @@
################################################################################
# Tenant VPC Module
#
# Creates tenant-isolated VPC with standard networking:
# - Dedicated CIDR block
# - Public/private subnets
# - NAT Gateway or NAT Instance
# - VPC Flow Logs
# - Optional Transit Gateway attachment
################################################################################
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
# Calculate subnets if not explicitly provided
azs = length(var.azs) > 0 ? var.azs : slice(data.aws_availability_zones.available.names, 0, var.az_count)
# Common tags for VPC resources
vpc_tags = merge(var.tags, {
Tenant = var.tenant_name
})
}
data "aws_availability_zones" "available" {
state = "available"
}
################################################################################
# VPC
################################################################################
resource "aws_vpc" "this" {
cidr_block = var.cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = merge(local.vpc_tags, {
Name = "${var.tenant_name}-vpc"
})
}
################################################################################
# Internet Gateway
################################################################################
resource "aws_internet_gateway" "this" {
count = length(var.public_subnets) > 0 ? 1 : 0
vpc_id = aws_vpc.this.id
tags = merge(local.vpc_tags, {
Name = "${var.tenant_name}-igw"
})
}
################################################################################
# Subnets
################################################################################
resource "aws_subnet" "public" {
count = length(var.public_subnets)
vpc_id = aws_vpc.this.id
cidr_block = var.public_subnets[count.index]
availability_zone = local.azs[count.index % length(local.azs)]
map_public_ip_on_launch = true
tags = merge(local.vpc_tags, {
Name = "${var.tenant_name}-public-${local.azs[count.index % length(local.azs)]}"
Tier = "public"
})
}
resource "aws_subnet" "private" {
count = length(var.private_subnets)
vpc_id = aws_vpc.this.id
cidr_block = var.private_subnets[count.index]
availability_zone = local.azs[count.index % length(local.azs)]
tags = merge(local.vpc_tags, {
Name = "${var.tenant_name}-private-${local.azs[count.index % length(local.azs)]}"
Tier = "private"
})
}
################################################################################
# NAT Gateway / Instance
################################################################################
resource "aws_eip" "nat" {
count = var.enable_nat && var.nat_mode == "gateway" ? 1 : 0
domain = "vpc"
tags = merge(local.vpc_tags, {
Name = "${var.tenant_name}-nat-eip"
})
depends_on = [aws_internet_gateway.this]
}
resource "aws_nat_gateway" "this" {
count = var.enable_nat && var.nat_mode == "gateway" ? 1 : 0
allocation_id = aws_eip.nat[0].id
subnet_id = aws_subnet.public[0].id
tags = merge(local.vpc_tags, {
Name = "${var.tenant_name}-nat-gateway"
})
depends_on = [aws_internet_gateway.this]
}
# NAT Instance (cost-optimized alternative)
data "aws_ami" "nat" {
count = var.enable_nat && var.nat_mode == "instance" ? 1 : 0
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["amzn-ami-vpc-nat-*"]
}
filter {
name = "architecture"
values = ["x86_64"]
}
}
resource "aws_security_group" "nat" {
count = var.enable_nat && var.nat_mode == "instance" ? 1 : 0
name_prefix = "${var.tenant_name}-nat-"
description = "NAT instance security group"
vpc_id = aws_vpc.this.id
ingress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = var.private_subnets
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = merge(local.vpc_tags, {
Name = "${var.tenant_name}-nat-sg"
})
}
resource "aws_instance" "nat" {
count = var.enable_nat && var.nat_mode == "instance" ? 1 : 0
ami = data.aws_ami.nat[0].id
instance_type = var.nat_instance_type
subnet_id = aws_subnet.public[0].id
vpc_security_group_ids = [aws_security_group.nat[0].id]
source_dest_check = false
associate_public_ip_address = true
tags = merge(local.vpc_tags, {
Name = "${var.tenant_name}-nat-instance"
})
}
################################################################################
# Route Tables
################################################################################
resource "aws_route_table" "public" {
count = length(var.public_subnets) > 0 ? 1 : 0
vpc_id = aws_vpc.this.id
tags = merge(local.vpc_tags, {
Name = "${var.tenant_name}-public-rt"
})
}
resource "aws_route" "public_internet" {
count = length(var.public_subnets) > 0 ? 1 : 0
route_table_id = aws_route_table.public[0].id
destination_cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.this[0].id
}
resource "aws_route_table_association" "public" {
count = length(var.public_subnets)
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public[0].id
}
resource "aws_route_table" "private" {
count = length(var.private_subnets) > 0 ? 1 : 0
vpc_id = aws_vpc.this.id
tags = merge(local.vpc_tags, {
Name = "${var.tenant_name}-private-rt"
})
}
resource "aws_route" "private_nat_gateway" {
count = var.enable_nat && var.nat_mode == "gateway" && length(var.private_subnets) > 0 ? 1 : 0
route_table_id = aws_route_table.private[0].id
destination_cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.this[0].id
}
resource "aws_route" "private_nat_instance" {
count = var.enable_nat && var.nat_mode == "instance" && length(var.private_subnets) > 0 ? 1 : 0
route_table_id = aws_route_table.private[0].id
destination_cidr_block = "0.0.0.0/0"
network_interface_id = aws_instance.nat[0].primary_network_interface_id
}
resource "aws_route_table_association" "private" {
count = length(var.private_subnets)
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private[0].id
}
################################################################################
# Transit Gateway Attachment
################################################################################
resource "aws_ec2_transit_gateway_vpc_attachment" "this" {
count = var.transit_gateway_id != "" ? 1 : 0
transit_gateway_id = var.transit_gateway_id
vpc_id = aws_vpc.this.id
subnet_ids = aws_subnet.private[*].id
tags = merge(local.vpc_tags, {
Name = "${var.tenant_name}-tgw-attachment"
})
}
################################################################################
# VPC Flow Logs
################################################################################
resource "aws_cloudwatch_log_group" "flow_logs" {
count = var.enable_flow_logs ? 1 : 0
name = "/aws/vpc/${var.tenant_name}/flow-logs"
retention_in_days = var.flow_log_retention_days
tags = local.vpc_tags
}
resource "aws_iam_role" "flow_logs" {
count = var.enable_flow_logs ? 1 : 0
name = "${var.tenant_name}-vpc-flow-logs-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "vpc-flow-logs.amazonaws.com"
}
}]
})
tags = local.vpc_tags
}
resource "aws_iam_role_policy" "flow_logs" {
count = var.enable_flow_logs ? 1 : 0
name = "flow-logs-policy"
role = aws_iam_role.flow_logs[0].id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = [
"logs:CreateLogStream",
"logs:PutLogEvents",
"logs:DescribeLogGroups",
"logs:DescribeLogStreams"
]
Effect = "Allow"
Resource = "${aws_cloudwatch_log_group.flow_logs[0].arn}:*"
}]
})
}
resource "aws_flow_log" "this" {
count = var.enable_flow_logs ? 1 : 0
vpc_id = aws_vpc.this.id
traffic_type = "ALL"
log_destination_type = "cloud-watch-logs"
log_destination = aws_cloudwatch_log_group.flow_logs[0].arn
iam_role_arn = aws_iam_role.flow_logs[0].arn
max_aggregation_interval = 60
tags = merge(local.vpc_tags, {
Name = "${var.tenant_name}-vpc-flow-log"
})
}

View File

@@ -0,0 +1,57 @@
################################################################################
# Tenant VPC - Outputs
################################################################################
output "vpc_id" {
value = aws_vpc.this.id
description = "VPC ID"
}
output "vpc_cidr" {
value = aws_vpc.this.cidr_block
description = "VPC CIDR block"
}
output "public_subnet_ids" {
value = aws_subnet.public[*].id
description = "Public subnet IDs"
}
output "private_subnet_ids" {
value = aws_subnet.private[*].id
description = "Private subnet IDs"
}
output "public_route_table_id" {
value = try(aws_route_table.public[0].id, null)
description = "Public route table ID"
}
output "private_route_table_id" {
value = try(aws_route_table.private[0].id, null)
description = "Private route table ID"
}
output "nat_public_ip" {
value = var.nat_mode == "gateway" ? (
try(aws_eip.nat[0].public_ip, null)
) : (
try(aws_instance.nat[0].public_ip, null)
)
description = "NAT Gateway/Instance public IP"
}
output "tgw_attachment_id" {
value = try(aws_ec2_transit_gateway_vpc_attachment.this[0].id, null)
description = "Transit Gateway attachment ID"
}
output "flow_log_group" {
value = try(aws_cloudwatch_log_group.flow_logs[0].name, null)
description = "Flow log CloudWatch log group"
}
output "azs" {
value = local.azs
description = "Availability zones used"
}

View File

@@ -0,0 +1,83 @@
################################################################################
# Tenant VPC - Input Variables
################################################################################
variable "tenant_name" {
type = string
description = "Tenant name (used for resource naming)"
}
variable "cidr" {
type = string
description = "VPC CIDR block"
}
variable "azs" {
type = list(string)
default = []
description = "Availability zones (auto-detected if empty)"
}
variable "az_count" {
type = number
default = 2
description = "Number of AZs if not specifying azs"
}
variable "public_subnets" {
type = list(string)
default = []
description = "Public subnet CIDRs"
}
variable "private_subnets" {
type = list(string)
description = "Private subnet CIDRs"
}
variable "enable_nat" {
type = bool
default = true
description = "Enable NAT for private subnets"
}
variable "nat_mode" {
type = string
default = "instance"
description = "NAT mode: gateway or instance"
validation {
condition = contains(["gateway", "instance"], var.nat_mode)
error_message = "Must be gateway or instance"
}
}
variable "nat_instance_type" {
type = string
default = "t4g.nano"
description = "NAT instance type (if using instance mode)"
}
variable "transit_gateway_id" {
type = string
default = ""
description = "Transit Gateway ID for attachment"
}
variable "enable_flow_logs" {
type = bool
default = true
description = "Enable VPC Flow Logs"
}
variable "flow_log_retention_days" {
type = number
default = 30
description = "Flow log retention in days"
}
variable "tags" {
type = map(string)
default = {}
description = "Tags to apply to resources"
}

View File

@@ -0,0 +1,58 @@
# vpc-endpoints
VPC Endpoints Module
## Usage
```hcl
module "vpc_endpoints" {
source = "../modules/vpc-endpoints"
# Required variables
vpc_id = ""
private_subnet_ids = ""
private_route_table_ids = ""
region = ""
# Optional: see variables.tf for all options
}
```
## Requirements
| Name | Version |
|------|---------|
| terraform | >= 1.5.0 |
| aws | >= 5.0 |
## Inputs
| Name | Description | Type | Required |
|------|-------------|------|----------|
| vpc_id | | `string` | yes |
| private_subnet_ids | | `list(string)` | yes |
| private_route_table_ids | | `list(string)` | yes |
| region | | `string` | yes |
| name_prefix | | `string` | no |
| enable_s3_endpoint | | `bool` | no |
| enable_dynamodb_endpoint | | `bool` | no |
| enable_ecr_endpoints | ECR API + DKR endpoints for container pulls without NAT | `bool` | no |
| enable_secrets_manager_endpoint | Secrets Manager endpoint for secret retrieval without NAT | `bool` | no |
| enable_ssm_endpoints | SSM, SSM Messages, EC2 Messages for Session Manager | `bool` | no |
| enable_logs_endpoint | CloudWatch Logs endpoint | `bool` | no |
| enable_kms_endpoint | KMS endpoint for encryption operations | `bool` | no |
| enable_sts_endpoint | STS endpoint for IAM role assumption | `bool` | no |
| enable_eks_endpoint | EKS endpoint for kubectl without public access | `bool` | no |
## Outputs
| Name | Description |
|------|-------------|
| s3_endpoint_id | |
| dynamodb_endpoint_id | |
| endpoints_security_group_id | |
| enabled_endpoints | |
## License
Apache 2.0 - See LICENSE for details.

View File

@@ -0,0 +1,334 @@
################################################################################
# VPC Endpoints Module
#
# Provides private connectivity to AWS services without NAT Gateway:
# - Gateway endpoints (S3, DynamoDB) - FREE
# - Interface endpoints (ECR, Secrets Manager, etc.) - ~$7/mo each
#
# Cost/Security tradeoff:
# - Gateway endpoints: Always enable (free, faster)
# - Interface endpoints: Enable for high-traffic services or security requirements
################################################################################
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
variable "vpc_id" {
type = string
}
variable "private_subnet_ids" {
type = list(string)
}
variable "private_route_table_ids" {
type = list(string)
}
variable "region" {
type = string
}
variable "name_prefix" {
type = string
default = "shared"
}
# Gateway endpoints (FREE - always enable)
variable "enable_s3_endpoint" {
type = bool
default = true
}
variable "enable_dynamodb_endpoint" {
type = bool
default = true
}
# Interface endpoints (~$7/mo each + data transfer)
variable "enable_ecr_endpoints" {
type = bool
default = false
description = "ECR API + DKR endpoints for container pulls without NAT"
}
variable "enable_secrets_manager_endpoint" {
type = bool
default = false
description = "Secrets Manager endpoint for secret retrieval without NAT"
}
variable "enable_ssm_endpoints" {
type = bool
default = false
description = "SSM, SSM Messages, EC2 Messages for Session Manager"
}
variable "enable_logs_endpoint" {
type = bool
default = false
description = "CloudWatch Logs endpoint"
}
variable "enable_kms_endpoint" {
type = bool
default = false
description = "KMS endpoint for encryption operations"
}
variable "enable_sts_endpoint" {
type = bool
default = false
description = "STS endpoint for IAM role assumption"
}
variable "enable_eks_endpoint" {
type = bool
default = false
description = "EKS endpoint for kubectl without public access"
}
################################################################################
# Security Group for Interface Endpoints
################################################################################
resource "aws_security_group" "endpoints" {
count = local.any_interface_endpoint ? 1 : 0
name = "${var.name_prefix}-vpc-endpoints"
description = "VPC Interface Endpoints"
vpc_id = var.vpc_id
ingress {
description = "HTTPS from VPC"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = [data.aws_vpc.main.cidr_block]
}
tags = { Name = "${var.name_prefix}-vpc-endpoints" }
}
data "aws_vpc" "main" {
id = var.vpc_id
}
locals {
any_interface_endpoint = (
var.enable_ecr_endpoints ||
var.enable_secrets_manager_endpoint ||
var.enable_ssm_endpoints ||
var.enable_logs_endpoint ||
var.enable_kms_endpoint ||
var.enable_sts_endpoint ||
var.enable_eks_endpoint
)
}
################################################################################
# Gateway Endpoints (FREE)
################################################################################
resource "aws_vpc_endpoint" "s3" {
count = var.enable_s3_endpoint ? 1 : 0
vpc_id = var.vpc_id
service_name = "com.amazonaws.${var.region}.s3"
vpc_endpoint_type = "Gateway"
route_table_ids = var.private_route_table_ids
tags = { Name = "${var.name_prefix}-s3" }
}
resource "aws_vpc_endpoint" "dynamodb" {
count = var.enable_dynamodb_endpoint ? 1 : 0
vpc_id = var.vpc_id
service_name = "com.amazonaws.${var.region}.dynamodb"
vpc_endpoint_type = "Gateway"
route_table_ids = var.private_route_table_ids
tags = { Name = "${var.name_prefix}-dynamodb" }
}
################################################################################
# ECR Endpoints (for container pulls without NAT)
################################################################################
resource "aws_vpc_endpoint" "ecr_api" {
count = var.enable_ecr_endpoints ? 1 : 0
vpc_id = var.vpc_id
service_name = "com.amazonaws.${var.region}.ecr.api"
vpc_endpoint_type = "Interface"
subnet_ids = var.private_subnet_ids
security_group_ids = [aws_security_group.endpoints[0].id]
private_dns_enabled = true
tags = { Name = "${var.name_prefix}-ecr-api" }
}
resource "aws_vpc_endpoint" "ecr_dkr" {
count = var.enable_ecr_endpoints ? 1 : 0
vpc_id = var.vpc_id
service_name = "com.amazonaws.${var.region}.ecr.dkr"
vpc_endpoint_type = "Interface"
subnet_ids = var.private_subnet_ids
security_group_ids = [aws_security_group.endpoints[0].id]
private_dns_enabled = true
tags = { Name = "${var.name_prefix}-ecr-dkr" }
}
################################################################################
# Secrets Manager Endpoint
################################################################################
resource "aws_vpc_endpoint" "secretsmanager" {
count = var.enable_secrets_manager_endpoint ? 1 : 0
vpc_id = var.vpc_id
service_name = "com.amazonaws.${var.region}.secretsmanager"
vpc_endpoint_type = "Interface"
subnet_ids = var.private_subnet_ids
security_group_ids = [aws_security_group.endpoints[0].id]
private_dns_enabled = true
tags = { Name = "${var.name_prefix}-secretsmanager" }
}
################################################################################
# SSM Endpoints (for Session Manager)
################################################################################
resource "aws_vpc_endpoint" "ssm" {
count = var.enable_ssm_endpoints ? 1 : 0
vpc_id = var.vpc_id
service_name = "com.amazonaws.${var.region}.ssm"
vpc_endpoint_type = "Interface"
subnet_ids = var.private_subnet_ids
security_group_ids = [aws_security_group.endpoints[0].id]
private_dns_enabled = true
tags = { Name = "${var.name_prefix}-ssm" }
}
resource "aws_vpc_endpoint" "ssmmessages" {
count = var.enable_ssm_endpoints ? 1 : 0
vpc_id = var.vpc_id
service_name = "com.amazonaws.${var.region}.ssmmessages"
vpc_endpoint_type = "Interface"
subnet_ids = var.private_subnet_ids
security_group_ids = [aws_security_group.endpoints[0].id]
private_dns_enabled = true
tags = { Name = "${var.name_prefix}-ssmmessages" }
}
resource "aws_vpc_endpoint" "ec2messages" {
count = var.enable_ssm_endpoints ? 1 : 0
vpc_id = var.vpc_id
service_name = "com.amazonaws.${var.region}.ec2messages"
vpc_endpoint_type = "Interface"
subnet_ids = var.private_subnet_ids
security_group_ids = [aws_security_group.endpoints[0].id]
private_dns_enabled = true
tags = { Name = "${var.name_prefix}-ec2messages" }
}
################################################################################
# CloudWatch Logs Endpoint
################################################################################
resource "aws_vpc_endpoint" "logs" {
count = var.enable_logs_endpoint ? 1 : 0
vpc_id = var.vpc_id
service_name = "com.amazonaws.${var.region}.logs"
vpc_endpoint_type = "Interface"
subnet_ids = var.private_subnet_ids
security_group_ids = [aws_security_group.endpoints[0].id]
private_dns_enabled = true
tags = { Name = "${var.name_prefix}-logs" }
}
################################################################################
# KMS Endpoint
################################################################################
resource "aws_vpc_endpoint" "kms" {
count = var.enable_kms_endpoint ? 1 : 0
vpc_id = var.vpc_id
service_name = "com.amazonaws.${var.region}.kms"
vpc_endpoint_type = "Interface"
subnet_ids = var.private_subnet_ids
security_group_ids = [aws_security_group.endpoints[0].id]
private_dns_enabled = true
tags = { Name = "${var.name_prefix}-kms" }
}
################################################################################
# STS Endpoint
################################################################################
resource "aws_vpc_endpoint" "sts" {
count = var.enable_sts_endpoint ? 1 : 0
vpc_id = var.vpc_id
service_name = "com.amazonaws.${var.region}.sts"
vpc_endpoint_type = "Interface"
subnet_ids = var.private_subnet_ids
security_group_ids = [aws_security_group.endpoints[0].id]
private_dns_enabled = true
tags = { Name = "${var.name_prefix}-sts" }
}
################################################################################
# EKS Endpoint
################################################################################
resource "aws_vpc_endpoint" "eks" {
count = var.enable_eks_endpoint ? 1 : 0
vpc_id = var.vpc_id
service_name = "com.amazonaws.${var.region}.eks"
vpc_endpoint_type = "Interface"
subnet_ids = var.private_subnet_ids
security_group_ids = [aws_security_group.endpoints[0].id]
private_dns_enabled = true
tags = { Name = "${var.name_prefix}-eks" }
}
################################################################################
# Outputs
################################################################################
output "s3_endpoint_id" {
value = var.enable_s3_endpoint ? aws_vpc_endpoint.s3[0].id : null
}
output "dynamodb_endpoint_id" {
value = var.enable_dynamodb_endpoint ? aws_vpc_endpoint.dynamodb[0].id : null
}
output "endpoints_security_group_id" {
value = local.any_interface_endpoint ? aws_security_group.endpoints[0].id : null
}
output "enabled_endpoints" {
value = {
s3 = var.enable_s3_endpoint
dynamodb = var.enable_dynamodb_endpoint
ecr = var.enable_ecr_endpoints
secrets_manager = var.enable_secrets_manager_endpoint
ssm = var.enable_ssm_endpoints
logs = var.enable_logs_endpoint
kms = var.enable_kms_endpoint
sts = var.enable_sts_endpoint
eks = var.enable_eks_endpoint
}
}

View File

@@ -0,0 +1,58 @@
# vpc-lite
VPC Lite Module
## Usage
```hcl
module "vpc_lite" {
source = "../modules/vpc-lite"
# 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 | VPC name prefix | `string` | yes |
| cidr | VPC CIDR block | `string` | no |
| azs | Availability zones (auto-detected if empty) | `list(string)` | no |
| az_count | Number of AZs to use (if azs not specified) | `number` | no |
| nat_mode | NAT mode: none, instance, or gateway | `string` | no |
| create_private_subnets | Create private subnets (set false for public-only) | `bool` | no |
| enable_vpc_endpoints | Create VPC endpoints for AWS services (recommended when nat_... | `bool` | no |
| vpc_endpoint_services | Gateway endpoints to create (s3, dynamodb) | `list(string)` | no |
| vpc_endpoint_interfaces | Interface endpoints to create (ecr.api, ecr.dkr, logs, ssm, ... | `list(string)` | no |
| enable_flow_logs | Enable VPC Flow Logs | `bool` | no |
| flow_log_retention_days | Flow log retention (shorter = cheaper) | `number` | no |
| tags | | `map(string)` | no |
## Outputs
| Name | Description |
|------|-------------|
| vpc_id | |
| vpc_cidr | |
| public_subnet_ids | |
| private_subnet_ids | |
| nat_mode | NAT mode used |
| nat_ip | NAT public IP (if applicable) |
| cost_estimate | Estimated monthly cost for NAT |
| internet_access | |
| vpc_endpoints | |
## License
Apache 2.0 - See LICENSE for details.

View File

@@ -0,0 +1,507 @@
################################################################################
# VPC Lite Module
#
# Cost-optimized VPC for small accounts/dev environments:
# - NO NAT Gateway ($32+/mo savings)
# - VPC Endpoints for AWS service access
# - Optional NAT Instance (t4g.nano ~$3/mo)
# - Public-only or public+private subnets
#
# Tradeoffs:
# - Private subnets can't reach internet (use VPC endpoints)
# - NAT Instance is single-AZ, not HA
# - For production, use standard VPC with NAT Gateway
#
# Usage:
# module "vpc" {
# source = "../modules/vpc-lite"
# name = "dev-vpc"
#
# # Choose one:
# nat_mode = "none" # No NAT - use VPC endpoints only
# nat_mode = "instance" # NAT Instance (~$3/mo)
# nat_mode = "gateway" # NAT Gateway (~$32/mo) - for prod
# }
################################################################################
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
variable "name" {
type = string
description = "VPC name prefix"
}
variable "cidr" {
type = string
default = "10.0.0.0/16"
description = "VPC CIDR block"
}
variable "azs" {
type = list(string)
default = []
description = "Availability zones (auto-detected if empty)"
}
variable "az_count" {
type = number
default = 2
description = "Number of AZs to use (if azs not specified)"
}
variable "nat_mode" {
type = string
default = "none"
description = "NAT mode: none, instance, or gateway"
validation {
condition = contains(["none", "instance", "gateway"], var.nat_mode)
error_message = "Must be none, instance, or gateway"
}
}
variable "create_private_subnets" {
type = bool
default = true
description = "Create private subnets (set false for public-only)"
}
variable "enable_vpc_endpoints" {
type = bool
default = true
description = "Create VPC endpoints for AWS services (recommended when nat_mode=none)"
}
variable "vpc_endpoint_services" {
type = list(string)
default = ["s3", "dynamodb"]
description = "Gateway endpoints to create (s3, dynamodb)"
}
variable "vpc_endpoint_interfaces" {
type = list(string)
default = []
description = "Interface endpoints to create (ecr.api, ecr.dkr, logs, ssm, etc.)"
}
variable "enable_flow_logs" {
type = bool
default = true
description = "Enable VPC Flow Logs"
}
variable "flow_log_retention_days" {
type = number
default = 14
description = "Flow log retention (shorter = cheaper)"
}
variable "tags" {
type = map(string)
default = {}
}
################################################################################
# Data Sources
################################################################################
data "aws_region" "current" {}
data "aws_availability_zones" "available" {
state = "available"
}
locals {
azs = length(var.azs) > 0 ? var.azs : slice(data.aws_availability_zones.available.names, 0, var.az_count)
# Cost estimates (us-east-1 pricing)
cost_estimate = {
none = "$0/mo for NAT (use VPC endpoints for AWS services)"
instance = "~$3/mo (t4g.nano NAT instance, single-AZ)"
gateway = "~$32/mo + data transfer (recommended for production)"
}
}
################################################################################
# VPC
################################################################################
resource "aws_vpc" "main" {
cidr_block = var.cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = merge(var.tags, {
Name = var.name
NatMode = var.nat_mode
CostTier = var.nat_mode == "none" ? "minimal" : (var.nat_mode == "instance" ? "low" : "standard")
})
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = merge(var.tags, { Name = "${var.name}-igw" })
}
################################################################################
# Subnets
################################################################################
resource "aws_subnet" "public" {
count = length(local.azs)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.cidr, 4, count.index)
availability_zone = local.azs[count.index]
map_public_ip_on_launch = true
tags = merge(var.tags, {
Name = "${var.name}-public-${local.azs[count.index]}"
Type = "public"
})
}
resource "aws_subnet" "private" {
count = var.create_private_subnets ? length(local.azs) : 0
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.cidr, 4, count.index + 8)
availability_zone = local.azs[count.index]
tags = merge(var.tags, {
Name = "${var.name}-private-${local.azs[count.index]}"
Type = "private"
})
}
################################################################################
# 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 = merge(var.tags, { Name = "${var.name}-public-rt" })
}
resource "aws_route_table" "private" {
count = var.create_private_subnets ? 1 : 0
vpc_id = aws_vpc.main.id
# NAT route added dynamically based on nat_mode
dynamic "route" {
for_each = var.nat_mode == "gateway" ? [1] : []
content {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main[0].id
}
}
dynamic "route" {
for_each = var.nat_mode == "instance" ? [1] : []
content {
cidr_block = "0.0.0.0/0"
network_interface_id = aws_instance.nat[0].primary_network_interface_id
}
}
# No route for nat_mode = "none" - private subnets are isolated
tags = merge(var.tags, { Name = "${var.name}-private-rt" })
}
resource "aws_route_table_association" "public" {
count = length(local.azs)
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "private" {
count = var.create_private_subnets ? length(local.azs) : 0
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private[0].id
}
################################################################################
# NAT Gateway (nat_mode = "gateway")
################################################################################
resource "aws_eip" "nat" {
count = var.nat_mode == "gateway" ? 1 : 0
domain = "vpc"
tags = merge(var.tags, { Name = "${var.name}-nat-eip" })
}
resource "aws_nat_gateway" "main" {
count = var.nat_mode == "gateway" ? 1 : 0
allocation_id = aws_eip.nat[0].id
subnet_id = aws_subnet.public[0].id
tags = merge(var.tags, { Name = "${var.name}-nat" })
depends_on = [aws_internet_gateway.main]
}
################################################################################
# NAT Instance (nat_mode = "instance")
# Uses Amazon Linux 2023 with iptables NAT
################################################################################
data "aws_ami" "nat" {
count = var.nat_mode == "instance" ? 1 : 0
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-arm64"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
resource "aws_security_group" "nat" {
count = var.nat_mode == "instance" ? 1 : 0
name = "${var.name}-nat-instance"
description = "NAT instance security group"
vpc_id = aws_vpc.main.id
ingress {
description = "Allow from VPC"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = [var.cidr]
}
egress {
description = "Allow all outbound"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = merge(var.tags, { Name = "${var.name}-nat-instance" })
}
resource "aws_instance" "nat" {
count = var.nat_mode == "instance" ? 1 : 0
ami = data.aws_ami.nat[0].id
instance_type = "t4g.nano" # ~$3/mo
subnet_id = aws_subnet.public[0].id
source_dest_check = false # Required for NAT
vpc_security_group_ids = [aws_security_group.nat[0].id]
user_data = <<-EOF
#!/bin/bash
# Enable IP forwarding and NAT
echo 1 > /proc/sys/net/ipv4/ip_forward
echo "net.ipv4.ip_forward = 1" >> /etc/sysctl.conf
# Configure iptables NAT
yum install -y iptables-services
iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
iptables -A FORWARD -i eth0 -o eth0 -m state --state RELATED,ESTABLISHED -j ACCEPT
iptables -A FORWARD -i eth0 -o eth0 -j ACCEPT
service iptables save
systemctl enable iptables
EOF
metadata_options {
http_endpoint = "enabled"
http_tokens = "required" # IMDSv2
http_put_response_hop_limit = 1
}
tags = merge(var.tags, { Name = "${var.name}-nat-instance" })
lifecycle {
ignore_changes = [ami]
}
}
################################################################################
# VPC Endpoints (recommended for nat_mode = "none")
################################################################################
# Gateway Endpoints (free)
resource "aws_vpc_endpoint" "gateway" {
for_each = var.enable_vpc_endpoints ? toset(var.vpc_endpoint_services) : []
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${data.aws_region.current.name}.${each.value}"
vpc_endpoint_type = "Gateway"
route_table_ids = compact([
aws_route_table.public.id,
var.create_private_subnets ? aws_route_table.private[0].id : null
])
tags = merge(var.tags, { Name = "${var.name}-${each.value}-endpoint" })
}
# Interface Endpoints (cost per hour + data)
resource "aws_security_group" "endpoints" {
count = var.enable_vpc_endpoints && length(var.vpc_endpoint_interfaces) > 0 ? 1 : 0
name = "${var.name}-vpc-endpoints"
description = "VPC Interface Endpoints"
vpc_id = aws_vpc.main.id
ingress {
description = "HTTPS from VPC"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = [var.cidr]
}
tags = merge(var.tags, { Name = "${var.name}-vpc-endpoints" })
}
resource "aws_vpc_endpoint" "interface" {
for_each = var.enable_vpc_endpoints ? toset(var.vpc_endpoint_interfaces) : []
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${data.aws_region.current.name}.${each.value}"
vpc_endpoint_type = "Interface"
private_dns_enabled = true
subnet_ids = var.create_private_subnets ? aws_subnet.private[*].id : aws_subnet.public[*].id
security_group_ids = [aws_security_group.endpoints[0].id]
tags = merge(var.tags, { Name = "${var.name}-${replace(each.value, ".", "-")}-endpoint" })
}
################################################################################
# Default Security Group - Deny All
################################################################################
resource "aws_default_security_group" "default" {
vpc_id = aws_vpc.main.id
tags = merge(var.tags, { Name = "${var.name}-default-deny" })
}
################################################################################
# Flow Logs (optional, shorter retention = cheaper)
################################################################################
resource "aws_flow_log" "main" {
count = var.enable_flow_logs ? 1 : 0
vpc_id = aws_vpc.main.id
traffic_type = "ALL"
log_destination_type = "cloud-watch-logs"
log_destination = aws_cloudwatch_log_group.flow_logs[0].arn
iam_role_arn = aws_iam_role.flow_logs[0].arn
max_aggregation_interval = 600 # 10 min aggregation (cheaper)
tags = merge(var.tags, { Name = "${var.name}-flow-logs" })
}
resource "aws_cloudwatch_log_group" "flow_logs" {
count = var.enable_flow_logs ? 1 : 0
name = "/aws/vpc/${var.name}/flow-logs"
retention_in_days = var.flow_log_retention_days
tags = merge(var.tags, { Name = "${var.name}-flow-logs" })
}
resource "aws_iam_role" "flow_logs" {
count = var.enable_flow_logs ? 1 : 0
name = "${var.name}-flow-logs"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "vpc-flow-logs.amazonaws.com" }
}]
})
tags = merge(var.tags, { Name = "${var.name}-flow-logs" })
}
resource "aws_iam_role_policy" "flow_logs" {
count = var.enable_flow_logs ? 1 : 0
name = "flow-logs"
role = aws_iam_role.flow_logs[0].id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = [
"logs:CreateLogStream",
"logs:PutLogEvents"
]
Resource = "${aws_cloudwatch_log_group.flow_logs[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 = var.create_private_subnets ? aws_subnet.private[*].id : []
}
output "nat_mode" {
value = var.nat_mode
description = "NAT mode used"
}
output "nat_ip" {
value = var.nat_mode == "gateway" ? aws_eip.nat[0].public_ip : (
var.nat_mode == "instance" ? aws_instance.nat[0].public_ip : null
)
description = "NAT public IP (if applicable)"
}
output "cost_estimate" {
value = local.cost_estimate[var.nat_mode]
description = "Estimated monthly cost for NAT"
}
output "internet_access" {
value = {
public_subnets = "Full internet access via IGW"
private_subnets = var.nat_mode == "none" ? "No internet - use VPC endpoints for AWS services" : "Internet via NAT ${var.nat_mode}"
}
description = "Internet access summary"
}
output "vpc_endpoints" {
value = {
gateway = [for k, v in aws_vpc_endpoint.gateway : k]
interface = [for k, v in aws_vpc_endpoint.interface : k]
}
description = "Created VPC endpoints"
}

View File

@@ -0,0 +1,57 @@
# waf-alb
WAF Module for ALB Protection
## Usage
```hcl
module "waf_alb" {
source = "../modules/waf-alb"
# 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 for the WAF Web ACL | `string` | yes |
| description | | `string` | no |
| rate_limit | Requests per 5-minute period per IP | `number` | no |
| rate_limit_action | | `string` | no |
| blocked_countries | ISO 3166-1 alpha-2 country codes to block | `list(string)` | no |
| allowed_countries | If set, ONLY these countries are allowed (overrides blocked) | `list(string)` | no |
| ip_allowlist | CIDR blocks to always allow | `list(string)` | no |
| ip_blocklist | CIDR blocks to always block | `list(string)` | no |
| enable_aws_managed_rules | | `bool` | no |
| enable_known_bad_inputs | | `bool` | no |
| enable_sql_injection | | `bool` | no |
| enable_linux_protection | | `bool` | no |
| enable_php_protection | | `bool` | no |
| enable_wordpress_protection | | `bool` | no |
| enable_bot_control | Bot Control (additional cost ~$10/mo + $1/million requests) | `bool` | no |
*...and 3 more variables. See `variables.tf` for complete list.*
## Outputs
| Name | Description |
|------|-------------|
| web_acl_arn | ARN of the WAF Web ACL - use this with ALB |
| web_acl_id | |
| web_acl_capacity | WCU capacity used (max 1500 for regional) |
## License
Apache 2.0 - See LICENSE for details.

View File

@@ -0,0 +1,561 @@
################################################################################
# WAF Module for ALB Protection
#
# Provides Web Application Firewall protection:
# - AWS Managed Rules (OWASP, Known Bad Inputs, etc.)
# - Rate limiting
# - Geo-blocking (optional)
# - IP allowlist/blocklist
# - Logging to S3/CloudWatch
#
# Attach to ALB: set waf_web_acl_arn in your workload
################################################################################
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
variable "name" {
type = string
description = "Name for the WAF Web ACL"
}
variable "description" {
type = string
default = "WAF Web ACL for ALB protection"
}
# Rate limiting
variable "rate_limit" {
type = number
default = 2000
description = "Requests per 5-minute period per IP"
}
variable "rate_limit_action" {
type = string
default = "block"
validation {
condition = contains(["block", "count"], var.rate_limit_action)
error_message = "Must be 'block' or 'count'"
}
}
# Geo restrictions
variable "blocked_countries" {
type = list(string)
default = []
description = "ISO 3166-1 alpha-2 country codes to block"
}
variable "allowed_countries" {
type = list(string)
default = []
description = "If set, ONLY these countries are allowed (overrides blocked)"
}
# IP lists
variable "ip_allowlist" {
type = list(string)
default = []
description = "CIDR blocks to always allow"
}
variable "ip_blocklist" {
type = list(string)
default = []
description = "CIDR blocks to always block"
}
# Managed rule settings
variable "enable_aws_managed_rules" {
type = bool
default = true
}
variable "enable_known_bad_inputs" {
type = bool
default = true
}
variable "enable_sql_injection" {
type = bool
default = true
}
variable "enable_linux_protection" {
type = bool
default = true
}
variable "enable_php_protection" {
type = bool
default = false
}
variable "enable_wordpress_protection" {
type = bool
default = false
}
variable "enable_bot_control" {
type = bool
default = false
description = "Bot Control (additional cost ~$10/mo + $1/million requests)"
}
# Logging
variable "enable_logging" {
type = bool
default = true
}
variable "log_destination_arn" {
type = string
default = ""
description = "S3 bucket ARN, CloudWatch Log Group ARN, or Kinesis Firehose ARN"
}
variable "tags" {
type = map(string)
default = {}
}
################################################################################
# IP Sets
################################################################################
resource "aws_wafv2_ip_set" "allowlist" {
count = length(var.ip_allowlist) > 0 ? 1 : 0
name = "${var.name}-allowlist"
description = "Allowed IP addresses"
scope = "REGIONAL"
ip_address_version = "IPV4"
addresses = var.ip_allowlist
tags = merge(var.tags, { Name = "${var.name}-allowlist" })
}
resource "aws_wafv2_ip_set" "blocklist" {
count = length(var.ip_blocklist) > 0 ? 1 : 0
name = "${var.name}-blocklist"
description = "Blocked IP addresses"
scope = "REGIONAL"
ip_address_version = "IPV4"
addresses = var.ip_blocklist
tags = merge(var.tags, { Name = "${var.name}-blocklist" })
}
################################################################################
# Web ACL
################################################################################
resource "aws_wafv2_web_acl" "main" {
name = var.name
description = var.description
scope = "REGIONAL"
default_action {
allow {}
}
# Rule 1: IP Allowlist (highest priority - allow first)
dynamic "rule" {
for_each = length(var.ip_allowlist) > 0 ? [1] : []
content {
name = "AllowlistedIPs"
priority = 0
override_action {
none {}
}
statement {
ip_set_reference_statement {
arn = aws_wafv2_ip_set.allowlist[0].arn
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "${var.name}-allowlist"
sampled_requests_enabled = true
}
}
}
# Rule 2: IP Blocklist
dynamic "rule" {
for_each = length(var.ip_blocklist) > 0 ? [1] : []
content {
name = "BlocklistedIPs"
priority = 1
action {
block {}
}
statement {
ip_set_reference_statement {
arn = aws_wafv2_ip_set.blocklist[0].arn
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "${var.name}-blocklist"
sampled_requests_enabled = true
}
}
}
# Rule 3: Geo blocking
dynamic "rule" {
for_each = length(var.blocked_countries) > 0 ? [1] : []
content {
name = "GeoBlock"
priority = 2
action {
block {}
}
statement {
geo_match_statement {
country_codes = var.blocked_countries
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "${var.name}-geoblock"
sampled_requests_enabled = true
}
}
}
# Rule 4: Geo allow (only specific countries)
dynamic "rule" {
for_each = length(var.allowed_countries) > 0 ? [1] : []
content {
name = "GeoAllow"
priority = 3
action {
block {}
}
statement {
not_statement {
statement {
geo_match_statement {
country_codes = var.allowed_countries
}
}
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "${var.name}-geoallow"
sampled_requests_enabled = true
}
}
}
# Rule 5: Rate limiting
rule {
name = "RateLimit"
priority = 10
action {
dynamic "block" {
for_each = var.rate_limit_action == "block" ? [1] : []
content {}
}
dynamic "count" {
for_each = var.rate_limit_action == "count" ? [1] : []
content {}
}
}
statement {
rate_based_statement {
limit = var.rate_limit
aggregate_key_type = "IP"
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "${var.name}-ratelimit"
sampled_requests_enabled = true
}
}
# Rule 6: AWS Managed Rules - Common Rule Set
dynamic "rule" {
for_each = var.enable_aws_managed_rules ? [1] : []
content {
name = "AWSManagedRulesCommonRuleSet"
priority = 20
override_action {
none {}
}
statement {
managed_rule_group_statement {
name = "AWSManagedRulesCommonRuleSet"
vendor_name = "AWS"
# Exclude rules that may cause false positives
rule_action_override {
name = "SizeRestrictions_BODY"
action_to_use {
count {}
}
}
rule_action_override {
name = "GenericRFI_BODY"
action_to_use {
count {}
}
}
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "${var.name}-common"
sampled_requests_enabled = true
}
}
}
# Rule 7: Known Bad Inputs
dynamic "rule" {
for_each = var.enable_known_bad_inputs ? [1] : []
content {
name = "AWSManagedRulesKnownBadInputsRuleSet"
priority = 21
override_action {
none {}
}
statement {
managed_rule_group_statement {
name = "AWSManagedRulesKnownBadInputsRuleSet"
vendor_name = "AWS"
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "${var.name}-badinputs"
sampled_requests_enabled = true
}
}
}
# Rule 8: SQL Injection
dynamic "rule" {
for_each = var.enable_sql_injection ? [1] : []
content {
name = "AWSManagedRulesSQLiRuleSet"
priority = 22
override_action {
none {}
}
statement {
managed_rule_group_statement {
name = "AWSManagedRulesSQLiRuleSet"
vendor_name = "AWS"
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "${var.name}-sqli"
sampled_requests_enabled = true
}
}
}
# Rule 9: Linux Protection
dynamic "rule" {
for_each = var.enable_linux_protection ? [1] : []
content {
name = "AWSManagedRulesLinuxRuleSet"
priority = 23
override_action {
none {}
}
statement {
managed_rule_group_statement {
name = "AWSManagedRulesLinuxRuleSet"
vendor_name = "AWS"
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "${var.name}-linux"
sampled_requests_enabled = true
}
}
}
# Rule 10: PHP Protection
dynamic "rule" {
for_each = var.enable_php_protection ? [1] : []
content {
name = "AWSManagedRulesPHPRuleSet"
priority = 24
override_action {
none {}
}
statement {
managed_rule_group_statement {
name = "AWSManagedRulesPHPRuleSet"
vendor_name = "AWS"
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "${var.name}-php"
sampled_requests_enabled = true
}
}
}
# Rule 11: WordPress Protection
dynamic "rule" {
for_each = var.enable_wordpress_protection ? [1] : []
content {
name = "AWSManagedRulesWordPressRuleSet"
priority = 25
override_action {
none {}
}
statement {
managed_rule_group_statement {
name = "AWSManagedRulesWordPressRuleSet"
vendor_name = "AWS"
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "${var.name}-wordpress"
sampled_requests_enabled = true
}
}
}
# Rule 12: Bot Control (costs extra)
dynamic "rule" {
for_each = var.enable_bot_control ? [1] : []
content {
name = "AWSManagedRulesBotControlRuleSet"
priority = 30
override_action {
none {}
}
statement {
managed_rule_group_statement {
name = "AWSManagedRulesBotControlRuleSet"
vendor_name = "AWS"
managed_rule_group_configs {
aws_managed_rules_bot_control_rule_set {
inspection_level = "COMMON"
}
}
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "${var.name}-botcontrol"
sampled_requests_enabled = true
}
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = var.name
sampled_requests_enabled = true
}
tags = merge(var.tags, { Name = var.name })
}
################################################################################
# Logging
################################################################################
resource "aws_wafv2_web_acl_logging_configuration" "main" {
count = var.enable_logging && var.log_destination_arn != "" ? 1 : 0
log_destination_configs = [var.log_destination_arn]
resource_arn = aws_wafv2_web_acl.main.arn
logging_filter {
default_behavior = "DROP"
filter {
behavior = "KEEP"
requirement = "MEETS_ANY"
condition {
action_condition {
action = "BLOCK"
}
}
condition {
action_condition {
action = "COUNT"
}
}
}
}
}
################################################################################
# Outputs
################################################################################
output "web_acl_arn" {
value = aws_wafv2_web_acl.main.arn
description = "ARN of the WAF Web ACL - use this with ALB"
}
output "web_acl_id" {
value = aws_wafv2_web_acl.main.id
}
output "web_acl_capacity" {
value = aws_wafv2_web_acl.main.capacity
description = "WCU capacity used (max 1500 for regional)"
}