Skip to content

microsoftexpert/tf-mod-github-actions-organization

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🐙 GitHub Actions — Organization Terraform Module

One deeply-typed boundary for organization-wide GitHub Actions governance — the allowed-actions policy, enabled-repositories scope, org-level Actions secrets and variables, and selected-repository allow-lists, all secure-by-default. Built for integrations/github v6.x.

Terraform GitHub provider module type resources


🧩 Overview

This composite module governs GitHub Actions at organization scope behind a single Terraform boundary:

  • 🛡️ Allowed-actions policy — restrict which actions/reusable workflows may run (all / local_only / selected), with a tunable allow-list for GitHub-owned, Marketplace-verified, and explicit owner/repo@ref patterns.
  • 🎯 Enabled-repositories scope — choose whether all, none, or only selected repositories may run Actions.
  • 🔑 Org Actions secrets — a for_each map of encrypted org secrets with all / private / selected visibility, value or (preferred) pre-encrypted value_encrypted.
  • 🧮 Org Actions variables — a for_each map of non-secret org variables shared across workflows.
  • 📇 Selected-repository allow-lists — standalone allow-lists wiring selected-visibility secrets to specific repositories by numeric id.
  • 🔒 Supply-chain hardening — optional sha_pinning_required to force full-commit-SHA pinning of actions.
  • 🪪 Read-only token baseline — an org-wide default GITHUB_TOKEN scope (workflow_permissions), defaulting to read so workflows can't write unless a workflow explicitly opts in.
  • 📇 Variable repository allow-lists — standalone allow-lists wiring selected-visibility org variables to specific repositories by numeric id (the variable-side mirror of the secret allow-list).
  • 🛡️ OIDC subject-claim hardening — an optional org OIDC subject-claim template (include_claim_keys) for least-privilege, keyless cloud federation (e.g. to Azure / AWS).

💡 Why it matters: Actions is the org's CI/CD blast radius. Centralizing the allowed-actions policy, org secrets, and repo scope in one reviewable module turns supply-chain governance into code review instead of a click-ops checklist.


❤️ Support this project

If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:

Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!


🗺️ Where this fits in the family

This is the org-scope half of the Actions family — the repo-scope sibling is tf-mod-github-actions-repository. It consumes numeric repository ids from the keystone tf-mod-github-repository and emits the policy + secret/variable inventory consumed by reporting and by repo-scope Actions config.

flowchart LR
 repo["tf-mod-github-repository<br/>(keystone)"]
 actorg["tf-mod-github-actions-organization<br/>(THIS · org-scope Actions)"]
 actrepo["tf-mod-github-actions-repository<br/>(repo-scope Actions)"]
 orgset["tf-mod-github-organization-settings"]
 orgrule["tf-mod-github-organization-ruleset"]
 rg["tf-mod-github-runner-group"]
 cloud["Azure / AWS<br/>(OIDC keyless trust)"]

 repo -- "repo_id (numeric)" --> actorg
 repo -- "repo name" --> actrepo
 orgset -. "org Actions enabled".-> actorg
 actorg -- "secret_names / variable_names" --> actrepo
 actorg -- "enabled_repositories policy" --> orgrule
 actorg -. "selected repos".-> rg
 actorg -. "OIDC sub claim".-> cloud

 style actorg fill:#8957E5,color:#fff
 style repo fill:#24292F,color:#fff
Loading

Consumes numeric repo_ids from tf-mod-github-repository; emits the resolved policy plus secret/variable names and ids. See the 🔌 Cross-Module Contract.


🧬 What this module builds

The primary resource is github_actions_organization_permissions.this; three for_each child collections hang off the same org boundary.

flowchart TD
 this["github_actions_organization_permissions.this<br/>(primary policy: allowed_actions + enabled_repositories)"]
 secrets["github_actions_organization_secret.secrets<br/>for_each = var.secrets"]
 vars["github_actions_organization_variable.variables<br/>for_each = var.variables"]
 allow["github_actions_organization_secret_repositories.allow<br/>for_each = var.secret_repository_access"]
 varallow["github_actions_organization_variable_repositories.variable_allow<br/>for_each = var.variable_repository_access"]
 workflow["github_actions_organization_workflow_permissions.workflow<br/>singleton · default_workflow_permissions = read"]
 oidc["github_actions_organization_oidc_subject_claim_customization_template.oidc<br/>singleton · include_claim_keys"]

 this --> secrets
 this --> vars
 this --> workflow
 this --> oidc
 secrets -- "selected-visibility allow-list" --> allow
 vars -- "selected-visibility allow-list" --> varallow

 style this fill:#8957E5,color:#fff
