Despliegue desde CI sin secretos (federación OIDC)
Un job de GitHub Actions despliega con Terraform en la nube usando su token OIDC, sin client secrets ni access keys guardados en el repositorio.
Problema
El pipeline necesita credenciales para desplegar, y una clave guardada como secret del repo es la credencial de larga duración más expuesta que tiene una organización: no vence sola, se copia entre repos y nadie sabe quién la usó. Los requisitos son: cero secretos estáticos, confianza acotada a un repositorio y a un environment concreto (no a cualquier rama ni a cualquier repo de la organización), permisos mínimos sobre un scope y tokens que duran minutos.
La topología es la misma en todas las nubes; lo que cambia es con qué se implementa cada rol.
Confianza federada
Azure
Federated identity credential (workload identity federation)
Uno por environment: issuer de GitHub + subject exacto + audience api://AzureADTokenExchange
Venís de AWS: allá este rol es IAM OIDC identity provider. No hay trust policy con comodines: cada environment, rama o tag es una credencial con subject exacto, con un máximo de 20 por identidad.
Venís de GCP: allá este rol es Workload Identity Federation (pool + provider OIDC). No hay condición CEL: cada environment, rama o tag es una credencial con subject exacto, con un máximo de 20 por identidad.
4 gotchas en este rol
- El subject tiene que coincidir exacto, sin comodines, y hay un máximo de 20 credenciales por identidad
- Una credencial recién creada puede fallar con AADSTS70021 durante unos minutos
- Crear varias credenciales en paralelo sobre la misma identidad da 409
- Desde el 15/07/2026, los repos nuevos, renombrados o transferidos usan otro formato de sub
AWS
IAM OIDC identity provider
Uno por cuenta para token.actions.githubusercontent.com; client_id_list = sts.amazonaws.com
Venís de Azure: allá este rol es Federated identity credential (workload identity federation).
Venís de GCP: allá este rol es Workload Identity Federation (pool + provider OIDC).
4 gotchas en este rol
GCP
Workload Identity Federation (pool + provider OIDC)
attribute_condition sobre repository_owner_id, repository_id y environment
Venís de Azure: allá este rol es Federated identity credential (workload identity federation).
Venís de AWS: allá este rol es IAM OIDC identity provider. La confianza es una condición CEL sobre atributos mapeados, y conviene acotarla por repository_id (inmutable) en vez del nombre.
4 gotchas en este rol
Identidad de despliegue
Azure
User-assigned Managed Identity
Sin secretos ni certificados; el workflow solo conoce client-id y tenant-id
Venís de AWS: allá este rol es IAM Role.
Venís de GCP: allá este rol es Service Account (impersonada).
Sin letra chica en este rol.
AWS
IAM Role
Trust policy: AssumeRoleWithWebIdentity con aud y sub exactos (StringEquals)
Venís de Azure: allá este rol es User-assigned Managed Identity. El rol junta identidad, confianza (trust policy) y permisos; en Azure son tres objetos separados. La trust policy admite StringLike, a diferencia del match exacto de un federated credential.
Venís de GCP: allá este rol es Service Account (impersonada).
2 gotchas en este rol
GCP
Service Account (impersonada)
roles/iam.workloadIdentityUser solo para el principalSet del repositorio
Venís de Azure: allá este rol es User-assigned Managed Identity.
Venís de AWS: allá este rol es IAM Role.
1 gotcha en este rol
Permisos mínimos
Azure
Azure RBAC
Un role assignment sobre el resource group destino
Venís de AWS: allá este rol es IAM policies adjuntas al rol.
Venís de GCP: allá este rol es IAM.
1 gotcha en este rol
AWS
IAM policies adjuntas al rol
Policies administradas acotadas; nunca AdministratorAccess
Venís de Azure: allá este rol es Azure RBAC.
Venís de GCP: allá este rol es IAM.
Sin letra chica en este rol.
GCP
IAM
Roles de proyecto acotados; nunca owner/editor
Venís de Azure: allá este rol es Azure RBAC.
Venís de AWS: allá este rol es IAM policies adjuntas al rol.
2 gotchas en este rol
Microsoft Azure
Implementación
- Confianza federada
- Federated identity credential (workload identity federation)Uno por environment: issuer de GitHub + subject exacto + audience api://AzureADTokenExchangeVenís de AWS: allá este rol es IAM OIDC identity provider. No hay trust policy con comodines: cada environment, rama o tag es una credencial con subject exacto, con un máximo de 20 por identidad.Venís de GCP: allá este rol es Workload Identity Federation (pool + provider OIDC). No hay condición CEL: cada environment, rama o tag es una credencial con subject exacto, con un máximo de 20 por identidad.
- Identidad de despliegue
- User-assigned Managed IdentitySin secretos ni certificados; el workflow solo conoce client-id y tenant-idVenís de AWS: allá este rol es IAM Role.Venís de GCP: allá este rol es Service Account (impersonada).
- Permisos mínimos
- Azure RBACUn role assignment sobre el resource group destinoVenís de AWS: allá este rol es IAM policies adjuntas al rol.Venís de GCP: allá este rol es IAM.
Modelo de costo
Escala a cero
Sin costo fijo
Managed identities, federated identity credentials y role assignments no tienen costo. Pagás solo los recursos que despliega el pipeline.
Piso mensual sin tráfico
USD 0/ mes
USD 0: no hay componentes con costo fijo.
Letra chica · 5 gotchas
El subject tiene que coincidir exacto, sin comodines, y hay un máximo de 20 credenciales por identidad
En Azure la confianza no es una policy: cada federated identity credential es un par issuer + subject que tiene que coincidir carácter por carácter con el sub del token. No hay comodines, así que necesitás una credencial por environment, rama o tag, con un tope de 20 por identidad. En AWS o GCP lo resolvés con StringLike o una condición CEL. Ojo: si cargás mal el subject, la credencial se crea sin error y la falla recién aparece en el login.
Con Contributor el pipeline no puede crear role assignments
Contributor no permite asignar roles en Azure RBAC (excluye Microsoft.Authorization/*/Write). Si tu Terraform asigna roles (por ejemplo, la identidad de una app sobre un Key Vault), Contributor no alcanza. La salida rápida es darle Owner o User Access Administrator al pipeline, y es justo lo que no querés. La salida correcta es Role Based Access Control Administrator con una condición que limite qué roles puede asignar (y que excluya los roles privilegiados).
Desde el 15/07/2026, los repos nuevos, renombrados o transferidos usan otro formato de sub
GitHub agrega los IDs numéricos al claim: repo:org@123/repo@456:environment:prod en lugar de repo:org/repo:environment:prod. Así un nombre reciclado no hereda la confianza. Los repos anteriores siguen con el formato viejo salvo que lo activen. Si renombrás un repo, el sub cambia y todas las credenciales exactas dejan de matchear. El módulo arma el subject con IDs cuando se los pasás.
Una credencial recién creada puede fallar con AADSTS70021 durante unos minutos
La credencial tarda en replicarse en la región. Si el mismo pipeline la crea y enseguida la usa, el token exchange puede fallar con "No matching federated identity record found for presented assertion" aunque el subject esté bien. Microsoft recomienda esperar y reintentar. Si el bootstrap y el primer deploy van en la misma corrida, separalos o agregá un reintento con espera.
Crear varias credenciales en paralelo sobre la misma identidad da 409
Las escrituras concurrentes de federated identity credentials bajo una misma user-assigned identity se rechazan con 409 Conflict. El provider azurerm las crea en serie desde la 3.40, así que un for_each no rompe. En ARM o Bicep, en cambio, un loop las crea en paralelo: usá el modo serial (batchSize 1) o encadená dependsOn.
Terraform
main.tf
# Despliegue desde CI sin secretos — Azure
#
# User-assigned managed identity con un federated identity credential por
# environment de GitHub (match exacto de issuer + subject) y un único role
# assignment sobre el resource group destino.
data "azurerm_client_config" "current" {}
locals {
github_issuer = "https://token.actions.githubusercontent.com"
# Único audience aceptado por Entra ID para federated identity credentials.
audience = "api://AzureADTokenExchange"
repo_path = var.github.owner_id == null ? "${var.github.owner}/${var.github.repository}" : "${var.github.owner}@${var.github.owner_id}/${var.github.repository}@${var.github.repository_id}"
# Si el job declara `environment:`, el sub es este (y no el de la rama).
subjects = { for env in var.environments : env => "repo:${local.repo_path}:environment:${env}" }
}
resource "azurerm_resource_group" "identity" {
name = "rg-${var.name}-identity"
location = var.location
tags = var.tags
}
resource "azurerm_user_assigned_identity" "deploy" {
name = "id-${var.name}"
resource_group_name = azurerm_resource_group.identity.name
location = azurerm_resource_group.identity.location
tags = var.tags
}
# azurerm crea las credenciales de una misma identidad en serie (desde 3.40);
# en ARM/Bicep hay que forzarlo (gotcha: fic-concurrent-writes-409).
resource "azurerm_federated_identity_credential" "github" {
for_each = local.subjects
name = "github-${each.key}"
user_assigned_identity_id = azurerm_user_assigned_identity.deploy.id
issuer = local.github_issuer
audience = [local.audience]
subject = each.value
}
resource "azurerm_role_assignment" "deploy" {
scope = var.target_scope_id
role_definition_name = var.role_definition_name
principal_id = azurerm_user_assigned_identity.deploy.principal_id
principal_type = "ServicePrincipal"
description = "GitHub Actions ${local.repo_path} (${join(", ", sort(tolist(var.environments)))})"
}variables.tf
variable "name" {
description = "Prefijo para nombrar los recursos (ej. \"deploy-web\")."
type = string
validation {
condition = can(regex("^[a-z0-9-]{3,24}$", var.name))
error_message = "name: 3-24 caracteres, minúsculas, números o guiones."
}
}
variable "location" {
description = "Región de Azure de la identidad (no tiene que ser la de los recursos que despliega)."
type = string
default = "eastus2"
}
variable "github" {
description = <<-EOT
Repositorio que puede desplegar. owner_id y repository_id (numéricos) activan el formato inmutable del
claim sub ("repo:owner@123/repo@456:..."), que GitHub usa por defecto en repos creados, renombrados o
transferidos desde el 15/07/2026 (gotcha: github-sub-format-changed).
EOT
type = object({
owner = string
repository = string
owner_id = optional(number)
repository_id = optional(number)
})
validation {
condition = (var.github.owner_id == null) == (var.github.repository_id == null)
error_message = "github: owner_id y repository_id van juntos (formato inmutable) o ninguno de los dos."
}
}
variable "environments" {
description = "Environments de GitHub que pueden desplegar. Uno por federated identity credential (sin comodines)."
type = set(string)
default = ["production"]
validation {
# Máximo 20 credenciales por identidad (gotcha: fic-exact-match-max-20).
condition = length(var.environments) >= 1 && length(var.environments) <= 20
error_message = "environments: entre 1 y 20 (límite de federated identity credentials por identidad)."
}
validation {
condition = alltrue([for e in var.environments : !strcontains(e, "*")])
error_message = "environments: los federated identity credentials no admiten comodines."
}
}
variable "target_scope_id" {
description = "ID del scope donde despliega el pipeline (idealmente un resource group, no la suscripción)."
type = string
validation {
condition = can(regex("^/subscriptions/[^/]+/resourceGroups/[^/]+$", var.target_scope_id))
error_message = "target_scope_id: tiene que ser un resource group (/subscriptions/<id>/resourceGroups/<nombre>)."
}
}
variable "role_definition_name" {
description = "Rol sobre el scope. Contributor no puede crear role assignments (gotcha: contributor-cannot-assign-roles)."
type = string
default = "Contributor"
validation {
condition = !contains(["Owner", "User Access Administrator"], var.role_definition_name)
error_message = "role_definition_name: Owner y User Access Administrator no; si el pipeline asigna roles, usá un role assignment con condición aparte."
}
}
variable "tags" {
description = "Tags comunes."
type = map(string)
default = {}
}outputs.tf
output "client_id" {
description = "AZURE_CLIENT_ID para azure/login (no es un secreto)."
value = azurerm_user_assigned_identity.deploy.client_id
}
output "tenant_id" {
description = "AZURE_TENANT_ID para azure/login."
value = data.azurerm_client_config.current.tenant_id
}
output "subjects" {
description = "Subjects exactos que acepta cada credencial. Compará con el sub real del token si el login falla con AADSTS70021."
value = local.subjects
}
output "workflow_snippet" {
description = "Fragmento mínimo del workflow."
value = <<-YAML
permissions:
id-token: write # sin esto el job no puede pedir el token OIDC
contents: read
jobs:
deploy:
environment: ${sort(tolist(var.environments))[0]}
steps:
- uses: azure/login@v2
with:
client-id: ${azurerm_user_assigned_identity.deploy.client_id}
tenant-id: ${data.azurerm_client_config.current.tenant_id}
subscription-id: <subscription del scope>
YAML
}versions.tf
terraform {
required_version = ">= 1.7.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 5.8"
}
}
}
provider "azurerm" {
features {}
# subscription_id se toma de ARM_SUBSCRIPTION_ID.
}Amazon Web Services
Implementación
- Confianza federada
- IAM OIDC identity providerUno por cuenta para token.actions.githubusercontent.com; client_id_list = sts.amazonaws.comVenís de Azure: allá este rol es Federated identity credential (workload identity federation).Venís de GCP: allá este rol es Workload Identity Federation (pool + provider OIDC).
- Identidad de despliegue
- IAM RoleTrust policy: AssumeRoleWithWebIdentity con aud y sub exactos (StringEquals)Venís de Azure: allá este rol es User-assigned Managed Identity. El rol junta identidad, confianza (trust policy) y permisos; en Azure son tres objetos separados. La trust policy admite StringLike, a diferencia del match exacto de un federated credential.Venís de GCP: allá este rol es Service Account (impersonada).
- Permisos mínimos
- IAM policies adjuntas al rolPolicies administradas acotadas; nunca AdministratorAccessVenís de Azure: allá este rol es Azure RBAC.Venís de GCP: allá este rol es IAM.
Modelo de costo
Escala a cero
Sin costo fijo
IAM y STS no tienen costo. Pagás solo los recursos que despliega el pipeline.
Piso mensual sin tráfico
USD 0/ mes
USD 0: no hay componentes con costo fijo.
Letra chica · 5 gotchas
IAM exige una condición sobre sub, pero acepta una que igual abre la puerta
El issuer de GitHub es el mismo para todas las organizaciones: sin acotar el sub, cualquier repositorio de GitHub podría asumir tu rol. Por eso IAM rechaza crear o actualizar una trust policy hacia ese provider si falta la condición sobre sub (o job_workflow_ref) o si es solo un comodín. Pero "repo:*" o "repo:mi-org/*" pasan la validación. El límite real lo ponés vos: este módulo usa StringEquals con un sub por environment.
Si el job declara un environment, el sub deja de tener la rama
GitHub incluye el environment en el sub cuando el job lo referencia; pull_request y la ref solo entran si el job no referencia un environment. Un job en main con environment production presenta repo:org/repo:environment:production, así que una trust policy que espera repo:org/repo:ref:refs/heads/main rechaza el AssumeRoleWithWebIdentity.
La sesión dura 1 hora por default, y encadenar roles la limita a 1 hora sí o sí
AssumeRoleWithWebIdentity da 3600 s por default; podés pedir hasta el MaxSessionDuration del rol (máximo 12 h), y pedir más que eso hace fallar la llamada. Si desde ese rol asumís otro (role chaining, típico en multi-cuenta), la sesión queda en 1 hora como máximo. Un apply largo que se pasa del límite falla a mitad de camino con el state a medio escribir.
Hay un solo OIDC provider por URL y por cuenta
Crear un segundo provider para token.actions.githubusercontent.com en la misma cuenta falla: IAM no permite registrar la misma URL dos veces en una cuenta. Si cada repo trae su propio stack de Terraform con el provider adentro, el segundo apply rompe. Crealo una vez (en el stack de la cuenta) y referencialo con el data source; el módulo lo permite con create_oidc_provider = false.
Para GitHub, IAM no usa el thumbprint para verificar (y no hace falta cargarlo)
IAM valida el certificado de GitHub contra su librería de CAs raíz confiables, así que thumbprint_list es opcional y, en el provider de Terraform, para IdPs como GitHub se guarda pero no se usa para verificar. Los thumbprints solo entran en juego si el IdP usa un certificado que no firma una de esas CAs, o si AWS no puede obtener el certificado TLS o se requiere TLS v1.3.
Terraform
main.tf
# Despliegue desde CI sin secretos — AWS
#
# OIDC provider de GitHub (uno por cuenta) y un rol IAM cuya trust policy
# exige el audience de STS y un sub exacto por environment.
locals {
github_host = "token.actions.githubusercontent.com"
repo_path = var.github.owner_id == null ? "${var.github.owner}/${var.github.repository}" : "${var.github.owner}@${var.github.owner_id}/${var.github.repository}@${var.github.repository_id}"
# Si el job declara `environment:`, el sub es este (y no el de la rama).
subjects = sort([for env in var.environments : "repo:${local.repo_path}:environment:${env}"])
provider_arn = var.create_oidc_provider ? aws_iam_openid_connect_provider.github[0].arn : data.aws_iam_openid_connect_provider.github[0].arn
trust_conditions = {
StringEquals = {
"${local.github_host}:aud" = "sts.amazonaws.com"
"${local.github_host}:sub" = local.subjects
}
}
}
# Sin thumbprint_list: para GitHub, IAM valida el certificado contra su librería
# de CAs raíz y no usa el thumbprint (gotcha: thumbprint-not-used).
resource "aws_iam_openid_connect_provider" "github" {
count = var.create_oidc_provider ? 1 : 0
url = "https://${local.github_host}"
client_id_list = ["sts.amazonaws.com"]
}
data "aws_iam_openid_connect_provider" "github" {
count = var.create_oidc_provider ? 0 : 1
url = "https://${local.github_host}"
}
resource "aws_iam_role" "deploy" {
name = var.name
description = "GitHub Actions ${local.repo_path} (${join(", ", local.subjects)})"
max_session_duration = var.max_session_duration
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRoleWithWebIdentity"
Principal = { Federated = local.provider_arn }
Condition = local.trust_conditions
}]
})
}
resource "aws_iam_role_policy_attachment" "deploy" {
for_each = var.policy_arns
role = aws_iam_role.deploy.name
policy_arn = each.value
}variables.tf
variable "name" {
description = "Nombre del rol de despliegue (ej. \"deploy-web\")."
type = string
validation {
condition = can(regex("^[a-zA-Z0-9+=,.@_-]{3,64}$", var.name))
error_message = "name: 3-64 caracteres válidos para un rol IAM."
}
}
variable "region" {
description = "Región del provider (IAM es global)."
type = string
default = "us-east-1"
}
variable "create_oidc_provider" {
description = "Crear el OIDC provider de GitHub. Hay uno solo por URL y por cuenta: si ya existe, false y se referencia (gotcha: one-oidc-provider-per-account)."
type = bool
default = true
}
variable "github" {
description = <<-EOT
Repositorio que puede desplegar. owner_id y repository_id (numéricos) activan el formato inmutable del
claim sub ("repo:owner@123/repo@456:..."), que GitHub usa por defecto en repos creados, renombrados o
transferidos desde el 15/07/2026.
EOT
type = object({
owner = string
repository = string
owner_id = optional(number)
repository_id = optional(number)
})
validation {
condition = (var.github.owner_id == null) == (var.github.repository_id == null)
error_message = "github: owner_id y repository_id van juntos (formato inmutable) o ninguno de los dos."
}
}
variable "environments" {
description = "Environments de GitHub que pueden asumir el rol (match exacto con StringEquals)."
type = set(string)
default = ["production"]
validation {
condition = length(var.environments) >= 1 && alltrue([for e in var.environments : !can(regex("[*?]", e))])
error_message = "environments: al menos uno y sin comodines (el módulo usa StringEquals a propósito)."
}
}
variable "policy_arns" {
description = "Policies administradas que necesita el despliegue (mínimo privilegio)."
type = set(string)
validation {
condition = !contains(var.policy_arns, "arn:aws:iam::aws:policy/AdministratorAccess")
error_message = "policy_arns: AdministratorAccess no. Acotá los permisos a lo que despliega el pipeline."
}
}
variable "max_session_duration" {
description = "Duración máxima de la sesión en segundos (1 h a 12 h). Si el apply dura más que la sesión, falla a mitad de camino."
type = number
default = 3600
validation {
condition = var.max_session_duration >= 3600 && var.max_session_duration <= 43200
error_message = "max_session_duration: entre 3600 y 43200."
}
}
variable "tags" {
description = "Tags comunes."
type = map(string)
default = {}
}outputs.tf
output "role_arn" {
description = "role-to-assume para aws-actions/configure-aws-credentials (no es un secreto)."
value = aws_iam_role.deploy.arn
}
output "subjects" {
description = "Valores exactos de sub que acepta la trust policy."
value = local.subjects
}
output "workflow_snippet" {
description = "Fragmento mínimo del workflow."
value = <<-YAML
permissions:
id-token: write # sin esto el job no puede pedir el token OIDC
contents: read
jobs:
deploy:
environment: ${sort(tolist(var.environments))[0]}
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${aws_iam_role.deploy.arn}
aws-region: ${var.region}
YAML
}versions.tf
terraform {
required_version = ">= 1.7.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.67"
}
}
}
provider "aws" {
region = var.region
default_tags {
tags = var.tags
}
}Google Cloud
Implementación
- Confianza federada
- Workload Identity Federation (pool + provider OIDC)attribute_condition sobre repository_owner_id, repository_id y environmentVenís de Azure: allá este rol es Federated identity credential (workload identity federation).Venís de AWS: allá este rol es IAM OIDC identity provider. La confianza es una condición CEL sobre atributos mapeados, y conviene acotarla por repository_id (inmutable) en vez del nombre.
- Identidad de despliegue
- Service Account (impersonada)roles/iam.workloadIdentityUser solo para el principalSet del repositorioVenís de Azure: allá este rol es User-assigned Managed Identity.Venís de AWS: allá este rol es IAM Role.
- Permisos mínimos
- IAMRoles de proyecto acotados; nunca owner/editorVenís de Azure: allá este rol es Azure RBAC.Venís de AWS: allá este rol es IAM policies adjuntas al rol.
Modelo de costo
Escala a cero
Sin costo fijo
Workload Identity Federation, service accounts e IAM no tienen costo. Pagás solo los recursos que despliega el pipeline.
Piso mensual sin tráfico
USD 0/ mes
USD 0: no hay componentes con costo fijo.
Letra chica · 5 gotchas
Sin attribute_condition, el issuer compartido de GitHub deja entrar a cualquier repo
GitHub usa un solo issuer para todos sus clientes, así que validar el issuer no alcanza. Google exige una condición que restrinja los tokens a tu organización o repositorio. En Terraform el campo figura como opcional y, sin condición, se acepta toda credencial válida del issuer. Además, todo claim que uses en la condición o en un principalSet tiene que estar mapeado en attribute_mapping.
Condicioná sobre los IDs numéricos, no sobre los nombres de org y repo
Un nombre de organización o de repositorio se puede liberar y volver a registrar: si tu condición dice repository == 'org/repo', el próximo dueño de ese nombre hereda la confianza. Google recomienda atributos inmutables, y GitHub manda repository_id y repository_owner_id en cada token. Es la misma razón por la que GitHub cambió el formato del sub en los repos creados desde el 15/07/2026.
El acceso directo sin service account no funciona con todos los servicios
Se puede dar roles directamente al principal federado, sin service account. Pero algunas APIs tienen limitaciones con identidades federadas y para esas hay que impersonar una service account. Este módulo impersona: funciona con todo y deja un solo lugar donde auditar permisos. Para esa ruta Google pide habilitar cuatro APIs: IAM, Resource Manager, Service Account Credentials y Security Token Service.
Los principals usan el número de proyecto, y los permisos tardan minutos en propagarse
Al otorgar roles a identidades externas se usa el número de proyecto, no el ID: el principalSet es principalSet://iam.googleapis.com/projects/NÚMERO_DE_PROYECTO/locations/global/workloadIdentityPools/.... Además, un cambio de IAM suele aplicarse en 2 minutos pero puede tardar 7 o más.
Un pool borrado bloquea su ID durante unos 30 días
Pools y providers se borran en modo soft delete y se eliminan de verdad a los ~30 días. Mientras tanto no podés crear otro con el mismo ID, así que un terraform destroy seguido de apply con el mismo pool_id falla. Las salidas son restaurarlo (undelete) e importarlo, o usar otro ID. Para entornos efímeros, generá el ID con un sufijo.
Terraform
main.tf
# Despliegue desde CI sin secretos — GCP
#
# Workload Identity Pool + provider OIDC de GitHub con una condición CEL sobre
# IDs inmutables, y una service account de despliegue que solo puede
# impersonar el principalSet de ese repositorio.
locals {
# Las cuatro APIs que pide la guía de Google para pipelines.
apis = ["iam.googleapis.com", "cloudresourcemanager.googleapis.com", "iamcredentials.googleapis.com", "sts.googleapis.com"]
envs_cel = join(", ", [for e in sort(tolist(var.environments)) : "'${e}'"])
# Sin condición, cualquier repo de GitHub podría obtener tokens: el issuer es compartido.
attribute_condition = "assertion.repository_owner_id == '${var.github.owner_id}' && assertion.repository_id == '${var.github.repository_id}' && assertion.environment in [${local.envs_cel}]"
attribute_mapping = {
"google.subject" = "assertion.sub"
"attribute.repository_id" = "assertion.repository_id"
"attribute.repository_owner_id" = "assertion.repository_owner_id"
"attribute.environment" = "assertion.environment"
}
}
resource "google_project_service" "required" {
for_each = toset(local.apis)
project = var.project_id
service = each.value
disable_on_destroy = false
}
resource "google_iam_workload_identity_pool" "github" {
project = var.project_id
workload_identity_pool_id = var.pool_id
display_name = "GitHub Actions"
description = "Tokens OIDC de GitHub Actions"
depends_on = [google_project_service.required]
}
resource "google_iam_workload_identity_pool_provider" "github" {
#checkov:skip=CKV_GCP_125:El check solo reconoce condiciones sobre assertion.sub con nombres; esta condición usa repository_owner_id y repository_id (inmutables), que es lo que recomienda Google.
project = var.project_id
workload_identity_pool_id = google_iam_workload_identity_pool.github.workload_identity_pool_id
workload_identity_pool_provider_id = "github-oidc"
display_name = "GitHub OIDC"
attribute_mapping = local.attribute_mapping
attribute_condition = local.attribute_condition
oidc {
issuer_uri = "https://token.actions.githubusercontent.com"
}
}
resource "google_service_account" "deploy" {
project = var.project_id
account_id = "gha-deploy"
display_name = "GitHub Actions deploy"
}
# Solo el principalSet de este repositorio puede impersonar la SA.
resource "google_service_account_iam_member" "workload_identity_user" {
service_account_id = google_service_account.deploy.name
role = "roles/iam.workloadIdentityUser"
member = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.github.name}/attribute.repository_id/${var.github.repository_id}"
}
resource "google_project_iam_member" "deploy" {
for_each = var.deploy_roles
project = var.project_id
role = each.value
member = "serviceAccount:${google_service_account.deploy.email}"
}variables.tf
variable "project_id" {
description = "Proyecto donde viven el pool y la service account de despliegue."
type = string
}
variable "region" {
description = "Región por defecto del provider."
type = string
default = "us-central1"
}
variable "pool_id" {
description = "ID del workload identity pool (4-32, minúsculas). Un pool borrado bloquea su ID ~30 días (gotcha: pool-soft-delete-30-days)."
type = string
default = "github"
validation {
condition = can(regex("^[a-z0-9-]{4,32}$", var.pool_id)) && !startswith(var.pool_id, "gcp-")
error_message = "pool_id: 4-32 caracteres [a-z0-9-], sin el prefijo reservado gcp-."
}
}
variable "github" {
description = <<-EOT
Repositorio que puede desplegar. En GCP la condición se arma sobre los IDs numéricos (inmutables), no sobre
los nombres: un nombre de org o repo reciclado no hereda la confianza.
EOT
type = object({
owner_id = number
repository_id = number
})
}
variable "environments" {
description = "Environments de GitHub que pueden desplegar."
type = set(string)
default = ["production"]
validation {
condition = length(var.environments) >= 1 && alltrue([for e in var.environments : can(regex("^[A-Za-z0-9_.-]+$", e))])
error_message = "environments: al menos uno, solo letras, números, '.', '_' o '-'."
}
}
variable "deploy_roles" {
description = "Roles de proyecto que necesita el despliegue (mínimo privilegio)."
type = set(string)
validation {
condition = length(setintersection(var.deploy_roles, ["roles/owner", "roles/editor", "roles/iam.serviceAccountUser", "roles/iam.serviceAccountTokenCreator"])) == 0
error_message = "deploy_roles: nada de owner/editor ni roles que permitan actuar como otras service accounts a nivel proyecto."
}
}outputs.tf
output "workload_identity_provider" {
description = "workload_identity_provider para google-github-actions/auth (usa el número de proyecto, no el ID)."
value = google_iam_workload_identity_pool_provider.github.name
}
output "service_account" {
description = "service_account para google-github-actions/auth."
value = google_service_account.deploy.email
}
output "attribute_condition" {
description = "Condición CEL que evalúa cada token."
value = local.attribute_condition
}
output "workflow_snippet" {
description = "Fragmento mínimo del workflow."
value = <<-YAML
permissions:
id-token: write # sin esto el job no puede pedir el token OIDC
contents: read
jobs:
deploy:
environment: ${sort(tolist(var.environments))[0]}
steps:
- uses: google-github-actions/auth@v3
with:
workload_identity_provider: ${google_iam_workload_identity_pool_provider.github.name}
service_account: ${google_service_account.deploy.email}
YAML
}versions.tf
terraform {
required_version = ">= 1.7.0"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 8.6"
}
}
}
provider "google" {
project = var.project_id
region = var.region
}