Módulo 4: Policy As Code With Conftest

6. Manos a la obra: la política "nunca destruir `Shipments`"

Descripción

Esta lección escribe no-destroy-shipments.rego, la primera política de este módulo que corre contra el terraform plan real de Andes Cargo, no contra un YAML de prueba. Cierra TM-06 de RISK-MAP.md"ningún control detiene un plan que destruye Shipments"— y contesta, con una política ejecutable, la pregunta que el Módulo 1, lección 6, dejó abierta a propósito.

Conexión con el módulo

Vuelve, un momento, al Módulo 1, lección 6 de esta guía: el incidente Claude Code destroy —un agente con credenciales reales corrió terraform destroy sobre infraestructura de producción, y un humano aprobó la advertencia en vez de detenerla— se retomó desde el ángulo de "¿qué control, no humano, lo hubiera detenido?". Esa lección mapeó cada control que los Módulos 2 a 7 de esta guía construyen contra el punto exacto de la cadena de fallos donde lo habrían detenido. Esta lección es una de esas respuestas, la más directa de todas: una política que no le pregunta a ningún humano si está seguro, no muestra ninguna advertencia que alguien pueda aprobar sin leer — simplemente falla el plan, con código de salida distinto de cero, antes de que exista la posibilidad de un apply.


Analogía: el corta-circuitos, no el cartel de advertencia

Un cartel que dice "peligro de descarga eléctrica" es una advertencia — funciona si alguien la lee y decide, con buen juicio, no tocar el cable. Un corta-circuitos es distinto por diseño: no le pregunta a nadie, no muestra ningún texto que alguien pueda ignorar bajo presión o distracción — cuando detecta una sobrecarga, corta el circuito, sin importar si la sobrecarga la causó una persona distraída, un electrodoméstico defectuoso, o —el caso que hace que este ejemplo importe en 2026— un sistema automatizado que se descontroló sin que nadie lo estuviera mirando en ese instante exacto.

El incidente del Módulo 1, lección 6, fue un cartel de advertencia que un humano leyó y decidió ignorar: Terraform mostró el plan completo, con la destrucción claramente marcada, y la aprobación llegó de todas formas. no-destroy-shipments.rego es el corta-circuitos: no muestra ninguna advertencia que alguien tenga que interpretar bajo presión —falla el proceso, con un código de salida que un pipeline de CI no puede ignorar aunque quisiera—, sin importar si el plan destructivo lo generó una persona apurada o un agente con credenciales corriendo sin supervisión directa.


Paso 1 — La regla, en prosa, antes de Rego

Del resource_change que exploraste en la lección 5, la condición que quieres prohibir es precisa: cualquier plan donde un recurso de tipo aws_dynamodb_table, cuyo nombre sea exactamente "Shipments", tenga "delete" entre sus acciones. Nota el matiz que la lección 5 ya adelantó: no comparas change.actions contra ["delete"] exacto —eso dejaría pasar un reemplazo, ["delete", "create"], que también borra y recrea la tabla, perdiendo todos sus datos en el proceso—, comparas si "delete" está contenido en esa lista.


Paso 2 — La política completa

# policy/no-destroy-shipments.rego
package main

deny contains msg if {
    some rc in input.resource_changes
    rc.type == "aws_dynamodb_table"
    "delete" in rc.change.actions
    table_name := object.get(rc.change.before, "name", rc.address)
    table_name == "Shipments"
    msg := sprintf(
        "%s: destroying the Shipments table (name=%q) is forbidden — this plan must never be applied",
        [rc.address, table_name],
    )
}