Loading

Resource inventory

  • 🛡️ github_actions_organization_permissions.this — the primary policy: allowed-actions mode, enabled-repositories mode, optional allow-list and selected-repo list, optional SHA-pinning.
  • 🔑 github_actions_organization_secret.secretsfor_each map of org Actions secrets keyed by secret name.
  • 🧮 github_actions_organization_variable.variablesfor_each map of org Actions variables keyed by variable name.
  • 📇 github_actions_organization_secret_repositories.allowfor_each map of selected-repository allow-lists for selected-visibility secrets, keyed by secret name.
  • 📇 github_actions_organization_variable_repositories.variable_allowfor_each map of selected-repository allow-lists for selected-visibility variables, keyed by variable name (the variable-side mirror of .allow).
  • 🪪 github_actions_organization_workflow_permissions.workflowsingleton org-wide default GITHUB_TOKEN scope (read by default; count-gated 0/1).
  • 🛡️ github_actions_organization_oidc_subject_claim_customization_template.oidcsingleton org OIDC subject-claim template (include_claim_keys; off unless set).

✅ Provider / Versions

Requirement Value
Terraform >= 1.12.0
Provider integrations/github ~> 6.0 (current 6.12.1)

⚠️ Provider source is integrations/github, never the deprecated hashicorp/github. Migrate old state with terraform state replace-provider. ℹ️ Auth (PAT / GitHub CLI / OAuth / GitHub App) and the target org (owner / GITHUB_OWNER) are provider concerns — this module declares no owner / token / app_auth variables.


📁 Module Structure

tf-mod-github-actions-organization/
├── providers.tf # terraform{} + required_providers (integrations/github ~> 6.0)
├── variables.tf # deeply-typed inputs, validations, secure defaults
├── main.tf # this policy + 3 for_each child collections
├── outputs.tf # id, resolved policy, secret/variable names + ids
├── SCOPE.md # in/out-of-scope, consumes/emits, token scopes, prerequisites
└── README.md # this file

⚙️ Quick Start

module "actions_org" {
  source = "git::https://github.com/microsoftexpert/tf-mod-github-actions-organization?ref=v1.0.0"

  # Secure default: only GitHub-owned + verified actions may run, org-wide.
  allowed_actions      = "selected"
  enabled_repositories = "all"

  secrets = {
    "NPM_TOKEN" = {
      visibility      = "private"
      value_encrypted = var.npm_token_encrypted # 🔒 pre-encrypted with the org public key
    }
  }

  variables = {
    "TF_LOG" = {
      value      = "INFO"
      visibility = "all"
    }
  }
}

🔌 Cross-Module Contract

Consumes

Input Type Source
enabled_repositories_config.repository_ids set(number) tf-mod-github-repository (repo_id)
secrets[*].selected_repository_ids set(number) tf-mod-github-repository (repo_id)
secret_repository_access[*].selected_repository_ids set(number) tf-mod-github-repository (repo_id)
variables[*].selected_repository_ids set(number) tf-mod-github-repository (repo_id)
variable_repository_access[*].selected_repository_ids set(number) tf-mod-github-repository (repo_id)
workflow_permissions.organization_slug string Caller / org login (matches provider owner)

Emits

Output Description Consumed by
id Org Actions permissions policy id (the organization) Reporting / module composition
allowed_actions Resolved allowed-actions policy Reporting / tf-mod-github-organization-ruleset
enabled_repositories Resolved enabled-repositories policy Reporting
sha_pinning_required Provider-resolved SHA-pinning flag Audit / compliance
secret_names Set of managed org secret names tf-mod-github-actions-repository, audit
secret_ids Map of secret name → resource id Reporting
variable_names Map of variable name → value (non-secret) Downstream workflow config
variable_ids Map of variable name → resource id Reporting
secret_repository_access_ids Map of secret name → allow-list resource id Reporting
variable_repository_access_ids Map of variable name → variable allow-list resource id Reporting
default_workflow_permissions Resolved org-wide default GITHUB_TOKEN scope (null when unmanaged) Audit / compliance
oidc_subject_claim_template Resolved OIDC include_claim_keys (null when unmanaged) Cloud OIDC trust config / audit

