feat: Terraform Foundation - AWS Landing Zone

Enterprise-grade multi-tenant AWS cloud foundation.

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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