Gestire un’organizzazione GitHub con Terraform: repository, team e permessi as-code

Man mano che un’organizzazione GitHub cresce — più repository, più team, collaboratori esterni, regole di branch protection diverse da un progetto all’altro — gestire tutto a mano dalle impostazioni web diventa rapidamente una fonte di errori: permessi concessi e mai revocati, branch protection impostata su un repo e dimenticata su un altro, nessuna storia di chi ha cambiato cosa e quando.

Questo articolo descrive come portare la governance di una organizzazione GitHub sotto Terraform, esattamente come si farebbe con l’infrastruttura cloud: repository, team, appartenenza ai team, permessi e branch protection diventano codice versionato, rivedibile in pull request e applicato tramite una pipeline CI/CD.

Se l’organizzazione ha già un repository per l’infrastruttura cloud (una landing zone Azure, AWS o GCP gestita con Terraform), conviene creare un repository separato e dedicato per la governance GitHub, con la propria autenticazione e la propria pipeline. Vedremo perché’ questa separazione conta, come autenticare Terraform verso le API GitHub senza un Personal Access Token legato a una persona, e come modellare le relazioni many-to-many tra team, utenti e repository.

Architettura: due repository, due credenziali

La scelta architetturale di partenza è avere due repository separati invece di un’unica cartella dentro il repository dell’infrastruttura cloud:

  • cloud-infra (nome di esempio) — gestisce l’infrastruttura cloud (subscription Azure, account AWS, ecc.) con il provider corrispondente (azurerm, aws, …).
  • github-governance (nome di esempio) — gestisce l’organizzazione GitHub (repository, team, membership, branch protection) con il provider integrations/github.

Il motivo non è solo estetico. Un errore nel codice che gestisce la membership dell’organizzazione può rimuovere l’accesso di una persona all’intera org GitHub, incluso l’accesso al repository che ospita la pipeline stessa: tenere questo codice separato dall’infrastruttura cloud limita il raggio d’azione di un simile errore, e permette di dare alla pipeline GitHub una identita’ con permessi completamente diversi da quella usata per Azure.

Le due credenziali usate non si sovrappongono mai:

  • OIDC Azure — usato solo per leggere/scrivere il file di stato Terraform sullo storage account condiviso. Non ha alcun permesso sulle risorse cloud: due identita’ dedicate (una in sola lettura, una in scrittura) hanno solo i ruoli data-plane necessari ad accedere al blob dello stato, niente permessi piu’ ampi sulla subscription.
  • GitHub App — usata dal provider Terraform integrations/github per chiamare le API GitHub e gestire repository, team e membership dell’organizzazione.

NOTA: Le due credenziali risolvono due problemi diversi e restano separate anche a livello concettuale: l’una autentica Terraform verso Azure (solo per leggere/scrivere lo stato), l’altra autentica Terraform verso GitHub (per gestire l’organizzazione). Nessuna delle due sostituisce l’altra.

Prerequisiti

  • Terraform — >= 1.5
  • Un’organizzazione GitHub — non un account utente personale: team e membership a livello di organizzazione esistono solo per le Organization
  • Un ruolo di owner sull’organizzazione — per creare la GitHub App e installarla
  • Un ruolo User Access Administrator (o Owner) — sulla subscription Azure Management, per assegnare i ruoli data-plane sullo storage account del backend
  • gh CLI e jq — per lo script di discovery/import dello stato GitHub esistente

ATTENZIONE: Se l’organizzazione ha già repository, team e membri, seguire il processo di adozione descritto più avanti prima di eseguire qualunque apply: senza import, il primo apply proverebbe a creare da zero risorse che esistono già, oppure — peggio — a rimuovere membership non presenti nel codice.

Passo 1: Creare e installare la GitHub App

Il provider Terraform integrations/github supporta diversi metodi di autenticazione: un Personal Access Token (PAT), un token OAuth, o una GitHub App. Per l’automazione CI/CD la GitHub App e’ la scelta raccomandata: i permessi sono scoped esplicitamente (non ereditano tutti quelli dell’utente proprietario del token), l’identita’ non e’ legata a una persona fisica, e l’accesso si revoca disinstallando la App senza toccare login o 2FA di nessuno.

Nell’organizzazione GitHub, da Settings > Developer settings > GitHub Apps > New GitHub App, si crea una App con questi permessi:

  • Repository > Administration — Read and write — gestione impostazioni e branch protection dei singoli repository
  • Repository > Metadata — Read-only — obbligatorio, impostato di default
  • Organization > Administration — Read and write — necessario per creare repository a livello di organizzazione
  • Organization > Members — Read and write — gestione membership e team