🔒 No secret values are ever emitted — only names and resource ids.


📚 Example Library

1 · Minimal — secure default policy only
module "actions_org" {
  source = "git::https://github.com/microsoftexpert/tf-mod-github-actions-organization?ref=v1.0.0"

  allowed_actions      = "selected" # GitHub-owned + verified only
  enabled_repositories = "all"
}
2 · Lock Actions down — local actions only
module "actions_org" {
  source = "git::https://github.com/microsoftexpert/tf-mod-github-actions-organization?ref=v1.0.0"

  # Only actions/workflows defined inside this org may run.
  allowed_actions      = "local_only"
  enabled_repositories = "all"
}

🔒 local_only is the strictest non-none posture — no Marketplace actions at all.

3 · Selected actions allow-list with explicit patterns
module "actions_org" {
  source = "git::https://github.com/microsoftexpert/tf-mod-github-actions-organization?ref=v1.0.0"

  allowed_actions      = "selected"
  enabled_repositories = "all"

  allowed_actions_config = {
    github_owned_allowed = true
    verified_allowed     = true
    patterns_allowed = [
      "aws-actions/configure-aws-credentials@*",
      "hashicorp/setup-terraform@*",
      "azure/login@v2",
    ]
  }
}

💡 patterns_allowed is only honored when allowed_actions = "selected".

4 · Require SHA pinning (supply-chain hardening)
module "actions_org" {
  source = "git::https://github.com/microsoftexpert/tf-mod-github-actions-organization?ref=v1.0.0"

  allowed_actions      = "selected"
  enabled_repositories = "all"
  sha_pinning_required = true
}

⚠️ sha_pinning_required is not available on every plan; forcing it can fail apply on orgs that don't support it. Left null (GitHub-managed default) unless you set it.

5 · Restrict Actions to selected repositories
module "actions_org" {
  source = "git::https://github.com/microsoftexpert/tf-mod-github-actions-organization?ref=v1.0.0"

  allowed_actions      = "selected"
  enabled_repositories = "selected"

  enabled_repositories_config = {
    repository_ids = [
      module.repo_app.repo_id,
      module.repo_infra.repo_id,
    ]
  }
}

💡 repository_ids are numeric repo ids (repo_id), not names. enabled_repositories_config is required when enabled_repositories = "selected".

6 · Org secret — private visibility (pre-encrypted)
module "actions_org" {
  source = "git::https://github.com/microsoftexpert/tf-mod-github-actions-organization?ref=v1.0.0"

  allowed_actions      = "selected"
  enabled_repositories = "all"

  secrets = {
    "DOCKERHUB_TOKEN" = {
      visibility      = "private" # only private repos in the org
      value_encrypted = var.dockerhub_token_encrypted
    }
  }
}

🔒 Prefer value_encrypted (set key_id with it) so plaintext never enters state.

7 · Org secret — selected repositories (inline allow-list)
module "actions_org" {
  source = "git::https://github.com/microsoftexpert/tf-mod-github-actions-organization?ref=v1.0.0"

  allowed_actions      = "selected"
  enabled_repositories = "all"

  secrets = {
    "PROD_DEPLOY_KEY" = {
      visibility              = "selected"
      value_encrypted         = var.prod_deploy_key_encrypted
      selected_repository_ids = [module.repo_app.repo_id]
    }
  }
}

⚠️ For a selected secret, own the allow-list in exactly one place — inline selected_repository_ids or secret_repository_access, never both (doing both causes perpetual plan churn).

8 · Org secret — selected repositories (standalone allow-list)
module "actions_org" {
  source = "git::https://github.com/microsoftexpert/tf-mod-github-actions-organization?ref=v1.0.0"

