Skip to the content
Documentation
Help Start
Reading in:
  • German: 9,059 of 9,059 pages
About the help in other languages
All help pages

Audit: what happens in Exponential, recorded

Exponential 6.0.15 records what happens in an installation: who signed in and who failed to, who moved, hid or removed content, who changed a role, a setting or a price, which command or cronjob ran, and what the audit…

Download as PDF

Address: https://doc.exponential.earth/exphelp/viewer/bc/6.0/audit.md

On this page

Audit: what happens in Exponential, recorded

Exponential 6.0.15 records what happens in an installation: who signed in and who failed to, who moved, hid or removed content, who changed a role, a setting or a price, which command or cronjob ran, and what the audit itself did. Every record is one line of JSON in a file per channel and day. Each line carries the hash of the line before it, so a changed, removed or inserted line shows. The audit is on in every installation by default. It costs less than a tenth of a millisecond on a page that records nothing.

This guide covers the finished subsystem. It is written for three readers:

  • administrators who read the audit in the admin interface (sections 2 and 3);
  • operators who keep it running: cron, rotation, archives, keys, retention, privacy, troubleshooting (sections 4, 6 and 7);
  • developers who record their own events or extend the audit with sinks, alert rules or archive formats (sections 3.4 and 5).

Every command in this guide was run on a reference installation (Exponential 6.0.15, SQLite, Apache with PHP-FPM, and Velocity) on 2026-10-02/03, and the output shown is the real output. Personal and installation-specific values were replaced by documentation values: addresses by 203.0.113.0/24, the host name by web1, the installation id, key id and fingerprint by example values, and e-mail addresses by @example.com addresses. Actions that change things (archiving, purging, key rotation, import) were run on a sandbox copy of the audit with test records, so the live log stayed as it was. This is said wherever it applies.

In short

What changedNew audit subsystem, on by default (4.x had it off): records go to var/<site>/log/audit/<channel>-<YYYY-MM-DD>.jsonl, with an index in the site's database, the console in the admin, the command exp:audit, alert rules, sinks and archives. New policies audit/read and audit/manage (only the Administrator role holds them).
Who is affectedEvery installation that upgrades. Existing installations need the index tables once. Old eZAudit::writeAudit() calls keep working. The audit's daily work runs from the frequent cronjob group.
How to checkphp bin/php/console exp:audit verify (each channel intact or broken)
How to fixCreate the index tables: php update/common/scripts/6.0/createaudittables.php; make sure the frequent cronjob group runs; give audit/read to the roles that should read the audit. See Quick start and upgrades.

Contents

  1. What the audit offers
  2. Quick start (two minutes)
  3. Usage guide: the admin interface · the command exp:audit · templates · the developer API
  4. Maintenance guide: cron · rotation and archives · retention · verify and restore · keys · the index · importing 4.x logs · upgrades · changing settings · troubleshooting · performance tuning · privacy and GDPR tasks · when the audit cannot write
  5. Internals: event flow · the record · the hash chain · taxonomy and registries · buffering · the index · sinks · alerts · Velocity · failure modes · security
  6. Reference configurations: small site · busy site · cluster · strict privacy · SIEM forwarding · long retention for compliance
  7. Settings reference
  8. Event reference (generated from the taxonomy registry)
  9. Proof: tests, tamper test, performance, permissions

1. What the audit offers

You want to knowThe audit gives you
Who signed in, who failed to, from which networkaccess.session.login, access.session.login.failed (with the reason), access.session.logout, account locks; the address truncated to its /24 network by default
Who removed, moved, hid or restored contentcontent.node.* and content.object.*. A subtree removal is one parent event with one child per node. A content job is the parent of everything its batches do
Who changed roles, policies, users, sections, states, classesaccess.role.*, access.policy.*, access.user.*, content.section.*, content.state.*, content.class.*, with the values before and after
Who changed a setting, cleared a cache, ran a command or a cronjobsystem.setting.write (with the diff; secrets masked), system.cache.clear, system.command.run, system.cronjob.run; the Velocity watchdog only when it starts the engine, backs off or skips (system.velocity.watchdog.*), never for a routine check
Who changed prices, VAT, currencies, discounts or removed an ordercommerce.*; exports and imports of data are data.*
Whether anyone tampered with the logexp:audit verify and the console show, per channel, intact or broken with the file, the line and the kind of change
To be told when something serious happensAlert rules for brute force, admin roles granted, settings written out of hours, mass deletes, the audit switched off, a broken chain. Alerts go to syslog/journald and to mail recipients you choose
To keep evidence for yearsDaily files and compressed archives (gzip, bzip2, xz, zstd, zip). Each archived day has a signed manifest. Retention is set per channel
To get the records outExport as CSV, JSON lines or a signed bundle. Copies go to syslog/journald (local, UDP, TCP, TLS) and to a signed webhook, or to sinks of your own
To answer a data-subject access requestexp:audit export --subject-user=<id> returns every record by or about that user, pseudonymised ones included

What it is made of:

  • Channels and files. Five channels: content, access, system, commerce (with data), and read for sampled reads, which is off by default. Each channel writes one file per UTC day, var/<site>/log/audit/<channel>-<YYYY-MM-DD>.jsonl, and starts a new part past MaxFileSize.
  • 148 event names in a taxonomy (domain.subject.action[.detail]), switchable at any rank (content.*, content.node.remove.*). Extensions add their own branches. See the event reference.
  • A hash chain per channel, daily signed checkpoints and signed archive manifests (HMAC-SHA-256 with a key that is generated on first use and never leaves settings/override).
  • An index in the site's own database (SQLite, MySQL/MariaDB, PostgreSQL, Oracle, MongoDB) for the console's search, filters and charts. The index can be rebuilt from the files at any time.
  • The Audit tab in the admin: a dashboard, a timeline with filters and full-text search, one event in full, charts, alerts, export, archives and settings. There is also a dashboard block, a sidebar link, an Audit tab on each node, and audit links on content jobs. Each of them is shown only to users who hold the policy.
  • The command exp:audit with 19 actions, and a cronjob part that does the daily work.
  • Privacy by default. Addresses are truncated, sessions hashed and user agents shortened. Content values, passwords, tokens and secrets are never recorded. Reads are off. The index pseudonymises personal fields after 90 days.

2. Quick start (two minutes)

The audit is already on. Check it, read it and verify it:

$ ./console exp:audit status --allow-root-user
Audit:        enabled
Directory:    var/site/log/audit
Installation: 6f1c3e0a-2b7d-4c55-9a01-3d2e4f5a6b7c
Signing key:  k1-20261002-3fa94c1b  fingerprint 3FA9 4C1B 77D0 E215
Channel     Files      Bytes    Today  Chain
content         2    1495660      600  intact (1506 records)
access          2    1233762       35  intact (1575 records)
system          2   13957482       64  intact (12641 records)
commerce        1      76442        0  intact (93 records)
read            1     105484        0  intact (133 records)
(exit 0)
$ ./console exp:audit tail --channel=access --name='access.session.*' --lines=3 --allow-root-user
2026-10-03 00:00:44.254  access   access.session.login               admin(14)              user 14 admin                      success  r-01M3ZH09NJPKY38DJD7G9T7GQZ  01M3ZH0A6YPVK60J8W374CDQG4
2026-10-03 00:00:49.997  access   access.session.login               admin(14)              user 14 admin                      success  r-01M3ZH0F9K0MNZG6DQ0KWQ572T  01M3ZH0FTDYQ1D8BW0G3G8EC8T
2026-10-03 00:02:54.653  access   access.session.login               admin(14)              user 14 admin                      success  r-01M3ZH48ZGGSYK3N7NR3Z2F3JX  01M3ZH49HXBMS8TY7HJCA7NWE0
$ ./console exp:audit verify --allow-root-user
access     INTACT    1575 records in 2 files, 2026-10-02T22:36:00.716Z to 2026-10-03T00:02:59.302Z, 1 checkpoint(s) matched
commerce   INTACT    93 records in 1 file, 2026-10-02T23:29:45.805Z to 2026-10-02T23:53:55.862Z, 1 checkpoint(s) matched
content    INTACT    1506 records in 2 files, 2026-10-02T23:28:16.227Z to 2026-10-03T00:02:34.006Z, 1 checkpoint(s) matched
read       INTACT    133 records in 1 file, 2026-10-02T23:41:59.913Z to 2026-10-02T23:48:57.088Z, 1 checkpoint(s) matched
system     INTACT    12648 records in 2 files, 2026-10-02T22:35:09.898Z to 2026-10-03T00:04:20.408Z, 3 checkpoint(s) matched
(exit 0)

Then, in the admin interface as an administrator, open the Audit tab (/audit/dashboard). To see a record appear, sign out and in again, then open Console: the sign-in is the newest access.session.login.

Three things to do on a new installation:

  1. Make sure the cronjob part runs. It ships in the frequent group, so php runcronjobs.php frequent every few minutes is enough (see 4.1). The dashboard warns when it has not run for an hour.
  2. Decide who gets alert mail. The default is the site's AdminEmail. Check it with ./console exp:audit alerts recipients (see 4.5).
  3. Back up settings/override/audit.ini.append.php. It holds the signing and pseudonym keys. Treat the backup as a secret. Without the file, archives signed with its keys can no longer be verified.

3. Usage guide

3.1 Who may see what: the policies

PolicyGrantsLimitation
audit/readthe Audit tab, dashboard, console, event, charts, alerts, export, recent; the dashboard block, sidebar link, node tab and job links; the fetch functionsChannel (content, access, system, commerce, read, and any channel an active extension adds, such as ai): for example, a shop auditor who sees only commerce
audit/managearchives and settings views, "Verify now"none

