Módulo 4: Policy As Code With Conftest

7. Manos a la obra: mínimo privilegio y buckets públicos como política

Descripción

Esta lección escribe las dos políticas que faltan en policy/: least-privilege-iam.rego, que falla cualquier política IAM con "Action": "*", y no-public-buckets.rego, que exige que todo bucket S3 del proyecto bloquee el acceso público. La primera confirma, con una regla automática, algo que el Módulo 2 ya resolvió a mano (TM-07, los roles recortados de Andes Cargo). La segunda encuentra —y resuelve, en esta misma lección, con HCL real— un riesgo que hasta ahora nadie había cerrado: TM-04 de RISK-MAP.md.

Conexión con el módulo

Estructuralmente, ambas políticas son más complejas que no-destroy-shipments.rego de la lección 6, por una razón concreta: en vez de mirar si un recurso se destruye, tienen que leer el contenido de una política IAM completa —un documento JSON, codificado como string dentro del plan— y recorrer sus Statement uno por uno. Esta es la primera vez en el módulo que vas a usar json.unmarshal, y la primera vez que una política de este módulo hace que un archivo HCL nuevo aparezca en andes-cargo-infra/.


Parte 1 — least-privilege-iam.rego

Analogía: la firma en blanco, versus la orden de compra con el monto exacto

Una firma en blanco autoriza a quien la tenga a llenar cualquier monto, cualquier concepto, después del hecho — quien la recibe confía en que nadie va a abusar de esa autorización, pero no hay nada en el papel mismo que lo impida. Una orden de compra con el monto exacto, el proveedor exacto, y el concepto exacto autoriza una sola cosa, y nada más — si alguien intenta usarla para comprar algo distinto, el papel mismo lo rechaza, sin necesitar que nadie esté mirando en ese momento. "Action": "*" en una política IAM es la firma en blanco: autoriza cualquier acción sobre el recurso indicado, sin importar cuál. least-privilege-iam.rego es la regla que rechaza cualquier firma en blanco antes de que llegue a existir.

El escenario: confirmando que TM-07 sigue resuelto, con una regla que lo vigila para siempre

El Módulo 2, lección 7, de esta guía recortó LambdaManifestProcessorRole y AppServerRole de "acceso al bucket completo" a "acceso exacto al prefijo que cada uno usa" — una corrección hecha a mano, verificada a mano, en un momento específico. Esta política convierte esa verificación puntual en una regla que corre cada vez que alguien propone un cambio, sin depender de que un humano se acuerde de revisarlo de nuevo la próxima vez.

La política

# policy/least-privilege-iam.rego
package main

deny contains msg if {
    some rc in input.resource_changes
    rc.type == "aws_iam_role_policy"
    some action in ["create", "update"]
    action in rc.change.actions
    policy := json.unmarshal(rc.change.after.policy)
    some statement in policy.Statement
    statement.Effect == "Allow"
    action_is_wildcard(statement.Action)
    msg := sprintf(
        "%s: statement %q allows Action \"*\" — scope it to the specific actions this role needs",
        [rc.address, object.get(statement, "Sid", "<no Sid>")],
    )
}

action_is_wildcard(action) if {
    action == "*"
}

action_is_wildcard(action) if {
    is_array(action)
    "*" in action
}

La pieza nueva, frente a la lección 6, es json.unmarshal(rc.change.after.policy). Cuando exploraste el plan en la lección 5, change.after ya tenía valores anidados como objetos (tags, attribute) — pero el campo policy de un aws_iam_role_policy no es un objeto anidado dentro del JSON del plan: es un string, con el JSON completo de la política IAM codificado adentro, exactamente como lo dejó la función jsonencode() de Terraform. json.unmarshal es la función de Rego que toma ese string y lo convierte en un objeto navegable, con el mismo mecanismo que usarías en Python con json.loads() sobre una cadena de texto. Sin este paso, policy.Statement no existiría — policy seguiría siendo un string plano, sin ninguna estructura que Rego pueda recorrer.

action_is_wildcard está declarada como una función separada, con dos definiciones — el patrón de Rego para "esto es verdad si A, o si B" que la lección 2 ya adelantó: un Action puede ser un string único ("*") o una lista de strings (["*", "s3:GetObject"], donde igual alcanza con que "*" esté presente en cualquier posición) — la política de AWS IAM permite ambas formas, así que la regla tiene que cubrir las dos.

