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.
| Parameter | Matches |
|---|---|
endpoint | Rows whose endpoint contains the text, such as /token |
clientId | Rows whose client id contains the text |
subjectId | Rows whose subject id contains the text |
outcome | Rows with exactly this outcome, such as deny |
flowId | Rows that carry this flow id |
from, to | Dates in YYYY-MM-DD form, read as UTC days. Both days are included. |
page, size | The 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
| Field | What it contains |
|---|---|
id | The row's numeric id |
requestId | The id of the HTTP request that wrote the row |
flowId | An id shared by the rows of one sign-in journey. See Follow one journey. |
endpoint | Where the event happened. See the next table. |
method | The HTTP method |
outcome | What happened. See Outcome values. |
clientId | The OAuth client of the request. Admin changes record admin. |
subjectId | The user's subject id. Empty when there is no user, as in a client_credentials request. |
requestSummary | Selected request fields, such as grant_type on a /token row |
policyInput | The input the policy engine evaluated. Empty when policy did not run. |
policyDecision | The result the policy engine returned, or an error object for an error row |
ip | The client IP address |
userAgent | The User-Agent header |
createdAt | When the row was written, in ISO 8601 UTC |
The authorization server writes a row at these points.
| Event | endpoint 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 change | The 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.
obligationrecords a decision that needs one more step, such as a person's approval.- An admin change records its action, such as
created,deleted,mint,revealorrotate:ID_TOKEN. - Other events record values such as
revokedfor 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.
Send feedback about this page