From c1044fcffcf65759fa15ab0fe2466537aa1b5e85 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 10 Mar 2026 08:26:51 -0400 Subject: [PATCH 1/3] Initial drafts of GitHub deployer modules --- modules/github-deployer-role/README.md | 68 +++ modules/github-deployer-role/main.tf | 411 ++++++++++++++++++ modules/github-deployer-role/outputs.tf | 33 ++ modules/github-deployer-role/variables.tf | 70 +++ modules/github-deployer-role/versions.tf | 10 + modules/github-s3-tfstate-access/README.md | 48 ++ modules/github-s3-tfstate-access/main.tf | 73 ++++ modules/github-s3-tfstate-access/outputs.tf | 29 ++ modules/github-s3-tfstate-access/variables.tf | 57 +++ modules/github-s3-tfstate-access/versions.tf | 14 + 10 files changed, 813 insertions(+) create mode 100644 modules/github-deployer-role/README.md create mode 100644 modules/github-deployer-role/main.tf create mode 100644 modules/github-deployer-role/outputs.tf create mode 100644 modules/github-deployer-role/variables.tf create mode 100644 modules/github-deployer-role/versions.tf create mode 100644 modules/github-s3-tfstate-access/README.md create mode 100644 modules/github-s3-tfstate-access/main.tf create mode 100644 modules/github-s3-tfstate-access/outputs.tf create mode 100644 modules/github-s3-tfstate-access/variables.tf create mode 100644 modules/github-s3-tfstate-access/versions.tf diff --git a/modules/github-deployer-role/README.md b/modules/github-deployer-role/README.md new file mode 100644 index 0000000..f2325a5 --- /dev/null +++ b/modules/github-deployer-role/README.md @@ -0,0 +1,68 @@ +# GitHub Deployer Role Module + +Creates an IAM role that GitHub Actions can assume through OIDC and attaches selected LambdaCron permission sets. + +## Usage + +```hcl +module "github_deployer_role" { + source = "./modules/github-deployer-role" + + role_name = "lambdacron-deployer" + github_oidc_provider_arn = "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com" + + github_subjects = [ + "repo:my-org/cloud-cron-consumer:ref:refs/heads/main", + "repo:my-org/cloud-cron-consumer:environment:prod", + ] + + github_job_workflow_refs = [ + "my-org/cloud-cron-consumer/.github/workflows/deploy.yml@refs/heads/main", + ] + + permission_sets = [ + "root", + "print-notification", + "email-notification", + "lambda-image-build", + ] + + tags = { + environment = "prod" + } +} +``` + +## Permission Sets + +- `scheduled-lambda`: Lambda + IAM execution-role/policy wiring + EventBridge schedule management + ECR image lookup/repository policy permissions required for image-based function create/update. +- `notification-plumbing`: SNS subscription + SQS queue + Lambda event source mapping. +- `print-notification`: Reuses `notification-plumbing` and adds notification Lambda/IAM deployment permissions. +- `email-notification`: Reuses `notification-plumbing` and adds notification Lambda/IAM deployment permissions. +- `sms-notification`: Reuses `notification-plumbing` and adds notification Lambda/IAM deployment permissions. +- `lambda-image-build`: Private ECR repository management, private repository policy management, and image push permissions. +- `lambda-image-republish`: Reuses `lambda-image-build` and adds public ECR auth + read permissions. +- `lambda-image-public`: Public ECR repository management + image push permissions. +- `root`: Reuses `scheduled-lambda` and adds shared SNS topic management. + +## Inputs + +- `role_name` (string): IAM role name. +- `role_description` (string): IAM role description. +- `max_session_duration` (number): Max assume-role session duration (seconds). +- `github_oidc_provider_arn` (string): GitHub OIDC provider ARN. +- `github_audience` (string): OIDC token audience, default `sts.amazonaws.com`. +- `github_subjects` (list(string)): Allowed OIDC subject patterns. +- `github_job_workflow_refs` (list(string)): Optional allowed OIDC `job_workflow_ref` patterns for workflow-level restriction. +- `permission_sets` (set(string)): Permission sets to attach. +- `additional_policy_arns` (list(string)): Extra managed policies to attach. +- `tags` (map(string)): Resource tags. + +## Outputs + +- `role_arn`: IAM role ARN. +- `role_name`: IAM role name. +- `permission_set_policy_arns`: Map of selected permission-set names to created policy ARNs. +- `selected_permission_sets`: Sorted selected permission-set names. +- `available_permission_sets`: Map of all available permission-set names and descriptions. +- `assume_role_policy_json`: Rendered trust policy JSON. diff --git a/modules/github-deployer-role/main.tf b/modules/github-deployer-role/main.tf new file mode 100644 index 0000000..f8d1b4f --- /dev/null +++ b/modules/github-deployer-role/main.tf @@ -0,0 +1,411 @@ +locals { + tags = merge({ managed_by = "lambdacron" }, var.tags) + + lambda_iam_statements = [ + { + sid = "IamRoleAndPolicyManagement" + actions = [ + "iam:AttachRolePolicy", + "iam:CreatePolicy", + "iam:CreatePolicyVersion", + "iam:CreateRole", + "iam:DeletePolicy", + "iam:DeletePolicyVersion", + "iam:DeleteRole", + "iam:DetachRolePolicy", + "iam:GetPolicy", + "iam:GetPolicyVersion", + "iam:GetRole", + "iam:ListAttachedRolePolicies", + "iam:ListRolePolicies", + "iam:ListPolicyVersions", + "iam:ListRoleTags", + "iam:TagPolicy", + "iam:TagRole", + "iam:UntagPolicy", + "iam:UntagRole", + "iam:UpdateAssumeRolePolicy", + ] + resources = ["*"] + }, + { + sid = "IamPassRoleToLambda" + actions = [ + "iam:PassRole", + ] + resources = ["arn:aws:iam::*:role/*"] + }, + { + sid = "LambdaFunctionManagement" + actions = [ + "lambda:AddPermission", + "lambda:CreateFunction", + "lambda:CreateFunctionUrlConfig", + "lambda:DeleteFunction", + "lambda:DeleteFunctionUrlConfig", + "lambda:GetFunction", + "lambda:GetFunctionConfiguration", + "lambda:GetFunctionUrlConfig", + "lambda:GetPolicy", + "lambda:ListVersionsByFunction", + "lambda:ListTags", + "lambda:RemovePermission", + "lambda:TagResource", + "lambda:UntagResource", + "lambda:UpdateFunctionCode", + "lambda:UpdateFunctionConfiguration", + "lambda:UpdateFunctionUrlConfig", + ] + resources = ["*"] + }, + ] + + eventbridge_schedule_statements = [ + { + sid = "EventBridgeScheduleManagement" + actions = [ + "events:DeleteRule", + "events:DescribeRule", + "events:ListTagsForResource", + "events:ListTargetsByRule", + "events:PutRule", + "events:PutTargets", + "events:RemoveTargets", + "events:TagResource", + "events:UntagResource", + ] + resources = ["*"] + }, + ] + + scheduled_lambda_ecr_statements = [ + { + sid = "EcrImageReadForLambdaCreateUpdate" + actions = [ + "ecr:BatchGetImage", + "ecr:GetDownloadUrlForLayer", + ] + resources = ["*"] + }, + { + sid = "EcrRepositoryPolicyReadWriteForLambdaCreateUpdate" + actions = [ + "ecr:GetRepositoryPolicy", + "ecr:SetRepositoryPolicy", + ] + resources = ["*"] + }, + ] + + root_sns_topic_statements = [ + { + sid = "SnsTopicManagement" + actions = [ + "sns:CreateTopic", + "sns:DeleteTopic", + "sns:GetTopicAttributes", + "sns:ListTagsForResource", + "sns:SetTopicAttributes", + "sns:TagResource", + "sns:UntagResource", + ] + resources = ["*"] + }, + ] + + notification_plumbing_statements = [ + { + sid = "SqsQueueManagement" + actions = [ + "sqs:CreateQueue", + "sqs:DeleteQueue", + "sqs:GetQueueAttributes", + "sqs:GetQueueUrl", + "sqs:ListQueueTags", + "sqs:SetQueueAttributes", + "sqs:TagQueue", + "sqs:UntagQueue", + ] + resources = ["*"] + }, + { + sid = "SnsSubscriptionManagement" + actions = [ + "sns:GetSubscriptionAttributes", + "sns:SetSubscriptionAttributes", + "sns:Subscribe", + "sns:Unsubscribe", + ] + resources = ["*"] + }, + { + sid = "LambdaEventSourceMappingManagement" + actions = [ + "lambda:CreateEventSourceMapping", + "lambda:DeleteEventSourceMapping", + "lambda:GetEventSourceMapping", + "lambda:ListEventSourceMappings", + "lambda:UpdateEventSourceMapping", + ] + resources = ["*"] + }, + ] + + ecr_private_repository_management_statements = [ + { + sid = "EcrPrivateRepositoryManagement" + actions = [ + "ecr:CreateRepository", + "ecr:DeleteRepository", + "ecr:DescribeImages", + "ecr:DescribeRepositories", + "ecr:GetLifecyclePolicy", + "ecr:ListTagsForResource", + "ecr:PutLifecyclePolicy", + "ecr:TagResource", + "ecr:UntagResource", + ] + resources = ["*"] + }, + ] + + ecr_private_push_statements = [ + { + sid = "EcrPrivatePushPull" + actions = [ + "ecr:BatchCheckLayerAvailability", + "ecr:BatchGetImage", + "ecr:CompleteLayerUpload", + "ecr:GetAuthorizationToken", + "ecr:GetDownloadUrlForLayer", + "ecr:InitiateLayerUpload", + "ecr:PutImage", + "ecr:UploadLayerPart", + ] + resources = ["*"] + }, + ] + + ecr_private_repository_policy_statements = [ + { + sid = "EcrPrivateRepositoryPolicyManagement" + actions = [ + "ecr:DeleteRepositoryPolicy", + "ecr:GetRepositoryPolicy", + "ecr:SetRepositoryPolicy", + ] + resources = ["*"] + }, + ] + + ecr_public_authentication_statements = [ + { + sid = "EcrPublicAuthentication" + actions = [ + "ecr-public:GetAuthorizationToken", + "sts:GetServiceBearerToken", + ] + resources = ["*"] + }, + ] + + ecr_public_read_statements = [ + { + sid = "EcrPublicRead" + actions = [ + "ecr-public:BatchGetImage", + "ecr-public:DescribeImages", + "ecr-public:GetDownloadUrlForLayer", + ] + resources = ["*"] + }, + ] + + ecr_public_repository_management_statements = [ + { + sid = "EcrPublicRepositoryManagement" + actions = [ + "ecr-public:CreateRepository", + "ecr-public:DeleteRepository", + "ecr-public:DescribeRepositories", + "ecr-public:GetRepositoryCatalogData", + "ecr-public:PutRepositoryCatalogData", + "ecr-public:TagResource", + "ecr-public:UntagResource", + ] + resources = ["*"] + }, + ] + + ecr_public_push_statements = [ + { + sid = "EcrPublicPush" + actions = [ + "ecr-public:BatchCheckLayerAvailability", + "ecr-public:CompleteLayerUpload", + "ecr-public:InitiateLayerUpload", + "ecr-public:PutImage", + "ecr-public:UploadLayerPart", + ] + resources = ["*"] + }, + ] + + scheduled_lambda_statements = concat( + local.lambda_iam_statements, + local.eventbridge_schedule_statements, + local.scheduled_lambda_ecr_statements, + ) + notification_channel_statements = concat(local.lambda_iam_statements, local.notification_plumbing_statements) + lambda_image_build_statements = concat( + local.ecr_private_repository_management_statements, + local.ecr_private_push_statements, + local.ecr_private_repository_policy_statements, + ) + lambda_image_republish_statements = concat( + local.lambda_image_build_statements, + local.ecr_public_authentication_statements, + local.ecr_public_read_statements, + ) + lambda_image_public_statements = concat( + local.ecr_public_repository_management_statements, + local.ecr_public_authentication_statements, + local.ecr_public_read_statements, + local.ecr_public_push_statements, + ) + root_module_statements = concat(local.scheduled_lambda_statements, local.root_sns_topic_statements) + + permission_set_catalog = { + "scheduled-lambda" = { + description = "Deploy the scheduled Lambda module (Lambda + IAM + EventBridge schedule wiring + ECR image lookup/repository policy permissions for image-based create/update)." + statements = local.scheduled_lambda_statements + } + "notification-plumbing" = { + description = "Deploy SNS subscription + SQS queue + Lambda event source mapping plumbing." + statements = local.notification_plumbing_statements + } + "print-notification" = { + description = "Deploy print notification module permissions (notification-plumbing + Lambda/IAM role wiring)." + statements = local.notification_channel_statements + } + "email-notification" = { + description = "Deploy email notification module permissions (notification-plumbing + Lambda/IAM role wiring)." + statements = local.notification_channel_statements + } + "sms-notification" = { + description = "Deploy SMS notification module permissions (notification-plumbing + Lambda/IAM role wiring)." + statements = local.notification_channel_statements + } + "lambda-image-build" = { + description = "Deploy private ECR image build module permissions (repository + repository policy management + image push)." + statements = local.lambda_image_build_statements + } + "lambda-image-republish" = { + description = "Deploy private ECR image republish module permissions (build set + public ECR auth/read)." + statements = local.lambda_image_republish_statements + } + "lambda-image-public" = { + description = "Deploy public ECR image publishing module permissions." + statements = local.lambda_image_public_statements + } + "root" = { + description = "Deploy root module permissions (scheduled-lambda set + shared SNS topic management)." + statements = local.root_module_statements + } + } + + unknown_permission_sets = setsubtract(var.permission_sets, toset(keys(local.permission_set_catalog))) + + selected_permission_sets = { + for permission_set in var.permission_sets : permission_set => local.permission_set_catalog[permission_set] + if contains(keys(local.permission_set_catalog), permission_set) + } +} + +data "aws_iam_policy_document" "assume_role" { + statement { + sid = "GitHubActionsAssumeRole" + effect = "Allow" + actions = ["sts:AssumeRoleWithWebIdentity"] + + principals { + type = "Federated" + identifiers = [var.github_oidc_provider_arn] + } + + condition { + test = "StringEquals" + variable = "token.actions.githubusercontent.com:aud" + values = [var.github_audience] + } + + condition { + test = "StringLike" + variable = "token.actions.githubusercontent.com:sub" + values = sort(var.github_subjects) + } + + dynamic "condition" { + for_each = length(var.github_job_workflow_refs) == 0 ? [] : [1] + + content { + test = "StringLike" + variable = "token.actions.githubusercontent.com:job_workflow_ref" + values = sort(var.github_job_workflow_refs) + } + } + } +} + +resource "aws_iam_role" "deployer" { + name = var.role_name + description = var.role_description + assume_role_policy = data.aws_iam_policy_document.assume_role.json + max_session_duration = var.max_session_duration + tags = local.tags + + lifecycle { + precondition { + condition = length(local.unknown_permission_sets) == 0 + error_message = "Unknown permission sets: ${join(", ", sort(tolist(local.unknown_permission_sets)))}. Valid names are: ${join(", ", sort(keys(local.permission_set_catalog)))}." + } + } +} + +data "aws_iam_policy_document" "permission_set" { + for_each = local.selected_permission_sets + + dynamic "statement" { + for_each = each.value.statements + + content { + sid = statement.value.sid + effect = "Allow" + actions = statement.value.actions + resources = statement.value.resources + } + } +} + +resource "aws_iam_policy" "permission_set" { + for_each = data.aws_iam_policy_document.permission_set + + name = substr("${var.role_name}-${each.key}", 0, 128) + description = "LambdaCron deployer permission set ${each.key}." + policy = each.value.json + tags = local.tags +} + +resource "aws_iam_role_policy_attachment" "permission_set" { + for_each = aws_iam_policy.permission_set + + role = aws_iam_role.deployer.name + policy_arn = each.value.arn +} + +resource "aws_iam_role_policy_attachment" "additional" { + for_each = toset(var.additional_policy_arns) + + role = aws_iam_role.deployer.name + policy_arn = each.value +} diff --git a/modules/github-deployer-role/outputs.tf b/modules/github-deployer-role/outputs.tf new file mode 100644 index 0000000..d568db9 --- /dev/null +++ b/modules/github-deployer-role/outputs.tf @@ -0,0 +1,33 @@ +output "role_arn" { + description = "ARN of the GitHub deployer role." + value = aws_iam_role.deployer.arn +} + +output "role_name" { + description = "Name of the GitHub deployer role." + value = aws_iam_role.deployer.name +} + +output "permission_set_policy_arns" { + description = "Managed policy ARNs created for each selected permission set." + value = { + for name, policy in aws_iam_policy.permission_set : name => policy.arn + } +} + +output "selected_permission_sets" { + description = "Permission set names attached to this role." + value = sort(keys(aws_iam_policy.permission_set)) +} + +output "available_permission_sets" { + description = "Catalog of all available permission set names and descriptions." + value = { + for name, set_definition in local.permission_set_catalog : name => set_definition.description + } +} + +output "assume_role_policy_json" { + description = "Rendered trust policy JSON for the role." + value = data.aws_iam_policy_document.assume_role.json +} diff --git a/modules/github-deployer-role/variables.tf b/modules/github-deployer-role/variables.tf new file mode 100644 index 0000000..a93bb38 --- /dev/null +++ b/modules/github-deployer-role/variables.tf @@ -0,0 +1,70 @@ +variable "role_name" { + description = "Name for the GitHub deployer IAM role." + type = string +} + +variable "role_description" { + description = "Description for the GitHub deployer IAM role." + type = string + default = "Role assumed by GitHub Actions to deploy LambdaCron Terraform modules." +} + +variable "max_session_duration" { + description = "Maximum CLI/API session duration in seconds for the assumed role." + type = number + default = 3600 + + validation { + condition = var.max_session_duration >= 3600 && var.max_session_duration <= 43200 + error_message = "max_session_duration must be between 3600 and 43200 seconds." + } +} + +variable "github_oidc_provider_arn" { + description = "ARN for the IAM OIDC provider for GitHub Actions (token.actions.githubusercontent.com)." + type = string +} + +variable "github_audience" { + description = "OIDC audience that GitHub includes in the token." + type = string + default = "sts.amazonaws.com" +} + +variable "github_subjects" { + description = "Allowed GitHub OIDC subject patterns (for example repo:my-org/my-repo:ref:refs/heads/main)." + type = list(string) + + validation { + condition = length(var.github_subjects) > 0 + error_message = "github_subjects must include at least one allowed subject pattern." + } +} + +variable "github_job_workflow_refs" { + description = "Optional allowed GitHub OIDC job_workflow_ref patterns (for example my-org/my-repo/.github/workflows/deploy.yml@refs/heads/main)." + type = list(string) + default = [] +} + +variable "permission_sets" { + description = "Permission set names to attach to the deployer role. Use output.available_permission_sets to discover valid names." + type = set(string) + + validation { + condition = length(var.permission_sets) > 0 + error_message = "permission_sets must contain at least one module permission set." + } +} + +variable "additional_policy_arns" { + description = "Additional pre-existing IAM managed policy ARNs to attach to the role." + type = list(string) + default = [] +} + +variable "tags" { + description = "Tags to apply to all created resources." + type = map(string) + default = {} +} diff --git a/modules/github-deployer-role/versions.tf b/modules/github-deployer-role/versions.tf new file mode 100644 index 0000000..69cb52b --- /dev/null +++ b/modules/github-deployer-role/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.6.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 4.0" + } + } +} diff --git a/modules/github-s3-tfstate-access/README.md b/modules/github-s3-tfstate-access/README.md new file mode 100644 index 0000000..c75ece6 --- /dev/null +++ b/modules/github-s3-tfstate-access/README.md @@ -0,0 +1,48 @@ +# Terraform Backend Access Module + +Creates an IAM managed policy that grants Terraform/OpenTofu backend access to an S3 state bucket and DynamoDB lock table, attaches it to a target IAM role, and can optionally manage related GitHub Actions secrets. + +## Usage + +```hcl +module "terraform_backend_access" { + source = "github.com/omsf/lambdacron//modules/github-s3-tfstate-access" + + role_name = aws_iam_role.deployer.name + state_bucket = "my-tf-state" + locks_table = "my-tf-state-locks" + aws_region = "us-east-2" +} +``` + +Optional GitHub Actions secrets management: + +```hcl +module "terraform_backend_access" { + source = "github.com/omsf/lambdacron//modules/github-s3-tfstate-access" + + role_name = aws_iam_role.deployer.name + state_bucket = "my-tf-state" + locks_table = "my-tf-state-locks" + aws_region = "us-east-2" + github_repository = "my-org/my-repo" +} +``` + +## Inputs + +- `role_name` (string): IAM role name to attach backend access policy to. +- `state_bucket` (string): S3 bucket name storing Terraform/OpenTofu state. +- `locks_table` (string): DynamoDB table name storing Terraform/OpenTofu locks. +- `aws_region` (string): Region for the lock table. +- `tags` (map(string)): Optional resource tags. +- `github_repository` (string, optional): `owner/repo`; when set, manages `TF_STATE_BUCKET` and `TF_STATE_TABLE` GitHub Actions secrets. + +## Outputs + +- `policy_arn`: Managed policy ARN. +- `policy_name`: Managed policy name. +- `policy_json`: Rendered policy document JSON. +- `attached_role_name`: Role name the policy is attached to. +- `github_actions_secret_names`: Names of managed GitHub Actions secrets. +- `github_actions_repository_name`: Repository name receiving managed secrets, or `null`. diff --git a/modules/github-s3-tfstate-access/main.tf b/modules/github-s3-tfstate-access/main.tf new file mode 100644 index 0000000..d065db4 --- /dev/null +++ b/modules/github-s3-tfstate-access/main.tf @@ -0,0 +1,73 @@ +locals { + tags = merge({ managed_by = "lambdacron" }, var.tags) + tf_state_bucket_arn = "arn:aws:s3:::${var.state_bucket}" + tf_state_object_arn = "${local.tf_state_bucket_arn}/*" + github_repository_name = ( + var.github_repository == null + ? null + : split("/", var.github_repository)[1] + ) + backend_actions_secrets = ( + local.github_repository_name == null + ? {} + : { + TF_STATE_BUCKET = var.state_bucket + TF_STATE_TABLE = var.locks_table + } + ) +} + +data "aws_caller_identity" "current" {} + +data "aws_iam_policy_document" "terraform_backend_access" { + statement { + sid = "TerraformStateBucketAccess" + actions = [ + "s3:GetBucketLocation", + "s3:ListBucket", + ] + resources = [local.tf_state_bucket_arn] + } + + statement { + sid = "TerraformStateObjectAccess" + actions = [ + "s3:DeleteObject", + "s3:GetObject", + "s3:PutObject", + ] + resources = [local.tf_state_object_arn] + } + + statement { + sid = "TerraformLockTableAccess" + actions = [ + "dynamodb:DeleteItem", + "dynamodb:DescribeTable", + "dynamodb:GetItem", + "dynamodb:PutItem", + "dynamodb:UpdateItem", + ] + resources = ["arn:aws:dynamodb:${var.aws_region}:${data.aws_caller_identity.current.account_id}:table/${var.locks_table}"] + } +} + +resource "aws_iam_policy" "terraform_backend_access" { + name_prefix = "lambdacron-tf-backend-access-" + description = "Allow Terraform/OpenTofu backend access to S3 state and DynamoDB locks." + policy = data.aws_iam_policy_document.terraform_backend_access.json + tags = local.tags +} + +resource "aws_iam_role_policy_attachment" "terraform_backend_access" { + role = var.role_name + policy_arn = aws_iam_policy.terraform_backend_access.arn +} + +resource "github_actions_secret" "backend" { + for_each = local.backend_actions_secrets + + repository = local.github_repository_name + secret_name = each.key + plaintext_value = each.value +} diff --git a/modules/github-s3-tfstate-access/outputs.tf b/modules/github-s3-tfstate-access/outputs.tf new file mode 100644 index 0000000..a16a795 --- /dev/null +++ b/modules/github-s3-tfstate-access/outputs.tf @@ -0,0 +1,29 @@ +output "policy_arn" { + description = "ARN of the managed policy granting Terraform/OpenTofu backend access." + value = aws_iam_policy.terraform_backend_access.arn +} + +output "policy_name" { + description = "Name of the managed policy granting Terraform/OpenTofu backend access." + value = aws_iam_policy.terraform_backend_access.name +} + +output "policy_json" { + description = "Rendered IAM policy document JSON for Terraform/OpenTofu backend access." + value = data.aws_iam_policy_document.terraform_backend_access.json +} + +output "attached_role_name" { + description = "IAM role name that the backend access policy is attached to." + value = var.role_name +} + +output "github_actions_secret_names" { + description = "GitHub Actions secret names managed by this module." + value = sort(keys(github_actions_secret.backend)) +} + +output "github_actions_repository_name" { + description = "GitHub repository name receiving managed backend secrets, or null when github_repository is not set." + value = local.github_repository_name +} diff --git a/modules/github-s3-tfstate-access/variables.tf b/modules/github-s3-tfstate-access/variables.tf new file mode 100644 index 0000000..3c17d39 --- /dev/null +++ b/modules/github-s3-tfstate-access/variables.tf @@ -0,0 +1,57 @@ +variable "role_name" { + description = "IAM role name to attach the backend access policy to." + type = string + + validation { + condition = length(trimspace(var.role_name)) > 0 + error_message = "role_name must be a non-empty IAM role name." + } +} + +variable "state_bucket" { + description = "S3 bucket name that stores Terraform/OpenTofu state." + type = string + + validation { + condition = length(trimspace(var.state_bucket)) > 0 + error_message = "state_bucket must be a non-empty S3 bucket name." + } +} + +variable "locks_table" { + description = "DynamoDB table name used for Terraform/OpenTofu state locking." + type = string + + validation { + condition = length(trimspace(var.locks_table)) > 0 + error_message = "locks_table must be a non-empty DynamoDB table name." + } +} + +variable "aws_region" { + description = "AWS region that hosts the DynamoDB lock table." + type = string + + validation { + condition = length(trimspace(var.aws_region)) > 0 + error_message = "aws_region must be a non-empty AWS region name." + } +} + +variable "tags" { + description = "Tags to apply to created IAM policy resources." + type = map(string) + default = {} +} + +variable "github_repository" { + description = "Optional GitHub repository in owner/repo format; when set, writes TF_STATE_BUCKET and TF_STATE_TABLE GitHub Actions secrets." + type = string + default = null + nullable = true + + validation { + condition = var.github_repository == null || can(regex("^[^/]+/[^/]+$", var.github_repository)) + error_message = "github_repository must be null or in owner/repo format." + } +} diff --git a/modules/github-s3-tfstate-access/versions.tf b/modules/github-s3-tfstate-access/versions.tf new file mode 100644 index 0000000..9f36ded --- /dev/null +++ b/modules/github-s3-tfstate-access/versions.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.6.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 4.0" + } + github = { + source = "integrations/github" + version = "~> 6.0" + } + } +} From 7a983a621d6283286a7033a32cacf2d589d116bd Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 10 Mar 2026 09:02:56 -0400 Subject: [PATCH 2/3] Make DynamoDB lock tables optional --- modules/github-s3-tfstate-access/README.md | 22 ++++++++++--- modules/github-s3-tfstate-access/main.tf | 33 +++++++++++-------- modules/github-s3-tfstate-access/variables.tf | 18 ++++++---- 3 files changed, 47 insertions(+), 26 deletions(-) diff --git a/modules/github-s3-tfstate-access/README.md b/modules/github-s3-tfstate-access/README.md index c75ece6..a271f60 100644 --- a/modules/github-s3-tfstate-access/README.md +++ b/modules/github-s3-tfstate-access/README.md @@ -1,9 +1,20 @@ # Terraform Backend Access Module -Creates an IAM managed policy that grants Terraform/OpenTofu backend access to an S3 state bucket and DynamoDB lock table, attaches it to a target IAM role, and can optionally manage related GitHub Actions secrets. +Creates an IAM managed policy that grants Terraform/OpenTofu backend access to an S3 state bucket, optionally grants DynamoDB lock-table access, attaches it to a target IAM role, and can optionally manage related GitHub Actions secrets. ## Usage +```hcl +module "terraform_backend_access" { + source = "github.com/omsf/lambdacron//modules/github-s3-tfstate-access" + + role_name = aws_iam_role.deployer.name + state_bucket = "my-tf-state" +} +``` + +If you still use DynamoDB locking, set `locks_table`: + ```hcl module "terraform_backend_access" { source = "github.com/omsf/lambdacron//modules/github-s3-tfstate-access" @@ -11,10 +22,11 @@ module "terraform_backend_access" { role_name = aws_iam_role.deployer.name state_bucket = "my-tf-state" locks_table = "my-tf-state-locks" - aws_region = "us-east-2" } ``` +`aws_region` is optional and only applies to DynamoDB lock-table access. If omitted, the module uses the configured AWS provider region. + Optional GitHub Actions secrets management: ```hcl @@ -33,10 +45,10 @@ module "terraform_backend_access" { - `role_name` (string): IAM role name to attach backend access policy to. - `state_bucket` (string): S3 bucket name storing Terraform/OpenTofu state. -- `locks_table` (string): DynamoDB table name storing Terraform/OpenTofu locks. -- `aws_region` (string): Region for the lock table. +- `locks_table` (string, optional): DynamoDB table name storing Terraform/OpenTofu locks. Omit to use lockfile-only backends. +- `aws_region` (string, optional): Region override for the lock table. Defaults to the configured AWS provider region. - `tags` (map(string)): Optional resource tags. -- `github_repository` (string, optional): `owner/repo`; when set, manages `TF_STATE_BUCKET` and `TF_STATE_TABLE` GitHub Actions secrets. +- `github_repository` (string, optional): `owner/repo`; when set, manages `TF_STATE_BUCKET` and, if `locks_table` is provided, `TF_STATE_TABLE` GitHub Actions secrets. ## Outputs diff --git a/modules/github-s3-tfstate-access/main.tf b/modules/github-s3-tfstate-access/main.tf index d065db4..4e0e1ee 100644 --- a/modules/github-s3-tfstate-access/main.tf +++ b/modules/github-s3-tfstate-access/main.tf @@ -2,6 +2,7 @@ locals { tags = merge({ managed_by = "lambdacron" }, var.tags) tf_state_bucket_arn = "arn:aws:s3:::${var.state_bucket}" tf_state_object_arn = "${local.tf_state_bucket_arn}/*" + lock_table_region = coalesce(var.aws_region, data.aws_region.current.name) github_repository_name = ( var.github_repository == null ? null @@ -10,14 +11,15 @@ locals { backend_actions_secrets = ( local.github_repository_name == null ? {} - : { - TF_STATE_BUCKET = var.state_bucket - TF_STATE_TABLE = var.locks_table - } + : merge( + { TF_STATE_BUCKET = var.state_bucket }, + var.locks_table == null ? {} : { TF_STATE_TABLE = var.locks_table }, + ) ) } data "aws_caller_identity" "current" {} +data "aws_region" "current" {} data "aws_iam_policy_document" "terraform_backend_access" { statement { @@ -39,16 +41,19 @@ data "aws_iam_policy_document" "terraform_backend_access" { resources = [local.tf_state_object_arn] } - statement { - sid = "TerraformLockTableAccess" - actions = [ - "dynamodb:DeleteItem", - "dynamodb:DescribeTable", - "dynamodb:GetItem", - "dynamodb:PutItem", - "dynamodb:UpdateItem", - ] - resources = ["arn:aws:dynamodb:${var.aws_region}:${data.aws_caller_identity.current.account_id}:table/${var.locks_table}"] + dynamic "statement" { + for_each = var.locks_table == null ? [] : [var.locks_table] + content { + sid = "TerraformLockTableAccess" + actions = [ + "dynamodb:DeleteItem", + "dynamodb:DescribeTable", + "dynamodb:GetItem", + "dynamodb:PutItem", + "dynamodb:UpdateItem", + ] + resources = ["arn:aws:dynamodb:${local.lock_table_region}:${data.aws_caller_identity.current.account_id}:table/${statement.value}"] + } } } diff --git a/modules/github-s3-tfstate-access/variables.tf b/modules/github-s3-tfstate-access/variables.tf index 3c17d39..99dcb8b 100644 --- a/modules/github-s3-tfstate-access/variables.tf +++ b/modules/github-s3-tfstate-access/variables.tf @@ -19,22 +19,26 @@ variable "state_bucket" { } variable "locks_table" { - description = "DynamoDB table name used for Terraform/OpenTofu state locking." + description = "Optional DynamoDB table name used for Terraform/OpenTofu state locking." type = string + default = null + nullable = true validation { - condition = length(trimspace(var.locks_table)) > 0 - error_message = "locks_table must be a non-empty DynamoDB table name." + condition = var.locks_table == null || length(trimspace(var.locks_table)) > 0 + error_message = "locks_table must be null or a non-empty DynamoDB table name." } } variable "aws_region" { - description = "AWS region that hosts the DynamoDB lock table." + description = "Optional AWS region override for the DynamoDB lock table; defaults to the configured AWS provider region." type = string + default = null + nullable = true validation { - condition = length(trimspace(var.aws_region)) > 0 - error_message = "aws_region must be a non-empty AWS region name." + condition = var.aws_region == null || length(trimspace(var.aws_region)) > 0 + error_message = "aws_region must be null or a non-empty AWS region name." } } @@ -45,7 +49,7 @@ variable "tags" { } variable "github_repository" { - description = "Optional GitHub repository in owner/repo format; when set, writes TF_STATE_BUCKET and TF_STATE_TABLE GitHub Actions secrets." + description = "Optional GitHub repository in owner/repo format; when set, writes TF_STATE_BUCKET and, when locks_table is set, TF_STATE_TABLE GitHub Actions secrets." type = string default = null nullable = true From 58dfe53cb9c0855f46c0c4eb394fe038d7df126e Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Thu, 12 Mar 2026 15:37:26 -0500 Subject: [PATCH 3/3] Make deployer more secure; make GitHub required for backend Resolves issues in Copilot review. --- modules/github-deployer-role/README.md | 11 ++ modules/github-deployer-role/main.tf | 177 ++++++++++++++---- modules/github-deployer-role/variables.tf | 11 ++ modules/github-s3-tfstate-access/README.md | 29 +-- modules/github-s3-tfstate-access/main.tf | 37 ++-- modules/github-s3-tfstate-access/outputs.tf | 2 +- modules/github-s3-tfstate-access/variables.tf | 8 +- 7 files changed, 200 insertions(+), 75 deletions(-) diff --git a/modules/github-deployer-role/README.md b/modules/github-deployer-role/README.md index f2325a5..9910551 100644 --- a/modules/github-deployer-role/README.md +++ b/modules/github-deployer-role/README.md @@ -27,6 +27,10 @@ module "github_deployer_role" { "lambda-image-build", ] + allowed_resource_name_prefixes = [ + "lambdacron", + ] + tags = { environment = "prod" } @@ -56,8 +60,15 @@ module "github_deployer_role" { - `github_job_workflow_refs` (list(string)): Optional allowed OIDC `job_workflow_ref` patterns for workflow-level restriction. - `permission_sets` (set(string)): Permission sets to attach. - `additional_policy_arns` (list(string)): Extra managed policies to attach. +- `allowed_resource_name_prefixes` (set(string)): Allowed name prefixes for deployer-managed resources; defaults to `["lambdacron"]`. - `tags` (map(string)): Resource tags. +## Scoping Behavior + +- Permission-set policies are scoped to the current AWS account and to resources whose names start with values in `allowed_resource_name_prefixes`. +- `iam:PassRole` is restricted to scoped IAM role ARNs and requires `iam:PassedToService = lambda.amazonaws.com`. +- A small set of actions remains wildcard-scoped where AWS APIs do not support resource-level scoping (for example some create/authentication APIs such as `sns:CreateTopic`, `sqs:CreateQueue`, `ecr:GetAuthorizationToken`, and event source mapping APIs). + ## Outputs - `role_arn`: IAM role ARN. diff --git a/modules/github-deployer-role/main.tf b/modules/github-deployer-role/main.tf index f8d1b4f..b774fd9 100644 --- a/modules/github-deployer-role/main.tf +++ b/modules/github-deployer-role/main.tf @@ -1,39 +1,104 @@ +data "aws_caller_identity" "current" {} + locals { tags = merge({ managed_by = "lambdacron" }, var.tags) + account_id = data.aws_caller_identity.current.account_id + resource_prefixes = [for prefix in var.allowed_resource_name_prefixes : trimspace(prefix)] + + iam_role_arns = [ + for prefix in local.resource_prefixes : "arn:aws:iam::${local.account_id}:role/${prefix}*" + ] + iam_policy_arns = [ + for prefix in local.resource_prefixes : "arn:aws:iam::${local.account_id}:policy/${prefix}*" + ] + lambda_function_arns = [ + for prefix in local.resource_prefixes : "arn:aws:lambda:*:${local.account_id}:function:${prefix}*" + ] + eventbridge_rule_arns = [ + for prefix in local.resource_prefixes : "arn:aws:events:*:${local.account_id}:rule/${prefix}*" + ] + sns_topic_arns = [ + for prefix in local.resource_prefixes : "arn:aws:sns:*:${local.account_id}:${prefix}*" + ] + sns_subscription_arns = [ + for prefix in local.resource_prefixes : "arn:aws:sns:*:${local.account_id}:${prefix}*" + ] + sqs_queue_arns = [ + for prefix in local.resource_prefixes : "arn:aws:sqs:*:${local.account_id}:${prefix}*" + ] + ecr_private_repository_arns = [ + for prefix in local.resource_prefixes : "arn:aws:ecr:*:${local.account_id}:repository/${prefix}*" + ] + ecr_public_repository_arns = [ + for prefix in local.resource_prefixes : "arn:aws:ecr-public::${local.account_id}:repository/${prefix}*" + ] + lambda_iam_statements = [ { - sid = "IamRoleAndPolicyManagement" + sid = "IamRoleManagement" + actions = [ + "iam:CreateRole", + "iam:DeleteRole", + "iam:GetRole", + "iam:ListRolePolicies", + "iam:ListRoleTags", + "iam:TagRole", + "iam:UntagRole", + "iam:UpdateAssumeRolePolicy", + ] + resources = local.iam_role_arns + }, + { + sid = "IamManagedPolicyManagement" actions = [ - "iam:AttachRolePolicy", "iam:CreatePolicy", "iam:CreatePolicyVersion", - "iam:CreateRole", "iam:DeletePolicy", "iam:DeletePolicyVersion", - "iam:DeleteRole", - "iam:DetachRolePolicy", "iam:GetPolicy", "iam:GetPolicyVersion", - "iam:GetRole", - "iam:ListAttachedRolePolicies", - "iam:ListRolePolicies", "iam:ListPolicyVersions", - "iam:ListRoleTags", "iam:TagPolicy", - "iam:TagRole", "iam:UntagPolicy", - "iam:UntagRole", - "iam:UpdateAssumeRolePolicy", ] - resources = ["*"] + resources = local.iam_policy_arns + }, + { + sid = "IamRoleManagedPolicyAttachment" + actions = [ + "iam:AttachRolePolicy", + "iam:DetachRolePolicy", + ] + resources = local.iam_role_arns + conditions = [ + { + test = "ArnLike" + variable = "iam:PolicyARN" + values = local.iam_policy_arns + }, + ] + }, + { + sid = "IamRoleAttachedPolicyRead" + actions = [ + "iam:ListAttachedRolePolicies", + ] + resources = local.iam_role_arns }, { sid = "IamPassRoleToLambda" actions = [ "iam:PassRole", ] - resources = ["arn:aws:iam::*:role/*"] + resources = local.iam_role_arns + conditions = [ + { + test = "StringEquals" + variable = "iam:PassedToService" + values = ["lambda.amazonaws.com"] + }, + ] }, { sid = "LambdaFunctionManagement" @@ -56,7 +121,7 @@ locals { "lambda:UpdateFunctionConfiguration", "lambda:UpdateFunctionUrlConfig", ] - resources = ["*"] + resources = local.lambda_function_arns }, ] @@ -74,7 +139,7 @@ locals { "events:TagResource", "events:UntagResource", ] - resources = ["*"] + resources = local.eventbridge_rule_arns }, ] @@ -85,7 +150,7 @@ locals { "ecr:BatchGetImage", "ecr:GetDownloadUrlForLayer", ] - resources = ["*"] + resources = local.ecr_private_repository_arns }, { sid = "EcrRepositoryPolicyReadWriteForLambdaCreateUpdate" @@ -93,15 +158,21 @@ locals { "ecr:GetRepositoryPolicy", "ecr:SetRepositoryPolicy", ] - resources = ["*"] + resources = local.ecr_private_repository_arns }, ] root_sns_topic_statements = [ { - sid = "SnsTopicManagement" + sid = "SnsTopicCreate" actions = [ "sns:CreateTopic", + ] + resources = ["*"] + }, + { + sid = "SnsTopicManagement" + actions = [ "sns:DeleteTopic", "sns:GetTopicAttributes", "sns:ListTagsForResource", @@ -109,15 +180,21 @@ locals { "sns:TagResource", "sns:UntagResource", ] - resources = ["*"] + resources = local.sns_topic_arns }, ] notification_plumbing_statements = [ { - sid = "SqsQueueManagement" + sid = "SqsQueueCreate" actions = [ "sqs:CreateQueue", + ] + resources = ["*"] + }, + { + sid = "SqsQueueManagement" + actions = [ "sqs:DeleteQueue", "sqs:GetQueueAttributes", "sqs:GetQueueUrl", @@ -126,17 +203,23 @@ locals { "sqs:TagQueue", "sqs:UntagQueue", ] - resources = ["*"] + resources = local.sqs_queue_arns + }, + { + sid = "SnsSubscriptionCreate" + actions = [ + "sns:Subscribe", + ] + resources = local.sns_topic_arns }, { sid = "SnsSubscriptionManagement" actions = [ "sns:GetSubscriptionAttributes", "sns:SetSubscriptionAttributes", - "sns:Subscribe", "sns:Unsubscribe", ] - resources = ["*"] + resources = local.sns_subscription_arns }, { sid = "LambdaEventSourceMappingManagement" @@ -153,9 +236,15 @@ locals { ecr_private_repository_management_statements = [ { - sid = "EcrPrivateRepositoryManagement" + sid = "EcrPrivateRepositoryCreate" actions = [ "ecr:CreateRepository", + ] + resources = ["*"] + }, + { + sid = "EcrPrivateRepositoryManagement" + actions = [ "ecr:DeleteRepository", "ecr:DescribeImages", "ecr:DescribeRepositories", @@ -165,24 +254,30 @@ locals { "ecr:TagResource", "ecr:UntagResource", ] - resources = ["*"] + resources = local.ecr_private_repository_arns }, ] ecr_private_push_statements = [ + { + sid = "EcrPrivateAuthorization" + actions = [ + "ecr:GetAuthorizationToken", + ] + resources = ["*"] + }, { sid = "EcrPrivatePushPull" actions = [ "ecr:BatchCheckLayerAvailability", "ecr:BatchGetImage", "ecr:CompleteLayerUpload", - "ecr:GetAuthorizationToken", "ecr:GetDownloadUrlForLayer", "ecr:InitiateLayerUpload", "ecr:PutImage", "ecr:UploadLayerPart", ] - resources = ["*"] + resources = local.ecr_private_repository_arns }, ] @@ -194,7 +289,7 @@ locals { "ecr:GetRepositoryPolicy", "ecr:SetRepositoryPolicy", ] - resources = ["*"] + resources = local.ecr_private_repository_arns }, ] @@ -217,15 +312,21 @@ locals { "ecr-public:DescribeImages", "ecr-public:GetDownloadUrlForLayer", ] - resources = ["*"] + resources = local.ecr_public_repository_arns }, ] ecr_public_repository_management_statements = [ { - sid = "EcrPublicRepositoryManagement" + sid = "EcrPublicRepositoryCreate" actions = [ "ecr-public:CreateRepository", + ] + resources = ["*"] + }, + { + sid = "EcrPublicRepositoryManagement" + actions = [ "ecr-public:DeleteRepository", "ecr-public:DescribeRepositories", "ecr-public:GetRepositoryCatalogData", @@ -233,7 +334,7 @@ locals { "ecr-public:TagResource", "ecr-public:UntagResource", ] - resources = ["*"] + resources = local.ecr_public_repository_arns }, ] @@ -247,7 +348,7 @@ locals { "ecr-public:PutImage", "ecr-public:UploadLayerPart", ] - resources = ["*"] + resources = local.ecr_public_repository_arns }, ] @@ -383,6 +484,16 @@ data "aws_iam_policy_document" "permission_set" { effect = "Allow" actions = statement.value.actions resources = statement.value.resources + + dynamic "condition" { + for_each = try(statement.value.conditions, []) + + content { + test = condition.value.test + variable = condition.value.variable + values = condition.value.values + } + } } } } diff --git a/modules/github-deployer-role/variables.tf b/modules/github-deployer-role/variables.tf index a93bb38..b878dc1 100644 --- a/modules/github-deployer-role/variables.tf +++ b/modules/github-deployer-role/variables.tf @@ -63,6 +63,17 @@ variable "additional_policy_arns" { default = [] } +variable "allowed_resource_name_prefixes" { + description = "Allowed resource name prefixes for deployer-managed IAM roles/policies, Lambda functions, EventBridge rules, SNS topics, SQS queues, and ECR repositories." + type = set(string) + default = ["lambdacron"] + + validation { + condition = length(var.allowed_resource_name_prefixes) > 0 && alltrue([for prefix in var.allowed_resource_name_prefixes : length(trimspace(prefix)) > 0]) + error_message = "allowed_resource_name_prefixes must contain at least one non-empty prefix." + } +} + variable "tags" { description = "Tags to apply to all created resources." type = map(string) diff --git a/modules/github-s3-tfstate-access/README.md b/modules/github-s3-tfstate-access/README.md index a271f60..eec069e 100644 --- a/modules/github-s3-tfstate-access/README.md +++ b/modules/github-s3-tfstate-access/README.md @@ -1,6 +1,6 @@ # Terraform Backend Access Module -Creates an IAM managed policy that grants Terraform/OpenTofu backend access to an S3 state bucket, optionally grants DynamoDB lock-table access, attaches it to a target IAM role, and can optionally manage related GitHub Actions secrets. +Creates an IAM managed policy that grants Terraform/OpenTofu backend access to an S3 state bucket, optionally grants DynamoDB lock-table access, attaches it to a target IAM role, and manages related GitHub Actions secrets for a target repository. ## Usage @@ -8,27 +8,14 @@ Creates an IAM managed policy that grants Terraform/OpenTofu backend access to a module "terraform_backend_access" { source = "github.com/omsf/lambdacron//modules/github-s3-tfstate-access" - role_name = aws_iam_role.deployer.name - state_bucket = "my-tf-state" + role_name = aws_iam_role.deployer.name + state_bucket = "my-tf-state" + github_repository = "my-org/my-repo" } ``` If you still use DynamoDB locking, set `locks_table`: -```hcl -module "terraform_backend_access" { - source = "github.com/omsf/lambdacron//modules/github-s3-tfstate-access" - - role_name = aws_iam_role.deployer.name - state_bucket = "my-tf-state" - locks_table = "my-tf-state-locks" -} -``` - -`aws_region` is optional and only applies to DynamoDB lock-table access. If omitted, the module uses the configured AWS provider region. - -Optional GitHub Actions secrets management: - ```hcl module "terraform_backend_access" { source = "github.com/omsf/lambdacron//modules/github-s3-tfstate-access" @@ -36,11 +23,13 @@ module "terraform_backend_access" { role_name = aws_iam_role.deployer.name state_bucket = "my-tf-state" locks_table = "my-tf-state-locks" - aws_region = "us-east-2" github_repository = "my-org/my-repo" } ``` +`aws_region` is optional and only applies to DynamoDB lock-table access. If omitted, the module uses the configured AWS provider region. +The module expects GitHub provider configuration for the same repository owner as `github_repository`. It validates this and fails fast on owner mismatches. + ## Inputs - `role_name` (string): IAM role name to attach backend access policy to. @@ -48,7 +37,7 @@ module "terraform_backend_access" { - `locks_table` (string, optional): DynamoDB table name storing Terraform/OpenTofu locks. Omit to use lockfile-only backends. - `aws_region` (string, optional): Region override for the lock table. Defaults to the configured AWS provider region. - `tags` (map(string)): Optional resource tags. -- `github_repository` (string, optional): `owner/repo`; when set, manages `TF_STATE_BUCKET` and, if `locks_table` is provided, `TF_STATE_TABLE` GitHub Actions secrets. +- `github_repository` (string): `owner/repo` receiving managed secrets. The module always manages `TF_STATE_BUCKET` and, if `locks_table` is provided, `TF_STATE_TABLE`. ## Outputs @@ -57,4 +46,4 @@ module "terraform_backend_access" { - `policy_json`: Rendered policy document JSON. - `attached_role_name`: Role name the policy is attached to. - `github_actions_secret_names`: Names of managed GitHub Actions secrets. -- `github_actions_repository_name`: Repository name receiving managed secrets, or `null`. +- `github_actions_repository_name`: Repository name receiving managed secrets. diff --git a/modules/github-s3-tfstate-access/main.tf b/modules/github-s3-tfstate-access/main.tf index 4e0e1ee..ef5aab2 100644 --- a/modules/github-s3-tfstate-access/main.tf +++ b/modules/github-s3-tfstate-access/main.tf @@ -1,26 +1,31 @@ locals { - tags = merge({ managed_by = "lambdacron" }, var.tags) - tf_state_bucket_arn = "arn:aws:s3:::${var.state_bucket}" - tf_state_object_arn = "${local.tf_state_bucket_arn}/*" - lock_table_region = coalesce(var.aws_region, data.aws_region.current.name) - github_repository_name = ( - var.github_repository == null - ? null - : split("/", var.github_repository)[1] - ) - backend_actions_secrets = ( - local.github_repository_name == null - ? {} - : merge( - { TF_STATE_BUCKET = var.state_bucket }, - var.locks_table == null ? {} : { TF_STATE_TABLE = var.locks_table }, - ) + tags = merge({ managed_by = "lambdacron" }, var.tags) + tf_state_bucket_arn = "arn:aws:s3:::${var.state_bucket}" + tf_state_object_arn = "${local.tf_state_bucket_arn}/*" + lock_table_region = coalesce(var.aws_region, data.aws_region.current.name) + github_repository_parts = split("/", var.github_repository) + github_repository_owner = local.github_repository_parts[0] + github_repository_name = local.github_repository_parts[1] + backend_actions_secrets = merge( + { TF_STATE_BUCKET = var.state_bucket }, + var.locks_table == null ? {} : { TF_STATE_TABLE = var.locks_table }, ) } data "aws_caller_identity" "current" {} data "aws_region" "current" {} +data "github_repository" "provider_context" { + name = local.github_repository_name +} + +check "github_provider_owner_matches_repository_owner" { + assert { + condition = data.github_repository.provider_context.full_name == var.github_repository + error_message = "GitHub provider owner mismatch: expected \"${var.github_repository}\" from repository name \"${local.github_repository_name}\", but provider resolved \"${data.github_repository.provider_context.full_name}\". Configure provider \"github\" with owner = \"${local.github_repository_owner}\"." + } +} + data "aws_iam_policy_document" "terraform_backend_access" { statement { sid = "TerraformStateBucketAccess" diff --git a/modules/github-s3-tfstate-access/outputs.tf b/modules/github-s3-tfstate-access/outputs.tf index a16a795..500ee32 100644 --- a/modules/github-s3-tfstate-access/outputs.tf +++ b/modules/github-s3-tfstate-access/outputs.tf @@ -24,6 +24,6 @@ output "github_actions_secret_names" { } output "github_actions_repository_name" { - description = "GitHub repository name receiving managed backend secrets, or null when github_repository is not set." + description = "GitHub repository name receiving managed backend secrets." value = local.github_repository_name } diff --git a/modules/github-s3-tfstate-access/variables.tf b/modules/github-s3-tfstate-access/variables.tf index 99dcb8b..54a59d9 100644 --- a/modules/github-s3-tfstate-access/variables.tf +++ b/modules/github-s3-tfstate-access/variables.tf @@ -49,13 +49,11 @@ variable "tags" { } variable "github_repository" { - description = "Optional GitHub repository in owner/repo format; when set, writes TF_STATE_BUCKET and, when locks_table is set, TF_STATE_TABLE GitHub Actions secrets." + description = "GitHub repository in owner/repo format. This module always manages TF_STATE_BUCKET and, when locks_table is set, TF_STATE_TABLE GitHub Actions secrets for that repository." type = string - default = null - nullable = true validation { - condition = var.github_repository == null || can(regex("^[^/]+/[^/]+$", var.github_repository)) - error_message = "github_repository must be null or in owner/repo format." + condition = can(regex("^[^/]+/[^/]+$", var.github_repository)) + error_message = "github_repository must be in owner/repo format." } }