Figura 1: Pagina dei permessi della GitHub App: sezioni Repository e Organization permissions configurate.

Dopo la creazione, generare una private key (.pem) dalla pagina della App, annotare l’App ID mostrato in cima alla pagina, installare la App sull’organizzazione con scope “All repositories”, e annotare l’Installation ID (l’ultimo numero nell’URL della pagina di installazione).

Figura 2: Pagina generale della GitHub App, con App ID, Client ID e il pulsante per generare la private key.

Figura 3: La GitHub App installata sull’organizzazione, sotto Settings > GitHub Apps > Installed GitHub Apps.

ATTENZIONE: La private key della GitHub App non ha un equivalente keyless come l’OIDC federato usato per Azure: serve sempre a firmare il JWT con cui la App si autentica verso le API GitHub. Va trattata come un segreto a tutti gli effetti (GitHub Actions secret, mai in un file .tf o .tfvars).

Una volta ottenuti App ID, Installation ID e private key, vanno impostati come variabili e secret del repository (Settings > Secrets and variables > Actions): le variabili in chiaro per App ID/Installation ID (non sensibili), un secret per la private key.

Figura 4: Repository variables configurate su GitHub Actions: valori non sensibili, mostrati in chiaro.

Figura 5: Repository secret configurato per la private key della GitHub App: il valore resta sempre oscurato da GitHub, anche per chi ha accesso in scrittura al repository.

Passo 2: Perche’ azuread e non msgraph per le identita’ Entra

Le due identità Azure usate per l’accesso al backend state sono create con il provider hashicorp/azuread, non con il più recente microsoft/msgraph. Vale la pena chiarire la differenza, perché’ i due provider convivono nel registry e la scelta non è ovvia a prima vista.

Il provider msgraph sta a azuread come azapi sta a azurerm: è un layer sottile e generico sopra le API Microsoft Graph, pensato per gestire risorse Entra non ancora coperte da resource tipizzate, o per accedere a funzionalità rilasciate da poco senza aspettare che azuread le implementi. E’ in Public Preview. Microsoft stessa lo posiziona come complemento, non sostituto: azuread resta il layer di convenienza raccomandato per i casi standard — ed e’ esattamente il nostro caso: App Registration, Service Principal e Federated Identity Credential per OIDC sono tutte risorse tipizzate, mature e generalmente disponibili in azuread.

NOTA: Regola pratica: usare azuread per tutto cio’ che ha gia’ una resource tipizzata (applicazioni, service principal, federated credential, gruppi, utenti); considerare msgraph solo per funzionalita’ Entra specifiche non ancora coperte da azuread, sapendo che è ancora in preview.

Passo 3: Bootstrap delle identita’ Azure per l’accesso al backend

Il repository di governance GitHub non gestisce alcuna risorsa Azure: il suo state Terraform, pero’, puo’ comunque vivere sullo stesso storage account condiviso usato dagli altri repository (accesso via Azure AD, non chiave di storage). Servono quindi due identita’ Entra dedicate, create una tantum da una cartella bootstrap-oidc/ interna al repository stesso — autosufficiente, senza dipendere da nessun altro repository.

Un errore facile da fare qui e’ assegnare a queste identita’ i ruoli Reader/Contributor come se dovessero gestire risorse Azure. Non e’ cosi’: Reader e Contributor sono ruoli control-plane ARM e non includono le dataActions necessarie a leggere o scrivere un blob. Con use_azuread_auth=true nel backend (niente autenticazione tramite chiave dello storage account), l’accesso ai dati richiede un ruolo data-plane esplicito:

local.backend_data_roles mappa ro su Storage Blob Data Reader e rw su Storage Blob Data Contributor. Nessuna delle due identita’ riceve un ruolo a livello di subscription: possono solo leggere/scrivere il blob dello stato, niente altro.

ATTENZIONE: Questo stesso problema si presenta per qualunque service principal che usi il backend azurerm con use_azuread_auth=true: se il piano di Terraform fallisce l’init con un errore 403 sullo storage account pur avendo Contributor sulla subscription, la causa è quasi sempre l’assenza del ruolo data-plane.

Passo 4: Modellare repository, team e permessi in Terraform

Il provider integrations/github espone risorse dedicate per ogni concetto dell’organizzazione: github_membership per l’appartenenza all’org, github_team e github_team_membership per i team, github_repository per i repository, github_team_repository per i permessi dei team sui repository, github_branch_protection per le regole sul branch di default.

