Skip to content

Audit Log

The Workbench records changes to users, tokens, groups, group memberships and privileges in an audit log, along with the requests that were refused for want of a permission, so that you can answer questions such as who revoked a colleague's access and when. Repeated denials are coalesced rather than recorded one by one, as described under Recorded Actions. Each event names the principal that made the change, the address the request came from, the object that was targeted, the state the change replaced and whether the change succeeded. Events live in the audit_events table of the server's auth.db authentication store. A change that succeeds records its event in the same database transaction as the change itself, so the log can neither lose a change that was applied nor claim one that was rolled back. An attempt that fails is rolled back with its event, so the server records a separate event for it afterwards with an outcome of failure, as described under Outcomes and Details.

Sign-in and sign-out events are outside the scope of the audit log, as are housekeeping operations such as expired token cleanup and session invalidation. The log begins when the feature is first deployed and does not reconstruct changes made before that point.

Recorded Actions

Every audited action carries a dotted noun.verb name. The following table describes the actions recorded for user and service account changes:

Action Description
user.create A user account was created.
user.update A user account's details were changed.
user.delete A user account was deleted.
user.enable A user account was enabled.
user.disable A user account was disabled.
user.set_superuser A user was made a superuser.
user.unset_superuser Superuser status was removed from a user.
service_account.create A service account was created.

The following table describes the actions recorded for API token changes:

Action Description
token.create An API token was issued.
token.delete An API token was deleted.
token.scope.set_connections A token's connection scope was set.
token.scope.set_tools A token's MCP tool scope was set.
token.scope.set_admin A token's admin permission scope was set.
token.scope.clear All scope restrictions were removed from a token.

The following table describes the actions recorded for group and membership changes:

Action Description
group.create A group was created.
group.update A group's name or description was changed.
group.delete A group was deleted.
group.member.add A user or group was added to a group.
group.member.remove A member was removed from a group.

The following table describes the actions recorded for privilege and permission changes, all of which target the group that holds the grant:

Action Description
privilege.mcp.grant An MCP privilege was granted to a group.
privilege.mcp.revoke An MCP privilege was revoked from a group.
privilege.connection.grant Connection access was granted to a group.
privilege.connection.revoke Connection access was revoked from a group.
permission.admin.grant An admin permission was granted to a group.
permission.admin.revoke An admin permission was revoked from a group.

The log also records one action of its own:

Action Description
audit.purge The retention purge removed events from the log.

An audit.purge event is attributed to the system actor, carries no target, and records the cutoff it applied in details.older_than and the number of events it removed in details.removed. The event is written in the same transaction as the deletion, so a log that has shrunk always explains why.

A request that an authorisation check refuses is recorded with the action the request would have used had it been allowed, so that a denial and the change it was refused share a vocabulary. Two action names appear only on denials: audit.read, for a refused read of the audit log itself, and token.scope.set, for a refused scope update, because a single endpoint covers all three scope types. A refused request that matches no known route records rbac. followed by the lower-case HTTP method, so that the denial is kept rather than dropped.

Repeated denials are coalesced rather than recorded one by one, because a client that retries a refused request in a loop would otherwise fill the log and bury the events that matter. The first denial for a given combination of actor, action and reason is recorded at once; identical denials in the next sixty seconds are counted instead of recorded; and the first denial after that window is recorded with a details.repeat_count giving the number of attempts it stands for. If no further denial arrives after the window closes, the suppressed attempts are written as a summary event of their own, carrying details.repeat_count alongside details.window_closed, so a burst that stops is still counted rather than lost.

Actor Types

Each event names the kind of principal that caused the change in the actor_type field. The following table describes the four actor types:

Actor type Description
user A signed-in user acting through the console or the REST API.
token A request authenticated with an API token.
cli An operator running a server command at the command line.
system A change the server made with no principal to attribute it to.

A user or token event also carries the client address the request arrived from, resolved through the server's trusted proxy settings. A cli event carries no address, because a command line has no peer address, and takes the actor name from the operating system user running the command. The system actor covers changes the server makes on its own account; the clearest example is the automatic account lockout after too many failed sign-in attempts, which records a user.disable event attributed to system with a details.reason of lockout.

The lockout is the one change that is applied even when its event cannot be recorded. Everywhere else a change is rolled back if the server cannot write the event that describes it, because refusing a change an operator asked for is the safe answer; here the server is defending an account against a password guessing attack, so the lockout is committed first and the event written after it. If the write fails, the account stays locked and the failure is reported in the server log.

Outcomes and Details

Each event carries an outcome of success, failure or denied. A success event records a change that was applied, a failure event records a change that was attempted and errored, and a denied event records a change that an authorisation check refused before it reached the store. Both failure and denied events carry the reason in the error field, and a denied event names the permission the caller was missing.

