diff --git a/CMakeLists.txt b/CMakeLists.txt index 9d40454..7530b4d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.14) -project(oatpp-authkit VERSION 0.10.0 LANGUAGES CXX) +project(oatpp-authkit VERSION 0.11.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/UserPermissionDb.hpp b/include/oatpp-authkit/db/UserPermissionDb.hpp new file mode 100644 index 0000000..bcbd9d2 --- /dev/null +++ b/include/oatpp-authkit/db/UserPermissionDb.hpp @@ -0,0 +1,178 @@ +#ifndef OATPP_AUTHKIT_DB_USER_PERMISSION_DB_HPP +#define OATPP_AUTHKIT_DB_USER_PERMISSION_DB_HPP + +// DbClient + declarative schema for user_property_permissions and +// user_group_permissions (authkit#14 PRs 2 & 3). +// +// Cross-table effective-permission queries that join consumer-side +// tables (e.g. fewo's property_set_members) stay in the consumer — only +// the standalone DbClient queries that operate on these two tables move +// here. + +#include "oatpp-authkit/dto/UserPermissionDto.hpp" +#include "oatpp-authkit/repo/SchemaContract.hpp" + +#include "oatpp-sqlite/orm.hpp" + +#include OATPP_CODEGEN_BEGIN(DbClient) + +namespace oatpp_authkit::db { + +/** + * @brief DbClient for user_property_permissions and user_group_permissions. + */ +class UserPermissionDb : public oatpp::orm::DbClient { +public: + UserPermissionDb(const std::shared_ptr& executor) + : oatpp::orm::DbClient(executor) {} + + // ---- user_property_permissions ---- + + QUERY(getAllPropertyPermissions, + "SELECT * FROM user_property_permissions " + "WHERE valid_from <= datetime('now') AND valid_until > datetime('now');") + + QUERY(getAllPropertyPermissionsRaw, + "SELECT * FROM user_property_permissions;") + + QUERY(getPropertyPermissionsForUser, + "SELECT * FROM user_property_permissions " + "WHERE user_id = :userId " + " AND valid_from <= datetime('now') AND valid_until > datetime('now');", + PARAM(oatpp::String, userId)) + + QUERY(getPropertyPermissionByEntityId, + "SELECT * FROM user_property_permissions " + "WHERE entity_id = :entityId " + " AND valid_from <= datetime('now') AND valid_until > datetime('now');", + PARAM(oatpp::String, entityId)) + + QUERY(upsertPropertyPermissionById, + "INSERT INTO user_property_permissions " + " (id, entity_id, user_id, property_id, permission, valid_from, valid_until) " + "VALUES " + " (:p.id, :p.entityId, :p.userId, :p.propertyId, :p.permission, " + " :p.validFrom, :p.validUntil) " + "ON CONFLICT(id) DO UPDATE SET " + " entity_id = excluded.entity_id, " + " user_id = excluded.user_id, " + " property_id = excluded.property_id, " + " permission = excluded.permission, " + " valid_from = excluded.valid_from, " + " valid_until = excluded.valid_until;", + PARAM(oatpp::Object, p)) + + QUERY(softDeletePropertyPermission, + "UPDATE user_property_permissions SET valid_until = datetime('now') " + "WHERE entity_id = :entityId AND valid_until > datetime('now');", + PARAM(oatpp::String, entityId)) + + // ---- user_group_permissions ---- + + QUERY(getAllGroupPermissions, + "SELECT * FROM user_group_permissions " + "WHERE valid_from <= datetime('now') AND valid_until > datetime('now');") + + QUERY(getAllGroupPermissionsRaw, + "SELECT * FROM user_group_permissions;") + + QUERY(getGroupPermissionsForUser, + "SELECT * FROM user_group_permissions " + "WHERE user_id = :userId " + " AND valid_from <= datetime('now') AND valid_until > datetime('now');", + PARAM(oatpp::String, userId)) + + QUERY(getGroupPermissionByEntityId, + "SELECT * FROM user_group_permissions " + "WHERE entity_id = :entityId " + " AND valid_from <= datetime('now') AND valid_until > datetime('now');", + PARAM(oatpp::String, entityId)) + + QUERY(upsertGroupPermissionById, + "INSERT INTO user_group_permissions " + " (id, entity_id, user_id, set_id, permission, valid_from, valid_until) " + "VALUES " + " (:p.id, :p.entityId, :p.userId, :p.setId, :p.permission, " + " :p.validFrom, :p.validUntil) " + "ON CONFLICT(id) DO UPDATE SET " + " entity_id = excluded.entity_id, " + " user_id = excluded.user_id, " + " set_id = excluded.set_id, " + " permission = excluded.permission, " + " valid_from = excluded.valid_from, " + " valid_until = excluded.valid_until;", + PARAM(oatpp::Object, p)) + + QUERY(softDeleteGroupPermission, + "UPDATE user_group_permissions SET valid_until = datetime('now') " + "WHERE entity_id = :entityId AND valid_until > datetime('now');", + PARAM(oatpp::String, entityId)) +}; + +/** + * @brief Declarative schema for `user_property_permissions`. + * + * Composes with `TemporalRepository` to produce + * the full table including the temporal `valid_until` + composite UNIQUE + * index. The natural-key UNIQUE `(user_id, property_id, valid_until)` is + * carried as an explicit index here so duplicate live grants for the + * same (user, property) pair are prevented at the DB level. + */ +struct UserPropertyPermissionSchema { + inline static constexpr repo::ColumnSpec kColumns[] = { + {"id", "TEXT PRIMARY KEY"}, + {"entity_id", "TEXT NOT NULL"}, + {"user_id", "TEXT NOT NULL"}, + {"property_id", "TEXT NOT NULL"}, + {"permission", "TEXT NOT NULL DEFAULT 'readonly'"}, + // valid_from / valid_until come from TemporalRepository. + }; + inline static constexpr repo::IndexSpec kIndexes[] = { + {"ix_{table}_entity_id", false, "(entity_id)"}, + {"ix_{table}_user_id", false, "(user_id)"}, + {"ux_{table}_user_property_until", true, + "(user_id, property_id, valid_until)"}, + }; + + inline static constexpr repo::DecoratorSchema kSchema = { + "UserPropertyPermissionSchema", + kColumns, sizeof(kColumns)/sizeof(kColumns[0]), + kIndexes, sizeof(kIndexes)/sizeof(kIndexes[0]), + nullptr, 0, + }; +}; + +/** + * @brief Declarative schema for `user_group_permissions`. + * + * Mirrors `UserPropertyPermissionSchema` with `set_id` instead of + * `property_id`. The natural-key UNIQUE prevents duplicate live grants + * for the same (user, set) pair. + */ +struct UserGroupPermissionSchema { + inline static constexpr repo::ColumnSpec kColumns[] = { + {"id", "TEXT PRIMARY KEY"}, + {"entity_id", "TEXT NOT NULL"}, + {"user_id", "TEXT NOT NULL"}, + {"set_id", "TEXT NOT NULL"}, + {"permission", "TEXT NOT NULL DEFAULT 'readonly'"}, + }; + inline static constexpr repo::IndexSpec kIndexes[] = { + {"ix_{table}_entity_id", false, "(entity_id)"}, + {"ix_{table}_user_id", false, "(user_id)"}, + {"ux_{table}_user_set_until", true, "(user_id, set_id, valid_until)"}, + }; + + inline static constexpr repo::DecoratorSchema kSchema = { + "UserGroupPermissionSchema", + 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/UserPermissionDto.hpp b/include/oatpp-authkit/dto/UserPermissionDto.hpp new file mode 100644 index 0000000..332f28e --- /dev/null +++ b/include/oatpp-authkit/dto/UserPermissionDto.hpp @@ -0,0 +1,71 @@ +#ifndef OATPP_AUTHKIT_DTO_USER_PERMISSION_DTO_HPP +#define OATPP_AUTHKIT_DTO_USER_PERMISSION_DTO_HPP + +// User property + group permission DTOs (authkit#14 PRs 2 & 3). +// Lifted from fewo-webapp's `src/dto/UserPropertyPermissionDto.hpp`. +// +// Per-property and per-property-set RBAC primitives. The effective- +// permission resolver lives in the consumer (fewo-webapp) because it +// joins `property_set_members`, which is a consumer-side concept; the +// raw tables move here so any oatpp-authkit consumer can reuse them +// without copying schema. + +#include "oatpp/core/macro/codegen.hpp" +#include "oatpp/core/Types.hpp" + +#include OATPP_CODEGEN_BEGIN(DTO) + +namespace oatpp_authkit::dto { + +/** + * @brief Per-property access grant. + * + * Maps a user to a property with one of `'readonly'` / `'editor'`. Live + * rows are temporal — soft-delete sets `valid_until` to `now()`. + */ +class UserPropertyPermissionDto : public oatpp::DTO { + DTO_INIT(UserPropertyPermissionDto, DTO) + + DTO_FIELD(String, id); + DTO_FIELD(String, entityId, "entity_id"); + DTO_FIELD(String, userId, "user_id"); + DTO_FIELD(String, propertyId, "property_id"); + DTO_FIELD(String, permission); + DTO_FIELD(String, validFrom, "valid_from"); + DTO_FIELD(String, validUntil, "valid_until"); +}; + +/** + * @brief Group-level access grant: user → property_set. + * + * `set_id` references a consumer-defined property-set table. The + * effective-permission resolver in the consumer expands group grants to + * member properties via its own join. + */ +class UserGroupPermissionDto : public oatpp::DTO { + DTO_INIT(UserGroupPermissionDto, DTO) + + DTO_FIELD(String, id); + DTO_FIELD(String, entityId, "entity_id"); + DTO_FIELD(String, userId, "user_id"); + DTO_FIELD(String, setId, "set_id"); + DTO_FIELD(String, permission); + 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::UserPropertyPermissionDto, + id, entityId, validFrom, validUntil) + +OATPP_AUTHKIT_REGISTER_TEMPORAL( + oatpp_authkit::dto::UserGroupPermissionDto, + id, entityId, validFrom, validUntil) + +#endif diff --git a/include/oatpp-authkit/repo/ConcreteUserPermissionRepository.hpp b/include/oatpp-authkit/repo/ConcreteUserPermissionRepository.hpp new file mode 100644 index 0000000..eef7c45 --- /dev/null +++ b/include/oatpp-authkit/repo/ConcreteUserPermissionRepository.hpp @@ -0,0 +1,141 @@ +#ifndef OATPP_AUTHKIT_REPO_CONCRETE_USER_PERMISSION_REPOSITORY_HPP +#define OATPP_AUTHKIT_REPO_CONCRETE_USER_PERMISSION_REPOSITORY_HPP + +// Concrete inner adapters of Repository and +// Repository (authkit#14 PRs 2 & 3). Stack each +// under TemporalRepository for versioning + soft-delete via valid_until. + +#include "oatpp-authkit/db/UserPermissionDb.hpp" +#include "oatpp-authkit/dto/UserPermissionDto.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 for `Repository`, + * delegating to `db::UserPermissionDb`. + * + * Schema lives in `db::UserPropertyPermissionSchema` — this repo + * contributes nothing to the schema, only adapts queries. + */ +class ConcreteUserPropertyPermissionRepository + : public Repository +{ +public: + inline static constexpr DecoratorSchema kSchema = { + "ConcreteUserPropertyPermissionRepository", + nullptr, 0, nullptr, 0, nullptr, 0, + }; + + explicit ConcreteUserPropertyPermissionRepository( + std::shared_ptr updb) + : m_db(std::move(updb)) {} + + oatpp::Object + findByEntityId(const oatpp::String& entityId) override + { + auto res = m_db->getPropertyPermissionByEntityId(entityId); + if (!res || !res->isSuccess()) return nullptr; + auto rows = res->template fetch< + oatpp::Vector>>(); + if (!rows || rows->empty()) return nullptr; + return (*rows)[0]; + } + + oatpp::Vector> list() override { + auto res = m_db->getAllPropertyPermissionsRaw(); + auto out = oatpp::Vector>::createShared(); + if (!res || !res->isSuccess()) return out; + auto fetched = res->template fetch< + oatpp::Vector>>(); + 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->upsertPropertyPermissionById(d); + } + + void softDelete(const oatpp::String& entityId) override { + m_db->softDeletePropertyPermission(entityId); + } + +private: + std::shared_ptr m_db; +}; + +/** + * @brief Inner adapter for `Repository`, + * delegating to `db::UserPermissionDb`. + */ +class ConcreteUserGroupPermissionRepository + : public Repository +{ +public: + inline static constexpr DecoratorSchema kSchema = { + "ConcreteUserGroupPermissionRepository", + nullptr, 0, nullptr, 0, nullptr, 0, + }; + + explicit ConcreteUserGroupPermissionRepository( + std::shared_ptr updb) + : m_db(std::move(updb)) {} + + oatpp::Object + findByEntityId(const oatpp::String& entityId) override + { + auto res = m_db->getGroupPermissionByEntityId(entityId); + if (!res || !res->isSuccess()) return nullptr; + auto rows = res->template fetch< + oatpp::Vector>>(); + if (!rows || rows->empty()) return nullptr; + return (*rows)[0]; + } + + oatpp::Vector> list() override { + auto res = m_db->getAllGroupPermissionsRaw(); + auto out = oatpp::Vector>::createShared(); + if (!res || !res->isSuccess()) return out; + auto fetched = res->template fetch< + oatpp::Vector>>(); + 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->upsertGroupPermissionById(d); + } + + void softDelete(const oatpp::String& entityId) override { + m_db->softDeleteGroupPermission(entityId); + } + +private: + std::shared_ptr m_db; +}; + +inline std::shared_ptr> +makeUserPropertyPermissionRepository(std::shared_ptr updb) +{ + auto concrete = std::make_shared(std::move(updb)); + return std::make_shared>(concrete); +} + +inline std::shared_ptr> +makeUserGroupPermissionRepository(std::shared_ptr updb) +{ + auto concrete = std::make_shared(std::move(updb)); + return std::make_shared>(concrete); +} + +} // namespace oatpp_authkit::repo + +#endif diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 4950a52..87b68c5 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -57,4 +57,9 @@ if(oatpp-sqlite_FOUND AND Threads_FOUND) target_link_libraries(test_role_template_schema PRIVATE oatpp::authkit oatpp::oatpp oatpp::oatpp-sqlite Threads::Threads) add_test(NAME role_template_schema COMMAND test_role_template_schema) + + add_executable(test_user_permission_schema test_user_permission_schema.cpp) + 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) endif() diff --git a/test/test_user_permission_schema.cpp b/test/test_user_permission_schema.cpp new file mode 100644 index 0000000..0db1626 --- /dev/null +++ b/test/test_user_permission_schema.cpp @@ -0,0 +1,82 @@ +// Tests for authkit#14 PRs 2 & 3 — user_property_permissions and +// user_group_permissions schemas compose correctly with TemporalRepository. + +#include "oatpp-authkit/db/UserPermissionDb.hpp" +#include "oatpp-authkit/dto/UserPermissionDto.hpp" +#include "oatpp-authkit/repo/ConcreteUserPermissionRepository.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_user_property_permissions_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< + UserPropertyPermissionSchema, + TemporalRepository>::create( + "user_property_permissions", exec); + + // 1 entity table + 3 schema-side indexes + 1 temporal index = 5 + REQUIRE(sqls.size() == 5); + + REQUIRE(contains(sqls[0], "CREATE TABLE IF NOT EXISTS user_property_permissions")); + REQUIRE(contains(sqls[0], "user_id TEXT NOT NULL")); + REQUIRE(contains(sqls[0], "property_id TEXT NOT NULL")); + REQUIRE(contains(sqls[0], "permission TEXT NOT NULL DEFAULT 'readonly'")); + REQUIRE(contains(sqls[0], "valid_until TEXT NOT NULL DEFAULT '9999-12-31T23:59:59Z'")); + + // Indexes: 3 from UserPropertyPermissionSchema in order, then 1 from TemporalRepository. + REQUIRE(contains(sqls[1], "ix_user_property_permissions_entity_id")); + REQUIRE(contains(sqls[2], "ix_user_property_permissions_user_id")); + REQUIRE(contains(sqls[3], "ux_user_property_permissions_user_property_until")); + REQUIRE(contains(sqls[3], "(user_id, property_id, valid_until)")); + REQUIRE(contains(sqls[4], "ux_user_property_permissions_entity_valid_until")); +} + +void test_user_group_permissions_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< + UserGroupPermissionSchema, + TemporalRepository>::create( + "user_group_permissions", exec); + + REQUIRE(sqls.size() == 5); + REQUIRE(contains(sqls[0], "CREATE TABLE IF NOT EXISTS user_group_permissions")); + REQUIRE(contains(sqls[0], "set_id TEXT NOT NULL")); + REQUIRE(contains(sqls[3], "ux_user_group_permissions_user_set_until")); + REQUIRE(contains(sqls[3], "(user_id, set_id, valid_until)")); +} + +} // namespace + +int main() { + test_user_property_permissions_create(); + test_user_group_permissions_create(); + std::printf("test_user_permission_schema: OK\n"); + return 0; +}