AuditLogRepository<T>: cross-cutting audit-trail decorator #11
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Decorator that emits an audit record on every mutation flowing through
Repository<TDto>. Composes naturally withScopeGuardRepository— both take anActorContextaccessor, so the audit record can capture who did what without controllers having to thread it through.Why
Every mutation in a holiday-rental app eventually wants a who/when/what trail ("who changed this booking on Tuesday?"). Putting it in a decorator means the controller layer doesn’t have to remember to log; you just stack the decorator at construction time and every
save/softDeletebecomes audited.Scope
entityTypeis a string the consumer passes (typeid is unportable);entityIdis read off the saved DTO via the sameTemporalFieldTraits<T>::entityIdintroduced in #10 — so the decorator works for any registered DTO without needing yet another trait.Sink is consumer-supplied. fewo-webapp can write its own
SqliteAuditSinkthat inserts into anaudit_logtable; tests use an in-memory sink that just appends to a vector.Constraints
bool(const std::exception&)callback that returns whether to rethrow; default = swallow.findByEntityIdandlistare NOT audited (reads are noisy and usually not interesting). OnlysaveandsoftDelete.Out of scope
Acceptance
IAuditSinkinterface,AuditEventstruct,AuditLogRepository<T>decorator compile in oatpp-authkit.Decision deferred to implementation time
entityTypeis a string parameter or comes from aTypeNameTraits<T>specialisation. Pick whichever is cleaner once you wire the first consumer.Agent Evaluation
Feasibility: High. Pure additive decorator, no changes to existing surfaces. Reuses
ActorContext(already present for ScopeGuard) andTemporalFieldTraits<T>::entityId(added in #10) so no new trait machinery is needed.Impact: Useful even before fewo-webapp wires it — gives the scaffold a who/what/when audit primitive that any consumer can opt into. Pairs naturally with the existing decorator stack (Audit ↔ Temporal ↔ ScopeGuard composes cleanly).
Effort: Small. ~150–200 LOC across
IAuditSink.hpp+AuditLogRepository.hpp+ tests. No build-system or external-dependency changes.Recommendation: Accept.
Implementation plan
include/oatpp-authkit/repo/IAuditSink.hpp: definesAuditEventPOD struct (actor user id, entity type, entity id, op, timestamp_ms) andIAuditSinkpure-abstract withrecord(const AuditEvent&).include/oatpp-authkit/repo/AuditLogRepository.hpp: decorator template, ctor takes inner repo + sink + actor accessor + entity type string + optional clock + optional sink-error callback. OverridessaveandsoftDeleteto call inner first, then build event and call sink. ReadsentityIdviaTemporalFieldTraits<T>::entityIdforsave; forsoftDeleteuses the id argument directly. Sink throws are caught and routed through the error callback (default: swallow).findByEntityIdandlistdelegate untouched (reads not audited).test_audit_log_repository.cppwith an in-memoryVectorAuditSinkthat appends events. Verifies: save records entity type/id/op/actor; softDelete records op=softDelete; sink throw is swallowed by default; stacked decorator order (Audit wrapping Temporal wrapping in-memory inner) records exactly once per logical save.Decision deferred to implementation time
entityTypeprovided as ctor string vs.TypeNameTraits<T>specialisation — pick whichever reads cleaner once you wire the first two consumers. Defaulting to ctor string keeps the surface tiny.Evaluated #11 (Small) — recommend Accept.
Re-evaluation
Agreed on both counts. Updating the design:
Operation enum
The audit record reflects intent, not the inner method name.
softDeletevs hard delete is an implementation detail of whichever decorator/adapter is below — both surface asAuditOp::Deletein the event.Distinguishing Create vs Update
The decorator does a one-shot
findByEntityIdon the inner before delegatingsave:dto->entity_idis null →Create(no lookup needed; the inner is about to allocate an id).dto->entity_idnon-null andfindByEntityIdreturns null →Create(caller supplied the id, but no row exists yet).dto->entity_idnon-null andfindByEntityIdreturns a row →Update.Cost: one extra read per write. Acceptable — audit logging is opt-in, and the consumer who stacks this decorator is choosing to pay for it.
Updated implementation plan
Step 2 of the plan changes:
Updated event struct
Everything else from the original evaluation stands. Still recommend Accept; still Small effort (the extra read adds ~10 LOC, no structural change).
Re-evaluated #11 — switched op to Create/Update/Delete enum, decorator does a pre-write lookup to distinguish Create from Update.
Re-evaluation
Yes on both. Updating the design:
Op enum gains Read
Readaudits single-entity reads — i.e.findByEntityId(id)— and emits one event with the entity id of the row that came back (or the requested id if it was a miss; both are useful for compliance).list()is not audited even whenReadis enabled. A list call returning N rows is a scan, not a per-entity access; emitting N events is noisy and emitting one event with no entity id is half-information. If list-auditing is ever needed it’s a separate concern (different event shape, separate decorator, or out-of-scope of this issue).Configurable per-op enable
Constructor takes a
std::set<AuditOp>of ops to record. Anything outside the set is delegated without emitting an event:Default is
{Create, Update, Delete}— the conservative starting point. Consumers that need read auditing pass{Create, Update, Delete, Read}; consumers that only care about deletes pass{Delete}.Behavioural notes
findByEntityId(id)— ifReadis enabled, emit event after the inner call returns (regardless of whether a row was found). The event records the requested id; the consumer can correlate with the inner-call result if needed.save(dto)— no extrafindByEntityIdneeded whenUpdateandCreateare both disabled. Tiny optimisation: skip the pre-write lookup entirely if neither is inenabledOps.softDelete(id)— emit only ifDeleteis inenabledOps.Updated acceptance
Add to the test list:
Recommend Accept; effort still Small (the set check is one-liner per method).
Re-evaluated #11 — added AuditOp::Read, made enabled ops a constructor-injected set defaulting to {Create,Update,Delete}; list() stays unaudited.
Owner picked option 1 (pre-write findByEntityId lookup). Design as documented stands; ready for accept.
Implemented #11 in
c6a2dba— 9/9 tests pass (incl. stacking with TemporalRepository, sink-throw swallow + rethrow paths). Auto-closed viaCloses #11. Library bumped 0.5.0 → 0.6.0.Implemented #11 → commit
c6a2dba. Newrepo/IAuditSink.hpp(AuditOpenum {Create,Update,Delete,Read},AuditEvent,IAuditSink) +repo/AuditLogRepository.hppdecorator with pre-writefindByEntityIdto distinguish Create vs Update, configurable enabled-op set (default{Create,Update,Delete}— Read opt-in,list()never audited), sink-failure handler (default swallow). 11 test cases intest_audit_log_repository.cppcover Create/Update/Delete events, Read-on-hit/miss, op filter, list passthrough, sink-throw swallow vs rethrow, and stacking with TemporalRepository. Version bumped to 0.6.0; all 9 ctest cases pass.