v0.3.1: Add db::AuditLog — lifted from fewo-webapp with table rename

Brings the generic audit-log helper (timestamp + actor + action + entity
+ changed_fields JSON) into the shared library so every consumer picks
up the same shape without reimplementing it. The table is now named
`audit_log` (was `command_log` in fewo-webapp); consumers copy
`AuditLog::CREATE_TABLE_SQL` into their schema.sql so class name and
table name stay in one source of truth.

Legacy data on fewo-webapp migrates via a one-shot
`INSERT INTO audit_log SELECT … FROM command_log; DROP TABLE command_log;`
statement in that project's schema.sql.

Closes #449 (fewo-webapp half follows in separate commits).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Uwe Schuster 2026-04-23 12:36:03 +02:00
parent ccb77daac5
commit 5cdcb69edb
2 changed files with 212 additions and 1 deletions

View file

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

View file

@ -0,0 +1,211 @@
#ifndef oatpp_authkit_db_AuditLog_hpp
#define oatpp_authkit_db_AuditLog_hpp
#include "oatpp-sqlite/orm.hpp"
#include "oatpp/core/macro/codegen.hpp"
#include "oatpp/core/macro/component.hpp"
#include <set>
#include <string>
#include <sstream>
namespace oatpp_authkit {
/**
* @brief Audit logging service logs entity mutations to the `audit_log` table.
*
* Replaces SQLite audit triggers with explicit C++ calls from controllers.
* Four operations:
* - logCreate(table, entityId)
* - logDelete(table, entityId)
* - logUpdate(table, entityId) no-diff form (junction changes, bulk patches)
* - logUpdate<Dto>(table, entityId, oldRow, newRow) computes a JSON field diff
*
* Schema: consumers copy `AuditLog::CREATE_TABLE_SQL` into their `schema.sql`
* (or execute it at startup) so every project that uses `AuditLog` ends up on
* the same table shape. That keeps the class name (`AuditLog`), the table
* name (`audit_log`), and the column set in one source of truth.
*
* Usage in controllers:
* m_auditLog->logCreate("bookings", entityId, actor);
* m_auditLog->logUpdate<BookingDto>("bookings", entityId, oldRow, newRow, actor);
*
* Note on legacy data (fewo-webapp only): the pre-lift table was named
* `command_log`; a one-shot migration (INSERT INTO audit_log SELECT
* FROM command_log; DROP TABLE command_log;) copies the existing rows over.
*/
class AuditLog {
public:
/**
* @brief DDL for the audit_log table + supporting indexes.
*
* Consumers include this in their schema-init flow (e.g. executing it
* at startup) so every project using `AuditLog` has the same table
* shape without each project re-declaring the column set.
*/
static constexpr const char* CREATE_TABLE_SQL = R"SQL(
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
table_name TEXT NOT NULL,
entity_id TEXT NOT NULL,
operation TEXT NOT NULL,
changed_fields TEXT,
actor TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at);
CREATE INDEX IF NOT EXISTS idx_audit_log_table_entity ON audit_log(table_name, entity_id);
)SQL";
#include OATPP_CODEGEN_BEGIN(DbClient)
/** @brief Minimal DbClient for inserting into audit_log. */
class AuditLogDb : public oatpp::orm::DbClient {
public:
AuditLogDb(const std::shared_ptr<oatpp::orm::Executor>& executor)
: oatpp::orm::DbClient(executor) {}
QUERY(logOp,
"INSERT INTO audit_log(table_name, entity_id, operation, changed_fields, actor) "
"VALUES (:t, :e, :o, :f, :a);",
PARAM(oatpp::String, t),
PARAM(oatpp::String, e),
PARAM(oatpp::String, o),
PARAM(oatpp::String, f),
PARAM(oatpp::String, a))
};
#include OATPP_CODEGEN_END(DbClient)
private:
std::shared_ptr<AuditLogDb> m_db;
/** @brief Fields to skip when computing UPDATE diffs — internal/metadata. */
static inline const std::set<std::string> SKIP_FIELDS = {
"id", "entity_id", "created_at", "updated_at", "valid_from"
};
static std::string escapeJson(const std::string& s) {
std::string out;
out.reserve(s.size());
for (char c : s) {
if (c == '\\') out += "\\\\";
else if (c == '"') out += "\\\"";
else out += c;
}
return out;
}
/**
* @brief Serialise an oatpp::Void to JSON: String / Int32 / Int64 /
* Float32 / Float64 / Boolean / null. Unknown types serialise as null.
*/
static std::string valueToJson(const oatpp::Void& value) {
if (!value.getPtr()) return "null";
auto classId = value.getValueType()->classId;
if (classId == oatpp::String::Class::CLASS_ID) {
auto* s = static_cast<const std::string*>(value.getPtr().get());
return "\"" + escapeJson(*s) + "\"";
}
if (classId == oatpp::Int32::Class::CLASS_ID) {
return std::to_string(*static_cast<const v_int32*>(value.getPtr().get()));
}
if (classId == oatpp::Int64::Class::CLASS_ID) {
return std::to_string(*static_cast<const v_int64*>(value.getPtr().get()));
}
if (classId == oatpp::Float64::Class::CLASS_ID) {
char buf[64];
std::snprintf(buf, sizeof(buf), "%g", *static_cast<const v_float64*>(value.getPtr().get()));
return buf;
}
if (classId == oatpp::Float32::Class::CLASS_ID) {
char buf[64];
std::snprintf(buf, sizeof(buf), "%g", (double)*static_cast<const v_float32*>(value.getPtr().get()));
return buf;
}
if (classId == oatpp::Boolean::Class::CLASS_ID) {
return *static_cast<const bool*>(value.getPtr().get()) ? "true" : "false";
}
return "null";
}
static bool valuesEqual(const oatpp::Void& a, const oatpp::Void& b) {
bool aNull = !a.getPtr();
bool bNull = !b.getPtr();
if (aNull && bNull) return true;
if (aNull || bNull) return false;
return valueToJson(a) == valueToJson(b);
}
public:
AuditLog(const std::shared_ptr<oatpp::orm::Executor>& executor)
: m_db(std::make_shared<AuditLogDb>(executor)) {}
/** @brief Log a CREATE (entity inserted). Optional connection pins to a transaction. */
void logCreate(const oatpp::String& table, const oatpp::String& entityId,
const oatpp::String& actor = nullptr,
const oatpp::provider::ResourceHandle<oatpp::orm::Connection>& connection = nullptr) {
m_db->logOp(table, entityId, "CREATE", nullptr, actor, connection);
}
/** @brief Log a DELETE (entity removed). Optional connection pins to a transaction. */
void logDelete(const oatpp::String& table, const oatpp::String& entityId,
const oatpp::String& actor = nullptr,
const oatpp::provider::ResourceHandle<oatpp::orm::Connection>& connection = nullptr) {
m_db->logOp(table, entityId, "DELETE", nullptr, actor, connection);
}
/** @brief Log an UPDATE without field-level diff (junction changes, bulk patches). */
void logUpdate(const oatpp::String& table, const oatpp::String& entityId,
const oatpp::String& actor = nullptr,
const oatpp::provider::ResourceHandle<oatpp::orm::Connection>& connection = nullptr) {
m_db->logOp(table, entityId, "UPDATE", nullptr, actor, connection);
}
/**
* @brief Log an UPDATE with a JSON diff of the fields whose values changed.
*
* Uses oatpp DTO reflection to produce `{"field": newValue, ...}`. If no
* field changed, no row is written.
*/
template<typename DtoType>
void logUpdate(const oatpp::String& table,
const oatpp::String& entityId,
const oatpp::Object<DtoType>& oldRow,
const oatpp::Object<DtoType>& newRow,
const oatpp::String& actor = nullptr,
const oatpp::provider::ResourceHandle<oatpp::orm::Connection>& connection = nullptr) {
std::string json = "{";
bool first = true;
for (auto* prop : oatpp::Object<DtoType>::getPropertiesList()) {
std::string fieldName(prop->name);
if (SKIP_FIELDS.count(fieldName)) continue;
auto oldVal = prop->get(static_cast<oatpp::BaseObject*>(oldRow.get()));
auto newVal = prop->get(static_cast<oatpp::BaseObject*>(newRow.get()));
if (!valuesEqual(oldVal, newVal)) {
if (!first) json += ",";
json += "\"" + fieldName + "\":" + valueToJson(newVal);
first = false;
}
}
json += "}";
if (!first) {
m_db->logOp(table, entityId, "UPDATE", oatpp::String(json), actor, connection);
}
}
};
} // namespace oatpp_authkit
#endif // oatpp_authkit_db_AuditLog_hpp