Audit log
Holotable records one row in audit_log for each thing someone does: signing
in and out, creating, changing or deleting a dashboard, source or template,
running a statement, asking the model for something, and every request that was
refused. The JSON log covers some of this too, but logs
are rotated away and a workspace admin cannot query them. This table is the
history that can’t be filled in after the fact.
The table comes with migration 012_audit_log.sql. There is nothing to
configure.
What is recorded
Section titled “What is recorded”| Action | When | Resource |
|---|---|---|
auth.login |
A session is minted: the OIDC callback, or a new demo visitor | — |
auth.logout |
Sign-out with a session that still verifies | — |
auth.backchannel_logout |
The realm ends sessions (actor kind realm) |
— |
authz.denied |
Any assertAuthorized refusal. detail names the permission and route |
what was being acted on, when the route knows |
dashboard.create |
Create, import (via: "import"), duplicate (via: "duplicate"), or an MCP client’s save_dashboard (via: "mcp") |
dashboard |
dashboard.update |
Save, rename or metadata edit (fields), restore (restoredFrom) |
dashboard |
dashboard.delete |
Delete | dashboard |
dashboard.stream |
A viewer opens or resumes a dashboard’s live stream; a second row with outcome: failure and detail.dropped when the stream is closed for falling behind |
dashboard |
dashboard.generate |
A generation finishes, or fails (modelConfig: which level answered; modelId: the catalog model, an id or env:<name>) |
source |
dashboard.chat |
A chat turn starts (modelConfig, modelId, and the timeRange, variables and panelId it runs with) |
dashboard |
chat.create, chat.update, chat.delete |
A conversation is started (sourceIds and timeRange, or dashboardId for one that continues a dashboard’s chat), renamed or changed (renamed, sourceIds, timeRange: what changed, never the title), or deleted (deleted: a count; all when every one went) |
conversation, or — for delete-all |
chat.turn |
An Explore turn starts (modelConfig, modelId, timeRange, sourceIds) |
conversation |
query.execute |
A statement run from preview (via: "preview"), by a chat (via: "chat", with conversationId and panelId for Explore) or by an MCP client’s run_query (via: "mcp") |
source |
source.create, source.update, source.delete |
Source changes, including hiding a column (fields: ["catalog"]) |
source |
source.test, source.refresh, source.discover, source.draft |
Connection test, applied catalog refresh, table discovery, drafted source | source, or — before one exists |
template.create, template.delete |
Workspace templates | template |
workspace.limits.update |
A platform admin changes a workspace’s LLM limits (before, after) |
workspace |
workspace.prompt.update |
A source-admin saves a workspace’s prompt customization (glossaryChars, metricDefinitions, examples: sizes, not the text) |
workspace |
workspace.model.create |
A source-admin adds a model to a workspace (name, provider, endpointHost, model, keyChanged; never a credential) |
model |
workspace.model.update |
A source-admin replaces a workspace’s model (as workspace.model.create), or saves the workspace’s default and personal-models toggle (defaultModel, previousDefault, allowPersonalKeys) |
model or workspace |
workspace.model.delete |
A source-admin deletes a workspace’s model | model |
workspace.model.test |
A source-admin tests a workspace model (provider, endpointHost, model); failure when the model did not answer |
workspace or model |
workspace.model.discover |
A source-admin lists a provider’s models for a workspace model (provider, endpointHost, count); failure when the provider did not answer |
workspace |
user.model.create |
Someone adds a personal model (as workspace.model.create) |
model |
user.model.update |
Someone replaces a personal model (as workspace.model.create) |
model |
user.model.delete |
Someone deletes a personal model | model |
user.model.test |
Someone tests a personal model, admitted in a workspace that allows personal models (provider, endpointHost, model) |
none |
user.model.discover |
Someone lists a provider’s models for a personal model (provider, endpointHost, count) |
none |
annotation.create |
An editor or pipeline writes an annotation (kind, at, source) |
annotation |
annotation.delete |
An editor deletes an annotation | annotation |
share.create |
An editor creates a read-only share link (shareId, expiresAt, allowedOrigins, timeRange) |
dashboard |
share.revoke |
An editor revokes a share link (shareId) |
dashboard |
token.create |
A source-admin creates an API token (tokenId, name, role, expiresAt) |
workspace |
token.revoke |
A source-admin revokes an API token (tokenId) |
workspace |
outcome is success, failure (a statement the guard refused or the source
rejected, a failed connection test, a generation that produced nothing) or
denied.
Executions
Section titled “Executions”A statement is recorded when a person runs it. /api/query and a chat’s
runQuery tool each write one query.execute row per statement. Opening a
dashboard’s stream writes one dashboard.stream row that lists every panel’s
panelId, sourceId and statement digest at the version that will run.
The shared poller’s refreshes are not recorded one by one. Each refresh
runs the same statements on a timer for whoever is subscribed, and a row per
panel per tick would bury everything else. The stream row already says who
caused those statements to run and exactly which ones they were. A reconnect
writes a new row (resumed: true), because each reconnect is authorized again.
What a row holds
Section titled “What a row holds”| Column | |
|---|---|
id |
Increasing. Use it to page |
at |
When the row was written |
workspace_id |
The workspace the action was authorized in, taken from the stored resource. NULL for sign-ins and sign-outs. For a refusal it is the workspace that was targeted |
actor_sub, actor_kind |
The verified identity’s subject, with kind user. For back-channel logout, the subject named by the realm’s signed token, with kind realm |
action, resource_type, resource_id, outcome |
As above |
request_id |
The same requestId as the request’s log lines and its error responses |
detail |
Context. platformAdmin: true when the platform-admin bypass allowed the action |
What detail never holds. detail goes through the same redaction as
every log line. Credential-shaped strings are scrubbed and secret-named keys
are replaced. SQL, prompts and chat messages are reduced to
{ sha256, length }: enough to tell that two rows ran the same statement,
nothing of what it said. A key that could hold query results (rows, data,
values, result, records) is dropped whatever its contents, so a metric
value cannot reach the table even if a caller passes one by mistake.
Reading it
Section titled “Reading it”GET /api/audit?workspaceId=&from=&to=&action=&outcome=&limit=&before=- A workspace source-admin reads the rows of the workspaces they
administer.
?workspaceId=narrows that and never widens it. - A platform admin reads any workspace. With no
workspaceIdthey get every row, including sign-ins and sign-outs. - Everyone else gets an empty list rather than a 403, so the endpoint never confirms that a workspace exists.
fromandtotake an ISO timestamp or a relative expression such asnow-24h. The server resolves them.actionandoutcomemust be values from the tables above. A filter that is set but invalid is a 400, never ignored.- Each page holds
limitrows (100 by default, at most 500), newest first. The response’snextis thebeforefor the next page, and isnullon the last page.
{ "entries": [ { "id": "2", "at": "2026-10-04T22:31:07.412Z", "workspaceId": "ops", "actorSub": "f3c1…", "actorKind": "user", "action": "authz.denied", "resourceType": "source", "resourceId": "ts-metrics", "outcome": "denied", "requestId": "bc9bb6c8-…", "detail": { "permission": "source:manage", "route": "sources.delete" } } ], "next": null}Append-only
Section titled “Append-only”Triggers on audit_log refuse UPDATE, DELETE and TRUNCATE for every
role, including the table’s owner and a superuser. Grants alone couldn’t
guarantee that: the app and the migrations connect as the same role, which
owns the table, and an owner can always grant itself back what was revoked.
If you run the app as its own role, separate from the one that runs migrations, grant that role only what the table needs. The trigger then becomes a second safeguard instead of the only one:
GRANT SELECT, INSERT ON audit_log TO holotable_app;The triggers stop the app’s own code paths, and anything that can only issue DML, from rewriting history. They do not stop a role that owns the table and deliberately runs DDL, because disabling a trigger is DDL. If you need tamper-evidence against your own database administrators, copy the rows somewhere they can’t reach. A SIEM export is not built in.
Retention
Section titled “Retention”Nothing removes audit rows: not the app, and not its retention sweep, which covers the generation log and conversations only (#494). Volume grows with activity: sign-ins, edits, stream opens and statements people run, not the poller’s refreshes. Pruning is a deliberate maintenance step, run as the table’s owner in one transaction so the triggers are off only for that transaction:
BEGIN;ALTER TABLE audit_log DISABLE TRIGGER audit_log_no_change;DELETE FROM audit_log WHERE at < now() - interval '400 days';ALTER TABLE audit_log ENABLE TRIGGER audit_log_no_change;COMMIT;ALTER TABLE takes an exclusive lock, so writes wait for the transaction to
finish. Keep the DELETE bounded.
When a write fails
Section titled “When a write fails”Recording an event never fails or delays the request it describes. A row that
can’t be written is logged as audit.write_failed (or audit.build_failed),
and the request carries on. Each lost row also increments
holotable_audit_write_failures_total, so alert on it:
increase(holotable_audit_write_failures_total[15m]) > 0Last verified against the code at commit 00ea858 (2026-10-05).