  allowed_actions      = "selected"
  enabled_repositories = "all"

  secrets = {
    "PROD_DEPLOY_KEY" = {
      visibility      = "selected"
      value_encrypted = var.prod_deploy_key_encrypted
      # allow-list owned by secret_repository_access below
    }
  }

  secret_repository_access = {
    "PROD_DEPLOY_KEY" = {
      selected_repository_ids = [
        module.repo_app.repo_id,
        module.repo_infra.repo_id,
      ]
    }
  }
}

💡 Use the standalone resource when the allow-list is large or managed by a different team than the secret value.

9 · Org variables across visibilities
module "actions_org" {
  source = "git::https://github.com/microsoftexpert/tf-mod-github-actions-organization?ref=v1.0.0"

  allowed_actions      = "selected"
  enabled_repositories = "all"

  variables = {
    "REGISTRY_HOST" = { value = "ghcr.io", visibility = "all" }
    "TF_VERSION"    = { value = "1.12.0", visibility = "all" }
    "DEPLOY_ENV" = {
      value                   = "production"
      visibility              = "selected"
      selected_repository_ids = [module.repo_app.repo_id]
    }
  }
}

💡 Variable values are not secret — they're emitted in plan output and via variable_names.

10 · Secrets + variables + allow-list together
module "actions_org" {
  source = "git::https://github.com/microsoftexpert/tf-mod-github-actions-organization?ref=v1.0.0"

  allowed_actions      = "selected"
  enabled_repositories = "all"

  secrets = {
    "NPM_TOKEN"  = { visibility = "all", value_encrypted = var.npm_token_encrypted }
    "PROD_TOKEN" = { visibility = "selected", value_encrypted = var.prod_token_encrypted }
  }

  variables = {
    "TF_LOG" = { value = "INFO", visibility = "all" }
  }

  secret_repository_access = {
    "PROD_TOKEN" = { selected_repository_ids = [module.repo_app.repo_id] }
  }
}
11 · Secure / hardened variant (recommended baseline)
module "actions_org" {
  source = "git::https://github.com/microsoftexpert/tf-mod-github-actions-organization?ref=v1.0.0"

  # Strict allow-list + SHA pinning + private secrets.
  allowed_actions      = "selected"
  enabled_repositories = "selected"
  sha_pinning_required = true

  allowed_actions_config = {
    github_owned_allowed = true
    verified_allowed     = false # block non-GitHub Marketplace creators
    patterns_allowed = [
      "hashicorp/setup-terraform@v3",
      "aws-actions/configure-aws-credentials@v4",
    ]
  }

  enabled_repositories_config = {
    repository_ids = [for r in module.repos : r.repo_id]
  }

  secrets = {
    "PROD_DEPLOY_KEY" = {
      visibility              = "selected"
      value_encrypted         = var.prod_deploy_key_encrypted
      selected_repository_ids = [module.repos["app"].repo_id]
    }
  }
}

🔒 verified_allowed = false + pinned @vN patterns + selected repo scope is the tightest practical posture short of local_only.

12 · for_each at scale — secrets from a map(object)
locals {
  org_secrets = {
    "NPM_TOKEN"     = { visibility = "all", value_encrypted = var.npm_token_encrypted }
    "DOCKERHUB_PAT" = { visibility = "all", value_encrypted = var.dockerhub_encrypted }
    "SNYK_TOKEN"    = { visibility = "private", value_encrypted = var.snyk_encrypted }
    "SONAR_TOKEN"   = { visibility = "private", value_encrypted = var.sonar_encrypted }
    "PROD_DEPLOY"   = { visibility = "selected", value_encrypted = var.prod_encrypted, selected_repository_ids = [module.repo_app.repo_id] }
  }
}

module "actions_org" {
  source = "git::https://github.com/microsoftexpert/tf-mod-github-actions-organization?ref=v1.0.0"

  allowed_actions      = "selected"
  enabled_repositories = "all"

  secrets = local.org_secrets

  variables = {
    for k, v in var.shared_vars : k => { value = v, visibility = "all" }
  }
}

⚠️ Bulk for_each over many secrets can hit GitHub secondary rate limits — see Troubleshooting.