Most events also carry a details field holding a JSON object. A change that replaces existing state, such as a user update or any delete, places the previous state in details.before and the new state in details.after, while a creation carries details.after alone. The user snapshot lists the identifier, username, display name, email address, annotation, enabled flag, superuser flag and service account flag, and never the password hash. The token snapshot lists the identifier, owner, annotation and expiry, and never the token or its hash.

Two characteristics of the recorded state are worth knowing when you read the log:

  • A grant event records that the grant was requested rather than that the grant changed anything, because granting a privilege a group already holds succeeds without altering the stored grant.
  • A privilege.connection.grant event records the connection identifier in details.connection_id, the access level the grant replaced in details.before.access_level and the level it set in details.after.access_level, so raising a group's access on a connection from read to read_write is visible as a change of level. A first grant carries a details.before of null. A privilege.connection.revoke event records the connection identifier and the level that was withdrawn in details.before.access_level.

Reading the Audit Log in the Console

Superusers can read the audit log from the Audit Log tab in the Security section of the Workbench console's admin panel. The tab is hidden from users who are not superusers, and no admin permission grants access to it.

The tab presents a filter bar offering an actor name, an action, a target type, an outcome and a date range, above a paged table of matching events shown newest first. Selecting the expander on a row opens that event's details object as formatted JSON, which is the quickest way to compare the state before a change with the state after it.

Reading the Audit Log with the REST API

The GET /api/v1/rbac/audit endpoint returns audit events as a JSON array, newest first. The endpoint is restricted to superusers and responds with 403 Forbidden to everyone else. The X-Total-Count response header carries the number of events matching the filters before limit and offset are applied, so that a client can size its pager.

A request made with an API token is refused as well, with the same status, when the token's admin scope has been narrowed to specific permissions: a scope is an explicit statement that the token is not a general-purpose stand-in for its owner, and no admin permission grants audit access. A token with no admin scope, or one holding the * wildcard, reads the log on the same terms as its owner.

The following table describes the query parameters the endpoint accepts:

Parameter Description
actor Actor name, matched exactly.
actor_type Actor type: user, token, cli or system.
action Action name, matched exactly.
target_type Target type: user, group or token.
target_id Numeric identifier of the target object.
outcome Outcome: success, failure or denied.
since Only events at or after this RFC 3339 timestamp.
until Only events at or before this RFC 3339 timestamp.
limit Page size, defaulting to 50 and capped at 500.
offset Number of events to skip before the page begins.

The following request returns the most recent denied events:

curl -H "Authorization: Bearer $TOKEN" \
    "https://workbench.example.com/api/v1/rbac/audit?outcome=denied"

Reads of the audit log are not themselves audited, so querying this endpoint adds no events to the log.

Reading the Audit Log at the Command Line

The server command line reads the audit log directly from the authentication store, which helps when the console is unavailable or when you want to pipe events into another tool. The following table describes the audit flags:

Flag Description
-list-audit List RBAC audit log events
-verify-audit-log Verify the audit log hash chain
-audit-actor string Filter audit events by actor name
-audit-action string Filter by action, such as group.create
-audit-target-type string Filter by target type
-audit-target-id int Filter by target identifier
-audit-outcome string Filter by outcome
-audit-since string Only events at or after this time
-audit-until string Only events at or before this time
-audit-limit int Maximum events to show (default: 50)
-json Print one JSON object per event instead of a table

The -audit-since and -audit-until flags accept either a full RFC 3339 timestamp, such as 2026-09-15T00:00:00Z, or a bare YYYY-MM-DD date, which the server reads as midnight UTC.

In the following example, -list-audit prints the most recent events as a table:

./bin/ai-dba-server -list-audit

The command prints the events newest first, followed by a count of the events shown against the total number matching the filters:

Audit events:
====================================================================================================================================
ID       Time                 Actor                Action                   Target                   Outcome  Error
------------------------------------------------------------------------------------------------------------------------------------
3        2026-09-15 11:09:18  dba-ops (cli)        group.delete             group:docs-demo          success
2        2026-09-15 11:09:17  dba-ops (cli)        user.create              user:jane.doe            success
1        2026-09-15 11:09:12  dba-ops (cli)        group.create             group:docs-demo          success
====================================================================================================================================
Showing 3 of 3 event(s).

In the following example, the flags combine to show the group changes one operator made since the start of the month, as JSON:

./bin/ai-dba-server -list-audit -audit-actor dba-ops \
    -audit-target-type group -audit-since 2026-09-01 -json

Each line of the JSON output is one complete event, including the details object and both hashes, so the output pipes straight into jq or a log shipper:

