On this page0
Unfamiliar word? See Glossary: words used in these docs. Looking for the tasks in order? Begin at Start Here.
Reference
Included in: All plans. Which Plan Includes What
Audit Log & Log Forwarding
Every significant action in your instance is written to one audit log: sign-ins, permission changes, approvals, exports, scans and support sessions. Entries can only be added, never edited or deleted by a person, and each one is chained to the one before it so tampering can be detected.
What It Does
You read the log in System → System Logs. There you can filter it, ask for entries in plain English, export what you see for an auditor, and run a check that the log has not been altered. The list re-reads itself every 30 seconds and has a refresh button (the circular-arrow icon; hovering over it shows Refresh Logs) for a fresh reading straight away; it is not a live stream. You decide how long entries are kept under Security Settings → Audit Log Retention, and you can send every entry to your own monitoring system (a SIEM) under Security Settings → Log forwarding (SIEM).
Nobody Can Delete An Entry
You Can Prove It Is Intact
If something here is not working as you expect, start with When something goes wrong.
The plain explanation is above. This part is for IT and developers: the exact settings, permission names, API addresses and data fields behind it. Closed: select the heading above to open it. While it is closed, your browser’s find-on-page (Ctrl+F or Cmd+F) does not search this part.
Reading The Log
The log is served by the logging service through the gateway. Reading, exporting and verifying need view_logs. Request and response shapes for these routes are in the API Reference; the limits below are what that table does not state.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /api/v1/user-management/logs | One page of entries, newest first, plus severity counts for the whole filtered window. ?limit= defaults to 25 and is capped at 500; ?page= starts at 0. | view_logs |
| GET | /api/v1/user-management/logs/export | The whole filtered window as a CSV or JSON file, capped at 50,000 entries. | view_logs |
| GET | /api/v1/user-management/logs/verify | Re-check the hash chain and return a verdict. | view_logs |
| DELETE | /api/v1/user-management/logs | Always refused with 403, and the attempt is logged as AUDIT_LOG_DELETE_BLOCKED. | manage_logs |
- Filters. The list and the export take the same filters:
level,userEmail,includeActions,excludeActions,daysAgo,q(text in the details) andcorrelationId. On screen these are Severity, User, Actions, Exclude, Time Range and Search. - Page size. A list request returns at most 500 entries however large
limitis. The response echoes thelimitandpageit used, withtotal,countsandretentionDays. - Export cap. An export holds the newest 50,000 matching entries at most. Two response headers say what you got:
X-Export-Row-CountandX-Export-Truncated(1when entries were left out). Narrow the time range and export again to get the rest. Every export is itself logged asAUDIT_LOG_EXPORTED, at warning level, with the filters used, the row count and whether it was cut off. - Plain-English search. The Ask in plain English box sends the question to the local AI model (
POST /api/v1/ai/parse-log-query,view_logs), which returns filters. The filters are then applied by the normal list request, so the model never reads the log.
Retention
The retention period is a setting, Security Settings → Audit Log Retention, between 90 and 3,650 days (ten years); it is 90 days until someone chooses. A value outside that range is refused, so retention can never be set below 90 days. The scheduled retention job is the only thing that removes entries; the DELETE route above exists only to refuse and record the attempt.
The Tamper-Evident Hash Chain
Every entry stores three chain values: chainSeq (its position), prevHash (the hash of the entry before it; empty only for the very first) and chainHash (its own hash). They are set by a database trigger when the row is inserted, not by application code. The trigger takes a lock first, so entries written at the same moment by different services are still linked one after another.
How One Hash Is Built
chainHash is the SHA-256, written as 64 lower-case hex characters, of eight values joined with the character |, in this order. A value that is missing counts as an empty string.
prevHashid, as a decimal numbercreatedAtin UTC, writtenYYYY-MM-DDTHH:MM:SS.ffffff: six digits of fraction, no zone letterlevelactiondetailsuserEmailpayload, as PostgreSQL prints ajsonbvalue as text
-- The definition, as it exists in the database (PostgreSQL, pgcrypto):
encode(digest(
coalesce("prevHash",'') || '|' || id || '|' ||
to_char("createdAt" AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US') || '|' ||
coalesce(level,'') || '|' || coalesce(action,'') || '|' || coalesce(details,'') || '|' ||
coalesce("userEmail",'') || '|' || coalesce(payload::text,''),
'sha256'), 'hex')
What The Verify Route Checks
- Content. Each entry’s stored
chainHashmust equal the hash recomputed from its own eight values. A failure is reported asbrokenType: "content". - Linkage. In
chainSeqorder, each entry’sprevHashmust equal thechainHashof the entry before it. A failure isbrokenType: "linkage"; it is what a removed or reordered entry causes. - Anchor. The entry named by the latest anchor must still exist with the same hash. A failure is
brokenType: "anchor". - The answer also carries
ok,chained,unchained(entries written before chaining existed, which are counted and not treated as a break),firstBrokenIdand theanchorwith a note saying whether an anchor file is confirmed to have left the server.
The Anchor
Once a day, at 02:30 by the logging service’s clock, the id and chainHash of the newest entry and the number of chained entries are recorded as an anchor. The same values are written to a small JSON file (anchorId, headId, headHash, rowCount, at) which is uploaded to your backup bucket under audit-anchors/, inside the prefix you set, when a backup destination is configured. It is encrypted like other backup objects when the backup key is set; open it with the decryptor under Recovery Tools. Each run writes AUDIT_CHAIN_ANCHORED to the log, as a warning while no anchor file has left the server. An anchor that exists only in the database protects against less: someone able to rewrite the whole chain in the database could rewrite the anchor row too. The copy in your bucket is the one they cannot reach.
Checking It Yourself
The JSON export (GET /api/v1/user-management/logs/export?format=json) returns every column of each entry, including chainSeq, prevHash and chainHash. The CSV export does not carry the chain columns. With a JSON export you can check the linkage, and compare an anchor file from your bucket against it, without trusting the instance. The export covers the last 30 days unless you pass daysAgo, and at most the newest 50,000 entries; the body’s truncated flag says when it was cut. To compare an older anchor, pass a daysAgo that reaches back to the anchor’s date, otherwise the entry it names is simply not in the export.
// verify-chain.js: run with node verify-chain.js audit_logs.json [audit_anchor.json]
const fs = require('fs');
const rows = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')).logs
.filter((r) => r.chainSeq != null)
.sort((a, b) => (BigInt(a.chainSeq) < BigInt(b.chainSeq) ? -1 : 1));
let bad = 0;
for (let i = 1; i < rows.length; i++) {
const prev = rows[i - 1], cur = rows[i];
// only compare neighbours: a filtered export has gaps in chainSeq
if (BigInt(cur.chainSeq) !== BigInt(prev.chainSeq) + 1n) continue;
if (cur.prevHash !== prev.chainHash) { bad++; console.log('BROKEN LINK at id', cur.id); }
}
console.log(rows.length, 'chained rows,', bad, 'broken links');
if (process.argv[3]) {
const a = JSON.parse(fs.readFileSync(process.argv[3], 'utf8'));
const head = rows.find((r) => r.id === a.headId);
console.log('anchor', a.at, head ? (head.chainHash === a.headHash ? 'MATCHES' : 'DOES NOT MATCH') : 'row not in this export');
}
- What this proves. No entry between two neighbours in the export was removed or reordered, and, with an anchor file, that the chain still passes through the entry your bucket recorded on that day.
- Recomputing a hash outside the database needs two things an export does not give exactly. The export prints
createdAtto the millisecond, while the hash uses six digits; entries written by the application have three trailing zeros, entries written by host maintenance scripts may not. Andpayloadmust be in PostgreSQL’s ownjsonbtext form: keys ordered by length and then by byte value,"key": valuewith one space after each colon and comma, numbers as the database stored them. The verify route recomputes every hash inside the database with the stored values, so use it for the content check. - What a forwarded event carries.
id,seqandchainHash, but notprevHash, and itsdetailsandpayloadare scrubbed copies. A SIEM can therefore spot a missingseqand can later compare achainHashit holds with the one in an export, but it cannot recompute a hash. - Retention. When old entries are removed by the retention job, the oldest entry left keeps a
prevHashthat points at an entry no longer present. The linkage check starts from the oldest entry present, so this is not reported as a break.
Audit Action Catalogue
The action field of an entry is a fixed name such as LOGIN_FAILURE. This is the list to write SIEM rules from: 888 action names in 14 groups, one group per part of the product that writes them, each with the level or levels it is written at (897 rows in all, as a few names are written by two parts and appear in both groups). It is generated from app commit 99e5035a2 by reading the 1,058 places the code writes an entry, and is not edited by hand.
- Level received. The level shown is the one that is stored, and so the one forwarded. Code that writes
warnis stored aswarning, and code that writescriticalis stored aserror; the list marks the second kind as “written as critical”, because those are the events the code itself ranks highest. More than one level on a row means the action is written at each of them, depending on the outcome, and “chosen at run time” means the code does not fix it. - Logs filter family. The family the entry falls under in the Actions and Exclude filters on System Logs (Sign-ins (all), Failed sign-ins, MFA, Users, Permissions & roles, Denied / blocked, Elevation (sudo), Workflows, Automations, Sync, Finance, Signatures, Compliance, Audit, Licensing). A family is a set of name fragments, so one action can be in several, and many are in none.
- Names built at run time. 14 names contain a part that is filled in when the entry is written, such as the provider type in
SYNC_<type>_FAILED. They are listed under their group with the variable part in angle brackets; match them by prefix. A further 2 places write a name they are handed at run time, so a name that only they write is not in this list. - Unknown names. The list is what this release writes from fixed names. Write rules that let an unlisted action through to review, not ones that drop it.
Sign-in and identity
87 actionsPasswords, MFA, passkeys, single sign-on, sessions, API tokens and the security settings. Written by identity-service.
API gateway
12 actionsRequests refused before they reach a service (blocked addresses, forged headers and failed request checks), plus AI calls and support sessions passing through the gateway. Written by api-gateway.
People, access and HR
236 actionsUsers, roles, groups, personas, access requests and reviews, SCIM, HR records, time off, reviews, surveys and training. Written by user-management-service.
Directory and provider sync
29 actionsConnected providers, directory actions, sync runs and AlignSync. Written by integration-service.
Workflows and automations
37 actionsWorkflow definitions, the tasks of a run, approvals and automations. Written by workflow-service.
Devices
82 actionsEnrollment, device commands, profiles and their rollouts, agent packages and recovery keys. Written by device-service.
Compliance, reports, backups and updates
177 actionsControls, evidence, policies, audits, reports and share links, backups and restores, key escrow, updates and data exports. Written by compliance-service.
Security Lab
46 actionsScopes, scans, findings, exceptions and remediation. Written by security-lab-service.
Recruitment
65 actionsRequisitions, candidates, interviews, offers and the careers site. Written by recruitment-service.
Zonitix Sign
13 actionsSigning requests, templates and signed documents. Written by signature-service.
Finance
78 actionsInvoices, budgets, purchase orders, vendors and period close. Written by the finance service as FINANCE_ plus its own action name. Written by document-parser-service.
Audit log and log forwarding
18 actionsExports, retention, the hash-chain anchor, forwarding destinations and the events the web app records. Written by logging-service.
Shared checks
12 actionsCode every service loads: permission checks, step-up authentication, key-escrow health, evidence retention and device reservations. The entry is written by whichever service ran the check. Written by shared code.
Scheduled host jobs
5 actionsJobs that run on the server itself on a timer and insert their result directly. Written by host scripts.
Log Forwarding
Forwarding streams each audit entry to up to 10 destinations. Managing destinations needs the administrator role, and the routes are listed under Log forwarding in the API Reference. Every destination receives the same event; only the envelope differs. The action names an event can carry are listed in the Audit Action Catalogue above.
The Event
| Field | Meaning |
|---|---|
| schema | Always "zonitix.audit.v1". |
| id | The audit-log row id. Use it to remove duplicates. |
| seq | The entry’s position in the hash chain (null for an entry written before chaining). |
| timestamp | When the entry was written, ISO 8601 in UTC. |
| action | The event name, for example LOGIN_FAILURE. The names are in the Audit Action Catalogue above. |
| level | debug, info, warning, error or critical. An entry stored as warn is sent as warning; an unrecognised level is sent as info. critical is sent only for an entry stored with that level: an entry the code writes as critical through the shared audit writer is stored, and so sent, as error (see the catalogue above). |
| actor | The email of the person who did it, or "system". |
| sourceService | The part of Zonitix that wrote the entry, when recorded. |
| ip, userAgent | The client address and browser, when recorded. |
| correlationId | Ties together the entries written by one request, when recorded. |
| host | Your instance’s host name. |
| details | The human-readable line, with secrets scrubbed. |
| payload | The structured detail, with secret-looking keys redacted. |
| chainHash | The entry’s hash in the tamper-evident chain. How it is built is under The Tamper-Evident Hash Chain above. |
One event is at most 64 KB. A larger one is not cut in the middle: its payload is replaced by { omitted: true, reason }, and the full row stays in System Logs and the export. The test event has id: 0, the action LOG_FORWARD_TEST and payload: { test: true }.
The Log Line, Per Destination Type
In the examples below, <event> stands for one event exactly as in the table above, with its keys in that order. The values are examples.
// <event>
{"schema":"zonitix.audit.v1","id":48213,"seq":48190,"timestamp":"2026-03-04T09:15:02.118Z","action":"LOGIN_FAILURE","level":"warning","actor":"dana@example.com","sourceService":"identity-service","ip":"203.0.113.7","userAgent":"Mozilla/5.0 ...","correlationId":"b7e1...","host":"zonitix.example.com","details":"...","payload":{"metadata":{...}},"chainHash":"9f2c..."}
HTTPS webhook. One event per line, each line a complete JSON object, with a line break after the last one. Split the body on line breaks and parse each line on its own. The other headers are X-Zonitix-Signature, X-Zonitix-Timestamp and X-Zonitix-Delivery-Id (a new id per attempt).
POST <your URL>
Content-Type: application/x-ndjson
X-Zonitix-Event-Count: 2
<event>
<event>
Splunk HTTP Event Collector. One HEC object per line. time is the entry’s timestamp in seconds since 1970, with a fraction. source is zonitix: plus the event’s sourceService, or zonitix:audit when the entry has none. sourcetype is _json unless you set one, and index comes last and is present only when you set one. The whole event sits under event, so its fields are event.action, event.level and so on.
POST <your URL> (a URL with no path gets /services/collector/event)
Authorization: Splunk <token>
Content-Type: application/json
{"time":1772615702.118,"host":"zonitix.example.com","source":"zonitix:identity-service","sourcetype":"_json","event":<event>,"index":"<your index>"}
Datadog Logs. One JSON array per request. status is the event’s level, with warning written as warn. service is the event’s sourceService, or zonitix-audit when the entry has none. evt.name is the action and usr.email the actor; network.client.ip is present only when the entry recorded an address. The whole event sits under zonitix, so its fields are zonitix.action, zonitix.payload and so on.
POST https://http-intake.logs.<site>/api/v2/logs
DD-API-KEY: <key>
Content-Type: application/json
[{"ddsource":"zonitix","ddtags":"source:zonitix,level:warning,<your tags>","hostname":"zonitix.example.com","service":"identity-service","status":"warn","date":"2026-03-04T09:15:02.118Z","message":"LOGIN_FAILURE: ...","evt":{"name":"LOGIN_FAILURE"},"usr":{"email":"dana@example.com"},"zonitix":<event>,"network":{"client":{"ip":"203.0.113.7"}}}]
Syslog over TLS. One RFC 5424 message per event, each preceded by its length in bytes and a space (RFC 5425 octet counting), over TLS 1.2 or later; the port is 6514 unless you set another. The number in angle brackets is facility 13 (log audit) times 8 plus the severity: 3 for error, 4 for warning, 6 for info, 7 for debug and 2 for critical, so a warning is <108>. After it come the timestamp, your instance’s host name, the app name (zonitix unless you set one), the event’s sourceService (or - when the entry has none) and the action. The action in that position is cut to 32 characters, and a character outside printable ASCII becomes _, so match on the action inside the JSON, not on the header. The bracketed element carries id, seq, actor, ip, correlationId and chainHash, leaving out any that are empty. The message body is a UTF-8 byte-order mark followed by the whole event as JSON.
<length> <108>1 2026-03-04T09:15:02.118Z zonitix.example.com zonitix identity-service LOGIN_FAILURE [zonitix@32473 id="48213" seq="48190" actor="dana@example.com" ip="203.0.113.7" correlationId="b7e1..." chainHash="9f2c..."] <BOM><event>
The collector’s certificate is always verified for syslog, against the public roots or the CA certificate you pasted. Syslog has no application-level acknowledgement, so “delivered” means written to a verified TLS session.
The Webhook Signature
// Webhook signature. The signed string is the timestamp, a dot, then the raw body.
X-Zonitix-Timestamp: <unix seconds>
X-Zonitix-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>
// Receiver: recompute the HMAC over the raw bytes with your signing secret, compare in
// constant time, and reject a timestamp more than 5 minutes from your clock.
A complete receiver in Node.js and in Python is on Webhooks: Verifying A Signed Delivery From Your Instance. Answer a bad signature with 401, not 400: a 401 is retried, while a 400 is read as the events being refused.
Batching, Retries And Skips
- Batches. Up to 200 events or 1 MB per request, whichever comes first, and up to 25 requests or 20 seconds of work per destination on each pass. Each request times out after 15 seconds.
- Retried without limit. A network or TLS failure,
401,403,404,408,429, any5xxand any redirect (redirects are not followed). The destination keeps its place and waits 5 seconds, then 10, then 20, doubling up to 5 minutes, each wait varied by up to 20 percent. Nothing is dropped while it waits. - Rejected. Any other
4xx(for example 400, 413 or 422) means the receiver refused the content. A batch of several events is halved and retried at once to find the event at fault. A single event rejected 3 times in a row is skipped so the rest can flow: it stays in the audit log, and the skip is recorded asLOG_FORWARD_EVENT_SKIPPED. - At least once. A batch that was received but not acknowledged is sent again, so a receiver can see the same event twice. De-duplicate on
id.
Forwarding Is Itself Audited
Adding, changing or deleting a destination, sending a test and skipping a backlog are logged (LOG_FORWARD_DESTINATION_CREATED, _UPDATED, _DELETED, LOG_FORWARD_TEST_SENT, LOG_FORWARD_CURSOR_RESET). So are a destination that starts failing, one that recovers, and a gap caused by entries ageing out before they could be sent (LOG_FORWARD_FAILING, LOG_FORWARD_RECOVERED, LOG_FORWARD_GAP).