ESEN

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.

Microsoft Azure

Despliegue desde CI sin secretos (federación OIDC) — Azuretoken OIDC (aud, sub)token de corta duraciónrol sobre un scopeterraform applyJob de GitHub ActionsConfianza federadaFederated identitycredential (workloadidentity federation)4 gotchas en este rolIdentidad de despliegueUser-assigned ManagedIdentityPermisos mínimosAzure RBAC1 gotcha en este rolRecursos destino

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

IAM● verificado hace 34 días◌ sin revisor externo

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.

⚑ Reportar desactualizado
IAM● verificado hace 34 días◌ sin revisor externo

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).

⚑ Reportar desactualizado
IAM● verificado hace 34 días◌ sin revisor externo

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.

⚑ Reportar desactualizado
Confiabilidad● verificado hace 34 días◌ sin revisor externo

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.

⚑ Reportar desactualizado
Operación● verificado hace 34 días◌ sin revisor externo

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.

⚑ Reportar desactualizado

Terraform

azurerm ~> 5.8✓ fmt · validate · tflint · checkov · terraform testGitHub ↗
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

Despliegue desde CI sin secretos (federación OIDC) — AWStoken OIDC (aud, sub)token de corta duraciónrol sobre un scopeterraform applyJob de GitHub ActionsConfianza federadaIAM OIDC identityprovider4 gotchas en este rolIdentidad de despliegueIAM Role2 gotchas en este rolPermisos mínimosIAM policies adjuntasal rolRecursos destino

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

Seguridad● verificado hace 34 días◌ sin revisor externo

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.

⚑ Reportar desactualizado
IAM● verificado hace 34 días◌ sin revisor externo

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.

⚑ Reportar desactualizado
Confiabilidad● verificado hace 34 días◌ sin revisor externo

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.

⚑ Reportar desactualizado
Operación● verificado hace 34 días◌ sin revisor externo

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.

⚑ Reportar desactualizado
Operación● verificado hace 34 días◌ sin revisor externo

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.

⚑ Reportar desactualizado

Terraform

aws ~> 6.67✓ fmt · validate · tflint · checkov · terraform testGitHub ↗
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

Despliegue desde CI sin secretos (federación OIDC) — GCPtoken OIDC (aud, sub)token de corta duraciónrol sobre un scopeterraform applyJob de GitHub ActionsConfianza federadaWorkload IdentityFederation (pool +provider OIDC)4 gotchas en este rolIdentidad de despliegueService Account(impersonada)1 gotcha en este rolPermisos mínimosIAM2 gotchas en este rolRecursos destino

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

Seguridad● verificado hace 34 días◌ sin revisor externo

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.

⚑ Reportar desactualizado
Seguridad● verificado hace 34 días◌ sin revisor externo

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.

⚑ Reportar desactualizado
IAM● verificado hace 34 días◌ sin revisor externo

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.

⚑ Reportar desactualizado
Confiabilidad● verificado hace 34 días◌ sin revisor externo

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.

⚑ Reportar desactualizado
Operación● verificado hace 34 días◌ sin revisor externo

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.

⚑ Reportar desactualizado

Terraform

google ~> 8.6✓ fmt · validate · tflint · checkov · terraform testGitHub ↗
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
}