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:
parent
ccb77daac5
commit
5cdcb69edb
2 changed files with 212 additions and 1 deletions
|
|
@ -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:
|
||||
|
|
|
|||
211
include/oatpp-authkit/db/AuditLog.hpp
Normal file
211
include/oatpp-authkit/db/AuditLog.hpp
Normal 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
|
||||
Loading…
Add table
Reference in a new issue