Le relazioni many-to-many (un team ha piu’ utenti, un team ha permessi su più repository) sono modellate con mappe a chiave composita, piuttosto che con blocchi annidati: rende ogni relazione una singola entry indipendente, facile da aggiungere o rimuovere senza toccare il resto della struttura.

Con questo schema, dare al team platform-team accesso admin sul repository app-backend diventa una singola riga in terraform.tfvars:

Passo 5: Creare i primi repository, team e permessi

A questo punto la governance dell’organizzazione e’ pronta per essere popolata: si tratta di dichiarare in terraform.tfvars (o in un file *.auto.tfvars.json, caricato automaticamente da Terraform senza dover passare -var-file) i repository, i team e i permessi desiderati, poi lasciare che sia il plan a mostrare esattamente cosa verra’ creato.

terraform init -backend-config=state.config
terraform plan # deve mostrare esattamente le risorse attese, “N to add”
# rivedere il plan, poi aprire una pull request e fare merge

ATTENZIONE: Il file con i valori (terraform.tfvars o *.auto.tfvars.json) VA COMMITTATO nel repository, anche se rivela membri, team e permessi interni: e’ la fonte di verita’ che la CI usa per sapere cosa e’ gia’ gestito. Se resta escluso da git (per esempio da una regola generica *.tfvars.json ereditata da un altro progetto), il primo plan eseguito in una pipeline CI parte da una configurazione vuota e propone di DISTRUGGERE tutto cio’ che era stato creato in precedenza, repository che ospita la pipeline stessa incluso. Se un plan mostra un numero di risorse da distruggere vicino al totale di quelle gestite, non fare mai apply: quasi certamente il file dati non e’ arrivato nel checkout della CI.

Un secondo problema, più subdolo perché’ non blocca il plan ma modifica silenziosamente risorse reali: il provider github assegna valori di default propri (true) a campi come allow_merge_commit, allow_squash_merge e allow_rebase_merge quando non sono specificati esplicitamente in configurazione. Se questi campi non vengono dichiarati fin dall’inizio, un apply successivo — magari mesi dopo, quando qualcuno disattiva manualmente un pulsante di merge dal portale — lo riabilita silenziosamente al primo plan/apply seguente, perché’ il provider lo riporta al proprio valore di default. Regola pratica: dichiarare esplicitamente in configurazione ogni campo che si vuole tenere sotto controllo, non solo quelli che interessano al momento della creazione, e controllare nel plan ogni singolo attributo che cambia, non solo il conteggio finale di add/change/destroy.

NOTA: Se invece l’organizzazione ha già repository, team e membri creati manualmente, e si vuole farli gestire da Terraform senza ricrearli, lo strumento e’ terraform import: si legge lo stato attuale tramite le API GitHub e si importa ogni risorsa nello stato Terraform una per una. È un percorso diverso da quello descritto qui (che parte da zero) e va affrontato con gli stessi accorgimenti appena visti: il file con i valori importati va comunque committato, e ogni campo con un default del provider va reso esplicito per evitare modifiche indesiderate al primo apply.

Passo 6: La pipeline CI/CD

Due workflow GitHub Actions gestiscono il ciclo di vita del codice: terraform-plan.yml sulle pull request, che esegue terraform fmt, terraform validate, una scansione di sicurezza con Trivy e infine terraform plan, pubblicandone l’output come commento sulla PR; terraform-apply.yml sui push a main, che esegue terraform apply in modo automatico dopo il merge.

Figura 6: Una pull request con il commento automatico del terraform plan pubblicato dalla pipeline.

Figura 7: Il dettaglio di un’esecuzione GitHub Actions del job di plan, completata con successo.

Figura 8: Il dettaglio di un’esecuzione del job di apply, anch’essa completata con successo dopo il merge su main.

Le credenziali della GitHub App arrivano al provider Terraform solo tramite variabili d’ambiente TF_VAR_*, mai scritte in un file:

Passo 7: Perche’ il gate è branch protection, non un Environment

Una pipeline che applica automaticamente modifiche a produzione dovrebbe avere un gate umano prima dell’apply. Il meccanismo più diretto in GitHub Actions è un Environment con required reviewer: l’apply si blocca finche’ qualcuno non approva esplicitamente quella esecuzione.

Su un repository privato con piano GitHub Free, pero’, gli Environment protection rules con required reviewer non sono disponibili: servono GitHub Team o Enterprise Cloud. Il gate scelto e’ quindi la branch protection su main — richiedere almeno una approvazione sulla pull request e il superamento dello status check “plan” prima del merge — combinata con il fatto che l’apply gira solo dopo, mai prima, dell’approvazione umana sulla PR.

