AuditLogRepository<T>: cross-cutting audit-trail decorator #11

Closed
opened 2026-04-29 15:25:28 +02:00 by uwe.admin · 9 comments
Owner

Decorator that emits an audit record on every mutation flowing through Repository<TDto>. Composes naturally with ScopeGuardRepository — both take an ActorContext accessor, 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 / softDelete becomes audited.

Scope

struct AuditEvent {
    std::string actorUserId;
    std::string entityType;   // e.g. "PersonDto"
    std::string entityId;
    std::string operation;    // "save" | "softDelete"
    int64_t     timestampMs;
};

struct IAuditSink {
    virtual ~IAuditSink() = default;
    virtual void record(const AuditEvent& ev) = 0;
};

template <class TDto>
class AuditLogRepository : public Repository<TDto> {
public:
    AuditLogRepository(std::shared_ptr<Repository<TDto>> inner,
                       std::shared_ptr<IAuditSink> sink,
                       std::function<ActorContext()> currentActor,
                       std::string entityType,
                       std::function<int64_t()> clock = {});
    // overrides delegate, then call sink->record(...)
};

entityType is a string the consumer passes (typeid is unportable); entityId is read off the saved DTO via the same TemporalFieldTraits<T>::entityId introduced 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 SqliteAuditSink that inserts into an audit_log table; tests use an in-memory sink that just appends to a vector.

Constraints

  • Decorator never throws on sink failure — best-effort logging mustn’t break the user’s write path. Sink failures swallowed (or routed through a configurable error callback). Recommend: take a bool(const std::exception&) callback that returns whether to rethrow; default = swallow.
  • No async / queue in v1. Sink is called synchronously. If the sink is slow, the consumer wraps it in a queue themselves.
  • findByEntityId and list are NOT audited (reads are noisy and usually not interesting). Only save and softDelete.
  • ≤200 LOC of header-only template code.

Out of scope

  • Read auditing (separate decorator if ever needed).
  • Diff capture ("old → new value"). Audit just records the fact of a mutation; the temporal repository already captures the before-state via versioning.
  • Cross-repo correlation IDs (request-scoped tracing). That belongs in an interceptor, not the repo layer.

Acceptance

  • IAuditSink interface, AuditEvent struct, AuditLogRepository<T> decorator compile in oatpp-authkit.
  • Tests cover: save emits one event with correct actor/entityType/entityId/op; softDelete emits one event with op=softDelete; sink throw is swallowed by default; multiple stacked decorators (Audit ↔ Temporal ↔ ScopeGuard) all fire in the expected order.
  • README entry under the repo decorator table.

Decision deferred to implementation time

  • Whether entityType is a string parameter or comes from a TypeNameTraits<T> specialisation. Pick whichever is cleaner once you wire the first consumer.
Decorator that emits an audit record on every mutation flowing through `Repository<TDto>`. Composes naturally with `ScopeGuardRepository` — both take an `ActorContext` accessor, 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` / `softDelete` becomes audited. ## Scope ```cpp struct AuditEvent { std::string actorUserId; std::string entityType; // e.g. "PersonDto" std::string entityId; std::string operation; // "save" | "softDelete" int64_t timestampMs; }; struct IAuditSink { virtual ~IAuditSink() = default; virtual void record(const AuditEvent& ev) = 0; }; template <class TDto> class AuditLogRepository : public Repository<TDto> { public: AuditLogRepository(std::shared_ptr<Repository<TDto>> inner, std::shared_ptr<IAuditSink> sink, std::function<ActorContext()> currentActor, std::string entityType, std::function<int64_t()> clock = {}); // overrides delegate, then call sink->record(...) }; ``` `entityType` is a string the consumer passes (typeid is unportable); `entityId` is read off the saved DTO via the same `TemporalFieldTraits<T>::entityId` introduced 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 `SqliteAuditSink` that inserts into an `audit_log` table; tests use an in-memory sink that just appends to a vector. ## Constraints - Decorator never throws on sink failure — best-effort logging mustn’t break the user’s write path. Sink failures swallowed (or routed through a configurable error callback). Recommend: take a `bool(const std::exception&)` callback that returns whether to rethrow; default = swallow. - No async / queue in v1. Sink is called synchronously. If the sink is slow, the consumer wraps it in a queue themselves. - `findByEntityId` and `list` are NOT audited (reads are noisy and usually not interesting). Only `save` and `softDelete`. - ≤200 LOC of header-only template code. ## Out of scope - Read auditing (separate decorator if ever needed). - Diff capture ("old → new value"). Audit just records the *fact* of a mutation; the temporal repository already captures the before-state via versioning. - Cross-repo correlation IDs (request-scoped tracing). That belongs in an interceptor, not the repo layer. ## Acceptance - `IAuditSink` interface, `AuditEvent` struct, `AuditLogRepository<T>` decorator compile in oatpp-authkit. - Tests cover: save emits one event with correct actor/entityType/entityId/op; softDelete emits one event with op=softDelete; sink throw is swallowed by default; multiple stacked decorators (Audit ↔ Temporal ↔ ScopeGuard) all fire in the expected order. - README entry under the repo decorator table. ## Decision deferred to implementation time - Whether `entityType` is a string parameter or comes from a `TypeNameTraits<T>` specialisation. Pick whichever is cleaner once you wire the first consumer.
Author
Owner

Agent Evaluation

Feasibility: High. Pure additive decorator, no changes to existing surfaces. Reuses ActorContext (already present for ScopeGuard) and TemporalFieldTraits<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

  1. New header include/oatpp-authkit/repo/IAuditSink.hpp: defines AuditEvent POD struct (actor user id, entity type, entity id, op, timestamp_ms) and IAuditSink pure-abstract with record(const AuditEvent&).
  2. New header include/oatpp-authkit/repo/AuditLogRepository.hpp: decorator template, ctor takes inner repo + sink + actor accessor + entity type string + optional clock + optional sink-error callback. Overrides save and softDelete to call inner first, then build event and call sink. Reads entityId via TemporalFieldTraits<T>::entityId for save; for softDelete uses the id argument directly. Sink throws are caught and routed through the error callback (default: swallow).
  3. findByEntityId and list delegate untouched (reads not audited).
  4. New test test_audit_log_repository.cpp with an in-memory VectorAuditSink that 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.
  5. README entry under the decorator table; bump library to 0.6.0.

Decision deferred to implementation time

entityType provided 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.

## Agent Evaluation **Feasibility:** High. Pure additive decorator, no changes to existing surfaces. Reuses `ActorContext` (already present for ScopeGuard) and `TemporalFieldTraits<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 1. New header `include/oatpp-authkit/repo/IAuditSink.hpp`: defines `AuditEvent` POD struct (actor user id, entity type, entity id, op, timestamp_ms) and `IAuditSink` pure-abstract with `record(const AuditEvent&)`. 2. New header `include/oatpp-authkit/repo/AuditLogRepository.hpp`: decorator template, ctor takes inner repo + sink + actor accessor + entity type string + optional clock + optional sink-error callback. Overrides `save` and `softDelete` to call inner first, then build event and call sink. Reads `entityId` via `TemporalFieldTraits<T>::entityId` for `save`; for `softDelete` uses the id argument directly. Sink throws are caught and routed through the error callback (default: swallow). 3. `findByEntityId` and `list` delegate untouched (reads not audited). 4. New test `test_audit_log_repository.cpp` with an in-memory `VectorAuditSink` that 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. 5. README entry under the decorator table; bump library to 0.6.0. ### Decision deferred to implementation time `entityType` provided 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.
uwe.admin added the
evaluated
label 2026-04-29 15:26:13 +02:00
Author
Owner

Evaluated #11 (Small) — recommend Accept.

Evaluated #11 (Small) — recommend Accept.
Author
Owner

Re-evaluation

Can those be Create, Update, Delete instead? softDelete is not necessarily the case if not configured, and I’d like to be able to distinguish between Create and Update.

Agreed on both counts. Updating the design:

Operation enum

enum class AuditOp { Create, Update, Delete };

The audit record reflects intent, not the inner method name. softDelete vs hard delete is an implementation detail of whichever decorator/adapter is below — both surface as AuditOp::Delete in the event.

Distinguishing Create vs Update

The decorator does a one-shot findByEntityId on the inner before delegating save:

  • dto->entity_id is null → Create (no lookup needed; the inner is about to allocate an id).
  • dto->entity_id non-null and findByEntityId returns null → Create (caller supplied the id, but no row exists yet).
  • dto->entity_id non-null and findByEntityId returns 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:

  1. Override save(dto): first determine AuditOp::Create vs AuditOp::Update by inspecting dto->entity_id and (if non-null) consulting m_inner->findByEntityId. Then delegate to m_inner->save(dto). After the inner call returns, build the event and call the sink. Override softDelete(id): delegate first, then emit an event with AuditOp::Delete.

Updated event struct

struct AuditEvent {
    std::string actorUserId;
    std::string entityType;
    std::string entityId;
    AuditOp     operation;     // Create | Update | Delete
    int64_t     timestampMs;
};

Everything else from the original evaluation stands. Still recommend Accept; still Small effort (the extra read adds ~10 LOC, no structural change).

## Re-evaluation > Can those be Create, Update, Delete instead? softDelete is not necessarily the case if not configured, and I’d like to be able to distinguish between Create and Update. Agreed on both counts. Updating the design: ### Operation enum ```cpp enum class AuditOp { Create, Update, Delete }; ``` The audit record reflects *intent*, not the inner method name. `softDelete` vs hard delete is an implementation detail of whichever decorator/adapter is below — both surface as `AuditOp::Delete` in the event. ### Distinguishing Create vs Update The decorator does a one-shot `findByEntityId` on the inner before delegating `save`: - `dto->entity_id` is null → `Create` (no lookup needed; the inner is about to allocate an id). - `dto->entity_id` non-null and `findByEntityId` returns null → `Create` (caller supplied the id, but no row exists yet). - `dto->entity_id` non-null and `findByEntityId` returns 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: > 2. Override `save(dto)`: first determine `AuditOp::Create` vs `AuditOp::Update` by inspecting `dto->entity_id` and (if non-null) consulting `m_inner->findByEntityId`. Then delegate to `m_inner->save(dto)`. After the inner call returns, build the event and call the sink. Override `softDelete(id)`: delegate first, then emit an event with `AuditOp::Delete`. ### Updated event struct ```cpp struct AuditEvent { std::string actorUserId; std::string entityType; std::string entityId; AuditOp operation; // Create | Update | Delete int64_t timestampMs; }; ``` Everything else from the original evaluation stands. Still recommend Accept; still Small effort (the extra read adds ~10 LOC, no structural change).
Author
Owner

Re-evaluated #11 — switched op to Create/Update/Delete enum, decorator does a pre-write lookup to distinguish Create from Update.

Re-evaluated #11 — switched op to Create/Update/Delete enum, decorator does a pre-write lookup to distinguish Create from Update.
Author
Owner

Re-evaluation

Could we make the logged operations configurable and add Read, too?

Yes on both. Updating the design:

Op enum gains Read

enum class AuditOp { Create, Update, Delete, Read };

Read audits 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 when Read is 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:

AuditLogRepository(std::shared_ptr<Repository<TDto>> inner,
                   std::shared_ptr<IAuditSink> sink,
                   std::function<ActorContext()> currentActor,
                   std::string entityType,
                   std::set<AuditOp> enabledOps = {AuditOp::Create, AuditOp::Update, AuditOp::Delete},
                   std::function<int64_t()> clock = {});

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) — if Read is 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 extra findByEntityId needed when Update and Create are both disabled. Tiny optimisation: skip the pre-write lookup entirely if neither is in enabledOps.
  • softDelete(id) — emit only if Delete is in enabledOps.

Updated acceptance

Add to the test list:

  • Read enabled → findByEntityId emits one event; list emits zero.
  • Read disabled → findByEntityId emits zero.
  • Custom op set (e.g. {Delete} only) → save/softDelete records only Delete events; Create/Update suppressed.

Recommend Accept; effort still Small (the set check is one-liner per method).

## Re-evaluation > Could we make the logged operations configurable and add Read, too? Yes on both. Updating the design: ### Op enum gains Read ```cpp enum class AuditOp { Create, Update, Delete, Read }; ``` `Read` audits **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 when `Read` is 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: ```cpp AuditLogRepository(std::shared_ptr<Repository<TDto>> inner, std::shared_ptr<IAuditSink> sink, std::function<ActorContext()> currentActor, std::string entityType, std::set<AuditOp> enabledOps = {AuditOp::Create, AuditOp::Update, AuditOp::Delete}, std::function<int64_t()> clock = {}); ``` 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)` — if `Read` is 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 extra `findByEntityId` needed when `Update` and `Create` are *both* disabled. Tiny optimisation: skip the pre-write lookup entirely if neither is in `enabledOps`. - `softDelete(id)` — emit only if `Delete` is in `enabledOps`. ### Updated acceptance Add to the test list: - Read enabled → findByEntityId emits one event; list emits zero. - Read disabled → findByEntityId emits zero. - Custom op set (e.g. {Delete} only) → save/softDelete records only Delete events; Create/Update suppressed. Recommend Accept; effort still Small (the set check is one-liner per method).
Author
Owner

Re-evaluated #11 — added AuditOp::Read, made enabled ops a constructor-injected set defaulting to {Create,Update,Delete}; list() stays unaudited.

Re-evaluated #11 — added AuditOp::Read, made enabled ops a constructor-injected set defaulting to {Create,Update,Delete}; list() stays unaudited.
Author
Owner

Owner picked option 1 (pre-write findByEntityId lookup). Design as documented stands; ready for accept.

Owner picked option 1 (pre-write findByEntityId lookup). Design as documented stands; ready for accept.
u.schuster added the
accepted
label 2026-04-29 15:50:52 +02:00
Author
Owner

Implemented #11 in c6a2dba — 9/9 tests pass (incl. stacking with TemporalRepository, sink-throw swallow + rethrow paths). Auto-closed via Closes #11. Library bumped 0.5.0 → 0.6.0.

Implemented #11 in c6a2dba — 9/9 tests pass (incl. stacking with TemporalRepository, sink-throw swallow + rethrow paths). Auto-closed via `Closes #11`. Library bumped 0.5.0 → 0.6.0.
Author
Owner

Implemented #11 → commit c6a2dba. New repo/IAuditSink.hpp (AuditOp enum {Create,Update,Delete,Read}, AuditEvent, IAuditSink) + repo/AuditLogRepository.hpp decorator with pre-write findByEntityId to 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 in test_audit_log_repository.cpp cover 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.

Implemented #11 → commit c6a2dba. New `repo/IAuditSink.hpp` (`AuditOp` enum {Create,Update,Delete,Read}, `AuditEvent`, `IAuditSink`) + `repo/AuditLogRepository.hpp` decorator with pre-write `findByEntityId` to 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 in `test_audit_log_repository.cpp` cover 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.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: uwe.admin/oatpp-authkit#11
No description provided.