Module 4: Bedrock Guardrails And Defense In Depth
3. Hands-on: declaring the complete guardrail in HCL
Description
modules/bedrock-guardrail/ (Module 3, lesson 3) already declares two policies — content_policy_config, sensitive_information_policy_config — with the dynamic block pattern that makes it possible to activate only what each case needs. This lesson extends that same module with the three remaining policies lesson 2 just explained — denied topics, contextual grounding, word filters —, without rewriting a single line of the two already existing, and runs terraform fmt/validate/plan for real on Andes Cargo's complete guardrail, all six policies active at once.
Connection to the module
This lesson inherits, without repeating it, the complete mechanism Module 3, lesson 1 explained precisely: validate and plan, for a resource that doesn't exist yet in any state, never need network. This lesson's guardrail remains that same new resource — just now with more nested blocks inside —, so everything that follows runs exactly as real as Module 3: no LocalStack, no AWS account, no token.
Analogy: adding drawers to modular furniture, without disassembling the ones that already work
A well-designed piece of modular furniture — a dresser with standard rails, for example — lets you add a new drawer without touching the ones already installed: the new drawer uses the same rail type, fits into the space reserved for it, and the existing drawers keep opening and closing exactly as before. modules/bedrock-guardrail/main.tf is designed with that same principle: each policy is an independent dynamic block, activated only if the corresponding input list has at least one element (for_each = length(var.X) > 0 ? [1] : []). Adding topic_policy_config, contextual_grounding_policy_config, and word_policy_config to this module is, literally, installing three new drawers onto the rails Module 3's design already left ready — without touching a single line of the two blocks that already worked.
Step 1 — Three new variables in modules/bedrock-guardrail/variables.tf
Added at the end of the file, after pii_entities, without modifying any of the already-existing variables:
# 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 = []
}
Notice denied_topics: it uses optional(list(string), []) inside the object type — the same default-value-in-nested-type syntax terraform-and-iac-guide already covered, applied here for the first time in this guide. Without that optional, any topic declared with no examples would fail validate with a type error — a topic with zero example sentences is perfectly valid per AWS documentation (lesson 2 already confirmed it: examples is optional).
Step 2 — Three new dynamic blocks in modules/bedrock-guardrail/main.tf
Added after the already-existing sensitive_information_policy_config block, before 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
}
}
}
}
Notice word_policy_config: its activation condition combines both variables with || (length(...) > 0 || length(...) > 0) — unlike the other four policies, each of which depends on a single input variable. It makes sense: the complete word_policy_config block is valid with only the managed list, only custom words, or both — there's no reason to require both at once.
Step 3 — bedrock.tf, Andes Cargo's complete guardrail
The manifest_extractor_guardrail module, now with six active inputs — two inherited from Module 3, four new from this lesson:
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
}
No change to the bedrock_manifest_extractor_role block or the two data "aws_iam_policy_document" blocks in bedrock.tf — exactly what Module 3, lesson 8's Exercise 3 already predicted: the file calling the module grows, the IAM role doesn't change at all.
Step 4 — terraform fmt, and a real error that appeared while writing this lesson
cd andes-cargo-infra/
terraform fmt -check -recursive
echo "fmt exit: $?"
What to expect (literal — this is exactly what happened while writing this lesson, with no editing of the result):
bedrock.tf
fmt exit: 3
This lesson's first attempt didn't pass fmt -check — custom_words had two extra spaces before the =, not aligned with managed_word_lists on the line above (terraform fmt aligns = signs across consecutive assignments). fmt -check doesn't fail for a syntax error; it fails because the file, as written, doesn't match what terraform fmt (without -check) would produce. The fix is the command's exact purpose:
terraform fmt -recursive
terraform fmt -check -recursive
echo "fmt exit: $?"
What to expect (literal):
bedrock.tf
fmt exit: 0
The first output (bedrock.tf, without the -check flag) is the name of the file fmt rewrote; the second confirms that, after the automatic fix, there's no longer any difference between the file and what the formatter would expect. This is the exact same pattern Module 3, lesson 8 already ran (fmt exit: 0 on the complete project) — the difference is you can see, for real, the moment something didn't pass the first time, and how it gets fixed with no manual intervention.
Step 5 — terraform validate, on the six-mechanism guardrail
terraform validate
What to expect (literal — run for real, with no LocalStack, no AWS account, in this environment):
Success! The configuration is valid.
Step 6 — terraform plan, isolating just the guardrail
terraform plan -input=false -no-color -target=module.manifest_extractor_guardrail -out=tfplan-m4-guardrail-only
What to expect (literal — run for real, with no LocalStack, no AWS account, in this environment):
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 — a single resource, aws_bedrock_guardrail.this, with five nested policy blocks inside, visible, literal, in the alphabetical order Terraform uses to display them: content_policy_config, contextual_grounding_policy_config, sensitive_information_policy_config, topic_policy_config, word_policy_config. This is lesson 2's complete evidence, turned into real HCL and confirmed by Terraform's own engine — not a promise, a verifiable plan.
Isolate it with the same Python filter pattern from Module 3, lesson 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)
"
What to expect (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
Step 7 — terraform plan on the complete project, confirming nothing else changed
terraform plan -input=false -no-color -out=tfplan-m4-guardrail
What to expect (literal — run for real):
Plan: 17 to add, 0 to change, 0 to destroy.
Seventeen resources — the exact same number Module 3, lesson 8 already confirmed. This isn't a coincidence: this lesson didn't add any new resource to andes-cargo-infra/, it only added nested blocks inside a resource Module 3 already declared (aws_bedrock_guardrail.this). terraform plan counts resources, not configuration blocks inside a resource — the 17 to add count confirms, with the same discipline for reading 0 to destroy Module 3 already taught, that this lesson extended existing infrastructure without creating a single new piece of deployment surface.
Common mistakes
Copying main.tf without the corresponding update to variables.tf, or the reverse (forgetting a dynamic block depends on a variable that has to exist first mistake). What happens: someone adds this lesson's three dynamic blocks to main.tf, but forgets to add denied_topics, grounding_filters, managed_word_lists, or custom_words to variables.tf. How to spot it: terraform validate fails with Reference to undeclared input variable, pointing to the exact line of the dynamic block referencing the missing variable. How to fix it: this module's two files always get edited together — every new variable in variables.tf needs its corresponding dynamic block in main.tf, and every new dynamic block needs its variable to already exist. This lesson presented them in that order (Step 1, then Step 2) precisely to reinforce that dependency.
Forgetting terraform fmt -check before assuming the HCL is ready (skipping this lesson's Step 4 mistake). What happens: someone writes syntactically valid HCL — terraform validate would pass fine — but with inconsistent spacing between assignments, like this lesson's own custom_words before it got fixed. How to spot it: terraform fmt -check -recursive returns a nonzero exit code and lists the file with inconsistent formatting, even though validate reports no error. How to fix it: terraform fmt without -check rewrites the file automatically — you never have to fix spacing by hand; this lesson's Step 4 showed exactly this sequence, with the real error, without editing it.
Assuming type = "DENY" in topics_config is optional because it doesn't appear explicitly in the module call (bedrock.tf) (not distinguishing the module level from the resource level mistake). What happens: someone looks for type = "DENY" in bedrock.tf and doesn't find it, and mistakenly concludes the value got omitted. How to spot it: check modules/bedrock-guardrail/main.tf, not bedrock.tf — the value "DENY" is hardcoded inside the module's dynamic "topics_config" block (type = "DENY"), not exposed as a configurable argument from whoever calls the module. How to fix it: this is a module design decision, not an oversight — AWS's documentation (lesson 2) confirms "DENY" is, today, the only valid value for that field, so the module fixes it internally instead of asking every caller to repeat it unnecessarily, the same "don't expose what never varies" principle already governing the rest of this module.
Exercises
Exercise 1 — Without looking at main.tf, write from memory the for_each condition that would activate word_policy_config only if EITHER of the two word sources has at least one element. Check your answer against this lesson's Step 2.
See solution
for_each = length(var.managed_word_lists) > 0 || length(var.custom_words) > 0 ? [1] : []. The || operator is the key piece: either condition being true activates the whole block — unlike this module's other four policies, each of which depends on exactly one input variable.
Exercise 2 — Explain why Plan: 17 to add in this lesson's Step 7 is the exact same number Module 3, lesson 8 already reported, despite this lesson adding genuinely new HCL code.
See solution
Because this lesson's new code — three variables, three dynamic blocks, four new arguments in the module call — lives inside a resource Module 3 already declared (aws_bedrock_guardrail.this), not as an additional, independent resource. terraform plan counts managed resources (each with its own create/update/destroy lifecycle), not the number of configuration blocks nested inside a single resource. A guardrail with two policies and a guardrail with five policies remain, both, exactly one aws_bedrock_guardrail resource — richer inside, identical in count.
Exercise 3 — Predict what terraform plan -target=module.manifest_extractor_guardrail would show if, by mistake, someone declared grounding_filters with only one element (GROUNDING, without RELEVANCE). Would it be a valid plan?
See solution
It would be a perfectly valid plan — grounding_filters is a list, and contextual_grounding_policy_config, per the schema cited in Module 3, lesson 2, accepts any number of filters_config (nesting=list, no minimum declared). The real result would be a guardrail evaluating only GROUNDING (is the response grounded in the source?) but never RELEVANCE (does the response answer the query?) — a legitimate configuration, though weaker than this lesson's, which deliberately covers both dimensions. This is the same lesson Module 3, lesson 2's Exercise 3 already taught about an empty filters_config: syntactically correct HCL can still leave a real coverage gap only a human review of the business case would catch — terraform validate could never point it out.
Summary and next step
This lesson extended modules/bedrock-guardrail/ with the three policies lesson 2 explained and Module 3 left pending — denied topics, contextual grounding, word filters —, following exactly the same dynamic block pattern that already governed the other two. You ran fmt, validate, and plan for real, including a real formatting error and its fix, and confirmed, with Module 3's Python filter, the five policy blocks present in the complete guardrail's plan — and Plan: 17 to add, 0 to change, 0 to destroy on the whole project, the same number Module 3 already left, confirming no new infrastructure got added, just depth inside what was already declared.
Before moving on you should be able to: add a new policy to this module, from memory, following the same three-piece pattern (variable, dynamic block, argument in the module call); explain why terraform plan's resource count didn't change despite the new HCL; and run yourself Step 6's Python filter against your own tfplan-m4-guardrail-only.
Lesson 4 steps back from all this HCL and asks what no terraform validate can answer: with all six policies active, is this guardrail, on its own, sufficient defense for extract-shipment-manifest-fields?
Resources
- Terraform Registry —
aws_bedrock_guardrail— official documentation for the complete resource this lesson declares. - Terraform Language Docs —
optional()in variable types — reference for the syntax used indenied_topics(Step 1). - This guide's Module 3, lesson 2 (
02-what-terraform-resources-exist-for-bedrock.md) — the five policies' real schema, the direct source for this lesson's HCL. - This guide's Module 3, lesson 3 (
03-hands-on-the-modules-bedrock-guardrail-module.md) — the original module, with the two policies this lesson extends without rewriting. - This guide's Module 3, lesson 8 (
08-project-andes-cargos-ai-infrastructure-declared.md) — the originalPlan: 17 to add, reconfirmed unchanged in this lesson's Step 7.