Lab 08 — Encode the Verdict as a Gate: policy that fails closed¶
Hands-on lab. Environment:
plaintext-labs/ztna/08-policy-as-code. Objective: write real OPA/Rego policy, prove the deny path fires, and leave behind a CI gate that exits non-zero on a fail-open policy and zero on the fix. Target: ~90 min, one finish line. This is a reference lab — realopain a container, no mocks.
✈ Flight card — the 6 things to hold¶
Glance here when you lose the thread. This replaces re-reading the module.
| # | Fact | Why it matters |
|---|---|---|
| 1 | OPA is a decision point, not the enforcer. PEP calls PDP; your code acts on the answer. | Policy is separable from the stack → testable in ms → gate-able in CI. |
| 2 | Rego is declarative — a rule whose condition is never true is silently absent. | No error. The deny you "wrote" may deny nothing. |
| 3 | A deny that never fires defaults to allow → fail open. | This is the whole module. Run the must-deny case and confirm it fired. |
| 4 | default allow := false + query the deny decision = absence fails closed. |
Structure, not vigilance, is what makes "no rule fired" mean reject. |
| 5 | K8s: deny explicit runAsUser: 0 AND omitted runAsUser (root by default). |
The omitted-field case is the one AI drafts almost always miss. |
| 6 | The gate is the deliverable: non-zero on broken, zero on fixed. | A test that only proves the allow path is theater; the flip is the proof. |
(If you can explain all six cold at the end — especially #2 and #3 — you've got the objective.)
↳ Go deeper — pull only when a step doesn't click: the module's centerpiece section and the gate is the deliverable.
Warm-up — answer before you touch the container (2 min)¶
Don't look below. Being forced to retrieve is what builds the memory.
- You wrote a
denyrule but typo'd the claim — you checkinput.user.roles(plural) when the request spells itinput.user.role. You querydata.corp.access.denyfor the request that must be blocked. What value comes back — and what happens if your enforcer treats "not denied" as "allow"? - A pod spec omits
runAsUserentirely — nosecurityContext.runAsUserat all. Is that pod root? Should your policy deny it? Why do AI-drafted admission policies usually let it through?
Setup¶
This is a reference lab — it ships a one-command environment in the companion
plaintext-labs repo. The container bundles the
real opa binary (pinned openpolicyagent/opa:0.68.0); every target runs real Rego evaluation.
git clone https://github.com/plaintext-security/plaintext-labs
cd plaintext-labs/ztna/08-policy-as-code
make up # start the OPA container (real opa, pinned)
make demo # run both scenarios — allow + deny cases for each
make test # opa test over every policy in data/policies/ (-v)
make eval POLICY=<path> INPUT=<path> # one-off: eval a policy against an input
make shell # OPA REPL loaded with the lab policies (distroless — no shell, the REPL is the surface)
make down # stop it when you're done
▸ On track if:
make demoprints the two scenario banners and, for each case, a JSON block whose"value"matches the label — e.g. Case A analyst READ shows"value": truefordata.corp.access.allow; Case B analyst WRITE shows"value": falseforallowand"value": truefordata.corp.access.deny. The policies you edit live indata/policies/; the requests indata/inputs/.Authorization note. Everything runs locally against bundled data you own — no external targets, no authorization needed. (The Rego you write here can later gate a real proxy or cluster; there the rule binds — only enforce against systems you own or have explicit written permission to test.)
Build it — read a little, do a little¶
Step 1 — Read the policy, predict, then run both scenarios¶
Concept (30 sec): Flight-card #1. OPA answers; it doesn't enforce. make demo feeds four data-access
requests and three pod specs through real opa eval and prints each decision. Predicting before
running is how you find out whether you actually understand the rules.
Do it: open data/policies/data-access.rego — find default allow := false, the three allow
rules (analyst-read, admin-any, service_account-read), and the two deny rules (analyst-write,
service_account-write). Read the four data/inputs/*.json and predict each verdict. Then read
data/policies/k8s-admission.rego and its three pod inputs. Run make demo.
▸ On track if: your predictions match the output — analyst READ →
allow: true; analyst WRITE →allow: false,deny: true; admin WRITE →allow: true; service_account WRITE →allow: false,deny: true; pod-root and pod-no-user →deny: true; pod-nonroot →deny: false. Where deny overrides allow (analyst write), confirm the deny query returnedtrue— not merely that allow was false.
Step 2 — Add an auditor role with a deny the others don't have¶
Concept (30 sec): Flight-card #2. Compliance wants an auditor who can read but can never
hit /export (bulk download), regardless of action. That's an allow rule and a deny rule that
overrides it — the exact shape where a silently-absent deny hurts.
Do it: in data-access.rego, uncomment/complete the lab-exercise block so auditor gets an
allow (read) and a deny on resource == "/export". Create data/inputs/auditor-export.json
(role: auditor, action: read, resource: /export). Confirm the denial:
▸ On track if: the emitted
datadocument showscorp.access.denyastruefor the auditor-export request (allow may also betrue— that's the point of deny overrides allow). Ifdenyisfalse, your rule isn't firing — check the claim name and the/exportstring exactly.
Step 3 — The fail-open trap: plant it, watch the deny vanish, catch it¶
Concept (30 sec): Flight-card #3 — the centerpiece. A deny whose condition is never true isn't an
error; it's a rule that quietly isn't there, and the default takes over.
Do it: break your new auditor//export deny in one realistic way — typo the claim
(input.user.roles), flip the comparison (!= where you meant ==), or guard it behind a field the
input never carries. Re-run the exact make eval from step 2. The denial disappears. Record the
one line you saw in fail-open-proof.md. Then restore the correct rule and confirm the deny is back.
▸ On track if: with the bug planted,
corp.access.denyfor auditor-export drops tofalse(the rule never fired, the default applied) — anddefault allow := false+ querying the deny decision is what makes that absence resolve to reject in your enforcer, not allow. Broken → open, fixed → closed: that pair is the lesson, not the syntax.
This is the one that ships breaches
A green demo with a silently-absent deny is exactly how Broken Access Control (OWASP A01) reaches production. The policy looks complete; the hole is the rule you meant to write. The only defense is running the must-deny case and confirming it fired — which is what step 4 makes automatic.
Step 4 — Make the deny path a test (so the trap can't come back)¶
Concept (30 sec): Flight-card #6. A discipline you have to remember is a discipline you'll forget.
opa test turns "run the deny case" into a unit test that a machine runs on every change.
Do it: in data/policies/data-access_test.rego, uncomment the auditor tests and confirm you have
at least test_analyst_write_denied, test_auditor_export_denied, and test_analyst_read_allowed. Run
make test. Then re-plant the step-3 bug and run make test again — the deny test must now go red.
Fix the policy, confirm green, keep this proof.
▸ On track if:
make testprintsPASSfor everytest_rule (data-access + k8s-admission, summaryPASS: N/N) with the policy correct — and with the bug re-planted,test_auditor_export_deniedflips to FAIL. A suite that stays all-green while the deny rule is broken is testing nothing.
Step 5 — K8s admission: the omitted-field trap, and an AI-drafted policy you must break-test¶
Concept (30 sec): Flight-card #5. "No root pods" means two cases: explicit runAsUser: 0 and an
omitted runAsUser (root by default). AI writes the first and forgets the second — the same fail-open
trap wearing a Kubernetes costume.
Do it: confirm k8s-admission.rego denies both — make eval on pod-root.json and
pod-no-user.json (both deny: true), and pod-nonroot.json (deny: false). Then run the
AI-drafting exercise: ask a model for "an OPA Rego policy that denies pods that don't set
readOnlyRootFilesystem: true", save it as data/policies/readonly-fs.rego. Before trusting it,
write data/inputs/pod-writable-fs.json (the case that must be denied) and a deny test in
data/policies/readonly-fs_test.rego. Run make test.
▸ On track if:
pod-no-user.jsonreturnsdeny: true(if it doesn't, the policy missed the omitted-field case — fix it); and yourreadonly-fsdeny test either passes, or fails and exposes that the AI wrote only the allow path — in which case you close the hole yourself and re-prove green.
Prove the control (your finish line)¶
One assertion is the whole module: the gate flips. Add a make ci gate (the repo ships one — opa
test ./data/policies/) and a GitHub Actions workflow .github/workflows/opa-test.yml (on push +
pull_request) that runs it. Have a model draft the workflow; read every line and confirm its opa
test invocation matches what you run locally.
The proof — run it both ways:
# 1) with a fail-open bug planted (re-break the auditor/export deny):
make ci ; echo "exit: $?" # → tests fail, exit NON-ZERO
# 2) restore the fix:
make ci ; echo "exit: $?" # → all tests pass, exit ZERO
Capture both exit: lines into fail-open-proof.md. A gate whose exit code doesn't change between
broken and fixed isn't a gate — it's a report. That single flip, allow proven and deny proven, is
what makes the policy a control instead of a hope.
Recall check — close the doc, answer from memory (3 min)¶
- Why can you unit-test an OPA policy in milliseconds without standing up a proxy or a cluster?
- You query
denyfor a request that should be blocked and getfalse. Name two distinct reasons the rule might not have fired — and whydefault allow := falsealone doesn't save you. - Name the two pod cases the admission policy must both deny, and say which one AI usually misses.
Missed one? Re-run the step that built it, or pull the module centerpiece — then re-answer.
Deliverables¶
Commit to your portfolio repo:
data/policies/data-access.rego— extended with theauditorread-allow +/exportdeny, structured to fail closed (default allow := false).data/policies/data-access_test.rego— the suite including the explicit deny-path cases (test_auditor_export_denied).data/policies/readonly-fs.rego+readonly-fs_test.rego— the AI-drafted policy with your deny-path test.data/inputs/auditor-export.jsonanddata/inputs/pod-writable-fs.json— the new inputs.fail-open-proof.md— the one-line record from step 3 (what the broken deny produced) plus the twoexit:captures from the finish line (broken vs. fixed) proving the gate flips..github/workflows/opa-test.yml— the CI gate.
Do not commit raw opa eval JSON dumps, or the data/ files seeded by the lab that you didn't
change. The git history is the audit trail for who changed which policy and when — that is
policy-as-code's whole point.
Automate & own it¶
Required — this is the judgment-as-code core of the module. The policy tests are the automation.
Your verdict is: "these access rules must hold, and a deny rule that silently fails open must never pass
review." Encode it as a portable gate — gate.sh, one script that:
- runs
opa test ./data/policies/(exit non-zero on any failing/erroring test), and - runs
opa evalon each must-deny input and asserts the deny actually fired (deny == true, not an empty/falseresult) — so a silently-absent deny fails the gate instead of sliding through, and - prints which policy/input combination blocked it.
Then prove the harness: with a fail-open bug planted gate.sh exits 1; with it fixed it exits 0; assert
the flip. Have a model draft the bash and the jq/opa eval checks; review every line — confirm an
opa error doesn't read as a clean pass, and that the gate fails for the right reason (the missing
deny), not an unrelated parse nit. This gate is your verdict made un-recurrable.
Definition of done (policy-as-code ✅)¶
- [ ]
make demoshows every labelled allow/deny case for both scenarios, and you confirmed each deny fired (not merely that allow was absent). - [ ] The
auditorrole is denied on/export, with an input and a passingopa testcase covering it. - [ ] You caught a fail-open gap: planted a silently-absent deny, watched
denydrop tofalse, and can explain whydefault allow := false+ querying the deny decision makes absence fail closed. - [ ]
make testis green; re-planting the deny bug turnstest_auditor_export_deniedFAIL (the test genuinely exercises the deny path). - [ ] The K8s policy denies both explicit-root and omitted-
runAsUserpods; the AI-draftedreadonly-fspolicy has a passing deny-path test. - [ ] The gate exits non-zero on the broken (fail-open) policy and zero on the fix — captured with
$?. You can explain all six flight-card facts cold.
Connects forward¶
OPA plugs into the identity-aware proxy from Module 06 as an external authorization provider: Pomerium (or OAuth2-Proxy) calls OPA on every request with the JWT claims, OPA returns allow/deny on fine-grained policy. Coarse authentication stays in the proxy; fine-grained, version-controlled, tested authorization lives in OPA. The gate you built here keeps that policy from silently failing open in production — and feeds Module 09, where the same access decisions become the log stream you detect on and watch for drift.
Marketable proof¶
"I write and unit-test OPA Rego policies for RBAC and Kubernetes admission, version them in git with an
opa testCI gate, and — the part that matters — I validate the deny path explicitly: I can show a policy that silently fails open (the deny rule that never fires), explain whydefault allow := falseplus querying the deny decision makes absence fail closed, and prove my CI gate exits non-zero on the broken policy and zero on the fix."
Stretch¶
- Integrate OPA with Pomerium: write a
policy.regothat Pomerium evaluates per-request instead of YAMLallowstanzas — the production pattern for complex authorization, tested independently of proxy config. - Convert
denyfrom a boolean to a partial set of reason strings (deny contains msg if { … }) so a rejection tells the developer why — then confirm your gate still catches a member that's never added (a reason you never emit = a deny that never fired). - Use OPA's
http.sendto fetch an external ACL (a JSON file served by a local nginx container) and evaluate against live data — then prove the gate still fails closed when the ACL fetch errors (a network failure must not read as "allow"). - Port one policy to Gatekeeper (
ConstraintTemplate+Constraint) in akindcluster and watch Kubernetes reject a root pod at admission time — the real enforcement point behind theopa evalyou've been running.
Comments
Sign in with GitHub to comment. Choose the type: Feedback (errors or suggestions on this page) · Hints (help for fellow learners — no spoilers) · General (anything else).