Skip to main content

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.

  • reason is the one reason the token policy gives for the whole request. all_resources_denied means every resource the client asked for was refused.
  • deny_reasons lists each reason a control added. Your rule's code and area are 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​