diff --git a/.github/workflows/ci-scripts.yml b/.github/workflows/ci-scripts.yml index ac3bc3b..1410665 100644 --- a/.github/workflows/ci-scripts.yml +++ b/.github/workflows/ci-scripts.yml @@ -40,3 +40,15 @@ jobs: - name: Validate multi-org command generation run: python scripts/generate_import_commands.py --example multi-org --repo-root . > /tmp/imports-multi.sh + + - name: Validate single-org OIDC provider command generation + run: python scripts/generate_import_commands.py --example single-org-oidc-provider --repo-root . > /tmp/imports-single-oidc-provider.sh + + - name: Validate multi-org OIDC provider command generation + run: python scripts/generate_import_commands.py --example multi-org-oidc-provider --repo-root . > /tmp/imports-multi-oidc-provider.sh + + - name: Validate single-org OIDC roles command generation + run: python scripts/generate_import_commands.py --example single-org-oidc-roles --repo-root . > /tmp/imports-single-oidc-roles.sh + + - name: Validate multi-org OIDC roles command generation + run: python scripts/generate_import_commands.py --example multi-org-oidc-roles --repo-root . > /tmp/imports-multi-oidc-roles.sh diff --git a/.github/workflows/ci-terraform.yml b/.github/workflows/ci-terraform.yml index f37be2d..f4d8212 100644 --- a/.github/workflows/ci-terraform.yml +++ b/.github/workflows/ci-terraform.yml @@ -38,12 +38,18 @@ jobs: fail-fast: false matrix: stack: + - modules/aws_github_oidc_provider + - modules/aws_github_actions_oidc_roles - modules/github_org_settings - modules/github_repo_platform - - examples/s3-backend-single-org - - examples/s3-backend-single-org-repos - - examples/s3-backend-multi-org - - examples/s3-backend-multi-org-repos + - examples/github_org_settings/single-org + - examples/github_repo_platform/single-org + - examples/aws_github_oidc_provider/single-account + - examples/aws_github_actions_oidc_roles/single-account + - examples/github_org_settings/multi-org + - examples/github_repo_platform/multi-org + - examples/aws_github_oidc_provider/multi-account + - examples/aws_github_actions_oidc_roles/multi-account steps: - name: Checkout uses: actions/checkout@v4 diff --git a/.github/workflows/ci-tflint.yml b/.github/workflows/ci-tflint.yml index fe576ef..c366fd5 100644 --- a/.github/workflows/ci-tflint.yml +++ b/.github/workflows/ci-tflint.yml @@ -24,12 +24,18 @@ jobs: fail-fast: false matrix: stack: + - modules/aws_github_oidc_provider + - modules/aws_github_actions_oidc_roles - modules/github_org_settings - modules/github_repo_platform - - examples/s3-backend-single-org - - examples/s3-backend-single-org-repos - - examples/s3-backend-multi-org - - examples/s3-backend-multi-org-repos + - examples/github_org_settings/single-org + - examples/github_repo_platform/single-org + - examples/aws_github_oidc_provider/single-account + - examples/aws_github_actions_oidc_roles/single-account + - examples/github_org_settings/multi-org + - examples/github_repo_platform/multi-org + - examples/aws_github_oidc_provider/multi-account + - examples/aws_github_actions_oidc_roles/multi-account steps: - name: Checkout uses: actions/checkout@v4 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..ae4a169 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +repos: + - repo: local + hooks: + - id: terraform-fmt-check + name: terraform fmt -check + entry: scripts/precommit_terraform.sh fmt-check + language: system + pass_filenames: false + - id: terraform-validate + name: terraform validate + entry: scripts/precommit_terraform.sh validate + language: system + pass_filenames: false + - id: terraform-tflint + name: tflint + entry: scripts/precommit_terraform.sh tflint + language: system + pass_filenames: false diff --git a/README.md b/README.md index b743a79..d281dbe 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,14 @@ # Platform SCM Terraform Catalog -Terraform modules and example stacks for managing GitHub organizations, repositories, environments, and environment-level secrets/variables with a catalog-driven workflow. +Terraform modules and example stacks for managing GitHub organizations, repositories, environments, GitHub Actions OIDC trust in AWS, and AWS IAM role-based access for GitHub Actions with a catalog-driven workflow. ## What This Repository Manages - Organization settings through `modules/github_org_settings`. - Repository lifecycle through `modules/github_repo_platform`. - Repository environments, environment secrets, and environment variables through `modules/github_repo_platform`. +- AWS IAM OIDC providers through `modules/aws_github_oidc_provider`. +- AWS IAM roles and policies for GitHub Actions through `modules/aws_github_actions_oidc_roles`. - Catalog-based onboarding, where YAML files define repository and environment intent. ## Resource Coverage @@ -16,6 +18,8 @@ Terraform modules and example stacks for managing GitHub organizations, reposito - Repository environments: `github_repository_environment`. - Environment secrets: `github_actions_environment_secret`. - Environment variables: `github_actions_environment_variable`. +- AWS OIDC providers: `aws_iam_openid_connect_provider`. +- AWS IAM role-based access: `aws_iam_role`, `aws_iam_policy`, and `aws_iam_role_policy_attachment`. Environment configuration model: - `repositories..shared_environment_secrets` and `repositories..shared_environment_variables` apply to all environments in a repository. @@ -25,6 +29,7 @@ Environment configuration model: Catalog loading model: - Example stacks load YAML definitions using `fileset(...)` + `yamldecode(...)`. - Repositories are onboarded by adding or updating catalog files instead of editing module wiring. +- OIDC providers, repository IAM roles, and repository short names are onboarded the same way. ## Quick Start @@ -36,17 +41,17 @@ Prerequisites: 1. Enter an example stack: ```bash -cd examples/s3-backend-single-org-repos +cd examples/github_repo_platform/single-org # or -cd examples/s3-backend-multi-org-repos +cd examples/github_repo_platform/multi-org ``` Org settings stacks: ```bash -cd examples/s3-backend-single-org +cd examples/github_org_settings/single-org # or -cd examples/s3-backend-multi-org +cd examples/github_org_settings/multi-org ``` 2. Copy example inputs and backend config: @@ -65,24 +70,59 @@ terraform validate terraform plan ``` +## Local Pre-Commit Checks + +Install and run Terraform checks locally before pushing: + +```bash +pip install pre-commit +pre-commit install +pre-commit run --all-files +``` + +Configured hooks: +- `terraform fmt -check` +- `terraform validate` (across modules and example stacks used in CI) +- `tflint` (across modules and example stacks used in CI) + ## Examples -- `examples/s3-backend-single-org-repos`: single-organization repository onboarding. -- `examples/s3-backend-multi-org-repos`: multi-organization repository onboarding with provider aliases. -- `examples/s3-backend-single-org`: single-organization settings management. -- `examples/s3-backend-multi-org`: multi-organization settings management. +- Directory index: `examples/README.md` +- `examples/github_repo_platform/single-org`: single-organization repository onboarding. +- `examples/github_repo_platform/multi-org`: multi-organization repository onboarding with provider aliases. +- `examples/github_org_settings/single-org`: single-organization settings management. +- `examples/github_org_settings/multi-org`: multi-organization settings management. +- `examples/aws_github_oidc_provider/single-account`: single-account GitHub OIDC provider management. +- `examples/aws_github_oidc_provider/multi-account`: multi-account GitHub OIDC provider management. +- `examples/aws_github_actions_oidc_roles/single-account`: single-account repository/environment IAM role management. +- `examples/aws_github_actions_oidc_roles/multi-account`: multi-account repository/environment IAM role management. ## Catalog-Driven Onboarding Workflow -- Single-org catalog path: `examples/s3-backend-single-org-repos/catalog/repositories/`. +- Single-org catalog path: `examples/github_repo_platform/single-org/catalog/repositories/`. - Multi-org catalog paths: - - `examples/s3-backend-multi-org-repos/catalog/orgs/platform/repositories/` - - `examples/s3-backend-multi-org-repos/catalog/orgs/shared/repositories/` + - `examples/github_repo_platform/multi-org/catalog/orgs/platform/repositories/` + - `examples/github_repo_platform/multi-org/catalog/orgs/shared/repositories/` - Onboard by adding/updating catalog YAML, then run Terraform plan/apply in the matching stack. +AWS OIDC/IAM catalog paths: +- OIDC providers: + - `examples/aws_github_oidc_provider/single-account/catalog/oidc-connections/` + - `examples/aws_github_oidc_provider/multi-account/catalog/orgs/platform/oidc-connections/` + - `examples/aws_github_oidc_provider/multi-account/catalog/orgs/shared/oidc-connections/` +- OIDC roles: + - `examples/aws_github_actions_oidc_roles/single-account/catalog/repositories/` + - `examples/aws_github_actions_oidc_roles/multi-account/catalog/orgs/platform/repositories/` + - `examples/aws_github_actions_oidc_roles/multi-account/catalog/orgs/shared/repositories/` +- Repository short names: + - `examples/aws_github_actions_oidc_roles/single-account/catalog/repo-short-names.yaml` + - `examples/aws_github_actions_oidc_roles/multi-account/catalog/orgs/platform/repo-short-names.yaml` + - `examples/aws_github_actions_oidc_roles/multi-account/catalog/orgs/shared/repo-short-names.yaml` + ## Operations And Migration Playbooks - Import existing resources into Terraform state: `docs/playbooks/import-existing-github-resources.md`. +- Import existing AWS OIDC/IAM resources into Terraform state: `docs/playbooks/import-existing-aws-oidc-resources.md`. - GitHub Actions variable/secret setup reference: `docs/playbooks/import-existing-github-resources.md#github-actions-configuration-reference`. diff --git a/docs/playbooks/import-existing-aws-oidc-resources.md b/docs/playbooks/import-existing-aws-oidc-resources.md new file mode 100644 index 0000000..606a918 --- /dev/null +++ b/docs/playbooks/import-existing-aws-oidc-resources.md @@ -0,0 +1,43 @@ +# Playbook: Import Existing AWS OIDC/IAM Resources Into Terraform State + +This playbook is for adopting existing AWS IAM OIDC providers, IAM roles, and IAM policies used by GitHub Actions into this repo's Terraform state without recreating them. + +## Scope + +This repository manages these AWS resources via split modules: + +- `modules/aws_github_oidc_provider` manages `aws_iam_openid_connect_provider` +- `modules/aws_github_actions_oidc_roles` manages `aws_iam_role` +- `modules/aws_github_actions_oidc_roles` manages `aws_iam_policy` +- `modules/aws_github_actions_oidc_roles` manages `aws_iam_role_policy_attachment` + +## Stacks + +- OIDC providers: + - `examples/aws_github_oidc_provider/single-account` + - `examples/aws_github_oidc_provider/multi-account` +- OIDC roles/policies: + - `examples/aws_github_actions_oidc_roles/single-account` + - `examples/aws_github_actions_oidc_roles/multi-account` + +## Generate Import Command Stubs + +```bash +# From repo root +python3 scripts/generate_import_commands.py --example single-org-oidc-provider +python3 scripts/generate_import_commands.py --example multi-org-oidc-provider +python3 scripts/generate_import_commands.py --example single-org-oidc-roles +python3 scripts/generate_import_commands.py --example multi-org-oidc-roles +``` + +Review output before running commands. + +## Notes + +- Import IDs for OIDC providers and customer-managed policies use ARNs. +- Import IDs for role-policy attachments follow `role-name/policy-arn`. +- If a resource imports to the wrong address, remove it from state and re-import: + +```bash +terraform state rm
+``` diff --git a/docs/playbooks/import-existing-github-resources.md b/docs/playbooks/import-existing-github-resources.md index 842223e..e1d7c73 100644 --- a/docs/playbooks/import-existing-github-resources.md +++ b/docs/playbooks/import-existing-github-resources.md @@ -17,11 +17,11 @@ This repository manages these GitHub resources via split modules: - Terraform CLI is installed locally. - Your selected stack is configured: - Repository onboarding stack: - - `examples/s3-backend-single-org-repos`, or - - `examples/s3-backend-multi-org-repos` + - `examples/github_repo_platform/single-org`, or + - `examples/github_repo_platform/multi-org` - Org settings stack (if importing org settings): - - `examples/s3-backend-single-org`, or - - `examples/s3-backend-multi-org` + - `examples/github_org_settings/single-org`, or + - `examples/github_org_settings/multi-org` - Backend config exists (`backend.hcl`) and points to the target state. - GitHub token(s) have sufficient scopes to read/update org/repo/environment settings. - Catalog YAML files match the existing GitHub resources you want Terraform to manage. @@ -63,7 +63,7 @@ State isolation: Single-org: ```bash -cd examples/s3-backend-single-org-repos +cd examples/github_repo_platform/single-org cp backend.hcl.example backend.hcl # if needed terraform init -backend-config=backend.hcl ``` @@ -71,7 +71,7 @@ terraform init -backend-config=backend.hcl Multi-org: ```bash -cd examples/s3-backend-multi-org-repos +cd examples/github_repo_platform/multi-org cp backend.hcl.example backend.hcl # if needed terraform init -backend-config=backend.hcl ``` @@ -79,7 +79,7 @@ terraform init -backend-config=backend.hcl Single-org org settings: ```bash -cd examples/s3-backend-single-org +cd examples/github_org_settings/single-org cp backend.hcl.example backend.hcl # if needed terraform init -backend-config=backend.hcl ``` @@ -87,7 +87,7 @@ terraform init -backend-config=backend.hcl Multi-org org settings: ```bash -cd examples/s3-backend-multi-org +cd examples/github_org_settings/multi-org cp backend.hcl.example backend.hcl # if needed terraform init -backend-config=backend.hcl ``` @@ -110,12 +110,12 @@ Address patterns used by the repository module: Module names: - Single-org example: - - Settings: `module.org_settings` (in `examples/s3-backend-single-org`) + - Settings: `module.org_settings` (in `examples/github_org_settings/single-org`) - Repositories: `module.org_repositories` - Multi-org example: - - Platform settings: `module.platform_org_settings` (in `examples/s3-backend-multi-org`) + - Platform settings: `module.platform_org_settings` (in `examples/github_org_settings/multi-org`) - Platform repositories: `module.platform_org_repositories` - - Shared settings: `module.shared_org_settings` (in `examples/s3-backend-multi-org`) + - Shared settings: `module.shared_org_settings` (in `examples/github_org_settings/multi-org`) - Shared repositories: `module.shared_org_repositories` ## 3. Import Order @@ -163,7 +163,7 @@ Replace placeholders (org/repo/env/names). ```bash # 4.1 Optional org settings -# Run this from examples/s3-backend-single-org +# Run this from examples/github_org_settings/single-org terraform import 'module.org_settings.github_organization_settings.this[0]' '' # 4.2 Repository @@ -192,7 +192,7 @@ terraform import 'module.org_repositories.github_actions_environment_secret.this ```bash # Optional org settings -# Run this from examples/s3-backend-multi-org +# Run this from examples/github_org_settings/multi-org terraform import 'module.platform_org_settings.github_organization_settings.this[0]' '' # Repository @@ -214,7 +214,7 @@ terraform import 'module.platform_org_repositories.github_actions_environment_se ```bash # Optional org settings (only if enabled in config) -# Run this from examples/s3-backend-multi-org +# Run this from examples/github_org_settings/multi-org terraform import 'module.shared_org_settings.github_organization_settings.this[0]' '' # Repository diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..6f24ee8 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,24 @@ +# Examples + +Examples are grouped by module to make ownership and usage intent explicit. + +## Layout + +- `github_org_settings/` + - `single-org/` + - `multi-org/` +- `github_repo_platform/` + - `single-org/` + - `multi-org/` +- `aws_github_oidc_provider/` + - `single-account/` + - `multi-account/` +- `aws_github_actions_oidc_roles/` + - `single-account/` + - `multi-account/` + +Each stack includes: +- `main.tf`, `providers.tf`, `variables.tf`, `versions.tf` +- `terraform.tfvars.example` +- `backend.hcl.example` +- `catalog/` YAML files for catalog-driven onboarding diff --git a/examples/aws_github_actions_oidc_roles/multi-account/README.md b/examples/aws_github_actions_oidc_roles/multi-account/README.md new file mode 100644 index 0000000..20d36f9 --- /dev/null +++ b/examples/aws_github_actions_oidc_roles/multi-account/README.md @@ -0,0 +1,22 @@ +# S3 Backend Multi-Org OIDC Roles Example + +Reference stack for managing repository and environment IAM roles/policies across two AWS accounts. + +## Catalog Location + +- Platform repo IAM catalog: `catalog/orgs/platform/repositories/*.yaml` +- Shared repo IAM catalog: `catalog/orgs/shared/repositories/*.yaml` +- Platform short names: `catalog/orgs/platform/repo-short-names.yaml` +- Shared short names: `catalog/orgs/shared/repo-short-names.yaml` +- Platform OIDC provider ARNs: `catalog/orgs/platform/oidc-provider-arns.yaml` +- Shared OIDC provider ARNs: `catalog/orgs/shared/oidc-provider-arns.yaml` + +## Local Run + +```bash +cp terraform.tfvars.example terraform.tfvars +cp backend.hcl.example backend.hcl +terraform init -backend-config=backend.hcl +terraform validate +terraform plan +``` diff --git a/examples/aws_github_actions_oidc_roles/multi-account/backend.hcl.example b/examples/aws_github_actions_oidc_roles/multi-account/backend.hcl.example new file mode 100644 index 0000000..2c30b57 --- /dev/null +++ b/examples/aws_github_actions_oidc_roles/multi-account/backend.hcl.example @@ -0,0 +1,5 @@ +bucket = "my-platform-tf-state" +key = "github-enterprise/aws-oidc-roles/multi-org/terraform.tfstate" +region = "us-east-1" +dynamodb_table = "my-platform-tf-locks" +encrypt = true diff --git a/examples/aws_github_actions_oidc_roles/multi-account/catalog/orgs/platform/oidc-provider-arns.yaml b/examples/aws_github_actions_oidc_roles/multi-account/catalog/orgs/platform/oidc-provider-arns.yaml new file mode 100644 index 0000000..45326e3 --- /dev/null +++ b/examples/aws_github_actions_oidc_roles/multi-account/catalog/orgs/platform/oidc-provider-arns.yaml @@ -0,0 +1 @@ +https://token.actions.githubusercontent.com: arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com diff --git a/examples/aws_github_actions_oidc_roles/multi-account/catalog/orgs/platform/repo-short-names.yaml b/examples/aws_github_actions_oidc_roles/multi-account/catalog/orgs/platform/repo-short-names.yaml new file mode 100644 index 0000000..e3cbead --- /dev/null +++ b/examples/aws_github_actions_oidc_roles/multi-account/catalog/orgs/platform/repo-short-names.yaml @@ -0,0 +1 @@ +github-com/platform/platform-api: plat-api diff --git a/examples/aws_github_actions_oidc_roles/multi-account/catalog/orgs/platform/repositories/platform-api.yaml b/examples/aws_github_actions_oidc_roles/multi-account/catalog/orgs/platform/repositories/platform-api.yaml new file mode 100644 index 0000000..f6ef143 --- /dev/null +++ b/examples/aws_github_actions_oidc_roles/multi-account/catalog/orgs/platform/repositories/platform-api.yaml @@ -0,0 +1,26 @@ +github_instance_slug: github-com +github_org: platform +oidc_issuer_url: https://token.actions.githubusercontent.com +tags: + managed_by: terraform +environments: + dev: + roles: + deploy: + role_description: deploy + customer_policies: + ecr-deploy: + policy_description: ecr-deploy + document: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - ecr:GetAuthorizationToken + Resource: "*" + prod: + roles: + deploy: + role_description: deploy + managed_policy_arns: + - arn:aws:iam::aws:policy/ReadOnlyAccess diff --git a/examples/aws_github_actions_oidc_roles/multi-account/catalog/orgs/shared/oidc-provider-arns.yaml b/examples/aws_github_actions_oidc_roles/multi-account/catalog/orgs/shared/oidc-provider-arns.yaml new file mode 100644 index 0000000..4cd3df2 --- /dev/null +++ b/examples/aws_github_actions_oidc_roles/multi-account/catalog/orgs/shared/oidc-provider-arns.yaml @@ -0,0 +1 @@ +https://token.actions.githubusercontent.com: arn:aws:iam::444455556666:oidc-provider/token.actions.githubusercontent.com diff --git a/examples/aws_github_actions_oidc_roles/multi-account/catalog/orgs/shared/repo-short-names.yaml b/examples/aws_github_actions_oidc_roles/multi-account/catalog/orgs/shared/repo-short-names.yaml new file mode 100644 index 0000000..fd9cf6a --- /dev/null +++ b/examples/aws_github_actions_oidc_roles/multi-account/catalog/orgs/shared/repo-short-names.yaml @@ -0,0 +1 @@ +github-com/shared/shared-infra: sh-infra diff --git a/examples/aws_github_actions_oidc_roles/multi-account/catalog/orgs/shared/repositories/shared-infra.yaml b/examples/aws_github_actions_oidc_roles/multi-account/catalog/orgs/shared/repositories/shared-infra.yaml new file mode 100644 index 0000000..c82a8de --- /dev/null +++ b/examples/aws_github_actions_oidc_roles/multi-account/catalog/orgs/shared/repositories/shared-infra.yaml @@ -0,0 +1,21 @@ +github_instance_slug: github-com +github_org: shared +oidc_issuer_url: https://token.actions.githubusercontent.com +tags: + managed_by: terraform +environments: + dev: + roles: + deploy: + role_description: deploy + customer_policies: + ssm-read: + policy_description: ssm-read + document: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - ssm:GetParameter + - ssm:GetParameters + Resource: "*" diff --git a/examples/aws_github_actions_oidc_roles/multi-account/main.tf b/examples/aws_github_actions_oidc_roles/multi-account/main.tf new file mode 100644 index 0000000..7474b10 --- /dev/null +++ b/examples/aws_github_actions_oidc_roles/multi-account/main.tf @@ -0,0 +1,98 @@ +locals { + platform_repository_files = fileset("${path.module}/catalog/orgs/platform/repositories", "*.yaml") + shared_repository_files = fileset("${path.module}/catalog/orgs/shared/repositories", "*.yaml") + + platform_repository_specs = { + for file_name in local.platform_repository_files : + trimsuffix(file_name, ".yaml") => yamldecode(file("${path.module}/catalog/orgs/platform/repositories/${file_name}")) + } + + shared_repository_specs = { + for file_name in local.shared_repository_files : + trimsuffix(file_name, ".yaml") => yamldecode(file("${path.module}/catalog/orgs/shared/repositories/${file_name}")) + } + + platform_repository_access = { + for repository_name, repo_cfg in local.platform_repository_specs : repository_name => { + github_instance_slug = trimspace(tostring(repo_cfg.github_instance_slug)) + github_org = trimspace(tostring(repo_cfg.github_org)) + repository = repository_name + oidc_issuer_url = trimspace(tostring(repo_cfg.oidc_issuer_url)) + environments = { + for env_name, env_cfg in try(repo_cfg.environments, {}) : env_name => { + roles = { + for role_key, role_cfg in try(env_cfg.roles, {}) : role_key => { + role_description = trimspace(tostring(role_cfg.role_description)) + managed_policy_arns = [for arn in try(role_cfg.managed_policy_arns, []) : tostring(arn)] + customer_policies = { + for policy_key, policy_cfg in try(role_cfg.customer_policies, {}) : policy_key => { + policy_description = trimspace(tostring(policy_cfg.policy_description)) + document_json = jsonencode(policy_cfg.document) + } + } + tags = { for key, value in try(role_cfg.tags, {}) : tostring(key) => tostring(value) } + } + } + } + } + tags = { for key, value in try(repo_cfg.tags, {}) : tostring(key) => tostring(value) } + } + } + + shared_repository_access = { + for repository_name, repo_cfg in local.shared_repository_specs : repository_name => { + github_instance_slug = trimspace(tostring(repo_cfg.github_instance_slug)) + github_org = trimspace(tostring(repo_cfg.github_org)) + repository = repository_name + oidc_issuer_url = trimspace(tostring(repo_cfg.oidc_issuer_url)) + environments = { + for env_name, env_cfg in try(repo_cfg.environments, {}) : env_name => { + roles = { + for role_key, role_cfg in try(env_cfg.roles, {}) : role_key => { + role_description = trimspace(tostring(role_cfg.role_description)) + managed_policy_arns = [for arn in try(role_cfg.managed_policy_arns, []) : tostring(arn)] + customer_policies = { + for policy_key, policy_cfg in try(role_cfg.customer_policies, {}) : policy_key => { + policy_description = trimspace(tostring(policy_cfg.policy_description)) + document_json = jsonencode(policy_cfg.document) + } + } + tags = { for key, value in try(role_cfg.tags, {}) : tostring(key) => tostring(value) } + } + } + } + } + tags = { for key, value in try(repo_cfg.tags, {}) : tostring(key) => tostring(value) } + } + } + + platform_repo_short_names = yamldecode(file("${path.module}/catalog/orgs/platform/repo-short-names.yaml")) + shared_repo_short_names = yamldecode(file("${path.module}/catalog/orgs/shared/repo-short-names.yaml")) + + platform_oidc_provider_arn_by_url = yamldecode(file("${path.module}/catalog/orgs/platform/oidc-provider-arns.yaml")) + shared_oidc_provider_arn_by_url = yamldecode(file("${path.module}/catalog/orgs/shared/oidc-provider-arns.yaml")) +} + +module "platform_org_oidc_roles" { + source = "../../../modules/aws_github_actions_oidc_roles" + + providers = { + aws = aws.platform + } + + repo_short_names = local.platform_repo_short_names + oidc_provider_arn_by_url = local.platform_oidc_provider_arn_by_url + repository_access = local.platform_repository_access +} + +module "shared_org_oidc_roles" { + source = "../../../modules/aws_github_actions_oidc_roles" + + providers = { + aws = aws.shared + } + + repo_short_names = local.shared_repo_short_names + oidc_provider_arn_by_url = local.shared_oidc_provider_arn_by_url + repository_access = local.shared_repository_access +} diff --git a/examples/aws_github_actions_oidc_roles/multi-account/providers.tf b/examples/aws_github_actions_oidc_roles/multi-account/providers.tf new file mode 100644 index 0000000..d4c9f07 --- /dev/null +++ b/examples/aws_github_actions_oidc_roles/multi-account/providers.tf @@ -0,0 +1,17 @@ +provider "aws" { + alias = "platform" + region = var.aws_region + + assume_role { + role_arn = var.platform_account_role_arn + } +} + +provider "aws" { + alias = "shared" + region = var.aws_region + + assume_role { + role_arn = var.shared_account_role_arn + } +} diff --git a/examples/aws_github_actions_oidc_roles/multi-account/terraform.tfvars.example b/examples/aws_github_actions_oidc_roles/multi-account/terraform.tfvars.example new file mode 100644 index 0000000..0ad0531 --- /dev/null +++ b/examples/aws_github_actions_oidc_roles/multi-account/terraform.tfvars.example @@ -0,0 +1,3 @@ +aws_region = "us-east-1" +platform_account_role_arn = "arn:aws:iam::111122223333:role/terraform-apply" +shared_account_role_arn = "arn:aws:iam::444455556666:role/terraform-apply" diff --git a/examples/aws_github_actions_oidc_roles/multi-account/variables.tf b/examples/aws_github_actions_oidc_roles/multi-account/variables.tf new file mode 100644 index 0000000..25d2d6e --- /dev/null +++ b/examples/aws_github_actions_oidc_roles/multi-account/variables.tf @@ -0,0 +1,14 @@ +variable "aws_region" { + type = string + description = "AWS region for provider operations and state backend." +} + +variable "platform_account_role_arn" { + type = string + description = "Role ARN assumed for platform AWS account operations." +} + +variable "shared_account_role_arn" { + type = string + description = "Role ARN assumed for shared AWS account operations." +} diff --git a/examples/aws_github_actions_oidc_roles/multi-account/versions.tf b/examples/aws_github_actions_oidc_roles/multi-account/versions.tf new file mode 100644 index 0000000..0d19f7f --- /dev/null +++ b/examples/aws_github_actions_oidc_roles/multi-account/versions.tf @@ -0,0 +1,12 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0.0" + } + } + + backend "s3" {} +} diff --git a/examples/aws_github_actions_oidc_roles/single-account/README.md b/examples/aws_github_actions_oidc_roles/single-account/README.md new file mode 100644 index 0000000..0aa7b17 --- /dev/null +++ b/examples/aws_github_actions_oidc_roles/single-account/README.md @@ -0,0 +1,19 @@ +# S3 Backend Single-Org OIDC Roles Example + +Reference stack for managing repository and environment IAM roles/policies for GitHub Actions OIDC. + +## Catalog Location + +- Repository role catalog: `catalog/repositories/*.yaml`. +- Repository short names: `catalog/repo-short-names.yaml`. +- OIDC provider ARNs by URL: `catalog/oidc-provider-arns.yaml`. + +## Local Run + +```bash +cp terraform.tfvars.example terraform.tfvars +cp backend.hcl.example backend.hcl +terraform init -backend-config=backend.hcl +terraform validate +terraform plan +``` diff --git a/examples/aws_github_actions_oidc_roles/single-account/backend.hcl.example b/examples/aws_github_actions_oidc_roles/single-account/backend.hcl.example new file mode 100644 index 0000000..a7b06e8 --- /dev/null +++ b/examples/aws_github_actions_oidc_roles/single-account/backend.hcl.example @@ -0,0 +1,5 @@ +bucket = "my-platform-tf-state" +key = "github-enterprise/aws-oidc-roles/single-org/terraform.tfstate" +region = "us-east-1" +dynamodb_table = "my-platform-tf-locks" +encrypt = true diff --git a/examples/aws_github_actions_oidc_roles/single-account/catalog/oidc-provider-arns.yaml b/examples/aws_github_actions_oidc_roles/single-account/catalog/oidc-provider-arns.yaml new file mode 100644 index 0000000..45326e3 --- /dev/null +++ b/examples/aws_github_actions_oidc_roles/single-account/catalog/oidc-provider-arns.yaml @@ -0,0 +1 @@ +https://token.actions.githubusercontent.com: arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com diff --git a/examples/aws_github_actions_oidc_roles/single-account/catalog/repo-short-names.yaml b/examples/aws_github_actions_oidc_roles/single-account/catalog/repo-short-names.yaml new file mode 100644 index 0000000..e3cbead --- /dev/null +++ b/examples/aws_github_actions_oidc_roles/single-account/catalog/repo-short-names.yaml @@ -0,0 +1 @@ +github-com/platform/platform-api: plat-api diff --git a/examples/aws_github_actions_oidc_roles/single-account/catalog/repositories/platform-api.yaml b/examples/aws_github_actions_oidc_roles/single-account/catalog/repositories/platform-api.yaml new file mode 100644 index 0000000..45a8f51 --- /dev/null +++ b/examples/aws_github_actions_oidc_roles/single-account/catalog/repositories/platform-api.yaml @@ -0,0 +1,42 @@ +github_instance_slug: github-com +github_org: platform +oidc_issuer_url: https://token.actions.githubusercontent.com +tags: + managed_by: terraform +environments: + dev: + roles: + deploy: + role_description: deploy + managed_policy_arns: + - arn:aws:iam::aws:policy/ReadOnlyAccess + customer_policies: + terraform-state: + policy_description: terraform-state + document: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - s3:GetObject + - s3:ListBucket + Resource: + - arn:aws:s3:::my-platform-tf-state + - arn:aws:s3:::my-platform-tf-state/* + prod: + roles: + deploy: + role_description: deploy + customer_policies: + terraform-state: + policy_description: terraform-state + document: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - s3:GetObject + - s3:ListBucket + Resource: + - arn:aws:s3:::my-platform-prod-tf-state + - arn:aws:s3:::my-platform-prod-tf-state/* diff --git a/examples/aws_github_actions_oidc_roles/single-account/main.tf b/examples/aws_github_actions_oidc_roles/single-account/main.tf new file mode 100644 index 0000000..6d46a49 --- /dev/null +++ b/examples/aws_github_actions_oidc_roles/single-account/main.tf @@ -0,0 +1,47 @@ +locals { + repository_files = fileset("${path.module}/catalog/repositories", "*.yaml") + + repository_specs = { + for file_name in local.repository_files : + trimsuffix(file_name, ".yaml") => yamldecode(file("${path.module}/catalog/repositories/${file_name}")) + } + + repository_access = { + for repository_name, repo_cfg in local.repository_specs : repository_name => { + github_instance_slug = trimspace(tostring(repo_cfg.github_instance_slug)) + github_org = trimspace(tostring(repo_cfg.github_org)) + repository = repository_name + oidc_issuer_url = trimspace(tostring(repo_cfg.oidc_issuer_url)) + environments = { + for env_name, env_cfg in try(repo_cfg.environments, {}) : env_name => { + roles = { + for role_key, role_cfg in try(env_cfg.roles, {}) : role_key => { + role_description = trimspace(tostring(role_cfg.role_description)) + managed_policy_arns = [for arn in try(role_cfg.managed_policy_arns, []) : tostring(arn)] + customer_policies = { + for policy_key, policy_cfg in try(role_cfg.customer_policies, {}) : policy_key => { + policy_description = trimspace(tostring(policy_cfg.policy_description)) + document_json = jsonencode(policy_cfg.document) + } + } + tags = { for key, value in try(role_cfg.tags, {}) : tostring(key) => tostring(value) } + } + } + } + } + tags = { for key, value in try(repo_cfg.tags, {}) : tostring(key) => tostring(value) } + } + } + + repo_short_names = yamldecode(file("${path.module}/catalog/repo-short-names.yaml")) + + oidc_provider_arn_by_url = yamldecode(file("${path.module}/catalog/oidc-provider-arns.yaml")) +} + +module "org_oidc_roles" { + source = "../../../modules/aws_github_actions_oidc_roles" + + repo_short_names = local.repo_short_names + oidc_provider_arn_by_url = local.oidc_provider_arn_by_url + repository_access = local.repository_access +} diff --git a/examples/aws_github_actions_oidc_roles/single-account/providers.tf b/examples/aws_github_actions_oidc_roles/single-account/providers.tf new file mode 100644 index 0000000..c9d7ccb --- /dev/null +++ b/examples/aws_github_actions_oidc_roles/single-account/providers.tf @@ -0,0 +1,3 @@ +provider "aws" { + region = var.aws_region +} diff --git a/examples/aws_github_actions_oidc_roles/single-account/terraform.tfvars.example b/examples/aws_github_actions_oidc_roles/single-account/terraform.tfvars.example new file mode 100644 index 0000000..bb891da --- /dev/null +++ b/examples/aws_github_actions_oidc_roles/single-account/terraform.tfvars.example @@ -0,0 +1 @@ +aws_region = "us-east-1" diff --git a/examples/aws_github_actions_oidc_roles/single-account/variables.tf b/examples/aws_github_actions_oidc_roles/single-account/variables.tf new file mode 100644 index 0000000..263291d --- /dev/null +++ b/examples/aws_github_actions_oidc_roles/single-account/variables.tf @@ -0,0 +1,4 @@ +variable "aws_region" { + type = string + description = "AWS region for provider operations and state backend." +} diff --git a/examples/aws_github_actions_oidc_roles/single-account/versions.tf b/examples/aws_github_actions_oidc_roles/single-account/versions.tf new file mode 100644 index 0000000..0d19f7f --- /dev/null +++ b/examples/aws_github_actions_oidc_roles/single-account/versions.tf @@ -0,0 +1,12 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0.0" + } + } + + backend "s3" {} +} diff --git a/examples/aws_github_oidc_provider/multi-account/README.md b/examples/aws_github_oidc_provider/multi-account/README.md new file mode 100644 index 0000000..ab0d2a8 --- /dev/null +++ b/examples/aws_github_oidc_provider/multi-account/README.md @@ -0,0 +1,18 @@ +# S3 Backend Multi-Org OIDC Provider Example + +Reference stack for managing AWS IAM OIDC providers across two AWS accounts using provider aliases. + +## Catalog Location + +- Platform org OIDC connections: `catalog/orgs/platform/oidc-connections/*.yaml`. +- Shared org OIDC connections: `catalog/orgs/shared/oidc-connections/*.yaml`. + +## Local Run + +```bash +cp terraform.tfvars.example terraform.tfvars +cp backend.hcl.example backend.hcl +terraform init -backend-config=backend.hcl +terraform validate +terraform plan +``` diff --git a/examples/aws_github_oidc_provider/multi-account/backend.hcl.example b/examples/aws_github_oidc_provider/multi-account/backend.hcl.example new file mode 100644 index 0000000..9c227af --- /dev/null +++ b/examples/aws_github_oidc_provider/multi-account/backend.hcl.example @@ -0,0 +1,5 @@ +bucket = "my-platform-tf-state" +key = "github-enterprise/aws-oidc-provider/multi-org/terraform.tfstate" +region = "us-east-1" +dynamodb_table = "my-platform-tf-locks" +encrypt = true diff --git a/examples/aws_github_oidc_provider/multi-account/catalog/orgs/platform/oidc-connections/github-com-platform.yaml b/examples/aws_github_oidc_provider/multi-account/catalog/orgs/platform/oidc-connections/github-com-platform.yaml new file mode 100644 index 0000000..db1338c --- /dev/null +++ b/examples/aws_github_oidc_provider/multi-account/catalog/orgs/platform/oidc-connections/github-com-platform.yaml @@ -0,0 +1,8 @@ +github_instance_slug: github-com +github_org: platform +issuer_url: https://token.actions.githubusercontent.com +audiences: + - sts.amazonaws.com +tags: + managed_by: terraform + org: platform diff --git a/examples/aws_github_oidc_provider/multi-account/catalog/orgs/shared/oidc-connections/github-com-shared.yaml b/examples/aws_github_oidc_provider/multi-account/catalog/orgs/shared/oidc-connections/github-com-shared.yaml new file mode 100644 index 0000000..eabde0a --- /dev/null +++ b/examples/aws_github_oidc_provider/multi-account/catalog/orgs/shared/oidc-connections/github-com-shared.yaml @@ -0,0 +1,8 @@ +github_instance_slug: github-com +github_org: shared +issuer_url: https://token.actions.githubusercontent.com +audiences: + - sts.amazonaws.com +tags: + managed_by: terraform + org: shared diff --git a/examples/aws_github_oidc_provider/multi-account/main.tf b/examples/aws_github_oidc_provider/multi-account/main.tf new file mode 100644 index 0000000..5edca8b --- /dev/null +++ b/examples/aws_github_oidc_provider/multi-account/main.tf @@ -0,0 +1,34 @@ +locals { + platform_oidc_connection_files = fileset("${path.module}/catalog/orgs/platform/oidc-connections", "*.yaml") + shared_oidc_connection_files = fileset("${path.module}/catalog/orgs/shared/oidc-connections", "*.yaml") + + platform_oidc_connections = { + for file_name in local.platform_oidc_connection_files : + trimsuffix(file_name, ".yaml") => yamldecode(file("${path.module}/catalog/orgs/platform/oidc-connections/${file_name}")) + } + + shared_oidc_connections = { + for file_name in local.shared_oidc_connection_files : + trimsuffix(file_name, ".yaml") => yamldecode(file("${path.module}/catalog/orgs/shared/oidc-connections/${file_name}")) + } +} + +module "platform_org_oidc_provider" { + source = "../../../modules/aws_github_oidc_provider" + + providers = { + aws = aws.platform + } + + oidc_connections = local.platform_oidc_connections +} + +module "shared_org_oidc_provider" { + source = "../../../modules/aws_github_oidc_provider" + + providers = { + aws = aws.shared + } + + oidc_connections = local.shared_oidc_connections +} diff --git a/examples/aws_github_oidc_provider/multi-account/providers.tf b/examples/aws_github_oidc_provider/multi-account/providers.tf new file mode 100644 index 0000000..d4c9f07 --- /dev/null +++ b/examples/aws_github_oidc_provider/multi-account/providers.tf @@ -0,0 +1,17 @@ +provider "aws" { + alias = "platform" + region = var.aws_region + + assume_role { + role_arn = var.platform_account_role_arn + } +} + +provider "aws" { + alias = "shared" + region = var.aws_region + + assume_role { + role_arn = var.shared_account_role_arn + } +} diff --git a/examples/aws_github_oidc_provider/multi-account/terraform.tfvars.example b/examples/aws_github_oidc_provider/multi-account/terraform.tfvars.example new file mode 100644 index 0000000..0ad0531 --- /dev/null +++ b/examples/aws_github_oidc_provider/multi-account/terraform.tfvars.example @@ -0,0 +1,3 @@ +aws_region = "us-east-1" +platform_account_role_arn = "arn:aws:iam::111122223333:role/terraform-apply" +shared_account_role_arn = "arn:aws:iam::444455556666:role/terraform-apply" diff --git a/examples/aws_github_oidc_provider/multi-account/variables.tf b/examples/aws_github_oidc_provider/multi-account/variables.tf new file mode 100644 index 0000000..25d2d6e --- /dev/null +++ b/examples/aws_github_oidc_provider/multi-account/variables.tf @@ -0,0 +1,14 @@ +variable "aws_region" { + type = string + description = "AWS region for provider operations and state backend." +} + +variable "platform_account_role_arn" { + type = string + description = "Role ARN assumed for platform AWS account operations." +} + +variable "shared_account_role_arn" { + type = string + description = "Role ARN assumed for shared AWS account operations." +} diff --git a/examples/aws_github_oidc_provider/multi-account/versions.tf b/examples/aws_github_oidc_provider/multi-account/versions.tf new file mode 100644 index 0000000..0d19f7f --- /dev/null +++ b/examples/aws_github_oidc_provider/multi-account/versions.tf @@ -0,0 +1,12 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0.0" + } + } + + backend "s3" {} +} diff --git a/examples/aws_github_oidc_provider/single-account/README.md b/examples/aws_github_oidc_provider/single-account/README.md new file mode 100644 index 0000000..5549d06 --- /dev/null +++ b/examples/aws_github_oidc_provider/single-account/README.md @@ -0,0 +1,17 @@ +# S3 Backend Single-Org OIDC Provider Example + +Reference stack for managing AWS IAM OIDC providers used by GitHub Actions. + +## Catalog Location + +- OIDC connections: `catalog/oidc-connections/*.yaml`. + +## Local Run + +```bash +cp terraform.tfvars.example terraform.tfvars +cp backend.hcl.example backend.hcl +terraform init -backend-config=backend.hcl +terraform validate +terraform plan +``` diff --git a/examples/aws_github_oidc_provider/single-account/backend.hcl.example b/examples/aws_github_oidc_provider/single-account/backend.hcl.example new file mode 100644 index 0000000..2b8a54d --- /dev/null +++ b/examples/aws_github_oidc_provider/single-account/backend.hcl.example @@ -0,0 +1,5 @@ +bucket = "my-platform-tf-state" +key = "github-enterprise/aws-oidc-provider/single-org/terraform.tfstate" +region = "us-east-1" +dynamodb_table = "my-platform-tf-locks" +encrypt = true diff --git a/examples/aws_github_oidc_provider/single-account/catalog/oidc-connections/github-com-platform.yaml b/examples/aws_github_oidc_provider/single-account/catalog/oidc-connections/github-com-platform.yaml new file mode 100644 index 0000000..c2f545c --- /dev/null +++ b/examples/aws_github_oidc_provider/single-account/catalog/oidc-connections/github-com-platform.yaml @@ -0,0 +1,8 @@ +github_instance_slug: github-com +github_org: platform +issuer_url: https://token.actions.githubusercontent.com +audiences: + - sts.amazonaws.com +tags: + managed_by: terraform + workload: github-actions diff --git a/examples/aws_github_oidc_provider/single-account/main.tf b/examples/aws_github_oidc_provider/single-account/main.tf new file mode 100644 index 0000000..87a0d91 --- /dev/null +++ b/examples/aws_github_oidc_provider/single-account/main.tf @@ -0,0 +1,14 @@ +locals { + oidc_connection_files = fileset("${path.module}/catalog/oidc-connections", "*.yaml") + + oidc_connections = { + for file_name in local.oidc_connection_files : + trimsuffix(file_name, ".yaml") => yamldecode(file("${path.module}/catalog/oidc-connections/${file_name}")) + } +} + +module "org_oidc_provider" { + source = "../../../modules/aws_github_oidc_provider" + + oidc_connections = local.oidc_connections +} diff --git a/examples/aws_github_oidc_provider/single-account/providers.tf b/examples/aws_github_oidc_provider/single-account/providers.tf new file mode 100644 index 0000000..c9d7ccb --- /dev/null +++ b/examples/aws_github_oidc_provider/single-account/providers.tf @@ -0,0 +1,3 @@ +provider "aws" { + region = var.aws_region +} diff --git a/examples/aws_github_oidc_provider/single-account/terraform.tfvars.example b/examples/aws_github_oidc_provider/single-account/terraform.tfvars.example new file mode 100644 index 0000000..bb891da --- /dev/null +++ b/examples/aws_github_oidc_provider/single-account/terraform.tfvars.example @@ -0,0 +1 @@ +aws_region = "us-east-1" diff --git a/examples/aws_github_oidc_provider/single-account/variables.tf b/examples/aws_github_oidc_provider/single-account/variables.tf new file mode 100644 index 0000000..263291d --- /dev/null +++ b/examples/aws_github_oidc_provider/single-account/variables.tf @@ -0,0 +1,4 @@ +variable "aws_region" { + type = string + description = "AWS region for provider operations and state backend." +} diff --git a/examples/aws_github_oidc_provider/single-account/versions.tf b/examples/aws_github_oidc_provider/single-account/versions.tf new file mode 100644 index 0000000..0d19f7f --- /dev/null +++ b/examples/aws_github_oidc_provider/single-account/versions.tf @@ -0,0 +1,12 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0.0" + } + } + + backend "s3" {} +} diff --git a/examples/s3-backend-multi-org/README.md b/examples/github_org_settings/multi-org/README.md similarity index 85% rename from examples/s3-backend-multi-org/README.md rename to examples/github_org_settings/multi-org/README.md index 7448120..5422c67 100644 --- a/examples/s3-backend-multi-org/README.md +++ b/examples/github_org_settings/multi-org/README.md @@ -27,5 +27,5 @@ terraform plan ## Related Docs -- Root usage guide: `../../README.md` -- Import existing resources: `../../docs/playbooks/import-existing-github-resources.md` +- Root usage guide: `../../../README.md` +- Import existing resources: `../../../docs/playbooks/import-existing-github-resources.md` diff --git a/examples/s3-backend-multi-org/backend.hcl.example b/examples/github_org_settings/multi-org/backend.hcl.example similarity index 100% rename from examples/s3-backend-multi-org/backend.hcl.example rename to examples/github_org_settings/multi-org/backend.hcl.example diff --git a/examples/s3-backend-multi-org/main.tf b/examples/github_org_settings/multi-org/main.tf similarity index 87% rename from examples/s3-backend-multi-org/main.tf rename to examples/github_org_settings/multi-org/main.tf index 6e75ef3..19ec3b4 100644 --- a/examples/s3-backend-multi-org/main.tf +++ b/examples/github_org_settings/multi-org/main.tf @@ -1,5 +1,5 @@ module "platform_org_settings" { - source = "../../modules/github_org_settings" + source = "../../../modules/github_org_settings" providers = { github = github.platform @@ -16,7 +16,7 @@ module "platform_org_settings" { } module "shared_org_settings" { - source = "../../modules/github_org_settings" + source = "../../../modules/github_org_settings" providers = { github = github.shared diff --git a/examples/s3-backend-multi-org-repos/providers.tf b/examples/github_org_settings/multi-org/providers.tf similarity index 100% rename from examples/s3-backend-multi-org-repos/providers.tf rename to examples/github_org_settings/multi-org/providers.tf diff --git a/examples/s3-backend-multi-org-repos/terraform.tfvars.example b/examples/github_org_settings/multi-org/terraform.tfvars.example similarity index 100% rename from examples/s3-backend-multi-org-repos/terraform.tfvars.example rename to examples/github_org_settings/multi-org/terraform.tfvars.example diff --git a/examples/s3-backend-multi-org-repos/variables.tf b/examples/github_org_settings/multi-org/variables.tf similarity index 100% rename from examples/s3-backend-multi-org-repos/variables.tf rename to examples/github_org_settings/multi-org/variables.tf diff --git a/examples/s3-backend-multi-org-repos/versions.tf b/examples/github_org_settings/multi-org/versions.tf similarity index 100% rename from examples/s3-backend-multi-org-repos/versions.tf rename to examples/github_org_settings/multi-org/versions.tf diff --git a/examples/s3-backend-single-org/README.md b/examples/github_org_settings/single-org/README.md similarity index 82% rename from examples/s3-backend-single-org/README.md rename to examples/github_org_settings/single-org/README.md index 3d576af..c8cd562 100644 --- a/examples/s3-backend-single-org/README.md +++ b/examples/github_org_settings/single-org/README.md @@ -24,5 +24,5 @@ terraform plan ## Related Docs -- Root usage guide: `../../README.md` -- Import existing resources: `../../docs/playbooks/import-existing-github-resources.md` +- Root usage guide: `../../../README.md` +- Import existing resources: `../../../docs/playbooks/import-existing-github-resources.md` diff --git a/examples/s3-backend-single-org/backend.hcl.example b/examples/github_org_settings/single-org/backend.hcl.example similarity index 100% rename from examples/s3-backend-single-org/backend.hcl.example rename to examples/github_org_settings/single-org/backend.hcl.example diff --git a/examples/s3-backend-single-org/main.tf b/examples/github_org_settings/single-org/main.tf similarity index 84% rename from examples/s3-backend-single-org/main.tf rename to examples/github_org_settings/single-org/main.tf index 633ee4a..8dc970a 100644 --- a/examples/s3-backend-single-org/main.tf +++ b/examples/github_org_settings/single-org/main.tf @@ -1,5 +1,5 @@ module "org_settings" { - source = "../../modules/github_org_settings" + source = "../../../modules/github_org_settings" manage_organization_settings = true diff --git a/examples/s3-backend-single-org-repos/providers.tf b/examples/github_org_settings/single-org/providers.tf similarity index 100% rename from examples/s3-backend-single-org-repos/providers.tf rename to examples/github_org_settings/single-org/providers.tf diff --git a/examples/s3-backend-single-org-repos/terraform.tfvars.example b/examples/github_org_settings/single-org/terraform.tfvars.example similarity index 100% rename from examples/s3-backend-single-org-repos/terraform.tfvars.example rename to examples/github_org_settings/single-org/terraform.tfvars.example diff --git a/examples/s3-backend-single-org-repos/variables.tf b/examples/github_org_settings/single-org/variables.tf similarity index 100% rename from examples/s3-backend-single-org-repos/variables.tf rename to examples/github_org_settings/single-org/variables.tf diff --git a/examples/s3-backend-multi-org/versions.tf b/examples/github_org_settings/single-org/versions.tf similarity index 100% rename from examples/s3-backend-multi-org/versions.tf rename to examples/github_org_settings/single-org/versions.tf diff --git a/examples/s3-backend-multi-org-repos/README.md b/examples/github_repo_platform/multi-org/README.md similarity index 88% rename from examples/s3-backend-multi-org-repos/README.md rename to examples/github_repo_platform/multi-org/README.md index 8414278..56e424d 100644 --- a/examples/s3-backend-multi-org-repos/README.md +++ b/examples/github_repo_platform/multi-org/README.md @@ -32,5 +32,5 @@ terraform plan ## Related Docs -- Root usage guide: `../../README.md` -- Import existing resources: `../../docs/playbooks/import-existing-github-resources.md` +- Root usage guide: `../../../README.md` +- Import existing resources: `../../../docs/playbooks/import-existing-github-resources.md` diff --git a/examples/s3-backend-multi-org-repos/backend.hcl.example b/examples/github_repo_platform/multi-org/backend.hcl.example similarity index 100% rename from examples/s3-backend-multi-org-repos/backend.hcl.example rename to examples/github_repo_platform/multi-org/backend.hcl.example diff --git a/examples/s3-backend-multi-org-repos/catalog/orgs/platform/repositories/platform-api.yaml b/examples/github_repo_platform/multi-org/catalog/orgs/platform/repositories/platform-api.yaml similarity index 100% rename from examples/s3-backend-multi-org-repos/catalog/orgs/platform/repositories/platform-api.yaml rename to examples/github_repo_platform/multi-org/catalog/orgs/platform/repositories/platform-api.yaml diff --git a/examples/s3-backend-multi-org-repos/catalog/orgs/shared/repositories/shared-infra.yaml b/examples/github_repo_platform/multi-org/catalog/orgs/shared/repositories/shared-infra.yaml similarity index 100% rename from examples/s3-backend-multi-org-repos/catalog/orgs/shared/repositories/shared-infra.yaml rename to examples/github_repo_platform/multi-org/catalog/orgs/shared/repositories/shared-infra.yaml diff --git a/examples/s3-backend-multi-org-repos/main.tf b/examples/github_repo_platform/multi-org/main.tf similarity index 89% rename from examples/s3-backend-multi-org-repos/main.tf rename to examples/github_repo_platform/multi-org/main.tf index 5744aef..885ddae 100644 --- a/examples/s3-backend-multi-org-repos/main.tf +++ b/examples/github_repo_platform/multi-org/main.tf @@ -14,7 +14,7 @@ locals { } module "platform_org_repositories" { - source = "../../modules/github_repo_platform" + source = "../../../modules/github_repo_platform" providers = { github = github.platform @@ -24,7 +24,7 @@ module "platform_org_repositories" { } module "shared_org_repositories" { - source = "../../modules/github_repo_platform" + source = "../../../modules/github_repo_platform" providers = { github = github.shared diff --git a/examples/s3-backend-multi-org/providers.tf b/examples/github_repo_platform/multi-org/providers.tf similarity index 100% rename from examples/s3-backend-multi-org/providers.tf rename to examples/github_repo_platform/multi-org/providers.tf diff --git a/examples/s3-backend-multi-org/terraform.tfvars.example b/examples/github_repo_platform/multi-org/terraform.tfvars.example similarity index 100% rename from examples/s3-backend-multi-org/terraform.tfvars.example rename to examples/github_repo_platform/multi-org/terraform.tfvars.example diff --git a/examples/s3-backend-multi-org/variables.tf b/examples/github_repo_platform/multi-org/variables.tf similarity index 100% rename from examples/s3-backend-multi-org/variables.tf rename to examples/github_repo_platform/multi-org/variables.tf diff --git a/examples/s3-backend-single-org-repos/versions.tf b/examples/github_repo_platform/multi-org/versions.tf similarity index 100% rename from examples/s3-backend-single-org-repos/versions.tf rename to examples/github_repo_platform/multi-org/versions.tf diff --git a/examples/s3-backend-single-org-repos/README.md b/examples/github_repo_platform/single-org/README.md similarity index 86% rename from examples/s3-backend-single-org-repos/README.md rename to examples/github_repo_platform/single-org/README.md index 6d1d24d..1d112ec 100644 --- a/examples/s3-backend-single-org-repos/README.md +++ b/examples/github_repo_platform/single-org/README.md @@ -30,5 +30,5 @@ terraform plan ## Related Docs -- Root usage guide: `../../README.md` -- Import existing resources: `../../docs/playbooks/import-existing-github-resources.md` +- Root usage guide: `../../../README.md` +- Import existing resources: `../../../docs/playbooks/import-existing-github-resources.md` diff --git a/examples/s3-backend-single-org-repos/backend.hcl.example b/examples/github_repo_platform/single-org/backend.hcl.example similarity index 100% rename from examples/s3-backend-single-org-repos/backend.hcl.example rename to examples/github_repo_platform/single-org/backend.hcl.example diff --git a/examples/s3-backend-single-org-repos/catalog/repositories/platform-api.yaml b/examples/github_repo_platform/single-org/catalog/repositories/platform-api.yaml similarity index 100% rename from examples/s3-backend-single-org-repos/catalog/repositories/platform-api.yaml rename to examples/github_repo_platform/single-org/catalog/repositories/platform-api.yaml diff --git a/examples/s3-backend-single-org-repos/main.tf b/examples/github_repo_platform/single-org/main.tf similarity index 86% rename from examples/s3-backend-single-org-repos/main.tf rename to examples/github_repo_platform/single-org/main.tf index ce66bbd..d45cc52 100644 --- a/examples/s3-backend-single-org-repos/main.tf +++ b/examples/github_repo_platform/single-org/main.tf @@ -8,7 +8,7 @@ locals { } module "org_repositories" { - source = "../../modules/github_repo_platform" + source = "../../../modules/github_repo_platform" repositories = local.repositories } diff --git a/examples/s3-backend-single-org/providers.tf b/examples/github_repo_platform/single-org/providers.tf similarity index 100% rename from examples/s3-backend-single-org/providers.tf rename to examples/github_repo_platform/single-org/providers.tf diff --git a/examples/s3-backend-single-org/terraform.tfvars.example b/examples/github_repo_platform/single-org/terraform.tfvars.example similarity index 100% rename from examples/s3-backend-single-org/terraform.tfvars.example rename to examples/github_repo_platform/single-org/terraform.tfvars.example diff --git a/examples/s3-backend-single-org/variables.tf b/examples/github_repo_platform/single-org/variables.tf similarity index 100% rename from examples/s3-backend-single-org/variables.tf rename to examples/github_repo_platform/single-org/variables.tf diff --git a/examples/s3-backend-single-org/versions.tf b/examples/github_repo_platform/single-org/versions.tf similarity index 100% rename from examples/s3-backend-single-org/versions.tf rename to examples/github_repo_platform/single-org/versions.tf diff --git a/modules/aws_github_actions_oidc_roles/README.md b/modules/aws_github_actions_oidc_roles/README.md new file mode 100644 index 0000000..d74605e --- /dev/null +++ b/modules/aws_github_actions_oidc_roles/README.md @@ -0,0 +1,80 @@ +# aws_github_actions_oidc_roles + +Terraform module for provisioning AWS IAM roles and policies for GitHub Actions OIDC authentication. + +Resources managed: +- `aws_iam_role` +- `aws_iam_policy` +- `aws_iam_role_policy_attachment` + +## Inputs + +- `repo_short_names` (map): short names keyed by `instance/org/repo`. +- `oidc_provider_arn_by_url` (map): OIDC provider ARNs keyed by normalized issuer URL. +- `oidc_audience` (string): expected audience, defaults to `sts.amazonaws.com`. +- `repository_access` (map): repository/environment/role definitions. + - Customer-managed policies must provide `document_json` (JSON string). + +Role model: +- Multiple roles are supported per repository environment. +- Trust policy is restricted to: + - `aud == sts.amazonaws.com` (or configured value) + - `sub == repo:/:environment:` + +Naming conventions: +- IAM role: `gh-actns---` +- IAM policy: `gh-actns---` + +Length handling: +- Roles: max 64 chars. +- Policies: max 128 chars. +- Overflow uses `truncate + -<8char_sha1>` deterministic suffix. + +## Example + +```hcl +module "platform_repo_roles" { + source = "../../modules/aws_github_actions_oidc_roles" + + repo_short_names = { + "github-com/platform/platform-api" = "plat-api" + } + + oidc_provider_arn_by_url = { + "https://token.actions.githubusercontent.com" = "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com" + } + + repository_access = { + "platform-api" = { + github_instance_slug = "github-com" + github_org = "platform" + repository = "platform-api" + oidc_issuer_url = "https://token.actions.githubusercontent.com" + environments = { + dev = { + roles = { + deploy = { + role_description = "deploy" + customer_policies = { + terraform-state = { + policy_description = "terraform-state" + document_json = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = ["s3:GetObject"] + Resource = ["arn:aws:s3:::example-bucket/*"] + } + ] + }) + } + } + } + } + } + } + } + } +} +``` diff --git a/modules/aws_github_actions_oidc_roles/main.tf b/modules/aws_github_actions_oidc_roles/main.tf new file mode 100644 index 0000000..5097114 --- /dev/null +++ b/modules/aws_github_actions_oidc_roles/main.tf @@ -0,0 +1,239 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0.0" + } + } +} + +locals { + role_name_max_length = 64 + policy_name_max_length = 128 + + repositories = { + for catalog_key, repo in var.repository_access : catalog_key => { + github_instance_slug = trimspace(repo.github_instance_slug) + github_org = trimspace(repo.github_org) + repository = trimspace(repo.repository) + oidc_issuer_url = trimsuffix(trimspace(repo.oidc_issuer_url), "/") + environments = repo.environments + tags = repo.tags + short_name_key = format("%s/%s/%s", trimspace(repo.github_instance_slug), trimspace(repo.github_org), trimspace(repo.repository)) + } + } + + missing_repo_short_name_keys = sort(tolist(setsubtract( + toset([for repo in values(local.repositories) : repo.short_name_key]), + toset(keys(var.repo_short_names)) + ))) + + missing_oidc_issuer_urls = sort(tolist(setsubtract( + toset([for repo in values(local.repositories) : repo.oidc_issuer_url]), + toset(keys(var.oidc_provider_arn_by_url)) + ))) + + roles = { + for entry in flatten([ + for repo in values(local.repositories) : [ + for env_name, env_cfg in repo.environments : [ + for role_key, role_cfg in env_cfg.roles : { + key = format("%s/%s/%s/%s/%s", repo.github_instance_slug, repo.github_org, repo.repository, env_name, role_key) + github_instance_slug = repo.github_instance_slug + github_org = repo.github_org + repository = repo.repository + oidc_issuer_url = repo.oidc_issuer_url + oidc_provider_arn = lookup(var.oidc_provider_arn_by_url, repo.oidc_issuer_url, "") + environment = env_name + role_catalog_key = role_key + role_description = role_cfg.role_description + managed_policy_arns = sort(distinct(role_cfg.managed_policy_arns)) + customer_policies = role_cfg.customer_policies + role_tags = merge(repo.tags, role_cfg.tags) + repo_short_name_raw = lookup(var.repo_short_names, repo.short_name_key, "") + repo_short_name = trim( + regexreplace(lower(lookup(var.repo_short_names, repo.short_name_key, "")), "[^a-z0-9-]", "-"), + "-" + ) + environment_slug = trim(regexreplace(lower(env_name), "[^a-z0-9-]", "-"), "-") + role_description_slug = trim(regexreplace(lower(role_cfg.role_description), "[^a-z0-9-]", "-"), "-") + } + ] + ] + ]) : entry.key => merge(entry, { + role_name_base = format( + "gh-actns-%s-%s-%s", + entry.repo_short_name, + entry.environment_slug, + entry.role_description_slug + ) + issuer_hostpath = replace(entry.oidc_issuer_url, "https://", "") + github_subject = format("repo:%s/%s:environment:%s", entry.github_org, entry.repository, entry.environment) + }) + } + + roles_with_names = { + for key, role in local.roles : key => merge(role, { + role_name = ( + length(role.role_name_base) <= local.role_name_max_length ? + role.role_name_base : + format( + "%s-%s", + substr(role.role_name_base, 0, local.role_name_max_length - 9), + substr(sha1(role.role_name_base), 0, 8) + ) + ) + }) + } + + customer_policies = { + for entry in flatten([ + for role_key, role in local.roles_with_names : [ + for policy_key, policy_cfg in role.customer_policies : { + key = format("%s/%s", role_key, policy_key) + role_key = role_key + role_name = role.role_name + policy_catalog_key = policy_key + policy_description = policy_cfg.policy_description + policy_document = jsondecode(policy_cfg.document_json) + repo_short_name = role.repo_short_name + environment_slug = role.environment_slug + policy_description_slug = trim(regexreplace(lower(policy_cfg.policy_description), "[^a-z0-9-]", "-"), "-") + tags = role.role_tags + } + ] + ]) : entry.key => merge(entry, { + policy_name_base = format( + "gh-actns-%s-%s-%s", + entry.repo_short_name, + entry.environment_slug, + entry.policy_description_slug + ) + }) + } + + customer_policies_with_names = { + for key, policy in local.customer_policies : key => merge(policy, { + policy_name = ( + length(policy.policy_name_base) <= local.policy_name_max_length ? + policy.policy_name_base : + format( + "%s-%s", + substr(policy.policy_name_base, 0, local.policy_name_max_length - 9), + substr(sha1(policy.policy_name_base), 0, 8) + ) + ) + }) + } + + managed_policy_attachments = { + for entry in flatten([ + for role_key, role in local.roles_with_names : [ + for policy_arn in role.managed_policy_arns : { + key = format("%s/%s", role_key, substr(sha1(policy_arn), 0, 12)) + role_key = role_key + policy_arn = policy_arn + } + ] + ]) : entry.key => entry + } + + invalid_slug_entries = compact(flatten([ + for key, role in local.roles_with_names : [ + length(role.repo_short_name) == 0 ? format("%s (repo short name)", key) : "", + length(role.environment_slug) == 0 ? format("%s (environment)", key) : "", + length(role.role_description_slug) == 0 ? format("%s (role description)", key) : "" + ] + ])) + + invalid_policy_slug_entries = compact([ + for key, policy in local.customer_policies_with_names : + length(policy.policy_description_slug) == 0 ? format("%s (policy description)", key) : "" + ]) +} + +check "repo_short_names_present" { + assert { + condition = length(local.missing_repo_short_name_keys) == 0 + error_message = format("Missing repo_short_names entries for keys: %s", join(", ", local.missing_repo_short_name_keys)) + } +} + +check "oidc_provider_mappings_present" { + assert { + condition = length(local.missing_oidc_issuer_urls) == 0 + error_message = format("Missing oidc_provider_arn_by_url entries for issuer URLs: %s", join(", ", local.missing_oidc_issuer_urls)) + } +} + +check "role_name_segments_valid" { + assert { + condition = length(local.invalid_slug_entries) == 0 + error_message = format("Invalid empty role name segments after normalization: %s", join(", ", local.invalid_slug_entries)) + } +} + +check "policy_name_segments_valid" { + assert { + condition = length(local.invalid_policy_slug_entries) == 0 + error_message = format("Invalid empty policy name segments after normalization: %s", join(", ", local.invalid_policy_slug_entries)) + } +} + +data "aws_iam_policy_document" "assume_role" { + for_each = local.roles_with_names + + statement { + effect = "Allow" + actions = ["sts:AssumeRoleWithWebIdentity"] + + principals { + type = "Federated" + identifiers = [each.value.oidc_provider_arn] + } + + condition { + test = "StringEquals" + variable = format("%s:aud", each.value.issuer_hostpath) + values = [var.oidc_audience] + } + + condition { + test = "StringEquals" + variable = format("%s:sub", each.value.issuer_hostpath) + values = [each.value.github_subject] + } + } +} + +resource "aws_iam_role" "this" { + for_each = local.roles_with_names + + name = each.value.role_name + assume_role_policy = data.aws_iam_policy_document.assume_role[each.key].json + tags = each.value.role_tags +} + +resource "aws_iam_policy" "this" { + for_each = local.customer_policies_with_names + + name = each.value.policy_name + policy = jsonencode(each.value.policy_document) + tags = each.value.tags +} + +resource "aws_iam_role_policy_attachment" "customer_managed" { + for_each = local.customer_policies_with_names + + role = aws_iam_role.this[each.value.role_key].name + policy_arn = aws_iam_policy.this[each.key].arn +} + +resource "aws_iam_role_policy_attachment" "aws_managed" { + for_each = local.managed_policy_attachments + + role = aws_iam_role.this[each.value.role_key].name + policy_arn = each.value.policy_arn +} diff --git a/modules/aws_github_actions_oidc_roles/outputs.tf b/modules/aws_github_actions_oidc_roles/outputs.tf new file mode 100644 index 0000000..75af168 --- /dev/null +++ b/modules/aws_github_actions_oidc_roles/outputs.tf @@ -0,0 +1,27 @@ +output "role_arns" { + description = "Role ARNs keyed by instance/org/repo/env/role catalog key." + value = { + for key, role in aws_iam_role.this : key => role.arn + } +} + +output "policy_arns" { + description = "Customer-managed policy ARNs keyed by instance/org/repo/env/role/policy catalog key." + value = { + for key, policy in aws_iam_policy.this : key => policy.arn + } +} + +output "role_names" { + description = "Role names keyed by instance/org/repo/env/role catalog key." + value = { + for key, role in aws_iam_role.this : key => role.name + } +} + +output "policy_names" { + description = "Customer-managed policy names keyed by instance/org/repo/env/role/policy catalog key." + value = { + for key, policy in aws_iam_policy.this : key => policy.name + } +} diff --git a/modules/aws_github_actions_oidc_roles/variables.tf b/modules/aws_github_actions_oidc_roles/variables.tf new file mode 100644 index 0000000..b92fad8 --- /dev/null +++ b/modules/aws_github_actions_oidc_roles/variables.tf @@ -0,0 +1,50 @@ +variable "repo_short_names" { + type = map(string) + description = "Repository short-name map keyed by instance/org/repo." +} + +variable "oidc_provider_arn_by_url" { + type = map(string) + description = "OIDC provider ARN map keyed by normalized issuer URL." +} + +variable "oidc_audience" { + type = string + description = "OIDC audience value expected in JWT tokens." + default = "sts.amazonaws.com" +} + +variable "repository_access" { + type = map(object({ + github_instance_slug = string + github_org = string + repository = string + oidc_issuer_url = string + environments = map(object({ + roles = map(object({ + role_description = string + managed_policy_arns = optional(list(string), []) + customer_policies = optional(map(object({ + policy_description = string + document_json = string + })), {}) + tags = optional(map(string), {}) + })) + })) + tags = optional(map(string), {}) + })) + description = "Repository/environment role catalog for GitHub Actions OIDC access." + default = {} + + validation { + condition = alltrue(flatten([ + for repo in values(var.repository_access) : [ + length(trimspace(repo.github_instance_slug)) > 0, + length(trimspace(repo.github_org)) > 0, + length(trimspace(repo.repository)) > 0, + length(trimspace(repo.oidc_issuer_url)) > 0 + ] + ])) + error_message = "Each repository_access entry requires non-empty github_instance_slug, github_org, repository, and oidc_issuer_url." + } +} diff --git a/modules/aws_github_oidc_provider/README.md b/modules/aws_github_oidc_provider/README.md new file mode 100644 index 0000000..8d6955b --- /dev/null +++ b/modules/aws_github_oidc_provider/README.md @@ -0,0 +1,41 @@ +# aws_github_oidc_provider + +Terraform module for managing AWS IAM OIDC providers used by GitHub Actions. + +Resources managed: +- `aws_iam_openid_connect_provider` + +## Inputs + +- `oidc_connections` (map): OIDC connection definitions keyed by a stable catalog key. + - `github_instance_slug` + - `github_org` + - `issuer_url` + - `audiences` (defaults to `sts.amazonaws.com`) + - `tags` + +## Behavior + +- Normalizes issuer URLs by trimming trailing slashes. +- Deduplicates provider resources by `(issuer_url, audiences)` fingerprint. +- Enforces unique issuer URL keys for predictable lookup by URL in downstream modules. + +## Example + +```hcl +module "platform_oidc_provider" { + source = "../../modules/aws_github_oidc_provider" + + oidc_connections = { + "github-com-platform" = { + github_instance_slug = "github-com" + github_org = "platform" + issuer_url = "https://token.actions.githubusercontent.com" + audiences = ["sts.amazonaws.com"] + tags = { + managed_by = "terraform" + } + } + } +} +``` diff --git a/modules/aws_github_oidc_provider/main.tf b/modules/aws_github_oidc_provider/main.tf new file mode 100644 index 0000000..d758e40 --- /dev/null +++ b/modules/aws_github_oidc_provider/main.tf @@ -0,0 +1,46 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0.0" + } + } +} + +locals { + normalized_connections = { + for key, conn in var.oidc_connections : key => { + github_instance_slug = trimspace(conn.github_instance_slug) + github_org = trimspace(conn.github_org) + issuer_url = trimsuffix(trimspace(conn.issuer_url), "/") + audiences = sort(distinct(conn.audiences)) + tags = conn.tags + } + } + + provider_fingerprints = { + for key, conn in local.normalized_connections : + key => sha1(jsonencode({ + issuer_url = conn.issuer_url + audiences = conn.audiences + })) + } + + unique_providers = { + for fingerprint in distinct(values(local.provider_fingerprints)) : + fingerprint => one([ + for key, conn in local.normalized_connections : + conn if local.provider_fingerprints[key] == fingerprint + ]) + } +} + +resource "aws_iam_openid_connect_provider" "this" { + for_each = local.unique_providers + + url = each.value.issuer_url + client_id_list = each.value.audiences + tags = each.value.tags +} diff --git a/modules/aws_github_oidc_provider/outputs.tf b/modules/aws_github_oidc_provider/outputs.tf new file mode 100644 index 0000000..7f81546 --- /dev/null +++ b/modules/aws_github_oidc_provider/outputs.tf @@ -0,0 +1,20 @@ +output "provider_arn_by_issuer_url" { + description = "OIDC provider ARNs keyed by normalized issuer URL." + value = { + for _, provider in aws_iam_openid_connect_provider.this : + provider.url => provider.arn + } +} + +output "provider_arn_by_connection_key" { + description = "OIDC provider ARNs keyed by input connection key." + value = { + for key, conn in local.normalized_connections : + key => aws_iam_openid_connect_provider.this[local.provider_fingerprints[key]].arn + } +} + +output "issuer_urls" { + description = "Normalized issuer URLs managed by this module instance." + value = sort([for _, provider in aws_iam_openid_connect_provider.this : provider.url]) +} diff --git a/modules/aws_github_oidc_provider/variables.tf b/modules/aws_github_oidc_provider/variables.tf new file mode 100644 index 0000000..9dd7c02 --- /dev/null +++ b/modules/aws_github_oidc_provider/variables.tf @@ -0,0 +1,28 @@ +variable "oidc_connections" { + type = map(object({ + github_instance_slug = string + github_org = string + issuer_url = string + audiences = optional(list(string), ["sts.amazonaws.com"]) + tags = optional(map(string), {}) + })) + description = "Map of GitHub OIDC connection definitions keyed by a stable catalog key." + default = {} + + validation { + condition = alltrue([ + for conn in values(var.oidc_connections) : + length(trimspace(conn.github_instance_slug)) > 0 && + length(trimspace(conn.github_org)) > 0 && + length(trimspace(conn.issuer_url)) > 0 + ]) + error_message = "Each OIDC connection requires non-empty github_instance_slug, github_org, and issuer_url." + } + + validation { + condition = length(distinct([ + for conn in values(var.oidc_connections) : trimsuffix(trimspace(conn.issuer_url), "/") + ])) == length(var.oidc_connections) + error_message = "oidc_connections must not contain duplicate issuer_url values after normalization." + } +} diff --git a/scripts/README.md b/scripts/README.md index 6b28675..8aa7354 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -28,6 +28,12 @@ python3 scripts/generate_import_commands.py --example single-org # Multi-org example python3 scripts/generate_import_commands.py --example multi-org +# Single-org AWS OIDC provider example +python3 scripts/generate_import_commands.py --example single-org-oidc-provider + +# Multi-org AWS OIDC roles example +python3 scripts/generate_import_commands.py --example multi-org-oidc-roles + # Include optional org settings imports python3 scripts/generate_import_commands.py --example multi-org --include-org-settings @@ -43,6 +49,7 @@ python3 scripts/generate_import_commands.py --example single-org --repo-root /pa Related documentation: - Import playbook: `../docs/playbooks/import-existing-github-resources.md` +- AWS import playbook: `../docs/playbooks/import-existing-aws-oidc-resources.md` ## Safety Notes diff --git a/scripts/generate_import_commands.py b/scripts/generate_import_commands.py index bd86c15..dd28d28 100755 --- a/scripts/generate_import_commands.py +++ b/scripts/generate_import_commands.py @@ -1,9 +1,12 @@ #!/usr/bin/env python3 -"""Generate Terraform import command stubs from repository catalog YAML files.""" +"""Generate Terraform import command stubs from catalog YAML files.""" from __future__ import annotations import argparse +import hashlib +import json +import re import sys from pathlib import Path from typing import Any @@ -16,6 +19,10 @@ ) from exc +ROLE_NAME_MAX_LENGTH = 64 +POLICY_NAME_MAX_LENGTH = 128 + + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Generate Terraform import command stubs from catalog YAML.", @@ -23,7 +30,14 @@ def parse_args() -> argparse.Namespace: ) parser.add_argument( "--example", - choices=["single-org", "multi-org"], + choices=[ + "single-org", + "multi-org", + "single-org-oidc-provider", + "multi-org-oidc-provider", + "single-org-oidc-roles", + "multi-org-oidc-roles", + ], required=True, help="Example stack to target.", ) @@ -35,11 +49,24 @@ def parse_args() -> argparse.Namespace: parser.add_argument( "--include-org-settings", action="store_true", - help="Include optional github_organization_settings import commands.", + help="Include optional github_organization_settings import commands for github examples.", ) return parser.parse_args() +def load_yaml_map(path: Path) -> dict[str, Any]: + if not path.exists(): + raise SystemExit(f"error: catalog file not found: {path}") + + with path.open("r", encoding="utf-8") as fh: + data = yaml.safe_load(fh) or {} + + if not isinstance(data, dict): + raise SystemExit(f"error: catalog file must parse to a map/object: {path}") + + return {str(k): v for k, v in data.items()} + + def load_repo_catalog(catalog_dir: Path) -> dict[str, dict[str, Any]]: if not catalog_dir.exists(): raise SystemExit(f"error: catalog directory not found: {catalog_dir}") @@ -112,33 +139,180 @@ def emit_repo_module_imports( f"terraform import 'module.{module_name}.github_actions_environment_secret.this[\"{repo_name}/{env_name}/\"]' '{repo_name}:{env_name}:'" ) +def normalize_slug(value: str) -> str: + value = re.sub(r"[^a-z0-9-]", "-", value.lower()).strip("-") + value = re.sub(r"-+", "-", value) + return value + + +def truncate_with_hash(name: str, max_length: int) -> str: + if len(name) <= max_length: + return name + digest = hashlib.sha1(name.encode("utf-8")).hexdigest()[:8] + return f"{name[: max_length - 9]}-{digest}" + + +def role_name(repo_short: str, env_name: str, role_description: str) -> str: + base = f"gh-actns-{normalize_slug(repo_short)}-{normalize_slug(env_name)}-{normalize_slug(role_description)}" + return truncate_with_hash(base, ROLE_NAME_MAX_LENGTH) + + +def policy_name(repo_short: str, env_name: str, policy_description: str) -> str: + base = f"gh-actns-{normalize_slug(repo_short)}-{normalize_slug(env_name)}-{normalize_slug(policy_description)}" + return truncate_with_hash(base, POLICY_NAME_MAX_LENGTH) + + +def oidc_fingerprint(issuer_url: str, audiences: list[str]) -> str: + material = { + "issuer_url": issuer_url.rstrip("/"), + "audiences": sorted(set(audiences)), + } + encoded = json.dumps(material, sort_keys=True, separators=(",", ":")) + return hashlib.sha1(encoded.encode("utf-8")).hexdigest() + + +def emit_oidc_provider_imports(module_name: str, connections: dict[str, dict[str, Any]]) -> None: + print(f"# Module: {module_name}") + + providers: dict[str, tuple[str, str]] = {} + for _, cfg in sorted(connections.items()): + issuer_url = str(cfg.get("issuer_url", "")).rstrip("/") + audiences = cfg.get("audiences") or ["sts.amazonaws.com"] + if not issuer_url: + continue + fingerprint = oidc_fingerprint( + issuer_url=issuer_url, + audiences=[str(a) for a in audiences], + ) + issuer_hostpath = issuer_url.replace("https://", "") + providers[fingerprint] = ( + issuer_url, + f"arn:aws:iam:::oidc-provider/{issuer_hostpath}", + ) + + for fingerprint, (issuer_url, import_arn) in sorted(providers.items()): + print(f"# issuer_url: {issuer_url}") + print( + f"terraform import 'module.{module_name}.aws_iam_openid_connect_provider.this[\"{fingerprint}\"]' '{import_arn}'" + ) + + +def emit_oidc_role_imports( + module_name: str, + repos: dict[str, dict[str, Any]], + repo_short_names: dict[str, Any], +) -> None: + print(f"# Module: {module_name}") + + for repo_name in sorted(repos.keys()): + repo_cfg = repos[repo_name] + github_instance_slug = str(repo_cfg.get("github_instance_slug", "")).strip() + github_org = str(repo_cfg.get("github_org", "")).strip() + environments = repo_cfg.get("environments", {}) + if not isinstance(environments, dict): + environments = {} + + short_key = f"{github_instance_slug}/{github_org}/{repo_name}" + repo_short = str(repo_short_names.get(short_key, "")) + + for env_name in sorted(str(k) for k in environments.keys()): + env_cfg = environments.get(env_name) or {} + if not isinstance(env_cfg, dict): + env_cfg = {} + roles = env_cfg.get("roles", {}) + if not isinstance(roles, dict): + roles = {} + + for role_key in sorted(str(k) for k in roles.keys()): + role_cfg = roles.get(role_key) or {} + if not isinstance(role_cfg, dict): + role_cfg = {} + + role_description = str(role_cfg.get("role_description", role_key)) + role_resource_key = ( + f"{github_instance_slug}/{github_org}/{repo_name}/{env_name}/{role_key}" + ) + role_name_value = role_name( + repo_short=repo_short, + env_name=env_name, + role_description=role_description, + ) + + print( + f"terraform import 'module.{module_name}.aws_iam_role.this[\"{role_resource_key}\"]' '{role_name_value}'" + ) + + customer_policies = role_cfg.get("customer_policies", {}) + if not isinstance(customer_policies, dict): + customer_policies = {} + + for policy_key in sorted(str(k) for k in customer_policies.keys()): + policy_cfg = customer_policies.get(policy_key) or {} + if not isinstance(policy_cfg, dict): + policy_cfg = {} + + policy_description = str(policy_cfg.get("policy_description", policy_key)) + policy_resource_key = f"{role_resource_key}/{policy_key}" + policy_name_value = policy_name( + repo_short=repo_short, + env_name=env_name, + policy_description=policy_description, + ) + policy_arn = f"arn:aws:iam:::policy/{policy_name_value}" + + print( + f"terraform import 'module.{module_name}.aws_iam_policy.this[\"{policy_resource_key}\"]' '{policy_arn}'" + ) + print( + "terraform import " + f"'module.{module_name}.aws_iam_role_policy_attachment.customer_managed[\"{policy_resource_key}\"]' " + f"'{role_name_value}/{policy_arn}'" + ) + + managed_policy_arns = role_cfg.get("managed_policy_arns", []) + if not isinstance(managed_policy_arns, list): + managed_policy_arns = [] + + for policy_arn in sorted({str(arn) for arn in managed_policy_arns}): + attachment_key = ( + f"{role_resource_key}/{hashlib.sha1(policy_arn.encode('utf-8')).hexdigest()[:12]}" + ) + print( + "terraform import " + f"'module.{module_name}.aws_iam_role_policy_attachment.aws_managed[\"{attachment_key}\"]' " + f"'{role_name_value}/{policy_arn}'" + ) + def main() -> int: args = parse_args() repo_root = Path(args.repo_root).resolve() if args.example == "single-org": - print("# Generated import commands for examples/s3-backend-single-org-repos") + print("# Generated import commands for examples/github_repo_platform/single-org") print("# Review before running. These commands assume provider import IDs like repo:env:name.") print() repos = load_repo_catalog( - repo_root / "examples" / "s3-backend-single-org-repos" / "catalog" / "repositories" + repo_root / "examples" / "github_repo_platform" / "single-org" / "catalog" / "repositories" ) if args.include_org_settings: - print("# Run org settings import from examples/s3-backend-single-org") + print("# Run org settings import from examples/github_org_settings/single-org") print("# Module: org_settings") emit_org_settings_import("org_settings", "") print() emit_repo_module_imports(module_name="org_repositories", repos=repos) - else: - print("# Generated import commands for examples/s3-backend-multi-org-repos") + return 0 + + if args.example == "multi-org": + print("# Generated import commands for examples/github_repo_platform/multi-org") print("# Review before running. These commands assume provider import IDs like repo:env:name.") print() platform_repos = load_repo_catalog( repo_root / "examples" - / "s3-backend-multi-org-repos" + / "github_repo_platform" + / "multi-org" / "catalog" / "orgs" / "platform" @@ -147,7 +321,8 @@ def main() -> int: shared_repos = load_repo_catalog( repo_root / "examples" - / "s3-backend-multi-org-repos" + / "github_repo_platform" + / "multi-org" / "catalog" / "orgs" / "shared" @@ -155,18 +330,154 @@ def main() -> int: ) if args.include_org_settings: - print("# Run org settings import from examples/s3-backend-multi-org") + print("# Run org settings import from examples/github_org_settings/multi-org") print("# Module: platform_org_settings") emit_org_settings_import("platform_org_settings", "") print() emit_repo_module_imports(module_name="platform_org_repositories", repos=platform_repos) print() if args.include_org_settings: - print("# Run org settings import from examples/s3-backend-multi-org") + print("# Run org settings import from examples/github_org_settings/multi-org") print("# Module: shared_org_settings") emit_org_settings_import("shared_org_settings", "") print() emit_repo_module_imports(module_name="shared_org_repositories", repos=shared_repos) + return 0 + + if args.example == "single-org-oidc-provider": + print("# Generated import commands for examples/aws_github_oidc_provider/single-account") + print("# Review before running and replace placeholders.") + print() + + connections = load_repo_catalog( + repo_root + / "examples" + / "aws_github_oidc_provider" + / "single-account" + / "catalog" + / "oidc-connections" + ) + emit_oidc_provider_imports(module_name="org_oidc_provider", connections=connections) + return 0 + + if args.example == "multi-org-oidc-provider": + print("# Generated import commands for examples/aws_github_oidc_provider/multi-account") + print("# Review before running and replace placeholders.") + print() + + platform_connections = load_repo_catalog( + repo_root + / "examples" + / "aws_github_oidc_provider" + / "multi-account" + / "catalog" + / "orgs" + / "platform" + / "oidc-connections" + ) + shared_connections = load_repo_catalog( + repo_root + / "examples" + / "aws_github_oidc_provider" + / "multi-account" + / "catalog" + / "orgs" + / "shared" + / "oidc-connections" + ) + + emit_oidc_provider_imports( + module_name="platform_org_oidc_provider", connections=platform_connections + ) + print() + emit_oidc_provider_imports(module_name="shared_org_oidc_provider", connections=shared_connections) + return 0 + + if args.example == "single-org-oidc-roles": + print("# Generated import commands for examples/aws_github_actions_oidc_roles/single-account") + print("# Review before running and replace placeholders.") + print() + + repos = load_repo_catalog( + repo_root + / "examples" + / "aws_github_actions_oidc_roles" + / "single-account" + / "catalog" + / "repositories" + ) + short_names = load_yaml_map( + repo_root + / "examples" + / "aws_github_actions_oidc_roles" + / "single-account" + / "catalog" + / "repo-short-names.yaml" + ) + + emit_oidc_role_imports( + module_name="org_oidc_roles", + repos=repos, + repo_short_names=short_names, + ) + return 0 + + print("# Generated import commands for examples/aws_github_actions_oidc_roles/multi-account") + print("# Review before running and replace placeholders.") + print() + + platform_repos = load_repo_catalog( + repo_root + / "examples" + / "aws_github_actions_oidc_roles" + / "multi-account" + / "catalog" + / "orgs" + / "platform" + / "repositories" + ) + shared_repos = load_repo_catalog( + repo_root + / "examples" + / "aws_github_actions_oidc_roles" + / "multi-account" + / "catalog" + / "orgs" + / "shared" + / "repositories" + ) + platform_short_names = load_yaml_map( + repo_root + / "examples" + / "aws_github_actions_oidc_roles" + / "multi-account" + / "catalog" + / "orgs" + / "platform" + / "repo-short-names.yaml" + ) + shared_short_names = load_yaml_map( + repo_root + / "examples" + / "aws_github_actions_oidc_roles" + / "multi-account" + / "catalog" + / "orgs" + / "shared" + / "repo-short-names.yaml" + ) + + emit_oidc_role_imports( + module_name="platform_org_oidc_roles", + repos=platform_repos, + repo_short_names=platform_short_names, + ) + print() + emit_oidc_role_imports( + module_name="shared_org_oidc_roles", + repos=shared_repos, + repo_short_names=shared_short_names, + ) return 0 diff --git a/scripts/precommit_terraform.sh b/scripts/precommit_terraform.sh new file mode 100755 index 0000000..8445a0b --- /dev/null +++ b/scripts/precommit_terraform.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +STACKS=( + "modules/aws_github_oidc_provider" + "modules/aws_github_actions_oidc_roles" + "modules/github_org_settings" + "modules/github_repo_platform" + "examples/github_org_settings/single-org" + "examples/github_repo_platform/single-org" + "examples/aws_github_oidc_provider/single-account" + "examples/aws_github_actions_oidc_roles/single-account" + "examples/github_org_settings/multi-org" + "examples/github_repo_platform/multi-org" + "examples/aws_github_oidc_provider/multi-account" + "examples/aws_github_actions_oidc_roles/multi-account" +) + +run_fmt_check() { + terraform -chdir="$ROOT_DIR" fmt -check -recursive +} + +run_validate() { + for stack in "${STACKS[@]}"; do + terraform -chdir="$ROOT_DIR/$stack" init -backend=false -input=false -no-color >/dev/null + terraform -chdir="$ROOT_DIR/$stack" validate -no-color + done +} + +run_tflint() { + tflint --init + for stack in "${STACKS[@]}"; do + tflint --chdir "$ROOT_DIR/$stack" --recursive + done +} + +case "${1:-}" in +fmt-check) + run_fmt_check + ;; +validate) + run_validate + ;; +tflint) + run_tflint + ;; +*) + echo "Usage: $0 {fmt-check|validate|tflint}" >&2 + exit 1 + ;; +esac diff --git a/templates/README.md b/templates/README.md index 4dbc6e3..0190af1 100644 --- a/templates/README.md +++ b/templates/README.md @@ -4,6 +4,8 @@ This directory contains workflow templates intended for downstream users of this - `workflows/terraform-repositories.yml`: repository/environment onboarding automation. - `workflows/terraform-org-settings.yml`: organization settings automation. +- `workflows/terraform-aws-oidc-provider.yml`: AWS IAM OIDC provider automation for GitHub Actions trust. +- `workflows/terraform-aws-oidc-roles.yml`: AWS IAM role/policy automation for repository and environment access. To use these in another repository: diff --git a/templates/workflows/terraform-aws-oidc-provider.yml b/templates/workflows/terraform-aws-oidc-provider.yml new file mode 100644 index 0000000..9b06497 --- /dev/null +++ b/templates/workflows/terraform-aws-oidc-provider.yml @@ -0,0 +1,245 @@ +name: Terraform AWS OIDC Provider + +on: + pull_request: + paths: + - "modules/aws_github_oidc_provider/**" + - "examples/aws_github_oidc_provider/single-account/**" + - "examples/aws_github_oidc_provider/multi-account/**" + - ".github/workflows/terraform-aws-oidc-provider.yml" + push: + branches: + - main + paths: + - "modules/aws_github_oidc_provider/**" + - "examples/aws_github_oidc_provider/single-account/**" + - "examples/aws_github_oidc_provider/multi-account/**" + - ".github/workflows/terraform-aws-oidc-provider.yml" + +permissions: + contents: read + id-token: write + +jobs: + detect-changes: + runs-on: ubuntu-latest + outputs: + single: ${{ steps.filter.outputs.single }} + multi: ${{ steps.filter.outputs.multi }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Detect changed stacks + id: filter + uses: dorny/paths-filter@v3 + with: + filters: | + single: + - "modules/aws_github_oidc_provider/**" + - "examples/aws_github_oidc_provider/single-account/**" + - ".github/workflows/terraform-aws-oidc-provider.yml" + multi: + - "modules/aws_github_oidc_provider/**" + - "examples/aws_github_oidc_provider/multi-account/**" + - ".github/workflows/terraform-aws-oidc-provider.yml" + + plan-single-org: + if: ${{ github.event_name == 'pull_request' && needs.detect-changes.outputs.single == 'true' }} + needs: detect-changes + runs-on: ubuntu-latest + env: + TF_IN_AUTOMATION: true + TF_VAR_aws_region: ${{ vars.TF_BACKEND_REGION }} + TF_STATE_BUCKET: ${{ vars.TF_STATE_BUCKET }} + TF_LOCK_TABLE: ${{ vars.TF_LOCK_TABLE }} + TF_BACKEND_REGION: ${{ vars.TF_BACKEND_REGION }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_TERRAFORM_ROLE_ARN }} + aws-region: ${{ vars.TF_BACKEND_REGION }} + + - name: Write backend config + working-directory: examples/aws_github_oidc_provider/single-account + run: | + cat > backend.hcl < backend.hcl < backend.hcl < backend.hcl < backend.hcl < backend.hcl < backend.hcl < backend.hcl < backend.hcl < backend.hcl < backend.hcl < backend.hcl < backend.hcl < backend.hcl <