Módulo 2: Federated Identity And Least Privilege Iam

7. Manos a la obra: endureciendo los roles reales de Andes Cargo

Descripción

Las lecciones 4, 5 y 6 construyeron identidad nueva — un rol que todavía no existía. Esta lección hace algo distinto: recorta permisos de los dos roles que ya existen, LambdaManifestProcessorRole y AppServerRole, cerrando TM-07 — el segundo hallazgo de RISK-MAP.md que este módulo resuelve, y el único de los siete riesgos del Módulo 1 que no se arregla creando algo nuevo, sino corrigiendo algo que ya estaba mal declarado.

Conexión con el módulo

Vuelve al inventario del Módulo 1, lección 4: AppServerRole y LambdaManifestProcessorRole tienen, letra por letra, la misma política de lectura de S3 — mismas acciones, mismo bucket completo, sin distinguir entre el prefijo manifests/ que solo la Lambda procesa y el prefijo photos/ que la app de tracking muestra. Esa lección te pidió anotarlo "con cuidado, lo vas a necesitar en la lección 5" del Módulo 1 — pero el hallazgo formal, TM-07, quedó para este módulo. Esta lección es esa promesa cumplida.


Analogía: la llave que abre todo el edificio frente a la que abre solo tu oficina

Un edificio de oficinas con un sistema de llaves mal diseñado le da a cada empleado una llave que abre todas las puertas —contabilidad, recursos humanos, la sala de servidores— aunque cada persona solo trabaje en una oficina específica. Funciona, en el sentido de que nadie se queda afuera de donde necesita entrar. Pero el día que alguien pierde esa llave, o que un empleado de un departamento se equivoca de puerta por curiosidad, el radio de exposición es el edificio completo, no la oficina que esa persona realmente usa. Un sistema de llaves bien diseñado le da a cada persona una llave que abre exactamente su oficina, y ninguna otra — perder esa llave expone una sola puerta, no el edificio entero.

LambdaManifestProcessorRole y AppServerRole, tal como los dejó terraform-and-iac-guide, son la primera versión de este edificio: ambos con la misma llave, que abre el bucket completo. Esta lección corta dos llaves nuevas, cada una específica para la puerta que su portador realmente necesita.


El hallazgo, con evidencia exacta

Del Módulo 1, lección 4 (awslocal iam get-role-policy, representativo, reconstruido del HCL real de terraform-and-iac-guide):

{
    "Statement": [
        {
            "Sid": "LambdaReadManifests",
            "Effect": "Allow",
            "Action": ["s3:GetObject", "s3:ListBucket"],
            "Resource": [
                "arn:aws:s3:::andes-cargo-shipment-docs",
                "arn:aws:s3:::andes-cargo-shipment-docs/*"
            ]
        }
    ]
}

La misma estructura, con el mismo Sid de patrón distinto (ReadManifests), aparece en AppServerRole-policy. Ambos roles pueden leer cualquier objeto del bucket, sin ninguna distinción de prefijo — manifests/ (los manifiestos que la Lambda procesa) y photos/ (las fotos de estado que la app de tracking muestra, confirmado en aws-serverless-and-containers-guide y terraform-and-iac-guide) quedan, para efectos de IAM, indistinguibles. Es exactamente TM-07: "AppServerRole has the same full-bucket scope as LambdaManifestProcessorRole", con el impacto documentado en THREAT-MODEL.md: "A compromise of the read-only tracking app grants access to raw manifests it never needed to read".


Paso 1 — El patrón correcto: s3:GetObject + s3:ListBucket acotados por prefijo

La documentación oficial de AWS —el mismo patrón que usarías para dar a un usuario acceso solo a una carpeta específica de un bucket compartido— separa el permiso en dos statements, no uno: s3:GetObject acotado al prefijo exacto vía el Resource, y s3:ListBucket acotado al mismo prefijo vía una condición s3:prefix, porque ListBucket es una operación sobre el bucket completo (no existe un "ARN de prefijo" para listar), así que necesita su propia condición para no exponer, sin querer, los nombres de objetos de otros prefijos.