{"id":2,"occurred_at":"2026-09-15T11:09:17.648856224Z","actor_type":"cli","actor_id":null,"actor_name":"dba-ops","action":"user.create","target_type":"user","target_id":1,"target_name":"jane.doe","outcome":"success","details":{"after":{"id":1,"username":"jane.doe","display_name":"","email":"","annotation":"","enabled":true,"is_superuser":false,"is_service_account":false}},"prev_hash":"76bed6cc892595f6035701e0b68b3c112088d4a14b2e2db9173043735f9f85e7","hash":"5438335f26c3c01a4f42976957dca65f25b48cf44c637ff38ac8bd042736a092","hash_version":1}

Tamper Evidence

Each event stores a SHA-256 hash computed over its own fields and over the hash of the event before it, so the log forms a chain in which altering any event invalidates every hash that follows. Each event also records, in its hash_version field, the version of the encoding its hash was computed under, and verification recomputes every event under the version it carries, so a future change to the format leaves earlier events verifiable and an event claiming a version the server does not know is reported as such rather than as a broken hash. The encoding records the length of each field alongside its value, so moving text across a boundary between two columns changes the hash rather than leaving it intact. A unique index on the link to the preceding event means no two events can claim the same predecessor, so the chain cannot be forked into two branches that each verify. A database trigger rejects updates to the audit_events table outright, and the only deletions the server issues come from the retention purge described below.

The server re-creates the unique index and the trigger every time it opens the authentication store, whatever schema version the store records, so a database from which either has been dropped is protected again at the next start. Verification also refuses to pass a log whose index or trigger is missing at the time it runs, because a chain that recomputes cleanly without them has shown nothing. A store whose chain has already forked cannot be given the index, and the server refuses to open it, naming the index and the query that finds the duplicates.

In the following example, -verify-audit-log recomputes the whole chain and reports whether the log is intact:

./bin/ai-dba-server -verify-audit-log

A healthy log reports the number of events checked:

Audit log verified: 3 event(s), chain intact

When the chain does not verify, the command names the first event whose hash does not match and exits with a non-zero status, so that the check can run unattended from a scheduled job.

Verification also compares the newest event's identifier against the highest identifier the table has ever issued, which SQLite records separately, so that events deleted from the newest end of the log are reported rather than left invisible: removing the tail leaves every surviving event correctly linked to the one before it and so would otherwise verify cleanly. The two figures are read together, so an event the server writes whilst the check is running cannot separate them, and they must agree exactly. A record of the highest identifier that has gone missing, that sits above the newest surviving event, or that sits below it, is reported, because none of the three is a state the database produces by itself; so is a store with no such record at all, because every table the server creates keeps one and removing it takes deliberate effort.

What Verification Does and Does Not Show

Read those checks for what they are. They catch deletions made without an attempt to cover them up, which is what a mistaken DELETE, a careless cleanup script or a misdirected restore looks like, and they raise the number of deliberate steps required to hide a deletion from one to several. They are not a defence against an attacker with write access to auth.db.

The hash chain is an unkeyed SHA-256, computed by the same server that stores it, so anyone who can write the file can recompute every hash after making a change and leave a chain that verifies cleanly. The record of the highest identifier issued is an ordinary table that the same party can set to whatever value makes the comparison agree. An offline verification that passes is therefore evidence that nothing careless happened, not proof that nothing deliberate did.

Treat a failed verification as evidence of tampering, and never a successful one as proof of its absence. What actually protects the log is keeping it out of reach: restrict access to the server's data directory, and copy events off the host, through the REST API or the -json output, to storage the server itself cannot write to. An independent copy is the only one of these measures that survives an attacker who reaches auth.db.

Retention

The http.auth.audit_retention_days setting controls how long the server keeps audit events and defaults to 90 days. Setting the value to 0 disables the purge and keeps events forever. The setting can also be supplied through the PGEDGE_AUDIT_RETENTION_DAYS environment variable; there is no command-line flag for it.

In the following example, the server keeps a year of audit events:

http:
  auth:
    audit_retention_days: 365

The purge runs once when the server starts and then every five minutes, alongside the expired token cleanup, deleting events older than the retention period. Because each remaining event still carries the hash of the event that preceded it, the chain stays verifiable across a purge; verification treats the earliest remaining event's recorded previous hash as its starting point.

If you must keep audit events for longer than the server retains them, export them to external storage before the purge removes them, either through the REST API or with -list-audit -json.

Next Steps

The following documents cover the changes the audit log records and the settings that control it.

  • The Account Management document describes the user and service account changes that the audit log records.
  • The Group Management document describes the group and membership changes that the audit log records.
  • The Permission Management document describes the privilege and permission grants that appear in the log.
  • The Server Configuration document describes http.auth.audit_retention_days alongside the other server settings.