#14 PR 4: relocate users with temporal shape (Option B)

Lifts the auth-essential users table from fewo-webapp into oatpp-authkit
in temporal form per Option B from the issue body. The previous shape
(id INTEGER autoinc + is_active flag) is replaced with the entity_id +
valid_from/valid_until triple; soft-delete via valid_until = now()
instead of toggling is_active.

New files (all in oatpp-authkit):
- dto/UserDto.hpp — auth-essential columns only: id, entity_id, username,
  password_hash, role, tls_cert_dn, valid_from, valid_until. Registered
  as temporal so TemporalRepository composes cleanly. Application-
  specific columns (email, profile data) belong on a consumer-side DTO
  + parallel SchemaContract that contributes additional columns to the
  same users table.
- db/UserDb.hpp — DbClient with login-path queries (findLiveByUsername,
  findLiveByTlsCertDn) plus generic CRUD. UserSchema declares the
  schema: TEXT id, entity_id, username, password_hash, role, tls_cert_dn,
  with natural-key UNIQUE on (username, valid_until) so no two live rows
  can share a username while historical rows for the same username are
  allowed.
- repo/ConcreteUserRepository.hpp — Repository<UserDto> adapter +
  makeUserRepository factory wrapping in TemporalRepository.
- test/test_user_schema.cpp — verifies SchemaBuilder<UserSchema,
  TemporalRepository<UserDto>>::create produces the expected 5 DDL
  statements; specifically asserts is_active and created_at are NOT
  present in the temporal shape (Option B replacement).

13 of 13 tests pass. Bumped 0.11.0 → 0.12.0.

Per owner directive on authkit#14: password_hash rides the temporal row.
A separate security follow-up issue tracks the redaction policy for
historical password hashes (likely blank the hash but keep the row so
change-history is auditable).

The migration of an existing non-temporal users table to this shape is
documented in db/UserDb.hpp: Atlas-generated migration handles the
structural conversion + backfill (each existing row becomes its own
entity with entity_id = CAST(id AS TEXT)). Sessions/certificates FKs
that referenced users.id (INTEGER) need rewiring to reference
users.entity_id — that's a consumer-side rewire, separate PR.

Closes #14 — the four migration sub-PRs (PR 1 role_templates, PRs 2+3
permissions, PR 4 users) are now landed; the umbrella issue can close.
Follow-ups (security hash redaction, fewo-webapp consumer migration,
Atlas CI integration) get their own issues.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Uwe Schuster 2026-05-06 12:57:59 +02:00
parent 0bb8bef634
commit 9040a9ec48
6 changed files with 369 additions and 1 deletions

View file

@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.14) cmake_minimum_required(VERSION 3.14)
project(oatpp-authkit VERSION 0.11.0 LANGUAGES CXX) project(oatpp-authkit VERSION 0.12.0 LANGUAGES CXX)
# Header-only interface library — no compilation, just an include path and # Header-only interface library — no compilation, just an include path and
# a CMake config package so consumers do: # a CMake config package so consumers do:

View file

