#14 PRs 2 & 3: relocate user_property_permissions + user_group_permissions

Lifts both per-property and per-property-set RBAC tables from fewo-webapp
into oatpp-authkit. Combined into one commit because they share a
DbClient and the cross-table effective-permission resolver — the resolver
itself stays in fewo since it joins property_set_members (a fewo-side
concept).

New files (all in oatpp-authkit):
- dto/UserPermissionDto.hpp — UserPropertyPermissionDto +
  UserGroupPermissionDto, both registered as temporal.
  EffectivePermissionDto stays in fewo (it's the result shape of fewo's
  property_set_members JOIN).
- db/UserPermissionDb.hpp — DbClient with CRUD for both tables. Each
  table also has a *Schema struct exposing kSchema for SchemaBuilder
  composition. Natural-key UNIQUE indexes carried explicitly:
  ux_..._user_property_until, ux_..._user_set_until.
- repo/ConcreteUserPermissionRepository.hpp — two concrete repos +
  makeUserPropertyPermissionRepository / makeUserGroupPermissionRepository
  factories that wrap each in TemporalRepository.
- test/test_user_permission_schema.cpp — verifies both schemas compose
  with TemporalRepository to produce the expected 5 DDL statements each
  (entity table + 3 schema indexes + 1 temporal composite index).

12 of 12 tests pass. Bumped 0.10.0 → 0.11.0.

Per-row natural-key UNIQUE prevents duplicate live grants for the same
(user_id, property_id) or (user_id, set_id) pair while still allowing
historical rows for the same key (their valid_until differs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Uwe Schuster 2026-05-06 12:39:52 +02:00
parent 3ccc25f231
commit 0bb8bef634
6 changed files with 478 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.10.0 LANGUAGES CXX) project(oatpp-authkit VERSION 0.11.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,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<oatpp::orm::Executor>& 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<dto::UserPropertyPermissionDto>, 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<dto::UserGroupPermissionDto>, 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<UserPropertyPermissionDto>` 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

View file

@ -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

View file

@ -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<UserPropertyPermissionDto> and
// Repository<UserGroupPermissionDto> (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 <memory>
namespace oatpp_authkit::repo {
/**
* @brief Inner adapter for `Repository<UserPropertyPermissionDto>`,
* delegating to `db::UserPermissionDb`.
*
* Schema lives in `db::UserPropertyPermissionSchema` this repo
* contributes nothing to the schema, only adapts queries.
*/
class ConcreteUserPropertyPermissionRepository
: public Repository<dto::UserPropertyPermissionDto>
{
public:
inline static constexpr DecoratorSchema kSchema = {
"ConcreteUserPropertyPermissionRepository",
nullptr, 0, nullptr, 0, nullptr, 0,
};
explicit ConcreteUserPropertyPermissionRepository(
std::shared_ptr<db::UserPermissionDb> updb)
: m_db(std::move(updb)) {}
oatpp::Object<dto::UserPropertyPermissionDto>
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<oatpp::Object<dto::UserPropertyPermissionDto>>>();
if (!rows || rows->empty()) return nullptr;
return (*rows)[0];
}
oatpp::Vector<oatpp::Object<dto::UserPropertyPermissionDto>> list() override {
auto res = m_db->getAllPropertyPermissionsRaw();
auto out = oatpp::Vector<oatpp::Object<dto::UserPropertyPermissionDto>>::createShared();
if (!res || !res->isSuccess()) return out;
auto fetched = res->template fetch<
oatpp::Vector<oatpp::Object<dto::UserPropertyPermissionDto>>>();
if (!fetched) return out;
for (auto& row : *fetched) if (row) out->push_back(row);
return out;
}
void save(const oatpp::Object<dto::UserPropertyPermissionDto>& d) override {
m_db->upsertPropertyPermissionById(d);
}
void softDelete(const oatpp::String& entityId) override {
m_db->softDeletePropertyPermission(entityId);
}
private:
std::shared_ptr<db::UserPermissionDb> m_db;
};
/**
* @brief Inner adapter for `Repository<UserGroupPermissionDto>`,
* delegating to `db::UserPermissionDb`.
*/
class ConcreteUserGroupPermissionRepository
: public Repository<dto::UserGroupPermissionDto>
{
public:
inline static constexpr DecoratorSchema kSchema = {
"ConcreteUserGroupPermissionRepository",
nullptr, 0, nullptr, 0, nullptr, 0,
};
explicit ConcreteUserGroupPermissionRepository(
std::shared_ptr<db::UserPermissionDb> updb)
: m_db(std::move(updb)) {}
oatpp::Object<dto::UserGroupPermissionDto>
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<oatpp::Object<dto::UserGroupPermissionDto>>>();
if (!rows || rows->empty()) return nullptr;
return (*rows)[0];
}
oatpp::Vector<oatpp::Object<dto::UserGroupPermissionDto>> list() override {
auto res = m_db->getAllGroupPermissionsRaw();
auto out = oatpp::Vector<oatpp::Object<dto::UserGroupPermissionDto>>::createShared();
if (!res || !res->isSuccess()) return out;
auto fetched = res->template fetch<
oatpp::Vector<oatpp::Object<dto::UserGroupPermissionDto>>>();
if (!fetched) return out;
for (auto& row : *fetched) if (row) out->push_back(row);
return out;
}
void save(const oatpp::Object<dto::UserGroupPermissionDto>& d) override {
m_db->upsertGroupPermissionById(d);
}
void softDelete(const oatpp::String& entityId) override {
m_db->softDeleteGroupPermission(entityId);
}
private:
std::shared_ptr<db::UserPermissionDb> m_db;
};
inline std::shared_ptr<Repository<dto::UserPropertyPermissionDto>>
makeUserPropertyPermissionRepository(std::shared_ptr<db::UserPermissionDb> updb)
{
auto concrete = std::make_shared<ConcreteUserPropertyPermissionRepository>(std::move(updb));
return std::make_shared<TemporalRepository<dto::UserPropertyPermissionDto>>(concrete);
}
inline std::shared_ptr<Repository<dto::UserGroupPermissionDto>>
makeUserGroupPermissionRepository(std::shared_ptr<db::UserPermissionDb> updb)
{
auto concrete = std::make_shared<ConcreteUserGroupPermissionRepository>(std::move(updb));
return std::make_shared<TemporalRepository<dto::UserGroupPermissionDto>>(concrete);
}
} // namespace oatpp_authkit::repo
#endif

View file

@ -57,4 +57,9 @@ if(oatpp-sqlite_FOUND AND Threads_FOUND)
target_link_libraries(test_role_template_schema target_link_libraries(test_role_template_schema
PRIVATE oatpp::authkit oatpp::oatpp oatpp::oatpp-sqlite Threads::Threads) PRIVATE oatpp::authkit oatpp::oatpp oatpp::oatpp-sqlite Threads::Threads)
add_test(NAME role_template_schema COMMAND test_role_template_schema) 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() endif()

View file

@ -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 <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_user_property_permissions_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<
UserPropertyPermissionSchema,
TemporalRepository<UserPropertyPermissionDto>>::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<std::string> sqls;
SqlExec exec = [&](const std::string& sql) { sqls.push_back(sql); };
SchemaBuilder<
UserGroupPermissionSchema,
TemporalRepository<UserGroupPermissionDto>>::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;
}