diff --git a/src/tf/github-actions.tf b/src/tf/github-actions.tf new file mode 100644 index 0000000..1423a5b --- /dev/null +++ b/src/tf/github-actions.tf @@ -0,0 +1,17 @@ +module "github_actions" { + source = "./modules/github-actions" + + application_ses_policy_arns = local.application_ses_policy_arns + application_ses_senders = local.application_ses_senders + aws_account_id = data.aws_caller_identity.current.account_id + oidc_provider_arn = aws_iam_openid_connect_provider.github_actions.arn + ses_identity_arns = [for identity in aws_sesv2_email_identity.domain : identity.arn] + state_bucket_arn = aws_s3_bucket.tfstate_state.arn + state_bucket_name = aws_s3_bucket.tfstate_state.bucket + state_lock_table_arn = aws_dynamodb_table.tflock_state.arn + vm_workloads_bootstrap_parameter_arns = [ + "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${local.sgfdevs_vms_eso_access_key_id_path}", + "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${local.sgfdevs_vms_eso_secret_access_key_path}", + ] + vm_workloads_parameter_arn = "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${local.sgfdevs_vms_parameter_path}" +} diff --git a/src/tf/modules/github-actions/common.tf b/src/tf/modules/github-actions/common.tf new file mode 100644 index 0000000..2eb09c1 --- /dev/null +++ b/src/tf/modules/github-actions/common.tf @@ -0,0 +1,180 @@ +locals { + repositories = { + app_config = { + github_subject = "repo:sgfdevs@53604170/infra-app-config@1298907442" + repository = "sgfdevs/infra-app-config" + role_name = "GitHubActionsInfraAppConfigRole" + state_key = "sgfdevs-infra-app-config/terraform.tfstate" + state_prefix = "sgfdevs-infra-app-config" + } + dns = { + github_subject = "repo:sgfdevs@53604170/infra-dns@1191039220" + repository = "sgfdevs/infra-dns" + role_name = "GitHubActionsInfraDNSRole" + state_key = "sgfdevs-infra-dns/terraform.tfstate" + state_prefix = "sgfdevs-infra-dns" + } + gh = { + github_subject = "repo:sgfdevs@53604170/infra-gh@1331469226" + repository = "sgfdevs/infra-gh" + role_name = "GitHubActionsInfraGHRole" + state_key = "sgfdevs/infra-gh/terraform.tfstate" + state_prefix = "sgfdevs/infra-gh" + } + vm_workloads = { + github_subject = "repo:sgfdevs@53604170/infra-vm-workloads@1189754282" + repository = "sgfdevs/infra-vm-workloads" + role_name = "GitHubActionsInfraVMWorkloadsRole" + state_key = "sgfdevs-vm-workloads/terraform.tfstate" + state_prefix = "sgfdevs-vm-workloads" + } + } +} + +resource "aws_iam_role" "github_actions" { + for_each = local.repositories + + name = each.value.role_name + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Principal = { + Federated = var.oidc_provider_arn + } + Action = "sts:AssumeRoleWithWebIdentity" + Condition = { + StringEquals = { + "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" + "token.actions.githubusercontent.com:sub" = [ + "${each.value.github_subject}:ref:refs/heads/main", + "${each.value.github_subject}:pull_request", + ] + } + } + } + ] + }) + + tags = { + Environment = "global" + GitHubMainSubject = "${each.value.github_subject}:ref:refs/heads/main" + ManagedBy = "OpenTofu" + Name = "sgfdevs-github-actions-infra-${replace(each.key, "_", "-")}-role" + Repository = each.value.repository + TerraformStateKey = each.value.state_key + TerraformStatePrefix = each.value.state_prefix + } +} + +resource "aws_iam_policy" "terraform_state_access" { + name = "GitHubActionsTerraformStateAccess" + description = "Repository-scoped OpenTofu state access derived from IAM role tags" + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "ReadStateBucketMetadata" + Effect = "Allow" + Action = [ + "s3:GetBucketLocation", + "s3:GetBucketVersioning", + ] + Resource = var.state_bucket_arn + }, + { + Sid = "ListRepositoryState" + Effect = "Allow" + Action = "s3:ListBucket" + Resource = var.state_bucket_arn + Condition = { + StringLike = { + "s3:prefix" = [ + "$${aws:PrincipalTag/TerraformStatePrefix}", + "$${aws:PrincipalTag/TerraformStatePrefix}/*", + ] + } + } + }, + { + Sid = "ReadRepositoryState" + Effect = "Allow" + Action = "s3:GetObject" + Resource = "${var.state_bucket_arn}/$${aws:PrincipalTag/TerraformStateKey}" + }, + { + Sid = "DescribeStateLockTable" + Effect = "Allow" + Action = "dynamodb:DescribeTable" + Resource = var.state_lock_table_arn + }, + { + Sid = "ManageRepositoryStateLock" + Effect = "Allow" + Action = [ + "dynamodb:DeleteItem", + "dynamodb:GetItem", + "dynamodb:PutItem", + ] + Resource = var.state_lock_table_arn + Condition = { + "ForAllValues:StringEquals" = { + "dynamodb:LeadingKeys" = "${var.state_bucket_name}/$${aws:PrincipalTag/TerraformStateKey}" + } + } + }, + { + Sid = "ReadRepositoryStateChecksum" + Effect = "Allow" + Action = "dynamodb:GetItem" + Resource = var.state_lock_table_arn + Condition = { + "ForAllValues:StringEquals" = { + "dynamodb:LeadingKeys" = "${var.state_bucket_name}/$${aws:PrincipalTag/TerraformStateKey}-md5" + } + } + }, + { + Sid = "WriteRepositoryStateFromMain" + Effect = "Allow" + Action = "s3:PutObject" + Resource = "${var.state_bucket_arn}/$${aws:PrincipalTag/TerraformStateKey}" + Condition = { + StringEquals = { + "token.actions.githubusercontent.com:sub" = "$${aws:PrincipalTag/GitHubMainSubject}" + } + } + }, + { + Sid = "WriteRepositoryStateChecksumFromMain" + Effect = "Allow" + Action = "dynamodb:PutItem" + Resource = var.state_lock_table_arn + Condition = { + "ForAllValues:StringEquals" = { + "dynamodb:LeadingKeys" = "${var.state_bucket_name}/$${aws:PrincipalTag/TerraformStateKey}-md5" + } + StringEquals = { + "token.actions.githubusercontent.com:sub" = "$${aws:PrincipalTag/GitHubMainSubject}" + } + } + }, + ] + }) + + tags = { + Environment = "global" + ManagedBy = "OpenTofu" + Name = "sgfdevs-github-actions-terraform-state-access" + } +} + +resource "aws_iam_role_policy_attachment" "terraform_state_access" { + for_each = local.repositories + + role = aws_iam_role.github_actions[each.key].name + policy_arn = aws_iam_policy.terraform_state_access.arn +} diff --git a/src/tf/modules/github-actions/infra-app-config.tf b/src/tf/modules/github-actions/infra-app-config.tf new file mode 100644 index 0000000..50c7a87 --- /dev/null +++ b/src/tf/modules/github-actions/infra-app-config.tf @@ -0,0 +1,124 @@ +resource "aws_iam_role_policy" "github_actions_app_config" { + name = "InfraAppConfigRepositoryAccess" + role = aws_iam_role.github_actions["app_config"].id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = concat( + [for key, application in var.application_ses_senders : { + Sid = "Create${replace(title(key), "_", "")}SESUsers" + Effect = "Allow" + Action = "iam:CreateUser" + Resource = "arn:aws:iam::${var.aws_account_id}:user/applications/${application.path}/*" + Condition = { + StringEquals = { + "aws:RequestTag/ManagedBy" = "OpenTofu" + "token.actions.githubusercontent.com:sub" = "$${aws:PrincipalTag/GitHubMainSubject}" + } + StringLike = { + "aws:RequestTag/SESFromAddress" = "*@${application.domain}" + } + Null = { + "aws:RequestTag/Application" = "false" + } + "ForAllValues:StringEquals" = { + "aws:TagKeys" = [ + "Application", + "Environment", + "ManagedBy", + "SESFromAddress", + ] + } + } + }], + [for key, application in var.application_ses_senders : { + Sid = "AttachOnly${replace(title(key), "_", "")}SESPolicy" + Effect = "Allow" + Action = [ + "iam:AttachUserPolicy", + "iam:DetachUserPolicy" + ] + Resource = "arn:aws:iam::${var.aws_account_id}:user/applications/${application.path}/*" + Condition = { + ArnEquals = { + "iam:PolicyARN" = var.application_ses_policy_arns[key] + } + StringEquals = { + "iam:ResourceTag/ManagedBy" = "OpenTofu" + "token.actions.githubusercontent.com:sub" = "$${aws:PrincipalTag/GitHubMainSubject}" + } + StringLike = { + "iam:ResourceTag/SESFromAddress" = "*@${application.domain}" + } + Null = { + "iam:ResourceTag/Application" = "false" + } + } + }], + [for key, application in var.application_ses_senders : { + Sid = "Read${replace(title(key), "_", "")}SESUsers" + Effect = "Allow" + Action = [ + "iam:GetAccessKeyLastUsed", + "iam:GetUser", + "iam:ListAccessKeys", + "iam:ListAttachedUserPolicies", + "iam:ListGroupsForUser", + "iam:ListUserTags", + ] + Resource = "arn:aws:iam::${var.aws_account_id}:user/applications/${application.path}/*" + Condition = { + StringEquals = { + "iam:ResourceTag/ManagedBy" = "OpenTofu" + } + StringLike = { + "iam:ResourceTag/SESFromAddress" = "*@${application.domain}" + } + Null = { + "iam:ResourceTag/Application" = "false" + } + } + }], + [for key, application in var.application_ses_senders : { + Sid = "Manage${replace(title(key), "_", "")}SESUsers" + Effect = "Allow" + Action = [ + "iam:CreateAccessKey", + "iam:DeleteAccessKey", + "iam:DeleteUser", + "iam:TagUser", + "iam:UntagUser", + "iam:UpdateAccessKey", + "iam:UpdateUser", + ] + Resource = "arn:aws:iam::${var.aws_account_id}:user/applications/${application.path}/*" + Condition = { + StringEquals = { + "iam:ResourceTag/ManagedBy" = "OpenTofu" + "token.actions.githubusercontent.com:sub" = "$${aws:PrincipalTag/GitHubMainSubject}" + } + StringLike = { + "iam:ResourceTag/SESFromAddress" = "*@${application.domain}" + } + Null = { + "iam:ResourceTag/Application" = "false" + } + } + }], + [for key, application in var.application_ses_senders : { + Sid = "RejectInvalid${replace(title(key), "_", "")}SenderTags" + Effect = "Deny" + Action = "iam:TagUser" + Resource = "arn:aws:iam::${var.aws_account_id}:user/applications/${application.path}/*" + Condition = { + Null = { + "aws:RequestTag/SESFromAddress" = "false" + } + StringNotLike = { + "aws:RequestTag/SESFromAddress" = "*@${application.domain}" + } + } + }], + ) + }) +} diff --git a/src/tf/modules/github-actions/infra-dns.tf b/src/tf/modules/github-actions/infra-dns.tf new file mode 100644 index 0000000..e0a5b2e --- /dev/null +++ b/src/tf/modules/github-actions/infra-dns.tf @@ -0,0 +1,19 @@ +resource "aws_iam_role_policy" "github_actions_dns" { + name = "InfraDNSRepositoryAccess" + role = aws_iam_role.github_actions["dns"].id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "ReadSESIdentities" + Effect = "Allow" + Action = [ + "ses:GetEmailIdentity", + "ses:ListTagsForResource", + ] + Resource = var.ses_identity_arns + }, + ] + }) +} diff --git a/src/tf/modules/github-actions/infra-vm-workloads.tf b/src/tf/modules/github-actions/infra-vm-workloads.tf new file mode 100644 index 0000000..b4e9731 --- /dev/null +++ b/src/tf/modules/github-actions/infra-vm-workloads.tf @@ -0,0 +1,54 @@ +resource "aws_iam_role_policy" "github_actions_vm_workloads" { + name = "InfraVMWorkloadsRepositoryAccess" + role = aws_iam_role.github_actions["vm_workloads"].id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "ReadWorkloadParameters" + Effect = "Allow" + Action = [ + "ssm:GetParameter", + "ssm:GetParameters", + "ssm:ListTagsForResource", + ] + Resource = var.vm_workloads_parameter_arn + }, + { + Sid = "ReadBootstrapParameters" + Effect = "Allow" + Action = [ + "ssm:GetParameter", + "ssm:GetParameters", + ] + Resource = var.vm_workloads_bootstrap_parameter_arns + Condition = { + StringEquals = { + "token.actions.githubusercontent.com:sub" = "$${aws:PrincipalTag/GitHubMainSubject}" + } + } + }, + { + Sid = "DescribeParameters" + Effect = "Allow" + Action = "ssm:DescribeParameters" + Resource = "*" + }, + { + Sid = "ManageWorkloadParametersFromMain" + Effect = "Allow" + Action = [ + "ssm:DeleteParameter", + "ssm:PutParameter", + ] + Resource = var.vm_workloads_parameter_arn + Condition = { + StringEquals = { + "token.actions.githubusercontent.com:sub" = "$${aws:PrincipalTag/GitHubMainSubject}" + } + } + }, + ] + }) +} diff --git a/src/tf/modules/github-actions/outputs.tf b/src/tf/modules/github-actions/outputs.tf new file mode 100644 index 0000000..b85ae21 --- /dev/null +++ b/src/tf/modules/github-actions/outputs.tf @@ -0,0 +1,19 @@ +output "app_config_role_arn" { + description = "IAM role ARN for infra-app-config GitHub Actions." + value = aws_iam_role.github_actions["app_config"].arn +} + +output "dns_role_arn" { + description = "IAM role ARN for infra-dns GitHub Actions." + value = aws_iam_role.github_actions["dns"].arn +} + +output "gh_role_arn" { + description = "IAM role ARN for infra-gh GitHub Actions." + value = aws_iam_role.github_actions["gh"].arn +} + +output "vm_workloads_role_arn" { + description = "IAM role ARN for infra-vm-workloads GitHub Actions." + value = aws_iam_role.github_actions["vm_workloads"].arn +} diff --git a/src/tf/modules/github-actions/variables.tf b/src/tf/modules/github-actions/variables.tf new file mode 100644 index 0000000..29ecb3a --- /dev/null +++ b/src/tf/modules/github-actions/variables.tf @@ -0,0 +1,53 @@ +variable "application_ses_policy_arns" { + description = "SES sender policy ARNs keyed by application." + type = map(string) +} + +variable "application_ses_senders" { + description = "Application SES sender configuration." + type = map(object({ + domain = string + path = string + policy_name = string + })) +} + +variable "aws_account_id" { + description = "AWS account containing the repository-managed resources." + type = string +} + +variable "oidc_provider_arn" { + description = "ARN of the GitHub Actions OIDC provider." + type = string +} + +variable "ses_identity_arns" { + description = "ARNs of the SES identities read by infra-dns." + type = list(string) +} + +variable "state_bucket_arn" { + description = "ARN of the shared OpenTofu state bucket." + type = string +} + +variable "state_bucket_name" { + description = "Name of the shared OpenTofu state bucket." + type = string +} + +variable "state_lock_table_arn" { + description = "ARN of the shared OpenTofu state lock table." + type = string +} + +variable "vm_workloads_bootstrap_parameter_arns" { + description = "ARNs of the bootstrap parameters read by infra-vm-workloads." + type = list(string) +} + +variable "vm_workloads_parameter_arn" { + description = "ARN pattern for parameters managed by infra-vm-workloads." + type = string +} diff --git a/src/tf/outputs.tf b/src/tf/outputs.tf index e31649e..797b03a 100644 --- a/src/tf/outputs.tf +++ b/src/tf/outputs.tf @@ -14,6 +14,30 @@ output "github_actions_role_arn" { sensitive = true } +output "github_actions_app_config_role_arn" { + description = "IAM role ARN for infra-app-config GitHub Actions." + value = module.github_actions.app_config_role_arn + sensitive = true +} + +output "github_actions_dns_role_arn" { + description = "IAM role ARN for infra-dns GitHub Actions." + value = module.github_actions.dns_role_arn + sensitive = true +} + +output "github_actions_gh_role_arn" { + description = "IAM role ARN for infra-gh GitHub Actions." + value = module.github_actions.gh_role_arn + sensitive = true +} + +output "github_actions_vm_workloads_role_arn" { + description = "IAM role ARN for infra-vm-workloads GitHub Actions." + value = module.github_actions.vm_workloads_role_arn + sensitive = true +} + output "ses_email_identity_arns" { description = "ARNs of the SES identities used for outbound email." value = { for domain, identity in aws_sesv2_email_identity.domain : domain => identity.arn }