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.
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 explicitowner/repo@refpatterns. - 🎯 Enabled-repositories scope — choose whether
all,none, or onlyselectedrepositories may run Actions. - 🔑 Org Actions secrets — a
for_eachmap of encrypted org secrets withall/private/selectedvisibility,valueor (preferred) pre-encryptedvalue_encrypted. - 🧮 Org Actions variables — a
for_eachmap 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_requiredto force full-commit-SHA pinning of actions. - 🪪 Read-only token baseline — an org-wide default
GITHUB_TOKENscope (workflow_permissions), defaulting toreadso 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.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- ⭐ Star this repository to help others discover this Terraform module.
- 🤝 Connect with me on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
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!
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
Consumes numeric
repo_ids fromtf-mod-github-repository; emits the resolved policy plus secret/variable names and ids. See the 🔌 Cross-Module Contract.
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
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.secrets—for_eachmap of org Actions secrets keyed by secret name. - 🧮
github_actions_organization_variable.variables—for_eachmap of org Actions variables keyed by variable name. - 📇
github_actions_organization_secret_repositories.allow—for_eachmap of selected-repository allow-lists forselected-visibility secrets, keyed by secret name. - 📇
github_actions_organization_variable_repositories.variable_allow—for_eachmap of selected-repository allow-lists forselected-visibility variables, keyed by variable name (the variable-side mirror of.allow). - 🪪
github_actions_organization_workflow_permissions.workflow— singleton org-wide defaultGITHUB_TOKENscope (readby default;count-gated 0/1). - 🛡️
github_actions_organization_oidc_subject_claim_customization_template.oidc— singleton org OIDC subject-claim template (include_claim_keys; off unless set).
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
| Provider | integrations/github ~> 6.0 (current 6.12.1) |
⚠️ Provider source isintegrations/github, never the deprecatedhashicorp/github. Migrate old state withterraform state replace-provider. ℹ️ Auth (PAT / GitHub CLI / OAuth / GitHub App) and the target org (owner/GITHUB_OWNER) are provider concerns — this module declares noowner/token/app_authvariables.
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
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"
}
}
}| 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) |
| 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.
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_onlyis the strictest non-noneposture — 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_allowedis only honored whenallowed_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_requiredis not available on every plan; forcing it can failapplyon orgs that don't support it. Leftnull(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_idsare numeric repo ids (repo_id), not names.enabled_repositories_configis required whenenabled_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(setkey_idwith 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 aselectedsecret, own the allow-list in exactly one place — inlineselected_repository_idsorsecret_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@vNpatterns +selectedrepo scope is the tightest practical posture short oflocal_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" }
}
}
⚠️ Bulkfor_eachover 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-repositoryemitsrepo_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)
}
}🔒
readis the least-privilege baseline.organization_slugis required by the provider for this resource — it is the only place the module accepts the org name (sibling org resources deriveownerfrom 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 aselectedvariable's allow-list in exactly one place — inlineselected_repository_idsorvariable_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
subclaim is assembled from these keys. A cloud role (Azure federated credential, AWS IAM role) trusts asubpattern — a broad claim trusts every repo (repo:org/*), whilerepo:org/app:environment:prodtrusts exactly one repo and environment. Tighteninginclude_claim_keysis 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_namesis a safe, non-sensitive handle.
Policy (primary this resource)
enabled_repositories—all(default) /none/selected.allowed_actions—selected(secure default) /all/local_only.sha_pinning_required—bool, defaultnull(GitHub-managed).allowed_actions_config—github_owned_allowed,verified_allowed,patterns_allowed(honored only whenallowed_actions = "selected").enabled_repositories_config—repository_ids(required whenenabled_repositories = "selected").
Secrets / variables / allow-lists
secrets—map(object)🔒 sensitive; visibility +value/value_encrypted(+key_id, optional string — id of the public key used to encryptvalue_encrypted; required with it) + optionalselected_repository_ids.variables—map(object);value+ visibility + optionalselected_repository_ids.secret_repository_access—map(object);selected_repository_idskeyed by secret name.variable_repository_access—map(object);selected_repository_idskeyed by variable name (variable-side mirror ofsecret_repository_access).
Org-wide governance (singletons)
workflow_permissions— optionalobject(nullable singleton); org-wide defaultGITHUB_TOKENscope:organization_slug(required),default_workflow_permissions(read/write, defaultread),can_approve_pull_request_reviews(defaultfalse).null(default) leaves it unmanaged.oidc_subject_claim_template— optionalobject(nullable singleton); org OIDC subject-claiminclude_claim_keys.null(default) leaves it unmanaged (GitHub's defaultsubapplies).
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)
})| 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_idsare non-sensitive handles.
idsemantics.github_actions_organization_permissions.this.idis the organization itself (the policy is a singleton per org) — there is nonode_id/slugfor the policy. Secret and variable resources exposeidonly; GitHub Actions secrets/variables have no node id or ARN.- Authoritative vs additive.
github_actions_organization_permissionsis 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 lastapplywins and the others churn. - Two ways to own a
selectedallow-list. Inlinesecrets[*].selected_repository_idsand the standalonegithub_actions_organization_secret_repositories.allowresource 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.secretsis markedsensitive = true, sofor_eachcannot iterate it directly;main.tfiteratesnonsensitive(toset(keys(var.secrets)))and reads sensitive fields back by key. Prefervalue_encrypted(setkey_idwith it) so plaintext never enters state; never echo a secret value into an output. enabled_repositories/allowed_actionsinterplay.enabled_repositories_configis only consumed whenenabled_repositories = "selected";allowed_actions_configonly whenallowed_actions = "selected". Thedynamicblocks 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 fromtf-mod-github-repository.repo_id, never the repo name. - No ForceNew traps here. Changing
allowed_actions/enabled_repositoriesupdates in place; renaming a secret/variable key (the map key) destroys-and-recreates that one entry (secret_name/variable_nameis the identity). - Eventual consistency. The GitHub REST API is eventually consistent — a freshly created repo's
repo_idmay briefly 404 when referenced by an allow-list. Terraform's dependency graph normally orders this correctly; an occasional re-applyresolves transient races. - Read-only token baseline.
workflow_permissionssets the org-wide defaultGITHUB_TOKENscope; it defaults toread(the provider's own default too), so workflows must opt into write via per-workflowpermissions:. It is a singleton gated withcount(0/1) and is authoritative — only one instance should own it per org. The provider requiresorganization_slugfor 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 deriveownerfrom 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 standalonegithub_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_templateis an off-by-default singleton. When set, it customizes thesubclaim GitHub mints in Actions OIDC tokens org-wide; narrowinginclude_claim_keyslets cloud trust policies bind to a tight subject (per-repo / per-environment), turning keyless federation into least-privilege. Leaving itnullkeeps 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.
- 🔒 Secure by default.
allowed_actions = "selected"blocks arbitrary third-party actions out of the box; secrets default toprivatevisibility. - 🪪 Least privilege.
selectedrepo scope and per-secret allow-lists keep secret blast radius minimal;verified_allowed = falsefor the tightest Marketplace posture. - 🤫 No secret leakage. Secret inputs are
sensitive; outputs expose names and ids only. - 🧬 Supply-chain hardening.
sha_pinning_requiredand explicitpatterns_allowedpin the action supply chain. - 🧱 Single source of truth. One authoritative policy resource per org; one owner per
selectedallow-list. - 🪪 Read-only by default. The org-wide
GITHUB_TOKENbaseline defaults toread; write is opt-in per workflow. - 🛡️ Scoped OIDC trust. Subject-claim customization narrows the
subclaim so keyless cloud federation is least-privilege, not org-wide.
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.
Offline proof gate (no GitHub credentials required):
terraform fmt -check # zero formatting differences
terraform validate # zero errors
tflint # core rules — no dedicated GitHub ruleset existsid = "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"])
| 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) |
tf-mod-github-repository— keystone module emittingrepo_idconsumed heretf-mod-github-actions-repository— repo-scope counterpart in the Actions familytf-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."