#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-authkit/repo/SchemaContract.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; /// Declarative schema contribution (authkit#14, D-replace). /// AuditLog touches no entity-table columns; it owns one sidecar /// `audit_log` table fixed across consumers. inline static constexpr ColumnSpec kAuditLogColumns[] = { {"id", "INTEGER PRIMARY KEY AUTOINCREMENT"}, {"actor_user_id", "TEXT"}, {"entity_type", "TEXT NOT NULL"}, {"entity_id", "TEXT NOT NULL"}, {"op", "TEXT NOT NULL"}, {"timestamp_ms", "INTEGER NOT NULL"}, }; inline static constexpr SidecarTableSpec kSidecars[] = { {"audit_log", kAuditLogColumns, sizeof(kAuditLogColumns) / sizeof(kAuditLogColumns[0])}, }; inline static constexpr DecoratorSchema kSchema = { "AuditLogRepository", nullptr, 0, nullptr, 0, kSidecars, sizeof(kSidecars) / sizeof(kSidecars[0]), }; 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