oatpp-authkit/include/oatpp-authkit/repo/AuditLogRepository.hpp
Uwe Schuster 606db5a109 #14 PR 0: replace imperative migration kit with declarative SchemaContract
D-replace per #14: rip out PREREQ + RESHAPE_STEPS + applyDecoratorMigrations
and replace with declarative DecoratorSchema (entity columns + indexes +
sidecar tables). SchemaBuilder<Decorators...>::create composes the stack
into a single CREATE TABLE per entity table; SchemaContract::verify
introspects-and-asserts at runtime so code can never run against an
under-migrated DB. Atlas (atlasgo.io) becomes the authority for schema
evolution between deploys — decorator code never runs ALTER at runtime.

- TemporalRepository contributes valid_from/valid_until + UNIQUE composite index
- AuditLogRepository contributes the audit_log sidecar table
- ScopeGuardRepository declares empty contributions for clean stacking
- 8 new tests in test_schema_contract.cpp covering compose / dedup / verify
- README updated; bumped 0.8.0 → 0.9.0

fewo-webapp does not yet call applyDecoratorMigrations, so this is a
clean cut — no consumer-side breakage. PRs 1-4 (role_templates,
user_property_permissions, user_group_permissions, users) follow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 12:14:51 +02:00

194 lines
7.6 KiB
C++

#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-authkit/repo/SchemaContract.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&)>;
/// 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<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