Configuration¶
Three things to configure:
- Chain entities —
audit_trail_chainconfig entities that declare which PSR-3 channels chain, what retention windows apply, and which context contributors run. - Secret entities —
audit_trail_secretconfig entities that referencedrupal/keyKey entities holding the actual HMAC bytes. - Settings —
audit_trail.settingsglobal tunables for the auto-archive lifecycle and auto-verify cron.
Chain entities¶
Each chain is a audit_trail_chain config entity. CRUD through
/admin/config/system/audit-trail/chains. The default chain
ships in config/install/ so a fresh module enables with a
working chain in place.
Per-chain settings:
mode — which entries chain¶
Two values, both respecting an explicit 'chain' => FALSE in
the PSR-3 context as a universal opt-out:
flag(default) — only entries that pass'chain' => TRUEin their context land in the chain. Everything else flows through todblog/syslogas usual. Use this when you want explicit opt-in per call.auto— every entry on a channel claimed by this chain chains automatically. The chain claims a channel either by matching its id (e.g. thewebdavchain claims thewebdavPSR-3 channel) or via itschannels[]list. Amode: autochain on thedefaultid does NOT wildcard every other channel — it claims only the channels it explicitly names, so unrelated dblog noise (PHP deprecations, core notices) stays out.
channels[] — additional channels routed into this chain¶
By default each PSR-3 channel chains to its own
same-id chain. channels[] lets you funnel several channels
into one chain, for installations that want a single
auditable sequence across related subsystems.
# config/install/audit_trail.chain.notarial.yml
status: true
id: notarial
label: 'Notarial workflow'
mode: auto
channels:
- webdav
- finance
- workflow
With the above, a save by webdav and a node update by
finance land in the same notarial chain in id (≈
arrival) order, and verifying notarial walks the whole
sequence.
Removing a channel from channels[] does not retroactively
move or rewrite rows that already chained under it. Those rows
keep their chain column pointing at this entity, stay
verifiable, and remain visible on the entries list. Only
future entries on the removed channel stop landing here —
they flow through to whatever chain claims that channel next
(or to plain dblog if none does). To wipe historical entries
on a channel, delete the chain entirely via the chain delete
form (the "Delete chain and entries" path).
Per-chain retention overrides¶
Each chain inherits the global cron_archive retention
windows from audit_trail.settings and can override any of
them:
archive_after: P30D # archive after 30 days
live_purge_after: P75Y # keep archived rows queryable for 75 years (notarial)
file_purge_after: P75Y # never auto-purge the WORM file
transient_purge_after: P30D # signed-purge the transient bucket after 30 days
segment_granularity: month # hour | day | week | month — one WORM archive per chain per bucket
Why ISO 8601 rather than seconds: durations on the scale of
years are mis-typed easily as raw seconds. P75Y is
unambiguous, future-readable, and matches what regulatory
documents use.
contributors[] — context contributor pipeline¶
Each chain runs its contributor plugins in ascending weight
order at write time. Each contributor inspects the subject
plus the caller context and returns a two-bucket payload
(permanent / transient) that the orchestrator merges per
tier. Higher-weight contributors can overwrite lower-weight
ones key-by-key.
Plugins discoverable under
<module>/src/Plugin/ContextContributor/ and tagged with the
#[ContextContributor] attribute. Enable them via the chain
edit form; per-instance settings render in-form for plugins
that declare them.
Buggy contributor plugins cannot cascade into the entity-save
hook that triggered the audit event. Every applies() /
contribute() call is wrapped in try/catch; throws surface
to the audit_trail logger channel with chain: FALSE and
the row still lands minus the offending contribution.
filters[] — per-chain filter pipeline¶
Filter plugins run before contributors and decide whether an event is even allowed to reach the chain. Useful when a channel is almost what you want to chain but a few noisy verbs / actions don't carry audit signal.
Bundled: request_method¶
Suppresses events based on the current HTTP request method. Configurable per-instance:
- Mode (
allow/disallow): allow— only events whose request method is one of the checked verbs pass through; everything else is suppressed. Useful when the legitimate-traffic shape is small and known (e.g. onaudit_trail_file, only GET and PUT actually move bytes; everything else is protocol housekeeping).disallow— events whose method is in the list are suppressed; everything else passes through. Useful when the noise pattern is small and known (e.g. quiet PROPFIND / LOCK / UNLOCK on a chain that also audits regular file activity).- Request methods — checkbox list covering standard HTTP
- WebDAV verbs (GET, HEAD, POST, PUT, PATCH, DELETE,
OPTIONS, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK,
UNLOCK). Custom verbs already in saved config (e.g.
REPORTfor CalDAV) appear as already-checked options so they can be unchecked through the UI. - Channel allow-list — scope the filter to specific channels (one per line). Empty applies to every channel the chain handles. Lets one chain hold filters whose policies differ per channel.
- Action allow-list — scope the filter to specific
actions (one per line, e.g.
file_downloaded). Empty applies to every action. - Forbid passthru on rejection — when checked, an event this filter rejects is dropped everywhere (no chain row AND no dblog row). Default (unchecked) lets rejections still reach dblog via the chain's passthru fanout, so operators retain visibility of what got suppressed.
Empty methods list is a no-op in both modes — events pass
through. CLI / cron requests always pass through, regardless
of mode (there's no HTTP method to match against).
Adding your own¶
Custom filters: ship a class extending AuditTrailFilterBase
under src/Plugin/AuditTrailFilter/ annotated with
#[AuditTrailFilter]. Drupal's plugin discovery finds them;
they show up in the chain edit form under the filter list.
See the bundled RequestMethodFilter for the reference
shape (shouldEmit() returns FALSE to suppress;
buildConfigurationForm() provides the per-instance UI).
Secret entities¶
Each audit_trail_secret config entity carries:
secret_id— integer matching the per-rowsecret_idcolumn. Auto-assigned at create time asmax(existing) + 1.key_id— id of adrupal/keyKey entity holding the bytes.secret_status—pending/active/retired.created/retiredtimestamps.
CRUD through
/admin/config/system/audit-trail/secrets. The module never
stores secret bytes itself; the drupal/key Key module's
providers decide where bytes live.
Provisioning a fresh secret¶
- Provision a Key entity holding 32 bytes of CSPRNG output via your chosen Key provider (config provider for testing, file / env / cloud-managed for production).
- Create an
audit_trail_secretentity referencing that Key. - Activate it via the secret list page "Activate" operation
(or
SecretRepositoryInterface::activate($id)from PHP).
The active secret is what getCurrentSecretId() returns to
the logger for fresh writes. The verifier dispatches per-row
to the row's stored secret_id, so a single chain can span
any number of rotated secrets.
Rotation¶
To rotate to a new secret:
- Provision a new Key entity holding fresh CSPRNG bytes.
- Create a new
audit_trail_secretentity inpendingstatus referencing the new Key. - Activate the new entity (the secrets list "Activate" form or the rotate drush command). The repository saves the new active first, then retires the previous active.
The activate-then-retire order is deliberate: a crash
mid-rotation leaves two active entities (benign — both
backed by valid Key bytes, fresh writes still succeed) rather
than zero (which would halt every chained write).
getCurrentSecretId() returns the highest-id active when
more than one exists, so fresh writes land on the newly
promoted secret even in the two-active window. Operators
converge to the steady state by re-running activate or by
retiring the leftover directly.
Retiring without replacement¶
The "Retire" operation on the secrets list flips an entity to retired status without promoting a new active. This is the emergency-isolation primitive: chained writes immediately start failing with the no-active-secret diagnostic, which is the loud-fail desired state in a key-compromise incident.
Key provider trade-offs¶
| Provider | Material location | Operator effort | Survives DB-read compromise? |
|---|---|---|---|
config |
Drupal DB (key_config_override) |
Zero (the Key form generates and stores) | No |
| File | Outside the webroot, owned by a separate UID | Provision per rotation | Yes if FS permissions are correct |
| Environment variable | Process memory only | Set in deploy / systemd / k8s | Yes |
| AWS Secrets Manager / GCP Secret Manager / Azure Key Vault | Managed cloud service | Provision in the cloud console | Yes |
| HashiCorp Vault | Vault | Provision via Vault CLI | Yes |
| HSM-backed providers | HSM | HSM ceremony | Yes |
audit_trail.settings — global tunables¶
cron_archive — automatic retention lifecycle¶
When enabled, a cron-driven worker progresses chain rows through their lifecycle automatically. The lifecycle is deterministic: every row passes through the same stages as it ages past the configured thresholds.
cron_archive:
enabled: false # off by default — opt in explicitly
cron_interval_seconds: 3600 # minimum gap between cron passes per chain
archive_after: P7D # rows older than this get archived
live_purge_after: P3M # archived rows older than this lose their live copy
file_purge_after: P2Y # archived files older than this get unlinked
transient_purge_after: P30D # signed-purge the transient column at this age (empty = disabled)
segment_granularity: week # hour | day | week | month — one archive per chain per bucket; `hour` exists for staging/test workflows
The duration thresholds all clock from each row's own
created timestamp. The settings form refuses any
configuration that breaks the archive_after <
live_purge_after < file_purge_after invariant.
transient_purge_after is optional: when empty (per-chain or
globally) the transient-purge cron pass simply doesn't run
for that chain.
auto_verify_enabled / auto_verify_max_age_hours¶
Cron-driven incremental verification. When enabled, each
cron pass walks every chain via verifyChainIncremental()
and writes the results to State. The runtime status report
(/admin/reports/status) surfaces stale runs (cron hasn't
verified in N hours) and broken chains as
REQUIREMENT_WARNING / REQUIREMENT_ERROR.
Lifecycle stages¶
| Stage transition | Trigger | What happens |
|---|---|---|
| Live → Archived | row.created + archive_after < now | Cron picks closed calendar buckets and calls ChainArchiver::archive(), landing an NDJSON under <archive_directory>/<chain>/<year>/<YYYY-MM-DD>--<id>.ndjson. The audit_trail_segment bookkeeping row is HMAC-signed. |
| Archived → Live-purged | row.created + live_purge_after < now | Cron calls ChainArchiver::purge(). Live rows are deleted from audit_trail; the NDJSON archive remains. audit_trail_segment.live_purged_at gets stamped. |
| Live-purged → File-purged | row.created + file_purge_after < now | Cron calls ChainArchiver::filePurge(). The NDJSON file is unlinked (after a SHA-256 sanity check that refuses to delete tampered files). The bookkeeping row stays with its anchor_before / anchor_after hashes so the verifier keeps bridging across the now-empty range. audit_trail_segment.file_purged_at gets stamped. |
| Transient column purge | row.created + transient_purge_after < now | Cron NULLs context_transient on eligible rows in place, creates a new audit_trail_segment row attesting the transition (transient_purged_at != 0), and emits a segment_transient_purged chain event. The chain still verifies because only context_transient_hash is signed; the new segment legitimizes the NULL via the segment-coverage rule in verifyTransientColumn. |
Each lifecycle transition writes a chained log row (action
= segment_archived / segment_live_purged /
segment_file_purged / segment_transient_purged /
segment_restored) back into the chain it affects, with
resource = 'segment:<id>'. The matching segment row records
the event's audit_trail.id in its *_event_id column,
signed into lifecycle_hmac. The verifier walks the chain
and cross-checks both directions of the mutual reference, so
tampering with either the chain event or the segment row's
state flags surfaces as a verifier error.
Pass ordering: transient-purge runs before archive¶
On any cron tick that runs both passes, transient-purge always executes first. This is load-bearing: once a row is archived, its NDJSON file captures whatever bytes the transient column held at that moment, signed into the file's SHA-256. A later transient-purge can NULL the live row's column but cannot retroactively scrub the bytes that already sealed into the NDJSON.
The settings-form invariant (transient_purge_after <
archive_after) exists for this reason: if archive could win
the race, raw transient bytes would leak into long-retention
WORM archives. Operators should keep that ordering in mind
when picking thresholds — if transient purge needs to run at
least N days before archive, configure thresholds so the gap
exists.
Misconfigured chains skip and log¶
When audit_trail_chain config carries a malformed ISO 8601
duration (typo, hand-edited YAML, partial config import) or
a segment_granularity outside the accepted value set,
resolveThresholds() returns NULL and cron skips that chain
for that tick — no archive, no purge, no transient purge.
The settings forms validate every duration at save time and
refuse invalid values, so the form path is safe. YAML edits
and drush config:set bypass the form. Whenever the skip
fires, the cron worker writes a WARNING-level entry to the
audit_trail logger channel naming the chain and the
admin-edit URL. CI / monitoring that alerts on
audit_trail-channel WARNINGs catches it; otherwise watch
the channel during cron windows.
Bucket granularity¶
hour / day / week (ISO) / month selects how cron
slices time into archive buckets. A bucket is only archived
once it has fully closed (the calendar window has ended)
AND every row in it has aged past archive_after. Both
conditions must hold.
- hour: one archive per chain per UTC hour. Intended
for staging / test workflows where waiting a full day
for the first archive to materialize is impractical.
Production installs typically run with
dayor coarser. - day: best for high-volume chains; one archive per chain per UTC day.
- week: sensible default for moderate traffic; one archive per chain per ISO week (Monday 00:00 UTC → next Monday 00:00 UTC).
- month: best for very-low-volume chains where annual archives would be too coarse. Aligns to UTC calendar months.
Buckets align to UTC, not site timezone¶
All bucket boundaries are computed in UTC, regardless of the
Drupal site's system.date:timezone setting. A site in
Asia/Tokyo (UTC+9) running day granularity produces
archives that close at 09:00 local time, not local midnight.
This is intentional — UTC is the only reproducible boundary
across servers in different zones and a deployment that moves
hosts. Operators planning daily WORM exports should align
their off-host transfer cadence to UTC midnight, not local.
Granularity must not exceed the smallest applicable threshold¶
The closed-bucket rule has a non-obvious consequence: when
segment_granularity is larger than archive_after (or
transient_purge_after), the granularity dominates and the
threshold is effectively ignored.
Concrete example: archive_after = P3D and
segment_granularity = month on a row written May 1.
- The row is past
archive_afterby May 4. - The May bucket end is June 1 00:00 UTC.
- The bucket isn't closed until June 1, and won't be archive-eligible until June 4 (June 1 + 3-day cutoff).
So the row sits in the live table until June 4 — ~34 days
after writing, not the 3 days the threshold suggested. Same
effect on transient_purge_after: the transient column lives
in the DB up to one granularity period longer than the
threshold value declares.
This matters for GDPR commitments. If transient_purge_after
exists because raw bytes can't legally linger beyond N days,
pairing it with a segment_granularity larger than N silently
breaks the commitment. The chains forms warn on this case at
save time; production deployments should pick granularity ≤
the smallest threshold that has a compliance constraint.
Switching granularity mid-chain¶
Changing the value on a chain that already has archives:
existing segments keep their original bucket size and stay
exactly as written. Newly-eligible rows after the change
bucket at the new granularity. No retroactive re-bucketing
happens (segments are immutable post-write). Operators
switching from week to day on a long-running chain will
see weekly archives in their old WORM exports and daily
archives in new ones — both verify, the bucket distinction
is purely operational.
Manual operation¶
Auto-archive can be triggered out of band:
drush audit_trail:auto-archive --uri=https://example.com
drush audit_trail:auto-archive --chain=default --uri=https://example.com
Runs the four lifecycle passes (transient-purge → archive →
live-purge → file-purge) immediately, bypassing the per-chain
throttle that hook_cron honors and without stamping
last_run_at (so the next scheduled tick fires on its own
cadence). Honors every other config knob and per-chain
retention override. Useful for smoke-testing after editing
thresholds or for moving a specific chain through the
lifecycle on demand. The manual
drush audit_trail:archive, audit_trail:purge, and
audit_trail:archive-restore commands remain available for
range-specific operations the cron flow doesn't cover.
A full walk of every chain (cold verification from genesis on
each chain) can be triggered on demand via
/admin/reports/audit-trail/verify-all-full. On a
multi-million-row chain this is minutes-to-hours of CPU — the
trigger is gated behind the separate run audit_trail
verification permission so read-only viewers can't accidentally
launch it.