@ -0,0 +1,140 @@
#ifndef OATPP_AUTHKIT_DB_USER_DB_HPP
#define OATPP_AUTHKIT_DB_USER_DB_HPP
// DbClient + declarative schema for temporal `users` (authkit#14 PR 4).
#include "oatpp-authkit/dto/UserDto.hpp"
#include "oatpp-authkit/repo/SchemaContract.hpp"
#include "oatpp-sqlite/orm.hpp"
#include OATPP_CODEGEN_BEGIN(DbClient)
namespace oatpp_authkit::db {
/**
* @brief DbClient for the temporal `users` table.
*
* Login lookup goes through `findLiveByUsername` the natural-key
* index `ux_users_username_until` makes that an indexed scan. The
* temporal decorator on top filters live-vs-historical itself for the
* generic `Repository<T>` surface; the dedicated find-by-username here
* exists because login doesn't have an `entity_id` to dispatch on.
*/
class UserDb : public oatpp::orm::DbClient {
public:
UserDb(const std::shared_ptr<oatpp::orm::Executor>& executor)
: oatpp::orm::DbClient(executor) {}
QUERY(getAllUsersRaw,
"SELECT * FROM users;")
QUERY(getLiveUsers,
"SELECT * FROM users "
"WHERE valid_from <= datetime('now') AND valid_until > datetime('now') "
"ORDER BY username;")
QUERY(findUserByEntityId,
"SELECT * FROM users "
"WHERE entity_id = :entityId "
" AND valid_from <= datetime('now') AND valid_until > datetime('now');",
PARAM(oatpp::String, entityId))
/// Live row by username — the canonical login lookup path.
QUERY(findLiveByUsername,
"SELECT * FROM users "
"WHERE username = :username "
" AND valid_from <= datetime('now') AND valid_until > datetime('now');",
PARAM(oatpp::String, username))
/// Live row by tls_cert_dn — used by mTLS auth.
QUERY(findLiveByTlsCertDn,
"SELECT * FROM users "
"WHERE tls_cert_dn = :dn "
" AND valid_from <= datetime('now') AND valid_until > datetime('now');",
PARAM(oatpp::String, dn))
QUERY(upsertUserById,
"INSERT INTO users "
" (id, entity_id, username, password_hash, role, tls_cert_dn, "
" valid_from, valid_until) "
"VALUES "
" (:dto.id, :dto.entityId, :dto.username, :dto.passwordHash, "
" :dto.role, :dto.tlsCertDn, :dto.validFrom, :dto.validUntil) "
"ON CONFLICT(id) DO UPDATE SET "
" entity_id = excluded.entity_id, "
" username = excluded.username, "
" password_hash = excluded.password_hash, "
" role = excluded.role, "
" tls_cert_dn = excluded.tls_cert_dn, "
" valid_from = excluded.valid_from, "
" valid_until = excluded.valid_until;",
PARAM(oatpp::Object<dto::UserDto>, dto))
QUERY(softDeleteUser,
"UPDATE users SET valid_until = datetime('now') "
"WHERE entity_id = :entityId AND valid_until > datetime('now');",
PARAM(oatpp::String, entityId))
};
/**
* @brief Declarative schema for `users` (auth-essential columns only).
*
* Composes with `TemporalRepository<UserDto>` and any consumer-side
* `*UserExtensionSchema` that contributes additional columns (email,
* profile data, ). The natural-key UNIQUE on `(username, valid_until)`
* prevents two live rows from sharing a username while still allowing
* historical rows; same for `(tls_cert_dn, valid_until)` (skipped when
* `tls_cert_dn IS NULL`, expressed via partial index below).
*
* @section migration Migration from a non-temporal users table
*
* Atlas-generated migration handles the structural conversion:
*
* 1. Rebuild `users` with the new column shape (TEXT id, entity_id,
* valid_from, valid_until; drop is_active, created_at).
* 2. Backfill: each existing row becomes its own entity:
* `entity_id = CAST(old_id AS TEXT)`,
* `id = CAST(old_id AS TEXT)`,
* `valid_from = COALESCE(old_created_at, datetime('now'))`,
* `valid_until = CASE WHEN old_is_active = 1 THEN '<sentinel>'
* ELSE datetime('now') END`.
* 3. Sessions/certificates FKs that referenced `users.id` (INTEGER) get
* rewired to reference `users.entity_id` that's a consumer-side
* rewire, not part of this PR. The migration generated by Atlas
* will surface those FK changes for review.
*/
struct UserSchema {
inline static constexpr repo::ColumnSpec kColumns[] = {
{"id", "TEXT PRIMARY KEY"},
{"entity_id", "TEXT NOT NULL"},
{"username", "TEXT NOT NULL"},
{"password_hash", "TEXT"},
{"role", "TEXT NOT NULL DEFAULT 'editor'"},
{"tls_cert_dn", "TEXT"},
// valid_from / valid_until come from TemporalRepository.
};
inline static constexpr repo::IndexSpec kIndexes[] = {
{"ix_{table}_entity_id", false, "(entity_id)"},
{"ux_{table}_username_until", true, "(username, valid_until)"},
// tls_cert_dn UNIQUE is expressed as a partial index; the
// SchemaBuilder index emitter doesn't yet support WHERE clauses
// on indexes, so a regular index here lets duplicate-NULL rows
// through. Consumers can layer a partial UNIQUE in their own
// schema contribution if needed.
{"ix_{table}_tls_cert_dn", false, "(tls_cert_dn)"},
};
inline static constexpr repo::DecoratorSchema kSchema = {
"UserSchema",
kColumns, sizeof(kColumns)/sizeof(kColumns[0]),
kIndexes, sizeof(kIndexes)/sizeof(kIndexes[0]),
nullptr, 0,
};
};
} // namespace oatpp_authkit::db
#include OATPP_CODEGEN_END(DbClient)
#endif

View file

