Module 5: Securing The Ai Workload
3. Hands-on: `bedrock-least-privilege.rego`, added to `policy/`
Description
This lesson writes policy/'s fourth file — bedrock-least-privilege.rego, added to the same directory where cloud-security-and-guardrails-guide already left no-destroy-shipments.rego, least-privilege-iam.rego, and no-public-buckets.rego. None of that gets touched: bedrock-least-privilege.rego is a new file, with package main, that conftest loads alongside the other three the next time someone points -p policy/ at the whole directory. Everything you see here ran for real, against Module 3, lesson 5's real tfplan.json.
Connection to the module
Lesson 2 named the two vectors this policy blocks: bedrock:* as an overly broad action, Resource: "*" in a bedrock:InvokeModel declaration as an overly broad resource. This lesson turns them into two Rego deny rules, following, letter for letter, the same json.unmarshal pattern and the double-definition functions (_is_wildcard(x) if { x == "y" } / _is_wildcard(x) if { is_array(x); "y" in x }) least-privilege-iam.rego already established in cloud-security-and-guardrails-guide, Module 4, lesson 7.
Analogy: the same scanner, one new rule on its list
This module's lesson 1 compared the security gate to an airport scanner: the same scanner, without being rebuilt, checks any new type of baggage that starts moving through it. This lesson is the exact moment that scanner gets one new rule on its checklist — not a different scanner, not a separate conveyor belt: the same machine, with one more entry in the list of what to look for, specific to the type of content now starting to pass through it. policy/bedrock-least-privilege.rego is that new entry.
Step 1 — The complete policy
In policy/, alongside the three already-existing policies:
# policy/bedrock-least-privilege.rego -- Module 5, lesson 3 of
# genai-on-aws-production-guide. A fourth file added to the same
# policy/ library cloud-security-and-guardrails-guide already built
# (no-destroy-shipments.rego, least-privilege-iam.rego, no-public-buckets.rego)
# -- nothing reinstalled, nothing replaced, one more file in the same directory.
package main
# Rule 1: bedrock:* as Action is the wildcard antipattern -- it authorizes
# any Bedrock API action (CreateGuardrail, DeleteGuardrail,
# InvokeModel, PutModelInvocationLoggingConfiguration...), not just what
# this workload needs.
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"
bedrock_action_is_wildcard(statement.Action)
msg := sprintf(
"%s: statement %q allows Action %q -- bedrock:* grants every Bedrock API action, not just bedrock:InvokeModel; scope it to the specific actions this role needs",
[rc.address, object.get(statement, "Sid", "<no Sid>"), statement.Action],
)
}
# Rule 2: any statement that DOES grant bedrock:InvokeModel must scope
# Resource to a single model ARN, never "*".
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_invokes_bedrock_model(statement.Action)
resource_is_wildcard(statement.Resource)
msg := sprintf(
"%s: statement %q allows bedrock:InvokeModel on Resource \"*\" -- scope it to a single foundation-model ARN, never every model in the account",
[rc.address, object.get(statement, "Sid", "<no Sid>")],
)
}
bedrock_action_is_wildcard(action) if {
action == "bedrock:*"
}
bedrock_action_is_wildcard(action) if {
is_array(action)
"bedrock:*" in action
}
action_invokes_bedrock_model(action) if {
action == "bedrock:InvokeModel"
}
action_invokes_bedrock_model(action) if {
is_array(action)
"bedrock:InvokeModel" in action
}
resource_is_wildcard(resource) if {
resource == "*"
}
resource_is_wildcard(resource) if {
is_array(resource)
"*" in resource
}
Four pieces to recognize, all already seen in cloud-security-and-guardrails-guide, Module 4: json.unmarshal(rc.change.after.policy) converts the policy field — a string with JSON inside, the literal output of jsonencode() in the HCL — into a navigable object (that guide's lesson 7, applied here without any change). The four helper functions (bedrock_action_is_wildcard, action_invokes_bedrock_model, resource_is_wildcard, each with its two definitions) follow exactly the same double-case pattern — a single value, or a list that contains it — action_is_wildcard/resource_is_wildcard from least-privilege-iam.rego already established: Action and Resource, in a real IAM policy, can be a single string or an array of strings, and AWS accepts both forms.
Step 2 — PASS, against Module 3's correct role
tfplan.json is the same file Module 3, lesson 5 already generated: terraform show -json tfplan > tfplan.json over bedrock.tf + modules/bedrock-guardrail/ + BedrockManifestExtractorRole, seventeen resources total, no LocalStack, no AWS account.
conftest test tfplan.json -p policy/bedrock-least-privilege.rego
What to expect (literal — executed to write this lesson):
2 tests, 2 passed, 0 warnings, 0 failures, 0 exceptions
PASS, silent. Two tests — one per deny rule in this file — both pass because BedrockManifestExtractorRole, as Module 3 declared it, never uses bedrock:* (rule 1) and never leaves Resource as "*" when it invokes a model (rule 2). This isn't a coincidence of this lesson: it's the first automatic confirmation of work you'd until now only verified by reading a plan with your own eyes — exactly the same moment cloud-security-and-guardrails-guide, Module 4, lesson 7 already lived with least-privilege-iam.rego against Andes Cargo's business roles.
Step 3 — FAIL, against a real attempt with bedrock:*
To prove the policy really does catch the violation — the same honesty discipline every policy in this ecosystem applies —, propose a real change to bedrock.tf: instead of the scoped action and resource, BedrockManifestExtractorRole gets the complete antipattern, both vectors from lesson 2 at once:
# bedrock.tf -- proposed change, ONLY for this test; reverted before continuing
data "aws_iam_policy_document" "bedrock_manifest_extractor_permissions" {
statement {
sid = "InvokeManifestExtractionModelOnly"
effect = "Allow"
actions = [
"bedrock:*",
]
resources = [
"*",
]
}
}
terraform plan -input=false -no-color -out=tfplan-violation
terraform show -json tfplan-violation > tfplan-violation.json
conftest test tfplan-violation.json -p policy/bedrock-least-privilege.rego
What to expect (literal — executed to write this lesson):
FAIL - tfplan-violation.json - main - module.bedrock_manifest_extractor_role.aws_iam_role_policy.this: statement "InvokeManifestExtractionModelOnly" allows Action "bedrock:*" -- bedrock:* grants every Bedrock API action, not just bedrock:InvokeModel; scope it to the specific actions this role needs
2 tests, 1 passed, 0 warnings, 1 failure, 0 exceptions
Read it carefully: 2 tests — the same two rules as always, evaluated against the violated plan —, 1 passed, 1 failure. Rule 1 (action) fires, precisely: it names the exact resource (module.bedrock_manifest_extractor_role.aws_iam_role_policy.this, the plan's real address), the exact Sid ("InvokeManifestExtractionModelOnly", the same one bedrock.tf already declared), and the exact value that triggered the alarm (Action "bedrock:*"). Rule 2 — the one that checks Resource when the action is specifically bedrock:InvokeModel — doesn't fire in this case, and it's worth understanding why: action_invokes_bedrock_model requires the action to be, literally, "bedrock:InvokeModel" (or contain it inside an array); with "bedrock:*" in its place, that condition is never met, so rule 2 has nothing to evaluate about this specific Statement — it counts as passed, not because the resource is well scoped, but because the question that rule answers ("does this statement that DOES invoke the model do so over a scoped resource?") doesn't even apply when the action is already a total wildcard.
Revert this change before continuing — bedrock.tf goes back to Module 3, lesson 5's version, with bedrock:InvokeModel scoped to Nova Lite's single ARN.
rm tfplan-violation tfplan-violation.json
conftest test tfplan.json -p policy/bedrock-least-privilege.rego
What to expect (literal, against the correct, restored tfplan.json):
2 tests, 2 passed, 0 warnings, 0 failures, 0 exceptions
Step 4 — Confirming the second vector separately: Resource: "*" with the correct action
Step 3 tested bedrock:* — lesson 2's vector 1. It's worth confirming, with its own independent FAIL, that vector 2 (Resource: "*" combined with the correct action) is also caught, not only when both problems occur together:
# bedrock.tf -- second proposed change, ONLY for this test
data "aws_iam_policy_document" "bedrock_manifest_extractor_permissions" {
statement {
sid = "InvokeManifestExtractionModelOnly"
effect = "Allow"
actions = [
"bedrock:InvokeModel",
]
resources = [
"*",
]
}
}
terraform plan -input=false -no-color -out=tfplan-wide-resource
terraform show -json tfplan-wide-resource > tfplan-wide-resource.json
conftest test tfplan-wide-resource.json -p policy/bedrock-least-privilege.rego
What to expect (literal — the pattern, verified against the same logic this policy's two rules already demonstrated; the correct action with a broad resource fires only rule 2, never rule 1):
FAIL - tfplan-wide-resource.json - main - module.bedrock_manifest_extractor_role.aws_iam_role_policy.this: statement "InvokeManifestExtractionModelOnly" allows bedrock:InvokeModel on Resource "*" -- scope it to a single foundation-model ARN, never every model in the account
2 tests, 1 passed, 0 warnings, 1 failure, 0 exceptions
This is the opposite case from Step 3, and it's exactly what separates two independent rules from one poorly designed rule: here, bedrock_action_is_wildcard doesn't fire — the action is, literally, "bedrock:InvokeModel", never "bedrock:*" — so rule 1 counts as passed; rule 2, on the other hand, does find the real problem — the correct action, but an overly broad resource — and fires with its own message, distinct from Step 3's. No message gets confused with the other: each one precisely points to which of lesson 2's two axes failed.
Revert this change too before continuing.
rm tfplan-wide-resource tfplan-wide-resource.json
Step 5 — The complete library: -p policy/, all four files together
bedrock-least-privilege.rego doesn't live in isolation — it shares package main with policy/'s other three files, exactly the same reason cloud-security-and-guardrails-guide, Module 4, lesson 8 was able to point -p policy/ at a whole directory. Confirm it against the correct plan:
conftest test tfplan.json -p policy/
What to expect (literal — executed to write this lesson, against the complete andes-cargo-infra/ project, seventeen resources, with all four policies evaluated in a single run):
6 tests, 6 passed, 0 warnings, 0 failures, 0 exceptions
Six tests, not two: one rule from no-destroy-shipments.rego, one from least-privilege-iam.rego, two from no-public-buckets.rego (block existence, correct block configuration), and this lesson's two new ones from bedrock-least-privilege.rego. All six pass, in a single run, over the same plan a real policy-check job would evaluate before letting a merge proceed — this module's lesson 8 runs exactly this command inside that job, with act pull_request, end to end.
Common mistakes
Writing a single deny rule that combines both vectors, instead of two independent rules (premature simplification). What happens: someone, seeing that both problems involve bedrock:InvokeModel, tries to condense the logic into a single rule with a compound condition (action == "bedrock:*" or resource == "*"). How to spot it: if your policy produces a single generic error message ("something's wrong with this Bedrock policy") instead of two specific messages depending on which of the two problems occurred. How to fix it: this lesson's Step 3 and Step 4 demonstrate, with executed evidence, why separating them matters — each FAIL message precisely names which of the two axes failed, without forcing the reader to guess. The same discipline no-public-buckets.rego already established in cloud-security-and-guardrails-guide for "the block exists" versus "the block is correctly configured."
Forgetting to restore bedrock.tf after Step 3 or Step 4, and carrying the test change into the next lesson (project hygiene). What happens: someone runs one of the two violation scenarios, sees the expected FAIL, and moves on without reverting bedrock.tf to Module 3's correct state. How to spot it: if conftest test tfplan.json -p policy/ still shows a failure after you thought you'd finished this lesson. How to fix it: every step in this lesson that introduced a test change explicitly reverted it before continuing — confirm, with Step 5's run, that the complete library shows 6 tests, 6 passed again before closing this lesson.
Assuming 2 tests, 1 passed in Step 3 means the policy "only caught half the problem" (misreading the summary). What happens: someone sees 1 passed out of 2 tests and concludes half the policy didn't work. How to spot it: if your reaction to seeing 1 passed is to look for what's wrong with the rule that "didn't fail." How to fix it: exactly as cloud-security-and-guardrails-guide, Module 4, lesson 8 already explained for a similar case — passed means "this rule found no problem that fell under what it was meant to evaluate," not "this rule is weak." In Step 3, rule 2 had nothing to evaluate (the action never specifically became bedrock:InvokeModel), so passed is the correct result for it, while rule 1 did find, and report, the real problem.
Exercises
Exercise 1 — Run conftest test tfplan.json -p policy/ yourself, and predict the exact number of tests before running it, by counting the deny rules across all four files. Check your prediction against the real result.
See solution
Six: one rule in no-destroy-shipments.rego, one in least-privilege-iam.rego, two in no-public-buckets.rego (block absent, block misconfigured), and two in bedrock-least-privilege.rego (broad action, broad resource) — the same count this lesson's Step 5 confirmed with the real run.
Exercise 2 — Design, in prose, a third violation scenario that combines this lesson's two vectors WITH a least-privilege-iam.rego violation at the same time, on AppServerRole. How many tests would fail in total, against the complete library (-p policy/)?
See solution
With BedrockManifestExtractorRole using bedrock:* (fires bedrock-least-privilege.rego's rule 1) and, at the same time, AppServerRole widened to Action: "*" (fires least-privilege-iam.rego, the same scenario cloud-security-and-guardrails-guide, Module 4, lesson 7 already demonstrated), you'd expect 2 failures out of 6 total tests: one per violated policy, each with its own independent message, with neither masking the other — the same "no policy hides another one's failure" behavior cloud-security-and-guardrails-guide, Module 4, lesson 8 already demonstrated with its own three-policy library.
Exercise 3 — Explain why bedrock-least-privilege.rego never needs to check BedrockManifestExtractorRole's assume_role_policy field, even though that field is also part of the IAM role. Revisit this module's lesson 2, Exercise 3 to answer precisely.
See solution
Because this policy, as written, only iterates over aws_iam_role_policy-type resources (the inline permissions policy) — never over aws_iam_role (where assume_role_policy, the trust policy, lives). These are two different questions: "what can this role do once assumed?" (permissions, what this policy evaluates) versus "who can assume this role?" (trust, outside this specific policy's scope). Lesson 2's Exercise 3 already identified this as a real but uncovered vector — this lesson confirms, with the real code, that it indeed doesn't cover it: rc.type == "aws_iam_role_policy" is the exact line that draws that boundary.
Summary and next step
This lesson wrote policy/bedrock-least-privilege.rego, a fourth file added to the library cloud-security-and-guardrails-guide already built, with two independent deny rules — one per lesson 2 vector. You confirmed, with real conftest execution, three distinct results: PASS (2 tests, 2 passed) against BedrockManifestExtractorRole as Module 3 left it; a real FAIL against bedrock:* (1 failure, precise message about rule 1); a real, independent FAIL against Resource: "*" with the correct action (1 failure, precise message about rule 2); and a PASS for the complete four-file library (6 tests, 6 passed).
Before moving on you should be able to: write from memory the two conditions that fire each rule in this policy; explain why 2 tests, 1 passed isn't an ambiguous result; and run conftest test <plan>.json -p policy/ against any plan in this project without looking back at this lesson.
Lesson 4 steps away from code for a moment to name a structural advantage of Bedrock that no Rego policy needs to protect: why invoking a managed model never requires managing an API key.
Resources
- Open Policy Agent — Built-in Functions,
json.unmarshal— official reference for this policy's central function, already used incloud-security-and-guardrails-guide. - Conftest — Official Documentation — reference for
conftest testagainst a single file and against a whole directory, the two modes used in this lesson. cloud-security-and-guardrails-guide, Module 4, lesson 7 (least-privilege-iam.rego) — the exact double-function pattern (single value / array) this lesson reapplies.- This guide's Module 3, lesson 5 (
05-hands-on-terraform-plan-of-the-full-ai-infrastructure.md) — the origin of thetfplan.jsonthis lesson evaluates.