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 and failures 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 two actions of its own:
| Action | Description |
|---|---|
audit.purge |
The retention purge removed events from the log. |
audit.rechain |
The log was re-hashed, or given a new starting point. |
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. It also records
the event it left as the oldest in the log, by identifier in
details.oldest_retained_id and by that event's own hash in
details.oldest_retained_hash, which is what verification later checks
the start of the log against. The event is written in the same
transaction as the deletion, so a log that has shrunk always explains
why.
An audit.rechain event is attributed to the operator who ran
-rechain-audit-log and records how many events were re-hashed in
details.events, how many of them were unkeyed in
details.unkeyed_events, and whether the log recomputed under its own
rules beforehand in details.legacy_chain_ok. It is written in the same
transaction as the rewrite and becomes the newest link of the rebuilt
chain, so a log whose every hash has changed carries the event that
explains why.
The re-anchor, which -rechain-audit-log performs on a keyed log that
no longer verifies, also writes an audit.rechain event, with
details.mode set to reanchor. That event rewrites nothing; it
records why verification failed in details.reason, whether the failure
looked like a changed server secret in details.key_mismatch, and the
oldest event it accepted in details.oldest_retained_id and
details.oldest_retained_hash, the same fields a purge records, and
the number of events the log held in details.events. Where
it accepted events as history, it records the last of them in
details.history_through_id, their number in details.history_events
and a digest of their contents in details.history_digest. Where an
earlier purge or re-anchor had recorded a starting point, the
details.previous_* fields record which event it was and whether it
verified, and, only where it verified, what it named: what an event
that does not verify says is whatever its writer chose, so it is not
signed into the new one. Where -previous-secret-file was given, the
event records in details.history_proven_by_previous_secret whether
that secret proved the history. A purge that runs whilst history
events survive copies the three history_* fields into its own event
for the events it keeps.
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 read of a
configuration object, such as a notification channel, is recorded as
the object's kind followed by .read, such as
notification_channel.read or alert_override.read. A token refused
a cluster move of a connection that its connection scope names only at
read is recorded as connection.cluster.update, as is a cluster move
of a connection the caller cannot see at all. 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, and any method
outside the standard HTTP set records rbac.other.
Refusals of configuration changes are recorded in the same way. A token
whose connection scope does not cover the target of a blackout, a
blackout schedule, a probe configuration or an alert, probe or channel
override is recorded under the action the change would have used, such
as blackout.update, blackout_schedule.create, probe_config.update
or alert_override.delete. A request to change a cluster group that
the caller does not own, or to create a group, or a cluster within a
group, without the manage_connections permission, is recorded under
cluster_group.create, cluster_group.update or
cluster_group.delete. A request to replace or clear a connection's
cluster relationships that would delete a manual relationship to a
connection the caller cannot see is recorded under cluster.update,
with the cluster as its target. A blackout or schedule
on a connection the token cannot see, or an override on a server,
cluster or group the caller cannot see, is refused as though it did
not exist, and that refusal is not recorded.
A request to read or act on a connection that the caller has no access
to is recorded as well, under connection.read for the connection's
details, databases, context or cluster, connection.current.update for
selecting it as the current connection, connection.query,
connection.query.validate or connection.query.write for running or
validating a query on it, metrics.read for its metrics, baselines and
performance summaries, and server_info.read for its server
information. A request to acknowledge an alert, to clear its
acknowledgement or to save its analysis, when the alert belongs to a
connection the caller cannot see, is refused as though the alert did
not exist, and that refusal is not recorded; any such refusal of an
alert the caller can see is recorded as alert.acknowledge,
alert.unacknowledge or alert.analysis.update, with the alert as
the target. A system alert belongs to no connection, so a request to
acknowledge one or to clear its acknowledgement needs a superuser or
the manage_alert_rules permission instead; a refused request from a
caller who can see system alerts is recorded as alert.acknowledge or
alert.unacknowledge, with the alert as the target and an error of
Permission denied: requires manage_alert_rules permission. A current
connection selected before the caller lost access to it is refused when
the client reads it, and recorded as connection.read; repeated reads
are coalesced like any other denial, as described below.
A request refused because the caller lacks the admin permission it
needs is recorded in the same way, with a reason that names the
permission, such as
Permission denied: requires manage_blackouts permission. This
covers blackouts and blackout schedules (manage_blackouts), alert
rules and alert overrides (manage_alert_rules), probe configurations
and probe overrides (manage_probes), notification channels, their
recipients and channel overrides (manage_notification_channels), and
clusters, cluster relationships and connections
(manage_connections), and system-scoped chat memories
(store_system_memory). A request to create a connection without
manage_connections is recorded as connection.create, and one to
change or delete a connection that the caller neither owns nor may
manage as connection.update or connection.delete; an owner who
tries to share a connection without manage_connections is recorded
as connection.update too. A request to pin or unpin a system-scoped
memory without store_system_memory is recorded as memory.update,
and one to delete it as memory.delete, each with a target such as
memories/42; a request aimed at another user's memory is refused as
though the memory did not exist, and that refusal is not recorded. A
server running without its authentication store still refuses these
requests, but has nowhere to record them, so no event is written.
Refusals by MCP tools are recorded too. A store_memory call that asks
for a system-scoped memory without the store_system_memory permission
is recorded as memory.create with a target of memories, and is
coalesced with repeated denials in the same way as a refused request.
A delete_memory call that targets a system-scoped memory without that
permission is recorded as memory.delete, with a target of memories/
followed by the memory's identifier, exactly as the REST route records
it; a call aimed at another user's memory is answered as though the
memory did not exist, and is not recorded.
A call to a tool that the caller has no privilege to use is recorded as
mcp.tool.call, with a target of tools/ followed by the tool's name,
or of unmatched when the server knows no tool by the name the call
gave, so that a name taken verbatim from the call never reaches the log.
The reason is the same for every tool, so that repeated refusals of
different tools by one caller are coalesced together.
A tool call refused because the caller cannot access the connection it
names is recorded with a target of connections/ followed by the
connection's identifier, under an action that depends on the tool:
| Tools | Action |
|---|---|
query_database, execute_explain, count_rows, similarity_search |
connection.query |
test_query |
connection.query.validate |
query_metrics, get_metric_baselines |
metrics.read |
get_schema_info, get_alert_rules, get_alert_history, get_blackouts, get_timeline_events, query_datastore |
connection.read |
A query_datastore call can name several connections in
connection_ids, so its refusal is recorded with a target of
connections alone rather than naming one of them.
A tool, or read_resource, that falls back to the caller's selected
connection, when the caller has since lost access to it, records a
connection.read on that connection, as the REST API does when it
refuses a read of the selected connection. In every case the message the
tool returns to the caller is unchanged.
Each denial names the object the request was aimed at in
details.target, as the kind of object followed by its numeric
identifier, such as users/12 or connections/7, or as the kind alone,
such as users, when the request names no single object. An override
is named by its kind, scope and scope identifier, such as
alert-overrides/server/5; the rule, probe or channel it overrides is
left out, because a probe name is free text taken from the request. A request
that matches no known route records a target of unmatched, so that
nothing taken verbatim from the request path reaches the log.
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, client address, action and reason is recorded at
once; identical denials in the next sixty seconds are counted instead of
recorded, whatever object each was aimed at; 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. Either row
lists, in details.targets, up to 20 distinct targets the coalesced
denials were aimed at, and counts in details.targets_truncated the
denials whose target did not fit in that list. The target is left out
of the coalescing key deliberately, so that a client cannot write a row
per attempt by varying the identifier it asks for.
Repeated failures are coalesced in the same way, because a caller holding a mutation permission could otherwise fill the log by repeating a request that always fails, such as creating a group that already exists. Two failures are identical when they share the actor, the client address, the action, the target and the error text.
A summary event for denials or failures also carries
details.first_seen, the time the window it covers opened, and
details.last_seen, the time of the last attempt it stands for, because
the event itself is stamped when it is written. The server writes the
summary for a burst that has stopped at its next periodic cleanup, which
runs every five minutes, or sooner if a later identical attempt arrives
first; a clean shutdown, including one requested with SIGTERM or
SIGINT, writes a summary for every window that is still open once
the requests in progress have finished, waiting up to five seconds for
them so that their own denials and failures are counted. The
counts of the open windows are lost from the audit log if the server
crashes, or if the summary write at shutdown fails; in the latter case
the server log records each unwritten summary, with its actor, action,
target or targets, error text or reason, and count. The server keeps the
coalescing state in memory, so a restart starts every window afresh, and
servers sharing one auth.db coalesce independently.
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
forwarded value is read without any port it carries, and one that
then does not parse as an IP address is ignored in favour of the
address of the connecting proxy. 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. The server caps the error text at 500 bytes, ending a
shortened value with ... (truncated).
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.grantevent records the connection identifier indetails.connection_id, the access level the grant replaced indetails.before.access_leveland the level it set indetails.after.access_level, so raising a group's access on a connection fromreadtoread_writeis visible as a change of level. A first grant carries adetails.beforeofnull. Aprivilege.connection.revokeevent records the connection identifier and the level that was withdrawn indetails.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. Each successful read is instead
noted in the server log, on a line prefixed [AUDIT] that names the
user or token that made the request, the filters in effect and the
number of events returned.
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. It needs the server secret file to do so, as does every other subcommand without exception, the read-only ones included: the hash chain is keyed by a key derived from that secret, and the command line resolves the key before it opens the store at all. A command that cannot find the secret reports the paths it searched and makes no change. 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 |
-rechain-audit-log |
Re-hash an inherited log, or re-anchor a failing one |
-confirm-rechain |
Confirm -rechain-audit-log without a prompt |
-previous-secret-file string |
Secret file of the older events |
-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. The example below is one such line, indented
here so that it fits the page:
{
"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": 3
}
Tamper Evidence
Each event stores a keyed hash (HMAC-SHA256) 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.
The key is derived from the server secret, so the chain cannot be
recomputed by someone who has the database file but not the secret.
Each event also records, in its hash_version field, the version of
the encoding its hash was computed under. Verification reads that
field, and an event
claiming a version this server will not compute, whether a version no
release ever wrote or the unkeyed version 1 encoding that is no longer
admissible, is reported as tampering: a version number is a value in
the file like any other, so relabelling an event must not put it beyond
the verifier's reach. A future change to the encoding is therefore not
free, as version 2 shows: introducing the keyed hash left every
version 1 event unverifiable, and an upgraded database has to be
re-chained, as described below. Verification also refuses an event
claiming a lower version than one before it, because the encodings only
ever move forwards. Version 3 computes the same keyed hash as version 2
under its own label; it marks an event written by a release that keeps
the tail record described below, so that an event can show which kind
of release wrote it without that claim being open to change.
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, and the only deletions the server
issues come from the retention purge described below. The one path that
updates events is the re-chain, which drops the trigger and re-creates
it inside the single transaction that rewrites the log, so the trigger
is never absent from any committed state of the database and no other
connection sees it gone.
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, and it checks what
each one does as well as its name: the index must be unique, must not
be partial and must cover the link to the preceding event and nothing
else, and the trigger must be the one the server creates, word for
word, and the only trigger that acts on the table. An object that keeps
the expected name but no longer protects the log, or an extra trigger
that could discard or delete events as they are written, is reported
with status 2. The server also refuses to record an event that a
trigger silently skips with RAISE(IGNORE), because the insert then
reports no row written; a trigger that deletes the event after it is
written leaves no such sign, and only verification, which refuses any
trigger it does not expect, catches it. 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. The status distinguishes the two cases that call for opposite responses:
| Status | Meaning |
|---|---|
0 |
The log verified. |
1 |
The check could not run, for example the store would not open. |
2 |
The chain is broken or has lost its tail. |
3 |
The oldest events do not verify under the key in use. |
A store that holds an unkeyed event does not open, so the command
reports it with status 1 and the reason the store refused.
Status 3 means that the oldest events, and possibly all of them, fail
under the key in use, each still linked to the one before it, whilst
every event after them verifies and links to the one before it, the
log has lost nothing from its tail, and no earlier event verified under
the key. That is the shape a server secret changed since those events
were written leaves behind, and the command reports it as a probable
wrong or rotated secret rather than as tampering. It never does so
where a purge or re-anchor event that verifies under the key records
where the log begins: such an event was written under the key in use,
after every older event had been checked or accepted, so no change of
secret can explain an event failing after it, and any failure on such
a log is reported with status 2. Check that secret_file names
the file the log was written under before treating it as an incident;
if the secret was changed deliberately, or the old one is lost, see
Recovering a Log That No Longer Verifies.
A failure of any other shape, such as an event in the middle of the log
that no longer verifies, is reported with status 2.
A log that a re-anchor has given a new starting point reports how many of its events were accepted as history:
Audit log verified: 2 event(s), chain intact
1 of them were accepted as history by the re-chain recorded in event 2,
and verify only as unchanged since then, not as written by this server
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. A disagreement is reported with status 2,
like any other contradiction of the chain. 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.
That record is an ordinary SQLite table with no protection of its own,
so someone who deletes the newest events can set it to the new highest
identifier as well. The server therefore also keeps a tail record in
the audit_tail table, naming the newest event by identifier and hash
under a keyed hash (HMAC-SHA256) derived from the server secret, and
moves it on in the same transaction that writes each event.
Verification requires the tail record to name the newest event and to
verify under the key, and reports status 2 when it names an event
that is no longer the newest, when it does not verify, when it names an
event but the log is empty, or when it is missing and the newest event
is version 3. Anyone able to write auth.db can point the record at
another event, but without the server secret the record then fails to
verify. The server moves the record on only when it names the newest
event and verifies under the secret in use; once it names an event
that has gone, or does not verify, the server leaves it as it is, so
the deletion is still reported however many events are written after
it under the same secret, and only the re-chain, described below,
gives the log a new tail record over one that does not qualify. A
change of secret ends that guarantee for the events written before it:
the record left by the old secret can no longer be checked, so a log
cut back to an earlier event written under that secret reports status
3 rather than 2, as described under
What Verification Does and Does Not Show.
A changed server secret is no exception: the record stays where the
old secret left it, naming the last event written under that secret.
The events the server goes on writing under the new secret are covered
by a second tail record in the same table, which the server starts at
the first of them and moves on by the same rule; its keyed hash also
covers the first record, so a second record cannot be paired with a
different first record. That does not stop an earlier copy of the
second record being put back: the first record does not change whilst
one secret is in use, so someone who saved the second record can
delete the events written after the one it names, set the record of
the highest identifier to match, and put the saved copy back, and the
log reports status 3, as described under
What Verification Does and Does Not Show.
In a log with the shape a changed secret leaves, described above under
status 3, verification accepts the first record when it names the
last event that fails under the current secret, and then requires the
second record to name the newest event and to verify under the current
secret. It also accepts a first record that names the newest event and
verifies under the current secret, which is what the server writes
when it adds the record to a log that a release before the tail record
went on writing after the secret changed, and, in the window before
the first event under a second new secret described below, a second
record that names the newest event without verifying. A first record
moved there from the second, as described below, never counts as
verifying under the current secret, because the server moves it only
once it fails under that secret. Anything else is reported with status
2, so deleting the newest events is caught after a change of secret
as it is before one. The record written under the old secret
cannot be checked without that secret, so an unattended re-anchor
requires it to verify under -previous-secret-file as well, as
described under
Recovering a Log That No Longer Verifies.
A re-anchor or re-chain leaves a single record naming its own event.
If the secret changes a second time before the log is re-anchored, the
server moves the second record into the place of the first when it
writes its first event under the newest secret, keeping beside it the
first record it replaces, over which its keyed hash was computed, and
starts the second record again at that event. The records then name the
last event written under the secret before and the newest event, which
is what verification asks of them, so the log reports status 3 rather
than 2. Events written under the oldest secret are no longer at the
end of the log, and the chain itself catches their deletion, because
the first event written after them links to the last of them. The
server writes a warning to its own log each time it starts the second
record and each time it moves it into the place of the first.
The keyed hash of a moved record shows only that the server wrote the
pair, not that the first record it was written beside was genuine.
Someone able to write auth.db can delete the newest events, put in
an event carrying the hash of the new newest event, and point the
first record at it; the server then starts the second record beside
it, and once the planted event is deleted and the secret changes, the
moved record carries the planted first record with the server's own
keyed hash. Verification and the re-anchor therefore also check the
event a second record, or a moved record, was written beside against
the log itself. That event must still be there, with the hash recorded,
before the event the record names; the server starts the second record
only when that event fails under the secret in use, which honestly
means it is the last event written under an earlier secret, and the
purge never removes it. Whilst the record is left as the server wrote
it, that event is missing and the secret has not changed again, the
log reports status 2, but this is not a guarantee. Verification
checks the event only when a record names one, and cannot check a
moved record's keyed hash under the current secret, so someone able to
write auth.db who removes the copy of the first record kept beside a
moved record, or points it at an event still in the log, makes the log
report status 3 again; so does rewriting the event before the
missing one and putting an event carrying the recorded hash back in
its place, linked into the chain, and a further change of secret does
the same with no edit at all.
As in the window described below, only a re-anchor given the previous
secret with -previous-secret-file refuses such a log, because the
moved record, or the rewritten event, no longer verifies under that
secret, or the log spans more than two secrets.
Until the server writes its first event under a new secret, neither
record verifies under the secret in use, so verification can check the
second record only by the event it names. Someone able to write
auth.db in that window can delete the newest events written under the
previous secret, set the record of the highest identifier to match, and
point the second record at the new newest event; verification reports
status 3, both then and after the server moves that record into the
place of the first. A re-anchor given the previous secret with
-previous-secret-file checks the record against it and refuses the
log, so re-anchor with -previous-secret-file after each change of
secret, and before the next; doing so also keeps the log from spanning
more than two secrets, which no unattended re-anchor can prove.
The server checks the definition of the audit_tail table each time it
opens the store, and verification checks it again. A table that differs
from the one the server creates, or any trigger that acts on it, could
stop the tail records from moving on or keep them where an earlier
state left them, so the server refuses to open such a store, and
-verify-audit-log reports status 2, until auth.db is restored
from a known-good copy.
A database written by a release that predates the tail record has no
such record and version 2 events. The server writes the record when it
opens the store, naming the newest event, but only if the highest
identifier SQLite has issued agrees with it, because a record written
over a log that has already lost its newest events would vouch for the
deletion; a log where the two disagree is left without one, and
verification continues to report it. Nor is the record written while
the newest event fails to verify under the secret in use, as when the
store is first opened with the wrong secret file, since a record signed
under that secret would read as altered under the right one; the next
open with the right secret writes it. The change is one way: an earlier
release run against the same auth.db afterwards writes version 2
events after version 3 ones and leaves the tail record behind them, and
verification reports both.
Verification checks the start of the log in the same spirit. Every
purge records the event it left as the oldest, as described above, and
nothing but the purge deletes events, so until the next purge the
oldest event must be exactly that one; a log whose oldest event is any
other is reported with status 2, because events have been deleted
from the start of it since the purge ran. Where no purge event records
the oldest event, because no purge has removed anything since the
server was upgraded to a release that writes the record, verification
falls back to a weaker check: an oldest event that follows another
event, which is no longer in the log, is reported only when no
audit.purge event survives at all to account for the removal. A log
whose first event follows a missing predecessor and which holds no
purge event is evidence of deletion, since the purge is the only thing
that legitimately produces that shape and it always leaves an event
behind.
An oldest event that starts a chain of its own, with no predecessor at
all, is accepted only where it is event 1, where it is itself a purge
event, or where an anchor records it. SQLite never reuses an
identifier in the audit_events table, even once every row has been
deleted, so the first event the log ever held is always event 1; a new
chain starting above it is what emptying the table and letting the
server write one more event leaves, and verification reports it with
status 2. Purge events are allowed because earlier builds could
purge the whole log and leave their own event as the first. A
re-anchor event is not, since no re-anchor starts a chain of its own,
so one kept from an earlier log and put back after the log is emptied
is reported in the same way. The purge refuses such a log as well. An
emptied log that you have accounted for, and to which the server has
since written an event, recovers with an interactive re-anchor, as
described under
Recovering a Log That No Longer Verifies.
Upgrading a Log Written Before the Keyed Chain
A database created by a release that predates the keyed chain holds events hashed under the earlier unkeyed encoding, and this release will not open such a database at all. The refusal names the events involved and what to do about them.
The reason is that the unkeyed hash can be computed by anyone who can
write auth.db, so a run of unkeyed events carries no evidence of who
wrote it: a genuine inherited log and a forged one are the same thing
to a verifier. Earlier designs tried to keep a signed boundary between
the events a database inherited and the events written since, and each
attempt turned out to be another way to have the server sign a forgery
under the real key. Nothing unkeyed is admissible now.
Treat a database that will not open for this reason as suspect, and
restore auth.db from a known-good copy. Only where this is the first
start after upgrading, and you are satisfied the file has not been
altered, re-hash the inherited events under the server secret:
./bin/ai-dba-server -rechain-audit-log
The command prints what it has found, which is the number of events,
how many of them are unkeyed, the oldest and newest timestamps, and
whether the log recomputes under the rules each event was written with,
and then asks you to type rechain before it writes anything. Pass
-confirm-rechain to answer in advance for an unattended run. The
prompt is shown only on a terminal; when the command's input is not a
terminal and -confirm-rechain is not given, it refuses without
reading anything, so that a confirmation piped in by a script is not
mistaken for an operator having read the figures. It re-hashes every
event as a keyed event in a single transaction, links
each one to the new hash of the event before it, and records the
re-chain itself as an audit.rechain event at the end of the log.
Verify the result immediately afterwards with -verify-audit-log.
That the existing log recomputes cleanly, which the command reports before asking, is worth very little on its own: the unkeyed hash is computable by anyone, so a rewrite done carefully leaves a log that recomputes just as cleanly. It rules out a careless edit and nothing more.
Understand what the re-chain does and does not assert. It attests the log exactly as the database holds it at the moment it runs: whatever that file says becomes what the keyed chain then vouches for. It proves that the log has not been altered since the re-chain, and nothing whatever about what happened before it. That is why it is an operator's deliberate act, taken on the figures, rather than something a start-up does by itself.
A database created at this release or later never needs it, because every event in it was keyed from the start.
The -previous-secret-file flag proves the history a re-anchor of a
keyed log accepts, and has no part in a re-hash. Writing unkeyed
events needs no key, so someone able to write auth.db can replace a
keyed log with unkeyed events that a re-hash would then sign, and no
secret can vouch for them. When -previous-secret-file is given and
the log would be re-hashed, the command therefore refuses and writes
nothing, interactively or not. If the server has only ever written
keyed events, an unkeyed log means the file has been replaced: restore
auth.db from a known-good copy.
The command refuses outright in three further cases, and writes
nothing in any of them. The first is a log that holds keyed and unkeyed events
together, whatever order they sit in. An upgraded database holds its
inherited unkeyed events and nothing else, so a log holding both means
unkeyed events were written into a log that was already keyed, which no
server path does; re-chaining would sign them under the server secret
and make them indistinguishable from events this server recorded. The
ordering is not consulted, because an event's identifier is a value in
the file like any other and can be chosen to sit below every genuine
event. The start-up refusal names this case separately, so an operator
meeting it is not sent to a command that cannot help them. Restore
auth.db from a known-good copy instead.
The second is an unattended run, with -confirm-rechain, of a log that
does not recompute under the rules its own events were written with.
The command prints that finding either way, but an unattended run has
nobody to weigh it, so it stops rather than signing a log the server has
just reported as not agreeing with itself. Inspect the log, and if you
still judge it sound, re-chain it interactively, where the warning
reaches you before you confirm.
The third is a log that changed between the figures you were shown and the rewrite itself. The command re-reads the number of events and the range of identifiers inside the transaction that holds the database's write lock, and abandons the rewrite if any of the three has moved, because the prompt may have sat waiting whilst something else wrote to the file. The message gives both the figures you approved and the ones it found, and nothing has been signed.
Recovering a Log That No Longer Verifies
A keyed log that stops verifying also stops the retention purge, as
described under Retention, and nothing the purge does by
itself clears the refusal. The two ordinary causes are a server secret
that has been changed or lost since the older events were written, and
events deleted or altered by someone able to write auth.db. The
-rechain-audit-log command recovers from both by re-anchoring the
log, which gives the log a new trusted starting point.
In the following example, -rechain-audit-log examines a keyed log and
offers to re-anchor it:
./bin/ai-dba-server -rechain-audit-log
The command runs verification first. A log that already verifies needs nothing, and the command says so without asking:
The audit log verifies as it stands; there is nothing to re-chain.
Otherwise the command prints why verification fails, the oldest event
it would accept with that event's hash, any starting point an earlier
purge or re-anchor recorded, and the events it would accept as history.
It then asks you to type rechain, and changes nothing on any other
answer. The following output comes from a log written under a server
secret that has since been replaced, with the failure reason shortened
here to fit the page:
Audit log in /var/lib/ai-workbench:
Events: 1
Oldest event: 2026-09-25T14:14:52Z
Newest event: 2026-09-25T14:14:52Z
Verification fails: audit chain key mismatch: audit row 1 does not ...
The oldest events do not verify under the current server secret, and the events
after them do. That is what changing or replacing the secret leaves behind. If
you have not changed secret_file, do not proceed: find the right secret file
instead, and the log will verify as it stands.
The re-chain would record:
Oldest event accepted: 1
Its hash: d0dd1659b885db7ca952077849fbcbca0b92be24d02b014a32b7386c81e4ffee
Previously recorded: (none)
Accepted as history: 1 event(s), 1 to 1
Events accepted as history are no longer shown to have been written by this
server. Whatever was done to them before now is accepted with them; from now on
the log shows only whether they change again.
No existing event is changed, re-signed or deleted. One audit.rechain event is
appended, signed under the current secret, recording the above as where the log
now begins.
Type "rechain" to proceed, or anything else to abort:
The prompt is shown only on a terminal. When the command's input is
not a terminal and -confirm-rechain is not given, it refuses without
reading anything, so that a confirmation piped in by a script is not
mistaken for an operator having read the plan.
The -confirm-rechain flag answers the prompt in advance, but only
where the command does not have to take the shape of the log on trust.
The shape a changed secret leaves, in which the oldest events fail,
linked to one another, and every event after them verifies and links,
is evidence and not proof: someone able to write auth.db can delete
the start of the log and put in its place one event whose hash is the
one the next event links to, and the result looks exactly like a
changed secret. An unattended re-anchor therefore proceeds only when
all of the following hold:
- The failure has the shape a changed secret leaves, and both tail records are where verification requires them. The whole log is checked, because the re-anchor accepts everything through the last failing event as history, so a rotated secret cannot carry a later deletion or edit through. This shows that nothing has been deleted from the end of the log since the server last wrote to it, within the limits described under What Verification Does and Does Not Show.
-previous-secret-filenames the secret file the older events were written under (it is refused for a log that would be re-hashed), and every event the re-anchor would accept as history verifies under that secret, links to the event before it, and accounts for the start of the log exactly as verification requires of a log written under one secret, and the tail record the previous secret left behind verifies under it and names the last of those events, so that none written under it after that record was written has been deleted from the end. Where that tail record was written beside another, the event it was written beside must still be in the log before the event the record names, and must fail under the previous secret, because the server writes such a record only beside an event that fails under the secret in use. An event or tail record forged without the previous secret fails under it, so the proof fails and the run stops. The server can, however, be led to sign a tail record itself, and the proof cannot tell an earlier copy of either tail record, which the server really wrote, from the current one, so it does not catch that copy being put back once the events written after the one it names have been deleted.- At least one event after the history verifies under the current secret, which shows that the current secret is the one the server runs with; a re-anchor signed under the wrong secret would stop every purge once the right one is back in place.
For any other case an unattended run prints the findings and the
reason it refused, and stops; re-anchor such a log interactively once
you have accounted for it, or restore auth.db from a known-good copy
instead. The plan shows what the previous secret proved whenever
-previous-secret-file is given, interactively or not.
In the following example, the unattended re-anchor proves the history under the secret the log was written under before it was rotated:
./bin/ai-dba-server -rechain-audit-log -confirm-rechain \
-previous-secret-file /etc/pgedge/ai-dba-server.secret.old
A single previous secret cannot prove every history. A log whose history spans more than one earlier secret, such as one rotated away and back again, or whose history includes events an earlier re-anchor accepted, fails the proof, and needs an interactive re-anchor.
Where every event verifies and the failure is in where the log begins
or ends, such as events lost from the tail, the re-anchor accepts no
history, and the plan warns that recording a new starting point removes
the evidence of the failure from the log; afterwards only the recorded
reason shows that it was there. A log that holds no events at all is
refused rather than re-anchored, because there is no event for a
starting point to record; restore auth.db from a known-good copy, or
run the re-anchor again once the server has written an event.
The re-anchor never deletes, rewrites or re-signs an existing event.
It appends one audit.rechain event, signed under the current server
secret, that records the oldest event as where the log now begins, in
the fields described under
Recorded Actions. Every event from the start of
the log through the last one that fails verification, or fails to link
to the event before it, is accepted as history, and the event records a
digest of their contents. From then on, verification and the purge
check those events against the digest and their number rather than
under the key, and hold every later event to the key as before. The
purge can therefore remove history events again as they age out, and
copies the digest of any it keeps into its own event.
Accepting events as history gives something up, and the plan says so before you confirm. History events are no longer shown to have been written by this server; anything done to them before the re-anchor, by anyone, is accepted with them, and the digest shows only that they have not changed since. That is why the re-anchor needs an operator's confirmation, or proof under the previous secret.
The audit.rechain event is an ordinary event as far as retention is
concerned, so the purge removes it once it is older than the retention
period, along with the reason it recorded. Later purge events carry
the starting point forward, but not the reason; export the event, as
described under Retention, if you need to keep the record
of why the log was re-anchored.
Verification and the purge treat the newest anchor, meaning the newest purge or re-anchor event that records a starting point, as authoritative. Every purge or re-anchor event from the newest down to that anchor must verify under the current secret: if one does not, verification holds every event to the key as if no anchor existed, and the purge refuses, rather than falling back to an older anchor. An older event cannot override a newer one: a copy of an earlier event put back into the log does not link into the chain where it sits, and both verification and the purge refuse a log in that state.
The command refuses, and writes nothing, when the log's unique index or
append-only trigger is missing or altered, when verification could not
read the log, and when the log changed between the plan you approved
and the write, which the command checks inside the transaction that
holds the database's write lock. After recording the event, the command
verifies the log again and reports any failure that a new starting
point does not account for. Verify the log with -verify-audit-log
afterwards in any case.
What Verification Does and Does Not Show
Read those checks for what they are. They catch deletions made without
recomputing the chain, events altered in place, and a table whose
newest identifier has been rolled back; taken together, 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.
Six limits are worth stating plainly.
An attacker holding both the server secret and write access to
auth.db can forge the log freely. The hash is keyed, so the secret is
what they need to rewrite events; with it they can recompute every hash
after making a change. Keep the secret file and the data directory
apart, as far as the operating system allows, so that an account able
to read one cannot reach the other.
Deleting the newest events is caught by the tail record, but only
against the file as it now stands. Nothing outside auth.db records
how far the log had reached, so an earlier copy of the audit_tail
table, put back together with the events after the one it names
deleted, and an earlier copy of the whole file, both verify. Deleting
every version 3 event together with the tail record, and setting the
record of the highest identifier to match, leaves a log that looks as
it did before the first release that keeps the tail record wrote to it,
so the events written before that upgrade remain exposed to deletion
from the end. In the same way, deleting every event written since the
server secret last changed, together with the second tail record, and
setting the record of the highest identifier to match, leaves a log
that looks as it did at the moment of the change; verification still
reports status 3. An unattended re-anchor refuses that log, because
no event verifies under the current secret, but accepts it once the
server has written its next event. More generally, status 3 vouches
for nothing written under the previous secret: verification checks
neither those events nor the tail record written alongside them, so
someone able to write auth.db can cut the log back to any earlier
event written under that secret, not just to the moment it changed, and
still see status 3. The same holds for a first record whose event
was altered so that it failed, long enough for the server to start
the second record beside it, and then restored: every event is then
present and correctly linked, so verification still reports status
3. Only a re-anchor given the previous secret with
-previous-secret-file checks those events, that record and the
event the record was written beside, and so refuses both cases. It
does not check either tail record against an earlier copy of itself,
however, so it accepts an earlier copy of either record put back with
the events written after the one it names deleted: a copy of the first
record saved under the previous secret, put back once the events after
it are cut and the second record removed, reads as status 3, and the
re-anchor accepts it once the server has written its next event. An
interactive re-anchor without -previous-secret-file warns, just
before it asks, that nothing has proven the events written under the
previous secret. A log emptied entirely, with the tail record and the
record of the highest identifier removed as well, reads as one that
has never held an event.
Deleting the oldest events is caught precisely only once a purge has recorded where the log begins. On a log whose purge events were all written by builds that predate the record, verification can say only whether some purge event survives to explain a missing predecessor, so a deletion from the start of that log still verifies until the next purge removes something and records the oldest event. Once the record exists, removing the oldest events and every purge event that mentions them is no longer enough whilst any later event survives: either the newest surviving purge event names an oldest event that is not there, and it cannot be rewritten without the server secret, or the oldest surviving event follows a predecessor that no purge event accounts for. Emptying the log entirely removes the record along with everything else, and leaves only the cases below. Events deleted and later put back exactly as they were, from a copy, leave nothing to find. Where no purge record survives, deleting every event except event 1, or deleting them all and putting back a copy of event 1, is the same as deleting the newest events: the tail record catches it, within the limits just described, but nothing else in the file tells that log from one that never grew. An event's identifier is not part of its hash either, so someone who can write the database could empty the log, whether or not a purge had recorded where it began, wait for the server to write one more event and move that event to identifier 1; the chain alone would accept that log, and it is the tail record, which names the newest event by identifier, that refuses it. A purge event written before purges recorded where the log began works the same way: someone who kept a copy of one can empty the log, put that event back under any identifier, and wait for the server to write one more event, and the chain alone accepts it, because the purge that recorded the head was deleted with everything else and the old event carries no record to check; the tail record refuses that log too, unless it was deleted along with the events and the sequence, as described above.
The re-chain blesses whatever the database contained at the moment it ran, as described above, so on an upgraded installation the keyed chain says nothing about the period before that.
A re-anchor likewise accepts its history events as they stood when it ran, as described above, so for those events verification shows only that they have not changed since, and nothing about who wrote them.
Nothing verifies the log at start-up. Verification runs only when you
run -verify-audit-log, so schedule it if you want the check to happen
at all, and compare the event count and the oldest retained event
against what you expect.
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.
Before it deletes anything, the purge checks the events it is about to
remove. They must begin with the oldest event that the newest purge or
re-anchor recorded, or with the very first event the log ever held if
no purge has run, every one of them must verify under the server secret, and each
must link to the one before it, through to the event that becomes the
new oldest. Without that check, an attacker who deleted the start of
the log could insert a single backdated event and wait for the next
purge to remove it and record a new oldest event over the deletion. A
purge that finds anything else deletes nothing and logs an error that
contains refusing to purge the audit log, and it keeps refusing at
every five-minute run until the log is dealt with. Where the refusal is
because the log does not verify, the error names
-verify-audit-log, which shows what the purge found, and
-rechain-audit-log, which records a new starting point once you have
accounted for it, as described under
Recovering a Log That No Longer Verifies.
Retention stops whilst the purge refuses, so auth.db grows until then.
Events a re-anchor accepted as history are checked against its digest
rather than under the key, so the purge removes them as they age out.
The purge removes a run of the oldest events and can express nothing else: it finds the oldest event inside the retention window and deletes everything below it by identifier, rather than deleting by timestamp. On a log the server wrote itself, where identifiers and timestamps rise together, the two are the same set of events. They part company when an event's timestamp has been altered in the database, which is the point: identifiers are assigned by SQLite in insertion order and nothing the server does writes them, so a backdated event cannot steer the purge into removing the newest events instead of the oldest. One consequence is worth knowing about, since it shows up on a server that has been idle for longer than the retention period: when no event at all falls inside the window, the purge removes nothing rather than emptying the log, and the backlog clears at the first purge to run once an event falls inside the window again, which is to say within five minutes of that event, or at the next start of the server.
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_daysalongside the other server settings.