Human-in-the-loop approvals and the audit trail
Objective
Model a human-in-the-loop approval gate as a default-deny OPA policy, and describe what an approval audit trail records and why replaying it matters.
Concept
A human-in-the-loop approval turns an autonomous action into a reviewed one. For a sensitive action, the firewall pauses, surfaces the request to a human, and proceeds only if that human approves. The policy's job is to define what "a valid approval" means so the decision is deterministic, not a matter of who was paying attention.
A valid approval, in the lab, has four properties, all required:
- It is present. No approval object, no allow.
- The approver is a human, not the agent.
approver_type == "human". An agent cannot approve its own action. This is the Politeness Trap again: self-approval would let a talked-into agent rubber-stamp itself. - It is scoped to this exact action.
scope_action == input.action.name. An approval to send a wire does not authorize deleting a backup. - It has not expired.
expires_at > input.now. Approvals are time-boxed so a stale grant cannot be replayed later.
Miss any one and the default deny stands. That is the whole discipline of a good gate: many narrow conditions, all required, defaulting to deny.
The approval audit trail is the record of these decisions. Every gated action produces an entry: the agent, the action and resource, the decision, and, for a human-in-the-loop action, who approved it, when, and under what scope. This is the Audit stage of Define, Enforce, Audit for agentic actions. Its value is after the fact: an auditor can ask "who authorized this wire, and were they allowed to," and replay the exact decision against the exact input. In the broader Fabric, Writ, the control plane, is where decision logs and replay live for the policies a PDP runs; the approval trail is the agentic-action record that feeds the same review discipline. An action with no corresponding approval entry is a red flag by construction.
Design a gate by writing the deny tests first. Each deny test names a way a bad action could slip through: no approval, self-approval, wrong scope, expired. If any deny test passes when it should fail, the gate has a hole. The allow tests only confirm the happy path still works.
Hands-on lab
This is the capstone. Open policy.rego in courses/defend-with-verdict. It ships as a starter that denies everything: default allow := false with a # TODO(learner) block where the approval gate belongs. Run the suite and you will see three failures:
git clone https://github.com/EnforceAuth/university-labs.git
cd university-labs
opa test courses/defend-with-verdict -v
Implement the TODO so all seven tests pass. You add two allow rules (low-risk actions run without a gate; high-risk actions require valid_approval) and the valid_approval rule, which is true only when all four properties hold. Each deny test guards one property:
test_high_risk_action_denied_without_approval— property 1, presence.test_high_risk_action_denied_on_agent_self_approval— property 2, human approver.test_high_risk_action_denied_on_scope_mismatch— property 3, scope.test_high_risk_action_denied_on_expired_approval— property 4, expiry.
Keep iterating until opa test reports PASS: 7/7. If your policy ever lets a high-risk action through with no valid human approval, a deny test fails, which is the gate doing its job. Keep iterating until opa test is green. The full task is in the lab README.md.
Check for understanding
- Name the four properties the lab requires for an approval to be valid. Which one closes off agent self-approval?
- What does the approval audit trail record for a human-in-the-loop action, and why is being able to replay a decision useful to an auditor?
- Why do you write the deny tests first when designing an approval gate, and what does it mean if a deny test unexpectedly passes?