Files
terraform-foundation/terraform/modules/github-oidc/main.tf
Greg Hendrickson 6136cde9bb 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
2026-02-02 02:57:23 +00:00

674 lines
22 KiB
HCL

################################################################################
# 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
}