mirror of
https://github.com/ghndrx/terraform-foundation.git
synced 2026-02-10 06:45:06 +00:00
feat: Terraform Foundation - AWS Landing Zone
Enterprise-grade multi-tenant AWS cloud foundation. Modules: - GitHub OIDC for keyless CI/CD authentication - IAM account settings and security baseline - AWS Config Rules for compliance - ABAC (Attribute-Based Access Control) - SCPs (Service Control Policies) Features: - Multi-account architecture - Cost optimization patterns - Security best practices - Comprehensive documentation Tech: Terraform, AWS Organizations, IAM Identity Center
This commit is contained in:
43
terraform/modules/account-baseline/README.md
Normal file
43
terraform/modules/account-baseline/README.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# account-baseline
|
||||
|
||||
Terraform module for AWS landing zone pattern.
|
||||
|
||||
Apply baseline security configuration to AWS accounts in a landing zone.
|
||||
|
||||
## Planned Features
|
||||
|
||||
- [ ] CloudTrail configuration (or org trail delegation)
|
||||
- [ ] AWS Config (or org aggregator delegation)
|
||||
- [ ] GuardDuty member enrollment
|
||||
- [ ] Security Hub member enrollment
|
||||
- [ ] IAM password policy
|
||||
- [ ] Standard IAM roles (admin, readonly, billing)
|
||||
- [ ] EBS default encryption
|
||||
- [ ] S3 public access block
|
||||
|
||||
## Planned Usage
|
||||
|
||||
```hcl
|
||||
module "baseline" {
|
||||
source = "../modules/account-baseline"
|
||||
|
||||
account_name = "workload-prod"
|
||||
|
||||
# Delegate to org-level services
|
||||
enable_cloudtrail = false
|
||||
enable_config = false
|
||||
|
||||
# Enroll in delegated admin services
|
||||
enable_guardduty = true
|
||||
enable_securityhub = true
|
||||
|
||||
tags = local.tags
|
||||
}
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
| Name | Version |
|
||||
|------|---------|
|
||||
| terraform | >= 1.5.0 |
|
||||
| aws | >= 5.0 |
|
||||
314
terraform/modules/account-baseline/main.tf
Normal file
314
terraform/modules/account-baseline/main.tf
Normal file
@@ -0,0 +1,314 @@
|
||||
################################################################################
|
||||
# Account Baseline Module
|
||||
#
|
||||
# Applies baseline security configuration to AWS accounts:
|
||||
# - EBS default encryption
|
||||
# - S3 account public access block
|
||||
# - IAM account password policy
|
||||
# - IAM Access Analyzer
|
||||
# - Security Hub enrollment (optional)
|
||||
# - GuardDuty enrollment (optional)
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.5.0"
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data "aws_caller_identity" "current" {}
|
||||
data "aws_region" "current" {}
|
||||
|
||||
locals {
|
||||
account_id = data.aws_caller_identity.current.account_id
|
||||
region = data.aws_region.current.id
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# EBS Default Encryption
|
||||
################################################################################
|
||||
|
||||
resource "aws_ebs_encryption_by_default" "this" {
|
||||
count = var.enable_ebs_encryption ? 1 : 0
|
||||
enabled = true
|
||||
}
|
||||
|
||||
resource "aws_ebs_default_kms_key" "this" {
|
||||
count = var.enable_ebs_encryption && var.ebs_kms_key_arn != null ? 1 : 0
|
||||
key_arn = var.ebs_kms_key_arn
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# S3 Account Public Access Block
|
||||
################################################################################
|
||||
|
||||
resource "aws_s3_account_public_access_block" "this" {
|
||||
count = var.enable_s3_block_public ? 1 : 0
|
||||
|
||||
block_public_acls = true
|
||||
block_public_policy = true
|
||||
ignore_public_acls = true
|
||||
restrict_public_buckets = true
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# IAM Password Policy
|
||||
################################################################################
|
||||
|
||||
resource "aws_iam_account_password_policy" "this" {
|
||||
count = var.enable_password_policy ? 1 : 0
|
||||
|
||||
minimum_password_length = var.password_policy.minimum_length
|
||||
require_lowercase_characters = var.password_policy.require_lowercase
|
||||
require_numbers = var.password_policy.require_numbers
|
||||
require_uppercase_characters = var.password_policy.require_uppercase
|
||||
require_symbols = var.password_policy.require_symbols
|
||||
allow_users_to_change_password = var.password_policy.allow_users_to_change
|
||||
max_password_age = var.password_policy.max_age_days
|
||||
password_reuse_prevention = var.password_policy.reuse_prevention_count
|
||||
hard_expiry = var.password_policy.hard_expiry
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# IAM Access Analyzer
|
||||
################################################################################
|
||||
|
||||
resource "aws_accessanalyzer_analyzer" "this" {
|
||||
count = var.enable_access_analyzer ? 1 : 0
|
||||
|
||||
analyzer_name = "${var.name}-access-analyzer"
|
||||
type = var.access_analyzer_type
|
||||
|
||||
tags = merge(var.tags, {
|
||||
Name = "${var.name}-access-analyzer"
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Security Hub
|
||||
################################################################################
|
||||
|
||||
resource "aws_securityhub_account" "this" {
|
||||
count = var.enable_securityhub ? 1 : 0
|
||||
|
||||
enable_default_standards = var.securityhub_enable_default_standards
|
||||
auto_enable_controls = var.securityhub_auto_enable_controls
|
||||
control_finding_generator = "SECURITY_CONTROL"
|
||||
}
|
||||
|
||||
resource "aws_securityhub_standards_subscription" "this" {
|
||||
for_each = var.enable_securityhub ? toset(var.securityhub_standards) : []
|
||||
|
||||
standards_arn = each.value
|
||||
|
||||
depends_on = [aws_securityhub_account.this]
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# GuardDuty
|
||||
################################################################################
|
||||
|
||||
resource "aws_guardduty_detector" "this" {
|
||||
count = var.enable_guardduty ? 1 : 0
|
||||
|
||||
enable = true
|
||||
finding_publishing_frequency = var.guardduty_finding_frequency
|
||||
|
||||
datasources {
|
||||
s3_logs {
|
||||
enable = true
|
||||
}
|
||||
kubernetes {
|
||||
audit_logs {
|
||||
enable = var.guardduty_kubernetes_audit
|
||||
}
|
||||
}
|
||||
malware_protection {
|
||||
scan_ec2_instance_with_findings {
|
||||
ebs_volumes {
|
||||
enable = var.guardduty_malware_protection
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tags = merge(var.tags, {
|
||||
Name = "${var.name}-guardduty"
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# AWS Config
|
||||
################################################################################
|
||||
|
||||
resource "aws_config_configuration_recorder" "this" {
|
||||
count = var.enable_config ? 1 : 0
|
||||
|
||||
name = "${var.name}-config-recorder"
|
||||
role_arn = aws_iam_role.config[0].arn
|
||||
|
||||
recording_group {
|
||||
all_supported = true
|
||||
include_global_resource_types = var.config_include_global_resources
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_config_delivery_channel" "this" {
|
||||
count = var.enable_config ? 1 : 0
|
||||
|
||||
name = "${var.name}-config-delivery"
|
||||
s3_bucket_name = var.config_s3_bucket
|
||||
s3_key_prefix = var.config_s3_prefix
|
||||
sns_topic_arn = var.config_sns_topic_arn
|
||||
|
||||
snapshot_delivery_properties {
|
||||
delivery_frequency = var.config_snapshot_frequency
|
||||
}
|
||||
|
||||
depends_on = [aws_config_configuration_recorder.this]
|
||||
}
|
||||
|
||||
resource "aws_config_configuration_recorder_status" "this" {
|
||||
count = var.enable_config ? 1 : 0
|
||||
|
||||
name = aws_config_configuration_recorder.this[0].name
|
||||
is_enabled = true
|
||||
|
||||
depends_on = [aws_config_delivery_channel.this]
|
||||
}
|
||||
|
||||
resource "aws_iam_role" "config" {
|
||||
count = var.enable_config ? 1 : 0
|
||||
|
||||
name = "${var.name}-config-role"
|
||||
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Action = "sts:AssumeRole"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
Service = "config.amazonaws.com"
|
||||
}
|
||||
}]
|
||||
})
|
||||
|
||||
tags = merge(var.tags, {
|
||||
Name = "${var.name}-config-role"
|
||||
})
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy_attachment" "config" {
|
||||
count = var.enable_config ? 1 : 0
|
||||
|
||||
role = aws_iam_role.config[0].name
|
||||
policy_arn = "arn:aws:iam::aws:policy/service-role/AWS_ConfigRole"
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "config_s3" {
|
||||
count = var.enable_config ? 1 : 0
|
||||
|
||||
name = "config-s3-access"
|
||||
role = aws_iam_role.config[0].id
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"s3:PutObject",
|
||||
"s3:PutObjectAcl"
|
||||
]
|
||||
Resource = "arn:aws:s3:::${var.config_s3_bucket}/${var.config_s3_prefix}/*"
|
||||
Condition = {
|
||||
StringEquals = {
|
||||
"s3:x-amz-acl" = "bucket-owner-full-control"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
Effect = "Allow"
|
||||
Action = "s3:GetBucketAcl"
|
||||
Resource = "arn:aws:s3:::${var.config_s3_bucket}"
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Standard IAM Roles
|
||||
################################################################################
|
||||
|
||||
resource "aws_iam_role" "admin" {
|
||||
count = var.create_admin_role ? 1 : 0
|
||||
|
||||
name = "${var.name}-admin"
|
||||
path = var.iam_role_path
|
||||
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Action = "sts:AssumeRole"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
AWS = var.trusted_admin_principals
|
||||
}
|
||||
Condition = var.require_mfa ? {
|
||||
Bool = {
|
||||
"aws:MultiFactorAuthPresent" = "true"
|
||||
}
|
||||
} : {}
|
||||
}]
|
||||
})
|
||||
|
||||
max_session_duration = var.admin_session_duration
|
||||
|
||||
tags = merge(var.tags, {
|
||||
Name = "${var.name}-admin"
|
||||
Role = "admin"
|
||||
})
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy_attachment" "admin" {
|
||||
count = var.create_admin_role ? 1 : 0
|
||||
|
||||
role = aws_iam_role.admin[0].name
|
||||
policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess"
|
||||
}
|
||||
|
||||
resource "aws_iam_role" "readonly" {
|
||||
count = var.create_readonly_role ? 1 : 0
|
||||
|
||||
name = "${var.name}-readonly"
|
||||
path = var.iam_role_path
|
||||
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Action = "sts:AssumeRole"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
AWS = var.trusted_readonly_principals
|
||||
}
|
||||
}]
|
||||
})
|
||||
|
||||
max_session_duration = var.readonly_session_duration
|
||||
|
||||
tags = merge(var.tags, {
|
||||
Name = "${var.name}-readonly"
|
||||
Role = "readonly"
|
||||
})
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy_attachment" "readonly" {
|
||||
count = var.create_readonly_role ? 1 : 0
|
||||
|
||||
role = aws_iam_role.readonly[0].name
|
||||
policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess"
|
||||
}
|
||||
56
terraform/modules/account-baseline/outputs.tf
Normal file
56
terraform/modules/account-baseline/outputs.tf
Normal file
@@ -0,0 +1,56 @@
|
||||
################################################################################
|
||||
# Account Baseline - Outputs
|
||||
################################################################################
|
||||
|
||||
output "ebs_encryption_enabled" {
|
||||
value = var.enable_ebs_encryption
|
||||
description = "Whether EBS encryption is enabled"
|
||||
}
|
||||
|
||||
output "s3_block_public_enabled" {
|
||||
value = var.enable_s3_block_public
|
||||
description = "Whether S3 public block is enabled"
|
||||
}
|
||||
|
||||
output "access_analyzer_arn" {
|
||||
value = try(aws_accessanalyzer_analyzer.this[0].arn, null)
|
||||
description = "Access Analyzer ARN"
|
||||
}
|
||||
|
||||
output "securityhub_enabled" {
|
||||
value = var.enable_securityhub
|
||||
description = "Whether Security Hub is enabled"
|
||||
}
|
||||
|
||||
output "guardduty_detector_id" {
|
||||
value = try(aws_guardduty_detector.this[0].id, null)
|
||||
description = "GuardDuty detector ID"
|
||||
}
|
||||
|
||||
output "config_recorder_id" {
|
||||
value = try(aws_config_configuration_recorder.this[0].id, null)
|
||||
description = "Config recorder ID"
|
||||
}
|
||||
|
||||
output "admin_role_arn" {
|
||||
value = try(aws_iam_role.admin[0].arn, null)
|
||||
description = "Admin IAM role ARN"
|
||||
}
|
||||
|
||||
output "readonly_role_arn" {
|
||||
value = try(aws_iam_role.readonly[0].arn, null)
|
||||
description = "Readonly IAM role ARN"
|
||||
}
|
||||
|
||||
output "baseline_status" {
|
||||
value = {
|
||||
ebs_encryption = var.enable_ebs_encryption
|
||||
s3_block_public = var.enable_s3_block_public
|
||||
password_policy = var.enable_password_policy
|
||||
access_analyzer = var.enable_access_analyzer
|
||||
securityhub = var.enable_securityhub
|
||||
guardduty = var.enable_guardduty
|
||||
config = var.enable_config
|
||||
}
|
||||
description = "Summary of baseline status"
|
||||
}
|
||||
206
terraform/modules/account-baseline/variables.tf
Normal file
206
terraform/modules/account-baseline/variables.tf
Normal file
@@ -0,0 +1,206 @@
|
||||
################################################################################
|
||||
# Account Baseline - Input Variables
|
||||
################################################################################
|
||||
|
||||
variable "name" {
|
||||
type = string
|
||||
description = "Name prefix for resources"
|
||||
}
|
||||
|
||||
# EBS Encryption
|
||||
variable "enable_ebs_encryption" {
|
||||
type = bool
|
||||
default = true
|
||||
description = "Enable EBS encryption by default"
|
||||
}
|
||||
|
||||
variable "ebs_kms_key_arn" {
|
||||
type = string
|
||||
default = null
|
||||
description = "KMS key ARN for EBS encryption (null = AWS managed)"
|
||||
}
|
||||
|
||||
# S3 Public Access
|
||||
variable "enable_s3_block_public" {
|
||||
type = bool
|
||||
default = true
|
||||
description = "Block public access to S3 at account level"
|
||||
}
|
||||
|
||||
# Password Policy
|
||||
variable "enable_password_policy" {
|
||||
type = bool
|
||||
default = true
|
||||
description = "Configure IAM password policy"
|
||||
}
|
||||
|
||||
variable "password_policy" {
|
||||
type = object({
|
||||
minimum_length = optional(number, 14)
|
||||
require_lowercase = optional(bool, true)
|
||||
require_uppercase = optional(bool, true)
|
||||
require_numbers = optional(bool, true)
|
||||
require_symbols = optional(bool, true)
|
||||
allow_users_to_change = optional(bool, true)
|
||||
max_age_days = optional(number, 90)
|
||||
reuse_prevention_count = optional(number, 24)
|
||||
hard_expiry = optional(bool, false)
|
||||
})
|
||||
default = {}
|
||||
description = "IAM password policy settings"
|
||||
}
|
||||
|
||||
# Access Analyzer
|
||||
variable "enable_access_analyzer" {
|
||||
type = bool
|
||||
default = true
|
||||
description = "Enable IAM Access Analyzer"
|
||||
}
|
||||
|
||||
variable "access_analyzer_type" {
|
||||
type = string
|
||||
default = "ACCOUNT"
|
||||
description = "Access Analyzer type (ACCOUNT or ORGANIZATION)"
|
||||
}
|
||||
|
||||
# Security Hub
|
||||
variable "enable_securityhub" {
|
||||
type = bool
|
||||
default = false
|
||||
description = "Enable Security Hub (set false if using delegated admin)"
|
||||
}
|
||||
|
||||
variable "securityhub_enable_default_standards" {
|
||||
type = bool
|
||||
default = false
|
||||
description = "Enable default Security Hub standards"
|
||||
}
|
||||
|
||||
variable "securityhub_auto_enable_controls" {
|
||||
type = bool
|
||||
default = true
|
||||
description = "Auto-enable new controls"
|
||||
}
|
||||
|
||||
variable "securityhub_standards" {
|
||||
type = list(string)
|
||||
default = []
|
||||
description = "Security Hub standard ARNs to enable"
|
||||
}
|
||||
|
||||
# GuardDuty
|
||||
variable "enable_guardduty" {
|
||||
type = bool
|
||||
default = false
|
||||
description = "Enable GuardDuty (set false if using delegated admin)"
|
||||
}
|
||||
|
||||
variable "guardduty_finding_frequency" {
|
||||
type = string
|
||||
default = "FIFTEEN_MINUTES"
|
||||
description = "GuardDuty finding publishing frequency"
|
||||
}
|
||||
|
||||
variable "guardduty_kubernetes_audit" {
|
||||
type = bool
|
||||
default = true
|
||||
description = "Enable GuardDuty Kubernetes audit logs"
|
||||
}
|
||||
|
||||
variable "guardduty_malware_protection" {
|
||||
type = bool
|
||||
default = true
|
||||
description = "Enable GuardDuty malware protection"
|
||||
}
|
||||
|
||||
# AWS Config
|
||||
variable "enable_config" {
|
||||
type = bool
|
||||
default = false
|
||||
description = "Enable AWS Config (set false if using org aggregator)"
|
||||
}
|
||||
|
||||
variable "config_s3_bucket" {
|
||||
type = string
|
||||
default = ""
|
||||
description = "S3 bucket for Config recordings"
|
||||
}
|
||||
|
||||
variable "config_s3_prefix" {
|
||||
type = string
|
||||
default = "config"
|
||||
description = "S3 key prefix for Config recordings"
|
||||
}
|
||||
|
||||
variable "config_sns_topic_arn" {
|
||||
type = string
|
||||
default = null
|
||||
description = "SNS topic for Config notifications"
|
||||
}
|
||||
|
||||
variable "config_snapshot_frequency" {
|
||||
type = string
|
||||
default = "TwentyFour_Hours"
|
||||
description = "Config snapshot delivery frequency"
|
||||
}
|
||||
|
||||
variable "config_include_global_resources" {
|
||||
type = bool
|
||||
default = true
|
||||
description = "Include global resources in Config"
|
||||
}
|
||||
|
||||
# IAM Roles
|
||||
variable "create_admin_role" {
|
||||
type = bool
|
||||
default = false
|
||||
description = "Create admin IAM role"
|
||||
}
|
||||
|
||||
variable "create_readonly_role" {
|
||||
type = bool
|
||||
default = false
|
||||
description = "Create readonly IAM role"
|
||||
}
|
||||
|
||||
variable "iam_role_path" {
|
||||
type = string
|
||||
default = "/"
|
||||
description = "IAM role path"
|
||||
}
|
||||
|
||||
variable "trusted_admin_principals" {
|
||||
type = list(string)
|
||||
default = []
|
||||
description = "ARNs allowed to assume admin role"
|
||||
}
|
||||
|
||||
variable "trusted_readonly_principals" {
|
||||
type = list(string)
|
||||
default = []
|
||||
description = "ARNs allowed to assume readonly role"
|
||||
}
|
||||
|
||||
variable "require_mfa" {
|
||||
type = bool
|
||||
default = true
|
||||
description = "Require MFA for admin role assumption"
|
||||
}
|
||||
|
||||
variable "admin_session_duration" {
|
||||
type = number
|
||||
default = 3600
|
||||
description = "Admin role session duration in seconds"
|
||||
}
|
||||
|
||||
variable "readonly_session_duration" {
|
||||
type = number
|
||||
default = 3600
|
||||
description = "Readonly role session duration in seconds"
|
||||
}
|
||||
|
||||
variable "tags" {
|
||||
type = map(string)
|
||||
default = {}
|
||||
description = "Tags to apply to resources"
|
||||
}
|
||||
49
terraform/modules/acm-certificate/README.md
Normal file
49
terraform/modules/acm-certificate/README.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# acm-certificate
|
||||
|
||||
ACM Certificate Module
|
||||
|
||||
## Usage
|
||||
|
||||
```hcl
|
||||
module "acm_certificate" {
|
||||
source = "../modules/acm-certificate"
|
||||
|
||||
# Required variables
|
||||
domain_name = ""
|
||||
|
||||
# Optional: see variables.tf for all options
|
||||
}
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
| Name | Version |
|
||||
|------|---------|
|
||||
| terraform | >= 1.5.0 |
|
||||
| aws | >= 5.0 |
|
||||
|
||||
## Inputs
|
||||
|
||||
| Name | Description | Type | Required |
|
||||
|------|-------------|------|----------|
|
||||
| domain_name | Primary domain name for the certificate | `string` | yes |
|
||||
| subject_alternative_names | Additional domain names (SANs) for the certificate | `list(string)` | no |
|
||||
| zone_id | Route53 zone ID for DNS validation (null for email validatio... | `string` | no |
|
||||
| validation_method | Validation method: DNS or EMAIL | `string` | no |
|
||||
| wait_for_validation | Wait for certificate validation to complete | `bool` | no |
|
||||
| validation_timeout | Timeout for certificate validation | `string` | no |
|
||||
| tags | | `map(string)` | no |
|
||||
|
||||
## Outputs
|
||||
|
||||
| Name | Description |
|
||||
|------|-------------|
|
||||
| certificate_arn | ARN of the certificate |
|
||||
| certificate_domain_name | Primary domain name |
|
||||
| certificate_status | Certificate status |
|
||||
| validation_records | |
|
||||
| validated_certificate_arn | ARN of the validated certificate |
|
||||
|
||||
## License
|
||||
|
||||
Apache 2.0 - See LICENSE for details.
|
||||
163
terraform/modules/acm-certificate/main.tf
Normal file
163
terraform/modules/acm-certificate/main.tf
Normal file
@@ -0,0 +1,163 @@
|
||||
################################################################################
|
||||
# ACM Certificate Module
|
||||
#
|
||||
# SSL/TLS certificates with:
|
||||
# - DNS or email validation
|
||||
# - Automatic Route53 validation records
|
||||
# - SAN (Subject Alternative Names) support
|
||||
# - Wildcard certificates
|
||||
#
|
||||
# Usage:
|
||||
# module "cert" {
|
||||
# source = "../modules/acm-certificate"
|
||||
#
|
||||
# domain_name = "example.com"
|
||||
# zone_id = "Z1234567890"
|
||||
#
|
||||
# subject_alternative_names = [
|
||||
# "*.example.com",
|
||||
# "api.example.com"
|
||||
# ]
|
||||
# }
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "domain_name" {
|
||||
type = string
|
||||
description = "Primary domain name for the certificate"
|
||||
}
|
||||
|
||||
variable "subject_alternative_names" {
|
||||
type = list(string)
|
||||
default = []
|
||||
description = "Additional domain names (SANs) for the certificate"
|
||||
}
|
||||
|
||||
variable "zone_id" {
|
||||
type = string
|
||||
default = null
|
||||
description = "Route53 zone ID for DNS validation (null for email validation)"
|
||||
}
|
||||
|
||||
variable "validation_method" {
|
||||
type = string
|
||||
default = "DNS"
|
||||
description = "Validation method: DNS or EMAIL"
|
||||
|
||||
validation {
|
||||
condition = contains(["DNS", "EMAIL"], var.validation_method)
|
||||
error_message = "Must be DNS or EMAIL"
|
||||
}
|
||||
}
|
||||
|
||||
variable "wait_for_validation" {
|
||||
type = bool
|
||||
default = true
|
||||
description = "Wait for certificate validation to complete"
|
||||
}
|
||||
|
||||
variable "validation_timeout" {
|
||||
type = string
|
||||
default = "45m"
|
||||
description = "Timeout for certificate validation"
|
||||
}
|
||||
|
||||
variable "tags" {
|
||||
type = map(string)
|
||||
default = {}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# ACM Certificate
|
||||
################################################################################
|
||||
|
||||
resource "aws_acm_certificate" "main" {
|
||||
domain_name = var.domain_name
|
||||
subject_alternative_names = var.subject_alternative_names
|
||||
validation_method = var.validation_method
|
||||
|
||||
lifecycle {
|
||||
create_before_destroy = true
|
||||
}
|
||||
|
||||
tags = merge(var.tags, { Name = var.domain_name })
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# DNS Validation Records
|
||||
################################################################################
|
||||
|
||||
resource "aws_route53_record" "validation" {
|
||||
for_each = var.validation_method == "DNS" && var.zone_id != null ? {
|
||||
for dvo in aws_acm_certificate.main.domain_validation_options : dvo.domain_name => {
|
||||
name = dvo.resource_record_name
|
||||
record = dvo.resource_record_value
|
||||
type = dvo.resource_record_type
|
||||
}
|
||||
} : {}
|
||||
|
||||
allow_overwrite = true
|
||||
name = each.value.name
|
||||
records = [each.value.record]
|
||||
ttl = 60
|
||||
type = each.value.type
|
||||
zone_id = var.zone_id
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Certificate Validation
|
||||
################################################################################
|
||||
|
||||
resource "aws_acm_certificate_validation" "main" {
|
||||
count = var.wait_for_validation ? 1 : 0
|
||||
|
||||
certificate_arn = aws_acm_certificate.main.arn
|
||||
validation_record_fqdns = var.validation_method == "DNS" && var.zone_id != null ? [for record in aws_route53_record.validation : record.fqdn] : null
|
||||
|
||||
timeouts {
|
||||
create = var.validation_timeout
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Outputs
|
||||
################################################################################
|
||||
|
||||
output "certificate_arn" {
|
||||
value = aws_acm_certificate.main.arn
|
||||
description = "ARN of the certificate"
|
||||
}
|
||||
|
||||
output "certificate_domain_name" {
|
||||
value = aws_acm_certificate.main.domain_name
|
||||
description = "Primary domain name"
|
||||
}
|
||||
|
||||
output "certificate_status" {
|
||||
value = aws_acm_certificate.main.status
|
||||
description = "Certificate status"
|
||||
}
|
||||
|
||||
output "validation_records" {
|
||||
value = var.validation_method == "DNS" ? {
|
||||
for dvo in aws_acm_certificate.main.domain_validation_options : dvo.domain_name => {
|
||||
name = dvo.resource_record_name
|
||||
type = dvo.resource_record_type
|
||||
value = dvo.resource_record_value
|
||||
}
|
||||
} : null
|
||||
description = "DNS validation records (if using DNS validation without auto Route53)"
|
||||
}
|
||||
|
||||
output "validated_certificate_arn" {
|
||||
value = var.wait_for_validation ? aws_acm_certificate_validation.main[0].certificate_arn : aws_acm_certificate.main.arn
|
||||
description = "ARN of the validated certificate"
|
||||
}
|
||||
68
terraform/modules/alb/README.md
Normal file
68
terraform/modules/alb/README.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# alb
|
||||
|
||||
Application Load Balancer Module
|
||||
|
||||
## Usage
|
||||
|
||||
```hcl
|
||||
module "alb" {
|
||||
source = "../modules/alb"
|
||||
|
||||
# Required variables
|
||||
name = ""
|
||||
vpc_id = ""
|
||||
subnet_ids = ""
|
||||
access_logs = ""
|
||||
target_groups = ""
|
||||
listener_rules = ""
|
||||
|
||||
# Optional: see variables.tf for all options
|
||||
}
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
| Name | Version |
|
||||
|------|---------|
|
||||
| terraform | >= 1.5.0 |
|
||||
| aws | >= 5.0 |
|
||||
|
||||
## Inputs
|
||||
|
||||
| Name | Description | Type | Required |
|
||||
|------|-------------|------|----------|
|
||||
| name | ALB name | `string` | yes |
|
||||
| vpc_id | VPC ID | `string` | yes |
|
||||
| subnet_ids | Subnet IDs (public for internet-facing, private for internal... | `list(string)` | yes |
|
||||
| internal | Internal ALB (no public IP) | `bool` | no |
|
||||
| certificate_arn | ACM certificate ARN for HTTPS | `string` | no |
|
||||
| additional_certificates | Additional certificate ARNs for SNI | `list(string)` | no |
|
||||
| ssl_policy | SSL policy for HTTPS listeners | `string` | no |
|
||||
| enable_deletion_protection | Prevent accidental deletion | `bool` | no |
|
||||
| enable_http2 | Enable HTTP/2 | `bool` | no |
|
||||
| idle_timeout | Idle timeout in seconds | `number` | no |
|
||||
| drop_invalid_header_fields | Drop requests with invalid headers | `bool` | no |
|
||||
| access_logs | | `object({` | yes |
|
||||
| target_groups | | `map(object({` | yes |
|
||||
| listener_rules | | `map(object({` | yes |
|
||||
| waf_arn | WAF Web ACL ARN to associate | `string` | no |
|
||||
|
||||
*...and 3 more variables. See `variables.tf` for complete list.*
|
||||
|
||||
## Outputs
|
||||
|
||||
| Name | Description |
|
||||
|------|-------------|
|
||||
| arn | ALB ARN |
|
||||
| arn_suffix | ALB ARN suffix (for CloudWatch metrics) |
|
||||
| dns_name | ALB DNS name |
|
||||
| zone_id | ALB hosted zone ID |
|
||||
| security_group_id | ALB security group ID |
|
||||
| target_group_arns | |
|
||||
| target_group_arn_suffixes | |
|
||||
| https_listener_arn | HTTPS listener ARN |
|
||||
| http_listener_arn | HTTP listener ARN |
|
||||
|
||||
## License
|
||||
|
||||
Apache 2.0 - See LICENSE for details.
|
||||
497
terraform/modules/alb/main.tf
Normal file
497
terraform/modules/alb/main.tf
Normal file
@@ -0,0 +1,497 @@
|
||||
################################################################################
|
||||
# Application Load Balancer Module
|
||||
#
|
||||
# Full-featured ALB with:
|
||||
# - HTTPS with ACM certificate
|
||||
# - HTTP to HTTPS redirect
|
||||
# - Access logging to S3
|
||||
# - WAF integration (optional)
|
||||
# - Multiple target groups
|
||||
# - Host/path-based routing
|
||||
# - Health checks
|
||||
#
|
||||
# Usage:
|
||||
# module "alb" {
|
||||
# source = "../modules/alb"
|
||||
#
|
||||
# name = "web-alb"
|
||||
# vpc_id = module.vpc.vpc_id
|
||||
# subnet_ids = module.vpc.public_subnet_ids
|
||||
#
|
||||
# certificate_arn = module.acm.certificate_arn
|
||||
#
|
||||
# target_groups = {
|
||||
# api = {
|
||||
# port = 8080
|
||||
# protocol = "HTTP"
|
||||
# target_type = "ip"
|
||||
# health_check_path = "/health"
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "name" {
|
||||
type = string
|
||||
description = "ALB name"
|
||||
}
|
||||
|
||||
variable "vpc_id" {
|
||||
type = string
|
||||
description = "VPC ID"
|
||||
}
|
||||
|
||||
variable "subnet_ids" {
|
||||
type = list(string)
|
||||
description = "Subnet IDs (public for internet-facing, private for internal)"
|
||||
}
|
||||
|
||||
variable "internal" {
|
||||
type = bool
|
||||
default = false
|
||||
description = "Internal ALB (no public IP)"
|
||||
}
|
||||
|
||||
variable "certificate_arn" {
|
||||
type = string
|
||||
default = ""
|
||||
description = "ACM certificate ARN for HTTPS"
|
||||
}
|
||||
|
||||
variable "additional_certificates" {
|
||||
type = list(string)
|
||||
default = []
|
||||
description = "Additional certificate ARNs for SNI"
|
||||
}
|
||||
|
||||
variable "ssl_policy" {
|
||||
type = string
|
||||
default = "ELBSecurityPolicy-TLS13-1-2-2021-06"
|
||||
description = "SSL policy for HTTPS listeners"
|
||||
}
|
||||
|
||||
variable "enable_deletion_protection" {
|
||||
type = bool
|
||||
default = true
|
||||
description = "Prevent accidental deletion"
|
||||
}
|
||||
|
||||
variable "enable_http2" {
|
||||
type = bool
|
||||
default = true
|
||||
description = "Enable HTTP/2"
|
||||
}
|
||||
|
||||
variable "idle_timeout" {
|
||||
type = number
|
||||
default = 60
|
||||
description = "Idle timeout in seconds"
|
||||
}
|
||||
|
||||
variable "drop_invalid_header_fields" {
|
||||
type = bool
|
||||
default = true
|
||||
description = "Drop requests with invalid headers"
|
||||
}
|
||||
|
||||
variable "access_logs" {
|
||||
type = object({
|
||||
enabled = bool
|
||||
bucket = string
|
||||
prefix = optional(string, "")
|
||||
})
|
||||
default = {
|
||||
enabled = false
|
||||
bucket = ""
|
||||
}
|
||||
description = "Access logging configuration"
|
||||
}
|
||||
|
||||
variable "target_groups" {
|
||||
type = map(object({
|
||||
port = number
|
||||
protocol = optional(string, "HTTP")
|
||||
target_type = optional(string, "ip")
|
||||
deregistration_delay = optional(number, 30)
|
||||
slow_start = optional(number, 0)
|
||||
|
||||
health_check_path = optional(string, "/")
|
||||
health_check_port = optional(string, "traffic-port")
|
||||
health_check_protocol = optional(string, "HTTP")
|
||||
health_check_interval = optional(number, 30)
|
||||
health_check_timeout = optional(number, 5)
|
||||
healthy_threshold = optional(number, 2)
|
||||
unhealthy_threshold = optional(number, 3)
|
||||
health_check_matcher = optional(string, "200-299")
|
||||
|
||||
stickiness_enabled = optional(bool, false)
|
||||
stickiness_duration = optional(number, 86400)
|
||||
}))
|
||||
default = {}
|
||||
description = "Target group configurations"
|
||||
}
|
||||
|
||||
variable "listener_rules" {
|
||||
type = map(object({
|
||||
priority = number
|
||||
target_group_key = string
|
||||
|
||||
# Conditions (at least one required)
|
||||
host_headers = optional(list(string), [])
|
||||
path_patterns = optional(list(string), [])
|
||||
http_headers = optional(map(list(string)), {})
|
||||
query_strings = optional(map(string), {})
|
||||
source_ips = optional(list(string), [])
|
||||
}))
|
||||
default = {}
|
||||
description = "HTTPS listener rules for routing"
|
||||
}
|
||||
|
||||
variable "waf_arn" {
|
||||
type = string
|
||||
default = ""
|
||||
description = "WAF Web ACL ARN to associate"
|
||||
}
|
||||
|
||||
variable "security_group_ids" {
|
||||
type = list(string)
|
||||
default = []
|
||||
description = "Additional security group IDs"
|
||||
}
|
||||
|
||||
variable "ingress_cidr_blocks" {
|
||||
type = list(string)
|
||||
default = ["0.0.0.0/0"]
|
||||
description = "CIDR blocks for ingress (HTTP/HTTPS)"
|
||||
}
|
||||
|
||||
variable "tags" {
|
||||
type = map(string)
|
||||
default = {}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Security Group
|
||||
################################################################################
|
||||
|
||||
resource "aws_security_group" "alb" {
|
||||
name = "${var.name}-alb"
|
||||
description = "ALB security group"
|
||||
vpc_id = var.vpc_id
|
||||
|
||||
ingress {
|
||||
description = "HTTPS"
|
||||
from_port = 443
|
||||
to_port = 443
|
||||
protocol = "tcp"
|
||||
cidr_blocks = var.ingress_cidr_blocks
|
||||
}
|
||||
|
||||
ingress {
|
||||
description = "HTTP (redirect)"
|
||||
from_port = 80
|
||||
to_port = 80
|
||||
protocol = "tcp"
|
||||
cidr_blocks = var.ingress_cidr_blocks
|
||||
}
|
||||
|
||||
egress {
|
||||
description = "All outbound"
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
tags = merge(var.tags, { Name = "${var.name}-alb" })
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Application Load Balancer
|
||||
################################################################################
|
||||
|
||||
resource "aws_lb" "main" {
|
||||
name = var.name
|
||||
internal = var.internal
|
||||
load_balancer_type = "application"
|
||||
security_groups = concat([aws_security_group.alb.id], var.security_group_ids)
|
||||
subnets = var.subnet_ids
|
||||
|
||||
enable_deletion_protection = var.enable_deletion_protection
|
||||
enable_http2 = var.enable_http2
|
||||
idle_timeout = var.idle_timeout
|
||||
drop_invalid_header_fields = var.drop_invalid_header_fields
|
||||
|
||||
dynamic "access_logs" {
|
||||
for_each = var.access_logs.enabled ? [1] : []
|
||||
content {
|
||||
bucket = var.access_logs.bucket
|
||||
prefix = var.access_logs.prefix
|
||||
enabled = true
|
||||
}
|
||||
}
|
||||
|
||||
tags = merge(var.tags, { Name = var.name })
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Target Groups
|
||||
################################################################################
|
||||
|
||||
resource "aws_lb_target_group" "main" {
|
||||
for_each = var.target_groups
|
||||
|
||||
name = "${var.name}-${each.key}"
|
||||
port = each.value.port
|
||||
protocol = each.value.protocol
|
||||
vpc_id = var.vpc_id
|
||||
target_type = each.value.target_type
|
||||
deregistration_delay = each.value.deregistration_delay
|
||||
slow_start = each.value.slow_start
|
||||
|
||||
health_check {
|
||||
enabled = true
|
||||
path = each.value.health_check_path
|
||||
port = each.value.health_check_port
|
||||
protocol = each.value.health_check_protocol
|
||||
interval = each.value.health_check_interval
|
||||
timeout = each.value.health_check_timeout
|
||||
healthy_threshold = each.value.healthy_threshold
|
||||
unhealthy_threshold = each.value.unhealthy_threshold
|
||||
matcher = each.value.health_check_matcher
|
||||
}
|
||||
|
||||
dynamic "stickiness" {
|
||||
for_each = each.value.stickiness_enabled ? [1] : []
|
||||
content {
|
||||
type = "lb_cookie"
|
||||
cookie_duration = each.value.stickiness_duration
|
||||
enabled = true
|
||||
}
|
||||
}
|
||||
|
||||
tags = merge(var.tags, { Name = "${var.name}-${each.key}" })
|
||||
|
||||
lifecycle {
|
||||
create_before_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# HTTPS Listener
|
||||
################################################################################
|
||||
|
||||
resource "aws_lb_listener" "https" {
|
||||
count = var.certificate_arn != "" ? 1 : 0
|
||||
|
||||
load_balancer_arn = aws_lb.main.arn
|
||||
port = 443
|
||||
protocol = "HTTPS"
|
||||
ssl_policy = var.ssl_policy
|
||||
certificate_arn = var.certificate_arn
|
||||
|
||||
default_action {
|
||||
type = length(var.target_groups) > 0 ? "forward" : "fixed-response"
|
||||
|
||||
dynamic "forward" {
|
||||
for_each = length(var.target_groups) > 0 ? [1] : []
|
||||
content {
|
||||
target_group {
|
||||
arn = aws_lb_target_group.main[keys(var.target_groups)[0]].arn
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dynamic "fixed_response" {
|
||||
for_each = length(var.target_groups) == 0 ? [1] : []
|
||||
content {
|
||||
content_type = "text/plain"
|
||||
message_body = "No backend configured"
|
||||
status_code = "503"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tags = merge(var.tags, { Name = "${var.name}-https" })
|
||||
}
|
||||
|
||||
# Additional certificates (SNI)
|
||||
resource "aws_lb_listener_certificate" "additional" {
|
||||
for_each = toset(var.additional_certificates)
|
||||
|
||||
listener_arn = aws_lb_listener.https[0].arn
|
||||
certificate_arn = each.value
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# HTTP Listener (Redirect to HTTPS)
|
||||
################################################################################
|
||||
|
||||
resource "aws_lb_listener" "http" {
|
||||
load_balancer_arn = aws_lb.main.arn
|
||||
port = 80
|
||||
protocol = "HTTP"
|
||||
|
||||
default_action {
|
||||
type = var.certificate_arn != "" ? "redirect" : "forward"
|
||||
|
||||
dynamic "redirect" {
|
||||
for_each = var.certificate_arn != "" ? [1] : []
|
||||
content {
|
||||
port = "443"
|
||||
protocol = "HTTPS"
|
||||
status_code = "HTTP_301"
|
||||
}
|
||||
}
|
||||
|
||||
dynamic "forward" {
|
||||
for_each = var.certificate_arn == "" && length(var.target_groups) > 0 ? [1] : []
|
||||
content {
|
||||
target_group {
|
||||
arn = aws_lb_target_group.main[keys(var.target_groups)[0]].arn
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tags = merge(var.tags, { Name = "${var.name}-http" })
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Listener Rules
|
||||
################################################################################
|
||||
|
||||
resource "aws_lb_listener_rule" "main" {
|
||||
for_each = var.certificate_arn != "" ? var.listener_rules : {}
|
||||
|
||||
listener_arn = aws_lb_listener.https[0].arn
|
||||
priority = each.value.priority
|
||||
|
||||
action {
|
||||
type = "forward"
|
||||
target_group_arn = aws_lb_target_group.main[each.value.target_group_key].arn
|
||||
}
|
||||
|
||||
# Host header condition
|
||||
dynamic "condition" {
|
||||
for_each = length(each.value.host_headers) > 0 ? [1] : []
|
||||
content {
|
||||
host_header {
|
||||
values = each.value.host_headers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Path pattern condition
|
||||
dynamic "condition" {
|
||||
for_each = length(each.value.path_patterns) > 0 ? [1] : []
|
||||
content {
|
||||
path_pattern {
|
||||
values = each.value.path_patterns
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# HTTP header conditions
|
||||
dynamic "condition" {
|
||||
for_each = each.value.http_headers
|
||||
content {
|
||||
http_header {
|
||||
http_header_name = condition.key
|
||||
values = condition.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Query string conditions
|
||||
dynamic "condition" {
|
||||
for_each = each.value.query_strings
|
||||
content {
|
||||
query_string {
|
||||
key = condition.key
|
||||
value = condition.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Source IP condition
|
||||
dynamic "condition" {
|
||||
for_each = length(each.value.source_ips) > 0 ? [1] : []
|
||||
content {
|
||||
source_ip {
|
||||
values = each.value.source_ips
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tags = merge(var.tags, { Name = "${var.name}-${each.key}" })
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# WAF Association
|
||||
################################################################################
|
||||
|
||||
resource "aws_wafv2_web_acl_association" "main" {
|
||||
count = var.waf_arn != "" ? 1 : 0
|
||||
|
||||
resource_arn = aws_lb.main.arn
|
||||
web_acl_arn = var.waf_arn
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Outputs
|
||||
################################################################################
|
||||
|
||||
output "arn" {
|
||||
value = aws_lb.main.arn
|
||||
description = "ALB ARN"
|
||||
}
|
||||
|
||||
output "arn_suffix" {
|
||||
value = aws_lb.main.arn_suffix
|
||||
description = "ALB ARN suffix (for CloudWatch metrics)"
|
||||
}
|
||||
|
||||
output "dns_name" {
|
||||
value = aws_lb.main.dns_name
|
||||
description = "ALB DNS name"
|
||||
}
|
||||
|
||||
output "zone_id" {
|
||||
value = aws_lb.main.zone_id
|
||||
description = "ALB hosted zone ID"
|
||||
}
|
||||
|
||||
output "security_group_id" {
|
||||
value = aws_security_group.alb.id
|
||||
description = "ALB security group ID"
|
||||
}
|
||||
|
||||
output "target_group_arns" {
|
||||
value = { for k, v in aws_lb_target_group.main : k => v.arn }
|
||||
description = "Target group ARNs"
|
||||
}
|
||||
|
||||
output "target_group_arn_suffixes" {
|
||||
value = { for k, v in aws_lb_target_group.main : k => v.arn_suffix }
|
||||
description = "Target group ARN suffixes"
|
||||
}
|
||||
|
||||
output "https_listener_arn" {
|
||||
value = length(aws_lb_listener.https) > 0 ? aws_lb_listener.https[0].arn : null
|
||||
description = "HTTPS listener ARN"
|
||||
}
|
||||
|
||||
output "http_listener_arn" {
|
||||
value = aws_lb_listener.http.arn
|
||||
description = "HTTP listener ARN"
|
||||
}
|
||||
50
terraform/modules/alerting/README.md
Normal file
50
terraform/modules/alerting/README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# alerting
|
||||
|
||||
Alerting Module
|
||||
|
||||
## Usage
|
||||
|
||||
```hcl
|
||||
module "alerting" {
|
||||
source = "../modules/alerting"
|
||||
|
||||
# Required variables
|
||||
name = ""
|
||||
|
||||
# Optional: see variables.tf for all options
|
||||
}
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
| Name | Version |
|
||||
|------|---------|
|
||||
| terraform | >= 1.5.0 |
|
||||
| aws | >= 5.0 |
|
||||
|
||||
## Inputs
|
||||
|
||||
| Name | Description | Type | Required |
|
||||
|------|-------------|------|----------|
|
||||
| name | Name prefix for alerting resources | `string` | yes |
|
||||
| email_endpoints | Email addresses to receive alerts | `list(string)` | no |
|
||||
| email_endpoints_critical | Email addresses for critical alerts only (uses email_endpoin... | `list(string)` | no |
|
||||
| slack_webhook_url | Slack webhook URL for notifications | `string` | no |
|
||||
| pagerduty_endpoint | PagerDuty Events API endpoint | `string` | no |
|
||||
| enable_aws_health_events | | `bool` | no |
|
||||
| enable_guardduty_events | | `bool` | no |
|
||||
| enable_securityhub_events | | `bool` | no |
|
||||
| tags | | `map(string)` | no |
|
||||
|
||||
## Outputs
|
||||
|
||||
| Name | Description |
|
||||
|------|-------------|
|
||||
| critical_topic_arn | SNS topic for critical alerts |
|
||||
| warning_topic_arn | SNS topic for warning alerts |
|
||||
| info_topic_arn | SNS topic for info alerts |
|
||||
| topics | |
|
||||
|
||||
## License
|
||||
|
||||
Apache 2.0 - See LICENSE for details.
|
||||
429
terraform/modules/alerting/main.tf
Normal file
429
terraform/modules/alerting/main.tf
Normal file
@@ -0,0 +1,429 @@
|
||||
################################################################################
|
||||
# Alerting Module
|
||||
#
|
||||
# Centralized alerting infrastructure:
|
||||
# - SNS topics by severity (critical, warning, info)
|
||||
# - Subscriptions (email, Slack, PagerDuty)
|
||||
# - CloudWatch composite alarms
|
||||
# - EventBridge rules for AWS events
|
||||
#
|
||||
# Usage:
|
||||
# module "alerting" {
|
||||
# source = "../modules/alerting"
|
||||
# name = "myproject-prod"
|
||||
#
|
||||
# email_endpoints = ["ops@example.com"]
|
||||
# slack_webhook_url = "https://hooks.slack.com/..."
|
||||
# }
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "name" {
|
||||
type = string
|
||||
description = "Name prefix for alerting resources"
|
||||
}
|
||||
|
||||
variable "email_endpoints" {
|
||||
type = list(string)
|
||||
default = []
|
||||
description = "Email addresses to receive alerts"
|
||||
}
|
||||
|
||||
variable "email_endpoints_critical" {
|
||||
type = list(string)
|
||||
default = []
|
||||
description = "Email addresses for critical alerts only (uses email_endpoints if empty)"
|
||||
}
|
||||
|
||||
variable "slack_webhook_url" {
|
||||
type = string
|
||||
default = ""
|
||||
description = "Slack webhook URL for notifications"
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "pagerduty_endpoint" {
|
||||
type = string
|
||||
default = ""
|
||||
description = "PagerDuty Events API endpoint"
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "enable_aws_health_events" {
|
||||
type = bool
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "enable_guardduty_events" {
|
||||
type = bool
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "enable_securityhub_events" {
|
||||
type = bool
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "tags" {
|
||||
type = map(string)
|
||||
default = {}
|
||||
}
|
||||
|
||||
data "aws_caller_identity" "current" {}
|
||||
data "aws_region" "current" {}
|
||||
|
||||
################################################################################
|
||||
# SNS Topics by Severity
|
||||
################################################################################
|
||||
|
||||
resource "aws_sns_topic" "critical" {
|
||||
name = "${var.name}-alerts-critical"
|
||||
kms_master_key_id = "alias/aws/sns"
|
||||
|
||||
tags = merge(var.tags, { Name = "${var.name}-critical", Severity = "critical" })
|
||||
}
|
||||
|
||||
resource "aws_sns_topic" "warning" {
|
||||
name = "${var.name}-alerts-warning"
|
||||
kms_master_key_id = "alias/aws/sns"
|
||||
|
||||
tags = merge(var.tags, { Name = "${var.name}-warning", Severity = "warning" })
|
||||
}
|
||||
|
||||
resource "aws_sns_topic" "info" {
|
||||
name = "${var.name}-alerts-info"
|
||||
kms_master_key_id = "alias/aws/sns"
|
||||
|
||||
tags = merge(var.tags, { Name = "${var.name}-info", Severity = "info" })
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# SNS Topic Policies
|
||||
################################################################################
|
||||
|
||||
data "aws_iam_policy_document" "sns_policy" {
|
||||
statement {
|
||||
sid = "AllowCloudWatchAlarms"
|
||||
effect = "Allow"
|
||||
|
||||
principals {
|
||||
type = "Service"
|
||||
identifiers = ["cloudwatch.amazonaws.com"]
|
||||
}
|
||||
|
||||
actions = ["sns:Publish"]
|
||||
resources = ["*"]
|
||||
|
||||
condition {
|
||||
test = "ArnLike"
|
||||
variable = "aws:SourceArn"
|
||||
values = ["arn:aws:cloudwatch:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:alarm:*"]
|
||||
}
|
||||
}
|
||||
|
||||
statement {
|
||||
sid = "AllowEventBridge"
|
||||
effect = "Allow"
|
||||
|
||||
principals {
|
||||
type = "Service"
|
||||
identifiers = ["events.amazonaws.com"]
|
||||
}
|
||||
|
||||
actions = ["sns:Publish"]
|
||||
resources = ["*"]
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_sns_topic_policy" "critical" {
|
||||
arn = aws_sns_topic.critical.arn
|
||||
policy = data.aws_iam_policy_document.sns_policy.json
|
||||
}
|
||||
|
||||
resource "aws_sns_topic_policy" "warning" {
|
||||
arn = aws_sns_topic.warning.arn
|
||||
policy = data.aws_iam_policy_document.sns_policy.json
|
||||
}
|
||||
|
||||
resource "aws_sns_topic_policy" "info" {
|
||||
arn = aws_sns_topic.info.arn
|
||||
policy = data.aws_iam_policy_document.sns_policy.json
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Email Subscriptions
|
||||
################################################################################
|
||||
|
||||
resource "aws_sns_topic_subscription" "critical_email" {
|
||||
for_each = toset(length(var.email_endpoints_critical) > 0 ? var.email_endpoints_critical : var.email_endpoints)
|
||||
|
||||
topic_arn = aws_sns_topic.critical.arn
|
||||
protocol = "email"
|
||||
endpoint = each.value
|
||||
}
|
||||
|
||||
resource "aws_sns_topic_subscription" "warning_email" {
|
||||
for_each = toset(var.email_endpoints)
|
||||
|
||||
topic_arn = aws_sns_topic.warning.arn
|
||||
protocol = "email"
|
||||
endpoint = each.value
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Slack Integration (via Lambda)
|
||||
################################################################################
|
||||
|
||||
data "archive_file" "slack_notifier" {
|
||||
count = var.slack_webhook_url != "" ? 1 : 0
|
||||
type = "zip"
|
||||
output_path = "${path.module}/slack_notifier.zip"
|
||||
|
||||
source {
|
||||
content = <<-PYTHON
|
||||
import json
|
||||
import urllib.request
|
||||
import os
|
||||
|
||||
def handler(event, context):
|
||||
webhook_url = os.environ['SLACK_WEBHOOK_URL']
|
||||
|
||||
for record in event.get('Records', []):
|
||||
message = json.loads(record['Sns']['Message'])
|
||||
|
||||
# Parse CloudWatch Alarm
|
||||
if 'AlarmName' in message:
|
||||
color = '#FF0000' if message['NewStateValue'] == 'ALARM' else '#36a64f'
|
||||
text = f"*{message['AlarmName']}*\n{message['AlarmDescription']}\n\nState: {message['NewStateValue']}\nReason: {message['NewStateReason']}"
|
||||
else:
|
||||
text = json.dumps(message, indent=2)
|
||||
color = '#FFA500'
|
||||
|
||||
payload = {
|
||||
'attachments': [{
|
||||
'color': color,
|
||||
'text': text,
|
||||
'footer': f"AWS | {message.get('Region', 'Unknown Region')}",
|
||||
}]
|
||||
}
|
||||
|
||||
req = urllib.request.Request(
|
||||
webhook_url,
|
||||
data=json.dumps(payload).encode('utf-8'),
|
||||
headers={'Content-Type': 'application/json'}
|
||||
)
|
||||
urllib.request.urlopen(req)
|
||||
|
||||
return {'statusCode': 200}
|
||||
PYTHON
|
||||
filename = "lambda_function.py"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_lambda_function" "slack_notifier" {
|
||||
count = var.slack_webhook_url != "" ? 1 : 0
|
||||
filename = data.archive_file.slack_notifier[0].output_path
|
||||
source_code_hash = data.archive_file.slack_notifier[0].output_base64sha256
|
||||
function_name = "${var.name}-slack-notifier"
|
||||
role = aws_iam_role.slack_notifier[0].arn
|
||||
handler = "lambda_function.handler"
|
||||
runtime = "python3.12"
|
||||
timeout = 30
|
||||
|
||||
environment {
|
||||
variables = {
|
||||
SLACK_WEBHOOK_URL = var.slack_webhook_url
|
||||
}
|
||||
}
|
||||
|
||||
tags = merge(var.tags, { Name = "${var.name}-slack-notifier" })
|
||||
}
|
||||
|
||||
resource "aws_iam_role" "slack_notifier" {
|
||||
count = var.slack_webhook_url != "" ? 1 : 0
|
||||
name = "${var.name}-slack-notifier"
|
||||
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = "sts:AssumeRole"
|
||||
Principal = { Service = "lambda.amazonaws.com" }
|
||||
}]
|
||||
})
|
||||
|
||||
tags = merge(var.tags, { Name = "${var.name}-slack-notifier" })
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy_attachment" "slack_notifier" {
|
||||
count = var.slack_webhook_url != "" ? 1 : 0
|
||||
role = aws_iam_role.slack_notifier[0].name
|
||||
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
|
||||
}
|
||||
|
||||
resource "aws_lambda_permission" "slack_critical" {
|
||||
count = var.slack_webhook_url != "" ? 1 : 0
|
||||
statement_id = "AllowSNSCritical"
|
||||
action = "lambda:InvokeFunction"
|
||||
function_name = aws_lambda_function.slack_notifier[0].function_name
|
||||
principal = "sns.amazonaws.com"
|
||||
source_arn = aws_sns_topic.critical.arn
|
||||
}
|
||||
|
||||
resource "aws_lambda_permission" "slack_warning" {
|
||||
count = var.slack_webhook_url != "" ? 1 : 0
|
||||
statement_id = "AllowSNSWarning"
|
||||
action = "lambda:InvokeFunction"
|
||||
function_name = aws_lambda_function.slack_notifier[0].function_name
|
||||
principal = "sns.amazonaws.com"
|
||||
source_arn = aws_sns_topic.warning.arn
|
||||
}
|
||||
|
||||
resource "aws_sns_topic_subscription" "slack_critical" {
|
||||
count = var.slack_webhook_url != "" ? 1 : 0
|
||||
topic_arn = aws_sns_topic.critical.arn
|
||||
protocol = "lambda"
|
||||
endpoint = aws_lambda_function.slack_notifier[0].arn
|
||||
}
|
||||
|
||||
resource "aws_sns_topic_subscription" "slack_warning" {
|
||||
count = var.slack_webhook_url != "" ? 1 : 0
|
||||
topic_arn = aws_sns_topic.warning.arn
|
||||
protocol = "lambda"
|
||||
endpoint = aws_lambda_function.slack_notifier[0].arn
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# EventBridge Rules - AWS Health Events
|
||||
################################################################################
|
||||
|
||||
resource "aws_cloudwatch_event_rule" "health" {
|
||||
count = var.enable_aws_health_events ? 1 : 0
|
||||
name = "${var.name}-health-events"
|
||||
description = "Capture AWS Health events"
|
||||
|
||||
event_pattern = jsonencode({
|
||||
source = ["aws.health"]
|
||||
detail-type = ["AWS Health Event"]
|
||||
})
|
||||
|
||||
tags = merge(var.tags, { Name = "${var.name}-health" })
|
||||
}
|
||||
|
||||
resource "aws_cloudwatch_event_target" "health" {
|
||||
count = var.enable_aws_health_events ? 1 : 0
|
||||
rule = aws_cloudwatch_event_rule.health[0].name
|
||||
target_id = "SendToSNS"
|
||||
arn = aws_sns_topic.warning.arn
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# EventBridge Rules - GuardDuty Findings
|
||||
################################################################################
|
||||
|
||||
resource "aws_cloudwatch_event_rule" "guardduty" {
|
||||
count = var.enable_guardduty_events ? 1 : 0
|
||||
name = "${var.name}-guardduty-findings"
|
||||
description = "Capture GuardDuty findings"
|
||||
|
||||
event_pattern = jsonencode({
|
||||
source = ["aws.guardduty"]
|
||||
detail-type = ["GuardDuty Finding"]
|
||||
detail = {
|
||||
severity = [{ numeric = [">=", 4] }] # Medium and above
|
||||
}
|
||||
})
|
||||
|
||||
tags = merge(var.tags, { Name = "${var.name}-guardduty" })
|
||||
}
|
||||
|
||||
resource "aws_cloudwatch_event_target" "guardduty_critical" {
|
||||
count = var.enable_guardduty_events ? 1 : 0
|
||||
rule = aws_cloudwatch_event_rule.guardduty[0].name
|
||||
target_id = "SendToSNSCritical"
|
||||
arn = aws_sns_topic.critical.arn
|
||||
|
||||
input_transformer {
|
||||
input_paths = {
|
||||
severity = "$.detail.severity"
|
||||
title = "$.detail.title"
|
||||
type = "$.detail.type"
|
||||
region = "$.region"
|
||||
}
|
||||
input_template = <<-EOF
|
||||
{
|
||||
"AlarmName": "GuardDuty Finding",
|
||||
"AlarmDescription": "<title>",
|
||||
"NewStateValue": "ALARM",
|
||||
"NewStateReason": "Type: <type>, Severity: <severity>",
|
||||
"Region": "<region>"
|
||||
}
|
||||
EOF
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# EventBridge Rules - Security Hub
|
||||
################################################################################
|
||||
|
||||
resource "aws_cloudwatch_event_rule" "securityhub" {
|
||||
count = var.enable_securityhub_events ? 1 : 0
|
||||
name = "${var.name}-securityhub-findings"
|
||||
description = "Capture Security Hub findings"
|
||||
|
||||
event_pattern = jsonencode({
|
||||
source = ["aws.securityhub"]
|
||||
detail-type = ["Security Hub Findings - Imported"]
|
||||
detail = {
|
||||
findings = {
|
||||
Severity = {
|
||||
Label = ["CRITICAL", "HIGH"]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
tags = merge(var.tags, { Name = "${var.name}-securityhub" })
|
||||
}
|
||||
|
||||
resource "aws_cloudwatch_event_target" "securityhub" {
|
||||
count = var.enable_securityhub_events ? 1 : 0
|
||||
rule = aws_cloudwatch_event_rule.securityhub[0].name
|
||||
target_id = "SendToSNSCritical"
|
||||
arn = aws_sns_topic.critical.arn
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Outputs
|
||||
################################################################################
|
||||
|
||||
output "critical_topic_arn" {
|
||||
value = aws_sns_topic.critical.arn
|
||||
description = "SNS topic for critical alerts"
|
||||
}
|
||||
|
||||
output "warning_topic_arn" {
|
||||
value = aws_sns_topic.warning.arn
|
||||
description = "SNS topic for warning alerts"
|
||||
}
|
||||
|
||||
output "info_topic_arn" {
|
||||
value = aws_sns_topic.info.arn
|
||||
description = "SNS topic for info alerts"
|
||||
}
|
||||
|
||||
output "topics" {
|
||||
value = {
|
||||
critical = aws_sns_topic.critical.arn
|
||||
warning = aws_sns_topic.warning.arn
|
||||
info = aws_sns_topic.info.arn
|
||||
}
|
||||
}
|
||||
36
terraform/modules/app-account/README.md
Normal file
36
terraform/modules/app-account/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# app-account
|
||||
|
||||
Terraform module for AWS landing zone pattern.
|
||||
|
||||
Provision new application/workload AWS accounts with account vending pattern.
|
||||
|
||||
## Planned Features
|
||||
|
||||
- [ ] Create account via AWS Organizations
|
||||
- [ ] Place in appropriate OU
|
||||
- [ ] Apply account baseline module
|
||||
- [ ] Configure VPC (shared or dedicated)
|
||||
- [ ] Create cross-account IAM roles
|
||||
- [ ] Set up budget alerts
|
||||
- [ ] Apply standard tags
|
||||
|
||||
## Planned Usage
|
||||
|
||||
```hcl
|
||||
module "app_account" {
|
||||
source = "../modules/app-account"
|
||||
|
||||
account_name = "myapp-prod"
|
||||
account_email = "aws+myapp-prod@company.com"
|
||||
environment = "prod"
|
||||
owner = "platform-team"
|
||||
|
||||
vpc_config = {
|
||||
mode = "shared" # Use shared VPC from network account
|
||||
}
|
||||
|
||||
budget_limit = 500
|
||||
|
||||
tags = local.tags
|
||||
}
|
||||
```
|
||||
222
terraform/modules/app-account/main.tf
Normal file
222
terraform/modules/app-account/main.tf
Normal file
@@ -0,0 +1,222 @@
|
||||
################################################################################
|
||||
# App Account Module
|
||||
#
|
||||
# Account vending machine for provisioning new workload accounts:
|
||||
# - Creates AWS account via Organizations
|
||||
# - Applies account baseline
|
||||
# - Sets up cross-account IAM roles
|
||||
# - Configures budget alerts
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.5.0"
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data "aws_organizations_organization" "this" {}
|
||||
|
||||
locals {
|
||||
# Generate account email if not provided
|
||||
account_email = var.account_email != "" ? var.account_email : "${var.email_prefix}+${var.account_name}@${var.email_domain}"
|
||||
|
||||
# Standard account tags
|
||||
account_tags = {
|
||||
AccountName = var.account_name
|
||||
Environment = var.environment
|
||||
Owner = var.owner
|
||||
CostCenter = var.cost_center
|
||||
OrganizationUnit = var.organizational_unit
|
||||
ManagedBy = "terraform"
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# AWS Account
|
||||
################################################################################
|
||||
|
||||
resource "aws_organizations_account" "this" {
|
||||
name = var.account_name
|
||||
email = local.account_email
|
||||
parent_id = var.organizational_unit_id
|
||||
|
||||
# IAM user access to billing (usually disabled)
|
||||
iam_user_access_to_billing = var.iam_user_access_to_billing ? "ALLOW" : "DENY"
|
||||
|
||||
# Role name for cross-account access from management account
|
||||
role_name = var.admin_role_name
|
||||
|
||||
# Don't close account on destroy (safety)
|
||||
close_on_deletion = var.close_on_deletion
|
||||
|
||||
tags = merge(var.tags, local.account_tags)
|
||||
|
||||
lifecycle {
|
||||
# Prevent accidental deletion
|
||||
prevent_destroy = false # Set to true in production
|
||||
|
||||
# Email cannot be changed
|
||||
ignore_changes = [email, role_name]
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Cross-Account IAM Role (in new account)
|
||||
# Note: This creates a role that can be assumed from the management account
|
||||
################################################################################
|
||||
|
||||
# Provider for the new account (assumes role created during account creation)
|
||||
provider "aws" {
|
||||
alias = "new_account"
|
||||
region = var.region
|
||||
|
||||
assume_role {
|
||||
role_arn = "arn:aws:iam::${aws_organizations_account.this.id}:role/${var.admin_role_name}"
|
||||
session_name = "terraform-account-setup"
|
||||
}
|
||||
}
|
||||
|
||||
# Readonly role for cross-account access
|
||||
resource "aws_iam_role" "cross_account_readonly" {
|
||||
provider = aws.new_account
|
||||
count = var.create_cross_account_roles ? 1 : 0
|
||||
|
||||
name = "cross-account-readonly"
|
||||
path = "/cross-account/"
|
||||
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Action = "sts:AssumeRole"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
AWS = var.readonly_trusted_principals
|
||||
}
|
||||
}]
|
||||
})
|
||||
|
||||
tags = merge(var.tags, {
|
||||
Name = "cross-account-readonly"
|
||||
})
|
||||
|
||||
depends_on = [aws_organizations_account.this]
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy_attachment" "cross_account_readonly" {
|
||||
provider = aws.new_account
|
||||
count = var.create_cross_account_roles ? 1 : 0
|
||||
|
||||
role = aws_iam_role.cross_account_readonly[0].name
|
||||
policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess"
|
||||
}
|
||||
|
||||
# Admin role for cross-account access (requires MFA)
|
||||
resource "aws_iam_role" "cross_account_admin" {
|
||||
provider = aws.new_account
|
||||
count = var.create_cross_account_roles ? 1 : 0
|
||||
|
||||
name = "cross-account-admin"
|
||||
path = "/cross-account/"
|
||||
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Action = "sts:AssumeRole"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
AWS = var.admin_trusted_principals
|
||||
}
|
||||
Condition = {
|
||||
Bool = {
|
||||
"aws:MultiFactorAuthPresent" = "true"
|
||||
}
|
||||
}
|
||||
}]
|
||||
})
|
||||
|
||||
max_session_duration = 3600
|
||||
|
||||
tags = merge(var.tags, {
|
||||
Name = "cross-account-admin"
|
||||
})
|
||||
|
||||
depends_on = [aws_organizations_account.this]
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy_attachment" "cross_account_admin" {
|
||||
provider = aws.new_account
|
||||
count = var.create_cross_account_roles ? 1 : 0
|
||||
|
||||
role = aws_iam_role.cross_account_admin[0].name
|
||||
policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Account Baseline (in new account)
|
||||
################################################################################
|
||||
|
||||
module "account_baseline" {
|
||||
source = "../account-baseline"
|
||||
count = var.apply_baseline ? 1 : 0
|
||||
|
||||
providers = {
|
||||
aws = aws.new_account
|
||||
}
|
||||
|
||||
name = var.account_name
|
||||
|
||||
enable_ebs_encryption = true
|
||||
enable_s3_block_public = true
|
||||
enable_password_policy = true
|
||||
enable_access_analyzer = true
|
||||
|
||||
# Security services typically managed by delegated admin
|
||||
enable_securityhub = false
|
||||
enable_guardduty = false
|
||||
enable_config = false
|
||||
|
||||
tags = merge(var.tags, local.account_tags)
|
||||
|
||||
depends_on = [aws_organizations_account.this]
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Budget (in new account)
|
||||
################################################################################
|
||||
|
||||
resource "aws_budgets_budget" "this" {
|
||||
provider = aws.new_account
|
||||
count = var.budget_limit > 0 ? 1 : 0
|
||||
|
||||
name = "${var.account_name}-monthly-budget"
|
||||
budget_type = "COST"
|
||||
limit_amount = tostring(var.budget_limit)
|
||||
limit_unit = "USD"
|
||||
time_unit = "MONTHLY"
|
||||
|
||||
notification {
|
||||
comparison_operator = "GREATER_THAN"
|
||||
threshold = 80
|
||||
threshold_type = "PERCENTAGE"
|
||||
notification_type = "ACTUAL"
|
||||
subscriber_email_addresses = [var.owner_email != "" ? var.owner_email : local.account_email]
|
||||
}
|
||||
|
||||
notification {
|
||||
comparison_operator = "GREATER_THAN"
|
||||
threshold = 100
|
||||
threshold_type = "PERCENTAGE"
|
||||
notification_type = "ACTUAL"
|
||||
subscriber_email_addresses = [var.owner_email != "" ? var.owner_email : local.account_email]
|
||||
}
|
||||
|
||||
tags = merge(var.tags, {
|
||||
Name = "${var.account_name}-monthly-budget"
|
||||
})
|
||||
|
||||
depends_on = [aws_organizations_account.this]
|
||||
}
|
||||
49
terraform/modules/app-account/outputs.tf
Normal file
49
terraform/modules/app-account/outputs.tf
Normal file
@@ -0,0 +1,49 @@
|
||||
################################################################################
|
||||
# App Account - Outputs
|
||||
################################################################################
|
||||
|
||||
output "account_id" {
|
||||
value = aws_organizations_account.this.id
|
||||
description = "AWS account ID"
|
||||
}
|
||||
|
||||
output "account_arn" {
|
||||
value = aws_organizations_account.this.arn
|
||||
description = "AWS account ARN"
|
||||
}
|
||||
|
||||
output "account_name" {
|
||||
value = aws_organizations_account.this.name
|
||||
description = "Account name"
|
||||
}
|
||||
|
||||
output "account_email" {
|
||||
value = aws_organizations_account.this.email
|
||||
description = "Account root email"
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
output "admin_role_arn" {
|
||||
value = "arn:aws:iam::${aws_organizations_account.this.id}:role/${var.admin_role_name}"
|
||||
description = "Admin role ARN for cross-account access"
|
||||
}
|
||||
|
||||
output "cross_account_readonly_role_arn" {
|
||||
value = var.create_cross_account_roles ? aws_iam_role.cross_account_readonly[0].arn : null
|
||||
description = "Cross-account readonly role ARN"
|
||||
}
|
||||
|
||||
output "cross_account_admin_role_arn" {
|
||||
value = var.create_cross_account_roles ? aws_iam_role.cross_account_admin[0].arn : null
|
||||
description = "Cross-account admin role ARN"
|
||||
}
|
||||
|
||||
output "budget_id" {
|
||||
value = var.budget_limit > 0 ? aws_budgets_budget.this[0].id : null
|
||||
description = "Budget ID"
|
||||
}
|
||||
|
||||
output "account_tags" {
|
||||
value = local.account_tags
|
||||
description = "Account tags"
|
||||
}
|
||||
131
terraform/modules/app-account/variables.tf
Normal file
131
terraform/modules/app-account/variables.tf
Normal file
@@ -0,0 +1,131 @@
|
||||
################################################################################
|
||||
# App Account - Input Variables
|
||||
################################################################################
|
||||
|
||||
# Account Identity
|
||||
variable "account_name" {
|
||||
type = string
|
||||
description = "Name for the new account"
|
||||
}
|
||||
|
||||
variable "account_email" {
|
||||
type = string
|
||||
default = ""
|
||||
description = "Root email for the account (auto-generated if empty)"
|
||||
}
|
||||
|
||||
variable "email_prefix" {
|
||||
type = string
|
||||
default = "aws"
|
||||
description = "Email prefix for auto-generated email"
|
||||
}
|
||||
|
||||
variable "email_domain" {
|
||||
type = string
|
||||
default = "example.com"
|
||||
description = "Email domain for auto-generated email"
|
||||
}
|
||||
|
||||
# Organization Placement
|
||||
variable "organizational_unit" {
|
||||
type = string
|
||||
default = "Workloads"
|
||||
description = "OU name (for tagging)"
|
||||
}
|
||||
|
||||
variable "organizational_unit_id" {
|
||||
type = string
|
||||
description = "OU ID to place the account in"
|
||||
}
|
||||
|
||||
# Account Metadata
|
||||
variable "environment" {
|
||||
type = string
|
||||
description = "Environment type (dev, staging, prod)"
|
||||
|
||||
validation {
|
||||
condition = contains(["dev", "staging", "prod", "sandbox"], var.environment)
|
||||
error_message = "Must be dev, staging, prod, or sandbox"
|
||||
}
|
||||
}
|
||||
|
||||
variable "cost_center" {
|
||||
type = string
|
||||
default = ""
|
||||
description = "Cost center for billing"
|
||||
}
|
||||
|
||||
variable "owner" {
|
||||
type = string
|
||||
description = "Team/person responsible for this account"
|
||||
}
|
||||
|
||||
variable "owner_email" {
|
||||
type = string
|
||||
default = ""
|
||||
description = "Owner email for notifications"
|
||||
}
|
||||
|
||||
variable "region" {
|
||||
type = string
|
||||
default = "us-east-1"
|
||||
description = "Primary region for the account"
|
||||
}
|
||||
|
||||
# IAM Configuration
|
||||
variable "admin_role_name" {
|
||||
type = string
|
||||
default = "OrganizationAccountAccessRole"
|
||||
description = "Name of admin role created in new account"
|
||||
}
|
||||
|
||||
variable "iam_user_access_to_billing" {
|
||||
type = bool
|
||||
default = false
|
||||
description = "Allow IAM users to access billing"
|
||||
}
|
||||
|
||||
variable "create_cross_account_roles" {
|
||||
type = bool
|
||||
default = true
|
||||
description = "Create cross-account IAM roles"
|
||||
}
|
||||
|
||||
variable "admin_trusted_principals" {
|
||||
type = list(string)
|
||||
default = []
|
||||
description = "ARNs allowed to assume admin role"
|
||||
}
|
||||
|
||||
variable "readonly_trusted_principals" {
|
||||
type = list(string)
|
||||
default = []
|
||||
description = "ARNs allowed to assume readonly role"
|
||||
}
|
||||
|
||||
# Baseline Configuration
|
||||
variable "apply_baseline" {
|
||||
type = bool
|
||||
default = true
|
||||
description = "Apply account baseline configuration"
|
||||
}
|
||||
|
||||
# Budget
|
||||
variable "budget_limit" {
|
||||
type = number
|
||||
default = 100
|
||||
description = "Monthly budget limit in USD (0 = no budget)"
|
||||
}
|
||||
|
||||
# Safety
|
||||
variable "close_on_deletion" {
|
||||
type = bool
|
||||
default = false
|
||||
description = "Close account when Terraform resource is deleted"
|
||||
}
|
||||
|
||||
variable "tags" {
|
||||
type = map(string)
|
||||
default = {}
|
||||
description = "Additional tags"
|
||||
}
|
||||
54
terraform/modules/backup-plan/README.md
Normal file
54
terraform/modules/backup-plan/README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# backup-plan
|
||||
|
||||
AWS Backup Module
|
||||
|
||||
## Usage
|
||||
|
||||
```hcl
|
||||
module "backup_plan" {
|
||||
source = "../modules/backup-plan"
|
||||
|
||||
# Required variables
|
||||
name = ""
|
||||
|
||||
# Optional: see variables.tf for all options
|
||||
}
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
| Name | Version |
|
||||
|------|---------|
|
||||
| terraform | >= 1.5.0 |
|
||||
| aws | >= 5.0 |
|
||||
|
||||
## Inputs
|
||||
|
||||
| Name | Description | Type | Required |
|
||||
|------|-------------|------|----------|
|
||||
| name | Backup plan name | `string` | yes |
|
||||
| tenant | Tenant name for resource selection | `string` | no |
|
||||
| backup_tag_key | Tag key to select resources for backup | `string` | no |
|
||||
| backup_tag_value | Tag value to select resources for backup | `string` | no |
|
||||
| daily_retention_days | | `number` | no |
|
||||
| weekly_retention_days | | `number` | no |
|
||||
| monthly_retention_days | | `number` | no |
|
||||
| enable_continuous_backup | Enable continuous backup for point-in-time recovery (RDS, S3... | `bool` | no |
|
||||
| enable_cross_region_copy | | `bool` | no |
|
||||
| dr_region | DR region for cross-region backup copy | `string` | no |
|
||||
| dr_retention_days | | `number` | no |
|
||||
| kms_key_arn | KMS key ARN for backup encryption (uses AWS managed key if n... | `string` | no |
|
||||
|
||||
## Outputs
|
||||
|
||||
| Name | Description |
|
||||
|------|-------------|
|
||||
| vault_arn | |
|
||||
| vault_name | |
|
||||
| plan_id | |
|
||||
| plan_arn | |
|
||||
| role_arn | |
|
||||
|
||||
## License
|
||||
|
||||
Apache 2.0 - See LICENSE for details.
|
||||
303
terraform/modules/backup-plan/main.tf
Normal file
303
terraform/modules/backup-plan/main.tf
Normal file
@@ -0,0 +1,303 @@
|
||||
################################################################################
|
||||
# AWS Backup Module
|
||||
#
|
||||
# Centralized backup management:
|
||||
# - Daily backups with configurable retention
|
||||
# - Cross-region copy for DR (optional)
|
||||
# - Tag-based resource selection
|
||||
#
|
||||
# Compliance: Meets HIPAA, SOC 2 backup requirements
|
||||
#
|
||||
# Note: Cross-region DR requires passing a provider alias for the DR region:
|
||||
#
|
||||
# provider "aws" {
|
||||
# alias = "dr"
|
||||
# region = "us-west-2"
|
||||
# }
|
||||
#
|
||||
# module "backup" {
|
||||
# source = "../modules/backup-plan"
|
||||
# providers = {
|
||||
# aws = aws
|
||||
# aws.dr = aws.dr
|
||||
# }
|
||||
# enable_cross_region_copy = true
|
||||
# dr_region = "us-west-2"
|
||||
# ...
|
||||
# }
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
configuration_aliases = [aws.dr]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "name" {
|
||||
type = string
|
||||
description = "Backup plan name"
|
||||
}
|
||||
|
||||
variable "tenant" {
|
||||
type = string
|
||||
description = "Tenant name for resource selection"
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "backup_tag_key" {
|
||||
type = string
|
||||
default = "Backup"
|
||||
description = "Tag key to select resources for backup"
|
||||
}
|
||||
|
||||
variable "backup_tag_value" {
|
||||
type = string
|
||||
default = "true"
|
||||
description = "Tag value to select resources for backup"
|
||||
}
|
||||
|
||||
# Retention settings
|
||||
variable "daily_retention_days" {
|
||||
type = number
|
||||
default = 35 # 5 weeks
|
||||
}
|
||||
|
||||
variable "weekly_retention_days" {
|
||||
type = number
|
||||
default = 90 # ~3 months
|
||||
}
|
||||
|
||||
variable "monthly_retention_days" {
|
||||
type = number
|
||||
default = 365 # 1 year
|
||||
}
|
||||
|
||||
variable "enable_continuous_backup" {
|
||||
type = bool
|
||||
default = false
|
||||
description = "Enable continuous backup for point-in-time recovery (RDS, S3)"
|
||||
}
|
||||
|
||||
# Cross-region DR
|
||||
variable "enable_cross_region_copy" {
|
||||
type = bool
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "dr_region" {
|
||||
type = string
|
||||
default = "us-west-2"
|
||||
description = "DR region for cross-region backup copy"
|
||||
}
|
||||
|
||||
variable "dr_retention_days" {
|
||||
type = number
|
||||
default = 30
|
||||
}
|
||||
|
||||
# KMS
|
||||
variable "kms_key_arn" {
|
||||
type = string
|
||||
default = null
|
||||
description = "KMS key ARN for backup encryption (uses AWS managed key if null)"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Backup Vault
|
||||
################################################################################
|
||||
|
||||
resource "aws_backup_vault" "main" {
|
||||
name = var.name
|
||||
kms_key_arn = var.kms_key_arn
|
||||
|
||||
tags = { Name = var.name }
|
||||
}
|
||||
|
||||
# Vault lock for compliance (prevents deletion)
|
||||
resource "aws_backup_vault_lock_configuration" "main" {
|
||||
backup_vault_name = aws_backup_vault.main.name
|
||||
min_retention_days = 7
|
||||
max_retention_days = 365
|
||||
changeable_for_days = 3 # Grace period before lock becomes immutable
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# DR Vault (Cross-Region)
|
||||
################################################################################
|
||||
|
||||
resource "aws_backup_vault" "dr" {
|
||||
count = var.enable_cross_region_copy ? 1 : 0
|
||||
provider = aws.dr
|
||||
|
||||
name = "${var.name}-dr"
|
||||
kms_key_arn = var.kms_key_arn
|
||||
|
||||
tags = { Name = "${var.name}-dr" }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# IAM Role
|
||||
################################################################################
|
||||
|
||||
resource "aws_iam_role" "backup" {
|
||||
name = "${var.name}-backup"
|
||||
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = "sts:AssumeRole"
|
||||
Principal = { Service = "backup.amazonaws.com" }
|
||||
}]
|
||||
})
|
||||
|
||||
tags = { Name = "${var.name}-backup" }
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy_attachment" "backup" {
|
||||
role = aws_iam_role.backup.name
|
||||
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSBackupServiceRolePolicyForBackup"
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy_attachment" "restore" {
|
||||
role = aws_iam_role.backup.name
|
||||
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSBackupServiceRolePolicyForRestores"
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy_attachment" "s3_backup" {
|
||||
role = aws_iam_role.backup.name
|
||||
policy_arn = "arn:aws:iam::aws:policy/AWSBackupServiceRolePolicyForS3Backup"
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy_attachment" "s3_restore" {
|
||||
role = aws_iam_role.backup.name
|
||||
policy_arn = "arn:aws:iam::aws:policy/AWSBackupServiceRolePolicyForS3Restore"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Backup Plan
|
||||
################################################################################
|
||||
|
||||
resource "aws_backup_plan" "main" {
|
||||
name = var.name
|
||||
|
||||
# Daily backup at 3 AM UTC
|
||||
rule {
|
||||
rule_name = "daily"
|
||||
target_vault_name = aws_backup_vault.main.name
|
||||
schedule = "cron(0 3 * * ? *)"
|
||||
start_window = 60 # 1 hour
|
||||
completion_window = 180 # 3 hours
|
||||
|
||||
lifecycle {
|
||||
delete_after = var.daily_retention_days
|
||||
}
|
||||
|
||||
dynamic "copy_action" {
|
||||
for_each = var.enable_cross_region_copy ? [1] : []
|
||||
content {
|
||||
destination_vault_arn = aws_backup_vault.dr[0].arn
|
||||
lifecycle {
|
||||
delete_after = var.dr_retention_days
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Weekly backup (Sunday 2 AM UTC)
|
||||
rule {
|
||||
rule_name = "weekly"
|
||||
target_vault_name = aws_backup_vault.main.name
|
||||
schedule = "cron(0 2 ? * SUN *)"
|
||||
start_window = 60
|
||||
completion_window = 180
|
||||
|
||||
lifecycle {
|
||||
delete_after = var.weekly_retention_days
|
||||
}
|
||||
}
|
||||
|
||||
# Monthly backup (1st of month, 1 AM UTC)
|
||||
rule {
|
||||
rule_name = "monthly"
|
||||
target_vault_name = aws_backup_vault.main.name
|
||||
schedule = "cron(0 1 1 * ? *)"
|
||||
start_window = 60
|
||||
completion_window = 180
|
||||
|
||||
lifecycle {
|
||||
delete_after = var.monthly_retention_days
|
||||
cold_storage_after = 90 # Move to cold storage after 90 days
|
||||
}
|
||||
}
|
||||
|
||||
# Continuous backup (point-in-time recovery)
|
||||
dynamic "rule" {
|
||||
for_each = var.enable_continuous_backup ? [1] : []
|
||||
content {
|
||||
rule_name = "continuous"
|
||||
target_vault_name = aws_backup_vault.main.name
|
||||
enable_continuous_backup = true
|
||||
|
||||
lifecycle {
|
||||
delete_after = 35 # Max for continuous backup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tags = { Name = var.name }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Resource Selection
|
||||
################################################################################
|
||||
|
||||
resource "aws_backup_selection" "tagged" {
|
||||
name = "${var.name}-tagged"
|
||||
plan_id = aws_backup_plan.main.id
|
||||
iam_role_arn = aws_iam_role.backup.arn
|
||||
|
||||
selection_tag {
|
||||
type = "STRINGEQUALS"
|
||||
key = var.backup_tag_key
|
||||
value = var.backup_tag_value
|
||||
}
|
||||
|
||||
# If tenant is specified, also match tenant tag
|
||||
dynamic "selection_tag" {
|
||||
for_each = var.tenant != null ? [1] : []
|
||||
content {
|
||||
type = "STRINGEQUALS"
|
||||
key = "Tenant"
|
||||
value = var.tenant
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Outputs
|
||||
################################################################################
|
||||
|
||||
output "vault_arn" {
|
||||
value = aws_backup_vault.main.arn
|
||||
}
|
||||
|
||||
output "vault_name" {
|
||||
value = aws_backup_vault.main.name
|
||||
}
|
||||
|
||||
output "plan_id" {
|
||||
value = aws_backup_plan.main.id
|
||||
}
|
||||
|
||||
output "plan_arn" {
|
||||
value = aws_backup_plan.main.arn
|
||||
}
|
||||
|
||||
output "role_arn" {
|
||||
value = aws_iam_role.backup.arn
|
||||
}
|
||||
54
terraform/modules/budget-alerts/README.md
Normal file
54
terraform/modules/budget-alerts/README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# budget-alerts
|
||||
|
||||
Budget Alerts Module
|
||||
|
||||
## Usage
|
||||
|
||||
```hcl
|
||||
module "budget_alerts" {
|
||||
source = "../modules/budget-alerts"
|
||||
|
||||
# Required variables
|
||||
monthly_budget = ""
|
||||
|
||||
# Optional: see variables.tf for all options
|
||||
}
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
| Name | Version |
|
||||
|------|---------|
|
||||
| terraform | >= 1.5.0 |
|
||||
| aws | >= 5.0 |
|
||||
|
||||
## Inputs
|
||||
|
||||
| Name | Description | Type | Required |
|
||||
|------|-------------|------|----------|
|
||||
| name_prefix | Prefix for budget names | `string` | no |
|
||||
| monthly_budget | Monthly budget amount in USD | `number` | yes |
|
||||
| currency | Budget currency | `string` | no |
|
||||
| alert_emails | Email addresses for budget alerts | `list(string)` | no |
|
||||
| alert_sns_topic_arn | SNS topic ARN for alerts (creates one if empty) | `string` | no |
|
||||
| alert_thresholds | Alert thresholds as percentage of budget | `list(number)` | no |
|
||||
| forecast_alert_threshold | Alert when forecasted spend exceeds this percentage | `number` | no |
|
||||
| service_budgets | | `map(number)` | no |
|
||||
| enable_anomaly_detection | Enable AWS Cost Anomaly Detection | `bool` | no |
|
||||
| anomaly_threshold_percentage | Anomaly alert threshold as percentage above expected | `number` | no |
|
||||
| anomaly_threshold_absolute | Minimum absolute dollar amount for anomaly alerts | `number` | no |
|
||||
| tags | | `map(string)` | no |
|
||||
|
||||
## Outputs
|
||||
|
||||
| Name | Description |
|
||||
|------|-------------|
|
||||
| monthly_budget_id | Monthly budget ID |
|
||||
| service_budget_ids | |
|
||||
| sns_topic_arn | SNS topic ARN for alerts |
|
||||
| anomaly_monitor_arn | Cost Anomaly Monitor ARN |
|
||||
| budget_summary | |
|
||||
|
||||
## License
|
||||
|
||||
Apache 2.0 - See LICENSE for details.
|
||||
359
terraform/modules/budget-alerts/main.tf
Normal file
359
terraform/modules/budget-alerts/main.tf
Normal file
@@ -0,0 +1,359 @@
|
||||
################################################################################
|
||||
# Budget Alerts Module
|
||||
#
|
||||
# AWS Budgets for cost monitoring:
|
||||
# - Monthly spend budgets
|
||||
# - Service-specific budgets
|
||||
# - Forecasted spend alerts
|
||||
# - Cost anomaly detection
|
||||
# - SNS/email notifications
|
||||
#
|
||||
# Usage:
|
||||
# module "budgets" {
|
||||
# source = "../modules/budget-alerts"
|
||||
#
|
||||
# monthly_budget = 1000
|
||||
# alert_emails = ["finance@example.com"]
|
||||
#
|
||||
# service_budgets = {
|
||||
# ec2 = 500
|
||||
# rds = 200
|
||||
# }
|
||||
# }
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "name_prefix" {
|
||||
type = string
|
||||
default = "account"
|
||||
description = "Prefix for budget names"
|
||||
}
|
||||
|
||||
variable "monthly_budget" {
|
||||
type = number
|
||||
description = "Monthly budget amount in USD"
|
||||
}
|
||||
|
||||
variable "currency" {
|
||||
type = string
|
||||
default = "USD"
|
||||
description = "Budget currency"
|
||||
}
|
||||
|
||||
variable "alert_emails" {
|
||||
type = list(string)
|
||||
default = []
|
||||
description = "Email addresses for budget alerts"
|
||||
}
|
||||
|
||||
variable "alert_sns_topic_arn" {
|
||||
type = string
|
||||
default = ""
|
||||
description = "SNS topic ARN for alerts (creates one if empty)"
|
||||
}
|
||||
|
||||
variable "alert_thresholds" {
|
||||
type = list(number)
|
||||
default = [50, 75, 90, 100, 110]
|
||||
description = "Alert thresholds as percentage of budget"
|
||||
}
|
||||
|
||||
variable "forecast_alert_threshold" {
|
||||
type = number
|
||||
default = 100
|
||||
description = "Alert when forecasted spend exceeds this percentage"
|
||||
}
|
||||
|
||||
variable "service_budgets" {
|
||||
type = map(number)
|
||||
default = {}
|
||||
description = "Per-service budgets (service name -> monthly amount)"
|
||||
}
|
||||
|
||||
variable "enable_anomaly_detection" {
|
||||
type = bool
|
||||
default = true
|
||||
description = "Enable AWS Cost Anomaly Detection"
|
||||
}
|
||||
|
||||
variable "anomaly_threshold_percentage" {
|
||||
type = number
|
||||
default = 10
|
||||
description = "Anomaly alert threshold as percentage above expected"
|
||||
}
|
||||
|
||||
variable "anomaly_threshold_absolute" {
|
||||
type = number
|
||||
default = 100
|
||||
description = "Minimum absolute dollar amount for anomaly alerts"
|
||||
}
|
||||
|
||||
variable "tags" {
|
||||
type = map(string)
|
||||
default = {}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Data Sources
|
||||
################################################################################
|
||||
|
||||
data "aws_caller_identity" "current" {}
|
||||
|
||||
################################################################################
|
||||
# SNS Topic for Alerts
|
||||
################################################################################
|
||||
|
||||
resource "aws_sns_topic" "budget_alerts" {
|
||||
count = var.alert_sns_topic_arn == "" ? 1 : 0
|
||||
name = "${var.name_prefix}-budget-alerts"
|
||||
|
||||
tags = merge(var.tags, { Name = "${var.name_prefix}-budget-alerts" })
|
||||
}
|
||||
|
||||
resource "aws_sns_topic_policy" "budget_alerts" {
|
||||
count = var.alert_sns_topic_arn == "" ? 1 : 0
|
||||
arn = aws_sns_topic.budget_alerts[0].arn
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Sid = "AllowBudgets"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
Service = "budgets.amazonaws.com"
|
||||
}
|
||||
Action = "sns:Publish"
|
||||
Resource = aws_sns_topic.budget_alerts[0].arn
|
||||
Condition = {
|
||||
StringEquals = {
|
||||
"aws:SourceAccount" = data.aws_caller_identity.current.account_id
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
Sid = "AllowCostAnomaly"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
Service = "costalerts.amazonaws.com"
|
||||
}
|
||||
Action = "sns:Publish"
|
||||
Resource = aws_sns_topic.budget_alerts[0].arn
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
resource "aws_sns_topic_subscription" "email" {
|
||||
for_each = var.alert_sns_topic_arn == "" ? toset(var.alert_emails) : []
|
||||
|
||||
topic_arn = aws_sns_topic.budget_alerts[0].arn
|
||||
protocol = "email"
|
||||
endpoint = each.value
|
||||
}
|
||||
|
||||
locals {
|
||||
sns_topic_arn = var.alert_sns_topic_arn != "" ? var.alert_sns_topic_arn : aws_sns_topic.budget_alerts[0].arn
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Monthly Account Budget
|
||||
################################################################################
|
||||
|
||||
resource "aws_budgets_budget" "monthly" {
|
||||
name = "${var.name_prefix}-monthly-budget"
|
||||
budget_type = "COST"
|
||||
limit_amount = var.monthly_budget
|
||||
limit_unit = var.currency
|
||||
time_unit = "MONTHLY"
|
||||
time_period_start = formatdate("YYYY-MM-01_00:00", timestamp())
|
||||
|
||||
cost_filter {
|
||||
name = "LinkedAccount"
|
||||
values = [data.aws_caller_identity.current.account_id]
|
||||
}
|
||||
|
||||
# Actual spend alerts
|
||||
dynamic "notification" {
|
||||
for_each = var.alert_thresholds
|
||||
content {
|
||||
comparison_operator = "GREATER_THAN"
|
||||
threshold = notification.value
|
||||
threshold_type = "PERCENTAGE"
|
||||
notification_type = "ACTUAL"
|
||||
subscriber_sns_topic_arns = [local.sns_topic_arn]
|
||||
subscriber_email_addresses = var.alert_sns_topic_arn != "" ? var.alert_emails : []
|
||||
}
|
||||
}
|
||||
|
||||
# Forecasted spend alert
|
||||
notification {
|
||||
comparison_operator = "GREATER_THAN"
|
||||
threshold = var.forecast_alert_threshold
|
||||
threshold_type = "PERCENTAGE"
|
||||
notification_type = "FORECASTED"
|
||||
subscriber_sns_topic_arns = [local.sns_topic_arn]
|
||||
subscriber_email_addresses = var.alert_sns_topic_arn != "" ? var.alert_emails : []
|
||||
}
|
||||
|
||||
tags = merge(var.tags, { Name = "${var.name_prefix}-monthly" })
|
||||
|
||||
lifecycle {
|
||||
ignore_changes = [time_period_start]
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Service-Specific Budgets
|
||||
################################################################################
|
||||
|
||||
locals {
|
||||
service_filters = {
|
||||
ec2 = "Amazon Elastic Compute Cloud - Compute"
|
||||
rds = "Amazon Relational Database Service"
|
||||
s3 = "Amazon Simple Storage Service"
|
||||
lambda = "AWS Lambda"
|
||||
dynamodb = "Amazon DynamoDB"
|
||||
cloudfront = "Amazon CloudFront"
|
||||
elasticache = "Amazon ElastiCache"
|
||||
eks = "Amazon Elastic Kubernetes Service"
|
||||
ecs = "Amazon Elastic Container Service"
|
||||
nat = "EC2 - Other" # NAT Gateway charges
|
||||
data = "AWS Data Transfer"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_budgets_budget" "services" {
|
||||
for_each = var.service_budgets
|
||||
|
||||
name = "${var.name_prefix}-${each.key}-budget"
|
||||
budget_type = "COST"
|
||||
limit_amount = each.value
|
||||
limit_unit = var.currency
|
||||
time_unit = "MONTHLY"
|
||||
time_period_start = formatdate("YYYY-MM-01_00:00", timestamp())
|
||||
|
||||
cost_filter {
|
||||
name = "Service"
|
||||
values = [lookup(local.service_filters, each.key, each.key)]
|
||||
}
|
||||
|
||||
notification {
|
||||
comparison_operator = "GREATER_THAN"
|
||||
threshold = 80
|
||||
threshold_type = "PERCENTAGE"
|
||||
notification_type = "ACTUAL"
|
||||
subscriber_sns_topic_arns = [local.sns_topic_arn]
|
||||
subscriber_email_addresses = var.alert_sns_topic_arn != "" ? var.alert_emails : []
|
||||
}
|
||||
|
||||
notification {
|
||||
comparison_operator = "GREATER_THAN"
|
||||
threshold = 100
|
||||
threshold_type = "PERCENTAGE"
|
||||
notification_type = "ACTUAL"
|
||||
subscriber_sns_topic_arns = [local.sns_topic_arn]
|
||||
subscriber_email_addresses = var.alert_sns_topic_arn != "" ? var.alert_emails : []
|
||||
}
|
||||
|
||||
tags = merge(var.tags, { Name = "${var.name_prefix}-${each.key}" })
|
||||
|
||||
lifecycle {
|
||||
ignore_changes = [time_period_start]
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Cost Anomaly Detection
|
||||
################################################################################
|
||||
|
||||
resource "aws_ce_anomaly_monitor" "main" {
|
||||
count = var.enable_anomaly_detection ? 1 : 0
|
||||
name = "${var.name_prefix}-anomaly-monitor"
|
||||
monitor_type = "DIMENSIONAL"
|
||||
monitor_dimension = "SERVICE"
|
||||
|
||||
tags = merge(var.tags, { Name = "${var.name_prefix}-anomaly-monitor" })
|
||||
}
|
||||
|
||||
resource "aws_ce_anomaly_subscription" "main" {
|
||||
count = var.enable_anomaly_detection ? 1 : 0
|
||||
name = "${var.name_prefix}-anomaly-alerts"
|
||||
frequency = "IMMEDIATE"
|
||||
|
||||
monitor_arn_list = [aws_ce_anomaly_monitor.main[0].arn]
|
||||
|
||||
subscriber {
|
||||
type = "SNS"
|
||||
address = local.sns_topic_arn
|
||||
}
|
||||
|
||||
dynamic "subscriber" {
|
||||
for_each = var.alert_sns_topic_arn != "" ? var.alert_emails : []
|
||||
content {
|
||||
type = "EMAIL"
|
||||
address = subscriber.value
|
||||
}
|
||||
}
|
||||
|
||||
threshold_expression {
|
||||
and {
|
||||
dimension {
|
||||
key = "ANOMALY_TOTAL_IMPACT_PERCENTAGE"
|
||||
match_options = ["GREATER_THAN_OR_EQUAL"]
|
||||
values = [tostring(var.anomaly_threshold_percentage)]
|
||||
}
|
||||
}
|
||||
and {
|
||||
dimension {
|
||||
key = "ANOMALY_TOTAL_IMPACT_ABSOLUTE"
|
||||
match_options = ["GREATER_THAN_OR_EQUAL"]
|
||||
values = [tostring(var.anomaly_threshold_absolute)]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tags = merge(var.tags, { Name = "${var.name_prefix}-anomaly-alerts" })
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Outputs
|
||||
################################################################################
|
||||
|
||||
output "monthly_budget_id" {
|
||||
value = aws_budgets_budget.monthly.id
|
||||
description = "Monthly budget ID"
|
||||
}
|
||||
|
||||
output "service_budget_ids" {
|
||||
value = { for k, v in aws_budgets_budget.services : k => v.id }
|
||||
description = "Service budget IDs"
|
||||
}
|
||||
|
||||
output "sns_topic_arn" {
|
||||
value = local.sns_topic_arn
|
||||
description = "SNS topic ARN for alerts"
|
||||
}
|
||||
|
||||
output "anomaly_monitor_arn" {
|
||||
value = var.enable_anomaly_detection ? aws_ce_anomaly_monitor.main[0].arn : null
|
||||
description = "Cost Anomaly Monitor ARN"
|
||||
}
|
||||
|
||||
output "budget_summary" {
|
||||
value = {
|
||||
monthly_limit = "$${var.monthly_budget}/month"
|
||||
alert_thresholds = [for t in var.alert_thresholds : "${t}%"]
|
||||
service_limits = { for k, v in var.service_budgets : k => "$${v}/month" }
|
||||
anomaly_detection = var.enable_anomaly_detection ? "Enabled (>${var.anomaly_threshold_percentage}% and >$${var.anomaly_threshold_absolute})" : "Disabled"
|
||||
}
|
||||
description = "Budget configuration summary"
|
||||
}
|
||||
60
terraform/modules/cloudtrail/README.md
Normal file
60
terraform/modules/cloudtrail/README.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# cloudtrail
|
||||
|
||||
CloudTrail Module
|
||||
|
||||
## Usage
|
||||
|
||||
```hcl
|
||||
module "cloudtrail" {
|
||||
source = "../modules/cloudtrail"
|
||||
|
||||
# Required variables
|
||||
name = ""
|
||||
|
||||
# Optional: see variables.tf for all options
|
||||
}
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
| Name | Version |
|
||||
|------|---------|
|
||||
| terraform | >= 1.5.0 |
|
||||
| aws | >= 5.0 |
|
||||
|
||||
## Inputs
|
||||
|
||||
| Name | Description | Type | Required |
|
||||
|------|-------------|------|----------|
|
||||
| name | Trail name | `string` | yes |
|
||||
| s3_bucket_name | S3 bucket for logs (created if empty) | `string` | no |
|
||||
| is_multi_region | Enable multi-region trail | `bool` | no |
|
||||
| is_organization_trail | Organization-wide trail (requires org management account) | `bool` | no |
|
||||
| enable_log_file_validation | Enable log file integrity validation | `bool` | no |
|
||||
| include_global_service_events | Include global service events (IAM, STS, CloudFront) | `bool` | no |
|
||||
| enable_cloudwatch_logs | Send logs to CloudWatch Logs | `bool` | no |
|
||||
| cloudwatch_log_retention_days | CloudWatch log retention in days | `number` | no |
|
||||
| enable_insights | Enable CloudTrail Insights (additional cost) | `bool` | no |
|
||||
| insight_selectors | Insight types to enable | `list(string)` | no |
|
||||
| enable_data_events | Enable data events logging | `bool` | no |
|
||||
| data_event_s3_buckets | S3 bucket ARNs for data events (empty = all buckets) | `list(string)` | no |
|
||||
| data_event_lambda_functions | Lambda function ARNs for data events (empty = all functions) | `list(string)` | no |
|
||||
| data_event_dynamodb_tables | DynamoDB table ARNs for data events | `list(string)` | no |
|
||||
| kms_key_arn | KMS key ARN for encryption (created if empty) | `string` | no |
|
||||
|
||||
*...and 3 more variables. See `variables.tf` for complete list.*
|
||||
|
||||
## Outputs
|
||||
|
||||
| Name | Description |
|
||||
|------|-------------|
|
||||
| trail_arn | CloudTrail ARN |
|
||||
| trail_name | CloudTrail name |
|
||||
| s3_bucket | S3 bucket for CloudTrail logs |
|
||||
| kms_key_arn | KMS key ARN for encryption |
|
||||
| cloudwatch_log_group | CloudWatch Logs group |
|
||||
| home_region | Trail home region |
|
||||
|
||||
## License
|
||||
|
||||
Apache 2.0 - See LICENSE for details.
|
||||
506
terraform/modules/cloudtrail/main.tf
Normal file
506
terraform/modules/cloudtrail/main.tf
Normal file
@@ -0,0 +1,506 @@
|
||||
################################################################################
|
||||
# CloudTrail Module
|
||||
#
|
||||
# Audit logging for AWS API activity:
|
||||
# - Management events (console, CLI, SDK)
|
||||
# - Data events (S3, Lambda, DynamoDB)
|
||||
# - Insights events (anomaly detection)
|
||||
# - Multi-region trail
|
||||
# - KMS encryption
|
||||
# - CloudWatch Logs integration
|
||||
# - S3 bucket with lifecycle
|
||||
#
|
||||
# Usage:
|
||||
# module "cloudtrail" {
|
||||
# source = "../modules/cloudtrail"
|
||||
# name = "org-trail"
|
||||
#
|
||||
# enable_data_events = true
|
||||
# data_event_buckets = ["my-bucket"]
|
||||
# }
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "name" {
|
||||
type = string
|
||||
description = "Trail name"
|
||||
}
|
||||
|
||||
variable "s3_bucket_name" {
|
||||
type = string
|
||||
default = ""
|
||||
description = "S3 bucket for logs (created if empty)"
|
||||
}
|
||||
|
||||
variable "is_multi_region" {
|
||||
type = bool
|
||||
default = true
|
||||
description = "Enable multi-region trail"
|
||||
}
|
||||
|
||||
variable "is_organization_trail" {
|
||||
type = bool
|
||||
default = false
|
||||
description = "Organization-wide trail (requires org management account)"
|
||||
}
|
||||
|
||||
variable "enable_log_file_validation" {
|
||||
type = bool
|
||||
default = true
|
||||
description = "Enable log file integrity validation"
|
||||
}
|
||||
|
||||
variable "include_global_service_events" {
|
||||
type = bool
|
||||
default = true
|
||||
description = "Include global service events (IAM, STS, CloudFront)"
|
||||
}
|
||||
|
||||
variable "enable_cloudwatch_logs" {
|
||||
type = bool
|
||||
default = true
|
||||
description = "Send logs to CloudWatch Logs"
|
||||
}
|
||||
|
||||
variable "cloudwatch_log_retention_days" {
|
||||
type = number
|
||||
default = 90
|
||||
description = "CloudWatch log retention in days"
|
||||
}
|
||||
|
||||
variable "enable_insights" {
|
||||
type = bool
|
||||
default = false
|
||||
description = "Enable CloudTrail Insights (additional cost)"
|
||||
}
|
||||
|
||||
variable "insight_selectors" {
|
||||
type = list(string)
|
||||
default = ["ApiCallRateInsight", "ApiErrorRateInsight"]
|
||||
description = "Insight types to enable"
|
||||
}
|
||||
|
||||
variable "enable_data_events" {
|
||||
type = bool
|
||||
default = false
|
||||
description = "Enable data events logging"
|
||||
}
|
||||
|
||||
variable "data_event_s3_buckets" {
|
||||
type = list(string)
|
||||
default = []
|
||||
description = "S3 bucket ARNs for data events (empty = all buckets)"
|
||||
}
|
||||
|
||||
variable "data_event_lambda_functions" {
|
||||
type = list(string)
|
||||
default = []
|
||||
description = "Lambda function ARNs for data events (empty = all functions)"
|
||||
}
|
||||
|
||||
variable "data_event_dynamodb_tables" {
|
||||
type = list(string)
|
||||
default = []
|
||||
description = "DynamoDB table ARNs for data events"
|
||||
}
|
||||
|
||||
variable "kms_key_arn" {
|
||||
type = string
|
||||
default = ""
|
||||
description = "KMS key ARN for encryption (created if empty)"
|
||||
}
|
||||
|
||||
variable "s3_log_retention_days" {
|
||||
type = number
|
||||
default = 365
|
||||
description = "S3 log retention in days"
|
||||
}
|
||||
|
||||
variable "s3_transition_to_glacier_days" {
|
||||
type = number
|
||||
default = 90
|
||||
description = "Days before transitioning logs to Glacier"
|
||||
}
|
||||
|
||||
variable "tags" {
|
||||
type = map(string)
|
||||
default = {}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Data Sources
|
||||
################################################################################
|
||||
|
||||
data "aws_caller_identity" "current" {}
|
||||
data "aws_region" "current" {}
|
||||
data "aws_partition" "current" {}
|
||||
|
||||
locals {
|
||||
bucket_name = var.s3_bucket_name != "" ? var.s3_bucket_name : "${var.name}-cloudtrail-${data.aws_caller_identity.current.account_id}"
|
||||
create_bucket = var.s3_bucket_name == ""
|
||||
create_kms = var.kms_key_arn == ""
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# KMS Key
|
||||
################################################################################
|
||||
|
||||
resource "aws_kms_key" "cloudtrail" {
|
||||
count = local.create_kms ? 1 : 0
|
||||
|
||||
description = "CloudTrail encryption key for ${var.name}"
|
||||
deletion_window_in_days = 30
|
||||
enable_key_rotation = true
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Sid = "Enable IAM policies"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
AWS = "arn:${data.aws_partition.current.partition}:iam::${data.aws_caller_identity.current.account_id}:root"
|
||||
}
|
||||
Action = "kms:*"
|
||||
Resource = "*"
|
||||
},
|
||||
{
|
||||
Sid = "Allow CloudTrail to encrypt logs"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
Service = "cloudtrail.amazonaws.com"
|
||||
}
|
||||
Action = [
|
||||
"kms:GenerateDataKey*",
|
||||
"kms:DescribeKey"
|
||||
]
|
||||
Resource = "*"
|
||||
Condition = {
|
||||
StringEquals = {
|
||||
"aws:SourceArn" = "arn:${data.aws_partition.current.partition}:cloudtrail:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:trail/${var.name}"
|
||||
}
|
||||
StringLike = {
|
||||
"kms:EncryptionContext:aws:cloudtrail:arn" = "arn:${data.aws_partition.current.partition}:cloudtrail:*:${data.aws_caller_identity.current.account_id}:trail/*"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
Sid = "Allow CloudTrail to describe key"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
Service = "cloudtrail.amazonaws.com"
|
||||
}
|
||||
Action = "kms:DescribeKey"
|
||||
Resource = "*"
|
||||
},
|
||||
{
|
||||
Sid = "Allow log decryption"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
AWS = "arn:${data.aws_partition.current.partition}:iam::${data.aws_caller_identity.current.account_id}:root"
|
||||
}
|
||||
Action = [
|
||||
"kms:Decrypt",
|
||||
"kms:ReEncryptFrom"
|
||||
]
|
||||
Resource = "*"
|
||||
Condition = {
|
||||
StringEquals = {
|
||||
"kms:CallerAccount" = data.aws_caller_identity.current.account_id
|
||||
}
|
||||
StringLike = {
|
||||
"kms:EncryptionContext:aws:cloudtrail:arn" = "arn:${data.aws_partition.current.partition}:cloudtrail:*:${data.aws_caller_identity.current.account_id}:trail/*"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
tags = merge(var.tags, { Name = "${var.name}-cloudtrail" })
|
||||
}
|
||||
|
||||
resource "aws_kms_alias" "cloudtrail" {
|
||||
count = local.create_kms ? 1 : 0
|
||||
name = "alias/${var.name}-cloudtrail"
|
||||
target_key_id = aws_kms_key.cloudtrail[0].key_id
|
||||
}
|
||||
|
||||
locals {
|
||||
kms_key_arn = local.create_kms ? aws_kms_key.cloudtrail[0].arn : var.kms_key_arn
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# S3 Bucket
|
||||
################################################################################
|
||||
|
||||
resource "aws_s3_bucket" "cloudtrail" {
|
||||
count = local.create_bucket ? 1 : 0
|
||||
bucket = local.bucket_name
|
||||
|
||||
tags = merge(var.tags, { Name = local.bucket_name })
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket_versioning" "cloudtrail" {
|
||||
count = local.create_bucket ? 1 : 0
|
||||
bucket = aws_s3_bucket.cloudtrail[0].id
|
||||
|
||||
versioning_configuration {
|
||||
status = "Enabled"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket_server_side_encryption_configuration" "cloudtrail" {
|
||||
count = local.create_bucket ? 1 : 0
|
||||
bucket = aws_s3_bucket.cloudtrail[0].id
|
||||
|
||||
rule {
|
||||
apply_server_side_encryption_by_default {
|
||||
sse_algorithm = "aws:kms"
|
||||
kms_master_key_id = local.kms_key_arn
|
||||
}
|
||||
bucket_key_enabled = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket_public_access_block" "cloudtrail" {
|
||||
count = local.create_bucket ? 1 : 0
|
||||
bucket = aws_s3_bucket.cloudtrail[0].id
|
||||
|
||||
block_public_acls = true
|
||||
block_public_policy = true
|
||||
ignore_public_acls = true
|
||||
restrict_public_buckets = true
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket_lifecycle_configuration" "cloudtrail" {
|
||||
count = local.create_bucket ? 1 : 0
|
||||
bucket = aws_s3_bucket.cloudtrail[0].id
|
||||
|
||||
rule {
|
||||
id = "archive-and-expire"
|
||||
status = "Enabled"
|
||||
|
||||
transition {
|
||||
days = var.s3_transition_to_glacier_days
|
||||
storage_class = "GLACIER"
|
||||
}
|
||||
|
||||
expiration {
|
||||
days = var.s3_log_retention_days
|
||||
}
|
||||
|
||||
noncurrent_version_expiration {
|
||||
noncurrent_days = 30
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket_policy" "cloudtrail" {
|
||||
count = local.create_bucket ? 1 : 0
|
||||
bucket = aws_s3_bucket.cloudtrail[0].id
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Sid = "AWSCloudTrailAclCheck"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
Service = "cloudtrail.amazonaws.com"
|
||||
}
|
||||
Action = "s3:GetBucketAcl"
|
||||
Resource = aws_s3_bucket.cloudtrail[0].arn
|
||||
Condition = {
|
||||
StringEquals = {
|
||||
"aws:SourceArn" = "arn:${data.aws_partition.current.partition}:cloudtrail:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:trail/${var.name}"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
Sid = "AWSCloudTrailWrite"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
Service = "cloudtrail.amazonaws.com"
|
||||
}
|
||||
Action = "s3:PutObject"
|
||||
Resource = "${aws_s3_bucket.cloudtrail[0].arn}/*"
|
||||
Condition = {
|
||||
StringEquals = {
|
||||
"s3:x-amz-acl" = "bucket-owner-full-control"
|
||||
"aws:SourceArn" = "arn:${data.aws_partition.current.partition}:cloudtrail:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:trail/${var.name}"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
Sid = "DenyInsecureTransport"
|
||||
Effect = "Deny"
|
||||
Principal = "*"
|
||||
Action = "s3:*"
|
||||
Resource = [
|
||||
aws_s3_bucket.cloudtrail[0].arn,
|
||||
"${aws_s3_bucket.cloudtrail[0].arn}/*"
|
||||
]
|
||||
Condition = {
|
||||
Bool = {
|
||||
"aws:SecureTransport" = "false"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# CloudWatch Logs
|
||||
################################################################################
|
||||
|
||||
resource "aws_cloudwatch_log_group" "cloudtrail" {
|
||||
count = var.enable_cloudwatch_logs ? 1 : 0
|
||||
name = "/aws/cloudtrail/${var.name}"
|
||||
retention_in_days = var.cloudwatch_log_retention_days
|
||||
|
||||
tags = merge(var.tags, { Name = var.name })
|
||||
}
|
||||
|
||||
resource "aws_iam_role" "cloudtrail_cloudwatch" {
|
||||
count = var.enable_cloudwatch_logs ? 1 : 0
|
||||
name = "${var.name}-cloudtrail-cloudwatch"
|
||||
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = "sts:AssumeRole"
|
||||
Principal = { Service = "cloudtrail.amazonaws.com" }
|
||||
}]
|
||||
})
|
||||
|
||||
tags = merge(var.tags, { Name = "${var.name}-cloudtrail-cloudwatch" })
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "cloudtrail_cloudwatch" {
|
||||
count = var.enable_cloudwatch_logs ? 1 : 0
|
||||
name = "cloudwatch-logs"
|
||||
role = aws_iam_role.cloudtrail_cloudwatch[0].id
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"logs:CreateLogStream",
|
||||
"logs:PutLogEvents"
|
||||
]
|
||||
Resource = "${aws_cloudwatch_log_group.cloudtrail[0].arn}:*"
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# CloudTrail
|
||||
################################################################################
|
||||
|
||||
resource "aws_cloudtrail" "main" {
|
||||
name = var.name
|
||||
s3_bucket_name = local.create_bucket ? aws_s3_bucket.cloudtrail[0].id : var.s3_bucket_name
|
||||
include_global_service_events = var.include_global_service_events
|
||||
is_multi_region_trail = var.is_multi_region
|
||||
is_organization_trail = var.is_organization_trail
|
||||
enable_log_file_validation = var.enable_log_file_validation
|
||||
kms_key_id = local.kms_key_arn
|
||||
|
||||
cloud_watch_logs_group_arn = var.enable_cloudwatch_logs ? "${aws_cloudwatch_log_group.cloudtrail[0].arn}:*" : null
|
||||
cloud_watch_logs_role_arn = var.enable_cloudwatch_logs ? aws_iam_role.cloudtrail_cloudwatch[0].arn : null
|
||||
|
||||
# Insights
|
||||
dynamic "insight_selector" {
|
||||
for_each = var.enable_insights ? var.insight_selectors : []
|
||||
content {
|
||||
insight_type = insight_selector.value
|
||||
}
|
||||
}
|
||||
|
||||
# Data events
|
||||
dynamic "event_selector" {
|
||||
for_each = var.enable_data_events ? [1] : []
|
||||
content {
|
||||
read_write_type = "All"
|
||||
include_management_events = true
|
||||
|
||||
# S3 data events
|
||||
dynamic "data_resource" {
|
||||
for_each = length(var.data_event_s3_buckets) > 0 ? [1] : (var.enable_data_events ? [1] : [])
|
||||
content {
|
||||
type = "AWS::S3::Object"
|
||||
values = length(var.data_event_s3_buckets) > 0 ? var.data_event_s3_buckets : ["arn:aws:s3"]
|
||||
}
|
||||
}
|
||||
|
||||
# Lambda data events
|
||||
dynamic "data_resource" {
|
||||
for_each = length(var.data_event_lambda_functions) > 0 ? [1] : []
|
||||
content {
|
||||
type = "AWS::Lambda::Function"
|
||||
values = var.data_event_lambda_functions
|
||||
}
|
||||
}
|
||||
|
||||
# DynamoDB data events
|
||||
dynamic "data_resource" {
|
||||
for_each = length(var.data_event_dynamodb_tables) > 0 ? [1] : []
|
||||
content {
|
||||
type = "AWS::DynamoDB::Table"
|
||||
values = var.data_event_dynamodb_tables
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tags = merge(var.tags, { Name = var.name })
|
||||
|
||||
depends_on = [
|
||||
aws_s3_bucket_policy.cloudtrail,
|
||||
]
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Outputs
|
||||
################################################################################
|
||||
|
||||
output "trail_arn" {
|
||||
value = aws_cloudtrail.main.arn
|
||||
description = "CloudTrail ARN"
|
||||
}
|
||||
|
||||
output "trail_name" {
|
||||
value = aws_cloudtrail.main.name
|
||||
description = "CloudTrail name"
|
||||
}
|
||||
|
||||
output "s3_bucket" {
|
||||
value = local.create_bucket ? aws_s3_bucket.cloudtrail[0].id : var.s3_bucket_name
|
||||
description = "S3 bucket for CloudTrail logs"
|
||||
}
|
||||
|
||||
output "kms_key_arn" {
|
||||
value = local.kms_key_arn
|
||||
description = "KMS key ARN for encryption"
|
||||
}
|
||||
|
||||
output "cloudwatch_log_group" {
|
||||
value = var.enable_cloudwatch_logs ? aws_cloudwatch_log_group.cloudtrail[0].name : null
|
||||
description = "CloudWatch Logs group"
|
||||
}
|
||||
|
||||
output "home_region" {
|
||||
value = aws_cloudtrail.main.home_region
|
||||
description = "Trail home region"
|
||||
}
|
||||
49
terraform/modules/cloudwatch-dashboard/README.md
Normal file
49
terraform/modules/cloudwatch-dashboard/README.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# cloudwatch-dashboard
|
||||
|
||||
CloudWatch Dashboard Module
|
||||
|
||||
## Usage
|
||||
|
||||
```hcl
|
||||
module "cloudwatch_dashboard" {
|
||||
source = "../modules/cloudwatch-dashboard"
|
||||
|
||||
# Required variables
|
||||
name = ""
|
||||
|
||||
# Optional: see variables.tf for all options
|
||||
}
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
| Name | Version |
|
||||
|------|---------|
|
||||
| terraform | >= 1.5.0 |
|
||||
| aws | >= 5.0 |
|
||||
|
||||
## Inputs
|
||||
|
||||
| Name | Description | Type | Required |
|
||||
|------|-------------|------|----------|
|
||||
| name | Dashboard name | `string` | yes |
|
||||
| ecs_clusters | ECS cluster names to monitor | `list(string)` | no |
|
||||
| ecs_services | ECS service names to monitor | `list(string)` | no |
|
||||
| rds_instances | RDS instance identifiers | `list(string)` | no |
|
||||
| lambda_functions | Lambda function names | `list(string)` | no |
|
||||
| alb_arns | ALB ARN suffixes (app/name/id) | `list(string)` | no |
|
||||
| api_gateway_apis | API Gateway API IDs | `list(string)` | no |
|
||||
| sqs_queues | SQS queue names | `list(string)` | no |
|
||||
| dynamodb_tables | DynamoDB table names | `list(string)` | no |
|
||||
| tags | | `map(string)` | no |
|
||||
|
||||
## Outputs
|
||||
|
||||
| Name | Description |
|
||||
|------|-------------|
|
||||
| dashboard_name | |
|
||||
| dashboard_arn | |
|
||||
|
||||
## License
|
||||
|
||||
Apache 2.0 - See LICENSE for details.
|
||||
431
terraform/modules/cloudwatch-dashboard/main.tf
Normal file
431
terraform/modules/cloudwatch-dashboard/main.tf
Normal file
@@ -0,0 +1,431 @@
|
||||
################################################################################
|
||||
# CloudWatch Dashboard Module
|
||||
#
|
||||
# Creates CloudWatch dashboards for common AWS services:
|
||||
# - ECS services
|
||||
# - RDS databases
|
||||
# - Lambda functions
|
||||
# - ALB/NLB
|
||||
# - API Gateway
|
||||
#
|
||||
# Usage:
|
||||
# module "dashboard" {
|
||||
# source = "../modules/cloudwatch-dashboard"
|
||||
# name = "myapp-prod"
|
||||
#
|
||||
# ecs_clusters = ["prod-cluster"]
|
||||
# ecs_services = ["myapp-api"]
|
||||
# rds_instances = ["myapp-db"]
|
||||
# lambda_functions = ["myapp-worker"]
|
||||
# alb_arns = ["arn:aws:elasticloadbalancing:..."]
|
||||
# }
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "name" {
|
||||
type = string
|
||||
description = "Dashboard name"
|
||||
}
|
||||
|
||||
variable "ecs_clusters" {
|
||||
type = list(string)
|
||||
default = []
|
||||
description = "ECS cluster names to monitor"
|
||||
}
|
||||
|
||||
variable "ecs_services" {
|
||||
type = list(string)
|
||||
default = []
|
||||
description = "ECS service names to monitor"
|
||||
}
|
||||
|
||||
variable "rds_instances" {
|
||||
type = list(string)
|
||||
default = []
|
||||
description = "RDS instance identifiers"
|
||||
}
|
||||
|
||||
variable "lambda_functions" {
|
||||
type = list(string)
|
||||
default = []
|
||||
description = "Lambda function names"
|
||||
}
|
||||
|
||||
variable "alb_arns" {
|
||||
type = list(string)
|
||||
default = []
|
||||
description = "ALB ARN suffixes (app/name/id)"
|
||||
}
|
||||
|
||||
variable "api_gateway_apis" {
|
||||
type = list(string)
|
||||
default = []
|
||||
description = "API Gateway API IDs"
|
||||
}
|
||||
|
||||
variable "sqs_queues" {
|
||||
type = list(string)
|
||||
default = []
|
||||
description = "SQS queue names"
|
||||
}
|
||||
|
||||
variable "dynamodb_tables" {
|
||||
type = list(string)
|
||||
default = []
|
||||
description = "DynamoDB table names"
|
||||
}
|
||||
|
||||
variable "tags" {
|
||||
type = map(string)
|
||||
default = {}
|
||||
}
|
||||
|
||||
data "aws_region" "current" {}
|
||||
|
||||
locals {
|
||||
region = data.aws_region.current.name
|
||||
|
||||
# ECS widgets
|
||||
ecs_widgets = length(var.ecs_clusters) > 0 ? [
|
||||
{
|
||||
type = "metric"
|
||||
x = 0
|
||||
y = 0
|
||||
width = 12
|
||||
height = 6
|
||||
properties = {
|
||||
title = "ECS CPU Utilization"
|
||||
region = local.region
|
||||
metrics = [
|
||||
for i, cluster in var.ecs_clusters : [
|
||||
"AWS/ECS", "CPUUtilization",
|
||||
"ClusterName", cluster,
|
||||
"ServiceName", try(var.ecs_services[i], cluster)
|
||||
]
|
||||
]
|
||||
stat = "Average"
|
||||
period = 300
|
||||
}
|
||||
},
|
||||
{
|
||||
type = "metric"
|
||||
x = 12
|
||||
y = 0
|
||||
width = 12
|
||||
height = 6
|
||||
properties = {
|
||||
title = "ECS Memory Utilization"
|
||||
region = local.region
|
||||
metrics = [
|
||||
for i, cluster in var.ecs_clusters : [
|
||||
"AWS/ECS", "MemoryUtilization",
|
||||
"ClusterName", cluster,
|
||||
"ServiceName", try(var.ecs_services[i], cluster)
|
||||
]
|
||||
]
|
||||
stat = "Average"
|
||||
period = 300
|
||||
}
|
||||
}
|
||||
] : []
|
||||
|
||||
# RDS widgets
|
||||
rds_widgets = length(var.rds_instances) > 0 ? [
|
||||
{
|
||||
type = "metric"
|
||||
x = 0
|
||||
y = 6
|
||||
width = 8
|
||||
height = 6
|
||||
properties = {
|
||||
title = "RDS CPU Utilization"
|
||||
region = local.region
|
||||
metrics = [
|
||||
for db in var.rds_instances : [
|
||||
"AWS/RDS", "CPUUtilization",
|
||||
"DBInstanceIdentifier", db
|
||||
]
|
||||
]
|
||||
stat = "Average"
|
||||
period = 300
|
||||
}
|
||||
},
|
||||
{
|
||||
type = "metric"
|
||||
x = 8
|
||||
y = 6
|
||||
width = 8
|
||||
height = 6
|
||||
properties = {
|
||||
title = "RDS Database Connections"
|
||||
region = local.region
|
||||
metrics = [
|
||||
for db in var.rds_instances : [
|
||||
"AWS/RDS", "DatabaseConnections",
|
||||
"DBInstanceIdentifier", db
|
||||
]
|
||||
]
|
||||
stat = "Average"
|
||||
period = 300
|
||||
}
|
||||
},
|
||||
{
|
||||
type = "metric"
|
||||
x = 16
|
||||
y = 6
|
||||
width = 8
|
||||
height = 6
|
||||
properties = {
|
||||
title = "RDS Free Storage"
|
||||
region = local.region
|
||||
metrics = [
|
||||
for db in var.rds_instances : [
|
||||
"AWS/RDS", "FreeStorageSpace",
|
||||
"DBInstanceIdentifier", db
|
||||
]
|
||||
]
|
||||
stat = "Average"
|
||||
period = 300
|
||||
}
|
||||
}
|
||||
] : []
|
||||
|
||||
# Lambda widgets
|
||||
lambda_widgets = length(var.lambda_functions) > 0 ? [
|
||||
{
|
||||
type = "metric"
|
||||
x = 0
|
||||
y = 12
|
||||
width = 8
|
||||
height = 6
|
||||
properties = {
|
||||
title = "Lambda Invocations"
|
||||
region = local.region
|
||||
metrics = [
|
||||
for fn in var.lambda_functions : [
|
||||
"AWS/Lambda", "Invocations",
|
||||
"FunctionName", fn
|
||||
]
|
||||
]
|
||||
stat = "Sum"
|
||||
period = 300
|
||||
}
|
||||
},
|
||||
{
|
||||
type = "metric"
|
||||
x = 8
|
||||
y = 12
|
||||
width = 8
|
||||
height = 6
|
||||
properties = {
|
||||
title = "Lambda Errors"
|
||||
region = local.region
|
||||
metrics = [
|
||||
for fn in var.lambda_functions : [
|
||||
"AWS/Lambda", "Errors",
|
||||
"FunctionName", fn
|
||||
]
|
||||
]
|
||||
stat = "Sum"
|
||||
period = 300
|
||||
}
|
||||
},
|
||||
{
|
||||
type = "metric"
|
||||
x = 16
|
||||
y = 12
|
||||
width = 8
|
||||
height = 6
|
||||
properties = {
|
||||
title = "Lambda Duration"
|
||||
region = local.region
|
||||
metrics = [
|
||||
for fn in var.lambda_functions : [
|
||||
"AWS/Lambda", "Duration",
|
||||
"FunctionName", fn
|
||||
]
|
||||
]
|
||||
stat = "Average"
|
||||
period = 300
|
||||
}
|
||||
}
|
||||
] : []
|
||||
|
||||
# ALB widgets
|
||||
alb_widgets = length(var.alb_arns) > 0 ? [
|
||||
{
|
||||
type = "metric"
|
||||
x = 0
|
||||
y = 18
|
||||
width = 8
|
||||
height = 6
|
||||
properties = {
|
||||
title = "ALB Request Count"
|
||||
region = local.region
|
||||
metrics = [
|
||||
for alb in var.alb_arns : [
|
||||
"AWS/ApplicationELB", "RequestCount",
|
||||
"LoadBalancer", alb
|
||||
]
|
||||
]
|
||||
stat = "Sum"
|
||||
period = 300
|
||||
}
|
||||
},
|
||||
{
|
||||
type = "metric"
|
||||
x = 8
|
||||
y = 18
|
||||
width = 8
|
||||
height = 6
|
||||
properties = {
|
||||
title = "ALB 5xx Errors"
|
||||
region = local.region
|
||||
metrics = [
|
||||
for alb in var.alb_arns : [
|
||||
"AWS/ApplicationELB", "HTTPCode_ELB_5XX_Count",
|
||||
"LoadBalancer", alb
|
||||
]
|
||||
]
|
||||
stat = "Sum"
|
||||
period = 300
|
||||
}
|
||||
},
|
||||
{
|
||||
type = "metric"
|
||||
x = 16
|
||||
y = 18
|
||||
width = 8
|
||||
height = 6
|
||||
properties = {
|
||||
title = "ALB Response Time"
|
||||
region = local.region
|
||||
metrics = [
|
||||
for alb in var.alb_arns : [
|
||||
"AWS/ApplicationELB", "TargetResponseTime",
|
||||
"LoadBalancer", alb
|
||||
]
|
||||
]
|
||||
stat = "Average"
|
||||
period = 300
|
||||
}
|
||||
}
|
||||
] : []
|
||||
|
||||
# SQS widgets
|
||||
sqs_widgets = length(var.sqs_queues) > 0 ? [
|
||||
{
|
||||
type = "metric"
|
||||
x = 0
|
||||
y = 24
|
||||
width = 12
|
||||
height = 6
|
||||
properties = {
|
||||
title = "SQS Messages Visible"
|
||||
region = local.region
|
||||
metrics = [
|
||||
for q in var.sqs_queues : [
|
||||
"AWS/SQS", "ApproximateNumberOfMessagesVisible",
|
||||
"QueueName", q
|
||||
]
|
||||
]
|
||||
stat = "Average"
|
||||
period = 300
|
||||
}
|
||||
},
|
||||
{
|
||||
type = "metric"
|
||||
x = 12
|
||||
y = 24
|
||||
width = 12
|
||||
height = 6
|
||||
properties = {
|
||||
title = "SQS Age of Oldest Message"
|
||||
region = local.region
|
||||
metrics = [
|
||||
for q in var.sqs_queues : [
|
||||
"AWS/SQS", "ApproximateAgeOfOldestMessage",
|
||||
"QueueName", q
|
||||
]
|
||||
]
|
||||
stat = "Maximum"
|
||||
period = 300
|
||||
}
|
||||
}
|
||||
] : []
|
||||
|
||||
# DynamoDB widgets
|
||||
dynamodb_widgets = length(var.dynamodb_tables) > 0 ? [
|
||||
{
|
||||
type = "metric"
|
||||
x = 0
|
||||
y = 30
|
||||
width = 12
|
||||
height = 6
|
||||
properties = {
|
||||
title = "DynamoDB Read Capacity"
|
||||
region = local.region
|
||||
metrics = [
|
||||
for t in var.dynamodb_tables : [
|
||||
"AWS/DynamoDB", "ConsumedReadCapacityUnits",
|
||||
"TableName", t
|
||||
]
|
||||
]
|
||||
stat = "Sum"
|
||||
period = 300
|
||||
}
|
||||
},
|
||||
{
|
||||
type = "metric"
|
||||
x = 12
|
||||
y = 30
|
||||
width = 12
|
||||
height = 6
|
||||
properties = {
|
||||
title = "DynamoDB Write Capacity"
|
||||
region = local.region
|
||||
metrics = [
|
||||
for t in var.dynamodb_tables : [
|
||||
"AWS/DynamoDB", "ConsumedWriteCapacityUnits",
|
||||
"TableName", t
|
||||
]
|
||||
]
|
||||
stat = "Sum"
|
||||
period = 300
|
||||
}
|
||||
}
|
||||
] : []
|
||||
|
||||
all_widgets = concat(
|
||||
local.ecs_widgets,
|
||||
local.rds_widgets,
|
||||
local.lambda_widgets,
|
||||
local.alb_widgets,
|
||||
local.sqs_widgets,
|
||||
local.dynamodb_widgets
|
||||
)
|
||||
}
|
||||
|
||||
resource "aws_cloudwatch_dashboard" "main" {
|
||||
dashboard_name = var.name
|
||||
|
||||
dashboard_body = jsonencode({
|
||||
widgets = local.all_widgets
|
||||
})
|
||||
}
|
||||
|
||||
output "dashboard_name" {
|
||||
value = aws_cloudwatch_dashboard.main.dashboard_name
|
||||
}
|
||||
|
||||
output "dashboard_arn" {
|
||||
value = aws_cloudwatch_dashboard.main.dashboard_arn
|
||||
}
|
||||
51
terraform/modules/config-rules/README.md
Normal file
51
terraform/modules/config-rules/README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# config-rules
|
||||
|
||||
AWS Config Rules Module
|
||||
|
||||
## Usage
|
||||
|
||||
```hcl
|
||||
module "config_rules" {
|
||||
source = "../modules/config-rules"
|
||||
|
||||
# Required variables
|
||||
|
||||
# Optional: see variables.tf for all options
|
||||
}
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
| Name | Version |
|
||||
|------|---------|
|
||||
| terraform | >= 1.5.0 |
|
||||
| aws | >= 5.0 |
|
||||
|
||||
## Inputs
|
||||
|
||||
| Name | Description | Type | Required |
|
||||
|------|-------------|------|----------|
|
||||
| enable_aws_config | Enable AWS Config (required for rules) | `bool` | no |
|
||||
| config_bucket | S3 bucket for Config snapshots (created if empty) | `string` | no |
|
||||
| config_sns_topic_arn | SNS topic for Config notifications | `string` | no |
|
||||
| delivery_frequency | Config snapshot delivery frequency | `string` | no |
|
||||
| enable_cis_benchmark | Enable CIS AWS Foundations Benchmark rules | `bool` | no |
|
||||
| enable_security_best_practices | Enable AWS Security Best Practices rules | `bool` | no |
|
||||
| enable_pci_dss | Enable PCI DSS compliance rules | `bool` | no |
|
||||
| enable_hipaa | Enable HIPAA compliance rules | `bool` | no |
|
||||
| rules | | `object({` | no |
|
||||
| auto_remediation | Enable auto-remediation for supported rules | `bool` | no |
|
||||
| tags | | `map(string)` | no |
|
||||
|
||||
## Outputs
|
||||
|
||||
| Name | Description |
|
||||
|------|-------------|
|
||||
| config_recorder_id | Config recorder ID |
|
||||
| config_bucket | S3 bucket for Config snapshots |
|
||||
| enabled_rules | |
|
||||
| compliance_packs | |
|
||||
|
||||
## License
|
||||
|
||||
Apache 2.0 - See LICENSE for details.
|
||||
514
terraform/modules/config-rules/main.tf
Normal file
514
terraform/modules/config-rules/main.tf
Normal file
@@ -0,0 +1,514 @@
|
||||
################################################################################
|
||||
# AWS Config Rules Module
|
||||
#
|
||||
# Compliance monitoring with managed rules:
|
||||
# - CIS AWS Foundations Benchmark
|
||||
# - PCI DSS
|
||||
# - HIPAA
|
||||
# - Custom rules
|
||||
# - Auto-remediation (optional)
|
||||
#
|
||||
# Usage:
|
||||
# module "config_rules" {
|
||||
# source = "../modules/config-rules"
|
||||
#
|
||||
# enable_cis_benchmark = true
|
||||
# enable_security_best_practices = true
|
||||
#
|
||||
# # Or pick individual rules
|
||||
# rules = {
|
||||
# s3-bucket-ssl = true
|
||||
# ec2-imdsv2 = true
|
||||
# }
|
||||
# }
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "enable_aws_config" {
|
||||
type = bool
|
||||
default = true
|
||||
description = "Enable AWS Config (required for rules)"
|
||||
}
|
||||
|
||||
variable "config_bucket" {
|
||||
type = string
|
||||
default = ""
|
||||
description = "S3 bucket for Config snapshots (created if empty)"
|
||||
}
|
||||
|
||||
variable "config_sns_topic_arn" {
|
||||
type = string
|
||||
default = ""
|
||||
description = "SNS topic for Config notifications"
|
||||
}
|
||||
|
||||
variable "delivery_frequency" {
|
||||
type = string
|
||||
default = "TwentyFour_Hours"
|
||||
description = "Config snapshot delivery frequency"
|
||||
}
|
||||
|
||||
# Compliance Packs
|
||||
variable "enable_cis_benchmark" {
|
||||
type = bool
|
||||
default = false
|
||||
description = "Enable CIS AWS Foundations Benchmark rules"
|
||||
}
|
||||
|
||||
variable "enable_security_best_practices" {
|
||||
type = bool
|
||||
default = true
|
||||
description = "Enable AWS Security Best Practices rules"
|
||||
}
|
||||
|
||||
variable "enable_pci_dss" {
|
||||
type = bool
|
||||
default = false
|
||||
description = "Enable PCI DSS compliance rules"
|
||||
}
|
||||
|
||||
variable "enable_hipaa" {
|
||||
type = bool
|
||||
default = false
|
||||
description = "Enable HIPAA compliance rules"
|
||||
}
|
||||
|
||||
# Individual Rules (all optional)
|
||||
variable "rules" {
|
||||
type = object({
|
||||
# S3 Security
|
||||
s3_bucket_public_read_prohibited = optional(bool, true)
|
||||
s3_bucket_public_write_prohibited = optional(bool, true)
|
||||
s3_bucket_ssl_requests_only = optional(bool, true)
|
||||
s3_bucket_logging_enabled = optional(bool, false)
|
||||
s3_bucket_versioning_enabled = optional(bool, false)
|
||||
s3_default_encryption_kms = optional(bool, false)
|
||||
|
||||
# EC2 Security
|
||||
ec2_imdsv2_check = optional(bool, true)
|
||||
ec2_instance_no_public_ip = optional(bool, false)
|
||||
ec2_ebs_encryption_by_default = optional(bool, true)
|
||||
ec2_security_group_attached_to_eni = optional(bool, false)
|
||||
restricted_ssh = optional(bool, true)
|
||||
restricted_rdp = optional(bool, true)
|
||||
|
||||
# IAM Security
|
||||
iam_root_access_key_check = optional(bool, true)
|
||||
iam_user_mfa_enabled = optional(bool, true)
|
||||
iam_user_no_policies_check = optional(bool, true)
|
||||
iam_password_policy = optional(bool, true)
|
||||
access_keys_rotated = optional(bool, true)
|
||||
access_keys_rotated_days = optional(number, 90)
|
||||
|
||||
# RDS Security
|
||||
rds_instance_public_access_check = optional(bool, true)
|
||||
rds_storage_encrypted = optional(bool, true)
|
||||
rds_multi_az_support = optional(bool, false)
|
||||
rds_snapshot_encrypted = optional(bool, true)
|
||||
|
||||
# Network Security
|
||||
vpc_flow_logs_enabled = optional(bool, true)
|
||||
vpc_default_security_group_closed = optional(bool, true)
|
||||
|
||||
# Encryption
|
||||
kms_cmk_not_scheduled_for_deletion = optional(bool, true)
|
||||
encrypted_volumes = optional(bool, true)
|
||||
|
||||
# Logging & Monitoring
|
||||
cloudtrail_enabled = optional(bool, true)
|
||||
cloudwatch_alarm_action_check = optional(bool, false)
|
||||
cw_loggroup_retention_period_check = optional(bool, false)
|
||||
guardduty_enabled_centralized = optional(bool, false)
|
||||
|
||||
# Lambda
|
||||
lambda_function_public_access_prohibited = optional(bool, true)
|
||||
lambda_inside_vpc = optional(bool, false)
|
||||
})
|
||||
default = {}
|
||||
description = "Individual Config rules to enable"
|
||||
}
|
||||
|
||||
variable "auto_remediation" {
|
||||
type = bool
|
||||
default = false
|
||||
description = "Enable auto-remediation for supported rules"
|
||||
}
|
||||
|
||||
variable "tags" {
|
||||
type = map(string)
|
||||
default = {}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Data Sources
|
||||
################################################################################
|
||||
|
||||
data "aws_caller_identity" "current" {}
|
||||
data "aws_region" "current" {}
|
||||
|
||||
################################################################################
|
||||
# S3 Bucket for Config
|
||||
################################################################################
|
||||
|
||||
resource "aws_s3_bucket" "config" {
|
||||
count = var.enable_aws_config && var.config_bucket == "" ? 1 : 0
|
||||
bucket = "aws-config-${data.aws_caller_identity.current.account_id}-${data.aws_region.current.name}"
|
||||
|
||||
tags = merge(var.tags, { Name = "aws-config" })
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket_versioning" "config" {
|
||||
count = var.enable_aws_config && var.config_bucket == "" ? 1 : 0
|
||||
bucket = aws_s3_bucket.config[0].id
|
||||
versioning_configuration { status = "Enabled" }
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket_server_side_encryption_configuration" "config" {
|
||||
count = var.enable_aws_config && var.config_bucket == "" ? 1 : 0
|
||||
bucket = aws_s3_bucket.config[0].id
|
||||
rule {
|
||||
apply_server_side_encryption_by_default { sse_algorithm = "AES256" }
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket_public_access_block" "config" {
|
||||
count = var.enable_aws_config && var.config_bucket == "" ? 1 : 0
|
||||
bucket = aws_s3_bucket.config[0].id
|
||||
block_public_acls = true
|
||||
block_public_policy = true
|
||||
ignore_public_acls = true
|
||||
restrict_public_buckets = true
|
||||
}
|
||||
|
||||
locals {
|
||||
config_bucket = var.config_bucket != "" ? var.config_bucket : (var.enable_aws_config ? aws_s3_bucket.config[0].id : "")
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# IAM Role for Config
|
||||
################################################################################
|
||||
|
||||
resource "aws_iam_role" "config" {
|
||||
count = var.enable_aws_config ? 1 : 0
|
||||
name = "AWSConfigRole"
|
||||
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = "sts:AssumeRole"
|
||||
Principal = { Service = "config.amazonaws.com" }
|
||||
}]
|
||||
})
|
||||
|
||||
tags = merge(var.tags, { Name = "AWSConfigRole" })
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy_attachment" "config" {
|
||||
count = var.enable_aws_config ? 1 : 0
|
||||
role = aws_iam_role.config[0].name
|
||||
policy_arn = "arn:aws:iam::aws:policy/service-role/AWS_ConfigRole"
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "config_s3" {
|
||||
count = var.enable_aws_config ? 1 : 0
|
||||
name = "s3-delivery"
|
||||
role = aws_iam_role.config[0].id
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Effect = "Allow"
|
||||
Action = ["s3:PutObject", "s3:PutObjectAcl"]
|
||||
Resource = "arn:aws:s3:::${local.config_bucket}/*"
|
||||
Condition = {
|
||||
StringLike = { "s3:x-amz-acl" = "bucket-owner-full-control" }
|
||||
}
|
||||
},
|
||||
{
|
||||
Effect = "Allow"
|
||||
Action = "s3:GetBucketAcl"
|
||||
Resource = "arn:aws:s3:::${local.config_bucket}"
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# AWS Config Recorder
|
||||
################################################################################
|
||||
|
||||
resource "aws_config_configuration_recorder" "main" {
|
||||
count = var.enable_aws_config ? 1 : 0
|
||||
name = "default"
|
||||
role_arn = aws_iam_role.config[0].arn
|
||||
|
||||
recording_group {
|
||||
all_supported = true
|
||||
include_global_resource_types = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_config_delivery_channel" "main" {
|
||||
count = var.enable_aws_config ? 1 : 0
|
||||
name = "default"
|
||||
s3_bucket_name = local.config_bucket
|
||||
sns_topic_arn = var.config_sns_topic_arn != "" ? var.config_sns_topic_arn : null
|
||||
|
||||
snapshot_delivery_properties {
|
||||
delivery_frequency = var.delivery_frequency
|
||||
}
|
||||
|
||||
depends_on = [aws_config_configuration_recorder.main]
|
||||
}
|
||||
|
||||
resource "aws_config_configuration_recorder_status" "main" {
|
||||
count = var.enable_aws_config ? 1 : 0
|
||||
name = aws_config_configuration_recorder.main[0].name
|
||||
is_enabled = true
|
||||
|
||||
depends_on = [aws_config_delivery_channel.main]
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Security Best Practices Rules
|
||||
################################################################################
|
||||
|
||||
# S3 Rules
|
||||
resource "aws_config_config_rule" "s3_bucket_public_read_prohibited" {
|
||||
count = var.enable_aws_config && (var.rules.s3_bucket_public_read_prohibited || var.enable_security_best_practices) ? 1 : 0
|
||||
name = "s3-bucket-public-read-prohibited"
|
||||
source {
|
||||
owner = "AWS"
|
||||
source_identifier = "S3_BUCKET_PUBLIC_READ_PROHIBITED"
|
||||
}
|
||||
depends_on = [aws_config_configuration_recorder.main]
|
||||
tags = var.tags
|
||||
}
|
||||
|
||||
resource "aws_config_config_rule" "s3_bucket_public_write_prohibited" {
|
||||
count = var.enable_aws_config && (var.rules.s3_bucket_public_write_prohibited || var.enable_security_best_practices) ? 1 : 0
|
||||
name = "s3-bucket-public-write-prohibited"
|
||||
source {
|
||||
owner = "AWS"
|
||||
source_identifier = "S3_BUCKET_PUBLIC_WRITE_PROHIBITED"
|
||||
}
|
||||
depends_on = [aws_config_configuration_recorder.main]
|
||||
tags = var.tags
|
||||
}
|
||||
|
||||
resource "aws_config_config_rule" "s3_bucket_ssl_requests_only" {
|
||||
count = var.enable_aws_config && (var.rules.s3_bucket_ssl_requests_only || var.enable_security_best_practices) ? 1 : 0
|
||||
name = "s3-bucket-ssl-requests-only"
|
||||
source {
|
||||
owner = "AWS"
|
||||
source_identifier = "S3_BUCKET_SSL_REQUESTS_ONLY"
|
||||
}
|
||||
depends_on = [aws_config_configuration_recorder.main]
|
||||
tags = var.tags
|
||||
}
|
||||
|
||||
# EC2 Rules
|
||||
resource "aws_config_config_rule" "ec2_imdsv2_check" {
|
||||
count = var.enable_aws_config && (var.rules.ec2_imdsv2_check || var.enable_security_best_practices) ? 1 : 0
|
||||
name = "ec2-imdsv2-check"
|
||||
source {
|
||||
owner = "AWS"
|
||||
source_identifier = "EC2_IMDSV2_CHECK"
|
||||
}
|
||||
depends_on = [aws_config_configuration_recorder.main]
|
||||
tags = var.tags
|
||||
}
|
||||
|
||||
resource "aws_config_config_rule" "ebs_encryption_by_default" {
|
||||
count = var.enable_aws_config && (var.rules.ec2_ebs_encryption_by_default || var.enable_security_best_practices) ? 1 : 0
|
||||
name = "ec2-ebs-encryption-by-default-check"
|
||||
source {
|
||||
owner = "AWS"
|
||||
source_identifier = "EC2_EBS_ENCRYPTION_BY_DEFAULT"
|
||||
}
|
||||
depends_on = [aws_config_configuration_recorder.main]
|
||||
tags = var.tags
|
||||
}
|
||||
|
||||
resource "aws_config_config_rule" "restricted_ssh" {
|
||||
count = var.enable_aws_config && (var.rules.restricted_ssh || var.enable_security_best_practices) ? 1 : 0
|
||||
name = "restricted-ssh"
|
||||
source {
|
||||
owner = "AWS"
|
||||
source_identifier = "INCOMING_SSH_DISABLED"
|
||||
}
|
||||
depends_on = [aws_config_configuration_recorder.main]
|
||||
tags = var.tags
|
||||
}
|
||||
|
||||
# IAM Rules
|
||||
resource "aws_config_config_rule" "iam_root_access_key_check" {
|
||||
count = var.enable_aws_config && (var.rules.iam_root_access_key_check || var.enable_security_best_practices) ? 1 : 0
|
||||
name = "iam-root-access-key-check"
|
||||
source {
|
||||
owner = "AWS"
|
||||
source_identifier = "IAM_ROOT_ACCESS_KEY_CHECK"
|
||||
}
|
||||
depends_on = [aws_config_configuration_recorder.main]
|
||||
tags = var.tags
|
||||
}
|
||||
|
||||
resource "aws_config_config_rule" "iam_user_mfa_enabled" {
|
||||
count = var.enable_aws_config && (var.rules.iam_user_mfa_enabled || var.enable_security_best_practices) ? 1 : 0
|
||||
name = "iam-user-mfa-enabled"
|
||||
source {
|
||||
owner = "AWS"
|
||||
source_identifier = "IAM_USER_MFA_ENABLED"
|
||||
}
|
||||
depends_on = [aws_config_configuration_recorder.main]
|
||||
tags = var.tags
|
||||
}
|
||||
|
||||
resource "aws_config_config_rule" "access_keys_rotated" {
|
||||
count = var.enable_aws_config && (var.rules.access_keys_rotated || var.enable_security_best_practices) ? 1 : 0
|
||||
name = "access-keys-rotated"
|
||||
source {
|
||||
owner = "AWS"
|
||||
source_identifier = "ACCESS_KEYS_ROTATED"
|
||||
}
|
||||
input_parameters = jsonencode({
|
||||
maxAccessKeyAge = var.rules.access_keys_rotated_days
|
||||
})
|
||||
depends_on = [aws_config_configuration_recorder.main]
|
||||
tags = var.tags
|
||||
}
|
||||
|
||||
# RDS Rules
|
||||
resource "aws_config_config_rule" "rds_instance_public_access_check" {
|
||||
count = var.enable_aws_config && (var.rules.rds_instance_public_access_check || var.enable_security_best_practices) ? 1 : 0
|
||||
name = "rds-instance-public-access-check"
|
||||
source {
|
||||
owner = "AWS"
|
||||
source_identifier = "RDS_INSTANCE_PUBLIC_ACCESS_CHECK"
|
||||
}
|
||||
depends_on = [aws_config_configuration_recorder.main]
|
||||
tags = var.tags
|
||||
}
|
||||
|
||||
resource "aws_config_config_rule" "rds_storage_encrypted" {
|
||||
count = var.enable_aws_config && (var.rules.rds_storage_encrypted || var.enable_security_best_practices) ? 1 : 0
|
||||
name = "rds-storage-encrypted"
|
||||
source {
|
||||
owner = "AWS"
|
||||
source_identifier = "RDS_STORAGE_ENCRYPTED"
|
||||
}
|
||||
depends_on = [aws_config_configuration_recorder.main]
|
||||
tags = var.tags
|
||||
}
|
||||
|
||||
# Network Rules
|
||||
resource "aws_config_config_rule" "vpc_flow_logs_enabled" {
|
||||
count = var.enable_aws_config && (var.rules.vpc_flow_logs_enabled || var.enable_security_best_practices) ? 1 : 0
|
||||
name = "vpc-flow-logs-enabled"
|
||||
source {
|
||||
owner = "AWS"
|
||||
source_identifier = "VPC_FLOW_LOGS_ENABLED"
|
||||
}
|
||||
depends_on = [aws_config_configuration_recorder.main]
|
||||
tags = var.tags
|
||||
}
|
||||
|
||||
resource "aws_config_config_rule" "vpc_default_security_group_closed" {
|
||||
count = var.enable_aws_config && (var.rules.vpc_default_security_group_closed || var.enable_security_best_practices) ? 1 : 0
|
||||
name = "vpc-default-security-group-closed"
|
||||
source {
|
||||
owner = "AWS"
|
||||
source_identifier = "VPC_DEFAULT_SECURITY_GROUP_CLOSED"
|
||||
}
|
||||
depends_on = [aws_config_configuration_recorder.main]
|
||||
tags = var.tags
|
||||
}
|
||||
|
||||
# CloudTrail Rule
|
||||
resource "aws_config_config_rule" "cloudtrail_enabled" {
|
||||
count = var.enable_aws_config && (var.rules.cloudtrail_enabled || var.enable_security_best_practices) ? 1 : 0
|
||||
name = "cloudtrail-enabled"
|
||||
source {
|
||||
owner = "AWS"
|
||||
source_identifier = "CLOUD_TRAIL_ENABLED"
|
||||
}
|
||||
depends_on = [aws_config_configuration_recorder.main]
|
||||
tags = var.tags
|
||||
}
|
||||
|
||||
# Encryption Rules
|
||||
resource "aws_config_config_rule" "encrypted_volumes" {
|
||||
count = var.enable_aws_config && (var.rules.encrypted_volumes || var.enable_security_best_practices) ? 1 : 0
|
||||
name = "encrypted-volumes"
|
||||
source {
|
||||
owner = "AWS"
|
||||
source_identifier = "ENCRYPTED_VOLUMES"
|
||||
}
|
||||
depends_on = [aws_config_configuration_recorder.main]
|
||||
tags = var.tags
|
||||
}
|
||||
|
||||
# Lambda Rules
|
||||
resource "aws_config_config_rule" "lambda_function_public_access_prohibited" {
|
||||
count = var.enable_aws_config && (var.rules.lambda_function_public_access_prohibited || var.enable_security_best_practices) ? 1 : 0
|
||||
name = "lambda-function-public-access-prohibited"
|
||||
source {
|
||||
owner = "AWS"
|
||||
source_identifier = "LAMBDA_FUNCTION_PUBLIC_ACCESS_PROHIBITED"
|
||||
}
|
||||
depends_on = [aws_config_configuration_recorder.main]
|
||||
tags = var.tags
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Outputs
|
||||
################################################################################
|
||||
|
||||
output "config_recorder_id" {
|
||||
value = var.enable_aws_config ? aws_config_configuration_recorder.main[0].id : null
|
||||
description = "Config recorder ID"
|
||||
}
|
||||
|
||||
output "config_bucket" {
|
||||
value = local.config_bucket
|
||||
description = "S3 bucket for Config snapshots"
|
||||
}
|
||||
|
||||
output "enabled_rules" {
|
||||
value = var.enable_aws_config ? {
|
||||
s3_public_read = var.rules.s3_bucket_public_read_prohibited || var.enable_security_best_practices
|
||||
s3_public_write = var.rules.s3_bucket_public_write_prohibited || var.enable_security_best_practices
|
||||
s3_ssl_only = var.rules.s3_bucket_ssl_requests_only || var.enable_security_best_practices
|
||||
ec2_imdsv2 = var.rules.ec2_imdsv2_check || var.enable_security_best_practices
|
||||
ebs_encryption = var.rules.ec2_ebs_encryption_by_default || var.enable_security_best_practices
|
||||
restricted_ssh = var.rules.restricted_ssh || var.enable_security_best_practices
|
||||
iam_root_key = var.rules.iam_root_access_key_check || var.enable_security_best_practices
|
||||
iam_mfa = var.rules.iam_user_mfa_enabled || var.enable_security_best_practices
|
||||
access_key_rotation = var.rules.access_keys_rotated || var.enable_security_best_practices
|
||||
rds_public = var.rules.rds_instance_public_access_check || var.enable_security_best_practices
|
||||
rds_encrypted = var.rules.rds_storage_encrypted || var.enable_security_best_practices
|
||||
vpc_flow_logs = var.rules.vpc_flow_logs_enabled || var.enable_security_best_practices
|
||||
cloudtrail = var.rules.cloudtrail_enabled || var.enable_security_best_practices
|
||||
} : null
|
||||
description = "List of enabled Config rules"
|
||||
}
|
||||
|
||||
output "compliance_packs" {
|
||||
value = {
|
||||
cis_benchmark = var.enable_cis_benchmark
|
||||
security_best = var.enable_security_best_practices
|
||||
pci_dss = var.enable_pci_dss
|
||||
hipaa = var.enable_hipaa
|
||||
}
|
||||
description = "Enabled compliance packs"
|
||||
}
|
||||
229
terraform/modules/github-oidc/README.md
Normal file
229
terraform/modules/github-oidc/README.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# GitHub OIDC Module
|
||||
|
||||
Secure CI/CD access from GitHub Actions to AWS without long-lived credentials.
|
||||
|
||||
## Features
|
||||
|
||||
- 🔐 **OIDC Provider** - Automatic setup of GitHub OIDC trust
|
||||
- 🎯 **Fine-grained access** - Restrict by repo, branch, tag, environment
|
||||
- 📦 **Pre-built templates** - Common patterns for Terraform, ECR, S3, Lambda
|
||||
- 🔧 **Custom roles** - Full flexibility for any use case
|
||||
- 📝 **Policy generation** - Build policies from simple statements
|
||||
|
||||
## Quick Start
|
||||
|
||||
```hcl
|
||||
module "github_oidc" {
|
||||
source = "../modules/github-oidc"
|
||||
|
||||
github_org = "myorg"
|
||||
|
||||
# Custom role
|
||||
roles = {
|
||||
deploy = {
|
||||
repos = ["myrepo"]
|
||||
branches = ["main"]
|
||||
policy_arns = ["arn:aws:iam::aws:policy/PowerUserAccess"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Pre-built Templates
|
||||
|
||||
### Terraform Deployments
|
||||
|
||||
```hcl
|
||||
module "github_oidc" {
|
||||
source = "../modules/github-oidc"
|
||||
|
||||
github_org = "myorg"
|
||||
|
||||
terraform_deploy_role = {
|
||||
enabled = true
|
||||
repos = ["infrastructure"]
|
||||
branches = ["main"]
|
||||
environments = ["production"]
|
||||
state_bucket = "myorg-tf-state"
|
||||
dynamodb_table = "terraform-locks"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ECR Push
|
||||
|
||||
```hcl
|
||||
module "github_oidc" {
|
||||
source = "../modules/github-oidc"
|
||||
|
||||
github_org = "myorg"
|
||||
|
||||
ecr_push_role = {
|
||||
enabled = true
|
||||
repos = ["backend", "frontend"]
|
||||
branches = ["main", "develop"]
|
||||
ecr_repos = ["backend", "frontend"]
|
||||
allow_create = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### S3 Static Site Deploy
|
||||
|
||||
```hcl
|
||||
module "github_oidc" {
|
||||
source = "../modules/github-oidc"
|
||||
|
||||
github_org = "myorg"
|
||||
|
||||
s3_deploy_role = {
|
||||
enabled = true
|
||||
repos = ["website"]
|
||||
branches = ["main"]
|
||||
bucket_arns = ["arn:aws:s3:::mysite.com"]
|
||||
cloudfront_arns = ["arn:aws:cloudfront::123456789012:distribution/EXAMPLE"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Lambda Deploy
|
||||
|
||||
```hcl
|
||||
module "github_oidc" {
|
||||
source = "../modules/github-oidc"
|
||||
|
||||
github_org = "myorg"
|
||||
|
||||
lambda_deploy_role = {
|
||||
enabled = true
|
||||
repos = ["serverless-api"]
|
||||
branches = ["main"]
|
||||
function_arns = ["arn:aws:lambda:us-east-1:123456789012:function:my-api"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Multiple Custom Roles
|
||||
|
||||
```hcl
|
||||
module "github_oidc" {
|
||||
source = "../modules/github-oidc"
|
||||
|
||||
github_org = "myorg"
|
||||
|
||||
roles = {
|
||||
# Read-only for PRs
|
||||
preview = {
|
||||
repos = ["webapp"]
|
||||
pull_request = true
|
||||
policy_arns = ["arn:aws:iam::aws:policy/ReadOnlyAccess"]
|
||||
}
|
||||
|
||||
# Full deploy for main
|
||||
deploy = {
|
||||
repos = ["webapp"]
|
||||
branches = ["main"]
|
||||
policy_arns = ["arn:aws:iam::aws:policy/PowerUserAccess"]
|
||||
}
|
||||
|
||||
# Tag-based releases
|
||||
release = {
|
||||
repos = ["webapp"]
|
||||
tags = ["v*"]
|
||||
policy_statements = [{
|
||||
actions = ["s3:PutObject", "cloudfront:CreateInvalidation"]
|
||||
resources = ["*"]
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Reusable Workflow Restriction
|
||||
|
||||
```hcl
|
||||
roles = {
|
||||
deploy = {
|
||||
repos = ["*"] # Any repo in org
|
||||
workflow_ref = "myorg/workflows/.github/workflows/deploy.yml@main"
|
||||
policy_arns = ["arn:aws:iam::aws:policy/PowerUserAccess"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Trust Conditions
|
||||
|
||||
```hcl
|
||||
roles = {
|
||||
restricted = {
|
||||
repos = ["myrepo"]
|
||||
branches = ["main"]
|
||||
extra_conditions = {
|
||||
StringEquals = {
|
||||
"token.actions.githubusercontent.com:actor" = ["trusted-user"]
|
||||
}
|
||||
}
|
||||
policy_arns = ["arn:aws:iam::aws:policy/AdministratorAccess"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## GitHub Actions Workflow
|
||||
|
||||
```yaml
|
||||
name: Deploy
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write # Required for OIDC
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::123456789012:role/github-deploy
|
||||
aws-region: us-east-1
|
||||
|
||||
- run: aws sts get-caller-identity
|
||||
```
|
||||
|
||||
## Inputs
|
||||
|
||||
| Name | Description | Type | Default |
|
||||
|------|-------------|------|---------|
|
||||
| `create_provider` | Create OIDC provider | `bool` | `true` |
|
||||
| `provider_arn` | Existing provider ARN | `string` | `""` |
|
||||
| `github_org` | GitHub organization | `string` | `""` |
|
||||
| `name_prefix` | Role name prefix | `string` | `"github"` |
|
||||
| `roles` | Custom role configs | `map(object)` | `{}` |
|
||||
| `terraform_deploy_role` | Terraform template | `object` | `{}` |
|
||||
| `ecr_push_role` | ECR template | `object` | `{}` |
|
||||
| `s3_deploy_role` | S3 template | `object` | `{}` |
|
||||
| `lambda_deploy_role` | Lambda template | `object` | `{}` |
|
||||
|
||||
## Outputs
|
||||
|
||||
| Name | Description |
|
||||
|------|-------------|
|
||||
| `provider_arn` | OIDC provider ARN |
|
||||
| `role_arns` | Map of custom role ARNs |
|
||||
| `all_role_arns` | All role ARNs (custom + templates) |
|
||||
| `terraform_role_arn` | Terraform role ARN |
|
||||
| `ecr_role_arn` | ECR role ARN |
|
||||
| `workflow_examples` | Example workflow snippets |
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Principle of least privilege** - Use specific repos/branches, not wildcards
|
||||
2. **Environment protection** - Use GitHub environments for production
|
||||
3. **Permissions boundary** - Consider attaching a boundary for defense-in-depth
|
||||
4. **Audit** - CloudTrail logs all AssumeRoleWithWebIdentity calls
|
||||
54
terraform/modules/github-oidc/examples/basic/main.tf
Normal file
54
terraform/modules/github-oidc/examples/basic/main.tf
Normal file
@@ -0,0 +1,54 @@
|
||||
################################################################################
|
||||
# GitHub OIDC - Basic Example
|
||||
#
|
||||
# Single role with branch restriction
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.5.0"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "aws" {
|
||||
region = "us-east-1"
|
||||
}
|
||||
|
||||
module "github_oidc" {
|
||||
source = "../../"
|
||||
|
||||
github_org = "example-org"
|
||||
name_prefix = "github"
|
||||
|
||||
roles = {
|
||||
deploy = {
|
||||
repos = ["my-app"]
|
||||
branches = ["main"]
|
||||
policy_statements = [
|
||||
{
|
||||
sid = "S3Access"
|
||||
actions = ["s3:GetObject", "s3:PutObject"]
|
||||
resources = ["arn:aws:s3:::my-bucket/*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
tags = {
|
||||
Environment = "production"
|
||||
Project = "my-app"
|
||||
}
|
||||
}
|
||||
|
||||
output "role_arn" {
|
||||
value = module.github_oidc.role_arns["deploy"]
|
||||
}
|
||||
|
||||
output "provider_arn" {
|
||||
value = module.github_oidc.provider_arn
|
||||
}
|
||||
126
terraform/modules/github-oidc/examples/multi-role/main.tf
Normal file
126
terraform/modules/github-oidc/examples/multi-role/main.tf
Normal file
@@ -0,0 +1,126 @@
|
||||
################################################################################
|
||||
# GitHub OIDC - Multi-Role Example
|
||||
#
|
||||
# Multiple roles with different permission levels
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.5.0"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "aws" {
|
||||
region = "us-east-1"
|
||||
}
|
||||
|
||||
# Permissions boundary for defense-in-depth
|
||||
resource "aws_iam_policy" "github_boundary" {
|
||||
name = "GitHubActionsBoundary"
|
||||
description = "Permissions boundary for GitHub Actions roles"
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Sid = "AllowedServices"
|
||||
Effect = "Allow"
|
||||
Action = ["s3:*", "ecr:*", "lambda:*", "logs:*", "cloudwatch:*"]
|
||||
Resource = "*"
|
||||
},
|
||||
{
|
||||
Sid = "DenyDangerous"
|
||||
Effect = "Deny"
|
||||
Action = [
|
||||
"iam:CreateUser",
|
||||
"iam:CreateAccessKey",
|
||||
"organizations:*",
|
||||
"account:*"
|
||||
]
|
||||
Resource = "*"
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
module "github_oidc" {
|
||||
source = "../../"
|
||||
|
||||
github_org = "example-org"
|
||||
name_prefix = "github"
|
||||
permissions_boundary = aws_iam_policy.github_boundary.arn
|
||||
|
||||
# Security settings
|
||||
max_session_hours_limit = 2
|
||||
deny_wildcard_repos = true
|
||||
|
||||
roles = {
|
||||
# Read-only for PR validation
|
||||
validate = {
|
||||
repos = ["infrastructure", "application"]
|
||||
pull_request = true
|
||||
policy_arns = ["arn:aws:iam::aws:policy/ReadOnlyAccess"]
|
||||
max_session_hours = 1
|
||||
}
|
||||
|
||||
# Deploy from main branch only
|
||||
deploy = {
|
||||
repos = ["infrastructure"]
|
||||
branches = ["main"]
|
||||
environments = ["production"]
|
||||
policy_statements = [
|
||||
{
|
||||
sid = "DeployAccess"
|
||||
actions = ["s3:*", "cloudfront:*", "lambda:*"]
|
||||
resources = ["*"]
|
||||
}
|
||||
]
|
||||
max_session_hours = 2
|
||||
}
|
||||
|
||||
# Release automation from tags
|
||||
release = {
|
||||
repos = ["application"]
|
||||
tags = ["v*", "release-*"]
|
||||
branches = [] # Only tags
|
||||
policy_statements = [
|
||||
{
|
||||
sid = "ECRPush"
|
||||
actions = ["ecr:*"]
|
||||
resources = ["arn:aws:ecr:*:*:repository/application"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# Reusable workflow restriction
|
||||
shared = {
|
||||
repos = ["*"] # Any repo
|
||||
workflow_ref = "example-org/shared-workflows/.github/workflows/deploy.yml@main"
|
||||
policy_statements = [
|
||||
{
|
||||
sid = "SharedDeploy"
|
||||
actions = ["s3:PutObject"]
|
||||
resources = ["arn:aws:s3:::artifacts-bucket/*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
tags = {
|
||||
Environment = "production"
|
||||
CostCenter = "platform"
|
||||
}
|
||||
}
|
||||
|
||||
output "all_roles" {
|
||||
value = module.github_oidc.all_role_arns
|
||||
}
|
||||
|
||||
output "security_status" {
|
||||
value = module.github_oidc.security_recommendations
|
||||
}
|
||||
159
terraform/modules/github-oidc/examples/templates/main.tf
Normal file
159
terraform/modules/github-oidc/examples/templates/main.tf
Normal file
@@ -0,0 +1,159 @@
|
||||
################################################################################
|
||||
# GitHub OIDC - Pre-built Templates Example
|
||||
#
|
||||
# Using pre-built role templates for common patterns
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.5.0"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "aws" {
|
||||
region = "us-east-1"
|
||||
}
|
||||
|
||||
data "aws_caller_identity" "current" {}
|
||||
|
||||
# Prerequisites - S3 bucket for Terraform state
|
||||
resource "aws_s3_bucket" "terraform_state" {
|
||||
bucket_prefix = "terraform-state-"
|
||||
force_destroy = true # For example only - remove in production
|
||||
|
||||
tags = {
|
||||
Purpose = "terraform-state"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket_versioning" "terraform_state" {
|
||||
bucket = aws_s3_bucket.terraform_state.id
|
||||
versioning_configuration {
|
||||
status = "Enabled"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_dynamodb_table" "terraform_locks" {
|
||||
name = "terraform-locks"
|
||||
billing_mode = "PAY_PER_REQUEST"
|
||||
hash_key = "LockID"
|
||||
|
||||
attribute {
|
||||
name = "LockID"
|
||||
type = "S"
|
||||
}
|
||||
|
||||
tags = {
|
||||
Purpose = "terraform-locks"
|
||||
}
|
||||
}
|
||||
|
||||
# ECR repository for container builds
|
||||
resource "aws_ecr_repository" "app" {
|
||||
name = "my-application"
|
||||
image_tag_mutability = "IMMUTABLE"
|
||||
|
||||
image_scanning_configuration {
|
||||
scan_on_push = true
|
||||
}
|
||||
|
||||
tags = {
|
||||
Purpose = "container-registry"
|
||||
}
|
||||
}
|
||||
|
||||
# GitHub OIDC with all templates enabled
|
||||
module "github_oidc" {
|
||||
source = "../../"
|
||||
|
||||
github_org = "example-org"
|
||||
name_prefix = "github"
|
||||
|
||||
# Terraform deployment role
|
||||
terraform_deploy_role = {
|
||||
enabled = true
|
||||
repos = ["infrastructure"]
|
||||
branches = ["main"]
|
||||
environments = ["production"]
|
||||
state_bucket = aws_s3_bucket.terraform_state.id
|
||||
state_bucket_key_prefix = "live/*"
|
||||
dynamodb_table = aws_dynamodb_table.terraform_locks.name
|
||||
allowed_services = ["ec2", "s3", "iam", "lambda", "rds", "vpc"]
|
||||
denied_actions = [
|
||||
"iam:CreateUser",
|
||||
"iam:CreateAccessKey",
|
||||
"organizations:*"
|
||||
]
|
||||
}
|
||||
|
||||
# ECR push role for container builds
|
||||
ecr_push_role = {
|
||||
enabled = true
|
||||
repos = ["my-application", "backend-api"]
|
||||
branches = ["main", "develop"]
|
||||
ecr_repos = [aws_ecr_repository.app.name]
|
||||
allow_create = false
|
||||
allow_delete = false
|
||||
}
|
||||
|
||||
# S3 deploy role for static sites
|
||||
s3_deploy_role = {
|
||||
enabled = true
|
||||
repos = ["frontend"]
|
||||
branches = ["main"]
|
||||
bucket_arns = ["arn:aws:s3:::www.example.com"]
|
||||
allowed_prefixes = ["*"]
|
||||
cloudfront_arns = [] # Add CloudFront distribution ARN if needed
|
||||
}
|
||||
|
||||
# Lambda deploy role for serverless
|
||||
lambda_deploy_role = {
|
||||
enabled = true
|
||||
repos = ["serverless-api"]
|
||||
branches = ["main"]
|
||||
function_arns = ["arn:aws:lambda:us-east-1:${data.aws_caller_identity.current.account_id}:function:api-*"]
|
||||
allow_create = false
|
||||
allow_logs = true
|
||||
}
|
||||
|
||||
tags = {
|
||||
Environment = "production"
|
||||
ManagedBy = "terraform"
|
||||
}
|
||||
}
|
||||
|
||||
# Outputs
|
||||
output "terraform_role_arn" {
|
||||
description = "Role ARN for Terraform deployments"
|
||||
value = module.github_oidc.terraform_role_arn
|
||||
}
|
||||
|
||||
output "ecr_role_arn" {
|
||||
description = "Role ARN for ECR push operations"
|
||||
value = module.github_oidc.ecr_role_arn
|
||||
}
|
||||
|
||||
output "s3_deploy_role_arn" {
|
||||
description = "Role ARN for S3 static site deployments"
|
||||
value = module.github_oidc.s3_deploy_role_arn
|
||||
}
|
||||
|
||||
output "lambda_deploy_role_arn" {
|
||||
description = "Role ARN for Lambda deployments"
|
||||
value = module.github_oidc.lambda_deploy_role_arn
|
||||
}
|
||||
|
||||
output "all_roles" {
|
||||
description = "All created role ARNs"
|
||||
value = module.github_oidc.all_role_arns
|
||||
}
|
||||
|
||||
output "workflow_examples" {
|
||||
description = "Example workflow snippets"
|
||||
value = module.github_oidc.workflow_examples
|
||||
}
|
||||
673
terraform/modules/github-oidc/main.tf
Normal file
673
terraform/modules/github-oidc/main.tf
Normal file
@@ -0,0 +1,673 @@
|
||||
################################################################################
|
||||
# GitHub OIDC Module
|
||||
#
|
||||
# AWS/Terraform/Security Best Practices:
|
||||
# - Least privilege IAM policies
|
||||
# - Input validation
|
||||
# - Explicit denies for dangerous actions
|
||||
# - Session duration limits
|
||||
# - CloudTrail monitoring integration
|
||||
# - Permissions boundary support
|
||||
# - No wildcard repos by default
|
||||
#
|
||||
# Security scanning: tfsec, checkov, tflint-aws
|
||||
################################################################################
|
||||
|
||||
data "aws_caller_identity" "current" {}
|
||||
data "aws_region" "current" {}
|
||||
data "aws_partition" "current" {}
|
||||
|
||||
################################################################################
|
||||
# Local Variables & Validation
|
||||
################################################################################
|
||||
|
||||
locals {
|
||||
account_id = data.aws_caller_identity.current.account_id
|
||||
region = data.aws_region.current.id
|
||||
partition = data.aws_partition.current.partition
|
||||
|
||||
# Validate permissions boundary requirement
|
||||
boundary_check = var.require_permissions_boundary && var.permissions_boundary == null ? tobool("Permissions boundary is required but not set") : true
|
||||
|
||||
# Normalize repo names with org prefix
|
||||
normalize_repo = { for k, v in var.roles : k => merge(v, {
|
||||
repos = [for repo in v.repos :
|
||||
!contains(split("/", repo), "/") && var.github_org != ""
|
||||
? "${var.github_org}/${repo}"
|
||||
: repo
|
||||
]
|
||||
# Cap session duration at limit
|
||||
max_session_hours = min(v.max_session_hours, var.max_session_hours_limit)
|
||||
})}
|
||||
|
||||
# Validate no wildcard repos unless workflow_ref is set
|
||||
wildcard_check = var.deny_wildcard_repos ? alltrue([
|
||||
for k, v in var.roles : !contains(v.repos, "*") || v.workflow_ref != ""
|
||||
]) : true
|
||||
|
||||
_ = local.wildcard_check ? true : tobool("Wildcard repos (*) require workflow_ref restriction or deny_wildcard_repos=false")
|
||||
|
||||
# Common tags
|
||||
common_tags = merge(var.tags, {
|
||||
ManagedBy = "terraform"
|
||||
Module = "github-oidc"
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# OIDC Provider
|
||||
################################################################################
|
||||
|
||||
data "tls_certificate" "github" {
|
||||
count = var.create_provider ? 1 : 0
|
||||
url = "https://token.actions.githubusercontent.com"
|
||||
}
|
||||
|
||||
resource "aws_iam_openid_connect_provider" "github" {
|
||||
count = var.create_provider ? 1 : 0
|
||||
|
||||
url = "https://token.actions.githubusercontent.com"
|
||||
client_id_list = ["sts.amazonaws.com"]
|
||||
thumbprint_list = [data.tls_certificate.github[0].certificates[0].sha1_fingerprint]
|
||||
|
||||
tags = merge(local.common_tags, {
|
||||
Name = "github-actions-oidc"
|
||||
Description = "GitHub Actions OIDC Identity Provider"
|
||||
})
|
||||
}
|
||||
|
||||
locals {
|
||||
provider_arn = var.create_provider ? aws_iam_openid_connect_provider.github[0].arn : var.provider_arn
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Custom Roles
|
||||
################################################################################
|
||||
|
||||
resource "aws_iam_role" "github" {
|
||||
for_each = local.normalize_repo
|
||||
|
||||
name = "${var.name_prefix}-${each.key}"
|
||||
path = var.path
|
||||
description = "GitHub Actions: ${join(", ", each.value.repos)}"
|
||||
max_session_duration = each.value.max_session_hours * 3600
|
||||
permissions_boundary = var.permissions_boundary
|
||||
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Sid = "GitHubActionsOIDC"
|
||||
Effect = "Allow"
|
||||
Action = "sts:AssumeRoleWithWebIdentity"
|
||||
Principal = {
|
||||
Federated = local.provider_arn
|
||||
}
|
||||
Condition = merge(
|
||||
{
|
||||
StringEquals = {
|
||||
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
|
||||
}
|
||||
StringLike = {
|
||||
"token.actions.githubusercontent.com:sub" = distinct(compact(concat(
|
||||
# Branch-based subjects
|
||||
flatten([for repo in each.value.repos :
|
||||
length(each.value.branches) > 0
|
||||
? [for branch in each.value.branches : "repo:${repo}:ref:refs/heads/${branch}"]
|
||||
: length(each.value.tags) == 0 && length(each.value.environments) == 0 && !each.value.pull_request
|
||||
? ["repo:${repo}:*"]
|
||||
: []
|
||||
]),
|
||||
# Tag-based subjects
|
||||
flatten([for repo in each.value.repos :
|
||||
[for tag in each.value.tags : "repo:${repo}:ref:refs/tags/${tag}"]
|
||||
]),
|
||||
# Environment-based subjects
|
||||
flatten([for repo in each.value.repos :
|
||||
[for env in each.value.environments : "repo:${repo}:environment:${env}"]
|
||||
]),
|
||||
# Pull request subjects
|
||||
each.value.pull_request
|
||||
? [for repo in each.value.repos : "repo:${repo}:pull_request"]
|
||||
: []
|
||||
)))
|
||||
}
|
||||
},
|
||||
# Workflow ref condition (for reusable workflows)
|
||||
each.value.workflow_ref != "" ? {
|
||||
StringEquals = merge(
|
||||
{ "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" },
|
||||
{ "token.actions.githubusercontent.com:job_workflow_ref" = each.value.workflow_ref }
|
||||
)
|
||||
} : {},
|
||||
# Extra conditions
|
||||
each.value.extra_conditions
|
||||
)
|
||||
}]
|
||||
})
|
||||
|
||||
tags = merge(local.common_tags, {
|
||||
Name = "${var.name_prefix}-${each.key}"
|
||||
GitHubRepos = join(",", slice(each.value.repos, 0, min(5, length(each.value.repos))))
|
||||
Purpose = "github-actions-oidc"
|
||||
})
|
||||
}
|
||||
|
||||
# Managed policy attachments
|
||||
resource "aws_iam_role_policy_attachment" "github" {
|
||||
for_each = {
|
||||
for pair in flatten([
|
||||
for role_name, role in local.normalize_repo : [
|
||||
for policy_arn in role.policy_arns : {
|
||||
role_name = role_name
|
||||
policy_arn = policy_arn
|
||||
}
|
||||
]
|
||||
]) : "${pair.role_name}-${md5(pair.policy_arn)}" => pair
|
||||
}
|
||||
|
||||
role = aws_iam_role.github[each.value.role_name].name
|
||||
policy_arn = each.value.policy_arn
|
||||
}
|
||||
|
||||
# Inline policies (raw JSON)
|
||||
resource "aws_iam_role_policy" "github_inline" {
|
||||
for_each = { for k, v in local.normalize_repo : k => v if v.inline_policy != "" }
|
||||
|
||||
name = "inline"
|
||||
role = aws_iam_role.github[each.key].id
|
||||
policy = each.value.inline_policy
|
||||
}
|
||||
|
||||
# Generated policies from statements
|
||||
resource "aws_iam_role_policy" "github_generated" {
|
||||
for_each = { for k, v in local.normalize_repo : k => v if length(v.policy_statements) > 0 }
|
||||
|
||||
name = "generated"
|
||||
role = aws_iam_role.github[each.key].id
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [for stmt in each.value.policy_statements : {
|
||||
Sid = stmt.sid != "" ? stmt.sid : null
|
||||
Effect = stmt.effect
|
||||
Action = stmt.actions
|
||||
Resource = stmt.resources
|
||||
Condition = length(stmt.conditions) > 0 ? {
|
||||
for cond in stmt.conditions : cond.test => {
|
||||
"${cond.variable}" = cond.values
|
||||
}
|
||||
} : null
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Terraform Deploy Role (Template)
|
||||
################################################################################
|
||||
|
||||
locals {
|
||||
tf_role_enabled = try(var.terraform_deploy_role.enabled, false)
|
||||
tf_repos = try(var.terraform_deploy_role.repos, [])
|
||||
tf_repos_normalized = [for repo in local.tf_repos :
|
||||
!contains(split("/", repo), "/") && var.github_org != ""
|
||||
? "${var.github_org}/${repo}"
|
||||
: repo
|
||||
]
|
||||
}
|
||||
|
||||
resource "aws_iam_role" "terraform" {
|
||||
count = local.tf_role_enabled ? 1 : 0
|
||||
|
||||
name = "${var.name_prefix}-terraform"
|
||||
path = var.path
|
||||
description = "GitHub Actions - Terraform deployment"
|
||||
max_session_duration = min(2, var.max_session_hours_limit) * 3600
|
||||
permissions_boundary = var.permissions_boundary
|
||||
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Sid = "GitHubActionsTerraform"
|
||||
Effect = "Allow"
|
||||
Action = "sts:AssumeRoleWithWebIdentity"
|
||||
Principal = { Federated = local.provider_arn }
|
||||
Condition = {
|
||||
StringEquals = { "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" }
|
||||
StringLike = {
|
||||
"token.actions.githubusercontent.com:sub" = concat(
|
||||
flatten([for repo in local.tf_repos_normalized :
|
||||
length(try(var.terraform_deploy_role.branches, [])) > 0
|
||||
? [for branch in var.terraform_deploy_role.branches : "repo:${repo}:ref:refs/heads/${branch}"]
|
||||
: ["repo:${repo}:*"]
|
||||
]),
|
||||
flatten([for repo in local.tf_repos_normalized :
|
||||
[for env in try(var.terraform_deploy_role.environments, []) : "repo:${repo}:environment:${env}"]
|
||||
])
|
||||
)
|
||||
}
|
||||
}
|
||||
}]
|
||||
})
|
||||
|
||||
tags = merge(local.common_tags, {
|
||||
Name = "${var.name_prefix}-terraform"
|
||||
Purpose = "terraform-deployment"
|
||||
})
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "terraform_state" {
|
||||
count = local.tf_role_enabled && try(var.terraform_deploy_role.state_bucket, "") != "" ? 1 : 0
|
||||
|
||||
name = "terraform-state"
|
||||
role = aws_iam_role.terraform[0].id
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Sid = "TerraformStateBucket"
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"s3:GetObject",
|
||||
"s3:PutObject",
|
||||
"s3:DeleteObject"
|
||||
]
|
||||
Resource = "arn:${local.partition}:s3:::${var.terraform_deploy_role.state_bucket}/${try(var.terraform_deploy_role.state_bucket_key_prefix, "*")}"
|
||||
},
|
||||
{
|
||||
Sid = "TerraformStateBucketList"
|
||||
Effect = "Allow"
|
||||
Action = ["s3:ListBucket"]
|
||||
Resource = "arn:${local.partition}:s3:::${var.terraform_deploy_role.state_bucket}"
|
||||
Condition = {
|
||||
StringLike = {
|
||||
"s3:prefix" = [try(var.terraform_deploy_role.state_bucket_key_prefix, "*")]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "terraform_lock" {
|
||||
count = local.tf_role_enabled && try(var.terraform_deploy_role.dynamodb_table, "") != "" ? 1 : 0
|
||||
|
||||
name = "terraform-lock"
|
||||
role = aws_iam_role.terraform[0].id
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Sid = "TerraformLockTable"
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"dynamodb:GetItem",
|
||||
"dynamodb:PutItem",
|
||||
"dynamodb:DeleteItem"
|
||||
]
|
||||
Resource = "arn:${local.partition}:dynamodb:*:${local.account_id}:table/${var.terraform_deploy_role.dynamodb_table}"
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
# Service-specific permissions (least privilege approach)
|
||||
resource "aws_iam_role_policy" "terraform_services" {
|
||||
count = local.tf_role_enabled && length(try(var.terraform_deploy_role.allowed_services, [])) > 0 ? 1 : 0
|
||||
|
||||
name = "terraform-services"
|
||||
role = aws_iam_role.terraform[0].id
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Sid = "AllowedServices"
|
||||
Effect = "Allow"
|
||||
Action = flatten([for svc in var.terraform_deploy_role.allowed_services : "${svc}:*"])
|
||||
Resource = "*"
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
# Explicit denies for dangerous actions
|
||||
resource "aws_iam_role_policy" "terraform_deny" {
|
||||
count = local.tf_role_enabled && length(try(var.terraform_deploy_role.denied_actions, [])) > 0 ? 1 : 0
|
||||
|
||||
name = "terraform-deny"
|
||||
role = aws_iam_role.terraform[0].id
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Sid = "ExplicitDeny"
|
||||
Effect = "Deny"
|
||||
Action = var.terraform_deploy_role.denied_actions
|
||||
Resource = "*"
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# ECR Push Role (Template)
|
||||
################################################################################
|
||||
|
||||
locals {
|
||||
ecr_role_enabled = try(var.ecr_push_role.enabled, false)
|
||||
ecr_repos_gh = try(var.ecr_push_role.repos, [])
|
||||
ecr_repos_normalized = [for repo in local.ecr_repos_gh :
|
||||
!contains(split("/", repo), "/") && var.github_org != ""
|
||||
? "${var.github_org}/${repo}"
|
||||
: repo
|
||||
]
|
||||
}
|
||||
|
||||
resource "aws_iam_role" "ecr" {
|
||||
count = local.ecr_role_enabled ? 1 : 0
|
||||
|
||||
name = "${var.name_prefix}-ecr-push"
|
||||
path = var.path
|
||||
description = "GitHub Actions - ECR push"
|
||||
max_session_duration = 3600
|
||||
permissions_boundary = var.permissions_boundary
|
||||
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Sid = "GitHubActionsECR"
|
||||
Effect = "Allow"
|
||||
Action = "sts:AssumeRoleWithWebIdentity"
|
||||
Principal = { Federated = local.provider_arn }
|
||||
Condition = {
|
||||
StringEquals = { "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" }
|
||||
StringLike = {
|
||||
"token.actions.githubusercontent.com:sub" = flatten([for repo in local.ecr_repos_normalized :
|
||||
length(try(var.ecr_push_role.branches, [])) > 0
|
||||
? [for branch in var.ecr_push_role.branches : "repo:${repo}:ref:refs/heads/${branch}"]
|
||||
: ["repo:${repo}:*"]
|
||||
])
|
||||
}
|
||||
}
|
||||
}]
|
||||
})
|
||||
|
||||
tags = merge(local.common_tags, {
|
||||
Name = "${var.name_prefix}-ecr-push"
|
||||
Purpose = "ecr-push"
|
||||
})
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "ecr" {
|
||||
count = local.ecr_role_enabled ? 1 : 0
|
||||
|
||||
name = "ecr-push"
|
||||
role = aws_iam_role.ecr[0].id
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = concat(
|
||||
[{
|
||||
Sid = "ECRAuth"
|
||||
Effect = "Allow"
|
||||
Action = "ecr:GetAuthorizationToken"
|
||||
Resource = "*" # Required - GetAuthorizationToken doesn't support resource constraints
|
||||
}],
|
||||
[{
|
||||
Sid = "ECRPush"
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"ecr:BatchCheckLayerAvailability",
|
||||
"ecr:GetDownloadUrlForLayer",
|
||||
"ecr:BatchGetImage",
|
||||
"ecr:PutImage",
|
||||
"ecr:InitiateLayerUpload",
|
||||
"ecr:UploadLayerPart",
|
||||
"ecr:CompleteLayerUpload",
|
||||
"ecr:DescribeRepositories",
|
||||
"ecr:DescribeImages"
|
||||
]
|
||||
Resource = [for repo in try(var.ecr_push_role.ecr_repos, []) :
|
||||
"arn:${local.partition}:ecr:*:${local.account_id}:repository/${repo}"
|
||||
]
|
||||
}],
|
||||
try(var.ecr_push_role.allow_create, false) ? [{
|
||||
Sid = "ECRCreate"
|
||||
Effect = "Allow"
|
||||
Action = ["ecr:CreateRepository", "ecr:TagResource"]
|
||||
Resource = "arn:${local.partition}:ecr:*:${local.account_id}:repository/*"
|
||||
}] : [],
|
||||
try(var.ecr_push_role.allow_delete, false) ? [{
|
||||
Sid = "ECRDelete"
|
||||
Effect = "Allow"
|
||||
Action = ["ecr:DeleteRepository", "ecr:BatchDeleteImage"]
|
||||
Resource = [for repo in try(var.ecr_push_role.ecr_repos, []) :
|
||||
"arn:${local.partition}:ecr:*:${local.account_id}:repository/${repo}"
|
||||
]
|
||||
}] : []
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# S3 Deploy Role (Template)
|
||||
################################################################################
|
||||
|
||||
locals {
|
||||
s3_role_enabled = try(var.s3_deploy_role.enabled, false)
|
||||
s3_repos = try(var.s3_deploy_role.repos, [])
|
||||
s3_repos_normalized = [for repo in local.s3_repos :
|
||||
!contains(split("/", repo), "/") && var.github_org != ""
|
||||
? "${var.github_org}/${repo}"
|
||||
: repo
|
||||
]
|
||||
}
|
||||
|
||||
resource "aws_iam_role" "s3_deploy" {
|
||||
count = local.s3_role_enabled ? 1 : 0
|
||||
|
||||
name = "${var.name_prefix}-s3-deploy"
|
||||
path = var.path
|
||||
description = "GitHub Actions - S3 deployment"
|
||||
max_session_duration = 3600
|
||||
permissions_boundary = var.permissions_boundary
|
||||
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Sid = "GitHubActionsS3Deploy"
|
||||
Effect = "Allow"
|
||||
Action = "sts:AssumeRoleWithWebIdentity"
|
||||
Principal = { Federated = local.provider_arn }
|
||||
Condition = {
|
||||
StringEquals = { "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" }
|
||||
StringLike = {
|
||||
"token.actions.githubusercontent.com:sub" = flatten([for repo in local.s3_repos_normalized :
|
||||
length(try(var.s3_deploy_role.branches, [])) > 0
|
||||
? [for branch in var.s3_deploy_role.branches : "repo:${repo}:ref:refs/heads/${branch}"]
|
||||
: ["repo:${repo}:*"]
|
||||
])
|
||||
}
|
||||
}
|
||||
}]
|
||||
})
|
||||
|
||||
tags = merge(local.common_tags, {
|
||||
Name = "${var.name_prefix}-s3-deploy"
|
||||
Purpose = "s3-static-site"
|
||||
})
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "s3_deploy" {
|
||||
count = local.s3_role_enabled ? 1 : 0
|
||||
|
||||
name = "s3-deploy"
|
||||
role = aws_iam_role.s3_deploy[0].id
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = concat(
|
||||
[{
|
||||
Sid = "S3Deploy"
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"s3:PutObject",
|
||||
"s3:GetObject",
|
||||
"s3:DeleteObject",
|
||||
"s3:GetObjectAcl",
|
||||
"s3:PutObjectAcl"
|
||||
]
|
||||
Resource = flatten([for bucket in try(var.s3_deploy_role.bucket_arns, []) : [
|
||||
for prefix in try(var.s3_deploy_role.allowed_prefixes, ["*"]) :
|
||||
"${bucket}/${prefix}"
|
||||
]])
|
||||
}],
|
||||
[{
|
||||
Sid = "S3List"
|
||||
Effect = "Allow"
|
||||
Action = ["s3:ListBucket", "s3:GetBucketLocation"]
|
||||
Resource = try(var.s3_deploy_role.bucket_arns, [])
|
||||
}],
|
||||
length(try(var.s3_deploy_role.cloudfront_arns, [])) > 0 ? [{
|
||||
Sid = "CloudFrontInvalidate"
|
||||
Effect = "Allow"
|
||||
Action = "cloudfront:CreateInvalidation"
|
||||
Resource = var.s3_deploy_role.cloudfront_arns
|
||||
}] : []
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Lambda Deploy Role (Template)
|
||||
################################################################################
|
||||
|
||||
locals {
|
||||
lambda_role_enabled = try(var.lambda_deploy_role.enabled, false)
|
||||
lambda_repos = try(var.lambda_deploy_role.repos, [])
|
||||
lambda_repos_normalized = [for repo in local.lambda_repos :
|
||||
!contains(split("/", repo), "/") && var.github_org != ""
|
||||
? "${var.github_org}/${repo}"
|
||||
: repo
|
||||
]
|
||||
}
|
||||
|
||||
resource "aws_iam_role" "lambda_deploy" {
|
||||
count = local.lambda_role_enabled ? 1 : 0
|
||||
|
||||
name = "${var.name_prefix}-lambda-deploy"
|
||||
path = var.path
|
||||
description = "GitHub Actions - Lambda deployment"
|
||||
max_session_duration = 3600
|
||||
permissions_boundary = var.permissions_boundary
|
||||
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Sid = "GitHubActionsLambda"
|
||||
Effect = "Allow"
|
||||
Action = "sts:AssumeRoleWithWebIdentity"
|
||||
Principal = { Federated = local.provider_arn }
|
||||
Condition = {
|
||||
StringEquals = { "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" }
|
||||
StringLike = {
|
||||
"token.actions.githubusercontent.com:sub" = flatten([for repo in local.lambda_repos_normalized :
|
||||
length(try(var.lambda_deploy_role.branches, [])) > 0
|
||||
? [for branch in var.lambda_deploy_role.branches : "repo:${repo}:ref:refs/heads/${branch}"]
|
||||
: ["repo:${repo}:*"]
|
||||
])
|
||||
}
|
||||
}
|
||||
}]
|
||||
})
|
||||
|
||||
tags = merge(local.common_tags, {
|
||||
Name = "${var.name_prefix}-lambda-deploy"
|
||||
Purpose = "lambda-deployment"
|
||||
})
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "lambda_deploy" {
|
||||
count = local.lambda_role_enabled ? 1 : 0
|
||||
|
||||
name = "lambda-deploy"
|
||||
role = aws_iam_role.lambda_deploy[0].id
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = concat(
|
||||
[{
|
||||
Sid = "LambdaDeploy"
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"lambda:UpdateFunctionCode",
|
||||
"lambda:UpdateFunctionConfiguration",
|
||||
"lambda:GetFunction",
|
||||
"lambda:GetFunctionConfiguration",
|
||||
"lambda:PublishVersion",
|
||||
"lambda:ListVersionsByFunction"
|
||||
]
|
||||
Resource = try(var.lambda_deploy_role.function_arns, [])
|
||||
}],
|
||||
try(var.lambda_deploy_role.allow_create, false) ? [{
|
||||
Sid = "LambdaCreate"
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"lambda:CreateFunction",
|
||||
"lambda:DeleteFunction",
|
||||
"lambda:TagResource",
|
||||
"lambda:AddPermission",
|
||||
"lambda:RemovePermission"
|
||||
]
|
||||
Resource = "arn:${local.partition}:lambda:*:${local.account_id}:function:*"
|
||||
}] : [],
|
||||
try(var.lambda_deploy_role.allow_create, false) ? [{
|
||||
Sid = "IAMPassRole"
|
||||
Effect = "Allow"
|
||||
Action = "iam:PassRole"
|
||||
Resource = "arn:${local.partition}:iam::${local.account_id}:role/*"
|
||||
Condition = {
|
||||
StringEquals = {
|
||||
"iam:PassedToService" = "lambda.amazonaws.com"
|
||||
}
|
||||
}
|
||||
}] : [],
|
||||
try(var.lambda_deploy_role.allow_logs, true) ? [{
|
||||
Sid = "CloudWatchLogs"
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"logs:DescribeLogGroups",
|
||||
"logs:DescribeLogStreams",
|
||||
"logs:GetLogEvents"
|
||||
]
|
||||
Resource = "arn:${local.partition}:logs:*:${local.account_id}:log-group:/aws/lambda/*"
|
||||
}] : []
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Security Monitoring (Optional)
|
||||
################################################################################
|
||||
|
||||
resource "aws_cloudwatch_log_metric_filter" "oidc_assume_role" {
|
||||
count = var.enable_cloudtrail_logging && var.alarm_sns_topic_arn != "" ? 1 : 0
|
||||
|
||||
name = "github-oidc-assume-role"
|
||||
pattern = "{ ($.eventName = AssumeRoleWithWebIdentity) && ($.requestParameters.roleArn = \"*${var.name_prefix}*\") }"
|
||||
log_group_name = "aws-cloudtrail-logs" # Adjust to your CloudTrail log group
|
||||
|
||||
metric_transformation {
|
||||
name = "GitHubOIDCAssumeRole"
|
||||
namespace = "Security/OIDC"
|
||||
value = "1"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_cloudwatch_metric_alarm" "oidc_high_usage" {
|
||||
count = var.enable_cloudtrail_logging && var.alarm_sns_topic_arn != "" ? 1 : 0
|
||||
|
||||
alarm_name = "github-oidc-high-usage"
|
||||
alarm_description = "High number of GitHub OIDC role assumptions"
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 1
|
||||
metric_name = "GitHubOIDCAssumeRole"
|
||||
namespace = "Security/OIDC"
|
||||
period = 300
|
||||
statistic = "Sum"
|
||||
threshold = 100
|
||||
treat_missing_data = "notBreaching"
|
||||
|
||||
alarm_actions = [var.alarm_sns_topic_arn]
|
||||
|
||||
tags = local.common_tags
|
||||
}
|
||||
159
terraform/modules/github-oidc/outputs.tf
Normal file
159
terraform/modules/github-oidc/outputs.tf
Normal file
@@ -0,0 +1,159 @@
|
||||
################################################################################
|
||||
# GitHub OIDC Module - Outputs
|
||||
################################################################################
|
||||
|
||||
output "provider_arn" {
|
||||
value = local.provider_arn
|
||||
description = "GitHub OIDC provider ARN"
|
||||
}
|
||||
|
||||
output "provider_url" {
|
||||
value = "https://token.actions.githubusercontent.com"
|
||||
description = "GitHub OIDC provider URL"
|
||||
}
|
||||
|
||||
# Custom roles
|
||||
output "role_arns" {
|
||||
value = { for k, v in aws_iam_role.github : k => v.arn }
|
||||
description = "Map of custom role names to ARNs"
|
||||
}
|
||||
|
||||
output "role_names" {
|
||||
value = { for k, v in aws_iam_role.github : k => v.name }
|
||||
description = "Map of custom role key to IAM role names"
|
||||
}
|
||||
|
||||
# Template roles
|
||||
output "terraform_role_arn" {
|
||||
value = local.tf_role_enabled ? aws_iam_role.terraform[0].arn : null
|
||||
description = "Terraform deploy role ARN"
|
||||
}
|
||||
|
||||
output "terraform_role_name" {
|
||||
value = local.tf_role_enabled ? aws_iam_role.terraform[0].name : null
|
||||
description = "Terraform deploy role name"
|
||||
}
|
||||
|
||||
output "ecr_role_arn" {
|
||||
value = local.ecr_role_enabled ? aws_iam_role.ecr[0].arn : null
|
||||
description = "ECR push role ARN"
|
||||
}
|
||||
|
||||
output "ecr_role_name" {
|
||||
value = local.ecr_role_enabled ? aws_iam_role.ecr[0].name : null
|
||||
description = "ECR push role name"
|
||||
}
|
||||
|
||||
output "s3_deploy_role_arn" {
|
||||
value = local.s3_role_enabled ? aws_iam_role.s3_deploy[0].arn : null
|
||||
description = "S3 deploy role ARN"
|
||||
}
|
||||
|
||||
output "s3_deploy_role_name" {
|
||||
value = local.s3_role_enabled ? aws_iam_role.s3_deploy[0].name : null
|
||||
description = "S3 deploy role name"
|
||||
}
|
||||
|
||||
output "lambda_deploy_role_arn" {
|
||||
value = local.lambda_role_enabled ? aws_iam_role.lambda_deploy[0].arn : null
|
||||
description = "Lambda deploy role ARN"
|
||||
}
|
||||
|
||||
output "lambda_deploy_role_name" {
|
||||
value = local.lambda_role_enabled ? aws_iam_role.lambda_deploy[0].name : null
|
||||
description = "Lambda deploy role name"
|
||||
}
|
||||
|
||||
# All role ARNs combined
|
||||
output "all_role_arns" {
|
||||
value = merge(
|
||||
{ for k, v in aws_iam_role.github : k => v.arn },
|
||||
local.tf_role_enabled ? { terraform = aws_iam_role.terraform[0].arn } : {},
|
||||
local.ecr_role_enabled ? { ecr = aws_iam_role.ecr[0].arn } : {},
|
||||
local.s3_role_enabled ? { s3_deploy = aws_iam_role.s3_deploy[0].arn } : {},
|
||||
local.lambda_role_enabled ? { lambda_deploy = aws_iam_role.lambda_deploy[0].arn } : {}
|
||||
)
|
||||
description = "All role ARNs (custom + templates)"
|
||||
}
|
||||
|
||||
# Security outputs
|
||||
output "iam_path" {
|
||||
value = var.path
|
||||
description = "IAM path used for roles (useful for permissions boundaries)"
|
||||
}
|
||||
|
||||
output "security_recommendations" {
|
||||
value = {
|
||||
permissions_boundary_set = var.permissions_boundary != null
|
||||
max_session_limited = var.max_session_hours_limit < 12
|
||||
wildcard_repos_denied = var.deny_wildcard_repos
|
||||
cloudtrail_monitoring = var.enable_cloudtrail_logging
|
||||
}
|
||||
description = "Security configuration status"
|
||||
}
|
||||
|
||||
# Workflow configuration helper
|
||||
output "github_actions_config" {
|
||||
value = {
|
||||
aws_region = local.region
|
||||
roles = merge(
|
||||
{ for k, v in aws_iam_role.github : k => {
|
||||
arn = v.arn
|
||||
name = v.name
|
||||
}},
|
||||
local.tf_role_enabled ? { terraform = {
|
||||
arn = aws_iam_role.terraform[0].arn
|
||||
name = aws_iam_role.terraform[0].name
|
||||
}} : {},
|
||||
local.ecr_role_enabled ? { ecr = {
|
||||
arn = aws_iam_role.ecr[0].arn
|
||||
name = aws_iam_role.ecr[0].name
|
||||
}} : {},
|
||||
local.s3_role_enabled ? { s3_deploy = {
|
||||
arn = aws_iam_role.s3_deploy[0].arn
|
||||
name = aws_iam_role.s3_deploy[0].name
|
||||
}} : {},
|
||||
local.lambda_role_enabled ? { lambda_deploy = {
|
||||
arn = aws_iam_role.lambda_deploy[0].arn
|
||||
name = aws_iam_role.lambda_deploy[0].name
|
||||
}} : {}
|
||||
)
|
||||
}
|
||||
description = "Configuration for GitHub Actions workflows"
|
||||
}
|
||||
|
||||
# Example workflow snippets
|
||||
output "workflow_examples" {
|
||||
value = {
|
||||
basic = <<-EOF
|
||||
# .github/workflows/deploy.yml
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
role-to-assume: <ROLE_ARN>
|
||||
aws-region: ${local.region}
|
||||
role-session-name: github-actions-${"$"}{{ github.run_id }}
|
||||
EOF
|
||||
|
||||
with_environment = <<-EOF
|
||||
# .github/workflows/deploy.yml
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
environment: production # Requires approval if configured
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
role-to-assume: <ROLE_ARN>
|
||||
aws-region: ${local.region}
|
||||
EOF
|
||||
}
|
||||
description = "Example GitHub Actions workflow snippets"
|
||||
}
|
||||
210
terraform/modules/github-oidc/tests/basic.tftest.hcl
Normal file
210
terraform/modules/github-oidc/tests/basic.tftest.hcl
Normal file
@@ -0,0 +1,210 @@
|
||||
################################################################################
|
||||
# GitHub OIDC Module - Basic Tests
|
||||
# Uses Terraform native testing framework
|
||||
################################################################################
|
||||
|
||||
# Mock AWS provider for unit tests
|
||||
mock_provider "aws" {
|
||||
mock_data "aws_caller_identity" {
|
||||
defaults = {
|
||||
account_id = "123456789012"
|
||||
arn = "arn:aws:iam::123456789012:user/test"
|
||||
user_id = "AIDATEST123456789"
|
||||
}
|
||||
}
|
||||
|
||||
mock_data "aws_region" {
|
||||
defaults = {
|
||||
name = "us-east-1"
|
||||
}
|
||||
}
|
||||
|
||||
mock_data "aws_partition" {
|
||||
defaults = {
|
||||
partition = "aws"
|
||||
dns_suffix = "amazonaws.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Test: Basic role creation
|
||||
run "basic_role_creation" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
github_org = "test-org"
|
||||
roles = {
|
||||
deploy = {
|
||||
repos = ["test-repo"]
|
||||
branches = ["main"]
|
||||
policy_statements = [{
|
||||
sid = "TestAccess"
|
||||
actions = ["s3:GetObject"]
|
||||
resources = ["arn:aws:s3:::test-bucket/*"]
|
||||
}]
|
||||
}
|
||||
}
|
||||
tags = {
|
||||
Environment = "test"
|
||||
}
|
||||
}
|
||||
|
||||
# Verify OIDC provider is created
|
||||
assert {
|
||||
condition = aws_iam_openid_connect_provider.github[0].url == "https://token.actions.githubusercontent.com"
|
||||
error_message = "OIDC provider URL is incorrect"
|
||||
}
|
||||
|
||||
# Verify role is created with correct name
|
||||
assert {
|
||||
condition = aws_iam_role.github["deploy"].name == "github-deploy"
|
||||
error_message = "Role name should be github-deploy"
|
||||
}
|
||||
|
||||
# Verify IAM path is set correctly
|
||||
assert {
|
||||
condition = aws_iam_role.github["deploy"].path == "/github-actions/"
|
||||
error_message = "Role path should be /github-actions/"
|
||||
}
|
||||
}
|
||||
|
||||
# Test: Repository normalization with org prefix
|
||||
run "repo_normalization" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
github_org = "my-org"
|
||||
roles = {
|
||||
test = {
|
||||
repos = ["repo-without-org"] # Should become my-org/repo-without-org
|
||||
branches = ["main"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Role should be created (validates normalization works)
|
||||
assert {
|
||||
condition = aws_iam_role.github["test"].name == "github-test"
|
||||
error_message = "Role should be created with normalized repo"
|
||||
}
|
||||
}
|
||||
|
||||
# Test: Multiple roles with different configurations
|
||||
run "multiple_roles" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
github_org = "test-org"
|
||||
roles = {
|
||||
validate = {
|
||||
repos = ["app"]
|
||||
pull_request = true
|
||||
max_session_hours = 1
|
||||
}
|
||||
deploy = {
|
||||
repos = ["app"]
|
||||
branches = ["main"]
|
||||
max_session_hours = 2
|
||||
}
|
||||
release = {
|
||||
repos = ["app"]
|
||||
tags = ["v*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Verify all roles are created
|
||||
assert {
|
||||
condition = length(aws_iam_role.github) == 3
|
||||
error_message = "Should create 3 roles"
|
||||
}
|
||||
}
|
||||
|
||||
# Test: Terraform deploy template role
|
||||
run "terraform_template_role" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
github_org = "test-org"
|
||||
terraform_deploy_role = {
|
||||
enabled = true
|
||||
repos = ["infra"]
|
||||
branches = ["main"]
|
||||
state_bucket = "my-tf-state"
|
||||
dynamodb_table = "terraform-locks"
|
||||
}
|
||||
}
|
||||
|
||||
# Verify Terraform role is created
|
||||
assert {
|
||||
condition = aws_iam_role.terraform[0].name == "github-terraform"
|
||||
error_message = "Terraform role should be created"
|
||||
}
|
||||
}
|
||||
|
||||
# Test: ECR push template role
|
||||
run "ecr_template_role" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
github_org = "test-org"
|
||||
ecr_push_role = {
|
||||
enabled = true
|
||||
repos = ["app"]
|
||||
branches = ["main"]
|
||||
ecr_repos = ["my-ecr-repo"]
|
||||
}
|
||||
}
|
||||
|
||||
# Verify ECR role is created
|
||||
assert {
|
||||
condition = aws_iam_role.ecr[0].name == "github-ecr-push"
|
||||
error_message = "ECR role should be created"
|
||||
}
|
||||
}
|
||||
|
||||
# Test: Session duration capping
|
||||
run "session_duration_capping" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
github_org = "test-org"
|
||||
max_session_hours_limit = 2
|
||||
roles = {
|
||||
test = {
|
||||
repos = ["app"]
|
||||
branches = ["main"]
|
||||
max_session_hours = 4 # Should be capped to 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Verify session duration is capped (2 hours = 7200 seconds)
|
||||
assert {
|
||||
condition = aws_iam_role.github["test"].max_session_duration == 7200
|
||||
error_message = "Session duration should be capped at 2 hours (7200 seconds)"
|
||||
}
|
||||
}
|
||||
|
||||
# Test: Existing provider ARN (no provider creation)
|
||||
run "existing_provider" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
create_provider = false
|
||||
provider_arn = "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
|
||||
github_org = "test-org"
|
||||
roles = {
|
||||
test = {
|
||||
repos = ["app"]
|
||||
branches = ["main"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Verify no provider is created
|
||||
assert {
|
||||
condition = length(aws_iam_openid_connect_provider.github) == 0
|
||||
error_message = "Should not create provider when create_provider=false"
|
||||
}
|
||||
}
|
||||
203
terraform/modules/github-oidc/tests/security.tftest.hcl
Normal file
203
terraform/modules/github-oidc/tests/security.tftest.hcl
Normal file
@@ -0,0 +1,203 @@
|
||||
################################################################################
|
||||
# GitHub OIDC Module - Security Tests
|
||||
# Validates security best practices are enforced
|
||||
################################################################################
|
||||
|
||||
mock_provider "aws" {
|
||||
mock_data "aws_caller_identity" {
|
||||
defaults = {
|
||||
account_id = "123456789012"
|
||||
}
|
||||
}
|
||||
|
||||
mock_data "aws_region" {
|
||||
defaults = {
|
||||
name = "us-east-1"
|
||||
}
|
||||
}
|
||||
|
||||
mock_data "aws_partition" {
|
||||
defaults = {
|
||||
partition = "aws"
|
||||
dns_suffix = "amazonaws.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Test: Wildcard repos denied by default
|
||||
run "wildcard_repos_denied" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
github_org = "test-org"
|
||||
deny_wildcard_repos = true
|
||||
roles = {
|
||||
test = {
|
||||
repos = ["*"] # Wildcard - should fail without workflow_ref
|
||||
branches = ["main"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect_failures = [
|
||||
# This should fail validation because wildcard repos require workflow_ref
|
||||
var.roles
|
||||
]
|
||||
}
|
||||
|
||||
# Test: Wildcard repos allowed with workflow_ref
|
||||
run "wildcard_repos_with_workflow_ref" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
github_org = "test-org"
|
||||
deny_wildcard_repos = true
|
||||
roles = {
|
||||
test = {
|
||||
repos = ["*"]
|
||||
workflow_ref = "test-org/workflows/.github/workflows/deploy.yml@main"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Should succeed because workflow_ref is specified
|
||||
assert {
|
||||
condition = aws_iam_role.github["test"].name == "github-test"
|
||||
error_message = "Should allow wildcard with workflow_ref"
|
||||
}
|
||||
}
|
||||
|
||||
# Test: IAM path isolation
|
||||
run "iam_path_isolation" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
github_org = "test-org"
|
||||
path = "/github-actions/"
|
||||
roles = {
|
||||
test = {
|
||||
repos = ["app"]
|
||||
branches = ["main"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Verify path is set for role isolation
|
||||
assert {
|
||||
condition = aws_iam_role.github["test"].path == "/github-actions/"
|
||||
error_message = "Role should use isolated IAM path"
|
||||
}
|
||||
}
|
||||
|
||||
# Test: Permissions boundary is applied
|
||||
run "permissions_boundary_applied" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
github_org = "test-org"
|
||||
permissions_boundary = "arn:aws:iam::123456789012:policy/TestBoundary"
|
||||
roles = {
|
||||
test = {
|
||||
repos = ["app"]
|
||||
branches = ["main"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Verify permissions boundary is set
|
||||
assert {
|
||||
condition = aws_iam_role.github["test"].permissions_boundary == "arn:aws:iam::123456789012:policy/TestBoundary"
|
||||
error_message = "Permissions boundary should be applied to role"
|
||||
}
|
||||
}
|
||||
|
||||
# Test: Terraform role has explicit denies
|
||||
run "terraform_role_explicit_denies" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
github_org = "test-org"
|
||||
terraform_deploy_role = {
|
||||
enabled = true
|
||||
repos = ["infra"]
|
||||
branches = ["main"]
|
||||
denied_actions = ["iam:CreateUser", "organizations:*"]
|
||||
}
|
||||
}
|
||||
|
||||
# Verify deny policy is created
|
||||
assert {
|
||||
condition = aws_iam_role_policy.terraform_deny[0].name == "terraform-deny"
|
||||
error_message = "Terraform deny policy should be created"
|
||||
}
|
||||
}
|
||||
|
||||
# Test: ECR role requires explicit repos
|
||||
run "ecr_explicit_repos_required" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
github_org = "test-org"
|
||||
ecr_push_role = {
|
||||
enabled = true
|
||||
repos = ["app"]
|
||||
ecr_repos = ["my-ecr-repo"] # Explicit ECR repo required
|
||||
}
|
||||
}
|
||||
|
||||
# Should succeed with explicit ECR repos
|
||||
assert {
|
||||
condition = aws_iam_role.ecr[0].name == "github-ecr-push"
|
||||
error_message = "ECR role should be created with explicit repos"
|
||||
}
|
||||
}
|
||||
|
||||
# Test: Role tags include security metadata
|
||||
run "security_tags" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
github_org = "test-org"
|
||||
roles = {
|
||||
test = {
|
||||
repos = ["app"]
|
||||
branches = ["main"]
|
||||
}
|
||||
}
|
||||
tags = {
|
||||
Environment = "production"
|
||||
}
|
||||
}
|
||||
|
||||
# Verify tags include ManagedBy and Module
|
||||
assert {
|
||||
condition = aws_iam_role.github["test"].tags["ManagedBy"] == "terraform"
|
||||
error_message = "Role should have ManagedBy tag"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = aws_iam_role.github["test"].tags["Module"] == "github-oidc"
|
||||
error_message = "Role should have Module tag"
|
||||
}
|
||||
}
|
||||
|
||||
# Test: Trust policy uses StringLike for subject claims
|
||||
run "trust_policy_string_like" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
github_org = "test-org"
|
||||
roles = {
|
||||
test = {
|
||||
repos = ["app"]
|
||||
branches = ["main", "develop"] # Multiple branches
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Role should be created with proper trust policy
|
||||
assert {
|
||||
condition = aws_iam_role.github["test"].assume_role_policy != ""
|
||||
error_message = "Trust policy should be set"
|
||||
}
|
||||
}
|
||||
248
terraform/modules/github-oidc/variables.tf
Normal file
248
terraform/modules/github-oidc/variables.tf
Normal file
@@ -0,0 +1,248 @@
|
||||
################################################################################
|
||||
# GitHub OIDC Module - Variables
|
||||
# With AWS/Terraform/Security Best Practices Validation
|
||||
################################################################################
|
||||
|
||||
variable "create_provider" {
|
||||
type = bool
|
||||
default = true
|
||||
description = "Create the OIDC provider. Set false if already exists in account."
|
||||
}
|
||||
|
||||
variable "provider_arn" {
|
||||
type = string
|
||||
default = ""
|
||||
description = "Existing OIDC provider ARN (required if create_provider = false)"
|
||||
|
||||
validation {
|
||||
condition = var.provider_arn == "" || can(regex("^arn:aws:iam::[0-9]{12}:oidc-provider/", var.provider_arn))
|
||||
error_message = "Provider ARN must be a valid IAM OIDC provider ARN."
|
||||
}
|
||||
}
|
||||
|
||||
variable "github_org" {
|
||||
type = string
|
||||
default = ""
|
||||
description = "GitHub organization. If set, prepended to repos that don't include org."
|
||||
|
||||
validation {
|
||||
condition = var.github_org == "" || can(regex("^[a-zA-Z0-9][a-zA-Z0-9-]*$", var.github_org))
|
||||
error_message = "GitHub org must be alphanumeric with hyphens (no leading hyphen)."
|
||||
}
|
||||
}
|
||||
|
||||
variable "name_prefix" {
|
||||
type = string
|
||||
default = "github"
|
||||
description = "Prefix for IAM role names"
|
||||
|
||||
validation {
|
||||
condition = can(regex("^[a-zA-Z][a-zA-Z0-9-_]*$", var.name_prefix))
|
||||
error_message = "Name prefix must start with letter, contain only alphanumeric, hyphens, underscores."
|
||||
}
|
||||
}
|
||||
|
||||
variable "path" {
|
||||
type = string
|
||||
default = "/github-actions/"
|
||||
description = "IAM path for roles (enables easier permission boundaries)"
|
||||
|
||||
validation {
|
||||
condition = can(regex("^/[a-zA-Z0-9/_-]*/$", var.path))
|
||||
error_message = "IAM path must start and end with /, contain only alphanumeric, /, -, _."
|
||||
}
|
||||
}
|
||||
|
||||
variable "permissions_boundary" {
|
||||
type = string
|
||||
default = null
|
||||
description = "ARN of permissions boundary to attach to roles (RECOMMENDED for defense-in-depth)"
|
||||
|
||||
validation {
|
||||
condition = var.permissions_boundary == null || can(regex("^arn:aws:iam::[0-9]{12}:policy/", var.permissions_boundary))
|
||||
error_message = "Permissions boundary must be a valid IAM policy ARN."
|
||||
}
|
||||
}
|
||||
|
||||
variable "require_permissions_boundary" {
|
||||
type = bool
|
||||
default = false
|
||||
description = "Require a permissions boundary to be set (security guardrail)"
|
||||
}
|
||||
|
||||
variable "max_session_hours_limit" {
|
||||
type = number
|
||||
default = 4
|
||||
description = "Maximum allowed session duration in hours (caps role max_session_hours)"
|
||||
|
||||
validation {
|
||||
condition = var.max_session_hours_limit >= 1 && var.max_session_hours_limit <= 12
|
||||
error_message = "Max session hours must be between 1 and 12."
|
||||
}
|
||||
}
|
||||
|
||||
variable "deny_wildcard_repos" {
|
||||
type = bool
|
||||
default = true
|
||||
description = "Deny roles that allow all repos (*). Set false only if using workflow_ref restriction."
|
||||
}
|
||||
|
||||
variable "roles" {
|
||||
type = map(object({
|
||||
# Repository configuration
|
||||
repos = list(string) # GitHub repos (owner/repo or just repo if github_org set)
|
||||
branches = optional(list(string), []) # Branch restrictions (empty = all branches)
|
||||
tags = optional(list(string), []) # Tag restrictions (e.g., ["v*", "release-*"])
|
||||
environments = optional(list(string), []) # GitHub environment restrictions
|
||||
|
||||
# Event type restrictions
|
||||
pull_request = optional(bool, false) # Allow from pull_request events
|
||||
workflow_ref = optional(string, "") # Restrict to specific reusable workflow
|
||||
|
||||
# IAM configuration
|
||||
policy_arns = optional(list(string), []) # Managed policy ARNs to attach
|
||||
inline_policy = optional(string, "") # Inline policy JSON
|
||||
policy_statements = optional(list(object({ # Policy statements to generate
|
||||
sid = optional(string, "")
|
||||
effect = optional(string, "Allow")
|
||||
actions = list(string)
|
||||
resources = list(string)
|
||||
conditions = optional(list(object({
|
||||
test = string
|
||||
variable = string
|
||||
values = list(string)
|
||||
})), [])
|
||||
})), [])
|
||||
|
||||
# Session configuration
|
||||
max_session_hours = optional(number, 1) # Maximum session duration (1-12)
|
||||
|
||||
# Extra trust conditions
|
||||
extra_conditions = optional(map(map(list(string))), {}) # Additional assume role conditions
|
||||
}))
|
||||
default = {}
|
||||
description = "Map of role configurations for GitHub Actions"
|
||||
|
||||
validation {
|
||||
condition = alltrue([
|
||||
for k, v in var.roles : length(v.repos) > 0
|
||||
])
|
||||
error_message = "Each role must specify at least one repository."
|
||||
}
|
||||
|
||||
validation {
|
||||
condition = alltrue([
|
||||
for k, v in var.roles : v.max_session_hours >= 1 && v.max_session_hours <= 12
|
||||
])
|
||||
error_message = "Role max_session_hours must be between 1 and 12."
|
||||
}
|
||||
|
||||
validation {
|
||||
condition = alltrue([
|
||||
for k, v in var.roles : alltrue([
|
||||
for repo in v.repos : can(regex("^[a-zA-Z0-9][a-zA-Z0-9-_.]*/[a-zA-Z0-9][a-zA-Z0-9-_.]*$|^[a-zA-Z0-9][a-zA-Z0-9-_.]*$|^\\*$", repo))
|
||||
])
|
||||
])
|
||||
error_message = "Repository names must be valid GitHub repo format (owner/repo or repo)."
|
||||
}
|
||||
}
|
||||
|
||||
# Pre-built role templates
|
||||
variable "terraform_deploy_role" {
|
||||
type = object({
|
||||
enabled = optional(bool, false)
|
||||
repos = optional(list(string), [])
|
||||
branches = optional(list(string), ["main"])
|
||||
environments = optional(list(string), [])
|
||||
state_bucket = optional(string, "")
|
||||
state_bucket_key_prefix = optional(string, "*") # Limit to specific paths
|
||||
dynamodb_table = optional(string, "")
|
||||
allowed_services = optional(list(string), []) # Limit to specific AWS services
|
||||
denied_actions = optional(list(string), [ # Explicit denies for safety
|
||||
"iam:CreateUser",
|
||||
"iam:CreateAccessKey",
|
||||
"iam:DeleteAccountPasswordPolicy",
|
||||
"organizations:*",
|
||||
"account:*"
|
||||
])
|
||||
})
|
||||
default = {}
|
||||
description = "Pre-configured role for Terraform deployments"
|
||||
}
|
||||
|
||||
variable "ecr_push_role" {
|
||||
type = object({
|
||||
enabled = optional(bool, false)
|
||||
repos = optional(list(string), [])
|
||||
branches = optional(list(string), ["main"])
|
||||
ecr_repos = optional(list(string), []) # Specific ECR repos (no default wildcard)
|
||||
allow_create = optional(bool, false)
|
||||
allow_delete = optional(bool, false) # Explicit opt-in for delete
|
||||
})
|
||||
default = {}
|
||||
description = "Pre-configured role for ECR push operations"
|
||||
|
||||
validation {
|
||||
condition = !try(var.ecr_push_role.enabled, false) || length(try(var.ecr_push_role.ecr_repos, [])) > 0
|
||||
error_message = "ECR push role requires explicit ecr_repos list (no wildcards for security)."
|
||||
}
|
||||
}
|
||||
|
||||
variable "s3_deploy_role" {
|
||||
type = object({
|
||||
enabled = optional(bool, false)
|
||||
repos = optional(list(string), [])
|
||||
branches = optional(list(string), ["main"])
|
||||
bucket_arns = optional(list(string), [])
|
||||
allowed_prefixes = optional(list(string), ["*"]) # Limit to specific paths
|
||||
cloudfront_arns = optional(list(string), [])
|
||||
})
|
||||
default = {}
|
||||
description = "Pre-configured role for S3 static site deployments"
|
||||
|
||||
validation {
|
||||
condition = !try(var.s3_deploy_role.enabled, false) || length(try(var.s3_deploy_role.bucket_arns, [])) > 0
|
||||
error_message = "S3 deploy role requires explicit bucket_arns list."
|
||||
}
|
||||
}
|
||||
|
||||
variable "lambda_deploy_role" {
|
||||
type = object({
|
||||
enabled = optional(bool, false)
|
||||
repos = optional(list(string), [])
|
||||
branches = optional(list(string), ["main"])
|
||||
function_arns = optional(list(string), [])
|
||||
allow_create = optional(bool, false)
|
||||
allow_logs = optional(bool, true) # Allow CloudWatch Logs access
|
||||
})
|
||||
default = {}
|
||||
description = "Pre-configured role for Lambda deployments"
|
||||
|
||||
validation {
|
||||
condition = !try(var.lambda_deploy_role.enabled, false) || length(try(var.lambda_deploy_role.function_arns, [])) > 0
|
||||
error_message = "Lambda deploy role requires explicit function_arns list."
|
||||
}
|
||||
}
|
||||
|
||||
variable "enable_cloudtrail_logging" {
|
||||
type = bool
|
||||
default = true
|
||||
description = "Create CloudWatch metric alarms for OIDC role assumptions"
|
||||
}
|
||||
|
||||
variable "alarm_sns_topic_arn" {
|
||||
type = string
|
||||
default = ""
|
||||
description = "SNS topic ARN for security alarms"
|
||||
}
|
||||
|
||||
variable "tags" {
|
||||
type = map(string)
|
||||
default = {}
|
||||
description = "Tags to apply to all resources"
|
||||
|
||||
validation {
|
||||
condition = !contains(keys(var.tags), "Name")
|
||||
error_message = "Name tag is auto-generated, do not specify in tags variable."
|
||||
}
|
||||
}
|
||||
18
terraform/modules/github-oidc/versions.tf
Normal file
18
terraform/modules/github-oidc/versions.tf
Normal file
@@ -0,0 +1,18 @@
|
||||
################################################################################
|
||||
# GitHub OIDC Module - Versions
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.5.0"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
tls = {
|
||||
source = "hashicorp/tls"
|
||||
version = ">= 4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
50
terraform/modules/iam-account-settings/README.md
Normal file
50
terraform/modules/iam-account-settings/README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# iam-account-settings
|
||||
|
||||
IAM Account Settings Module
|
||||
|
||||
## Usage
|
||||
|
||||
```hcl
|
||||
module "iam_account_settings" {
|
||||
source = "../modules/iam-account-settings"
|
||||
|
||||
# Required variables
|
||||
password_policy = ""
|
||||
|
||||
# Optional: see variables.tf for all options
|
||||
}
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
| Name | Version |
|
||||
|------|---------|
|
||||
| terraform | >= 1.5.0 |
|
||||
| aws | >= 5.0 |
|
||||
|
||||
## Inputs
|
||||
|
||||
| Name | Description | Type | Required |
|
||||
|------|-------------|------|----------|
|
||||
| account_alias | AWS account alias (appears in sign-in URL) | `string` | no |
|
||||
| password_policy | | `object({` | yes |
|
||||
| enable_password_policy | Enable custom password policy | `bool` | no |
|
||||
| enforce_mfa | Create IAM policy to enforce MFA for all actions | `bool` | no |
|
||||
| mfa_grace_period_days | Days new users have before MFA is required (0 = immediate) | `number` | no |
|
||||
| mfa_exempt_roles | Role names exempt from MFA requirement | `list(string)` | no |
|
||||
| tags | | `map(string)` | no |
|
||||
|
||||
## Outputs
|
||||
|
||||
| Name | Description |
|
||||
|------|-------------|
|
||||
| account_alias | AWS account alias |
|
||||
| signin_url | |
|
||||
| password_policy | |
|
||||
| mfa_enforcement_policy_arn | MFA enforcement policy ARN |
|
||||
| mfa_required_group | Group name for users requiring MFA |
|
||||
| mfa_scp_template_policy | Template policy for MFA SCP (copy to Organizations) |
|
||||
|
||||
## License
|
||||
|
||||
Apache 2.0 - See LICENSE for details.
|
||||
338
terraform/modules/iam-account-settings/main.tf
Normal file
338
terraform/modules/iam-account-settings/main.tf
Normal file
@@ -0,0 +1,338 @@
|
||||
################################################################################
|
||||
# IAM Account Settings Module
|
||||
#
|
||||
# Account-level IAM security settings:
|
||||
# - Password policy (complexity, rotation, reuse)
|
||||
# - MFA enforcement via SCP/IAM policy
|
||||
# - Account alias
|
||||
# - SAML providers
|
||||
#
|
||||
# Usage:
|
||||
# module "iam_settings" {
|
||||
# source = "../modules/iam-account-settings"
|
||||
#
|
||||
# account_alias = "mycompany-prod"
|
||||
#
|
||||
# password_policy = {
|
||||
# minimum_length = 14
|
||||
# require_symbols = true
|
||||
# require_numbers = true
|
||||
# require_uppercase = true
|
||||
# require_lowercase = true
|
||||
# max_age_days = 90
|
||||
# password_reuse_prevention = 24
|
||||
# allow_users_to_change = true
|
||||
# }
|
||||
#
|
||||
# enforce_mfa = true
|
||||
# }
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "account_alias" {
|
||||
type = string
|
||||
default = ""
|
||||
description = "AWS account alias (appears in sign-in URL)"
|
||||
}
|
||||
|
||||
variable "password_policy" {
|
||||
type = object({
|
||||
minimum_length = optional(number, 14)
|
||||
require_symbols = optional(bool, true)
|
||||
require_numbers = optional(bool, true)
|
||||
require_uppercase_characters = optional(bool, true)
|
||||
require_lowercase_characters = optional(bool, true)
|
||||
allow_users_to_change_password = optional(bool, true)
|
||||
max_password_age = optional(number, 90)
|
||||
password_reuse_prevention = optional(number, 24)
|
||||
hard_expiry = optional(bool, false)
|
||||
})
|
||||
default = {}
|
||||
description = "Password policy settings"
|
||||
}
|
||||
|
||||
variable "enable_password_policy" {
|
||||
type = bool
|
||||
default = true
|
||||
description = "Enable custom password policy"
|
||||
}
|
||||
|
||||
variable "enforce_mfa" {
|
||||
type = bool
|
||||
default = false
|
||||
description = "Create IAM policy to enforce MFA for all actions"
|
||||
}
|
||||
|
||||
variable "mfa_grace_period_days" {
|
||||
type = number
|
||||
default = 0
|
||||
description = "Days new users have before MFA is required (0 = immediate)"
|
||||
}
|
||||
|
||||
variable "mfa_exempt_roles" {
|
||||
type = list(string)
|
||||
default = []
|
||||
description = "Role names exempt from MFA requirement"
|
||||
}
|
||||
|
||||
variable "tags" {
|
||||
type = map(string)
|
||||
default = {}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Account Alias
|
||||
################################################################################
|
||||
|
||||
resource "aws_iam_account_alias" "main" {
|
||||
count = var.account_alias != "" ? 1 : 0
|
||||
account_alias = var.account_alias
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Password Policy
|
||||
################################################################################
|
||||
|
||||
resource "aws_iam_account_password_policy" "main" {
|
||||
count = var.enable_password_policy ? 1 : 0
|
||||
|
||||
minimum_password_length = var.password_policy.minimum_length
|
||||
require_symbols = var.password_policy.require_symbols
|
||||
require_numbers = var.password_policy.require_numbers
|
||||
require_uppercase_characters = var.password_policy.require_uppercase_characters
|
||||
require_lowercase_characters = var.password_policy.require_lowercase_characters
|
||||
allow_users_to_change_password = var.password_policy.allow_users_to_change_password
|
||||
max_password_age = var.password_policy.max_password_age
|
||||
password_reuse_prevention = var.password_policy.password_reuse_prevention
|
||||
hard_expiry = var.password_policy.hard_expiry
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# MFA Enforcement Policy
|
||||
################################################################################
|
||||
|
||||
# This policy denies all actions (except MFA setup) if MFA is not present
|
||||
resource "aws_iam_policy" "enforce_mfa" {
|
||||
count = var.enforce_mfa ? 1 : 0
|
||||
|
||||
name = "EnforceMFA"
|
||||
description = "Denies all actions except MFA setup when MFA is not enabled"
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Sid = "AllowViewAccountInfo"
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"iam:GetAccountPasswordPolicy",
|
||||
"iam:ListVirtualMFADevices"
|
||||
]
|
||||
Resource = "*"
|
||||
},
|
||||
{
|
||||
Sid = "AllowManageOwnPasswords"
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"iam:ChangePassword",
|
||||
"iam:GetUser"
|
||||
]
|
||||
Resource = "arn:aws:iam::*:user/$${aws:username}"
|
||||
},
|
||||
{
|
||||
Sid = "AllowManageOwnAccessKeys"
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"iam:CreateAccessKey",
|
||||
"iam:DeleteAccessKey",
|
||||
"iam:ListAccessKeys",
|
||||
"iam:UpdateAccessKey",
|
||||
"iam:GetAccessKeyLastUsed"
|
||||
]
|
||||
Resource = "arn:aws:iam::*:user/$${aws:username}"
|
||||
},
|
||||
{
|
||||
Sid = "AllowManageOwnSigningCertificates"
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"iam:DeleteSigningCertificate",
|
||||
"iam:ListSigningCertificates",
|
||||
"iam:UpdateSigningCertificate",
|
||||
"iam:UploadSigningCertificate"
|
||||
]
|
||||
Resource = "arn:aws:iam::*:user/$${aws:username}"
|
||||
},
|
||||
{
|
||||
Sid = "AllowManageOwnSSHPublicKeys"
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"iam:DeleteSSHPublicKey",
|
||||
"iam:GetSSHPublicKey",
|
||||
"iam:ListSSHPublicKeys",
|
||||
"iam:UpdateSSHPublicKey",
|
||||
"iam:UploadSSHPublicKey"
|
||||
]
|
||||
Resource = "arn:aws:iam::*:user/$${aws:username}"
|
||||
},
|
||||
{
|
||||
Sid = "AllowManageOwnGitCredentials"
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"iam:CreateServiceSpecificCredential",
|
||||
"iam:DeleteServiceSpecificCredential",
|
||||
"iam:ListServiceSpecificCredentials",
|
||||
"iam:ResetServiceSpecificCredential",
|
||||
"iam:UpdateServiceSpecificCredential"
|
||||
]
|
||||
Resource = "arn:aws:iam::*:user/$${aws:username}"
|
||||
},
|
||||
{
|
||||
Sid = "AllowManageOwnVirtualMFADevice"
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"iam:CreateVirtualMFADevice",
|
||||
"iam:DeleteVirtualMFADevice"
|
||||
]
|
||||
Resource = "arn:aws:iam::*:mfa/*"
|
||||
},
|
||||
{
|
||||
Sid = "AllowManageOwnUserMFA"
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"iam:DeactivateMFADevice",
|
||||
"iam:EnableMFADevice",
|
||||
"iam:ListMFADevices",
|
||||
"iam:ResyncMFADevice"
|
||||
]
|
||||
Resource = "arn:aws:iam::*:user/$${aws:username}"
|
||||
},
|
||||
{
|
||||
Sid = "DenyAllExceptListedIfNoMFA"
|
||||
Effect = "Deny"
|
||||
NotAction = [
|
||||
"iam:CreateVirtualMFADevice",
|
||||
"iam:EnableMFADevice",
|
||||
"iam:GetUser",
|
||||
"iam:GetMFADevice",
|
||||
"iam:ListMFADevices",
|
||||
"iam:ListVirtualMFADevices",
|
||||
"iam:ResyncMFADevice",
|
||||
"sts:GetSessionToken",
|
||||
"iam:ChangePassword",
|
||||
"iam:GetAccountPasswordPolicy"
|
||||
]
|
||||
Resource = "*"
|
||||
Condition = {
|
||||
BoolIfExists = {
|
||||
"aws:MultiFactorAuthPresent" = "false"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
tags = merge(var.tags, { Name = "EnforceMFA" })
|
||||
}
|
||||
|
||||
# Group for users who must have MFA
|
||||
resource "aws_iam_group" "mfa_required" {
|
||||
count = var.enforce_mfa ? 1 : 0
|
||||
name = "MFARequired"
|
||||
}
|
||||
|
||||
resource "aws_iam_group_policy_attachment" "mfa_required" {
|
||||
count = var.enforce_mfa ? 1 : 0
|
||||
group = aws_iam_group.mfa_required[0].name
|
||||
policy_arn = aws_iam_policy.enforce_mfa[0].arn
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# MFA Enforcement SCP (for Organizations)
|
||||
################################################################################
|
||||
|
||||
# This can be attached at the OU level for organization-wide enforcement
|
||||
resource "aws_iam_policy" "mfa_scp_template" {
|
||||
count = var.enforce_mfa ? 1 : 0
|
||||
|
||||
name = "MFA-SCP-Template"
|
||||
description = "Template SCP for MFA enforcement (apply via aws_organizations_policy)"
|
||||
|
||||
# Note: This is an IAM policy format - for SCP, use this as a template
|
||||
# SCPs don't support aws:MultiFactorAuthPresent for all actions
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Sid = "DenyStopAndTerminateWithoutMFA"
|
||||
Effect = "Deny"
|
||||
Action = [
|
||||
"ec2:StopInstances",
|
||||
"ec2:TerminateInstances",
|
||||
"rds:DeleteDBInstance",
|
||||
"rds:DeleteDBCluster",
|
||||
"s3:DeleteBucket",
|
||||
"iam:DeleteUser",
|
||||
"iam:DeleteRole"
|
||||
]
|
||||
Resource = "*"
|
||||
Condition = {
|
||||
BoolIfExists = {
|
||||
"aws:MultiFactorAuthPresent" = "false"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
tags = merge(var.tags, { Name = "MFA-SCP-Template" })
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Outputs
|
||||
################################################################################
|
||||
|
||||
output "account_alias" {
|
||||
value = var.account_alias != "" ? var.account_alias : null
|
||||
description = "AWS account alias"
|
||||
}
|
||||
|
||||
output "signin_url" {
|
||||
value = var.account_alias != "" ? "https://${var.account_alias}.signin.aws.amazon.com/console" : null
|
||||
description = "AWS console sign-in URL"
|
||||
}
|
||||
|
||||
output "password_policy" {
|
||||
value = var.enable_password_policy ? {
|
||||
minimum_length = var.password_policy.minimum_length
|
||||
require_symbols = var.password_policy.require_symbols
|
||||
require_numbers = var.password_policy.require_numbers
|
||||
require_uppercase = var.password_policy.require_uppercase_characters
|
||||
require_lowercase = var.password_policy.require_lowercase_characters
|
||||
max_age_days = var.password_policy.max_password_age
|
||||
reuse_prevention = var.password_policy.password_reuse_prevention
|
||||
} : null
|
||||
description = "Password policy settings"
|
||||
}
|
||||
|
||||
output "mfa_enforcement_policy_arn" {
|
||||
value = var.enforce_mfa ? aws_iam_policy.enforce_mfa[0].arn : null
|
||||
description = "MFA enforcement policy ARN"
|
||||
}
|
||||
|
||||
output "mfa_required_group" {
|
||||
value = var.enforce_mfa ? aws_iam_group.mfa_required[0].name : null
|
||||
description = "Group name for users requiring MFA"
|
||||
}
|
||||
|
||||
output "mfa_scp_template_policy" {
|
||||
value = var.enforce_mfa ? aws_iam_policy.mfa_scp_template[0].policy : null
|
||||
description = "Template policy for MFA SCP (copy to Organizations)"
|
||||
}
|
||||
60
terraform/modules/iam-role/README.md
Normal file
60
terraform/modules/iam-role/README.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# iam-role
|
||||
|
||||
IAM Role Module
|
||||
|
||||
## Usage
|
||||
|
||||
```hcl
|
||||
module "iam_role" {
|
||||
source = "../modules/iam-role"
|
||||
|
||||
# Required variables
|
||||
name = ""
|
||||
|
||||
# Optional: see variables.tf for all options
|
||||
}
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
| Name | Version |
|
||||
|------|---------|
|
||||
| terraform | >= 1.5.0 |
|
||||
| aws | >= 5.0 |
|
||||
|
||||
## Inputs
|
||||
|
||||
| Name | Description | Type | Required |
|
||||
|------|-------------|------|----------|
|
||||
| name | Role name | `string` | yes |
|
||||
| role_type | Type: service, cross-account, oidc | `string` | no |
|
||||
| description | Role description | `string` | no |
|
||||
| path | IAM path | `string` | no |
|
||||
| max_session_duration | Maximum session duration in seconds (1-12 hours) | `number` | no |
|
||||
| service | AWS service principal (e.g., lambda.amazonaws.com) | `string` | no |
|
||||
| services | Multiple service principals | `list(string)` | no |
|
||||
| trusted_account_ids | Account IDs that can assume this role | `list(string)` | no |
|
||||
| trusted_role_arns | Specific role ARNs that can assume this role | `list(string)` | no |
|
||||
| require_mfa | Require MFA for cross-account assumption | `bool` | no |
|
||||
| require_external_id | External ID required for assumption | `string` | no |
|
||||
| oidc_provider_arn | OIDC provider ARN | `string` | no |
|
||||
| oidc_subjects | Allowed OIDC subjects (e.g., repo:org/repo:*) | `list(string)` | no |
|
||||
| oidc_audiences | OIDC audiences | `list(string)` | no |
|
||||
| managed_policies | List of managed policy ARNs to attach | `list(string)` | no |
|
||||
|
||||
*...and 4 more variables. See `variables.tf` for complete list.*
|
||||
|
||||
## Outputs
|
||||
|
||||
| Name | Description |
|
||||
|------|-------------|
|
||||
| role_arn | Role ARN |
|
||||
| role_name | Role name |
|
||||
| role_id | Role unique ID |
|
||||
| instance_profile_arn | Instance profile ARN |
|
||||
| instance_profile_name | Instance profile name |
|
||||
| assume_role_command | |
|
||||
|
||||
## License
|
||||
|
||||
Apache 2.0 - See LICENSE for details.
|
||||
352
terraform/modules/iam-role/main.tf
Normal file
352
terraform/modules/iam-role/main.tf
Normal file
@@ -0,0 +1,352 @@
|
||||
################################################################################
|
||||
# IAM Role Module
|
||||
#
|
||||
# Common IAM role patterns:
|
||||
# - Service roles (EC2, Lambda, ECS, etc.)
|
||||
# - Cross-account roles (OrganizationAccountAccessRole pattern)
|
||||
# - OIDC roles (GitHub Actions, EKS service accounts)
|
||||
# - Instance profiles
|
||||
#
|
||||
# Usage:
|
||||
# # Lambda execution role
|
||||
# module "lambda_role" {
|
||||
# source = "../modules/iam-role"
|
||||
#
|
||||
# name = "my-lambda"
|
||||
# role_type = "service"
|
||||
# service = "lambda.amazonaws.com"
|
||||
# managed_policies = ["arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"]
|
||||
# }
|
||||
#
|
||||
# # GitHub Actions OIDC
|
||||
# module "github_role" {
|
||||
# source = "../modules/iam-role"
|
||||
#
|
||||
# name = "github-deploy"
|
||||
# role_type = "oidc"
|
||||
# oidc_provider_arn = aws_iam_openid_connect_provider.github.arn
|
||||
# oidc_subjects = ["repo:myorg/myrepo:*"]
|
||||
# }
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "name" {
|
||||
type = string
|
||||
description = "Role name"
|
||||
}
|
||||
|
||||
variable "role_type" {
|
||||
type = string
|
||||
default = "service"
|
||||
description = "Type: service, cross-account, oidc"
|
||||
|
||||
validation {
|
||||
condition = contains(["service", "cross-account", "oidc"], var.role_type)
|
||||
error_message = "Must be service, cross-account, or oidc"
|
||||
}
|
||||
}
|
||||
|
||||
variable "description" {
|
||||
type = string
|
||||
default = ""
|
||||
description = "Role description"
|
||||
}
|
||||
|
||||
variable "path" {
|
||||
type = string
|
||||
default = "/"
|
||||
description = "IAM path"
|
||||
}
|
||||
|
||||
variable "max_session_duration" {
|
||||
type = number
|
||||
default = 3600
|
||||
description = "Maximum session duration in seconds (1-12 hours)"
|
||||
}
|
||||
|
||||
# Service role settings
|
||||
variable "service" {
|
||||
type = string
|
||||
default = ""
|
||||
description = "AWS service principal (e.g., lambda.amazonaws.com)"
|
||||
}
|
||||
|
||||
variable "services" {
|
||||
type = list(string)
|
||||
default = []
|
||||
description = "Multiple service principals"
|
||||
}
|
||||
|
||||
# Cross-account settings
|
||||
variable "trusted_account_ids" {
|
||||
type = list(string)
|
||||
default = []
|
||||
description = "Account IDs that can assume this role"
|
||||
}
|
||||
|
||||
variable "trusted_role_arns" {
|
||||
type = list(string)
|
||||
default = []
|
||||
description = "Specific role ARNs that can assume this role"
|
||||
}
|
||||
|
||||
variable "require_mfa" {
|
||||
type = bool
|
||||
default = false
|
||||
description = "Require MFA for cross-account assumption"
|
||||
}
|
||||
|
||||
variable "require_external_id" {
|
||||
type = string
|
||||
default = ""
|
||||
description = "External ID required for assumption"
|
||||
}
|
||||
|
||||
# OIDC settings
|
||||
variable "oidc_provider_arn" {
|
||||
type = string
|
||||
default = ""
|
||||
description = "OIDC provider ARN"
|
||||
}
|
||||
|
||||
variable "oidc_subjects" {
|
||||
type = list(string)
|
||||
default = []
|
||||
description = "Allowed OIDC subjects (e.g., repo:org/repo:*)"
|
||||
}
|
||||
|
||||
variable "oidc_audiences" {
|
||||
type = list(string)
|
||||
default = ["sts.amazonaws.com"]
|
||||
description = "OIDC audiences"
|
||||
}
|
||||
|
||||
# Policies
|
||||
variable "managed_policies" {
|
||||
type = list(string)
|
||||
default = []
|
||||
description = "List of managed policy ARNs to attach"
|
||||
}
|
||||
|
||||
variable "inline_policies" {
|
||||
type = map(string)
|
||||
default = {}
|
||||
description = "Map of inline policy name -> JSON policy document"
|
||||
}
|
||||
|
||||
# Instance profile
|
||||
variable "create_instance_profile" {
|
||||
type = bool
|
||||
default = false
|
||||
description = "Create an instance profile (for EC2)"
|
||||
}
|
||||
|
||||
# Permissions boundary
|
||||
variable "permissions_boundary" {
|
||||
type = string
|
||||
default = ""
|
||||
description = "Permissions boundary ARN"
|
||||
}
|
||||
|
||||
variable "tags" {
|
||||
type = map(string)
|
||||
default = {}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Data Sources
|
||||
################################################################################
|
||||
|
||||
data "aws_caller_identity" "current" {}
|
||||
|
||||
locals {
|
||||
service_principals = var.service != "" ? [var.service] : var.services
|
||||
|
||||
description = var.description != "" ? var.description : (
|
||||
var.role_type == "service" ? "Service role for ${join(", ", local.service_principals)}" :
|
||||
var.role_type == "cross-account" ? "Cross-account role" :
|
||||
"OIDC role"
|
||||
)
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Assume Role Policy
|
||||
################################################################################
|
||||
|
||||
data "aws_iam_policy_document" "assume_role" {
|
||||
# Service role trust
|
||||
dynamic "statement" {
|
||||
for_each = var.role_type == "service" && length(local.service_principals) > 0 ? [1] : []
|
||||
content {
|
||||
effect = "Allow"
|
||||
actions = ["sts:AssumeRole"]
|
||||
principals {
|
||||
type = "Service"
|
||||
identifiers = local.service_principals
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Cross-account trust (account IDs)
|
||||
dynamic "statement" {
|
||||
for_each = var.role_type == "cross-account" && length(var.trusted_account_ids) > 0 ? [1] : []
|
||||
content {
|
||||
effect = "Allow"
|
||||
actions = ["sts:AssumeRole"]
|
||||
principals {
|
||||
type = "AWS"
|
||||
identifiers = [for id in var.trusted_account_ids : "arn:aws:iam::${id}:root"]
|
||||
}
|
||||
|
||||
dynamic "condition" {
|
||||
for_each = var.require_mfa ? [1] : []
|
||||
content {
|
||||
test = "Bool"
|
||||
variable = "aws:MultiFactorAuthPresent"
|
||||
values = ["true"]
|
||||
}
|
||||
}
|
||||
|
||||
dynamic "condition" {
|
||||
for_each = var.require_external_id != "" ? [1] : []
|
||||
content {
|
||||
test = "StringEquals"
|
||||
variable = "sts:ExternalId"
|
||||
values = [var.require_external_id]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Cross-account trust (specific roles)
|
||||
dynamic "statement" {
|
||||
for_each = var.role_type == "cross-account" && length(var.trusted_role_arns) > 0 ? [1] : []
|
||||
content {
|
||||
effect = "Allow"
|
||||
actions = ["sts:AssumeRole"]
|
||||
principals {
|
||||
type = "AWS"
|
||||
identifiers = var.trusted_role_arns
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# OIDC trust
|
||||
dynamic "statement" {
|
||||
for_each = var.role_type == "oidc" && var.oidc_provider_arn != "" ? [1] : []
|
||||
content {
|
||||
effect = "Allow"
|
||||
actions = ["sts:AssumeRoleWithWebIdentity"]
|
||||
principals {
|
||||
type = "Federated"
|
||||
identifiers = [var.oidc_provider_arn]
|
||||
}
|
||||
|
||||
dynamic "condition" {
|
||||
for_each = length(var.oidc_subjects) > 0 ? [1] : []
|
||||
content {
|
||||
test = "StringLike"
|
||||
variable = "${replace(var.oidc_provider_arn, "/.*oidc-provider\\//", "")}:sub"
|
||||
values = var.oidc_subjects
|
||||
}
|
||||
}
|
||||
|
||||
condition {
|
||||
test = "StringEquals"
|
||||
variable = "${replace(var.oidc_provider_arn, "/.*oidc-provider\\//", "")}:aud"
|
||||
values = var.oidc_audiences
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# IAM Role
|
||||
################################################################################
|
||||
|
||||
resource "aws_iam_role" "main" {
|
||||
name = var.name
|
||||
description = local.description
|
||||
path = var.path
|
||||
max_session_duration = var.max_session_duration
|
||||
|
||||
assume_role_policy = data.aws_iam_policy_document.assume_role.json
|
||||
permissions_boundary = var.permissions_boundary != "" ? var.permissions_boundary : null
|
||||
|
||||
tags = merge(var.tags, { Name = var.name })
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Managed Policies
|
||||
################################################################################
|
||||
|
||||
resource "aws_iam_role_policy_attachment" "managed" {
|
||||
for_each = toset(var.managed_policies)
|
||||
role = aws_iam_role.main.name
|
||||
policy_arn = each.value
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Inline Policies
|
||||
################################################################################
|
||||
|
||||
resource "aws_iam_role_policy" "inline" {
|
||||
for_each = var.inline_policies
|
||||
name = each.key
|
||||
role = aws_iam_role.main.id
|
||||
policy = each.value
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Instance Profile
|
||||
################################################################################
|
||||
|
||||
resource "aws_iam_instance_profile" "main" {
|
||||
count = var.create_instance_profile ? 1 : 0
|
||||
name = var.name
|
||||
role = aws_iam_role.main.name
|
||||
|
||||
tags = merge(var.tags, { Name = var.name })
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Outputs
|
||||
################################################################################
|
||||
|
||||
output "role_arn" {
|
||||
value = aws_iam_role.main.arn
|
||||
description = "Role ARN"
|
||||
}
|
||||
|
||||
output "role_name" {
|
||||
value = aws_iam_role.main.name
|
||||
description = "Role name"
|
||||
}
|
||||
|
||||
output "role_id" {
|
||||
value = aws_iam_role.main.unique_id
|
||||
description = "Role unique ID"
|
||||
}
|
||||
|
||||
output "instance_profile_arn" {
|
||||
value = var.create_instance_profile ? aws_iam_instance_profile.main[0].arn : null
|
||||
description = "Instance profile ARN"
|
||||
}
|
||||
|
||||
output "instance_profile_name" {
|
||||
value = var.create_instance_profile ? aws_iam_instance_profile.main[0].name : null
|
||||
description = "Instance profile name"
|
||||
}
|
||||
|
||||
output "assume_role_command" {
|
||||
value = var.role_type == "cross-account" ? "aws sts assume-role --role-arn ${aws_iam_role.main.arn} --role-session-name my-session" : null
|
||||
description = "CLI command to assume the role"
|
||||
}
|
||||
40
terraform/modules/identity-center/README.md
Normal file
40
terraform/modules/identity-center/README.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# identity-center
|
||||
|
||||
Terraform module for AWS landing zone pattern.
|
||||
|
||||
Configure AWS IAM Identity Center (formerly AWS SSO).
|
||||
|
||||
## Planned Features
|
||||
|
||||
- [ ] Default permission sets (Admin, PowerUser, ReadOnly, Billing)
|
||||
- [ ] Custom permission sets with managed + inline policies
|
||||
- [ ] Group-to-account assignments
|
||||
- [ ] SCIM provisioning setup
|
||||
- [ ] MFA enforcement
|
||||
- [ ] Session duration policies
|
||||
|
||||
## Planned Usage
|
||||
|
||||
```hcl
|
||||
module "identity_center" {
|
||||
source = "../modules/identity-center"
|
||||
|
||||
default_permission_sets = true
|
||||
|
||||
permission_sets = {
|
||||
DatabaseAdmin = {
|
||||
description = "Database administration access"
|
||||
session_duration = "PT8H"
|
||||
managed_policies = ["arn:aws:iam::aws:policy/AmazonRDSFullAccess"]
|
||||
}
|
||||
}
|
||||
|
||||
group_assignments = {
|
||||
admins_prod = {
|
||||
group_name = "AWS-Admins"
|
||||
permission_set = "AdministratorAccess"
|
||||
account_ids = ["111111111111", "222222222222"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
145
terraform/modules/identity-center/main.tf
Normal file
145
terraform/modules/identity-center/main.tf
Normal file
@@ -0,0 +1,145 @@
|
||||
################################################################################
|
||||
# Identity Center Module
|
||||
#
|
||||
# Configures AWS IAM Identity Center (formerly AWS SSO):
|
||||
# - Permission sets with managed and inline policies
|
||||
# - Account assignments for groups
|
||||
# - Default permission sets (Admin, PowerUser, ReadOnly, Billing)
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.5.0"
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data "aws_ssoadmin_instances" "this" {}
|
||||
|
||||
locals {
|
||||
instance_arn = tolist(data.aws_ssoadmin_instances.this.arns)[0]
|
||||
identity_store_id = tolist(data.aws_ssoadmin_instances.this.identity_store_ids)[0]
|
||||
|
||||
# Default permission sets
|
||||
default_permission_sets = var.create_default_permission_sets ? {
|
||||
AdministratorAccess = {
|
||||
description = "Full administrator access"
|
||||
session_duration = "PT4H"
|
||||
managed_policies = ["arn:aws:iam::aws:policy/AdministratorAccess"]
|
||||
inline_policy = ""
|
||||
}
|
||||
PowerUserAccess = {
|
||||
description = "Power user access (no IAM)"
|
||||
session_duration = "PT4H"
|
||||
managed_policies = ["arn:aws:iam::aws:policy/PowerUserAccess"]
|
||||
inline_policy = ""
|
||||
}
|
||||
ReadOnlyAccess = {
|
||||
description = "Read-only access"
|
||||
session_duration = "PT8H"
|
||||
managed_policies = ["arn:aws:iam::aws:policy/ReadOnlyAccess"]
|
||||
inline_policy = ""
|
||||
}
|
||||
Billing = {
|
||||
description = "Billing access"
|
||||
session_duration = "PT4H"
|
||||
managed_policies = ["arn:aws:iam::aws:policy/job-function/Billing"]
|
||||
inline_policy = ""
|
||||
}
|
||||
ViewOnlyAccess = {
|
||||
description = "View-only access (no data access)"
|
||||
session_duration = "PT8H"
|
||||
managed_policies = ["arn:aws:iam::aws:policy/job-function/ViewOnlyAccess"]
|
||||
inline_policy = ""
|
||||
}
|
||||
} : {}
|
||||
|
||||
# Merge default and custom permission sets
|
||||
all_permission_sets = merge(local.default_permission_sets, var.permission_sets)
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Permission Sets
|
||||
################################################################################
|
||||
|
||||
resource "aws_ssoadmin_permission_set" "this" {
|
||||
for_each = local.all_permission_sets
|
||||
|
||||
instance_arn = local.instance_arn
|
||||
name = each.key
|
||||
description = each.value.description
|
||||
session_duration = each.value.session_duration
|
||||
|
||||
tags = merge(var.tags, {
|
||||
Name = each.key
|
||||
})
|
||||
}
|
||||
|
||||
# Attach managed policies
|
||||
resource "aws_ssoadmin_managed_policy_attachment" "this" {
|
||||
for_each = {
|
||||
for pair in flatten([
|
||||
for ps_name, ps in local.all_permission_sets : [
|
||||
for policy in ps.managed_policies : {
|
||||
key = "${ps_name}-${replace(policy, "/.*//", "")}"
|
||||
ps_name = ps_name
|
||||
policy_arn = policy
|
||||
}
|
||||
]
|
||||
]) : pair.key => pair
|
||||
}
|
||||
|
||||
instance_arn = local.instance_arn
|
||||
permission_set_arn = aws_ssoadmin_permission_set.this[each.value.ps_name].arn
|
||||
managed_policy_arn = each.value.policy_arn
|
||||
}
|
||||
|
||||
# Attach inline policies
|
||||
resource "aws_ssoadmin_permission_set_inline_policy" "this" {
|
||||
for_each = {
|
||||
for name, ps in local.all_permission_sets : name => ps
|
||||
if ps.inline_policy != ""
|
||||
}
|
||||
|
||||
instance_arn = local.instance_arn
|
||||
permission_set_arn = aws_ssoadmin_permission_set.this[each.key].arn
|
||||
inline_policy = each.value.inline_policy
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Account Assignments
|
||||
################################################################################
|
||||
|
||||
# Look up groups from Identity Store
|
||||
data "aws_identitystore_group" "this" {
|
||||
for_each = toset([for a in var.account_assignments : a.group_name])
|
||||
|
||||
identity_store_id = local.identity_store_id
|
||||
|
||||
alternate_identifier {
|
||||
unique_attribute {
|
||||
attribute_path = "DisplayName"
|
||||
attribute_value = each.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Create account assignments
|
||||
resource "aws_ssoadmin_account_assignment" "this" {
|
||||
for_each = {
|
||||
for a in var.account_assignments :
|
||||
"${a.group_name}-${a.permission_set}-${a.account_id}" => a
|
||||
}
|
||||
|
||||
instance_arn = local.instance_arn
|
||||
permission_set_arn = aws_ssoadmin_permission_set.this[each.value.permission_set].arn
|
||||
|
||||
principal_id = data.aws_identitystore_group.this[each.value.group_name].group_id
|
||||
principal_type = "GROUP"
|
||||
|
||||
target_id = each.value.account_id
|
||||
target_type = "AWS_ACCOUNT"
|
||||
}
|
||||
28
terraform/modules/identity-center/outputs.tf
Normal file
28
terraform/modules/identity-center/outputs.tf
Normal file
@@ -0,0 +1,28 @@
|
||||
################################################################################
|
||||
# Identity Center - Outputs
|
||||
################################################################################
|
||||
|
||||
output "instance_arn" {
|
||||
value = local.instance_arn
|
||||
description = "Identity Center instance ARN"
|
||||
}
|
||||
|
||||
output "identity_store_id" {
|
||||
value = local.identity_store_id
|
||||
description = "Identity Store ID"
|
||||
}
|
||||
|
||||
output "permission_set_arns" {
|
||||
value = { for k, v in aws_ssoadmin_permission_set.this : k => v.arn }
|
||||
description = "Map of permission set names to ARNs"
|
||||
}
|
||||
|
||||
output "sso_start_url" {
|
||||
value = "https://${local.identity_store_id}.awsapps.com/start"
|
||||
description = "SSO portal start URL"
|
||||
}
|
||||
|
||||
output "assignment_count" {
|
||||
value = length(aws_ssoadmin_account_assignment.this)
|
||||
description = "Number of account assignments created"
|
||||
}
|
||||
36
terraform/modules/identity-center/variables.tf
Normal file
36
terraform/modules/identity-center/variables.tf
Normal file
@@ -0,0 +1,36 @@
|
||||
################################################################################
|
||||
# Identity Center - Input Variables
|
||||
################################################################################
|
||||
|
||||
variable "create_default_permission_sets" {
|
||||
type = bool
|
||||
default = true
|
||||
description = "Create default permission sets (Admin, PowerUser, ReadOnly, Billing)"
|
||||
}
|
||||
|
||||
variable "permission_sets" {
|
||||
type = map(object({
|
||||
description = string
|
||||
session_duration = optional(string, "PT4H")
|
||||
managed_policies = optional(list(string), [])
|
||||
inline_policy = optional(string, "")
|
||||
}))
|
||||
default = {}
|
||||
description = "Custom permission sets to create"
|
||||
}
|
||||
|
||||
variable "account_assignments" {
|
||||
type = list(object({
|
||||
group_name = string
|
||||
permission_set = string
|
||||
account_id = string
|
||||
}))
|
||||
default = []
|
||||
description = "Group to account/permission assignments"
|
||||
}
|
||||
|
||||
variable "tags" {
|
||||
type = map(string)
|
||||
default = {}
|
||||
description = "Tags to apply to resources"
|
||||
}
|
||||
54
terraform/modules/kms-key/README.md
Normal file
54
terraform/modules/kms-key/README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# kms-key
|
||||
|
||||
KMS Key Module
|
||||
|
||||
## Usage
|
||||
|
||||
```hcl
|
||||
module "kms_key" {
|
||||
source = "../modules/kms-key"
|
||||
|
||||
# Required variables
|
||||
name = ""
|
||||
|
||||
# Optional: see variables.tf for all options
|
||||
}
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
| Name | Version |
|
||||
|------|---------|
|
||||
| terraform | >= 1.5.0 |
|
||||
| aws | >= 5.0 |
|
||||
|
||||
## Inputs
|
||||
|
||||
| Name | Description | Type | Required |
|
||||
|------|-------------|------|----------|
|
||||
| name | Key name (used for alias) | `string` | yes |
|
||||
| description | Key description | `string` | no |
|
||||
| deletion_window_in_days | Waiting period before key deletion (7-30 days) | `number` | no |
|
||||
| enable_key_rotation | Enable automatic key rotation (annual) | `bool` | no |
|
||||
| multi_region | Create a multi-region key | `bool` | no |
|
||||
| key_usage | Key usage: ENCRYPT_DECRYPT or SIGN_VERIFY | `string` | no |
|
||||
| key_spec | Key spec (SYMMETRIC_DEFAULT, RSA_2048, ECC_NIST_P256, etc.) | `string` | no |
|
||||
| admin_principals | IAM ARNs with full admin access to the key | `list(string)` | no |
|
||||
| user_principals | IAM ARNs with encrypt/decrypt access | `list(string)` | no |
|
||||
| service_principals | AWS service principals that can use the key (e.g., logs.amaz... | `list(string)` | no |
|
||||
| grant_accounts | Account IDs with cross-account access | `list(string)` | no |
|
||||
| tags | | `map(string)` | no |
|
||||
|
||||
## Outputs
|
||||
|
||||
| Name | Description |
|
||||
|------|-------------|
|
||||
| key_id | KMS key ID |
|
||||
| key_arn | KMS key ARN |
|
||||
| alias_arn | KMS alias ARN |
|
||||
| alias_name | KMS alias name |
|
||||
| key_policy | Key policy document |
|
||||
|
||||
## License
|
||||
|
||||
Apache 2.0 - See LICENSE for details.
|
||||
290
terraform/modules/kms-key/main.tf
Normal file
290
terraform/modules/kms-key/main.tf
Normal file
@@ -0,0 +1,290 @@
|
||||
################################################################################
|
||||
# KMS Key Module
|
||||
#
|
||||
# Customer-managed KMS keys for encryption:
|
||||
# - Automatic key rotation
|
||||
# - Cross-account access
|
||||
# - Service-specific grants
|
||||
# - Alias management
|
||||
# - Key policies
|
||||
#
|
||||
# Usage:
|
||||
# module "kms" {
|
||||
# source = "../modules/kms-key"
|
||||
#
|
||||
# name = "myapp-encryption"
|
||||
# description = "Encryption key for myapp"
|
||||
#
|
||||
# service_principals = ["logs.amazonaws.com", "s3.amazonaws.com"]
|
||||
# }
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "name" {
|
||||
type = string
|
||||
description = "Key name (used for alias)"
|
||||
}
|
||||
|
||||
variable "description" {
|
||||
type = string
|
||||
default = ""
|
||||
description = "Key description"
|
||||
}
|
||||
|
||||
variable "deletion_window_in_days" {
|
||||
type = number
|
||||
default = 30
|
||||
description = "Waiting period before key deletion (7-30 days)"
|
||||
|
||||
validation {
|
||||
condition = var.deletion_window_in_days >= 7 && var.deletion_window_in_days <= 30
|
||||
error_message = "Must be between 7 and 30 days"
|
||||
}
|
||||
}
|
||||
|
||||
variable "enable_key_rotation" {
|
||||
type = bool
|
||||
default = true
|
||||
description = "Enable automatic key rotation (annual)"
|
||||
}
|
||||
|
||||
variable "multi_region" {
|
||||
type = bool
|
||||
default = false
|
||||
description = "Create a multi-region key"
|
||||
}
|
||||
|
||||
variable "key_usage" {
|
||||
type = string
|
||||
default = "ENCRYPT_DECRYPT"
|
||||
description = "Key usage: ENCRYPT_DECRYPT or SIGN_VERIFY"
|
||||
|
||||
validation {
|
||||
condition = contains(["ENCRYPT_DECRYPT", "SIGN_VERIFY", "GENERATE_VERIFY_MAC"], var.key_usage)
|
||||
error_message = "Must be ENCRYPT_DECRYPT, SIGN_VERIFY, or GENERATE_VERIFY_MAC"
|
||||
}
|
||||
}
|
||||
|
||||
variable "key_spec" {
|
||||
type = string
|
||||
default = "SYMMETRIC_DEFAULT"
|
||||
description = "Key spec (SYMMETRIC_DEFAULT, RSA_2048, ECC_NIST_P256, etc.)"
|
||||
}
|
||||
|
||||
variable "admin_principals" {
|
||||
type = list(string)
|
||||
default = []
|
||||
description = "IAM ARNs with full admin access to the key"
|
||||
}
|
||||
|
||||
variable "user_principals" {
|
||||
type = list(string)
|
||||
default = []
|
||||
description = "IAM ARNs with encrypt/decrypt access"
|
||||
}
|
||||
|
||||
variable "service_principals" {
|
||||
type = list(string)
|
||||
default = []
|
||||
description = "AWS service principals that can use the key (e.g., logs.amazonaws.com)"
|
||||
}
|
||||
|
||||
variable "grant_accounts" {
|
||||
type = list(string)
|
||||
default = []
|
||||
description = "Account IDs with cross-account access"
|
||||
}
|
||||
|
||||
variable "tags" {
|
||||
type = map(string)
|
||||
default = {}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Data Sources
|
||||
################################################################################
|
||||
|
||||
data "aws_caller_identity" "current" {}
|
||||
data "aws_region" "current" {}
|
||||
|
||||
################################################################################
|
||||
# KMS Key
|
||||
################################################################################
|
||||
|
||||
resource "aws_kms_key" "main" {
|
||||
description = var.description != "" ? var.description : "KMS key for ${var.name}"
|
||||
deletion_window_in_days = var.deletion_window_in_days
|
||||
enable_key_rotation = var.key_spec == "SYMMETRIC_DEFAULT" ? var.enable_key_rotation : false
|
||||
multi_region = var.multi_region
|
||||
key_usage = var.key_usage
|
||||
customer_master_key_spec = var.key_spec
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = concat(
|
||||
# Root account access (required)
|
||||
[{
|
||||
Sid = "EnableRootPermissions"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"
|
||||
}
|
||||
Action = "kms:*"
|
||||
Resource = "*"
|
||||
}],
|
||||
|
||||
# Admin principals
|
||||
length(var.admin_principals) > 0 ? [{
|
||||
Sid = "KeyAdministrators"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
AWS = var.admin_principals
|
||||
}
|
||||
Action = [
|
||||
"kms:Create*",
|
||||
"kms:Describe*",
|
||||
"kms:Enable*",
|
||||
"kms:List*",
|
||||
"kms:Put*",
|
||||
"kms:Update*",
|
||||
"kms:Revoke*",
|
||||
"kms:Disable*",
|
||||
"kms:Get*",
|
||||
"kms:Delete*",
|
||||
"kms:TagResource",
|
||||
"kms:UntagResource",
|
||||
"kms:ScheduleKeyDeletion",
|
||||
"kms:CancelKeyDeletion"
|
||||
]
|
||||
Resource = "*"
|
||||
}] : [],
|
||||
|
||||
# User principals
|
||||
length(var.user_principals) > 0 ? [{
|
||||
Sid = "KeyUsers"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
AWS = var.user_principals
|
||||
}
|
||||
Action = [
|
||||
"kms:Encrypt",
|
||||
"kms:Decrypt",
|
||||
"kms:ReEncrypt*",
|
||||
"kms:GenerateDataKey*",
|
||||
"kms:DescribeKey"
|
||||
]
|
||||
Resource = "*"
|
||||
}] : [],
|
||||
|
||||
# Service principals
|
||||
length(var.service_principals) > 0 ? [{
|
||||
Sid = "AllowServices"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
Service = var.service_principals
|
||||
}
|
||||
Action = [
|
||||
"kms:Encrypt",
|
||||
"kms:Decrypt",
|
||||
"kms:ReEncrypt*",
|
||||
"kms:GenerateDataKey*",
|
||||
"kms:DescribeKey"
|
||||
]
|
||||
Resource = "*"
|
||||
Condition = {
|
||||
StringEquals = {
|
||||
"aws:SourceAccount" = data.aws_caller_identity.current.account_id
|
||||
}
|
||||
}
|
||||
}] : [],
|
||||
|
||||
# Cross-account access
|
||||
length(var.grant_accounts) > 0 ? [{
|
||||
Sid = "CrossAccountAccess"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
AWS = [for acct in var.grant_accounts : "arn:aws:iam::${acct}:root"]
|
||||
}
|
||||
Action = [
|
||||
"kms:Encrypt",
|
||||
"kms:Decrypt",
|
||||
"kms:ReEncrypt*",
|
||||
"kms:GenerateDataKey*",
|
||||
"kms:DescribeKey"
|
||||
]
|
||||
Resource = "*"
|
||||
}] : [],
|
||||
|
||||
# Allow grants (needed for some AWS services)
|
||||
[{
|
||||
Sid = "AllowGrants"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
AWS = concat(
|
||||
["arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"],
|
||||
var.user_principals
|
||||
)
|
||||
}
|
||||
Action = [
|
||||
"kms:CreateGrant",
|
||||
"kms:ListGrants",
|
||||
"kms:RevokeGrant"
|
||||
]
|
||||
Resource = "*"
|
||||
Condition = {
|
||||
Bool = {
|
||||
"kms:GrantIsForAWSResource" = "true"
|
||||
}
|
||||
}
|
||||
}]
|
||||
)
|
||||
})
|
||||
|
||||
tags = merge(var.tags, { Name = var.name })
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Alias
|
||||
################################################################################
|
||||
|
||||
resource "aws_kms_alias" "main" {
|
||||
name = "alias/${var.name}"
|
||||
target_key_id = aws_kms_key.main.key_id
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Outputs
|
||||
################################################################################
|
||||
|
||||
output "key_id" {
|
||||
value = aws_kms_key.main.key_id
|
||||
description = "KMS key ID"
|
||||
}
|
||||
|
||||
output "key_arn" {
|
||||
value = aws_kms_key.main.arn
|
||||
description = "KMS key ARN"
|
||||
}
|
||||
|
||||
output "alias_arn" {
|
||||
value = aws_kms_alias.main.arn
|
||||
description = "KMS alias ARN"
|
||||
}
|
||||
|
||||
output "alias_name" {
|
||||
value = aws_kms_alias.main.name
|
||||
description = "KMS alias name"
|
||||
}
|
||||
|
||||
output "key_policy" {
|
||||
value = aws_kms_key.main.policy
|
||||
description = "Key policy document"
|
||||
}
|
||||
65
terraform/modules/lambda-function/README.md
Normal file
65
terraform/modules/lambda-function/README.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# lambda-function
|
||||
|
||||
Lambda Function Module
|
||||
|
||||
## Usage
|
||||
|
||||
```hcl
|
||||
module "lambda_function" {
|
||||
source = "../modules/lambda-function"
|
||||
|
||||
# Required variables
|
||||
name = ""
|
||||
vpc_config = ""
|
||||
function_url = ""
|
||||
|
||||
# Optional: see variables.tf for all options
|
||||
}
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
| Name | Version |
|
||||
|------|---------|
|
||||
| terraform | >= 1.5.0 |
|
||||
| aws | >= 5.0 |
|
||||
|
||||
## Inputs
|
||||
|
||||
| Name | Description | Type | Required |
|
||||
|------|-------------|------|----------|
|
||||
| name | Function name | `string` | yes |
|
||||
| description | Function description | `string` | no |
|
||||
| runtime | Lambda runtime | `string` | no |
|
||||
| handler | Function handler | `string` | no |
|
||||
| architectures | CPU architecture (arm64 or x86_64) | `list(string)` | no |
|
||||
| memory_size | Memory in MB (128-10240) | `number` | no |
|
||||
| timeout | Timeout in seconds (max 900) | `number` | no |
|
||||
| reserved_concurrent_executions | Reserved concurrency (-1 = unreserved) | `number` | no |
|
||||
| source_dir | Local source directory to zip | `string` | no |
|
||||
| source_file | Single source file to deploy | `string` | no |
|
||||
| s3_bucket | S3 bucket containing deployment package | `string` | no |
|
||||
| s3_key | S3 key for deployment package | `string` | no |
|
||||
| image_uri | Container image URI | `string` | no |
|
||||
| vpc_config | | `object({` | yes |
|
||||
| environment | | `map(string)` | no |
|
||||
|
||||
*...and 12 more variables. See `variables.tf` for complete list.*
|
||||
|
||||
## Outputs
|
||||
|
||||
| Name | Description |
|
||||
|------|-------------|
|
||||
| function_name | Function name |
|
||||
| function_arn | Function ARN |
|
||||
| invoke_arn | Invoke ARN (for API Gateway) |
|
||||
| qualified_arn | Qualified ARN (includes version) |
|
||||
| role_arn | IAM role ARN |
|
||||
| role_name | IAM role name |
|
||||
| log_group_name | CloudWatch log group name |
|
||||
| function_url | Function URL |
|
||||
| version | Published version |
|
||||
|
||||
## License
|
||||
|
||||
Apache 2.0 - See LICENSE for details.
|
||||
501
terraform/modules/lambda-function/main.tf
Normal file
501
terraform/modules/lambda-function/main.tf
Normal file
@@ -0,0 +1,501 @@
|
||||
################################################################################
|
||||
# Lambda Function Module
|
||||
#
|
||||
# Reusable Lambda deployment with:
|
||||
# - S3 or local zip deployment
|
||||
# - VPC access (optional)
|
||||
# - Environment variables
|
||||
# - Secrets Manager integration
|
||||
# - CloudWatch logs
|
||||
# - X-Ray tracing
|
||||
# - Provisioned concurrency
|
||||
# - Function URL (optional)
|
||||
#
|
||||
# Usage:
|
||||
# module "api_lambda" {
|
||||
# source = "../modules/lambda-function"
|
||||
#
|
||||
# name = "my-api"
|
||||
# runtime = "nodejs20.x"
|
||||
# handler = "index.handler"
|
||||
#
|
||||
# source_dir = "${path.module}/src"
|
||||
#
|
||||
# environment = {
|
||||
# LOG_LEVEL = "info"
|
||||
# }
|
||||
# }
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
archive = {
|
||||
source = "hashicorp/archive"
|
||||
version = ">= 2.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "name" {
|
||||
type = string
|
||||
description = "Function name"
|
||||
}
|
||||
|
||||
variable "description" {
|
||||
type = string
|
||||
default = ""
|
||||
description = "Function description"
|
||||
}
|
||||
|
||||
variable "runtime" {
|
||||
type = string
|
||||
default = "nodejs20.x"
|
||||
description = "Lambda runtime"
|
||||
}
|
||||
|
||||
variable "handler" {
|
||||
type = string
|
||||
default = "index.handler"
|
||||
description = "Function handler"
|
||||
}
|
||||
|
||||
variable "architectures" {
|
||||
type = list(string)
|
||||
default = ["arm64"]
|
||||
description = "CPU architecture (arm64 or x86_64)"
|
||||
}
|
||||
|
||||
variable "memory_size" {
|
||||
type = number
|
||||
default = 256
|
||||
description = "Memory in MB (128-10240)"
|
||||
}
|
||||
|
||||
variable "timeout" {
|
||||
type = number
|
||||
default = 30
|
||||
description = "Timeout in seconds (max 900)"
|
||||
}
|
||||
|
||||
variable "reserved_concurrent_executions" {
|
||||
type = number
|
||||
default = -1
|
||||
description = "Reserved concurrency (-1 = unreserved)"
|
||||
}
|
||||
|
||||
# Deployment options
|
||||
variable "source_dir" {
|
||||
type = string
|
||||
default = ""
|
||||
description = "Local source directory to zip"
|
||||
}
|
||||
|
||||
variable "source_file" {
|
||||
type = string
|
||||
default = ""
|
||||
description = "Single source file to deploy"
|
||||
}
|
||||
|
||||
variable "s3_bucket" {
|
||||
type = string
|
||||
default = ""
|
||||
description = "S3 bucket containing deployment package"
|
||||
}
|
||||
|
||||
variable "s3_key" {
|
||||
type = string
|
||||
default = ""
|
||||
description = "S3 key for deployment package"
|
||||
}
|
||||
|
||||
variable "image_uri" {
|
||||
type = string
|
||||
default = ""
|
||||
description = "Container image URI"
|
||||
}
|
||||
|
||||
# VPC configuration
|
||||
variable "vpc_config" {
|
||||
type = object({
|
||||
subnet_ids = list(string)
|
||||
security_group_ids = list(string)
|
||||
})
|
||||
default = null
|
||||
description = "VPC configuration for Lambda"
|
||||
}
|
||||
|
||||
# Environment
|
||||
variable "environment" {
|
||||
type = map(string)
|
||||
default = {}
|
||||
description = "Environment variables"
|
||||
}
|
||||
|
||||
variable "secrets" {
|
||||
type = map(string)
|
||||
default = {}
|
||||
description = "Secrets Manager ARNs (name -> ARN)"
|
||||
}
|
||||
|
||||
variable "ssm_parameters" {
|
||||
type = map(string)
|
||||
default = {}
|
||||
description = "SSM parameter ARNs (name -> ARN)"
|
||||
}
|
||||
|
||||
# Layers
|
||||
variable "layers" {
|
||||
type = list(string)
|
||||
default = []
|
||||
description = "Lambda layer ARNs"
|
||||
}
|
||||
|
||||
# Tracing
|
||||
variable "tracing_mode" {
|
||||
type = string
|
||||
default = "Active"
|
||||
description = "X-Ray tracing mode (Active, PassThrough, or empty)"
|
||||
}
|
||||
|
||||
# Logging
|
||||
variable "log_retention_days" {
|
||||
type = number
|
||||
default = 14
|
||||
description = "CloudWatch log retention in days"
|
||||
}
|
||||
|
||||
variable "log_format" {
|
||||
type = string
|
||||
default = "Text"
|
||||
description = "Log format: Text or JSON"
|
||||
}
|
||||
|
||||
# Function URL
|
||||
variable "function_url" {
|
||||
type = object({
|
||||
enabled = bool
|
||||
auth_type = optional(string, "NONE")
|
||||
cors_origins = optional(list(string), ["*"])
|
||||
cors_methods = optional(list(string), ["*"])
|
||||
cors_headers = optional(list(string), ["*"])
|
||||
invoke_mode = optional(string, "BUFFERED")
|
||||
})
|
||||
default = {
|
||||
enabled = false
|
||||
}
|
||||
description = "Lambda function URL configuration"
|
||||
}
|
||||
|
||||
# Provisioned concurrency
|
||||
variable "provisioned_concurrency" {
|
||||
type = number
|
||||
default = 0
|
||||
description = "Provisioned concurrency (0 = disabled)"
|
||||
}
|
||||
|
||||
# Additional IAM policies
|
||||
variable "policy_arns" {
|
||||
type = list(string)
|
||||
default = []
|
||||
description = "Additional IAM policy ARNs to attach"
|
||||
}
|
||||
|
||||
variable "inline_policy" {
|
||||
type = string
|
||||
default = ""
|
||||
description = "Inline IAM policy JSON"
|
||||
}
|
||||
|
||||
# Dead letter queue
|
||||
variable "dead_letter_arn" {
|
||||
type = string
|
||||
default = ""
|
||||
description = "SQS queue or SNS topic ARN for failed invocations"
|
||||
}
|
||||
|
||||
variable "tags" {
|
||||
type = map(string)
|
||||
default = {}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Data Sources
|
||||
################################################################################
|
||||
|
||||
data "aws_caller_identity" "current" {}
|
||||
data "aws_region" "current" {}
|
||||
|
||||
################################################################################
|
||||
# Archive (if using source_dir)
|
||||
################################################################################
|
||||
|
||||
data "archive_file" "lambda" {
|
||||
count = var.source_dir != "" ? 1 : (var.source_file != "" ? 1 : 0)
|
||||
|
||||
type = "zip"
|
||||
output_path = "${path.module}/.terraform/${var.name}.zip"
|
||||
|
||||
source_dir = var.source_dir != "" ? var.source_dir : null
|
||||
source_file = var.source_file != "" ? var.source_file : null
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# IAM Role
|
||||
################################################################################
|
||||
|
||||
resource "aws_iam_role" "lambda" {
|
||||
name = "${var.name}-lambda"
|
||||
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = "sts:AssumeRole"
|
||||
Principal = { Service = "lambda.amazonaws.com" }
|
||||
}]
|
||||
})
|
||||
|
||||
tags = merge(var.tags, { Name = "${var.name}-lambda" })
|
||||
}
|
||||
|
||||
# Basic execution role
|
||||
resource "aws_iam_role_policy_attachment" "basic" {
|
||||
role = aws_iam_role.lambda.name
|
||||
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
|
||||
}
|
||||
|
||||
# VPC access
|
||||
resource "aws_iam_role_policy_attachment" "vpc" {
|
||||
count = var.vpc_config != null ? 1 : 0
|
||||
role = aws_iam_role.lambda.name
|
||||
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
|
||||
}
|
||||
|
||||
# X-Ray
|
||||
resource "aws_iam_role_policy_attachment" "xray" {
|
||||
count = var.tracing_mode != "" ? 1 : 0
|
||||
role = aws_iam_role.lambda.name
|
||||
policy_arn = "arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess"
|
||||
}
|
||||
|
||||
# Secrets Manager access
|
||||
resource "aws_iam_role_policy" "secrets" {
|
||||
count = length(var.secrets) > 0 ? 1 : 0
|
||||
name = "secrets-access"
|
||||
role = aws_iam_role.lambda.id
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = "secretsmanager:GetSecretValue"
|
||||
Resource = values(var.secrets)
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
# SSM Parameter access
|
||||
resource "aws_iam_role_policy" "ssm" {
|
||||
count = length(var.ssm_parameters) > 0 ? 1 : 0
|
||||
name = "ssm-access"
|
||||
role = aws_iam_role.lambda.id
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = ["ssm:GetParameter", "ssm:GetParameters"]
|
||||
Resource = values(var.ssm_parameters)
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
# Additional policies
|
||||
resource "aws_iam_role_policy_attachment" "additional" {
|
||||
for_each = toset(var.policy_arns)
|
||||
role = aws_iam_role.lambda.name
|
||||
policy_arn = each.value
|
||||
}
|
||||
|
||||
# Inline policy
|
||||
resource "aws_iam_role_policy" "inline" {
|
||||
count = var.inline_policy != "" ? 1 : 0
|
||||
name = "inline"
|
||||
role = aws_iam_role.lambda.id
|
||||
policy = var.inline_policy
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# CloudWatch Log Group
|
||||
################################################################################
|
||||
|
||||
resource "aws_cloudwatch_log_group" "lambda" {
|
||||
name = "/aws/lambda/${var.name}"
|
||||
retention_in_days = var.log_retention_days
|
||||
|
||||
tags = merge(var.tags, { Name = var.name })
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Lambda Function
|
||||
################################################################################
|
||||
|
||||
resource "aws_lambda_function" "main" {
|
||||
function_name = var.name
|
||||
description = var.description != "" ? var.description : "Lambda function ${var.name}"
|
||||
role = aws_iam_role.lambda.arn
|
||||
|
||||
# Deployment package
|
||||
filename = var.source_dir != "" || var.source_file != "" ? data.archive_file.lambda[0].output_path : null
|
||||
source_code_hash = var.source_dir != "" || var.source_file != "" ? data.archive_file.lambda[0].output_base64sha256 : null
|
||||
s3_bucket = var.s3_bucket != "" ? var.s3_bucket : null
|
||||
s3_key = var.s3_key != "" ? var.s3_key : null
|
||||
image_uri = var.image_uri != "" ? var.image_uri : null
|
||||
package_type = var.image_uri != "" ? "Image" : "Zip"
|
||||
|
||||
# Runtime config (not for container images)
|
||||
runtime = var.image_uri == "" ? var.runtime : null
|
||||
handler = var.image_uri == "" ? var.handler : null
|
||||
architectures = var.architectures
|
||||
layers = var.image_uri == "" ? var.layers : null
|
||||
|
||||
# Resources
|
||||
memory_size = var.memory_size
|
||||
timeout = var.timeout
|
||||
reserved_concurrent_executions = var.reserved_concurrent_executions
|
||||
|
||||
# Environment
|
||||
dynamic "environment" {
|
||||
for_each = length(var.environment) > 0 ? [1] : []
|
||||
content {
|
||||
variables = var.environment
|
||||
}
|
||||
}
|
||||
|
||||
# VPC
|
||||
dynamic "vpc_config" {
|
||||
for_each = var.vpc_config != null ? [var.vpc_config] : []
|
||||
content {
|
||||
subnet_ids = vpc_config.value.subnet_ids
|
||||
security_group_ids = vpc_config.value.security_group_ids
|
||||
}
|
||||
}
|
||||
|
||||
# Tracing
|
||||
dynamic "tracing_config" {
|
||||
for_each = var.tracing_mode != "" ? [1] : []
|
||||
content {
|
||||
mode = var.tracing_mode
|
||||
}
|
||||
}
|
||||
|
||||
# Dead letter queue
|
||||
dynamic "dead_letter_config" {
|
||||
for_each = var.dead_letter_arn != "" ? [1] : []
|
||||
content {
|
||||
target_arn = var.dead_letter_arn
|
||||
}
|
||||
}
|
||||
|
||||
# Logging
|
||||
logging_config {
|
||||
log_format = var.log_format
|
||||
log_group = aws_cloudwatch_log_group.lambda.name
|
||||
}
|
||||
|
||||
tags = merge(var.tags, { Name = var.name })
|
||||
|
||||
depends_on = [aws_cloudwatch_log_group.lambda]
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Function URL
|
||||
################################################################################
|
||||
|
||||
resource "aws_lambda_function_url" "main" {
|
||||
count = var.function_url.enabled ? 1 : 0
|
||||
|
||||
function_name = aws_lambda_function.main.function_name
|
||||
authorization_type = var.function_url.auth_type
|
||||
invoke_mode = var.function_url.invoke_mode
|
||||
|
||||
cors {
|
||||
allow_origins = var.function_url.cors_origins
|
||||
allow_methods = var.function_url.cors_methods
|
||||
allow_headers = var.function_url.cors_headers
|
||||
max_age = 86400
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Provisioned Concurrency
|
||||
################################################################################
|
||||
|
||||
resource "aws_lambda_alias" "live" {
|
||||
count = var.provisioned_concurrency > 0 ? 1 : 0
|
||||
|
||||
name = "live"
|
||||
function_name = aws_lambda_function.main.function_name
|
||||
function_version = aws_lambda_function.main.version
|
||||
}
|
||||
|
||||
resource "aws_lambda_provisioned_concurrency_config" "main" {
|
||||
count = var.provisioned_concurrency > 0 ? 1 : 0
|
||||
|
||||
function_name = aws_lambda_function.main.function_name
|
||||
provisioned_concurrent_executions = var.provisioned_concurrency
|
||||
qualifier = aws_lambda_alias.live[0].name
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Outputs
|
||||
################################################################################
|
||||
|
||||
output "function_name" {
|
||||
value = aws_lambda_function.main.function_name
|
||||
description = "Function name"
|
||||
}
|
||||
|
||||
output "function_arn" {
|
||||
value = aws_lambda_function.main.arn
|
||||
description = "Function ARN"
|
||||
}
|
||||
|
||||
output "invoke_arn" {
|
||||
value = aws_lambda_function.main.invoke_arn
|
||||
description = "Invoke ARN (for API Gateway)"
|
||||
}
|
||||
|
||||
output "qualified_arn" {
|
||||
value = aws_lambda_function.main.qualified_arn
|
||||
description = "Qualified ARN (includes version)"
|
||||
}
|
||||
|
||||
output "role_arn" {
|
||||
value = aws_iam_role.lambda.arn
|
||||
description = "IAM role ARN"
|
||||
}
|
||||
|
||||
output "role_name" {
|
||||
value = aws_iam_role.lambda.name
|
||||
description = "IAM role name"
|
||||
}
|
||||
|
||||
output "log_group_name" {
|
||||
value = aws_cloudwatch_log_group.lambda.name
|
||||
description = "CloudWatch log group name"
|
||||
}
|
||||
|
||||
output "function_url" {
|
||||
value = var.function_url.enabled ? aws_lambda_function_url.main[0].function_url : null
|
||||
description = "Function URL"
|
||||
}
|
||||
|
||||
output "version" {
|
||||
value = aws_lambda_function.main.version
|
||||
description = "Published version"
|
||||
}
|
||||
34
terraform/modules/ram-share/README.md
Normal file
34
terraform/modules/ram-share/README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# ram-share
|
||||
|
||||
Terraform module for AWS landing zone pattern.
|
||||
|
||||
Share resources across accounts via AWS Resource Access Manager.
|
||||
|
||||
## Planned Features
|
||||
|
||||
- [ ] VPC subnet sharing
|
||||
- [ ] Transit Gateway sharing
|
||||
- [ ] Route53 Resolver rule sharing
|
||||
- [ ] Organization-wide sharing option
|
||||
- [ ] OU-level sharing
|
||||
|
||||
## Planned Usage
|
||||
|
||||
```hcl
|
||||
module "vpc_share" {
|
||||
source = "../modules/ram-share"
|
||||
|
||||
name = "shared-vpc-subnets"
|
||||
|
||||
resources = [
|
||||
aws_subnet.private_a.arn,
|
||||
aws_subnet.private_b.arn,
|
||||
]
|
||||
|
||||
# Share with specific accounts
|
||||
principals = ["111111111111", "222222222222"]
|
||||
|
||||
# Or share with entire org
|
||||
# allow_organization = true
|
||||
}
|
||||
```
|
||||
83
terraform/modules/ram-share/main.tf
Normal file
83
terraform/modules/ram-share/main.tf
Normal file
@@ -0,0 +1,83 @@
|
||||
################################################################################
|
||||
# RAM Share Module
|
||||
#
|
||||
# Shares resources across accounts via AWS Resource Access Manager:
|
||||
# - VPC subnets
|
||||
# - Transit Gateway
|
||||
# - Route53 Resolver rules
|
||||
# - Any RAM-supported resource
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.5.0"
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data "aws_organizations_organization" "this" {
|
||||
count = var.share_with_organization ? 1 : 0
|
||||
}
|
||||
|
||||
locals {
|
||||
# Organization ARN for org-wide sharing
|
||||
org_arn = var.share_with_organization ? data.aws_organizations_organization.this[0].arn : null
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Resource Share
|
||||
################################################################################
|
||||
|
||||
resource "aws_ram_resource_share" "this" {
|
||||
name = var.name
|
||||
allow_external_principals = var.allow_external_principals
|
||||
|
||||
# Enable org sharing if specified
|
||||
permission_arns = var.permission_arns
|
||||
|
||||
tags = merge(var.tags, {
|
||||
Name = var.name
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Resource Associations
|
||||
################################################################################
|
||||
|
||||
resource "aws_ram_resource_association" "this" {
|
||||
for_each = toset(var.resource_arns)
|
||||
|
||||
resource_arn = each.value
|
||||
resource_share_arn = aws_ram_resource_share.this.arn
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Principal Associations
|
||||
################################################################################
|
||||
|
||||
# Share with organization
|
||||
resource "aws_ram_principal_association" "organization" {
|
||||
count = var.share_with_organization ? 1 : 0
|
||||
|
||||
principal = local.org_arn
|
||||
resource_share_arn = aws_ram_resource_share.this.arn
|
||||
}
|
||||
|
||||
# Share with specific OUs
|
||||
resource "aws_ram_principal_association" "ous" {
|
||||
for_each = toset(var.principal_ous)
|
||||
|
||||
principal = each.value
|
||||
resource_share_arn = aws_ram_resource_share.this.arn
|
||||
}
|
||||
|
||||
# Share with specific accounts
|
||||
resource "aws_ram_principal_association" "accounts" {
|
||||
for_each = toset(var.principal_accounts)
|
||||
|
||||
principal = each.value
|
||||
resource_share_arn = aws_ram_resource_share.this.arn
|
||||
}
|
||||
27
terraform/modules/ram-share/outputs.tf
Normal file
27
terraform/modules/ram-share/outputs.tf
Normal file
@@ -0,0 +1,27 @@
|
||||
################################################################################
|
||||
# RAM Share - Outputs
|
||||
################################################################################
|
||||
|
||||
output "share_arn" {
|
||||
value = aws_ram_resource_share.this.arn
|
||||
description = "Resource share ARN"
|
||||
}
|
||||
|
||||
output "share_id" {
|
||||
value = aws_ram_resource_share.this.id
|
||||
description = "Resource share ID"
|
||||
}
|
||||
|
||||
output "resource_associations" {
|
||||
value = { for k, v in aws_ram_resource_association.this : k => v.id }
|
||||
description = "Map of resource associations"
|
||||
}
|
||||
|
||||
output "principal_count" {
|
||||
value = (
|
||||
(var.share_with_organization ? 1 : 0) +
|
||||
length(var.principal_ous) +
|
||||
length(var.principal_accounts)
|
||||
)
|
||||
description = "Number of principals shared with"
|
||||
}
|
||||
49
terraform/modules/ram-share/variables.tf
Normal file
49
terraform/modules/ram-share/variables.tf
Normal 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"
|
||||
}
|
||||
57
terraform/modules/route53-zone/README.md
Normal file
57
terraform/modules/route53-zone/README.md
Normal 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.
|
||||
418
terraform/modules/route53-zone/main.tf
Normal file
418
terraform/modules/route53-zone/main.tf
Normal 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)"
|
||||
}
|
||||
97
terraform/modules/scps/README.md
Normal file
97
terraform/modules/scps/README.md
Normal 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
|
||||
388
terraform/modules/scps/main.tf
Normal file
388
terraform/modules/scps/main.tf
Normal 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
|
||||
}
|
||||
26
terraform/modules/scps/outputs.tf
Normal file
26
terraform/modules/scps/outputs.tf
Normal 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"
|
||||
}
|
||||
75
terraform/modules/scps/variables.tf
Normal file
75
terraform/modules/scps/variables.tf
Normal 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"
|
||||
}
|
||||
54
terraform/modules/security-baseline/README.md
Normal file
54
terraform/modules/security-baseline/README.md
Normal 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.
|
||||
334
terraform/modules/security-baseline/main.tf
Normal file
334
terraform/modules/security-baseline/main.tf
Normal 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
|
||||
}
|
||||
}
|
||||
34
terraform/modules/security-groups/README.md
Normal file
34
terraform/modules/security-groups/README.md
Normal 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
|
||||
}
|
||||
```
|
||||
395
terraform/modules/security-groups/main.tf
Normal file
395
terraform/modules/security-groups/main.tf
Normal 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" }
|
||||
}
|
||||
51
terraform/modules/security-groups/outputs.tf
Normal file
51
terraform/modules/security-groups/outputs.tf
Normal 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"
|
||||
}
|
||||
79
terraform/modules/security-groups/variables.tf
Normal file
79
terraform/modules/security-groups/variables.tf
Normal 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"
|
||||
}
|
||||
51
terraform/modules/shared-vpc/README.md
Normal file
51
terraform/modules/shared-vpc/README.md
Normal 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.
|
||||
267
terraform/modules/shared-vpc/main.tf
Normal file
267
terraform/modules/shared-vpc/main.tf
Normal 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
|
||||
}
|
||||
40
terraform/modules/shared-vpc/variables.tf
Normal file
40
terraform/modules/shared-vpc/variables.tf
Normal 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
|
||||
}
|
||||
38
terraform/modules/tenant-baseline/README.md
Normal file
38
terraform/modules/tenant-baseline/README.md
Normal 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
|
||||
}
|
||||
```
|
||||
102
terraform/modules/tenant-baseline/main.tf
Normal file
102
terraform/modules/tenant-baseline/main.tf
Normal 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
|
||||
}
|
||||
72
terraform/modules/tenant-baseline/outputs.tf
Normal file
72
terraform/modules/tenant-baseline/outputs.tf
Normal 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"
|
||||
}
|
||||
158
terraform/modules/tenant-baseline/variables.tf
Normal file
158
terraform/modules/tenant-baseline/variables.tf
Normal 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"
|
||||
}
|
||||
38
terraform/modules/tenant-budget/README.md
Normal file
38
terraform/modules/tenant-budget/README.md
Normal 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
|
||||
}
|
||||
```
|
||||
144
terraform/modules/tenant-budget/main.tf
Normal file
144
terraform/modules/tenant-budget/main.tf
Normal 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
|
||||
})
|
||||
}
|
||||
28
terraform/modules/tenant-budget/outputs.tf
Normal file
28
terraform/modules/tenant-budget/outputs.tf
Normal 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"
|
||||
}
|
||||
61
terraform/modules/tenant-budget/variables.tf
Normal file
61
terraform/modules/tenant-budget/variables.tf
Normal 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"
|
||||
}
|
||||
45
terraform/modules/tenant-iam/README.md
Normal file
45
terraform/modules/tenant-iam/README.md
Normal 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
|
||||
279
terraform/modules/tenant-iam/main.tf
Normal file
279
terraform/modules/tenant-iam/main.tf
Normal 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"
|
||||
}
|
||||
52
terraform/modules/tenant-iam/outputs.tf
Normal file
52
terraform/modules/tenant-iam/outputs.tf
Normal 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"
|
||||
}
|
||||
97
terraform/modules/tenant-iam/variables.tf
Normal file
97
terraform/modules/tenant-iam/variables.tf
Normal 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"
|
||||
}
|
||||
60
terraform/modules/tenant-onboard/README.md
Normal file
60
terraform/modules/tenant-onboard/README.md
Normal 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.
|
||||
243
terraform/modules/tenant-onboard/main.tf
Normal file
243
terraform/modules/tenant-onboard/main.tf
Normal 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
|
||||
}
|
||||
}
|
||||
70
terraform/modules/tenant-onboard/variables.tf
Normal file
70
terraform/modules/tenant-onboard/variables.tf
Normal 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
|
||||
}
|
||||
51
terraform/modules/tenant-security-group/README.md
Normal file
51
terraform/modules/tenant-security-group/README.md
Normal 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.
|
||||
201
terraform/modules/tenant-security-group/main.tf
Normal file
201
terraform/modules/tenant-security-group/main.tf
Normal 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
|
||||
}
|
||||
44
terraform/modules/tenant-security-group/variables.tf
Normal file
44
terraform/modules/tenant-security-group/variables.tf
Normal 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
|
||||
}
|
||||
36
terraform/modules/tenant-vpc/README.md
Normal file
36
terraform/modules/tenant-vpc/README.md
Normal 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
|
||||
}
|
||||
```
|
||||
333
terraform/modules/tenant-vpc/main.tf
Normal file
333
terraform/modules/tenant-vpc/main.tf
Normal 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"
|
||||
})
|
||||
}
|
||||
57
terraform/modules/tenant-vpc/outputs.tf
Normal file
57
terraform/modules/tenant-vpc/outputs.tf
Normal 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"
|
||||
}
|
||||
83
terraform/modules/tenant-vpc/variables.tf
Normal file
83
terraform/modules/tenant-vpc/variables.tf
Normal 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"
|
||||
}
|
||||
58
terraform/modules/vpc-endpoints/README.md
Normal file
58
terraform/modules/vpc-endpoints/README.md
Normal 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.
|
||||
334
terraform/modules/vpc-endpoints/main.tf
Normal file
334
terraform/modules/vpc-endpoints/main.tf
Normal 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
|
||||
}
|
||||
}
|
||||
58
terraform/modules/vpc-lite/README.md
Normal file
58
terraform/modules/vpc-lite/README.md
Normal 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.
|
||||
507
terraform/modules/vpc-lite/main.tf
Normal file
507
terraform/modules/vpc-lite/main.tf
Normal 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"
|
||||
}
|
||||
57
terraform/modules/waf-alb/README.md
Normal file
57
terraform/modules/waf-alb/README.md
Normal 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.
|
||||
561
terraform/modules/waf-alb/main.tf
Normal file
561
terraform/modules/waf-alb/main.tf
Normal 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)"
|
||||
}
|
||||
Reference in New Issue
Block a user