Tres piezas nuevas frente a la política de la lección 4, todas necesarias para trabajar contra la estructura real de un plan:

  • some rc in input.resource_changes — recorre la lista completa de cambios, probando la regla contra cada uno. Esta es la forma en Rego de lo que en Python sería for rc in input["resource_changes"]: — con la diferencia de paradigma que la lección 2 ya anticipó: no acumulas un resultado paso a paso, declaras que la regla es verdadera si existe al menos un rc que cumple el resto de las condiciones.
  • "delete" in rc.change.actions — el operador in de Rego, verificando pertenencia a una lista, exactamente como la lección 5 advirtió que había que hacerlo.
  • object.get(rc.change.before, "name", rc.address) — lee el campo name de change.before (el estado del recurso antes de la destrucción, poblado porque un delete siempre tiene before, nunca after), con un valor de reserva (rc.address) si por algún motivo name no estuviera presente. Esta función —object.get(objeto, clave, default)— es la forma segura de leer un campo en Rego sin que la regla entera falle si esa clave no existe; vas a usarla de nuevo, con el mismo propósito, en la lección 7.

Paso 3 — Generando un plan que sí destruye la tabla, para probar la política de verdad

Una política que nunca viste fallar no es una política probada — es una promesa sin evidencia. Para confirmar que no-destroy-shipments.rego de verdad detecta una destrucción, necesitas un plan real donde Shipments se destruya. La forma estándar y de solo lectura de generar ese plan —la misma que usarías para previsualizar qué haría un terraform destroy antes de confirmarlo— es el modo -destroy de terraform plan:

terraform plan -destroy -out=destroy.tfplan
terraform show -json destroy.tfplan > destroy.tfplan.json

