Lab 14 — Data, the Last Pillar: label-based authorization and exfil detection¶
Hands-on lab. Environment:
plaintext-labs/ztna/14-data-pillar. Objective: make the classification label an access-control input — extend OPA (Module 08) with a role × classification policy that fails closed on a missing label, and extend Sigma (Module 09) with a detection for bulk reads of restricted data. Target: ~100–120 min, one finish line. This is a reference lab — the exactopaandsigma-cli/detect.pytoolchain from Modules 08 and 09, pointed at a new input.
✈ Flight card — the 7 things to hold¶
Glance here when you lose the thread. This replaces re-reading the module.
| # | Fact | Why it matters |
|---|---|---|
| 1 | The classification label IS an access-control input, not metadata. |
Reading it is not enough — the policy decides from it, the same way it decides from a role claim. |
| 2 | default effective_classification := "restricted" is the one line that makes absence fail closed. |
Break it and an unlabeled record silently becomes world-readable — the fail-open trap, one config line deep. |
| 3 | Role × classification: analyst/auditor read internal; only data-officer/admin read restricted. | deny overrides allow, and querying deny — not just "not allow" — is the must-deny proof. |
| 4 | OPA decides ONE request; Sigma decides a SESSION'S volume. | A stolen-but-valid credential passes OPA on every single read — count across the session is the only tell. |
| 5 | The dangerous session looks identical to the legitimate one: same device, same country, same role. | Volume is the only anomaly (Snowflake, 2024: stolen creds, no MFA, no data-layer guardrail caught the volume). |
| 6 | This module closes NIST 800-207's fifth pillar. | Identity (02) → device (03) → network (07) → workload (12) → data (14) — the set an enterprise buyer expects covered end to end. |
| 7 | The real handles: roles analyst/auditor/data-officer/admin; sessions sess_D1 (dpatel, normal, 3 restricted reads) vs sess_D2 (dpatel, overnight burst, 8 reads); threshold > 5. |
These are the exact strings in the data — the module prose used the general shape. |
(If you can explain all seven cold at the end — especially #2 and #4 — you've got the objective.)
↳ Go deeper — pull only when a step doesn't click: the module's label-based authorization section and the volume signal section.
Warm-up — answer before you start the containers (2 min)¶
Don't look below. Being forced to retrieve is what builds the memory.
- A record's
classificationfield is missing entirely — nobody wrote a policy rule for "no label." Should that record be readable by an ordinary authenticated role? Why is "no rule fired" a dangerous answer here specifically? dpatel(a legitimatedata-officer) reads 3 restricted records at reasonable hours, then reads 8 more at 2 AM from the same device, same account, same country. Which OPA query, run against any one of those 11 reads, tells you something is wrong? (Trick question — read it twice.)- Name one control a CASB, a DLP-egress scanner, or a customer-managed encryption key (CMK/BYOK) would add here that neither the OPA policy nor the Sigma rule in this lab can give you.
Setup¶
This is a reference lab with a one-command environment in the companion
plaintext-labs repo. It runs the same two
engines as Modules 08 and 09 — real opa (pinned openpolicyagent/opa:0.68.0) and the sigma-cli +
detect.py teaching matcher — pointed at a new input: a classification label.
git clone https://github.com/plaintext-security/plaintext-labs
cd plaintext-labs/ztna/14-data-pillar
make up # start the OPA server + build the sigma/detect.py image
make demo # run both halves: label-based decisions, then the bulk-read detection
make test # opa test over data/policies/
make check # the committed regression gate (check-data.sh)
make down # stop it when you're done
▸ On track if:
make demoprints PART 1 — six OPA decisions:analyst→ public (allow),analyst→ restricted (allowanddeny),data-officer→ restricted (allow), and the unlabeled record'seffective_classificationanddenyforanalyst— followed by PART 2 (the Sigma rule againstdata/access-logs.jsonl), ending with a block of exactly 8[HIT]lines, all taggedsession_id=sess_D2, and the summaryMatched 8 of the events.Authorization note. Everything here runs locally against bundled data you own — no external targets, no authorization needed. The corpus and access log are fictional (
data/records.json,data/access-logs.jsonl) — this is honor-system, the same footing as Modules 08 and 09.
Build it — read a little, do a little¶
Step 1 — Read the corpus like the policy will¶
Concept (30 sec): Flight-card #1. Open data/records.json before you open the policy. In every
earlier module the access decision came from a claim about the requester (a role, a device posture, a
SPIFFE ID). Here it also comes from a claim about the resource — and that claim lives in the data, not
in code.
Do it: read data/records.json. Find rec-003 (restricted), rec-002 (internal), rec-001
(public) — and then find rec-004 and rec-009, the two records where the classification field is
null and "Restricted" (wrong case) respectively. Predict, before running anything, whether an
analyst should be able to read each of the nine records. Then open data/policies/data-classification.rego
and confirm your predictions against default allow := false, the three allow rules, and the deny
rule.
▸ On track if: you predicted
analystcan read rec-001, rec-002, rec-006, rec-007 (public/internal) and cannot read rec-003, rec-005, rec-008 (restricted) — and, before reading the fail-closed section below, you flagged rec-004 and rec-009 as "not obviously public or internal, so I'm not sure" rather than defaulting them to public. That hesitation is the correct instinct — Step 3 makes it a rule.
Step 2 — Prove the must-deny case¶
Concept (30 sec): Flight-card #3. allow == false and deny == true are not the same fact. The
finish line for label-based authorization is the second one: a merely-authenticated, non-privileged role
reading restricted data must produce a fired deny, not just an absent allow.
Do it: run the two queries by hand:
make eval POLICY=data/policies/data-classification.rego INPUT=data/inputs/analyst-read-restricted.json
Read the emitted data document for both corp.data.allow and corp.data.deny. Then run the same eval
against data/inputs/data-officer-read-restricted.json and confirm the opposite pattern.
▸ On track if:
analyst→ restricted showsallow: falseanddeny: true;data-officer→ restricted showsallow: true. Ifdenycame backfalsefor the analyst case, the rule isn't firing — re-check the role set in the deny rule, not just the allow rules.
Step 3 — The centerpiece: break fail-closed, watch it fail open, fix it¶
Concept (30 sec): Flight-card #2 — the single load-bearing line in this whole lab. A record with no recognizable label isn't "ungoverned" — it's supposed to be the most restrictive tier, by construction, not by a rule someone remembered to write for every possible garbage value. Prove that by breaking it on purpose.
Do it: open data-classification.rego and change the one line
to
Re-run the eval from Step 2 but point it at data/inputs/analyst-read-unlabeled.json, querying
corp.data.effective_classification and then corp.data.allow. Watch the unlabeled record become
world-readable. Record the one-line proof — the exact "value" you saw — in fail-closed-proof.md.
Then restore the original line and re-run both queries to confirm the fix.
▸ On track if: with the bug planted,
effective_classificationreads"public"andallowreadstruefor the analyst reading the unlabeled record — a record that should be maximally protected is now the easiest one to read. With the line restored,effective_classificationis back to"restricted"anddenyistrue. Broken → open, fixed → closed: that pair is the lesson, not the syntax — the exact shape as Module 08's fail-open centerpiece, one layer down.
This is how under-classified data becomes a breach
A record with no label, or a mis-typed one (rec-009's "Restricted", wrong case, is silently not
the same string as "restricted" to Rego's case-sensitive match), is not a corner case — it is the
everyday failure mode of any real data inventory: migrations lose tags, new tables ship unlabeled,
someone fat-fingers a taxonomy value. The only defense is a default that resolves the unknown case to
the most restrictive tier, and a test that asserts it — not a policy author's discipline.
Step 4 — Detect the volume signal, then tune the threshold both ways¶
Concept (30 sec): Flight-card #4 + #5. OPA already proved every single read in data/access-logs.jsonl
was, on its own, a legitimate decision — dpatel is a data-officer, and a data-officer may read
restricted data. Nothing in the OPA layer is wrong about any one of those 11 reads. What's wrong is all
8 of them landing in one session at 2 AM. That's a different question, answered by a different tool.
Do it: run make demo (Part 2) or make detect RULE=examples/zt-bulk-restricted-read.yml and read
the [HIT] lines. Confirm they're all sess_D2, none are sess_D1. Then open the rule and change the
threshold > 5 to > 10 — re-run and watch the burst (8 reads) go silent, a missed compromise. Set
it back and instead try > 2 — re-run and watch dpatel's normal 3-read session (sess_D1) also
fire — a false positive that would train the SOC to ignore this rule. Restore > 5.
▸ On track if: at threshold
> 5you see exactly 8 hits, allsess_D2. At> 10you see 0 hits (a missed exfil). At> 2you see hits from both sessions (a false positive on ordinary work). Restoring> 5is not an arbitrary choice — it's the value that catches the real burst while staying silent ondpatel's andcsingh's legitimate single-digit restricted reads elsewhere in the log.▸ Note the boundary with Module 09. This rule doesn't look at country, device, or time of day at all — the burst events even share the same device and country as the normal ones. A geo-rule like Module 09's would say nothing here. That's the point: label-based volume is a different signal than identity or geography, and a mature detection program runs both.
Step 5 — Map the controls you can't self-host (assessed, not stood up)¶
Concept (30 sec): OPA and Sigma close the gap this track can build for free. A production data pillar also layers controls that require a vendor platform or a KMS this lab cannot stand up in a container — name them honestly rather than pretending the two engines above are the whole picture.
Do it (write it, don't run it): in your deliverable, map each of the following to what it would have added to the Snowflake scenario, and to this lab's corpus/log if it existed for real:
- CASB (Cloud Access Security Broker) — a proxy/API-connector layer in front of the SaaS/warehouse that can enforce policy and log activity the app itself doesn't emit.
- DLP egress scanning — content inspection on the way out (a bulk CSV download, an API response over some row-count threshold) — the generalized, content-aware cousin of the count-based Sigma rule you just tuned.
- Tokenization / format-preserving encryption — restricted fields (SSNs, card numbers) stored as irreversible or format-preserving tokens, so a bulk read returns tokens, not the raw value, even if authorization was granted.
- CMK/BYOK (customer-managed keys) — the org holds the encryption key outside the vendor's control, so a vendor-side account compromise cannot decrypt data at rest without a separate key compromise.
- Data residency — controls on where a copy of restricted data is allowed to live at all, which a role/classification check alone says nothing about.
Label this section assessed from a design, not something stood up — same honesty as Module 03's device-posture policy.
▸ On track if: each control names the specific gap it closes that neither
data-classification.regonorzt-bulk-restricted-read.ymlcan — e.g., DLP egress scanning would have flagged the mass query volume even faster than a session-count Sigma rule reading logs after the fact; CMK/BYOK would not have stopped the read, but would have limited what a later storage-layer compromise could decrypt.
Prove the control (your finish line)¶
Three assertions, watched — not just asserted:
- The label-based deny fires.
analystreading arestrictedrecord:allow: false,deny: true.- Fail-closed, proven by breaking it. The unlabeled record (
rec-004) resolves toeffective_classification: "restricted"— and with the default line changed to"public", you watchedanalyst's read of it flip toallow: true. Restoring the line restores the deny.- The exfil rule fires on the burst, stays silent on the normal session.
sess_D2(8 restricted reads, one 2 AM session) produces exactly 8[HIT]lines;sess_D1(dpatel's 3 ordinary restricted reads) produces none.
Run make check and confirm all four checks report PASS. The proof is the pair in #2 — a policy
that only ever shows you the fixed behavior never demonstrated it fails closed; you have to see it fail
open first.
Recall check — close the doc, answer from memory (3 min)¶
- Which single line in the Rego policy makes an unlabeled record resolve to
restrictedinstead ofpublic— and what did you observe when you (temporarily) changed it? - Why does querying
denyprove more than queryingallowand finding itfalse? dpatel's 11 restricted reads across the log are all individually authorized by OPA. What tool catches the 8 that matter, and on what dimension does it key that OPA never looks at?- Name one control from Step 5 that would have caught the Snowflake-style pattern before the data left, not just detected it afterward in a log.
Missed one? Re-run the step that built it, or pull the module's label-based authorization section — then re-answer.
Deliverables¶
data/policies/data-classification.rego— the label-based policy,default allow := false,default effective_classification := "restricted"intact (restored after Step 3).data/policies/data-classification_test.rego— the passing test suite,opa testgreen.fail-closed-proof.md— the one-line record from Step 3: whateffective_classificationandallowreturned with the default line broken, and confirmation they returned to the safe values after the fix.examples/zt-bulk-restricted-read.yml— your tuned rule (restored to> 5after Step 4), plus a one-paragraph note on what> 10and> 2each got wrong.data-controls-design.md— the Step 5 write-up: CASB / DLP-egress / tokenization / CMK-BYOK / data residency mapped to gaps neither engine in this lab closes, tied to the Snowflake case, labelled assessed from a design.
Lab artifacts you didn't change — the seeded data/records.json, data/access-logs.jsonl — stay
committed as shipped; don't regenerate them. The git history on the policy and the rule is the audit
trail for who changed the classification logic and when.
Automate & own it¶
Required. check-data.sh is the regression gate for this module — it asserts (1) the label-based
deny fires for a non-privileged role on restricted data, (2) the unlabeled record resolves to
"restricted" and is denied (fail-closed), (3) a privileged role is still allowed (this isn't
deny-everything), and (4) the bulk-read Sigma rule fires on sess_D2 and stays silent on sess_D1.
Run it, watch it pass:
Then prove it both ways, the same discipline as Module 08's gate.sh: re-plant the Step 3 bug
(default effective_classification := "public") and re-run make check — check #2 must go RED.
Restore the fix and confirm GREEN again. Have a model draft the docker compose run / grep
plumbing if you like; you verify it fails for the right reason (the missing deny), not an unrelated
parse error, and that it fails closed — a script that silently exits 0 when a docker compose call
errors is worse than no gate at all.
Definition of done (data-pillar ✅)¶
- [ ]
make demoshows all five OPA decisions from Part 1 and exactly 8[HIT]lines (sess_D2) from Part 2. - [ ] You watched fail-closed break and recover: the unlabeled record read as
public/allow: truewith the default line changed, and back torestricted/deny: truerestored — captured infail-closed-proof.md. - [ ]
make testis green overdata/policies/. - [ ] You tuned the Sigma threshold both directions (
> 10misses the burst,> 2flags normal work) and can explain why> 5is the defensible choice, not an arbitrary one. - [ ]
make checkpasses all four assertions; re-planting the Step 3 bug turns check #2 RED, and restoring the fix turns it green again. - [ ]
data-controls-design.mdmaps CASB / DLP-egress / tokenization / CMK-BYOK / data residency to specific gaps, tied to the Snowflake case, labelled assessed-from-design. - [ ] You can explain all seven flight-card facts cold, especially #2 and #4.
Connects forward¶
This module is built entirely from two engines you already own: Module 08's OPA now decides on a
new input (the record's classification, not just the requester's role), and Module 09's Sigma /
detect.py now keys on a new signal (restricted-read volume per session, not geography). Nothing here
is a new tool — it's the same judgment-as-code and detection-as-code disciplines pointed at the pillar the
rest of the track left open.
It also closes the set the track's capstone was judged against. The capstone (Modules 10–12's project)
proves every request was authenticated and authorized — but its audit trail says nothing about what was
authorized. Feed this module's classification-aware Rego into the capstone's policy, and its access log
through zt-bulk-restricted-read.yml, and the capstone's proof finally covers the fifth pillar too:
identity → device → network → workload → data, the full NIST SP 800-207 set an enterprise buyer
expects to see closed end to end.
Marketable proof¶
"I make data classification an access-control input, not metadata: I write OPA/Rego policy that authorizes by role × classification and fails closed on a missing or unrecognized label — I can show it break open and recover, not just assert it. I write a Sigma detection that catches bulk-restricted-data reads as a session-volume signal, distinct from identity or geography, tuned against both a too-loose and a too-tight threshold. And I can map the CASB/DLP/tokenization/CMK/ data-residency controls a production data-loss-prevention program layers on top — honestly labelled as assessed, not stood up — the last of the five Zero Trust pillars closed."
Stretch¶
- Impossible-volume, not just impossible-travel. Extend the held-out idea from Module 09: build a
small labelled corpus of session/read-count pairs (a few genuine bulk-report jobs, a few real
compromises) and score
zt-bulk-restricted-read.yml's precision/recall the way Module 09'seval.pyscores the geo-rule — is a flat count threshold enough, or do you need a rate (reads per minute) too? - Wire the two engines together. Extend
data-classification.regoto accept asession_read_countinput field and add adenywhen a single request arrives already flagged as part of a bulk pattern — the PDP consuming the detection's output, closing the loop from "detect after the fact" to "deny the next one in real time." - Field-level, not record-level. Real restricted records are rarely restricted end-to-end — a support
ticket might be
internalexcept for one embedded SSN. Sketch (don't build) what a field-level classification scheme would need from the Rego input shape, and name the tokenization control from Step 5 that makes it tractable at scale. - Map to ATT&CK. Tie the bulk-read detection to T1530 (Data from Cloud Storage — the read) and T1567 (Exfiltration Over Web Service — the move-out), and write one sentence on which lab artifact is the control for each.
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).