NOTA: Lo stesso limite di piano vale per la SSO organizzativa con Microsoft Entra ID: e’ una funzionalita’ di GitHub Enterprise Cloud, non disponibile su Free o Team. E’ comunque un problema diverso dall’autenticazione della pipeline: la SSO governa come le persone accedono all’organizzazione, non come Terraform chiama le API GitHub — anche con la SSO attiva, l’automazione userebbe comunque una GitHub App.

Verifica

Il modo piu’ affidabile per verificare l’intera catena — OIDC Azure per il backend, GitHub App per il provider github, commento automatico sulla PR, apply su main — e’ aggiungere temporaneamente un data source di sola lettura che forza una vera chiamata API:

Aprendo una pull request con questa aggiunta, il commento automatico deve mostrare organization_verified valorizzato con l’ID reale dell’organizzazione: se compare, l’intera catena di autenticazione funziona davvero, non solo sulla carta. Dopo il merge, lo stesso output deve comparire anche nel log dello step di apply.

  • Una pull request di prova — il commento automatico deve mostrare il plan con organization_verified valorizzato, non un errore di autenticazione
  • Il merge della pull request — deve eseguire l’apply su main senza errori, con lo stesso output confermato
  • Branch protection su main — deve mostrare lo status check “plan” tra quelli richiesti

COMPLETATO: Con questo test end-to-end eseguito con successo — plan sulla pull request e apply su main entrambi verdi, con la lettura reale dell’organizzazione confermata in entrambi — la governance dell’organizzazione GitHub è ora sotto controllo di versione: ogni cambiamento futuro a repository, team o permessi passa da una pull request rivedibile, invece che da un click nelle impostazioni web.

Figura 9: L’elenco dei repository dell’organizzazione dopo la creazione via Terraform.

Figura 10: L’elenco dei team dell’organizzazione, anch’essi gestiti da Terraform.

Risoluzione problemi comuni

I sei problemi seguenti sono quelli effettivamente incontrati mettendo in funzione questa pipeline per la prima volta: nessuno era prevedibile leggendo solo la documentazione dei singoli strumenti, ognuno emerge solo eseguendo davvero un plan o un apply in CI.

Errore 403 durante terraform init sul backend azurerm

Il service principal ha un ruolo control-plane (Reader/Contributor) ma manca del ruolo data-plane sullo storage account. Verificare che sia assegnato Storage Blob Data Reader (per il piano) o Storage Blob Data Contributor (per l’apply) direttamente sullo storage account del backend, come descritto nel Passo 3.

 

terraform plan fallisce su “Error acquiring the state lock” nonostante Storage Blob Data Reader

Storage Blob Data Reader copre le operazioni di lettura, ma acquisire il lock (lease) sul blob dello stato — cosa che Terraform fa anche durante un semplice plan, per evitare di leggere uno stato a meta’ di una scrittura concorrente — richiede il permesso di lease, incluso solo in Storage Blob Data Contributor/Owner. La soluzione non e’ alzare i permessi del ruolo RO (vanificherebbe la separazione RO/RW): il plan non scrive mai lo stato, quindi disabilitare il lock solo li’ e’ sicuro.

terraform plan -input=false -no-color -lock=false -out=tfplan

ATTENZIONE: Se lo stesso comando e’ dentro una pipe verso tee per salvare l’output (come nel commento automatico sulla PR), la shell di default riporta come exit code quello dell’ULTIMO comando della pipe (tee), non quello di terraform plan: un plan fallito puo’ quindi risultare come step verde nella Action. Aggiungere sempre set -o pipefail prima della pipe.

 

terraform init funziona, ma il primo plan/apply in CI fallisce lo stesso sullo state lock

Se il file di stato per quella chiave (key) non e’ mai esistito prima, nessuna delle due identita’ RO/RW puo’ crearlo da sola: creare il blob iniziale e’ un’operazione che il backend azurerm esegue durante il primo init/apply, ma richiede piu’ permessi di quelli concessi a un’identita’ pensata solo per leggere/scrivere un blob gia’ esistente. Va risolto con un singolo terraform init -backend-config=state.config eseguito in locale con un account con permessi ampi sullo storage account, una tantum per ciascuna chiave di stato: da li’ in avanti RO e RW bastano.

 

AADSTS700213: No matching federated identity record found

Dal 15 luglio 2026 i repository GitHub di nuova creazione usano di default un formato “immutabile” del subject claim OIDC, con gli ID numerici di organizzazione e repository invece dei soli nomi — pensato per sopravvivere a rename e trasferimenti. Il formato classico repo:org/repo:… smette quindi di corrispondere silenziosamente: il token che GitHub emette usa repo:org@org-id/repo@repo-id:… e nessuna federated credential esistente lo riconosce.

