ACL audit log
In QuestDB Enterprise 4.2.0 and later, the ACL audit log records
changes to access control in sys.acl_audit_log, so you can investigate who
granted access, changed credentials, or modified a principal. Each row identifies
the authenticated principal, the principal it acted as, and the statement that
caused the change, when available. See Recorded changes for
cases where these fields are NULL.
For read auditing rather than access control changes, see Audited views.
Read the log
Reading the log requires all three permissions:
LIST USERS.USER DETAILS.SELECTonsys.acl_audit_log, or on all tables.
The statement texts disclose identities, memberships, grants, and external group
aliases, so SELECT ON ALL TABLES alone is not enough. Without both
LIST USERS and USER DETAILS, the table looks missing. Permissions may be
inherited through a group. The built-in admin and principals with
DATABASE ADMIN can read the log.
For an existing user named auditor, grant:
GRANT LIST USERS, USER DETAILS TO auditor;
GRANT SELECT ON 'sys.acl_audit_log' TO auditor;
The user also needs an
endpoint permission such as HTTP
or PGWIRE to connect.
SELECT change_id, session_principal, effective_principal,
is_external, statement_kind, statement_text
FROM 'sys.acl_audit_log'
WHERE change_id IN '$now-7d..$now'
ORDER BY change_id DESC;
Log columns
sys.acl_audit_log is a WAL table partitioned by day, with change_id as its
designated timestamp.
| Column | Type | Description |
|---|---|---|
change_id | TIMESTAMP | Microsecond timestamp for correlating a change with its ACL and credential rows. Subject to recovery limits. |
session_principal | VARCHAR | Principal that authenticated the session. NULL when no principal is available. |
effective_principal | VARCHAR | Principal whose permissions were used, such as an assumed service account. NULL when no principal is available. |
is_external | BOOLEAN | Whether the session principal is managed by an external identity provider, such as OIDC. Assuming a service account does not change this flag. |
origin | VARCHAR | user for changes caused by user statements, or migration for changes made by an internal ACL migration. |
statement_kind | VARCHAR | Statement category, such as CREATE USER, GRANT, or ALTER SERVICE ACCOUNT. |
statement_text | VARCHAR | Statement text with passwords and REST token values replaced by '****'. NULL when the text is unavailable. |
With ASSUME SERVICE ACCOUNT,
session_principal remains the user who connected, while effective_principal
names the service account. Otherwise, the two normally match. Use is_external
to distinguish an OIDC identity from a local user with the same name.
SELECT change_id, session_principal, is_external, statement_text
FROM 'sys.acl_audit_log'
WHERE effective_principal = 'risk_admin'
AND session_principal != effective_principal
ORDER BY change_id DESC;
This query detects assumptions only when the principal names differ. An
external user can assume a local service account with the same name; those
cases are not returned. is_external describes the authenticated identity,
not whether a service account was assumed, so these columns alone cannot
conclusively distinguish same-name assumptions.
Recorded changes
The log covers changes to users, groups, service accounts, group membership, external aliases, grants and revokes, passwords, JWK tokens, and persistent REST tokens. It also records per-principal memory limit changes and resource group assignments. Creating, altering, or dropping a resource group itself is not an ACL change and is not recorded here.
An audited statement produces one audit row even when it writes multiple ACL or
credential rows. For example, CREATE USER ... WITH PASSWORD records the
creation and password assignment under one change_id, and a cascading REVOKE
uses the same ID for all affected grants. Each statement in a batch records its
own text, not the entire batch.
DDL can produce audit rows for its permission changes, but this is not a complete DDL log:
- Owner grants on new tables, views, materialized views, live views, and columns record the creator as the principal, with no statement text.
- Grant cleanup after dropping or renaming a table or column records the
statement kind, with
NULLprincipals and statement text. Its origin is stilluser. - DDL that writes no ACL rows leaves no audit row.
The following also leave no audit row:
- Authentication attempts and ordinary queries.
- Statements that fail before their first write, or write nothing, such as
CREATE USER IF NOT EXISTSwhen the user already exists. - Transient REST token creation or removal. These tokens exist only in memory on the node that issued them.
- REST token TTL refreshes. Refreshed credential rows retain the issuing
change's ID instead of creating another audit row.
DROP TOKEN TYPE RESTis audited only when it removes a persistent token.
Link changes to ACL rows
The following system tables carry a change_id column linking a row to its
audit entry:
sys.acl_entitiessys.acl_external_groupssys.acl_jwk_tokenssys.acl_linkssys.acl_passwordssys.acl_permissionssys.acl_permissions_historysys.acl_rest_tokens
These are protected tables: only the built-in admin can query them, even when another principal can read the audit log. For example, run this as the built-in admin:
SELECT p.name, p.table_name, p.column_name, p.status,
a.change_id, a.session_principal, a.statement_text
FROM 'sys.acl_permissions' p
JOIN 'sys.acl_audit_log' a ON p.change_id = a.change_id
WHERE p.name = 'risk_analyst'
ORDER BY a.change_id DESC;
Do not assume this join is one-to-one: a change_id can be reused in the
recovery case described below, so a permission row
can match more than one audit entry.
Permission compaction
preserves change_id when archiving rows to sys.acl_permissions_history.
Query that table instead for archived grants and revokes. Rows written before
the column was added keep a NULL ID and do not join to the log. Audit
retention can also remove the corresponding log row.
Delivery and failure behavior
Before an ACL statement writes its first change, QuestDB commits its audit row to the log's WAL. If that commit fails, the ACL change does not proceed. The row becomes queryable after WAL apply, so it may not appear immediately. An audit row is not proof that the whole statement succeeded: a later write can fail after the audit row was committed.
Two post-DDL operations are best-effort exceptions: grant cleanup after a drop
or rename, and the owner grant on a newly added column. The DDL has already
committed, so these permission changes still proceed if their audit row cannot
be written. The server logs the failure, and the affected rows have no
change_id.
With
cairo.wal.apply.suspended.write.denied=true,
listing the directory name of sys.acl_audit_log in
cairo.wal.apply.suspended.tables
blocks ACL statements. It also blocks creating objects for owners other than the
built-in admin, including tables created by ingestion, because these operations
must grant permissions to the owner.
To restore those operations, remove the audit log's directory-name entry from
cairo.wal.apply.suspended.tables, then
reload the configuration
or restart the server. The entry includes the table-ID suffix, for example
sys.acl_audit_log~42; the SQL table name alone does not match. Use the existing
configured entry rather than looking it up in tables(), which does not list
this system table.
Change IDs and recovery
While a node remains primary, newly allocated change IDs increase even when the
system clock moves backward. Audit, ACL, and credential timestamps can therefore
be ahead of the wall clock until it catches up. Do not assume that an ID is an
exact wall-clock event time, or that a query bounded by $now includes every
recent change after a clock correction.
After a restart or promotion, QuestDB derives the next ID from timestamps in
applied audit, ACL, and credential rows. A statement that committed its audit
row but failed before writing any ACL or credential row can be missed if its
audit row is still unapplied during recovery. If the clock is at or below that
ID, a later change can reuse it. Do not use change_id alone as a globally
unique event identifier, such as a unique key in an external archive.
Protection and administration
The log is system-managed. With access control enabled, no principal, including the built-in admin, can insert or update its rows through SQL or authenticated ingestion.
Only the built-in admin, without an assumed service account, can truncate the
log, drop partitions, set TTL, or change its storage policy. Ordinary table
grants, ALL, DATABASE ADMIN, and SYSTEM ADMIN do not authorize these
operations. These capabilities mean the log is not an immutable archive.
The following are refused even to the built-in admin:
- Creating an object under the reserved name
sys.acl_audit_log. Only the server creates the log. - Dropping or renaming the log, or renaming another table to its name.
- Structural changes to its columns or deduplication settings.
SUSPEND WALandRESUME WAL FROM TXN. Skipping transactions could discard audit rows for changes that already took effect.REBASE WALis also unavailable because it requires a manually suspended table.
A failed WAL apply can still suspend the table. Plain
RESUME WAL applies waiting rows
without skipping them and remains available with the RESUME WAL permission.
SYSTEM ADMIN and DATABASE ADMIN holders can inspect the log's WAL state
through WAL diagnostics even without permission to read its rows.
Keep both acl.enabled and
line.tcp.acl.enabled enabled,
and restrict network access to ingestion endpoints. Unauthenticated ILP over TCP
does not enforce the system-managed table write protections.
Retention
The default policy, TO PARQUET 1d, never deletes rows. It makes inactive daily
partitions eligible for Parquet conversion when the reference time is at least
one day after the partition's start, not its end. By default, the reference time
is the earlier of the latest audit timestamp and the wall clock. Conversion runs
asynchronously.
DROP LOCAL uses the same
partition-start rule, not each
row's age. With daily partitions, DROP LOCAL 365d can delete a row almost a day
before it is 365 days old. For a minimum 365-day timestamp-based retention
window, use DROP LOCAL 366d to include a one-day partition allowance. The
window is based on change_id, not elapsed time since the change was recorded;
see Change IDs and recovery. A quiet log may retain
partitions longer while its latest timestamp does not advance.
Set acl.audit.storage.policy on
every node before the log is first created:
acl.audit.storage.policy=TO PARQUET 1d, DROP LOCAL 366d
The setting applies only at table creation. To apply the same retention window to an existing log, run this as the built-in admin on the primary:
ALTER TABLE 'sys.acl_audit_log'
SET STORAGE POLICY(TO PARQUET 1d, DROP LOCAL 366d);
Only TO PARQUET and DROP LOCAL stages are supported. TO REMOTE and
DROP REMOTE are rejected, even though the log replicates. Without remote
storage, DROP LOCAL deletes the affected partitions, so keep backups or export
the log before retention removes evidence you need.
An empty setting creates the table without a policy. A rejected policy is logged and the table is created without it. Verify the policy after the first startup and after a replica's first promotion:
SHOW CREATE TABLE 'sys.acl_audit_log';
A non-zero TTL blocks setting a storage policy. Clear it first with
ALTER TABLE 'sys.acl_audit_log' SET TTL 0 HOURS.
If the built-in admin is disabled, nobody can change the policy or manually remove rows. Configure retention before disabling it. An existing policy keeps running without an enabled admin; changing it later requires temporarily enabling the built-in admin and restarting the server.
Replication, upgrades, and restores
Unlike sys.view_audit, both the definition and rows of sys.acl_audit_log
replicate. The primary writes the log, and
replicas receive it along with its storage policy. A replica promoted before
receiving the log creates its own, using its local acl.audit.storage.policy
setting. An ACL statement that finds the log missing during promotion creates it
with the same policy.
When upgrading from a release before QuestDB Enterprise 4.2.0, the server
creates the log and adds change_id to existing ACL and credential tables.
It does not reconstruct past changes. Tables with suspended WAL apply get the
column at a later startup after they are resumed. Until the permissions history
table has the column, compaction archives rows without their change IDs.
A backup taken on a release before QuestDB Enterprise 4.2.0 is handled
differently: startup neither creates the missing log nor adds change_id to
restored tables. This allows continued restore updates from an older source. See
Restoring access control tables
for when the log and columns appear, and what to do after the final restore.
Before upgrading or rolling back, review the
ACL audit log upgrade considerations.
Releases before 4.2.0 do not enforce the log's protections and cannot compact
the permissions table after it gains change_id.