Confirmando PASS, contra el plan real de Andes Cargo

conftest test tfplan.json -p policy/least-privilege-iam.rego

Qué esperar (literal, ejecutado para escribir esta lección):

1 test, 1 passed, 0 warnings, 0 failures, 0 exceptions

PASS, silencioso — los dos roles reales de Andes Cargo, tal como el Módulo 2 los dejó, no tienen ningún Statement con Action: "*". Esto no es una coincidencia de diseño de esta lección: es la confirmación automática, por primera vez, de un trabajo que hasta ahora solo habías verificado leyendo un plan con tus propios ojos.

Confirmando FAIL, contra un intento real de ensanchar AppServerRole

Para probar que la política de verdad detecta una violación —la misma disciplina de la lección 6—, propone un cambio real sobre iam.tf: en vez del patrón acotado a photos/*, AppServerRole recibe acceso total al bucket:

# iam.tf — cambio propuesto, SOLO para esta prueba; revertido antes de seguir
data "aws_iam_policy_document" "ec2_permissions" {
  statement {
    sid     = "BroadBucketAccess"
    effect  = "Allow"
    actions = ["*"]

    resources = [
      "arn:aws:s3:::${var.bucket_name}",
      "arn:aws:s3:::${var.bucket_name}/*",
    ]
  }
}
terraform plan -out=tfplan && terraform show -json tfplan > tfplan.json
conftest test tfplan.json -p policy/least-privilege-iam.rego

Qué esperar (literal, ejecutado para escribir esta lección):

FAIL - tfplan.json - main - module.app_server_role.aws_iam_role_policy.this: statement "BroadBucketAccess" allows Action "*" — scope it to the specific actions this role needs

1 test, 0 passed, 0 warnings, 1 failure, 0 exceptions

El mensaje señala, con precisión, tres cosas: qué recurso (module.app_server_role.aws_iam_role_policy.this, el address real del plan), qué Statement dentro de esa política ("BroadBucketAccess", el Sid real que le diste al cambio propuesto), y por qué (allows Action "*"). Revierte este cambio antes de seguir — iam.tf vuelve a la versión del Módulo 2, lección 7, con los dos roles acotados a su prefijo exacto.


Parte 2 — no-public-buckets.rego, y el riesgo que sí estaba abierto

El hallazgo, antes de escribir la política: TM-04 sigue Open

A diferencia de mínimo privilegio (TM-07, ya resuelto desde el Módulo 2), TM-04 de RISK-MAP.md"S3 bucket with no public-access block"— sigue exactamente como el Módulo 1 lo encontró: module.shipment_docs_bucket, declarado en s3.tf, nunca tuvo un recurso aws_s3_bucket_public_access_block. Corre la política antes de escribir ningún HCL nuevo, para confirmarlo con evidencia, no de memoria:

# policy/no-public-buckets.rego
package main

public_access_block_locked_down(pab) if {
    pab.block_public_acls == true
    pab.block_public_policy == true
    pab.ignore_public_acls == true
    pab.restrict_public_buckets == true
}

deny contains msg if {
    bucket_changes := [rc |
        some rc in input.resource_changes
        rc.type == "aws_s3_bucket"
        "create" in rc.change.actions
    ]

    pab_changes := [rc |
        some rc in input.resource_changes
        rc.type == "aws_s3_bucket_public_access_block"
    ]

    count(bucket_changes) > count(pab_changes)

    some rc in bucket_changes
    msg := sprintf(
        "%s: no aws_s3_bucket_public_access_block found for this bucket — public access is not blocked",
        [rc.address],
    )
}

deny contains msg if {
    some rc in input.resource_changes
    rc.type == "aws_s3_bucket_public_access_block"
    not public_access_block_locked_down(rc.change.after)
    msg := sprintf(
        "%s: block_public_acls, block_public_policy, ignore_public_acls and restrict_public_buckets must all be true",
        [rc.address],
    )
}

Dos reglas deny independientes, cada una cubriendo un caso distinto: la primera dispara si hay más buckets que bloques de acceso público en todo el plan —el caso "nadie declaró ninguna protección"—; la segunda dispara si un aws_s3_bucket_public_access_block sí existe, pero algún flag está en false —el caso "alguien lo declaró, pero mal, dejando una rendija abierta"—. Esta política, a propósito, no intenta vincular cada bucket con su bloque específico por dirección —una versión más avanzada haría eso cruzando configuration.root_module.resources[].expressions del plan, fuera del alcance de esta lección—: cuenta buckets contra bloques de acceso, un invariante correcto y suficiente para un proyecto, como Andes Cargo, con un solo bucket.

conftest test tfplan.json -p policy/no-public-buckets.rego

Qué esperar (literal, ejecutado para escribir esta lección — el plan de andes-cargo-infra/ tal como quedó al cierre del Módulo 2, sin ningún aws_s3_bucket_public_access_block todavía):

FAIL - tfplan.json - main - module.shipment_docs_bucket.aws_s3_bucket.this: no aws_s3_bucket_public_access_block found for this bucket — public access is not blocked

2 tests, 1 passed, 0 warnings, 1 failure, 0 exceptions

2 tests —una por cada regla deny de este archivo—, 1 passed (la segunda regla, sobre un bloque mal configurado, no tiene nada que evaluar todavía porque ningún bloque existe: no dispara, así que cuenta como pasada), 1 failure (la primera regla, que sí encontró el bucket sin protección). Esto no es un error de la política — es la política funcionando exactamente como se espera, encontrando un riesgo real que THREAT-MODEL.md documentó desde el Módulo 1 y que ningún módulo anterior había cerrado todavía.

El fix real: aws_s3_bucket_public_access_block, en s3.tf

# s3.tf — agregado al final del archivo
resource "aws_s3_bucket_public_access_block" "shipment_docs" {
  bucket = module.shipment_docs_bucket.bucket_id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

Los cuatro argumentos en true son la configuración más estricta posible que ofrece este recurso — bloquea, a la vez, ACLs públicas nuevas (block_public_acls), políticas de bucket que otorguen acceso público (block_public_policy), que se respeten ACLs públicas ya existentes (ignore_public_acls, invertido: true significa "ignóralas", es decir, bloquéalas también), y cualquier acceso público derivado de cualquier fuente (restrict_public_buckets, la red de seguridad final). Este es, letra por letra, el mismo recurso que terraform-and-iac-guide, Módulo 8, lección 4, mostró como el ejemplo de "cambio que una revisión de plan debería aprobar sin dudar" — nombrado ahí, sin construirse; construido aquí.

fmt, validate, plan: el motor real

terraform fmt -recursive
terraform validate

Qué esperar (literal):

Success! The configuration is valid.
terraform plan -out=tfplan

Qué esperar (literal, cierre del resumen):

Plan: 15 to add, 0 to change, 0 to destroy.

Quince, no catorce — el nuevo aws_s3_bucket_public_access_block se suma a los catorce recursos que ya conocías desde la lección 5.

PASS, confirmado dos veces

terraform show -json tfplan > tfplan.json
conftest test tfplan.json -p policy/no-public-buckets.rego

Qué esperar (literal, ejecutado para escribir esta lección):

2 tests, 2 passed, 0 warnings, 0 failures, 0 exceptions

Y, para confirmar que la segunda regla de verdad revisa el contenido —no solo la existencia— del bloque, prueba un caso a medio resolver: cambia restrict_public_buckets = true a restrict_public_buckets = false (un error real y plausible, no un caso extremo inventado), y vuelve a generar el plan:

Qué esperar (literal, ejecutado para escribir esta lección, con ese único flag en false):

FAIL - tfplan.json - main - aws_s3_bucket_public_access_block.shipment_docs: block_public_acls, block_public_policy, ignore_public_acls and restrict_public_buckets must all be true

2 tests, 1 passed, 0 warnings, 1 failure, 0 exceptions

Revierte restrict_public_buckets a true antes de seguir. Este último resultado es la evidencia de que la política no se conforma con "existe un bloque" — exige que el bloque, además, tenga la configuración correcta, cerrando la rendija que un aws_s3_bucket_public_access_block mal configurado podría dejar abierta sin que nadie lo notara.


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

- | 4 | TM-04 | Information disclosure | S3 bucket with no public-access block | `no-public-buckets.rego` | M4 | Open |
+ | 4 | TM-04 | Information disclosure | S3 bucket with no public-access block | `no-public-buckets.rego` | M4 | Resolved (M4.7): `aws_s3_bucket_public_access_block.shipment_docs` added with all four flags `true`, verified via `terraform plan` (Plan: 15 to add) and `conftest test` |

Con esta fila y TM-06 (lección 6) resueltas, este módulo deja RISK-MAP.md con cuatro de siete filas cerradas — TM-01 y TM-07 desde el Módulo 2, TM-06 y TM-04 desde este módulo.


Errores comunes

Intentar leer rc.change.after.policy.Statement directamente, sin json.unmarshal (de estructura de datos, específico de este módulo). Qué pasa: alguien, acostumbrado a que otros campos de change.after ya son objetos navegables, asume que policy también lo es, y escribe rc.change.after.policy.Statement sin convertir nada primero. Cómo detectarlo: un error de tipo en tiempo de evaluación, o —peor, más silencioso— la condición nunca se cumple, porque intentar navegar un campo dentro de un string no produce el objeto que esperabas. Cómo corregirlo: policy es un string con JSON adentro —el resultado literal de jsonencode() en el HCL original—, no un objeto anidado; siempre pasa por json.unmarshal(...) primero, exactamente como el Paso de esta lección.

Olvidar que aws_iam_role_policy.lambda_write_shipments puede tener change.after.policy ausente en algunos plan, y que la política debe tolerarlo (de robustez, hallazgo real de este proyecto específico). Qué pasa: el campo policy de ese recurso específico depende de aws_dynamodb_table.shipments.arn —un valor que Terraform todavía no puede calcular en un plan limpio, sin estado previo—, así que aparece como conocido-después-de-aplicar, no como un string presente en after. Cómo detectarlo: si tu política produce un error en vez de simplemente no disparar para ese recurso específico. Cómo corregirlo: la política de este módulo funciona correctamente sin ningún manejo especial, porque rc.change.after.policy para ese recurso es indefinido —Rego trata una clave ausente como indefinida, no como un error—, y una expresión indefinida hace que esa instancia de la regla simplemente no aporte nada al conjunto deny. Si alguna vez ves un error en vez de este comportamiento silencioso, es señal de que el resto de tu política no está usando json.unmarshal de forma que tolere una entrada ausente.

Ampliar no-public-buckets.rego para que rastree qué bloque pertenece a qué bucket, antes de necesitarlo (de alcance prematuro). Qué pasa: alguien, después de leer la nota sobre el límite de esta política (contar buckets contra bloques, no vincularlos por dirección), intenta escribir de inmediato la versión completa que cruza configuration.root_module.resources[].expressions. Cómo detectarlo: si tu política creció en complejidad antes de tener un segundo bucket real que la necesite. Cómo corregirlo: la política de esta lección resuelve correctamente el caso real de Andes Cargo —un solo bucket— con una regla simple, legible, fácil de auditar; la complejidad de vincular por dirección solo se justifica el día en que el proyecto tenga más de un bucket S3, y ese día todavía no llegó. Escribir para el problema que tienes, no para el que podrías tener algún día, es la misma disciplina que ya viste en otros módulos de este ecosistema.


Ejercicios

Ejercicio 1 — Extiende least-privilege-iam.rego para también prohibir Resource: "*". Sin cambiar la regla existente sobre Action, agrega una segunda condición equivalente que dispare si algún Statement con Effect: "Allow" tiene Resource igual a "*" (o una lista que la contenga).

Ver solución
deny contains msg if {
    some rc in input.resource_changes
    rc.type == "aws_iam_role_policy"
    some action in ["create", "update"]
    action in rc.change.actions
    policy := json.unmarshal(rc.change.after.policy)
    some statement in policy.Statement
    statement.Effect == "Allow"
    resource_is_wildcard(statement.Resource)
    msg := sprintf(
        "%s: statement %q allows Resource \"*\" — scope it to the specific resources this role needs",
        [rc.address, object.get(statement, "Sid", "<no Sid>")],
    )
}

resource_is_wildcard(resource) if {
    resource == "*"
}

resource_is_wildcard(resource) if {
    is_array(resource)
    "*" in resource
}

El patrón es idéntico al de Action, con el mismo par de funciones (resource_is_wildcard, en vez de action_is_wildcard) para cubrir tanto el caso de un string único como el de una lista. Correr esta regla contra el plan real de Andes Cargo debería dar PASS: cada Statement de los tres roles reales tiene un Resource acotado a un ARN o prefijo específico, nunca "*".

Ejercicio 2 — Explica, a un compañero, por qué no-public-buckets.rego tiene dos reglas deny separadas, en vez de una sola más compleja. Tu compañero pregunta por qué no se escribió como una sola regla que revise "existe un bloque, y está bien configurado" en una sola pasada.

Ver solución

Una respuesta razonable: "Son dos preguntas distintas, con dos formas distintas de fallar. La primera pregunta es 'existe siquiera un `aws_s3_bucket_public_access_block' — sin eso, no hay nada que revisar. La segunda es 'lo que existe, está bien configurado' — solo tiene sentido preguntarla si la primera ya es cierta. Combinarlas en una sola regla significaría que, si nadie declaró ningún bloque, la política tendría que decidir arbitrariamente si eso cuenta como 'mal configurado' o como un caso aparte — separarlas hace que cada mensaje de error sea preciso sobre cuál de los dos problemas reales encontró, en vez de un mensaje genérico que obligue a adivinar cuál de las dos cosas falló."

Ejercicio 3 — Predice el resultado si Andes Cargo agregara un segundo bucket S3 sin su propio bloque de acceso público. Con la política de esta lección (la versión que cuenta, no la que vincula por dirección), si el proyecto agregara un segundo aws_s3_bucket sin ningún aws_s3_bucket_public_access_block adicional, ¿el plan resultante pasaría o fallaría? ¿Y si agregara el segundo bucket con su propio bloque, pero el bloque del primer bucket (shipment_docs) se borrara por error del mismo plan?

Ver solución

Primer caso: fallaría — count(bucket_changes) pasaría a 2, count(pab_changes) seguiría en 1, y 2 > 1 dispara la primera regla. Segundo caso, el más interesante: si el segundo bucket llega con su propio bloque (pab_changes también sube a 2) pero el bloque del primero se borra en el mismo plan, count(bucket_changes) sería 2 y count(pab_changes) también 2 — la comparación count(bucket_changes) > count(pab_changes) sería falsa, y la política, tal como está escrita, no detectaría el problema, porque solo compara cantidades totales, no qué bucket específico quedó sin protección. Este es exactamente el límite que la nota de esta lección ya anticipó: una política que cuenta, en vez de vincular por dirección, puede dejar pasar un caso donde el número total coincide mientras un bucket específico queda expuesto — la razón concreta por la que, en un proyecto con más de un bucket, valdría la pena escribir la versión más avanzada.


Resumen y siguiente paso

En esta lección escribiste dos políticas: least-privilege-iam.rego, que confirmó automáticamente —con PASS real— que los roles de Andes Cargo siguen cumpliendo TM-07 desde el Módulo 2, y que detectó —con FAIL real— un intento propuesto de ensancharlos; y no-public-buckets.rego, que encontró un riesgo real todavía abierto (TM-04), y que verificaste corregido con HCL real —aws_s3_bucket_public_access_block— y un segundo FAIL real contra una configuración a medio resolver. Aprendiste json.unmarshal, la función que convierte un campo de política IAM (un string con JSON adentro) en un objeto navegable, y el patrón de dos funciones para cubrir tanto un valor único como una lista.

Antes de avanzar deberías poder: escribir una política Rego que recorra Statement dentro de una política IAM anidada como string; explicar por qué no-public-buckets.rego usa dos reglas deny separadas; y nombrar las cuatro filas de RISK-MAP.md que ya están Resolved al cierre de esta lección.

La lección 8, el proyecto que cierra este módulo, reúne las tres políticas —no-destroy-shipments.rego, least-privilege-iam.rego, no-public-buckets.rego— en una sola biblioteca policy/, corrida de una sola vez contra el plan actual (que pasa las tres) y contra un plan que las viola a propósito (que falla, con los tres mensajes de deny visibles a la vez).

Recursos

  1. Open Policy Agent — Built-in Functions, json.unmarshal — la referencia oficial de la función usada para leer el campo policy de un aws_iam_role_policy.
  2. AWS Docs — Blocking public access to your Amazon S3 storage — la fuente oficial de los cuatro flags de aws_s3_bucket_public_access_block, usados en esta lección con la configuración más estricta posible.
  3. Terraform Registry — aws_s3_bucket_public_access_block — referencia completa del recurso HCL agregado en esta lección.
  4. terraform-and-iac-guide, Módulo 8, lección 4 — el punto exacto donde este mismo recurso se nombró como "el tipo de cambio que una revisión de plan debería aprobar sin dudar", sin construirse.
  5. Este curso, Módulo 2, lección 7 — el trabajo original de mínimo privilegio (TM-07) que least-privilege-iam.rego ahora verifica automáticamente.