Reproduce a decision from the audit log
Use this page to check that a decision in the audit log follows from the policy the server was running. You export one row, fetch the policy bundle, and run the row's input through OPA. The result must match the row.
You need curl, jq and the OPA command line, the Admin API key, and the bundle key. Set three values, then run the blocks below in order in one shell. Each block checks the answer it gets.
export POLICYARC_URL="https://your-environment.example"
export POLICYARC_ADMIN_KEY="paste the Admin API key"
export POLICYARC_BUNDLE_KEY="paste the bundle key"
What the log holds
Each row is one event, such as a token request or a gateway call. For a policy decision, policyInput holds the input the policy engine evaluated and policyDecision holds what it returned. Audit log lists every field, the endpoints that write rows, and the export filters.
Some events write no row or leave the input elsewhere.
- A managed gateway row leaves
policyInputempty. The input is on the/introspectrow with the sameflowId. - A request to
/bc-authorizewrites a row only when it fails with a server error. Hold an agent's write for a person's approval lists the rows one approval left. - When the audit write itself fails, the server logs a warning and writes no row. Audit log says which requests then fail, with HTTP 500 or, at the gateway, HTTP 403.
Make a decision to check
Create a client through the Admin API, grant it client_credentials for openid on the built-in userinfo type, and ask it for a token. Policy allows the request and writes a row. The block names the client auditor- and the time, so you can run it again. The client stays when you finish, and DELETE /admin/api/clients/{clientId} removes it. The block waits for the policy engine to load the new grant, which takes a few seconds.
export AUDIT_CLIENT_ID="auditor-$(date +%s)"
curl -fsS -o /dev/null -X POST "$POLICYARC_URL/admin/api/clients" -H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" \
-H 'Content-Type: application/json' \
-d "{\"clientId\": \"$AUDIT_CLIENT_ID\", \"clientType\": \"confidential\", \"clientName\": \"Auditor guide\", \"redirectUris\": [\"https://auditor.example/cb\"]}"
secret=$(curl -fsS -X POST "$POLICYARC_URL/admin/api/clients/$AUDIT_CLIENT_ID/rotate-secret" \
-H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" | jq -r .client_secret)
curl -fsS -o /dev/null -X PATCH "$POLICYARC_URL/admin/api/policy-data/by-key?dataKey=pbac.operator.controls.client_entitlements&path=client_id.$AUDIT_CLIENT_ID" \
-H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" -H 'Content-Type: application/json' \
-d '{"grant_types": ["client_credentials"], "granted_resources": [{"type": "urn:as:userinfo", "scopes": ["openid"]}]}'
for attempt in $(seq 30); do
status=$(curl -sS -o /dev/null -w '%{http_code}' -u "$AUDIT_CLIENT_ID:$secret" "$POLICYARC_URL/token" \
-d grant_type=client_credentials -d scope=openid)
[ "$status" = 200 ] && break
sleep 1
done
[ "$status" = 200 ] || { echo "Expected HTTP 200 from /token, got $status" >&2; false; }
The page checks an allowed decision on purpose. A request refused because the client has no grant gets the same answer from the /authorize rules as from the /token rules, so replaying it against the wrong rules would still match. The allowed request does not. The /authorize rules refuse it with grant_type_not_allowed.
Export the row
Export the client's allowed rows and keep the one row. The block saves its input and its decision to files.
export AUDIT_DIR=$(mktemp -d)
curl -fsS "$POLICYARC_URL/admin/api/audit-logs/export?clientId=$AUDIT_CLIENT_ID&endpoint=/token&outcome=allow" \
-H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" > "$AUDIT_DIR/rows.json"
jq -e 'length == 1' "$AUDIT_DIR/rows.json" >/dev/null
jq '.[0].policyInput' "$AUDIT_DIR/rows.json" > "$AUDIT_DIR/input.json"
jq '.[0].policyDecision' "$AUDIT_DIR/rows.json" > "$AUDIT_DIR/decision.json"
export AUDIT_AT=$(jq -r '.[0].createdAt' "$AUDIT_DIR/rows.json")
jq '{allow, reason, deny_reasons}' "$AUDIT_DIR/decision.json"
jq -e '.allow == true and .reason == null and .granted_resources[0].type == "urn:as:userinfo"' "$AUDIT_DIR/decision.json" >/dev/null
The row says the request was allowed, with no reason, and granted the userinfo type. A request made while the policy engine is still loading the grant is refused and leaves a row with outcome deny. The export leaves those out.
Fetch the policy that was served
GET /admin/api/bundle-audit/at returns the bundle record that was current at a time. A record holds the bundle's ETag, its rule paths and its data keys. It does not hold the bundle itself. The bundle endpoint serves only the current bundle, so the check below fails when the policy changed after the decision.
then_etag=$(curl -fsS "$POLICYARC_URL/admin/api/bundle-audit/at?timestamp=$AUDIT_AT" \
-H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" | jq -r .etag)
now_etag=$(curl -fsS -D - -o "$AUDIT_DIR/bundle.tar.gz" "$POLICYARC_URL/bundles/pbac/bundle.tar.gz" \
-H "X-Bundle-API-Key: $POLICYARC_BUNDLE_KEY" | tr -d '\r' | awk 'tolower($1) == "etag:" {print $2}')
echo "then $then_etag"
echo "now $now_etag"
[ "$then_etag" = "$now_etag" ] || { echo "The policy changed after the decision. Use your own copy of the bundle with ETag $then_etag." >&2; false; }
The policy engine loads a new bundle on its next poll. A decision made in the seconds after a policy change can still have used the bundle before it.
Evaluate the input again
Run the row's input through the bundle. A /token decision comes from data.oauth.token. That is the default of the server setting opa.token.path, /v1/data/oauth/token. If your server sets another path, query that one. This page reproduces /token rows only.
opa eval --bundle "$AUDIT_DIR/bundle.tar.gz" --input "$AUDIT_DIR/input.json" --format json 'data.oauth.token' |
jq '.result[0].expressions[0].value | {allow, reason, deny_reasons}' > "$AUDIT_DIR/replayed.json"
cat "$AUDIT_DIR/replayed.json"
diff <(jq -S '{allow, reason, deny_reasons}' "$AUDIT_DIR/decision.json") <(jq -S . "$AUDIT_DIR/replayed.json")
When diff prints nothing, OPA returned the same allow, reason and deny_reasons that the row recorded.
Compare those three fields and not the whole object. The server adds decision to the row after OPA answers. The row keeps only the fields the server reads from the result, with null where OPA returned none. Other fields of the result, such as eligible and the ones that start with _, are left out.
What a replay cannot show
- A row with outcome
obligationrecordsreasonasapproval_required, which the server writes over the reason OPA returned. Its replay does not match. - A row with outcome
errorhas nopolicyInput, so there is nothing to replay. - The server adds
request_idto the input it sends OPA, and the row does not keep it. No shipped rule reads it. A rule of yours that does would replay differently. - A deploy the policy engine refused, and a rollback, each leave a bundle record. A record shows a bundle the server built, not one OPA activated. Deploy and roll back shows both.
- When writing a bundle record fails, the server logs an error and writes none, so a policy change can have no record.
Keep the bundles you need
The server keeps a record of each bundle change, not the bundle. To reproduce decisions after a policy change, save each bundle when it changes, named by its ETag. GET /admin/api/bundle-audit lists the changes, newest first.
Retention and deletion
The server never deletes audit rows and has no retention setting. Migration V73 adds database triggers that reject UPDATE, DELETE and TRUNCATE on audit_log and bundle_audit_log. A database owner can disable those triggers, and the role the server connects as, pbac by default, owns the tables. The Admin API and the admin UI have no route that changes or deletes a row. Audit log shows how an operator purges old rows.
Send feedback about this page