Skip to content

Configuration

Three things to configure:

  1. Chain entitiesaudit_trail_chain config entities that declare which PSR-3 channels chain, what retention windows apply, and which context contributors run.
  2. Secret entitiesaudit_trail_secret config entities that reference drupal/key Key entities holding the actual HMAC bytes.
  3. Settingsaudit_trail.settings global 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' => TRUE in their context land in the chain. Everything else flows through to dblog / syslog as 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. the webdav chain claims the webdav PSR-3 channel) or via its channels[] list. A mode: auto chain on the default id 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. on audit_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. REPORT for 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-row secret_id column. Auto-assigned at create time as max(existing) + 1.
  • key_id — id of a drupal/key Key entity holding the bytes.
  • secret_statuspending / active / retired.
  • created / retired timestamps.

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

  1. 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).
  2. Create an audit_trail_secret entity referencing that Key.
  3. 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:

  1. Provision a new Key entity holding fresh CSPRNG bytes.
  2. Create a new audit_trail_secret entity in pending status referencing the new Key.
  3. 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 day or 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_after by 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.