oatpp-authkit/include/oatpp-authkit/db/RoleTemplateDb.hpp
Uwe Schuster 3ccc25f231 #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>
2026-05-06 12:36:18 +02:00

277 lines
12 KiB
C++

#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