Masking sensitive fields with Enterprise OPA
Objective
Configure field-level masking so PII and PHI never enter a decision log, using Enterprise OPA decision_logs.mask and the open-source system.log.mask rule.
Concept
A decision log is most useful when it captures input, but input is where the sensitive data lives. If a request carries a patient SSN or a medical record number, logging it verbatim copies that data into every downstream log store. Masking removes or redacts named fields from the event before it is shipped, so you keep the evidence without spreading the secret.
There are two mechanisms, one open source and one enterprise, and they share the same model.
Open-source OPA: the system.log.mask rule. OPA evaluates a rule at data.system.log.mask over every decision-log event just before it is emitted. The event itself is the input to that rule, so the original decision input sits at input.input and the result at input.result. The rule returns a set of JSON pointers, and OPA removes each pointer from the logged event:
package system.log
mask contains "/input/subject/ssn" if {
input.input.subject.ssn
}
Each returned string is treated as a remove operation on that path. The subject SSN never leaves the node in the log. The rule can also return objects for finer control, for example an upsert that replaces a value with a redacted placeholder rather than removing the key.
Wiring the rule into the pipeline. A mask rule only runs if the decision-log plugin is told to use it. That is a one-line config: point mask_decision at the rule's path. The lab's ../lab/opa-config.yaml does exactly this:
decision_logs:
console: true
mask_decision: /system/log/mask
mask_decision defaults to /system/log/mask, which is the fully-qualified path of the mask rule in mask.rego (package system.log, rule mask). OPA now evaluates that rule over every event before shipping it.
Enterprise OPA: decision_logs.mask. Enterprise OPA (eopa) provides masking through its decision_logs.mask configuration in the log pipeline. It follows the same idea, name the fields to protect, but adds capability for scale: masking as a configured stage rather than only a policy rule, alongside related pipeline features like delta bundles and datasources. The exact config schema is product-specific, so confirm it against the Enterprise OPA docs for your version; for this lab we model it as a list of the same JSON pointers the system.log.mask rule returns:
decision_logs:
mask:
- '/input/subject/ssn'
- '/input/resource/mrn'
Use Enterprise OPA masking when you want masking managed as pipeline configuration across a fleet; use the system.log.mask rule when you want it expressed as policy and tested with opa test, which is what this lab does.
Two rules of thumb:
- Mask the identifiers, keep the context. Strip the SSN and the MRN. Keep the care team, the action, and the result, because those are exactly what an auditor needs to answer "who could read this record, and why." Over-masking destroys the evidence value of the log.
- Prefer removal or redaction to never logging
inputat all. Droppinginputentirely makes replay and investigation impossible. Masking is the middle path: log the decision, redact the payload.
decision-log event
{ input: { subject: { ssn, ... }, resource: { mrn, care_team } }, result: {...} }
|
v data.system.log.mask returns { "/input/subject/ssn", "/input/resource/mrn" }
v
shipped event (ssn and mrn removed; care_team and result intact)
Text version of the diagram: the mask rule inspects the event and returns the JSON pointers to sensitive fields; OPA removes those pointers, so the shipped event keeps the care team and result but not the SSN or MRN.
Hands-on lab
Open ../lab/mask.rego. It is a system.log.mask policy that returns pointers for /input/subject/ssn and /input/resource/mrn. Open ../lab/policy_test.rego and find the four masking tests. Two work at the set level: test_mask_selects_ssn_and_mrn asserts the sensitive pointers are selected, and test_mask_does_not_select_care_team_or_result guards against over-masking. Two work at the behavior level: test_mask_redacts_ssn_and_mrn_from_event applies the mask with json.remove and asserts the SSN and MRN are actually gone from the shipped event, while test_mask_preserves_care_team_and_result_after_redaction asserts the care team and result survive redaction. The behavior tests prove real redaction, not just that a pointer was named.
Run the suite from the cloned university-labs repo:
git clone https://github.com/EnforceAuth/university-labs.git
cd university-labs
opa test courses/decision-logs-and-compliance-evidence -v
Then try the exercise: add a rule to mask.rego that also masks the subject id, add a set-level test asserting /input/subject/id is selected, and add a behavior-level test asserting redacted_event.input.subject.id is gone. Re-run and keep the suite green. Notice that the mask policy is tested exactly like any other Rego, that is the point of expressing masking as policy.
Check for understanding
- In a
system.log.maskpolicy, the original decision input is at which path:input,input.input, orinput.result? Why? - What is the risk of masking too much, for example dropping the entire
inputobject? - Name one difference between the open-source
system.log.maskrule and Enterprise OPAdecision_logs.mask.