statement {
  sid     = "ReadManifestsOnly"
  effect  = "Allow"
  actions = ["s3:GetObject"]

  resources = [
    "arn:aws:s3:::${var.bucket_name}/manifests/*",
  ]
}

statement {
  sid     = "ListManifestsPrefixOnly"
  effect  = "Allow"
  actions = ["s3:ListBucket"]

  resources = [
    "arn:aws:s3:::${var.bucket_name}",
  ]

  condition {
    test     = "StringLike"
    variable = "s3:prefix"
    values   = ["manifests/*"]
  }
}

Fíjate en la diferencia clave frente al HCL original de terraform-and-iac-guide: el Resource de GetObject ya no es .../andes-cargo-shipment-docs/* (cualquier objeto, de cualquier prefijo) sino .../andes-cargo-shipment-docs/manifests/* — el prefijo exacto que la Lambda procesa. ListBucket, que solo puede apuntar al ARN del bucket completo (nunca a un prefijo, porque lista, no lee), queda acotado por la condición s3:prefix con StringLike — el mismo operador de condición que ya usaste en la lección 5, aquí sobre una clave de contexto distinta.


Paso 2 — Actualizando iam.tf: las dos políticas completas

En iam.tf —el mismo archivo que terraform-and-iac-guide ya declaró—, reemplaza el data "aws_iam_policy_document" "lambda_permissions" existente:

data "aws_iam_policy_document" "lambda_permissions" {
  statement {
    sid     = "ReadManifestsOnly"
    effect  = "Allow"
    actions = ["s3:GetObject"]

    resources = [
      "arn:aws:s3:::${var.bucket_name}/manifests/*",
    ]
  }

  statement {
    sid     = "ListManifestsPrefixOnly"
    effect  = "Allow"
    actions = ["s3:ListBucket"]

    resources = [
      "arn:aws:s3:::${var.bucket_name}",
    ]

    condition {
      test     = "StringLike"
      variable = "s3:prefix"
      values   = ["manifests/*"]
    }
  }
}

Y, para AppServerRole, el mismo patrón, con el prefijo espejado a photos/:

data "aws_iam_policy_document" "ec2_permissions" {
  statement {
    sid     = "ReadPhotosOnly"
    effect  = "Allow"
    actions = ["s3:GetObject"]

    resources = [
      "arn:aws:s3:::${var.bucket_name}/photos/*",
    ]
  }

  statement {
    sid     = "ListPhotosPrefixOnly"
    effect  = "Allow"
    actions = ["s3:ListBucket"]

    resources = [
      "arn:aws:s3:::${var.bucket_name}",
    ]

    condition {
      test     = "StringLike"
      variable = "s3:prefix"
      values   = ["photos/*"]
    }
  }
}

Ningún otro archivo cambia — modules/iam-role/, las dos llamadas a ese módulo (module "lambda_manifest_processor_role" y module "app_server_role"), y las dos trust policies (lambda_trust, ec2_trust) quedan exactamente iguales. Solo las políticas de permisos se recortan; quién puede asumir cada rol no cambia en absoluto.


Paso 3 — fmt, validate, plan: el motor real, sobre el proyecto completo

terraform fmt -recursive
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 — se muestra la parte de este plan que corresponde a los dos roles recortados; el plan completo incluye además modules/oidc-provider/, ya construido en las lecciones 4-5):

  # module.app_server_role.aws_iam_role_policy.this will be created
  + resource "aws_iam_role_policy" "this" {
      + id          = (known after apply)
      + name        = "AppServerRole-policy"
      + name_prefix = (known after apply)
      + policy      = jsonencode(
            {
              + Statement = [
                  + {
                      + Action   = "s3:GetObject"
                      + Effect   = "Allow"
                      + Resource = "arn:aws:s3:::andes-cargo-shipment-docs/photos/*"
                      + Sid      = "ReadPhotosOnly"
                    },
                  + {
                      + Action    = "s3:ListBucket"
                      + Condition = {
                          + StringLike = {
                              + "s3:prefix" = "photos/*"
                            }
                        }
                      + Effect    = "Allow"
                      + Resource  = "arn:aws:s3:::andes-cargo-shipment-docs"
                      + Sid       = "ListPhotosPrefixOnly"
                    },
                ]
              + Version   = "2012-10-17"
            }
        )
      + role        = (known after apply)
    }

  # module.lambda_manifest_processor_role.aws_iam_role_policy.this will be created
  + resource "aws_iam_role_policy" "this" {
      + id          = (known after apply)
      + name        = "LambdaManifestProcessorRole-policy"
      + name_prefix = (known after apply)
      + policy      = jsonencode(
            {
              + Statement = [
                  + {
                      + Action   = "s3:GetObject"
                      + Effect   = "Allow"
                      + Resource = "arn:aws:s3:::andes-cargo-shipment-docs/manifests/*"
                      + Sid      = "ReadManifestsOnly"
                    },
                  + {
                      + Action    = "s3:ListBucket"
                      + Condition = {
                          + StringLike = {
                              + "s3:prefix" = "manifests/*"
                            }
                        }
                      + Effect    = "Allow"
                      + Resource  = "arn:aws:s3:::andes-cargo-shipment-docs"
                      + Sid       = "ListManifestsPrefixOnly"
                    },
                ]
              + Version   = "2012-10-17"
            }
        )
      + role        = (known after apply)
    }

Confirma, línea por línea, contra el hallazgo de TM-07: AppServerRole-policy ahora apunta exclusivamente a .../photos/*; LambdaManifestProcessorRole-policy apunta exclusivamente a .../manifests/*. Ya no comparten el mismo Resource — el hallazgo exacto que THREAT-MODEL.md documentó como riesgo ya no es cierto en este HCL.

Nota sobre este plan específico: en un proyecto real, este plan mostraría 1 to change, 0 to destroy para cada política (aws_iam_role_policy se reemplaza in-place cuando su contenido cambia, con el mismo name de recurso) — este entorno de validación aislado, sin ningún estado previo aplicado, muestra will be created porque parte de cero. El comportamiento real de Terraform ante un cambio de política existente es un update in-place, no un destroy+create — la referencia al rol (role = aws_iam_role.this.id) nunca cambia, así que no hay ninguna razón para recrear el recurso completo.


Paso 4 — Aplicando y verificando (representativo): antes y después, uno al lado del otro

Qué esperar (representativo), mismo motivo de siempre en esta guía:

tflocal apply -auto-approve
awslocal iam get-role-policy --role-name LambdaManifestProcessorRole --policy-name LambdaManifestProcessorRole-policy

Qué esperar (representativo — reconstruido del plan real de arriba):

{
    "RoleName": "LambdaManifestProcessorRole",
    "PolicyName": "LambdaManifestProcessorRole-policy",
    "PolicyDocument": {
        "Version": "2012-10-17",
        "Statement": [
            {
                "Sid": "ReadManifestsOnly",
                "Effect": "Allow",
                "Action": "s3:GetObject",
                "Resource": "arn:aws:s3:::andes-cargo-shipment-docs/manifests/*"
            },
            {
                "Sid": "ListManifestsPrefixOnly",
                "Effect": "Allow",
                "Action": "s3:ListBucket",
                "Resource": "arn:aws:s3:::andes-cargo-shipment-docs",
                "Condition": {
                    "StringLike": { "s3:prefix": "manifests/*" }
                }
            }
        ]
    }
}

Compara esto, campo por campo, contra la política antes de esta lección (Módulo 1, lección 4): antes, un único Resource con .../andes-cargo-shipment-docs/* — cualquier objeto, cualquier prefijo. Ahora, dos statements, cada uno acotado exactamente al prefijo que la función process-shipment-manifest realmente lee. AppServerRole-policy sigue el mismo patrón, con photos/* en vez de manifests/* — verificable con el mismo comando, cambiando el nombre del rol.


Actualizando RISK-MAP.md: la segunda fila que este módulo cierra

Con esta lección, actualiza la fila TM-07 de RISK-MAP.md (Módulo 1, lección 8):

- | 2 | TM-07 | Elevation of privilege | `AppServerRole` broader than its actual usage | Least-privilege role tightening | M2.7 | Open |
+ | 2 | TM-07 | Elevation of privilege | `AppServerRole` broader than its actual usage | Least-privilege role tightening | M2.7 | Resolved (M2.7): both roles' S3 permissions scoped to their exact prefix (`manifests/*`, `photos/*`), verified via `terraform plan` and `awslocal iam get-role-policy` |

Con TM-01 (lección 5) y TM-07 (esta lección) ambas actualizadas, este módulo deja RISK-MAP.md con dos de siete filas resueltas — el proyecto de la lección 8 confirma el estado completo del módulo antes de cerrarlo.


Errores comunes

Olvidar que s3:ListBucket no puede acotarse con un Resource de prefijo, solo con una condición (el error más común de este patrón, verificado contra la documentación oficial). Qué pasa: alguien, tratando de replicar el patrón de GetObject, escribe resources = ["arn:aws:s3:::${var.bucket_name}/manifests/*"] para el statement de ListBucket, sin ninguna condición. Cómo detectarlo: terraform plan no muestra ningún error —sintácticamente es válido—, pero la llamada real a s3:ListBucket con ese Resource fallaría contra AWS real, porque ListBucket es una operación sobre el bucket, no sobre un objeto — un ARN con un /prefijo/* al final no es un recurso válido para esta acción específica. Cómo corregirlo: ListBucket siempre apunta al ARN del bucket completo (arn:aws:s3:::bucket-name, sin sufijo), y se acota con una condición s3:prefix, exactamente el patrón de esta lección — el mismo que documenta oficialmente AWS para este caso.

Recortar solo una de las dos políticas, dejando la otra con el bucket completo (de trabajo incompleto). Qué pasa: alguien actualiza lambda_permissions pero, apurado, no llega a actualizar ec2_permissions en la misma sesión de trabajo. Cómo detectarlo: si tu plan muestra solo un cambio de política (module.lambda_manifest_processor_role...) sin el correspondiente cambio en module.app_server_role.... Cómo corregirlo: TM-07 describe específicamente que ambos roles comparten el mismo alcance amplio — resolver el hallazgo completo exige recortar los dos, no uno. RISK-MAP.md no debería marcarse como Resolved hasta que ambas políticas estén verificadas.

Asumir que recortar el Resource de GetObject ya resuelve el acceso de lectura sin necesitar el statement de ListBucket. Qué pasa: alguien declara solo el statement de GetObject, sin el de ListBucket, asumiendo que "leer objetos" es suficiente. Cómo detectarlo: cualquier operación que necesite listar el contenido de un prefijo antes de leerlo objeto por objeto (por ejemplo, un cliente S3 que hace list_objects_v2 antes de iterar) fallaría con AccessDenied en esa llamada específica, aunque GetObject funcionara perfectamente para un objeto cuyo key ya se conoce de antemano. Cómo corregirlo: GetObject y ListBucket son dos acciones de IAM completamente independientes que cubren dos operaciones distintas de la API de S3 — un flujo real casi siempre necesita ambas, exactamente como estructura esta lección.


Ejercicios

Ejercicio 1 — Explica por qué s3:ListBucket necesita una condición, y s3:GetObject no. Sin volver a mirar esta lección, explica en dos o tres frases la diferencia estructural entre ambas acciones que hace que una se acote con Resource y la otra con Condition.

Ver solución

s3:GetObject opera sobre un objeto específico, identificado por su key completa dentro del bucket — el ARN de un objeto (arn:aws:s3:::bucket/key) ya incluye, por diseño, toda la información necesaria para acotar el permiso a un prefijo, con un comodín al final (bucket/manifests/*). s3:ListBucket, en cambio, opera sobre el bucket como contenedor — no hay ningún "ARN de prefijo" porque listar no es una operación sobre un objeto, es una operación sobre la colección completa; el único ARN válido para esta acción es el del bucket entero. Acotarla a un prefijo específico exige, entonces, una condición separada (s3:prefix) que IAM evalúa contra el parámetro prefix que la llamada real a ListObjectsV2 incluya.

Ejercicio 2 — Predice qué pasaría si AppServerRole intentara leer un objeto bajo manifests/ después de esta lección. Con la política recortada de esta lección aplicada, ¿qué le pasaría a una llamada s3:GetObject desde AppServerRole sobre andes-cargo-shipment-docs/manifests/shipment-4471-manifest.txt?

Ver solución

Fallaría con AccessDenied (contra una cuenta AWS real, con enforcement activo) — y es exactamente el comportamiento correcto, la prueba de que TM-07 quedó resuelto. La política recortada de AppServerRole solo permite s3:GetObject sobre arn:aws:s3:::andes-cargo-shipment-docs/photos/*; un objeto bajo manifests/ está fuera de ese alcance, sin ninguna excepción. Antes de esta lección, esa misma llamada habría tenido éxito —exactamente el riesgo que TM-07 documentó—.

Ejercicio 3 — Escribe la actualización completa de la fila TM-01 en RISK-MAP.md, con el mismo formato que esta lección usó para TM-07. Basándote en el patrón de actualización de esta lección, escribe cómo debería quedar la fila TM-01 de RISK-MAP.md después de las lecciones 4 y 5 de este módulo.

Ver solución
- | 1 | TM-01 | Spoofing | Long-lived static pipeline credentials | OIDC federation + scoped trust policy | M2 | Open |
+ | 1 | TM-01 | Spoofing | Long-lived static pipeline credentials | OIDC federation + scoped trust policy | M2 | Resolved (M2.5): `aws_iam_openid_connect_provider` + `aws_iam_role` with scoped `assume_role_policy` applied to `AndesCargoDeployRole`, verified via `awslocal iam get-role` |

El patrón es el mismo en ambos casos: el Status cambia de Open a Resolved (lección exacta), con una nota que nombra el mecanismo concreto y el comando de verificación real usado — nunca solo la palabra "Resolved" sin evidencia, la misma disciplina que el Módulo 1, lección 8, ya exigió en su Ejercicio 1.


Resumen y siguiente paso

En esta lección recortaste LambdaManifestProcessorRole y AppServerRole de "acceso al bucket completo" a "acceso exacto al prefijo que cada uno realmente usa" — manifests/* para la Lambda, photos/* para la app de tracking—, con el patrón oficial de AWS de s3:GetObject acotado por Resource y s3:ListBucket acotado por Condition. Corriste fmt/validate/plan de verdad sobre el proyecto completo, y confirmaste, campo por campo contra el hallazgo original de THREAT-MODEL.md, que TM-07 ya no describe la realidad del HCL. Actualizaste RISK-MAP.md con la segunda fila resuelta de este módulo.

Antes de avanzar deberías poder: escribir de memoria el patrón de dos statements (GetObject + ListBucket con condición) para acotar acceso a un prefijo de S3; explicar por qué ambos roles necesitaban el mismo tratamiento, no solo uno; y actualizar una fila de RISK-MAP.md con el formato completo de evidencia.

La lección 8, el proyecto que cierra este módulo, reúne modules/oidc-provider/ completo, corre el plan combinado de todo lo construido en las lecciones 4-7, y agrega el documento corto —representativo— de qué cambiaría, si Andes Cargo alguna vez federara contra una cuenta AWS real.

Recursos

  1. AWS Docs — Controlling access to a bucket with user policies — la fuente oficial del patrón GetObject/ListBucket acotado por prefijo, usado en esta lección.
  2. Terraform Registry — data.aws_iam_policy_document — referencia completa del bloque condition, ya usado en la lección 5 sobre una clave de contexto distinta (token.actions.githubusercontent.com:sub).
  3. AWS CLI — iam get-role-policy — referencia completa del comando de verificación del Paso 4.
  4. Este curso, Módulo 1, lecciones 4 y 5 — el inventario original y el hallazgo TM-07, la fuente completa del riesgo que esta lección resuelve.