Lifts the auth-essential users table from fewo-webapp into oatpp-authkit in temporal form per Option B from the issue body. The previous shape (id INTEGER autoinc + is_active flag) is replaced with the entity_id + valid_from/valid_until triple; soft-delete via valid_until = now() instead of toggling is_active. New files (all in oatpp-authkit): - dto/UserDto.hpp — auth-essential columns only: id, entity_id, username, password_hash, role, tls_cert_dn, valid_from, valid_until. Registered as temporal so TemporalRepository composes cleanly. Application- specific columns (email, profile data) belong on a consumer-side DTO + parallel SchemaContract that contributes additional columns to the same users table. - db/UserDb.hpp — DbClient with login-path queries (findLiveByUsername, findLiveByTlsCertDn) plus generic CRUD. UserSchema declares the schema: TEXT id, entity_id, username, password_hash, role, tls_cert_dn, with natural-key UNIQUE on (username, valid_until) so no two live rows can share a username while historical rows for the same username are allowed. - repo/ConcreteUserRepository.hpp — Repository<UserDto> adapter + makeUserRepository factory wrapping in TemporalRepository. - test/test_user_schema.cpp — verifies SchemaBuilder<UserSchema, TemporalRepository<UserDto>>::create produces the expected 5 DDL statements; specifically asserts is_active and created_at are NOT present in the temporal shape (Option B replacement). 13 of 13 tests pass. Bumped 0.11.0 → 0.12.0. Per owner directive on authkit#14: password_hash rides the temporal row. A separate security follow-up issue tracks the redaction policy for historical password hashes (likely blank the hash but keep the row so change-history is auditable). The migration of an existing non-temporal users table to this shape is documented in db/UserDb.hpp: Atlas-generated migration handles the structural conversion + backfill (each existing row becomes its own entity with entity_id = CAST(id AS TEXT)). Sessions/certificates FKs that referenced users.id (INTEGER) need rewiring to reference users.entity_id — that's a consumer-side rewire, separate PR. Closes #14 — the four migration sub-PRs (PR 1 role_templates, PRs 2+3 permissions, PR 4 users) are now landed; the umbrella issue can close. Follow-ups (security hash redaction, fewo-webapp consumer migration, Atlas CI integration) get their own issues. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|---|---|---|
| cmake | ||
| docs | ||
| include/oatpp-authkit | ||
| test | ||
| .gitignore | ||
| CMakeLists.txt | ||
| README.md | ||
oatpp-authkit
Header-only C++ library distilled from fewo-webapp's hardened auth / security stack. Header-only, oatpp 1.3+, C++17.
What's in v0.1 (the clean-lift set)
| Header | Purpose |
|---|---|
interceptor/SecurityHeadersInterceptor.hpp |
CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy. Strict defaults. |
interceptor/BodySizeLimitInterceptor.hpp |
Reject request bodies above a configurable limit with 413 before they hit your handlers. |
handler/JsonErrorHandler.hpp |
Normalises thrown exceptions into {status, message} JSON so controllers never leak raw HTML error pages. |
util/RateLimiter.hpp |
In-memory token-bucket keyed on an arbitrary string (typically the client IP from clientIpTrusted). |
util/TokenExtract.hpp |
extractToken (Cookie/Bearer), isValidIp (IPv4/IPv6 via inet_pton), clientIpTrusted (loopback-gated XFF). |
startup/RequireEncryptionKey.hpp |
requireEncryptionKey(envVarName, encryptionEnabled, allowPlaintext) — refuse startup without a symmetric key unless a dev flag overrides. |
repo/Repository.hpp + IHistoryRepository.hpp + TemporalFieldTraits.hpp + TemporalAt.hpp + ActorContext.hpp |
Pure-abstract Repository<TDto> interface set distilled from fewo-webapp's per-entity *Db clients. Mixed UUID allocation on save, separate IHistoryRepository<T> for temporal versions, TemporalFieldTraits<T> to map canonical (entity_id, valid_from, valid_until) onto whatever a DTO actually calls them, ActorContext placeholder for the scope-guard decorator. |
repo/TemporalRepository.hpp |
Decorator that wraps any Repository<TDto> and turns it into a temporally-versioned one. Stable-live + historical-copy semantics (authkit#13): the live row's id PK is preserved across updates; each prior version is captured as a fresh row with a new id. softDelete closes the live row in place; with ON UPDATE CASCADE on consumer-side composite child FKs, child rows follow automatically. findByEntityIdAt(id, at) returns the version live at a point in time; implements IHistoryRepository<T>. Inner adapter is expected to expose all rows (live + historical) and treat save as upsert keyed by id (per-row PK). DTOs register their four temporal columns via OATPP_AUTHKIT_REGISTER_TEMPORAL(Dto, id, entity_id, valid_from, valid_until). |
repo/ScopeGuardRepository.hpp |
Generic resource-scope decorator. Takes a bool(ActorContext, TDto) predicate at construction; gates every method on it. Throws ScopeDeniedException on deny (catchers translate to 403). Knows nothing about consumer-specific concepts like "property" or "tenant" — the predicate decides. |
repo/IQueryable.hpp |
Optional capability for repos that resolve a typed query AST. field<&Dto::col>().eq(...) style DSL composes via && / ` |
repo/IAuditSink.hpp + repo/AuditLogRepository.hpp |
Cross-cutting audit-trail decorator. Emits an AuditEvent (actor, entity type/id, op, timestamp) per mutation through a consumer-supplied IAuditSink. Ops are Create / Update / Delete / Read; pre-write findByEntityId lookup distinguishes Create from Update. Configurable enabled-op set (default {Create,Update,Delete} — Read is opt-in, list() never audited). Sink failures are caught and swallowed unless a bool(const std::exception&) handler asks to rethrow. Stacks with TemporalRepository and ScopeGuardRepository. |
repo/SchemaContract.hpp |
Declarative schema model for the decorator stack (authkit#14). Each decorator exposes a static constexpr DecoratorSchema kSchema listing the columns/indexes it contributes to the entity table plus any sidecar tables it owns. SchemaBuilder<Decorators…>::create(table, exec) composes contributions into a single CREATE TABLE per entity table; sidecars emit separately. SchemaContract<Decorators…>::verify(table, probe) is a runtime introspect-and-assert that throws SchemaContractViolation if any required column or sidecar is missing. Decorator code never runs ALTER at runtime — Atlas (atlasgo.io) owns evolution between deploys; the C++ side only declares desired state and checks it. |
Decorator schema contributions
| Decorator | Entity columns | Entity indexes | Sidecar tables |
|---|---|---|---|
TemporalRepository<T> |
valid_from TEXT NOT NULL DEFAULT '', valid_until TEXT NOT NULL DEFAULT '9999-12-31T23:59:59Z' |
UNIQUE INDEX ux_{table}_entity_valid_until ON {table}(entity_id, valid_until) |
(none) |
AuditLogRepository<T> |
(none) | (none) | audit_log (id, actor_user_id, entity_type, entity_id, op, timestamp_ms) |
ScopeGuardRepository<T> |
(none) | (none) | (none) |
The concrete repo at the bottom of the stack contributes the entity_id + business columns. Stacking is declarative; column dedup keeps duplicate contributions safe.
Wiring it up:
#include "oatpp-authkit/repo/SchemaContract.hpp"
auto exec = [&](const std::string& sql) { /* run DDL */ };
auto probe = [&](const std::string& sql) { /* run SELECT, return bool */ };
// On a fresh DB (e.g. CI dev DB that Atlas inspects):
oatpp_authkit::repo::SchemaBuilder<
ConcretePersonRepository,
oatpp_authkit::repo::TemporalRepository<PersonDto>,
oatpp_authkit::repo::AuditLogRepository<PersonDto>>::create("persons", exec);
// At every app startup, against a populated DB:
oatpp_authkit::repo::SchemaContract<
ConcretePersonRepository,
oatpp_authkit::repo::TemporalRepository<PersonDto>,
oatpp_authkit::repo::AuditLogRepository<PersonDto>>::verify("persons", probe);
Atlas wiring (out of scope for this header): point atlas migrate diff's
--dev-url at a SQLite that SchemaBuilder has populated, and --url
at the live prod DB. Atlas emits versioned migration SQL; the deploy
pipeline applies it. The decorator code stays unchanged across schema
evolutions.
Consume via CMake
# FetchContent (pin to a tag):
include(FetchContent)
FetchContent_Declare(oatpp-authkit
GIT_REPOSITORY https://git.uwe-schuster.info/uwe.admin/oatpp-authkit.git
GIT_TAG v0.1.0)
FetchContent_MakeAvailable(oatpp-authkit)
target_link_libraries(app PRIVATE oatpp::authkit)
Or after cmake --install:
find_package(oatpp-authkit 0.1 REQUIRED)
target_link_libraries(app PRIVATE oatpp::authkit)
Browser-friendly 401/403
By default AuthInterceptor returns application/json for every rejection,
which is correct for /api/* callers but breaks browser navigation: a user
following a stale link or an expired password-reset URL sees a raw
{"status":"Unauthorized"} instead of a real page.
Override IAuthPolicy::unauthenticatedRedirect(path) to redirect browser
navigations to a login or landing page while keeping JSON responses for
fetch/axios callers (detected via path prefix /api/,
X-Requested-With: XMLHttpRequest, or an Accept header that prefers
application/json):
class AppAuthPolicy : public oatpp_authkit::IAuthPolicy {
public:
std::optional<std::string>
unauthenticatedRedirect(const std::string& path) override {
return "/?next=" + oatpp_authkit::AuthInterceptor::urlEncode(path);
}
};
Returning std::nullopt (the default) preserves the legacy JSON behaviour
for all responses.
Tests
cmake -B build -DOATPP_AUTHKIT_BUILD_TESTS=ON
cmake --build build
ctest --test-dir build --output-on-failure
Tests are off by default so consumers pulling the library in via
FetchContent don't pay the cost.
Roadmap
- v0.2 —
AuthInterceptor+requireAdminported onto three seams (IAuthBackend,IAuthPolicy,IRuntimeConfig) so consumers plug in their own user store, public-path list, and admin role set without forking the interceptor. - Later — session cookie helpers, API-key rotation, re-encryption migration.
See docs/security-baseline.md for language-neutral CSP / rate-limit / body-size
constants that non-C++ consumers can re-implement directly.