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
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
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
Identidad de despliegue
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
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
Permisos mínimos
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
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.
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
}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
}
}