13 · Wiring repository ids from tf-mod-github-repository
module "repo_app" {
  source     = "git::https://github.com/microsoftexpert/tf-mod-github-repository?ref=v1.0.0"
  name       = "app"
  visibility = "private"
}

module "actions_org" {
  source = "git::https://github.com/microsoftexpert/tf-mod-github-actions-organization?ref=v1.0.0"

  allowed_actions      = "selected"
  enabled_repositories = "selected"

  enabled_repositories_config = {
    repository_ids = [module.repo_app.repo_id] # numeric id, not name
  }
}

💡 The keystone tf-mod-github-repository emits repo_id (numeric) specifically for these allow-lists. The repo name (id) won't work here.

14 · Read-only org workflow-token baseline (recommended)
module "actions_org" {
  source = "git::https://github.com/microsoftexpert/tf-mod-github-actions-organization?ref=v1.0.0"

  allowed_actions      = "selected"
  enabled_repositories = "all"

  # Org-wide default GITHUB_TOKEN scope = read-only. A workflow that needs write
  # must request it explicitly via `permissions:` in its workflow file.
  workflow_permissions = {
    organization_slug = "financial-partners-org"
    # default_workflow_permissions => "read" (secure default)
    # can_approve_pull_request_reviews => false (secure default)
  }
}

🔒 read is the least-privilege baseline. organization_slug is required by the provider for this resource — it is the only place the module accepts the org name (sibling org resources derive owner from the provider).

15 · Selected-visibility org variable + its repository allow-list
module "actions_org" {
  source = "git::https://github.com/microsoftexpert/tf-mod-github-actions-organization?ref=v1.0.0"

  allowed_actions      = "selected"
  enabled_repositories = "all"

  variables = {
    "DEPLOY_ENV" = {
      value      = "production"
      visibility = "selected"
      # allow-list owned by variable_repository_access below — NOT inline
    }
  }

  variable_repository_access = {
    "DEPLOY_ENV" = {
      selected_repository_ids = [
        module.repo_app.repo_id,
        module.repo_infra.repo_id,
      ]
    }
  }
}

⚠️ Own a selected variable's allow-list in exactly one place — inline selected_repository_ids or variable_repository_access, never both (doing both causes perpetual plan churn). This is the same rule the module enforces for secrets.

16 · OIDC subject-claim customization — hardened keyless federation
module "actions_org" {
  source = "git::https://github.com/microsoftexpert/tf-mod-github-actions-organization?ref=v1.0.0"

  allowed_actions      = "selected"
  enabled_repositories = "all"

  # Narrow the OIDC `sub` claim org-wide so cloud trust policies can bind tightly
  # (per-repo / per-environment) instead of trusting the whole org.
  oidc_subject_claim_template = {
    include_claim_keys = ["repo", "context", "job_workflow_ref"]
  }
}

