Security Testing, AI & ComplianceAudit Log & Log Forwarding
On this page0

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

There is no delete button, and a request to delete entries is refused. Old entries leave only through the retention job, and only after they are older than the period you set.

You Can Prove It Is Intact

The verify button on System Logs re-checks the chain and reports whether the trail has been tampered with. It is evidence of tampering, not a claim that tampering is impossible.

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.

MethodPathPurposeAuth
GET/api/v1/user-management/logsOne 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/exportThe whole filtered window as a CSV or JSON file, capped at 50,000 entries.view_logs
GET/api/v1/user-management/logs/verifyRe-check the hash chain and return a verdict.view_logs
DELETE/api/v1/user-management/logsAlways 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) and correlationId. On screen these are Severity, User, Actions, Exclude, Time Range and Search.
  • Page size. A list request returns at most 500 entries however large limit is. The response echoes the limit and page it used, with total, counts and retentionDays.
  • Export cap. An export holds the newest 50,000 matching entries at most. Two response headers say what you got: X-Export-Row-Count and X-Export-Truncated (1 when entries were left out). Narrow the time range and export again to get the rest. Every export is itself logged as AUDIT_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.

  1. prevHash
  2. id, as a decimal number
  3. createdAt in UTC, written YYYY-MM-DDTHH:MM:SS.ffffff: six digits of fraction, no zone letter
  4. level
  5. action
  6. details
  7. userEmail
  8. payload, as PostgreSQL prints a jsonb value 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 chainHash must equal the hash recomputed from its own eight values. A failure is reported as brokenType: "content".
  • Linkage. In chainSeq order, each entry’s prevHash must equal the chainHash of the entry before it. A failure is brokenType: "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), firstBrokenId and the anchor with 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 createdAt to 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. And payload must be in PostgreSQL’s own jsonb text form: keys ordered by length and then by byte value, "key": value with 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, seq and chainHash, but not prevHash, and its details and payload are scrubbed copies. A SIEM can therefore spot a missing seq and can later compare a chainHash it 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 prevHash that 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 warn is stored as warning, and code that writes critical is stored as error; 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

FieldMeaning
schemaAlways "zonitix.audit.v1".
idThe audit-log row id. Use it to remove duplicates.
seqThe entry’s position in the hash chain (null for an entry written before chaining).
timestampWhen the entry was written, ISO 8601 in UTC.
actionThe event name, for example LOGIN_FAILURE. The names are in the Audit Action Catalogue above.
leveldebug, 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).
actorThe email of the person who did it, or "system".
sourceServiceThe part of Zonitix that wrote the entry, when recorded.
ip, userAgentThe client address and browser, when recorded.
correlationIdTies together the entries written by one request, when recorded.
hostYour instance’s host name.
detailsThe human-readable line, with secrets scrubbed.
payloadThe structured detail, with secret-looking keys redacted.
chainHashThe 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, any 5xx and 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 as LOG_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).

Related reference