Skip to main content

Audit log

The manager records a durable, tenant-scoped audit trail of administrative and data-plane activity. When QOD_TELEMETRY_STORE=postgres (the default), every event is written to the qodstate_audit table in the control-plane Postgres database. When QOD_TELEMETRY_STORE=none, nothing is recorded and the Audit page is hidden from the navigation.

What is captured

Events fall into four families:

FamilyMeaning
control-planeMutating REST operations: tenant, database, pool, user, role, group, membership, node, and federation changes
authLogin successes, login failures, logouts, session revocations, and rejected API keys
data-denialSQL statements blocked by the ACL validator at the FlightSQL edge
data-writeDDL and DML statements that completed successfully at the FlightSQL edge

Read statements (SELECT, SHOW, EXPLAIN without DML) are not audited. They belong to statement history, which is a separate subsystem.

Action taxonomy

The action field is a dotted string. The full set of values emitted by the current implementation is:

Auth

ActionDescription
auth.loginSuccessful login
auth.login.failureFailed login (bad credentials, expired, or unknown user)
auth.logoutSession logout
auth.revokeExplicit session revocation
auth.api-key.failureRejected X-API-Key header; rate-limited to one event per source IP per minute

Control-plane: tenants and databases

ActionDescription
tenant.createTenant created
tenant.deleteTenant deleted
tenant.auth.updateTenant auth provider updated
tenant.setDisabledTenant enabled or disabled
database.createTenant-db created
database.updateTenant-db updated
database.deleteTenant-db deleted

Control-plane: pools

ActionDescription
pool.createPool created
pool.scalePool scaled up or down
pool.stopPool drained to zero nodes (pool record kept)
pool.deletePool deleted
pool.setDisabledPool enabled or disabled (create-disabled toggle)
pool.setResourcesPool CPU or memory limits updated
pool.setPodTemplatePool pod template updated (superuser only)
pool.permission.grantPool permission granted to a role
pool.permission.revokePool permission revoked from a role

Control-plane: users, roles, groups, and memberships

ActionDescription
user.createUser created
user.updateUser updated
user.deleteUser deleted
role.createRole created
role.deleteRole deleted
role.permission.grantTable permission granted on a role
role.permission.revokeTable permission revoked from a role
role.rowPolicy.setRow-level policy set on a role
role.rowPolicy.deleteRow-level policy deleted from a role
role.columnPolicy.setColumn-level policy set on a role
role.columnPolicy.deleteColumn-level policy deleted from a role
group.createGroup created
group.deleteGroup deleted
membership.user-role.addUser assigned to role
membership.user-role.removeUser removed from role
membership.user-group.addUser added to group
membership.user-group.removeUser removed from group
membership.group-role.addGroup assigned to role
membership.group-role.removeGroup removed from role

Control-plane: nodes, statements, federation, and manifest

ActionDescription
node.quarantineNode quarantined (excluded from routing)
node.unquarantineNode returned to rotation
node.restartNode restarted via the reconcile path
statement.killBest-effort statement kill requested
federation.source.upsertFederated source created or updated
federation.source.deleteFederated source deleted
federation.secret.upsertFederation secret created or updated
federation.secret.deleteFederation secret deleted
manifest.importManifest imported (full control-plane apply)

Data-plane

ActionDescription
sql.deniedStatement blocked by the ACL validator; detail includes the denied table and missing verb
sql.writeDML statement completed; detail includes sql text (capped at 500 chars) and durationMs
sql.ddlDDL statement completed; detail includes sql text (capped at 500 chars) and durationMs

Each event row

Every row in qodstate_audit carries:

FieldValuesNotes
tsISO-8601 timestampEvent time
familycontrol-plane, auth, data-denial, data-writeCoarse grouping
actorusernameanonymous for unauthenticated 401s; static-key for API-key callers that cannot be resolved to a user
actor_realmsystem, tenantSuperuser sessions are system
tenanttenant id or nullNull for tenant-less events (see below)
actiondotted verb (see taxonomy above)
targetresource identifier or nullPool key, role id, node id, table name, etc.
outcomeok, denied, errordenied for scope-gate rejections; error for unexpected failures
originrest, flightsqlWhere the event originated
detailkey-value mapWhitelisted context (see Sanitization)

Who sees what

