Test a policy and explain a denial
A refused token request tells the caller only access_denied. The reason is in the audit log. This page finds it, replays the same request against your rule with opa eval, and turns it into a test.
The examples use the reports_read_only.rego rule and the client from Write a policy.
Find the reason
List the newest denied token request for the client.
curl -fsS "$POLICYARC_URL/admin/api/audit-logs?endpoint=/token&outcome=deny&clientId=$CLIENT_ID&size=1" \
-H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" |
jq '.content[0] | {id, createdAt, reason: .policyDecision.reason, deny_reasons: .policyDecision.deny_reasons}'
What we got after a refused write:
{
"id": 139,
"createdAt": "2026-10-13T02:51:29.187Z",
"reason": "all_resources_denied",
"deny_reasons": [
{
"area": "pbac.controls.reports_read_only",
"code": "reports_write_blocked",
"action": "write",
"resource": "urn:example:reports"
}
]
}
Read the two fields together.
reasonis the one reason the token policy gives for the whole request.all_resources_deniedmeans every resource the client asked for was refused.deny_reasonslists each reason a control added. Your rule'scodeandareaare here.
Some shipped checks fill only reason. When we put the client on the denylist, reason was client_denied and deny_reasons was empty. When a client was not yet entitled to the grant it used, reason was grant_type_not_allowed.
In the Admin Portal the same row is under Access Activity. Select the row and read its Policy Decision.
A request that was narrowed, not refused
When a client asks for read write and your rule refuses write, the client gets a token for read. The audit row for that request has outcome allow, and its deny_reasons still lists reports_write_blocked. Search allowed rows too when an action is missing from a token.
The server also writes a second row for that request, with requestSummary.event set to scope_narrowed. It names the dropped action with the reason not_in_client_grant, even when the client was entitled to it and a policy dropped it. Trust deny_reasons over that label.
Replay the request against your rule
The audit row keeps the input the policy engine received, in policyInput. It holds the whole request. Your rule sees one resource and one action at a time, so pick them out.
row=$(curl -fsS "$POLICYARC_URL/admin/api/audit-logs?endpoint=/token&outcome=deny&clientId=$CLIENT_ID&size=1" \
-H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" | jq '.content[0].id')
curl -fsS "$POLICYARC_URL/admin/api/audit-logs/$row" -H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" |
jq '.policyInput | .resources[0] as $r | {resource: ($r | {type, id, properties}), action: $r.actions[0], subject, context}' > input.json
Then ask OPA what your rule says about it.
opa eval -d reports_read_only.rego -i input.json 'data.pbac.controls.reports_read_only.deny' --format pretty
The output is the same reason the audit row recorded.
[
{
"action": "write",
"area": "pbac.controls.reports_read_only",
"code": "reports_write_blocked",
"resource": "urn:example:reports"
}
]
If your rule reads a data row, pass it too. Save the row's payload as data.json in the shape {"custom.reports_allowlist": {...}} and add -d data.json.
Keep it as a test
Copy the parts of input.json your rule reads into a test. Put the test in its own package outside pbac.controls.
package pbac.controls_test.reports_read_only
import data.pbac.controls.reports_read_only
import rego.v1
refused_write := {
"resource": {"type": "urn:example:reports", "id": null, "properties": {}},
"action": "write",
"subject": null,
"context": {"client": {"client_id": "c2dc0c12-90ac-460d-8a0c-b9b4b9ac706b"}, "grant_type": "client_credentials"},
}
test_write_is_refused if {
some r in reports_read_only.deny with input as refused_write
r.code == "reports_write_blocked"
}
test_read_is_allowed if {
count(reports_read_only.deny) == 0 with input as object.union(refused_write, {"action": "read"})
}
Save it beside the rule and run opa test . -v. Each policy pattern ships with tests in this form.
Next steps
Send feedback about this page