Qué esperar (literal, ejecutado para escribir esta lección — recorte del recurso relevante):

  # aws_dynamodb_table.shipments will be destroyed
  - resource "aws_dynamodb_table" "shipments" {
      - arn                         = "arn:aws:dynamodb:us-east-1:000000000000:table/Shipments" -> null
      - billing_mode                = "PAY_PER_REQUEST" -> null
      - hash_key                    = "shipmentId" -> null
      - id                          = "Shipments" -> null
      - name                        = "Shipments" -> null
      - tags                        = {
          - "Environment" = "dev"
[...]
    }

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

terraform plan -destroy es exactamente el comando que previsualiza un terraform destroy sin ejecutarlo — la misma disciplina de "lee el plan antes de aplicarlo" que ya conoces desde terraform-and-iac-guide, aplicada al caso más peligroso de todos: destruir, no crear. Es, en espíritu, el mismo comando que un ingeniero (o un agente, como el del incidente del Módulo 1) correría antes de un terraform destroy real — y es exactamente el punto donde no-destroy-shipments.rego va a interceptar el intento, antes de que llegue más lejos.


Paso 4 — conftest test, contra el intento de destrucción: FAIL

conftest test destroy.tfplan.json -p policy/no-destroy-shipments.rego

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

FAIL - destroy.tfplan.json - main - aws_dynamodb_table.shipments: destroying the Shipments table (name="Shipments") is forbidden — this plan must never be applied

1 test, 0 passed, 0 warnings, 1 failure, 0 exceptions
echo $?
1

Compara el mensaje completo, campo por campo, contra la política del Paso 2: %s se convirtió en aws_dynamodb_table.shipments (el rc.address real), %q se convirtió en "Shipments" (el table_name real, con comillas — así es exactamente como %q de Rego lo formatea). Este es el mismo mecanismo del sprintf de la política, funcionando contra datos reales por primera vez en este módulo.


Paso 5 — Un cambio inocuo, el mismo plan.json de la lección 5: PASS

La misma política, sin cambiar una línea, contra el plan normal de andes-cargo-infra/ —el que solo agrega recursos, generado en la lección 5, sin ninguna destrucción:

conftest test tfplan.json -p policy/no-destroy-shipments.rego

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

1 test, 1 passed, 0 warnings, 0 failures, 0 exceptions
echo $?
0

Este es el resultado que hace que una política sea confiable en un pipeline real: no solo detecta el caso malo (Paso 4), también deja pasar, sin fricción, cualquier cambio que no lo viole — un plan que agrega catorce recursos nuevos, ninguno de ellos una destrucción de Shipments, pasa exactamente como debería.


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

- | 5 | TM-06 | Denial of service | No control blocks a destructive `plan` on `Shipments` | `no-destroy-shipments.rego` | M4 | Open |
+ | 5 | TM-06 | Denial of service | No control blocks a destructive `plan` on `Shipments` | `no-destroy-shipments.rego` | M4 | Resolved (M4.6): `no-destroy-shipments.rego` fails any plan where `aws_dynamodb_table.shipments` has "delete" in its actions, verified via `conftest test` against a real `terraform plan -destroy` output |

Errores comunes

Comparar rc.change.actions contra "delete" como si fuera un string, en vez de usar in (repetido de la lección 5, con consecuencia real aquí). Qué pasa: alguien escribe rc.change.actions == "delete" en la política. Cómo detectarlo: la política nunca dispara, ni siquiera contra el destroy.tfplan.json del Paso 3 — porque rc.change.actions es ["delete"], una lista, y una lista nunca es igual a un string aunque contenga exactamente ese valor. Cómo corregirlo: usa "delete" in rc.change.actions, exactamente como en el Paso 2 — este es el error más silencioso de los tres de esta lección, porque no produce ningún mensaje de error: la política simplemente nunca falla, dándote una falsa sensación de seguridad.

Escribir la política sobre rc.change.after en vez de rc.change.before para leer el nombre de la tabla (de estructura de datos, específico de destrucciones). Qué pasa: alguien, acostumbrado a que after es donde vive la información relevante (como en la lección 5, donde el recurso se estaba creando), intenta leer rc.change.after.name para una destrucción. Cómo detectarlo: rego_type_error o una condición que nunca se cumple, porque change.after es null para cualquier recurso que se está destruyendo — no tiene ningún campo que leer. Cómo corregirlo: para una destrucción, la información completa del recurso vive en change.before (el estado antes del plan, que es lo único que existe cuando el resultado es que el recurso deja de existir) — la relación se invierte exactamente al revés de una creación, tal como la lección 5 ya predijo en su Ejercicio 3.

Probar la política únicamente contra el plan destructivo, sin confirmar también que un cambio inocuo pasa (de cobertura incompleta). Qué pasa: alguien escribe la regla, la ve fallar contra destroy.tfplan.json, y da la lección por terminada sin correr el Paso 5. Cómo detectarlo: si nunca corriste conftest test contra un plan que no destruye nada. Cómo corregirlo: una política que solo sabes que falla, pero nunca confirmaste que deja pasar el caso correcto, podría estar mal escrita de una forma que la haga fallar siempre —por ejemplo, si accidentalmente quitaste la condición rc.type == "aws_dynamodb_table" y ahora dispara contra cualquier tipo de recurso destruido—. El Paso 5 de esta lección no es opcional: es la mitad de la evidencia de que la política hace exactamente lo que se supone que hace, ni más ni menos.


Ejercicios

Ejercicio 1 — Extiende la política para proteger también el bucket andes-cargo-shipment-docs. Sin cambiar la regla existente, agrega una segunda regla deny a no-destroy-shipments.rego (o a un archivo nuevo dentro de policy/) que dispare si algún aws_s3_bucket con name o bucket igual a "andes-cargo-shipment-docs" tiene "delete" en sus acciones.

Ver solución
deny contains msg if {
    some rc in input.resource_changes
    rc.type == "aws_s3_bucket"
    "delete" in rc.change.actions
    bucket_name := object.get(rc.change.before, "bucket", rc.address)
    bucket_name == "andes-cargo-shipment-docs"
    msg := sprintf(
        "%s: destroying the shipment-docs bucket (bucket=%q) is forbidden — this plan must never be applied",
        [rc.address, bucket_name],
    )
}

El patrón es idéntico al de Shipments, con dos cambios: rc.type == "aws_s3_bucket" en vez de aws_dynamodb_table, y el campo que identifica al recurso es bucket (el nombre de un bucket S3 en el schema de Terraform), no name. Si tu solución sigue esta misma estructura —mismo operador in, mismo uso de object.get con valor de reserva—, tienes la generalización correcta del patrón de esta lección.

Ejercicio 2 — Explica por qué esta política usa object.get con un valor de reserva, en vez de leer rc.change.before.name directamente. ¿Qué pasaría, en teoría, si rc.change.before no tuviera un campo name por alguna razón, y la política intentara leerlo con acceso directo (rc.change.before.name) en vez de object.get?

Ver solución

Con acceso directo (rc.change.before.name), si el campo name no existiera en ese objeto por cualquier motivo, Rego trataría esa expresión como indefinida (no como un error) — la regla completa simplemente no dispararía para ese recurso, sin ningún aviso de que algo salió distinto de lo esperado. Con object.get(rc.change.before, "name", rc.address), en cambio, siempre obtienes un valor —el name real si existe, o el address del recurso como reserva si no— lo que significa que la comparación table_name == "Shipments" siempre se evalúa contra algo, nunca queda indefinida por un campo ausente. Es una forma de hacer la política más resistente a estructuras de plan ligeramente distintas a las que probaste, sin que eso signifique menos estricta en el caso que sí importa.

Ejercicio 3 — Predice qué mostraría conftest test si el plan destructivo incluyera, además de Shipments, la destrucción de un recurso sin relación (por ejemplo, module.app_server_role.aws_iam_role_policy.this). Sin correrlo todavía, predice: ¿la política de esta lección dispararía también para ese segundo recurso? ¿Por qué sí o por qué no?

Ver solución

No dispararía para el segundo recurso — no-destroy-shipments.rego, tal como está escrita, filtra explícitamente por rc.type == "aws_dynamodb_table" antes de revisar cualquier otra condición. Un aws_iam_role_policy destruido, sin importar cuán inesperado sea ese cambio, no cumple esa primera condición, así que la regla nunca llega a evaluar el resto del cuerpo para ese recurso. Esto es, a propósito, una política de alcance estrecho —protege exactamente un recurso, Shipments, no "cualquier destrucción inesperada"—; proteger otros recursos específicos (como el Ejercicio 1 de esta lección hizo con el bucket) exige escribir una regla adicional para cada uno, no ampliar esta regla para que cubra de más.


Resumen y siguiente paso

En esta lección escribiste no-destroy-shipments.rego, la política que reemplaza el grep artesanal de cicd-and-gitops-on-aws-guide por una regla Rego real, corrida contra el terraform plan de Andes Cargo. La confirmaste en ambas direcciones: FAIL real contra un plan -destroy que sí destruye la tabla (conftest test destroy.tfplan.json, código de salida 1), y PASS real contra el plan normal que solo agrega recursos (código de salida 0). Cerraste TM-06 de RISK-MAP.md — la primera de las dos filas que este módulo resuelve, y la respuesta directa a la pregunta que el Módulo 1, lección 6, dejó abierta sobre el incidente Claude Code destroy.

Antes de avanzar deberías poder: escribir de memoria una política que recorra resource_changes filtrando por type y por "delete" in actions; explicar por qué esta política lee change.before, no change.after; y generar tú mismo un plan destructivo de prueba con terraform plan -destroy, sin necesitar ningún apply real.

La lección 7 escribe dos políticas más, con una diferencia estructural importante: en vez de mirar si un recurso se destruye, van a mirar el contenido de una política IAM y de la configuración de un bucket — la primera vez en este módulo que una regla Rego necesita leer un campo que, dentro del JSON, es en sí mismo otro documento JSON codificado como string.

Recursos

  1. Terraform CLI — terraform plan, modo -destroy — la referencia oficial del modo usado en el Paso 3 de esta lección para generar un plan destructivo de solo lectura.
  2. Open Policy Agent — Built-in Functions, object.get — la referencia de la función usada para leer campos de forma segura en esta política.
  3. Este curso, Módulo 1, lección 6 — el incidente Claude Code destroy, la pregunta abierta que esta lección contesta con una política ejecutable.
  4. Este curso, Módulo 1, lección 8 (RISK-MAP.md) — la fila TM-06 que esta lección cierra.