locksyk/audit-trail-bundle

Append-only audit trails over Doctrine flushes: the bundle owns changeset normalization, sensitive-field skipping and the guarded write; your application owns the entities, actors and verbs.

Maintainers

Package info

github.com/LocksyK/audit-trail-bundle

Type:symfony-bundle

pkg:composer/locksyk/audit-trail-bundle

Transparency log

Statistics

Installs: 5

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.0 2026-08-16 00:05 UTC

This package is auto-updated.

Last update: 2026-08-16 00:07:58 UTC


README

Append-only audit trails over Doctrine flushes, split along a simple seam: the bundle owns the mechanics - changeset capture during onFlush, value normalization (enums, dates, entity refs), sensitive-field skipping, and folding the entries into the flush in progress - while your application owns the semantics: which entities are audited, who the actor was, what actions mean, and how subjects are worded on screen.

Transactional guarantee

Entries are written inside the same flush - and the same database transaction - as the changes they describe. The writer runs during onFlush; whatever it persists is folded into the unit of work already flushing, so there is exactly one flush and one commit. If an entry cannot be written, the flush throws and the business change rolls back with it: the operation fails loudly rather than leaving a committed change with no record. For an application whose charter is to be the record, that is the correct failure mode - a trail that can silently miss a committed change is not a trail.

Two consequences of writing before the INSERTs execute:

  • Subject identifiers must be application-assigned (UUIDs and the like) if entries record them: a database-generated id does not exist yet when a creation is described. Alternatively reference the subject by association and let Doctrine resolve the key.
  • write() must only persist new entities - never flush, and never mutate already-managed ones (their changesets are already computed).

Subjects are data, never sentences

The record stores no constructed display strings. A subject is described as a reference - {id, type, parts} by default, where parts are the leaf values identifying it ({"system": "Billing", "name": "Reporting"}) - and the wording happens at read time, in the consuming UI (see the frontend companion below). Facts freeze; presentation stays current: entries survive renames, and wording can improve after the fact.

That shape is the shipped default's, not the bundle's. AuditSubjectDescriberInterface::describe() returns array<string, mixed> - a payload fragment - and nothing in the bundle reads a key back out of it: the recorder embeds the fragment and carries it, while your writer fills the entry's own subject columns from the subject entity it is handed. It must be an array with string keys only because a deletion payload is the description, and that is what a payload is.

{id, type, parts} is really one half of a matched pair:

  • SubjectPartsDescriber writes it and declares that shape on itself, so a writer that destructures a reference can depend on the class rather than on the interface;
  • the frontend's default reader recognizes exactly it.

Replace one and you replace the other. A describer emitting a different shape passes a readSubjectRef to the view side to say how to read it (see the frontend companion below); nothing else changes, because nothing else looks.

Payload shapes, by the entry's action:

  • created - plain snapshot values;
  • deleted - the subject's full reference: after the row is gone, the reference is all a reader has;
  • every other action - [old, new] pairs per changed field.

Subjects appearing as values inside payloads (an entity-valued field, many-to-many elements) are normalized to the same reference shape.

Two things follow from parts being the stored form, both worth deciding before the first entry is written:

  • Parts are exportable by construction. They are handed to the frontend to be worded, so nothing belongs in a part that you would not put in an export. This costs nothing over storing a display string: composition adds punctuation, not confidentiality, so a label built from a part is exactly as sensitive as the part.
  • A subject's parts keys are a long-term compatibility surface. Which keys a given type puts in parts is what your renderers read back over the whole history of your trail, and old entries keep the keys they were written with. Renderer fallbacks (a missing key rendering as a placeholder) are how that stays forward-compatible; there is no migration path, because entries are append-only.

Audited entities implement AuditSubjectInterface (an id and the parts); the shipped default describer does the rest, so most applications configure nothing:

final class Thing implements AuditSubjectInterface
{
    public function getAuditSubjectId(): string { /* uuid */ }
    public function getAuditSubjectParts(): array { return ['name' => $this->name]; }
}

// Your entry persistence (actor, associations):
final class MyEntryWriter implements AuditEntryWriterInterface { /* ... */ }
# config/packages/audit_trail.yaml
audit_trail:
    writer: App\History\MyEntryWriter
    # describer: defaults to the shipped SubjectPartsDescriber
    skip_fields: [secretHash, tokenHash, lastUsedAt]

Workflow-style entities can implement AuditActionProviderInterface to replace the generic "updated" with a domain action ("approved", with a note) - such updates are recorded even when every changed field was skip-listed, so pure transitions still land on the record.

