#15: RedactedFieldRepository — null credentials on historical rows

Adds a decorator that sits below TemporalRepository and redacts
configured fields whenever it sees a save with valid_until != SENTINEL
(i.e., a historical row being closed by the temporal close-then-update
flow). The live row keeps its values intact.

Per Option B from the issue thread: by default the user-repo factory
redacts both passwordHash and tlsCertDn. Empty redaction list passes
everything through unchanged, so non-user temporal stacks compose the
decorator without surprise behaviour.

Files:
- repo/RedactedFieldRepository.hpp — new decorator. Schema contribution
  is empty (purely a save-time transform). Field-name matching uses
  oatpp's reflective property dispatcher and matches against the C++
  identifier name (first DTO_FIELD argument).
- repo/ConcreteUserRepository.hpp — makeUserRepository now wraps the
  concrete repo in RedactedFieldRepository<UserDto>{"passwordHash",
  "tlsCertDn"} before passing to TemporalRepository. Optional second
  argument lets consumers override the redaction list.
- test/test_redacted_field_repository.cpp — five tests cover live-row
  pass-through, historical-row redaction (both fields), partial
  redaction list, empty list, and null-valid_until treated as live.
- README.md — adds RedactedFieldRepository to the header inventory.

14 of 14 tests pass. Bumped 0.12.0 → 0.13.0.

Closes #15

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Uwe Schuster 2026-05-06 20:52:02 +02:00
parent 9040a9ec48
commit 52449e4159
6 changed files with 321 additions and 3 deletions

View file

@ -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:

View file

@ -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<TDto>::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<TDto>`. |
| `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<Decorators…>::create(table, exec)` composes contributions into a single `CREATE TABLE` per entity table; sidecars emit separately. `SchemaContract<Decorators…>::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

View file

@ -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<db::UserDb> 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<Repository<dto::UserDto>>
makeUserRepository(std::shared_ptr<db::UserDb> udb) {
makeUserRepository(std::shared_ptr<db::UserDb> udb,
std::vector<std::string> fieldsToRedact =
{"passwordHash", "tlsCertDn"}) {
auto concrete = std::make_shared<ConcreteUserRepository>(std::move(udb));
return std::make_shared<TemporalRepository<dto::UserDto>>(concrete);
auto redacted = std::make_shared<RedactedFieldRepository<dto::UserDto>>(
concrete, std::move(fieldsToRedact));
return std::make_shared<TemporalRepository<dto::UserDto>>(redacted);
}
} // namespace oatpp_authkit::repo

View file

@ -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<UserDto>(
// RedactedFieldRepository<UserDto>(
// 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 <memory>
#include <string>
#include <utility>
#include <vector>
namespace oatpp_authkit::repo {
/**
* @brief Decorator that redacts named fields on historical rows.
*
* `TDto` must register a `TemporalFieldTraits<TDto>` 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 <typename TDto>
class RedactedFieldRepository : public Repository<TDto> {
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<Repository<TDto>> inner,
std::vector<std::string> fieldsToRedact)
: m_inner(std::move(inner))
, m_fieldsToRedact(std::move(fieldsToRedact)) {}
oatpp::Object<TDto> findByEntityId(const oatpp::String& entityId) override {
return m_inner->findByEntityId(entityId);
}
oatpp::Vector<oatpp::Object<TDto>> list() override {
return m_inner->list();
}
void save(const oatpp::Object<TDto>& 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<TDto>& dto) {
auto& vu = TemporalFieldTraits<TDto>::validUntil(dto);
if (!vu) return false;
return std::string(*vu) != TemporalRepository<TDto>::SENTINEL;
}
void redactFields(const oatpp::Object<TDto>& dto) const {
const auto* dispatcher = static_cast<
const oatpp::data::mapping::type::__class::AbstractObject::PolymorphicDispatcher*>(
oatpp::Object<TDto>::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<oatpp::BaseObject*>(dto.get()),
oatpp::Void(nullptr, p->type));
break;
}
}
}
}
std::shared_ptr<Repository<TDto>> m_inner;
std::vector<std::string> m_fieldsToRedact;
};
} // namespace oatpp_authkit::repo
#endif

View file

@ -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

View file

@ -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 <cassert>
#include <cstdio>
#include <memory>
#include <string>
#include <vector>
#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<CredDto> {
public:
std::vector<oatpp::Object<CredDto>> saved;
oatpp::Object<CredDto> findByEntityId(const oatpp::String&) override { return nullptr; }
oatpp::Vector<oatpp::Object<CredDto>> list() override {
return oatpp::Vector<oatpp::Object<CredDto>>::createShared();
}
void save(const oatpp::Object<CredDto>& dto) override { saved.push_back(dto); }
void softDelete(const oatpp::String&) override {}
};
oatpp::Object<CredDto> 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<FakeInner>();
RedactedFieldRepository<CredDto> redacted(
inner, {"passwordHash", "tlsCertDn"});
// Live row: valid_until == SENTINEL.
auto live = makeRow(TemporalRepository<CredDto>::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<FakeInner>();
RedactedFieldRepository<CredDto> 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<FakeInner>();
RedactedFieldRepository<CredDto> 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<FakeInner>();
RedactedFieldRepository<CredDto> 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<FakeInner>();
RedactedFieldRepository<CredDto> 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;
}