#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:
parent
9040a9ec48
commit
52449e4159
6 changed files with 321 additions and 3 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
120
include/oatpp-authkit/repo/RedactedFieldRepository.hpp
Normal file
120
include/oatpp-authkit/repo/RedactedFieldRepository.hpp
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
169
test/test_redacted_field_repository.cpp
Normal file
169
test/test_redacted_field_repository.cpp
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue