diff --git a/CMakeLists.txt b/CMakeLists.txt index a57980a..d75cbe9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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: diff --git a/include/oatpp-authkit/db/AuditLog.hpp b/include/oatpp-authkit/db/AuditLog.hpp new file mode 100644 index 0000000..5cf4813 --- /dev/null +++ b/include/oatpp-authkit/db/AuditLog.hpp @@ -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 +#include +#include + +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(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("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& 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 m_db; + + /** @brief Fields to skip when computing UPDATE diffs — internal/metadata. */ + static inline const std::set 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(value.getPtr().get()); + return "\"" + escapeJson(*s) + "\""; + } + if (classId == oatpp::Int32::Class::CLASS_ID) { + return std::to_string(*static_cast(value.getPtr().get())); + } + if (classId == oatpp::Int64::Class::CLASS_ID) { + return std::to_string(*static_cast(value.getPtr().get())); + } + if (classId == oatpp::Float64::Class::CLASS_ID) { + char buf[64]; + std::snprintf(buf, sizeof(buf), "%g", *static_cast(value.getPtr().get())); + return buf; + } + if (classId == oatpp::Float32::Class::CLASS_ID) { + char buf[64]; + std::snprintf(buf, sizeof(buf), "%g", (double)*static_cast(value.getPtr().get())); + return buf; + } + if (classId == oatpp::Boolean::Class::CLASS_ID) { + return *static_cast(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& executor) + : m_db(std::make_shared(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& 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& 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& 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 + void logUpdate(const oatpp::String& table, + const oatpp::String& entityId, + const oatpp::Object& oldRow, + const oatpp::Object& newRow, + const oatpp::String& actor = nullptr, + const oatpp::provider::ResourceHandle& connection = nullptr) { + std::string json = "{"; + bool first = true; + + for (auto* prop : oatpp::Object::getPropertiesList()) { + std::string fieldName(prop->name); + + if (SKIP_FIELDS.count(fieldName)) continue; + + auto oldVal = prop->get(static_cast(oldRow.get())); + auto newVal = prop->get(static_cast(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