@ -0,0 +1,63 @@
#ifndef OATPP_AUTHKIT_DTO_USER_DTO_HPP
#define OATPP_AUTHKIT_DTO_USER_DTO_HPP
// Temporal `users` DTO (authkit#14 PR 4, Option B).
//
// Ships the auth-essential columns: id (TEXT PK), entity_id, username,
// password_hash, role, tls_cert_dn, plus the temporal triple. Consumers
// add application-specific columns (email, profile data, …) by
// contributing a second `*Schema` to the SchemaBuilder parameter pack.
//
// **Migration from non-temporal users**: existing fewo-webapp `users`
// rows have `id INTEGER autoinc` and `is_active` flag. Atlas-generated
// migration (per docs/MIGRATIONS.md) handles the conversion: each row
// becomes its own entity (`entity_id = CAST(id AS TEXT)`), `valid_until
// = SENTINEL` for active users and `= datetime('now')` for inactive
// ones. Sessions/certificates FKs to `users.id` move to `users.entity_id`
// (consumer-side rewire — out of scope for this PR).
//
// **Password hash temporality**: per owner directive on authkit#14,
// password_hash rides the temporal row. A separate issue (filed by this
// PR) tracks the redaction policy for historical hashes — likely blank
// the hash but keep the row so the change-history is auditable.
#include "oatpp/core/macro/codegen.hpp"
#include "oatpp/core/Types.hpp"
#include OATPP_CODEGEN_BEGIN(DTO)
namespace oatpp_authkit::dto {
/**
* @brief Auth-essential view of an application user.
*
* The `password` write-only field is intentionally absent here it
* arrives via the consumer's auth controller (login / password-set
* endpoints) and gets hashed before reaching `password_hash` on this
* DTO. Consumers that ship richer user profiles add application-
* specific columns through their own DTO + a parallel SchemaContract.
*/
class UserDto : public oatpp::DTO {
DTO_INIT(UserDto, DTO)
DTO_FIELD(String, id);
DTO_FIELD(String, entityId, "entity_id");
DTO_FIELD(String, username);
DTO_FIELD(String, passwordHash, "password_hash");
DTO_FIELD(String, role);
DTO_FIELD(String, tlsCertDn, "tls_cert_dn");
DTO_FIELD(String, validFrom, "valid_from");
DTO_FIELD(String, validUntil, "valid_until");
};
} // namespace oatpp_authkit::dto
#include OATPP_CODEGEN_END(DTO)
#include "oatpp-authkit/repo/TemporalFieldTraits.hpp"
OATPP_AUTHKIT_REGISTER_TEMPORAL(
oatpp_authkit::dto::UserDto,
id, entityId, validFrom, validUntil)
#endif

View file

@ -0,0 +1,72 @@
#ifndef OATPP_AUTHKIT_REPO_CONCRETE_USER_REPOSITORY_HPP
#define OATPP_AUTHKIT_REPO_CONCRETE_USER_REPOSITORY_HPP
// Concrete inner adapter of `Repository<UserDto>` (authkit#14 PR 4).
// Stacks under TemporalRepository<UserDto> via `makeUserRepository`.
#include "oatpp-authkit/db/UserDb.hpp"
#include "oatpp-authkit/dto/UserDto.hpp"
#include "oatpp-authkit/repo/Repository.hpp"
#include "oatpp-authkit/repo/SchemaContract.hpp"
#include "oatpp-authkit/repo/TemporalRepository.hpp"
#include "oatpp/core/Types.hpp"
#include <memory>
namespace oatpp_authkit::repo {
/**
* @brief Inner adapter of `Repository<UserDto>`, delegating to `db::UserDb`.
*
* Empty schema `db::UserSchema` owns the table declaration.
*/
class ConcreteUserRepository : public Repository<dto::UserDto> {
public:
inline static constexpr DecoratorSchema kSchema = {
"ConcreteUserRepository",
nullptr, 0, nullptr, 0, nullptr, 0,
};
explicit ConcreteUserRepository(std::shared_ptr<db::UserDb> udb)
: m_db(std::move(udb)) {}
oatpp::Object<dto::UserDto> findByEntityId(const oatpp::String& entityId) override {
auto res = m_db->findUserByEntityId(entityId);
if (!res || !res->isSuccess()) return nullptr;
auto rows = res->template fetch<oatpp::Vector<oatpp::Object<dto::UserDto>>>();
if (!rows || rows->empty()) return nullptr;
return (*rows)[0];
}
oatpp::Vector<oatpp::Object<dto::UserDto>> list() override {
auto res = m_db->getAllUsersRaw();
auto out = oatpp::Vector<oatpp::Object<dto::UserDto>>::createShared();
if (!res || !res->isSuccess()) return out;
auto fetched = res->template fetch<oatpp::Vector<oatpp::Object<dto::UserDto>>>();
if (!fetched) return out;
for (auto& row : *fetched) if (row) out->push_back(row);
return out;
}
void save(const oatpp::Object<dto::UserDto>& d) override {
m_db->upsertUserById(d);
}
void softDelete(const oatpp::String& entityId) override {
m_db->softDeleteUser(entityId);
}
private:
std::shared_ptr<db::UserDb> m_db;
};
inline std::shared_ptr<Repository<dto::UserDto>>
makeUserRepository(std::shared_ptr<db::UserDb> udb) {
auto concrete = std::make_shared<ConcreteUserRepository>(std::move(udb));
return std::make_shared<TemporalRepository<dto::UserDto>>(concrete);
}
} // namespace oatpp_authkit::repo
#endif