No role except Administrator (which holds */*) has these policies in a new installation. To give someone read access, create a role with audit/read (optionally limited by Channel) in Roles and policies and assign it.

A user without the policy sees nothing of the audit: no tab, no sidebar link, no block, no node tab, no job links, and an empty fetch. A typed URL is refused and recorded as access.permission.refused. Every allowed view is itself recorded as system.audit.read.

Password re-entry before manage actions (Q9; [AuditConsoleSettings] ReauthForManage, off by default). With ReauthForManage=enabled, "Verify now" (dashboard, console, audit/recent) first shows a small form asking for the signed-in user's password. The form posts to the same view with the same button and the form token, so a correct password runs the action at once. The password is checked against the stored hash the way the sign-in checks it (eZUser::authenticateHash()), without signing in again and without changing the session's user. A correct password holds for ReauthMinutes (10) in that session and for that user only. Each attempt is recorded: access.session.reauth (after: action, minutes) or access.session.reauth.failed (result failed, reason credentials; never the password). Cancel returns to the page and does nothing. The command line never asks: whoever runs exp:audit already holds the server. The archives and settings views have no manage buttons (Appendix C), so "Verify now" is the action this guards today; expAuditReauth::gate() is the one call a new manage action makes:

$form = expAuditReauth::gate( $Module, 'AuditVerifyNowButton', 'audit/dashboard', ezpI18n::tr( 'design/admin/audit', 'Verify now' ) );
if ( $form !== null )
    return $form;   // the password form, or the redirect of Cancel

Checked on the reference installation over HTTP (Apache), with ReauthForManage=enabled set for the test and the override file put back byte for byte afterwards:

PASS Verify now asks for the password: 200
PASS the password form carries the form token
PASS wrong password: the form again (200)
PASS right password: the action runs (302)
PASS within ReauthMinutes: not asked again (302)
PASS both attempts are in the console

3.2 The admin interface

The Audit tab (after Design in the top menu) opens the dashboard. The left menu has Dashboard, Console, Recent events, Charts, Alerts and Export, plus Archives and Settings for audit/manage.

ViewURLWhat you see
Dashboardaudit/dashboardCards that link into the detailed views. Health: audit on/off, each channel's chain as last verified, Verify now (manage; with the password again when ReauthForManage is on), the signing key's age, with a hint after one year. Today and 7 days: per channel and family, events per day. Security: failed logins by address and by login (hashed for unknown accounts), the latest role grants, refused views. Alerts and their mail recipients. Activity: top actors and objects today, the latest warnings. Operations: the cronjob part (a warning when it has not run for an hour), the index (rows, lag, last reindex), archives, sinks. Quick links. It never reads the files, so it opens in about 25 ms (the 7-day figures are cached for a minute)
Consoleaudit/consoleThe timeline, newest first, with a filter form, full-text search and paging, and the chain state of each channel at the top. Records not indexed yet are merged into the first page, so the newest event is always there
Eventaudit/event/<id>One record in full: when (local and UTC), who, the request, object and target with links into the admin (node, object, user, role, job), before and after side by side, the parent, the children, the other events of the same request and job, and the chain position (prev, hash, whether the hash matches the record now). /(format)/json returns the line as it is in the file
Chartsaudit/chartsEvents per day per channel, refusals and failures per day, logins against failed logins, top actors, top event names; the last 14 days (/(days)/<n>, up to 366), under the console's filters. Drawn in HTML/CSS, each chart with its numbers in a table. Needs the index
Alertsaudit/alertsThe alerts that fired (system.audit.alert: rule, group, count, window, first and last matching events) and the rules in use with any problem. Read-only
Exportaudit/exportThe console's current filter as CSV, JSON lines (the lines as written, so hashes can be checked) or JSON. The page says how many records the filter has before anything starts. Download now: at most MaxExportRecords records (a larger filter is cut and says so, header X-Exp-Audit-Export-Cut), streamed: the file starts at once and is sent while it is read, on Apache and on Velocity alike (Streaming the export). Export in the background: a content job (auditexport) writes the same bytes to a file, up to MaxBackgroundExportRecords, shows its progress on content/job/<id> and then offers the file on the export page (audit/export/(download)/<id>, only to the user who asked for it). The CSV has the generic columns; filtered on the channel of an extension's source, also the columns that source declares (expai: provider, model, region, purpose, lane, tokens in and out, latency, status, fallback, cost), and the page names them. Recorded as system.audit.export with the sha256 of what was sent (a background export when its file is complete, with via background and the job)
Archives (manage)audit/archivesPer channel: live files (count, size, oldest), archives and manifests, each live file's verification state (intact, repaired, broken at line n, unchecked), the signing key id and fingerprint. Read-only: archiving and restoring are done by the cronjob part and exp:audit
Settings (manage)audit/settingsThe effective audit.ini with the file each value comes from, secrets masked, keys as ids and fingerprints, the sinks and format handlers with what keeps them from working, the index state (tables, full-text kind, rows, lag). Read-only: write settings with exp:ini (see 4.9)
Recentaudit/recentThe latest 100 events, all channels or /(channel)/<name>, with a chain line per channel

Extensions' sources. An active extension can register a source of the console ([AuditSourceSettings] Sources[], see 3.5, "An extension's source in the console"). Its events then have a dashboard panel, a line of links on the console, recent, charts, alerts and export views, charts of their own, the extension's fields and links on audit/event, columns of its own in the CSV export of its channel, and an entry in this left menu. Without such an extension the views are exactly as described above. Exponential Intelligence (expai) is one: the channel ai, the panel "Exponential Intelligence" and the menu entries "AI events" and "EI: Log" (with expai/log).

The left menu follows the Channel limitation. A link into a channel the user may not read under the Channel limitation of audit/read is left out of the menu, instead of offering a page the console refuses: a link whose address is an audit view filtered on a channel (audit/console/(channel)/ai), or one that menu.ini names with AuditChannel_<key>=<channel>. A user of audit/read limited to content and access therefore does not see "AI events". The template asks fetch( 'audit', 'hidden_menu_links', hash( 'section', 'Leftmenu_audit' ) ) (expAuditSourceRegistry::hiddenMenuLinks()) and hands the keys to design:parts/ini_menu.tpl as hidden_links.

Console filters are URL parameters, so every filtered view can be bookmarked and shared:

ParameterExampleMeans
(channel)(channel)/accessone channel
(name)(name)/access.session.**, a prefix ending in .* (it matches the prefix itself too) or a whole name; anything else, such as access.session.login*, is refused: the page says why and lists nothing
(user), (login)(user)/14, (login)/editor1by the actor
(object), (target)(object)/node:275<type>:<id>: node, object, user, role, job, setting…
(result)(result)/refusedsuccess, refused, failed
(severity)(severity)/warningthis severity or worse
(request), (job), (run), (parent)(job)/20261003-000230-29a7dcc0correlation: one request, one content job, one cronjob run, one parent's children
(ip)(ip)/203.0.113.0/24as recorded (after privacy)
(from), (to)(from)/2026-10-01/(to)/2026-10-02T12:00YYYY-MM-DD, …THH:MM or …THH:MM:SS; without an offset in the site's time zone (the console prints site times), with Z or +02:00 that instant; (to) includes the day, minute or second given
(q)(q)/workoutfull-text search over names, object names and before/after values
(legacy_file)(legacy_file)/login.logthe events a 4.x audit file name stood for
(offset), (limit)(limit)/200paging, at most 500 per page

Example: every failed login of the last day from one network: /audit/console/(name)/access.session.login.failed/(ip)/203.0.113.0/24/(from)/2026-10-02.

Elsewhere in the admin (each only with audit/read, and only for channels the user may read):

  • Dashboard block "Audit: security events" on the admin dashboard: the latest notice-or-worse events of the access and system channels, open alerts, and the "audit is off" or "chain broken" warnings (manage).
  • Sidebar link "Audit trail" in the dashboard's left menu.
  • Node tab "Audit" in every node view: the last ten events about that node and a link to all of them (audit/console/(object)/node:<id>).
  • Content jobs: "Audit trail" on content/job/<id> and a link per job on content/jobs (audit/console/(job)/<id>): everything that job did, node by node.
  • Setup menu entry "Audit" (manage).

Every admin response carries the header X-Exp-Request-Id. The same id is in request.id of every record that request wrote, so audit/console/(request)/<id> shows exactly what one click did.

3.3 The command exp:audit

./console exp:audit <action> [options] --allow-root-user (or php bin/php/audit.php). The default action is status. Run as root, it writes files owned by the site user. Reading is recorded as system.audit.read and every change as its own event. --json prints machine-readable results for every action that reads.

ActionDoesOptions
statusaudit on/off, directory, installation, signing key, files, bytes, today's records and chain per channel--json
channelsper channel: routes, files, newest file, chain head--json
tailthe newest records; --follow keeps printing--channel= --name= --lines=20 --follow --json
show <id>one record in full, its hash re-checked--json
searchthe index (or the files with --files), newest firstfilters (below), --files --limit=50 --json
verifythe hash chains; exit 0 intact, 1 broken, 2 error--channel= --date=YYYY-MM-DD --archives --json
checkpointa signed checkpoint of every channel's head now
rotatecloses the newest file of channels idle since an earlier day; runs retention--channel= --dry-run
archivecompresses days older than LiveDays (or --before=) into signed archives--channel= --before= --format= --dry-run
restoredecompresses an archived day for reading--channel= --date= [--to=<dir>]
purgeremoves archives older than ArchiveDays (and old index rows)--channel= --dry-run
reindexrebuilds the index, or catches up--incremental --archives --channel=
pseudonymisethe index's pseudonymisation now--dry-run
exportrecords to a filefilters, --subject-user=<id> --format=jsonl|csv|bundle --out=
importthe 4.x text logs into the new format--dir= --file= --dry-run --keep-originals
keylist, fingerprint, rotate, rotate --pseudonym
sinkslist, test <name>, flush [<name>]
alertslist, test <rule> --replay=<date>, recipients [--rule=]
cronone run of the cronjob part--daily (the daily tasks now)

Filters of search and export: --channel= --name=<pattern> --user=<id> --login= --object=<type:id> --target=<type:id> --result=success|refused|failed --severity=<min> --request= --job= --run= --ip=<network> --from=<time> --to=<time> --query=<text> --legacy-file=<4.x file name>.

  • --query=<text> is the full-text search, the console's (q). It cannot be --q: -q/--quiet is every script's standard quiet option, and the option parser keeps that name for it.
  • --name (also for tail) takes *, a prefix of one to five ranks ending in .* (access.*, access.session.*; the prefix itself matches too), or a whole name of 3 to 6 ranks. The first rank is a domain (content, access, system, commerce, data). Anything else, such as access.session.login*, access.*.failed or a bare access.session, is refused with the reason and exit code 2, and nothing is searched.
  • Times (one rule for the command): --from/--to take YYYY-MM-DD, YYYY-MM-DDTHH:MM or YYYY-MM-DDTHH:MM:SS (a space for the T works too). Without an offset they are UTC, the zone the command prints. With Z, +HH:MM, -HH:MM, +HHMM or +HH they are that instant. --to includes the whole day, minute or second given. A malformed time is refused (exit 2). The console reads (from)/(to) the same way, but there a bare time is in the site's time zone, because the console prints site times.
$ ./console exp:audit search --query=logout --name='access.session.*' --limit=2 --allow-root-user
2026-10-03 00:44:39.636  access   access.session.logout              admin(14)              user 14                            success  r-01M3ZKGQTA32V9XK0NQ4WN57R8  01M3ZKGQTM7DC5263YNBNDGTWF
2026-10-03 00:44:29.179  access   access.session.logout              admin(14)              user 14                            success  r-01M3ZKGDKG2PB5W55ZDAEZRWXA  01M3ZKGDKV0B6SA799A0KDFEBT
2 record(s) from the index (limit 2: --limit=)

$ ./console exp:audit search --name='access.session.login*' --allow-root-user; echo "exit $?"
--name: 'access.session.login*': a * may only be the whole pattern or the last rank after a dot (did you mean 'access.session.login.*'?); allowed: *, a prefix of ranks ending in .* (access.*, access.session.*) or a whole name (access.session.login)
exit 2

$ ./console exp:audit search --q=logout --allow-root-user
bin/php/audit.php: invalid option `--q'

# the same ten minutes, once in UTC (as printed) and once with the offset of Central European Summer Time
$ ./console exp:audit search --name=access.session.login --from=2026-10-03T00:30 --to=2026-10-03T00:40 --limit=3 --allow-root-user
$ ./console exp:audit search --name=access.session.login --from=2026-10-03T02:30+02:00 --to=2026-10-03T02:40+02:00 --limit=3 --allow-root-user
2026-10-03 00:33:56.882  access   access.session.login               admin(14)              user 14 admin                      success  r-01M3ZJX3M2G4BG5EFY2CMR9Z3X  01M3ZJX44JDSQV7624W6SA9B0S
2026-10-03 00:33:40.684  access   access.session.login               admin(14)              user 14 admin                      success  r-01M3ZJWKTDYFT34YRJSXWG6118  01M3ZJWMAC35VNXCDRSXF37ER2
2026-10-03 00:33:24.440  access   access.session.login               admin(14)              user 14 admin                      success  r-01M3ZJW3XRXMD69TEGFYNYDKKX  01M3ZJW4ER0H05HBARG2SF7GPJ
3 record(s) from the index (limit 3: --limit=)            (both commands print these three records)

$ ./console exp:audit search --to=2026-10-02T24:00 --allow-root-user
--to: '2026-10-02T24:00' is not a time (YYYY-MM-DD, YYYY-MM-DDTHH:MM[:SS], optionally with Z or +HH:MM)

Reading

$ ./console exp:audit channels --allow-root-user
content
  routes:  content.*
  files:   2 (1495660 bytes), newest content-2026-10-03.jsonl
  head:    seq 600 sha256:9861883047ff7fe7…
access
  routes:  access.*
  files:   2 (1233762 bytes), newest access-2026-10-03.jsonl
  head:    seq 35 sha256:086dd84595769212…
system
  routes:  system.*, (default)
  files:   2 (13959023 bytes), newest system-2026-10-03.jsonl
  head:    seq 66 sha256:7ebd8aa793b8e02d…
commerce
  routes:  commerce.*, data.*
  files:   1 (76442 bytes), newest commerce-2026-10-02.jsonl
  head:    seq 93 sha256:90805b7bb47a2979…
read
  routes:  content.node.view, content.search.*, content.object.download
  files:   1 (105484 bytes), newest read-2026-10-02.jsonl
  head:    seq 133 sha256:1c389fc0ecf5597d…

tail columns: time (UTC), channel, name, actor (login(user id)), object, result, request id, event id.

$ ./console exp:audit tail --lines=5 --allow-root-user
2026-10-03 00:04:02.789  system   system.cronjob.run                 anonymous(10)          cronjob audit                      success  r-01M3ZH6C0HHAVXBWZZPPSM6MHS  01M3ZH6C352JHDB4QF2NQB7QP8
2026-10-03 00:04:02.797  system   system.command.run                 anonymous(10)          command runcronjobs.php            success  r-01M3ZH6C0HHAVXBWZZPPSM6MHS  01M3ZH6C3DD30VS3A3FFQQN7P5
2026-10-03 00:04:18.285  system   system.audit.read                  anonymous(10)          audit status                       success  r-01M3ZH6V7EZCJMTDRY3FE67SQN  01M3ZH6V7DV9VTS7JW2VFEQ0XG
2026-10-03 00:04:18.307  system   system.command.run                 anonymous(10)          command bin/php/audit.php          success  r-01M3ZH6V7EZCJMTDRY3FE67SQN  01M3ZH6V83RTAYEB5Y4BBBRSBK
2026-10-03 00:04:19.019  system   system.command.run                 anonymous(10)          command bin/php/audit.php          success  r-01M3ZH6VYBCDG293S16NS72R8C  01M3ZH6VYBJARPGHC93VDZKXYX

tail --follow prints the newest --lines and then every new record as it is written (Ctrl-C ends):

$ ./console exp:audit tail --channel=system --lines=2 --follow --allow-root-user
2026-10-03 00:14:41.422  system   system.audit.export                anonymous(10)          audit export                       success  r-01M3ZHSVREF60990SHTRJ8M3K5  01M3ZHSVREK9D7G6M59MQG6QQE
2026-10-03 00:14:41.450  system   system.command.run                 anonymous(10)          command bin/php/audit.php          success  r-01M3ZHSVREF60990SHTRJ8M3K5  01M3ZHSVSAMKDX7PKRDFJYYYY1

show prints one record, says where it is, and recomputes its hash:

$ ./console exp:audit show 01M3ZH49HXBMS8TY7HJCA7NWE0 --allow-root-user
Event 01M3ZH49HXBMS8TY7HJCA7NWE0   access.session.login   success
  where      access-2026-10-03.jsonl line 26, seq 26
  time       2026-10-03T00:02:54.653Z
  channel    access
  severity   info
  verb       login
  depth      0
  request    {"engine":"velocity","host":"web1","id":"r-01M3ZH48ZGGSYK3N7NR3Z2F3JX","method":"POST","module":"user/login","ms":630,"pid":1322013,"siteaccess":"admin","status":200,"url":"/admin/user/login"}
  actor      {"ip":"203.0.113.0/24","login":"admin","roles":[2],"session":"h:7617a963afc3ca9c","ua":"curl 7","user_id":14}
  object     {"id":14,"login":"admin","type":"user"}
  after      {"handler":"standard"}
  prev       sha256:4c3e5dc8417119194c764488576c7c66afe914cea1eb74119465a824edb376bf
  hash       sha256:949b036fdd6f0c59e0214ca1032d56a7a514a4f6b43f8189214b3774b8cc72ba
  check      the hash matches the record

(The host and address above were replaced for this guide, so this printed record no longer matches its hash. On the reference installation it does.) show <id> --json prints the line exactly as it is in the file.

search reads the index, or the files with --files:

$ ./console exp:audit search --name='access.session.login.*' --limit=3 --allow-root-user
2026-10-03 00:02:54.653  access   access.session.login               admin(14)              user 14 admin                      success  r-01M3ZH48ZGGSYK3N7NR3Z2F3JX  01M3ZH49HXBMS8TY7HJCA7NWE0
2026-10-03 00:00:49.997  access   access.session.login               admin(14)              user 14 admin                      success  r-01M3ZH0F9K0MNZG6DQ0KWQ572T  01M3ZH0FTDYQ1D8BW0G3G8EC8T
2026-10-03 00:00:44.254  access   access.session.login               admin(14)              user 14 admin                      success  r-01M3ZH09NJPKY38DJD7G9T7GQZ  01M3ZH0A6YPVK60J8W374CDQG4
3 record(s) from the index (limit 3: --limit=)
$ ./console exp:audit search --result=refused --limit=5 --allow-root-user
2026-10-02 23:50:36.333  access   access.permission.refused          anonymous(10)          view visual/templatecreate         refused  r-01M3ZGDRHCS5D6G93K0DC7FSEX  01M3ZGDRHD4C1TCD02FBFGJE1Y
2026-10-02 23:50:29.393  access   access.permission.refused          anonymous(10)          view visual/templatecreate         refused  r-01M3ZGDHRGP2PEGW0TGDE652MP  01M3ZGDHRHRB0P70G8QQPQGRXN
2026-10-02 23:50:28.537  access   access.permission.refused          anonymous(10)          view visual/templatecreate         refused  r-01M3ZGDGXRFDQ0XVBP8KPNP99W  01M3ZGDGXSH26FHEQMX3ERQ0NS
2026-10-02 23:49:05.745  access   access.permission.refused          a4-test-shopauditor(13734) view audit/settings                refused  r-01M3ZGB02AVXHGJTC09J8YNYJY  01M3ZGB02HEHN5M90ANG13V99P
2026-10-02 23:49:05.602  access   access.permission.refused          a4-test-shopauditor(13734) view audit/archives                refused  r-01M3ZGAZXS74X4W4FFQPRCTD91  01M3ZGAZY2KE8MAQ36K8JSXD40
5 record(s) from the index (limit 5: --limit=)

The trail of a content job, from the files:

$ ./console exp:audit search --files --channel=content --name='content.job.*' --limit=4 --allow-root-user
2026-10-03 00:02:34.006  content  content.job.finish                 admin(14)              job 20261003-000230-29a7dcc0       success  r-01M3ZH3JR5MDZ2MX1N4YH1P70E  01M3ZH3NCP3RC9KMSPFNRV3T7J
2026-10-03 00:02:31.308  content  content.job.start                  admin(14)              job 20261003-000230-29a7dcc0       success  r-01M3ZH3JR5MDZ2MX1N4YH1P70E  01M3ZH3JRCYH3PWDPP095T1JFF
2026-10-03 00:02:31.301  content  content.job.create                 admin(14)              job 20261003-000230-29a7dcc0       success  r-01M3ZH3JR5MDZ2MX1N4YH1P70E  01M3ZH3JR5QQ59WJWEWP6ZPM8A
2026-10-03 00:02:20.739  content  content.job.finish                 admin(14)              job 20261003-000218-0611fb7d       success  r-01M3ZH31289HVC0HTG3W5QYKTK  01M3ZH38E3BYY485E46S7ZP4P8
4 record(s) from the files (limit 4: --limit=)

Verifying

$ ./console exp:audit verify --channel=access --date=2026-10-02 --allow-root-user
access     INTACT    1540 records in 1 file, 2026-10-02T22:36:00.716Z to 2026-10-03T00:00:34.640Z, 1 checkpoint(s) matched
$ ./console exp:audit verify --channel=commerce --json --allow-root-user
{
    "commerce": {
        "channel": "commerce",
        "result": "intact",
        "records": 93,
        "files": 1,
        "first_time": "2026-10-02T23:29:45.805Z",
        "last_time": "2026-10-02T23:53:55.862Z",
        "breaks": [],
        "repairs": [],
        "notices": [],
        "checkpoints": 1,
        "last_seq": 93,
        "last_hash": "sha256:90805b7bb47a2979b4791f5ad7288fcf2783ae3e01730e0ac3859aefa42bc675"
    }
}

verify --archives also reads every archive through its format handler and checks the manifests' HMACs, their chain (previous_manifest) and that the oldest live file continues from the newest archived one. On a channel with archives (sandbox, after archive below):

$ ./console exp:audit verify --archives            (sandbox)
content    INTACT    archives: 3 manifests, 3 files, 21 records, 2026-06-01T09:04:00.000Z to 2026-10-03T00:04:03.819Z
access     INTACT    archives: 2 manifests, 2 files, 6 records, 2026-06-01T09:04:00.000Z to 2026-06-03T09:04:00.000Z
system     INTACT    archives: 3 manifests, 3 files, 10 records, 2026-06-01T09:04:00.000Z to 2026-10-03T00:04:03.822Z
access     INTACT    2 records in 1 file, 2026-06-03T09:04:00.000Z to 2026-06-03T09:04:00.000Z, 1 checkpoint(s) matched
content    INTACT    2 records in 1 file, 2026-10-03T00:04:03.820Z to 2026-10-03T00:04:03.819Z, 1 checkpoint(s) matched
system     INTACT    13 records in 1 file, 2026-10-03T00:04:03.822Z to 2026-10-03T00:05:22.619Z

A channel whose files repeat a record id says how many records are distinct, with a repeated_ids notice per file (not a break: the repeats are chained like every other line; see Repeated records):

$ ./console exp:audit verify --channel=content --allow-root-user
content    INTACT    41295 records in 9 files, 2026-10-02T23:28:16.227Z to 2026-10-12T14:26:18.609Z, 10 checkpoint(s) matched, 27652 distinct (13643 repeated)
           notice: content-2026-10-04.jsonl: repeated_ids (2322 line(s) repeat a record id written before in this file; the index and the exports keep the first)

A broken chain prints BROKEN with the first break (file, line, kind) and exits 1. The kinds are explained in 5.3. checkpoint writes a signed anchor now (the cronjob part writes one a day):

$ ./console exp:audit checkpoint --allow-root-user
Checkpoint written: 01M3ZGYDH0BNG4E5NASHP69XSX (system channel)

Rotating, archiving, restoring, purging

Dry runs on the reference installation (the oldest live file is from the day before, so nothing is due yet):

$ ./console exp:audit rotate --dry-run --allow-root-user
content    nothing to close (today's file is the newest, or the newest is closed)
access     nothing to close (today's file is the newest, or the newest is closed)
system     nothing to close (today's file is the newest, or the newest is closed)
commerce   would close commerce-2026-10-02.jsonl (93 records)
read       would close read-2026-10-02.jsonl (133 records)

$ ./console exp:audit archive --dry-run --allow-root-user
content    nothing due
access     nothing due
system     nothing due
commerce   nothing due
read       nothing due

$ ./console exp:audit purge --dry-run --allow-root-user
content    would remove 0 archived day(s) before 2024-10-03
access     would remove 0 archived day(s) before 2024-10-03
system     would remove 0 archived day(s) before 2024-10-03
commerce   would remove 0 archived day(s) before 2024-10-03
read       would remove 0 archived day(s) before 2026-07-05

In the sandbox, which has three days from June (older than LiveDays=90):

$ ./console exp:audit archive --dry-run            (sandbox)
content    2026-06-01 would be archived with gzip: content-2026-06-01.jsonl
content    2026-06-02 would be archived with gzip: content-2026-06-02.jsonl
content    2026-06-03 would be archived with gzip: content-2026-06-03.jsonl
access     2026-06-01 would be archived with gzip: access-2026-06-01.jsonl
access     2026-06-02 would be archived with gzip: access-2026-06-02.jsonl
system     2026-06-01 would be archived with gzip: system-2026-06-01.jsonl
system     2026-06-02 would be archived with gzip: system-2026-06-02.jsonl
system     2026-06-03 would be archived with gzip: system-2026-06-03.jsonl
commerce   nothing due
read       nothing due

$ ./console exp:audit archive                      (sandbox)
content    2026-06-01 archived with gzip (k1-20261003-0a43315c, chain intact): …/log/archive/content/2026/content-2026-06-01.manifest.json
content    2026-06-02 archived with gzip (k1-20261003-0a43315c, chain intact): …/log/archive/content/2026/content-2026-06-02.manifest.json
content    2026-06-03 archived with gzip (k1-20261003-0a43315c, chain intact): …/log/archive/content/2026/content-2026-06-03.manifest.json
access     2026-06-01 archived with gzip (k1-20261003-0a43315c, chain intact): …/log/archive/access/2026/access-2026-06-01.manifest.json
access     2026-06-02 archived with gzip (k1-20261003-0a43315c, chain intact): …/log/archive/access/2026/access-2026-06-02.manifest.json
system     2026-06-01 archived with gzip (k1-20261003-0a43315c, chain intact): …/log/archive/system/2026/system-2026-06-01.manifest.json
system     2026-06-02 archived with gzip (k1-20261003-0a43315c, chain intact): …/log/archive/system/2026/system-2026-06-02.manifest.json
system     2026-06-03 archived with gzip (k1-20261003-0a43315c, chain intact): …/log/archive/system/2026/system-2026-06-03.manifest.json
commerce   nothing due
read       nothing due

access-2026-06-03.jsonl was not archived: it was still the newest file of its channel. A channel's newest file is never archived until it is closed, either by the first write of a later day or by rotate:

$ ./console exp:audit rotate --dry-run             (sandbox)
content    nothing to close (today's file is the newest, or the newest is closed)
access     would close access-2026-06-03.jsonl (2 records)
…
$ ./console exp:audit restore --channel=content --date=2026-06-02      (sandbox)
var/tmp/audit-stage6/sandbox/log/restored/content-2026-06-02.jsonl  (identical to the archived live file)

Restored files go to <LogDir>/restored/ for reading, verifying and searching (search --files). They never go back into the live chain.

$ ./console exp:audit purge --dry-run              (sandbox, ArchiveDays=100)
content    would remove 3 archived day(s) before 2026-06-25: content-2026-06-01.manifest.json, content-2026-06-02.manifest.json, content-2026-06-03.manifest.json
access     would remove 2 archived day(s) before 2026-06-25: access-2026-06-01.manifest.json, access-2026-06-02.manifest.json
system     would remove 3 archived day(s) before 2026-06-25: system-2026-06-01.manifest.json, system-2026-06-02.manifest.json, system-2026-06-03.manifest.json
commerce   would remove 0 archived day(s) before 2024-10-03
read       would remove 0 archived day(s) before 2026-07-05

$ ./console exp:audit purge                        (sandbox, ArchiveDays=100)
content    removed 3 archived day(s) before 2026-06-25: content-2026-06-01.manifest.json, content-2026-06-02.manifest.json, content-2026-06-03.manifest.json
…

Each removal is written to <ArchiveDir>/<channel>/purged.jsonl and recorded as system.audit.purge with the file names and their sha256, so the record of what existed outlives the data. Live files that continued from a purged day still verify INTACT.

The index

$ ./console exp:audit reindex --incremental --allow-root-user
Index: 43 rows in 64 ms

$ ./console exp:audit pseudonymise --dry-run --allow-root-user
Pseudonymised: {"ok":true,"rows":0,"error":"","cutoff":"2026-07-05T00:04:33Z"}

reindex without --incremental empties the index tables (one channel with --channel=) and rebuilds them from the files. On the reference installation that took about 6 seconds for 13 000 records.

Exporting

$ ./console exp:audit export --name='access.*' --from=2026-10-02 --format=csv --out=var/tmp/audit-stage6/access.csv --allow-root-user
1573 record(s) exported as csv to var/tmp/audit-stage6/access.csv (sha256 86b22d7342bd43acf46a6668ba40518c7408d1cf5ca663304916b3d9916c34e7)

$ ./console exp:audit export --channel=content --format=jsonl --limit=50 --out=var/tmp/audit-stage6/content.jsonl --allow-root-user
50 record(s) exported as jsonl to var/tmp/audit-stage6/content.jsonl (sha256 53b2145e194588e610470375ed8173642149f5847cd19b999dd814aaa8dfb369)

$ ./console exp:audit export --name='access.*' --format=bundle --out=var/tmp/audit-stage6/bundle --allow-root-user
1573 record(s) exported as bundle to var/tmp/audit-stage6/bundle/records.jsonl (sha256 a87a91ae085f6130d349ada65eb7c32f40779b0c071b77be784b79f576aad2e1), signed manifest var/tmp/audit-stage6/bundle/manifest.json

A bundle is records.jsonl (the lines as written) and manifest.json, which holds the filter, the count, the sha256 and an HMAC with the signing key. It is what you hand to an auditor: the hashes of the lines can be checked against each other, and the manifest against the key's fingerprint.

Importing the 4.x logs

$ ./console exp:audit import --dry-run --allow-root-user
var/site/log/audit/login.log             skipped: imported before (same sha256)

On a sandbox directory with two 4.x files:

$ ./console exp:audit import --dir=…/old --dry-run          (sandbox)
…/old/login.log 1 entries, 1 record(s) would be imported
…/old/failed_login.log 2 entries, 2 record(s) would be imported

$ ./console exp:audit import --dir=…/old --keep-originals   (sandbox)
…/old/login.log 1 entries, 1 record(s) imported
…/old/failed_login.log 2 entries, 2 record(s) imported
Imported into var/tmp/audit-stage6/sandbox/log/imported (outside the chain, marked imported); originals archived: var/tmp/audit-stage6/sandbox/log/archive/legacy/legacy-20261003-000544-gescfc.manifest.json

$ ./console exp:audit import --dir=…/old --keep-originals   (sandbox, again)
…/old/login.log skipped: imported before (same sha256)
…/old/failed_login.log skipped: imported before (same sha256)

An imported record (one line of imported/access-2026-09-30.jsonl):

{"actor":{"ip":"203.0.113.0/24","login":"editor1","user_id":14},"after":{"legacy":{"User ID":"14"}},"channel":"access","id":"01M3SE0M1G2JED4MV2DQSF32BA","imported":true,"name":"access.session.login","object":{"login":"editor1","type":"user"},"request":{"siteaccess":"admin","url":"/user/login"},"result":"success","severity":"info","source":{"file":"login.log","line":1,"sha256":"7e7a8b9cf24e21b2e1a8658ff2943f9e63aeabb11914eca9224be9643939e622"},"time":"2026-09-30T15:13:02.000Z","v":1,"verb":"login","x":{"legacy":{"name":"user-login"}}}

The 4.x time stamps carry no time zone and are read in the server's time zone (08:13:02 there became 15:13:02Z). The privacy rules apply (the address truncated), and an attempted login of a failed login is hashed.

Keys

$ ./console exp:audit key list --allow-root-user
k1-20261002-3fa94c1b  3FA9 4C1B 77D0 E215  (active)

Rotation (sandbox):

$ ./console exp:audit key rotate                   (sandbox)
Signing key rotated: k1-20261003-0a43315c -> k2-20261003-f12e3c8d (fingerprint F12E 3C8D 8CC8 8C12); the old key stays for its archives

$ ./console exp:audit key list                     (sandbox)
k1-20261003-0a43315c  0A43 315C DD87 355B
k2-20261003-f12e3c8d  F12E 3C8D 8CC8 8C12  (active)

The archives signed with k1 still verified INTACT after the rotation (verify --archives, sandbox).

Sinks and alerts

$ ./console exp:audit sinks list --allow-root-user
syslog     expAuditSyslogSink     ready; spooled 0; channels access, system
webhook    expAuditWebhookSink    not ready: no URL is set ([AuditSink_webhook] URL); spooled 0; channels -
mail       expAuditMailSink       ready; spooled 0; channels -; last delivery 2026-10-02T23:45:02Z

$ ./console exp:audit sinks test syslog --allow-root-user
Test record 01M3ZH9RJ2B3K5B6RSW394HYJK delivered through syslog
(exit 0)

$ ./console exp:audit sinks test webhook --allow-root-user
Not delivered through webhook: no URL is set ([AuditSink_webhook] URL)
(exit 1)

$ ./console exp:audit sinks flush --allow-root-user
Nothing spooled

What the syslog sink writes, as journald shows it:

$ journalctl -t exponential -n 1 -o short-iso
2026-10-02T17:05:53-0700 web1 exponential[1325003]: 1 2026-10-03T00:05:53.861Z web1 exponential 1325003 system [exp@32473 id="01M3ZH9RJ5912SQKP1B2FAQHAT" name="system.command.run" seq="124" channel="system" user="anonymous" ip="" result="success" request="r-01M3ZH9RJ25FV748WK0Z3SK7N4" hash="sha256:da6e4…"] {"v":1,…}
$ ./console exp:audit alerts list --allow-root-user
Alerts: enabled, evaluated in flush, cronjob
brute_force              threshold access.session.login.failed        20 in 300 s by actor.ip, critical -> syslog,mail
brute_force_user         threshold access.session.login.failed        10 in 900 s by object.id, alert -> syslog,mail
admin_role_granted       match     access.role.assign                 critical -> syslog,mail
settings_out_of_hours    schedule  system.setting.write               warning -> syslog
mass_delete              threshold content.node.remove.*              500 in 600 s by actor.user_id, critical -> syslog,mail
audit_disabled           match     system.audit.disable               emergency -> syslog,mail
chain_broken             match     system.audit.chain.broken          alert -> syslog,mail

$ ./console exp:audit alerts test brute_force --replay=2026-10-02 --allow-root-user
brute_force over 15986 record(s) since 2026-10-02: 0 alert(s) would fire (nothing recorded)

$ ./console exp:audit alerts recipients --rule=brute_force --allow-root-user
brute_force
  from:     default -> admin
  mail to:  [email protected]  (admin)

alerts test runs a rule over past records and only reports. Use it to tune a threshold before you change it.

The cronjob part, by hand

$ ./console exp:audit cron --allow-root-user
Audit index: 2 rows
Audit cronjob run done

cron --daily runs the daily tasks now (rotation, verification, archiving, retention, pseudonymisation, checkpoint) instead of waiting for RotateAfter.

3.4 Templates: fetch functions and the operator

The audit module has six fetch functions. Each checks audit/read and its Channel limitation for the current user and returns an empty result, never an error, when the user may not read. A template therefore cannot leak events. Use is recorded as system.audit.read once per request and fetch.

FetchParametersReturns
eventschannel name user login object target result severity request job run from to q offset limit (limit 10)rows: id time time_utc name label channel actor result reason object …
countthe same filtersan integer
eventidone record
chain_statuschannel, verify (false)per channel: result, files, first break, verified at
can_readchannelwhether the current user may read that channel (or any)
hidden_menu_linkssection (Leftmenu_audit)the keys of that menu's links into a channel the current user may not read (for parts/ini_menu.tpl's hidden_links)

The node view's Audit tab (design/admin/templates/tabs/audit/node.tpl) is a complete example:

{def $audit_tab_module = 'audit'
     $audit_tab_events = fetch( $audit_tab_module, 'events', hash( 'object', hash( 'type', 'node', 'id', $node.node_id ), 'limit', 10 ) )}
{if fetch( $audit_tab_module, 'can_read', hash( 'channel', 'content' ) )}
{foreach $audit_tab_events as $e}
    {$e.time|wash} <a href={concat( 'audit/event/', $e.id )|ezurl}>{$e.label|wash}</a> {$e.actor|wash} {$e.result|wash}
{/foreach}
<a href={concat( 'audit/console/(object)/node:', $node.node_id )|ezurl}>All audit events of this node</a>
{/if}
{undef $audit_tab_module $audit_tab_events}

The module name is passed through a variable on purpose. The fetch is then not compiled into a direct class call, so a server process started before the audit classes existed shows nothing instead of failing.

The operator audit_label turns a name into its label, translated in the context kernel/audit: {$e.name|audit_label|wash} gives "Node move" for content.node.move.

3.5 The developer API

All classes are in kernel/classes/audit/ and in the kernel autoload array. Guard every call with class_exists(), so code also runs where the audit is missing or a Velocity worker predates it. The audit never throws to its caller: a failure to record goes to error.log and your code carries on.

Recording an event

// kernel/classes/ezcontentobjecttreenode.php, eZContentObjectTreeNode::move()
if ( class_exists( 'expAuditHook' ) )
    expAuditHook::emit( 'content.node.move', function () use ( $node, $oldParentNodeID, $newParentNodeID, $oldPath, $nodeID ) {
        $newParent = eZContentObjectTreeNode::fetch( $newParentNodeID );
        return array( 'object' => expAuditHook::node( $node ),
                      'target' => expAuditHook::node( $newParent ) ?: array( 'type' => 'node', 'id' => $newParentNodeID ),
                      'before' => array( 'parent' => (int)$oldParentNodeID, 'path' => (string)$oldPath ),
                      'after' => array( 'parent' => (int)$newParentNodeID,
                                        'path' => $newParent ? $newParent->attribute( 'path_string' ) . $nodeID . '/' : null ) );
    } );
  • expAuditHook::emit( $name, $data ) is the kernel's hook point. $data can be an array or a closure. The closure only runs when the name is on, so describing a node costs nothing when the event is off. Nothing a hook point does can throw into the request. Helpers describe things the same way everywhere: node(), object(), user(), role(), policy(), section(), contentClass(), states(), languages(), ids().
  • expAudit::event( $name, array $data = array() ) is the core call, and returns the event id (a 26-character ULID) or null when nothing was recorded. $data keys:
KeyMeaning
object, targetarrays: type, id and identifying fields (name, object_id, class, file, block, variable …)
before, afterthe values that changed. They are cut at MaxValueLength. Secrets are masked. Never put content attribute values here
result, reason, errorsuccess (default), refused or failed; a reason code (policy, token, credentials, not_found, expired, …); for failed, array( 'ref' => …, 'message' => … ). Refused raises the severity to at least notice, failed to at least warning
severityan RFC 5424 name, overriding the registry's
parenta parent event id (see below)
xyour extension's own fields, under its name: array( 'myext' => array( … ) )
actoronly for an actor other than the current user (a command acting for someone)
verbdefault: the catalogue's verb, else the name's third rank
  • expAudit::isOn( $name ): ask before building expensive values when you do not use a closure.
  • A refusal is the same name with a result: expAudit::event( 'access.permission.refused', array( 'object' => array( 'type' => 'view', 'id' => 'setup/cache' ), 'result' => 'refused', 'reason' => 'policy' ) );

Parents and children

$parent = expAudit::begin( 'content.node.remove', array( 'object' => array( 'type' => 'node', 'id' => 89, 'name' => 'Archive 2019' ) ) );
foreach ( $subtree as $node )
    expAudit::event( 'content.node.remove', array( 'parent' => $parent, 'object' => array( 'type' => 'node', 'id' => $node->attribute( 'node_id' ) ) ) );
expAudit::end( $parent, array( 'after' => array( 'removed' => count( $subtree ) ) ) );

begin() returns the id at once and writes the parent at end(), after its children, so the parent can hold totals. Past ChildDepth levels or MaxChildren children, children are counted in the parent's after.children_omitted instead of being written. expAudit::withParent( $id, function () { … } ) makes $id the parent of every event inside, for code that does not pass ids. The content job worker wraps each batch this way. A parent still open when the request ends is written with result: failed, reason: error. expAudit::setJob( $jobID ) and expAudit::setRun( $runID ) stamp job and run on every following event of the process. expAuditHook::muted( array( 'content.object.purge' ), function () { … } ) keeps inner hook points quiet while an outer one records the action as a whole. For example, the purge inside a removal.

Your own event names: a taxonomy branch

// extension/myext/classes/myextauditbranch.php
class myExtAuditBranch implements expAuditTaxonomyBranch
{
    public function events()
    {
        return array(
            'content.myext_poll.vote'  => array( 'label' => 'Poll vote',   'severity' => 'info',   'default' => 'off' ),
            'content.myext_poll.close' => array( 'label' => 'Poll closed', 'severity' => 'notice', 'default' => 'on', 'channel' => 'content' ),
        );
    }
}
# extension/myext/settings/audit.ini.append.php
[AuditEventSettings]
Branches[myext]=myExtAuditBranch
// extension/myext/modules/poll/close.php
if ( class_exists( 'expAudit' ) )
    expAudit::event( 'content.myext_poll.close', array( 'object' => array( 'type' => 'poll', 'id' => $pollID ) ) );

Names are 3 to 6 ranks of [a-z][a-z0-9_]* in one of the five domains (content, access, system, commerce, data). Start your subjects with your extension's name (content.myext_*). A name claimed twice, a class that is missing or does not implement the interface, or a malformed name is left out and reported by the RAD survey (registry auditbranches) and in audit/settings. Regenerate the autoloads after adding the class (php bin/php/ezpgenerateautoloads.php -e).

Recording an existing ezpEvent without code: the bridge

[AuditBridgeSettings]
Bridge[content/state/assign]=content.object.state
Bridge[myext/vote]=content.myext_poll.vote

One listener per entry is attached when ezpEvent::registerEventListeners() runs (web) or once per ezpEvent instance (commands). It is attached only once in a Velocity worker. The listener records after.args, the event's arguments made scalar (objects as class#id; arguments of session/* events are session ids and are only recorded hashed). A filter event's value passes through unchanged. Shipped: session/regenerate → access.session.regenerate, and session/destroy and session/gc → access.session.expire. Both are off by default.

An extension's source in the console

A branch puts an extension's names into the audit; a source tells the console how to show them. An extension that records events of its own (best in a channel of its own) registers a class implementing expAuditSource, usually by extending expAuditSourceBase, which answers every method with "nothing":

// extension/myext/classes/myextauditsource.php
class myExtAuditSource extends expAuditSourceBase
{
    public function label()   { return ezpI18n::tr( 'extension/myext/audit', 'Polls' ); }
    public function channel() { return 'polls'; }
    public function problem() { return class_exists( 'myExtPoll' ) ? '' : 'the classes of myext are not loaded'; }

    /** Links on the console's views ($view: audit/console, audit/recent, audit/charts, audit/alerts, audit/export) */
    public function links( $view, array $filters )
    {
        $links = array( array( 'label' => 'Votes', 'url' => self::consoleUrl( 'audit/console', array( 'name' => 'content.myext_poll.vote' ) ) ) );
        if ( self::hasPolicy( 'poll', 'manage' ) )
            $links[] = array( 'label' => 'All polls', 'url' => 'poll/list/(state)/open' );
        return $links;
    }

    /** The dashboard panel, from the index: figures, lists, links, note */
    public function dashboard( expAuditQuery $q, ?array $allowed, array $period )
    {
        $week = array( 'from_ms' => $period['week_ms'], 'name' => 'content.myext_poll.vote' );
        return array( 'figures' => array( array( 'label' => 'votes, 7 days', 'value' => $q->count( $week, $allowed ),
                                                 'url' => self::consoleUrl( 'audit/console', array( 'name' => 'content.myext_poll.vote' ) ) ) ),
                      'lists' => array( array( 'title' => 'Top voters', 'items' => array() ) ) );
    }

    /** audit/event: the record's fields as the extension names them, and links into its pages */
    public function event( array $record )
    {
        return array( 'fields' => array( array( 'label' => 'Poll', 'value' => $record['object']['id'] ) ),
                      'links' => array( array( 'label' => 'The poll', 'url' => 'poll/view/' . (int)$record['object']['id'] ) ) );
    }

    /** The address of an object type the extension records (the Object line of audit/event) */
    public function linkFor( $type, $id ) { return $type === 'poll' ? 'poll/view/' . (int)$id : null; }

    /** audit/charts: each series counts a name pattern per day */
    public function charts()
    {
        return array( array( 'title' => 'Polls per day', 'series' => array( array( 'label' => 'votes', 'name' => 'content.myext_poll.vote' ),
                                                                            array( 'label' => 'closed', 'name' => 'content.myext_poll.close' ) ) ) );
    }

    /** The CSV export of the channel: column => path of the value in the record, after the generic columns */
    public function exportColumns()
    {
        return array( 'poll' => 'object.id', 'choice' => 'after.choice', 'votes' => 'after.votes' );
    }
}
# extension/myext/settings/audit.ini.append.php (no empty "Channels[]" or "Rules[]" line: it would clear the kernel's list)
[AuditEventSettings]
Branches[myext]=myExtAuditBranch          # its events() give 'channel' => 'polls'
[AuditChannelSettings]
Channels[]=polls
[AuditChannel_polls]
LiveDays=90
ArchiveDays=730
MaxFileSize=64M
ArchiveFormat=gzip
Sinks[]
[AuditSourceSettings]
Sources[myext]=myExtAuditSource
# extension/myext/settings/menu.ini.append.php: the audit sidebar (each link only with its policies)
[Leftmenu_audit]
Links[myext_events]=audit/console/(channel)/polls
LinkNames[myext_events]=Poll events
PolicyList_myext_events[]=audit/read

