Módulo 2: Federated Identity And Least Privilege Iam
8. Proyecto: la identidad federada de Andes Cargo
Descripción
Este proyecto reúne las cuatro lecciones "manos a la obra" de este módulo en una sola pieza verificable: modules/oidc-provider/ completo (identity provider + rol federado), los dos roles reales de Andes Cargo recortados a mínimo privilegio, un plan combinado que corre sobre el proyecto entero, y un documento corto —honestamente representativo— de qué cambiaría, y qué no, si Andes Cargo alguna vez federara contra una cuenta AWS real.
Conexión con el módulo
Con este proyecto, RISK-MAP.md cierra sus dos primeras filas: TM-01 (lección 5) y TM-07 (lección 7). El Módulo 3 abre la tercera —TM-05, secretos en texto plano— sobre exactamente esta misma base: una identidad ya endurecida, lista para que un secreto viva únicamente donde alguien con la identidad correcta pueda leerlo.
Paso 1 — modules/oidc-provider/, completo
Los tres archivos, tal como quedaron al cerrar la lección 5 — nada cambia en el resto de este proyecto.
modules/oidc-provider/variables.tf:
variable "thumbprint_list" {
description = "SHA-1 thumbprints of the GitHub Actions OIDC issuer's TLS certificate chain."
type = list(string)
default = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}
variable "tags" {
description = "Tags applied to the OIDC provider and the role this module creates."
type = map(string)
default = {}
}
variable "role_name" {
description = "Name of the IAM role that GitHub Actions assumes via OIDC."
type = string
}
variable "github_repo_ref" {
description = "repo:owner/name:ref:refs/heads/branch pattern allowed to assume this role."
type = string
}
modules/oidc-provider/main.tf:
resource "aws_iam_openid_connect_provider" "github_actions" {
url = "https://token.actions.githubusercontent.com"
client_id_list = [
"sts.amazonaws.com",
]
thumbprint_list = var.thumbprint_list
tags = var.tags
}
data "aws_iam_policy_document" "trust" {
statement {
sid = "GitHubActionsOIDC"
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.github_actions.arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:sub"
values = [var.github_repo_ref]
}
}
}
resource "aws_iam_role" "deploy" {
name = var.role_name
assume_role_policy = data.aws_iam_policy_document.trust.json
tags = var.tags
}
modules/oidc-provider/outputs.tf:
output "provider_arn" {
description = "ARN of the GitHub Actions OIDC identity provider."
value = aws_iam_openid_connect_provider.github_actions.arn
}
output "role_name" {
description = "Name of the created deploy role."
value = aws_iam_role.deploy.name
}
output "role_arn" {
description = "ARN of the created deploy role."
value = aws_iam_role.deploy.arn
}
Y, en la raíz de andes-cargo-infra/, oidc.tf:
module "github_oidc" {
source = "./modules/oidc-provider"
role_name = "AndesCargoDeployRole"
github_repo_ref = "repo:andes-cargo/andes-cargo-infra:ref:refs/heads/main"
tags = local.common_tags
}
Paso 2 — El plan combinado: identidad nueva + roles recortados, en una sola corrida
terraform fmt -recursive
terraform init
terraform validate
Qué esperar (literal, ejecutado para escribir esta lección):
Success! The configuration is valid.
terraform plan
Qué esperar (literal, ejecutado para escribir esta lección — el proyecto completo de este módulo, oidc.tf + iam.tf recortado, en una sola corrida):
Terraform will perform the following actions:
# module.app_server_role.aws_iam_role.this will be created
# module.app_server_role.aws_iam_role_policy.this will be created
# module.github_oidc.data.aws_iam_policy_document.trust will be read during apply
# module.github_oidc.aws_iam_openid_connect_provider.github_actions will be created
# module.github_oidc.aws_iam_role.deploy will be created
# module.lambda_manifest_processor_role.aws_iam_role.this will be created
# module.lambda_manifest_processor_role.aws_iam_role_policy.this will be created
Plan: 6 to add, 0 to change, 0 to destroy.
Seis recursos gestionados —el data nunca cuenta, la misma regla de siempre—: dos por cada uno de los tres roles del proyecto (el rol en sí + su política inline), más el identity provider. Fíjate en algo que confirma todo lo que construiste en este módulo, en una sola vista: AppServerRole y LambdaManifestProcessorRole ya no comparten ninguna política idéntica —la razón por la que TM-07 se resuelve—, y AndesCargoDeployRole es un tercer rol, con un tercer tipo de trust policy (Federated, no Service), que ninguno de los otros dos tiene — tres identidades, tres propósitos distintos, ninguna con más alcance del que necesita.
Paso 3 — Aplicando el proyecto completo (representativo)
Qué esperar (representativo), mismo motivo de siempre:
tflocal apply -auto-approve
module.app_server_role.aws_iam_role.this: Creating...
module.lambda_manifest_processor_role.aws_iam_role.this: Creating...
module.github_oidc.aws_iam_openid_connect_provider.github_actions: Creating...
module.app_server_role.aws_iam_role.this: Creation complete after 1s [id=AppServerRole]
module.lambda_manifest_processor_role.aws_iam_role.this: Creation complete after 1s [id=LambdaManifestProcessorRole]
module.github_oidc.aws_iam_openid_connect_provider.github_actions: Creation complete after 1s [id=arn:aws:iam::000000000000:oidc-provider/token.actions.githubusercontent.com]
module.app_server_role.aws_iam_role_policy.this: Creating...
module.lambda_manifest_processor_role.aws_iam_role_policy.this: Creating...
module.github_oidc.data.aws_iam_policy_document.trust: Reading...
module.app_server_role.aws_iam_role_policy.this: Creation complete after 0s [id=AppServerRole:AppServerRole-policy]
module.lambda_manifest_processor_role.aws_iam_role_policy.this: Creation complete after 0s [id=LambdaManifestProcessorRole:LambdaManifestProcessorRole-policy]
module.github_oidc.data.aws_iam_policy_document.trust: Read complete after 0s [id=3924781605]
module.github_oidc.aws_iam_role.deploy: Creating...
module.github_oidc.aws_iam_role.deploy: Creation complete after 1s [id=AndesCargoDeployRole]
Apply complete! Resources: 6 added, 0 changed, 0 destroyed.
Fíjate en el paralelismo: los tres puntos de partida del grafo —AppServerRole, LambdaManifestProcessorRole, y el identity provider— se crean juntos, sin esperarse entre sí (ninguno depende de otro), exactamente el mismo comportamiento que ya viste con terraform graph en terraform-and-iac-guide, Módulo 8, lección 2. AndesCargoDeployRole es, de los tres roles, el único con una cadena de dependencia real: espera al identity provider, después al data que lee su ARN, y solo entonces se crea.
awslocal iam list-roles --query 'Roles[].RoleName'
Qué esperar (representativo):
["AppServerRole", "LambdaManifestProcessorRole", "AndesCargoDeployRole"]
Tres roles — los dos heredados, ahora recortados, más el nuevo rol federado. andes-cargo-infra/ nunca tuvo, antes de este módulo, ningún rol pensado para que lo asuma algo que no fuera un servicio de AWS (lambda.amazonaws.com, ec2.amazonaws.com) — AndesCargoDeployRole es el primero pensado para una identidad externa, con un mecanismo de confianza completamente distinto.
Paso 4 — El documento corto: qué cambiaría contra una cuenta AWS real
En la raíz de andes-cargo-infra/, crea FEDERATION-TO-REAL-AWS.md — un documento corto, honestamente etiquetado como representativo desde su primera línea, que responde una sola pregunta: si Andes Cargo dejara de ser un caso de laboratorio y federara de verdad contra una cuenta AWS real, ¿qué de todo lo construido en este módulo tendría que cambiar?
# FEDERATION-TO-REAL-AWS.md — What Changes Against a Real AWS Account
**Status:** Representative (not executed) · **Scope:** `modules/oidc-provider/` and its call site
**Why representative:** this repository's Terraform, applied against LocalStack Hobby, cannot be
applied against a real AWS account without an actual account to target — this document is the
honest map of that gap, not a promise this guide tested.
## What does NOT change: all of it
Every file inside `modules/oidc-provider/` — `main.tf`, `variables.tf`, `outputs.tf` — is
account-agnostic HCL. `aws_iam_openid_connect_provider`, the trust policy's two conditions, and
`aws_iam_role.deploy` reference no LocalStack-specific value anywhere. The two tightened policies
in `iam.tf` (Lesson 7) are equally account-agnostic. This is not a coincidence: it is the direct
result of never hardcoding the account ID `000000000000` inside any `resource` or `data` block in
this module — every ARN that needs an account ID is either resolved by the provider at apply time,
or, in the trust policy's `sub` condition, refers to a GitHub repository, never to an AWS account.
## What changes: exactly one thing, the target account
**The `provider "aws"` block.** Every LocalStack-specific argument this ecosystem has used since
`terraform-and-iac-guide` Module 1 — `access_key = "test"`, `secret_key = "test"`,
`skip_credentials_validation`, `skip_metadata_api_check`, `skip_requesting_account_id`, and the
entire `endpoints { ... }` block pointing at `http://localhost:4566` — disappears entirely. A
provider block targeting real AWS needs none of them: Terraform resolves real credentials from the
standard credential chain (an IAM user's access key via `aws configure`, an assumed role, or —
appropriately, given this module's own subject — OIDC federation for the operator's own CI/CD, the
same mechanism this module builds for Andes Cargo's pipeline).
```hcl
# LocalStack (this guide, all modules) # Real AWS (representative, not executed here)
provider "aws" { provider "aws" {
region = "us-east-1" region = "us-east-1"
access_key = "test" # credentials resolved from the standard
secret_key = "test" # chain -- no access_key/secret_key here
skip_credentials_validation = true }
skip_metadata_api_check = true
skip_requesting_account_id = true
endpoints { ... http://localhost:4566 ... }
}
```
## What changes on the GitHub side: the account number inside one ARN
`cicd-and-gitops-on-aws-guide` Module 4, lesson 5 already showed the exact YAML a real pipeline
would use — `role-to-assume: arn:aws:iam::123456789012:role/AndesCargoDeployRole`. The only
account-specific value in that entire workflow is the twelve-digit account number inside that one
ARN, replacing the placeholder `123456789012` with Andes Cargo's real account ID. Nothing else in
that YAML, and nothing in this module's HCL, changes.
## What becomes executable, not just representative
With a real AWS account behind `AndesCargoDeployRole`, `IAM Policy Enforcement` is no longer a paid
LocalStack feature that this guide has to work around — it is simply how IAM always works on a real
account, no plan tier required. The exact experiment of Lesson 6 (presenting a JWT to
`sts:AssumeRoleWithWebIdentity`) would, for the first time, produce a real, enforced result: a
genuine GitHub Actions-signed token would succeed, and this lesson's test JWT — signed with a
symmetric test key, using an algorithm (`HS256`) real AWS does not even accept for this operation —
would fail outright.
## What this document is not
This is not a migration runbook, a cost estimate, or a security review of a specific AWS account —
those are out of scope for a $0 lab guide. It is a scoped answer to one question: how much of the
HCL this module built would survive, unchanged, a move to a real account. The answer, verified
against every file this module touched, is: all of it except the `provider` block itself.
Paso 5 — RISK-MAP.md, con el módulo completo
Con las lecciones 5 y 7 ya actualizadas, RISK-MAP.md queda así en sus dos primeras filas:
| Order | ID | STRIDE | Risk | Control | Module | Status |
|---|---|---|---|---|---|---|
| 1 | TM-01 | Spoofing | Long-lived static pipeline credentials | OIDC federation + scoped trust policy | M2 | Resolved (M2.5) |
| 2 | TM-07 | Elevation of privilege | AppServerRole broader than its actual usage | Least-privilege role tightening | M2.7 | Resolved (M2.7) |
Cinco filas siguen Open — TM-05 (secretos en texto plano, Módulo 3), TM-04 y TM-06 (Módulo 4), TM-02 (Módulo 6), TM-03 (Módulo 7) — exactamente el orden que la sección Decision de ese documento ya justificó.
El cierre del Módulo 2
Con modules/oidc-provider/ aplicado, los dos roles reales recortados, y FEDERATION-TO-REAL-AWS.md documentando honestamente el límite exacto de este laboratorio, este módulo entrega lo que prometió en su primera lección: identidad federada construida de verdad, no solo nombrada — hasta el límite exacto que LocalStack Hobby permite, con ese límite convertido en el experimento pedagógico de la lección 6, no escondido. andes-cargo-infra/ tiene ahora tres roles, cada uno con exactamente el alcance que necesita, y cero credenciales de larga vida agregadas por este módulo.
Errores comunes
Publicar FEDERATION-TO-REAL-AWS.md como si fuera una guía de migración probada (de alcance del documento). Qué pasa: alguien toma este documento y lo presenta como "así se federa Andes Cargo contra AWS real, ya verificado". Cómo detectarlo: si tu descripción del documento omite la palabra "representative" de su propio encabezado. Cómo corregirlo: el documento es explícito, desde su primera línea, sobre qué es y qué no es — un mapa honesto del cambio necesario, no una migración ejecutada. La diferencia importa: alguien que lo use como checklist real debería, de todos modos, correr terraform plan contra la cuenta real antes de cualquier apply, exactamente la misma disciplina que toda esta guía enseña.
Pensar que este proyecto agregó algún recurso de negocio nuevo (de expectativa, retomado de la lección 1 de este módulo). Qué pasa: alguien revisa el plan de seis recursos y asume que alguno de ellos es parte de la infraestructura de negocio de Andes Cargo (el bucket, la tabla, la función). Cómo detectarlo: si buscas aws_s3_bucket, aws_dynamodb_table, o aws_lambda_function en el plan de esta lección. Cómo corregirlo: los seis recursos son, sin excepción, de la capa de identidad — tres roles (uno nuevo, dos recortados) y sus políticas, más un identity provider. Ni un solo recurso de negocio se tocó en todo este módulo, exactamente como prometió el diseño de esta guía.
Confundir el número de recursos del plan (6) con el número de roles (3). Qué pasa: alguien, viendo Plan: 6 to add, concluye que este módulo creó seis roles distintos. Cómo detectarlo: si tu conteo de roles nuevos no coincide con awslocal iam list-roles del Paso 3. Cómo corregirlo: cuenta los seis recursos uno por uno contra el plan del Paso 2: AppServerRole aporta dos (el rol + su política inline), LambdaManifestProcessorRole aporta otros dos, y AndesCargoDeployRole aporta uno solo —el identity provider es el sexto recurso, un recurso propio, no un rol—. Dos roles recortados × dos recursos cada uno (cuatro) + un rol nuevo (uno) + el identity provider (uno) = seis, con exactamente tres roles reales detrás de ese número. La forma correcta de verificar cuántos roles existen, sin hacer aritmética sobre el plan, es siempre awslocal iam list-roles, no el número crudo de Plan: N to add.
Ejercicios
Ejercicio 1 — Reconstruye de memoria los tres roles de andes-cargo-infra/ al cerrar este módulo, con su trust policy y su alcance de permisos. Sin mirar atrás, lista los tres roles, qué puede asumir cada uno, y a qué prefijo de S3 (si aplica) tiene acceso cada uno.
Ver solución
LambdaManifestProcessorRole — confía en lambda.amazonaws.com; lee manifests/* (s3:GetObject + s3:ListBucket condicionado), escribe en Shipments (heredado, sin cambios de este módulo). AppServerRole — confía en ec2.amazonaws.com; lee photos/* (mismo patrón, prefijo distinto). AndesCargoDeployRole — confía en el identity provider de GitHub Actions (Federated), condicionado a aud == sts.amazonaws.com y sub LIKE repo:andes-cargo/andes-cargo-infra:ref:refs/heads/main; sin ninguna política de permisos declarada todavía en este módulo (ese es, deliberadamente, trabajo fuera del alcance de este módulo específico — la trust policy resuelve "quién puede asumir este rol", no "qué puede hacer una vez adentro").
Ejercicio 2 — Explica por qué FEDERATION-TO-REAL-AWS.md concluye que "todo el HCL sobrevive" al cambio de cuenta. Usando la evidencia del propio documento, explica por qué ningún archivo de modules/oidc-provider/ necesitó, en ningún momento de este módulo, escribir la cuenta 000000000000 de forma literal.
Ver solución
Porque cada referencia que en teoría necesitaría un número de cuenta se resuelve de una de dos formas que nunca requieren escribirlo a mano: o Terraform lo calcula automáticamente a partir de las credenciales activas del provider (el ARN del identity provider, el ARN de cada rol, ambos known after apply), o la condición de la trust policy no necesita ningún número de cuenta en absoluto —el patrón de sub referencia un repositorio de GitHub, no una cuenta de AWS—. La única cuenta 000000000000 que aparece en toda esta guía vive en el bloque provider, en los ARNs reconstruidos de las secciones representativas (para que el ejemplo sea legible), y en el estado real de LocalStack — nunca dentro de un resource o data del propio módulo.
Ejercicio 3 — Defiende, frente a un entrevistador técnico, por qué este módulo vale como evidencia de dominio de OIDC aunque nunca corrió contra AWS real. Un entrevistador pregunta: "¿probaste esto contra una cuenta real de AWS?". ¿Cómo responderías, usando la estructura completa de este módulo como evidencia?
Ver solución
Una respuesta completa suena, más o menos, así: "No contra una cuenta real, y puedo explicar exactamente por qué, con la fuente técnica citada: LocalStack Hobby, el laboratorio gratuito de todo este proyecto, no incluye IAM Policy Enforcement —está documentado como una funcionalidad de sus planes de pago—. Lo que sí puedo mostrar es que el HCL completo —el identity provider, la trust policy con sus dos condiciones exactas, los dos roles existentes recortados a mínimo privilegio— pasa validate y produce el plan correcto contra el motor real de Terraform, y que entiendo, con precisión, la diferencia exacta entre lo que construí y lo que haría falta para probarlo de punta a punta: una cuenta AWS real, nada más, porque el HCL en sí no tiene ningún cambio pendiente. Ese nivel de honestidad sobre el límite exacto de lo que probé es, en sí mismo, parte de lo que estoy demostrando."
Resumen y siguiente paso
Con este proyecto cerraste el Módulo 2 completo: modules/oidc-provider/ aplicado de punta a punta, con el identity provider y la trust policy que las lecciones 4 y 5 construyeron; los dos roles reales de Andes Cargo recortados a mínimo privilegio exacto, cerrando TM-07; y FEDERATION-TO-REAL-AWS.md, el documento representativo que traza, con precisión, el único cambio real que separaría este módulo de una federación contra una cuenta AWS de verdad — el bloque provider, nada del HCL de identidad en sí.
Antes de cerrar este módulo deberías poder: recitar los tres roles de andes-cargo-infra/ con su trust policy y su alcance exacto; explicar por qué modules/oidc-provider/ es account-agnostic, sin ningún número de cuenta escrito a mano; y defender, con evidencia técnica citada, por qué este módulo es evidencia real de dominio de OIDC aunque el apply final quede representativo.
El Módulo 3 abre la tercera fila de RISK-MAP.md: TM-05, el .secrets en texto plano heredado de cicd-and-gitops-on-aws-guide, reemplazado por SSM Parameter Store y Secrets Manager — sobre exactamente la identidad, ya endurecida, que este módulo dejó lista.
Recursos
- Este módulo, lecciones 4, 5 y 7 — la fuente completa del HCL reunido en este proyecto.
cicd-and-gitops-on-aws-guide, Módulo 4, lección 5 — el YAML completo queFEDERATION-TO-REAL-AWS.mdreferencia como la pieza que ya estaba lista del lado de GitHub Actions.- AWS Docs — IAM Roles for GitHub Actions — referencia oficial completa del mecanismo que este proyecto deja construido.
- Este curso, Módulo 1, lección 8 (
RISK-MAP.md) — el documento que este proyecto actualiza con las dos primeras filas resueltas.