Skip to main content

Audit log

The authorization server writes a row to the audit_log table for its policy decisions, its OAuth errors and the changes administrators make. Use this page to find a row, export rows, and plan retention.

Set two values. Run the blocks below in order in one shell. Each one checks the answer it gets.

export POLICYARC_URL="https://your-environment.example"
export POLICYARC_ADMIN_KEY="paste the Admin API key"

Find a row​

This token request names a client that does not exist, so it fails and writes one row.

code=$(curl -sS -o /dev/null -w '%{http_code}' -u audit-demo:wrong "$POLICYARC_URL/token" -d grant_type=client_credentials)
[ "$code" = 401 ] || { echo "Expected HTTP 401, got $code" >&2; false; }

List the rows for that client. Results are newest first.

row=$(curl -fsS "$POLICYARC_URL/admin/api/audit-logs?clientId=audit-demo&endpoint=/token&size=1" \
-H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" | jq -c '.content[0]')
echo "$row" | jq '{id, endpoint, outcome, clientId, policyDecision}'
jq -e '.outcome == "error" and .policyDecision.error == "invalid_client"' <<<"$row" >/dev/null
export AUDIT_ID=$(jq -r .id <<<"$row")

The row has outcome error and a policyDecision that holds the OAuth error, invalid_client.

Fetch one row by its id.

curl -fsS "$POLICYARC_URL/admin/api/audit-logs/$AUDIT_ID" -H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" |
jq -e --argjson id "$AUDIT_ID" '.id == $id' >/dev/null

GET /admin/api/audit-logs takes these filters, in any combination.

ParameterMatches
endpointRows whose endpoint contains the text, such as /token
clientIdRows whose client id contains the text
subjectIdRows whose subject id contains the text
outcomeRows with exactly this outcome, such as deny
flowIdRows that carry this flow id
from, toDates in YYYY-MM-DD form, read as UTC days. Both days are included.
page, sizeThe page number, from 0, and the page size

The list is always sorted by createdAt, newest first. A sort parameter does not change the order.

In the Admin Portal, under Operate, select Access Activity to see the same rows with a filter bar.

Export rows​

GET /admin/api/audit-logs/export returns the matching rows as JSON, or as CSV with format=csv. It takes the list filters, plus fromInstant and toInstant for an exact time window in ISO 8601.

curl -fsS "$POLICYARC_URL/admin/api/audit-logs/export?clientId=audit-demo" \
-H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" | jq -e 'length >= 1' >/dev/null
header=$(curl -fsS "$POLICYARC_URL/admin/api/audit-logs/export?clientId=audit-demo&format=csv" \
-H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" | head -n1)
echo "$header"
[ "$header" = "id,request_id,flow_id,endpoint,method,outcome,client_id,subject_id,ip,user_agent,created_at,request_summary,policy_input,policy_decision" ]

An export returns at most 10,000 rows, the newest first. The response does not say when it stopped at the limit. No block on this page checks the limit, because it needs more than 10,000 rows. We checked it once, on an instance with 10,054 rows, and the export returned exactly 10,000. If an export returns 10,000 rows, narrow it with from and to, or fromInstant and toInstant, and export each window.

The Export JSON and Export CSV buttons on Access Activity call the same route with the filters you set.

What a row records​

FieldWhat it contains
idThe row's numeric id
requestIdThe id of the HTTP request that wrote the row
flowIdAn id shared by the rows of one sign-in journey. See Follow one journey.
endpointWhere the event happened. See the next table.
methodThe HTTP method
outcomeWhat happened. See Outcome values.
clientIdThe OAuth client of the request. Admin changes record admin.
subjectIdThe user's subject id. Empty when there is no user, as in a client_credentials request.
requestSummarySelected request fields, such as grant_type on a /token row
policyInputThe input the policy engine evaluated. Empty when policy did not run.
policyDecisionThe result the policy engine returned, or an error object for an error row
ipThe client IP address
userAgentThe User-Agent header
createdAtWhen the row was written, in ISO 8601 UTC

The authorization server writes a row at these points.

Eventendpoint value
Token request/token
Authorization request/authorize
Introspection/introspect
Revocation/revoke
Dynamic client registration/register
Identity provider callback/callback and /oauth2/callback
Backchannel (CIBA) request that failed with a server error/bc-authorize
Backchannel (CIBA) approver's decision/bc-authorize/callback
AuthZEN evaluation/access/v1/evaluation
Managed gateway call checked against policy/gateway/<resource>/<path>
Gateway connection tools and account linking/gateway/mcp, /gateway/link and /gateway/connect
Admin changeThe Admin API path, such as /admin/api/secrets

A managed gateway row leaves policyInput empty. The input for that call is on the /introspect row with the same flowId.

Outcome values​

allow, deny and error are access decisions. Other rows carry other values.

  • obligation records a decision that needs one more step, such as a person's approval.
  • An admin change records its action, such as created, deleted, mint, reveal or rotate:ID_TOKEN.
  • Other events record values such as revoked for a revoked token.

To count access decisions, filter on allow, deny and error only.

Follow one journey​

An authorization code sign-in spans /authorize, the identity provider callback, /token, and later /introspect calls. The authorization server creates a random flowId at the start of /authorize and writes it on the rows of that journey. Filter on flowId to see them together.

A client_credentials token gets a new flowId when the token is issued. A refused client_credentials request has no flowId, as the row above shows.

The same id is the flow_id field of an introspection response, and the pbac.flow.id span attribute when telemetry export is on.

Policy bundle history​

A second table, bundle_audit_log, gets a row each time the policy bundle that the policy engine loads changes. GET /admin/api/bundle-audit/at?timestamp=<ISO 8601> returns the bundle row that was current at that time. Use it with a row's createdAt to find the policy that was served when the decision was made. The policy engine loads a new bundle on its next poll, so a decision made just after a change can still use the previous bundle.

What the database refuses​

Migration V73 adds triggers to audit_log and bundle_audit_log. The database rejects UPDATE, DELETE and TRUNCATE on these tables through those triggers. The error names the statement and the table, such as DELETE on audit_log is not allowed.

A database owner can disable the triggers. The tables belong to the database role the authorization server connects as. We disabled the trigger as that role inside a transaction, and a DELETE then succeeded. The migration's own comment says the triggers stop an application bug or a stray statement, and do not stop someone who holds the database owner's credentials.

The Admin API has read routes for both tables and no route that changes or deletes a row. The admin UI has none either.

Retention​

The authorization server never deletes audit rows, and it has no retention setting. The tables grow until you remove rows. Retention is yours to manage.

A plain DELETE fails because of the triggers. To purge old rows, a database owner disables the trigger, deletes, and enables it again in one transaction. Take an export first if you need to keep the rows.

BEGIN;
ALTER TABLE audit_log DISABLE TRIGGER audit_log_append_only;
DELETE FROM audit_log WHERE created_at < now() - interval '400 days';
ALTER TABLE audit_log ENABLE TRIGGER audit_log_append_only;
COMMIT;

We have not run this purge against a hosted environment. On a hosted environment, the database belongs to PolicyArc, so ask support.

When an audit write fails​

The authorization server writes the row in its own transaction. When that write fails, it logs Failed to persist audit log at WARN and writes no row. /token, /register and admin changes then return HTTP 500, and a gateway call returns HTTP 403 {"error":"Introspection failed"}, even one that policy allows. We saw this with a database trigger that rejected every insert. One admin change, POST /admin/api/secrets, returned 500 and still saved the secret, so check the state after a 500 before you retry.