Authoritatively governs which repositories an already-installed GitHub App can access — declares the complete selected-repository allow-list for one App installation behind a single, least-privilege module boundary. Built for integrations/github v6.x.
This module wraps a single github_app_installation_repositories resource (this) and gives you:
- 🔐 Authoritative repo access for an installed App — declare the exact set of repositories an existing GitHub App installation may reach; the resource owns the whole set.
- 🧹 Drift-free reconciliation — any repository the App can currently reach but absent from
selected_repositoriesis removed on apply. State becomes the access model. - 🎯 Set-typed, order-independent —
selected_repositoriesis aset(string)of bare repo names, so reordering never churns the plan and adding/removing one name is a minimal diff. - 🔗 Composable by name — wire repository names straight from
tf-mod-github-repository(module.repository.id), no numeric ids to look up. - ✅ Fail-fast validation —
installation_idmust be the numeric installation id (digits only); every repo entry must be a bare name (noowner/repo, no ids); the set must be non-empty. - 🚧 Clear boundary — installs nothing; it only governs an installation that already exists. App installation is a one-time, out-of-band action.
💡 Why it matters: A GitHub App with broad repo access is a high-value path into your source supply chain. Making the App's repository reach an authoritative, reviewed allow-list means the Terraform diff is the access review — no repo is silently in scope.
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 module governs access an already-installed GitHub App has, not the repository itself — it consumes repository names from the keystone repository module and is a terminal leaf: nothing in this suite wires from its outputs.
flowchart LR
repo["tf-mod-github-repository<br/>(keystone)"]
appinst["tf-mod-github-app-installation<br/>(THIS MODULE)"]
repo -->|"id (repo name)"| appinst
style appinst fill:#8957E5,color:#fff
style repo fill:#24292F,color:#fff
This module consumes bare repository names (the id output of tf-mod-github-repository) into selected_repositories; it emits id / selected_repositories for access-review and compliance tooling — see the Typical wiring section. It is a terminal grant with no downstream module consumers in this suite.
A single, flat, authoritative repository-access association for an already-installed GitHub App — both arguments are required scalars/sets, so there are no nested or dynamic blocks.
flowchart TD
this["github_app_installation_repositories.this<br/>(keystone)<br/>authoritative repo allow-list for an installed App"]
style this fill:#8957E5,color:#fff
tf-mod-github-app-installation/
├── providers.tf # terraform >= 1.12.0; integrations/github ~> 6.0
├── variables.tf # installation_id, selected_repositories
├── main.tf # github_app_installation_repositories.this
├── outputs.tf # id, installation_id, selected_repositories
├── SCOPE.md # resource scope, token scopes, prerequisites, emits, gotchas
└── README.md # this file
module "app_repos" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-app-installation?ref=v1.0.0"
installation_id = "12345678" # numeric installation id — NOT the app id / client id / slug
selected_repositories = ["platform-api", "platform-web"]
}
⚠️ Theowner/organization and authentication (PAT / OAuth / GitHub App) are provider concerns — configure them on the caller'sprovider "github"block, never as module inputs. The provider identity must be an organization owner.
- The App must already be installed. This module does not install the GitHub App — installation is a one-time action via the GitHub UI or an App manifest, performed out of band. The module governs only which repositories an existing installation can reach. You supply the installation's id; you never create the installation here.
- You need the numeric
installation_id— not the App id, OAuth client id, or App slug. Find it at Settings → Installed GitHub Apps → your app → Configure; it is the number in the resulting URL (.../installations/<installation_id>). Or read it programmatically from thegithub_organization_app_installationsdata source (installations[*].id) or the REST API (see the Example Library). - Provider identity must be an organization owner. Governing another App's repository access is an org-admin operation — a repo-scoped token returns
403 / Resource not accessible. - Installation should use "only select repositories" access. This resource manages the selected repository set; confirm the installation's
repository_selectionisselected(notall) before applying. Converting an "all repositories" installation to a managed selected set is a meaningful change — make sure it is intended. - Authoritative / overwrite behavior. On apply the module sets the installation's repository access to exactly
selected_repositories. Any repository the App can currently reach but omitted here is removed. Inventory the installation's current access before the first apply. - Plan / edition: any org plan that supports GitHub Apps (Free / Team / Enterprise). No Enterprise-only features are used by this module.
| Scope / Permission | Required for | Notes |
|---|---|---|
admin:org (classic PAT) |
Managing a GitHub App installation's repository access | Org-owner-level scope. |
| Organization administration: Read & write (fine-grained PAT / GitHub App) | The fine-grained equivalent of admin:org for this operation |
The provider identity must be an organization owner, or an org-wide App granted this permission. |
⚠️ This is an org-admin operation — the running identity must be an organization owner. A repo-scoped token fails with403 / Resource not accessible. ℹ️ This resource is not compatible with the GitHub App Installation authentication method. Authenticate the provider with a PAT or OAuth token belonging to an org owner — not an App installation token.
Derived from SCOPE.md Emits — one row per output.
| Output | Type | Typically consumed by |
|---|---|---|
id |
string (installation id) |
Composition / depends_on ordering and audit. Equals the installation id and the terraform import id. |
installation_id |
string |
Audit / reporting — echoes the governed installation id. |
selected_repositories |
set(string) |
Access-review / compliance tooling — the exact repository access in effect after apply. |
idis the installation id.github_app_installation_repositoriesuses the installation id as its resourceid(andoutput.idreturns exactly that). There is nonode_id/slug/repo_id/html_urlhere because the resource manages an association set, not a first-class GitHub object — and there are no ARNs on GitHub. The import id is also the installation id (terraform import... 12345678).- Authoritative overwrite semantics. Applying sets the installation's selected repositories to exactly
var.selected_repositories. Any repo the App reaches today but not listed here is removed on apply. This is the opposite of the additive singular resource (below): there is no "leave the others alone" mode. The trade-off is the strength — the state is the complete, reviewable access model. installation_idis NOT the App id or client id. Three different numbers/strings live in GitHub's App world: the App id (identifies the App globally), the OAuth client id (Iv1.…-style, for OAuth flows), and the installation id (identifies this org's installation of the App). Only the installation id belongs here. Find it under Settings → Installed GitHub Apps → app → Configure (the number in the URL), or via thegithub_organization_app_installationsdata source (installations[*].id, matched onapp_slug). The module validates it is digits-only to catch the common mistake of pasting a slug or client id.installation_idis ForceNew (the identity). It is both the import id and the computedid, so changing it points the module at a different installation — Terraform destroys the old association and creates a new one. Treat a change as a re-target, not an in-place edit.- Modeled as a
set(string)of bare names. The provider schema is aTypeSetof strings (despite the docs prose saying "list"), so order is irrelevant, duplicates collapse, and adding/removing one name yields a minimal, stable plan. Repos are referenced by name (e.g.platform-api), neverowner/repoand never numeric ids — the owner is a provider concern. - Cannot drop to zero — and destroy leaves one repo behind. GitHub does not allow a "selected repositories" installation with zero repositories. The module enforces a non-empty set at plan time. For the same reason, destroying this resource leaves the App installed on one repository (a documented provider behavior) — finish removal in the GitHub UI, or switch the installation to "all repositories".
- Authoritative plural vs additive singular — why the plural. uses the authoritative
github_app_installation_repositories(this module) so the App's reach is declarative and drift-free, the same rationale asgithub_repository_collaboratorsover its singular. The additive singulargithub_app_installation_repositorymanages one repo at a time and leaves others untouched — useful only when per-repo grants must be delegated across separate states/owners. Documented alternative:
# DOCUMENTED ALTERNATIVE — not this module. Additive, one repo per resource,
# no authoritative reconcile (other repos the app reaches are left alone).
resource "github_app_installation_repository" "api" {
installation_id = "12345678"
repository = "platform-api"
}- No tags, no timeouts. GitHub has no resource-tagging concept and this resource exposes no
timeoutsblock — there is deliberately notags/tags_all/timeoutstail and noowner/token/app_authinputs. - Eventual consistency & secondary rate limits. Installation/repository changes can take a moment to propagate; a re-plan shortly after apply may briefly show movement. Driving many installations via
for_eachissues many serial API writes and can trip GitHub's secondary (abuse) rate limit (403withRetry-After) — apply in batches, lower-parallelism, or rely on the provider's back-off.
1 · Minimal — one installation, two repositories
module "app_repos" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-app-installation?ref=v1.0.0"
installation_id = "12345678"
selected_repositories = ["platform-api", "platform-web"]
}ℹ️ The set must be non-empty — GitHub cannot install an App on zero selected repositories.
2 · Cross-module — wire a repo name from `tf-mod-github-repository`
module "repository" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-repository?ref=v1.0.0"
name = "platform-api"
visibility = "private"
}
module "app_repos" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-app-installation?ref=v1.0.0"
installation_id = "12345678"
selected_repositories = [module.repository.id] # wire by id (== the repository name)
}3 · Build the allow-list from a `for_each`'d repository module
module "repos" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-repository?ref=v1.0.0"
for_each = toset(["platform-api", "platform-web", "data-pipeline"])
name = each.key
visibility = "private"
}
module "app_repos" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-app-installation?ref=v1.0.0"
installation_id = "12345678"
selected_repositories = [for r in module.repos : r.id] # every repo the module created
}4 · `for_each` at the module level — several installed Apps, each with its own allow-list
locals {
app_access = {
ci_bot = {
installation_id = "12345678"
repositories = ["platform-api", "platform-web"]
}
security_scanner = {
installation_id = "23456789"
repositories = ["platform-api", "data-pipeline", "regulated-loan-service"]
}
}
}
module "app_repos" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-app-installation?ref=v1.0.0"
for_each = local.app_access
installation_id = each.value.installation_id
selected_repositories = each.value.repositories
}5 · Two Apps over a shared repository group
locals {
shared_repos = ["platform-api", "platform-web"]
app_ids = {
ci_bot = "12345678"
security_scanner = "23456789"
}
}
module "app_repos" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-app-installation?ref=v1.0.0"
for_each = local.app_ids
installation_id = each.value
selected_repositories = local.shared_repos
}ℹ️ Each installation is authoritative only for itself — two Apps can both include the same repository without conflict. The module never reconciles one App's set against another's.
6 · Secure / hardened — explicit, reviewed allow-list
# Regulated repository set — the App's access is an explicit, reviewed allow-list.
# AUTHORITATIVE: applying this REMOVES the App from any repository not listed here.
module "scanner_repos" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-app-installation?ref=v1.0.0"
installation_id = "23456789"
selected_repositories = [
"regulated-loan-service",
"regulated-loan-service-docs",
]
}🔒 Because the set is authoritative, anyone who grants the App another repository on GitHub.com has that grant revoked on the next apply — exactly what you want for a regulated source supply chain. Keep the list short and reviewed.
7 · Discover the `installation_id` instead of hard-coding it
data "github_organization_app_installations" "all" {}
locals {
# Match the installation by the App's URL-friendly slug.
ci_bot = one([
for inst in data.github_organization_app_installations.all.installations :
inst if inst.app_slug == "my-ci-bot"
])
}
module "ci_bot_repos" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-app-installation?ref=v1.0.0"
installation_id = local.ci_bot.id # the numeric installation id, discovered
selected_repositories = ["platform-api", "platform-web"]
}💡
one(...)returns the single matching installation (or errors if the slug is ambiguous/absent), so a typo fails the plan instead of silently targeting the wrong App.
8 · Revoke access authoritatively — drop a name
# There is no "remove" verb: you revoke an App's access to a repo by REMOVING
# its name from the set and letting the authoritative reconcile do the work.
# Before: ["platform-api", "platform-web", "legacy-service"]
# After: ["platform-api", "platform-web"] # legacy-service is revoked on apply
module "app_repos" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-app-installation?ref=v1.0.0"
installation_id = "12345678"
selected_repositories = ["platform-api", "platform-web"]
}
⚠️ The plan shows the dropped repository explicitly as a removal — review it; this is a real access revocation.
9 · End-to-end composition — repositories + App access
module "repository_api" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-repository?ref=v1.0.0"
name = "platform-api"
visibility = "private"
}
module "repository_web" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-repository?ref=v1.0.0"
name = "platform-web"
visibility = "private"
}
module "ci_app_repos" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-app-installation?ref=v1.0.0"
installation_id = "12345678"
selected_repositories = [module.repository_api.id, module.repository_web.id]
}10 · Auditing output — expose the applied access across a fleet
module "app_repos" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-app-installation?ref=v1.0.0"
for_each = local.app_access
installation_id = each.value.installation_id
selected_repositories = each.value.repositories
}
output "app_repo_access" {
description = "Map of app key => repository names that App can access (for access review / compliance)."
value = { for k, m in module.app_repos : k => m.selected_repositories }
}11 · Importing an existing installation's repository set
module "app_repos" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-app-installation?ref=v1.0.0"
installation_id = "12345678"
selected_repositories = ["platform-api", "platform-web"]
}
# Then, once (the import id is the installation id):
# terraform import 'module.app_repos.github_app_installation_repositories.this' 12345678
⚠️ On the first plan after import, any repository the App can currently reach but missing fromselected_repositoriesshows as a removal — review carefully before applying.
12 · Documented alternative — the additive singular resource
# NOT this module. github_app_installation_repository manages ONE repo's grant
# at a time and never reconciles the others — use it only when per-repo grants
# must be delegated across separate states or owners.
resource "github_app_installation_repository" "api" {
installation_id = "12345678"
repository = "platform-api"
}
resource "github_app_installation_repository" "web" {
installation_id = "12345678"
repository = "platform-web"
}ℹ️ Prefer the authoritative plural (this module) for declarative, drift-free access. Reach for the singular only when you explicitly need additive, non-authoritative grants.
Identity
installation_id(string, required) — the numeric GitHub App installation id (e.g."12345678"). ForceNew (it is the resource id and import id). Not the App id, OAuth client id, or App slug. Validated digits-only.
Repository allow-list
selected_repositories(set(string), required) — the authoritative set of bare repository names the installation may access. Must be non-empty; each entry must be a bare name (letters, digits,.,_,-) — notowner/repo, not a URL, not a numeric id. Wire fromtf-mod-github-repository(module.repository.id).
No tags, no timeouts, no owner / token / app_auth inputs — auth and the target organization are provider concerns.
| Output | Description |
|---|---|
id |
Resource id — the installation id (also the terraform import id). Primary cross-module reference. |
installation_id |
The GitHub App installation id whose repository access is governed (echoed for audit/reporting). |
selected_repositories |
The applied authoritative set of repository names the App can access — the exact access in effect after apply. |
ℹ️ No
sensitiveoutputs — an installation id and a set of repository names are not secrets.
- 🔐 Authoritative = drift-free — the App's complete repository reach lives in state; out-of-band grants are reconciled away. The diff is the access review.
- 🎯 Explicit least-privilege allow-list — every repository the App can touch is named and reviewable; nothing is implicitly in scope.
- 🧱 Separation of duties — App installation (a one-time human/UI action) is deliberately kept separate from App repository access (this module, an org-admin/security concern with its own lifecycle and token scope).
- 🏢 Provider owns auth & owner — no
owner/token/app_authinputs; the module is portable across orgs by swapping provider config. - ✅ Fail fast — digits-only
installation_id, non-empty + bare-name validation onselected_repositories; invalid input fails at plan, not apply. - 🚫 No tags / timeouts tail — GitHub has neither; the module surface stays minimal.
terraform init -backend=false
terraform validate
terraform fmt -check
terraform plan
terraform apply
terraform output
⚠️ Review the plan before applying — because the set is authoritative, any repository dropped fromselected_repositories(or attached out-of-band) is a real access revocation/grant.⚠️ Pin the version. Always source at?ref=v1.0.0— never a branch. Branch sources drift silently and break reproducibility.
| Symptom | Cause | Resolution |
|---|---|---|
403 / Resource not accessible / Must have admin rights to Organization |
The provider identity is not an org owner / lacks the right scope | Use a classic PAT with admin:org, or a fine-grained token / App with Organization administration: Read & write, as an organization owner. |
| Auth fails when using a GitHub App installation token | This resource is not compatible with the GitHub App Installation auth method | Authenticate the provider with a PAT or OAuth token belonging to an org owner. |
| A repository shows as a removal on every plan | Authoritative reconcile — the repo was granted in the UI/out-of-band but is not in selected_repositories |
Add the name to selected_repositories, or accept the removal if the grant should not exist. |
selected_repositories must contain at least one repository name |
Empty set passed | GitHub cannot install an App on zero repositories — supply at least one name; to fully remove access, uninstall the App in the UI. |
After destroy, the App is still on one repository |
Documented GitHub behavior — an installation cannot drop to zero repositories | Finish removal in the GitHub UI, or switch the installation to "all repositories". |
404 Not Found on the installation |
Wrong installation_id (e.g. the App id, client id, or slug was used) |
Use the numeric installation id from Installed GitHub Apps → app → Configure (URL), or the github_organization_app_installations data source. |
| Plan wants to replace the whole resource | installation_id changed (it is ForceNew — the resource id) |
Expected when re-targeting a different installation; confirm the replace is intended. |
Each selected_repositories entry must be a bare repository name… |
An entry used owner/repo, a URL, or a numeric id |
Pass repository names only; the owner/org is a provider concern. |
installation_id must be the numeric GitHub App installation id (digits only) |
A slug or Iv1.… client id was passed |
Use the numeric installation id. |
403 You have exceeded a secondary rate limit on bulk apply |
Many installations driven via for_each issue many serial API writes |
Apply in smaller batches, lower terraform apply -parallelism, or let the provider's back-off retry pace the run. |
integrations/githubprovider —github_app_installation_repositoriesresource reference (the authoritative plural used here)integrations/githubprovider —github_app_installation_repositoryresource reference (the additive singular alternative)integrations/githubprovider —github_organization_app_installationsdata source (discovering installation ids)- Sibling modules — tf-mod-github-repository (keystone; emits the repo
id/name wired intoselected_repositories), tf-mod-github-repository-collaborators, tf-mod-github-team - GitHub docs — reviewing and managing installed GitHub Apps in an organization; selected vs. all repository access