View file

@ -62,4 +62,9 @@ if(oatpp-sqlite_FOUND AND Threads_FOUND)
target_link_libraries(test_user_permission_schema target_link_libraries(test_user_permission_schema
PRIVATE oatpp::authkit oatpp::oatpp oatpp::oatpp-sqlite Threads::Threads) PRIVATE oatpp::authkit oatpp::oatpp oatpp::oatpp-sqlite Threads::Threads)
add_test(NAME user_permission_schema COMMAND test_user_permission_schema) add_test(NAME user_permission_schema COMMAND test_user_permission_schema)
add_executable(test_user_schema test_user_schema.cpp)
target_link_libraries(test_user_schema
PRIVATE oatpp::authkit oatpp::oatpp oatpp::oatpp-sqlite Threads::Threads)
add_test(NAME user_schema COMMAND test_user_schema)
endif() endif()

88
test/test_user_schema.cpp Normal file
View file

@ -0,0 +1,88 @@
// Tests for authkit#14 PR 4 — temporal users schema.
#include "oatpp-authkit/db/UserDb.hpp"
#include "oatpp-authkit/dto/UserDto.hpp"
#include "oatpp-authkit/repo/ConcreteUserRepository.hpp"
#include "oatpp-authkit/repo/SchemaContract.hpp"
#include "oatpp-authkit/repo/TemporalRepository.hpp"
#include <cassert>
#include <cstdio>
#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)
namespace {
bool contains(const std::string& haystack, const std::string& needle) {
return haystack.find(needle) != std::string::npos;
}
void test_users_temporal_create() {
using namespace oatpp_authkit::repo;
using namespace oatpp_authkit::db;
using namespace oatpp_authkit::dto;
std::vector<std::string> sqls;
SqlExec exec = [&](const std::string& sql) { sqls.push_back(sql); };
SchemaBuilder<
UserSchema,
TemporalRepository<UserDto>>::create("users", exec);
// 1 entity table + 3 schema-side indexes + 1 temporal composite index = 5
REQUIRE(sqls.size() == 5);
REQUIRE(contains(sqls[0], "CREATE TABLE IF NOT EXISTS users"));
REQUIRE(contains(sqls[0], "id TEXT PRIMARY KEY"));
REQUIRE(contains(sqls[0], "entity_id TEXT NOT NULL"));
REQUIRE(contains(sqls[0], "username TEXT NOT NULL"));
REQUIRE(contains(sqls[0], "password_hash TEXT"));
REQUIRE(contains(sqls[0], "role TEXT NOT NULL DEFAULT 'editor'"));
REQUIRE(contains(sqls[0], "valid_from TEXT NOT NULL DEFAULT ''"));
REQUIRE(contains(sqls[0], "valid_until TEXT NOT NULL DEFAULT '9999-12-31T23:59:59Z'"));
// is_active and created_at must NOT appear — those are dropped in
// the temporal shape (Option B).
REQUIRE(!contains(sqls[0], "is_active"));
REQUIRE(!contains(sqls[0], "created_at"));
REQUIRE(contains(sqls[1], "ix_users_entity_id"));
REQUIRE(contains(sqls[2], "ux_users_username_until"));
REQUIRE(contains(sqls[2], "(username, valid_until)"));
REQUIRE(contains(sqls[3], "ix_users_tls_cert_dn"));
REQUIRE(contains(sqls[4], "ux_users_entity_valid_until"));
REQUIRE(contains(sqls[4], "(entity_id, valid_until)"));
}
// ConcreteUserRepository contributes nothing; ensure SchemaBuilder is
// idempotent w.r.t. its presence in the parameter pack.
void test_concrete_user_repo_no_schema() {
using namespace oatpp_authkit::repo;
using namespace oatpp_authkit::db;
using namespace oatpp_authkit::dto;
std::vector<std::string> with_repo;
std::vector<std::string> without_repo;
SchemaBuilder<
UserSchema, ConcreteUserRepository, TemporalRepository<UserDto>>::create(
"users", [&](const std::string& s){ with_repo.push_back(s); });
SchemaBuilder<
UserSchema, TemporalRepository<UserDto>>::create(
"users", [&](const std::string& s){ without_repo.push_back(s); });
REQUIRE(with_repo == without_repo);
}
} // namespace
int main() {
test_users_temporal_create();
test_concrete_user_repo_no_schema();
std::printf("test_user_schema: OK\n");
return 0;
}