phil / audit-bundle
Append-only audit trail for Symfony: hash-chained rows indexed by actor, action and record, read a page at a time, one chain per site, facts fed to dashboards, salted commitments on personal fields, external anchors
Package info
github.com/parler-haut-interagir-librement/audit-bundle
Type:symfony-bundle
pkg:composer/phil/audit-bundle
Requires
- php: >=8.5
- phil/ddd-bundle: ^0.1@dev
- psr/clock: ^1.0
- symfony/config: ^8.2
- symfony/dependency-injection: ^8.2
Requires (Dev)
- ext-pdo_sqlite: *
- doctrine/dbal: ^4.5
- doctrine/orm: ^3.7
- phil/tenancy-bundle: ^0.1@dev
- phil/url: ^0.1@dev
- phpunit/phpunit: ^13.4
- psr/log: ^3.0
- symfony/clock: ^8.2
- symfony/console: ^8.2
- symfony/event-dispatcher: ^8.2
- symfony/framework-bundle: ^8.2
- symfony/http-client: ^8.2
- symfony/http-client-contracts: ^3.6
- symfony/http-kernel: ^8.2
- symfony/messenger: ^8.2
- symfony/security-core: ^8.2
- symfony/workflow: ^8.2
Suggests
- doctrine/dbal: To keep the trail in a table of a Doctrine connection
- phil/tenancy-bundle: To stamp each row with the active tenant and hold reading to it
- phil/url: To read the collector's endpoint (feed.otlp), with symfony/http-client
- symfony/console: For phil:audit:anchor, phil:audit:verify and phil:audit:feed
- symfony/http-client: To send the facts to an OpenTelemetry collector (feed.otlp)
- symfony/messenger: To dispatch each recorded row to dashboards (dispatch_bus)
- symfony/security-core: To name the current account in the rows the bundle records
- symfony/workflow: To record the transitions of Auditable subjects (workflow_segment)
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-15 03:40:41 UTC
README
An append-only audit trail that shows tampering and still lets you erase personal information.
phil/audit-bundle keeps an append-only, hash-chained audit trail in a table of a Doctrine connection. Each row is
hashed with SHA-256 over its fields and the hash of the row before, per segment. A discontinuity row records a
restore. The boundary hashes of every segment are anchored through an AnchorWriter, and a command verifies each
segment against its anchors and fails loudly on a break. The trail holds up against an administrator with access to
the database only when the anchors go to append-only storage that administrator cannot rewrite. Without anchors, a
full rewrite of the chain goes undetected.
It exists because a tamper-evident log and the right to erasure pull against each other: hashing a plaintext value
into the chain makes it impossible to delete. Here a personal field enters the chain only as a per-row salted
commitment, HMAC-SHA256(key = salt, message = value), with a salt of at least 128 bits from a CSPRNG and a
constant-time comparison. To erase the field, the application deletes the plaintext and the salt and keeps the
commitment, so every hash and anchor still verifies. The bundle does not perform that erasure itself.
Each row is indexed by its facts — who acted, what was done, to which record, in which tenant —, identifiers the
hash covers, so that the log is read back by actor, action, record and period, a page at a time on its indexes. The
history of a record holds the changes of state its Symfony workflows went through beside every other event about it.
The bundle exposes the state of the chain for a viewer, holds rows and reads to the current tenant when
phil/tenancy-bundle is installed, and can give each site — each tenant — a chain of its own, anchored and verified
on its own. It feeds dashboards with the facts of each row, identifiers only and never a payload, as JSON Lines, as
OpenTelemetry log records or through an exporter of yours, at least once and from where the feed stopped. These are
services without screens: phil/audit-viewer shows them in an EasyAdmin back office.
It is for Symfony applications that must show who did what to records holding personal information, and it pairs
well with phil/privacy-bundle, which declares personal fields and assembles access and erasure answers. It is a
log, not event sourcing, and it is read only: going back to an earlier version of a record belongs to the bundle that
keeps real versions of it. By design it does not ship a canonical serialization before the application freezes one,
store anchors, partition tables, define database roles, or decide which events are audited and which fields are
personal.
The bundle builds on phil/ddd-bundle and PSR-20 clocks, with Doctrine DBAL, the Console, HttpClient, Messenger,
Security and Workflow components and phil/tenancy-bundle as options. It is built of ports and adapters: it carries
what the design decides, and leaves ports such as CanonicalSerializer, AnchorWriter, AnchorReader and
AuditRowRepository for what the application decides.
Status
| Type | State |
|---|---|
Domain\Commitment\Salt, Domain\Commitment\Commitment |
Implemented and tested |
Domain\Contract\CanonicalSerializer |
Port only; no implementation shipped, on purpose (a one-way door) |
Domain\Contract\AnchorWriter, AnchorReader |
Ports; JsonLinesAnchorStore for development |
AuditRow, RowKind, ChainVerifier, ChainBreak, Anchor, Domain\Contract\AuditRowRepository |
Implemented and tested |
SiteSegment; site_segments, phil:audit:verify --tenant |
Implemented and tested |
DashboardFeed, ExportedFact, FactExporter, FeedCheckpoints; JsonLinesFactExporter, OtlpLogExporter, DbalFeedCheckpoints, phil:audit:feed, FeedOnRecorded |
Implemented and tested |
AuditTrail (events, discontinuities), Anchoring, TrailVerification |
Implemented and tested |
DbalAuditRowRepository and its table; phil:audit:anchor, phil:audit:verify |
Implemented and tested |
Facts, Subject; AuditQuery, Cursor, AuditPage; AuditLog, ChainStatus |
Implemented and tested |
DbalAuditLogReader, ScanningAuditLogReader |
Implemented and tested |
Tenant, actor and record compared byte by byte (binary collation, then === in PHP) |
Implemented and tested |
WorkflowAuditListener, MessengerAuditNotifier, TenancyTenantScope, SecurityActorResolver |
Implemented and tested; registered when their component is |
PayloadReader |
Port only; the inverse of the application's serializer |
| Erasure of a field | The application's: the plaintext and the salt deleted, the commitment kept |
PhilAuditBundle |
Configuration connection, serializer, anchor_writer, anchor_reader, anchor_file, dispatch_bus, workflow_segment, site_segments, feed |
Documentation
- Installation — requirements, including why
phil/ddd-bundleandsymfony/configare declared, and registering the bundle. - Configuration — the keys under
phil_audit(connection,serializer,anchor_writer,anchor_reader,anchor_file,dispatch_bus,workflow_segment,site_segments,feed), the services wired, and how to register your own adapters. - Reading the log — the facts a row is indexed by,
AuditQuery, pages and cursors,AuditLog, the history of a record,ChainStatus, and the two readers. - Integrations — changes of state from Symfony Workflow, the current account from the
Security component, the tenant from
phil/tenancy-bundle, and the dispatch to dashboards with Messenger. - One chain per site — site segments, what verification adds, what a tenant sees, and
SiteSegment. - Feeding dashboards — the feed, its places, at least once and bounded, the JSON Lines and OpenTelemetry exporters, and an exporter of your own.
- The hash chain — the threat model and the full design (chained rows, versioned serialization, erasure that keeps the hash, discontinuity rows, per-segment verification), with what exists of each.
- Salted commitments and erasure — why HMAC,
SaltandCommitmentmethod by method, storage, and the erasure contract. - Anchoring — why anchors decide whether the trail holds, acceptable destinations, and the
AnchorWriterport. - The serialization door — why the bundle ships no serialization, versioning, the decisions an implementation must freeze, and an example.
- Extension points — every port method by method, adapter invariants, a serializer registry, an object-lock anchor writer and a payload reader.
- Testing — how the bundle is tested, its benchmarks and fuzz targets, running its tests in a monorepo or alone, golden vectors for a serializer and refusal tests for an anchor destination.
- API reference — every type by layer, with its role and state.
Quick start
composer require phil/audit-bundle:^0.1@dev
use Phil\AuditBundle\Domain\Commitment\Commitment; use Phil\AuditBundle\Domain\Commitment\Salt; $salt = Salt::generate(); // 16 bytes from the CSPRNG, stored beside the plaintext $commitment = Commitment::of($salt, $email); // 32 bytes, kept forever, hashed into the row // Erasure: delete the plaintext and the salt, keep the commitment. The row hash does not move. Commitment::fromBytes($stored)->matches(Salt::fromBytes($storedSalt), $email); // constant time
use Phil\AuditBundle\Domain\Chain\Facts; use Phil\AuditBundle\Domain\Chain\Subject; use Phil\AuditBundle\Domain\Read\AuditQuery; $trail->record('orders', ['amount' => '10.00'], Facts::of('order.paid', $account->id(), Subject::of('order', '42'))); $page = $log->page(AuditQuery::all()->byActor($account->id())->limitedTo(100)); // the latest first $history = $log->history(Subject::of('order', '42')); // the earliest first
phil_audit: serializer: App\Audit\CanonicalSerializerV1 site_segments: true # one chain per tenant feed: json_lines: '%kernel.logs_dir%/audit-facts.jsonl' # facts for the dashboards, identifiers only
Before anchoring anything, write and freeze your CanonicalSerializer, and point your AnchorWriter at
append-only storage.
Requirements
PHP 8.5 or later, phil/ddd-bundle 0.1, psr/clock 1.0, symfony/config and symfony/dependency-injection 8.2.
Contributing
Contributions are welcome on the PHIL monorepo: read the contribution guide and the code of conduct. Report a vulnerability privately, as the security policy explains, never in a public issue.
License
MIT, © Philippe Gamache, doing business as Le Gnome Archiviste; a Parler Haut Interagir Librement project. See LICENSE.