diff --git a/CMakeLists.txt b/CMakeLists.txt index 521443f..0e97eaf 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.14) -project(oatpp-authkit VERSION 0.12.0 LANGUAGES CXX) +project(oatpp-authkit VERSION 0.13.0 LANGUAGES CXX) # Header-only interface library — no compilation, just an include path and # a CMake config package so consumers do: diff --git a/README.md b/README.md index 32fb830..ee86b01 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ hardened auth / security stack. Header-only, oatpp 1.3+, C++17. | `repo/IQueryable.hpp` | Optional capability for repos that resolve a typed query AST. `field<&Dto::col>().eq(...)` style DSL composes via `&&` / `||` / `!`; `Query::toSql()` emits parameterised SQL plus a bind bag. Bounded surface — equality, range, IN, LIKE, NULL, ORDER BY, LIMIT/OFFSET. No joins, subqueries, or aggregates. Concrete repos opt in by deriving `IQueryable`. | | `repo/IAuditSink.hpp` + `repo/AuditLogRepository.hpp` | Cross-cutting audit-trail decorator. Emits an `AuditEvent` (actor, entity type/id, op, timestamp) per mutation through a consumer-supplied `IAuditSink`. Ops are `Create` / `Update` / `Delete` / `Read`; pre-write `findByEntityId` lookup distinguishes Create from Update. Configurable enabled-op set (default `{Create,Update,Delete}` — `Read` is opt-in, `list()` never audited). Sink failures are caught and swallowed unless a `bool(const std::exception&)` handler asks to rethrow. Stacks with `TemporalRepository` and `ScopeGuardRepository`. | | `repo/SchemaContract.hpp` | Declarative schema model for the decorator stack (authkit#14). Each decorator exposes a `static constexpr DecoratorSchema kSchema` listing the columns/indexes it contributes to the entity table plus any sidecar tables it owns. `SchemaBuilder::create(table, exec)` composes contributions into a single `CREATE TABLE` per entity table; sidecars emit separately. `SchemaContract::verify(table, probe)` is a runtime introspect-and-assert that throws `SchemaContractViolation` if any required column or sidecar is missing. Decorator code never runs ALTER at runtime — Atlas (atlasgo.io) owns evolution between deploys; the C++ side only declares desired state and checks it. | +| `repo/RedactedFieldRepository.hpp` | Decorator that nulls out named fields on **historical** rows only (authkit#15). Sits below `TemporalRepository` and inspects each `save`: if `valid_until != SENTINEL`, the row is being closed as a historical version, so the configured fields (e.g. `passwordHash`, `tlsCertDn`) are set to null before persisting. The live row keeps its values intact. Built for the case where a credential rides a temporal row — every change creates a historical version with the prior secret preserved, and the redaction prevents a DB breach from yielding every credential a user has ever had. | ## Decorator schema contributions diff --git a/include/oatpp-authkit/repo/ConcreteUserRepository.hpp b/include/oatpp-authkit/repo/ConcreteUserRepository.hpp index 0909514..8a7413f 100644 --- a/include/oatpp-authkit/repo/ConcreteUserRepository.hpp +++ b/include/oatpp-authkit/repo/ConcreteUserRepository.hpp @@ -6,6 +6,7 @@ #include "oatpp-authkit/db/UserDb.hpp" #include "oatpp-authkit/dto/UserDto.hpp" +#include "oatpp-authkit/repo/RedactedFieldRepository.hpp" #include "oatpp-authkit/repo/Repository.hpp" #include "oatpp-authkit/repo/SchemaContract.hpp" #include "oatpp-authkit/repo/TemporalRepository.hpp" @@ -61,10 +62,33 @@ private: std::shared_ptr m_db; }; +/** + * @brief Compose the user repository stack with credential redaction + * baked in (authkit#15). + * + * TemporalRepository( + * RedactedFieldRepository( + * ConcreteUserRepository(udb), + * {"passwordHash", "tlsCertDn"})) + * + * On every password change the prior hash gets blanked on the historical + * row before the temporal decorator persists it. `tlsCertDn` follows the + * same policy. The audit-trail (when did this user exist, when was their + * password rotated) survives in `valid_from`/`valid_until`/`username`/ + * `role`; only the credential surface is redacted. + * + * Default redaction list is `{"passwordHash", "tlsCertDn"}` per the + * issue thread's Option B. Pass a different list to the overload below + * if a consumer wants different behaviour. + */ inline std::shared_ptr> -makeUserRepository(std::shared_ptr udb) { +makeUserRepository(std::shared_ptr udb, + std::vector fieldsToRedact = + {"passwordHash", "tlsCertDn"}) { auto concrete = std::make_shared(std::move(udb)); - return std::make_shared>(concrete); + auto redacted = std::make_shared>( + concrete, std::move(fieldsToRedact)); + return std::make_shared>(redacted); } } // namespace oatpp_authkit::repo diff --git a/include/oatpp-authkit/repo/RedactedFieldRepository.hpp b/include/oatpp-authkit/repo/RedactedFieldRepository.hpp new file mode 100644 index 0000000..eca71d5 --- /dev/null +++ b/include/oatpp-authkit/repo/RedactedFieldRepository.hpp @@ -0,0 +1,120 @@ +#ifndef OATPP_AUTHKIT_REPO_REDACTED_FIELD_REPOSITORY_HPP +#define OATPP_AUTHKIT_REPO_REDACTED_FIELD_REPOSITORY_HPP + +// Decorator that nulls out named fields on historical rows (authkit#15). +// +// Use case: when password_hash and similar credentials ride a temporal +// row, every change creates a historical version with the prior secret +// preserved. A DB breach then yields every credential the user has ever +// had — a known-plaintext oracle for guessing future passwords. +// +// This decorator sits **between** TemporalRepository and the concrete +// repo. TemporalRepository's `save` flow calls inner `save` twice: +// +// 1. Historical clone with `valid_until = now()` (the row being closed) +// 2. Live row with `valid_until = SENTINEL` (the new version) +// +// We redact configured fields whenever `valid_until != SENTINEL` on +// entry — i.e. only on the historical insert. The live row keeps its +// values intact. +// +// Stack: +// +// TemporalRepository( +// RedactedFieldRepository( +// ConcreteUserRepository(udb), +// {"passwordHash", "tlsCertDn"})) + +#include "oatpp-authkit/repo/Repository.hpp" +#include "oatpp-authkit/repo/TemporalFieldTraits.hpp" +#include "oatpp-authkit/repo/TemporalRepository.hpp" + +#include "oatpp/core/Types.hpp" + +#include +#include +#include +#include + +namespace oatpp_authkit::repo { + +/** + * @brief Decorator that redacts named fields on historical rows. + * + * `TDto` must register a `TemporalFieldTraits` specialisation so + * the decorator can read `valid_until` to distinguish historical rows + * from live ones. Field-name matching uses oatpp's reflective property + * dispatcher and matches against the **C++ identifier** name (the first + * macro argument to `DTO_FIELD`), not the JSON-qualified column name. + * + * Schema contribution: empty. The redaction is purely a save-time + * transform; no extra columns or indexes are needed. + */ +template +class RedactedFieldRepository : public Repository { +public: + inline static constexpr DecoratorSchema kSchema = { + "RedactedFieldRepository", + nullptr, 0, nullptr, 0, nullptr, 0, + }; + + /** + * @param inner Concrete adapter (or any further-inner stack). + * @param fieldsToRedact C++ identifier names of DTO fields to null + * out on historical writes (e.g. `"passwordHash"`). + */ + RedactedFieldRepository(std::shared_ptr> inner, + std::vector fieldsToRedact) + : m_inner(std::move(inner)) + , m_fieldsToRedact(std::move(fieldsToRedact)) {} + + oatpp::Object findByEntityId(const oatpp::String& entityId) override { + return m_inner->findByEntityId(entityId); + } + + oatpp::Vector> list() override { + return m_inner->list(); + } + + void save(const oatpp::Object& dto) override { + if (isHistorical(dto)) redactFields(dto); + m_inner->save(dto); + } + + void softDelete(const oatpp::String& entityId) override { + m_inner->softDelete(entityId); + } + +private: + /// A row is historical iff `valid_until` is non-null and not the + /// SENTINEL. The TemporalRepository sets `valid_until = now()` on + /// the close-clone and `valid_until = SENTINEL` on the live update. + static bool isHistorical(const oatpp::Object& dto) { + auto& vu = TemporalFieldTraits::validUntil(dto); + if (!vu) return false; + return std::string(*vu) != TemporalRepository::SENTINEL; + } + + void redactFields(const oatpp::Object& dto) const { + const auto* dispatcher = static_cast< + const oatpp::data::mapping::type::__class::AbstractObject::PolymorphicDispatcher*>( + oatpp::Object::Class::getType()->polymorphicDispatcher); + for (auto* p : dispatcher->getProperties()->getList()) { + const std::string name(p->name); + for (const auto& target : m_fieldsToRedact) { + if (name == target) { + p->set(static_cast(dto.get()), + oatpp::Void(nullptr, p->type)); + break; + } + } + } + } + + std::shared_ptr> m_inner; + std::vector m_fieldsToRedact; +}; + +} // namespace oatpp_authkit::repo + +#endif diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 628b55f..e966e70 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -46,6 +46,10 @@ add_executable(test_schema_contract test_schema_contract.cpp) target_link_libraries(test_schema_contract PRIVATE oatpp::authkit oatpp::oatpp) add_test(NAME schema_contract COMMAND test_schema_contract) +add_executable(test_redacted_field_repository test_redacted_field_repository.cpp) +target_link_libraries(test_redacted_field_repository PRIVATE oatpp::authkit oatpp::oatpp) +add_test(NAME redacted_field_repository COMMAND test_redacted_field_repository) + # RoleTemplateDb pulls in oatpp-sqlite for its DbClient queries. Linking # the test against oatpp::oatpp-sqlite provides the QUERY codegen # definitions; the test itself doesn't open a real DB, only compiles diff --git a/test/test_redacted_field_repository.cpp b/test/test_redacted_field_repository.cpp new file mode 100644 index 0000000..2a60446 --- /dev/null +++ b/test/test_redacted_field_repository.cpp @@ -0,0 +1,169 @@ +// Tests for authkit#15 — RedactedFieldRepository decorator. + +#include "oatpp-authkit/repo/RedactedFieldRepository.hpp" +#include "oatpp-authkit/repo/Repository.hpp" +#include "oatpp-authkit/repo/TemporalFieldTraits.hpp" +#include "oatpp-authkit/repo/TemporalRepository.hpp" + +#include "oatpp/core/macro/codegen.hpp" +#include "oatpp/core/Types.hpp" + +#include +#include +#include +#include +#include + +#define REQUIRE(cond) do { \ + if (!(cond)) { std::fprintf(stderr, "REQUIRE failed: %s @ %s:%d\n", \ + #cond, __FILE__, __LINE__); std::abort(); } } while (0) + +#include OATPP_CODEGEN_BEGIN(DTO) + +namespace { + +class CredDto : public oatpp::DTO { + DTO_INIT(CredDto, DTO) + DTO_FIELD(String, id); + DTO_FIELD(String, entity_id); + DTO_FIELD(String, valid_from); + DTO_FIELD(String, valid_until); + DTO_FIELD(String, username); + DTO_FIELD(String, passwordHash); + DTO_FIELD(String, tlsCertDn); +}; + +} // namespace + +#include OATPP_CODEGEN_END(DTO) + +OATPP_AUTHKIT_REGISTER_TEMPORAL(CredDto, id, entity_id, valid_from, valid_until) + +namespace { + +using namespace oatpp_authkit::repo; + +// In-memory inner that just records what got saved, for inspection. +class FakeInner : public Repository { +public: + std::vector> saved; + + oatpp::Object findByEntityId(const oatpp::String&) override { return nullptr; } + oatpp::Vector> list() override { + return oatpp::Vector>::createShared(); + } + void save(const oatpp::Object& dto) override { saved.push_back(dto); } + void softDelete(const oatpp::String&) override {} +}; + +oatpp::Object makeRow(const std::string& vu, + const std::string& password, + const std::string& certDn) { + auto d = CredDto::createShared(); + d->id = "id1"; + d->entity_id = "ent1"; + d->valid_from = "2026-01-01T00:00:00Z"; + d->valid_until = vu; + d->username = "alice"; + d->passwordHash = password; + d->tlsCertDn = certDn; + return d; +} + +void test_live_row_passes_through_unchanged() { + auto inner = std::make_shared(); + RedactedFieldRepository redacted( + inner, {"passwordHash", "tlsCertDn"}); + + // Live row: valid_until == SENTINEL. + auto live = makeRow(TemporalRepository::SENTINEL, + "$bcrypt$secret", "CN=alice"); + redacted.save(live); + + REQUIRE(inner->saved.size() == 1); + auto& got = inner->saved[0]; + REQUIRE(got->passwordHash); + REQUIRE(std::string(*got->passwordHash) == "$bcrypt$secret"); + REQUIRE(got->tlsCertDn); + REQUIRE(std::string(*got->tlsCertDn) == "CN=alice"); +} + +void test_historical_row_redacts_named_fields() { + auto inner = std::make_shared(); + RedactedFieldRepository redacted( + inner, {"passwordHash", "tlsCertDn"}); + + // Historical row: valid_until is a real timestamp, not the sentinel. + auto historical = makeRow("2026-05-06T12:00:00Z", + "$bcrypt$secret", "CN=alice"); + redacted.save(historical); + + REQUIRE(inner->saved.size() == 1); + auto& got = inner->saved[0]; + REQUIRE(!got->passwordHash); // redacted to null + REQUIRE(!got->tlsCertDn); // redacted to null + // Non-redacted fields survive. + REQUIRE(got->username); + REQUIRE(std::string(*got->username) == "alice"); + REQUIRE(got->valid_until); + REQUIRE(std::string(*got->valid_until) == "2026-05-06T12:00:00Z"); +} + +void test_partial_redaction_list() { + auto inner = std::make_shared(); + RedactedFieldRepository redacted(inner, {"passwordHash"}); + + auto historical = makeRow("2026-05-06T12:00:00Z", + "$bcrypt$secret", "CN=alice"); + redacted.save(historical); + + auto& got = inner->saved[0]; + REQUIRE(!got->passwordHash); // redacted + REQUIRE(got->tlsCertDn); // NOT redacted (not in list) + REQUIRE(std::string(*got->tlsCertDn) == "CN=alice"); +} + +void test_empty_redaction_list_passes_everything_through() { + auto inner = std::make_shared(); + RedactedFieldRepository redacted(inner, {}); + + auto historical = makeRow("2026-05-06T12:00:00Z", + "$bcrypt$secret", "CN=alice"); + redacted.save(historical); + + auto& got = inner->saved[0]; + REQUIRE(got->passwordHash); + REQUIRE(std::string(*got->passwordHash) == "$bcrypt$secret"); + REQUIRE(got->tlsCertDn); +} + +void test_null_valid_until_treated_as_live() { + auto inner = std::make_shared(); + RedactedFieldRepository redacted( + inner, {"passwordHash", "tlsCertDn"}); + + // valid_until null — treat as live (the temporal decorator hasn't + // set it yet on a fresh insert, before deciding sentinel). + auto fresh = CredDto::createShared(); + fresh->id = "id2"; + fresh->entity_id = "ent2"; + fresh->passwordHash = "$bcrypt$fresh"; + fresh->tlsCertDn = "CN=bob"; + redacted.save(fresh); + + auto& got = inner->saved[0]; + REQUIRE(got->passwordHash); // not redacted + REQUIRE(got->tlsCertDn); +} + +} // namespace + +int main() { + test_live_row_passes_through_unchanged(); + test_historical_row_redacts_named_fields(); + test_partial_redaction_list(); + test_empty_redaction_list_passes_everything_through(); + test_null_valid_until_treated_as_live(); + std::printf("test_redacted_field_repository: OK\n"); + return 0; +}