Módulo 4: Bedrock Guardrails And Defense In Depth
3. Manos a la obra: declarando el guardrail completo en HCL
Descripción
modules/bedrock-guardrail/ (Módulo 3, lección 3) ya declara dos políticas —content_policy_config, sensitive_information_policy_config— con el patrón de bloque dynamic que hace posible activar solo lo que cada caso necesita. Esta lección extiende ese mismo módulo con las tres políticas restantes que la lección 2 acaba de explicar —temas denegados, grounding contextual, filtros de palabras—, sin reescribir una sola línea de las dos ya existentes, y corre terraform fmt/validate/plan de verdad sobre el guardrail completo de Andes Cargo, las seis políticas activas al mismo tiempo.
Conexión con el módulo
Esta lección hereda, sin repetirlo, el mecanismo completo que el Módulo 3, lección 1 explicó con precisión: validate y plan, para un recurso que todavía no existe en ningún state, nunca necesitan red. El guardrail de esta lección sigue siendo ese mismo recurso nuevo —solo que ahora con más bloques anidados adentro—, así que todo lo que sigue corre exactamente igual de real que el Módulo 3: sin LocalStack, sin cuenta AWS, sin token.
Analogía: agregar cajones a un mueble modular, sin desarmar los que ya funcionan
Un mueble modular bien diseñado —una cómoda con rieles estándar, por ejemplo— permite agregar un cajón nuevo sin tocar los que ya están instalados: el cajón nuevo usa el mismo tipo de riel, entra en el espacio reservado para él, y los cajones existentes siguen abriendo y cerrando exactamente igual que antes. modules/bedrock-guardrail/main.tf está diseñado con ese mismo principio: cada política es un bloque dynamic independiente, activado solo si la lista de entrada correspondiente tiene al menos un elemento (for_each = length(var.X) > 0 ? [1] : []). Agregar topic_policy_config, contextual_grounding_policy_config y word_policy_config a este módulo es, literalmente, instalar tres cajones nuevos en los rieles que el diseño del Módulo 3 ya dejó preparados — sin tocar ni una línea de los dos bloques que ya funcionaban.
Paso 1 — Tres variables nuevas en modules/bedrock-guardrail/variables.tf
Agregadas al final del archivo, después de pii_entities, sin modificar ninguna de las variables ya existentes:
# Module 4, lesson 3 -- the three policies Module 3 left undeclared.
variable "denied_topics" {
description = "Topics to deny in the topic policy. Each entry names a topic, defines it in prose, and optionally lists example phrases the guardrail should treat as belonging to that topic."
type = list(object({
name = string
definition = string
examples = optional(list(string), [])
}))
default = []
}
variable "grounding_filters" {
description = "Contextual grounding policy filters (GROUNDING, RELEVANCE), each with a confidence threshold between 0 and 0.99. A threshold of 1 is invalid -- it would block all content."
type = list(object({
type = string
threshold = number
}))
default = []
}
variable "managed_word_lists" {
description = "Managed word lists to enable in the word policy (currently only PROFANITY exists as a managed list)."
type = list(string)
default = []
}
variable "custom_words" {
description = "Custom words or short phrases (exact match, up to three words each) to block in the word policy."
type = list(string)
default = []
}
Fíjate en denied_topics: usa optional(list(string), []) dentro del tipo object — la misma sintaxis de valores por defecto en tipos anidados que terraform-and-iac-guide ya cubrió, aquí aplicada por primera vez en esta guía. Sin ese optional, cualquier tema declarado sin examples fallaría validate con un error de tipo — un tema con cero frases de ejemplo es perfectamente válido según la documentación de AWS (la lección 2 ya lo confirmó: examples es opcional).
Paso 2 — Tres bloques dynamic nuevos en modules/bedrock-guardrail/main.tf
Agregados después del bloque sensitive_information_policy_config ya existente, antes de tags = var.tags:
# Module 4, lesson 3 -- denied topics: a business-specific theme, not a word
# or an entity type, so it lives in its own policy block (topic_policy_config),
# never inside content_policy_config or word_policy_config.
dynamic "topic_policy_config" {
for_each = length(var.denied_topics) > 0 ? [1] : []
content {
dynamic "topics_config" {
for_each = var.denied_topics
content {
name = topics_config.value.name
definition = topics_config.value.definition
type = "DENY"
examples = topics_config.value.examples
}
}
}
}
# Module 4, lesson 3 -- contextual grounding: only evaluates model OUTPUT
# against a grounding source and a query, never the input prompt alone.
dynamic "contextual_grounding_policy_config" {
for_each = length(var.grounding_filters) > 0 ? [1] : []
content {
dynamic "filters_config" {
for_each = var.grounding_filters
content {
type = filters_config.value.type
threshold = filters_config.value.threshold
}
}
}
}
# Module 4, lesson 3 -- word filters: exact-match, not context-aware like
# content or topic filters. A managed list (PROFANITY) and/or custom words.
dynamic "word_policy_config" {
for_each = length(var.managed_word_lists) > 0 || length(var.custom_words) > 0 ? [1] : []
content {
dynamic "managed_word_lists_config" {
for_each = var.managed_word_lists
content {
type = managed_word_lists_config.value
}
}
dynamic "words_config" {
for_each = var.custom_words
content {
text = words_config.value
}
}
}
}
Fíjate en word_policy_config: su condición de activación combina las dos variables con || (length(...) > 0 || length(...) > 0) — a diferencia de las otras cuatro políticas, que cada una depende de una sola variable de entrada. Tiene sentido: el bloque word_policy_config completo es válido con solo la lista gestionada, solo palabras personalizadas, o ambas — no hay ninguna razón para exigir las dos a la vez.
Paso 3 — bedrock.tf, el guardrail completo de Andes Cargo
El módulo manifest_extractor_guardrail, ahora con las seis entradas activas —dos heredadas del Módulo 3, cuatro nuevas de esta lección—:
module "manifest_extractor_guardrail" {
source = "./modules/bedrock-guardrail"
name = "andes-cargo-manifest-extractor-guardrail"
blocked_input_messaging = "This input is not allowed due to content policy violations."
blocked_outputs_messaging = "This output is not allowed due to content policy violations."
content_filters = [
{
type = "PROMPT_ATTACK"
input_strength = "HIGH"
output_strength = "NONE"
}
]
pii_entities = [
{ type = "EMAIL", action = "ANONYMIZE" },
{ type = "PHONE", action = "ANONYMIZE" }
]
denied_topics = [
{
name = "ProhibitedShipmentGuidance"
definition = "Guidance, instructions, or advice about smuggling, evading customs inspections, or shipping illegal, prohibited, or undeclared goods."
examples = [
"How do I hide undeclared goods from customs inspection?",
"What is the best way to avoid a customs check on this shipment?",
]
}
]
grounding_filters = [
{ type = "GROUNDING", threshold = 0.75 },
{ type = "RELEVANCE", threshold = 0.75 },
]
managed_word_lists = ["PROFANITY"]
custom_words = ["undisclosed cargo", "avoid inspection"]
tags = local.common_tags
}
Ningún cambio al bloque bedrock_manifest_extractor_role ni a las dos data "aws_iam_policy_document" de bedrock.tf — exactamente lo que el Ejercicio 3 del Módulo 3, lección 8 ya predijo: el archivo que llama al módulo crece, el rol IAM no cambia en absoluto.
Paso 4 — terraform fmt, y un error real que apareció al escribir esta lección
cd andes-cargo-infra/
terraform fmt -check -recursive
echo "fmt exit: $?"
Qué esperar (literal — esto es exactamente lo que pasó al escribir esta lección, sin editar el resultado):
bedrock.tf
fmt exit: 3
El primer intento de esta lección no pasó fmt -check — custom_words tenía dos espacios extra antes del =, sin alinearse con managed_word_lists en la línea de arriba (terraform fmt alinea los signos = de asignaciones consecutivas). fmt -check no falla por un error de sintaxis; falla porque el archivo, tal como está escrito, no coincide con lo que terraform fmt (sin -check) produciría. La corrección es el propósito exacto del comando:
terraform fmt -recursive
terraform fmt -check -recursive
echo "fmt exit: $?"
Qué esperar (literal):
bedrock.tf
fmt exit: 0
La primera salida (bedrock.tf, sin la bandera -check) es el nombre del archivo que fmt reescribió; la segunda confirma que, después del arreglo automático, ya no hay ninguna diferencia entre el archivo y lo que el formateador esperaría. Este es el mismo patrón exacto que el Módulo 3, lección 8 ya corrió (fmt exit: 0 sobre el proyecto completo) — la diferencia es que aquí puedes ver, de verdad, el momento en que algo no pasó a la primera, y cómo se corrige sin intervención manual.
Paso 5 — terraform validate, sobre el guardrail de seis mecanismos
terraform validate
Qué esperar (literal — corrido de verdad, sin LocalStack, sin cuenta AWS, en este entorno):
Success! The configuration is valid.
Paso 6 — terraform plan, aislando solo el guardrail
terraform plan -input=false -no-color -target=module.manifest_extractor_guardrail -out=tfplan-m4-guardrail-only
Qué esperar (literal — corrido de verdad, sin LocalStack, sin cuenta AWS, en este entorno):
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# module.manifest_extractor_guardrail.aws_bedrock_guardrail.this will be created
+ resource "aws_bedrock_guardrail" "this" {
+ blocked_input_messaging = "This input is not allowed due to content policy violations."
+ blocked_outputs_messaging = "This output is not allowed due to content policy violations."
+ created_at = (known after apply)
+ description = (known after apply)
+ guardrail_arn = (known after apply)
+ guardrail_id = (known after apply)
+ name = "andes-cargo-manifest-extractor-guardrail"
+ region = "us-east-1"
+ status = (known after apply)
+ tags = {
+ "Environment" = "dev"
+ "ManagedBy" = "terraform"
+ "Project" = "andes-cargo"
}
+ tags_all = {
+ "Environment" = "dev"
+ "ManagedBy" = "terraform"
+ "Project" = "andes-cargo"
}
+ updated_at = (known after apply)
+ version = (known after apply)
+ content_policy_config {
+ tier_config = (known after apply)
+ filters_config {
+ input_strength = "HIGH"
+ output_strength = "NONE"
+ type = "PROMPT_ATTACK"
}
}
+ contextual_grounding_policy_config {
+ filters_config {
+ threshold = 0.75
+ type = "GROUNDING"
}
+ filters_config {
+ threshold = 0.75
+ type = "RELEVANCE"
}
}
+ sensitive_information_policy_config {
+ pii_entities_config {
+ action = "ANONYMIZE"
+ input_action = (known after apply)
+ input_enabled = (known after apply)
+ output_action = (known after apply)
+ output_enabled = (known after apply)
+ type = "EMAIL"
}
+ pii_entities_config {
+ action = "ANONYMIZE"
+ input_action = (known after apply)
+ input_enabled = (known after apply)
+ output_action = (known after apply)
+ output_enabled = (known after apply)
+ type = "PHONE"
}
}
+ topic_policy_config {
+ tier_config = (known after apply)
+ topics_config {
+ definition = "Guidance, instructions, or advice about smuggling, evading customs inspections, or shipping illegal, prohibited, or undeclared goods."
+ examples = [
+ "How do I hide undeclared goods from customs inspection?",
+ "What is the best way to avoid a customs check on this shipment?",
]
+ name = "ProhibitedShipmentGuidance"
+ type = "DENY"
}
}
+ word_policy_config {
+ managed_word_lists_config {
+ type = "PROFANITY"
}
+ words_config {
+ text = "undisclosed cargo"
}
+ words_config {
+ text = "avoid inspection"
}
}
}
Plan: 1 to add, 0 to change, 0 to destroy.
Plan: 1 to add — un solo recurso, aws_bedrock_guardrail.this, con cinco bloques de política anidados adentro, visibles, literales, en el orden alfabético que Terraform usa para mostrarlos: content_policy_config, contextual_grounding_policy_config, sensitive_information_policy_config, topic_policy_config, word_policy_config. Esta es la evidencia completa de la lección 2, convertida en HCL real y confirmada por el propio motor de Terraform — no una promesa, un plan verificable.
Aíslalo con el mismo patrón de filtro Python del Módulo 3, lección 8:
terraform show -json tfplan-m4-guardrail-only | python3 -c "
import json, sys
data = json.load(sys.stdin)
rc = [r for r in data['resource_changes'] if r['type'] == 'aws_bedrock_guardrail'][0]
after = rc['change']['after']
policies = [k for k in after if k.endswith('_policy_config')]
print(len(policies), 'policy blocks declared on the guardrail:')
for p in sorted(policies):
print(' -', p)
"
Qué esperar (literal):
5 policy blocks declared on the guardrail:
- content_policy_config
- contextual_grounding_policy_config
- sensitive_information_policy_config
- topic_policy_config
- word_policy_config
Paso 7 — terraform plan sobre el proyecto completo, confirmando que nada más cambió
terraform plan -input=false -no-color -out=tfplan-m4-guardrail
Qué esperar (literal — corrido de verdad):
Plan: 17 to add, 0 to change, 0 to destroy.
Diecisiete recursos — el mismo número exacto que el Módulo 3, lección 8 ya confirmó. No es una coincidencia: esta lección no agregó ningún recurso nuevo a andes-cargo-infra/, solo agregó bloques anidados dentro de un recurso que el Módulo 3 ya declaraba (aws_bedrock_guardrail.this). terraform plan cuenta recursos, no bloques de configuración dentro de un recurso — el conteo de 17 to add confirma, con la misma disciplina del 0 to destroy que el Módulo 3 ya enseñó a leer, que esta lección extendió infraestructura existente sin crear ni una pieza nueva de superficie de despliegue.
Errores comunes
Copiar main.tf sin la actualización correspondiente en variables.tf, o al revés (de olvidar que un bloque dynamic depende de una variable que tiene que existir primero). Qué pasa: alguien agrega los tres bloques dynamic de esta lección a main.tf, pero olvida agregar denied_topics, grounding_filters, managed_word_lists o custom_words a variables.tf. Cómo detectarlo: terraform validate falla con Reference to undeclared input variable, señalando la línea exacta del bloque dynamic que referencia la variable faltante. Cómo corregirlo: los dos archivos de este módulo se editan siempre juntos — cada variable nueva en variables.tf necesita su bloque dynamic correspondiente en main.tf, y cada bloque dynamic nuevo necesita que su variable ya exista. Esta lección los presentó en ese orden (Paso 1, luego Paso 2) precisamente para reforzar esa dependencia.
Olvidar terraform fmt -check antes de asumir que el HCL está listo (de saltarse el Paso 4 de esta lección). Qué pasa: alguien escribe HCL sintácticamente válido —terraform validate pasaría sin problema— pero con espaciado inconsistente entre asignaciones, como el custom_words de esta misma lección antes de corregirlo. Cómo detectarlo: terraform fmt -check -recursive devuelve un código de salida distinto de cero y lista el archivo con formato inconsistente, aunque validate no reporte ningún error. Cómo corregirlo: terraform fmt sin -check reescribe el archivo automáticamente — nunca hay que corregir el espaciado a mano; el Paso 4 de esta lección mostró exactamente esta secuencia, con el error real, sin editarlo.
Asumir que type = "DENY" en topics_config es opcional porque no aparece explícitamente en la llamada del módulo (bedrock.tf) (de no distinguir el nivel del módulo del nivel del recurso). Qué pasa: alguien busca type = "DENY" en bedrock.tf y no lo encuentra, y concluye erróneamente que el valor se omitió. Cómo detectarlo: revisa modules/bedrock-guardrail/main.tf, no bedrock.tf — el valor "DENY" está hardcodeado dentro del bloque dynamic "topics_config" del módulo (type = "DENY"), no expuesto como un argumento configurable desde quien llama al módulo. Cómo corregirlo: esto es una decisión de diseño del módulo, no un descuido — la documentación de AWS (lección 2) confirma que "DENY" es, hoy, el único valor válido para ese campo, así que el módulo lo fija internamente en vez de pedirle a cada llamada que lo repita sin necesidad, el mismo principio de "no exponer lo que nunca varía" que ya rige el resto de este módulo.
Ejercicios
Ejercicio 1 — Sin mirar main.tf, escribe de memoria la condición for_each que activaría word_policy_config solo si CUALQUIERA de las dos fuentes de palabras tiene al menos un elemento. Verifica tu respuesta contra el Paso 2 de esta lección.
Ver solución
for_each = length(var.managed_word_lists) > 0 || length(var.custom_words) > 0 ? [1] : []. El operador || es la pieza clave: cualquiera de las dos condiciones siendo verdadera activa el bloque completo — a diferencia de las otras cuatro políticas de este módulo, que cada una depende de exactamente una variable de entrada.
Ejercicio 2 — Explica por qué Plan: 17 to add en el Paso 7 de esta lección es el mismo número exacto que el Módulo 3, lección 8 ya reportó, a pesar de que esta lección agregó código HCL genuinamente nuevo.
Ver solución
Porque el código nuevo de esta lección —tres variables, tres bloques dynamic, cuatro argumentos nuevos en la llamada al módulo— vive dentro de un recurso que el Módulo 3 ya declaraba (aws_bedrock_guardrail.this), no como un recurso adicional independiente. terraform plan cuenta recursos gestionados (cada uno con su propio ciclo de vida de create/update/destroy), no la cantidad de bloques de configuración anidados dentro de un solo recurso. Un guardrail con dos políticas y un guardrail con cinco políticas siguen siendo, ambos, exactamente un recurso aws_bedrock_guardrail — más rico por dentro, idéntico en conteo.
Ejercicio 3 — Predice qué mostraría terraform plan -target=module.manifest_extractor_guardrail si, por error, alguien declarara grounding_filters con un solo elemento (GROUNDING, sin RELEVANCE). ¿Sería un plan válido?
Ver solución
Sería un plan perfectamente válido — grounding_filters es una lista, y contextual_grounding_policy_config, según el schema citado en el Módulo 3, lección 2, acepta cualquier número de filters_config (nesting=list, sin mínimo declarado). El resultado real sería un guardrail que solo evalúa GROUNDING (¿la respuesta está fundamentada en la fuente?) pero nunca RELEVANCE (¿la respuesta contesta la consulta?) — una configuración legítima, aunque más débil que la de esta lección, que cubre ambas dimensiones a propósito. Esta es la misma lección que el Ejercicio 3 del Módulo 3, lección 2 ya enseñó sobre filters_config vacío: HCL sintácticamente correcto puede, de todas formas, dejar una brecha de cobertura real que solo una revisión humana del caso de negocio detectaría — terraform validate nunca podría señalarlo.
Resumen y siguiente paso
Esta lección extendió modules/bedrock-guardrail/ con las tres políticas que la lección 2 explicó y el Módulo 3 dejó pendientes —temas denegados, grounding contextual, filtros de palabras—, siguiendo exactamente el mismo patrón de bloque dynamic que ya regía las otras dos. Corriste fmt, validate y plan de verdad, incluido un error real de formato y su corrección, y confirmaste, con el filtro Python del Módulo 3, los cinco bloques de política presentes en el plan del guardrail completo —y Plan: 17 to add, 0 to change, 0 to destroy sobre el proyecto entero, el mismo número que el Módulo 3 ya dejó, confirmando que nada de infraestructura nueva se agregó, solo profundidad dentro de lo ya declarado.
Antes de avanzar deberías poder: agregar una política nueva a este módulo, de memoria, siguiendo el mismo patrón de tres piezas (variable, bloque dynamic, argumento en la llamada del módulo); explicar por qué el conteo de recursos de terraform plan no cambió a pesar del HCL nuevo; y ejecutar tú mismo el filtro Python del Paso 6 contra tu propio tfplan-m4-guardrail-only.
La lección 4 da un paso atrás de todo este HCL y pregunta lo que ningún terraform validate puede contestar: con las seis políticas activas, ¿es este guardrail, por sí solo, suficiente defensa para extract-shipment-manifest-fields?
Recursos
- Terraform Registry —
aws_bedrock_guardrail— documentación oficial del recurso completo que esta lección declara. - Terraform Language Docs —
optional()en tipos de variable — referencia de la sintaxis usada endenied_topics(Paso 1). - Módulo 3, lección 2 de esta guía (
02-what-terraform-resources-exist-for-bedrock.md) — el schema real de las cinco políticas, fuente directa del HCL de esta lección. - Módulo 3, lección 3 de esta guía (
03-hands-on-the-modules-bedrock-guardrail-module.md) — el módulo original, con las dos políticas que esta lección extiende sin reescribir. - Módulo 3, lección 8 de esta guía (
08-project-andes-cargos-ai-infrastructure-declared.md) — elPlan: 17 to addoriginal, reconfirmado sin cambios en el Paso 7 de esta lección.