Access to GET /api/audit/list follows the same scope rules as every other RBAC read:

  • Superusers see all rows, including rows where tenant is null. The tenant filter returns only that tenant's rows (null-tenant rows are not included when a tenant filter is set). Use noTenant=true to select only the null-tenant rows.
  • Tenant admins see only rows whose tenant equals one of their administrable tenants. Rows with tenant IS NULL are not visible to tenant admins. The noTenant parameter is ignored for tenant admins.
  • Static-key callers are treated as superusers.

Rows with tenant IS NULL represent events with no tenant scope: anonymous authentication failures, node operations that span tenants, and manifest imports. These are exclusively visible to superusers so tenant admins cannot infer deployment topology from them.

The tenant admin scope is enforced server-side. A tenant admin requesting ?tenant=other-tenant gets back their own tenant's rows, not an error, so no cross-tenant existence is leaked via differential error codes.

Retention

The manager runs an hourly background purge that deletes rows older than QOD_AUDIT_RETENTION_DAYS (default 90). The purge runs hourly and deletes all rows older than the cutoff in one statement.

To keep audit records for a longer period:

QOD_AUDIT_RETENTION_DAYS=365 qod start

To disable retention (keep forever), set QOD_AUDIT_RETENTION_DAYS=0. The purge runs but deletes nothing.

In HA mode the hourly purge runs only on the singleton leader (the replica holding the HaCoordinator Postgres advisory lock). A failover hands the duty to the next leader on its next tick.

Sanitization

The detail field is built exclusively from whitelisted keys per action. The AuditEvent constructor rejects any detail map that contains a key matching password, secret, token, jwt, or credential (case-insensitive substring match). This is a construction-time guarantee, not a filtering step: no code path can store these values by accident because the constructor throws before any write reaches the store.

Specifically, the following never enter the audit trail:

  • Login passwords
  • Federation secret values
  • Session JWTs
  • Full metastore connection maps (which may contain passwords)
  • Raw pod template bodies (which may contain paths and credentials)

SQL text in data-plane events (sql.write, sql.ddl, sql.denied) is capped at 500 characters. The text is recorded verbatim; statements that inline credentials (for example CREATE SECRET) will persist those literals in the audit trail up to the 500-char cap. The key whitelist described above guards structured detail fields only, not SQL text.

The none off switch

Set QOD_TELEMETRY_STORE=none to disable the audit subsystem entirely:

  • Every appendAudit call is a no-op.
  • Every listAudit call returns empty.
  • The journal fiber, the hourly purge duty, and the rollup duties are not started.
  • The qod_journal_dropped_total counter stays at zero (not recording is intentional, not a drop).
  • The Audit entry is hidden from the admin UI navigation. A deep link to the Audit page shows an empty state with a "telemetry is disabled" message.

The postgres and none values are the only accepted values for QOD_TELEMETRY_STORE. Any other value is refused at startup.

Querying the audit log with the CLI

The examples below use the qod CLI; they assume qod login has stored a session, or QOD_API_KEY is set for CI scripts.

# List the 50 most recent control-plane events (default limit)
qod audit list --family control-plane --limit 50

# Page through with the keyset cursor
qod audit list --before <nextBefore-from-previous-page>

# Failed logins in a window
qod audit list --action auth.login.failure --from 2026-07-01T00:00:00Z

The exhaustive action vocabulary (for building filter UIs) is served by a static registry:

qod audit actions

Filter parameters

ParameterDescription
familyFilter by event family (control-plane, auth, data-denial, data-write)
tenantFilter by tenant id. Superusers and static-key callers: returns exactly that tenant's rows (null-tenant rows not included). Tenant admins: narrows within their manageable tenants; a tenant outside their set silently falls back to all manageable rows.
noTenantSet to true to return only rows with no tenant scope (superusers and static-key callers only; ignored for tenant admins). Wins over tenant when both are supplied: if noTenant=true and ?tenant=x are both present, only null-tenant rows are returned.
actorFilter by actor username (exact match)
actionFilter by action string (exact match, e.g. auth.login.failure)
qSubstring match on target and action
fromISO-8601 instant; return only events at or after this time
toISO-8601 instant; return only events before this time
limitNumber of rows to return (default 50, max 500)
beforeOpaque keyset cursor from nextBefore in a prior response; used to fetch the next page of older results

Results are returned newest-first. The response includes a nextBefore cursor whenever the page is non-empty; the UI shows a "Load more" button only when the page is full (the limit was reached).