What each view does with it (only for a source that works here, problem() is '', and whose channel the user may read under the Channel limitation of audit/read):

ViewWhat the source adds
audit/dashboardits panel after Activity: figures (each optionally a link and a state success, refused or failed), lists, links, a note; kept for a minute per user (its links follow the user's policies)
audit/consolethe channel's option reads "polls (Polls)"; a line of the source's links under the filter
audit/recent, audit/alerts, audit/exportthe line of links; on alerts, a rule on the source's names shows the source under its event
audit/chartsthe line of links and one card per chart: a bar per series and day, the numbers in a table (left out when the page is filtered on another channel)
audit/eventfor a record of the source's channel, a card with the source's fields and links; the Object and Target lines link through linkFor()
CSV exportaudit/export/(channel)/polls/(format)/csv and exp:audit export --channel=polls --format=csv: the generic columns, then exportColumns() (a value that is not there is empty, a boolean 1 or 0, a list or a hash JSON); another channel and no channel filter keep the generic columns. The JSON and JSON lines exports carry every field anyway
left menua link into the source's channel (audit/console/(channel)/polls, or one named with AuditChannel_<key>=polls in [Leftmenu_audit]) is left out for a user who may not read the channel
audit/settingsa Sources card: identifier, label, channel, class and what keeps it from working (shown only when a source is registered)
policiesthe Channel limitation of audit/read lists every channel of Channels[], an extension's named after its source

Rules every source follows, enforced by expAuditSourceRegistry:

  • Addresses are module/view paths with view parameters (expai/log/(errors)/1), never a query string, an absolute URL, a leading slash or ... Anything else is dropped. Build console addresses with consoleUrl().
  • Only links the user may follow. The source checks its own policies (hasPolicy( $module, $function )); the console checks audit/read and the channel.
  • Never throw into a view. A source that throws is left out of that call and written to error.log.
  • Export only safe columns. exportColumns() names fields for a spreadsheet that is handed on: counts, codes, identifiers, never a text a user typed, an answer, a key or a token. Column names are a-z, digits and _ (at most 48), paths are ranks into the record (after.usage.in); a generic column's name, a malformed name or path is dropped.
  • Name fields so the privacy rules leave them. A field whose name contains password, secret, token, salt, credential, privatekey or apikey is written as [secret] (input_tokens too): put counts under a neutral key (expai: after.usage.in, after.usage.out).
  • With the extension inactive nothing changes: its settings are not read, no source, no channel, no menu entry. Its files stay in var/log/audit/ and are listed by exp:audit status like any channel's.

Exponential Intelligence (extension expai) is the reference: classes/audit/expaiauditsource.php, its branch expaiauditbranch.php, the recorder expaiaudit.php, and its documentation doc/audit-trail.md.

A sink, an alert rule class, an archive format

Each is a class named in audit.ini, counted by the RAD survey (registries auditsinks, auditalertrules, auditformats):

class myExtAuditSink extends expAuditSinkBase          // implements expAuditSink
{
    public function name()    { return 'myext'; }
    public function problem() { return trim( (string)$this->setting( 'Endpoint' ) ) === '' ? 'no Endpoint is set' : ''; }
    /** @param array[] $records already through the privacy rules; @return int how many were delivered */
    public function deliver( array $records ) { /* send them */ return count( $records ); }
}
// audit.ini: [AuditSinkSettings] SinkClasses[myext]=myExtAuditSink ; [AuditSink_myext] Endpoint=… ; [AuditChannel_access] Sinks[]=myext
  • Alert rules implement expAuditAlertRule::evaluate( array $config, array $records, $state ) and return the alerts (group, count, events, message). They are registered as [AuditAlertSettings] RuleClasses[<key>]=<class> and used by [AlertRule_<name>] Class=<key>. Extend expAuditAlertRuleBase for the window state.
  • Format handlers implement expAuditFormatHandler (name, problem, extension, compress( $source, $target, $level ), open( $archive ) returning a stream of the plain lines). Extend expAuditFormatBase and register [AuditArchiveSettings] FormatHandlers[<name>]=<class>.

problem() returns '' when the class can work here, otherwise the reason. The settings view and sinks list show it.

Settings writes and the 4.x API

  • Every write through expIniEditor (exp:ini, the debug bar, the settings views) is recorded as system.setting.write, one record per changed variable, with the diff. Values of variables that expIniEditor::isSecret() recognises become [secret]. A write to audit.ini is also system.audit.setting.write, and setting Audit to anything but enabled is system.audit.disable, recorded before it takes effect.
  • eZAudit::writeAudit( $name, $attributes ) keeps working for extensions. The old names are mapped through [AuditCompatSettings] Map[], and the attributes go under after.legacy (Comment, NeverRecord[] keys and secrets dropped). Unmapped names become system.legacy.<name>. The kernel no longer calls it: its call sites raise native events.
  • The location names of LoveStack (mugoweb/ezpublish-legacy, commit 9c931192e0, with thanks to Mugo Web) are mapped as well, with their attributes read into the record: location-assign is content.node.add (object; after: parents, main node), location-remove is content.node.remove (the removed node as the object; before: parent, main) and main-node-update is content.node.main (the new main node as the target; after: main node, parent). Their files (location_assign.log, location_remove.log, location_main_update.log) are in AuditFileNames[], so exp:audit import reads them and (legacy_file) finds their events.

Location changes: who added, removed or chose a location

The kernel records the location changes itself, from eZContentOperationCollection, in the channel content:

What the editor didEventConsole labelAboutBefore → after
Added a location (Locations window, Add locations, a content job)content.node.add, one per new locationLocation addthe object; the new node is the targetlocations → locations, node, parent, main node
Removed a location (Locations window, a content job)content.node.removeLocation removethe removed nodeparent, path, object id, name, main → –
Chose another main locationcontent.node.mainMain location changethe object; the new main node is the targetmain node → main node, parent
Removed the main locationcontent.node.remove, then content.node.main with cause location-remove

content.node.remove is on by default. content.node.add and content.node.main are off, as before: switch them on with Enabled[]=content.node.add and Enabled[]=content.node.main (or content.node.*) in settings/override/audit.ini.append.php. The console shows the records under their labels, and audit/event/<id> lists object, target and the before and after values, with links to the nodes. On an upgrade nothing changes until they are switched on.


4. Maintenance guide

4.1 Cron

The cronjob part audit (cronjobs/audit.php, class Exponential\Cronjob\Kernel\Audit) does all the scheduled work:

  • every run: the incremental index (at most 120 s), delivery of the sink spools (webhook, network syslog, mail), alert evaluation over records since its cursor, the check that Audit was not switched off by hand;
  • once a day, on the first run after [AuditRotationSettings] RotateAfter (00:15, the site's time zone): close idle day files, verify every channel, archive what is older than LiveDays, purge what is older than ArchiveDays, pseudonymise index rows older than PseudonymiseAfterDays, remove index rows older than KeepDays, and write the signed checkpoint.

It ships in the frequent group of settings/cronjob.ini, so the usual crontab line is enough:

*/5 * * * * cd /path/to/exponential && php runcronjobs.php -q frequent

On its own: php runcronjobs.php audit. A run takes <LogDir>/.cron.lock, so two runs never overlap. The daily marker is <LogDir>/.cron-daily. On the reference installation the part runs every minute in the publishing group (settings/override/cronjob.ini.append.php). Each run is recorded as system.cronjob.run (part audit). The dashboard's Operations card warns when the part has not run for an hour.

4.2 Rotation and archives

  • Daily files. Files are named by the UTC date at the moment of writing. The first write of a new UTC day closes the previous day's file with system.audit.file.close. exp:audit rotate closes a channel that nobody wrote to since.
  • Size. Past MaxFileSize (64M by default, 256M for read), the day continues in <channel>-<date>.2.jsonl, .3.jsonl …, linked in the same chain.
  • Archiving (daily task, or exp:audit archive): each closed day older than LiveDays (90; read: 30) is verified (VerifyBeforeArchive), compressed with the channel's ArchiveFormat into <ArchiveDir>/<channel>/<YYYY>/<file>.jsonl.<ext>, read back and compared by sha256 (VerifyAfterArchive), and given a signed manifest <channel>-<date>.manifest.json. Only then is the live file removed. A broken day is archived all the same (removing evidence would be worse); its manifest says broken and system.audit.chain.broken is recorded.
  • Formats: gzip (zlib, always there), bzip2 (ext-bz2), xz (the xz binary), zstd (ext-zstd or the binary), zip (ext-zip). A handler that cannot work here reports why in audit/settings, and archiving falls back to gzip. The manifest names the handler actually used. On the reference installation all five work.
  • Where archives go. ArchiveDir (default log/audit/archive under the var directory) is best on another file system, or on a mount the web server user can add files to but not change (see 5.11). Modes: FileMode=0440, DirMode=0750.

4.3 Retention

Per channel in [AuditChannel_<channel>]: LiveDays (uncompressed in LogDir) and ArchiveDays (kept as archives). Shipped: 90 and 730 for content, access, system and commerce, and 30 and 90 for read. The shipped channel blocks set both values, so a default in [AuditRotationSettings] reaches a channel only when you clear the channel block's own value. Set them per channel (see the small-site configuration). The index keeps its rows for [AuditIndexSettings] KeepDays (730).

Retention never removes a signing key from the settings: exp:audit purge names the keys that retained archives still need. Remove a key yourself only when no archive signed with it remains.

4.4 Verify and restore

  • Verify every day (the cronjob part does), after any incident, and before handing records to anyone: exp:audit verify (live), exp:audit verify --archives (everything), --channel=, --date=. Exit code 0 = intact, 1 = broken, 2 = error, so it fits monitoring: ./console exp:audit verify -q --allow-root-user || alert. Verifying the 14 MB system channel took 2.5 s on the reference installation.
  • Verify now in the console and on the dashboard (manage) verifies every channel the user may read and stores the result. Plain page views show the stored state and never walk the files.
  • What to do with BROKEN: do not edit or remove anything. Note the file, line and kind, copy the directory somewhere safe, and compare with what left the server (syslog or journal copies, webhook receiver, checkpoints sent by the sinks, archives). 5.3 explains each kind. A broken file stays a record: the console still shows it and marks it.
  • Restore an archived day for reading: exp:audit restore --channel=<c> --date=<YYYY-MM-DD> [--to=<dir>], then search --files or read the file. The day is decompressed into <LogDir>/restored/ and checked against the manifest's sha256.

4.5 Alerts and their recipients

Alert rules are [AlertRule_<name>] blocks listed in [AuditAlertSettings] Rules[]. Each firing is a system.audit.alert record sent to the rule's Sinks[]. Mail goes to:

  1. the rule's own [AlertRule_<rule>] Recipients[], else
  2. [AuditAlertSettings] Recipients[], else
  3. [AuditSink_mail] Receivers[] (the older name), else
  4. admin = site.ini [MailSettings] AdminEmail.
RecipientMeans
adminsite.ini [MailSettings] AdminEmail
address:[email protected] (or a bare address)that address
group:securitythe named list [AlertRecipients_security]: Addresses[] and Recipients[] (any kind, other groups too; loops are cut)
user:14, login:editor1that user, with the e-mail address it has when the mail is sent
usergroup:12, usergroup:<remote id>every enabled user below that user group, sub-groups included
role:Administrator, role:<id>every enabled user the role is assigned to, directly or through a group

Addresses are deduplicated without regard to case. Disabled users and invalid addresses are left out. Nothing in the event itself ever becomes a recipient. Mail is sent by the cronjob part only (a request never waits on SMTP), one mail per recipient, at most one per rule and recipient within [AuditSink_mail] Throttle (900 s). Check with exp:audit alerts recipients [--rule=<rule>], which lists every address with the entries that produced it and any problem (an unknown group, a disabled user, a missing role).

To tune a rule, replay it first: exp:audit alerts test mass_delete --replay=2026-09-01. Rule classes: threshold (Threshold events in Window seconds per GroupBy; fires once per window, again when the count doubles; CountChildren=enabled counts children), match (every matching record; Policies[] for role grants), schedule (outside BusinessDays/BusinessHours in the site's time zone, once per group and window).

4.6 Keys

  • What exists: an installation id, signing keys (SigningKey[<key id>], one active) and a pseudonym key, all in settings/override/audit.ini.append.php (mode 0640, never committed). They are generated on the first event, which records system.audit.key.create. exp:ini, the debug bar and the settings view mask them. Only ids and fingerprints are shown.
  • Back them up with the rest of settings/override, and treat the backup as a secret. Without the signing keys, archives and checkpoints signed with them can no longer be verified (unknown_key). Without the pseudonym key, new pseudonyms no longer match old ones.
  • Rotate the signing key once a year (the dashboard reminds you after 365 days), and at once if the key may have been read: exp:audit key rotate. The new key becomes active, the old ones stay for their archives, and system.audit.key.rotate is recorded. The command clears the INI cache so that other processes read the new active key; if a Velocity worker keeps signing with the old one, restart Velocity.
  • The pseudonym key is replaced only on purpose: exp:audit key rotate --pseudonym, then exp:audit pseudonymise. Old and new pseudonyms of the same person then differ.
  • Keys from a secrets store: set [AuditKeySettings] GenerateKeys=disabled and provide InstallationID, ActiveSigningKey, SigningKey[<id>] (base64, 32 bytes) and PseudonymKey in settings/override yourself.
  • Several servers must share one key file (see the cluster configuration).

4.7 The index

The index (expaudit_event, expaudit_cursor, expaudit_file in the site's database) is a copy of the files for the console. Nothing writes to it during a page request. It is filled:

  • by the cronjob part (every run, incremental, at most BatchSize rows per batch, rows and cursor in one transaction, so a crash never indexes a line twice or skips one);
  • briefly when a console view opens (a bounded catch-up), and by exp:audit reindex --incremental;
  • from scratch by exp:audit reindex (all channels, or --channel=).

The indexer checks each record's prev against the cursor. A mismatch marks the file broken in expaudit_file and records system.audit.chain.broken once. Full-text search is FTS5 with the trigram tokenizer on SQLite, FULLTEXT on MySQL/MariaDB, a generated tsvector with GIN on PostgreSQL, Oracle Text when available, and LIKE everywhere else (and for terms under three characters). Imported records (<LogDir>/imported/) are indexed without a chain check. Reads are indexed only with IndexReads=enabled.

Repeated records

The index holds one row per record id (id is its primary key), so a line that repeats an id already indexed is read and left out. Before 2026-10-12 (#396) a process forked with records still in its buffer (pcntl_fork(): the content job tests' workers, the asynchronous publisher, a worker forked after its parent recorded something) wrote its copy of the parent's buffer and open parent events too, so the same records went into the channel again, once per child, each time with a new seq, prev and hash. The repeated lines are part of the chain (verify says INTACT and counts them, see Verifying), so they stay in the files: removing them would break the chain. The index, the console, its counts, charts and alerts, the web export and exp:audit export (which leaves a repeated id out the same way) all show each record once.

On the reference installation (2026-10-12): every repeat was in the content channel, 13 643 lines repeating 1 692 ids, all written by PHPUnit runs of the content job tests (records content.job.create/resume of the test process, repeated by each forked worker), on 10-04 (2 322), 10-05 (5 107), 10-06 (4 414) and 10-12 (1 800). The files held 41 295 content lines, 27 652 distinct, and the index 27 652 rows: nothing was missing. Every other channel matched day by day; read is not indexed (IndexReads=disabled).

Since #396 expAudit remembers the process its buffer belongs to. A forked child starts with an empty buffer, no open parents and no failed records of its parent, and a request id (and request.pid) of its own; it keeps the job, the run and the parent stack, so its records still name the job and the parent event they work for. The parent writes its records once.

To compare files and index per channel and day, count the distinct ids of the files (not their lines) against SELECT channel, COUNT(*) FROM expaudit_event GROUP BY channel; a difference other than read and the few records written since the last index run is a real gap, and exp:audit reindex --incremental (idempotent: ids already in the index are skipped) fills it.

Size on the reference installation: about 0.8 to 1.1 KB per record in the files. Plan for about 1.2 KB per row in the index.

Events per dayIndex rows after 2 yearsIndexLive files (90 days)Archives (2 years, gzip about 1:8)
1 000 (small site)0.73 Mabout 0.9 GBabout 80 MBabout 80 MB
10 000 (editorial site)7.3 Mabout 9 GBabout 0.8 GBabout 0.8 GB
100 000 (reads sampled on)73 Mkeep reads out of the index (IndexReads=disabled)8 GB8 GB

4.8 Upgrades

An installation made before 6.0.15:

  1. Index tables: php update/common/scripts/6.0/createaudittables.php (every engine: MySQL/MariaDB, PostgreSQL, SQLite, Oracle, MongoDB). Tables that exist are left alone, and the script then indexes the files (--no-index skips that, --dry-run only reports). On the reference installation, where the tables exist:

    $ sudo -u <site user> php update/common/scripts/6.0/createaudittables.php --no-index
    Database: sqlite, missing tables: none
    the tables exist already
    Full-text search: fts5

    The SQL upgrade files update/database/{mysql,postgresql,sqlite}/6.0/dbupdate-6.0.0-6.0.15.sql contain the same statements.

  2. A 4.x override that switches the audit off. 4.x shipped Audit=disabled, and many sites kept that in settings/override. Check where the value comes from:

    $ ./console exp:ini where audit.ini/AuditSettings/Audit --allow-root-user
    audit.ini/AuditSettings/Audit (load order of siteaccess site)
     1. settings/audit.ini                                           scope default
          Audit=enabled
    In effect:
          Audit=enabled

    Remove an old Audit=disabled line unless you mean it: ./console exp:ini rem audit.ini/AuditSettings/Audit global.

  3. The old logs: exp:audit import --dry-run, then exp:audit import (add --keep-originals to leave the files in place). See 3.3.

  4. Code: php bin/php/ezpgenerateautoloads.php -k, clear caches, reload PHP-FPM, and restart Velocity (its workers record nothing from classes they have not loaded).

  5. Policies: give audit/read (and audit/manage) to whoever should read the audit. Only Administrator has them.

4.9 Changing settings

Never edit settings/audit.ini. Put changes in settings/override/audit.ini.append.php (or a siteaccess's or an extension's audit.ini.append.php), preferably with exp:ini, which records the write and keeps a backup. Preview with --dry-run:

$ ./console exp:ini set audit.ini/AuditChannel_content/ArchiveDays 365 global --dry-run --allow-root-user
--- a/settings/override/audit.ini.append.php
+++ b/settings/override/audit.ini.append.php
@@ -6,4 +6,7 @@
 ActiveSigningKey=********
 SigningKey[k1-20261002-3fa94c1b]=********

+[AuditChannel_content]
+ArchiveDays=365
+
 */ ?>
Dry run: set audit.ini/AuditChannel_content/ArchiveDays in global, nothing written

$ ./console exp:ini get audit.ini/AuditChannel_content/ArchiveDays --allow-root-user
730

After a change: exp:ini clears the INI cache itself. After a hand edit, run php bin/php/ezcache.php --clear-tag=ini. If a Velocity worker still shows the old value, restart Velocity. A new class (a sink, a branch) always needs a Velocity restart. Every write to audit.ini is recorded (system.audit.setting.write), and switching the audit off fires the audit_disabled alert.

4.10 Troubleshooting

SymptomCauseFix
No Audit tab, no dashboard blockthe user lacks audit/read; or Velocity's workers started before the audit classes existedassign a role with audit/read; restart Velocity
Nothing is recorded at allAudit=disabled somewhere (the dashboard says "Audit is switched off"; system.audit.disable was recorded)exp:ini where audit.ini/AuditSettings/Audit, remove the override
Nothing recorded, error.log says "writing the audit channel …" or AUDIT-UNWRITTEN linesLogDir not writable (owner, mode, full disk)fix ownership (chown -R <site user>: var/<site>/log/audit, dirs 0770, files 0640) or free space. The AUDIT-UNWRITTEN lines hold the records that could not be written
Records from Apache but not from Velocity (:8080)the workers predate the audit classes (every hook is guarded by class_exists)restart Velocity
The files have more lines than the console has events; verify says N distinct (M repeated)lines that repeat a record id, written by forked processes before #396 (Repeated records)nothing to do: the index and the exports show each record once; the lines stay, they are part of the chain
The console misses the newest events, or charts are emptythe index is behind (cronjob part not running) or its tables are missingexp:audit reindex --incremental; createaudittables.php; check cron
Dashboard: "The audit cronjob part has not run for over an hour"no crontab line, or the group that holds audit.php is not runadd runcronjobs.php frequent (or audit) to the crontab
verify says BROKENa file was changed after it was written (see the kind)do not repair; follow 4.4
verify says no_origin for the oldest live fileits archived predecessor is not where the verifier looks (ArchiveDir changed, archives moved)put the archives back under the configured ArchiveDir, or verify with --archives
unknown_keya manifest or checkpoint is signed with a key id no longer in the settingsrestore that SigningKey[<id>] from the settings backup
repaireda process died in the middle of a write; the next write appended system.audit.chain.repairnothing to do: the torn bytes stay in the file for inspection
An old day file is not archivedit is still its channel's newest file (nothing written to that channel since)exp:audit rotate, or wait for the next write
journalctl -t exponential finds nothingTransport=devlog on a journald that does not parse RFC 5424 headers on /dev/loguse Transport=local (journald's native socket)
The webhook spool growsthe receiver is down or refuses (system.audit.sink.failed, once per sink and hour)exp:audit sinks list shows the last error; fix the receiver; exp:audit sinks flush
No alert mailno recipient, mail transport in debug mode (DebugSending), throttled, or the cronjob part does not runexp:audit alerts recipients; site.ini [MailSettings]; check cron
search --name=…login* exits 2: "a * may only be the whole pattern …"a pattern is *, a prefix ending in .* or a whole name--name='access.session.login.*'
search --q=… says "invalid option --q'" | -qis the standard quiet option |--query=…`
--from seems to miss recordsa bare --from/--to is UTC, as printed (the console's bare times are site time)give UTC, or add the offset: --from=2026-10-03T01:30+02:00
An admin form answers 503 "Not done: the audit cannot record it"; AUDIT-REFUSED in error.logOnWriteFailure=refuse and LogDir cannot be writtenfix LogDir as above; see 4.13
"Verify now" asks for the passwordReauthForManage=enabledenter it; it holds ReauthMinutes in that session
A settings change has no effectINI cachephp bin/php/ezcache.php --clear-tag=ini (exp:ini does this itself)
The log directory grows fastreads on, or a chatty extension name onexp:audit status (today's counts per channel), search --channel=… --limit=…; switch the name off in Disabled[]; lower SampleRate

4.11 Performance tuning

Measured cost on the reference installation (2 000 requests each, in the kernel; 9.3): a page that records nothing pays 0.08 ms more with the audit on. A request that records one event pays about 0.6–0.8 ms (one append). Content jobs show no measurable difference. To go further:

  • Keep reads off, or sample them low (SampleRate=0.001) and limit them to Sections[]/Classes[].
  • Switch off names you do not need: [AuditEventSettings] Disabled[]=content.node.sort. A name that is off costs one array lookup (0.001 ms).
  • Long commands and workers: raise MaxEvents/MaxBytes/FlushInterval for fewer, larger appends.
  • Keep ImmediateEvents[] to security events. Each one is its own append.
  • Network sinks (webhook, udp/tcp/tls syslog) never slow a request: they are spooled and sent by the cronjob part. Transport=local writes to journald at flush (included in the measured 0.6 ms).
  • Big indexes: KeepDays shorter than ArchiveDays, IndexReads=disabled, BatchSize up for faster catch-up.
  • Put LogDir on local disk, and ArchiveDir elsewhere.

Streaming the export

The download of audit/export is sent while it is read (expAuditExportStream, #393), so a large filter starts downloading at once and never holds the whole file in memory:

  • Reading. The first page of 500 comes from the console's own page (with the records of the live files that are not indexed yet, on top), the rest by position in the same order: the ordered ids of the index in segments of 10,000 (expAuditQuery::orderedIds(), after the last id read), then the full rows 500 at a time by id (byIds()). The export used OFFSET pages of 500 before, and each made the database sort every whole record of the filter again: on SQLite 0.5–0.7 s a page, 35 s for 27,000 records. The bytes are the same as before for the same filter. MongoDB, or a database that refuses that SQL, keeps the OFFSET pages. The SQL query cache is switched off while an export reads, so its pages do not fill the cache.
  • Sending. Content-Type, Content-Disposition: attachment, Cache-Control: private, no-store, X-Content-Type-Options: nosniff, no Content-Length, and X-Accel-Buffering: no: Exponential Velocity (0.0.4.49 and later) streams a response with that header from its pool workers instead of collecting it, and a proxy in front does not hold it either. The output buffers are ended and the first piece (the CSV header line) is flushed at once, then every 64 KB. The session is closed first (eZSession::stop()), so the user's other pages do not wait.
  • In the background. Above MaxExportRecords, or whenever a file is wanted later, "Export in the background" starts the content job auditexport (expContentJobAuditExport, content.ini JobTypes[auditexport]). It writes var/<site>/jobs/content/<id>.export (site user, 0660, no web access) in batches of BatchSize × 100 records, keeping the stream's state and the file's length in its checkpoint: a job resumed after a crash cuts what the last batch wrote after its checkpoint and writes it again. The job page shows the progress and opens the export page when it is done; the file is removed with the job (KeepDays). The download of the file is streamed the same way and recorded as system.audit.read with the job.

Measured on the reference installation (channel content, 26,889 records up to a fixed time, CSV of 7.7 MB):

beforeafter
Apache + PHP-FPM (:443), first byte / total0.75 s / 38.3 s0.45 s / 3.3 s
Velocity (:8080), first byte / total504 after 30.6 s0.4–1.3 s / 3.4–4.1 s
reading and formatting only (CLI), time / peak memory36.4 s / 130 MB2.9 s / 22 MB
background job (6 batches of 5,000)–4 s, peak 18.9 MB; 7–9 s from the click to the file on the export page

The output is byte for byte the same as before for a CSV of 528 records (ai channel, with its source columns), of 4,390 records as CSV, JSON lines and JSON, and of the 26,889 records; the background file of the same filter too.

4.12 Privacy and GDPR tasks

(This describes what the audit does. It is not legal advice. Document it in your own record of processing.)

  • Lawful basis: legitimate interest in the security and accountability of the system (Art. 6(1)(f)), and a legal obligation where a law requires access logging (Art. 6(1)(c)). Art. 32 names logging as a security measure.

  • Minimisation by default: addresses truncated to /24 (IPv6 /48), sessions hashed (the session id is never recorded), user agents reduced to browser and OS family, e-mail addresses and attempted logins of unknown users hashed, no content values, never passwords, hashes, tokens, reset keys or secrets, no POST bodies, reads off.

  • Stricter: per field full | truncate | hash | off in [AuditPrivacySettings] Field[], BeforeAfter=keys, and shorter retention. See the strict-privacy configuration. A hash is a keyed HMAC, so the same person still groups together (alerts by address keep working) but cannot be read back.

  • Retention: 90 days live and 2 years archived, per channel, removed automatically, and each removal recorded.

  • Pseudonymisation: the index replaces login, address, user agent and personal fields by their hashed form after PseudonymiseAfterDays (daily, recorded as system.audit.pseudonymise; preview with exp:audit pseudonymise --dry-run). The files are not rewritten (that would break the chain). The full values remain only in the archives, readable with audit/manage. A site that must not keep them even there uses hash from the start.

  • Access request (Art. 15): every record by or about a user, pseudonymised ones included:

    $ ./console exp:audit export --subject-user=14 --format=jsonl --out=var/tmp/audit-stage6/subject-14.jsonl --allow-root-user
    3625 record(s) exported as jsonl to var/tmp/audit-stage6/subject-14.jsonl (sha256 b82831ef24eeb594b8da40b21498fdefc0ba7f3368c3668997ec042cfa7ad283)

    Review the file before you hand it out: it holds records of other people's actions on that user too.

  • Erasure (Art. 17): audit records are kept under Art. 17(3)(b) and (e) for their retention period. Deleting one would break the chain. Answer with the retention period and the pseudonymisation already applied.

4.13 When the audit cannot write

[AuditSettings] OnWriteFailure decides what happens to an action whose record cannot be written (full disk, permissions, a missing mount):

  • continue (the shipped default): the action goes on. error.log gets the failure and, at the end of the request, one AUDIT-UNWRITTEN <channel> <record> line per record, so nothing is lost silently.
  • refuse: the actions of the kind that is written at once ([AuditBufferSettings] ImmediateEvents[]: access.*, system.audit.*, system.setting.write) are refused while their channel cannot be written, so nothing security-relevant happens unrecorded. Each refusal is an AUDIT-REFUSED line in error.log, with the reason.

What refuse guards, and only this (expAuditGuard):

ActionEvent checkedRefused with
a POST by a signed-in user to a view of [AuditReadSettings] AlwaysModules[] (setup, role, user, audit, settings): role and policy changes, user administration, setup forms, the audit's "Verify now"the access channel (access.view.sensitive), and for setup/settings the system channel (system.setting.write), for audit system.audit.readHTTP 503 with the page audit/refused.tpl (it names the channel, never a path); the view does not run
an INI write through expIniEditor (exp:ini, the debug bar, the settings forms)system.setting.writeexpIniException "Refused: …", nothing written
exp:audit rotate, archive, restore, purge, reindex, pseudonymise, import, export, checkpoint, key rotateits own system.audit.* namethe message and exit code 2; --dry-run is never refused

Never refused, so the site stays usable: GET requests, anything an anonymous visitor does, pages, content editing, the views in [AuditSettings] RefuseExemptViews[] (user/login, user/logout: people can still sign in and out; their records go to error.log), and reading and diagnosing the audit (exp:audit status, channels, tail, search, verify, the console's GET views).

"Cannot be written" is decided before the action by a probe of the event's channel that writes nothing (the directory exists or can be created and is writable, the channel lock opens, the newest file can be appended to, more than 1 MB is free), and by the request's own last write of that channel. With the audit switched off nothing is refused (there is nothing to record). To leave refuse while the audit cannot write, fix LogDir, or edit settings/override/audit.ini.append.php by hand: an exp:ini write would itself be refused as unrecorded.

Shown without touching the live settings, with the audit pointed at a log directory below a plain file (expAuditConfig::setOverride() in one process):

OnWriteFailure=continue
  system.setting.write   allowed
  access.role.assign     allowed
  system.audit.archive   allowed
  content.node.move      allowed
OnWriteFailure=refuse
  system.setting.write   REFUSED (system: the directory var/tmp/audit-refuse-demo/blocker/log does not exist and cannot be created)
  access.role.assign     REFUSED (access: the directory var/tmp/audit-refuse-demo/blocker/log does not exist and cannot be created)
  system.audit.archive   REFUSED (system: the directory var/tmp/audit-refuse-demo/blocker/log does not exist and cannot be created)
  content.node.move      allowed

and in error.log:

AUDIT-REFUSED system.audit.archive (OnWriteFailure=refuse): the audit channel system cannot be written: the directory var/tmp/audit-refuse-demo/blocker/log does not exist and cannot be created

content.node.move is buffered, not written at once, so it is never refused. The tests (expAuditFilterGuardReauthTest AG-06 to AG-08) prove the guard, the refused settings write and the refused admin POST the same way.


5. Internals

5.1 Event flow

 call site (kernel hook, extension, eZAudit::writeAudit, ezpEvent bridge, settings write)
   │  expAuditHook::emit( name, closure )  ──► off?  return (one array lookup, the closure never runs)
   ▼
 expAudit::event()
   │  expAuditConfig::get()          settings snapshot per request (keyed by REQUEST_TIME_FLOAT)
   │  expAuditTaxonomy::decide()     on? sampled? channel, severity, immediate? (compiled once per settings hash)
   │  parent / depth / child limits
   │  build(): request and actor context, ULID id, time ──► expAuditPrivacy::apply()   (privacy before anything is kept)
   ▼
 immediate?  ── yes ──► write now
   │ no
   ▼
 expAuditBuffer (per channel) ── full (MaxEvents/MaxBytes) or FlushInterval (CLI) ──► flush early (+ system.audit.overflow)
   │  end of request: eZExecution cleanup handler → expAudit::flushFinal()
   │  fatal error: flushOnFatal() (records system.error.fatal); last resort: shutdown function
   ▼
 expAuditWriter::append( channel, records )
   │  flock <LogDir>/.<channel>.lock, read the head from the file's last line (never from memory),
   │  torn last line? → system.audit.chain.repair; new UTC day? → file.close + new file with file.open;
   │  seq, prev, hash per record (canonical JSON), one fwrite, fflush, unlock
   ▼
 after the records are in the file:
   ├─ expAuditSinkRegistry::dispatch()   syslog local at once; webhook / network syslog / mail → spool
   ├─ expAuditAlertEvaluator::afterWrite()   only rules whose Event matches a written record touch their state
   ├─ key creation events, daily checkpoint (first write of a UTC day)
   ▼
 cronjob part (every run): index ◄── files, spools → sinks, alert windows; daily: rotate, verify, archive, purge,
                           pseudonymise, checkpoint

Writing never depends on the database, the network or a sink. The file is always written first, and every copy follows it.

5.2 The record

One record is one line of canonical JSON. Fields whose value is null are left out:

FieldMeaning
vformat version (1)
idevent id: a ULID, 26 characters, sortable by time
seqposition in the file: 1, 2, 3 … without gaps (line number = seq)
name, channel, severity, verbtaxonomy name, channel written to, RFC 5424 severity, the action
timeRFC 3339 UTC with milliseconds, when it happened
requestid (r-<ULID>, also the header X-Exp-Request-Id), siteaccess, method (CLI on the command line), url (truncated: query values and secret path parameters cut), module, engine (apache, velocity, frankenphp, cli), host, pid, ms, status (the exit code for a command)
actoruser_id, login, roles, session (h: + 16 hex, never the id), ip (a network), ua (family), impersonator (the process user when a content job acts as its owner), cli (OS user, command line with secrets masked)
object, targettype, id and identifying fields
before, afterthe values that changed
result, reason, errorsuccess, refused or failed; why
parent, depth, job, runcorrelation
xan extension's fields under its name
imported, sourceimported 4.x records only (no prev/hash)
prev, hashthe chain

Canonical JSON (what the hash covers): the record without hash, object keys sorted by their UTF-8 bytes at every depth, no whitespace, / and non-ASCII characters not escaped, integers only (durations are integer ms, money is a string). An empty object is {}. The line on disk is that form with "hash" in its sorted place, so a line whose bytes are not canonical is itself a sign of editing (noncanonical).

A complete record from the reference installation (wrapped here; one line on disk; address and host replaced):

{"actor":{"ip":"203.0.113.0/24","login":"admin","roles":[2],"session":"h:7617a963afc3ca9c","ua":"curl 7","user_id":14},
 "after":{"handler":"standard"},"channel":"access","depth":0,
 "hash":"sha256:949b036fdd6f0c59e0214ca1032d56a7a514a4f6b43f8189214b3774b8cc72ba","id":"01M3ZH49HXBMS8TY7HJCA7NWE0",
 "name":"access.session.login","object":{"id":14,"login":"admin","type":"user"},
 "prev":"sha256:4c3e5dc8417119194c764488576c7c66afe914cea1eb74119465a824edb376bf",
 "request":{"engine":"velocity","host":"web1","id":"r-01M3ZH48ZGGSYK3N7NR3Z2F3JX","method":"POST","module":"user/login",
            "ms":630,"pid":1322013,"siteaccess":"admin","status":200,"url":"/admin/user/login"},
 "result":"success","seq":26,"severity":"info","time":"2026-10-03T00:02:54.653Z","v":1,"verb":"login"}

5.3 The hash chain and what verification finds

  • hash = "sha256:" + hex( SHA-256( canonical( record without hash ) ) ). prev is the previous record's hash in the same channel, across all its files.
  • The first file of a channel starts from the genesis value sha256( "exponential-audit:" + installation id + ":" + channel ), so a chain cannot be passed off as another installation's or channel's.
  • Each file starts with system.audit.file.open (seq 1, naming the previous file, its last seq and hash) and a closed file ends with system.audit.file.close.
  • Checkpoints: once a day, and on exp:audit checkpoint, system.audit.checkpoint in the system channel holds every channel's current file, seq and hash, signed with HMAC-SHA-256 and the active key, and goes to the sinks. A chain recomputed after the fact no longer matches a checkpoint signed before.
  • Manifests: each archived day has a manifest (files, counts, first and last seq and hash, sha256 of the plain and the compressed file, the verification result, previous_manifest, key_id, hmac). Manifests of a channel are chained, so removing a whole archived day shows too.

What verify reports:

KindMeans
alteredthe line's content does not give its hash: the record was changed
noncanonicalthe line is not in canonical form: edited by hand or by a tool
linkprev is not the previous record's hash: a line was removed or inserted before it
gap / reorderedseq jumps forward / goes back: lines removed / swapped
unparseablenot JSON
missing_filea file between two others is gone
truncatedthe next file's open record names a different end than the previous file has: lines were cut off, or the previous file was rewritten
no_originthe first file starts neither from genesis nor from an archived file
rewrittena signed checkpoint recorded another hash at that seq: the chain was recomputed
checkpoint_invalid, unknown_keya checkpoint whose HMAC does not verify, or whose key is not in the settings
archives: archive_sha256, hmac_invalid, previous_manifestan archive changed, a manifest edited, an archived day removed
repaired (not a break)a torn last line was followed by system.audit.chain.repair

The verifier keeps going after a break (using the record's own prev), so a report lists every damaged stretch. 9.2 shows each kind produced on a copy of the reference installation's real files.

5.4 Taxonomy and registries

  • A name is domain.subject.action[.detail[.detail]]. Settings use patterns: a whole name, or a prefix ending in .* that also matches the prefix itself (content.node.remove.* matches content.node.remove and content.node.remove.trash).
  • decide(): an always name is always on. A sampled name is on only with Reads=enabled and then sampled at SampleRate. Otherwise the most specific of Enabled[]/Disabled[] wins, and on a tie Disabled[] wins. With no match the registry default applies. MinSeverity drops lower severities. The answers are compiled once per settings hash and kept for the life of the process.
  • Channel: an exact Route[] entry, then the branch's own channel, then the most specific Route[] pattern, then DefaultChannel. Only channels listed in Channels[] are used.
  • The four registries, which the RAD survey (Setup › RAD) counts and checks:
RegistrySettingOn the reference installation
auditbranches[AuditEventSettings] Branches[]0 (none registered)
auditsinks[AuditSinkSettings] SinkClasses[]syslog, webhook, mail; 0 broken
auditalertrules[AuditAlertSettings] RuleClasses[]threshold, match, schedule; 0 broken
auditformats[AuditArchiveSettings] FormatHandlers[]gzip, bzip2, xz, zstd, zip; 0 broken
(sources)[AuditSourceSettings] Sources[]none in the kernel; expai registers expai (channel ai). Listed with their problems in audit/settings, not counted by the RAD survey

5.5 Buffering and flushing

Events are collected per channel and written in one append per channel at the end of the request, through the eZExecution cleanup handler that ezpKernelWeb::shutdown() and eZScript::shutdown() run. A fatal error is caught by flushOnFatal(), which also records system.error.fatal, and a shutdown function is the last resort. ImmediateEvents[] (access., system.audit., system.setting.write) are written at once, so a security event is in the file even if the request then dies. Past MaxEvents/MaxBytes the buffer flushes early and records system.audit.overflow once. Commands, cronjobs and content job workers also flush every FlushInterval seconds. A failed append keeps the records for one more try at the end. If that fails too, each record becomes an AUDIT-UNWRITTEN <channel> <json> line in error.log, so no record is lost silently. The buffer, the open parents and the failed records belong to the process that collected them: a child forked with pcntl_fork() drops its copy of them on its first event, flush or reset, and gets a request id of its own, so only the parent writes them (Repeated records).

5.6 The index tables

expaudit_event: one row per record (id, channel, seq, file_name, name, domain_name, severity 0–7, time_ms, request_id, siteaccess, engine, module_view, user_id, login, ip, session_h, ua, verb, object_type/id/name, target_type/id, result, reason, parent_id, depth, job_id, run_id, imported, pseudonymised, record, search_text), with indexes on time, name, user, object, request, job, parent, address, result and domain/severity. expaudit_cursor: per channel and file, the byte offset, last seq and hash already indexed. expaudit_file: each file's state and verification. The schema is in share/db_schema.dba and kernel/sql/<engine>/. Oracle and MongoDB get it from the same definition through expAuditIndexSchema::install(). Table names start with exp, and the drivers' relationList() only lists ez*, so missingTables() asks each engine directly.

5.7 Sinks

The file is always written. Sinks are copies, delivered after the record is in the file, and fed only records that passed the privacy rules.

  • syslog (expAuditSyslogSink): RFC 5424, one message per record, MSGID = channel, structured data exp@32473 (the documentation enterprise number of RFC 5612) with id, name, seq, channel, user, ip, result, request and hash, then the JSON record. Transport=local writes to journald's native socket where journald runs (identifier AppName, fields EXP_AUDIT_ID/NAME/CHANNEL/SEQ/HASH/RESULT/SEVERITY/REQUEST), else to /dev/log. devlog sends the RFC 5424 line to /dev/log. udp sends header and structured data only (RFC 5426). tcp and tls send the whole record with octet counting (RFC 6587/5425). Local delivery happens at flush; network transports go through the spool. Under open_basedir (shared hosting, a PHP pool limited to the site folder) the sink checks the setting before it touches /run/systemd/journal/socket or /dev/log (parsed as PHP does, decided once per process): a socket outside the allowed paths is never touched, so no open_basedir warning reaches the error log, and local, journald and devlog then go through PHP's own syslog() (identifier AppName, the sink's facility, the RFC 5424 line without <PRI>).
  • webhook (expAuditWebhookSink): POST JSON {"v":1,"installation":…,"site":…,"batch":…,"events":[…]} in batches of BatchSize or BatchSeconds, with headers X-Exponential-Timestamp, X-Exponential-Batch and X-Exponential-Signature: sha256=<hex HMAC-SHA-256( SigningSecret, timestamp + "." + body )>. Receivers check the signature, refuse timestamps more than 300 s away (expAuditWebhookSink::verify() does both), and de-duplicate by event id (delivery is at least once). Retries: Retries times with RetryBackoff doubled each time. After the last one the batch stays spooled and system.audit.sink.failed is recorded.
  • mail (expAuditMailSink): from the cronjob part only, through the kernel's mail transport (or [AuditSink_mail] Transport=<class>), to the recipients of 4.5.
  • Spools: <LogDir>/spool/<sink>.jsonl under flock(), delivered by the cronjob part and by exp:audit sinks flush. Critical records are also tried right after the response where PHP-FPM allows it.

5.8 Alert evaluation

At flush, after the write, only rules whose Event pattern matches a written record are evaluated, so a request without failed logins or role changes pays nothing. match and schedule rules decide at once. threshold rules keep their window in <LogDir>/alerts/<rule>.json (per group the event ids and times inside the window) under flock(). The cronjob part evaluates records since its cursor, closes windows, and checks the .state file against the settings: a change made by editing a file by hand still produces system.audit.disable and the audit_disabled alert. An alert for (rule, group) fires once per window and again when the count doubles. Each firing is system.audit.alert (rule, group, count, window, first and last event, sinks, message) with the rule's severity.

5.9 Velocity

Velocity's workers are persistent, so several things work differently from PHP-FPM:

  • ezpKernelWeb::__construct() calls expAudit::resetRequest(): anything a previous request left is flushed, then the buffer, open parents, request id, job, run and cached actors are cleared. A changed REQUEST_TIME_FLOAT also resets the per-request settings snapshot. The cleanup handler, not the shutdown function, flushes each request.
  • The 4.x $GLOBALS caches of eZAudit are gone, so a changed audit.ini is seen on the next request.
  • Every hook point checks class_exists( 'expAuditHook' ), and templates fetch through a variable module name. A worker started before the audit classes existed records nothing and shows nothing until exp:velocity restart.
  • The bridge's listeners are attached once per worker (a second attach replaces the first), so an event is never recorded twice.
  • 1 000 requests in one worker were tested: no record carried another request's id.
  • The export's download sends X-Accel-Buffering: no, so a pool worker streams it as it is written (Velocity 0.0.4.49 and later; under streamTimeout, not the 30 s requestTimeout that made it a 504 before). The view never wraps the download and eZExecution::cleanExit() in a catch: under Velocity cleanExit() throws to end the request, and a caught exception would append the page to the file.

5.10 Failure modes

What failsWhat happens
LogDir cannot be writtenOnWriteFailure=continue (default): the request carries on; error.log gets the failure and, at the end, one AUDIT-UNWRITTEN line per record. refuse: the guarded actions are refused first (4.13)
A process dies mid-writethe next append repairs the torn line (system.audit.chain.repair); verify says repaired
The database is downnothing changes at request time (the index is not written then); the cronjob part's index run fails and catches up later
A sink is downrecords wait in the spool; system.audit.sink.failed once per sink and hour; delivered when it is back
The cronjob part stopsfiles keep being written; index, spools, archives, checkpoints wait; the dashboard warns after an hour
The key file is lostnew keys are generated on the next event (system.audit.key.create); archives and checkpoints of the old keys report unknown_key until the old SigningKey[] is restored
An extension's branch class is missingits names are left out (not recorded), the RAD survey and the settings view say why
The audit classes are missing (old worker)nothing is recorded by that process; nothing breaks
Audit is switched offsystem.audit.disable is recorded first; the audit_disabled alert (emergency) goes to syslog and mail

5.11 Security

WhoCanThe answer
An editor without audit policiessee nothingpolicies on every view, link, block and fetch; refusals recorded
Someone using a signed-in session left openrun audit manage actionsReauthForManage=enabled asks for the password first; the attempts are recorded
A user with audit/readread the channels the limitation allows, export themreads and exports are recorded; personal fields truncated or hashed by default
An administrator with audit/managechange settings, switch the audit off, rotate keyseach is recorded before it takes effect; audit_disabled alert to syslog and mail; the record leaves the server through the sinks
Code running as the web server userstop recording, read the key, rewrite live files and recompute the chainit cannot rewrite what already left the server (syslog, webhook, mail, checkpoints); archives on a path it cannot change; checkpoints signed before the compromise expose the rewrite (rewritten)
root or the hosting provideranything on the serverout of scope: only copies held elsewhere (sinks) can show what happened
Someone on the network—syslog over tls, webhook over HTTPS with signatures

What the chain proves: the records of a channel, from its genesis or the last verified checkpoint or manifest up to the point checked, are the ones that were written, in that order, with none removed, inserted or changed, unless someone who could write the files recomputed the chain after the last signed checkpoint. It does not prove that every event was recorded (code that never calls the audit leaves no break), that a record's content is true, or who changed a file. The HMAC adds that only a key holder could have signed a checkpoint or manifest. A key read by an attacker weakens what is signed after that moment, not what was signed and sent away before. Send checkpoints off the server (syslog or webhook) to make that hold. See the SIEM and compliance configurations.


6. Reference configurations

Each block below is a complete settings/override/audit.ini.append.php body (the file starts with <?php /* #?ini charset="utf-8"? and ends with */ ?>). Each was validated on the reference installation by a script that reads them out of this guide. For each one it builds a temporary INI root with the shipped settings/audit.ini and the block as its append file, lets eZINI merge them, and gives the result to the audit in a throwaway directory. It then asks every part whether it can work: the taxonomy, the event decisions, each channel's days, size and format handler, the sinks the channels use, the alert rules, the privacy rules (on a sample record) and the index settings. The live settings were not changed. The validator's output for each configuration is shown under it.

6.1 Small site

# Reference configuration: small-site
# One server, a handful of editors. The shipped defaults stay; only who is mailed and
# how long archives are kept change. File: settings/override/audit.ini.append.php
[AuditAlertSettings]
Recipients[]
Recipients[]=admin
Recipients[]=role:Administrator

# One year of archives instead of two. The shipped channel blocks set ArchiveDays
# themselves, so it is set per channel ([AuditRotationSettings] would not be read).
[AuditChannel_content]
ArchiveDays=365

[AuditChannel_access]
ArchiveDays=365

[AuditChannel_system]
ArchiveDays=365

[AuditChannel_commerce]
ArchiveDays=365
== small-site
  audit enabled; LogDir log/audit; ArchiveDir log/audit/archive
  events recorded: 109 of 135 (as shipped)
  channel content   live   90 d, archived  365 d, gzip, max 64M, sinks -
  channel access    live   90 d, archived  365 d, gzip, max 64M, sinks syslog
  channel system    live   90 d, archived  365 d, gzip, max 64M, sinks syslog
  channel commerce  live   90 d, archived  365 d, gzip, max 64M, sinks -
  channel read      live   30 d, archived   90 d, zstd, max 256M, sinks -
  sink syslog   ready (channels access, system)
  sink webhook  not ready: no URL is set ([AuditSink_webhook] URL)
  sink mail     ready
  alert rules: 7, all usable
  index on, keep 730 d, pseudonymise after 90 d; buffer 500 events / 1048576 bytes, flush every 5 s
  PASS loads and every part can work with it

With it: a crontab line for runcronjobs.php frequent, and a backup of settings/override.

6.2 Busy site

# Reference configuration: busy-site
# An editorial site with many publishes per hour on one or two servers.
# File: settings/override/audit.ini.append.php
[AuditEventSettings]
# "Who published this?" is the question asked most; the shipped default leaves it off.
Enabled[]=content.object.create
Enabled[]=content.object.publish

[AuditBufferSettings]
# Long requests and commands write fewer, larger appends.
MaxEvents=2000
MaxBytes=4M
FlushInterval=10

[AuditChannel_content]
MaxFileSize=256M
ArchiveFormat=zstd

[AuditChannel_system]
MaxFileSize=256M
ArchiveFormat=zstd

[AuditArchiveSettings]
# Another file system: create it first, owned by the site user, mode 0750.
ArchiveDir=/srv/audit-archive/example

[AuditIndexSettings]
BatchSize=10000
# The console searches one year; older years are in the archives.
KeepDays=365

[AlertRule_mass_delete]
# Editors clean up large sections here; 500 nodes in 10 minutes is normal work.
Threshold=2000
== busy-site
  audit enabled; LogDir log/audit; ArchiveDir /srv/audit-archive/example
  events recorded: 111 of 135 (against the shipped defaults: +content.object.create +content.object.publish)
  channel content   live   90 d, archived  730 d, zstd, max 256M, sinks -
  channel access    live   90 d, archived  730 d, gzip, max 64M, sinks syslog
  channel system    live   90 d, archived  730 d, zstd, max 256M, sinks syslog
  channel commerce  live   90 d, archived  730 d, gzip, max 64M, sinks -
  channel read      live   30 d, archived   90 d, zstd, max 256M, sinks -
  sink syslog   ready (channels access, system)
  sink webhook  not ready: no URL is set ([AuditSink_webhook] URL)
  sink mail     ready
  alert rules: 7, all usable
  index on, keep 365 d, pseudonymise after 90 d; buffer 2000 events / 4194304 bytes, flush every 10 s
  PASS loads and every part can work with it

Before switching it on: mkdir -p /srv/audit-archive/example && chown <site user>: /srv/audit-archive/example && chmod 0750 /srv/audit-archive/example. Run exp:audit alerts test mass_delete --replay=<a month ago> to check that the new threshold fits.

6.3 Cluster

# Reference configuration: cluster
# Several web servers behind a load balancer, one database. Every server writes into
# one shared directory, so each channel stays one chain. The file system must support
# POSIX locks across servers (NFSv4, CephFS, GlusterFS); the writer takes flock() on
# every append. settings/override is shared or copied identically to every server.
[AuditSettings]
LogDir=/mnt/exponential-shared/audit

[AuditArchiveSettings]
ArchiveDir=/mnt/exponential-archive/audit

[AuditPrivacySettings]
# The server's host name tells which server answered (request.host).
Field[request.host]=full

[AuditSink_syslog]
# Each server forwards its own records to the central collector.
Transport=tcp
Host=logs.example.com
Port=514

[AuditChannel_content]
Sinks[]=syslog

[AuditChannel_commerce]
Sinks[]=syslog
== cluster
  audit enabled; LogDir /mnt/exponential-shared/audit; ArchiveDir /mnt/exponential-archive/audit
  events recorded: 109 of 135 (as shipped)
  channel content   live   90 d, archived  730 d, gzip, max 64M, sinks syslog
  channel access    live   90 d, archived  730 d, gzip, max 64M, sinks syslog
  channel system    live   90 d, archived  730 d, gzip, max 64M, sinks syslog
  channel commerce  live   90 d, archived  730 d, gzip, max 64M, sinks syslog
  channel read      live   30 d, archived   90 d, zstd, max 256M, sinks -
  sink syslog   ready (channels content, access, system, commerce)
  sink webhook  not ready: no URL is set ([AuditSink_webhook] URL)
  sink mail     ready
  alert rules: 7, all usable
  index on, keep 730 d, pseudonymise after 90 d; buffer 500 events / 1048576 bytes, flush every 5 s
  PASS loads and every part can work with it

How it works across servers: every lock file (.<channel>.lock, .keys.lock, .cron.lock, .index.lock) and the daily marker live in the shared LogDir. The chain head is read from the file under the lock at every append, never from memory. So any number of servers write one chain per channel, and the cronjob part may run on all of them: one run at a time does the work, and the daily tasks run once a day for the cluster. Generate the keys once before the other servers record (./console exp:audit checkpoint --apply on one server), then share or copy settings/override/audit.ini.append.php to every server. Two servers with different keys would write different genesis values and fail verification. What was checked on the reference installation: the configuration loads and every part accepts it, and ten concurrent writers to one channel keep the chain intact (test B2). A multi-server cluster itself was not available to test.

6.4 Strict privacy

# Reference configuration: strict-privacy
# A site that must keep as little personal data as possible, also in its archives.
# File: settings/override/audit.ini.append.php
[AuditPrivacySettings]
Field[actor.login]=hash
Field[actor.ip]=hash
Field[actor.ua]=off
Field[actor.cli.os_user]=hash
Field[request.url]=truncate
Field[request.host]=hash
Field[object.name]=truncate
PseudonymiseAfterDays=30

[AuditRecordSettings]
# Which fields changed, not their values.
BeforeAfter=keys

[AuditChannel_content]
LiveDays=30
ArchiveDays=180

[AuditChannel_access]
LiveDays=30
ArchiveDays=180

[AuditChannel_system]
LiveDays=30
ArchiveDays=180

[AuditChannel_commerce]
LiveDays=30
ArchiveDays=180

[AuditIndexSettings]
KeepDays=180
== strict-privacy
  audit enabled; LogDir log/audit; ArchiveDir log/audit/archive
  events recorded: 109 of 135 (as shipped)
  channel content   live   30 d, archived  180 d, gzip, max 64M, sinks -
  channel access    live   30 d, archived  180 d, gzip, max 64M, sinks syslog
  channel system    live   30 d, archived  180 d, gzip, max 64M, sinks syslog
  channel commerce  live   30 d, archived  180 d, gzip, max 64M, sinks -
  channel read      live   30 d, archived   90 d, zstd, max 256M, sinks -
  sink syslog   ready (channels access, system)
  sink webhook  not ready: no URL is set ([AuditSink_webhook] URL)
  sink mail     ready
  alert rules: 7, all usable
  sample record: login h:a7b9fa3a5c1c2d01, ip h:b1c47ab3fda84e2d, ua (off), url /user/login?redirect=…, host h:50f7904f8d21e9e6, object.name "A very long article title that goes on and on well past sixty-fo…", before {"parent":"sha256:d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35"}
  index on, keep 180 d, pseudonymise after 30 d; buffer 500 events / 1048576 bytes, flush every 5 s
  PASS loads and every part can work with it

The sample record line shows what is written: logins, addresses and hosts as keyed hashes, no user agent, names cut at 64 characters, and before/after values as their sha256. Grouping still works: the brute-force rules group by the hashed address. With the shipped defaults the same sample reads login editor1, ip 203.0.113.0/24, ua Firefox 131 / Linux.

6.5 SIEM forwarding

# Reference configuration: siem-forwarding
# Every record leaves the server at once to a SIEM: syslog over TLS for the stream,
# a signed webhook for the security events. File: settings/override/audit.ini.append.php
[AuditSink_syslog]
Transport=tls
Host=siem.example.com
Port=6514
Facility=local4
MinSeverity=info

[AuditSink_webhook]
URL=https://siem.example.com/api/ingest/exponential
# A long random secret (openssl rand -hex 32); the receiver checks X-Exponential-Signature.
SigningSecret=3f9a0c12b47d5e6f8a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f6071
BatchSize=200
MinSeverity=notice
Events[]
Events[]=access.*
Events[]=system.audit.*
Events[]=system.setting.*

[AuditChannel_content]
Sinks[]=syslog

[AuditChannel_access]
Sinks[]=webhook

[AuditChannel_system]
Sinks[]=webhook

[AuditChannel_commerce]
Sinks[]=syslog
== siem-forwarding
  audit enabled; LogDir log/audit; ArchiveDir log/audit/archive
  events recorded: 109 of 135 (as shipped)
  channel content   live   90 d, archived  730 d, gzip, max 64M, sinks syslog
  channel access    live   90 d, archived  730 d, gzip, max 64M, sinks syslog, webhook
  channel system    live   90 d, archived  730 d, gzip, max 64M, sinks syslog, webhook
  channel commerce  live   90 d, archived  730 d, gzip, max 64M, sinks syslog
  channel read      live   30 d, archived   90 d, zstd, max 256M, sinks -
  sink syslog   ready (channels content, access, system, commerce)
  sink webhook  ready (channels access, system)
  sink mail     ready
  alert rules: 7, all usable
  index on, keep 730 d, pseudonymise after 90 d; buffer 500 events / 1048576 bytes, flush every 5 s
  PASS loads and every part can work with it

Sinks[]=… lines append to the shipped lists, so access and system keep their shipped syslog entry. Replace the example secret with your own (exp:ini masks it, and settings/override is never committed). Then exp:audit sinks test syslog and exp:audit sinks test webhook send one test record each, and the receiver must answer 2xx. With TLS, PHP verifies the collector's certificate against the system CA store. The receiver's check in PHP is expAuditWebhookSink::verify( $body, $headers, $secret ).

6.6 Compliance: long retention

# Reference configuration: compliance-long-retention
# Ten years of evidence, kept compressed on write-once storage, with the daily signed
# checkpoints sent off the server. File: settings/override/audit.ini.append.php
[AuditEventSettings]
Enabled[]=content.object.create
Enabled[]=content.object.publish
Enabled[]=commerce.order.create
Enabled[]=commerce.order.status
Enabled[]=commerce.payment.approve
Enabled[]=data.export.pdf
Enabled[]=data.infocollection.view

[AuditChannel_content]
ArchiveDays=3650
ArchiveFormat=xz

[AuditChannel_access]
ArchiveDays=3650
ArchiveFormat=xz

[AuditChannel_system]
ArchiveDays=3650
ArchiveFormat=xz
Sinks[]=webhook

[AuditChannel_commerce]
ArchiveDays=3650
ArchiveFormat=xz

[AuditArchiveSettings]
# A mount the web server can add files to but not change (WORM, or a backup target).
ArchiveDir=/srv/worm/exponential-audit

[AuditIndexSettings]
# Two years searchable in the console; earlier years: exp:audit restore, then read.
KeepDays=730

[AuditSink_webhook]
URL=https://evidence.example.com/exponential/checkpoints
SigningSecret=9b1c0f7e2d4a6c8e0f1a3b5c7d9e1f2a4b6c8d0e2f4a6b8c0d2e4f6a8b0c2d4e
MinSeverity=info
Events[]
Events[]=system.audit.checkpoint
Events[]=system.audit.chain.broken
Events[]=system.audit.purge
Events[]=system.audit.key.rotate
== compliance-long-retention
  audit enabled; LogDir log/audit; ArchiveDir /srv/worm/exponential-audit
  events recorded: 116 of 135 (against the shipped defaults: +content.object.create +content.object.publish +commerce.order.create +commerce.order.status +commerce.payment.approve +data.export.pdf +data.infocollection.view)
  channel content   live   90 d, archived 3650 d, xz, max 64M, sinks -
  channel access    live   90 d, archived 3650 d, xz, max 64M, sinks syslog
  channel system    live   90 d, archived 3650 d, xz, max 64M, sinks syslog, webhook
  channel commerce  live   90 d, archived 3650 d, xz, max 64M, sinks -
  channel read      live   30 d, archived   90 d, zstd, max 256M, sinks -
  sink syslog   ready (channels access, system)
  sink webhook  ready (channels system)
  sink mail     ready
  alert rules: 7, all usable
  index on, keep 730 d, pseudonymise after 90 d; buffer 500 events / 1048576 bytes, flush every 5 s
  PASS loads and every part can work with it

Keep every SigningKey[] for as long as the archives it signed (rotation adds keys and never removes them). Store the settings backup with the same retention. Run exp:audit verify --archives monthly and keep its output.


7. Settings reference

settings/audit.ini is the complete, commented reference: every variable with its default and allowed values. This section summarises it. Change values in an override, never in that file (4.9).

BlockVariable (default)Meaning
[AuditSettings]Audit (enabled)on or off; switching it off is recorded first
LogDir (log/audit)live files, relative to the var directory, or absolute
AuditFileNames[]the 4.x file names, as aliases for (legacy_file) filters and the import
OnWriteFailure (continue)continue: a failed write never stops the request; refuse: the actions of ImmediateEvents[] are refused while their channel cannot be written (4.13)
RefuseExemptViews[] (user/login, user/logout)POSTs refuse never refuses
[AuditEventSettings]Enabled[], Disabled[]patterns; the most specific wins, Disabled[] on a tie
Branches[<ext>]extension taxonomy branches
MinSeverity (info)lower severities are not recorded
[AuditSourceSettings]Sources[<ext>]the console's sources of extensions: panel, links, event fields, charts (3.5, "An extension's source in the console")
[AuditChannelSettings]Channels[], Route[<pattern>], DefaultChannel (system)channels and routing
[AuditChannel_<c>]LiveDays, ArchiveDays, MaxFileSize, ArchiveFormat, Sinks[]per channel; see the shipped values in 4.3
[AuditRecordSettings]BeforeAfter (enabled | keys | disabled), MaxValueLength (512), ChildDepth (3), MaxChildren (10000), RequestContext, RequestIdHeader (X-Exp-Request-Id), TrustedRequestIdHeader (empty; trusted from loopback only), MaxLineBytes (262144)what a record holds
[AuditPrivacySettings]Field[<field>] (full | truncate | hash | off), IPv4Prefix (24), IPv6Prefix (48), SecretPathViews[], NeverRecord[], PseudonymiseAfterDays (90)privacy; actor.session takes only hash or off
[AuditBufferSettings]Buffering, ImmediateEvents[], MaxEvents (500), MaxBytes (1M), FlushInterval (5)5.5
[AuditChainSettings]Algorithm (sha256), Checkpoints (enabled)the chain is always on
[AuditKeySettings]GenerateKeys (enabled); generated: InstallationID, ActiveSigningKey, SigningKey[], PseudonymKey4.6
[AuditReadSettings]Reads (disabled), SampleRate (0.01), Sections[], Classes[], AlwaysModules[] (setup, role, user, audit, settings)sampled reads; views of AlwaysModules[] are always access.view.sensitive for signed-in users
[AuditSinkSettings]SinkClasses[], SpoolDir (log/audit/spool)5.7
[AuditSink_syslog]Transport (local), Host, Port (514), Facility (authpriv), AppName (exponential), Events[], MinSeverity (info)
[AuditSink_webhook]URL, SigningSecret, BatchSize (100), BatchSeconds (10), Timeout (5), Retries (5), RetryBackoff (30), Events[], MinSeverity (notice)
[AuditSink_mail]Receivers[] (older name of the default recipients), MinSeverity (critical), Events[], Throttle (900), Transport
[AuditAlertSettings]Alerts, EvaluateIn[] (flush, cronjob), Rules[], RuleClasses[], BusinessDays (1-5), BusinessHours (7-19), Recipients[]4.5
[AlertRecipients_<name>]Addresses[], Recipients[]named recipient groups
[AlertRule_<name>]Class, Event, Threshold, Window, GroupBy, Severity, Sinks[], Recipients[], CountChildren, Policies[], OutOfHoursthe seven shipped rules
[AuditRotationSettings]defaults for the channel blocks; VerifyBeforeArchive, VerifyAfterArchive, RotateAfter (00:15)
[AuditArchiveSettings]ArchiveDir, FormatHandlers[], Level[], FileMode (0440), DirMode (0750)
[AuditIndexSettings]Index, BatchSize (2000), IndexReads (disabled), FullText, KeepDays (730)
[AuditConsoleSettings]PageSize (50), MaxExportRecords (100000), MaxBackgroundExportRecords (1000000), ReauthForManage (disabled), ReauthMinutes (10)the password again before manage actions (3.1); the largest download and background export (Streaming the export)
[AuditCompatSettings]Map[], UnmappedAsLegacy, LegacyFiles (disabled: also write the 4.x text files)
[AuditBridgeSettings]Bridge[<ezpEvent>]=<name>3.5

Related settings elsewhere: cronjob.ini [CronjobPart-audit] and the frequent group; menu.ini [Topmenu_audit]; dashboard.ini [DashboardBlock_audit]; admininterface.ini [AdditionalTab_audit]; module.ini ModuleList[]=audit.


8. Event reference

Generated from expAuditTaxonomy::registry() and the settings in effect on the reference installation by a generator script. Raised in comes from the code: the call sites that raise the name. On here is what decide() answers with the reference installation's settings. Written says whether the event is appended at once or with the request's buffer. The two descriptive columns are kept with their names by the generator. Columns: Sev. = severity; Default = shipped (always cannot be switched off; sampled = a read, recorded only with Reads=enabled).

148 names (44 content, 40 access, 43 system, 12 commerce, 9 data); shipped default: 99 on, 23 off, 23 always, 3 sampled.

EventFires whenRecords (before → after; never)Raised inVerbSev.DefaultOn hereCh.Written
content.object.createthe first version of a new object is published– → class, languages, section, owner (never attribute values)content/ezcontentoperationcollection.php:auditPublished, views/content/restore.php:runpublishinfooffnocontentbuffered
content.object.publisha later version is publishedversion, languages, modified → version, languages, modified, changed attribute identifiers (never attribute values)content/ezcontentoperationcollection.php:auditPublished, views/content/restore.php:runpublishinfooffnocontentbuffered
content.object.translatea published version adds a language the previous one did not havelanguages → languagescontent/ezcontentoperationcollection.php:auditPublished, views/content/restore.php:runtranslateinfooffnocontentbuffered
content.object.translation.removea translation is removed from an objectlanguages → languagescontent/ezcontentoperationcollection.php:removeTranslationremovenoticeonyescontentbuffered
content.version.removearchived or draft versions are removed by an editorstatus, language → –views/content/history.php:run, views/content/removeeditversion.php:runremovenoticeonyescontentbuffered
content.previewlink.createa share link to the preview of a draft is made (Preview links)– → version, language, expiry, note (never the token)expcontentpreviewlink.php:createcreatenoticeonyescontentbuffered
content.previewlink.revokea share link is withdrawn before it expiresexpiry → revokedexpcontentpreviewlink.php:revokerevokenoticeonyescontentbuffered
content.previewlink.opena reviewer opens a draft preview with a working share link– → how often it was openedexpcontentpreviewlink.php:openopeninfoonyescontentbuffered
content.node.movea location gets a new parent [content-move]parent, path → parent, pathezcontentobjecttreenode.php:movemoveinfoonyescontentbuffered
content.node.copya node or subtree is copied– → new node id, new object idcontentjob/expcontentjobcopysubtree.php:copyNode, ezcontentobjecttreenodeoperations.php:auditSubtreeCopy, views/content/copy.php:copyObjectcopyinfooffnocontentbuffered
content.node.adda location is added to an object, one record per new location [location-assign]locations → locations, the new node and its parent, main node (the new node is the target)content/ezcontentoperationcollection.php:addAssignment, content/ezcontentoperationcollection.php:auditLocationsAddedaddinfooffnocontentbuffered
content.node.removea location is removed, the object stays [location-remove]parent, path, object id, name, main (from the Locations window) → –ezcontentobjecttreenode.php:removeSubtrees, ezcontentobjecttreenode.php:removeNodeFromTree, content/ezcontentoperationcollection.php:removeNodesremovenoticeonyescontentbuffered
content.node.remove.trashan object goes to the trash [content-delete]parent, path, object id, name, class → –ezcontentobject.php:removeThis, ezcontentobjecttreenode.php:removeSubtrees, ezcontentobjecttreenode.php:removeNodeFromTree, ezcontentobjecttreenode.php:auditRemovalDataremovenoticeonyescontentbuffered
content.object.removean object is removed for good, not through the trash [content-delete]name, class, owner, locations → –ezcontentobjecttreenode.php:removeSubtrees, ezcontentobjecttreenode.php:removeNodeFromTree, ezcontentobjecttreenode.php:auditRemovalDataremovenoticeonyescontentbuffered
content.object.purgean object is purged (trash emptied, version purge) [content-delete]name, class → –ezcontentobject.php:purge, ezcontentobjecttreenode.php:removeNodeFromTreepurgenoticeonyescontentbuffered
content.object.restorean object is restored from the trash– → node, parentviews/content/restore.php:runrestoreinfoonyescontentbuffered
content.trash.emptythe trash is emptied, or a selection purgedcount → countservices/trash.php:auditedpurgenoticeonyescontentbuffered
content.file.alias.regeneratethe aliases of an image field version are made again from its original (content checks, missing files)– → field, version, language, problem, aliasesclasses/contentcheck/expcontentcheckfilefix.php:auditchangeinfoonyescontentbuffered
content.file.restorea missing file is copied back from another version of the same fieldsource version, source path → field, version, language, problemclasses/contentcheck/expcontentcheckfilefix.php:auditrestorenoticeonyescontentbuffered
content.file.replacea missing file is uploaded again at its expected path– → field, version, language, problemclasses/contentcheck/expcontentcheckfilefix.php:auditchangenoticeonyescontentbuffered
content.file.reference.removea field version that names a missing file is emptied (the database only)– → field, version, language, problemclasses/contentcheck/expcontentcheckfilefix.php:auditremovenoticeonyescontentbuffered
content.file.review.marka field version with a missing file or alias is marked for review– → field, version, language, problemclasses/contentcheck/expcontentcheckfilefix.php:auditchangeinfoonyescontentbuffered
content.node.hidea subtree is hidden [content-hide]visibility → visibilitycontentjob/expcontentjobhidesubtree.php:mainStep, ezcontentobjecttreenode.php:hideSubTreehideinfoonyescontentbuffered
content.node.reveala subtree is revealed [content-hide]visibility → visibilitycontentjob/expcontentjobhidesubtree.php:mainStep, ezcontentobjecttreenode.php:unhideSubTreerevealinfoonyescontentbuffered
content.graphql.mutationa mutation of the GraphQL API runs (the parent of the kernel's own events of the change)– → mutation, signed in by, request id, API key id, field identifiers; status, content id, node id, version (never field values)graphql/expgraphqlmutations.php:runchangenoticeonyescontentbuffered
content.graphql.mutation.faileda mutation is refused (signed out, no scope graphql:write, no form token, no policy, not found) or fails– → mutation, signed in by, request id, API key id; reasongraphql/expgraphqlmutations.php:refusal, graphql/expgraphqlmutations.php:runchangenoticeonyescontentbuffered
content.node.swaptwo locations swap their objectsobject ids → object idscontent/ezcontentoperationcollection.php:swapNodeswapinfoonyescontentbuffered
content.node.sectiona section is assigned to a subtree [section-assign]; after.scope says which objects below changed (subtree, inherited, object)section → sectioncontentjob/expcontentjobsectionsubtree.php:prepare, ezcontentobjecttreenode.php:assignSectionToSubTree, ezcontentobjecttreenode.php:removeNodeFromTreeassigninfoonyescontentbuffered
content.object.stateobject states are assigned [state-assign]states → statescontent/ezcontentoperationcollection.php:updateObjectStateassigninfoonyescontentbuffered
content.object.hold.refuseda version of an object held for review is not published: the user's content/publish does not allow the held state (Review hold)– → the version (stays a draft)expreviewhold.php:checkPublishrefusenoticeonyescontentbuffered
content.object.hold.autothe first publication of a visitor's submission is held for review and hidden (content.ini [ReviewHoldSettings], Review hold); the actor's address and browser are those of the submission– → held, the hidden nodes, the class, the creatorexpreviewholdauto.php:publishedholdinfoonyescontentbuffered
content.object.hold.placea review hold is placed with a reason (Dashboard > Recent content)held → held, reasonexpreviewholdauto.php:placeholdnoticeonyescontentbuffered
content.object.hold.releasea review hold is released; the nodes the auto-hold hid are revealedheld, auto, reason → held, reason, revealed nodes, nodes left hiddenexpreviewholdauto.php:releasereleasenoticeonyescontentbuffered
content.moderation.actionan action of Dashboard > Recent content on a selection (Moderation): in the request or as a content job– → action, mode, counts, the ids done, refused and failed, reason, section, state, jobmoderation/expmoderationaction.php:auditchangenoticeonyescontentbuffered
content.node.mainthe main location changes: chosen by an editor, or the next location when the main one is removed [main-node-update]main node → main node, its parent, cause (location-remove when the main location was removed)content/ezcontentoperationcollection.php:updateMainAssignment, content/ezcontentoperationcollection.php:removeNodes, content/ezcontentoperationcollection.php:auditMainLocationChangedassigninfooffnocontentbuffered
content.node.sorta node's sort order changesfield, order → field, ordercontent/ezcontentoperationcollection.php:changeSortOrdersortinfooffnocontentbuffered
content.node.prioritypriorities of children changepriorities → prioritiescontent/ezcontentoperationcollection.php:updatePrioritysortinfooffnocontentbuffered
content.object.always_availablethe always-available flag changesflag → flagcontent/ezcontentoperationcollection.php:updateAlwaysAvailablechangeinfooffnocontentbuffered
content.object.initial_languagethe initial language changeslanguage → languagecontent/ezcontentoperationcollection.php:updateInitialLanguagechangeinfooffnocontentbuffered
content.urlalias.changeURL aliases or wildcards are added or removedalias → aliasviews/content/urlalias.php:run, views/content/urlalias_global.php:run, views/content/urlalias_wildcard.php:runchangeinfooffnocontentbuffered
content.urlalias.importA URL map is imported (exp:url:import, the import screen): counts of created, updated, skipped, failed rowsurlmap_batch → noneurlmap/expurlmapimporter.php:applyimportnoticeonnocontentbuffered
content.urlalias.import_undoA URL map import is undone (exp:url:undo): counts of undone and kept entries, the batch's stateurlmap_batch → noneurlmap/expurlmapimporter.php:undoundonoticeonnocontentbuffered
content.class.createa new content class is stored– → identifier, attribute identifiersviews/class/edit.php:storeClasscreateinfoonyescontentbuffered
content.class.changea class definition is storedidentifier, attributes (identifier, datatype, required, searchable) → the sameviews/class/edit.php:storeClasschangeinfoonyescontentbuffered
content.class.removeclasses are removedidentifier, object count → –views/class/removeclass.php:run, views/class/removegroup.php:runremovenoticeonyescontentbuffered
content.class.copya class is copied– → identifierviews/class/copy.php:runcopyinfooffnocontentbuffered
content.section.changea section is created or editedname, identifier, navigation part → the sameviews/section/edit.php:runchangeinfoonyescontentbuffered
content.section.removea section is removedname, identifier → –views/section/list.php:runremovenoticeonyescontentbuffered
content.state.changea state or state group is created or editedidentifier, translations → the sameviews/state/edit.php:run, views/state/group_edit.php:runchangeinfoonyescontentbuffered
content.state.removestate groups or states are removedidentifier → –views/state/groups.php:run, views/state/group.php:runremovenoticeonyescontentbuffered
content.job.createa content job is created– → type, params (node ids only), modecontentjob/expcontentjob.php:createcreateinfoonyescontentbuffered
content.job.starta worker starts or resumes a jobstate → state, attemptscontentjob/expcontentjobworker.php:auditStartstartinfoonyescontentbuffered
content.job.finisha job ends done– → nodes done, mscontentjob/expcontentjobworker.php:runLockedfinishinfoonyescontentbuffered
content.job.faila job ends failed– → error, node idcontentjob/expcontentjobworker.php:failfailwarningonyescontentbuffered
content.job.cancela cancel is requested or takes effectstate → statecontentjob/expcontentjob.php:cancel, contentjob/expcontentjobworker.php:cancelled, contentjob/expcontentjobworker.php:auditEndcancelinfoonyescontentbuffered
content.job.resumea stopped job is resumedstate → statecontentjob/expcontentjob.php:resumeresumeinfoonyescontentbuffered
content.node.viewa node is viewed (Z6)– (never the rendered page)views/content/view.php:runreadinfosamplednoreadbuffered
content.search.querya search is run (Z6)– → phrase (truncated to 64 characters), hit countviews/content/advancedsearch.php:run, views/content/search.php:runreadinfosamplednoreadbuffered
content.object.downloada file attribute is downloaded–views/content/download.php:runreadinfosamplednoreadbuffered
content.finder.searcha search in the content finder (contentfinder/search)– → type asked, types tried, text (truncated to 128 characters), results shown, results found but not shownviews/contentfinder/search.php:auditreadinfoonyescontentbuffered
content.finder.actionan action of the content finder is done or refused (move to trash, delete permanently, purge, remove a version); the kernel's removal events are its children– → action, locations, children (refused: reason)contentfinder/expcontentfinderaction.php:execute, contentfinder/expcontentfinderaction.php:auditRefusalremovenoticeonyescontentbuffered
content.file.orphan.quarantineorphan storage files are moved to the quarantine (contentcheck/storageaction, exp:content-check --quarantine)– → count, bytes, failed, files (paths below the storage, at most 200), viacontentcheck/expcontentcheckquarantine.php:auditmovenoticeonyescontentbuffered
content.file.orphan.restorequarantined files are moved back to the storage– → count, bytes, failed, files, viacontentcheck/expcontentcheckquarantine.php:auditrestorenoticeonyescontentbuffered
content.file.orphan.deletequarantined files are deleted (after their own confirmation)– → count, bytes, failed, files, viacontentcheck/expcontentcheckquarantine.php:auditremovewarningonyescontentbuffered
content.file.orphan.refusedan action on storage files is refused for a missing policy– → action (reason: policy)views/contentcheck/storageaction.php:runaccessnoticeonyescontentbuffered
content.file.permissions.fixthe expected owner and mode are set on used storage files– → count, bytes, failed, files, viacontentcheck/expcontentcheckquarantine.php:auditchangenoticeonyescontentbuffered
access.session.logina user logs in [user-login]– → session (hashed), handler (standard, ldap, …) (never the password)datatypes/ezuser/ezuser.php:loginSucceededlogininfoonyesaccessat once
access.session.login.faileda login attempt fails [user-failed-login]– → attempts, reason (never the password; the attempted login of an unknown user is always hashed, as it is often a password typed in the wrong field)datatypes/ezuser/ezuser.php:loginFailedloginnoticeonyesaccessat once
access.session.logouta user logs outsession → –datatypes/ezuser/ezuser.php:logoutCurrentlogoutinfoonyesaccessat once
access.session.regeneratethe session id is renewedold hash → new hashezpEvent session/regenerate (bridge)regenerateinfooffnoaccessat once
access.session.expirea session is destroyed or collected– → countezpEvent session/destroy, session/gc (bridge)expireinfooffnoaccessat once
access.session.reautha user re-enters the password before an audit/manage action (Q9), or answers "Verify it's you" (Verify it's you)– → method, group, view, minutesaudit/console/expauditreauth.php:confirm, reauth/expreauth.php:moduleResultreauthinfoonyesaccessat once
access.session.reauth.failedthat re-entry or answer fails– → method, group, view, failures; reason credentials, code, passkey_*, method (never the password or code)audit/console/expauditreauth.php:confirm, reauth/expreauth.php:moduleResultreauthnoticeonyesaccessat once
access.session.reauth.ask"Verify it's you" is asked before a sensitive action– → group, view, methodreauth/expreauth.php:moduleResultreauthinfoonyesaccessat once
access.session.revokea user's other sessions end after a password change ([PasswordSettings] EndOtherSessions): deleted at once by the database session handler, or one session signed out on its next request because its password stamp is out of date– → count; reason password_change or password_changedclasses/exppasswordpolicy.php:endOtherSessions, classes/exppasswordpolicy.php:endStaleSessionrevokenoticeonyesaccessat once
access.session.revoke (sessions page)a user signs out one session or all other sessions at user/sessions, or an administrator with user/sessions signs out a user's sessions at user/sessions/<id> (doc/eng-us/features/6.0/user-sessions.md)– → count, sessions (row ids), scope (one, others, all), by (user, admin), actor_id; reason user_request or adminuser/expusersessiontracker.php:auditRevokerevokenoticeonyesaccessat once
access.session.new_devicea sign-in comes from a browser, system and device kind the account has not used within [UserSessionSettings] RetentionDays; the "New sign-in" mail goes out– → session, browser, os, device, methoduser/expusersessiontracker.php:newDevicenew_devicenoticeonyesaccessat once
access.session.new_locationa sign-in comes from a country the account has not signed in from within [UserSessionSettings] RetentionDays (only with a local GeoIP database, GeoIPDatabase); it goes into the "New sign-in" mail– → session, country, location, methoduser/expusersessiontracker.php:newDevicenew_locationnoticeonyesaccessat once
access.user.activity.confirmthe owner of an account answers "Yes, it was me" to a security event the security activity asks "Was this you?" about (user/security)– → event (the row id of expuser_audit), event_name; reason user_requestuser/expusersecurityactivity.php:confirmconfirminfoonyesaccessat once
access.user.two_factor.enrolltwo-step sign-in is turned on for an account (sevenx_authentication_2fa)– → methoduser/expsigninsecondfactor.php:auditenrollnoticeonyesaccessat once
access.user.two_factor.removetwo-step sign-in is turned off or reset– → methoduser/expsigninsecondfactor.php:auditremovewarningonyesaccessat once
access.user.two_factor.recovery_useda recovery code signs in in place of the app's code– → leftuser/expsigninsecondfactor.php:auditusewarningonyesaccessat once
access.user.two_factor.recovery_issuedan account gets a new set of recovery codes– → countuser/expsigninsecondfactor.php:auditissuenoticeonyesaccessat once
access.user.two_factor.verify.faileda wrong code at the second step of a web sign-in– → methoduser/expsigninsecondfactor.php:auditverifynoticeonyesaccessat once
access.user.lockfailed logins reach [UserSettings] MaxNumberOfFailedLoginattempts → attempts, enableddatatypes/ezuser/ezuser.php:setFailedLoginAttemptslockwarningonyesaccessat once
access.user.unlockthe failed-login counter is reset by an administratorattempts → 0datatypes/ezuser/ezuser.php:setFailedLoginAttemptsunlocknoticeonyesaccessat once
access.permission.refuseda module view is refused by policy– → the policy asked (module/function), limitation that failedlib/ezutils/classes/ezmodule.php:handleErroraccessnoticeonyesaccessat once
access.uiswitch.changea signed-in user changes an interface choice of the administration (uiswitch/set: the display language, ...)value, siteaccess → value, siteaccess; target the providerclasses/uiswitch/expuiswitch.php:setupdateinfoonyesaccessat once
access.token.refuseda POST is refused for a missing or wrong form tokenpost · module/viewezpkernelweb.php:formTokenRefusalResultpostnoticeonyesaccessat once
access.view.sensitivea view of setup, role, user or audit is opened (Z6 "always")– (never POST bodies)ezpkernelweb.php:auditSensitiveViewreadinfoalwaysyesaccessat once
access.user.createa user account is created– → login, email (privacy rules), groupsdatatypes/ezuser/ezuser.php:auditStorecreatenoticeonyesaccessat once
access.user.activatean account is activated by its linkenabled → enabled (never the activation hash)user/ezuseroperationcollection.php:activationactivatenoticeonyesaccessat once
access.user.enablean administrator enables or disables an accountenabled, max_login → the sameuser/ezuseroperationcollection.php:setSettingsenablenoticeonyesaccessat once
access.user.disablean administrator enables or disables an accountenabled, max_login → the sameuser/ezuseroperationcollection.php:setSettingsdisablenoticeonyesaccessat once
access.user.removea user is removedlogin, email (privacy rules) → –datatypes/ezuser/ezuser.php:removeUserremovenoticeonyesaccessat once
access.user.changea new version of a user account is published (the profile fields edited in any interface, REST or a command)version, the changed fields as text → version, the same fields (never the account attribute: login, e-mail and password have their own events; NeverRecord[] drops fields by identifier)content/ezcontentoperationcollection.php:auditUserChangedchangenoticeonyesaccessat once
access.user.movea user account is moved to another groupgroup node, name → group node, nameclasses/ezcontentobjecttreenode.php:movemovenoticeonyesaccessat once
access.user.approval.requestthe workflow event "Approve user accounts" holds a new account in a group that requires approval (user-account-approval.md)– → version, groups, approvers, mails sent, workflow, process; target the approval itemclasses/user/expuserapproval.php:requestedrequestnoticeonyesaccessat once
access.user.approval.approvean approver approves such an account (the collaboration item)– → version, process, who asked; target the approval itemclasses/user/expuserapproval.php:decidedapprovenoticeonyesaccessat once
access.user.approval.denyan approver denies such an account– → version, process, who asked; target the approval itemclasses/user/expuserapproval.php:decideddenynoticeonyesaccessat once
access.user.approval.refuseda move or new location of an account or user group into a group that requires approval is refused for someone who would wait for the approval– → workflow, event; target the nodeclasses/user/expuserapproval.php:placementAllowedplacewarningonyesaccessat once
access.registration.approvean administrator approves a registration from the link of the "New user registered" mail (user/registration): activates the account, or accepts its approval itemstate → state (email, administrator, workflow → active, pending)classes/user/expregistrationadminaction.php:approveapprovenoticeonyesaccessat once
access.registration.rejectan administrator rejects a registration from the link of that mail: denies its approval item and removes the accountstate → missingclasses/user/expregistrationadminaction.php:rejectrejectnoticeonyesaccessat once
access.registration.refuseda link of that mail is refused: invalid, changed, expired or used token, the wrong action, a user without the policy, an account no longer waiting– → reason, actionclasses/user/expregistrationadminaction.php:auditRefusedaccessnoticeonyesaccessat once
access.user.deactivate_movean account is deactivated and its locations are brought together in the group of deactivated users (Deactivate and move)enabled, main parent node, other parent nodes → enabled, the group's node, the removed location nodes, whether the locations were remembereduser/expuserdeactivation.php:deactivatedisablenoticeonyesaccessat once
access.user.reactivate_movean account is enabled and moved back to its remembered locations (Reactivate and move back)enabled, main parent node, parent nodes → enabled, main parent node, added parent nodes, fallback used, count not restoreduser/expuserdeactivation.php:reactivateenablenoticeonyesaccessat once
access.user.deactivated_group.createthe group of deactivated users is created on first use or by bin/php/ezcreatedeactivatedusergroup.php– → node, object, parent node, remote id, section, hiddenuser/expuserdeactivation.php:createGroupcreatenoticeonyesaccessat once
access.user.email.changethe e-mail address of an account changesemail → email (privacy rules: hashed by default)datatypes/ezuser/ezuser.php:auditStorechangenoticeonyesaccessat once
access.user.login.changethe login name changeslogin → logindatatypes/ezuser/ezuser.php:auditStorechangenoticeonyesaccessat once
access.user.password.changea password is changed [user-password-change*]– (never the password, the hash or its type)datatypes/ezuser/ezuser.php:auditStorechangenoticeonyesaccessat once
access.user.password.change.faileda change is refused (wrong old password, rules; demo_mode: an administrator while EXP_DEMO_MODE=1, with the path) [user-password-change-self-fail]– → reasonviews/user/password.php:run, expdemomode.php:refusedchangenoticeonyesaccessat once
access.user.password.change_required.setan account is marked "change password at next sign-in" (the account edit form, exp:user-password-change-required set)– → source (edit, console)user/exppasswordchangerequired.php:setrequirenoticeonyesaccessat once
access.user.password.change_required.clearthe mark is cleared: the user set a new password, or an administrator unticked it– → source (password, edit, console)user/exppasswordchangerequired.php:clearclearnoticeonyesaccessat once
access.user.password.change_required.redirecta marked account is sent to user/password instead of a page, or its sign-in is refused (REST 403 password_change_required, WebDAV, SOAP, the expservices login)– → channel (web, rest, webdav, soap, expservices), the view asked foruser/exppasswordchangerequired.php:moduleResult, :refusesSignInredirectinfoonyesaccessat once
access.user.second_factor.refuseda password that was right is refused on a sign-in path outside the web login form because the account needs a second step (Second step on the other sign-in paths)– → channel (rest, webdav, soap, expservices), whether a code was sent; reason second_factor_required, second_factor_invalid, second_factor_setup_required (never the password or the code)user/expsigninsecondfactor.php:checkrefusenoticeonyesaccessat once
access.user.app_password.createa user makes an app password on the two-step sign-in page (user2fa/setup)– → id, name, channels, created, expires (never the password, its hash or its salt)user/expsigninsecondfactor.php:audit (sevenx_authentication_2fa)createnoticeonyesaccessat once
access.user.app_password.revokean app password is revoked by its owner, or with every other one when the second step is removed or reset– → id, name, channels; reason owner, second_step_removed, second_step_resetuser/expsigninsecondfactor.php:audit (sevenx_authentication_2fa)revokenoticeonyesaccessat once
access.user.app_password.usean app password signs in on REST, WebDAV or SOAP (at most once per [AppPasswords] LastUsedInterval per app password)– → id, name, channels, the channel useduser/expsigninsecondfactor.php:audit (sevenx_authentication_2fa)useinfoonyesaccessat once
access.user.password.reset.requesta reset mail is requested [user-forgotpassword]– → mail sent yes/no (never the hash key)views/user/forgotpassword.php:runrequestnoticeonyesaccessat once
access.user.password.reseta reset completes [user-forgotpassword]– (never the hash key, never the password)views/user/forgotpassword.php:runresetnoticeonyesaccessat once
access.user.password.reset.faileda reset link is unknown or expired, or the address unknown [user-forgotpassword-fail]– → reason unknown_key/expired/unknown_email/demo_mode (never the key; an unknown e-mail address is hashed)views/user/forgotpassword.php:runresetnoticeonyesaccessat once
access.role.createa role is created– → nameviews/role/edit.php:auditRoleStored, views/role/edit.php:applyRolecreatenoticeonyesaccessat once
access.role.changea role is stored after editing [role-change]name, policies → name, policiesviews/role/edit.php:auditRoleStored, views/role/edit.php:applyRolechangenoticeonyesaccessat once
access.role.removeroles are removedname, policies, assignments → –ezrole.php:removeThisremovenoticeonyesaccessat once
access.role.copya role is copied– → nameezrole.php:copycopynoticeonyesaccessat once
access.role.assigna role is assigned to a user or group [role-assign]– → limitation (subtree, section)ezrole.php:assignToUser, ezrole.php:auditAssignmentassignnoticeonyesaccessat once
access.role.unassignan assignment is removedlimitation → –ezrole.php:removeUserAssignment, ezrole.php:removeUserAssignmentByIDunassignnoticeonyesaccessat once
access.policy.adda policy is added to a role– → module, function, limitationsezrole.php:appendPolicy, views/role/edit.php:auditRoleStoredaddnoticeonyesaccessat once
access.policy.removea policy is removedmodule, function, limitations → –ezpolicy.php:removeThis, views/role/edit.php:auditRoleStoredremovenoticeonyesaccessat once
access.apikey.createa user makes a personal API key on apikey/list (API keys)– → key id, name, prefix, owner; scopes, expiry (never the secret, its hash or its salt)apikey/list.phpcreatenoticeonyesaccessat once
access.apikey.revokea key is revoked by its owner or on oauthadmin/keysstatus → status, by (owner or administrator)private/modules/oauthadmin/keyaction.php, apikey/list.phprevokenoticeonyesaccessat once
access.apikey.usea key is used for the first time– → first use, scopesprivate/rest/classes/auth/apikey_auth_filter.php:runuseinfoonyesaccessat once
access.apikey.use.faileda REST request with a key is refused– → reason malformed, unknown, secret, revoked, expired, owner, transport, scope, policy, permission, rate_limited; route and scope for a scope refusal (an unknown key by the prefix it claims; never the secret)private/rest/classes/auth/apikey_auth_filter.php:refuse, private/rest/classes/auth/apikey_rest.php:authorizeusenoticeonyesaccessat once
access.passkey.adda user adds a passkey on user/passkeys (Passkeys); refused: result failed with the reason– → passkey id, name, algorithm, synced, aaguid (never the key or the credential id)passkey/exppasskeyservice.php:registeraddnoticeonyesaccessat once
access.passkey.renamea user renames a passkeyname → passkey id, namepasskey/exppasskeyservice.php:renamerenameinfoonyesaccessat once
access.passkey.removea user removes one of their passkeys (not the last one while the account could not sign in without it)– → passkey id, name, algorithmpasskey/exppasskeyservice.php:removeremovenoticeonyesaccessat once
access.passkey.usea passkey signs in or answers "Verify it's you"– → passkey id, name, purpose (login, reauth), user verifiedpasskey/exppasskeyservice.php:verifyLogin, passkey/exppasskeyservice.php:verifyForUseruseinfoonyesaccessat once
access.passkey.use.faileda passkey is refused– → purpose; reason challenge, origin, rp_id, signature, counter, user_verified, unknown, owner, disabled, locked, store ...passkey/exppasskeyservice.php:refusedusenoticeonyesaccessat once
access.passkey.revokean administrator revokes a passkey of another account on user/passkeys/<user id>– → passkey id, name, algorithm, by (the administrator)passkey/exppasskeyservice.php:revokerevokewarningonyesaccessat once
access.passkey.password_refusedthe sign-in form refuses the password of an account that signs in with passkeys only ([PasskeySettings] PasswordSignIn)– → channelpasskey/exppasskeypolicy.php:refusesPasswordForpassword_refusednoticeonyesaccessat once
access.passkey.requiredthe passkey policy sends a user without a passkey to add one (required)– → view, policypasskey/exppasskeypolicy.php:moduleResultrequiredinfoonyesaccessat once
access.passkey.suggestedthe passkey policy suggests adding a passkey, once per session (preferred)– → view, policypasskey/exppasskeypolicy.php:moduleResultsuggestedinfoonyesaccessat once
system.setting.writean INI file is writtenvalue in that file and value in effect → the same; for expIniEditor also the unified diff from diff() (never a value whose variable expIniEditor::isSecret() recognises: it is written as [secret])audit/expaudit.php:settingWritewritenoticeonyessystemat once
system.setting.undoa debug bar write is undoneas system.setting.writedebugbar/expdebugbarsettings.php:undoundonoticeonyessystembuffered
system.extension.changeActiveExtensions or its order is writtenlist → listezpactiveextensions.php:writechangenoticeonyessystembuffered
system.cache.clearcaches are cleared on request– → ids, msezcache.php:auditCleared (also for bin/php/ezcache.php, one record per run, through eZCacheHelper::auditCleared())clearinfoonyessystembuffered
system.cronjob.runa cronjob part runs (parent: the runcronjobs invocation)– → ms, resultezruncronjobs.php:auditPartruninfoonyessystembuffered
system.cronjob.faila part throws or exits non-zero– → errorezruncronjobs.php:auditPartrunwarningonyessystembuffered
system.cronjob.pausea cronjob part, or every part ("all"), is paused at Setup > Cronjobs– → pausedexpcronjobpause.php:auditpausenoticeonyessystembuffered
system.cronjob.resumea paused part, or every part, is resumed– → pausedexpcronjobpause.php:auditresumenoticeonyessystembuffered
system.log.reada log file is opened at Setup > Log files (once per file and session per logviewer.ini [ViewerAuditSettings] ReadInterval), or read by exp:logs (once per run)– → size, unmasked (cli, files)expLogViewer::auditRead, views/setup/logs.php, views/setup/logsfeed.php, commands/explogs.phpreadinfoonyessystembuffered
system.log.downloada range of a log file is downloaded– → size, from, to, unmaskedexpLogViewer::audit, views/setup/logsaction.phpdownloadnoticeonyessystembuffered
system.log.rotatea log file is rotated now– → size, ok, renamed (error)expLogViewer::audit, views/setup/logsaction.phprotatenoticeonyessystembuffered
system.log.truncatea log file is emptied– → size, ok (error)expLogViewer::audit, views/setup/logsaction.phptruncatewarningonyessystembuffered
system.log.deletean archived rotation of a log file is removed– → size, ok (error)expLogViewer::audit, views/setup/logsaction.phpdeletenoticeonyessystembuffered
system.command.runa command runs (not a run of exp:velocity watchdog run, which records system.velocity.watchdog.* only when it acts; a command opts out with eZScript::setAuditCommandRun( false ))– → exit code, msezscript.php:auditCommandRun (from shutdown)runinfoonyessystembuffered
system.package.installa package is installed– → name, version, itemsezpackage.php:installinstallnoticeonyessystembuffered
system.package.uninstalla package is uninstalledname, version → –ezpackage.php:uninstalluninstallnoticeonyessystembuffered
system.package.importa package archive is imported– → name, version, sha256 of the archiveezpackage.php:importimportnoticeonyessystembuffered
system.package.removea package is removed from its repository (package/list)name, version, repository, files, bytes, installed, installer source → –views/package/list.php:removeremovenoticeonyessystembuffered
system.install.runan installation is made– → siteaccesses, packages, database enginesetup/steps/ezstep_create_sites.php:initinstallnoticeonyessystembuffered
system.demo.modea process starts while demo mode is on (EXP_DEMO_MODE=1, demo-mode.md), once per process– → SAPI, pidexpdemomode.php:announce (from ezpkernelweb.php:requestInit, ezscript.php:initialize)modewarningalwaysyessystembuffered
system.upgrade.runan upgrade check or upgrade script runs; exp:upgrade --apply ends– → result, stages, applied, failed, backupviews/setup/systemupgrade.php:run, expupgrade.php:executerunnoticeonyessystembuffered
system.upgrade.stepexp:upgrade applied a step, or it failed– → step id, stage, engine, duration, reasonexpupgrade.php:executeapplynoticeonyessystembuffered
system.upgrade.backupexp:upgrade made its backup before the first write– → backup, files, resultexpupgrade.php:executebackupnoticeonyessystembuffered
system.upgrade.rollbackexp:upgrade:rollback --apply restored a caption– → caption, parts, safety caption, resultexpupgraderollback.php:runrollbackwarningonyessystembuffered
system.upgrade.captiona caption was taken (pre, post, safety), checked (--verify) or pruned– → caption, kind, source, label, resultexpupgradecaption.php:take, verify, prunetake, verify, prunenoticeonyessystembuffered
system.upgrade.verifythe verification after an upgrade or a rollback ran (exp:upgrade:verify --after)– → failed checks, resultexpupgradeverifyafter.php:runverifynoticeonyessystembuffered
system.velocity.deployexp:velocity deploy or restart runs– → steps, ms, resultexpvelocity.php:restart, expvelocitydeploy.php:rundeploynoticeonyessystembuffered
system.velocity.watchdog.startthe Velocity watchdog started the engine (success) or a start failed (failed, warning)– → reason (down, restart-stale), detailexpvelocitywatchdog.php:audit (from commands/velocity.php, watchdog run)startnoticeonyessystembuffered
system.velocity.watchdog.backoffthe watchdog left a down engine alone after three failed starts in a row– → reason, detailexpvelocitywatchdog.php:auditbackoffwarningonyessystembuffered
system.velocity.watchdog.skipthe watchdog left a down engine alone: stopped on purpose, a restart in progress, the lock held, the previous check still busy, or an error; only when the reason changes (the first skip of a period or a new reason; the last one is kept in watchdog-last.json beside the markers and cleared when the engine runs or is started again), not on every check– → reason (intentionally-stopped, restart-in-progress, lock-held, overlap, error), detailexpvelocitywatchdog.php:auditskipinfoonyessystembuffered
system.repair.queuethe repair queue is written or a repair key createdentries → entries (never the key)lib/ezutils/classes/ezprepairqueue.php:writeSettingschangenoticeonyessystembuffered
system.maintenance.changemaintenance mode is switchedmode → modeexpmaintenance.php:auditChangechangenoticeonyessystembuffered
system.template.changea template is created or edited in the adminsha256 → sha256 (never the template text)views/visual/templateedit.php:run, views/visual/templatecreate.php:runchangenoticeonyessystembuffered
system.workflow.trigger.changeworkflow triggers are changedworkflow → workflowviews/trigger/list.php:runchangenoticeonyessystembuffered
system.error.fatala request ends in a fatal error– → error reference (eZExecution::errorReference()), file:line (never the message's arguments)audit/expaudit.php:flushOnFatalfatalerroronyessystembuffered
system.audit.enableAudit=enabled takes effect where it was disabledstate → stateaudit/alerts/expauditalertevaluator.php:cronjobenablenoticealwaysyessystemat once
system.audit.disableAudit=disabled is written or found (Default installation)state → stateaudit/expaudit.php:settingWrite, audit/alerts/expauditalertevaluator.php:cronjobdisablewarningalwaysyessystemat once
system.audit.setting.writeany audit.ini variable is written (child of system.setting.write)as system.setting.writeaudit/expaudit.php:settingWritesettingnoticealwaysyessystemat once
system.audit.readan audit console view, the fetch or exp:audit tail/search is used (Q9)– → filters, result countaudit/archive/expauditarchiver.php:restore, audit/console/expauditconsole.php:recordRead, commands/audit.php:status, commands/audit.php:tail, commands/audit.php:show, commands/audit.php:searchreadinfoalwaysyessystemat once
system.audit.exportrecords are exported– → filters, format, count, sha256 of the filecommands/audit.php:exportexportinfoalwaysyessystemat once
system.audit.rotatea live file is closed and a new one started– → file, records, last hashaudit/archive/expauditmaintenance.php:rotaterotateinfoalwaysyessystemat once
system.audit.archivefiles are compressed into the archive– → files, manifest, key idaudit/archive/expauditarchiver.php:archiveDayarchiveinfoalwaysyessystemat once
system.audit.purgeretention removes live files, archives or index rows– → files, rows, oldest keptaudit/archive/expauditarchiver.php:purge, audit/index/expauditindexer.php:purgeOldpurgeinfoalwaysyessystemat once
system.audit.pseudonymiseindex rows past PseudonymiseAfterDays are pseudonymised– → rowsaudit/index/expauditindexer.php:pseudonymisepseudonymiseinfoalwaysyessystemat once
system.audit.verifya chain or an archive is verified– → intact/broken, recordsaudit/archive/expauditmaintenance.php:verify, commands/audit.php:verifyverifyinfoalwaysyessystemat once
system.audit.chain.brokenverification finds a break– → file, line, kind (see Verification)audit/archive/expauditarchiver.php:archiveDay, audit/archive/expauditmaintenance.php:verify, audit/index/expauditindexer.php:recordBreak, commands/audit.php:verifychainerroralwaysyessystemat once
system.audit.chain.repairthe writer finds a torn last line and continues after it– → bytes skipped, last good hashaudit/expauditwriter.phpchainnoticealwaysyessystemat once
system.audit.checkpointthe daily signed anchor of every channel's last hash– → per channel: file, seq, hash, HMACaudit/expaudit.php:checkpointcheckpointinfoalwaysyessystemat once
system.audit.reindexthe index is rebuilt– → rows, msaudit/index/expauditindexer.php:rebuild, commands/audit.php:reindexreindexinfoalwaysyessystemat once
system.audit.importold text logs are imported (Z4)– → file, sha256, recordsaudit/archive/expauditimporter.php:importimportinfoalwaysyessystemat once
system.audit.key.createa signing or pseudonym key is generated (Z7)– → key id, fingerprint (never the key)audit/expaudit.php:writekeynoticealwaysyessystemat once
system.audit.key.rotatea new signing key becomes activekey id → key idcommands/audit.php:keykeynoticealwaysyessystemat once
system.audit.sink.faileda sink cannot deliver (after its retries)– → sink, error, spooled countaudit/sinks/expauditsinkregistry.php:deliverSpoolsinkwarningalwaysyessystemat once
system.audit.alertan alert rule fires– → rule, count, window, groupaudit/alerts/expauditalertevaluator.phpalertwarningalwaysyessystemat once
system.audit.overflowthe buffer exceeded its limit and flushed early, or could not be written– → events, bytesaudit/expaudit.php:storeoverflowwarningalwaysyessystemat once
system.audit.file.openthe first record of every channel file (links to the previous file)– → previous file, its last seq and hashaudit/expauditwriter.phpfileinfoalwaysyessystemat once
system.audit.file.closethe last record of a rotated file– → recordsaudit/expauditwriter.phpfileinfoalwaysyessystemat once
commerce.order.deletean order is removed [order-delete]order number, status, total → – (never customer address fields)ezorder.php:cleanupOrderremovenoticeonyescommercebuffered
commerce.order.purgeall orders are removed [order-delete]count → –ezorder.php:cleanuppurgenoticeonyescommercebuffered
commerce.order.item.removean order item is removedproduct, count → –ezorder.php:removeItemremovenoticeonyescommercebuffered
commerce.order.createan order is activated (checkout confirmed)– → order number, total, currency (never the customer's address)ezorder.php:activatecreateinfooffnocommercebuffered
commerce.order.statusthe order status changesstatus → statusezorder.php:modifyStatuschangeinfooffnocommercebuffered
commerce.order.archivean order is archived or brought backflag → flagezorder.php:archiveOrderarchiveinfooffnocommercebuffered
commerce.order.unarchivean order is archived or brought backflag → flagezorder.php:unArchiveOrderunarchiveinfooffnocommercebuffered
commerce.basket.checkouta basket goes to checkout– → items, totalviews/shop/checkout.php:runcheckoutinfooffnocommercebuffered
commerce.payment.approvea payment is approvedstatus → status (never card or account data)shop/classes/ezpaymentobject.php:approveapproveinfooffnocommercebuffered
commerce.vat.changeVAT types or rules changerates → ratesezvatrule.php:removeVatRule, ezvatrule.php:store, ezvattype.php:store, ezvattype.php:removeThischangeinfoonyescommercebuffered
commerce.currency.changecurrencies are created, changed or removedcode, rate → code, rateshop/classes/ezcurrencydata.php:removeCurrencyList, shop/classes/ezcurrencydata.php:storechangeinfoonyescommercebuffered
commerce.discount.changediscount groups or rules changerule → ruleezdiscountrule.php:store, ezdiscountrule.php:removeByID, ezdiscountsubrule.php:store, ezdiscountsubrule.php:removechangeinfoonyescommercebuffered
data.export.csvcontent is exported as CSV– → node, rows, columnscommands/ezcsvexport.php:run, views/content/subitemsexport.php:runexportinfoonyescommercebuffered
data.export.packagea package is exported– → name, file, format (xml or json), sha256views/package/export.php:runexportinfoonyescommercebuffered
data.export.jsona user downloads the own sign-in data (user/security)– → sessions, events, file, sourceviews/user/security.php:downloadexportinfoonyescommercebuffered
data.export.pdfa PDF export is generated–views/content/pdf.php:expModuleViewContentPdfPassthroughexportinfooffnocommercebuffered
data.import.csvCSV is imported– → rows, createdcommands/ezcsvimport.php:runimportinfoonyescommercebuffered
data.import.dbaa .dba file is imported– → tables, rowscommands/ezimportdbafile.php:runimportinfoonyescommercebuffered
data.import.rssan RSS import runs– → createdcronjobs/rssimport.php:rssImportAuditimportinfooffnocommercebuffered
data.infocollection.removecollected information is removedcount → – (never the collected values)views/infocollector/collectionlist.php:run, views/infocollector/overview.php:runremovenoticeonyescommercebuffered
data.infocollection.viewcollected information is opened–views/infocollector/view.php:runreadinfooffnocommercebuffered
data.index.rebuildthe search index is rebuilt– → objects, mscommands/updatesearchindex.php:runrebuildinfoonyescommercebuffered

access.session.reauth and access.session.reauth.failed are raised by the password re-entry before manage actions (ReauthForManage, 3.1); with the shipped default (disabled) nothing raises them.


9. Proof

The acceptance tests of the design (Z9), run at the end of the work on 2026-10-02/03.

9.1 Tests

php vendor/bin/phpunit tests/tests/kernel/classes/audit/: 119 tests, 6 049 assertions, OK (2026-10-12, run as root: as the site user the settings-write tests cannot create their directory under a root-owned var/tmp/ini-tests, and the journald test needs root; earlier runs: one marked risky: testWriteFailureNeverThrows deliberately replaces the error handler to provoke a write failure).

Test classTestsCovers
expAuditRecordTest12record format and canonical JSON (B1), names, patterns, routing, branches, parents
expAuditChainTest9genesis, linked day files, size rotation, concurrent appends (B2), tamper cases T0–T8
expAuditPrivacyTest7every field × full/truncate/hash/off, /24 and /48, secret paths, unknown logins hashed (B5)
expAuditBufferTest7exception, exit, cleanExit, fatal error, 1 000 Velocity requests in one worker (B4)
expAuditForkTest4a forked child writes only its own records and leaves its parent's open parents and buffer to the parent, also when it only exits; repeated ids verify intact, are counted, and the export gives each once (AF-01–AF-04)
expAuditCompatTest7the 15 4.x names, HashKey never kept, keys, ownership, settings writes (B3)
expAuditHookTest5every catalogue name has a hook point; off names build nothing; verbs; the bridge (live database)
eZAuditLocationTest5a location added, chosen as main, removed (main and secondary) records add, main, remove and the promoted main node; off by default; the LoveStack location names; labels (live database, rolled back)
expAuditIndexTest10schema, incremental index, crash between batch and cursor, rebuild = incremental, search, pseudonymisation, fetch functions (live database, test channels)
expAuditSinksTest5RFC 5424 and journald, a webhook receiver verifying every signature, outage and retry, mail throttling (E1)
expAuditAlertsTest9every built-in rule reached, not reached, repeated; INI rules; replay (E2)
expAuditArchiveTest6each format handler, restore, retention, T9–T12, key rotation, the daily run (E3)
expAuditImportTest1both 4.x header forms, re-import skipped, the legacy manifest (E4)
expAuditFilterGuardReauthTest9name patterns, malformed filters find nothing, the time rule, --query, OnWriteFailure=refuse (the guard, a settings write, an admin POST), ReauthForManage (AG-01–AG-09; live database, throwaway log directories)
expAuditMailRecipientsTest8every recipient kind against the live database (temporary users and groups, removed afterwards)
expAuditExportStreamTest7the streamed export (EX-01–EX-07): the same bytes as the OFFSET loop before #393 for CSV, JSON lines and JSON over several pages and cut by a limit, batch by batch through its state, with unindexed records on top; ordered ids and rows by id; the query cache off while reading; the background job's file, a crash between a batch and its checkpoint, only the owner downloads (live database, test channels)
expAuditSourceTest8the console's sources (AS-01–AS-08): none registered changes nothing, a source of its own channel, what keeps one out, view paths only, a source that throws, the Channel limitation, a branch routed into the source's channel, the policy limitation lists it

Also tests/tests/kernel/classes/expViewAccessTest.php (the dashboard permission fix): 14 tests, 68 assertions, OK (6 skipped).

9.2 Tamper test

The tests prove T0–T12 on generated records. Stage 6 repeated the file cases on copies of the reference installation's real access channel (1 565 records over two day files), verified with the live keys. The live files were hashed before and checked afterwards to still begin with exactly the bytes copied:

Channel access: access-2026-10-02.jsonl, access-2026-10-03.jsonl (copies in var/tmp/audit-stage6/tamper-20261002-170104/)
Edited file: access-2026-10-02.jsonl
PASS T0  no change                                                  INTACT   1565 records (expected intact)
PASS T1  one byte of line 100 changed                               BROKEN   first break access-2026-10-02.jsonl line 100: altered (kinds: altered; expected altered at line 100)
PASS T2  line 50 removed                                            BROKEN   first break access-2026-10-02.jsonl line 50: link (kinds: link, gap; expected link + gap at line 50)
PASS T3  lines 61 and 62 swapped                                    BROKEN   first break access-2026-10-02.jsonl line 61: link (kinds: link, gap, reordered; expected reordered)
PASS T4  a forged line with a correct hash after line 70            BROKEN   first break access-2026-10-02.jsonl line 72: link (kinds: link, reordered; expected link at line 72)
PASS T5  20 records before checkpoint seq 1539 rewritten, chain recomputed BROKEN   first break access-2026-10-02.jsonl line 1539: rewritten (kinds: rewritten, truncated; expected rewritten at line 1539)
PASS T6  the oldest day file access-2026-10-02.jsonl removed        BROKEN   first break access-2026-10-03.jsonl line 1: no_origin (kinds: no_origin; expected no_origin or missing_file)
PASS T7  the last 10 lines of access-2026-10-02.jsonl cut off       BROKEN   first break access-2026-10-03.jsonl line 1: truncated (kinds: truncated; expected truncated)
PASS the live access files were not changed by the test (every live file still begins with the bytes copied)
PASS all cases

T5 is the attacker who recomputes the chain without the key. The signed checkpoint written before shows the rewrite at its seq, and the next day's file.open record still names the old last hash (truncated). T8 (a torn line, repaired) and T9–T12 (archives) are proven by expAuditChainTest and expAuditArchiveTest. exp:audit verify on the live log afterwards: every channel INTACT (output in section 2).

9.3 Performance

Per request (P1, P2), measured in the kernel as in stages 2 and 3: 2 000 requests per case, alternating in blocks of 100 between Audit on (the live settings, every stage 3 hook point) and Audit=disabled:

CaseOn p50 / p95Off p50 / p95Added (p50)Target
R: a request without events (reset, request id header, bridge attached, sensitive-view check, an off hook point, a sampled read with reads off, final flush)0.118 / 0.156 ms0.040 / 0.061 ms+0.078 ms< 2 ms
W: one buffered event (a node described from the database) and its append0.796 / 1.087 ms0.040 / 0.063 ms+0.756 ms< 2 ms
I: one immediate access event and the syslog sink at flush (journald, 200 requests under a test identifier)0.615 / 0.768 ms0.040 / 0.058 ms+0.575 ms< 2 ms

A front page or admin page that records nothing therefore pays less than a tenth of a millisecond, and a request that records something pays well under the 2 ms budget, the journald copy included.

Content jobs (P3): a content job copy and a content job remove (no trash) of a 144-node test subtree under Media, three rounds, alternating between the audit on and off:

source 14757: 144 nodes; 3 rounds, copy job + remove job, audit on (live settings) and off (Audit=disabled)
round 1 off copy done 144 nodes   5.15 s | remove done   2.91 s | total   8.06 s
round 1 on  copy done 144 nodes   5.21 s | remove done   2.68 s | total   7.89 s
round 2 on  copy done 144 nodes   4.95 s | remove done   2.76 s | total   7.71 s
round 2 off copy done 144 nodes   6.00 s | remove done   2.74 s | total   8.74 s
round 3 off copy done 144 nodes   5.62 s | remove done   2.54 s | total   8.17 s
round 3 on  copy done 144 nodes   4.95 s | remove done   2.63 s | total   7.58 s
copy: on 15.11 s, off 16.77 s | remove: on 8.07 s, off 8.19 s | total on 23.18 s, off 24.97 s, ratio 0.928
PASS content jobs with the audit within +5% of the time without (ratio 0.928)

The audit's share is below the run-to-run noise (the target was at most 1.05). The on rounds wrote 6 job creates, 6 starts, 6 finishes and 432 content.object.remove children. The off rounds wrote nothing. The test folder was removed afterwards by a content job:

removed the test folder 14756 (146 nodes) by content job 20261003-000230-29a7dcc0: done
PASS no test content left: 0 objects named 'Audit stage 6 test%' or 'A6 %', 0 nodes below 14756

P4 (ten concurrent writers to one channel, the chain intact, no torn lines) is expAuditChainTest::testConcurrentAppends.

9.4 Permissions

  • A1 (stage 1, the dashboard shows only what a user may open): 0 failures in 336 checks per siteaccess for Anonymous, Member, Partner, Editor, a subtree-limited Editor and Administrator (Appendix B, stage 1).
  • A2/A3 (stage 4, the audit console): admin, Editor, Auditor (audit/read), Shop auditor (audit/read limited to Channel commerce), Audit manager and Administrator, on admin4 and admin, Apache and Velocity. Tab, sidebar link, dashboard block, job links and every view were shown or refused as specified, the shop auditor saw commerce only, and every refusal was recorded as access.permission.refused. All PASS (Appendix B, stage 4).

9.5 Registries and configurations

auditbranches    Audit taxonomy branches    0 entries, 0 broken:
auditsinks       Audit sinks                3 entries, 0 broken: syslog=expAuditSyslogSink, webhook=expAuditWebhookSink, mail=expAuditMailSink
auditalertrules  Audit alert rule classes   3 entries, 0 broken: threshold=expAuditThresholdRule, match=expAuditMatchRule, schedule=expAuditScheduleRule
auditformats     Audit archive formats      5 entries, 0 broken: gzip=expAuditGzipFormat, bzip2=expAuditBzip2Format, xz=expAuditXzFormat, zstd=expAuditZstdFormat, zip=expAuditZipFormat
PASS the RAD survey counts the audit registries

The six reference configurations: PASS 6 reference configurations (section 6). The event reference: PASS 135 names, every one described.


Appendix A: design decisions (27 questions, 2026-10-02)

#QuestionDecision
Q1StorageBoth: JSON lines files are the record; an index in the site's database serves the console (F4)
Q2FieldsRequest context (event id, request id, siteaccess, URL/method, module/view, engine Apache/Velocity/CLI, host, pid, duration, HTTP status), actor (user id, login, roles at the time, session hash, IP v4/v6, user agent, impersonation, CLI user + command), before/after values (configurable, never passwords/tokens), result + reason (success/refused/failed, policy/token/lock reason, error)
Q3PrivacyConfigurable, safe default: per field full / truncated IP (/24, /48) / hashed / off; user agent on/off; never passwords or tokens; personal fields pseudonymised after the retention period
Q4IntegrityHash chain + signed archives: each event carries the previous event's hash per file; archives get a checksum manifest and an HMAC; the console shows whether a chain is intact or where it breaks
Q5FamiliesContent lifecycle, users + access, system + config, commerce + data, and the classification must be generic and reusable ("track almost everything, zoology style")
Q6FilesChannels by family, daily files; INI maps events to channels; the old per-event file names keep working as aliases
Q7Sinkssyslog/journald, webhook/HTTP, e-mail on critical events, a sink registry for extensions
Q8RotationBy day and size; compressed archives (gzip, bzip2, xz, zstd, zip through format handlers in a registry) to a configurable archive path; retention per channel; scheduled by a cronjob part (also runnable from the console and the command)
Q9AccessNew policies audit/read and audit/manage; every console access is itself audited; optional password re-entry before manage actions
Q10ConsoleTimeline + filters + search, event detail + links, charts + alerts view, export
F1TaxonomyBoth: hierarchical dotted names for configuration and routing, and every record also carries actor / verb / object / target / result (ActivityStreams-like)
F2CorrelationRequest id (also a response header), session and job ids, parent/child events (depth configurable)
F3SpeedBuffered, flushed in one append at request end (also on fatal errors); security events written at once; per-request state reset for Velocity's persistent workers
F4IndexThe site's main database (schema on every engine: Z1)
F5AlertsBuilt-in rules (brute force, admin role granted, settings written out of hours, mass delete, audit disabled or chain broken), INI rules, rule classes in a registry
F6Retention90 days live, 2 years archived, per channel; personal fields pseudonymised in the index after 90 days
F7DashboardOnly what the user's policies allow: every dashboard block and sidebar link checks access to its module/view (and limitations)
Z1EnginesSQLite, MySQL/MariaDB, PostgreSQL, Oracle, MongoDB: schema and tests on each one reachable here
Z2PlacementA new audit module with its own top tab, dashboard sidebar link + block, links from content/job and content/jobs, the Setup menu
Z3APIexpAudit::event() (the old eZAudit::writeAudit() keeps working and maps to it), template operator/fetch (policy checked), ezpEvent bridge, command exp:audit
Z4Old logsImported into the new format (marked imported, outside the chain); originals archived
Z5DefaultOn by default in every installation (owner: "enabled in a default installation by default conventions, vs ezp4 where it was off")
Z6ReadsOptional, sampled: node views and searches per section/class with a sample rate; views of sensitive admin modules (setup, role, user, audit) always
Z7KeyGenerated on first use, stored in settings/override (never committed), shown as a fingerprint; key rotation with key ids in archives
Z8DeliveryStages with sign-off
Z9ProofCoverage matrix, tamper test, performance, permission matrix
Z10DocsOperator guide, developer guide, event reference, security notes

Every point the design marked "Proposed" was accepted by the owner and built as described in this guide, apart from the items listed in Appendix C.

Appendix B: how it was built, stage by stage

Before 6.0.15, eZAudit (off by default) wrote plain text blocks per event name through eZLog. Rotation kept about 800 KB per file and silently deleted older history. Its $GLOBALS caches made it unsafe under Velocity, a password-reset key and mistyped passwords could end up in the log, and the ezmbpaex password events had no file at all and were dropped. Ten event names were written from 36 call sites. The audit described here replaces it, and eZAudit::writeAudit() remains as a compatibility path.

Stage 1: the dashboard permission defect (F7). The admin dashboard showed setup features to every user who could open it. expViewAccess (kernel/classes/expviewaccess.php) now decides whether the current user can open an address the way ezpKernelWeb decides the request: URL aliases, module and view, [SiteAccessRules], RequireUserLogin, PolicyOmitList, the login siteaccess limitation, hasAccessToView() with limitations, and the node or object of content/view and content/edit. Templates ask fetch( 'user', 'can_open', hash( 'uri', … ) ). Top tabs, every left menu, the dashboard blocks (ViewList[]), the admin4 dashboard's links and the context menus use it. New settings: menu.ini [MenuAccessSettings] CheckViewAccess, NoAccessLinks; dashboard.ini ViewList[]. Matrix A1: 0 failures in 336 checks per siteaccess (admin and admin4).

UserVisible linksopenHidden (administrator sees)refused
Anonymous, Member, Partnersign-in page–4444
Editor26262121
Editor, subtree-limited27272020
Administrator (test) / admin48 / 61all00

Stage 2: the event core. expAudit, the taxonomy registry (135 names), the buffer and writer (channel locks, the chain head read from the file, open and close records, size rotation, torn-line repair, ownership), verifier, reader, keys, privacy, canonical JSON, the compatibility path, the Velocity reset and the X-Exp-Request-Id header, exp:audit status/channels/tail/show/verify/checkpoint, the view audit/recent. Measured: a request without events 0.090 ms on against 0.029 ms off. Deviations from the original text, now part of the design: the header is X-Exp-Request-Id; TrustedRequestIdHeader is trusted from loopback only; a failed login of a known account has reason: credentials, an unknown one reason: not_found with the login hashed.

Stage 3: instrumentation. Every hook point goes through expAuditHook (guarded, never throwing, data built only when on). The kernel's writeAudit() calls became native events with full fields, subtrees and content jobs became parents of their parts, inner hook points are muted while an outer one records, and the ezpEvent bridge was added. Coverage matrix C1, run through real HTTP requests and CLI calls on test fixtures:

ServerPASSFAILn/a
Apache (PHP-FPM)5905
Velocity (:8080)5905
CLI78045

Names no server raised in the test were the audit's own (system.audit.*, proven by the stage 2, 4 and 5 tests), and actions not run against live data (package install/import, installation, deploy, order purge, search index rebuild, dba import, RSS import), each listed with its reason in the stage 3 commit of this document. The fixtures were removed afterwards with proof. Overhead: a request without events 0.127 ms, +0.04 ms over stage 2. Found on the way and fixed separately: content/pdf answered 500 (eZContentObject::cacheInfo() called statically).

Stage 4: index and console. The three index tables on every engine (with full text per engine), the incremental indexer, search (expAuditQuery), the module audit with dashboard, console, event, charts, alerts, export, archives and settings, the policies with the Channel limitation, the navigation part, the fetch functions and the operator, the top tab, sidebar link, dashboard block, Setup entry, job links and node tab, and translations (eng-US, ger-DE, 240 messages). Proof: schema on SQLite (21 checks) and generated SQL for MySQL, PostgreSQL, Oracle and MongoDB (15 checks); 13 097 records rebuilt in 6.0 s; FTS5 and LIKE gave the same ids; expAuditIndexTest 10/10; permission matrix A2/A3 PASS for six users on admin4 and admin, Apache and Velocity; Playwright at 960 px, scale 2, light and dark. The dashboard opens in about 25 ms with its 7-day figures cached. Deviations: SQLite FTS5 uses the trigram tokenizer; filter forms post and redirect to the bookmarkable URL; charts are HTML/CSS.

Stage 5: sinks, alerts, rotation. The sink registry with spools and retries, syslog/journald (journald's native protocol for Transport=local, because journald 252 does not parse RFC 5424 headers on /dev/log), the signed webhook, mail with configurable recipients (an owner decision on 2026-10-02), the alert evaluator with the three rule classes and seven shipped rules, five format handlers, archives with signed and chained manifests, retention with a purge ledger, the import of the 4.x logs (the reference installation's own login.log imported, originals archived), the cronjob part, and exp:audit complete. Tests E1–E4 plus the recipients test: 81 tests in all at that point. Deviations: match rules fire once per record, schedule rules once per group and window; retention never removes a key; restored and imported files have their own directories.

Stage 6: proof and this guide. The full test run, the tamper test on copies of real files, the performance measurement against the targets, the content job timing, the RAD survey count, the validation of the reference configurations, and the generated event reference (section 9).

Appendix C: known issues (2026-10-03)

Fixed since this list was first written: exp:audit search --query (3.3), malformed name patterns refused, one time rule for --from/--to, the password re-entry ReauthForManage (3.1) and OnWriteFailure=refuse (4.13), and the console's export, which took 35 s for a channel of 27,000 records, timed out on Velocity and could not run a larger filter in the background (Streaming the export, 2026-10-12).

Found while writing this guide. Each was checked on the reference installation:

IssueEffectUntil it is fixed
The archives and settings views and the alerts view are read-only (no "Archive now", no acknowledge, no settings form)these actions are done on the command lineexp:audit archive/restore/key, exp:ini set audit.ini/…

Something wrong on this page? Report an error

Documentation license

This documentation is free documentation, licensed under the GNU Free Documentation License 1.3 or later.

Exponential Copyright © 1998-2026 Exponential Foundation and 7x