An event's identity is the (subjectType, action) column pair - there is no composed verb string. The two facts stay two columns, so "all approvals" is one exact filter and nothing ever parses an order.approved-style string back apart; anything that wants a single event key (message routing, say) composes one at that edge.

The entry shape

AuditEntryInterface is the read contract every entry entity shares; AuditEntryTrait is the shipped implementation of its columns: UUIDv7 id, action, subjectType, subjectId, subjectParts (JSONB), payload (JSONB), note, and the attribution columns (below). Your entity composes the trait and keeps everything app-typed and class-level: the constructor (assign the trait properties directly - they are your entity's own; the stampAuditEntry() helper mints the id), the occurredAt column (timestamp type and precision are app policy, and Doctrine cannot override an inherited column's type - the interface still demands the getter), the actor association, timeline anchors, table name, indexes, API resource configuration. Other column mappings can be overridden per entity with #[ORM\AttributeOverrides].

Serialization: the trait's properties carry the bundle-owned audit_trail:read group; add it to your resource's normalizationContext alongside your own group.

Entry immutability is yours to enforce where the entries live; AppendOnlyGuard::createSql('my_entry_table') returns the canonical PostgreSQL statements (row trigger against UPDATE/DELETE plus a statement trigger against TRUNCATE) for your migration to execute.

Actors

Attribution is typed, because "everything has an email address" would paper over a real difference. ActorType names three kinds of actor:

  • human - a person (identifier: email);
  • service - a machine principal, e.g. a service-account API key (identifier: the account/key name);
  • system - the application's own automation, schedulers and the like (identifier: the job name). Without this kind, automation records a null actor - and null must keep meaning "attribution was not captured", which is a finding, never a design.

An ActorRef carries {type, identifier}; the trait stores them as actorType/actorIdentifier (both null when nothing was captured), plus impersonatorIdentifier - the human really driving when the action happened under impersonation. An impersonator is always a legitimate human user, by invariant.

Resolution

Writers usually resolve the actor from the security token - which console commands, message consumers and cron do not have. The bundle registers AuditActorContext as the hand-off point: anything actorless supplies the actor (ActorRef::system('nightly-import'), ActorRef::human($email), or your own actor object), the writer consults it first and falls back to its usual source.

// In a console command (scoped; restores the previous actor even
// when the callback throws):
$this->actorContext->withActor(ActorRef::human($email), fn () => $this->import($doc));

When symfony/security-core is installed, AuditActorResolver encodes the whole chain: explicit context first, else the token - unwrapping SwitchUserToken so impersonated actions record both who they were done as and who was really driving - else a null ref, which your writer should treat as reportable. Principals that would be misnamed by the human default (machine accounts sharing a firewall with people) implement AuditActorInterface and name their own ref.

Read visibility

Writing is never filtered, but READS can be narrowed by subject type - e.g. account-change history visible to admins only. Implement AuditVisibilityPolicyInterface (return null for unrestricted, a list of visible subject-type strings otherwise - resolve the current user yourself, the bundle stays security-agnostic) and configure:

audit_trail:
    # ...
    visibility:
        entity: App\Entity\AuditEntry     # your entry entity
        subject_type_field: subjectType   # default
        subject_id_field: subjectId       # default (used by carve-outs)
        policy: App\History\MyVisibilityPolicy

When API Platform is installed, collection AND item queries on the entry resource are narrowed automatically - an out-of-policy entry 404s like it never existed. AuditVisibilityPolicyInterface is aliased to your policy, so custom read endpoints (reports, exports) can autowire it and apply the same rule. An empty list hides everything; UnrestrictedAuditVisibilityPolicy is the shipped no-op.

Own-subject carve-outs

A policy may additionally implement AuditVisibilityCarveOutsInterface to expose individual subjects whose TYPE is otherwise hidden - the canonical case being a user's own credentials, under a credential type that is otherwise admin-only. carvedOutSubjects() returns subject-type => [ids...]; those entries become readable alongside the visible types (with an empty visible-type list, the carve-outs alone are readable). Carve-outs are ignored when the policy is unrestricted, and a type mapped to an empty id list is treated as no carve-out. The lookup runs per read request - keep it cheap (a user's own credential ids).

Frontend companion

frontend/ holds @locksyk/audit-trail-view, the presentation half of the recorder's payload contract (plain TypeScript, no framework dependency). formatValue, payloadRows and summarizePayload turn payloads into display rows and one-line summaries; createSubjectRenderer builds your wording module - the per-type formatters that turn subject references and subject parts into text, in the one place wording belongs; readSubjectRef says what a reference looks like when your describer is not the shipped one. See frontend/README.md.

License

GPL-2.0-only.