diff --git a/CMakeLists.txt b/CMakeLists.txt index b3f7577..5740e0b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.14) -project(oatpp-authkit VERSION 0.7.0 LANGUAGES CXX) +project(oatpp-authkit VERSION 0.8.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 313c58e..419d9c3 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ hardened auth / security stack. Header-only, oatpp 1.3+, C++17. | `util/TokenExtract.hpp` | `extractToken` (Cookie/Bearer), `isValidIp` (IPv4/IPv6 via `inet_pton`), `clientIpTrusted` (loopback-gated XFF). | | `startup/RequireEncryptionKey.hpp` | `requireEncryptionKey(envVarName, encryptionEnabled, allowPlaintext)` — refuse startup without a symmetric key unless a dev flag overrides. | | `repo/Repository.hpp` + `IHistoryRepository.hpp` + `TemporalFieldTraits.hpp` + `TemporalAt.hpp` + `ActorContext.hpp` | Pure-abstract `Repository` interface set distilled from fewo-webapp's per-entity `*Db` clients. Mixed UUID allocation on `save`, separate `IHistoryRepository` for temporal versions, `TemporalFieldTraits` to map canonical (entity_id, valid_from, valid_until) onto whatever a DTO actually calls them, `ActorContext` placeholder for the scope-guard decorator. | -| `repo/TemporalRepository.hpp` | Decorator that wraps any `Repository` and turns it into a temporally-versioned one. `save` closes the prior live version and inserts a new one; `findByEntityIdAt(id, at)` returns the version live at a point in time; implements `IHistoryRepository`. Inner adapter is expected to expose all rows (live + historical) and treat `save` as upsert keyed by `(entity_id, valid_from)`. DTOs register their three temporal columns via `OATPP_AUTHKIT_REGISTER_TEMPORAL`. | +| `repo/TemporalRepository.hpp` | Decorator that wraps any `Repository` and turns it into a temporally-versioned one. **Stable-live + historical-copy semantics (authkit#13):** the live row's `id` PK is preserved across updates; each prior version is captured as a fresh row with a new `id`. `softDelete` closes the live row in place; with `ON UPDATE CASCADE` on consumer-side composite child FKs, child rows follow automatically. `findByEntityIdAt(id, at)` returns the version live at a point in time; implements `IHistoryRepository`. Inner adapter is expected to expose all rows (live + historical) and treat `save` as upsert keyed by **`id`** (per-row PK). DTOs register their four temporal columns via `OATPP_AUTHKIT_REGISTER_TEMPORAL(Dto, id, entity_id, valid_from, valid_until)`. | | `repo/ScopeGuardRepository.hpp` | Generic resource-scope decorator. Takes a `bool(ActorContext, TDto)` predicate at construction; gates every method on it. Throws `ScopeDeniedException` on deny (catchers translate to 403). Knows nothing about consumer-specific concepts like "property" or "tenant" — the predicate decides. | | `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`. | @@ -24,7 +24,7 @@ hardened auth / security stack. Header-only, oatpp 1.3+, C++17. | Decorator | `PREREQ` | `RESHAPE_STEPS` | |-----------|----------|------------------| -| `TemporalRepository` | (none) | `add_valid_from`, `add_valid_until`, `drop_unique_entity_id` (consumer-overridable noop), `composite_unique` — composite `UNIQUE(entity_id, valid_until)` so close-then-insert can run inside a deferred-FK transaction. | +| `TemporalRepository` | (none) | `add_valid_from`, `add_valid_until`, `drop_unique_entity_id` (consumer-overridable noop), `composite_unique` — composite `UNIQUE(entity_id, valid_until)`. With stable-live save semantics, no FK deferral is required; consumer-side child FKs use `ON UPDATE CASCADE` to follow `valid_until` flips on delete. | | `AuditLogRepository` | `CREATE TABLE IF NOT EXISTS audit_log (…)` — fixed shape, no `{table}` placeholder. | (none) | | `ScopeGuardRepository` | (none) | (none) | diff --git a/include/oatpp-authkit/repo/TemporalFieldTraits.hpp b/include/oatpp-authkit/repo/TemporalFieldTraits.hpp index 10bee52..cd938fd 100644 --- a/include/oatpp-authkit/repo/TemporalFieldTraits.hpp +++ b/include/oatpp-authkit/repo/TemporalFieldTraits.hpp @@ -7,7 +7,7 @@ namespace oatpp_authkit::repo { /** * @brief Trait that tells `TemporalRepository` where `T` keeps its - * identity, valid_from and valid_until columns. + * row PK, entity identity, valid_from and valid_until columns. * * Primary template is intentionally undefined — using * `TemporalFieldTraits` against a DTO that hasn't been registered @@ -15,9 +15,19 @@ namespace oatpp_authkit::repo { * `OATPP_AUTHKIT_REGISTER_TEMPORAL` once per temporal DTO. * * Each accessor returns `oatpp::String&` so the repository can both read - * and rewrite the value (closing a prior version sets `valid_until` on a - * loaded row). Field names on the DTO are arbitrary — the trait is the - * canonical name, the DTO column is whatever the consumer picked. + * and rewrite the value. Field names on the DTO are arbitrary — the + * trait is the canonical name, the DTO column is whatever the consumer + * picked. + * + * Four canonical fields: + * - `id` : per-row PK (version UUID). Preserved across in-place + * updates of the live row; freshly allocated for each + * historical copy. + * - `entityId` : stable logical identity, shared by every version of + * the same logical entity. + * - `validFrom` : ISO-8601 timestamp when this version became effective. + * - `validUntil` : ISO-8601 timestamp when this version ceased to be + * effective; SENTINEL while live. */ template struct TemporalFieldTraits; // intentionally undefined @@ -29,18 +39,22 @@ struct TemporalFieldTraits; // intentionally undefined * scope (typically right after the DTO definition): * * OATPP_AUTHKIT_REGISTER_TEMPORAL(PersonDto, - * entity_id, valid_from, valid_until) + * id, entity_id, valid_from, valid_until) * - * The three trailing identifiers are the actual DTO_FIELD member names — + * The four trailing identifiers are the actual DTO_FIELD member names — * they don't have to match the canonical names. A DTO that uses * `effective_from` / `effective_until` registers exactly the same way. + * + * `IdMember` is the per-row PK. `EntityIdMember` is the stable logical + * identity that's shared by every version of the same entity. */ -#define OATPP_AUTHKIT_REGISTER_TEMPORAL(Dto, IdMember, FromMember, UntilMember) \ +#define OATPP_AUTHKIT_REGISTER_TEMPORAL(Dto, IdMember, EntityIdMember, FromMember, UntilMember) \ namespace oatpp_authkit::repo { \ template<> struct TemporalFieldTraits { \ - static ::oatpp::String& entityId (const ::oatpp::Object& d) { return d->IdMember; } \ - static ::oatpp::String& validFrom (const ::oatpp::Object& d) { return d->FromMember; } \ - static ::oatpp::String& validUntil (const ::oatpp::Object& d) { return d->UntilMember; } \ + static ::oatpp::String& id (const ::oatpp::Object& d) { return d->IdMember; } \ + static ::oatpp::String& entityId (const ::oatpp::Object& d) { return d->EntityIdMember; } \ + static ::oatpp::String& validFrom (const ::oatpp::Object& d) { return d->FromMember; } \ + static ::oatpp::String& validUntil (const ::oatpp::Object& d) { return d->UntilMember; } \ }; \ } diff --git a/include/oatpp-authkit/repo/TemporalRepository.hpp b/include/oatpp-authkit/repo/TemporalRepository.hpp index 740f414..022e91a 100644 --- a/include/oatpp-authkit/repo/TemporalRepository.hpp +++ b/include/oatpp-authkit/repo/TemporalRepository.hpp @@ -36,9 +36,9 @@ namespace oatpp_authkit::repo { * * The wrapped inner `Repository` is expected to: * - * - Treat `save(dto)` as **upsert keyed by (entity_id, valid_from)**. New - * `valid_from` ⇒ insert a new row. Existing `valid_from` ⇒ update the row - * (this is how `save(closedPrior)` closes a prior version). + * - Treat `save(dto)` as **upsert keyed by `id`** (the per-row PK). If + * `dto->id` matches an existing row, UPDATE it in place. Otherwise + * INSERT a new row. * - Treat `list()` as **all rows including historical ones** — no filtering * by `valid_until`. This decorator does the live-vs-historical filtering * itself. @@ -48,16 +48,33 @@ namespace oatpp_authkit::repo { * * @section semantics Decorator semantics * - * - `save(dto)`: if `dto->entity_id` is null, allocate one. Look up the - * currently live row for that entity id; if present, copy it, set its - * `valid_until = now`, and `save` it (closes the old version). Then set - * the new dto's `valid_from = now`, `valid_until = SENTINEL`, and `save` it. + * Write semantics (authkit#13): **stable-live row + historical copy.** + * The live row's `id` PK is preserved across every update; only its + * mutable columns (and `valid_from`) change. Each prior version is + * captured as a fresh row with a new `id`. + * + * - `save(dto)`: + * - If no live row exists for `dto->entity_id` (or `entity_id` is null), + * this is a fresh insert: allocate `entity_id` if null, set `id` if + * null, `valid_from = now`, `valid_until = SENTINEL`, save. + * - Otherwise: + * 1. Clone the existing live row in memory (`b`). Give `b` a fresh `id` + * and set `b.valid_until = now()`. Save `b` — it's the historical copy. + * 2. Set `dto.id = liveRow.id` (preserve the live PK). Set + * `dto.valid_from = now()`, `dto.valid_until = SENTINEL`. Save `dto` + * — the inner UPDATEs the live row in place by PK. + * + * FK consequence: child rows referencing the live row via the composite + * key `(entity_id, valid_until)` continue to resolve to the same row + * identity throughout the operation; no FK deferral required. + * * - `findByEntityId(id)` returns the row whose `valid_until == SENTINEL`. * - `findByEntityIdAt(id, at)` returns the version live at that timestamp. * - `list()` returns only live rows. * - `history(id)` returns all versions ordered ascending by `valid_from`. - * - `softDelete(id)` closes the live row (sets its `valid_until = now`) but - * does not insert a new version. + * - `softDelete(id)` closes the live row (sets its `valid_until = now`) + * but does not insert a new version. With `ON UPDATE CASCADE` on every + * composite child FK, child rows follow automatically. */ template class TemporalRepository @@ -170,9 +187,9 @@ public: } /** - * Close the previous live version (if any) and insert a new live row. - * Mutates `dto` in place to fill in `entity_id` (if null), `valid_from`, - * and `valid_until`. + * Save a new dto. Stable-live + historical-copy semantics: the live + * row's `id` PK is preserved across updates; the prior version is + * captured as a fresh row with a new `id`. See class doc for details. */ void save(const oatpp::Object& dto) override { if (!F::entityId(dto)) F::entityId(dto) = m_idgen(); @@ -180,14 +197,24 @@ public: const int64_t nowMs = m_clock(); const std::string nowIso = isoFromMillis(nowMs); - // Close the existing live version (if any). auto live = findByEntityId(F::entityId(dto)); - if (live) { - F::validUntil(live) = oatpp::String(nowIso); - m_inner->save(live); + if (!live) { + // Fresh insert. + if (!F::id(dto)) F::id(dto) = m_idgen(); + F::validFrom(dto) = oatpp::String(nowIso); + F::validUntil(dto) = oatpp::String(SENTINEL); + m_inner->save(dto); + return; } - // Insert the new live version. + // Update path: insert a historical copy with a new PK, then + // update the live row in place by its existing PK. + auto historical = cloneDto(live); + F::id(historical) = m_idgen(); + F::validUntil(historical) = oatpp::String(nowIso); + m_inner->save(historical); + + F::id(dto) = F::id(live); // preserve live PK F::validFrom(dto) = oatpp::String(nowIso); F::validUntil(dto) = oatpp::String(SENTINEL); m_inner->save(dto); @@ -234,6 +261,21 @@ private: }; } + /// Field-wise deep copy via oatpp's DTO reflection. Used to capture + /// the live row's content as the historical copy before the live row + /// is updated in place. + static oatpp::Object cloneDto(const oatpp::Object& src) { + auto dst = TDto::createShared(); + const auto* dispatcher = static_cast< + const oatpp::data::mapping::type::__class::AbstractObject::PolymorphicDispatcher*>( + oatpp::Object::Class::getType()->polymorphicDispatcher); + for (auto* p : dispatcher->getProperties()->getList()) { + p->set(static_cast(dst.get()), + p->get(static_cast(src.get()))); + } + return dst; + } + static IdGen defaultIdGen() { return [] { static thread_local std::mt19937_64 rng{std::random_device{}()}; diff --git a/test/test_audit_log_repository.cpp b/test/test_audit_log_repository.cpp index e6e9f69..bc4c914 100644 --- a/test/test_audit_log_repository.cpp +++ b/test/test_audit_log_repository.cpp @@ -29,6 +29,7 @@ namespace { class AuditDto : public oatpp::DTO { DTO_INIT(AuditDto, DTO) + DTO_FIELD(String, id); // per-row PK DTO_FIELD(String, entity_id); DTO_FIELD(String, valid_from); DTO_FIELD(String, valid_until); @@ -39,7 +40,7 @@ class AuditDto : public oatpp::DTO { } // namespace -OATPP_AUTHKIT_REGISTER_TEMPORAL(AuditDto, entity_id, valid_from, valid_until) +OATPP_AUTHKIT_REGISTER_TEMPORAL(AuditDto, id, entity_id, valid_from, valid_until) namespace { diff --git a/test/test_decorator_migrations.cpp b/test/test_decorator_migrations.cpp index d744ac8..128184a 100644 --- a/test/test_decorator_migrations.cpp +++ b/test/test_decorator_migrations.cpp @@ -41,6 +41,7 @@ namespace { class TestDto : public oatpp::DTO { DTO_INIT(TestDto, DTO) + DTO_FIELD(String, id); // per-row PK DTO_FIELD(String, entity_id); DTO_FIELD(String, valid_from); DTO_FIELD(String, valid_until); @@ -51,7 +52,7 @@ class TestDto : public oatpp::DTO { #include OATPP_CODEGEN_END(DTO) -OATPP_AUTHKIT_REGISTER_TEMPORAL(TestDto, entity_id, valid_from, valid_until) +OATPP_AUTHKIT_REGISTER_TEMPORAL(TestDto, id, entity_id, valid_from, valid_until) namespace { diff --git a/test/test_repository_decorators.cpp b/test/test_repository_decorators.cpp index df219be..88d3645 100644 --- a/test/test_repository_decorators.cpp +++ b/test/test_repository_decorators.cpp @@ -31,6 +31,7 @@ namespace { class MockTemporalDto : public oatpp::DTO { DTO_INIT(MockTemporalDto, DTO) + DTO_FIELD(String, id); // per-row PK (version UUID) DTO_FIELD(String, entity_id); DTO_FIELD(String, valid_from); DTO_FIELD(String, valid_until); @@ -41,7 +42,7 @@ class MockTemporalDto : public oatpp::DTO { #include OATPP_CODEGEN_END(DTO) } // namespace -OATPP_AUTHKIT_REGISTER_TEMPORAL(MockTemporalDto, entity_id, valid_from, valid_until) +OATPP_AUTHKIT_REGISTER_TEMPORAL(MockTemporalDto, id, entity_id, valid_from, valid_until) namespace { int g_failures = 0; @@ -53,14 +54,15 @@ int g_failures = 0; } \ } while (0) -// In-memory adapter: rows keyed by (entity_id, valid_from). save() upserts. +// In-memory adapter: rows keyed by `id` PK (per-row UUID). save() upserts — +// matches the new TemporalRepository inner contract (authkit#13). // Exposes ALL rows via list() — the temporal decorator filters to live. class InMemoryAllRows : public oatpp_authkit::repo::Repository { - std::map, oatpp::Object> rows; + std::map> rows; public: oatpp::Object findByEntityId(const oatpp::String& id) override { // Not used by TemporalRepository — included for interface completeness. - for (auto& kv : rows) if (kv.first.first == std::string(*id)) return kv.second; + for (auto& kv : rows) if (kv.second->entity_id && std::string(*kv.second->entity_id) == std::string(*id)) return kv.second; return nullptr; } oatpp::Vector> list() override { @@ -69,11 +71,11 @@ public: return v; } void save(const oatpp::Object& dto) override { - rows[{*dto->entity_id, *dto->valid_from}] = dto; + rows[std::string(*dto->id)] = dto; } void softDelete(const oatpp::String& id) override { for (auto it = rows.begin(); it != rows.end(); ) { - if (it->first.first == std::string(*id)) it = rows.erase(it); else ++it; + if (it->second->entity_id && std::string(*it->second->entity_id) == std::string(*id)) it = rows.erase(it); else ++it; } } }; @@ -86,25 +88,39 @@ struct StepClock { int64_t operator()() { int64_t v = ms; ms += 1000; return v; } }; +// Sequencing idgen so each call returns a fresh string — needed now that +// the decorator allocates both entity_id and per-row PK. +struct SeqIdGen { + int n{0}; + oatpp::String operator()() { + char buf[16]; + std::snprintf(buf, sizeof(buf), "id%04d", n++); + return oatpp::String(buf); + } +}; + void test_save_closes_prior_version_and_inserts_new() { using namespace oatpp_authkit::repo; auto inner = std::make_shared(); auto clock = std::make_shared(); + auto ids = std::make_shared(); TemporalRepository repo(inner, [clock]{ return (*clock)(); }, - []{ return oatpp::String("alice"); }); + [ids]{ return (*ids)(); }); - // First save — entity_id auto-allocated, valid_from = now1, valid_until = SENTINEL. + // First save — entity_id + id auto-allocated, valid_from = now1, valid_until = SENTINEL. auto v1 = MockTemporalDto::createShared(); v1->name = oatpp::String("alice v1"); repo.save(v1); REQUIRE(v1->entity_id); + REQUIRE(v1->id); REQUIRE(std::string(*v1->valid_until) == TemporalRepository::SENTINEL); REQUIRE(inner->list()->size() == 1); + const std::string livePkAfterFirst = std::string(*v1->id); - // Second save — old version's valid_until is closed; new live row inserted. + // Second save — historical copy with new PK, live row updated in place. auto v2 = MockTemporalDto::createShared(); v2->entity_id = v1->entity_id; v2->name = oatpp::String("alice v2"); @@ -113,11 +129,20 @@ void test_save_closes_prior_version_and_inserts_new() { auto allAfter = inner->list(); REQUIRE(allAfter->size() == 2); int liveCount = 0; + std::string livePkAfterSecond, historicalPk; for (auto& row : *allAfter) { if (std::string(*row->valid_until) - == TemporalRepository::SENTINEL) ++liveCount; + == TemporalRepository::SENTINEL) { + ++liveCount; + livePkAfterSecond = std::string(*row->id); + } else { + historicalPk = std::string(*row->id); + } } - REQUIRE(liveCount == 1); // Only one row is live. + REQUIRE(liveCount == 1); // exactly one live + REQUIRE(livePkAfterSecond == livePkAfterFirst); // stable live PK + REQUIRE(historicalPk != livePkAfterFirst); // historical has fresh PK + REQUIRE(std::string(*v2->id) == livePkAfterFirst); // dto reflects preserved PK } void test_live_read_returns_only_sentinel_row() { @@ -229,6 +254,7 @@ void test_scope_guard_denies_when_predicate_false() { // Seed inner with two rows in different scopes. for (const char* sc : {"prop-A", "prop-B"}) { auto dto = MockTemporalDto::createShared(); + dto->id = oatpp::String(std::string("pk-") + sc); dto->entity_id = oatpp::String(sc); // reuse scope as id for simplicity dto->valid_from = oatpp::String("2020-01-01T00:00:00Z"); dto->valid_until = oatpp::String("9999-12-31T23:59:59Z"); diff --git a/test/test_temporal_field_traits.cpp b/test/test_temporal_field_traits.cpp index 0b0d125..112ddda 100644 --- a/test/test_temporal_field_traits.cpp +++ b/test/test_temporal_field_traits.cpp @@ -29,7 +29,8 @@ namespace { // TemporalRepository couldn't reach these fields. class OddNamesDto : public oatpp::DTO { DTO_INIT(OddNamesDto, DTO) - DTO_FIELD(String, id); + DTO_FIELD(String, row_pk); + DTO_FIELD(String, id); // entity_id (logical), per the original test intent DTO_FIELD(String, effective_from); DTO_FIELD(String, effective_until); DTO_FIELD(String, payload); @@ -39,7 +40,7 @@ class OddNamesDto : public oatpp::DTO { } // namespace -OATPP_AUTHKIT_REGISTER_TEMPORAL(OddNamesDto, id, effective_from, effective_until) +OATPP_AUTHKIT_REGISTER_TEMPORAL(OddNamesDto, row_pk, id, effective_from, effective_until) namespace { @@ -55,10 +56,10 @@ int g_failures = 0; // Same in-memory adapter shape as the decorator tests — keys rows by // (id, effective_from), exposes ALL rows via list(). class InMemoryAllRows : public oatpp_authkit::repo::Repository { - std::map, oatpp::Object> rows; + std::map> rows; // keyed by row_pk public: oatpp::Object findByEntityId(const oatpp::String& id) override { - for (auto& kv : rows) if (kv.first.first == std::string(*id)) return kv.second; + for (auto& kv : rows) if (kv.second->id && std::string(*kv.second->id) == std::string(*id)) return kv.second; return nullptr; } oatpp::Vector> list() override { @@ -67,11 +68,11 @@ public: return v; } void save(const oatpp::Object& dto) override { - rows[{*dto->id, *dto->effective_from}] = dto; + rows[std::string(*dto->row_pk)] = dto; } void softDelete(const oatpp::String& id) override { for (auto it = rows.begin(); it != rows.end(); ) { - if (it->first.first == std::string(*id)) it = rows.erase(it); else ++it; + if (it->second->id && std::string(*it->second->id) == std::string(*id)) it = rows.erase(it); else ++it; } } };