From c6a2dba22b2d55836e93dc781a4d25d8c9a44ca2 Mon Sep 17 00:00:00 2001 From: Uwe Schuster Date: Wed, 29 Apr 2026 15:54:11 +0200 Subject: [PATCH] =?UTF-8?q?#11:=20AuditLogRepository=20+=20IAuditSink?= =?UTF-8?q?=20=E2=80=94=20cross-cutting=20audit=20decorator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #11 Co-Authored-By: Claude Opus 4.7 (1M context) --- CMakeLists.txt | 2 +- README.md | 1 + .../oatpp-authkit/repo/AuditLogRepository.hpp | 171 +++++++++ include/oatpp-authkit/repo/IAuditSink.hpp | 65 ++++ test/CMakeLists.txt | 4 + test/test_audit_log_repository.cpp | 334 ++++++++++++++++++ 6 files changed, 576 insertions(+), 1 deletion(-) create mode 100644 include/oatpp-authkit/repo/AuditLogRepository.hpp create mode 100644 include/oatpp-authkit/repo/IAuditSink.hpp create mode 100644 test/test_audit_log_repository.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 5140a71..ec8784d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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: diff --git a/README.md b/README.md index 040d098..8634f36 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ hardened auth / security stack. Header-only, oatpp 1.3+, C++17. | `repo/TemporalRepository.hpp` | Decorator that wraps any `Repository` 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`. 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::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`. | +| `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 diff --git a/include/oatpp-authkit/repo/AuditLogRepository.hpp b/include/oatpp-authkit/repo/AuditLogRepository.hpp new file mode 100644 index 0000000..f6407ab --- /dev/null +++ b/include/oatpp-authkit/repo/AuditLogRepository.hpp @@ -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` 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 +#include +#include +#include +#include +#include +#include +#include + +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::entityId`, so the decorator works for any + * DTO that registered the trait via `OATPP_AUTHKIT_REGISTER_TEMPORAL`. + */ +template +class AuditLogRepository : public Repository { +public: + using ActorAccess = std::function; + using Clock = std::function; + using SinkErrorHandler = std::function; + + AuditLogRepository(std::shared_ptr> inner, + std::shared_ptr sink, + ActorAccess currentActor, + std::string entityType, + std::set 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 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::entityId(row); + if (rowId) id = std::string(*rowId); + } + emit(AuditOp::Read, id); + } + return row; + } + + oatpp::Vector> list() override { + return m_inner->list(); // intentionally unaudited — see header doc + } + + void save(const oatpp::Object& dto) override { + const AuditOp op = classifySave(dto); + m_inner->save(dto); + if (m_enabledOps.count(op)) { + std::string id; + auto& field = TemporalFieldTraits::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& dto) { + auto& id = TemporalFieldTraits::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( + system_clock::now().time_since_epoch()).count(); + }; + } + + std::shared_ptr> m_inner; + std::shared_ptr m_sink; + ActorAccess m_currentActor; + std::string m_entityType; + std::set m_enabledOps; + Clock m_clock; + SinkErrorHandler m_onSinkError; +}; + +} // namespace oatpp_authkit::repo + +#endif diff --git a/include/oatpp-authkit/repo/IAuditSink.hpp b/include/oatpp-authkit/repo/IAuditSink.hpp new file mode 100644 index 0000000..4dc70ae --- /dev/null +++ b/include/oatpp-authkit/repo/IAuditSink.hpp @@ -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` (authkit#11). +// The decorator emits an `AuditEvent` per mutation (and optionally per +// single-entity read) through an `IAuditSink` the consumer supplies. + +#include +#include + +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` 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 diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 97a6d1b..b57105a 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -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) diff --git a/test/test_audit_log_repository.cpp b/test/test_audit_log_repository.cpp new file mode 100644 index 0000000..e6e9f69 --- /dev/null +++ b/test/test_audit_log_repository.cpp @@ -0,0 +1,334 @@ +// Tests for the oatpp-authkit#11 AuditLogRepository 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 +#include +#include +#include +#include +#include +#include + +#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 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 { + std::map> rows; + int nextId{1}; +public: + int saveCalls{0}, deleteCalls{0}, findCalls{0}; + + oatpp::Object findByEntityId(const oatpp::String& id) override { + ++findCalls; + auto it = rows.find(std::string(*id)); + return it == rows.end() ? nullptr : it->second; + } + oatpp::Vector> list() override { + auto v = oatpp::Vector>::createShared(); + for (auto& kv : rows) v->push_back(kv.second); + return v; + } + void save(const oatpp::Object& 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(); + auto sink = std::make_shared(); + auto clk = std::make_shared(); + AuditLogRepository 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(); + auto sink = std::make_shared(); + AuditLogRepository 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(); + auto sink = std::make_shared(); + AuditLogRepository 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(); + auto sink = std::make_shared(); + AuditLogRepository 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(); + auto sink = std::make_shared(); + AuditLogRepository 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(); + auto sink = std::make_shared(); + AuditLogRepository 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(); + auto sink = std::make_shared(); + AuditLogRepository 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(); + auto sink = std::make_shared(); + AuditLogRepository 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(); + auto sink = std::make_shared(); + AuditLogRepository 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(); + auto sink = std::make_shared(); + AuditLogRepository 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(); + auto temporal = std::make_shared>(adapter); + auto sink = std::make_shared(); + AuditLogRepository 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; +}