gh api orgs/<org> –jq .id
gh api repos/<org>/<repo> –jq .id

Con questi due ID, il subject corretto per un repository creato dopo quella data diventa:

repo:contoso@123456789/github-governance@987654321:pull_request

 

Stesso errore AADSTS700213, ma solo sul job di apply (non sul plan)

Un job GitHub Actions che referenzia un Environment (environment: production nello YAML) cambia il subject claim del token OIDC da “basato su ref” a “basato su environment”, anche se quel job gira solo in risposta a un push su un branch specifico. Il subject per il job RW non e’ quindi repo:…:ref:refs/heads/main ma:

repo:contoso@123456789/github-governance@987654321:environment:production

Se si aggiunge o rimuove un blocco environment: da un job dopo aver gia’ creato le federated credential, va sempre verificato se il subject atteso e’ ancora corretto.

 

terraform import fallisce con “resource not found” su github_team

Il provider integrations/github accetta sia lo slug del team sia l’ID numerico come identificativo per l’import. Se uno dei due formati fallisce, verificare lo slug esatto con gh api orgs/<org>/teams e provare l’alternativa numerica.

 

L’azione Trivy fallisce con “unable to find version”

aquasecurity/trivy-action pubblica i tag release con il prefisso “v” (v0.36.0), non come numero puro (0.36.0): un pin senza la “v” fallisce la risoluzione dell’action prima ancora di eseguire lo step. Verificare sempre i tag reali con gh api repos/aquasecurity/trivy-action/tags –jq ‘.[].name’ invece di fidarsi della prima versione trovata in un esempio.

 

Il plan mostra la rimozione di membri non previsti

Prima di qualunque apply che tocca members.tf, rivedere sempre l’output del plan con attenzione: una entry mancante in var.members rispetto allo stato reale importato significa che quel membro verrà rimosso dall’organizzazione al primo apply.

Uso quotidiano: come si fa una modifica

Con il bootstrap completato e l’organizzazione importata, il ciclo di vita di ogni modifica futura — aggiungere un membro, cambiare un permesso, creare un repository — segue sempre lo stesso schema, senza mai eseguire terraform apply a mano:

  • 1. Modificare il codice — aggiungere o cambiare una entry in variables.tf/terraform.tfvars o in org-state.auto.tfvars.json su un branch
  • 2. Aprire una pull request — parte automaticamente solo il plan (identita’ RO): il risultato compare come commento sulla PR, nulla viene scritto su GitHub
  • 3. Rivedere il diff — controllare che il plan mostri esattamente le modifiche attese — specialmente su membership e permessi, dove un campo dimenticato puo’ tradursi in una rimozione di accesso
  • 4. Fare merge — il merge su main fa scattare l’apply (identita’ RW) in modo completamente automatico: non c’è nessun comando da lanciare manualmente dopo il merge

NOTA: Questo stesso schema — PR che genera solo un plan, merge che genera l’apply — vale identico anche per il repository dell’infrastruttura cloud. L’unica differenza pratica e’ il peso delle conseguenze: un merge sul repository di governance GitHub cambia permessi e repository, un merge su un repository Azure/AWS/GCP può creare risorse a pagamento (una rete con firewall dedicato, un cluster, un database gestito). Su un ambiente dimostrativo o di test, vale la pena lasciare aperta la pull request senza mergiarla finchè non si è pronti a sostenerne il costo — il plan resta comunque consultabile in ogni momento, sola lettura.

Figura 11: Una pull request sul repository dell’infrastruttura cloud: il plan propone quasi 2.000 risorse, ma resta una pull request aperta finche’ non si decide consapevolmente di fare merge.

Conclusioni

Portare la governance di una organizzazione GitHub sotto Terraform trasforma un insieme di impostazioni sparse tra decine di pagine web in uno stato dichiarativo, versionato e rivedibile in pull request. La separazione in un repository dedicato, con una GitHub App scoped e identita’ Azure limitate al solo accesso allo stato, mantiene il raggio d’azione di un errore contenuto: un problema nella gestione GitHub non puo’ toccare l’infrastruttura Azure, e viceversa.

Per chi vuole estendere questa base, i passi naturali successivi sono: aggiungere github_repository_ruleset per policy piu’ granulari delle classiche branch protection rule, automatizzare la creazione di nuovi repository da un template standard con github_repository_file per i file di base (CODEOWNERS, workflow condivisi), e valutare un upgrade a GitHub Team se servisse davvero il gate di approvazione su Environment invece della sola branch protection.