Problem/Motivation

McpAuditLogger is a general-purpose, tamper-evident audit facility that happens to live inside an MCP governance module: hash-chained, optionally encrypted, independently verifiable.

Every regulated-industry buyer wants that for far more than agent traffic — personnel-record reads, permission grants, configuration changes, break-glass logins. The immediate driver was a consumer that needed to log reads of sensitive fields, where the honest options were to depend on mcp_sentinel for an entirely unrelated reason, or to reimplement a hash chain. Both are bad, in different ways.

There is also a naming problem that is not cosmetic: an enterprise buyer evaluating audit posture should not have to work out why the answer is a module named after the Model Context Protocol.

Proposed resolution

Extract it into its own project — Audit Chain — with mcp_sentinel as its first consumer.

Chain behaviour is deliberately unchanged: the same canonical payload and key order, the same HMAC-SHA256 over prev_hash|canonical with a plain SHA-256 fallback, encryption applied after hashing so enabling it later does not invalidate earlier rows, and the same append lock preventing two concurrent writers from forking the chain. The point of the extraction was to stop "governed AI" being a prerequisite for "governed anything", not to redesign the chain.

One addition: a channel column identifying the consumer, bound into the row hash so an entry cannot be re-attributed to another channel after the fact — which matters when one channel is the thing being audited.

McpAuditLogger keeps its entire public surface and becomes the MCP policy in front of the shared chain: which operations are suppressed, what a change diff contains, how redaction and DLP apply. Callers and submodules need no change.

Data model changes

An update hook migrates existing entries out of mcp_sentinel_audit_log into the shared table. Two details of that migration are deliberate and worth recording, because both are easy to "simplify" into something wrong:

  • The signing key and encryption profile move first. The chain is keyed, so a row rehashed or read under a different key is indistinguishable from a tampered one.
  • Rows are copied verbatim, with their original prev_hash and row_hash, under an empty channel — never re-chained. Re-chaining would be one line and would destroy the evidence: hashes recomputed by a migration prove only that the migration ran, and would silently repair a chain that had been broken beforehand. Audit Chain omits the channel from the canonical payload when it is empty precisely so these rows keep verifying against their original hashes.

Retention and every read path cover that legacy channel alongside the current one, so the audit log, the dashboard counts and the anomaly rules do not appear to lose their entire history the moment the update runs.

Remaining tasks

Constraints carried into the new project's documentation, because they are consumer obligations rather than implementation details:

  • Do not log per access check. A hook like hook_entity_field_access() fires per field, per entity, per render; an entry each time produces a chain nobody can read. Dedupe per request and flush once at kernel.terminate.
  • Rotating the encryption profile orphans prior rows. The chain is computed over the plaintext, so rows encrypted under the old profile stop verifying, and the failure looks exactly like tampering.

Implementation: mcp_sentinel PR 69 (the consumer side). It is intentionally held as a draft until Audit Chain has a published release to depend on.

Comments

jmcerda created an issue. See original summary.

jmcerda’s picture

Status: Active » Fixed

Done in 2.0.0. The tamper-evident chain now lives in its own project: https://www.drupal.org/project/audit_chain (`drupal/audit_chain`, released 1.0.1), and mcp_sentinel 2.0.0 takes it as a required dependency.

Config keys moved out of `mcp_sentinel.settings` into `audit_chain.settings`: `audit_hash_key` → `hash_key`, `audit_encryption_profile` → `encryption_profile`, `siem_enabled` → `stream_enabled`. Update 10016 copies them across before migrating rows.

Existing `mcp_sentinel_audit_log` rows migrate into `audit_chain_log` in batches, **copied verbatim and deliberately not re-chained**. Re-hashing during a migration would only prove the migration ran, and would paper over any tampering that predated it. The legacy table is left in place on purpose for the operator to drop by hand once satisfied.

Verified on a production site: 2,002 rows migrated, with the moved settings carried across intact.

Two consequences for anyone upgrading, both tracked separately in this queue:

* **#3613962** — 2.0.0 cannot be deployed in one step if your pipeline runs `updb` before `cim`. The `audit_logger` service holds a hard reference to `audit_chain.logger`, so the container fails to compile before `cim` has had any chance to enable `audit_chain`, and drush cannot recover the site because drush needs the same container. Enabling `audit_chain` in a prior release is the workaround.
* "Update 10016 copies the moved audit settings but leaves the originals behind as silent no-ops" — the originals stay in `mcp_sentinel.settings` after the migration.

A note for operators migrating an existing chain: `drush audit-chain:verify` may report the chain broken at the first row that was written with an unkeyed hash. That is pre-existing state being surfaced honestly rather than a migration defect — see audit_chain #3613960.

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.

jmcerda’s picture

Version: 1.x-dev » 2.0.0
Status: Fixed » Closed (fixed)

Shipped in 2.0.0. Closing after release.