diff --git a/CMakeLists.txt b/CMakeLists.txt index 7530b4d..521443f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ 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 # a CMake config package so consumers do: diff --git a/include/oatpp-authkit/db/UserDb.hpp b/include/oatpp-authkit/db/UserDb.hpp new file mode 100644 index 0000000..45ca118 --- /dev/null +++ b/include/oatpp-authkit/db/UserDb.hpp @@ -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` 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& 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)) + + 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` 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 '' + * 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 diff --git a/include/oatpp-authkit/dto/UserDto.hpp b/include/oatpp-authkit/dto/UserDto.hpp new file mode 100644 index 0000000..68c0e47 --- /dev/null +++ b/include/oatpp-authkit/dto/UserDto.hpp @@ -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 diff --git a/include/oatpp-authkit/repo/ConcreteUserRepository.hpp b/include/oatpp-authkit/repo/ConcreteUserRepository.hpp new file mode 100644 index 0000000..0909514 --- /dev/null +++ b/include/oatpp-authkit/repo/ConcreteUserRepository.hpp @@ -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` (authkit#14 PR 4). +// Stacks under TemporalRepository 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 + +namespace oatpp_authkit::repo { + +/** + * @brief Inner adapter of `Repository`, delegating to `db::UserDb`. + * + * Empty schema — `db::UserSchema` owns the table declaration. + */ +class ConcreteUserRepository : public Repository { +public: + inline static constexpr DecoratorSchema kSchema = { + "ConcreteUserRepository", + nullptr, 0, nullptr, 0, nullptr, 0, + }; + + explicit ConcreteUserRepository(std::shared_ptr udb) + : m_db(std::move(udb)) {} + + oatpp::Object findByEntityId(const oatpp::String& entityId) override { + auto res = m_db->findUserByEntityId(entityId); + if (!res || !res->isSuccess()) return nullptr; + auto rows = res->template fetch>>(); + if (!rows || rows->empty()) return nullptr; + return (*rows)[0]; + } + + oatpp::Vector> list() override { + auto res = m_db->getAllUsersRaw(); + auto out = oatpp::Vector>::createShared(); + if (!res || !res->isSuccess()) return out; + auto fetched = res->template fetch>>(); + if (!fetched) return out; + for (auto& row : *fetched) if (row) out->push_back(row); + return out; + } + + void save(const oatpp::Object& d) override { + m_db->upsertUserById(d); + } + + void softDelete(const oatpp::String& entityId) override { + m_db->softDeleteUser(entityId); + } + +private: + std::shared_ptr m_db; +}; + +inline std::shared_ptr> +makeUserRepository(std::shared_ptr udb) { + auto concrete = std::make_shared(std::move(udb)); + return std::make_shared>(concrete); +} + +} // namespace oatpp_authkit::repo + +#endif diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 87b68c5..628b55f 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -62,4 +62,9 @@ if(oatpp-sqlite_FOUND AND Threads_FOUND) target_link_libraries(test_user_permission_schema PRIVATE oatpp::authkit oatpp::oatpp oatpp::oatpp-sqlite Threads::Threads) 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() diff --git a/test/test_user_schema.cpp b/test/test_user_schema.cpp new file mode 100644 index 0000000..f15b3f1 --- /dev/null +++ b/test/test_user_schema.cpp @@ -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 +#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) + +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 sqls; + SqlExec exec = [&](const std::string& sql) { sqls.push_back(sql); }; + + SchemaBuilder< + UserSchema, + TemporalRepository>::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 with_repo; + std::vector without_repo; + + SchemaBuilder< + UserSchema, ConcreteUserRepository, TemporalRepository>::create( + "users", [&](const std::string& s){ with_repo.push_back(s); }); + + SchemaBuilder< + UserSchema, TemporalRepository>::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; +}