Audit
Every policy decision in PBAC is recorded — who requested access, what they requested, what OPA decided, and why. The audit log is append-only and immutable. It answers the question every regulator asks: who accessed what, when, and under what authority?
What gets logged
PBAC logs every policy evaluation at every OAuth decision point. Both allow and deny outcomes are recorded.
| Decision point | Endpoint | When |
|---|---|---|
| Token issuance | POST /token | Every token request |
| Authorization | GET /authorize | Every authorize redirect |
| Introspection | POST /introspect | Every token validation |
| Registration | POST /register | Every DCR request |
| Callback | GET /callback | Every IdP callback |
Each audit entry captures three layers of context:
| Field | What it contains |
|---|---|
endpoint | The request path (/token, /introspect, etc.) |
outcome | allow, deny, or error |
clientId | The OAuth client_id of the requester |
subjectId | The user's sub claim (empty for machine-to-machine flows) |
flowId | Opaque, non-PII id shared by every request in one journey (/authorize → callback → /token → /introspect) — see Correlating a full journey |
requestSummary | Key request fields — grant type, auth method, token presence |
policyInput | The JSON input that OPA evaluated, without the user's IdP claim values (subject.claims) |
policyDecision | The JSON output from OPA — scopes, obligations, every deny reason in reasons. Values policy authored for a client (subject_claims, token_response_extra) appear by name only |
ip | Client IP address |
userAgent | User-Agent header |
createdAt | Timestamp (ISO 8601) |
The policyInput and policyDecision fields are the critical pair: they record what OPA saw and what OPA decided. IdP claim values live only in the user's stored claims (subject_userinfo), not in the audit row, so replaying a decision that read a claim (an identity segment, a groups rule) needs the user's claims from subject_userinfo, which holds their current values.
Correlating a full journey
A single user action is rarely one request. An authorization-code login spans
/authorize, one or more IdP callbacks, /token, and later /introspect calls
from the resource server or gateway — each landing its own audit row. The
flowId ties them together.
PBAC mints an opaque, non-PII flowId at the start of /authorize and carries it
across every downstream request in that journey:
The same id surfaces in three places, so one journey can be reconstructed end to end across the audit log, your traces, and your own logs:
- Audit log — every row in the journey shares the
flowId, so filtering by it returns the whole/authorize → … → /introspecttrail. - Introspection response — the
flow_idfield is returned to the resource server, letting an RS stitch its logs to the PBAC journey. - Traces — the
pbac.flow.idspan attribute carries the same value, so a journey lines up with distributed traces exported over OTLP.
In the admin UI, each Activity/Audit row shows a flow chip; clicking it (or the Flow ID link on an entry's detail) filters the view to that single journey. Multi-hop obligation re-evaluations inherit the id automatically.
Immutability
The audit log is append-only. Entries cannot be modified or deleted through the Admin API or admin UI. This is a design constraint, not a limitation — compliance frameworks (HIPAA, SOC 2, FedRAMP) require that audit trails be tamper-evident.
Bundle audit
Separate from the request audit log, PBAC records every change to the policy bundle it serves to OPA: when the content changed, what it changed to, and which write changed it. This answers a different compliance question: what policy was in effect at the time of a given decision?
Each entry records:
| Field | Purpose |
|---|---|
etag | Content hash of the bundle after the change |
previousEtag | Content hash of the bundle before it |
ruleCount, rulePaths, dataKeys | What the bundle carries |
bundleSizeBytes | Size of the bundle |
triggerSource | What made the change (see below) |
triggeredBy | Request ID of the write, to join with the request audit log |
createdAt | When the change was committed |
triggerSource is one of:
| Value | The change came from |
|---|---|
startup | The first bundle a new database serves |
api | An admin API or admin UI write, or a change the AS makes itself, such as a connector sync |
batch_deploy | POST /admin/api/policy-bundle/deploy |
deploy_rollback | A deploy that OPA refused to activate, undone by restoring the earlier rules and data |
rule_save_rollback | A single rule save that OPA refused to activate, undone |
Exactly once. A write records its entry in the same database transaction as the change itself. A change that commits has exactly one entry, with that write's trigger source, and a change that rolls back has none. A write that changes nothing the bundle carries adds no entry. This holds with any number of AS replicas.
A failed entry fails the write. If the entry cannot be written, the policy change is not saved either. The admin API answers 503 with {"error": "policy_change_not_recorded"}, and nothing has changed. Retry once the database is healthy.
At most once, outside a transaction. A few changes are made without a surrounding transaction: a connector sync, and the confirm step of a rule save or deploy, which rebuilds the bundle to read its revision. The change is already saved when the entry is written, so if the entry fails, the AS logs an error and keeps the change. The confirm step's rebuild finds the entry its save already wrote and adds nothing.
Combined with the request audit log, you can reconstruct the full picture: this decision was made at this time, under this bundle, with this input, producing this output.
Querying
The audit log is queryable via the Admin API with filters for any combination of:
- Endpoint — show only
/tokenor/introspectentries - Client — show decisions for a specific
client_id - Subject — show decisions for a specific user
- Flow — show every row in one journey, by
flowId - Outcome — show only denials, or only allows
- Date range — entries between two timestamps
Results are paginated and sorted by timestamp (most recent first). Individual entries can be fetched by ID to retrieve the full policyInput and policyDecision JSON.
The admin UI provides the same filters with a visual interface, including formatted JSON views of the policy input and decision for each entry.
Compliance use cases
| Framework | What audit provides |
|---|---|
| HIPAA | Who accessed patient data, under what authority, with full policy context |
| SOC 2 | Continuous evidence of access control enforcement and change tracking |
| FedRAMP | Decision-level logging with immutable trails for federal workloads |
| GDPR | Record of lawful basis for data access (consent, legitimate interest) via policy data |
| PIPEDA | Canadian privacy law compliance — consent-based access logged with full decision context |
The audit log is not just for compliance reviews. It's operational: when a user reports they can't access a resource, the audit log shows the exact OPA input and output for their request — including which rule denied them and why.
Next steps
- Policy Model — How the policy decisions that get audited are made
- Architecture — Where audit fits in the overall system