#14 PR 1: relocate role_templates module + Atlas migration docs
Lifts role_templates / role_template_fields / user_role_assignments from fewo-webapp into oatpp-authkit, exposed via the declarative SchemaContract introduced in PR 0. New files (all in oatpp-authkit): - dto/RoleTemplateDto.hpp — RoleTemplateDto, RoleTemplateFieldDto, UserRoleAssignmentDto. UserWithPermissionsDto stays in fewo (fewo- specific /api/auth/me response shape). - db/RoleTemplateDb.hpp — DbClient with all queries (CRUD + cascade soft-delete + getEffectiveFieldPermissions). RoleTemplateSchema struct declares the three tables' columns/indexes/sidecar tables in the new declarative form. TemporalRepository overlays valid_until + the composite UNIQUE(entity_id, valid_until) index. - repo/ConcreteRoleTemplateRepository.hpp — Repository<RoleTemplateDto> inner adapter; makeRoleTemplateRepository helper composes the stack. - docs/MIGRATIONS.md — Atlas workflow for consumers (atlasgo.io as the diff-driven migration tool; SchemaBuilder produces desired state, Atlas generates versioned SQL, SchemaContract::verify asserts at runtime). - test/test_role_template_schema.cpp — verifies SchemaBuilder< RoleTemplateSchema, TemporalRepository<RoleTemplateDto>> emits the expected 5 DDL statements (2 sidecars + entity table + 2 indexes) with composite-FK + ON UPDATE CASCADE on both sidecars. 11 of 11 tests pass. RoleTemplateDto is registered as temporal via OATPP_AUTHKIT_REGISTER_TEMPORAL so TemporalRepository compiles cleanly. Atlas binary integration in CI is documented but not yet wired — owner deferred to a follow-up after the first concrete consumer migration. The shipped role_templates stack itself is fully consumable today; fewo- webapp's switch from local copies to oatpp-authkit-shipped headers is the natural next PR. Bumped 0.9.0 → 0.10.0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
606db5a109
commit
3ccc25f231
7 changed files with 704 additions and 1 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
cmake_minimum_required(VERSION 3.14)
|
cmake_minimum_required(VERSION 3.14)
|
||||||
project(oatpp-authkit VERSION 0.9.0 LANGUAGES CXX)
|
project(oatpp-authkit VERSION 0.10.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:
|
||||||
|
|
|
||||||
102
docs/MIGRATIONS.md
Normal file
102
docs/MIGRATIONS.md
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
# Schema migrations with oatpp-authkit
|
||||||
|
|
||||||
|
oatpp-authkit (since v0.9.0) ships a declarative schema model: each
|
||||||
|
decorator in a `Repository<TDto>` stack exposes a static
|
||||||
|
`DecoratorSchema kSchema` listing the columns/indexes/sidecar tables it
|
||||||
|
needs. `SchemaBuilder<…>::create(table, exec)` composes the contributions
|
||||||
|
into a single `CREATE TABLE` per entity table. `SchemaContract::verify`
|
||||||
|
asserts the live DB matches at runtime.
|
||||||
|
|
||||||
|
This document covers the **deploy-time** companion: how to evolve a live
|
||||||
|
database between releases when the decorator stack changes. The
|
||||||
|
recommended tool is [Atlas](https://atlasgo.io) (declarative schema-as-
|
||||||
|
code, language-agnostic).
|
||||||
|
|
||||||
|
## The model: dev DB as desired state
|
||||||
|
|
||||||
|
Atlas's "diff-driven migration" workflow is a clean fit:
|
||||||
|
|
||||||
|
1. **Desired state** — a schema produced by running `SchemaBuilder` once
|
||||||
|
against an empty SQLite. The output of all `CREATE TABLE` /
|
||||||
|
`CREATE INDEX` statements *is* the desired state.
|
||||||
|
2. **Current state** — what the production database actually contains.
|
||||||
|
3. **Migration** — `atlas migrate diff` compares (1) and (2) and emits
|
||||||
|
versioned SQL files.
|
||||||
|
4. **Apply** — at deploy time, `atlas migrate apply` runs the new
|
||||||
|
migration files against prod.
|
||||||
|
|
||||||
|
Decorator code never runs ALTER at runtime. It only:
|
||||||
|
|
||||||
|
- declares `kSchema` (compile-time);
|
||||||
|
- runs `SchemaBuilder` against an empty DB (CI) — produces desired state;
|
||||||
|
- runs `SchemaContract::verify` at app startup against the live DB — fails
|
||||||
|
loud if a column/sidecar required by the stack is missing (i.e. the
|
||||||
|
migration didn't run).
|
||||||
|
|
||||||
|
## Wiring it into a consumer's CI
|
||||||
|
|
||||||
|
A consumer of oatpp-authkit (e.g. fewo-webapp, palibu, …) has its own DB
|
||||||
|
schema that combines the authkit-shipped contributions with its own
|
||||||
|
local tables. The schema-snapshot workflow:
|
||||||
|
|
||||||
|
1. **Build a small standalone tool** (`tools/schema_snapshot.cpp`) that
|
||||||
|
instantiates the full set of `SchemaBuilder<…>::create` calls for every
|
||||||
|
entity in the app, writing all DDL to a temporary SQLite.
|
||||||
|
2. **Atlas inspects** the resulting SQLite:
|
||||||
|
```
|
||||||
|
atlas schema inspect --url "sqlite://./tmp_schema.db" --format '{{ hcl . }}' > schema.hcl
|
||||||
|
```
|
||||||
|
3. **Commit `schema.hcl`** to the repo. Diffs are reviewable per change.
|
||||||
|
4. **At deploy**:
|
||||||
|
```
|
||||||
|
atlas migrate diff --to "file://schema.hcl" --dir "file://migrations" \
|
||||||
|
--dev-url "sqlite://file?mode=memory" \
|
||||||
|
--format atlas
|
||||||
|
atlas migrate apply --url "sqlite://prod.db" --dir "file://migrations"
|
||||||
|
```
|
||||||
|
|
||||||
|
The first `migrate diff` emits a versioned migration file; subsequent
|
||||||
|
schema changes (decorator-level or app-level) regenerate the migration
|
||||||
|
list. Each release includes the new migration files; deploy applies them.
|
||||||
|
|
||||||
|
## Atlas-free fallback
|
||||||
|
|
||||||
|
For consumers that don't want Atlas as a dependency, `SchemaBuilder`'s
|
||||||
|
output is plain SQL — pipe it into any migration tool (Flyway, goose,
|
||||||
|
hand-rolled scripts). The C++ side stays unchanged.
|
||||||
|
|
||||||
|
The runtime guarantee — `SchemaContract::verify` throwing on missing
|
||||||
|
columns/sidecars — works regardless of which migration tool you used.
|
||||||
|
|
||||||
|
## Example: a consumer using oatpp-authkit's role_templates module
|
||||||
|
|
||||||
|
The `dto::RoleTemplateDto` + `db::RoleTemplateSchema` +
|
||||||
|
`repo::ConcreteRoleTemplateRepository` + `repo::TemporalRepository<…>`
|
||||||
|
stack ships in oatpp-authkit since v0.10.0. A consumer wires it up like:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
#include "oatpp-authkit/db/RoleTemplateDb.hpp"
|
||||||
|
#include "oatpp-authkit/repo/ConcreteRoleTemplateRepository.hpp"
|
||||||
|
|
||||||
|
// One-shot at CI: produce desired-state DDL.
|
||||||
|
oatpp_authkit::repo::SchemaBuilder<
|
||||||
|
oatpp_authkit::db::RoleTemplateSchema,
|
||||||
|
oatpp_authkit::repo::TemporalRepository<oatpp_authkit::dto::RoleTemplateDto>
|
||||||
|
>::create("role_templates", exec);
|
||||||
|
|
||||||
|
// Every app startup: assert the live DB matches.
|
||||||
|
oatpp_authkit::repo::SchemaContract<
|
||||||
|
oatpp_authkit::db::RoleTemplateSchema,
|
||||||
|
oatpp_authkit::repo::TemporalRepository<oatpp_authkit::dto::RoleTemplateDto>
|
||||||
|
>::verify("role_templates", probe);
|
||||||
|
|
||||||
|
// Routine repository use.
|
||||||
|
auto rtdb = std::make_shared<oatpp_authkit::db::RoleTemplateDb>(executor);
|
||||||
|
auto repo = oatpp_authkit::repo::makeRoleTemplateRepository(rtdb);
|
||||||
|
auto liveTemplate = repo->findByEntityId("seed00000000000000000000role01rt");
|
||||||
|
```
|
||||||
|
|
||||||
|
Atlas treats the `CREATE TABLE` output of `SchemaBuilder::create` as the
|
||||||
|
desired state for those three tables (`role_templates` +
|
||||||
|
`role_template_fields` + `user_role_assignments`); the consumer's own
|
||||||
|
schema-snapshot tool aggregates these alongside its app-specific tables.
|
||||||
277
include/oatpp-authkit/db/RoleTemplateDb.hpp
Normal file
277
include/oatpp-authkit/db/RoleTemplateDb.hpp
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
#ifndef OATPP_AUTHKIT_DB_ROLE_TEMPLATE_DB_HPP
|
||||||
|
#define OATPP_AUTHKIT_DB_ROLE_TEMPLATE_DB_HPP
|
||||||
|
|
||||||
|
// DbClient + declarative schema contribution for role templates,
|
||||||
|
// field permissions, and user role assignments (authkit#14 PR 1).
|
||||||
|
//
|
||||||
|
// Lifted from fewo-webapp's `src/db/RoleTemplateDb.hpp`. The queries are
|
||||||
|
// unchanged; new in this header is `RoleTemplateSchema::kSchema`, which
|
||||||
|
// declares the columns/indexes/sidecar tables this module needs in the
|
||||||
|
// declarative `SchemaContract` style introduced in PR 0.
|
||||||
|
|
||||||
|
#include "oatpp-authkit/dto/RoleTemplateDto.hpp"
|
||||||
|
#include "oatpp-authkit/repo/SchemaContract.hpp"
|
||||||
|
|
||||||
|
#include "oatpp-sqlite/orm.hpp"
|
||||||
|
|
||||||
|
#include OATPP_CODEGEN_BEGIN(DbClient)
|
||||||
|
|
||||||
|
namespace oatpp_authkit::db {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief DbClient for role templates / field permissions / user assignments.
|
||||||
|
*
|
||||||
|
* @section schema Schema contract
|
||||||
|
*
|
||||||
|
* `RoleTemplateSchema::kSchema` (defined below) names the three tables
|
||||||
|
* this module owns: `role_templates` (entity), `role_template_fields`
|
||||||
|
* (sidecar with composite-FK), `user_role_assignments` (sidecar with
|
||||||
|
* composite-FK). Composes into a `SchemaBuilder` parameter pack alongside
|
||||||
|
* `TemporalRepository<RoleTemplateDto>` to produce the full schema.
|
||||||
|
*
|
||||||
|
* @section queries Queries
|
||||||
|
*
|
||||||
|
* All temporal CRUD goes through the `TemporalRepository<RoleTemplateDto>`
|
||||||
|
* decorator on top of `ConcreteRoleTemplateRepository`. The DbClient
|
||||||
|
* methods below cover queries that don't fit the basic Repository<T>
|
||||||
|
* contract (effective-permission resolution, cascade soft-delete) and the
|
||||||
|
* raw queries the concrete repo uses internally.
|
||||||
|
*/
|
||||||
|
class RoleTemplateDb : public oatpp::orm::DbClient {
|
||||||
|
public:
|
||||||
|
RoleTemplateDb(const std::shared_ptr<oatpp::orm::Executor>& executor)
|
||||||
|
: oatpp::orm::DbClient(executor) {}
|
||||||
|
|
||||||
|
// ========== Role Templates (basic temporal CRUD) ==========
|
||||||
|
|
||||||
|
/// All live templates, ordered by name. Used by the controller list endpoint.
|
||||||
|
QUERY(getAllTemplates,
|
||||||
|
"SELECT * FROM role_templates "
|
||||||
|
"WHERE valid_from <= datetime('now') AND valid_until > datetime('now') "
|
||||||
|
"ORDER BY name;")
|
||||||
|
|
||||||
|
/// All rows (live + historical) for the temporal decorator's filter.
|
||||||
|
QUERY(getAllTemplatesRaw,
|
||||||
|
"SELECT * FROM role_templates;")
|
||||||
|
|
||||||
|
QUERY(getTemplateByEntityId,
|
||||||
|
"SELECT * FROM role_templates "
|
||||||
|
"WHERE entity_id = :id "
|
||||||
|
"AND valid_from <= datetime('now') AND valid_until > datetime('now');",
|
||||||
|
PARAM(oatpp::String, id))
|
||||||
|
|
||||||
|
/// Upsert keyed by `id` (per-row PK), per the Repository<T> contract
|
||||||
|
/// for temporal stacks. The temporal decorator drives close-then-insert
|
||||||
|
/// via this single method.
|
||||||
|
QUERY(upsertTemplateById,
|
||||||
|
"INSERT INTO role_templates "
|
||||||
|
" (id, entity_id, name, description, is_system, valid_from, valid_until) "
|
||||||
|
"VALUES "
|
||||||
|
" (:dto.id, :dto.entityId, :dto.name, :dto.description, "
|
||||||
|
" :dto.isSystem, :dto.validFrom, :dto.validUntil) "
|
||||||
|
"ON CONFLICT(id) DO UPDATE SET "
|
||||||
|
" entity_id = excluded.entity_id, "
|
||||||
|
" name = excluded.name, "
|
||||||
|
" description = excluded.description, "
|
||||||
|
" is_system = excluded.is_system, "
|
||||||
|
" valid_from = excluded.valid_from, "
|
||||||
|
" valid_until = excluded.valid_until;",
|
||||||
|
PARAM(oatpp::Object<dto::RoleTemplateDto>, dto))
|
||||||
|
|
||||||
|
QUERY(softDeleteTemplate,
|
||||||
|
"UPDATE role_templates SET valid_until = datetime('now') "
|
||||||
|
"WHERE entity_id = :id AND valid_until > datetime('now');",
|
||||||
|
PARAM(oatpp::String, id))
|
||||||
|
|
||||||
|
/// Cascade the soft-delete to link rows. On fresh installs the
|
||||||
|
/// composite FK + ON UPDATE CASCADE handles this automatically; these
|
||||||
|
/// explicit UPDATEs are defensive.
|
||||||
|
QUERY(cascadeSoftDeleteFields,
|
||||||
|
"UPDATE role_template_fields SET template_valid_until = datetime('now') "
|
||||||
|
"WHERE template_id = :id AND template_valid_until > datetime('now');",
|
||||||
|
PARAM(oatpp::String, id))
|
||||||
|
|
||||||
|
QUERY(cascadeSoftDeleteAssignments,
|
||||||
|
"UPDATE user_role_assignments SET template_valid_until = datetime('now') "
|
||||||
|
"WHERE template_id = :id AND template_valid_until > datetime('now');",
|
||||||
|
PARAM(oatpp::String, id))
|
||||||
|
|
||||||
|
// ========== Template Fields ==========
|
||||||
|
|
||||||
|
QUERY(getFieldsForTemplate,
|
||||||
|
"SELECT * FROM role_template_fields "
|
||||||
|
"WHERE template_id = :templateId "
|
||||||
|
"ORDER BY entity_type, field_name;",
|
||||||
|
PARAM(oatpp::String, templateId))
|
||||||
|
|
||||||
|
QUERY(insertField,
|
||||||
|
"INSERT OR REPLACE INTO role_template_fields "
|
||||||
|
" (id, template_id, entity_type, field_name, permission) "
|
||||||
|
"VALUES "
|
||||||
|
" (:dto.id, :dto.templateId, :dto.entityType, :dto.fieldName, "
|
||||||
|
" :dto.permission);",
|
||||||
|
PARAM(oatpp::Object<dto::RoleTemplateFieldDto>, dto))
|
||||||
|
|
||||||
|
QUERY(deleteField,
|
||||||
|
"DELETE FROM role_template_fields WHERE id = :id;",
|
||||||
|
PARAM(oatpp::String, id))
|
||||||
|
|
||||||
|
QUERY(deleteFieldsForTemplate,
|
||||||
|
"DELETE FROM role_template_fields WHERE template_id = :templateId;",
|
||||||
|
PARAM(oatpp::String, templateId))
|
||||||
|
|
||||||
|
// ========== User Assignments ==========
|
||||||
|
|
||||||
|
QUERY(getAllAssignments,
|
||||||
|
"SELECT * FROM user_role_assignments "
|
||||||
|
"WHERE valid_from <= datetime('now') AND valid_until > datetime('now') "
|
||||||
|
"ORDER BY user_id;")
|
||||||
|
|
||||||
|
QUERY(getAssignmentsForUser,
|
||||||
|
"SELECT * FROM user_role_assignments "
|
||||||
|
"WHERE user_id = :userId "
|
||||||
|
"AND valid_from <= datetime('now') AND valid_until > datetime('now');",
|
||||||
|
PARAM(oatpp::String, userId))
|
||||||
|
|
||||||
|
QUERY(insertAssignment,
|
||||||
|
"INSERT INTO user_role_assignments "
|
||||||
|
" (id, entity_id, user_id, template_id, property_id) "
|
||||||
|
"VALUES "
|
||||||
|
" (:dto.id, :dto.entityId, :dto.userId, :dto.templateId, "
|
||||||
|
" :dto.propertyId);",
|
||||||
|
PARAM(oatpp::Object<dto::UserRoleAssignmentDto>, dto))
|
||||||
|
|
||||||
|
QUERY(softDeleteAssignment,
|
||||||
|
"UPDATE user_role_assignments SET valid_until = datetime('now') "
|
||||||
|
"WHERE entity_id = :entityId AND valid_until > datetime('now');",
|
||||||
|
PARAM(oatpp::String, entityId))
|
||||||
|
|
||||||
|
// ========== Field Permission Resolution ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Effective field permissions for a user on one entity type.
|
||||||
|
*
|
||||||
|
* Combines all active template assignments. If a user has multiple
|
||||||
|
* templates (e.g. property-scoped), takes the MAX permission per field
|
||||||
|
* (write > readonly > hidden). Returns only explicitly granted fields
|
||||||
|
* — unlisted fields are denied.
|
||||||
|
*
|
||||||
|
* The composite-temporal joins (`template_valid_until > now()`) make
|
||||||
|
* soft-deleted templates drop out automatically.
|
||||||
|
*/
|
||||||
|
QUERY(getEffectiveFieldPermissions,
|
||||||
|
"SELECT rtf.entity_type, rtf.field_name, MAX(rtf.permission) AS permission "
|
||||||
|
"FROM user_role_assignments ura "
|
||||||
|
"JOIN role_template_fields rtf ON rtf.template_id = ura.template_id "
|
||||||
|
"WHERE ura.user_id = :userId "
|
||||||
|
" AND ura.valid_from <= datetime('now') AND ura.valid_until > datetime('now') "
|
||||||
|
" AND ura.template_valid_until > datetime('now') "
|
||||||
|
" AND rtf.template_valid_until > datetime('now') "
|
||||||
|
" AND rtf.entity_type = :entityType "
|
||||||
|
" AND (ura.property_id IS NULL OR ura.property_id = :propertyId) "
|
||||||
|
"GROUP BY rtf.entity_type, rtf.field_name;",
|
||||||
|
PARAM(oatpp::String, userId),
|
||||||
|
PARAM(oatpp::String, entityType),
|
||||||
|
PARAM(oatpp::String, propertyId))
|
||||||
|
|
||||||
|
QUERY(getAllEffectiveFieldPermissions,
|
||||||
|
"SELECT rtf.entity_type, rtf.field_name, MAX(rtf.permission) AS permission "
|
||||||
|
"FROM user_role_assignments ura "
|
||||||
|
"JOIN role_template_fields rtf ON rtf.template_id = ura.template_id "
|
||||||
|
"WHERE ura.user_id = :userId "
|
||||||
|
" AND ura.valid_from <= datetime('now') AND ura.valid_until > datetime('now') "
|
||||||
|
" AND ura.template_valid_until > datetime('now') "
|
||||||
|
" AND rtf.template_valid_until > datetime('now') "
|
||||||
|
"GROUP BY rtf.entity_type, rtf.field_name;",
|
||||||
|
PARAM(oatpp::String, userId))
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Declarative schema contribution for the role-templates module.
|
||||||
|
*
|
||||||
|
* Three tables: `role_templates` (the entity), plus the two sidecars
|
||||||
|
* `role_template_fields` and `user_role_assignments` that carry the
|
||||||
|
* composite-FK temporal partner column `template_valid_until`.
|
||||||
|
*
|
||||||
|
* Designed to compose into a `SchemaBuilder` parameter pack alongside
|
||||||
|
* `TemporalRepository<RoleTemplateDto>`:
|
||||||
|
*
|
||||||
|
* @code
|
||||||
|
* SchemaBuilder<
|
||||||
|
* RoleTemplateSchema,
|
||||||
|
* TemporalRepository<RoleTemplateDto>>::create("role_templates", exec);
|
||||||
|
* @endcode
|
||||||
|
*
|
||||||
|
* The `id`, `entity_id`, business columns, and a `valid_from`-with-default
|
||||||
|
* are contributed here; the temporal decorator's `kSchema` overlays
|
||||||
|
* `valid_until` with the SENTINEL default + the composite UNIQUE index.
|
||||||
|
*
|
||||||
|
* The sidecar tables are emitted by name (no `{table}` substitution) and
|
||||||
|
* carry the composite FK to `role_templates(entity_id, valid_until)`.
|
||||||
|
*/
|
||||||
|
struct RoleTemplateSchema {
|
||||||
|
inline static constexpr repo::ColumnSpec kRoleTemplateColumns[] = {
|
||||||
|
{"id", "TEXT PRIMARY KEY"},
|
||||||
|
{"entity_id", "TEXT NOT NULL"},
|
||||||
|
{"name", "TEXT NOT NULL"},
|
||||||
|
{"description", "TEXT NOT NULL DEFAULT ''"},
|
||||||
|
{"is_system", "INTEGER NOT NULL DEFAULT 0"},
|
||||||
|
{"valid_from", "TEXT NOT NULL DEFAULT (datetime('now'))"},
|
||||||
|
// valid_until is contributed by TemporalRepository's kSchema,
|
||||||
|
// along with the composite UNIQUE(entity_id, valid_until).
|
||||||
|
};
|
||||||
|
inline static constexpr repo::IndexSpec kRoleTemplateIndexes[] = {
|
||||||
|
{"ix_{table}_entity_id", false, "(entity_id)"},
|
||||||
|
};
|
||||||
|
|
||||||
|
inline static constexpr repo::ColumnSpec kFieldColumns[] = {
|
||||||
|
{"id", "TEXT PRIMARY KEY"},
|
||||||
|
{"template_id", "TEXT NOT NULL"},
|
||||||
|
{"template_valid_until", "TEXT NOT NULL DEFAULT '9999-12-31T23:59:59Z'"},
|
||||||
|
{"entity_type", "TEXT NOT NULL"},
|
||||||
|
{"field_name", "TEXT NOT NULL"},
|
||||||
|
{"permission", "TEXT NOT NULL"},
|
||||||
|
{"_fk_to_role_templates",
|
||||||
|
"FOREIGN KEY (template_id, template_valid_until) REFERENCES "
|
||||||
|
"role_templates(entity_id, valid_until) ON UPDATE CASCADE"},
|
||||||
|
};
|
||||||
|
|
||||||
|
inline static constexpr repo::ColumnSpec kAssignmentColumns[] = {
|
||||||
|
{"id", "TEXT PRIMARY KEY"},
|
||||||
|
{"entity_id", "TEXT NOT NULL"},
|
||||||
|
{"user_id", "TEXT NOT NULL"},
|
||||||
|
{"template_id", "TEXT NOT NULL"},
|
||||||
|
{"template_valid_until", "TEXT NOT NULL DEFAULT '9999-12-31T23:59:59Z'"},
|
||||||
|
{"property_id", "TEXT"},
|
||||||
|
{"valid_from", "TEXT NOT NULL DEFAULT (datetime('now'))"},
|
||||||
|
{"valid_until", "TEXT NOT NULL DEFAULT '9999-12-31T23:59:59Z'"},
|
||||||
|
{"_fk_to_role_templates",
|
||||||
|
"FOREIGN KEY (template_id, template_valid_until) REFERENCES "
|
||||||
|
"role_templates(entity_id, valid_until) ON UPDATE CASCADE"},
|
||||||
|
};
|
||||||
|
|
||||||
|
inline static constexpr repo::SidecarTableSpec kSidecars[] = {
|
||||||
|
{"role_template_fields",
|
||||||
|
kFieldColumns,
|
||||||
|
sizeof(kFieldColumns) / sizeof(kFieldColumns[0])},
|
||||||
|
{"user_role_assignments",
|
||||||
|
kAssignmentColumns,
|
||||||
|
sizeof(kAssignmentColumns) / sizeof(kAssignmentColumns[0])},
|
||||||
|
};
|
||||||
|
|
||||||
|
inline static constexpr repo::DecoratorSchema kSchema = {
|
||||||
|
"RoleTemplateSchema",
|
||||||
|
kRoleTemplateColumns,
|
||||||
|
sizeof(kRoleTemplateColumns) / sizeof(kRoleTemplateColumns[0]),
|
||||||
|
kRoleTemplateIndexes,
|
||||||
|
sizeof(kRoleTemplateIndexes) / sizeof(kRoleTemplateIndexes[0]),
|
||||||
|
kSidecars,
|
||||||
|
sizeof(kSidecars) / sizeof(kSidecars[0]),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace oatpp_authkit::db
|
||||||
|
|
||||||
|
#include OATPP_CODEGEN_END(DbClient)
|
||||||
|
|
||||||
|
#endif
|
||||||
84
include/oatpp-authkit/dto/RoleTemplateDto.hpp
Normal file
84
include/oatpp-authkit/dto/RoleTemplateDto.hpp
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
#ifndef OATPP_AUTHKIT_DTO_ROLE_TEMPLATE_DTO_HPP
|
||||||
|
#define OATPP_AUTHKIT_DTO_ROLE_TEMPLATE_DTO_HPP
|
||||||
|
|
||||||
|
// Role template + field-permission + user-assignment DTOs (authkit#14 PR 1).
|
||||||
|
// Lifted from fewo-webapp's `src/dto/RoleTemplateDto.hpp` (consumer-side
|
||||||
|
// `UserWithPermissionsDto` stays in fewo — it's the /api/auth/me response
|
||||||
|
// shape, application-specific).
|
||||||
|
//
|
||||||
|
// The composite-temporal FK partner field (`templateValidUntil`) was added
|
||||||
|
// by fewo-webapp#459 PR 7 and follows the same convention here: every child
|
||||||
|
// row of a temporal `role_templates` row carries a sidecar that tracks the
|
||||||
|
// parent's `valid_until` via `ON UPDATE CASCADE` on the composite FK.
|
||||||
|
|
||||||
|
#include "oatpp/core/macro/codegen.hpp"
|
||||||
|
#include "oatpp/core/Types.hpp"
|
||||||
|
|
||||||
|
#include OATPP_CODEGEN_BEGIN(DTO)
|
||||||
|
|
||||||
|
namespace oatpp_authkit::dto {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief A role template (e.g. Cleaning, Accountant, Co-Host).
|
||||||
|
*/
|
||||||
|
class RoleTemplateDto : public oatpp::DTO {
|
||||||
|
DTO_INIT(RoleTemplateDto, DTO)
|
||||||
|
|
||||||
|
DTO_FIELD(String, id);
|
||||||
|
DTO_FIELD(String, entityId, "entity_id");
|
||||||
|
DTO_FIELD(String, name);
|
||||||
|
DTO_FIELD(String, description);
|
||||||
|
DTO_FIELD(Int32, isSystem, "is_system");
|
||||||
|
DTO_FIELD(String, validFrom, "valid_from");
|
||||||
|
DTO_FIELD(String, validUntil, "valid_until");
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief A field-level permission within a role template.
|
||||||
|
*
|
||||||
|
* `templateValidUntil` is the composite-temporal FK partner — tracks the
|
||||||
|
* parent `role_templates` row's `valid_until` via ON UPDATE CASCADE so a
|
||||||
|
* soft-delete of the template (which moves its `valid_until` from the
|
||||||
|
* sentinel to `now()`) propagates here without an explicit UPDATE.
|
||||||
|
*/
|
||||||
|
class RoleTemplateFieldDto : public oatpp::DTO {
|
||||||
|
DTO_INIT(RoleTemplateFieldDto, DTO)
|
||||||
|
|
||||||
|
DTO_FIELD(String, id);
|
||||||
|
DTO_FIELD(String, templateId, "template_id");
|
||||||
|
DTO_FIELD(String, templateValidUntil, "template_valid_until");
|
||||||
|
DTO_FIELD(String, entityType, "entity_type");
|
||||||
|
DTO_FIELD(String, fieldName, "field_name");
|
||||||
|
DTO_FIELD(String, permission); ///< 'hidden' | 'readonly' | 'write'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Assignment of a role template to a user (optionally property-scoped).
|
||||||
|
*
|
||||||
|
* `templateValidUntil` mirrors `RoleTemplateFieldDto::templateValidUntil` —
|
||||||
|
* the composite-FK partner for the temporal FK to `role_templates`.
|
||||||
|
*/
|
||||||
|
class UserRoleAssignmentDto : public oatpp::DTO {
|
||||||
|
DTO_INIT(UserRoleAssignmentDto, DTO)
|
||||||
|
|
||||||
|
DTO_FIELD(String, id);
|
||||||
|
DTO_FIELD(String, entityId, "entity_id");
|
||||||
|
DTO_FIELD(String, userId, "user_id");
|
||||||
|
DTO_FIELD(String, templateId, "template_id");
|
||||||
|
DTO_FIELD(String, templateValidUntil, "template_valid_until");
|
||||||
|
DTO_FIELD(String, propertyId, "property_id"); ///< optional
|
||||||
|
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::RoleTemplateDto,
|
||||||
|
id, entityId, validFrom, validUntil)
|
||||||
|
|
||||||
|
#endif
|
||||||
113
include/oatpp-authkit/repo/ConcreteRoleTemplateRepository.hpp
Normal file
113
include/oatpp-authkit/repo/ConcreteRoleTemplateRepository.hpp
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
#ifndef OATPP_AUTHKIT_REPO_CONCRETE_ROLE_TEMPLATE_REPOSITORY_HPP
|
||||||
|
#define OATPP_AUTHKIT_REPO_CONCRETE_ROLE_TEMPLATE_REPOSITORY_HPP
|
||||||
|
|
||||||
|
// Concrete inner adapter of `Repository<RoleTemplateDto>` (authkit#14 PR 1).
|
||||||
|
// Stacks under TemporalRepository<RoleTemplateDto> via `makeRoleTemplateRepository`.
|
||||||
|
|
||||||
|
#include "oatpp-authkit/db/RoleTemplateDb.hpp"
|
||||||
|
#include "oatpp-authkit/dto/RoleTemplateDto.hpp"
|
||||||
|
#include "oatpp-authkit/repo/Repository.hpp"
|
||||||
|
#include "oatpp-authkit/repo/TemporalRepository.hpp"
|
||||||
|
#include "oatpp-authkit/repo/SchemaContract.hpp"
|
||||||
|
|
||||||
|
#include "oatpp/core/Types.hpp"
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
namespace oatpp_authkit::repo {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Inner adapter of `Repository<RoleTemplateDto>`, delegating to
|
||||||
|
* `RoleTemplateDb`.
|
||||||
|
*
|
||||||
|
* Per the inner-repository contract documented on `TemporalRepository`:
|
||||||
|
*
|
||||||
|
* - `save(dto)` is upsert keyed by `id` (per-row PK), via
|
||||||
|
* `RoleTemplateDb::upsertTemplateById`. The decorator calls this twice
|
||||||
|
* per update — once for the live row in place, once for the historical
|
||||||
|
* clone with a new `id`.
|
||||||
|
* - `list()` returns ALL rows (live + historical) via `getAllTemplatesRaw`;
|
||||||
|
* `TemporalRepository` filters live-vs-historical itself.
|
||||||
|
* - `findByEntityId` / `softDelete` are not used by `TemporalRepository`
|
||||||
|
* (it overrides them with temporal-aware versions). They're implemented
|
||||||
|
* here so the type satisfies `Repository<RoleTemplateDto>`.
|
||||||
|
*
|
||||||
|
* Schema contribution is deliberately empty — `RoleTemplateSchema`
|
||||||
|
* (defined in `db/RoleTemplateDb.hpp`) owns the table declarations. This
|
||||||
|
* concrete repo only adapts. Stack as:
|
||||||
|
*
|
||||||
|
* @code
|
||||||
|
* SchemaBuilder<
|
||||||
|
* db::RoleTemplateSchema,
|
||||||
|
* TemporalRepository<dto::RoleTemplateDto>>::create("role_templates", exec);
|
||||||
|
* @endcode
|
||||||
|
*/
|
||||||
|
class ConcreteRoleTemplateRepository
|
||||||
|
: public Repository<dto::RoleTemplateDto>
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
/// Empty schema — `db::RoleTemplateSchema` is the schema partner that
|
||||||
|
/// goes into the SchemaBuilder parameter pack.
|
||||||
|
inline static constexpr DecoratorSchema kSchema = {
|
||||||
|
"ConcreteRoleTemplateRepository",
|
||||||
|
nullptr, 0,
|
||||||
|
nullptr, 0,
|
||||||
|
nullptr, 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
explicit ConcreteRoleTemplateRepository(std::shared_ptr<db::RoleTemplateDb> rtdb)
|
||||||
|
: m_db(std::move(rtdb)) {}
|
||||||
|
|
||||||
|
oatpp::Object<dto::RoleTemplateDto>
|
||||||
|
findByEntityId(const oatpp::String& entityId) override
|
||||||
|
{
|
||||||
|
auto res = m_db->getTemplateByEntityId(entityId);
|
||||||
|
if (!res || !res->isSuccess()) return nullptr;
|
||||||
|
auto rows = res->template fetch<oatpp::Vector<oatpp::Object<dto::RoleTemplateDto>>>();
|
||||||
|
if (!rows || rows->empty()) return nullptr;
|
||||||
|
return (*rows)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
oatpp::Vector<oatpp::Object<dto::RoleTemplateDto>> list() override {
|
||||||
|
auto res = m_db->getAllTemplatesRaw();
|
||||||
|
auto out = oatpp::Vector<oatpp::Object<dto::RoleTemplateDto>>::createShared();
|
||||||
|
if (!res || !res->isSuccess()) return out;
|
||||||
|
auto fetched = res->template fetch<
|
||||||
|
oatpp::Vector<oatpp::Object<dto::RoleTemplateDto>>>();
|
||||||
|
if (!fetched) return out;
|
||||||
|
for (auto& row : *fetched) {
|
||||||
|
if (row) out->push_back(row);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
void save(const oatpp::Object<dto::RoleTemplateDto>& d) override {
|
||||||
|
m_db->upsertTemplateById(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
void softDelete(const oatpp::String& entityId) override {
|
||||||
|
m_db->softDeleteTemplate(entityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::shared_ptr<db::RoleTemplateDb> m_db;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Compose the role-template repository stack.
|
||||||
|
*
|
||||||
|
* Wraps the concrete repo in `TemporalRepository<RoleTemplateDto>` so
|
||||||
|
* callers get versioning + soft-delete-via-valid-until semantics. No
|
||||||
|
* scope guard is added at this layer — role-template management is
|
||||||
|
* admin-only at the controller level, and there's no per-property scope.
|
||||||
|
*/
|
||||||
|
inline std::shared_ptr<Repository<dto::RoleTemplateDto>>
|
||||||
|
makeRoleTemplateRepository(std::shared_ptr<db::RoleTemplateDb> rtdb)
|
||||||
|
{
|
||||||
|
auto concrete = std::make_shared<ConcreteRoleTemplateRepository>(std::move(rtdb));
|
||||||
|
return std::make_shared<TemporalRepository<dto::RoleTemplateDto>>(concrete);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace oatpp_authkit::repo
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
@ -45,3 +45,16 @@ add_test(NAME audit_log_repository COMMAND test_audit_log_repository)
|
||||||
add_executable(test_schema_contract test_schema_contract.cpp)
|
add_executable(test_schema_contract test_schema_contract.cpp)
|
||||||
target_link_libraries(test_schema_contract PRIVATE oatpp::authkit oatpp::oatpp)
|
target_link_libraries(test_schema_contract PRIVATE oatpp::authkit oatpp::oatpp)
|
||||||
add_test(NAME schema_contract COMMAND test_schema_contract)
|
add_test(NAME schema_contract COMMAND test_schema_contract)
|
||||||
|
|
||||||
|
# RoleTemplateDb pulls in oatpp-sqlite for its DbClient queries. Linking
|
||||||
|
# the test against oatpp::oatpp-sqlite provides the QUERY codegen
|
||||||
|
# definitions; the test itself doesn't open a real DB, only compiles
|
||||||
|
# against the schema declarations.
|
||||||
|
find_package(oatpp-sqlite QUIET)
|
||||||
|
find_package(Threads QUIET)
|
||||||
|
if(oatpp-sqlite_FOUND AND Threads_FOUND)
|
||||||
|
add_executable(test_role_template_schema test_role_template_schema.cpp)
|
||||||
|
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)
|
||||||
|
endif()
|
||||||
|
|
|
||||||
114
test/test_role_template_schema.cpp
Normal file
114
test/test_role_template_schema.cpp
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
// Tests for authkit#14 PR 1 — role_templates schema contribution composes
|
||||||
|
// correctly with the TemporalRepository decorator.
|
||||||
|
|
||||||
|
#include "oatpp-authkit/db/RoleTemplateDb.hpp"
|
||||||
|
#include "oatpp-authkit/dto/RoleTemplateDto.hpp"
|
||||||
|
#include "oatpp-authkit/repo/ConcreteRoleTemplateRepository.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchemaBuilder<RoleTemplateSchema, TemporalRepository<RoleTemplateDto>>
|
||||||
|
// emits the three tables + their indexes. Verify the composition.
|
||||||
|
void test_role_templates_full_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<
|
||||||
|
RoleTemplateSchema,
|
||||||
|
TemporalRepository<RoleTemplateDto>>::create("role_templates", exec);
|
||||||
|
|
||||||
|
// Two sidecars (role_template_fields + user_role_assignments) +
|
||||||
|
// one entity table + one entity_id index + one composite UNIQUE index
|
||||||
|
// = 5
|
||||||
|
REQUIRE(sqls.size() == 5);
|
||||||
|
|
||||||
|
// Sidecar 1: role_template_fields with composite FK
|
||||||
|
REQUIRE(contains(sqls[0], "CREATE TABLE IF NOT EXISTS role_template_fields"));
|
||||||
|
REQUIRE(contains(sqls[0], "template_id TEXT NOT NULL"));
|
||||||
|
REQUIRE(contains(sqls[0], "template_valid_until TEXT NOT NULL DEFAULT '9999-12-31T23:59:59Z'"));
|
||||||
|
REQUIRE(contains(sqls[0],
|
||||||
|
"FOREIGN KEY (template_id, template_valid_until) REFERENCES "
|
||||||
|
"role_templates(entity_id, valid_until) ON UPDATE CASCADE"));
|
||||||
|
|
||||||
|
// Sidecar 2: user_role_assignments with composite FK
|
||||||
|
REQUIRE(contains(sqls[1], "CREATE TABLE IF NOT EXISTS user_role_assignments"));
|
||||||
|
REQUIRE(contains(sqls[1], "user_id TEXT NOT NULL"));
|
||||||
|
REQUIRE(contains(sqls[1],
|
||||||
|
"FOREIGN KEY (template_id, template_valid_until) REFERENCES "
|
||||||
|
"role_templates(entity_id, valid_until) ON UPDATE CASCADE"));
|
||||||
|
|
||||||
|
// Entity table: role_templates with all RoleTemplateSchema columns +
|
||||||
|
// valid_until from TemporalRepository.
|
||||||
|
REQUIRE(contains(sqls[2], "CREATE TABLE IF NOT EXISTS role_templates"));
|
||||||
|
REQUIRE(contains(sqls[2], "id TEXT PRIMARY KEY"));
|
||||||
|
REQUIRE(contains(sqls[2], "entity_id TEXT NOT NULL"));
|
||||||
|
REQUIRE(contains(sqls[2], "name TEXT NOT NULL"));
|
||||||
|
REQUIRE(contains(sqls[2], "is_system INTEGER NOT NULL DEFAULT 0"));
|
||||||
|
REQUIRE(contains(sqls[2], "valid_from TEXT NOT NULL DEFAULT (datetime('now'))"));
|
||||||
|
REQUIRE(contains(sqls[2], "valid_until TEXT NOT NULL DEFAULT '9999-12-31T23:59:59Z'"));
|
||||||
|
|
||||||
|
// Indexes: ix_role_templates_entity_id (RoleTemplateSchema)
|
||||||
|
// ux_role_templates_entity_valid_until (TemporalRepository)
|
||||||
|
REQUIRE(contains(sqls[3], "CREATE INDEX IF NOT EXISTS ix_role_templates_entity_id"));
|
||||||
|
REQUIRE(contains(sqls[3], "ON role_templates (entity_id)"));
|
||||||
|
REQUIRE(contains(sqls[4], "CREATE UNIQUE INDEX IF NOT EXISTS ux_role_templates_entity_valid_until"));
|
||||||
|
REQUIRE(contains(sqls[4], "ON role_templates (entity_id, valid_until)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that ConcreteRoleTemplateRepository contributes nothing to the
|
||||||
|
// schema — RoleTemplateSchema owns the table declarations, the concrete
|
||||||
|
// repo only adapts queries. Stacking the concrete repo into the builder
|
||||||
|
// must not duplicate columns.
|
||||||
|
void test_concrete_repo_contributes_no_schema() {
|
||||||
|
using namespace oatpp_authkit::repo;
|
||||||
|
using namespace oatpp_authkit::db;
|
||||||
|
using namespace oatpp_authkit::dto;
|
||||||
|
|
||||||
|
std::vector<std::string> sqls_with;
|
||||||
|
std::vector<std::string> sqls_without;
|
||||||
|
|
||||||
|
SchemaBuilder<
|
||||||
|
RoleTemplateSchema,
|
||||||
|
ConcreteRoleTemplateRepository,
|
||||||
|
TemporalRepository<RoleTemplateDto>>::create(
|
||||||
|
"role_templates",
|
||||||
|
[&](const std::string& s){ sqls_with.push_back(s); });
|
||||||
|
|
||||||
|
SchemaBuilder<
|
||||||
|
RoleTemplateSchema,
|
||||||
|
TemporalRepository<RoleTemplateDto>>::create(
|
||||||
|
"role_templates",
|
||||||
|
[&](const std::string& s){ sqls_without.push_back(s); });
|
||||||
|
|
||||||
|
// Including ConcreteRoleTemplateRepository in the pack changes nothing
|
||||||
|
// — empty kSchema contributes no DDL.
|
||||||
|
REQUIRE(sqls_with == sqls_without);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
test_role_templates_full_create();
|
||||||
|
test_concrete_repo_contributes_no_schema();
|
||||||
|
std::printf("test_role_template_schema: OK\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue