#11: AuditLogRepository<T> + IAuditSink — cross-cutting audit decorator

Closes #11

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Uwe Schuster 2026-04-29 15:54:11 +02:00
parent 1baff07b71
commit c6a2dba22b
6 changed files with 576 additions and 1 deletions

View file

@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.14)
project(oatpp-authkit VERSION 0.5.0 LANGUAGES CXX)
project(oatpp-authkit VERSION 0.6.0 LANGUAGES CXX)
# Header-only interface library — no compilation, just an include path and
# a CMake config package so consumers do:

View file

@ -17,6 +17,7 @@ hardened auth / security stack. Header-only, oatpp 1.3+, C++17.
| `repo/TemporalRepository.hpp` | Decorator that wraps any `Repository<TDto>` and turns it into a temporally-versioned one. `save` closes the prior live version and inserts a new one; `findByEntityIdAt(id, at)` returns the version live at a point in time; implements `IHistoryRepository<T>`. Inner adapter is expected to expose all rows (live + historical) and treat `save` as upsert keyed by `(entity_id, valid_from)`. DTOs register their three temporal columns via `OATPP_AUTHKIT_REGISTER_TEMPORAL`. |
| `repo/ScopeGuardRepository.hpp` | Generic resource-scope decorator. Takes a `bool(ActorContext, TDto)` predicate at construction; gates every method on it. Throws `ScopeDeniedException` on deny (catchers translate to 403). Knows nothing about consumer-specific concepts like "property" or "tenant" — the predicate decides. |
| `repo/IQueryable.hpp` | Optional capability for repos that resolve a typed query AST. `field<&Dto::col>().eq(...)` style DSL composes via `&&` / `||` / `!`; `Query<TDto>::toSql()` emits parameterised SQL plus a bind bag. Bounded surface — equality, range, IN, LIKE, NULL, ORDER BY, LIMIT/OFFSET. No joins, subqueries, or aggregates. Concrete repos opt in by deriving `IQueryable<TDto>`. |
| `repo/IAuditSink.hpp` + `repo/AuditLogRepository.hpp` | Cross-cutting audit-trail decorator. Emits an `AuditEvent` (actor, entity type/id, op, timestamp) per mutation through a consumer-supplied `IAuditSink`. Ops are `Create` / `Update` / `Delete` / `Read`; pre-write `findByEntityId` lookup distinguishes Create from Update. Configurable enabled-op set (default `{Create,Update,Delete}``Read` is opt-in, `list()` never audited). Sink failures are caught and swallowed unless a `bool(const std::exception&)` handler asks to rethrow. Stacks with `TemporalRepository` and `ScopeGuardRepository`. |
## Consume via CMake

View file

@ -0,0 +1,171 @@
#ifndef OATPP_AUTHKIT_REPO_AUDIT_LOG_REPOSITORY_HPP
#define OATPP_AUTHKIT_REPO_AUDIT_LOG_REPOSITORY_HPP
// Cross-cutting audit-trail decorator (authkit#11). Emits an `AuditEvent`
// through a consumer-supplied `IAuditSink` on every audited operation.
// Composes naturally with `ScopeGuardRepository` and `TemporalRepository`
// — all three accept an `ActorContext` accessor and stack via the same
// `Repository<T>` interface.
#include "oatpp-authkit/repo/Repository.hpp"
#include "oatpp-authkit/repo/IAuditSink.hpp"
#include "oatpp-authkit/repo/ActorContext.hpp"
#include "oatpp-authkit/repo/TemporalFieldTraits.hpp"
#include "oatpp/core/Types.hpp"
#include <chrono>
#include <cstdint>
#include <exception>
#include <functional>
#include <memory>
#include <set>
#include <string>
#include <utility>
namespace oatpp_authkit::repo {
/**
* @brief Decorator that audits every mutation flowing through a repository.
*
* @section semantics Per-method behaviour
*
* - `save(dto)`: if `entity_id` is null, the inner is about to allocate
* one operation is `Create`. If `entity_id` is non-null, the decorator
* performs a one-shot `findByEntityId` on the inner *before* delegating;
* a hit means `Update`, a miss means `Create` (caller-supplied id, no
* row yet). Inner is then called; on success a single event is recorded.
* - `softDelete(id)`: delegated first; on success a single `Delete` event
* is recorded.
* - `findByEntityId(id)`: delegated first; if `AuditOp::Read` is in the
* enabled set, a single `Read` event is recorded with the entity id of
* the row that came back (or the requested id on miss both are
* useful for compliance traces).
* - `list()`: passed through unchanged. Lists are scans; emitting one
* event per row is noisy and emitting a single event with no entity id
* is half-information. Out of scope for this decorator.
*
* @section robustness Sink failures
*
* `IAuditSink::record` is called inside a `try/catch`. By default the
* exception is swallowed audit logging is best-effort and must not
* break the user's write path. Pass `sinkErrorHandler(...)` (or supply
* the optional last constructor arg) to override; the handler returns
* `true` to rethrow, `false` to swallow.
*
* `entityId` for `save` events is read through
* `TemporalFieldTraits<TDto>::entityId`, so the decorator works for any
* DTO that registered the trait via `OATPP_AUTHKIT_REGISTER_TEMPORAL`.
*/
template <typename TDto>
class AuditLogRepository : public Repository<TDto> {
public:
using ActorAccess = std::function<ActorContext()>;
using Clock = std::function<std::int64_t()>;
using SinkErrorHandler = std::function<bool(const std::exception&)>;
AuditLogRepository(std::shared_ptr<Repository<TDto>> inner,
std::shared_ptr<IAuditSink> sink,
ActorAccess currentActor,
std::string entityType,
std::set<AuditOp> enabledOps =
{AuditOp::Create, AuditOp::Update, AuditOp::Delete},
Clock clock = {},
SinkErrorHandler onSinkError = {})
: m_inner(std::move(inner))
, m_sink(std::move(sink))
, m_currentActor(std::move(currentActor))
, m_entityType(std::move(entityType))
, m_enabledOps(std::move(enabledOps))
, m_clock(clock ? std::move(clock) : defaultClock())
, m_onSinkError(std::move(onSinkError))
{}
oatpp::Object<TDto> findByEntityId(const oatpp::String& entityId) override {
auto row = m_inner->findByEntityId(entityId);
if (m_enabledOps.count(AuditOp::Read)) {
// On miss, fall back to the requested id — still useful for
// compliance. On hit, prefer the id stored on the row.
std::string id = entityId ? std::string(*entityId) : std::string();
if (row) {
auto& rowId = TemporalFieldTraits<TDto>::entityId(row);
if (rowId) id = std::string(*rowId);
}
emit(AuditOp::Read, id);
}
return row;
}
oatpp::Vector<oatpp::Object<TDto>> list() override {
return m_inner->list(); // intentionally unaudited — see header doc
}
void save(const oatpp::Object<TDto>& dto) override {
const AuditOp op = classifySave(dto);
m_inner->save(dto);
if (m_enabledOps.count(op)) {
std::string id;
auto& field = TemporalFieldTraits<TDto>::entityId(dto);
if (field) id = std::string(*field);
emit(op, id);
}
}
void softDelete(const oatpp::String& entityId) override {
m_inner->softDelete(entityId);
if (m_enabledOps.count(AuditOp::Delete)) {
emit(AuditOp::Delete, entityId ? std::string(*entityId) : std::string());
}
}
private:
AuditOp classifySave(const oatpp::Object<TDto>& dto) {
auto& id = TemporalFieldTraits<TDto>::entityId(dto);
if (!id) return AuditOp::Create; // inner will allocate the id
// Caller-supplied id: distinguish Create-with-id vs Update.
return m_inner->findByEntityId(id) ? AuditOp::Update : AuditOp::Create;
}
void emit(AuditOp op, std::string entityId) {
AuditEvent ev;
ev.entityType = m_entityType;
ev.entityId = std::move(entityId);
ev.op = op;
ev.timestampMs = m_clock();
try {
ev.actorUserId = m_currentActor().userId;
} catch (...) {
// Actor accessor failure shouldn't break the write path either.
ev.actorUserId.clear();
}
try {
m_sink->record(ev);
} catch (const std::exception& e) {
if (m_onSinkError && m_onSinkError(e)) throw;
// else: swallow — audit logging is best-effort.
} catch (...) {
// Non-std::exception — always swallow; the handler signature
// takes std::exception&, so we cannot route it.
}
}
static Clock defaultClock() {
return [] {
using namespace std::chrono;
return duration_cast<milliseconds>(
system_clock::now().time_since_epoch()).count();
};
}
std::shared_ptr<Repository<TDto>> m_inner;
std::shared_ptr<IAuditSink> m_sink;
ActorAccess m_currentActor;
std::string m_entityType;
std::set<AuditOp> m_enabledOps;
Clock m_clock;
SinkErrorHandler m_onSinkError;
};
} // namespace oatpp_authkit::repo
#endif

View file

@ -0,0 +1,65 @@
#ifndef OATPP_AUTHKIT_REPO_I_AUDIT_SINK_HPP
#define OATPP_AUTHKIT_REPO_I_AUDIT_SINK_HPP
// Cross-cutting audit primitive used by `AuditLogRepository<T>` (authkit#11).
// The decorator emits an `AuditEvent` per mutation (and optionally per
// single-entity read) through an `IAuditSink` the consumer supplies.
#include <cstdint>
#include <string>
namespace oatpp_authkit::repo {
/**
* @brief What kind of operation produced the audit event.
*
* Reflects intent, not the inner method name `softDelete` and a
* hypothetical hard delete both surface as `Delete`. `Read` covers
* single-entity lookups (`findByEntityId`) only; `list()` is intentionally
* not audited because it is a scan, not a per-entity access.
*/
enum class AuditOp { Create, Update, Delete, Read };
inline const char* toString(AuditOp op) {
switch (op) {
case AuditOp::Create: return "Create";
case AuditOp::Update: return "Update";
case AuditOp::Delete: return "Delete";
case AuditOp::Read: return "Read";
}
return "Unknown";
}
/**
* @brief Audit record emitted on every audited operation.
*
* `entityType` is supplied by the decorator's owner at construction time
* (typeid is unportable, and consumers usually have a stable string they
* already use elsewhere table name, DTO name, etc.).
*/
struct AuditEvent {
std::string actorUserId;
std::string entityType;
std::string entityId;
AuditOp op{AuditOp::Read};
std::int64_t timestampMs{0};
};
/**
* @brief Where audit events go. Consumer-supplied.
*
* Implementations are typically a database insert (fewo-webapp's plan: an
* `audit_log` table behind a sqlite-backed sink) or, in tests, a vector
* append. Sink failures should not break the user's write path
* `AuditLogRepository<T>` catches exceptions thrown from `record` and
* routes them through a configurable error callback.
*/
class IAuditSink {
public:
virtual ~IAuditSink() = default;
virtual void record(const AuditEvent& ev) = 0;
};
} // namespace oatpp_authkit::repo
#endif

View file

@ -37,3 +37,7 @@ add_test(NAME queryable COMMAND test_queryable)
add_executable(test_temporal_field_traits test_temporal_field_traits.cpp)
target_link_libraries(test_temporal_field_traits PRIVATE oatpp::authkit oatpp::oatpp)
add_test(NAME temporal_field_traits COMMAND test_temporal_field_traits)
add_executable(test_audit_log_repository test_audit_log_repository.cpp)
target_link_libraries(test_audit_log_repository PRIVATE oatpp::authkit oatpp::oatpp)
add_test(NAME audit_log_repository COMMAND test_audit_log_repository)

View file

@ -0,0 +1,334 @@
// Tests for the oatpp-authkit#11 AuditLogRepository<T> decorator.
//
// Verifies the audit decorator emits events with the right shape, picks
// Create vs Update via a pre-write lookup, swallows sink failures by
// default, respects the configurable enabled-op set, and stacks correctly
// with TemporalRepository.
#include "oatpp-authkit/repo/AuditLogRepository.hpp"
#include "oatpp-authkit/repo/Repository.hpp"
#include "oatpp-authkit/repo/IAuditSink.hpp"
#include "oatpp-authkit/repo/ActorContext.hpp"
#include "oatpp-authkit/repo/TemporalFieldTraits.hpp"
#include "oatpp-authkit/repo/TemporalRepository.hpp"
#include "oatpp/core/macro/codegen.hpp"
#include "oatpp/core/Types.hpp"
#include <cstdio>
#include <map>
#include <memory>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
#include OATPP_CODEGEN_BEGIN(DTO)
namespace {
class AuditDto : public oatpp::DTO {
DTO_INIT(AuditDto, DTO)
DTO_FIELD(String, entity_id);
DTO_FIELD(String, valid_from);
DTO_FIELD(String, valid_until);
DTO_FIELD(String, name);
};
#include OATPP_CODEGEN_END(DTO)
} // namespace
OATPP_AUTHKIT_REGISTER_TEMPORAL(AuditDto, entity_id, valid_from, valid_until)
namespace {
int g_failures = 0;
#define REQUIRE(expr) do { \
if (!(expr)) { \
std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \
++g_failures; \
} \
} while (0)
using namespace oatpp_authkit::repo;
// ─── Helpers ────────────────────────────────────────────────────────────────
struct VectorSink : public IAuditSink {
std::vector<AuditEvent> events;
void record(const AuditEvent& ev) override { events.push_back(ev); }
};
struct ThrowingSink : public IAuditSink {
int calls{0};
void record(const AuditEvent&) override {
++calls;
throw std::runtime_error("sink down");
}
};
class InMemoryAdapter : public Repository<AuditDto> {
std::map<std::string, oatpp::Object<AuditDto>> rows;
int nextId{1};
public:
int saveCalls{0}, deleteCalls{0}, findCalls{0};
oatpp::Object<AuditDto> findByEntityId(const oatpp::String& id) override {
++findCalls;
auto it = rows.find(std::string(*id));
return it == rows.end() ? nullptr : it->second;
}
oatpp::Vector<oatpp::Object<AuditDto>> list() override {
auto v = oatpp::Vector<oatpp::Object<AuditDto>>::createShared();
for (auto& kv : rows) v->push_back(kv.second);
return v;
}
void save(const oatpp::Object<AuditDto>& dto) override {
++saveCalls;
if (!dto->entity_id) {
dto->entity_id = oatpp::String("auto-" + std::to_string(nextId++));
}
rows[std::string(*dto->entity_id)] = dto;
}
void softDelete(const oatpp::String& id) override {
++deleteCalls;
rows.erase(std::string(*id));
}
};
ActorContext alice() {
ActorContext a;
a.userId = "alice";
return a;
}
struct StepClock {
std::int64_t ms{1700000000000LL};
std::int64_t operator()() { auto v = ms; ms += 1000; return v; }
};
// ─── Tests ──────────────────────────────────────────────────────────────────
void test_save_with_null_id_emits_create() {
auto inner = std::make_shared<InMemoryAdapter>();
auto sink = std::make_shared<VectorSink>();
auto clk = std::make_shared<StepClock>();
AuditLogRepository<AuditDto> audit(inner, sink, alice, "AuditDto",
{AuditOp::Create, AuditOp::Update, AuditOp::Delete},
[clk]{ return (*clk)(); });
auto dto = AuditDto::createShared();
dto->name = oatpp::String("first");
audit.save(dto);
REQUIRE(sink->events.size() == 1);
REQUIRE(sink->events[0].op == AuditOp::Create);
REQUIRE(sink->events[0].actorUserId == "alice");
REQUIRE(sink->events[0].entityType == "AuditDto");
REQUIRE(!sink->events[0].entityId.empty()); // inner allocated id
REQUIRE(sink->events[0].timestampMs > 0);
// No pre-write lookup when id was null on entry.
REQUIRE(inner->findCalls == 0);
}
void test_save_with_existing_id_emits_update() {
auto inner = std::make_shared<InMemoryAdapter>();
auto sink = std::make_shared<VectorSink>();
AuditLogRepository<AuditDto> audit(inner, sink, alice, "AuditDto");
auto v1 = AuditDto::createShared();
v1->entity_id = oatpp::String("abc");
v1->name = oatpp::String("v1");
audit.save(v1);
REQUIRE(sink->events.back().op == AuditOp::Create);
auto v2 = AuditDto::createShared();
v2->entity_id = oatpp::String("abc");
v2->name = oatpp::String("v2");
audit.save(v2);
REQUIRE(sink->events.back().op == AuditOp::Update);
REQUIRE(sink->events.back().entityId == "abc");
}
void test_save_with_caller_id_but_no_row_is_create() {
auto inner = std::make_shared<InMemoryAdapter>();
auto sink = std::make_shared<VectorSink>();
AuditLogRepository<AuditDto> audit(inner, sink, alice, "AuditDto");
auto dto = AuditDto::createShared();
dto->entity_id = oatpp::String("brand-new");
dto->name = oatpp::String("first");
audit.save(dto);
REQUIRE(sink->events.size() == 1);
REQUIRE(sink->events[0].op == AuditOp::Create);
REQUIRE(sink->events[0].entityId == "brand-new");
}
void test_soft_delete_emits_delete() {
auto inner = std::make_shared<InMemoryAdapter>();
auto sink = std::make_shared<VectorSink>();
AuditLogRepository<AuditDto> audit(inner, sink, alice, "AuditDto");
auto dto = AuditDto::createShared();
dto->entity_id = oatpp::String("xyz");
audit.save(dto);
audit.softDelete(oatpp::String("xyz"));
REQUIRE(sink->events.size() == 2);
REQUIRE(sink->events[1].op == AuditOp::Delete);
REQUIRE(sink->events[1].entityId == "xyz");
}
void test_read_only_audited_when_enabled() {
auto inner = std::make_shared<InMemoryAdapter>();
auto sink = std::make_shared<VectorSink>();
AuditLogRepository<AuditDto> audit(inner, sink, alice, "AuditDto",
{AuditOp::Create, AuditOp::Read}); // explicit opt-in for Read
auto dto = AuditDto::createShared();
dto->entity_id = oatpp::String("rid");
audit.save(dto);
REQUIRE(sink->events.size() == 1);
REQUIRE(sink->events[0].op == AuditOp::Create);
(void)audit.findByEntityId(oatpp::String("rid"));
REQUIRE(sink->events.size() == 2);
REQUIRE(sink->events[1].op == AuditOp::Read);
REQUIRE(sink->events[1].entityId == "rid");
// Read on a miss still emits, with the requested id.
(void)audit.findByEntityId(oatpp::String("missing"));
REQUIRE(sink->events.size() == 3);
REQUIRE(sink->events[2].op == AuditOp::Read);
REQUIRE(sink->events[2].entityId == "missing");
}
void test_default_does_not_audit_reads() {
auto inner = std::make_shared<InMemoryAdapter>();
auto sink = std::make_shared<VectorSink>();
AuditLogRepository<AuditDto> audit(inner, sink, alice, "AuditDto");
auto dto = AuditDto::createShared();
dto->entity_id = oatpp::String("rid");
audit.save(dto);
(void)audit.findByEntityId(oatpp::String("rid"));
REQUIRE(sink->events.size() == 1); // only the save
REQUIRE(sink->events[0].op == AuditOp::Create);
}
void test_list_is_never_audited() {
auto inner = std::make_shared<InMemoryAdapter>();
auto sink = std::make_shared<VectorSink>();
AuditLogRepository<AuditDto> audit(inner, sink, alice, "AuditDto",
{AuditOp::Create, AuditOp::Update, AuditOp::Delete, AuditOp::Read});
for (int i = 0; i < 3; ++i) {
auto d = AuditDto::createShared();
d->entity_id = oatpp::String("id-" + std::to_string(i));
audit.save(d);
}
sink->events.clear();
auto rows = audit.list();
REQUIRE(rows->size() == 3);
REQUIRE(sink->events.empty()); // list is opaque to audit
}
void test_filter_skips_disabled_ops() {
auto inner = std::make_shared<InMemoryAdapter>();
auto sink = std::make_shared<VectorSink>();
AuditLogRepository<AuditDto> audit(inner, sink, alice, "AuditDto",
{AuditOp::Delete}); // only deletes
auto dto = AuditDto::createShared();
dto->entity_id = oatpp::String("id");
audit.save(dto);
REQUIRE(sink->events.empty()); // Create filtered out
audit.softDelete(oatpp::String("id"));
REQUIRE(sink->events.size() == 1);
REQUIRE(sink->events[0].op == AuditOp::Delete);
}
void test_sink_throw_swallowed_by_default() {
auto inner = std::make_shared<InMemoryAdapter>();
auto sink = std::make_shared<ThrowingSink>();
AuditLogRepository<AuditDto> audit(inner, sink, alice, "AuditDto");
auto dto = AuditDto::createShared();
dto->entity_id = oatpp::String("id");
bool threw = false;
try { audit.save(dto); } catch (...) { threw = true; }
REQUIRE(!threw);
REQUIRE(sink->calls == 1);
REQUIRE(inner->saveCalls == 1); // inner write still happened
}
void test_sink_throw_rethrows_when_handler_says_so() {
auto inner = std::make_shared<InMemoryAdapter>();
auto sink = std::make_shared<ThrowingSink>();
AuditLogRepository<AuditDto> audit(
inner, sink, alice, "AuditDto",
{AuditOp::Create, AuditOp::Update, AuditOp::Delete},
{},
[](const std::exception&) { return true; });
auto dto = AuditDto::createShared();
dto->entity_id = oatpp::String("id");
bool threw = false;
try { audit.save(dto); } catch (const std::runtime_error&) { threw = true; }
REQUIRE(threw);
}
void test_stacks_with_temporal_repository() {
// Audit ↔ Temporal: the audit decorator wraps the temporal one. Each
// logical save the consumer issues should produce exactly one audit
// event, even though the temporal layer may insert/update multiple
// physical rows beneath it.
auto adapter = std::make_shared<InMemoryAdapter>();
auto temporal = std::make_shared<TemporalRepository<AuditDto>>(adapter);
auto sink = std::make_shared<VectorSink>();
AuditLogRepository<AuditDto> audit(temporal, sink, alice, "AuditDto");
auto v1 = AuditDto::createShared();
v1->name = oatpp::String("first");
audit.save(v1);
REQUIRE(sink->events.size() == 1);
REQUIRE(sink->events[0].op == AuditOp::Create);
auto v2 = AuditDto::createShared();
v2->entity_id = v1->entity_id;
v2->name = oatpp::String("second");
audit.save(v2);
REQUIRE(sink->events.size() == 2);
REQUIRE(sink->events[1].op == AuditOp::Update);
REQUIRE(sink->events[1].entityId == std::string(*v1->entity_id));
audit.softDelete(v1->entity_id);
REQUIRE(sink->events.size() == 3);
REQUIRE(sink->events[2].op == AuditOp::Delete);
}
} // namespace
int main() {
test_save_with_null_id_emits_create();
test_save_with_existing_id_emits_update();
test_save_with_caller_id_but_no_row_is_create();
test_soft_delete_emits_delete();
test_read_only_audited_when_enabled();
test_default_does_not_audit_reads();
test_list_is_never_audited();
test_filter_skips_disabled_ops();
test_sink_throw_swallowed_by_default();
test_sink_throw_rethrows_when_handler_says_so();
test_stacks_with_temporal_repository();
std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures);
return g_failures ? 1 : 0;
}