Audit Log & Tamper Verification¶
TDB writes a tamper-evident, hash-chained audit log for every query, every refused attempt and every query that fails. Every request — REST or MCP — produces one entry. The log is the primary compliance artifact for SOC 2, HIPAA, and EU AI Act readiness.
Log format¶
The log is NDJSON (newline-delimited JSON), one JSON object per line. Default path: tdb_audit.jsonl. Override with TDB_LOG_FILE.
There are two entry types, distinguished by event: query for a query that ran, and denied for an attempt that was refused. Both are signed into the same chain. Enterprise also records events that are neither, such as sign-ins and, from 0.32.0, query approvals: approval_requested and approval_decided.
event: "query" — a query that executed:
{
"event": "query",
"ts": "2026-05-22T09:01:23.456789+00:00",
"source_id": "f47ac10b-...",
"sql": "SELECT COUNT(*) FROM orders",
"rows_returned": 1,
"key_hint": "tdbk_a...",
"seq": 42,
"prev_hash": "a3f1...",
"hash": "9c82..."
}
| Field | Description |
|---|---|
event |
"query" for an executed query, "denied" for a refused attempt; "query_started" precedes each statement on the wire gateway (enterprise, from 0.14.0) and each stream (from 0.31.0) |
ts |
ISO 8601 timestamp (UTC) of when the query executed |
source_id |
UUID of the registered source that was queried |
sql |
The SQL statement executed (or <view:name> for named views). With TDB_AUDIT_SQL=redacted, the statement with its values replaced by ? |
sql_redacted, sql_sha256 |
From community 0.16.0 / enterprise 0.27.0, with TDB_AUDIT_SQL=redacted only: true, and the SHA-256 of the redacted text. Runs of one statement with different values share it |
rows_returned |
Number of rows included in the response (query entries only) |
key_hint |
First 6 characters of the key that made the request, then .... The raw key is never written to the log |
user, user_id, user_inactive |
From 0.24.0, entries made with a JWT: user is the person (the full value, so two people cannot look alike). SSO entries add user_id, and user_inactive when a removed person is refused. API key entries do not have these |
seq |
Monotonically increasing sequence number, starting from 1 |
prev_hash |
SHA-256 hash of the previous entry (genesis entry uses 000...0) |
hash |
SHA-256 hash of this entry (computed over all fields except hash itself) |
mcp_client, mcp_protocol |
MCP entries only (from community 0.12.0 / enterprise 0.18.0): the client's self-reported name, name/version from its clientInfo or else its User-Agent, at most 200 characters; and the protocol version it was served at. See the MCP audit notes |
lean |
MCP entries served in Lean Mode only (from community 0.13.0 / enterprise 0.21.0): the settings applied, e.g. {"format": "table"} |
policy_sha256 |
Enterprise, from 0.22.0, while a policy file is loaded: on every query and denial entry, the SHA-256 of the file in force |
approval_id |
Enterprise, from 0.32.0: a query run under a query approval, and a refusal that waits on or was denied by one |
policy_rule, policy_masked, policy_withheld, policy_row_filtered |
A query run under a policy rule: the rule's name, {table: [masked columns]}, {table: [withheld columns]} and the tables a row filter applied to. Present (possibly empty) on every query under a rule; a denial under a rule carries policy_rule |
page, cursor_id, rows_so_far, result_capped |
Enterprise, from 0.31.0, a page: its number, an ID linking the chain's pages (not the cursor, which is never logged), the rows sent across the chain, and true on the page that reached TDB_MAX_RESULT_ROWS |
stream_id, stream_format, stream_outcome |
Enterprise, from 0.31.0, a stream: query_started is written before it runs, then query with rows_returned and how it ended. stream_id is also the X-TDB-Stream-Id response header. A query_started with no query means the process died during the stream |
params_count |
Wire gateway entries with TDB_AUDIT_SQL=redacted: how many bound values the statement had, in place of params |
transport, client_user, statement_kind, params, outcome, sqlstate |
Wire gateway entries only — see the gateway's audit trail |
Keeping values out of the log¶
From community 0.16.0 / enterprise 0.27.0
By default the log stores each statement exactly as submitted. That makes it good evidence, and it
also means a value written into the SQL (WHERE email = 'ann@example.com') is stored in the log, so
the log needs the same protection as the data. Set TDB_AUDIT_SQL=redacted to keep those values out:
{
"event": "query",
"sql": "SELECT name FROM customers WHERE email = ?",
"sql_redacted": true,
"sql_sha256": "1a8f6d9d...",
"rows_returned": 1
}
- What is hidden: string literals in every form the supported engines accept, numbers and comments. A string that is never closed is hidden to the end of the statement.
- What stays: keywords, table names and column names, which can themselves be sensitive.
- Application log: engine errors in the application log are scrubbed the same way, and tracebacks are withheld.
- Wire gateway (enterprise): bound values are not stored, only
params_count. - Not rewritten: entries already in the log, and the query response, which still repeats the SQL to the caller who sent it.
The cost: an investigation can see what ran, but not with which values, so choose it deliberately
if you rely on the log to reconstruct exactly what someone asked. A value other than full or
redacted stops TDB at startup, so a typo cannot silently leave values in the log. Entries from
before and after a change of mode sit in one log, and in enterprise the hash chain verifies across it.
From the same releases, a new audit file is created with mode 0600, readable only by the user
TDB runs as. An existing file keeps its mode, so check it with ls -l. A log shipper that runs as
another user needs access granted to a new file, for example by running it as the same user.
Denied attempts¶
A trail that records only what succeeded cannot answer the question an auditor actually asks: who tried what, and was turned away? TDB records refusals as first-class audit events, signed into the same chain, so a deleted denial breaks verification exactly as a deleted query does.
A query that was accepted and then failed is recorded the same way, under a reason
saying why. From community 0.17.0 / enterprise 0.28.0 that covers every failure; before
them, only a timeout or a busy source on REST. Agents often send a query that fails, so an
alert on event: "denied" should filter on reason.
event: "denied" — an attempt that was refused:
{
"event": "denied",
"action": "query",
"reason": "sql_validation_failed",
"ts": "2026-05-22T09:01:24.881204+00:00",
"source_id": "f47ac10b-...",
"sql": "DROP TABLE orders",
"key_hint": "tdbk_a...",
"seq": 43,
"prev_hash": "9c82...",
"hash": "4d10..."
}
Two fields describe the refusal:
| Field | Description |
|---|---|
action |
What was attempted: auth, authorize, query, register, ratelimit, mcp_auth, mcp_query, mcp_call, schema (enterprise from 0.12.0, community from 0.18.0), and — enterprise — view_list or view_run |
reason |
Machine-readable cause — see the table below |
reason |
Raised when |
|---|---|
missing_credentials |
No Authorization header was sent |
invalid_api_key |
The key is not recognised |
invalid_or_expired_token |
A JWT failed signature or expiry validation |
insufficient_role_required_<role>_have_<role> |
RBAC refused the request (403) |
rate_limit_exceeded |
The key's per-minute budget was exhausted (429) |
limit_exceeds_max |
A request's limit was above the deployment's TDB_MAX_ROWS (enterprise): POST /v1/query from 0.2.0; MCP tools and view runs from 0.16.0 |
sql_validation_failed |
The statement was not a permitted read-only SELECT — including, from community 0.7.2 / enterprise 0.12.0, SQL that some supported engine would read as more than one statement |
prompt_injection_detected |
The prompt-injection filter rejected the input |
source_not_found |
The referenced source does not exist |
path_outside_allowed_dir |
A CSV path resolved outside TDB_ALLOWED_DATA_DIR |
sql_file_access |
The SQL itself named a file other than the source's own (from community 0.15.0 / enterprise 0.26.0; outside its data directory from 0.7.0 / 0.11.0) |
file_unreadable |
The CSV backing a source is missing or unreadable |
unsupported_source_type |
The source_type is not a registered connector |
registry_conflict |
The source name is already taken |
invalid_origin |
An MCP request came from a browser page on another origin (403; from community 0.10.0 / enterprise 0.15.0). See MCP: browser origins |
tool_not_permitted_<tool> |
The key's allowed_tools scope excludes this MCP tool — or, from enterprise 0.12.0, the REST route equivalent to it (see RBAC) |
wire_requires_read_role |
Wire gateway: the key or token is not read-role, or is a static admin key |
row_cap_exceeded |
Wire gateway: a result exceeded TDB_PG_MAX_ROWS; the connection was closed |
statement_after_transaction_end |
Wire gateway: a statement followed COMMIT/ROLLBACK in the same batch |
privileged_db_role |
Enterprise, from 0.12.0: the source connects as a database role that can reach the database server's files or programs (see connectors) |
missing_api_key |
An MCP request carried no bearer token |
invalid_connection |
Registration: the connection block is not valid for the source_type |
query_failed |
The engine rejected the query, for example a column that does not exist (from community 0.17.0 / enterprise 0.28.0). The engine's error text is in the application log only, because it can quote data |
source_unavailable |
The source's file was missing or unreadable when the query ran (from community 0.17.0 / enterprise 0.28.0) |
internal_error |
The query failed for any other reason, including a driver error from a source that cannot be reached; see the application log (from community 0.17.0 / enterprise 0.28.0) |
invalid_arguments |
An enterprise MCP tool or a view run refused its arguments: a column or function it does not know, or a view parameter that does not fit (from 0.28.0) |
view_not_found |
A view run named a view that does not exist (enterprise, from 0.28.0) |
query_timeout |
The query ran past TDB_QUERY_TIMEOUT and was cancelled at the source (enterprise) |
source_busy |
Another process had the DuckDB file open for writing (enterprise, from 0.23.0). See DuckDB |
approval_pending, approval_denied, approval_expired, approval_void, approval_unresolved, approval_requires_identity, approval_limit, approval_not_found, approval_not_approver, approval_self |
A query approval held, refused or could not be decided (enterprise, Mid-Market, from 0.32.0). See Errors & audit reasons |
insufficient_role_required_<role>_have_<role> |
The key's role is below what the endpoint needs (enterprise) |
tool_not_permitted_<tool> |
The key's allowed_tools does not include the tool (enterprise) |
What the caller sees for each reason, and what to do: Errors & audit reasons.
To review refusals only:
To find keys probing for access:
Community edition
tdb-community records the same denied events with the same action and
reason fields. The hash chain (seq, prev_hash, hash) and
GET /v1/audit/verify are enterprise features — community entries are
unsigned NDJSON.
Tenants¶
With tenants on (Enterprise, from 0.33.0), each tenant has its own log in its
directory, verified on its own, and every entry carries "tenant". What belongs to no tenant
(refusals before a tenant is known, the operator's actions, each start's tenant list) is in
the deployment's own chain, TDB_TENANTS_DIR/.deployment/audit.jsonl. The operator verifies
or exports any of them with ?tenant=<name> or ?tenant=_deployment.
Hash chain¶
Each entry's hash is a SHA-256 digest of the entry's canonical JSON (all fields sorted, no whitespace, hash field excluded). The next entry includes the previous hash as prev_hash.
This creates a chain: to forge or delete any entry, an attacker would need to recompute every subsequent hash — and TDB detects the break during verification.
Entry 1: seq=1, prev_hash=000...0, hash=H1
Entry 2: seq=2, prev_hash=H1, hash=H2
Entry 3: seq=3, prev_hash=H2, hash=H3
Deleting entry 2 would make entry 3's prev_hash point to a non-existent hash. Adding a forged entry would produce the wrong hash value. Both are detected by GET /v1/audit/verify.
Verifying the log¶
Requires admin role. Reads the entire log file and walks the hash chain.
Response — valid log:
Response — tampered log:
Run this check as part of your daily compliance job, or trigger it after any log rotation.
Exporting to a SIEM¶
Use POST /v1/audit/export?destination=splunk|s3 to ship the log onward. Exports are incremental — each destination resumes where it left off, so a scheduled export does not re-send history. GET /v1/audit/export/status shows how far each has consumed.
Synchronous or backgrounded¶
By default the request waits for the export to finish and returns what it
shipped, which is what a cron job checking errors needs:
A first export of a large backlog can take a while. Pass wait=false to get
202 straight away and let it run in the background:
curl -fsS -X POST "http://localhost:8000/v1/audit/export?destination=s3&wait=false" \
-H "Authorization: Bearer $ADMIN_KEY"
{"accepted": true, "destination": "s3", "detail": "Export started. Read the outcome from GET /v1/audit/export/status."}
Nothing is waiting on that run, so its outcome is recorded instead of returned.
GET /v1/audit/export/status reports the last run for every destination —
including synchronous ones, so the field means the same thing either way:
{
"destinations": [
{
"name": "s3",
"configured": true,
"last_exported_seq": 4210,
"last_run": {
"state": "succeeded",
"started_at": "2026-08-12T04:10:02+00:00",
"finished_at": "2026-08-12T04:10:39+00:00",
"exported": 120,
"skipped": 0,
"errors": []
}
}
]
}
state is running, succeeded or failed; last_run is null for a
destination that has never exported.
One run per destination at a time
A second export to a destination that is already running returns 409. Both runs would read the same cursor and ship the same entries, so overlapping them duplicates events in your SIEM.
This matters most with wait=false: a cron firing every five minutes against
an export that takes ten will collide. Either widen the interval past the
slowest run, or treat 409 as "the previous run is still going" and skip.
A run whose process is killed is treated as abandoned after one hour, so a crash cannot block a destination permanently.
Configuration¶
| Variable | Default | Description |
|---|---|---|
TDB_LOG_FILE |
tdb_audit.jsonl |
Path to the NDJSON audit log |
TDB_LOG_LEVEL |
INFO |
Log verbosity (DEBUG, INFO, WARNING, ERROR) |
TDB_AUDIT_SQL |
full |
redacted keeps statement values out of the logs |
TDB_AUDIT_ROTATE_MAX_BYTES |
0 (off) |
Seal the log into a segment once it exceeds this size |
TDB_AUDIT_ROTATE_MAX_DAYS |
0 (off) |
Seal the log once its oldest entry is older than this |
Retention: sealed segments¶
Enterprise, from 0.4.0
Before 0.4.0 the log grew without bound and rotation was entirely manual.
TDB never deletes an audit entry. Retention here decides when the live log is sealed, not when anything is dropped.
When a threshold is crossed, TDB closes the live log with a final rotated entry,
renames it to a timestamped segment, and starts a fresh log:
tdb_audit.jsonl ← live chain
tdb_audit-20260801T031500Z-seq48210.jsonl ← sealed segment
tdb_audit-20260715T014500Z-seq22119.jsonl ← sealed segment
Each segment keeps its chain intact and verifies on its own. The new live log opens
with a chain_anchor entry naming the segment it follows and that segment's final
hash, so the segments form a verifiable sequence rather than disconnected files.
Rotation is evaluated when an entry is written and once at startup. Set either
variable, both, or neither; whichever threshold is crossed first seals the log.
You can also seal on demand with POST /v1/audit/rotate (admin).
Why you should enable this
Not for speed — that argument expired in 0.8.0. Every audit write needs the previous entry's hash. 0.4.0 made that independent of history for a running server, and 0.8.0 made the first write after a restart independent of it too, by reading the chain head backwards from the end of the file (measured at 500,000 entries: 4.3 s before, under a millisecond after, and flat as the log grows). A large live log no longer slows startup or the first request.
Enable rotation for the reasons that remain: a multi-gigabyte single file is awkward to back up, copy and retain, and sealed segments are what let you prune old history while keeping every remaining segment independently verifiable. Size the live log to whatever your backup story wants.
Verifying across segments¶
GET /v1/audit/verify proves the live log has not been altered. It cannot prove
a whole segment was not removed, because every segment legitimately restarts its
chain from genesis.
GET /v1/audit/history walks the sealed segments, the live log, and the anchors
linking them:
{ "valid": true, "segments": 4, "entries": 128340,
"segment_files": ["tdb_audit-20260715T014500Z-seq22119.jsonl", "..."] }
Removing a segment from the middle of the sequence is reported as invalid. Pruning the oldest segments is a supported retention action and stays valid — that is how you actually delete old audit data.
Community edition: rotate with logrotate¶
tdb-community has no built-in rotation; its log grows until you rotate it. It
opens the file for every entry, so an ordinary logrotate rule that renames
the file is safe: the next entry starts a new file and nothing is lost.
Two rules:
- Mount the directory, not the file. Run with
-e TDB_LOG_FILE=/logs/tdb_audit.jsonl -v /srv/tdb/logs:/logs. If you bind-mount the file itself, the container keeps writing to the renamed file and the new one stays empty. - Do not use
copytruncate. Entries written between the copy and the truncate are lost.
Backup¶
- Back up sealed segments to immutable object storage (S3, GCS). They never change again, which makes them well suited to write-once buckets.
- Run
GET /v1/audit/historybefore pruning anything, to confirm the sequence is intact first.
Do not edit the log file
Any modification — including deleting lines or reordering entries — will break the hash chain and fail verification. Treat the log as write-once. Use POST /v1/audit/rotate rather than truncating the file yourself: rotation is recorded in the chain, hand-truncation is indistinguishable from tampering.
Legacy entries¶
Entries written by TDB versions before hash-chaining was introduced do not contain seq or hash fields. The verifier skips these entries and starts chain validation from the first signed entry it encounters. Legacy and signed entries can coexist in the same file.