🔒 Why scoped subject claims matter: GitHub mints an OIDC token whose sub claim is assembled from these keys. A cloud role (Azure federated credential, AWS IAM role) trusts a sub pattern — a broad claim trusts every repo (repo:org/*), while repo:org/app:environment:prod trusts exactly one repo and environment. Tightening include_claim_keys is what makes keyless federation (no stored cloud secrets) least-privilege instead of org-wide blast radius.

17 · 🏗️ End-to-end composition — full suite wired outputs → inputs
# 1) Keystone repositories
module "repos" {
  source   = "git::https://github.com/microsoftexpert/tf-mod-github-repository?ref=v1.0.0"
  for_each = toset(["app", "infra", "docs"])

  name       = each.key
  visibility = "private"
}

# 2) Org-scope Actions governance (THIS module)
module "actions_org" {
  source = "git::https://github.com/microsoftexpert/tf-mod-github-actions-organization?ref=v1.0.0"

  allowed_actions      = "selected"
  enabled_repositories = "selected"
  sha_pinning_required = true

  allowed_actions_config = {
    github_owned_allowed = true
    verified_allowed     = true
    patterns_allowed     = ["hashicorp/setup-terraform@v3"]
  }

  enabled_repositories_config = {
    repository_ids = [for r in module.repos : r.repo_id]
  }

  secrets = {
    "PROD_DEPLOY_KEY" = {
      visibility              = "selected"
      value_encrypted         = var.prod_deploy_key_encrypted
      selected_repository_ids = [module.repos["app"].repo_id]
    }
  }

  variables = {
    "TF_VERSION" = { value = "1.12.0", visibility = "all" }
    "DEPLOY_ENV" = { value = "production", visibility = "selected" }
  }

  # Org-wide read-only GITHUB_TOKEN baseline.
  workflow_permissions = {
    organization_slug = "financial-partners-org"
  }

  # Allow-list for the selected variable (owned here, not inline on the variable).
  variable_repository_access = {
    "DEPLOY_ENV" = { selected_repository_ids = [module.repos["app"].repo_id] }
  }

  # Hardened OIDC subject claim for keyless cloud federation.
  oidc_subject_claim_template = {
    include_claim_keys = ["repo", "context", "job_workflow_ref"]
  }
}

# 3) Repo-scope Actions config consumes the org secret inventory
module "actions_repo_app" {
  source     = "git::https://github.com/microsoftexpert/tf-mod-github-actions-repository?ref=v1.0.0"
  repository = module.repos["app"].id

  # Reference org secrets by name so repo workflows know what's available org-wide.
  inherited_org_secret_names = module.actions_org.secret_names
}

🔒 Secrets flow as names, never values — module.actions_org.secret_names is a safe, non-sensitive handle.


📥 Inputs

Policy (primary this resource)

  • enabled_repositoriesall (default) / none / selected.
  • allowed_actionsselected (secure default) / all / local_only.
  • sha_pinning_requiredbool, default null (GitHub-managed).
  • allowed_actions_configgithub_owned_allowed, verified_allowed, patterns_allowed (honored only when allowed_actions = "selected").
  • enabled_repositories_configrepository_ids (required when enabled_repositories = "selected").

Secrets / variables / allow-lists

  • secretsmap(object) 🔒 sensitive; visibility + value/value_encrypted (+ key_id, optional string — id of the public key used to encrypt value_encrypted; required with it) + optional selected_repository_ids.
  • variablesmap(object); value + visibility + optional selected_repository_ids.
  • secret_repository_accessmap(object); selected_repository_ids keyed by secret name.
  • variable_repository_accessmap(object); selected_repository_ids keyed by variable name (variable-side mirror of secret_repository_access).

Org-wide governance (singletons)

  • workflow_permissions — optional object (nullable singleton); org-wide default GITHUB_TOKEN scope: organization_slug (required), default_workflow_permissions (read/write, default read), can_approve_pull_request_reviews (default false). null (default) leaves it unmanaged.
  • oidc_subject_claim_template — optional object (nullable singleton); org OIDC subject-claim include_claim_keys. null (default) leaves it unmanaged (GitHub's default sub applies).
Full object schemas
allowed_actions_config = object({
  github_owned_allowed = optional(bool, true)
  verified_allowed     = optional(bool, true)
  patterns_allowed     = optional(set(string), [])
})

enabled_repositories_config = object({
  repository_ids = set(number)
})

secrets = map(object({
  visibility              = optional(string, "private") # "all" | "private" | "selected"
  value                   = optional(string)            # 🔒 sensitive
  value_encrypted         = optional(string)            # 🔒 sensitive — preferred
  key_id                  = optional(string)            # id of the public key used to encrypt value_encrypted; required with it
  selected_repository_ids = optional(set(number))       # required when visibility = "selected"
}))

variables = map(object({
  value                   = string                      # non-secret
  visibility              = optional(string, "private") # "all" | "private" | "selected"
  selected_repository_ids = optional(set(number))
}))

secret_repository_access = map(object({
  selected_repository_ids = set(number)
}))

variable_repository_access = map(object({
  selected_repository_ids = set(number)
}))

workflow_permissions = object({
  organization_slug                = string                   # REQUIRED by the provider for this resource
  default_workflow_permissions     = optional(string, "read") # "read" | "write"
  can_approve_pull_request_reviews = optional(bool, false)
})

oidc_subject_claim_template = object({
  include_claim_keys = list(string)
})

🧾 Outputs

Output Description
id Org Actions permissions policy id (the organization)
allowed_actions Resolved allowed-actions policy
enabled_repositories Resolved enabled-repositories policy
sha_pinning_required Provider-resolved SHA-pinning flag (may be null)
secret_names Set of managed org secret names
secret_ids Map of secret name → resource id (empty when none)
variable_names Map of variable name → value (non-secret)
variable_ids Map of variable name → resource id (empty when none)
secret_repository_access_ids Map of secret name → allow-list resource id (empty when none)
variable_repository_access_ids Map of variable name → variable allow-list resource id (empty when none)
default_workflow_permissions Resolved org-wide default GITHUB_TOKEN scope (read/write; null when unmanaged)
oidc_subject_claim_template Resolved OIDC include_claim_keys list (null when unmanaged)

🔒 No secret values are emitted by design. secret_names/secret_ids are non-sensitive handles.


🧠 Architecture Notes

  • id semantics. github_actions_organization_permissions.this.id is the organization itself (the policy is a singleton per org) — there is no node_id/slug for the policy. Secret and variable resources expose id only; GitHub Actions secrets/variables have no node id or ARN.
  • Authoritative vs additive. github_actions_organization_permissions is an authoritative singleton — applying it overwrites the org's Actions policy wholesale. Only one instance should manage it across your whole Terraform estate, or the last apply wins and the others churn.
  • Two ways to own a selected allow-list. Inline secrets[*].selected_repository_ids and the standalone github_actions_organization_secret_repositories.allow resource both write the same underlying list. Pick one per secret — owning it in both places produces a permanent diff on every plan.
  • Sensitive handling. var.secrets is marked sensitive = true, so for_each cannot iterate it directly; main.tf iterates nonsensitive(toset(keys(var.secrets))) and reads sensitive fields back by key. Prefer value_encrypted (set key_id with it) so plaintext never enters state; never echo a secret value into an output.
  • enabled_repositories / allowed_actions interplay. enabled_repositories_config is only consumed when enabled_repositories = "selected"; allowed_actions_config only when allowed_actions = "selected". The dynamic blocks emit nothing otherwise, so leftover config is silently ignored (validations still enforce the required pairings).
  • Numeric ids, not names. Every repository allow-list (policy scope, secret scope) takes numeric repo_ids — wire from tf-mod-github-repository.repo_id, never the repo name.
  • No ForceNew traps here. Changing allowed_actions/enabled_repositories updates in place; renaming a secret/variable key (the map key) destroys-and-recreates that one entry (secret_name/variable_name is the identity).
  • Eventual consistency. The GitHub REST API is eventually consistent — a freshly created repo's repo_id may briefly 404 when referenced by an allow-list. Terraform's dependency graph normally orders this correctly; an occasional re-apply resolves transient races.
  • Read-only token baseline. workflow_permissions sets the org-wide default GITHUB_TOKEN scope; it defaults to read (the provider's own default too), so workflows must opt into write via per-workflow permissions:. It is a singleton gated with count (0/1) and is authoritative — only one instance should own it per org. The provider requires organization_slug for this resource, so it is a required field of the opt-in object — the single place the module takes the org name (sibling org resources derive owner from the provider). Because the slug is required and there is no zero-argument data source to derive it, the read-only baseline is opt-in (set the object) rather than on-by-default.
  • Own a variable's allow-list once. Exactly as with secrets, a selected-visibility variable's repository list can be owned inline (variables[*].selected_repository_ids) or via the standalone github_actions_organization_variable_repositories.variable_allow (variable_repository_access) — never both, or you get a permanent diff. The standalone resource exists for large lists or split team ownership.
  • OIDC subject-claim hardening. oidc_subject_claim_template is an off-by-default singleton. When set, it customizes the sub claim GitHub mints in Actions OIDC tokens org-wide; narrowing include_claim_keys lets cloud trust policies bind to a tight subject (per-repo / per-environment), turning keyless federation into least-privilege. Leaving it null keeps GitHub's default subject claim; removing the block destroys the customization (reverts to default).
  • Rulesets vs branch protection is out of scope here (org-governance/repository families) — this module governs Actions, not branch policy.

🧱 Design Principles

  • 🔒 Secure by default. allowed_actions = "selected" blocks arbitrary third-party actions out of the box; secrets default to private visibility.
  • 🪪 Least privilege. selected repo scope and per-secret allow-lists keep secret blast radius minimal; verified_allowed = false for the tightest Marketplace posture.
  • 🤫 No secret leakage. Secret inputs are sensitive; outputs expose names and ids only.
  • 🧬 Supply-chain hardening. sha_pinning_required and explicit patterns_allowed pin the action supply chain.
  • 🧱 Single source of truth. One authoritative policy resource per org; one owner per selected allow-list.
  • 🪪 Read-only by default. The org-wide GITHUB_TOKEN baseline defaults to read; write is opt-in per workflow.
  • 🛡️ Scoped OIDC trust. Subject-claim customization narrows the sub claim so keyless cloud federation is least-privilege, not org-wide.

🚀 Runbook

terraform init -backend=false
terraform validate
terraform fmt -check
terraform plan
terraform apply
terraform output

⚠️ Always pin the module source to a tag — ?ref=v1.0.0, never a branch. Branches move; tags are immutable.


🧪 Testing

Offline proof gate (no GitHub credentials required):

terraform fmt -check # zero formatting differences
terraform validate # zero errors
tflint # core rules — no dedicated GitHub ruleset exists

💬 Example Output

id = "financial-partners-org"
allowed_actions = "selected"
enabled_repositories = "selected"
sha_pinning_required = true
secret_names = toset(["PROD_DEPLOY_KEY"])
secret_ids = { "PROD_DEPLOY_KEY" = "financial-partners-org/PROD_DEPLOY_KEY" }
variable_names = { "TF_VERSION" = "1.12.0" }
variable_ids = { "TF_VERSION" = "financial-partners-org/TF_VERSION" }
secret_repository_access_ids = {}
variable_repository_access_ids = { "DEPLOY_ENV" = "financial-partners-org/DEPLOY_ENV" }
default_workflow_permissions = "read"
oidc_subject_claim_template = tolist(["repo", "context", "job_workflow_ref"])

🔍 Troubleshooting

Symptom Cause Resolution
403 Resource not accessible by integration Token missing admin:org (classic) or org Actions: read/write (fine-grained) Grant the required scope; identity must be an org owner
enabled_repositories_config is required when enabled_repositories = 'selected' Variable validation tripped Supply enabled_repositories_config.repository_ids, or set enabled_repositories to all/none
Perpetual diff on a selected secret's repo list Allow-list owned in both inline selected_repository_ids and secret_repository_access Own it in exactly one place
apply fails on sha_pinning_required Org/plan doesn't support SHA pinning Leave sha_pinning_required = null (GitHub default)
Secret allow-list 404s a repo id API eventual consistency / wrong id type Use numeric repo_id (not name); re-apply to clear transient races
You have exceeded a secondary rate limit on bulk for_each Too many secret/variable writes too fast Reduce parallelism (terraform apply -parallelism=2); retry after backoff
Org secrets/variables can't be created Org on Free plan Org-level Actions secrets/variables require GitHub Team or Enterprise
Missing required argument: organization_slug on workflow_permissions The provider requires the org slug for this resource Set workflow_permissions = { organization_slug = "<your-org>" }
Workflow token still has write after apply workflow_permissions not set (singleton unmanaged) Set the workflow_permissions object to enforce the read baseline
Perpetual diff on a selected variable's repo list Allow-list owned in both inline selected_repository_ids and variable_repository_access Own it in exactly one place
OIDC apply fails / empty sub claim include_claim_keys empty or contains an invalid claim key Provide a non-empty list of valid keys (e.g. repo, context, job_workflow_ref, environment)

🔗 Related Docs

  • tf-mod-github-repository — keystone module emitting repo_id consumed here
  • tf-mod-github-actions-repository — repo-scope counterpart in the Actions family
  • tf-mod-github-organization-settings — org governance (enable Actions org-wide)
  • integrations/github provider reference — github_actions_organization_permissions, _secret, _variable, _secret_repositories
  • GitHub Docs — managing GitHub Actions settings for an organization

💙 "Infrastructure as Code should be standardized, consistent, and secure."

Packages

 
 
 

Contributors

Languages