This project is not covered by Drupal’s security advisory policy.

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

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

The useful distinction is not “the application says it logged that,” but “this record can be checked for changes 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',
  'bundle' => 'person',
  'id' => $node->id(),
  'label' => $node->label(),
  'field' => 'field_salary',
]);

entity_type, bundle, id, and label become indexed columns. Every other key is serialized into metadata. The hash also covers the actor, timestamp, IP address, user agent, channel, and operation.

Evidence-required consumers

Call logKeyed() instead of log() when an unsigned row is unacceptable. It throws AuditChainSigningUnavailableException and writes nothing if the signing key will not resolve. signingStatus() returns {keyed, key_id} for precondition checks. Ordinary auditing should keep using log(), which prefers an unsigned row over a dropped one.

Request-scoped collector

Do not log once per access check. Hooks such as hook_entity_field_access() run per field, entity, and render. Logging every call floods a chain that cannot later be reduced without breaking it. Use the collector instead:

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

The collector deduplicates within the request and writes once at kernel.terminate. The first occurrence wins.

Verifying

drush audit-chain:verify

The exit code is the contract: non-zero means the chain does not verify. The result identifies the condition that needs attention:

  • BROKEN — a row's content or ordering no longer matches its hash.
  • UNSIGNED — rows are intact and ordered but were written without the configured signing key. Anyone with database access could recompute them.
  • SEAL BROKEN — a stored hash in a sealed historical prefix no longer matches the seal digest.
  • SEAL FOREIGN — the sealed-prefix digest still matches, but its MAC cannot be authenticated with this environment's current or retired keys.

A foreign seal remains fail-closed: verification exits non-zero and evidence export stays blocked. It is reported as an operational warning without dispatching the integrity-failure event because unchanged copied hashes are not, by themselves, evidence of tampering.

Scheduled verification and alerting

Scheduled verification runs a full check on cron at the configured interval. Each run records a durable verdict for the status report. An integrity failure logs an error to the audit_chain channel and dispatches AuditChainVerificationFailedEvent; the check never modifies the chain.

The Require keyed verification option refuses unkeyed operation when no signing key resolves or when history was written unsigned. Use it where plain SHA-256 does not meet the assurance requirement.

Exporting evidence off-system

drush audit-chain:export --destination=https://evidence.example.com/ingest

drush audit-chain:export --destination=/var/evidence/chain.ndjson --from-id=1

The exporter sends versioned NDJSON to an HTTPS endpoint or appends it to a server file under an exclusive lock. Plain HTTP is refused except to loopback. Per-destination checkpoints advance only after successful delivery, so delivery is at least once and consumers must deduplicate on row id.

The export is data-minimized: it includes identifiers and hash-chain columns, but excludes metadata, IP addresses, user agents, and entity labels. Because the exported subset cannot recompute row_hash, verification remains an on-system duty. Export refuses while the latest scheduled verification is failing.

Configuration

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

  • Signing key — a Key entity, preferably stored outside the database with the File or Environment provider. Empty means plain SHA-256.
  • Retired signing keys — keys accepted when verifying older rows and seals after rotation.
  • Encryption profile — encrypts metadata at rest.
  • Stream entries — emits structured records to the audit_chain logger channel for SIEM forwarding.
  • Scheduled verification interval and Require keyed verification — control cron verification and its assurance floor.
  • Export evidence off-system on cron, destination, and channel filter — control scheduled evidence delivery.

Rotating the encryption profile

Metadata encrypted under one profile cannot be read with another profile alone, and verification needs the plaintext covered by each row's hash. Keep the old profile available and re-encrypt existing rows before removing it:

drush audit-chain:reencrypt --from=old_profile --to=new_profile

The command rewrites ciphertext without changing the hash chain. The status report warns while rows still name an old profile. Evidence export is not a substitute because exported records deliberately exclude metadata.

Sealing an unverifiable prefix

If historical rows were written unkeyed or are otherwise unverifiable under the configured signing keys, do not re-chain them. Recomputed hashes would erase the evidence of a prior change. Create a keyed anchor over the stored hashes instead:

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

drush audit-chain:verify

The seal proves nothing about the prefix before it was created. It makes future changes to those stored hashes detectable and lets verification continue from the next row. Seal creation requires a resolvable active signing key.

Database refreshes and foreign seals

A database refresh copies the seal but should not copy the source environment's signing key. The target therefore reports SEAL FOREIGN when the copied prefix hashes still match but the seal MAC cannot be authenticated locally. Verify the prefix on the source environment before relying on it. Do not copy a production key merely to make a refreshed environment pass.

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 record.

Origin

Audit Chain was extracted from MCP Sentinel, where it records AI-agent traffic. The same integrity requirement applies to personnel-record reads, permission grants, configuration changes, and break-glass logins. MCP Sentinel remains its first consumer.

Requirements

Maintainers

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

Supporting organizations: 

Project information

Releases