Skip to content

The admin-only audit trail of executed queries

A searchable history of statements, outcomes and error detail, readable by admins only, and on this engine it also records what the agent was permitted to run.

An incident review arrives at the same three words: who ran that. PostgreSQL can answer it, but only if somebody configured the server to record statements before the one you are asking about ran. A self-hosted query audit log inside the tool answers from the other side: it records what was run through Studio, with the outcome and the error detail, and it is readable by admins only.

That is a smaller claim than a server-side audit extension makes, and it is deliberately a different one. The trail is application-level and engine-independent: the same record shape on PostgreSQL as on Redis, because it is written by the application, not by the engine.

What the trail records, statement by statement

There are two records with two different lifetimes.

The per-statement history record is written for a statement run from the editor. Its fields are the ones an incident actually asks for:

Field What it holds
query the statement text as sent
executedAt the timestamp
executionTime measured duration in milliseconds
status success or error
rowCount rows returned, where the engine reported one
errorMessage the failure text, when the status is error
connectionId, connectionName, tabName which connection and which tab

Source: QueryHistoryItem in src/lib/types.ts. History lives in the history collection, capped at 500 entries and held per user in the server storage table where server storage is configured.

The audit event is the server-side record, and it is a different structure: type, action, target, connectionName, user, result, duration, details, reason, and correlationId for the agent path. Event types include maintenance, kill_session, connection_test, masking_config, threshold_config, agent_operation, and the authentication events - login_success, login_failure, logout, permission_denied, rate_limit_exceeded. The buffer holds 1000 events and filters on type, result, connection name and a since timestamp. GET /api/admin/audit takes a type, and a limit that defaults to 100 and applies to the unfiltered read.

Two details in that structure are constraints rather than features. reason is a closed union of typed codes, never free text, so a refusal is recorded as insufficient_role or agent_risk_exceeds_policy rather than as the driver’s own sentence. The fields that stay free text pass one sanitiser first: a URI-shaped credential is collapsed to its scheme and host, and the value is bounded to 254 characters. It does not walk nested keys, so it bounds a record’s shape rather than promising that nothing sensitive reaches a details string. And ip is derived from forwarded headers and documented in the source as a hint, not an identity - X-Forwarded-For is attacker-controlled, and nothing in the product makes an authorization decision from it.

Why a self-hosted query audit log is admin-only

A statement is not metadata. It names tables and columns, and its WHERE clause frequently carries the literal values someone was looking up, so a history of statements is a second copy of a slice of the data, held under different access rules than the database’s. Handing that to every reader would be a quiet privilege escalation.

So the read surface is one endpoint with one rule. GET /api/admin/audit checks the role in the handler itself, and returns 403 whether the caller had no session at all or a valid session with the wrong role; the response does not distinguish the two. The proxy in front of it gates the /admin pages rather than this path, and the source says so plainly: middleware is an optimisation, not the authorization boundary. A caller that presents a valid login and the wrong role does leave a permission_denied event with reason insufficient_role, metered per account so that polling the endpoint in a loop cannot flush the ring buffer. The maintenance toolkit is governed by the same rule for the same reason: POST /api/db/maintenance runs VACUUM ANALYZE, ANALYZE, REINDEX and pg_terminate_backend(<pid>), and the operator who can terminate a backend is the operator who is already trusted to read what everyone ran.

There is one more rule that exists because evidence you can write is not evidence. POST /api/admin/audit appends to the in-app buffer and deliberately does not reach the stdout channel: its body is client-supplied, and giving it the authoritative channel would let an admin session forge an indistinguishable log line. Authoritative events are emitted as one structured JSON line on stdout, into whatever already collects your container logs. The rest of the control set is on the security page, stated with its gaps.

Application history is not a database audit extension

Here is the limit, flatly. The audit trail and the maintenance toolkit are admin-only, and the trail is application-level history of statements run through Studio - not a database-native audit facility covering everything that touched the server. A psql session, a migration runner, a cron job, the application’s own ORM: none of them appear in it, because none of them went through the application that writes the record.

Database-native coverage on PostgreSQL is server configuration and extensions, and Studio reads those rather than replacing them. Slow-query history needs pg_stat_statements; without the extension the slow-query view falls back to a live pg_stat_activity snapshot and health returns a single placeholder row reading “pg_stat_statements extension not enabled” rather than an empty list that would read as “no slow queries”.

The in-app ring buffer carries a second boundary: it is per-process. Restart the container, or run more than one replica, and the buffer is not the record - the stdout line is. If you need retention past 1000 events, that is a decision about your log store, not a setting in the product.

What it covers on an engine where the agent can execute

PostgreSQL is one of the three engines where agent AUTO mode runs, and that changes what the trail contains. Every statement the agent sends passes through executeAuditedOperation before the driver is touched: a policy decision event is recorded first, then an execution-outcome event, both sharing one server-generated correlationId. A refusal emits the decision event alone with a typed agent_* reason code. The emission is not wrapped in a try/catch, so an execution that cannot be audited does not run.

What those events carry is narrow by design: the registry-resolved operation id, an agent:<role> actor label, the outcome, the reason code, the elapsed time and the correlation id. Never the statement text, never the session identifier, never a driver message. And agent_operation is its own event type rather than a flag, because an operator filtering the log needs to separate what a human ran from what an agent was permitted to run:

curl -s --cookie auth.txt \
  'http://localhost:3000/api/admin/audit?type=agent_operation'

Two things this does not mean. First, a statement you run yourself in the editor takes the ordinary path: POST /api/db/query calls the provider directly and receives neither the policy decision nor the audit event, so it is a history record, not an agent_operation. Second, AUTO mode on this engine requires a least-privilege role - opening the execution profile probes the role and refuses with PROFILE_PRIVILEGES_TOO_BROAD unless superuser and membership of pg_read_server_files, pg_write_server_files and pg_execute_server_program all read back false. PLAN mode opens on every connection, is handed no tools, and executes nothing, so it produces no execution events to audit. The rest of that boundary is on the features page.

Using it as change evidence, and where that stops

For a change record, the useful shape is already there: an account, a timestamp, a target, a result and a duration, for the operations that changed something. A VACUUM on a table, a terminated backend, a masking rule edited, a login that failed before it succeeded - each is one event with a typed reason, and rate_limit_exceeded additionally names the bucket that tripped, so a broad address flood reads differently from a targeted attack on one account.

Where that stops is worth being exact about. The trail attributes to an account, not to a person; if three engineers share a login, the log says so only by saying nothing. It answers “did anyone touch this table through Studio”, not “did anything touch this table” - for that you need the server’s own logging, and the reconciliation is yours. And retention is a bounded buffer plus your log pipeline: the evidence is as durable as the pipeline and no more.