diff --git a/README.md b/README.md index 6a8ab47..1e813c7 100644 --- a/README.md +++ b/README.md @@ -27,20 +27,25 @@ module "oidc_github" { source = "unfunco/oidc-github/aws" version = "2.0.2" - github_repositories = ["org/repo"] + github_subjects = ["org/repo"] } ``` -By default, it will only allow the `main` branch of the specified repository to -assume the IAM role, you can set the `default_branch_name` variable to `master` -if necessary, or specify `*` to allow all branches to assume the role. To allow -specific branches or tags, you can include an explicit ref: +By default, bare `github_subjects` entries are expanded to the +`ref:refs/heads/main` subject. You can set `default_subject` to a different +value such as `ref:refs/heads/master`, `pull_request`, or `*`, but `*` is +broader than most projects need. + +Each `github_subjects` entry can also include an explicit GitHub OIDC subject +suffix. That means pull requests do **not** require `default_subject = "*"`, +and can be allowed explicitly alongside the default branch: ```terraform -github_repositories = [ - "org/repo:ref:refs/heads/main", +github_subjects = [ + "org/repo", + "org/repo:pull_request", "org/repo:ref:refs/heads/release/*", "org/repo:ref:refs/tags/v*", ] @@ -104,28 +109,28 @@ applied, the JWT will contain an updated `iss` claim. ### Inputs -| Name | Description | Type | Default | Required | -| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | ---------------------------------------- | :------: | -| additional_audiences | Additional OIDC audiences allowed to assume the role. | `list(string)` | `null` | no | -| additional_thumbprints | Additional thumbprints for the OIDC provider. | `list(string)` | `[]` | no | -| create | Enable/disable the creation of all resources. | `bool` | `true` | no | -| create_iam_role | Enable/disable creation of the IAM role. | `bool` | `true` | no | -| create_oidc_provider | Enable/disable the creation of the GitHub OIDC provider. | `bool` | `true` | no | -| dangerously_attach_admin_policy | Enable/disable the attachment of the AdministratorAccess policy. | `bool` | `false` | no | -| default_branch_name | Default branch name for repositories without an explicit ref. Use '\*' to allow all refs (less secure). | `string` | `"main"` | no | -| enterprise_slug | Enterprise slug for GitHub Enterprise Cloud customers. | `string` | `""` | no | -| github_repositories | GitHub organization/repository names authorized to assume the role. | `list(string)` | `[]` | no | -| iam_role_description | Description of the IAM role to be created. | `string` | `"Assumed by the GitHub OIDC provider."` | no | -| iam_role_force_detach_policies | Force detachment of policies attached to the IAM role. | `bool` | `false` | no | -| iam_role_inline_policies | Inline policies map with policy name as key and json as value. | `map(string)` | `{}` | no | -| iam_role_max_session_duration | The maximum session duration in seconds. | `number` | `3600` | no | -| iam_role_name | The name of the IAM role to be created and made assumable by GitHub Actions. | `string` | `"GitHubActions"` | no | -| iam_role_path | The path under which to create IAM role. | `string` | `"/"` | no | -| iam_role_permissions_boundary | The ARN of the permissions boundary to be used by the IAM role. | `string` | `""` | no | -| iam_role_policy_names | AWS managed IAM policy names to attach to the IAM role. Provide the value after `policy/`, for example `ReadOnlyAccess` or `service-role/AWSLambdaBasicExecutionRole`. | `list(string)` | `[]` | no | -| iam_role_tags | Additional tags to be applied to the IAM role. | `map(string)` | `{}` | no | -| oidc_provider_tags | Tags to be applied to the OIDC provider. | `map(string)` | `{}` | no | -| tags | Tags to be applied to all applicable resources. | `map(string)` | `{}` | no | +| Name | Description | Type | Default | Required | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------- | ---------------------------------------- | :------: | +| additional_audiences | Additional OIDC audiences allowed to assume the role. | `list(string)` | `null` | no | +| additional_thumbprints | Additional thumbprints for the OIDC provider. | `list(string)` | `[]` | no | +| create | Enable/disable the creation of all resources. | `bool` | `true` | no | +| create_iam_role | Enable/disable creation of the IAM role. | `bool` | `true` | no | +| create_oidc_provider | Enable/disable the creation of the GitHub OIDC provider. | `bool` | `true` | no | +| dangerously_attach_admin_policy | Enable/disable the attachment of the AdministratorAccess policy. | `bool` | `false` | no | +| default_subject | Default GitHub OIDC subject pattern appended to github_subjects entries without an explicit subject suffix. Examples: ref:refs/heads/main, pull_request, \*. | `string` | `"ref:refs/heads/main"` | no | +| enterprise_slug | Enterprise slug for GitHub Enterprise Cloud customers. This changes the OIDC issuer URL and IAM condition keys. | `string` | `""` | no | +| github_subjects | GitHub repository subject patterns authorized to assume the role. Entries may be bare owner/repository values or include an explicit subject suffix such as :pull_request or :ref:refs/tags/v\*. | `list(string)` | `[]` | no | +| iam_role_description | Description of the IAM role to be created. | `string` | `"Assumed by the GitHub OIDC provider."` | no | +| iam_role_force_detach_policies | Force detachment of policies attached to the IAM role. | `bool` | `false` | no | +| iam_role_inline_policies | Inline policies map with policy name as key and json as value. | `map(string)` | `{}` | no | +| iam_role_max_session_duration | The maximum session duration in seconds. | `number` | `3600` | no | +| iam_role_name | The name of the IAM role to be created and made assumable by GitHub Actions. | `string` | `"GitHubActions"` | no | +| iam_role_path | The path under which to create IAM role. | `string` | `"/"` | no | +| iam_role_permissions_boundary | The ARN of the permissions boundary to be used by the IAM role. | `string` | `""` | no | +| iam_role_policy_names | AWS managed IAM policy names to attach to the IAM role. Provide the value after `policy/`, for example `ReadOnlyAccess` or `service-role/AWSLambdaBasicExecutionRole`. | `list(string)` | `[]` | no | +| iam_role_tags | Additional tags to be applied to the IAM role. | `map(string)` | `{}` | no | +| oidc_provider_tags | Tags to be applied to the OIDC provider. | `map(string)` | `{}` | no | +| tags | Tags to be applied to all applicable resources. | `map(string)` | `{}` | no | ### Outputs diff --git a/data.tf b/data.tf index 6acb30e..2c97ddd 100644 --- a/data.tf +++ b/data.tf @@ -6,7 +6,7 @@ data "aws_partition" "this" { } data "aws_iam_policy_document" "assume_role" { - count = var.create ? 1 : 0 + count = var.create && local.has_github_subjects ? 1 : 0 version = "2012-10-17" @@ -17,11 +17,11 @@ data "aws_iam_policy_document" "assume_role" { condition { test = "StringLike" values = [ - for repo in var.github_repositories : + for subject in var.github_subjects : format( "repo:%s%s", - repo, - length(regexall(":+", repo)) > 0 ? "" : local.default_repository_sub_claim_suffix, + subject, + length(regexall(":+", subject)) > 0 ? "" : local.default_subject_suffix, ) ] variable = "${local.oidc_issuer}:sub" diff --git a/examples/basic/main.tf b/examples/basic/main.tf index 647e090..7e42c3d 100644 --- a/examples/basic/main.tf +++ b/examples/basic/main.tf @@ -3,6 +3,6 @@ provider "aws" {} module "oidc_github" { source = "../.." - attach_lambda_full_access_policy = true - github_repositories = var.github_repositories + github_subjects = var.github_subjects + iam_role_policy_names = ["ReadOnlyAccess"] } diff --git a/examples/basic/variables.tf b/examples/basic/variables.tf index 516e58d..0525bba 100644 --- a/examples/basic/variables.tf +++ b/examples/basic/variables.tf @@ -1,5 +1,5 @@ -variable "github_repositories" { +variable "github_subjects" { default = [] - description = "GitHub organization/repository names authorized to assume the role." + description = "GitHub repository subject patterns authorized to assume the role." type = list(string) } diff --git a/examples/multiple-roles/main.tf b/examples/multiple-roles/main.tf index 76785a5..5d63d24 100644 --- a/examples/multiple-roles/main.tf +++ b/examples/multiple-roles/main.tf @@ -12,8 +12,8 @@ module "label" { module "oidc_github" { source = "../.." - create_iam_role = false - github_repositories = var.github_repositories + create_iam_role = false + github_subjects = var.github_subjects } resource "aws_iam_role" "network" { diff --git a/examples/multiple-roles/variables.tf b/examples/multiple-roles/variables.tf index 516e58d..0525bba 100644 --- a/examples/multiple-roles/variables.tf +++ b/examples/multiple-roles/variables.tf @@ -1,5 +1,5 @@ -variable "github_repositories" { +variable "github_subjects" { default = [] - description = "GitHub organization/repository names authorized to assume the role." + description = "GitHub repository subject patterns authorized to assume the role." type = list(string) } diff --git a/main.tf b/main.tf index 7bf7c88..f56a740 100644 --- a/main.tf +++ b/main.tf @@ -2,30 +2,24 @@ // SPDX-License-Identifier: MIT locals { - create_iam_role = var.create && var.create_iam_role && ( - var.github_repositories != null && length(var.github_repositories) > 0 - ) + has_github_subjects = var.github_subjects != null && length(var.github_subjects) > 0 - create_oidc_provider = var.create && var.create_oidc_provider && ( - var.github_repositories != null && length(var.github_repositories) > 0 - ) + create_iam_role = var.create && var.create_iam_role && local.has_github_subjects + + create_oidc_provider = var.create && var.create_oidc_provider && local.has_github_subjects custom_iam_role_policy_arns = local.create_iam_role ? toset([ for policy_name in var.iam_role_policy_names : format("arn:%s:iam::aws:policy/%s", data.aws_partition.this[0].partition, policy_name) ]) : toset([]) - dangerously_attach_admin_policy = local.create_iam_role && var.dangerously_attach_admin_policy - - default_branch_name = trimspace(var.default_branch_name) - default_repository_sub_claim_suffix = ( - local.default_branch_name == "*" ? ":*" : format(":ref:refs/heads/%s", local.default_branch_name) - ) + default_subject = trimspace(var.default_subject) + default_subject_suffix = local.default_subject == "*" ? ":*" : format(":%s", local.default_subject) enterprise_slug_path = var.enterprise_slug != "" ? format("/%s", var.enterprise_slug) : "" - github_organizations = toset([ - for repo in var.github_repositories : split("/", repo)[0] + github_repository_owners = toset([ + for subject in var.github_subjects : split("/", subject)[0] ]) oidc_issuer = format( @@ -79,7 +73,7 @@ resource "aws_iam_openid_connect_provider" "github" { count = local.create_oidc_provider ? 1 : 0 client_id_list = concat( - [for org in local.github_organizations : format("https://github.com/%s", org)], + [for owner in local.github_repository_owners : format("https://github.com/%s", owner)], [format("sts.%s", data.aws_partition.this[0].dns_suffix)], ) diff --git a/oidc-github.tftest.hcl b/oidc-github.tftest.hcl index 2d662d3..01a91d5 100644 --- a/oidc-github.tftest.hcl +++ b/oidc-github.tftest.hcl @@ -73,8 +73,8 @@ run "create_nothing" { run "create_oidc_provider_only" { variables { - create_iam_role = false - github_repositories = ["unfunco/terraform-aws-oidc-github"] + create_iam_role = false + github_subjects = ["unfunco/terraform-aws-oidc-github"] } command = plan @@ -92,8 +92,8 @@ run "create_oidc_provider_only" { run "enterprise_slug_updates_created_oidc_provider_principal" { variables { - enterprise_slug = "octo-enterprise" - github_repositories = ["unfunco/terraform-aws-oidc-github"] + enterprise_slug = "octo-enterprise" + github_subjects = ["unfunco/terraform-aws-oidc-github"] } command = plan @@ -123,9 +123,9 @@ run "enterprise_slug_updates_created_oidc_provider_principal" { } } -run "sub_claim_default_branch" { +run "sub_claim_default_subject" { variables { - github_repositories = ["unfunco/terraform-aws-oidc-github"] + github_subjects = ["unfunco/terraform-aws-oidc-github"] } command = plan @@ -137,13 +137,42 @@ run "sub_claim_default_branch" { ]), "repo:unfunco/terraform-aws-oidc-github:ref:refs/heads/main" ) - error_message = "Sub claim should include ref:refs/heads/main for default branch" + error_message = "Sub claim should include ref:refs/heads/main for the default subject" } } +run "sub_claim_supports_pull_request_default_subject" { + variables { + default_subject = "pull_request" + github_subjects = ["unfunco/terraform-aws-oidc-github"] + } + + command = plan + + assert { + condition = flatten([ + jsondecode(data.aws_iam_policy_document.assume_role[0].json).Statement[0].Condition.StringLike["token.actions.githubusercontent.com:sub"], + ]) == [ + "repo:unfunco/terraform-aws-oidc-github:pull_request", + ] + error_message = "Sub claim should support pull_request as the default subject" + } +} + +run "default_subject_rejects_leading_colon" { + variables { + default_subject = ":pull_request" + github_subjects = ["unfunco/terraform-aws-oidc-github"] + } + + command = plan + + expect_failures = [var.default_subject] +} + run "sub_claim_preserves_explicit_ref" { variables { - github_repositories = ["unfunco/terraform-aws-oidc-github:ref:refs/tags/v*"] + github_subjects = ["unfunco/terraform-aws-oidc-github:ref:refs/tags/v*"] } command = plan @@ -154,14 +183,31 @@ run "sub_claim_preserves_explicit_ref" { ]) == [ "repo:unfunco/terraform-aws-oidc-github:ref:refs/tags/v*", ] - error_message = "Explicit refs should be preserved without appending the default branch" + error_message = "Explicit refs should be preserved without appending the default subject" + } +} + +run "sub_claim_preserves_pull_request_subject" { + variables { + github_subjects = ["unfunco/terraform-aws-oidc-github:pull_request"] + } + + command = plan + + assert { + condition = flatten([ + jsondecode(data.aws_iam_policy_document.assume_role[0].json).Statement[0].Condition.StringLike["token.actions.githubusercontent.com:sub"], + ]) == [ + "repo:unfunco/terraform-aws-oidc-github:pull_request", + ] + error_message = "Explicit pull_request subjects should be preserved without appending the default subject" } } run "aud_claim_includes_additional_audiences" { variables { additional_audiences = ["https://github.com/unfunco"] - github_repositories = ["unfunco/terraform-aws-oidc-github"] + github_subjects = ["unfunco/terraform-aws-oidc-github"] } command = plan @@ -182,7 +228,7 @@ run "aud_claim_includes_additional_audiences" { run "create_role_with_existing_oidc_provider" { variables { create_oidc_provider = false - github_repositories = ["unfunco/terraform-aws-oidc-github"] + github_subjects = ["unfunco/terraform-aws-oidc-github"] } command = plan @@ -203,11 +249,30 @@ run "create_role_with_existing_oidc_provider" { } } +run "assume_role_policy_available_with_existing_oidc_provider" { + variables { + create_oidc_provider = false + github_subjects = ["unfunco/terraform-aws-oidc-github"] + } + + command = plan + + assert { + condition = output.assume_role_policy != "" + error_message = "Assume role policy output should be available when reusing an existing OIDC provider" + } + + assert { + condition = output.assume_role_policy == data.aws_iam_policy_document.assume_role[0].json + error_message = "Assume role policy output should match the generated policy when reusing an existing OIDC provider" + } +} + run "enterprise_slug_updates_existing_oidc_provider_principal" { variables { create_oidc_provider = false enterprise_slug = "octo-enterprise" - github_repositories = ["unfunco/terraform-aws-oidc-github"] + github_subjects = ["unfunco/terraform-aws-oidc-github"] } command = plan @@ -240,7 +305,7 @@ run "enterprise_slug_updates_existing_oidc_provider_principal" { run "custom_policy_attachments_are_keyed_by_generated_arn" { variables { - github_repositories = ["unfunco/terraform-aws-oidc-github"] + github_subjects = ["unfunco/terraform-aws-oidc-github"] iam_role_policy_names = [ "AmazonS3FullAccess", "ReadOnlyAccess", @@ -266,7 +331,7 @@ run "custom_policy_attachments_are_keyed_by_generated_arn" { run "custom_policy_names_reject_full_arns" { variables { - github_repositories = ["unfunco/terraform-aws-oidc-github"] + github_subjects = ["unfunco/terraform-aws-oidc-github"] iam_role_policy_names = ["arn:aws:iam::aws:policy/ReadOnlyAccess"] } diff --git a/outputs.tf b/outputs.tf index 6a1e3be..24bd269 100644 --- a/outputs.tf +++ b/outputs.tf @@ -3,7 +3,7 @@ output "assume_role_policy" { description = "The assume role policy document that can be attached to your IAM roles." - value = local.create_oidc_provider ? data.aws_iam_policy_document.assume_role[0].json : "" + value = var.create && local.has_github_subjects ? data.aws_iam_policy_document.assume_role[0].json : "" } output "iam_role_arn" { diff --git a/variables.tf b/variables.tf index d0f445e..0c81773 100644 --- a/variables.tf +++ b/variables.tf @@ -42,31 +42,36 @@ variable "dangerously_attach_admin_policy" { type = bool } -variable "default_branch_name" { - default = "main" - description = "Default branch name for repositories without an explicit ref. Use '*' to allow all refs (less secure)." +variable "default_subject" { + default = "ref:refs/heads/main" + description = "Default GitHub OIDC subject pattern appended to github_subjects entries without an explicit subject suffix. Examples: ref:refs/heads/main, pull_request, *." type = string + + validation { + condition = trimspace(var.default_subject) != "" && !startswith(trimspace(var.default_subject), ":") + error_message = "The default subject must not be empty or start with ':'. Use values such as ref:refs/heads/main, pull_request, or *." + } } variable "enterprise_slug" { default = "" - description = "Enterprise slug for GitHub Enterprise Cloud customers." + description = "Enterprise slug for GitHub Enterprise Cloud customers. This changes the OIDC issuer URL and IAM condition keys." type = string } -variable "github_repositories" { +variable "github_subjects" { default = [] - description = "GitHub organization/repository names authorized to assume the role." + description = "GitHub repository subject patterns authorized to assume the role. Entries may be bare owner/repository values or include an explicit subject suffix such as :pull_request or :ref:refs/tags/v*." type = list(string) validation { - // Ensures each element of github_repositories list matches the - // organization/repository format used by GitHub. + // Ensures each element of github_subjects matches a GitHub + // owner/repository value with an optional explicit subject suffix. condition = length([ - for repo in var.github_repositories : 1 - if length(regexall("^[A-Za-z0-9_.-]+?/([A-Za-z0-9_.:/\\-\\*]+)$", repo)) > 0 - ]) == length(var.github_repositories) - error_message = "Repositories must be specified in the organization/repository format." + for subject in var.github_subjects : 1 + if length(regexall("^[A-Za-z0-9_.-]+?/([A-Za-z0-9_.:/\\-\\*]+)$", subject)) > 0 + ]) == length(var.github_subjects) + error_message = "Subjects must be specified as owner/repository, optionally followed by a subject suffix such as :pull_request or :ref:refs/heads/main." } }