Drupal is a registered trademark of Dries Buytaert
Release: reCAPTCHA 8.x-3.5 Minor update available for module recaptcha (8.x-3.5). Release: Search API Solr 4.3.14 Minor update available for module search_api_solr (4.3.14). Release: ECA: Event - Condition - Action 2.1.22 Minor update available for module eca (2.1.22). Release: WebProfiler 11.2.1 Minor update available for module webprofiler (11.2.1). Usage Milestone: Entity Browser Module entity_browser crossed 100,000 active installs. Release: Editoria11y SI (SiteImprove) 3.0.0 Major update available for module editoria11y_si (3.0.0). Release: Diagnosis 1.0.3 Minor update available for theme diagnosis (1.0.3). Release: DiBa Integration 1.3.3 Minor update available for module diba_integration (1.3.3). Release: AlternativeCommerce NovaPoshta API 1.1.10 Minor update available for module basket_novaposhta (1.1.10). Module Revived: WebProfiler 11.2.0 Module webprofiler updated after 14 months of inactivity (11.2.0).

This module provides tamper-evident audit logging by creating a chain of records where each entry's hash includes the previous one, making any alteration detectable. It can be configured with a signing key to prevent unauthorized database edits and includes options for encrypting metadata and streaming entries to external logging systems.

Tamper-evident audit logging for Drupal, usable by any module.

Each entry's hash covers its own content and the previous entry's hash. Any later insertion, deletion or edit breaks that chain and is detectable by an independent verification pass. With an HMAC key configured, forging a repair also requires the key — so a database-level edit cannot be quietly papered over.

The difference that matters: not "the application says it logged that", but a record that can be shown not to have been altered since it was written.

Using it

Inject Drupal\audit_chain\AuditChainLoggerInterface and write an entry:

\Drupal::service('audit_chain.logger')->log('personnel', 'field_read', [
  'entity_type' => 'node',
  'id' => $node->id(),
  'field' => 'field_salary',
]);

entity_type, bundle, id and label become indexed columns; every other key is serialised into metadata. All of it — plus actor, timestamp, IP and user agent — is covered by the hash. The first argument is the channel (the consumer). It is bound into the hash, so an entry cannot be re-attributed after the fact.

Request-scoped collector

Do not log once per access check. Hooks like hook_entity_field_access() fire per field, per entity, per render; an entry each time floods a chain you cannot un-flood. Use the collector instead:

\Drupal::service('audit_chain.collector')->collect('personnel', 'field_read', [
  'entity_type' => 'node',
  'id' => $entity->id(),
  'field' => $field_name,
]);

It deduplicates per request (channel, operation, entity keys; first occurrence wins) and writes once at kernel.terminate, so the obvious call is the safe one and the chain lock stays off the request critical path.

Verifying

drush audit-chain:verify

Exit code is the contract: non-zero means the chain does not verify. Two failures are reported differently:

  • Tampered — a row's content or ordering no longer matches its hash.
  • Unsigned / written unkeyed — rows are intact and in order, but were hashed without the configured signing key (usually an unresolvable Key entity at write time). Nothing was edited; anyone with database access can rewrite them.

Both exit non-zero. The status report flags a configured key that will not resolve while writes are still happening.

Configuration

Configuration → System → Audit Chain (/admin/config/system/audit-chain):

  • Signing key — a Key entity (prefer File or Environment, outside the database). Empty means plain SHA-256.
  • Retired signing keys — keys this chain was signed with previously. Verification accepts them so rotating the signing key does not make earlier rows look tampered with.
  • Encryption profile — encrypts metadata at rest. Rotating orphans existing ciphertext; export or re-encrypt first. The status report warns when rows still reference a profile the site no longer uses.
  • Stream entries — emit each entry to the audit_chain logger channel for SIEM forwarding.

Sealing an unverifiable prefix

If history was written unkeyed (or is otherwise not verifiable under today's signing keys), do not re-chain it. Recomputed hashes would paper over tampering. Instead:

drush audit-chain:seal --through=1997 --reason="pre-key unkeyed production segment"
drush audit-chain:verify

The seal is a site-local genesis anchor over stored row_hash values. It proves nothing about the past; it makes any future change to that prefix detectable and lets post-seal verification exit cleanly. Only rows that do not verify under the configured keys may be sealed. See the 1.3.0 release notes.

What it does not do

  • It does not make deletion impossible — it makes deletion evident.
  • It does not order events across servers.
  • It is not a replacement for dblog or syslog. Those are operational logs; this is an evidentiary one.

Origin

Extracted from MCP Sentinel, where it grew up as the trail for AI-agent traffic. It was never specific to that: personnel-record reads, permission grants, configuration changes and break-glass logins all want the same guarantee without an AI-governance module. MCP Sentinel remains its first consumer.

Requirements

Maintainers

Maintained by Jeremy Michael Cerda, sponsored by Wilkes & Liberty, LLC.

Activity

Tracked releases
7
Tracked since
Jul 2026
Latest release
1 week ago
Releases (12 mo)
7 ▲ from 0
Maintenance
Active

Release Timeline

Releases

Version Type Release date
1.3.0 Stable Aug 1, 2026
1.2.0 Stable Jul 31, 2026
1.1.0 Stable Jul 31, 2026
1.0.2 Stable Jul 30, 2026
1.0.1 Stable Jul 29, 2026
1.0.0 Stable Jul 29, 2026
1.x-dev Dev Jul 29, 2026