Compare commits

...

29 commits
v0.1.0 ... main

Author SHA1 Message Date
9976efe1de #16 (audit L-1..L-8): fix the low-severity findings
L-1 RequireRole: guard std::stoi on the bundle id — a non-numeric/out-of-range
    value now yields a clean 401 instead of an uncaught exception → 500.
    AuthPrincipal::id documented as numeric-only (carry UUIDs in username).
L-2 SmtpTransport: require TLS (CURLUSESSL_ALL) for non-loopback relays so a
    stripped STARTTLS can't downgrade credentials/body to cleartext; localhost
    relay stays opportunistic.
L-3 AuditLog: escapeJson now escapes all control chars (RFC 8259) so a newline
    in a field can't forge/corrupt the audit JSON; SKIP_FIELDS gains credential
    names (password/passwordHash/tlsCertDn/apiKey/token/secret) so secrets never
    land in changed_fields.
L-4 ws/Hub: consume the thread_local auth handoff once, up front, and clear it
    unconditionally — a stale value can't attach to a later connection on a
    reused worker thread.
L-5 TemporalRepository: default id generator draws 128 bits from the platform
    CSPRNG (std::random_device) per call instead of a once-seeded mt19937_64,
    so entity_ids aren't predictable from observed output.
L-6 AuthInterceptor: expired-session sweep is now a lock-free atomic timer and
    exception-isolated; documented that resolveBySessionHash() must enforce
    expiry at query time (the sweep is GC only).
L-7 new util/ConstantTime.hpp (constantTimeEquals) + TokenHasher doc requiring a
    >=256-bit cryptographic hash.
L-8 IQueryable: likeEscape + Field::likeContains/likePrefix emit
    `LIKE ? ESCAPE '\'` with %/_/\ escaped for untrusted terms; documented the
    compile-time identifier-source invariant.

Tests: new test_constant_time; likeEscape/likeContains/likePrefix cases added to
test_queryable. All 20 ctest targets pass. README + header docs updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 14:03:01 +02:00
fafee1278f #16 (audit M-1..M-12): fix the medium-severity findings
M-1  TokenExtract: exact-name cookie parse (new pure cookieValue helper) —
     a substring find("session=") could be shadowed by a sibling xsession=,
     defeating __Host-/__Secure- prefix guarantees.
M-2  AuthInterceptor: gate setup-mode pseudo-admin on a loopback bind and log
     the grant; document that IAuthBackend::hasActiveUsers() must fail closed.
M-3  ws/Hub: empty propertyIds now means NO access for non-admins (was "all") —
     a non-admin whose scope set failed to populate no longer gets every
     property's notifications. Admins still get all via role.
M-4  new util/OriginCheck.hpp (originHostname/sameOrigin/originAllowed) +
     Hub doc: WSController must validate Origin at the handshake (CSWSH).
M-6  RedactedFieldRepository: ctor throws on an unknown redaction field name
     (a typo would silently redact nothing, leaving credentials in history).
M-7  RateLimiter: ctor validates capacity (finite >=1) / refillRate (finite >0),
     throws std::invalid_argument — zero/negative/NaN silently disabled it.
M-8  TokenExtract: document that clientIpTrusted's "unknown"/"invalid" sentinels
     collapse to one shared rate-limit bucket off-proxy.
M-9  new util/SessionCookie.hpp: safe-by-default Set-Cookie builder
     (HttpOnly+Secure+SameSite=Strict+Path=/), rejects control chars / ';'.
M-10 AuthInterceptor: Origin/Referer-vs-Host check on session mutations
     (defence in depth atop X-Requested-With); cert path documented as
     non-browser / not CSRF-gated.
M-11 AuthInterceptor: optional injected RateLimiter throttles invalid-token
     attempts per client IP → 429.
M-12 AuthInterceptor: sanitize request method/path (strip control chars, cap
     length) before logging — closes log-line forging (CWE-117).

(M-5 — temporal non-atomic save — was already resolved by the H-4 fix.)

Tests: new test_token_extract / test_rate_limiter / test_origin_check /
test_session_cookie; extended test_redacted_field_repository. All 19 ctest
targets pass. README + header docs updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 13:53:22 +02:00
2e11408240 #16 (audit H-1..H-5): fix the five high-severity findings
- H-1 cert-DN spoofing: IRuntimeConfig::certAuthTrusted() now defaults to
  false (fail-closed). X-SSL-Client-DN is an ordinary request header; a
  loopback bind does not prove it came from a TLS-terminating proxy.
  Consumers must opt in explicitly behind a header-stripping proxy.

- H-3 scope reparenting: ScopeGuardRepository::save() now also checks the
  EXISTING row's scope (via a new required entity-id accessor), so an actor
  can't claim an out-of-scope row by relabelling it in the request body.

- H-2 IQueryable bypass: add ScopeGuardQueryable<T> — filters query()
  results through the same predicate so the queryable surface can't escape
  the scope guard.

- H-4 TemporalRepository TOCTOU: serialise the read-modify-write with a
  per-instance mutex (no more duplicate-live / lost-update under concurrent
  same-entity saves) and add an optional TxRunner so the close-then-insert
  pair can commit/rollback atomically.

- H-5 SMTP header injection: reject CR/LF/NUL in `to`/`fromAddress` before
  building the envelope and From:/To: header lines.

Tests: expand test_repository_decorators (reparenting + queryable filtering),
add curl-guarded test_smtp_transport (base64 vectors + CRLF guard). All 15
ctest targets pass. README updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 12:49:03 +02:00
52449e4159 #15: RedactedFieldRepository — null credentials on historical rows
Adds a decorator that sits below TemporalRepository and redacts
configured fields whenever it sees a save with valid_until != SENTINEL
(i.e., a historical row being closed by the temporal close-then-update
flow). The live row keeps its values intact.

Per Option B from the issue thread: by default the user-repo factory
redacts both passwordHash and tlsCertDn. Empty redaction list passes
everything through unchanged, so non-user temporal stacks compose the
decorator without surprise behaviour.

Files:
- repo/RedactedFieldRepository.hpp — new decorator. Schema contribution
  is empty (purely a save-time transform). Field-name matching uses
  oatpp's reflective property dispatcher and matches against the C++
  identifier name (first DTO_FIELD argument).
- repo/ConcreteUserRepository.hpp — makeUserRepository now wraps the
  concrete repo in RedactedFieldRepository<UserDto>{"passwordHash",
  "tlsCertDn"} before passing to TemporalRepository. Optional second
  argument lets consumers override the redaction list.
- test/test_redacted_field_repository.cpp — five tests cover live-row
  pass-through, historical-row redaction (both fields), partial
  redaction list, empty list, and null-valid_until treated as live.
- README.md — adds RedactedFieldRepository to the header inventory.

14 of 14 tests pass. Bumped 0.12.0 → 0.13.0.

Closes #15

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 20:52:02 +02:00
9040a9ec48 #14 PR 4: relocate users with temporal shape (Option B)
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>
2026-05-06 12:57:59 +02:00
0bb8bef634 #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>
2026-05-06 12:39:52 +02:00
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
606db5a109 #14 PR 0: replace imperative migration kit with declarative SchemaContract
D-replace per #14: rip out PREREQ + RESHAPE_STEPS + applyDecoratorMigrations
and replace with declarative DecoratorSchema (entity columns + indexes +
sidecar tables). SchemaBuilder<Decorators...>::create composes the stack
into a single CREATE TABLE per entity table; SchemaContract::verify
introspects-and-asserts at runtime so code can never run against an
under-migrated DB. Atlas (atlasgo.io) becomes the authority for schema
evolution between deploys — decorator code never runs ALTER at runtime.

- TemporalRepository contributes valid_from/valid_until + UNIQUE composite index
- AuditLogRepository contributes the audit_log sidecar table
- ScopeGuardRepository declares empty contributions for clean stacking
- 8 new tests in test_schema_contract.cpp covering compose / dedup / verify
- README updated; bumped 0.8.0 → 0.9.0

fewo-webapp does not yet call applyDecoratorMigrations, so this is a
clean cut — no consumer-side breakage. PRs 1-4 (role_templates,
user_property_permissions, user_group_permissions, users) follow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 12:14:51 +02:00
792e509b67 #13: TemporalRepository save — stable-live + historical-copy semantics
The decorator's save() flow now preserves the live row's id PK across
updates and captures each prior version as a fresh row with a new id.
This unblocks fewo-webapp#459: the consumer's composite-FK schema needs
stable child references to the live row (UNIQUE(entity_id, valid_until)
with ON UPDATE CASCADE on every child FK), which the previous
close-then-insert flow couldn't provide.

New flow on update (when a live row exists for entity_id):
  1. Clone the live row in memory (cloneDto via oatpp reflection),
     assign a fresh id and set valid_until=now, save → INSERT historical.
  2. Set the new dto's id=live.id (preserve PK), valid_from=now,
     valid_until=SENTINEL, save → inner UPDATEs the live row in place by
     PK.

Inner adapter contract changes from "upsert keyed by (entity_id,
valid_from)" to "upsert keyed by id (per-row PK)". TemporalFieldTraits
gains an id() accessor; OATPP_AUTHKIT_REGISTER_TEMPORAL grows from 4 to
5 args (Dto + IdMember + EntityIdMember + FromMember + UntilMember).

Tests: test_repository_decorators asserts livePk stability across saves
and fresh historicalPk per version; remaining decorator tests updated to
the 5-arg macro form. README's TemporalRepository.hpp row rewritten to
describe the new write semantics.

Bumped CMake version 0.7.0 → 0.8.0 (semantic break — save() no longer
reallocates the live PK; consumers depending on the old contract need
audit).

Closes #13

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 00:10:03 +02:00
b5e1ea1894 #12: per-decorator migration kit (Prereq.hpp)
Each decorator now bundles its schema prereqs alongside its code via
DecoratorPrereq (additive CREATE-IF-NOT-EXISTS) and ReshapeStep
(non-idempotent reshape gated on a detectSql probe).

applyDecoratorMigrations<Decorators...>(table, probe, exec, recorder)
walks the listed decorators at startup, runs every PREREQ, runs every
reshape step whose probe returns false. Database-agnostic — consumer
wires probe/exec to their DbClient. SCHEMA_MIGRATIONS_TABLE_SQL is
provided for observability; the detect-probe is the source of truth.

TemporalRepository ships add_valid_from / add_valid_until /
drop_unique_entity_id / composite_unique (UNIQUE(entity_id, valid_until)
so close-then-insert can run in a deferred-FK transaction).
AuditLogRepository ships the audit_log CREATE TABLE.
ScopeGuardRepository ships nothing — exposes empty PREREQ + zero-length
RESHAPE_STEPS so it can be listed in applyDecoratorMigrations alongside
the schema-touching decorators without SFINAE.

Closes #12

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 21:47:03 +02:00
f5b33a5857 Bump to 0.6.1 (Hub::Listener friend fix)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 16:05:05 +02:00
87bf3b6e56 Hub.hpp: friend Listener so it can call sharedMapper()
sharedMapper was made private in #6 but Listener::handleMessage (defined
later in the same header) still calls Hub::sharedMapper(). Any consumer
that actually instantiates the WebSocket Listener hit a private-access
compile error. Add `friend class Listener;` so Listener can reach the
shared ObjectMapper without exposing it publicly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 16:04:45 +02:00
c6a2dba22b #11: AuditLogRepository<T> + IAuditSink — cross-cutting audit decorator
Closes #11

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 15:54:11 +02:00
1baff07b71 #10: TemporalFieldTraits<T> — decouple decorator from canonical column names
Replace hard-coded dto->entity_id/valid_from/valid_until accesses in
TemporalRepository with trait calls (F::entityId/validFrom/validUntil).
DTOs register canonical→actual member name mapping via
OATPP_AUTHKIT_REGISTER_TEMPORAL. Forgetting to register is a hard
compile error. ITemporalEntity marker is gone; the trait specialisation
carries the contract. Bumps version 0.4.0 → 0.5.0.

New test verifies the full save/close/history/softDelete flow against a
DTO whose columns are id/effective_from/effective_until rather than the
canonical names — exercises the renaming the trait enables.

Closes #10

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 14:23:40 +02:00
55516d4cf1 #9: Optional IQueryable<T> capability + in-house query AST
Closes #9

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 12:55:29 +02:00
08cd32446f #8: TemporalRepository<T> + ScopeGuardRepository<T> decorators
Two cross-cutting decorators that wrap any Repository<TDto> from #7.

TemporalRepository<TDto>:
- Requires TDto : ITemporalEntity (compile-time static_assert).
- save() finds the existing live version, closes its valid_until, and
  inserts a new row at valid_until = '9999-12-31T23:59:59Z' sentinel.
- findByEntityId() returns the live row; findByEntityIdAt(id, at) does
  the [valid_from, valid_until) point-in-time read.
- list() returns live rows only; history(id) returns all versions
  ordered by valid_from. Implements IHistoryRepository<TDto>.
- softDelete closes the live row without inserting a new version.
- Clock and id-generator are constructor-injected (defaults: system_clock
  + 32-char hex from mt19937_64) so the unit tests are deterministic.

The decorator's contract on the inner repository: list() must expose all
rows including historical, and save() must be upsert keyed by
(entity_id, valid_from). Documented on the class.

ScopeGuardRepository<TDto>:
- Generic; knows nothing about "property"/"tenant"/etc. Constructor
  takes a std::function<bool(ActorContext, TDto)> predicate plus a
  std::function<ActorContext()> accessor (so a single instance can
  serve many requests with different actors).
- list() filters; findByEntityId/save/softDelete throw
  ScopeDeniedException on deny.

Tests cover the five acceptance criteria from the issue body:
  - Temporal save closes the prior version
  - Live read returns only the row with valid_until = sentinel
  - Point-in-time read returns the version live at that time
  - History returns all versions in order
  - Scope guard short-circuits when the predicate returns false

ctest: 6/6 green (4 prior + repository_interface + repository_decorators).

Closes #8

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 22:51:39 +02:00
a0c61b3d94 #7: Repository<T> interface set + ITemporalEntity + IHistoryRepository<T>
Header-only foundation for the structural refactor that moves fewo-webapp
from per-entity *Db clients to a shared Repository<TDto> abstraction. This
ships interfaces only — no concrete implementations, no callers updated.

Decisions baked in (all settled in the issue body):
- Mixed entity_id allocation: caller may supply, otherwise the concrete
  repo generates a UUID inside save().
- UnitOfWork / cross-repo transactions: explicitly out of scope.
- Repository<T> is a virtual-method interface, not a C++20 concept.
- History queries live on a separate IHistoryRepository<T> so non-temporal
  repos don't have to implement a stub.

Decorators (TemporalRepository<T>, ScopeGuardRepository<T>) follow in #8;
the optional IQueryable<T> capability for typed filtering follows in #9.
The fewo-webapp Person pilot (uwe.admin/fewo-webapp#457) and the wider
26-entity rollout (uwe.admin/fewo-webapp#458) build on this.

Closes #7

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 22:42:47 +02:00
f43f5f0633 #6: route ad-hoc JSON through ObjectMapper (Option A — DI everywhere, all-in-one)
- New dto/InternalDto.hpp with JsonErrorDto, WsEntityEventDto,
  WsPresenceUpdateDto, WsClientMsgDto.

- JsonErrorHandler: now takes a shared ObjectMapper (DI). Body built
  via writeToString on JsonErrorDto. Closes the audit's concrete bug
  where status.description was embedded raw — a Status with a `"`/`\\`
  in the description previously emitted invalid JSON.

- AuthInterceptor: takes an optional ObjectMapper ctor arg (defaults to
  a fresh mapper). makeForbidden's `msg` is now serialised via
  JsonErrorDto + ObjectMapper, so a `"` in a forbidden-reason no longer
  breaks the response envelope.

- Hub: process-wide sharedMapper() with optional setObjectMapper()
  override. buildPresenceMsg / notifyBooking / notifyPerson all go
  through ObjectMapper-emitted DTOs. User-supplied IDs / property IDs
  / usernames containing `"`/`\\`/control chars are now escaped.

- Listener: jsonStr/jsonInt regex parsers gone. handleMessage parses
  inbound frames via ObjectMapper::readFromString into WsClientMsgDto.
  Malformed JSON / nested objects / escaped quotes — previously silent
  corruption — now produce a clean drop of the frame.

- test/test_json_serialization.cpp: 4 cases pinning the round-trip
  behaviour (special chars in usernames, IDs, status.description, and
  malformed-input rejection).

Bump to 0.4.0 — ctor signatures changed (additive defaults, but the
behaviour of the JSON envelopes is now governed by ObjectMapper).

Closes #6

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:56:05 +02:00
0d2312499e #3: SecurityHeadersInterceptor — strict baseline + CspOverride ctor (Option B)
Aligns the default CSP, X-Frame-Options, HSTS and Permissions-Policy with
docs/security-baseline.md:
  - script-src/style-src drop 'unsafe-inline' and the unpkg.com allowance
  - img-src narrows from 'self' data: https: → 'self' data:
  - connect-src narrows from 'self' wss: ws: → 'self'
  - frame-ancestors flips from 'self' → 'none'
  - X-Frame-Options flips from SAMEORIGIN → DENY
  - HSTS keeps max-age=63072000 but drops includeSubDomains by default
    (apex-clobbering hazard noted in audit #1)
  - Permissions-Policy header added with the baseline sensor allowlist

Adds a CspOverride struct + ctor so consumers that genuinely need a
relaxation (Swagger UI subtree, cross-origin connect, …) can flip
individual directives without forking the interceptor. Empty fields
inherit the strict baseline.

Bumps to 0.3.6 (alongside owner's pending #4 + #5 + #6 work).

Closes #3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:54:58 +02:00
bccd57f47e #5: add IRuntimeConfig::certAuthTrusted() — gate X-SSL-Client-DN trust
New virtual hook on IRuntimeConfig, defaulting to isLoopback() so existing
consumers keep their current behaviour. AuthInterceptor now consults
certAuthTrusted() (instead of isLoopback() directly) to decide whether to
honour an inbound X-SSL-Client-DN header.

Operators with an SSH tunnel to a loopback bind, or a non-TLS proxy that
forwards X-SSL-Client-DN from untrusted clients, can now override the
hook to require additional gating (e.g. an env var, a TLS-only port).

Bump to 0.3.5 (additive — no consumer break).

Closes #5

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:39:57 +02:00
950012d946 #4: BodySizeLimitInterceptor — fail-closed on missing/malformed Content-Length
Body-bearing methods (POST/PUT/PATCH) now reject:
- missing Content-Length → 411
- malformed Content-Length → 400
- non-identity Transfer-Encoding (chunked, etc.) → 411
- declared length > maxBytes → 413 (unchanged)

GET/HEAD/DELETE/OPTIONS/TRACE pass through unchanged. Consumers needing
the legacy fail-open behaviour pass `requireContentLength = false`.

Bump to 0.3.3 (behaviour tightening — consumers on default ctor see new
411/400 responses on requests that previously sailed through).

Closes #4

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:36:50 +02:00
abf6153439 #2: Browser-friendly 401/403 — content-negotiate JSON vs HTML/redirect
AuthInterceptor previously returned application/json for every rejection,
which is wrong for browser navigation: the user followed a /set-password
link and saw a raw {"status":"Unauthorized"} blob.

Add wantsJson() negotiation (path /api/* OR X-Requested-With OR Accept
prefers application/json over text/html) and an IAuthPolicy hook
unauthenticatedRedirect(path) that lets consumers bounce browser
navigations to a landing/login page. JSON callers (fetch/axios) still
get JSON 401/403. Default policy returns nullopt → minimal HTML error
page, never raw JSON to a browser.

Same hook covers both 401 and 403 (decision Option A on the issue) so
consumers wire one redirect target for both unauth and forbidden cases.

Bootstrap a minimal test harness (decision Option T2): CMake option
OATPP_AUTHKIT_BUILD_TESTS gates enable_testing() + a tests subdir.
Adds test_negotiation covering wantsJson + urlEncode. No third-party
test framework — assertions use <cassert> + a tiny REQUIRE macro so the
suite stays dependency-free for future tests.

Closes #2

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 13:23:08 +02:00
46971acf99 AuthInterceptor: strip query string before policy check
Request-target from getStartingLine().path includes the query string
(e.g. "/set-password?token=abc"), causing exact-match public-path
checks like `path == "/set-password"` in IAuthPolicy::isPublicPath
to fail and the request to be rejected with 401.

Strip the query string once at the top of intercept() so policies
and access logs see clean paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 11:41:48 +02:00
448cd9ef8c v0.3.2: Add mail::SmtpTransport — lifted from fewo-webapp
Pure libcurl SMTP + MIME transport, DTO-free so it drops into any
consumer that can cough up host/port/from/user/pass. Callers adapt
their own settings row/DTO to `oatpp_authkit::mail::SmtpConfig`.

Closes the email-service half of #447 (tracked under fewo-webapp #454).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:06:35 +02:00
5cdcb69edb v0.3.1: Add db::AuditLog — lifted from fewo-webapp with table rename
Brings the generic audit-log helper (timestamp + actor + action + entity
+ changed_fields JSON) into the shared library so every consumer picks
up the same shape without reimplementing it. The table is now named
`audit_log` (was `command_log` in fewo-webapp); consumers copy
`AuditLog::CREATE_TABLE_SQL` into their schema.sql so class name and
table name stay in one source of truth.

Legacy data on fewo-webapp migrates via a one-shot
`INSERT INTO audit_log SELECT … FROM command_log; DROP TABLE command_log;`
statement in that project's schema.sql.

Closes #449 (fewo-webapp half follows in separate commits).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 12:36:03 +02:00
ccb77daac5 Add ws::Hub + ws::Listener — WebSocket pub/sub hub
Lifted from fewo-webapp src/ws/ — zero fewo-webapp domain coupling in
the public surface. Classes renamed WSHub→Hub, WSListener→Listener and
namespaced under oatpp_authkit::ws.

Features:
- 64 KB per-message cap (rejects fragmented frames exceeding the buffer)
- 500-socket cap
- Detached housekeeper thread pinging idle sockets >90 s, closing >180 s
- Per-socket SocketInfo (userId, role, property scopes) populated via
  thread_local handoff from the HTTP controller that served the upgrade

Consumers construct a Hub and pass it to oatpp's
HttpConnectionHandler::setSocketInstanceListener. No other integration
required.

Unblocks fewo-webapp #452.
2026-04-22 23:19:40 +02:00
f9a244bf2b Add systemd::notify helper (zero-dep sd_notify protocol)
Lifted from fewo-webapp (src/App.cpp). 15-line helper that speaks the
systemd notification protocol directly — no libsystemd link — for
Type=notify services.

Silent no-op when NOTIFY_SOCKET is unset so the same binary runs
unchanged under systemd or as a plain background process.

Supports Linux abstract-namespace sockets.

Unblocks fewo-webapp #451 and its twin extractions for derived projects.
2026-04-22 23:01:40 +02:00
Uwe Schuster
081e0b36dc v0.2.1: wrap clean-lift headers in namespace oatpp_authkit
The four clean-lift headers (SecurityHeadersInterceptor,
BodySizeLimitInterceptor, JsonErrorHandler, RateLimiter) were copied
verbatim in v0.1.0 and left in the global namespace — consumers that
adopt the library alongside existing same-named classes (e.g. fewo-webapp
during the #417 swap) would hit ODR clashes.

Wrap them in the same namespace the v0.2 auth seams use. Patch bump; no
API surface change beyond the qualifier.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:53:21 +02:00
Uwe Schuster
495c8ddbb9 v0.2.0: IAuthBackend/IAuthPolicy/IRuntimeConfig seams + AuthInterceptor port
Ports the fewo-webapp AuthInterceptor + requireAdmin onto three abstract
interfaces so consumer apps plug in their own user store, public paths,
and runtime config without forking:

  auth/AuthPrincipal.hpp      library-owned {id, username, role} value
  auth/IAuthBackend.hpp       resolveBy{Session,ApiKey,Cert}, hasActiveUsers,
                              deleteExpiredSessions
  auth/IAuthPolicy.hpp        isPublicPath, adminRoles, readonlyRoles,
                              setupModeActive (defaults: admin/readonly,
                              no public paths, setup off)
  auth/IRuntimeConfig.hpp     bindAddress, isLoopback
  auth/AuthInterceptor.hpp    intercept() running the same 6-step ladder as
                              fewo's original (public → setup → cert DN →
                              session/API key → CSRF → readonly)
  auth/RequireRole.hpp        requireUser + requireAdmin helpers reading
                              bundle data (config-driven role sets, not
                              hard-coded 'admin')

TokenHasher is passed in so the library doesn't prescribe SHA-256 vs.
whatever. Bundle keys match fewo's existing controllers so the consumer
migration in #418 is a straightforward adapter swap.

Smoke-compiled against oatpp 1.3.0 headers.

Closes fewo-webapp#413

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:48:43 +02:00
65 changed files with 8219 additions and 82 deletions

View file

@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.14) cmake_minimum_required(VERSION 3.14)
project(oatpp-authkit VERSION 0.1.0 LANGUAGES CXX) project(oatpp-authkit VERSION 0.13.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:
@ -44,3 +44,12 @@ install(FILES
"${CMAKE_CURRENT_BINARY_DIR}/oatpp-authkit-config.cmake" "${CMAKE_CURRENT_BINARY_DIR}/oatpp-authkit-config.cmake"
"${CMAKE_CURRENT_BINARY_DIR}/oatpp-authkit-config-version.cmake" "${CMAKE_CURRENT_BINARY_DIR}/oatpp-authkit-config-version.cmake"
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/oatpp-authkit) DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/oatpp-authkit)
# ─── Tests ───────────────────────────────────────────────────────────────────
# Off by default so consumers pulling us in via FetchContent don't pay the
# cost. Enable with -DOATPP_AUTHKIT_BUILD_TESTS=ON.
option(OATPP_AUTHKIT_BUILD_TESTS "Build oatpp-authkit unit tests" OFF)
if(OATPP_AUTHKIT_BUILD_TESTS)
enable_testing()
add_subdirectory(test)
endif()

View file

@ -10,9 +10,59 @@ hardened auth / security stack. Header-only, oatpp 1.3+, C++17.
| `interceptor/SecurityHeadersInterceptor.hpp` | CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy. Strict defaults. | | `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. | | `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. | | `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/RateLimiter.hpp` | In-memory token-bucket keyed on an arbitrary string (typically the client IP from `clientIpTrusted`). The constructor validates its args (`capacity` finite ≥1, `refillRate` finite >0) and throws `std::invalid_argument` otherwise — a zero/negative/NaN rate previously disabled the limiter silently (authkit#16 M-7). |
| `util/TokenExtract.hpp` | `extractToken` (Cookie/Bearer), `isValidIp` (IPv4/IPv6 via `inet_pton`), `clientIpTrusted` (loopback-gated XFF). | | `util/TokenExtract.hpp` | `extractToken` (Cookie/Bearer) + `cookieValue(header,name)` exact-name cookie parse (authkit#16 M-1 — no substring matching, so a sibling `xsession=` can't shadow `session=`), `isValidIp` (IPv4/IPv6 via `inet_pton`), `clientIpTrusted` (loopback-gated XFF; returns the `"unknown"`/`"invalid"` sentinels off-proxy — treat as one shared rate-limit bucket, M-8). |
| `util/OriginCheck.hpp` | `originHostname`, `sameOrigin(originOrReferer, host)`, `originAllowed(origin, allowlist)` — pure CSRF/CSWSH origin helpers (authkit#16 M-4/M-10). Used by `AuthInterceptor` for session mutations; call `sameOrigin`/`originAllowed` in your WSController to block Cross-Site WebSocket Hijacking at the handshake. |
| `util/SessionCookie.hpp` | `buildSetSessionCookie(token, opts)` / `buildClearSessionCookie(opts)` — safe-by-default `Set-Cookie` builder (HttpOnly + Secure + SameSite=Strict + Path=/ by default; opt out explicitly). Rejects control chars / `;` in fields (authkit#16 M-9). Returns the header value only; framework-agnostic. |
| `util/ConstantTime.hpp` | `constantTimeEquals(a, b)` — branch-free secret comparison for consumers that compare a token/HMAC/hash in memory rather than via an indexed store lookup (authkit#16 L-7). |
| `mail/SmtpTransport.hpp` | libcurl SMTP+MIME sender. Requires TLS (`CURLUSESSL_ALL`) for non-loopback relays so credentials/body can't be sent cleartext if STARTTLS is stripped (authkit#16 L-2); a `localhost`/`127.0.0.1` relay stays opportunistic. Rejects CR/LF/NUL in `to`/`fromAddress` (header-injection guard, authkit#16 H-5). |
| `startup/RequireEncryptionKey.hpp` | `requireEncryptionKey(envVarName, encryptionEnabled, allowPlaintext)` — refuse startup without a symmetric key unless a dev flag overrides. | | `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, an actor accessor, and an `entity_id` accessor at construction; gates every method on the predicate. On `save` the predicate must pass on the incoming DTO **and**, for an update, on the row as it currently stands — so an actor can't reparent an out-of-scope row into its own scope by relabelling it in the request body. Throws `ScopeDeniedException` on deny (catchers translate to 403). Knows nothing about consumer-specific concepts like "property" or "tenant" — the predicate decides. **`ScopeGuardQueryable<T>`** (same header) is the variant for `IQueryable` inners: it filters `query()` results through the predicate too, so the queryable surface can't bypass the guard. |
| `repo/IQueryable.hpp` | Optional capability for repos that resolve a typed query AST. `field<&Dto::col>().eq(...)` style DSL composes via `&&` / `||` / `!`; `Query<TDto>::toSql()` emits parameterised SQL plus a bind bag. Bounded surface — equality, range, IN, LIKE, NULL, ORDER BY, LIMIT/OFFSET. No joins, subqueries, or aggregates. For user-supplied search terms use `likeContains`/`likePrefix` (or `likeEscape`), which escape `%`/`_`/`\` and emit `LIKE ? ESCAPE '\'` (authkit#16 L-8); raw `like()` binds the pattern verbatim (trusted patterns only). Column/table identifiers come only from compile-time registration — never from request data. Concrete repos opt in by deriving `IQueryable<TDto>`. Wrap a scope-guarded `IQueryable` with `ScopeGuardQueryable<T>` (not the plain `ScopeGuardRepository`) so `query()` is scope-filtered. |
| `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. |
| `repo/RedactedFieldRepository.hpp` | Decorator that nulls out named fields on **historical** rows only (authkit#15). Sits below `TemporalRepository` and inspects each `save`: if `valid_until != SENTINEL`, the row is being closed as a historical version, so the configured fields (e.g. `passwordHash`, `tlsCertDn`) are set to null before persisting. The live row keeps its values intact. Built for the case where a credential rides a temporal row — every change creates a historical version with the prior secret preserved, and the redaction prevents a DB breach from yielding every credential a user has ever had. The constructor throws `std::invalid_argument` if a configured field name isn't a DTO member (authkit#16 M-6) — a typo would otherwise silently redact nothing. |
## 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:
```cpp
#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 ## Consume via CMake
@ -34,6 +84,43 @@ find_package(oatpp-authkit 0.1 REQUIRED)
target_link_libraries(app PRIVATE oatpp::authkit) 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`):
```cpp
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
```bash
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 ## Roadmap
- **v0.2**`AuthInterceptor` + `requireAdmin` ported onto three seams - **v0.2**`AuthInterceptor` + `requireAdmin` ported onto three seams

102
docs/MIGRATIONS.md Normal file
View 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.

View file

@ -0,0 +1,376 @@
#ifndef OATPP_AUTHKIT_AUTH_INTERCEPTOR_HPP
#define OATPP_AUTHKIT_AUTH_INTERCEPTOR_HPP
#include <atomic>
#include <chrono>
#include <cstdint>
#include <memory>
#include <string>
#include <functional>
#include "oatpp/web/server/interceptor/RequestInterceptor.hpp"
#include "oatpp/web/protocol/http/outgoing/Response.hpp"
#include "oatpp/web/protocol/http/outgoing/ResponseFactory.hpp"
#include "oatpp/web/protocol/http/Http.hpp"
#include "oatpp/parser/json/mapping/ObjectMapper.hpp"
#include "IAuthBackend.hpp"
#include "IAuthPolicy.hpp"
#include "IRuntimeConfig.hpp"
#include "../util/TokenExtract.hpp"
#include "../util/OriginCheck.hpp"
#include "../util/RateLimiter.hpp"
#include "../dto/InternalDto.hpp"
namespace oatpp_authkit {
/** @brief Caller-supplied hash function — SHA-256 on the raw token typically.
*
* authkit#16 L-7: MUST be a fixed-length cryptographic hash (256-bit, e.g.
* SHA-256) over a high-entropy token. The store looks the session/API key up
* by this hash, so a weak/short/truncating hash weakens matching. Consumers
* that compare a secret in memory (rather than via an indexed lookup) should
* use `oatpp_authkit::constantTimeEquals` (`util/ConstantTime.hpp`). */
using TokenHasher = std::function<std::string(const std::string&)>;
/**
* @brief Generic request interceptor built on IAuthBackend + IAuthPolicy + IRuntimeConfig.
*
* Order of checks:
* 1. Public path pass.
* 2. Setup mode (empty users table + policy->setupModeActive() + loopback bind) pseudo-admin.
* 3. X-SSL-Client-DN header (only trusted when `IRuntimeConfig::certAuthTrusted()`) cert auth.
* 4. Session cookie / Bearer token backend->resolveBySessionHash / resolveByApiKeyHash.
* (Invalid tokens are optionally per-IP rate-limited 429 when a RateLimiter is supplied.)
* 5. CSRF defence (session cookie + mutation): require X-Requested-With AND,
* when present, an Origin/Referer whose host matches the request Host.
* 6. Readonly roles cannot mutate.
*
* Bundle data written on success (consumed by requireAdmin / requireUser):
* auth_user_id (oatpp::String, decimal int)
* auth_user_role (oatpp::String)
* auth_username (oatpp::String)
*/
class AuthInterceptor : public oatpp::web::server::interceptor::RequestInterceptor {
private:
std::shared_ptr<IAuthBackend> m_backend;
std::shared_ptr<IAuthPolicy> m_policy;
std::shared_ptr<IRuntimeConfig> m_runtime;
TokenHasher m_hashToken;
std::shared_ptr<oatpp::data::mapping::ObjectMapper> m_mapper;
std::shared_ptr<RateLimiter> m_authLimiter; ///< Optional (authkit#16 M-11): throttles invalid-token attempts per client IP.
using Status = oatpp::web::protocol::http::Status;
using ResponseFactory = oatpp::web::protocol::http::outgoing::ResponseFactory;
std::shared_ptr<OutgoingResponse> makeJsonError(Status status, const std::string& body) {
auto r = ResponseFactory::createResponse(status, body.c_str());
r->putHeader("Content-Type", "application/json");
return r;
}
/** @brief Build a JsonErrorDto-shaped body via ObjectMapper (#6) — escapes
* any user-supplied `msg` so a stray `"`/`\\`/control character doesn't
* break the JSON envelope. */
std::shared_ptr<OutgoingResponse> makeJsonError(Status status,
const std::string& statusName,
const std::string& msg) {
auto dto = dto::JsonErrorDto::createShared();
dto->status = oatpp::String(statusName);
dto->code = status.code;
if (!msg.empty()) dto->message = oatpp::String(msg);
oatpp::String json = m_mapper->writeToString(dto);
auto r = ResponseFactory::createResponse(status, json);
r->putHeader("Content-Type", "application/json");
return r;
}
std::shared_ptr<OutgoingResponse> makeHtmlError(Status status, const std::string& title) {
std::string body = "<!doctype html><meta charset=\"utf-8\"><title>"
+ title + "</title><h1>" + title + "</h1>";
auto r = ResponseFactory::createResponse(status, body.c_str());
r->putHeader("Content-Type", "text/html; charset=utf-8");
return r;
}
std::shared_ptr<OutgoingResponse> makeRedirect(const std::string& location) {
auto r = ResponseFactory::createResponse(Status::CODE_302, "");
r->putHeader("Location", location.c_str());
r->putHeader("Cache-Control", "no-store");
return r;
}
public:
/**
* @brief Heuristic: does this caller expect a JSON error body?
*
* True when any of:
* - path begins with `/api/` (API surface always JSON)
* - `X-Requested-With: XMLHttpRequest` (jQuery/axios/explicit AJAX)
* - `Accept` mentions `application/json` and does NOT prefer `text/html`
*
* Otherwise treated as a browser navigation that should get HTML or a
* redirect. Exposed as a static so the negotiation rule is unit-testable
* without spinning up a request.
*/
static bool wantsJson(const std::string& path,
const std::string& xRequestedWith,
const std::string& accept)
{
if (path.size() >= 5 && path.compare(0, 5, "/api/") == 0) return true;
if (!xRequestedWith.empty()) return true;
bool hasJson = accept.find("application/json") != std::string::npos;
bool hasHtml = accept.find("text/html") != std::string::npos;
if (hasJson && !hasHtml) return true;
return false;
}
/**
* @brief Percent-encode the unreserved subset for use in a `next=` param.
* Static + side-effect-free so consumers and tests can reuse it.
*/
static std::string urlEncode(const std::string& s) {
static const char* hex = "0123456789ABCDEF";
std::string out;
out.reserve(s.size());
for (unsigned char c : s) {
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~') {
out.push_back(static_cast<char>(c));
} else {
out.push_back('%');
out.push_back(hex[c >> 4]);
out.push_back(hex[c & 0xF]);
}
}
return out;
}
private:
bool requestWantsJson(const std::shared_ptr<IncomingRequest>& req,
const std::string& path)
{
auto xrw = req->getHeader("X-Requested-With");
auto accept = req->getHeader("Accept");
return wantsJson(path,
xrw ? *xrw : std::string{},
accept ? *accept : std::string{});
}
std::shared_ptr<OutgoingResponse> makeUnauthorized(
const std::shared_ptr<IncomingRequest>& req, const std::string& path)
{
if (requestWantsJson(req, path))
return makeJsonError(Status::CODE_401, "Unauthorized", "");
if (auto loc = m_policy->unauthenticatedRedirect(path))
return makeRedirect(*loc);
return makeHtmlError(Status::CODE_401, "Unauthorized");
}
std::shared_ptr<OutgoingResponse> makeForbidden(
const std::shared_ptr<IncomingRequest>& req, const std::string& path,
const std::string& msg = "")
{
if (requestWantsJson(req, path)) {
// #6: route through ObjectMapper so any caller-supplied `msg`
// containing `"`/`\\`/control chars is escaped instead of breaking
// the response envelope.
return makeJsonError(Status::CODE_403, "Forbidden", msg);
}
if (auto loc = m_policy->unauthenticatedRedirect(path))
return makeRedirect(*loc);
return makeHtmlError(Status::CODE_403, "Forbidden");
}
void writeBundle(const std::shared_ptr<IncomingRequest>& req, const AuthPrincipal& p) {
req->putBundleData("auth_user_id", oatpp::String(std::to_string(p.id).c_str()));
req->putBundleData("auth_user_role", oatpp::String(p.role.c_str()));
req->putBundleData("auth_username", oatpp::String(p.username.c_str()));
}
/** @brief Neutralise control characters before logging (authkit#16 M-12).
* The request path/method are attacker-controlled; a raw CR/LF in the
* request target would otherwise forge log lines (CWE-117). */
static std::string sanitizeForLog(const std::string& s) {
std::string out;
const std::size_t cap = 256;
out.reserve(s.size() < cap ? s.size() : cap);
for (unsigned char c : s) {
if (out.size() >= cap) break;
out.push_back((c < 0x20 || c == 0x7f) ? '?' : static_cast<char>(c));
}
return out;
}
static void logEvent(int status, const std::string& method,
const std::string& path, const std::string& reason) {
OATPP_LOGW("authkit", "[%d] %s %s — %s",
status, sanitizeForLog(method).c_str(),
sanitizeForLog(path).c_str(), reason.c_str());
}
bool isMutation(const std::string& method) {
return method != "GET" && method != "HEAD" && method != "OPTIONS";
}
bool isReadonly(const std::string& role) {
return m_policy->readonlyRoles().count(role) > 0;
}
public:
AuthInterceptor(std::shared_ptr<IAuthBackend> backend,
std::shared_ptr<IAuthPolicy> policy,
std::shared_ptr<IRuntimeConfig> runtime,
TokenHasher hashToken,
std::shared_ptr<oatpp::data::mapping::ObjectMapper> mapper = nullptr,
std::shared_ptr<RateLimiter> authRateLimiter = nullptr)
: m_backend(std::move(backend))
, m_policy(std::move(policy))
, m_runtime(std::move(runtime))
, m_hashToken(std::move(hashToken))
, m_mapper(mapper ? mapper : oatpp::parser::json::mapping::ObjectMapper::createShared())
, m_authLimiter(std::move(authRateLimiter)) {}
std::shared_ptr<OutgoingResponse> intercept(
const std::shared_ptr<IncomingRequest>& request) override
{
// Periodic expired-session GC — at most once per hour, process-wide.
// authkit#16 L-6: this is best-effort cleanup, NOT the expiry gate —
// resolveBySessionHash() must itself reject expired sessions (see
// IAuthBackend). The timer is a lock-free atomic so concurrent requests
// don't race the read-modify-write, and the sweep is exception-isolated
// so a transient DB error during GC can't 500 an otherwise-valid request.
{
using Clock = std::chrono::steady_clock;
static std::atomic<std::int64_t> lastCleanupMs{-1};
const std::int64_t nowMs = std::chrono::duration_cast<std::chrono::milliseconds>(
Clock::now().time_since_epoch()).count();
std::int64_t prev = lastCleanupMs.load(std::memory_order_relaxed);
if (prev < 0) {
// First request: arm the timer, don't sweep yet.
lastCleanupMs.compare_exchange_strong(prev, nowMs);
} else if (nowMs - prev >= 3600000) {
// Only the thread that wins the CAS performs the sweep.
if (lastCleanupMs.compare_exchange_strong(prev, nowMs)) {
try { m_backend->deleteExpiredSessions(); } catch (...) {}
}
}
}
std::string path = request->getStartingLine().path.std_str();
const std::string method = request->getStartingLine().method.std_str();
// Strip query string — request-target includes it, but policy checks
// (and access logs) want just the path.
auto qpos = path.find('?');
if (qpos != std::string::npos) path.resize(qpos);
if (m_policy->isPublicPath(path)) return nullptr;
// Setup mode: empty users + policy opts in + loopback bind → pseudo-admin.
// authkit#16 M-2: gate on isLoopback() so a stray SETUP_MODE sentinel can
// never expose anonymous admin on a public bind, and log the grant (it
// was previously silent). hasActiveUsers() must fail closed (see
// IAuthBackend) — a swallowed DB error returning false would otherwise
// open the entire API.
if (m_policy->setupModeActive() && m_runtime->isLoopback()
&& !m_backend->hasActiveUsers()) {
logEvent(200, method, path, "setup-mode pseudo-admin granted (no users yet)");
AuthPrincipal p{0, "setup", "admin"};
writeBundle(request, p);
return nullptr;
}
// TLS cert DN — only trusted when the runtime hook says so (#5).
// `certAuthTrusted()` defaults to `false` (fail closed); consumers must
// opt in explicitly and only behind a proxy that strips the inbound
// `X-SSL-Client-DN` header and re-sets it from a verified client cert.
//
// authkit#16 M-10: the cert path is deliberately NOT CSRF-gated. CSRF is
// a browser-cookie problem; cert auth is for non-browser / mTLS clients
// that don't auto-attach an ambient credential, so `X-Requested-With` /
// Origin checks don't apply. Do not expose cert auth to cookie-bearing
// browser sessions.
auto certDnH = request->getHeader("X-SSL-Client-DN");
if (m_runtime->certAuthTrusted() && certDnH && !certDnH->empty()) {
if (auto p = m_backend->resolveByCertDn(std::string(*certDnH))) {
writeBundle(request, *p);
if (isReadonly(p->role) && isMutation(method)) {
logEvent(403, method, path, "readonly cert user mutation");
return makeForbidden(request, path);
}
return nullptr;
}
}
// Session / API key token.
std::string token = extractToken(request);
if (token.empty()) {
logEvent(401, method, path, "no token");
return makeUnauthorized(request, path);
}
std::string hash = m_hashToken(token);
std::optional<AuthPrincipal> p;
bool viaSession = false;
if ((p = m_backend->resolveBySessionHash(hash))) {
viaSession = true;
} else if ((p = m_backend->resolveByApiKeyHash(hash))) {
viaSession = false;
} else {
// authkit#16 M-11: when an optional limiter is wired in, throttle
// repeated invalid-token submissions per client IP (token guessing /
// credential stuffing) and answer 429 before the 401.
if (m_authLimiter) {
const std::string ip = clientIpTrusted(request, m_runtime->bindAddress());
if (!m_authLimiter->allow("authfail:" + ip)) {
logEvent(429, method, path, "auth rate limit (invalid token)");
return makeJsonError(Status::CODE_429, "Too Many Requests", "");
}
}
logEvent(401, method, path, "invalid token");
return makeUnauthorized(request, path);
}
// CSRF defence-in-depth: session cookie + mutation requires X-Requested-With.
if (viaSession && isMutation(method)) {
auto xrw = request->getHeader("X-Requested-With");
if (!xrw || xrw->empty()) {
logEvent(403, method, path, "missing X-Requested-With");
return makeForbidden(request, path, "Missing X-Requested-With header");
}
// authkit#16 M-10: second CSRF layer — when an Origin (or, failing
// that, Referer) header is present on a cookie-auth mutation, its
// host must match the request Host. Catches cross-site forgeries
// even if a permissive CORS policy ever lets X-Requested-With
// through. Compared by hostname (port/scheme ignored) to stay
// correct behind a TLS-terminating proxy; when neither header is
// present we fall back to the X-Requested-With guarantee above.
auto host = request->getHeader("Host");
auto origin = request->getHeader("Origin");
auto referer = request->getHeader("Referer");
const std::string hostStr = host ? std::string(*host) : std::string();
if (origin && !origin->empty()) {
if (!sameOrigin(std::string(*origin), hostStr)) {
logEvent(403, method, path, "Origin/Host mismatch");
return makeForbidden(request, path, "Cross-origin request rejected");
}
} else if (referer && !referer->empty()) {
if (!sameOrigin(std::string(*referer), hostStr)) {
logEvent(403, method, path, "Referer/Host mismatch");
return makeForbidden(request, path, "Cross-origin request rejected");
}
}
}
writeBundle(request, *p);
if (isReadonly(p->role) && isMutation(method)) {
logEvent(403, method, path, "readonly user mutation");
return makeForbidden(request, path);
}
return nullptr;
}
};
} // namespace oatpp_authkit
#endif

View file

@ -0,0 +1,28 @@
#ifndef OATPP_AUTHKIT_AUTH_PRINCIPAL_HPP
#define OATPP_AUTHKIT_AUTH_PRINCIPAL_HPP
#include <string>
namespace oatpp_authkit {
/**
* @brief Library-owned authenticated-user value.
*
* Intentionally decoupled from any consumer-specific DTO so the library
* stays portable. Consumers translate from their own UserDto (or whatever)
* into this struct inside their IAuthBackend implementation.
*/
struct AuthPrincipal {
/// Stable numeric id from the user store. NOTE (authkit#16 L-1): this is an
/// `int`, so it only round-trips numeric ids. A store keyed on UUIDs / other
/// non-numeric ids must not stuff them here — `requireUser` rejects a
/// non-numeric bundle id with 401. Carry such identities in `username` (or
/// extend this struct) instead.
int id{0};
std::string username;
std::string role; ///< Arbitrary string; policy decides what "admin"/"readonly" mean.
};
} // namespace oatpp_authkit
#endif

View file

@ -0,0 +1,66 @@
#ifndef OATPP_AUTHKIT_AUTH_IAUTH_BACKEND_HPP
#define OATPP_AUTHKIT_AUTH_IAUTH_BACKEND_HPP
#include <optional>
#include <string>
#include "AuthPrincipal.hpp"
namespace oatpp_authkit {
/**
* @brief Consumer-supplied adapter from library primitives user store.
*
* The library never reads the database directly. The interceptor calls
* these methods, the concrete implementation (owned by the consumer app)
* wraps `UserDb` / `CertificateDb` / whatever and returns library-owned
* `AuthPrincipal` structs.
*
* All methods must be thread-safe (the interceptor is invoked from oatpp
* worker threads).
*/
class IAuthBackend {
public:
virtual ~IAuthBackend() = default;
/** @brief Look up an *active, non-expired* session by its hashed token.
*
* @warning Enforce expiry HERE (authkit#16 L-6): filter on the session's
* `expires_at` in this query and return `std::nullopt` for an
* expired row. The interceptor's periodic `deleteExpiredSessions`
* is best-effort garbage collection that only runs on request
* traffic relying on it for expiry would leave a stale token
* valid until the next sweep (or indefinitely on an idle server). */
virtual std::optional<AuthPrincipal> resolveBySessionHash(const std::string& hash) = 0;
/** @brief Look up an API key by its hashed token; also touch `last_used_at`. */
virtual std::optional<AuthPrincipal> resolveByApiKeyHash(const std::string& hash) = 0;
/**
* @brief Look up a user by TLS client cert DN. Return nullopt if your
* app doesn't support cert auth the interceptor silently skips
* this step.
*/
virtual std::optional<AuthPrincipal> resolveByCertDn(const std::string& /*dn*/) {
return std::nullopt;
}
/** @brief True iff at least one active user exists. Used for setup-mode gate.
*
* @warning Must FAIL CLOSED (authkit#16 M-2): on any uncertainty a DB
* error, a timeout, an empty result you can't trust return
* `true` (or throw), never `false`. A `false` returned on a
* swallowed error opens the setup-mode pseudo-admin path and
* grants unauthenticated admin to every request. The interceptor
* additionally gates setup mode on a loopback bind, but the
* authoritative "are we still in first-run setup?" answer is
* yours and must not degrade open. */
virtual bool hasActiveUsers() = 0;
/** @brief Delete expired session rows. Called periodically by the interceptor. */
virtual void deleteExpiredSessions() = 0;
};
} // namespace oatpp_authkit
#endif

View file

@ -0,0 +1,69 @@
#ifndef OATPP_AUTHKIT_AUTH_IAUTH_POLICY_HPP
#define OATPP_AUTHKIT_AUTH_IAUTH_POLICY_HPP
#include <optional>
#include <set>
#include <string>
namespace oatpp_authkit {
/**
* @brief Consumer-supplied policy for public paths, roles, and setup mode.
*
* Ships with a conservative default impl (no public paths, `admin`/`readonly`
* role conventions, setup mode always off). Subclass to add your app's
* public-path list (`/guest/*`, `/calendar.ics`, etc.) and to expose the
* `SETUP_MODE` sentinel check.
*/
class IAuthPolicy {
public:
virtual ~IAuthPolicy() = default;
/** @brief True iff the given path bypasses auth entirely. */
virtual bool isPublicPath(const std::string& /*path*/) { return false; }
/** @brief Roles that pass an admin-required check. */
virtual const std::set<std::string>& adminRoles() {
static const std::set<std::string> k{"admin"};
return k;
}
/** @brief Roles that may only read (GET/HEAD/OPTIONS); mutations → 403. */
virtual const std::set<std::string>& readonlyRoles() {
static const std::set<std::string> k{"readonly"};
return k;
}
/**
* @brief Setup-mode escape hatch: when true AND the user table is empty,
* the interceptor allows unauthenticated requests and injects a
* pseudo-admin into the bundle. Consumers typically gate this on
* the presence of a `SETUP_MODE` sentinel file.
*/
virtual bool setupModeActive() { return false; }
/**
* @brief Where to send a browser navigation that hits a 401/403, instead
* of the default JSON error body.
*
* Returning `std::nullopt` (the default) keeps the legacy behaviour:
* `AuthInterceptor` always responds with `application/json`. Returning a
* URL makes the interceptor emit a `302` with `Location:` set whenever
* the request looks like a browser navigation (HTML `Accept`, no
* `X-Requested-With`, path outside `/api/`). API callers (`fetch`,
* `axios`, anything sending `X-Requested-With: XMLHttpRequest` or
* targeting `/api/*`) still receive the JSON 401/403 so client-side
* error handling keeps working.
*
* The same hook covers both 401 (no/invalid auth) and 403 (logged in
* but not allowed) typical wiring is to bounce both to `/` or
* `/login?next=...`. Consumers that need to differentiate can branch
* inside the override.
*/
virtual std::optional<std::string>
unauthenticatedRedirect(const std::string& /*path*/) { return std::nullopt; }
};
} // namespace oatpp_authkit
#endif

View file

@ -0,0 +1,60 @@
#ifndef OATPP_AUTHKIT_AUTH_IRUNTIME_CONFIG_HPP
#define OATPP_AUTHKIT_AUTH_IRUNTIME_CONFIG_HPP
#include <string>
namespace oatpp_authkit {
/**
* @brief Runtime config surface the interceptor needs.
*
* Small enough that consumers typically implement it inline against their
* existing Config globals. Provided as an interface rather than a struct
* so the values can change at runtime (e.g. bind address flipping during
* test setup) without restarting the interceptor.
*/
class IRuntimeConfig {
public:
virtual ~IRuntimeConfig() = default;
/** @brief Host the service is bound to ("127.0.0.1", "::1", "0.0.0.0", ...). */
virtual std::string bindAddress() = 0;
/** @brief Convenience: true iff `bindAddress()` is a loopback literal.
*
* Used as the *binding* gate (e.g. trusting `X-Forwarded-For` / `X-Real-IP`).
* For cert-DN trust, prefer `certAuthTrusted()` operators with an SSH tunnel
* or a misconfigured proxy can forward `X-SSL-Client-DN` from untrusted clients
* even when the service binds to loopback.
*/
virtual bool isLoopback() {
const std::string a = bindAddress();
return a == "127.0.0.1" || a == "::1" || a == "localhost";
}
/** @brief Whether incoming `X-SSL-Client-DN` headers should be trusted (#5).
*
* Default: `false` **fail closed**. `X-SSL-Client-DN` is an ordinary
* request header; binding to loopback does NOT guarantee it originates
* from a TLS-terminating proxy. An SSH tunnel, a co-located process, or a
* reverse proxy that forwards the client-supplied header verbatim can all
* present an arbitrary DN to a loopback-bound service, so trusting it by
* default is an authentication-bypass primitive. Consumers must opt in
* explicitly, and only once the upstream proxy unconditionally strips the
* inbound header and re-sets it from a verified client certificate, e.g.:
*
* bool certAuthTrusted() override {
* return isLoopback() && std::getenv("TRUST_CERT_DN") != nullptr;
* }
*
* When this returns `false`, `AuthInterceptor` ignores any inbound
* `X-SSL-Client-DN` header and falls through to token / session auth.
*/
virtual bool certAuthTrusted() {
return false;
}
};
} // namespace oatpp_authkit
#endif

View file

@ -0,0 +1,70 @@
#ifndef OATPP_AUTHKIT_AUTH_REQUIRE_ROLE_HPP
#define OATPP_AUTHKIT_AUTH_REQUIRE_ROLE_HPP
#include <memory>
#include <string>
#include "oatpp/web/protocol/http/Http.hpp"
#include "oatpp/web/server/api/ApiController.hpp"
#include "oatpp/core/macro/codegen.hpp"
#include "IAuthPolicy.hpp"
namespace oatpp_authkit {
using IncomingRequest = oatpp::web::protocol::http::incoming::Request;
using Status = oatpp::web::protocol::http::Status;
/**
* @brief Pull the authenticated user into local scope inside a controller
* endpoint. Throws 401 when no principal is present in the bundle.
*
* Usage inside an ENDPOINT:
* auto me = oatpp_authkit::requireUser(request);
* // me.id, me.role, me.username
*/
inline AuthPrincipal requireUser(const std::shared_ptr<IncomingRequest>& request) {
auto id = request->getBundleData<oatpp::String>("auth_user_id");
auto role = request->getBundleData<oatpp::String>("auth_user_role");
auto username = request->getBundleData<oatpp::String>("auth_username");
OATPP_ASSERT_HTTP(id && role, Status::CODE_401, "Authentication required");
AuthPrincipal p;
// authkit#16 L-1: parse defensively. The bundle id is normally a decimal
// written by AuthInterceptor, but a non-numeric / out-of-range value (or a
// future principal id that isn't an int) must surface as a clean 401, not
// an uncaught std::invalid_argument/out_of_range escaping the endpoint as a
// 500. The OATPP_ASSERT_HTTP is kept OUTSIDE the try so its HttpError isn't
// swallowed by the catch.
bool idOk = false;
{
const std::string idStr(*id);
try {
std::size_t consumed = 0;
int parsed = std::stoi(idStr, &consumed);
if (consumed == idStr.size()) { p.id = parsed; idOk = true; }
} catch (...) { idOk = false; }
}
OATPP_ASSERT_HTTP(idOk, Status::CODE_401, "Authentication required");
p.role = std::string(*role);
p.username = username ? std::string(*username) : "";
return p;
}
/**
* @brief Reject the request with 403 unless the authenticated user is in
* the policy's admin role set.
*/
inline AuthPrincipal requireAdmin(const std::shared_ptr<IncomingRequest>& request,
IAuthPolicy& policy)
{
auto me = requireUser(request);
OATPP_ASSERT_HTTP(policy.adminRoles().count(me.role) > 0,
Status::CODE_403, "Admin required");
return me;
}
} // namespace oatpp_authkit
#endif

View file

@ -0,0 +1,237 @@
#ifndef oatpp_authkit_db_AuditLog_hpp
#define oatpp_authkit_db_AuditLog_hpp
#include "oatpp-sqlite/orm.hpp"
#include "oatpp/core/macro/codegen.hpp"
#include "oatpp/core/macro/component.hpp"
#include <set>
#include <string>
#include <sstream>
namespace oatpp_authkit {
/**
* @brief Audit logging service logs entity mutations to the `audit_log` table.
*
* Replaces SQLite audit triggers with explicit C++ calls from controllers.
* Four operations:
* - logCreate(table, entityId)
* - logDelete(table, entityId)
* - logUpdate(table, entityId) no-diff form (junction changes, bulk patches)
* - logUpdate<Dto>(table, entityId, oldRow, newRow) computes a JSON field diff
*
* Schema: consumers copy `AuditLog::CREATE_TABLE_SQL` into their `schema.sql`
* (or execute it at startup) so every project that uses `AuditLog` ends up on
* the same table shape. That keeps the class name (`AuditLog`), the table
* name (`audit_log`), and the column set in one source of truth.
*
* Usage in controllers:
* m_auditLog->logCreate("bookings", entityId, actor);
* m_auditLog->logUpdate<BookingDto>("bookings", entityId, oldRow, newRow, actor);
*
* Note on legacy data (fewo-webapp only): the pre-lift table was named
* `command_log`; a one-shot migration (INSERT INTO audit_log SELECT
* FROM command_log; DROP TABLE command_log;) copies the existing rows over.
*/
class AuditLog {
public:
/**
* @brief DDL for the audit_log table + supporting indexes.
*
* Consumers include this in their schema-init flow (e.g. executing it
* at startup) so every project using `AuditLog` has the same table
* shape without each project re-declaring the column set.
*/
static constexpr const char* CREATE_TABLE_SQL = R"SQL(
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
table_name TEXT NOT NULL,
entity_id TEXT NOT NULL,
operation TEXT NOT NULL,
changed_fields TEXT,
actor TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at);
CREATE INDEX IF NOT EXISTS idx_audit_log_table_entity ON audit_log(table_name, entity_id);
)SQL";
#include OATPP_CODEGEN_BEGIN(DbClient)
/** @brief Minimal DbClient for inserting into audit_log. */
class AuditLogDb : public oatpp::orm::DbClient {
public:
AuditLogDb(const std::shared_ptr<oatpp::orm::Executor>& executor)
: oatpp::orm::DbClient(executor) {}
QUERY(logOp,
"INSERT INTO audit_log(table_name, entity_id, operation, changed_fields, actor) "
"VALUES (:t, :e, :o, :f, :a);",
PARAM(oatpp::String, t),
PARAM(oatpp::String, e),
PARAM(oatpp::String, o),
PARAM(oatpp::String, f),
PARAM(oatpp::String, a))
};
#include OATPP_CODEGEN_END(DbClient)
private:
std::shared_ptr<AuditLogDb> m_db;
/** @brief Fields to skip when computing UPDATE diffs — internal/metadata
* plus credentials. authkit#16 L-3: never copy a secret into the long-lived
* `audit_log.changed_fields` column (covers both snake_case and camelCase
* identifiers since the diff matches on the DTO's C++ field name). */
static inline const std::set<std::string> SKIP_FIELDS = {
"id", "entity_id", "created_at", "updated_at", "valid_from",
"password", "passwordHash", "password_hash",
"tlsCertDn", "tls_cert_dn",
"apiKey", "api_key", "token", "secret"
};
/** @brief RFC 8259-compliant JSON string escaping. authkit#16 L-3: the
* previous version escaped only `\` and `"`, so a control character (e.g.
* a newline in a user-supplied name) produced invalid JSON in the audit
* trail and allowed newline/log injection into anything re-emitting the
* column. */
static std::string escapeJson(const std::string& s) {
static const char* hex = "0123456789abcdef";
std::string out;
out.reserve(s.size() + 8);
for (unsigned char c : s) {
switch (c) {
case '\\': out += "\\\\"; break;
case '"': out += "\\\""; break;
case '\b': out += "\\b"; break;
case '\f': out += "\\f"; break;
case '\n': out += "\\n"; break;
case '\r': out += "\\r"; break;
case '\t': out += "\\t"; break;
default:
if (c < 0x20) {
out += "\\u00";
out += hex[(c >> 4) & 0xF];
out += hex[c & 0xF];
} else {
out += static_cast<char>(c);
}
}
}
return out;
}
/**
* @brief Serialise an oatpp::Void to JSON: String / Int32 / Int64 /
* Float32 / Float64 / Boolean / null. Unknown types serialise as null.
*/
static std::string valueToJson(const oatpp::Void& value) {
if (!value.getPtr()) return "null";
auto classId = value.getValueType()->classId;
if (classId == oatpp::String::Class::CLASS_ID) {
auto* s = static_cast<const std::string*>(value.getPtr().get());
return "\"" + escapeJson(*s) + "\"";
}
if (classId == oatpp::Int32::Class::CLASS_ID) {
return std::to_string(*static_cast<const v_int32*>(value.getPtr().get()));
}
if (classId == oatpp::Int64::Class::CLASS_ID) {
return std::to_string(*static_cast<const v_int64*>(value.getPtr().get()));
}
if (classId == oatpp::Float64::Class::CLASS_ID) {
char buf[64];
std::snprintf(buf, sizeof(buf), "%g", *static_cast<const v_float64*>(value.getPtr().get()));
return buf;
}
if (classId == oatpp::Float32::Class::CLASS_ID) {
char buf[64];
std::snprintf(buf, sizeof(buf), "%g", (double)*static_cast<const v_float32*>(value.getPtr().get()));
return buf;
}
if (classId == oatpp::Boolean::Class::CLASS_ID) {
return *static_cast<const bool*>(value.getPtr().get()) ? "true" : "false";
}
return "null";
}
static bool valuesEqual(const oatpp::Void& a, const oatpp::Void& b) {
bool aNull = !a.getPtr();
bool bNull = !b.getPtr();
if (aNull && bNull) return true;
if (aNull || bNull) return false;
return valueToJson(a) == valueToJson(b);
}
public:
AuditLog(const std::shared_ptr<oatpp::orm::Executor>& executor)
: m_db(std::make_shared<AuditLogDb>(executor)) {}
/** @brief Log a CREATE (entity inserted). Optional connection pins to a transaction. */
void logCreate(const oatpp::String& table, const oatpp::String& entityId,
const oatpp::String& actor = nullptr,
const oatpp::provider::ResourceHandle<oatpp::orm::Connection>& connection = nullptr) {
m_db->logOp(table, entityId, "CREATE", nullptr, actor, connection);
}
/** @brief Log a DELETE (entity removed). Optional connection pins to a transaction. */
void logDelete(const oatpp::String& table, const oatpp::String& entityId,
const oatpp::String& actor = nullptr,
const oatpp::provider::ResourceHandle<oatpp::orm::Connection>& connection = nullptr) {
m_db->logOp(table, entityId, "DELETE", nullptr, actor, connection);
}
/** @brief Log an UPDATE without field-level diff (junction changes, bulk patches). */
void logUpdate(const oatpp::String& table, const oatpp::String& entityId,
const oatpp::String& actor = nullptr,
const oatpp::provider::ResourceHandle<oatpp::orm::Connection>& connection = nullptr) {
m_db->logOp(table, entityId, "UPDATE", nullptr, actor, connection);
}
/**
* @brief Log an UPDATE with a JSON diff of the fields whose values changed.
*
* Uses oatpp DTO reflection to produce `{"field": newValue, ...}`. If no
* field changed, no row is written.
*/
template<typename DtoType>
void logUpdate(const oatpp::String& table,
const oatpp::String& entityId,
const oatpp::Object<DtoType>& oldRow,
const oatpp::Object<DtoType>& newRow,
const oatpp::String& actor = nullptr,
const oatpp::provider::ResourceHandle<oatpp::orm::Connection>& connection = nullptr) {
std::string json = "{";
bool first = true;
for (auto* prop : oatpp::Object<DtoType>::getPropertiesList()) {
std::string fieldName(prop->name);
if (SKIP_FIELDS.count(fieldName)) continue;
auto oldVal = prop->get(static_cast<oatpp::BaseObject*>(oldRow.get()));
auto newVal = prop->get(static_cast<oatpp::BaseObject*>(newRow.get()));
if (!valuesEqual(oldVal, newVal)) {
if (!first) json += ",";
json += "\"" + fieldName + "\":" + valueToJson(newVal);
first = false;
}
}
json += "}";
if (!first) {
m_db->logOp(table, entityId, "UPDATE", oatpp::String(json), actor, connection);
}
}
};
} // namespace oatpp_authkit
#endif // oatpp_authkit_db_AuditLog_hpp

View 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

View file

@ -0,0 +1,140 @@
#ifndef OATPP_AUTHKIT_DB_USER_DB_HPP
#define OATPP_AUTHKIT_DB_USER_DB_HPP
// DbClient + declarative schema for temporal `users` (authkit#14 PR 4).
#include "oatpp-authkit/dto/UserDto.hpp"
#include "oatpp-authkit/repo/SchemaContract.hpp"
#include "oatpp-sqlite/orm.hpp"
#include OATPP_CODEGEN_BEGIN(DbClient)
namespace oatpp_authkit::db {
/**
* @brief DbClient for the temporal `users` table.
*
* Login lookup goes through `findLiveByUsername` the natural-key
* index `ux_users_username_until` makes that an indexed scan. The
* temporal decorator on top filters live-vs-historical itself for the
* generic `Repository<T>` surface; the dedicated find-by-username here
* exists because login doesn't have an `entity_id` to dispatch on.
*/
class UserDb : public oatpp::orm::DbClient {
public:
UserDb(const std::shared_ptr<oatpp::orm::Executor>& executor)
: oatpp::orm::DbClient(executor) {}
QUERY(getAllUsersRaw,
"SELECT * FROM users;")
QUERY(getLiveUsers,
"SELECT * FROM users "
"WHERE valid_from <= datetime('now') AND valid_until > datetime('now') "
"ORDER BY username;")
QUERY(findUserByEntityId,
"SELECT * FROM users "
"WHERE entity_id = :entityId "
" AND valid_from <= datetime('now') AND valid_until > datetime('now');",
PARAM(oatpp::String, entityId))
/// Live row by username — the canonical login lookup path.
QUERY(findLiveByUsername,
"SELECT * FROM users "
"WHERE username = :username "
" AND valid_from <= datetime('now') AND valid_until > datetime('now');",
PARAM(oatpp::String, username))
/// Live row by tls_cert_dn — used by mTLS auth.
QUERY(findLiveByTlsCertDn,
"SELECT * FROM users "
"WHERE tls_cert_dn = :dn "
" AND valid_from <= datetime('now') AND valid_until > datetime('now');",
PARAM(oatpp::String, dn))
QUERY(upsertUserById,
"INSERT INTO users "
" (id, entity_id, username, password_hash, role, tls_cert_dn, "
" valid_from, valid_until) "
"VALUES "
" (:dto.id, :dto.entityId, :dto.username, :dto.passwordHash, "
" :dto.role, :dto.tlsCertDn, :dto.validFrom, :dto.validUntil) "
"ON CONFLICT(id) DO UPDATE SET "
" entity_id = excluded.entity_id, "
" username = excluded.username, "
" password_hash = excluded.password_hash, "
" role = excluded.role, "
" tls_cert_dn = excluded.tls_cert_dn, "
" valid_from = excluded.valid_from, "
" valid_until = excluded.valid_until;",
PARAM(oatpp::Object<dto::UserDto>, dto))
QUERY(softDeleteUser,
"UPDATE users SET valid_until = datetime('now') "
"WHERE entity_id = :entityId AND valid_until > datetime('now');",
PARAM(oatpp::String, entityId))
};
/**
* @brief Declarative schema for `users` (auth-essential columns only).
*
* Composes with `TemporalRepository<UserDto>` and any consumer-side
* `*UserExtensionSchema` that contributes additional columns (email,
* profile data, ). The natural-key UNIQUE on `(username, valid_until)`
* prevents two live rows from sharing a username while still allowing
* historical rows; same for `(tls_cert_dn, valid_until)` (skipped when
* `tls_cert_dn IS NULL`, expressed via partial index below).
*
* @section migration Migration from a non-temporal users table
*
* Atlas-generated migration handles the structural conversion:
*
* 1. Rebuild `users` with the new column shape (TEXT id, entity_id,
* valid_from, valid_until; drop is_active, created_at).
* 2. Backfill: each existing row becomes its own entity:
* `entity_id = CAST(old_id AS TEXT)`,
* `id = CAST(old_id AS TEXT)`,
* `valid_from = COALESCE(old_created_at, datetime('now'))`,
* `valid_until = CASE WHEN old_is_active = 1 THEN '<sentinel>'
* ELSE datetime('now') END`.
* 3. Sessions/certificates FKs that referenced `users.id` (INTEGER) get
* rewired to reference `users.entity_id` that's a consumer-side
* rewire, not part of this PR. The migration generated by Atlas
* will surface those FK changes for review.
*/
struct UserSchema {
inline static constexpr repo::ColumnSpec kColumns[] = {
{"id", "TEXT PRIMARY KEY"},
{"entity_id", "TEXT NOT NULL"},
{"username", "TEXT NOT NULL"},
{"password_hash", "TEXT"},
{"role", "TEXT NOT NULL DEFAULT 'editor'"},
{"tls_cert_dn", "TEXT"},
// valid_from / valid_until come from TemporalRepository.
};
inline static constexpr repo::IndexSpec kIndexes[] = {
{"ix_{table}_entity_id", false, "(entity_id)"},
{"ux_{table}_username_until", true, "(username, valid_until)"},
// tls_cert_dn UNIQUE is expressed as a partial index; the
// SchemaBuilder index emitter doesn't yet support WHERE clauses
// on indexes, so a regular index here lets duplicate-NULL rows
// through. Consumers can layer a partial UNIQUE in their own
// schema contribution if needed.
{"ix_{table}_tls_cert_dn", false, "(tls_cert_dn)"},
};
inline static constexpr repo::DecoratorSchema kSchema = {
"UserSchema",
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,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,73 @@
#ifndef OATPP_AUTHKIT_DTO_INTERNAL_DTO_HPP
#define OATPP_AUTHKIT_DTO_INTERNAL_DTO_HPP
#include "oatpp/codegen/dto/base_define.hpp"
#include "oatpp/core/macro/codegen.hpp"
#include "oatpp/core/Types.hpp"
#include OATPP_CODEGEN_BEGIN(DTO)
namespace oatpp_authkit::dto {
/**
* @brief Body shape emitted by JsonErrorHandler and AuthInterceptor::makeJsonError (#6).
*
* Replaces ad-hoc string concatenation. Going through ObjectMapper
* guarantees the embedded `status` / `message` strings are properly
* escaped the previous hand-rolled `JsonErrorHandler::handleError`
* embedded `status.description` raw, which would emit invalid JSON for
* any `Status{, "I'm a \"teapot\""}` description.
*/
class JsonErrorDto : public oatpp::DTO {
DTO_INIT(JsonErrorDto, DTO)
DTO_FIELD(String, status);
DTO_FIELD(Int32, code);
DTO_FIELD(String, message);
};
/**
* @brief Outbound WS broadcast for booking/person lifecycle events (#6).
*
* {"type":"booking_updated","id":"42"}
*/
class WsEntityEventDto : public oatpp::DTO {
DTO_INIT(WsEntityEventDto, DTO)
DTO_FIELD(String, type);
DTO_FIELD(String, id);
};
/**
* @brief Outbound WS broadcast for presence updates (#6).
*
* {"type":"presence_update","booking_id":"42","users":["alice","bob"]}
*/
class WsPresenceUpdateDto : public oatpp::DTO {
DTO_INIT(WsPresenceUpdateDto, DTO)
DTO_FIELD(String, type);
DTO_FIELD(String, booking_id);
DTO_FIELD(List<String>, users);
};
/**
* @brief Inbound WS message envelope (#6) replaces `Listener::jsonStr/jsonInt`.
*
* The toy regex parsers in the previous implementation mishandled escaped
* quotes and nested objects; routing through `ObjectMapper` rejects
* malformed inbound payloads cleanly.
*/
class WsClientMsgDto : public oatpp::DTO {
DTO_INIT(WsClientMsgDto, DTO)
DTO_FIELD(String, type);
DTO_FIELD(String, booking_id);
DTO_FIELD(String, user);
};
} // namespace oatpp_authkit::dto
#include OATPP_CODEGEN_END(DTO)
#endif

View 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

View file

@ -0,0 +1,63 @@
#ifndef OATPP_AUTHKIT_DTO_USER_DTO_HPP
#define OATPP_AUTHKIT_DTO_USER_DTO_HPP
// Temporal `users` DTO (authkit#14 PR 4, Option B).
//
// Ships the auth-essential columns: id (TEXT PK), entity_id, username,
// password_hash, role, tls_cert_dn, plus the temporal triple. Consumers
// add application-specific columns (email, profile data, …) by
// contributing a second `*Schema` to the SchemaBuilder parameter pack.
//
// **Migration from non-temporal users**: existing fewo-webapp `users`
// rows have `id INTEGER autoinc` and `is_active` flag. Atlas-generated
// migration (per docs/MIGRATIONS.md) handles the conversion: each row
// becomes its own entity (`entity_id = CAST(id AS TEXT)`), `valid_until
// = SENTINEL` for active users and `= datetime('now')` for inactive
// ones. Sessions/certificates FKs to `users.id` move to `users.entity_id`
// (consumer-side rewire — out of scope for this PR).
//
// **Password hash temporality**: per owner directive on authkit#14,
// password_hash rides the temporal row. A separate issue (filed by this
// PR) tracks the redaction policy for historical hashes — likely blank
// the hash but keep the row so the change-history is auditable.
#include "oatpp/core/macro/codegen.hpp"
#include "oatpp/core/Types.hpp"
#include OATPP_CODEGEN_BEGIN(DTO)
namespace oatpp_authkit::dto {
/**
* @brief Auth-essential view of an application user.
*
* The `password` write-only field is intentionally absent here it
* arrives via the consumer's auth controller (login / password-set
* endpoints) and gets hashed before reaching `password_hash` on this
* DTO. Consumers that ship richer user profiles add application-
* specific columns through their own DTO + a parallel SchemaContract.
*/
class UserDto : public oatpp::DTO {
DTO_INIT(UserDto, DTO)
DTO_FIELD(String, id);
DTO_FIELD(String, entityId, "entity_id");
DTO_FIELD(String, username);
DTO_FIELD(String, passwordHash, "password_hash");
DTO_FIELD(String, role);
DTO_FIELD(String, tlsCertDn, "tls_cert_dn");
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::UserDto,
id, entityId, validFrom, validUntil)
#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

@ -3,28 +3,46 @@
#include "oatpp/web/server/handler/ErrorHandler.hpp" #include "oatpp/web/server/handler/ErrorHandler.hpp"
#include "oatpp/web/protocol/http/outgoing/ResponseFactory.hpp" #include "oatpp/web/protocol/http/outgoing/ResponseFactory.hpp"
#include "oatpp/parser/json/mapping/ObjectMapper.hpp"
#include "../dto/InternalDto.hpp"
namespace oatpp_authkit {
/** /**
* @brief Custom error handler that returns JSON error responses. * @brief Custom error handler that returns JSON error responses.
* *
* Replaces oatpp's default plain-text error handler so that * Replaces oatpp's default plain-text error handler so that
* OATPP_ASSERT_HTTP errors are returned as JSON objects matching * OATPP_ASSERT_HTTP errors are returned as JSON objects matching the
* the StatusDto schema: {"status": "...", "code": N, "message": "..."}. * `JsonErrorDto` schema: `{"status": "...", "code": N, "message": "..."}`.
* This allows the frontend's coreFetch to parse error details reliably. *
* Routing through `ObjectMapper` (DI'd) replaces the previous hand-rolled
* concatenation that embedded `status.description` raw see #6.
*/ */
class JsonErrorHandler : public oatpp::web::server::handler::ErrorHandler { class JsonErrorHandler : public oatpp::web::server::handler::ErrorHandler {
private:
std::shared_ptr<oatpp::data::mapping::ObjectMapper> m_mapper;
public: public:
/**
* @param mapper Shared JSON object mapper. Pass nullptr for a
* handler-owned default mapper (back-compat path).
*/
explicit JsonErrorHandler(std::shared_ptr<oatpp::data::mapping::ObjectMapper> mapper = nullptr)
: m_mapper(mapper ? mapper : oatpp::parser::json::mapping::ObjectMapper::createShared()) {}
std::shared_ptr<oatpp::web::protocol::http::outgoing::Response> std::shared_ptr<oatpp::web::protocol::http::outgoing::Response>
handleError(const oatpp::web::protocol::http::Status& status, handleError(const oatpp::web::protocol::http::Status& status,
const oatpp::String& message, const oatpp::String& message,
const Headers& headers) override const Headers& headers) override
{ {
auto json = oatpp::String( auto dto = dto::JsonErrorDto::createShared();
"{\"status\":\"" + std::string(status.description) + dto->status = oatpp::String(std::string(status.description));
"\",\"code\":" + std::to_string(status.code) + dto->code = status.code;
",\"message\":\"" + escapeJson(message ? message->c_str() : "") + "\"}" dto->message = message ? message : oatpp::String("");
);
oatpp::String json = m_mapper->writeToString(dto);
auto response = oatpp::web::protocol::http::outgoing::ResponseFactory::createResponse( auto response = oatpp::web::protocol::http::outgoing::ResponseFactory::createResponse(
status, json status, json
@ -37,30 +55,8 @@ public:
return response; return response;
} }
private:
static std::string escapeJson(const char* s) {
std::string out;
for (; *s; ++s) {
switch (*s) {
case '"': out += "\\\""; break;
case '\\': out += "\\\\"; break;
case '\n': out += "\\n"; break;
case '\r': out += "\\r"; break;
case '\t': out += "\\t"; break;
default:
if (static_cast<unsigned char>(*s) < 0x20) {
char buf[8];
snprintf(buf, sizeof(buf), "\\u%04x", static_cast<unsigned char>(*s));
out += buf;
} else {
out += *s;
}
}
}
return out;
}
}; };
} // namespace oatpp_authkit
#endif // HANDLER_JSON_ERROR_HANDLER_HPP #endif // HANDLER_JSON_ERROR_HANDLER_HPP

View file

@ -4,40 +4,102 @@
#include "oatpp/web/server/interceptor/RequestInterceptor.hpp" #include "oatpp/web/server/interceptor/RequestInterceptor.hpp"
#include "oatpp/web/protocol/http/outgoing/ResponseFactory.hpp" #include "oatpp/web/protocol/http/outgoing/ResponseFactory.hpp"
namespace oatpp_authkit {
/** /**
* @brief Request interceptor that rejects requests exceeding a body size limit. * @brief Request interceptor that rejects oversized or under-declared request bodies.
* *
* Checks the Content-Length header and returns HTTP 413 (Payload Too Large) * Behaviour for body-bearing methods (`POST`, `PUT`, `PATCH`):
* if the declared body size exceeds the configured maximum. * - missing `Content-Length` `411 Length Required` (audit #4: closes
* chunked-transfer / HTTP/2 bypass that previously sailed through silently)
* - malformed `Content-Length` `400 Bad Request`
* - `Transfer-Encoding: chunked` (or any non-identity encoding) `411`
* (we cannot enforce a cap without buffering an unbounded stream; reject
* by default rather than fall through to oatpp's much higher ceiling)
* - declared length above `maxBytes` `413 Payload Too Large`
*
* Methods that don't carry a body (`GET`, `HEAD`, `DELETE`, `OPTIONS`, `TRACE`)
* pass through untouched `Content-Length` absence is normal there.
*
* Consumers that genuinely need to accept missing/chunked bodies on body-
* bearing methods can construct with `requireContentLength = false` to revert
* to the legacy fail-open behaviour.
*/ */
class BodySizeLimitInterceptor : public oatpp::web::server::interceptor::RequestInterceptor { class BodySizeLimitInterceptor : public oatpp::web::server::interceptor::RequestInterceptor {
private: private:
size_t m_maxBytes; size_t m_maxBytes;
bool m_requireContentLength;
static bool methodCarriesBody(const oatpp::String& method) {
if (!method) return false;
const std::string m = *method;
return m == "POST" || m == "PUT" || m == "PATCH";
}
static std::shared_ptr<OutgoingResponse> jsonResponse(int code, const char* phrase, const char* body) {
auto r = oatpp::web::protocol::http::outgoing::ResponseFactory::createResponse(
oatpp::web::protocol::http::Status(code, phrase), body);
r->putHeader("Content-Type", "application/json");
return r;
}
public: public:
/** /**
* @param maxBytes Maximum allowed request body size in bytes. * @param maxBytes Maximum allowed request body size in bytes.
* @param requireContentLength When `true` (default), body-bearing methods
* must declare a parseable `Content-Length`;
* missing/malformed/chunked reject. Set
* `false` for the legacy lax behaviour.
*/ */
explicit BodySizeLimitInterceptor(size_t maxBytes) : m_maxBytes(maxBytes) {} explicit BodySizeLimitInterceptor(size_t maxBytes, bool requireContentLength = true)
: m_maxBytes(maxBytes), m_requireContentLength(requireContentLength) {}
std::shared_ptr<OutgoingResponse> intercept(const std::shared_ptr<IncomingRequest>& request) override { std::shared_ptr<OutgoingResponse> intercept(const std::shared_ptr<IncomingRequest>& request) override {
const auto& line = request->getStartingLine();
if (!methodCarriesBody(line.method.toString())) {
return nullptr;
}
auto transferEncoding = request->getHeader("Transfer-Encoding");
if (m_requireContentLength && transferEncoding && !transferEncoding->empty()) {
std::string te = *transferEncoding;
for (auto& c : te) c = std::tolower(static_cast<unsigned char>(c));
if (te.find("identity") == std::string::npos) {
return jsonResponse(411, "Length Required",
"{\"status\":\"Length Required\"}");
}
}
auto contentLength = request->getHeader("Content-Length"); auto contentLength = request->getHeader("Content-Length");
if (contentLength && !contentLength->empty()) { if (!contentLength || contentLength->empty()) {
if (m_requireContentLength) {
return jsonResponse(411, "Length Required",
"{\"status\":\"Length Required\"}");
}
return nullptr;
}
size_t len = 0;
try { try {
size_t len = std::stoull(std::string(*contentLength)); size_t pos = 0;
if (len > m_maxBytes) { len = std::stoull(std::string(*contentLength), &pos);
auto response = oatpp::web::protocol::http::outgoing::ResponseFactory::createResponse( if (pos != contentLength->size()) throw std::invalid_argument("trailing");
oatpp::web::protocol::http::Status(413, "Payload Too Large"),
"{\"status\":\"Payload Too Large\"}");
response->putHeader("Content-Type", "application/json");
return response;
}
} catch (...) { } catch (...) {
// Malformed Content-Length — let it through, Oat++ will handle it if (m_requireContentLength) {
return jsonResponse(400, "Bad Request",
"{\"status\":\"Bad Request\"}");
} }
return nullptr;
} }
return nullptr; // pass through
if (len > m_maxBytes) {
return jsonResponse(413, "Payload Too Large",
"{\"status\":\"Payload Too Large\"}");
}
return nullptr;
} }
}; };
} // namespace oatpp_authkit
#endif #endif

View file

@ -3,37 +3,123 @@
#include "oatpp/web/server/interceptor/ResponseInterceptor.hpp" #include "oatpp/web/server/interceptor/ResponseInterceptor.hpp"
#include <string>
namespace oatpp_authkit {
/**
* @brief Per-directive overrides for the strict baseline CSP.
*
* Empty string = "use the strict baseline value for this directive".
* Set a directive to a non-empty string to relax (or further tighten) it.
*
* Example allow Swagger UI's inline scripts on a single subtree only by
* wrapping this interceptor and swapping `scriptSrc` for matching paths:
*
* CspOverride relaxed;
* relaxed.scriptSrc = "'self' 'unsafe-inline'";
* relaxed.styleSrc = "'self' 'unsafe-inline'";
*
* The vast majority of consumers should leave this default-constructed.
*/
struct CspOverride {
std::string defaultSrc; // baseline: 'self'
std::string scriptSrc; // baseline: 'self'
std::string styleSrc; // baseline: 'self'
std::string imgSrc; // baseline: 'self' data:
std::string connectSrc; // baseline: 'self'
std::string fontSrc; // baseline: 'self'
std::string frameAncestors; // baseline: 'none'
std::string baseUri; // baseline: 'self'
std::string formAction; // baseline: 'self'
/** Set to false to drop the HSTS header entirely (e.g. for non-TLS dev). */
bool sendHsts = true;
/** Set to true to add `includeSubDomains` to HSTS (off by default — apex-clobbering hazard). */
bool hstsIncludeSubdomains = false;
/** Override X-Frame-Options. Empty = baseline `DENY`. */
std::string xFrameOptions;
/** Override Permissions-Policy. Empty = baseline (sensors disabled). */
std::string permissionsPolicy;
};
/** /**
* @brief Response interceptor that adds standard security headers to all responses. * @brief Response interceptor that adds standard security headers to all responses.
* *
* Headers added: * Defaults track `docs/security-baseline.md`:
* - X-Content-Type-Options: nosniff prevents MIME type sniffing * - `X-Content-Type-Options: nosniff`
* - X-Frame-Options: SAMEORIGIN prevents clickjacking * - `X-Frame-Options: DENY`
* - Referrer-Policy: strict-origin-when-cross-origin limits referrer leakage * - `Referrer-Policy: strict-origin-when-cross-origin`
* - Content-Security-Policy restricts resource loading sources * - `Strict-Transport-Security: max-age=63072000` (no `includeSubDomains` by default)
* - `Permissions-Policy: accelerometer=(), camera=(), `
* - `Content-Security-Policy:`
* `default-src 'self'; script-src 'self'; style-src 'self';`
* `img-src 'self' data:; connect-src 'self'; font-src 'self';`
* `frame-ancestors 'none'; base-uri 'self'; form-action 'self'`
*
* Construct with a `CspOverride` to relax individual directives without
* forking the interceptor see the struct doc for the typical use.
*/ */
class SecurityHeadersInterceptor : public oatpp::web::server::interceptor::ResponseInterceptor { class SecurityHeadersInterceptor : public oatpp::web::server::interceptor::ResponseInterceptor {
private:
CspOverride m_override;
static const std::string& orDefault(const std::string& v, const std::string& fallback) {
return v.empty() ? fallback : v;
}
public: public:
SecurityHeadersInterceptor() = default;
explicit SecurityHeadersInterceptor(CspOverride override) : m_override(std::move(override)) {}
std::shared_ptr<OutgoingResponse> intercept( std::shared_ptr<OutgoingResponse> intercept(
const std::shared_ptr<IncomingRequest>& request, const std::shared_ptr<IncomingRequest>& request,
const std::shared_ptr<OutgoingResponse>& response) override { const std::shared_ptr<OutgoingResponse>& response) override {
static const std::string DEF_DEFAULT = "'self'";
static const std::string DEF_SCRIPT = "'self'";
static const std::string DEF_STYLE = "'self'";
static const std::string DEF_IMG = "'self' data:";
static const std::string DEF_CONNECT = "'self'";
static const std::string DEF_FONT = "'self'";
static const std::string DEF_FRAME_ANC = "'none'";
static const std::string DEF_BASE = "'self'";
static const std::string DEF_FORM = "'self'";
static const std::string DEF_XFRAME = "DENY";
static const std::string DEF_PERMISSIONS =
"accelerometer=(), camera=(), geolocation=(), gyroscope=(),"
" magnetometer=(), microphone=(), payment=(), usb=()";
const std::string csp =
"default-src " + orDefault(m_override.defaultSrc, DEF_DEFAULT) + "; "
"script-src " + orDefault(m_override.scriptSrc, DEF_SCRIPT) + "; "
"style-src " + orDefault(m_override.styleSrc, DEF_STYLE) + "; "
"img-src " + orDefault(m_override.imgSrc, DEF_IMG) + "; "
"connect-src " + orDefault(m_override.connectSrc, DEF_CONNECT) + "; "
"font-src " + orDefault(m_override.fontSrc, DEF_FONT) + "; "
"frame-ancestors "+ orDefault(m_override.frameAncestors, DEF_FRAME_ANC) + "; "
"base-uri " + orDefault(m_override.baseUri, DEF_BASE) + "; "
"form-action " + orDefault(m_override.formAction, DEF_FORM);
response->putHeader("X-Content-Type-Options", "nosniff"); response->putHeader("X-Content-Type-Options", "nosniff");
response->putHeader("X-Frame-Options", "SAMEORIGIN"); response->putHeader("X-Frame-Options",
orDefault(m_override.xFrameOptions, DEF_XFRAME).c_str());
response->putHeader("Referrer-Policy", "strict-origin-when-cross-origin"); response->putHeader("Referrer-Policy", "strict-origin-when-cross-origin");
response->putHeader("Content-Security-Policy", response->putHeader("Permissions-Policy",
"default-src 'self'; " orDefault(m_override.permissionsPolicy, DEF_PERMISSIONS).c_str());
"script-src 'self' 'unsafe-inline' https://unpkg.com; " response->putHeader("Content-Security-Policy", csp.c_str());
"style-src 'self' 'unsafe-inline' https://unpkg.com; "
"img-src 'self' data: https:; " if (m_override.sendHsts) {
"connect-src 'self' wss: ws:; " const std::string hsts = m_override.hstsIncludeSubdomains
"font-src 'self'; " ? "max-age=63072000; includeSubDomains"
"frame-ancestors 'self'; " : "max-age=63072000";
"base-uri 'self'; " response->putHeader("Strict-Transport-Security", hsts.c_str());
"form-action 'self'"); }
response->putHeader("Strict-Transport-Security",
"max-age=63072000; includeSubDomains");
return response; return response;
} }
}; };
} // namespace oatpp_authkit
#endif #endif

View file

@ -0,0 +1,171 @@
#ifndef oatpp_authkit_mail_SmtpTransport_hpp
#define oatpp_authkit_mail_SmtpTransport_hpp
/**
* @file SmtpTransport.hpp
* @brief Pure libcurl SMTP+MIME transport lifted from fewo-webapp #454.
*
* Handles MAIL FROM / RCPT TO / STARTTLS / optional SMTP AUTH / MIME multipart
* body + attachments / RFC 2047 encoded Subject. Knows nothing about templates,
* DTOs or databases callers hand over the fully-rendered HTML body, the
* subject line, any attachment blobs and an `SmtpConfig` struct. Use a tiny
* adapter in the caller to map from whatever DTO/settings row you have to
* `SmtpConfig` so this header stays free of project-specific types.
*
* Consumers: add `#include <oatpp-authkit/mail/SmtpTransport.hpp>`; link
* against libcurl (authkit itself is header-only so the consumer's CMake
* owns the curl dependency).
*/
#include <curl/curl.h>
#include <string>
#include <utility>
#include <vector>
namespace oatpp_authkit::mail {
/** @brief Plain-struct SMTP config; projects adapt from their own DTO/settings row. */
struct SmtpConfig {
std::string host;
int port = 587;
std::string fromAddress;
std::string username; // empty = no SMTP AUTH
std::string password;
};
/** @brief True if `s` contains CR, LF or NUL — characters that would let a
* caller-influenced address smuggle extra SMTP/MIME headers (BCC injection,
* added recipients, body injection) when concatenated into a header line. */
inline bool hasHeaderInjectionChars(const std::string& s) {
return s.find('\r') != std::string::npos
|| s.find('\n') != std::string::npos
|| s.find('\0') != std::string::npos;
}
/** @brief RFC 4648 Base64 encode — used for RFC 2047 Subject headers. */
inline std::string base64Encode(const std::string& data) {
static const char* table =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string out;
out.reserve(((data.size() + 2) / 3) * 4);
for (size_t i = 0; i < data.size(); i += 3) {
unsigned int b = (unsigned char)data[i] << 16;
if (i + 1 < data.size()) b |= (unsigned char)data[i + 1] << 8;
if (i + 2 < data.size()) b |= (unsigned char)data[i + 2];
out += table[(b >> 18) & 0x3f];
out += table[(b >> 12) & 0x3f];
out += (i + 1 < data.size()) ? table[(b >> 6) & 0x3f] : '=';
out += (i + 2 < data.size()) ? table[b & 0x3f] : '=';
}
return out;
}
/**
* @brief Send a single email via libcurl SMTP.
*
* @param to Recipient address.
* @param subject Plain UTF-8 subject; wrapped as an RFC 2047 encoded-word so
* non-ASCII characters (umlauts etc.) survive.
* @param htmlBody text/html body (quoted-printable on the wire).
* @param attachments (filename, blob) pairs; `.pdf`/`.ics` extensions get
* recognised Content-Type, everything else goes as
* application/octet-stream.
* @param cfg SMTP configuration.
* @return Empty string on success; error message otherwise. Callers typically
* log a non-empty result and treat it as a soft failure.
*/
inline std::string send(
const std::string& to,
const std::string& subject,
const std::string& htmlBody,
const std::vector<std::pair<std::string, std::string>>& attachments,
const SmtpConfig& cfg)
{
if (cfg.host.empty()) return "SMTP not configured (no host)";
if (cfg.fromAddress.empty()) return "SMTP not configured (no from_address)";
// Reject control characters in the addresses before they reach the envelope
// (MAIL FROM / RCPT TO) and the From:/To: header lines. The subject is safe
// — it is RFC 2047 base64 encoded-word wrapped below — but the addresses are
// concatenated raw, so a `\r\n` here would inject arbitrary headers.
if (hasHeaderInjectionChars(to)) return "invalid recipient address (control characters)";
if (hasHeaderInjectionChars(cfg.fromAddress)) return "invalid from address (control characters)";
CURL* curl = curl_easy_init();
if (!curl) return "curl_easy_init failed";
std::string url = "smtp://" + cfg.host + ":" + std::to_string(cfg.port);
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
curl_easy_setopt(curl, CURLOPT_MAIL_FROM, ("<" + cfg.fromAddress + ">").c_str());
struct curl_slist* rcpt = nullptr;
rcpt = curl_slist_append(rcpt, to.c_str());
curl_easy_setopt(curl, CURLOPT_MAIL_RCPT, rcpt);
if (!cfg.username.empty()) {
curl_easy_setopt(curl, CURLOPT_USERNAME, cfg.username.c_str());
curl_easy_setopt(curl, CURLOPT_PASSWORD, cfg.password.c_str());
}
// authkit#16 L-2: require TLS for non-loopback relays. CURLUSESSL_TRY would
// silently fall back to cleartext if STARTTLS is unavailable or stripped by
// a MITM, leaking the SMTP AUTH credentials and message body. A local relay
// (localhost / pipe transport) stays on TRY with verification relaxed since
// there's no network hop to protect.
const bool loopbackRelay = (cfg.host == "localhost" || cfg.host == "127.0.0.1");
curl_easy_setopt(curl, CURLOPT_USE_SSL, loopbackRelay ? CURLUSESSL_TRY : CURLUSESSL_ALL);
if (loopbackRelay) {
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L);
}
// Keep worker threads from blocking on a dead mail server indefinitely.
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 10L);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L);
// Build MIME body: text/html body first, then attachments.
curl_mime* mime = curl_mime_init(curl);
curl_mimepart* bodyPart = curl_mime_addpart(mime);
curl_mime_data(bodyPart, htmlBody.c_str(), (curl_off_t)htmlBody.size());
curl_mime_type(bodyPart, "text/html; charset=utf-8");
curl_mime_encoder(bodyPart, "quoted-printable");
for (const auto& [fname, fcontent] : attachments) {
curl_mimepart* apart = curl_mime_addpart(mime);
curl_mime_data(apart, fcontent.c_str(), (curl_off_t)fcontent.size());
curl_mime_filename(apart, fname.c_str());
curl_mime_encoder(apart, "base64");
std::string mtype = "application/octet-stream";
if (fname.size() > 4) {
std::string ext = fname.substr(fname.size() - 4);
if (ext == ".pdf") mtype = "application/pdf";
else if (ext == ".ics") mtype = "text/calendar; charset=utf-8";
}
curl_mime_type(apart, mtype.c_str());
}
curl_easy_setopt(curl, CURLOPT_MIMEPOST, mime);
// RFC 2047 encoded-word Subject so non-ASCII survives.
std::string encodedSubject = "=?UTF-8?B?" + base64Encode(subject) + "?=";
struct curl_slist* hdrs = nullptr;
hdrs = curl_slist_append(hdrs, ("From: " + cfg.fromAddress).c_str());
hdrs = curl_slist_append(hdrs, ("To: " + to).c_str());
hdrs = curl_slist_append(hdrs, ("Subject: " + encodedSubject).c_str());
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, hdrs);
CURLcode res = curl_easy_perform(curl);
std::string err;
if (res != CURLE_OK) err = std::string(curl_easy_strerror(res));
curl_mime_free(mime);
curl_slist_free_all(rcpt);
curl_slist_free_all(hdrs);
curl_easy_cleanup(curl);
return err;
}
} // namespace oatpp_authkit::mail
#endif // oatpp_authkit_mail_SmtpTransport_hpp

View file

@ -0,0 +1,27 @@
#ifndef OATPP_AUTHKIT_REPO_ACTOR_CONTEXT_HPP
#define OATPP_AUTHKIT_REPO_ACTOR_CONTEXT_HPP
#include <string>
#include <vector>
namespace oatpp_authkit::repo {
/**
* @brief Who is performing a repository action, plus what they're scoped to.
*
* Passed to the scope-guard decorator predicate (added in oatpp-authkit#8) so
* resource-level authorisation can be evaluated outside the concrete repo.
*
* Kept deliberately minimal consumers extend by composing this struct into
* a richer per-app context if needed. The fields here are the union of what
* the fewo-webapp property-scope guard needs (user id + a list of allowed
* resource ids) and nothing more.
*/
struct ActorContext {
std::string userId;
std::vector<std::string> allowedScopes; ///< Opaque ids; consumer decides their meaning (property ids, tenant ids, …).
};
} // namespace oatpp_authkit::repo
#endif

View file

@ -0,0 +1,194 @@
#ifndef OATPP_AUTHKIT_REPO_AUDIT_LOG_REPOSITORY_HPP
#define OATPP_AUTHKIT_REPO_AUDIT_LOG_REPOSITORY_HPP
// Cross-cutting audit-trail decorator (authkit#11). Emits an `AuditEvent`
// through a consumer-supplied `IAuditSink` on every audited operation.
// Composes naturally with `ScopeGuardRepository` and `TemporalRepository`
// — all three accept an `ActorContext` accessor and stack via the same
// `Repository<T>` interface.
#include "oatpp-authkit/repo/Repository.hpp"
#include "oatpp-authkit/repo/IAuditSink.hpp"
#include "oatpp-authkit/repo/ActorContext.hpp"
#include "oatpp-authkit/repo/TemporalFieldTraits.hpp"
#include "oatpp-authkit/repo/SchemaContract.hpp"
#include "oatpp/core/Types.hpp"
#include <chrono>
#include <cstdint>
#include <exception>
#include <functional>
#include <memory>
#include <set>
#include <string>
#include <utility>
namespace oatpp_authkit::repo {
/**
* @brief Decorator that audits every mutation flowing through a repository.
*
* @section semantics Per-method behaviour
*
* - `save(dto)`: if `entity_id` is null, the inner is about to allocate
* one operation is `Create`. If `entity_id` is non-null, the decorator
* performs a one-shot `findByEntityId` on the inner *before* delegating;
* a hit means `Update`, a miss means `Create` (caller-supplied id, no
* row yet). Inner is then called; on success a single event is recorded.
* - `softDelete(id)`: delegated first; on success a single `Delete` event
* is recorded.
* - `findByEntityId(id)`: delegated first; if `AuditOp::Read` is in the
* enabled set, a single `Read` event is recorded with the entity id of
* the row that came back (or the requested id on miss both are
* useful for compliance traces).
* - `list()`: passed through unchanged. Lists are scans; emitting one
* event per row is noisy and emitting a single event with no entity id
* is half-information. Out of scope for this decorator.
*
* @section robustness Sink failures
*
* `IAuditSink::record` is called inside a `try/catch`. By default the
* exception is swallowed audit logging is best-effort and must not
* break the user's write path. Pass `sinkErrorHandler(...)` (or supply
* the optional last constructor arg) to override; the handler returns
* `true` to rethrow, `false` to swallow.
*
* `entityId` for `save` events is read through
* `TemporalFieldTraits<TDto>::entityId`, so the decorator works for any
* DTO that registered the trait via `OATPP_AUTHKIT_REGISTER_TEMPORAL`.
*/
template <typename TDto>
class AuditLogRepository : public Repository<TDto> {
public:
using ActorAccess = std::function<ActorContext()>;
using Clock = std::function<std::int64_t()>;
using SinkErrorHandler = std::function<bool(const std::exception&)>;
/// Declarative schema contribution (authkit#14, D-replace).
/// AuditLog touches no entity-table columns; it owns one sidecar
/// `audit_log` table fixed across consumers.
inline static constexpr ColumnSpec kAuditLogColumns[] = {
{"id", "INTEGER PRIMARY KEY AUTOINCREMENT"},
{"actor_user_id", "TEXT"},
{"entity_type", "TEXT NOT NULL"},
{"entity_id", "TEXT NOT NULL"},
{"op", "TEXT NOT NULL"},
{"timestamp_ms", "INTEGER NOT NULL"},
};
inline static constexpr SidecarTableSpec kSidecars[] = {
{"audit_log", kAuditLogColumns,
sizeof(kAuditLogColumns) / sizeof(kAuditLogColumns[0])},
};
inline static constexpr DecoratorSchema kSchema = {
"AuditLogRepository",
nullptr, 0,
nullptr, 0,
kSidecars, sizeof(kSidecars) / sizeof(kSidecars[0]),
};
AuditLogRepository(std::shared_ptr<Repository<TDto>> inner,
std::shared_ptr<IAuditSink> sink,
ActorAccess currentActor,
std::string entityType,
std::set<AuditOp> enabledOps =
{AuditOp::Create, AuditOp::Update, AuditOp::Delete},
Clock clock = {},
SinkErrorHandler onSinkError = {})
: m_inner(std::move(inner))
, m_sink(std::move(sink))
, m_currentActor(std::move(currentActor))
, m_entityType(std::move(entityType))
, m_enabledOps(std::move(enabledOps))
, m_clock(clock ? std::move(clock) : defaultClock())
, m_onSinkError(std::move(onSinkError))
{}
oatpp::Object<TDto> findByEntityId(const oatpp::String& entityId) override {
auto row = m_inner->findByEntityId(entityId);
if (m_enabledOps.count(AuditOp::Read)) {
// On miss, fall back to the requested id — still useful for
// compliance. On hit, prefer the id stored on the row.
std::string id = entityId ? std::string(*entityId) : std::string();
if (row) {
auto& rowId = TemporalFieldTraits<TDto>::entityId(row);
if (rowId) id = std::string(*rowId);
}
emit(AuditOp::Read, id);
}
return row;
}
oatpp::Vector<oatpp::Object<TDto>> list() override {
return m_inner->list(); // intentionally unaudited — see header doc
}
void save(const oatpp::Object<TDto>& dto) override {
const AuditOp op = classifySave(dto);
m_inner->save(dto);
if (m_enabledOps.count(op)) {
std::string id;
auto& field = TemporalFieldTraits<TDto>::entityId(dto);
if (field) id = std::string(*field);
emit(op, id);
}
}
void softDelete(const oatpp::String& entityId) override {
m_inner->softDelete(entityId);
if (m_enabledOps.count(AuditOp::Delete)) {
emit(AuditOp::Delete, entityId ? std::string(*entityId) : std::string());
}
}
private:
AuditOp classifySave(const oatpp::Object<TDto>& dto) {
auto& id = TemporalFieldTraits<TDto>::entityId(dto);
if (!id) return AuditOp::Create; // inner will allocate the id
// Caller-supplied id: distinguish Create-with-id vs Update.
return m_inner->findByEntityId(id) ? AuditOp::Update : AuditOp::Create;
}
void emit(AuditOp op, std::string entityId) {
AuditEvent ev;
ev.entityType = m_entityType;
ev.entityId = std::move(entityId);
ev.op = op;
ev.timestampMs = m_clock();
try {
ev.actorUserId = m_currentActor().userId;
} catch (...) {
// Actor accessor failure shouldn't break the write path either.
ev.actorUserId.clear();
}
try {
m_sink->record(ev);
} catch (const std::exception& e) {
if (m_onSinkError && m_onSinkError(e)) throw;
// else: swallow — audit logging is best-effort.
} catch (...) {
// Non-std::exception — always swallow; the handler signature
// takes std::exception&, so we cannot route it.
}
}
static Clock defaultClock() {
return [] {
using namespace std::chrono;
return duration_cast<milliseconds>(
system_clock::now().time_since_epoch()).count();
};
}
std::shared_ptr<Repository<TDto>> m_inner;
std::shared_ptr<IAuditSink> m_sink;
ActorAccess m_currentActor;
std::string m_entityType;
std::set<AuditOp> m_enabledOps;
Clock m_clock;
SinkErrorHandler m_onSinkError;
};
} // namespace oatpp_authkit::repo
#endif

View 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

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

@ -0,0 +1,96 @@
#ifndef OATPP_AUTHKIT_REPO_CONCRETE_USER_REPOSITORY_HPP
#define OATPP_AUTHKIT_REPO_CONCRETE_USER_REPOSITORY_HPP
// Concrete inner adapter of `Repository<UserDto>` (authkit#14 PR 4).
// Stacks under TemporalRepository<UserDto> via `makeUserRepository`.
#include "oatpp-authkit/db/UserDb.hpp"
#include "oatpp-authkit/dto/UserDto.hpp"
#include "oatpp-authkit/repo/RedactedFieldRepository.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 of `Repository<UserDto>`, delegating to `db::UserDb`.
*
* Empty schema `db::UserSchema` owns the table declaration.
*/
class ConcreteUserRepository : public Repository<dto::UserDto> {
public:
inline static constexpr DecoratorSchema kSchema = {
"ConcreteUserRepository",
nullptr, 0, nullptr, 0, nullptr, 0,
};
explicit ConcreteUserRepository(std::shared_ptr<db::UserDb> udb)
: m_db(std::move(udb)) {}
oatpp::Object<dto::UserDto> findByEntityId(const oatpp::String& entityId) override {
auto res = m_db->findUserByEntityId(entityId);
if (!res || !res->isSuccess()) return nullptr;
auto rows = res->template fetch<oatpp::Vector<oatpp::Object<dto::UserDto>>>();
if (!rows || rows->empty()) return nullptr;
return (*rows)[0];
}
oatpp::Vector<oatpp::Object<dto::UserDto>> list() override {
auto res = m_db->getAllUsersRaw();
auto out = oatpp::Vector<oatpp::Object<dto::UserDto>>::createShared();
if (!res || !res->isSuccess()) return out;
auto fetched = res->template fetch<oatpp::Vector<oatpp::Object<dto::UserDto>>>();
if (!fetched) return out;
for (auto& row : *fetched) if (row) out->push_back(row);
return out;
}
void save(const oatpp::Object<dto::UserDto>& d) override {
m_db->upsertUserById(d);
}
void softDelete(const oatpp::String& entityId) override {
m_db->softDeleteUser(entityId);
}
private:
std::shared_ptr<db::UserDb> m_db;
};
/**
* @brief Compose the user repository stack with credential redaction
* baked in (authkit#15).
*
* TemporalRepository(
* RedactedFieldRepository(
* ConcreteUserRepository(udb),
* {"passwordHash", "tlsCertDn"}))
*
* On every password change the prior hash gets blanked on the historical
* row before the temporal decorator persists it. `tlsCertDn` follows the
* same policy. The audit-trail (when did this user exist, when was their
* password rotated) survives in `valid_from`/`valid_until`/`username`/
* `role`; only the credential surface is redacted.
*
* Default redaction list is `{"passwordHash", "tlsCertDn"}` per the
* issue thread's Option B. Pass a different list to the overload below
* if a consumer wants different behaviour.
*/
inline std::shared_ptr<Repository<dto::UserDto>>
makeUserRepository(std::shared_ptr<db::UserDb> udb,
std::vector<std::string> fieldsToRedact =
{"passwordHash", "tlsCertDn"}) {
auto concrete = std::make_shared<ConcreteUserRepository>(std::move(udb));
auto redacted = std::make_shared<RedactedFieldRepository<dto::UserDto>>(
concrete, std::move(fieldsToRedact));
return std::make_shared<TemporalRepository<dto::UserDto>>(redacted);
}
} // namespace oatpp_authkit::repo
#endif

View file

@ -0,0 +1,65 @@
#ifndef OATPP_AUTHKIT_REPO_I_AUDIT_SINK_HPP
#define OATPP_AUTHKIT_REPO_I_AUDIT_SINK_HPP
// Cross-cutting audit primitive used by `AuditLogRepository<T>` (authkit#11).
// The decorator emits an `AuditEvent` per mutation (and optionally per
// single-entity read) through an `IAuditSink` the consumer supplies.
#include <cstdint>
#include <string>
namespace oatpp_authkit::repo {
/**
* @brief What kind of operation produced the audit event.
*
* Reflects intent, not the inner method name `softDelete` and a
* hypothetical hard delete both surface as `Delete`. `Read` covers
* single-entity lookups (`findByEntityId`) only; `list()` is intentionally
* not audited because it is a scan, not a per-entity access.
*/
enum class AuditOp { Create, Update, Delete, Read };
inline const char* toString(AuditOp op) {
switch (op) {
case AuditOp::Create: return "Create";
case AuditOp::Update: return "Update";
case AuditOp::Delete: return "Delete";
case AuditOp::Read: return "Read";
}
return "Unknown";
}
/**
* @brief Audit record emitted on every audited operation.
*
* `entityType` is supplied by the decorator's owner at construction time
* (typeid is unportable, and consumers usually have a stable string they
* already use elsewhere table name, DTO name, etc.).
*/
struct AuditEvent {
std::string actorUserId;
std::string entityType;
std::string entityId;
AuditOp op{AuditOp::Read};
std::int64_t timestampMs{0};
};
/**
* @brief Where audit events go. Consumer-supplied.
*
* Implementations are typically a database insert (fewo-webapp's plan: an
* `audit_log` table behind a sqlite-backed sink) or, in tests, a vector
* append. Sink failures should not break the user's write path
* `AuditLogRepository<T>` catches exceptions thrown from `record` and
* routes them through a configurable error callback.
*/
class IAuditSink {
public:
virtual ~IAuditSink() = default;
virtual void record(const AuditEvent& ev) = 0;
};
} // namespace oatpp_authkit::repo
#endif

View file

@ -0,0 +1,31 @@
#ifndef OATPP_AUTHKIT_REPO_I_HISTORY_REPOSITORY_HPP
#define OATPP_AUTHKIT_REPO_I_HISTORY_REPOSITORY_HPP
#include "oatpp/core/Types.hpp"
namespace oatpp_authkit::repo {
/**
* @brief All historical versions for a temporal entity.
*
* Kept separate from `Repository<T>` deliberately non-temporal repos
* (caches, lookup tables, anything without `valid_from` / `valid_until`)
* don't have a meaningful answer to `history()` and shouldn't be forced
* to implement a stub. The temporal decorator in oatpp-authkit#8 is the
* canonical implementer.
*
* Returns versions ordered ascending by `valid_from`, oldest first. An
* empty vector means the entity id was never seen.
*/
template <class TDto>
class IHistoryRepository {
public:
virtual ~IHistoryRepository() = default;
virtual oatpp::Vector<oatpp::Object<TDto>>
history(const oatpp::String& entityId) = 0;
};
} // namespace oatpp_authkit::repo
#endif

View file

@ -0,0 +1,379 @@
#ifndef OATPP_AUTHKIT_REPO_IQUERYABLE_HPP
#define OATPP_AUTHKIT_REPO_IQUERYABLE_HPP
// Optional IQueryable<T> capability for the Repository<T> layer (authkit#9).
//
// A typed query DSL that emits parameterised SQL plus a bind bag. Bounded to
// equality / range / IN / LIKE / NULL / AND / OR / NOT / ORDER BY /
// LIMIT / OFFSET — no joins, subqueries, or aggregates. Concrete repos opt
// into the capability by deriving from `IQueryable<TDto>` and translating
// `Query<T>::toSql()` into their underlying store's prepared statements.
#include "oatpp-authkit/repo/Repository.hpp"
#include "oatpp/core/Types.hpp"
#include <cstdint>
#include <initializer_list>
#include <memory>
#include <sstream>
#include <string>
#include <type_traits>
#include <utility>
#include <variant>
#include <vector>
namespace oatpp_authkit::repo {
// ─── Schema registration ────────────────────────────────────────────────────
//
// Concrete DTOs register their column / table names by specialising these
// two function templates. The primary templates are intentionally declared
// without a definition: forgetting to register a field is a hard compile or
// link error rather than a runtime surprise.
//
// SECURITY INVARIANT (authkit#16 L-8): column and table *identifiers* are
// emitted into SQL unparameterised (SQL placeholders can't bind identifiers).
// They come ONLY from these compile-time registrations / `Field<&Dto::mem>`,
// never from request data — so there is no injection vector. Never construct
// an `OrderBySpec`/`Field` column name from a runtime/user string; map a
// client sort field to a registered `Field` via an allowlist first. All
// *values* (eq/ne/in/like/...) are always bound as `?` parameters.
template <auto MemPtr>
const char* columnName();
template <typename TDto>
const char* tableName();
#define OATPP_AUTHKIT_REGISTER_FIELD(Dto, mem, colName) \
template <> \
inline const char* \
::oatpp_authkit::repo::columnName<&Dto::mem>() { return colName; }
#define OATPP_AUTHKIT_REGISTER_TABLE(Dto, name) \
template <> \
inline const char* \
::oatpp_authkit::repo::tableName<Dto>() { return name; }
// ─── Bind values ────────────────────────────────────────────────────────────
using BindValue = std::variant<std::monostate, // null
std::int64_t,
double,
std::string,
bool>;
inline BindValue toBindValue(std::nullptr_t) { return BindValue{}; }
inline BindValue toBindValue(bool v) { return BindValue{v}; }
inline BindValue toBindValue(int v) { return BindValue{static_cast<std::int64_t>(v)}; }
inline BindValue toBindValue(long v) { return BindValue{static_cast<std::int64_t>(v)}; }
inline BindValue toBindValue(long long v) { return BindValue{static_cast<std::int64_t>(v)}; }
inline BindValue toBindValue(unsigned v) { return BindValue{static_cast<std::int64_t>(v)}; }
inline BindValue toBindValue(double v) { return BindValue{v}; }
inline BindValue toBindValue(float v) { return BindValue{static_cast<double>(v)}; }
inline BindValue toBindValue(const char* v) { return BindValue{std::string(v)}; }
inline BindValue toBindValue(const std::string& v) { return BindValue{v}; }
inline BindValue toBindValue(const oatpp::String& v) {
return v ? BindValue{std::string(*v)} : BindValue{};
}
/**
* @brief Escape LIKE wildcards (`%`, `_`) and the escape char (`\`) in a
* user-supplied search term so they're matched literally (authkit#16
* L-8). Pair with the `LIKE ? ESCAPE '\'` clause emitted by
* `Field::likeContains` / `Field::likePrefix`.
*/
inline std::string likeEscape(const std::string& term) {
std::string out;
out.reserve(term.size() + 4);
for (char c : term) {
if (c == '\\' || c == '%' || c == '_') out.push_back('\\');
out.push_back(c);
}
return out;
}
// ─── AST nodes ──────────────────────────────────────────────────────────────
class AstNode {
public:
virtual ~AstNode() = default;
virtual void emit(std::ostringstream& sql,
std::vector<BindValue>& binds) const = 0;
};
class CompareNode : public AstNode {
std::string col_;
const char* op_;
BindValue val_;
public:
CompareNode(std::string c, const char* o, BindValue v)
: col_(std::move(c)), op_(o), val_(std::move(v)) {}
void emit(std::ostringstream& sql,
std::vector<BindValue>& binds) const override {
sql << col_ << ' ' << op_ << " ?";
binds.push_back(val_);
}
};
class InNode : public AstNode {
std::string col_;
std::vector<BindValue> vals_;
public:
InNode(std::string c, std::vector<BindValue> vs)
: col_(std::move(c)), vals_(std::move(vs)) {}
void emit(std::ostringstream& sql,
std::vector<BindValue>& binds) const override {
if (vals_.empty()) { sql << "0"; return; } // empty IN ⇒ always false
sql << col_ << " IN (";
for (std::size_t i = 0; i < vals_.size(); ++i) {
if (i) sql << ", ";
sql << "?";
binds.push_back(vals_[i]);
}
sql << ")";
}
};
class IsNullNode : public AstNode {
std::string col_;
bool isNull_;
public:
IsNullNode(std::string c, bool n) : col_(std::move(c)), isNull_(n) {}
void emit(std::ostringstream& sql,
std::vector<BindValue>&) const override {
sql << col_ << (isNull_ ? " IS NULL" : " IS NOT NULL");
}
};
/** @brief `col LIKE ? ESCAPE '\'` — the explicit ESCAPE makes a `\`-escaped
* pattern (see `likeEscape`) treat `%`/`_` literally. */
class LikeNode : public AstNode {
std::string col_;
BindValue val_;
public:
LikeNode(std::string c, BindValue v) : col_(std::move(c)), val_(std::move(v)) {}
void emit(std::ostringstream& sql,
std::vector<BindValue>& binds) const override {
sql << col_ << " LIKE ? ESCAPE '\\'";
binds.push_back(val_);
}
};
class CombineNode : public AstNode {
const char* sep_;
std::vector<std::shared_ptr<AstNode>> children_;
public:
CombineNode(const char* sep,
std::vector<std::shared_ptr<AstNode>> kids)
: sep_(sep), children_(std::move(kids)) {}
void emit(std::ostringstream& sql,
std::vector<BindValue>& binds) const override {
if (children_.empty()) { sql << "1"; return; }
sql << "(";
for (std::size_t i = 0; i < children_.size(); ++i) {
if (i) sql << ' ' << sep_ << ' ';
children_[i]->emit(sql, binds);
}
sql << ")";
}
};
class NotNode : public AstNode {
std::shared_ptr<AstNode> child_;
public:
explicit NotNode(std::shared_ptr<AstNode> c) : child_(std::move(c)) {}
void emit(std::ostringstream& sql,
std::vector<BindValue>& binds) const override {
sql << "NOT (";
child_->emit(sql, binds);
sql << ")";
}
};
// ─── Predicate composition wrapper ──────────────────────────────────────────
class Predicate {
std::shared_ptr<AstNode> node_;
public:
Predicate() = default;
explicit Predicate(std::shared_ptr<AstNode> n) : node_(std::move(n)) {}
bool empty() const noexcept { return !node_; }
std::shared_ptr<AstNode> node() const noexcept { return node_; }
void emit(std::ostringstream& sql,
std::vector<BindValue>& binds) const {
if (node_) node_->emit(sql, binds);
}
friend Predicate operator&&(const Predicate& a, const Predicate& b) {
if (a.empty()) return b;
if (b.empty()) return a;
return Predicate{std::make_shared<CombineNode>(
"AND", std::vector<std::shared_ptr<AstNode>>{a.node_, b.node_})};
}
friend Predicate operator||(const Predicate& a, const Predicate& b) {
if (a.empty()) return b;
if (b.empty()) return a;
return Predicate{std::make_shared<CombineNode>(
"OR", std::vector<std::shared_ptr<AstNode>>{a.node_, b.node_})};
}
friend Predicate operator!(const Predicate& a) {
if (a.empty()) return a;
return Predicate{std::make_shared<NotNode>(a.node_)};
}
};
// ─── Field references ───────────────────────────────────────────────────────
//
// `field<&PersonDto::email>().eq("foo@bar")` resolves the column name through
// the `columnName<>` specialisation. Comparison methods return `Predicate`s
// that compose with `operator&&` / `||` / `!`.
template <auto MemPtr>
class Field {
public:
const char* column() const { return columnName<MemPtr>(); }
template <typename V> Predicate eq(V&& v) const { return mk("=", std::forward<V>(v)); }
template <typename V> Predicate ne(V&& v) const { return mk("!=", std::forward<V>(v)); }
template <typename V> Predicate lt(V&& v) const { return mk("<", std::forward<V>(v)); }
template <typename V> Predicate gt(V&& v) const { return mk(">", std::forward<V>(v)); }
template <typename V> Predicate le(V&& v) const { return mk("<=", std::forward<V>(v)); }
template <typename V> Predicate ge(V&& v) const { return mk(">=", std::forward<V>(v)); }
template <typename C>
Predicate in(const C& values) const {
std::vector<BindValue> bs;
for (auto& v : values) bs.push_back(toBindValue(v));
return Predicate{std::make_shared<InNode>(column(), std::move(bs))};
}
template <typename V>
Predicate in(std::initializer_list<V> values) const {
std::vector<BindValue> bs;
for (auto& v : values) bs.push_back(toBindValue(v));
return Predicate{std::make_shared<InNode>(column(), std::move(bs))};
}
/** @brief Raw `col LIKE ?` with the pattern bound verbatim. The caller owns
* the `%`/`_` wildcards only pass a TRUSTED pattern here. For a
* user-supplied search term use `likeContains` / `likePrefix` (which
* escape the metacharacters), or wrap it with `likeEscape`. */
Predicate like(const std::string& pat) const {
return Predicate{std::make_shared<CompareNode>(
column(), "LIKE", BindValue{pat})};
}
/** @brief Substring match of a user-supplied `term` with LIKE wildcards
* escaped emits `col LIKE '%<escaped>%' ESCAPE '\'` (authkit#16 L-8). */
Predicate likeContains(const std::string& term) const {
return Predicate{std::make_shared<LikeNode>(
column(), BindValue{"%" + likeEscape(term) + "%"})};
}
/** @brief Prefix match of a user-supplied `term` with LIKE wildcards
* escaped emits `col LIKE '<escaped>%' ESCAPE '\'`. */
Predicate likePrefix(const std::string& term) const {
return Predicate{std::make_shared<LikeNode>(
column(), BindValue{likeEscape(term) + "%"})};
}
Predicate isNull() const { return Predicate{std::make_shared<IsNullNode>(column(), true)}; }
Predicate isNotNull() const { return Predicate{std::make_shared<IsNullNode>(column(), false)}; }
private:
template <typename V>
Predicate mk(const char* op, V&& v) const {
return Predicate{std::make_shared<CompareNode>(
column(), op, toBindValue(std::forward<V>(v)))};
}
};
template <auto MemPtr>
inline Field<MemPtr> field() { return Field<MemPtr>{}; }
// ─── Query builder ──────────────────────────────────────────────────────────
struct OrderBySpec {
std::string column;
bool ascending;
};
template <typename TDto>
class Query {
Predicate where_;
std::vector<OrderBySpec> orderBy_;
std::int64_t limit_ = -1;
std::int64_t offset_ = 0;
public:
Query& where(Predicate p) {
where_ = where_.empty() ? std::move(p) : (where_ && std::move(p));
return *this;
}
template <auto MemPtr>
Query& orderBy(Field<MemPtr> f, bool ascending = true) {
orderBy_.push_back({f.column(), ascending});
return *this;
}
template <auto MemPtr>
Query& orderByDesc(Field<MemPtr> f) { return orderBy(f, false); }
Query& limit(std::int64_t n) { limit_ = n; return *this; }
Query& offset(std::int64_t n) { offset_ = n; return *this; }
const Predicate& wherePredicate() const { return where_; }
const std::vector<OrderBySpec>& orderBySpecs() const { return orderBy_; }
std::int64_t limitValue() const { return limit_; }
std::int64_t offsetValue() const { return offset_; }
/**
* Render the query as a parameterised `SELECT * FROM <table> ...`.
* Concrete repositories take the returned text + bind bag and feed
* them into their underlying prepared-statement mechanism.
*/
struct Sql {
std::string text;
std::vector<BindValue> binds;
};
Sql toSql() const {
std::ostringstream s;
std::vector<BindValue> binds;
s << "SELECT * FROM " << tableName<TDto>();
if (!where_.empty()) {
s << " WHERE ";
where_.emit(s, binds);
}
if (!orderBy_.empty()) {
s << " ORDER BY ";
for (std::size_t i = 0; i < orderBy_.size(); ++i) {
if (i) s << ", ";
s << orderBy_[i].column
<< (orderBy_[i].ascending ? " ASC" : " DESC");
}
}
if (limit_ >= 0) s << " LIMIT " << limit_;
if (offset_ > 0) s << " OFFSET " << offset_;
return {s.str(), std::move(binds)};
}
};
// ─── Capability interface ───────────────────────────────────────────────────
/**
* @brief Optional capability for repositories that can resolve a typed `Query`.
*
* Concrete repos derive from `IQueryable<TDto>` (instead of plain
* `Repository<TDto>`) when they want to expose AST-driven filtering.
* Decorators stay agnostic they wrap `Repository<TDto>` and downcast only
* when a caller specifically asks for the queryable surface.
*/
template <typename TDto>
class IQueryable : public Repository<TDto> {
public:
virtual oatpp::Vector<oatpp::Object<TDto>>
query(const Query<TDto>& q) = 0;
};
} // namespace oatpp_authkit::repo
#endif

View file

@ -0,0 +1,141 @@
#ifndef OATPP_AUTHKIT_REPO_REDACTED_FIELD_REPOSITORY_HPP
#define OATPP_AUTHKIT_REPO_REDACTED_FIELD_REPOSITORY_HPP
// Decorator that nulls out named fields on historical rows (authkit#15).
//
// Use case: when password_hash and similar credentials ride a temporal
// row, every change creates a historical version with the prior secret
// preserved. A DB breach then yields every credential the user has ever
// had — a known-plaintext oracle for guessing future passwords.
//
// This decorator sits **between** TemporalRepository and the concrete
// repo. TemporalRepository's `save` flow calls inner `save` twice:
//
// 1. Historical clone with `valid_until = now()` (the row being closed)
// 2. Live row with `valid_until = SENTINEL` (the new version)
//
// We redact configured fields whenever `valid_until != SENTINEL` on
// entry — i.e. only on the historical insert. The live row keeps its
// values intact.
//
// Stack:
//
// TemporalRepository<UserDto>(
// RedactedFieldRepository<UserDto>(
// ConcreteUserRepository(udb),
// {"passwordHash", "tlsCertDn"}))
#include "oatpp-authkit/repo/Repository.hpp"
#include "oatpp-authkit/repo/TemporalFieldTraits.hpp"
#include "oatpp-authkit/repo/TemporalRepository.hpp"
#include "oatpp/core/Types.hpp"
#include <memory>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
namespace oatpp_authkit::repo {
/**
* @brief Decorator that redacts named fields on historical rows.
*
* `TDto` must register a `TemporalFieldTraits<TDto>` specialisation so
* the decorator can read `valid_until` to distinguish historical rows
* from live ones. Field-name matching uses oatpp's reflective property
* dispatcher and matches against the **C++ identifier** name (the first
* macro argument to `DTO_FIELD`), not the JSON-qualified column name.
*
* Schema contribution: empty. The redaction is purely a save-time
* transform; no extra columns or indexes are needed.
*/
template <typename TDto>
class RedactedFieldRepository : public Repository<TDto> {
public:
inline static constexpr DecoratorSchema kSchema = {
"RedactedFieldRepository",
nullptr, 0, nullptr, 0, nullptr, 0,
};
/**
* @param inner Concrete adapter (or any further-inner stack).
* @param fieldsToRedact C++ identifier names of DTO fields to null
* out on historical writes (e.g. `"passwordHash"`).
*/
RedactedFieldRepository(std::shared_ptr<Repository<TDto>> inner,
std::vector<std::string> fieldsToRedact)
: m_inner(std::move(inner))
, m_fieldsToRedact(std::move(fieldsToRedact))
{
// authkit#16 M-6: fail loud if a configured field name doesn't exist on
// the DTO. A typo (or passing the JSON column name instead of the C++
// identifier) would otherwise silently redact nothing, leaving the
// credential in history — the exact breach this decorator prevents.
const auto* dispatcher = static_cast<
const oatpp::data::mapping::type::__class::AbstractObject::PolymorphicDispatcher*>(
oatpp::Object<TDto>::Class::getType()->polymorphicDispatcher);
for (const auto& target : m_fieldsToRedact) {
bool found = false;
for (auto* p : dispatcher->getProperties()->getList()) {
if (target == p->name) { found = true; break; }
}
if (!found) {
throw std::invalid_argument(
"RedactedFieldRepository: unknown DTO field '" + target +
"' (use the C++ identifier from DTO_FIELD, not the JSON name)");
}
}
}
oatpp::Object<TDto> findByEntityId(const oatpp::String& entityId) override {
return m_inner->findByEntityId(entityId);
}
oatpp::Vector<oatpp::Object<TDto>> list() override {
return m_inner->list();
}
void save(const oatpp::Object<TDto>& dto) override {
if (isHistorical(dto)) redactFields(dto);
m_inner->save(dto);
}
void softDelete(const oatpp::String& entityId) override {
m_inner->softDelete(entityId);
}
private:
/// A row is historical iff `valid_until` is non-null and not the
/// SENTINEL. The TemporalRepository sets `valid_until = now()` on
/// the close-clone and `valid_until = SENTINEL` on the live update.
static bool isHistorical(const oatpp::Object<TDto>& dto) {
auto& vu = TemporalFieldTraits<TDto>::validUntil(dto);
if (!vu) return false;
return std::string(*vu) != TemporalRepository<TDto>::SENTINEL;
}
void redactFields(const oatpp::Object<TDto>& dto) const {
const auto* dispatcher = static_cast<
const oatpp::data::mapping::type::__class::AbstractObject::PolymorphicDispatcher*>(
oatpp::Object<TDto>::Class::getType()->polymorphicDispatcher);
for (auto* p : dispatcher->getProperties()->getList()) {
const std::string name(p->name);
for (const auto& target : m_fieldsToRedact) {
if (name == target) {
p->set(static_cast<oatpp::BaseObject*>(dto.get()),
oatpp::Void(nullptr, p->type));
break;
}
}
}
}
std::shared_ptr<Repository<TDto>> m_inner;
std::vector<std::string> m_fieldsToRedact;
};
} // namespace oatpp_authkit::repo
#endif

View file

@ -0,0 +1,68 @@
#ifndef OATPP_AUTHKIT_REPO_REPOSITORY_HPP
#define OATPP_AUTHKIT_REPO_REPOSITORY_HPP
#include "oatpp/core/Types.hpp"
namespace oatpp_authkit::repo {
/**
* @brief Pure-abstract per-DTO repository interface.
*
* Generic on `TDto`, temporal-agnostic. Concrete adapters wrap an
* `oatpp::orm::DbClient` (or any other store) and implement the four
* methods below. Cross-cutting concerns (temporal versioning, scope
* authorisation) are added by stacking decorators from oatpp-authkit#8
* around the concrete adapter at construction time.
*
* @section semantics Method semantics
*
* - `findByEntityId(entityId)` Single live row matching `entity_id`.
* Returns null `oatpp::Object` when not found. The decorator that adds
* point-in-time reads exposes a different method (`findByEntityId(id, at)`)
* on its own surface; the abstract here stays narrow.
*
* - `list()` All live rows for this entity type, no filtering. Filtered
* reads land in the optional `IQueryable<T>` capability tracked by
* oatpp-authkit#9; do not bake filter predicates into this base interface.
*
* - `save(dto)` Mixed `entity_id` allocation:
* - If `dto->entity_id` is null on entry, the implementation generates
* a fresh UUID and writes it back to the DTO before persisting.
* - If `dto->entity_id` is non-null, it is used as-is.
* No upsert semantics are implied at this layer the temporal decorator
* in oatpp-authkit#8 turns "save" into a versioning insert; without that
* decorator the concrete repo decides whether `save` is insert-or-update
* on its own.
*
* - `softDelete(entityId)` Marks the row removed without erasing history.
* Concrete repos typically set a `deleted_at` column or its equivalent.
*
* @section design Design decisions (all settled in the issue body)
*
* 1. `entity_id` allocation is mixed (caller may supply or leave null).
* 2. UnitOfWork / cross-repo transactions are explicitly out of scope.
* 3. `Repository<T>` is a virtual-method interface, not a C++20 concept.
* 4. History queries live on a separate `IHistoryRepository<T>` so non-
* temporal repos don't have to implement them.
*/
template <class TDto>
class Repository {
public:
virtual ~Repository() = default;
/** @brief Single live row by stable entity id. Null oatpp::Object when not found. */
virtual oatpp::Object<TDto> findByEntityId(const oatpp::String& entityId) = 0;
/** @brief All live rows for this entity type (no filtering at this layer). */
virtual oatpp::Vector<oatpp::Object<TDto>> list() = 0;
/** @brief Persist DTO; allocate UUID for `entity_id` if null on entry. */
virtual void save(const oatpp::Object<TDto>& dto) = 0;
/** @brief Mark the row removed without erasing it. */
virtual void softDelete(const oatpp::String& entityId) = 0;
};
} // namespace oatpp_authkit::repo
#endif

View file

@ -0,0 +1,289 @@
#ifndef OATPP_AUTHKIT_REPO_SCHEMA_CONTRACT_HPP
#define OATPP_AUTHKIT_REPO_SCHEMA_CONTRACT_HPP
// Declarative schema model for the decorator stack (authkit#14).
//
// Each decorator (and the concrete repo at the bottom of the stack)
// exposes a `static constexpr DecoratorSchema kSchema = {…}` listing the
// columns/indexes it contributes to the entity table plus any sidecar
// tables it owns. A `SchemaBuilder<Decorators…>::create(table, exec)`
// composes all contributions into a single `CREATE TABLE` per entity
// table; sidecar tables are emitted separately.
//
// Schema authority lives **in the C++ decorator code**. Atlas
// (atlasgo.io) consumes the result by inspecting an empty SQLite that the
// builder has populated and treats that as the desired state for diff/
// apply against running prod databases. Decorator code never runs ALTER
// at runtime; `SchemaContract::verify` only introspects-and-asserts.
//
// This replaces the imperative PREREQ + RESHAPE_STEPS kit from
// authkit#12 (D-replace per the issue thread).
#include <cstddef>
#include <functional>
#include <stdexcept>
#include <string>
#include <unordered_set>
#include <utility>
#include <vector>
namespace oatpp_authkit::repo {
/**
* @brief One column a decorator contributes to a table.
*
* `type` is the full SQL type fragment as it would appear in
* `CREATE TABLE` after the column name e.g. `"TEXT NOT NULL"` or
* `"TEXT NOT NULL DEFAULT '9999-12-31T23:59:59Z'"`. Kept as a single
* string so consumers don't have to decompose constraints; Atlas's
* inspector parses it once on the round-trip.
*/
struct ColumnSpec {
const char* name;
const char* type;
};
/**
* @brief One index a decorator contributes to the entity table.
*
* `nameTemplate` may contain `{table}` substituted with the live table
* name at build time. The decorator-local convention is to prefix
* decorator-owned indexes (e.g. `ux_{table}_`) so multiple decorators
* stacking on the same table don't collide.
*/
struct IndexSpec {
const char* nameTemplate;
bool unique;
const char* columns; ///< e.g. `"(entity_id, valid_until)"`
};
/**
* @brief A sidecar table owned by one decorator (e.g. `audit_log`).
*
* Sidecar tables have fixed names the placeholder substitution that
* applies to entity-table contributions does not apply here. Multiple
* decorators referencing the same sidecar (e.g. two repos auditing
* through the same `audit_log`) is fine `SchemaBuilder::create` emits
* each sidecar with `CREATE TABLE IF NOT EXISTS`, idempotent.
*/
struct SidecarTableSpec {
const char* name;
const ColumnSpec* columns;
std::size_t numColumns;
};
/**
* @brief What one decorator (or concrete repo) contributes to the schema.
*
* The concrete repo at the bottom of the stack typically contributes
* the entity_id + business columns; decorators above contribute their
* cross-cutting columns (temporal triple, scope columns, ).
*/
struct DecoratorSchema {
const char* decoratorName;
const ColumnSpec* entityColumns;
std::size_t numEntityColumns;
const IndexSpec* entityIndexes;
std::size_t numEntityIndexes;
const SidecarTableSpec* sidecarTables;
std::size_t numSidecarTables;
};
/**
* @brief Wraps a callable that runs a single SQL statement against the
* consumer's database. Decoupled from any specific ORM so authkit stays
* portable.
*/
using SqlExec = std::function<void(const std::string& sql)>;
/**
* @brief Wraps a callable that returns true iff the query yields 1 row.
* Used by `SchemaContract::verify` to introspect column presence.
*/
using SqlProbe = std::function<bool(const std::string& sql)>;
/**
* @brief Substitutes `{table}` -> `tableName` in `sqlTemplate`.
* Pure string replacement no SQL parsing.
*/
inline std::string instantiate(const std::string& sqlTemplate,
const std::string& tableName) {
std::string out(sqlTemplate);
static const std::string ph = "{table}";
for (std::size_t pos = out.find(ph); pos != std::string::npos;
pos = out.find(ph, pos + tableName.size())) {
out.replace(pos, ph.size(), tableName);
}
return out;
}
/**
* @brief Thrown by `SchemaContract::verify` when a decorator's required
* columns are absent from the live DB. Catchers (typically the consumer's
* startup wiring) translate to a fatal startup error.
*/
class SchemaContractViolation : public std::runtime_error {
public:
using std::runtime_error::runtime_error;
};
namespace detail {
inline std::string emitColumnList(const std::vector<ColumnSpec>& cols) {
std::string out;
bool first = true;
for (const auto& c : cols) {
if (!first) out += ",\n ";
first = false;
out += c.name;
out += " ";
out += c.type;
}
return out;
}
inline std::string emitCreateTable(const std::string& tableName,
const std::vector<ColumnSpec>& cols) {
return "CREATE TABLE IF NOT EXISTS " + tableName + " (\n "
+ emitColumnList(cols) + "\n)";
}
inline std::string emitCreateIndex(const std::string& tableName,
const IndexSpec& idx) {
std::string name = instantiate(idx.nameTemplate, tableName);
std::string out = "CREATE ";
if (idx.unique) out += "UNIQUE ";
out += "INDEX IF NOT EXISTS " + name + " ON " + tableName + " " + idx.columns;
return out;
}
inline void appendUniqueColumns(std::vector<ColumnSpec>& acc,
std::unordered_set<std::string>& seen,
const ColumnSpec* src,
std::size_t n) {
for (std::size_t i = 0; i < n; ++i) {
std::string key(src[i].name ? src[i].name : "");
if (seen.insert(key).second) acc.push_back(src[i]);
}
}
inline void appendIndexes(std::vector<IndexSpec>& acc,
const IndexSpec* src,
std::size_t n) {
for (std::size_t i = 0; i < n; ++i) acc.push_back(src[i]);
}
inline void emitSidecars(const DecoratorSchema& s, const SqlExec& exec) {
for (std::size_t i = 0; i < s.numSidecarTables; ++i) {
const auto& t = s.sidecarTables[i];
std::vector<ColumnSpec> cols(t.columns, t.columns + t.numColumns);
exec(emitCreateTable(t.name, cols));
}
}
} // namespace detail
/**
* @brief Composes every decorator's contributions into a single CREATE
* per entity table + sidecar CREATEs.
*
* The parameter pack is the full repository stack, bottom-up. Typical use:
*
* @code
* SchemaBuilder<
* ConcretePersonRepository,
* TemporalRepository<PersonDto>,
* ScopeGuardRepository<PersonDto>,
* AuditLogRepository<PersonDto>
* >::create("persons", exec);
* @endcode
*
* The builder runs in two passes:
*
* 1. **Sidecars first**, so any cross-table FK from the entity table
* (none today, but future-proofing) can resolve.
* 2. **Entity table** with the union of all `entityColumns`. Columns are
* deduplicated by name on first appearance; later decorators
* contributing the same column name are silently skipped (current
* behavior). Indexes are emitted in declaration order.
*/
template <typename... Decorators>
class SchemaBuilder {
public:
static void create(const std::string& tableName, const SqlExec& exec) {
(detail::emitSidecars(Decorators::kSchema, exec), ...);
std::vector<ColumnSpec> cols;
std::unordered_set<std::string> seen;
std::vector<IndexSpec> idxs;
(detail::appendUniqueColumns(cols, seen,
Decorators::kSchema.entityColumns,
Decorators::kSchema.numEntityColumns), ...);
(detail::appendIndexes(idxs,
Decorators::kSchema.entityIndexes,
Decorators::kSchema.numEntityIndexes), ...);
if (!cols.empty()) {
exec(detail::emitCreateTable(tableName, cols));
}
for (const auto& idx : idxs) {
exec(detail::emitCreateIndex(tableName, idx));
}
}
};
/**
* @brief Runtime introspect-and-assert. On startup, walks the decorator
* stack and probes the live DB for every required column. Throws
* `SchemaContractViolation` if any are missing guarantees the running
* code can never operate against an under-migrated DB.
*
* Atlas owns the migration that put the columns there; this only checks
* that the migration ran. SQLite-specific probe SQL is used by default
* (`pragma_table_info`); consumers on other engines wire their own probe
* via the `SqlProbe` callback signature, which receives a fully-formed
* `SELECT 1 FROM pragma_table_info('table') WHERE name='col'` query.
*/
template <typename... Decorators>
class SchemaContract {
public:
static void verify(const std::string& tableName, const SqlProbe& probe) {
(verifyOne<Decorators>(tableName, probe), ...);
}
private:
template <typename D>
static void verifyOne(const std::string& tableName, const SqlProbe& probe) {
const auto& s = D::kSchema;
for (std::size_t i = 0; i < s.numEntityColumns; ++i) {
const char* col = s.entityColumns[i].name;
if (!col || !*col) continue;
std::string q = "SELECT 1 FROM pragma_table_info('" + tableName +
"') WHERE name='" + col + "'";
if (!probe(q)) {
throw SchemaContractViolation(
std::string("schema contract violation: decorator '") +
s.decoratorName + "' requires column '" + col +
"' on table '" + tableName + "', but it is missing");
}
}
for (std::size_t i = 0; i < s.numSidecarTables; ++i) {
const auto& t = s.sidecarTables[i];
std::string q = std::string("SELECT 1 FROM sqlite_master WHERE "
"type='table' AND name='") + t.name + "'";
if (!probe(q)) {
throw SchemaContractViolation(
std::string("schema contract violation: decorator '") +
s.decoratorName + "' requires sidecar table '" + t.name +
"', but it is missing");
}
}
}
};
} // namespace oatpp_authkit::repo
#endif

View file

@ -0,0 +1,210 @@
#ifndef OATPP_AUTHKIT_REPO_SCOPE_GUARD_REPOSITORY_HPP
#define OATPP_AUTHKIT_REPO_SCOPE_GUARD_REPOSITORY_HPP
#include "oatpp-authkit/repo/Repository.hpp"
#include "oatpp-authkit/repo/ActorContext.hpp"
#include "oatpp-authkit/repo/SchemaContract.hpp"
#include "oatpp-authkit/repo/IQueryable.hpp"
#include "oatpp/core/Types.hpp"
#include <functional>
#include <memory>
#include <stdexcept>
#include <utility>
namespace oatpp_authkit::repo {
/**
* @brief Thrown when the scope guard predicate denies an operation.
*
* Catchers (typically the controller layer) translate this into the
* appropriate HTTP error 403 Forbidden in fewo-webapp's case. The
* decorator stays library-portable by throwing a plain exception rather
* than coupling to oatpp's `OatppException` hierarchy.
*/
class ScopeDeniedException : public std::runtime_error {
public:
using std::runtime_error::runtime_error;
};
/**
* @brief Decorator that gates every repository operation on a predicate.
*
* Generic knows nothing about "property" / "tenant" / any consumer-
* specific scope concept. The predicate decides; this class just calls it.
*
* @section semantics Per-method behaviour
*
* - `findByEntityId(id)`: load from inner; if non-null and predicate
* denies, throw `ScopeDeniedException`. (Information-leak vs. clean
* error tradeoff: throwing is the safer default callers that want to
* silently 404 instead can catch and translate.)
* - `list()`: load from inner; filter out rows the predicate denies.
* - `save(dto)`: predicate must pass on the incoming dto AND, for an update,
* on the row as it currently stands. Checking only the incoming dto would
* let an actor reparent an out-of-scope row into its own scope by setting
* the scope field in the request body (BOLA / set-your-own-scope). The
* existing-row lookup uses the constructor-injected `entityIdOf` accessor.
* - `softDelete(id)`: load from inner; if denied, throw; otherwise delegate.
*
* The actor is provided via a constructor-injected accessor so a single
* `ScopeGuardRepository` instance can serve many requests with different
* actors (typically the accessor reads from the per-request authenticated
* principal fewo-webapp's `AuthInterceptor` populates one).
*
* @note `IQueryable<TDto>` is a *separate* data-access surface. Wrapping an
* `IQueryable` repo in this decorator does NOT guard `query()` a
* caller that obtains the inner queryable would bypass the scope
* predicate entirely. Use `ScopeGuardQueryable<TDto>` (below) when the
* inner exposes the queryable capability.
*/
template <class TDto>
class ScopeGuardRepository : public Repository<TDto> {
public:
using Predicate = std::function<bool(const ActorContext&, const oatpp::Object<TDto>&)>;
using ActorAccess = std::function<ActorContext()>;
/// Extracts the stable `entity_id` from a DTO (e.g. `[](auto& d){ return d->entity_id; }`).
/// Used to load the existing row on `save()` so an update can't reparent an
/// out-of-scope row. Returns null for a not-yet-allocated entity (fresh insert).
using EntityIdAccess = std::function<oatpp::String(const oatpp::Object<TDto>&)>;
/// Declarative schema contribution (authkit#14, D-replace).
/// ScopeGuard touches no schema — empty contributions exposed so it
/// composes cleanly into `SchemaBuilder<…>` parameter packs.
inline static constexpr DecoratorSchema kSchema = {
"ScopeGuardRepository",
nullptr, 0,
nullptr, 0,
nullptr, 0,
};
ScopeGuardRepository(std::shared_ptr<Repository<TDto>> inner,
Predicate isAllowed,
ActorAccess currentActor,
EntityIdAccess entityIdOf)
: m_inner(std::move(inner))
, m_isAllowed(std::move(isAllowed))
, m_currentActor(std::move(currentActor))
, m_entityIdOf(std::move(entityIdOf))
{}
oatpp::Object<TDto> findByEntityId(const oatpp::String& entityId) override {
auto row = m_inner->findByEntityId(entityId);
if (!row) return row;
if (!m_isAllowed(m_currentActor(), row)) {
throw ScopeDeniedException("scope guard denied findByEntityId");
}
return row;
}
oatpp::Vector<oatpp::Object<TDto>> list() override {
auto inAll = m_inner->list();
auto out = oatpp::Vector<oatpp::Object<TDto>>::createShared();
const ActorContext actor = m_currentActor();
for (auto& row : *inAll) {
if (m_isAllowed(actor, row)) out->push_back(row);
}
return out;
}
void save(const oatpp::Object<TDto>& dto) override {
const ActorContext actor = m_currentActor();
// 1. The incoming DTO must be in scope — you can't write into a scope
// you don't own.
if (!m_isAllowed(actor, dto)) {
throw ScopeDeniedException("scope guard denied save (incoming)");
}
// 2. If this is an update of an existing entity, the row as it stands
// NOW must also be in scope. Otherwise an actor scoped to A could
// take an entity currently owned by B and reparent it into A simply
// by setting the scope field in the body. A fresh insert has a null
// entity_id (or no matching row) and skips this check.
if (m_entityIdOf) {
auto eid = m_entityIdOf(dto);
if (eid) {
auto existing = m_inner->findByEntityId(eid);
if (existing && !m_isAllowed(actor, existing)) {
throw ScopeDeniedException("scope guard denied save (existing row out of scope)");
}
}
}
m_inner->save(dto);
}
void softDelete(const oatpp::String& entityId) override {
auto row = m_inner->findByEntityId(entityId);
if (!row) return; // Nothing to delete; matches Repository<T>::softDelete being a no-op for unknown ids.
if (!m_isAllowed(m_currentActor(), row)) {
throw ScopeDeniedException("scope guard denied softDelete");
}
m_inner->softDelete(entityId);
}
private:
std::shared_ptr<Repository<TDto>> m_inner;
Predicate m_isAllowed;
ActorAccess m_currentActor;
EntityIdAccess m_entityIdOf;
};
/**
* @brief `ScopeGuardRepository` for inners that also expose `IQueryable<TDto>`.
*
* `ScopeGuardRepository` guards only the four `Repository<TDto>` methods; the
* `IQueryable::query()` surface is separate, so a scope-guarded `IQueryable`
* repo would otherwise leak every row a raw query returns. This decorator
* closes that hole: it implements `IQueryable<TDto>`, delegates the CRUD
* methods to an embedded `ScopeGuardRepository` (same predicate / actor /
* entity-id semantics, including the reparenting check), and post-filters
* `query()` results through the predicate exactly like `list()` does.
*
* Wire this not the plain `ScopeGuardRepository` whenever the concrete
* inner derives from `IQueryable<TDto>`.
*/
template <class TDto>
class ScopeGuardQueryable : public IQueryable<TDto> {
public:
using Predicate = typename ScopeGuardRepository<TDto>::Predicate;
using ActorAccess = typename ScopeGuardRepository<TDto>::ActorAccess;
using EntityIdAccess = typename ScopeGuardRepository<TDto>::EntityIdAccess;
ScopeGuardQueryable(std::shared_ptr<IQueryable<TDto>> inner,
Predicate isAllowed,
ActorAccess currentActor,
EntityIdAccess entityIdOf)
: m_inner(std::move(inner))
, m_isAllowed(isAllowed)
, m_currentActor(currentActor)
, m_guard(m_inner, std::move(isAllowed), std::move(currentActor), std::move(entityIdOf))
{}
oatpp::Object<TDto> findByEntityId(const oatpp::String& entityId) override {
return m_guard.findByEntityId(entityId);
}
oatpp::Vector<oatpp::Object<TDto>> list() override { return m_guard.list(); }
void save(const oatpp::Object<TDto>& dto) override { m_guard.save(dto); }
void softDelete(const oatpp::String& entityId) override { m_guard.softDelete(entityId); }
/** @brief Run the inner query, then drop every row the predicate denies. */
oatpp::Vector<oatpp::Object<TDto>> query(const Query<TDto>& q) override {
auto rows = m_inner->query(q);
auto out = oatpp::Vector<oatpp::Object<TDto>>::createShared();
if (!rows) return out;
const ActorContext actor = m_currentActor();
for (auto& row : *rows) {
if (m_isAllowed(actor, row)) out->push_back(row);
}
return out;
}
private:
std::shared_ptr<IQueryable<TDto>> m_inner;
Predicate m_isAllowed;
ActorAccess m_currentActor;
ScopeGuardRepository<TDto> m_guard;
};
} // namespace oatpp_authkit::repo
#endif

View file

@ -0,0 +1,33 @@
#ifndef OATPP_AUTHKIT_REPO_TEMPORAL_AT_HPP
#define OATPP_AUTHKIT_REPO_TEMPORAL_AT_HPP
#include <cstdint>
namespace oatpp_authkit::repo {
/**
* @brief Point-in-time selector for temporal reads.
*
* Concrete repositories implementing temporal versioning use this to
* choose between "live" (`valid_until = sentinel`) and "as-of a specific
* timestamp" reads. The interface is decoupled from any particular clock
* type; consumers pass milliseconds-since-epoch.
*/
struct TemporalAt {
enum class Kind { Live, At };
Kind kind{Kind::Live};
int64_t timestamp{0}; ///< Milliseconds since epoch; only meaningful when kind == At.
static TemporalAt live() {
return TemporalAt{Kind::Live, 0};
}
static TemporalAt at(int64_t ts) {
return TemporalAt{Kind::At, ts};
}
};
} // namespace oatpp_authkit::repo
#endif

View file

@ -0,0 +1,61 @@
#ifndef OATPP_AUTHKIT_REPO_TEMPORAL_FIELD_TRAITS_HPP
#define OATPP_AUTHKIT_REPO_TEMPORAL_FIELD_TRAITS_HPP
#include "oatpp/core/Types.hpp"
namespace oatpp_authkit::repo {
/**
* @brief Trait that tells `TemporalRepository<T>` where `T` keeps its
* row PK, entity identity, valid_from and valid_until columns.
*
* Primary template is intentionally undefined using
* `TemporalFieldTraits<MyDto>` against a DTO that hasn't been registered
* is a hard compile error pointing at the call site. Specialise with
* `OATPP_AUTHKIT_REGISTER_TEMPORAL` once per temporal DTO.
*
* Each accessor returns `oatpp::String&` so the repository can both read
* and rewrite the value. Field names on the DTO are arbitrary the
* trait is the canonical name, the DTO column is whatever the consumer
* picked.
*
* Four canonical fields:
* - `id` : per-row PK (version UUID). Preserved across in-place
* updates of the live row; freshly allocated for each
* historical copy.
* - `entityId` : stable logical identity, shared by every version of
* the same logical entity.
* - `validFrom` : ISO-8601 timestamp when this version became effective.
* - `validUntil` : ISO-8601 timestamp when this version ceased to be
* effective; SENTINEL while live.
*/
template <class TDto>
struct TemporalFieldTraits; // intentionally undefined
} // namespace oatpp_authkit::repo
/**
* Register a temporal DTO with the trait machinery. Place at namespace
* scope (typically right after the DTO definition):
*
* OATPP_AUTHKIT_REGISTER_TEMPORAL(PersonDto,
* id, entity_id, valid_from, valid_until)
*
* The four trailing identifiers are the actual DTO_FIELD member names
* they don't have to match the canonical names. A DTO that uses
* `effective_from` / `effective_until` registers exactly the same way.
*
* `IdMember` is the per-row PK. `EntityIdMember` is the stable logical
* identity that's shared by every version of the same entity.
*/
#define OATPP_AUTHKIT_REGISTER_TEMPORAL(Dto, IdMember, EntityIdMember, FromMember, UntilMember) \
namespace oatpp_authkit::repo { \
template<> struct TemporalFieldTraits<Dto> { \
static ::oatpp::String& id (const ::oatpp::Object<Dto>& d) { return d->IdMember; } \
static ::oatpp::String& entityId (const ::oatpp::Object<Dto>& d) { return d->EntityIdMember; } \
static ::oatpp::String& validFrom (const ::oatpp::Object<Dto>& d) { return d->FromMember; } \
static ::oatpp::String& validUntil (const ::oatpp::Object<Dto>& d) { return d->UntilMember; } \
}; \
}
#endif

View file

@ -0,0 +1,332 @@
#ifndef OATPP_AUTHKIT_REPO_TEMPORAL_REPOSITORY_HPP
#define OATPP_AUTHKIT_REPO_TEMPORAL_REPOSITORY_HPP
#include "oatpp-authkit/repo/Repository.hpp"
#include "oatpp-authkit/repo/IHistoryRepository.hpp"
#include "oatpp-authkit/repo/TemporalFieldTraits.hpp"
#include "oatpp-authkit/repo/TemporalAt.hpp"
#include "oatpp-authkit/repo/SchemaContract.hpp"
#include "oatpp/core/Types.hpp"
#include <chrono>
#include <ctime>
#include <cstdio>
#include <functional>
#include <memory>
#include <mutex>
#include <random>
#include <string>
#include <type_traits>
#include <utility>
#include <vector>
namespace oatpp_authkit::repo {
/**
* @brief Decorator that turns any `Repository<TDto>` into a temporally-versioned one.
*
* `TDto` must register a `TemporalFieldTraits<TDto>` specialisation (use
* the `OATPP_AUTHKIT_REGISTER_TEMPORAL` macro right after the DTO
* definition). The trait names the DTO members that hold the canonical
* `entity_id`, `valid_from`, `valid_until` columns actual member names
* on the DTO are arbitrary, the trait does the mapping. Forgetting to
* register surfaces as a hard compile error at the first trait use.
*
* @section contract Inner repository contract
*
* The wrapped inner `Repository<TDto>` is expected to:
*
* - Treat `save(dto)` as **upsert keyed by `id`** (the per-row PK). If
* `dto->id` matches an existing row, UPDATE it in place. Otherwise
* INSERT a new row.
* - Treat `list()` as **all rows including historical ones** no filtering
* by `valid_until`. This decorator does the live-vs-historical filtering
* itself.
* - `findByEntityId` and `softDelete` on the inner are **not used by the
* decorator**; the decorator overrides them with temporal-aware
* implementations.
*
* @section semantics Decorator semantics
*
* Write semantics (authkit#13): **stable-live row + historical copy.**
* The live row's `id` PK is preserved across every update; only its
* mutable columns (and `valid_from`) change. Each prior version is
* captured as a fresh row with a new `id`.
*
* - `save(dto)`:
* - If no live row exists for `dto->entity_id` (or `entity_id` is null),
* this is a fresh insert: allocate `entity_id` if null, set `id` if
* null, `valid_from = now`, `valid_until = SENTINEL`, save.
* - Otherwise:
* 1. Clone the existing live row in memory (`b`). Give `b` a fresh `id`
* and set `b.valid_until = now()`. Save `b` it's the historical copy.
* 2. Set `dto.id = liveRow.id` (preserve the live PK). Set
* `dto.valid_from = now()`, `dto.valid_until = SENTINEL`. Save `dto`
* the inner UPDATEs the live row in place by PK.
*
* FK consequence: child rows referencing the live row via the composite
* key `(entity_id, valid_until)` continue to resolve to the same row
* identity throughout the operation; no FK deferral required.
*
* - `findByEntityId(id)` returns the row whose `valid_until == SENTINEL`.
* - `findByEntityIdAt(id, at)` returns the version live at that timestamp.
* - `list()` returns only live rows.
* - `history(id)` returns all versions ordered ascending by `valid_from`.
* - `softDelete(id)` closes the live row (sets its `valid_until = now`)
* but does not insert a new version. With `ON UPDATE CASCADE` on every
* composite child FK, child rows follow automatically.
*/
template <class TDto>
class TemporalRepository
: public Repository<TDto>
, public IHistoryRepository<TDto>
{
public:
/**
* Sentinel valid_until value indicating the row is currently live.
* ISO-8601 UTC, lexically greater than any plausible real timestamp,
* matches the convention used by fewo-webapp's existing temporal tables.
*/
static constexpr const char* SENTINEL = "9999-12-31T23:59:59Z";
/// Declarative schema contribution (authkit#14, D-replace).
/// Atlas owns evolution between deploys; this declares what the
/// decorator needs the live entity table to look like. The composite
/// UNIQUE index makes close-then-insert safe inside a transaction.
inline static constexpr ColumnSpec kEntityColumns[] = {
{"valid_from", "TEXT NOT NULL DEFAULT ''"},
{"valid_until", "TEXT NOT NULL DEFAULT '9999-12-31T23:59:59Z'"},
};
inline static constexpr IndexSpec kEntityIndexes[] = {
{"ux_{table}_entity_valid_until", true, "(entity_id, valid_until)"},
};
inline static constexpr DecoratorSchema kSchema = {
"TemporalRepository",
kEntityColumns, sizeof(kEntityColumns) / sizeof(kEntityColumns[0]),
kEntityIndexes, sizeof(kEntityIndexes) / sizeof(kEntityIndexes[0]),
nullptr, 0,
};
using Clock = std::function<int64_t()>; ///< Returns milliseconds since epoch.
using IdGen = std::function<oatpp::String()>;
/// Runs a unit of work, ideally inside a DB transaction so the historical
/// insert + live update commit or roll back together. The default just
/// invokes the callback inline (no cross-statement atomicity); consumers
/// that have a connection/transaction handle should pass a runner that
/// wraps the callback in `BEGIN … COMMIT` / `ROLLBACK`.
using TxRunner = std::function<void(const std::function<void()>&)>;
/**
* @param inner Concrete adapter that exposes all-rows-including-historical.
* @param clock Optional injected clock for tests; default uses system_clock.
* @param idgen Optional injected id generator for tests; default is a 32-char hex from mt19937_64.
* @param txRunner Optional transaction wrapper for the close-then-insert
* write pair; default runs the writes inline. A per-instance mutex
* already serialises the read-modify-write within this process so
* concurrent saves of the same entity can't produce two live rows;
* supply a real transaction runner for crash/rollback atomicity.
*/
explicit TemporalRepository(std::shared_ptr<Repository<TDto>> inner,
Clock clock = {},
IdGen idgen = {},
TxRunner txRunner = {})
: m_inner(std::move(inner))
, m_clock(clock ? std::move(clock) : defaultClock())
, m_idgen(idgen ? std::move(idgen) : defaultIdGen())
, m_runTx(txRunner ? std::move(txRunner) : defaultTxRunner())
{}
using F = TemporalFieldTraits<TDto>;
/** @brief Live row for the given entity_id, or null. */
oatpp::Object<TDto> findByEntityId(const oatpp::String& entityId) override {
auto all = m_inner->list();
for (auto& row : *all) {
auto& id = F::entityId(row);
auto& vu = F::validUntil(row);
if (id && vu
&& std::string(*id) == std::string(*entityId)
&& std::string(*vu) == SENTINEL) {
return row;
}
}
return nullptr;
}
/** @brief Version of `entityId` live at the given point in time. */
oatpp::Object<TDto> findByEntityIdAt(const oatpp::String& entityId, const TemporalAt& at) {
if (at.kind == TemporalAt::Kind::Live) {
return findByEntityId(entityId);
}
const std::string atIso = isoFromMillis(at.timestamp);
auto all = m_inner->list();
for (auto& row : *all) {
auto& id = F::entityId(row);
if (!id || std::string(*id) != std::string(*entityId)) continue;
auto& vf = F::validFrom(row);
auto& vu = F::validUntil(row);
const std::string from = vf ? std::string(*vf) : std::string();
const std::string until = vu ? std::string(*vu) : std::string();
if (from <= atIso && atIso < until) return row;
}
return nullptr;
}
/** @brief All currently-live rows. */
oatpp::Vector<oatpp::Object<TDto>> list() override {
auto out = oatpp::Vector<oatpp::Object<TDto>>::createShared();
auto all = m_inner->list();
for (auto& row : *all) {
auto& vu = F::validUntil(row);
if (vu && std::string(*vu) == SENTINEL) {
out->push_back(row);
}
}
return out;
}
/**
* Save a new dto. Stable-live + historical-copy semantics: the live
* row's `id` PK is preserved across updates; the prior version is
* captured as a fresh row with a new `id`. See class doc for details.
*/
void save(const oatpp::Object<TDto>& dto) override {
if (!F::entityId(dto)) F::entityId(dto) = m_idgen();
// Serialise the read-modify-write so two concurrent saves of the same
// entity can't both observe the same live row and each insert a new
// SENTINEL row (lost update / two live rows). In-process guard only;
// see TxRunner for cross-statement / crash atomicity.
std::lock_guard<std::mutex> lock(m_writeMutex);
const int64_t nowMs = m_clock();
const std::string nowIso = isoFromMillis(nowMs);
auto live = findByEntityId(F::entityId(dto));
if (!live) {
// Fresh insert.
if (!F::id(dto)) F::id(dto) = m_idgen();
F::validFrom(dto) = oatpp::String(nowIso);
F::validUntil(dto) = oatpp::String(SENTINEL);
m_inner->save(dto);
return;
}
// Update path: compute both rows, then commit the historical copy and
// the in-place live update as one unit of work so a failure between
// the two can't leave a closed-but-not-replaced or duplicate-live row.
auto historical = cloneDto(live);
F::id(historical) = m_idgen();
F::validUntil(historical) = oatpp::String(nowIso);
F::id(dto) = F::id(live); // preserve live PK
F::validFrom(dto) = oatpp::String(nowIso);
F::validUntil(dto) = oatpp::String(SENTINEL);
m_runTx([&] {
m_inner->save(historical);
m_inner->save(dto);
});
}
/** @brief Close the live row without inserting a new version. */
void softDelete(const oatpp::String& entityId) override {
std::lock_guard<std::mutex> lock(m_writeMutex);
auto live = findByEntityId(entityId);
if (!live) return;
F::validUntil(live) = oatpp::String(isoFromMillis(m_clock()));
m_inner->save(live);
}
/** @brief All versions for `entityId`, oldest first. */
oatpp::Vector<oatpp::Object<TDto>>
history(const oatpp::String& entityId) override
{
std::vector<oatpp::Object<TDto>> bucket;
auto all = m_inner->list();
for (auto& row : *all) {
auto& id = F::entityId(row);
if (id && std::string(*id) == std::string(*entityId)) {
bucket.push_back(row);
}
}
std::sort(bucket.begin(), bucket.end(),
[](const oatpp::Object<TDto>& a, const oatpp::Object<TDto>& b) {
auto& af_s = F::validFrom(a);
auto& bf_s = F::validFrom(b);
const std::string af = af_s ? std::string(*af_s) : std::string();
const std::string bf = bf_s ? std::string(*bf_s) : std::string();
return af < bf;
});
auto out = oatpp::Vector<oatpp::Object<TDto>>::createShared();
for (auto& r : bucket) out->push_back(r);
return out;
}
private:
static Clock defaultClock() {
return [] {
using namespace std::chrono;
return duration_cast<milliseconds>(system_clock::now().time_since_epoch()).count();
};
}
static TxRunner defaultTxRunner() {
return [](const std::function<void()>& work) { work(); };
}
/// Field-wise deep copy via oatpp's DTO reflection. Used to capture
/// the live row's content as the historical copy before the live row
/// is updated in place.
static oatpp::Object<TDto> cloneDto(const oatpp::Object<TDto>& src) {
auto dst = TDto::createShared();
const auto* dispatcher = static_cast<
const oatpp::data::mapping::type::__class::AbstractObject::PolymorphicDispatcher*>(
oatpp::Object<TDto>::Class::getType()->polymorphicDispatcher);
for (auto* p : dispatcher->getProperties()->getList()) {
p->set(static_cast<oatpp::BaseObject*>(dst.get()),
p->get(static_cast<oatpp::BaseObject*>(src.get())));
}
return dst;
}
static IdGen defaultIdGen() {
return [] {
// authkit#16 L-5: draw 128 bits straight from the platform CSPRNG
// (std::random_device → getrandom()/urandom on Linux) on every
// call. The old code seeded a mt19937_64 once from a single
// random_device sample, making the whole id stream predictable from
// observed outputs — a problem if a consumer ever treats entity_id
// as an unguessable handle. Consumers needing a hard guarantee can
// still inject their own IdGen.
static thread_local std::random_device rd;
char buf[33];
std::snprintf(buf, sizeof(buf), "%08x%08x%08x%08x",
(unsigned)rd(), (unsigned)rd(), (unsigned)rd(), (unsigned)rd());
return oatpp::String(buf);
};
}
static std::string isoFromMillis(int64_t ms) {
std::time_t secs = static_cast<std::time_t>(ms / 1000);
std::tm tmv{};
gmtime_r(&secs, &tmv);
char buf[32];
std::snprintf(buf, sizeof(buf), "%04d-%02d-%02dT%02d:%02d:%02d.%03lldZ",
tmv.tm_year + 1900, tmv.tm_mon + 1, tmv.tm_mday,
tmv.tm_hour, tmv.tm_min, tmv.tm_sec,
(long long)(ms % 1000));
return std::string(buf);
}
std::shared_ptr<Repository<TDto>> m_inner;
Clock m_clock;
IdGen m_idgen;
TxRunner m_runTx;
std::mutex m_writeMutex;
};
} // namespace oatpp_authkit::repo
#endif

View file

@ -0,0 +1,54 @@
#ifndef OATPP_AUTHKIT_SYSTEMD_NOTIFY_HPP
#define OATPP_AUTHKIT_SYSTEMD_NOTIFY_HPP
#include <cstdlib>
#include <cstring>
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>
namespace oatpp_authkit::systemd {
/**
* @brief Protocol-level sd_notify(3) implementation.
*
* Speaks the systemd notification protocol by writing a datagram to
* $NOTIFY_SOCKET no libsystemd dependency. Used to signal
* `Type=notify` services with `READY=1`, `STATUS=...`, `WATCHDOG=1`.
*
* No-op (silent return) when NOTIFY_SOCKET is unset the same binary
* can run under systemd or as a plain background process without
* conditional logic at the call site.
*
* Supports Linux abstract-namespace sockets (leading '@' in the env
* var, mapped to a leading NUL byte in the sockaddr path).
*
* Example:
* @code
* oatpp_authkit::systemd::notify("READY=1\nSTATUS=Accepting connections");
* // … later, from a watchdog thread:
* oatpp_authkit::systemd::notify("WATCHDOG=1");
* @endcode
*/
inline void notify(const char* state) {
const char* sock = std::getenv("NOTIFY_SOCKET");
if (!sock || !*sock) return;
int fd = ::socket(AF_UNIX, SOCK_DGRAM | SOCK_CLOEXEC, 0);
if (fd < 0) return;
struct sockaddr_un addr{};
addr.sun_family = AF_UNIX;
if (sock[0] == '@') {
// Linux abstract namespace: leading '@' maps to a NUL byte.
addr.sun_path[0] = '\0';
std::strncpy(addr.sun_path + 1, sock + 1, sizeof(addr.sun_path) - 2);
} else {
std::strncpy(addr.sun_path, sock, sizeof(addr.sun_path) - 1);
}
::sendto(fd, state, std::strlen(state), MSG_NOSIGNAL,
(struct sockaddr*)&addr, sizeof(addr));
::close(fd);
}
} // namespace oatpp_authkit::systemd
#endif

View file

@ -0,0 +1,36 @@
#ifndef OATPP_AUTHKIT_UTIL_CONSTANT_TIME_HPP
#define OATPP_AUTHKIT_UTIL_CONSTANT_TIME_HPP
// Constant-time comparison (authkit#16 L-7).
//
// The interceptor looks tokens up by hash in the store (effectively
// constant-time via an indexed equality), so it doesn't need this. But a
// consumer that ever compares a secret (token, HMAC, hash) in memory must not
// use std::string::operator== / memcmp — those short-circuit on the first
// mismatching byte and leak, via timing, how much of the secret was guessed.
#include <cstddef>
#include <string>
namespace oatpp_authkit {
/**
* @brief Compare two byte strings without an early-exit on the first
* differing byte. The length difference is folded into the result, so
* unequal-length inputs still take time proportional to the longer one
* (the length of a fixed-size hash/token is not itself secret).
*/
inline bool constantTimeEquals(const std::string& a, const std::string& b) {
const std::size_t n = a.size() > b.size() ? a.size() : b.size();
volatile unsigned char diff = static_cast<unsigned char>(a.size() ^ b.size());
for (std::size_t i = 0; i < n; ++i) {
const unsigned char ca = (i < a.size()) ? static_cast<unsigned char>(a[i]) : 0;
const unsigned char cb = (i < b.size()) ? static_cast<unsigned char>(b[i]) : 0;
diff = static_cast<unsigned char>(diff | (ca ^ cb));
}
return diff == 0;
}
} // namespace oatpp_authkit
#endif

View file

@ -0,0 +1,73 @@
#ifndef OATPP_AUTHKIT_UTIL_ORIGIN_CHECK_HPP
#define OATPP_AUTHKIT_UTIL_ORIGIN_CHECK_HPP
// Origin / Referer validation helpers (authkit#16 M-4, M-10).
//
// Pure, dependency-free string helpers for CSRF defence-in-depth and for
// WebSocket Cross-Site-WebSocket-Hijacking (CSWSH) protection. The library
// can't enforce these everywhere — the WS upgrade decision lives in the
// consumer's WSController — so these primitives let consumers do the check
// at the right point, and `AuthInterceptor` uses them for session mutations.
#include <algorithm>
#include <cctype>
#include <string>
#include <vector>
namespace oatpp_authkit {
/**
* @brief Extract the lowercased hostname from an `Origin` / `Referer` value or
* a `Host` header. Strips scheme, port, path and query.
*
* "https://app.example.com:8443/x?y" "app.example.com"
* "app.example.com:443" "app.example.com"
*/
inline std::string originHostname(const std::string& v) {
std::string s = v;
auto scheme = s.find("://");
if (scheme != std::string::npos) s = s.substr(scheme + 3);
auto slash = s.find('/');
if (slash != std::string::npos) s = s.substr(0, slash);
auto colon = s.find(':');
if (colon != std::string::npos) s = s.substr(0, colon);
std::transform(s.begin(), s.end(), s.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return s;
}
/**
* @brief Same-origin check by hostname: the `Origin` (or `Referer`) host must
* equal the request `Host` host. Port/scheme are intentionally ignored
* to avoid false positives behind TLS-terminating reverse proxies
* (Origin omits the default port; Host may or may not carry one) a
* cross-*host* request is the unambiguous CSRF/CSWSH signal.
*
* Returns `true` (don't block) when either input is empty the caller can't
* decide and should fall back to another control (e.g. `X-Requested-With`).
*/
inline bool sameOrigin(const std::string& originOrReferer, const std::string& hostHeader) {
if (originOrReferer.empty() || hostHeader.empty()) return true;
return originHostname(originOrReferer) == originHostname(hostHeader);
}
/**
* @brief Allowlist check: the `Origin` host must be one of `allowedHosts`
* (each compared by hostname via `originHostname`). Use for WS upgrades
* when the allowed origins aren't simply "same host as the request".
*
* Returns `false` for an empty / unparseable origin i.e. fail closed.
*/
inline bool originAllowed(const std::string& origin, const std::vector<std::string>& allowedHosts) {
if (origin.empty()) return false;
const std::string h = originHostname(origin);
if (h.empty()) return false;
for (const auto& a : allowedHosts) {
if (originHostname(a) == h) return true;
}
return false;
}
} // namespace oatpp_authkit
#endif

View file

@ -2,10 +2,14 @@
#define UTIL_RATE_LIMITER_HPP #define UTIL_RATE_LIMITER_HPP
#include <chrono> #include <chrono>
#include <cmath>
#include <mutex> #include <mutex>
#include <stdexcept>
#include <string> #include <string>
#include <unordered_map> #include <unordered_map>
namespace oatpp_authkit {
/** /**
* @brief Per-key token bucket rate limiter. * @brief Per-key token bucket rate limiter.
* *
@ -23,11 +27,22 @@
class RateLimiter { class RateLimiter {
public: public:
/** /**
* @param capacity Maximum burst size (tokens). * @param capacity Maximum burst size (tokens). Must be finite and >= 1.
* @param refillRate Tokens added per second. * @param refillRate Tokens added per second. Must be finite and > 0.
*
* @throws std::invalid_argument on non-finite / out-of-range values
* (authkit#16 M-7). A zero/negative `refillRate` previously made
* every bucket evict on each sweep (limiter silently disabled
* brute-force bypass), and NaN made `allow()` reject everything
* (DoS). Fail loud at construction instead.
*/ */
RateLimiter(double capacity, double refillRate) RateLimiter(double capacity, double refillRate)
: m_capacity(capacity), m_refillRate(refillRate) {} : m_capacity(capacity), m_refillRate(refillRate) {
if (!std::isfinite(capacity) || capacity < 1.0)
throw std::invalid_argument("RateLimiter: capacity must be finite and >= 1");
if (!std::isfinite(refillRate) || refillRate <= 0.0)
throw std::invalid_argument("RateLimiter: refillRate must be finite and > 0");
}
/** @brief Try to consume one token for the given key. Returns true if allowed. */ /** @brief Try to consume one token for the given key. Returns true if allowed. */
bool allow(const std::string& key) { bool allow(const std::string& key) {
@ -82,4 +97,6 @@ private:
std::unordered_map<std::string, Bucket> m_buckets; std::unordered_map<std::string, Bucket> m_buckets;
}; };
} // namespace oatpp_authkit
#endif // UTIL_RATE_LIMITER_HPP #endif // UTIL_RATE_LIMITER_HPP

View file

@ -0,0 +1,65 @@
#ifndef OATPP_AUTHKIT_UTIL_SESSION_COOKIE_HPP
#define OATPP_AUTHKIT_UTIL_SESSION_COOKIE_HPP
// Safe-by-default Set-Cookie builder for session tokens (authkit#16 M-9).
//
// The library reads the session cookie (util/TokenExtract.hpp) but previously
// shipped no helper to *write* it, so every consumer hand-rolled `Set-Cookie`
// and the security attributes (HttpOnly / Secure / SameSite) were easy to
// forget. This builder defaults to the hardened set; opt OUT explicitly.
//
// Returns the header *value* only (decoupled from any HTTP framework) — the
// consumer sets it via e.g. `response->putHeader("Set-Cookie", value)`.
#include <stdexcept>
#include <string>
namespace oatpp_authkit {
/** @brief Set-Cookie attributes. Defaults are the hardened set. */
struct SessionCookieOptions {
std::string name = "session"; ///< Cookie name. For `__Host-` prefix guarantees, set "__Host-session" (requires secure=true, path="/", no domain).
bool httpOnly = true; ///< Block JS access (document.cookie).
bool secure = true; ///< HTTPS-only. Leave true in prod; only disable for plaintext dev.
std::string sameSite = "Strict"; ///< "Strict" | "Lax" | "None" (""=omit). "None" requires secure=true per spec.
std::string path = "/";
long maxAgeSeconds = -1; ///< <0 ⇒ session cookie (no Max-Age); 0 ⇒ expire now (clear).
};
/**
* @brief Build a `Set-Cookie` header value for a session token.
* @throws std::invalid_argument if `token`, `name` or `path` contain control
* characters / `;` (header/cookie-injection guard).
*/
inline std::string buildSetSessionCookie(const std::string& token,
const SessionCookieOptions& opt = {}) {
auto reject = [](const std::string& s) {
for (unsigned char c : s)
if (c < 0x20 || c == 0x7f || c == ';') return true;
return false;
};
if (reject(token) || reject(opt.name) || reject(opt.path))
throw std::invalid_argument("buildSetSessionCookie: control char / ';' in cookie field");
std::string c = opt.name + "=" + token;
if (!opt.path.empty()) c += "; Path=" + opt.path;
if (opt.maxAgeSeconds >= 0) c += "; Max-Age=" + std::to_string(opt.maxAgeSeconds);
if (opt.httpOnly) c += "; HttpOnly";
if (opt.secure) c += "; Secure";
if (!opt.sameSite.empty()) c += "; SameSite=" + opt.sameSite;
return c;
}
/**
* @brief Build a `Set-Cookie` value that clears the session cookie (logout).
* Same attributes as the original so the browser matches and removes it.
*/
inline std::string buildClearSessionCookie(const SessionCookieOptions& opt = {}) {
SessionCookieOptions o = opt;
o.maxAgeSeconds = 0;
return buildSetSessionCookie("", o);
}
} // namespace oatpp_authkit
#endif

View file

@ -9,6 +9,44 @@ namespace oatpp_authkit {
using IncomingRequest = oatpp::web::protocol::http::incoming::Request; using IncomingRequest = oatpp::web::protocol::http::incoming::Request;
/**
* @brief Read the value of an exact-named cookie from a `Cookie` header.
*
* Splits the header on `;`, trims optional whitespace, and matches the cookie
* *name* exactly. A naive `header.find("name=")` substring search would also
* match `xname=`, `my_name=`, `notname=` etc. and latch onto the first hit
* so an attacker who can plant a sibling cookie (subdomain / less-trusted
* same-site host) could shadow the real one, defeating the `__Host-`/
* `__Secure-` prefix guarantees the session cookie may rely on. Pure and
* side-effect-free so the parsing is unit-testable without a request.
*
* @return the cookie value (whitespace-trimmed), or "" if not present.
*/
inline std::string cookieValue(const std::string& cookieHeader, const std::string& name) {
std::size_t i = 0;
const std::size_t n = cookieHeader.size();
while (i < n) {
std::size_t semi = cookieHeader.find(';', i);
std::size_t end = (semi == std::string::npos) ? n : semi;
std::size_t b = i;
while (b < end && (cookieHeader[b] == ' ' || cookieHeader[b] == '\t')) ++b;
std::size_t eq = cookieHeader.find('=', b);
if (eq != std::string::npos && eq < end) {
std::string key = cookieHeader.substr(b, eq - b);
while (!key.empty() && (key.back() == ' ' || key.back() == '\t')) key.pop_back();
if (key == name) {
std::size_t vb = eq + 1, ve = end;
while (vb < ve && (cookieHeader[vb] == ' ' || cookieHeader[vb] == '\t')) ++vb;
while (ve > vb && (cookieHeader[ve - 1] == ' ' || cookieHeader[ve - 1] == '\t')) --ve;
return cookieHeader.substr(vb, ve - vb);
}
}
if (semi == std::string::npos) break;
i = semi + 1;
}
return "";
}
/** /**
* @brief Pull the session token from an incoming request. * @brief Pull the session token from an incoming request.
* *
@ -19,13 +57,8 @@ using IncomingRequest = oatpp::web::protocol::http::incoming::Request;
inline std::string extractToken(const std::shared_ptr<IncomingRequest>& request) { inline std::string extractToken(const std::shared_ptr<IncomingRequest>& request) {
auto cookie = request->getHeader("Cookie"); auto cookie = request->getHeader("Cookie");
if (cookie && !cookie->empty()) { if (cookie && !cookie->empty()) {
const std::string& c = *cookie; std::string tok = cookieValue(*cookie, "session");
auto pos = c.find("session="); if (!tok.empty()) return tok;
if (pos != std::string::npos) {
pos += 8;
auto end = c.find(';', pos);
return end == std::string::npos ? c.substr(pos) : c.substr(pos, end - pos);
}
} }
auto auth = request->getHeader("Authorization"); auto auth = request->getHeader("Authorization");
if (auth && !auth->empty()) { if (auth && !auth->empty()) {
@ -56,6 +89,16 @@ inline bool isValidIp(const std::string& s) {
* *
* The `bindAddress` argument carries the host the service is listening on; * The `bindAddress` argument carries the host the service is listening on;
* pass your runtime config value here. * pass your runtime config value here.
*
* @warning Rate-limiting note (authkit#16 M-8): when the service is NOT
* loopback-bound (no trusted ingress proxy), or the proxy omits
* `X-Forwarded-For`/`X-Real-IP`, this returns the constant sentinel
* `"unknown"` (or `"invalid"`) for *every* caller so a per-IP rate
* limiter keyed on it collapses to a single shared bucket and per-IP
* brute-force throttling stops isolating attackers. Deploy
* loopback-bound behind a proxy that sets `X-Forwarded-For`; treat
* `"unknown"`/`"invalid"` as one anonymous bucket and size that
* limit conservatively.
*/ */
inline std::string clientIpTrusted( inline std::string clientIpTrusted(
const std::shared_ptr<IncomingRequest>& req, const std::shared_ptr<IncomingRequest>& req,

View file

@ -0,0 +1,457 @@
#pragma once
#include <atomic>
#include <chrono>
#include <map>
#include <mutex>
#include <optional>
#include <set>
#include <string>
#include <thread>
#include <unordered_map>
#include <vector>
#include "oatpp-websocket/ConnectionHandler.hpp"
#include "oatpp-websocket/WebSocket.hpp"
#include "oatpp/parser/json/mapping/ObjectMapper.hpp"
#include "Listener.hpp"
#include "../dto/InternalDto.hpp"
namespace oatpp_authkit::ws {
/**
* @brief Per-socket authentication and property-access metadata.
*
* Populated by WSController during the WebSocket handshake and picked up
* by Hub::onAfterCreate via thread_local storage.
*/
struct SocketInfo {
std::string userId;
std::string username;
std::string role;
std::set<std::string> propertyIds; ///< Properties this socket may receive scoped events for. Empty = NONE for non-admins (admins get all via role). See socketHasPropertyAccess.
};
/**
* @brief Singleton that owns all active WebSocket connections and dispatches
* server-push notifications and presence tracking.
*
* Implements `oatpp::websocket::ConnectionHandler::SocketInstanceListener`
* so that it is notified whenever a WebSocket connection is established or
* torn down. All state (socket set, presence maps) is protected by a single
* static mutex and is therefore safe to access from multiple server threads.
*
* Only authenticated connections (validated by WSController before the
* handshake) are accepted. Each socket stores the user's identity and
* property-access set so that booking notifications can be scoped to
* authorised recipients.
*
* @warning CSWSH (authkit#16 M-4): a cookie-authenticated WebSocket upgrade is
* exposed to Cross-Site WebSocket Hijacking unless the `Origin` header
* is validated at the handshake. The Hub runs *after* the upgrade and
* cannot see `Origin`, so the WSController MUST reject disallowed
* origins before setting `t_pendingAuth` use
* `oatpp_authkit::sameOrigin(originHeader, hostHeader)` or
* `oatpp_authkit::originAllowed(originHeader, allowlist)` from
* `util/OriginCheck.hpp`.
*
* **Serverclient change notifications**
* @code
* {"type":"booking_updated","id":"<uuid>"}
* {"type":"booking_created","id":"<uuid>"}
* {"type":"booking_deleted","id":"<uuid>"}
* {"type":"person_updated","id":"<uuid>"}
* {"type":"feature_request_updated","id":"<uuid>"}
* @endcode
*
* **Clientserver presence messages** (handled in Listener):
* @code
* {"type":"presence_open","booking_id":"<uuid>"}
* {"type":"presence_close","booking_id":"<uuid>"}
* @endcode
*
* **Serverclient presence update** (broadcast whenever presence changes):
* @code
* {"type":"presence_update","booking_id":"<uuid>","users":["alice","bob"]}
* @endcode
*/
struct HubHousekeeper; // forward-declare for friend (#439)
class Listener; // forward-declare for friend (Listener calls Hub::sharedMapper)
class Hub
: public oatpp::websocket::ConnectionHandler::SocketInstanceListener {
friend struct HubHousekeeper;
friend class Listener;
public:
using WebSocket = oatpp::websocket::WebSocket;
/**
* @brief Thread-local slot used by WSController to pass authenticated
* user context to onAfterCreate (which runs on the same thread).
*/
static inline thread_local std::optional<SocketInfo> t_pendingAuth;
public:
/** @brief Hard cap on simultaneously-connected WebSocket clients (#439).
* When reached, new connections are accepted by oatpp's transport layer
* but immediately closed with code 1013 (Try Again Later). */
static constexpr std::size_t kMaxSockets = 500;
/** @brief Idle durations (#439). Any socket that has not sent a frame or
* answered a pong within kIdlePing receives a ping; if it does not produce
* any traffic within kIdleClose total, it is closed with code 1001. */
static constexpr std::chrono::seconds kIdlePing {90};
static constexpr std::chrono::seconds kIdleClose{180};
private:
static std::mutex s_mx;
static std::unordered_map<const WebSocket*, SocketInfo> s_sockets;
/** @brief Last time a frame (any opcode) arrived from the peer, used by the
* housekeeper thread to expire silent sockets (#439). */
static std::unordered_map<const WebSocket*, std::chrono::steady_clock::time_point> s_lastSeen;
// Presence: booking entity_id → set of usernames currently editing it
static std::map<std::string, std::set<std::string>> s_presence;
// Per-socket presence: socket → map of booking entity_id → username
static std::map<const WebSocket*, std::map<std::string, std::string>> s_socketPresence;
/**
* @brief Process-wide ObjectMapper for outbound WS frames (#6).
*
* Lazy-initialised on first use; consumers can override via
* `setObjectMapper()` to share the same mapper instance with the rest of
* the app. Mapper is thread-safe for concurrent `writeToString` use.
*/
static std::shared_ptr<oatpp::data::mapping::ObjectMapper>& sharedMapper() {
static std::shared_ptr<oatpp::data::mapping::ObjectMapper> m
= oatpp::parser::json::mapping::ObjectMapper::createShared();
return m;
}
/**
* @brief Serialise a presence-update notification as a JSON string (#6).
*
* Routes through ObjectMapper so usernames / booking IDs containing `"`,
* `\\`, or control characters are escaped properly. The previous hand-
* rolled concatenation produced invalid JSON for any such input.
*/
static std::string buildPresenceMsg(const std::string& bookingId, const std::set<std::string>& users) {
auto dto = dto::WsPresenceUpdateDto::createShared();
dto->type = oatpp::String("presence_update");
dto->booking_id = oatpp::String(bookingId);
dto->users = {};
for (const auto& u : users) dto->users->push_back(oatpp::String(u));
return std::string(*sharedMapper()->writeToString(dto));
}
/** @brief Build a `{type,id}` event envelope via ObjectMapper (#6). */
static std::string buildEntityEvent(const char* type, const std::string& id) {
auto dto = dto::WsEntityEventDto::createShared();
dto->type = oatpp::String(type);
dto->id = oatpp::String(id);
return std::string(*sharedMapper()->writeToString(dto));
}
public:
/** @brief Replace the process-wide ObjectMapper (#6). Call once at startup
* if the host application has its own mapper instance to share. */
static void setObjectMapper(std::shared_ptr<oatpp::data::mapping::ObjectMapper> m) {
if (m) sharedMapper() = std::move(m);
}
private:
/**
* @brief Check whether a socket has access to a given property.
*
* Admins (role == "admin") see everything. For everyone else, access is
* granted only if `propertyId` is explicitly in their `propertyIds` set.
*
* authkit#16 M-3: an empty `propertyIds` now means NO access (fail closed),
* not "all". Previously a non-admin whose permission set failed to populate
* (DB hiccup, race, or simply no grants yet) would receive every property's
* notifications a cross-tenant leak.
*/
static bool socketHasPropertyAccess(const SocketInfo& info, const std::string& propertyId) {
if (info.role == "admin") return true;
return info.propertyIds.find(propertyId) != info.propertyIds.end();
}
public:
// --- SocketInstanceListener interface (1.3.0) ---
/**
* @brief Called by oatpp after a new WebSocket connection is established.
*
* Picks up authenticated user context from the thread_local slot set by
* WSController. If no auth context is present, the socket is immediately
* closed (should not happen since WSController rejects unauthenticated
* upgrade requests).
*/
void onAfterCreate(const WebSocket& socket,
const std::shared_ptr<const ParameterMap>&) override
{
socket.setListener(std::make_shared<Listener>());
// authkit#16 L-4: consume the thread-local handoff exactly once, up
// front, and clear it unconditionally. If a prior connection's
// onAfterCreate ever failed to clear it (or oatpp reuses this worker
// thread), a leftover value must NOT attach to this socket — and our
// own value must not leak to the next connection on this thread.
std::optional<SocketInfo> pending = std::move(t_pendingAuth);
t_pendingAuth.reset();
if (!pending.has_value()) {
// Should not happen — WSController validates before handshake.
OATPP_LOGW("Hub", "WebSocket connected without auth context — closing");
try { socket.sendClose(4001, "Unauthorized"); } catch (...) {}
return;
}
{
std::lock_guard<std::mutex> g(s_mx);
if (s_sockets.size() >= kMaxSockets) {
// #439: refuse extra connections beyond the cap rather than
// allowing unbounded growth of s_sockets / presence maps.
OATPP_LOGW("Hub", "socket cap %zu hit — rejecting", kMaxSockets);
try { socket.sendClose(1013, "Server Busy"); } catch (...) {}
return;
}
s_sockets[&socket] = std::move(*pending);
s_lastSeen[&socket] = std::chrono::steady_clock::now();
}
OATPP_LOGD("Hub", "client connected: %s (total=%zu)",
s_sockets[&socket].username.c_str(), s_sockets.size());
}
/**
* @brief Bump the last-seen timestamp for a socket. Called by Listener
* on every incoming frame/pong so the idle housekeeper can
* distinguish live from dead peers (#439).
*/
static void touchSocket(const WebSocket* socket) {
std::lock_guard<std::mutex> g(s_mx);
auto it = s_lastSeen.find(socket);
if (it != s_lastSeen.end()) it->second = std::chrono::steady_clock::now();
}
/**
* @brief Called by oatpp before a WebSocket connection is closed.
*/
void onBeforeDestroy(const WebSocket& socket) override {
{
std::lock_guard<std::mutex> g(s_mx);
s_sockets.erase(&socket);
s_lastSeen.erase(&socket);
OATPP_LOGD("Hub", "client disconnected (total=%zu)", s_sockets.size());
}
presenceCleanup(&socket);
}
// --- Broadcast ---
/**
* @brief Send a JSON string to every connected client. Thread-safe.
*/
static void broadcast(const std::string& json) {
oatpp::String msg = json.c_str();
std::lock_guard<std::mutex> g(s_mx);
for (auto& [ws, info] : s_sockets) {
try { ws->sendOneFrameText(msg); }
catch (...) { /* ignore dead sockets */ }
}
}
/**
* @brief Send a JSON string only to sockets that have access to the
* given property. If propertyId is empty, broadcasts to all.
*/
static void broadcastToProperty(const std::string& json, const std::string& propertyId) {
if (propertyId.empty()) { broadcast(json); return; }
oatpp::String msg = json.c_str();
std::lock_guard<std::mutex> g(s_mx);
for (auto& [ws, info] : s_sockets) {
if (socketHasPropertyAccess(info, propertyId)) {
try { ws->sendOneFrameText(msg); }
catch (...) {}
}
}
}
/**
* @brief Broadcast a booking lifecycle event, scoped to a property.
* @param type Event type: `"booking_created"`, `"booking_updated"`, or `"booking_deleted"`.
* @param id The booking entity_id affected.
* @param propertyId The property this booking belongs to (empty = broadcast to all).
*/
static void notifyBooking(const char* type, const std::string& id, const std::string& propertyId) {
broadcastToProperty(buildEntityEvent(type, id), propertyId);
}
/**
* @brief Broadcast a booking lifecycle event to all connected clients.
*
* Legacy overload for call sites that do not have the property ID readily
* available. Sends to all authenticated sockets.
*/
static void notifyBooking(const char* type, const std::string& id) {
broadcast(buildEntityEvent(type, id));
}
/**
* @brief Broadcast a person lifecycle event to all connected clients.
*
* Persons are cross-cutting (linked to bookings across properties), so
* notifications are not property-scoped.
*/
static void notifyPerson(const char* type, const std::string& id) {
broadcast(buildEntityEvent(type, id));
}
// --- Presence ---
/**
* @brief Look up the authenticated username for a socket.
* @return The username, or empty string if not found.
*/
static std::string getSocketUsername(const WebSocket* socket) {
std::lock_guard<std::mutex> g(s_mx);
auto it = s_sockets.find(socket);
if (it != s_sockets.end()) return it->second.username;
return "";
}
/**
* @brief Register that a user has opened the edit modal for a booking.
*
* Uses the server-validated username from the socket's auth context
* instead of trusting the client-sent username.
*/
static void presenceOpen(const WebSocket* socket, const std::string& bookingId, const std::string& /* clientUser */) {
std::string username = getSocketUsername(socket);
if (username.empty()) return;
std::string msg;
{
std::lock_guard<std::mutex> g(s_mx);
s_presence[bookingId].insert(username);
s_socketPresence[socket][bookingId] = username;
msg = buildPresenceMsg(bookingId, s_presence[bookingId]);
}
broadcast(msg);
}
/**
* @brief Deregister a user from the presence set for a booking.
*/
static void presenceClose(const WebSocket* socket, const std::string& bookingId) {
std::string msg;
{
std::lock_guard<std::mutex> g(s_mx);
auto sockIt = s_socketPresence.find(socket);
if (sockIt == s_socketPresence.end()) return;
auto bidIt = sockIt->second.find(bookingId);
if (bidIt == sockIt->second.end()) return;
s_presence[bookingId].erase(bidIt->second);
const auto& remaining = s_presence[bookingId];
msg = buildPresenceMsg(bookingId, remaining);
if (remaining.empty()) s_presence.erase(bookingId);
sockIt->second.erase(bidIt);
}
broadcast(msg);
}
/**
* @brief Remove all presence entries owned by a disconnecting socket.
*/
static void presenceCleanup(const WebSocket* socket) {
std::vector<std::string> msgs;
{
std::lock_guard<std::mutex> g(s_mx);
auto sockIt = s_socketPresence.find(socket);
if (sockIt == s_socketPresence.end()) return;
for (auto& [bookingId, username] : sockIt->second) {
s_presence[bookingId].erase(username);
msgs.push_back(buildPresenceMsg(bookingId, s_presence[bookingId]));
if (s_presence[bookingId].empty()) s_presence.erase(bookingId);
}
s_socketPresence.erase(sockIt);
}
for (const auto& m : msgs) broadcast(m);
}
};
inline std::mutex Hub::s_mx;
inline std::unordered_map<const oatpp::websocket::WebSocket*, SocketInfo> Hub::s_sockets;
inline std::unordered_map<const oatpp::websocket::WebSocket*, std::chrono::steady_clock::time_point> Hub::s_lastSeen;
inline std::map<std::string, std::set<std::string>> Hub::s_presence;
inline std::map<const oatpp::websocket::WebSocket*, std::map<std::string, std::string>> Hub::s_socketPresence;
/**
* @brief Background sweeper that pings silent WebSocket peers and closes
* ones past the idle-close threshold (#439).
*
* Started once at static-init time, detached. Wakes every 30 s, iterates
* Hub::s_sockets under its mutex to build a work list, then releases
* the lock before issuing any pings/closes to avoid holding s_mx across
* I/O. The thread runs for the process lifetime; a clean-shutdown signal
* would be nice but is not required oatpp tears the listener down
* first and subsequent send{Ping,Close} calls no-op on a dead socket.
*/
struct HubHousekeeper {
std::thread t;
HubHousekeeper() {
t = std::thread([]{
using namespace std::chrono_literals;
while (true) {
std::this_thread::sleep_for(30s);
auto now = std::chrono::steady_clock::now();
std::vector<const oatpp::websocket::WebSocket*> toPing, toClose;
{
std::lock_guard<std::mutex> g(Hub::s_mx);
for (auto& kv : Hub::s_lastSeen) {
auto dt = now - kv.second;
if (dt > Hub::kIdleClose) toClose.push_back(kv.first);
else if (dt > Hub::kIdlePing) toPing.push_back(kv.first);
}
}
for (auto* ws : toPing) { try { ws->sendPing(oatpp::String("")); } catch (...) {} }
for (auto* ws : toClose) { try { ws->sendClose(1001, "Idle timeout"); } catch (...) {} }
}
});
t.detach();
}
};
inline HubHousekeeper s_wsHubHousekeeper;
inline void Listener::touchActivity(const WebSocket* socket) { Hub::touchSocket(socket); }
// Listener::handleMessage defined here (after Hub) to break the circular dependency.
//
// #6: parse inbound WS frames via ObjectMapper instead of the toy
// jsonStr/jsonInt regex parsers — those mishandled escaped quotes,
// nested objects, and unicode escapes. ObjectMapper rejects malformed
// payloads cleanly (caught here so a bad client frame is just dropped,
// never an unhandled exception).
inline void Listener::handleMessage(const WebSocket& socket, const std::string& text) {
Hub::touchSocket(&socket); // #439: record activity to suppress idle close
oatpp::Object<dto::WsClientMsgDto> msg;
try {
msg = Hub::sharedMapper()->readFromString<oatpp::Object<dto::WsClientMsgDto>>(
oatpp::String(text));
} catch (...) {
return; // malformed JSON — drop frame
}
if (!msg || !msg->type || !msg->booking_id) return;
const std::string type = std::string(*msg->type);
const std::string bookingId = std::string(*msg->booking_id);
if (bookingId.empty()) return;
if (type == "presence_open") {
// Client-sent "user" field is ignored; server uses the authenticated username.
Hub::presenceOpen(&socket, bookingId, "");
} else if (type == "presence_close") {
Hub::presenceClose(&socket, bookingId);
}
}
} // namespace oatpp_authkit::ws

View file

@ -0,0 +1,122 @@
#pragma once
#include "oatpp-websocket/WebSocket.hpp"
#include "oatpp/core/data/stream/BufferStream.hpp"
#include <cctype>
#include <string>
namespace oatpp_authkit::ws {
/**
* @brief Per-connection WebSocket listener.
*
* One instance is created per accepted WebSocket connection by Hub::onAfterCreate().
* Handles ping/pong housekeeping, reassembles fragmented text frames, and
* dispatches fully received messages to handleMessage().
*
* The following clientserver presence messages are parsed:
* @code
* {"type":"presence_open","booking_id":42,"user":"alice"}
* {"type":"presence_close","booking_id":42}
* @endcode
*
* @note handleMessage() is defined at the bottom of Hub.hpp (after Hub is
* fully declared) to avoid a circular include dependency between
* Listener.hpp and Hub.hpp.
*/
class Listener : public oatpp::websocket::WebSocket::Listener {
public:
/** @brief Hard cap on a single reassembled WS message (#439). Frames that
* push the accumulated buffer past this are dropped and the connection
* closed with code 1009 (Message Too Big). */
static constexpr std::size_t kMaxMessageBytes = 64 * 1024;
private:
oatpp::data::stream::BufferOutputStream m_buffer{256}; ///< Accumulates frame payloads until end-of-message.
bool m_overflowed = false; ///< Set when kMaxMessageBytes was exceeded; drop remainder of the current message.
/**
* @brief Dispatch a fully received text-frame message to Hub presence handlers.
*
* Defined in Hub.hpp after Hub is fully declared to avoid a circular
* include dependency.
*
* @param socket The WebSocket connection the message arrived on.
* @param text The complete UTF-8 text of the message.
*/
void handleMessage(const WebSocket& socket, const std::string& text);
public:
/**
* @brief Respond to a WebSocket ping frame with a pong.
* @param socket The connection that sent the ping.
* @param msg The ping payload to echo back.
*/
void onPing(const WebSocket& socket, const oatpp::String& msg) override {
socket.sendPong(msg);
touchActivity(&socket);
}
/** @brief Bump activity timestamp on pong so the idle sweeper treats the
* peer as live even if they never send application traffic (#439). */
void onPong(const WebSocket& socket, const oatpp::String&) override {
touchActivity(&socket);
}
private:
/** @brief Forward declaration; defined in Hub.hpp alongside handleMessage
* to break the HubListener circular include. */
static void touchActivity(const WebSocket* socket);
public:
/**
* @brief Log the close frame code when the client initiates a close.
* @param code The WebSocket close status code.
*/
void onClose(const WebSocket&, v_uint16 code, const oatpp::String&) override {
OATPP_LOGD("WS", "client closed (code=%d)", (int)code);
}
/**
* @brief Accumulate frame chunks and dispatch the message when complete.
*
* oatpp calls this method once per frame chunk. A `size` of 0 signals
* the end of the message; at that point the buffer is flushed and, if
* the opcode is a text frame (opcode == 1), handleMessage() is called.
*
* @param socket The WebSocket connection.
* @param opcode WebSocket opcode (1 = text, 2 = binary, etc.).
* @param data Pointer to the chunk payload bytes.
* @param size Number of bytes in this chunk, or 0 at end of message.
*/
void readMessage(const WebSocket& socket, v_uint8 opcode,
p_char8 data, oatpp::v_io_size size) override {
touchActivity(&socket); // #439: any inbound frame counts as activity
if (size > 0) {
if (m_overflowed) return; // ignore remaining frames of a too-large message
if (m_buffer.getCurrentPosition() + (std::size_t)size > kMaxMessageBytes) {
// #439: cap a single authenticated client from OOMing the
// process by streaming gigabytes into a single text frame.
m_overflowed = true;
m_buffer.setCurrentPosition(0);
OATPP_LOGW("WS", "client exceeded %zu B message cap — closing", kMaxMessageBytes);
try { socket.sendClose(1009, "Message Too Big"); } catch (...) {}
return;
}
m_buffer.writeSimple(data, size);
} else {
// size == 0 signals end of message
if (m_overflowed) {
m_overflowed = false; // reset for next message (though the socket is closing)
m_buffer.setCurrentPosition(0);
return;
}
std::string text = m_buffer.toString();
m_buffer.setCurrentPosition(0);
if (opcode == 1 && !text.empty()) { // text frame
handleMessage(socket, text);
}
}
}
};
} // namespace oatpp_authkit::ws

103
test/CMakeLists.txt Normal file
View file

@ -0,0 +1,103 @@
# Minimal test harness for oatpp-authkit.
#
# Adds plain executable tests linked against the INTERFACE library and oatpp.
# No third-party test framework — assertions use <cassert> and a tiny REQUIRE
# macro so the suite stays portable and dependency-free.
find_package(oatpp REQUIRED)
add_executable(test_negotiation test_negotiation.cpp)
target_link_libraries(test_negotiation PRIVATE oatpp::authkit oatpp::oatpp)
add_test(NAME negotiation COMMAND test_negotiation)
add_executable(test_token_extract test_token_extract.cpp)
target_link_libraries(test_token_extract PRIVATE oatpp::authkit oatpp::oatpp)
add_test(NAME token_extract COMMAND test_token_extract)
add_executable(test_rate_limiter test_rate_limiter.cpp)
target_link_libraries(test_rate_limiter PRIVATE oatpp::authkit oatpp::oatpp)
add_test(NAME rate_limiter COMMAND test_rate_limiter)
add_executable(test_origin_check test_origin_check.cpp)
target_link_libraries(test_origin_check PRIVATE oatpp::authkit oatpp::oatpp)
add_test(NAME origin_check COMMAND test_origin_check)
add_executable(test_constant_time test_constant_time.cpp)
target_link_libraries(test_constant_time PRIVATE oatpp::authkit oatpp::oatpp)
add_test(NAME constant_time COMMAND test_constant_time)
add_executable(test_session_cookie test_session_cookie.cpp)
target_link_libraries(test_session_cookie PRIVATE oatpp::authkit oatpp::oatpp)
add_test(NAME session_cookie COMMAND test_session_cookie)
add_executable(test_body_size_limit test_body_size_limit.cpp)
target_link_libraries(test_body_size_limit PRIVATE oatpp::authkit oatpp::oatpp)
add_test(NAME body_size_limit COMMAND test_body_size_limit)
add_executable(test_security_headers test_security_headers.cpp)
target_link_libraries(test_security_headers PRIVATE oatpp::authkit oatpp::oatpp)
add_test(NAME security_headers COMMAND test_security_headers)
add_executable(test_json_serialization test_json_serialization.cpp)
target_link_libraries(test_json_serialization PRIVATE oatpp::authkit oatpp::oatpp)
add_test(NAME json_serialization COMMAND test_json_serialization)
add_executable(test_repository_interface test_repository_interface.cpp)
target_link_libraries(test_repository_interface PRIVATE oatpp::authkit oatpp::oatpp)
add_test(NAME repository_interface COMMAND test_repository_interface)
add_executable(test_repository_decorators test_repository_decorators.cpp)
target_link_libraries(test_repository_decorators PRIVATE oatpp::authkit oatpp::oatpp)
add_test(NAME repository_decorators COMMAND test_repository_decorators)
add_executable(test_queryable test_queryable.cpp)
target_link_libraries(test_queryable PRIVATE oatpp::authkit oatpp::oatpp)
add_test(NAME queryable COMMAND test_queryable)
add_executable(test_temporal_field_traits test_temporal_field_traits.cpp)
target_link_libraries(test_temporal_field_traits PRIVATE oatpp::authkit oatpp::oatpp)
add_test(NAME temporal_field_traits COMMAND test_temporal_field_traits)
add_executable(test_audit_log_repository test_audit_log_repository.cpp)
target_link_libraries(test_audit_log_repository PRIVATE oatpp::authkit oatpp::oatpp)
add_test(NAME audit_log_repository COMMAND test_audit_log_repository)
add_executable(test_schema_contract test_schema_contract.cpp)
target_link_libraries(test_schema_contract PRIVATE oatpp::authkit oatpp::oatpp)
add_test(NAME schema_contract COMMAND test_schema_contract)
add_executable(test_redacted_field_repository test_redacted_field_repository.cpp)
target_link_libraries(test_redacted_field_repository PRIVATE oatpp::authkit oatpp::oatpp)
add_test(NAME redacted_field_repository COMMAND test_redacted_field_repository)
# SmtpTransport.hpp pulls in <curl/curl.h> and needs libcurl at link time.
# Guard the test so the suite still builds where curl dev headers are absent.
find_package(CURL QUIET)
if(CURL_FOUND)
add_executable(test_smtp_transport test_smtp_transport.cpp)
target_link_libraries(test_smtp_transport PRIVATE oatpp::authkit oatpp::oatpp CURL::libcurl)
add_test(NAME smtp_transport COMMAND test_smtp_transport)
endif()
# 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)
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)
add_executable(test_user_schema test_user_schema.cpp)
target_link_libraries(test_user_schema
PRIVATE oatpp::authkit oatpp::oatpp oatpp::oatpp-sqlite Threads::Threads)
add_test(NAME user_schema COMMAND test_user_schema)
endif()

View file

@ -0,0 +1,335 @@
// Tests for the oatpp-authkit#11 AuditLogRepository<T> decorator.
//
// Verifies the audit decorator emits events with the right shape, picks
// Create vs Update via a pre-write lookup, swallows sink failures by
// default, respects the configurable enabled-op set, and stacks correctly
// with TemporalRepository.
#include "oatpp-authkit/repo/AuditLogRepository.hpp"
#include "oatpp-authkit/repo/Repository.hpp"
#include "oatpp-authkit/repo/IAuditSink.hpp"
#include "oatpp-authkit/repo/ActorContext.hpp"
#include "oatpp-authkit/repo/TemporalFieldTraits.hpp"
#include "oatpp-authkit/repo/TemporalRepository.hpp"
#include "oatpp/core/macro/codegen.hpp"
#include "oatpp/core/Types.hpp"
#include <cstdio>
#include <map>
#include <memory>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
#include OATPP_CODEGEN_BEGIN(DTO)
namespace {
class AuditDto : public oatpp::DTO {
DTO_INIT(AuditDto, DTO)
DTO_FIELD(String, id); // per-row PK
DTO_FIELD(String, entity_id);
DTO_FIELD(String, valid_from);
DTO_FIELD(String, valid_until);
DTO_FIELD(String, name);
};
#include OATPP_CODEGEN_END(DTO)
} // namespace
OATPP_AUTHKIT_REGISTER_TEMPORAL(AuditDto, id, entity_id, valid_from, valid_until)
namespace {
int g_failures = 0;
#define REQUIRE(expr) do { \
if (!(expr)) { \
std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \
++g_failures; \
} \
} while (0)
using namespace oatpp_authkit::repo;
// ─── Helpers ────────────────────────────────────────────────────────────────
struct VectorSink : public IAuditSink {
std::vector<AuditEvent> events;
void record(const AuditEvent& ev) override { events.push_back(ev); }
};
struct ThrowingSink : public IAuditSink {
int calls{0};
void record(const AuditEvent&) override {
++calls;
throw std::runtime_error("sink down");
}
};
class InMemoryAdapter : public Repository<AuditDto> {
std::map<std::string, oatpp::Object<AuditDto>> rows;
int nextId{1};
public:
int saveCalls{0}, deleteCalls{0}, findCalls{0};
oatpp::Object<AuditDto> findByEntityId(const oatpp::String& id) override {
++findCalls;
auto it = rows.find(std::string(*id));
return it == rows.end() ? nullptr : it->second;
}
oatpp::Vector<oatpp::Object<AuditDto>> list() override {
auto v = oatpp::Vector<oatpp::Object<AuditDto>>::createShared();
for (auto& kv : rows) v->push_back(kv.second);
return v;
}
void save(const oatpp::Object<AuditDto>& dto) override {
++saveCalls;
if (!dto->entity_id) {
dto->entity_id = oatpp::String("auto-" + std::to_string(nextId++));
}
rows[std::string(*dto->entity_id)] = dto;
}
void softDelete(const oatpp::String& id) override {
++deleteCalls;
rows.erase(std::string(*id));
}
};
ActorContext alice() {
ActorContext a;
a.userId = "alice";
return a;
}
struct StepClock {
std::int64_t ms{1700000000000LL};
std::int64_t operator()() { auto v = ms; ms += 1000; return v; }
};
// ─── Tests ──────────────────────────────────────────────────────────────────
void test_save_with_null_id_emits_create() {
auto inner = std::make_shared<InMemoryAdapter>();
auto sink = std::make_shared<VectorSink>();
auto clk = std::make_shared<StepClock>();
AuditLogRepository<AuditDto> audit(inner, sink, alice, "AuditDto",
{AuditOp::Create, AuditOp::Update, AuditOp::Delete},
[clk]{ return (*clk)(); });
auto dto = AuditDto::createShared();
dto->name = oatpp::String("first");
audit.save(dto);
REQUIRE(sink->events.size() == 1);
REQUIRE(sink->events[0].op == AuditOp::Create);
REQUIRE(sink->events[0].actorUserId == "alice");
REQUIRE(sink->events[0].entityType == "AuditDto");
REQUIRE(!sink->events[0].entityId.empty()); // inner allocated id
REQUIRE(sink->events[0].timestampMs > 0);
// No pre-write lookup when id was null on entry.
REQUIRE(inner->findCalls == 0);
}
void test_save_with_existing_id_emits_update() {
auto inner = std::make_shared<InMemoryAdapter>();
auto sink = std::make_shared<VectorSink>();
AuditLogRepository<AuditDto> audit(inner, sink, alice, "AuditDto");
auto v1 = AuditDto::createShared();
v1->entity_id = oatpp::String("abc");
v1->name = oatpp::String("v1");
audit.save(v1);
REQUIRE(sink->events.back().op == AuditOp::Create);
auto v2 = AuditDto::createShared();
v2->entity_id = oatpp::String("abc");
v2->name = oatpp::String("v2");
audit.save(v2);
REQUIRE(sink->events.back().op == AuditOp::Update);
REQUIRE(sink->events.back().entityId == "abc");
}
void test_save_with_caller_id_but_no_row_is_create() {
auto inner = std::make_shared<InMemoryAdapter>();
auto sink = std::make_shared<VectorSink>();
AuditLogRepository<AuditDto> audit(inner, sink, alice, "AuditDto");
auto dto = AuditDto::createShared();
dto->entity_id = oatpp::String("brand-new");
dto->name = oatpp::String("first");
audit.save(dto);
REQUIRE(sink->events.size() == 1);
REQUIRE(sink->events[0].op == AuditOp::Create);
REQUIRE(sink->events[0].entityId == "brand-new");
}
void test_soft_delete_emits_delete() {
auto inner = std::make_shared<InMemoryAdapter>();
auto sink = std::make_shared<VectorSink>();
AuditLogRepository<AuditDto> audit(inner, sink, alice, "AuditDto");
auto dto = AuditDto::createShared();
dto->entity_id = oatpp::String("xyz");
audit.save(dto);
audit.softDelete(oatpp::String("xyz"));
REQUIRE(sink->events.size() == 2);
REQUIRE(sink->events[1].op == AuditOp::Delete);
REQUIRE(sink->events[1].entityId == "xyz");
}
void test_read_only_audited_when_enabled() {
auto inner = std::make_shared<InMemoryAdapter>();
auto sink = std::make_shared<VectorSink>();
AuditLogRepository<AuditDto> audit(inner, sink, alice, "AuditDto",
{AuditOp::Create, AuditOp::Read}); // explicit opt-in for Read
auto dto = AuditDto::createShared();
dto->entity_id = oatpp::String("rid");
audit.save(dto);
REQUIRE(sink->events.size() == 1);
REQUIRE(sink->events[0].op == AuditOp::Create);
(void)audit.findByEntityId(oatpp::String("rid"));
REQUIRE(sink->events.size() == 2);
REQUIRE(sink->events[1].op == AuditOp::Read);
REQUIRE(sink->events[1].entityId == "rid");
// Read on a miss still emits, with the requested id.
(void)audit.findByEntityId(oatpp::String("missing"));
REQUIRE(sink->events.size() == 3);
REQUIRE(sink->events[2].op == AuditOp::Read);
REQUIRE(sink->events[2].entityId == "missing");
}
void test_default_does_not_audit_reads() {
auto inner = std::make_shared<InMemoryAdapter>();
auto sink = std::make_shared<VectorSink>();
AuditLogRepository<AuditDto> audit(inner, sink, alice, "AuditDto");
auto dto = AuditDto::createShared();
dto->entity_id = oatpp::String("rid");
audit.save(dto);
(void)audit.findByEntityId(oatpp::String("rid"));
REQUIRE(sink->events.size() == 1); // only the save
REQUIRE(sink->events[0].op == AuditOp::Create);
}
void test_list_is_never_audited() {
auto inner = std::make_shared<InMemoryAdapter>();
auto sink = std::make_shared<VectorSink>();
AuditLogRepository<AuditDto> audit(inner, sink, alice, "AuditDto",
{AuditOp::Create, AuditOp::Update, AuditOp::Delete, AuditOp::Read});
for (int i = 0; i < 3; ++i) {
auto d = AuditDto::createShared();
d->entity_id = oatpp::String("id-" + std::to_string(i));
audit.save(d);
}
sink->events.clear();
auto rows = audit.list();
REQUIRE(rows->size() == 3);
REQUIRE(sink->events.empty()); // list is opaque to audit
}
void test_filter_skips_disabled_ops() {
auto inner = std::make_shared<InMemoryAdapter>();
auto sink = std::make_shared<VectorSink>();
AuditLogRepository<AuditDto> audit(inner, sink, alice, "AuditDto",
{AuditOp::Delete}); // only deletes
auto dto = AuditDto::createShared();
dto->entity_id = oatpp::String("id");
audit.save(dto);
REQUIRE(sink->events.empty()); // Create filtered out
audit.softDelete(oatpp::String("id"));
REQUIRE(sink->events.size() == 1);
REQUIRE(sink->events[0].op == AuditOp::Delete);
}
void test_sink_throw_swallowed_by_default() {
auto inner = std::make_shared<InMemoryAdapter>();
auto sink = std::make_shared<ThrowingSink>();
AuditLogRepository<AuditDto> audit(inner, sink, alice, "AuditDto");
auto dto = AuditDto::createShared();
dto->entity_id = oatpp::String("id");
bool threw = false;
try { audit.save(dto); } catch (...) { threw = true; }
REQUIRE(!threw);
REQUIRE(sink->calls == 1);
REQUIRE(inner->saveCalls == 1); // inner write still happened
}
void test_sink_throw_rethrows_when_handler_says_so() {
auto inner = std::make_shared<InMemoryAdapter>();
auto sink = std::make_shared<ThrowingSink>();
AuditLogRepository<AuditDto> audit(
inner, sink, alice, "AuditDto",
{AuditOp::Create, AuditOp::Update, AuditOp::Delete},
{},
[](const std::exception&) { return true; });
auto dto = AuditDto::createShared();
dto->entity_id = oatpp::String("id");
bool threw = false;
try { audit.save(dto); } catch (const std::runtime_error&) { threw = true; }
REQUIRE(threw);
}
void test_stacks_with_temporal_repository() {
// Audit ↔ Temporal: the audit decorator wraps the temporal one. Each
// logical save the consumer issues should produce exactly one audit
// event, even though the temporal layer may insert/update multiple
// physical rows beneath it.
auto adapter = std::make_shared<InMemoryAdapter>();
auto temporal = std::make_shared<TemporalRepository<AuditDto>>(adapter);
auto sink = std::make_shared<VectorSink>();
AuditLogRepository<AuditDto> audit(temporal, sink, alice, "AuditDto");
auto v1 = AuditDto::createShared();
v1->name = oatpp::String("first");
audit.save(v1);
REQUIRE(sink->events.size() == 1);
REQUIRE(sink->events[0].op == AuditOp::Create);
auto v2 = AuditDto::createShared();
v2->entity_id = v1->entity_id;
v2->name = oatpp::String("second");
audit.save(v2);
REQUIRE(sink->events.size() == 2);
REQUIRE(sink->events[1].op == AuditOp::Update);
REQUIRE(sink->events[1].entityId == std::string(*v1->entity_id));
audit.softDelete(v1->entity_id);
REQUIRE(sink->events.size() == 3);
REQUIRE(sink->events[2].op == AuditOp::Delete);
}
} // namespace
int main() {
test_save_with_null_id_emits_create();
test_save_with_existing_id_emits_update();
test_save_with_caller_id_but_no_row_is_create();
test_soft_delete_emits_delete();
test_read_only_audited_when_enabled();
test_default_does_not_audit_reads();
test_list_is_never_audited();
test_filter_skips_disabled_ops();
test_sink_throw_swallowed_by_default();
test_sink_throw_rethrows_when_handler_says_so();
test_stacks_with_temporal_repository();
std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures);
return g_failures ? 1 : 0;
}

View file

@ -0,0 +1,25 @@
// Smoke test for BodySizeLimitInterceptor — confirms the header compiles
// in a consumer translation unit and the constructor surface matches the
// documented API. Behavioural tests against real IncomingRequest objects
// would need a full oatpp request fixture; pinning the API surface here is
// enough to catch the kinds of breakage this header is at risk of.
#include "oatpp-authkit/interceptor/BodySizeLimitInterceptor.hpp"
#include <cstdio>
#include <memory>
int main() {
using oatpp_authkit::BodySizeLimitInterceptor;
// Default: fail-closed.
auto strict = std::make_shared<BodySizeLimitInterceptor>(1024);
(void)strict;
// Opt-out: legacy fail-open behaviour.
auto lax = std::make_shared<BodySizeLimitInterceptor>(1024, /*requireContentLength=*/false);
(void)lax;
std::printf("BodySizeLimitInterceptor API ok\n");
return 0;
}

View file

@ -0,0 +1,44 @@
// Tests for oatpp-authkit/util/ConstantTime.hpp (authkit#16 L-7).
// Verifies functional correctness; timing-invariance is a property of the
// branch-free implementation, not asserted here.
#include "oatpp-authkit/util/ConstantTime.hpp"
#include <cstdio>
#include <string>
namespace {
int g_failures = 0;
#define REQUIRE(expr) do { \
if (!(expr)) { \
std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \
++g_failures; \
} \
} while (0)
using namespace oatpp_authkit;
void test_constant_time_equals() {
REQUIRE(constantTimeEquals("", ""));
REQUIRE(constantTimeEquals("abc", "abc"));
REQUIRE(constantTimeEquals(std::string(64, 'a'), std::string(64, 'a')));
REQUIRE(!constantTimeEquals("abc", "abd")); // differ at last byte
REQUIRE(!constantTimeEquals("abc", "xbc")); // differ at first byte
REQUIRE(!constantTimeEquals("abc", "ab")); // length mismatch (prefix)
REQUIRE(!constantTimeEquals("ab", "abc"));
REQUIRE(!constantTimeEquals("", "a"));
// Embedded NUL handled (string-length aware, not C-string).
REQUIRE(constantTimeEquals(std::string("a\0b", 3), std::string("a\0b", 3)));
REQUIRE(!constantTimeEquals(std::string("a\0b", 3), std::string("a\0c", 3)));
}
} // namespace
int main() {
test_constant_time_equals();
std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures);
return g_failures ? 1 : 0;
}

View file

@ -0,0 +1,97 @@
// Tests for the #6 ObjectMapper migration — verifies that the JSON envelopes
// produced by JsonErrorHandler / Hub::buildEntityEvent / Hub::buildPresenceMsg
// escape special characters instead of emitting raw text. The previous
// hand-rolled concatenations broke when given a `"`/`\\`/control-char string.
// Avoid pulling Hub.hpp here — it includes oatpp-websocket, which is a
// separate optional dependency not necessarily on the test target's include
// path. The escaping behaviour we care about is purely a property of
// ObjectMapper round-tripping the InternalDto types, so we exercise the
// DTOs directly.
#include "oatpp-authkit/handler/JsonErrorHandler.hpp"
#include "oatpp-authkit/dto/InternalDto.hpp"
#include "oatpp/parser/json/mapping/ObjectMapper.hpp"
#include "oatpp/web/protocol/http/Http.hpp"
#include <cstdio>
#include <set>
#include <string>
namespace {
int g_failures = 0;
#define REQUIRE(expr) do { \
if (!(expr)) { \
std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \
++g_failures; \
} \
} while (0)
void test_presence_dto_round_trips_special_chars() {
auto m = oatpp::parser::json::mapping::ObjectMapper::createShared();
auto dto = oatpp_authkit::dto::WsPresenceUpdateDto::createShared();
dto->type = oatpp::String("presence_update");
dto->booking_id = oatpp::String("id-with-\"-quote");
dto->users = {};
dto->users->push_back(oatpp::String("al\"ice"));
dto->users->push_back(oatpp::String("bo\\b"));
auto json = m->writeToString(dto);
auto rt = m->readFromString<oatpp::Object<oatpp_authkit::dto::WsPresenceUpdateDto>>(json);
REQUIRE(rt);
REQUIRE(std::string(*rt->booking_id) == "id-with-\"-quote");
REQUIRE(rt->users->size() == 2);
auto it = rt->users->begin();
REQUIRE(std::string(**it++) == "al\"ice");
REQUIRE(std::string(**it) == "bo\\b");
}
void test_entity_event_dto_round_trip() {
auto m = oatpp::parser::json::mapping::ObjectMapper::createShared();
auto dto = oatpp_authkit::dto::WsEntityEventDto::createShared();
dto->type = oatpp::String("booking_updated");
dto->id = oatpp::String("id-with-\"-and-\\");
auto json = m->writeToString(dto);
auto rt = m->readFromString<oatpp::Object<oatpp_authkit::dto::WsEntityEventDto>>(json);
REQUIRE(rt);
REQUIRE(std::string(*rt->id) == "id-with-\"-and-\\");
}
void test_client_msg_dto_rejects_malformed() {
auto m = oatpp::parser::json::mapping::ObjectMapper::createShared();
bool threw = false;
try {
m->readFromString<oatpp::Object<oatpp_authkit::dto::WsClientMsgDto>>(
oatpp::String("{not json"));
} catch (...) { threw = true; }
REQUIRE(threw);
}
void test_json_error_dto_round_trip() {
auto m = oatpp::parser::json::mapping::ObjectMapper::createShared();
auto dto = oatpp_authkit::dto::JsonErrorDto::createShared();
dto->status = oatpp::String("I'm a \"teapot\"");
dto->code = 418;
dto->message = oatpp::String("brew\nfailure");
auto json = m->writeToString(dto);
auto rt = m->readFromString<oatpp::Object<oatpp_authkit::dto::JsonErrorDto>>(json);
REQUIRE(rt);
REQUIRE(std::string(*rt->status) == "I'm a \"teapot\"");
REQUIRE(rt->code == 418);
REQUIRE(std::string(*rt->message) == "brew\nfailure");
}
} // namespace
int main() {
test_presence_dto_round_trips_special_chars();
test_entity_event_dto_round_trip();
test_client_msg_dto_rejects_malformed();
test_json_error_dto_round_trip();
std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures);
return g_failures ? 1 : 0;
}

77
test/test_negotiation.cpp Normal file
View file

@ -0,0 +1,77 @@
// Tests for AuthInterceptor::wantsJson + urlEncode (the negotiation primitives
// that decide whether a 401/403 returns JSON vs HTML/redirect).
//
// Kept dependency-free on purpose — the harness exists so future tests have
// somewhere to land, not to pull in doctest/Catch2.
#include "oatpp-authkit/auth/AuthInterceptor.hpp"
#include <cstdio>
#include <cstdlib>
#include <string>
namespace {
int g_failures = 0;
#define REQUIRE(expr) do { \
if (!(expr)) { \
std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \
++g_failures; \
} \
} while (0)
using oatpp_authkit::AuthInterceptor;
void test_wantsJson_api_path() {
// /api/* always wants JSON, no matter what Accept says.
REQUIRE( AuthInterceptor::wantsJson("/api/users", "", "text/html"));
REQUIRE( AuthInterceptor::wantsJson("/api/", "", ""));
}
void test_wantsJson_xrequested_with() {
// Explicit AJAX wins regardless of path/Accept.
REQUIRE( AuthInterceptor::wantsJson("/admin", "XMLHttpRequest", "text/html"));
}
void test_wantsJson_accept_header() {
// application/json without text/html → JSON.
REQUIRE( AuthInterceptor::wantsJson("/admin", "", "application/json"));
// text/html present → browser navigation.
REQUIRE(!AuthInterceptor::wantsJson("/admin", "", "text/html,application/xhtml+xml"));
REQUIRE(!AuthInterceptor::wantsJson("/admin", "", "text/html,application/json"));
// No Accept → assume browser (HTML/redirect).
REQUIRE(!AuthInterceptor::wantsJson("/set-password", "", ""));
}
void test_wantsJson_set_password_browser() {
// The motivating regression: a browser following the password-reset link
// must NOT be served JSON. (Path is public so it shouldn't reach this in
// normal flow, but if auth ever rejects it the user sees HTML/redirect.)
REQUIRE(!AuthInterceptor::wantsJson("/set-password",
"",
"text/html,application/xhtml+xml,application/xml;q=0.9"));
}
void test_urlEncode() {
REQUIRE(AuthInterceptor::urlEncode("/admin") == "%2Fadmin");
REQUIRE(AuthInterceptor::urlEncode("/set-password?t=1")== "%2Fset-password%3Ft%3D1");
REQUIRE(AuthInterceptor::urlEncode("abc-_.~123") == "abc-_.~123");
REQUIRE(AuthInterceptor::urlEncode(" ") == "%20");
}
} // namespace
int main() {
test_wantsJson_api_path();
test_wantsJson_xrequested_with();
test_wantsJson_accept_header();
test_wantsJson_set_password_browser();
test_urlEncode();
if (g_failures) {
std::fprintf(stderr, "%d test(s) failed\n", g_failures);
return 1;
}
std::puts("ok");
return 0;
}

View file

@ -0,0 +1,59 @@
// Tests for oatpp-authkit/util/OriginCheck.hpp (authkit#16 M-4 / M-10).
#include "oatpp-authkit/util/OriginCheck.hpp"
#include <cstdio>
#include <string>
#include <vector>
namespace {
int g_failures = 0;
#define REQUIRE(expr) do { \
if (!(expr)) { \
std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \
++g_failures; \
} \
} while (0)
using namespace oatpp_authkit;
void test_origin_hostname() {
REQUIRE(originHostname("https://app.example.com") == "app.example.com");
REQUIRE(originHostname("https://app.example.com:8443/x?y=1") == "app.example.com");
REQUIRE(originHostname("app.example.com:443") == "app.example.com");
REQUIRE(originHostname("HTTP://App.Example.COM") == "app.example.com");
REQUIRE(originHostname("example.com") == "example.com");
}
void test_same_origin() {
// Origin host matches Host (port/scheme ignored).
REQUIRE(sameOrigin("https://example.com", "example.com"));
REQUIRE(sameOrigin("https://example.com:8443", "example.com"));
REQUIRE(sameOrigin("https://example.com/page", "example.com")); // Referer form
REQUIRE(sameOrigin("https://example.com", "example.com:443"));
// Cross-host → blocked.
REQUIRE(!sameOrigin("https://evil.com", "example.com"));
REQUIRE(!sameOrigin("https://example.com.evil.com", "example.com"));
// Empty inputs → can't decide → don't block (caller falls back).
REQUIRE(sameOrigin("", "example.com"));
REQUIRE(sameOrigin("https://example.com", ""));
}
void test_origin_allowed() {
std::vector<std::string> allow = {"app.example.com", "https://admin.example.com"};
REQUIRE(originAllowed("https://app.example.com", allow));
REQUIRE(originAllowed("https://admin.example.com:8443/x", allow));
REQUIRE(!originAllowed("https://evil.com", allow));
REQUIRE(!originAllowed("", allow)); // fail closed
}
} // namespace
int main() {
test_origin_hostname();
test_same_origin();
test_origin_allowed();
std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures);
return g_failures ? 1 : 0;
}

243
test/test_queryable.cpp Normal file
View file

@ -0,0 +1,243 @@
// Tests for the oatpp-authkit#9 IQueryable<T> capability.
//
// Verifies that the AST emits the expected parameterised SQL and that the
// bind bag captures the values in order. No real database is involved —
// these tests exercise the SQL emitter and the builder API.
#include "oatpp-authkit/repo/IQueryable.hpp"
#include "oatpp/core/macro/codegen.hpp"
#include "oatpp/core/Types.hpp"
#include <cstdio>
#include <string>
#include <variant>
#include <vector>
#include OATPP_CODEGEN_BEGIN(DTO)
namespace {
class MockQueryDto : public oatpp::DTO {
DTO_INIT(MockQueryDto, DTO)
DTO_FIELD(String, entity_id);
DTO_FIELD(String, name);
DTO_FIELD(String, email);
DTO_FIELD(Int64, age);
DTO_FIELD(Boolean, active);
};
#include OATPP_CODEGEN_END(DTO)
} // namespace
OATPP_AUTHKIT_REGISTER_TABLE(MockQueryDto, "mock_query")
OATPP_AUTHKIT_REGISTER_FIELD(MockQueryDto, entity_id, "entity_id")
OATPP_AUTHKIT_REGISTER_FIELD(MockQueryDto, name, "name")
OATPP_AUTHKIT_REGISTER_FIELD(MockQueryDto, email, "email")
OATPP_AUTHKIT_REGISTER_FIELD(MockQueryDto, age, "age")
OATPP_AUTHKIT_REGISTER_FIELD(MockQueryDto, active, "active")
namespace {
int g_failures = 0;
#define REQUIRE(expr) do { \
if (!(expr)) { \
std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \
++g_failures; \
} \
} while (0)
#define REQUIRE_EQ(a, b) do { \
auto _av = (a); auto _bv = (b); \
if (!(_av == _bv)) { \
std::fprintf(stderr, "FAIL %s:%d %s == %s ('%s' vs '%s')\n", \
__FILE__, __LINE__, #a, #b, \
std::string(_av).c_str(), std::string(_bv).c_str()); \
++g_failures; \
} \
} while (0)
using namespace oatpp_authkit::repo;
// Helper: pull a typed value out of a BindValue, or "<null>" / "<wrong>".
template <typename T>
std::string bindAsString(const BindValue& v) {
if (auto* p = std::get_if<T>(&v)) {
if constexpr (std::is_same_v<T, std::string>) return *p;
else return std::to_string(*p);
}
if (std::holds_alternative<std::monostate>(v)) return "<null>";
return "<wrong-type>";
}
void test_equality_emits_parameterised_sql() {
auto sql = Query<MockQueryDto>()
.where(field<&MockQueryDto::email>().eq("foo@bar"))
.toSql();
REQUIRE_EQ(sql.text,
std::string("SELECT * FROM mock_query WHERE email = ?"));
REQUIRE(sql.binds.size() == 1);
REQUIRE_EQ(bindAsString<std::string>(sql.binds[0]),
std::string("foo@bar"));
}
void test_and_or_combines_predicates() {
auto sql = Query<MockQueryDto>()
.where(field<&MockQueryDto::active>().eq(true)
&& (field<&MockQueryDto::age>().gt(18)
|| field<&MockQueryDto::age>().lt(5)))
.toSql();
REQUIRE_EQ(sql.text, std::string(
"SELECT * FROM mock_query WHERE "
"(active = ? AND (age > ? OR age < ?))"));
REQUIRE(sql.binds.size() == 3);
REQUIRE(std::get<bool>(sql.binds[0]) == true);
REQUIRE(std::get<std::int64_t>(sql.binds[1]) == 18);
REQUIRE(std::get<std::int64_t>(sql.binds[2]) == 5);
}
void test_repeated_where_implies_and() {
auto sql = Query<MockQueryDto>()
.where(field<&MockQueryDto::email>().eq("foo@bar"))
.where(field<&MockQueryDto::active>().eq(true))
.toSql();
REQUIRE_EQ(sql.text, std::string(
"SELECT * FROM mock_query WHERE "
"(email = ? AND active = ?)"));
}
void test_range_emits_inclusive_and_exclusive() {
auto sql = Query<MockQueryDto>()
.where(field<&MockQueryDto::age>().ge(18)
&& field<&MockQueryDto::age>().le(65))
.toSql();
REQUIRE_EQ(sql.text, std::string(
"SELECT * FROM mock_query WHERE (age >= ? AND age <= ?)"));
REQUIRE(std::get<std::int64_t>(sql.binds[0]) == 18);
REQUIRE(std::get<std::int64_t>(sql.binds[1]) == 65);
}
void test_in_with_multiple_values() {
auto sql = Query<MockQueryDto>()
.where(field<&MockQueryDto::email>().in({"a@x", "b@x", "c@x"}))
.toSql();
REQUIRE_EQ(sql.text, std::string(
"SELECT * FROM mock_query WHERE email IN (?, ?, ?)"));
REQUIRE(sql.binds.size() == 3);
REQUIRE_EQ(std::get<std::string>(sql.binds[0]), std::string("a@x"));
REQUIRE_EQ(std::get<std::string>(sql.binds[2]), std::string("c@x"));
}
void test_in_with_empty_list_is_always_false() {
std::vector<std::string> empty;
auto sql = Query<MockQueryDto>()
.where(field<&MockQueryDto::email>().in(empty))
.toSql();
REQUIRE_EQ(sql.text, std::string("SELECT * FROM mock_query WHERE 0"));
REQUIRE(sql.binds.empty());
}
void test_like_pattern_is_bound_not_interpolated() {
auto sql = Query<MockQueryDto>()
.where(field<&MockQueryDto::name>().like("Al%"))
.toSql();
REQUIRE_EQ(sql.text, std::string(
"SELECT * FROM mock_query WHERE name LIKE ?"));
REQUIRE_EQ(std::get<std::string>(sql.binds[0]), std::string("Al%"));
}
void test_like_contains_escapes_wildcards() {
// authkit#16 L-8: a user term with %/_/\ must be matched literally via an
// explicit ESCAPE clause, not treated as wildcards.
auto sql = Query<MockQueryDto>()
.where(field<&MockQueryDto::name>().likeContains("50%_off\\x"))
.toSql();
REQUIRE_EQ(sql.text, std::string(
"SELECT * FROM mock_query WHERE name LIKE ? ESCAPE '\\'"));
REQUIRE_EQ(std::get<std::string>(sql.binds[0]),
std::string("%50\\%\\_off\\\\x%"));
auto pfx = Query<MockQueryDto>()
.where(field<&MockQueryDto::name>().likePrefix("a_b"))
.toSql();
REQUIRE_EQ(pfx.text, std::string(
"SELECT * FROM mock_query WHERE name LIKE ? ESCAPE '\\'"));
REQUIRE_EQ(std::get<std::string>(pfx.binds[0]), std::string("a\\_b%"));
// The bare likeEscape helper.
REQUIRE_EQ(likeEscape("100%_\\"), std::string("100\\%\\_\\\\"));
REQUIRE_EQ(likeEscape("plain"), std::string("plain"));
}
void test_is_null_and_is_not_null() {
auto a = Query<MockQueryDto>()
.where(field<&MockQueryDto::email>().isNull())
.toSql();
REQUIRE_EQ(a.text, std::string(
"SELECT * FROM mock_query WHERE email IS NULL"));
REQUIRE(a.binds.empty());
auto b = Query<MockQueryDto>()
.where(field<&MockQueryDto::email>().isNotNull())
.toSql();
REQUIRE_EQ(b.text, std::string(
"SELECT * FROM mock_query WHERE email IS NOT NULL"));
}
void test_not_negates_predicate() {
auto sql = Query<MockQueryDto>()
.where(!field<&MockQueryDto::active>().eq(true))
.toSql();
REQUIRE_EQ(sql.text, std::string(
"SELECT * FROM mock_query WHERE NOT (active = ?)"));
}
void test_order_by_and_limit_offset() {
auto sql = Query<MockQueryDto>()
.where(field<&MockQueryDto::active>().eq(true))
.orderBy(field<&MockQueryDto::name>())
.orderByDesc(field<&MockQueryDto::age>())
.limit(50)
.offset(100)
.toSql();
REQUIRE_EQ(sql.text, std::string(
"SELECT * FROM mock_query WHERE active = ? "
"ORDER BY name ASC, age DESC LIMIT 50 OFFSET 100"));
REQUIRE(std::get<bool>(sql.binds[0]) == true);
}
void test_no_where_no_clauses_is_plain_select() {
auto sql = Query<MockQueryDto>().toSql();
REQUIRE_EQ(sql.text, std::string("SELECT * FROM mock_query"));
REQUIRE(sql.binds.empty());
}
void test_oatpp_string_value_is_unwrapped() {
auto sql = Query<MockQueryDto>()
.where(field<&MockQueryDto::email>().eq(oatpp::String("z@x")))
.toSql();
REQUIRE_EQ(std::get<std::string>(sql.binds[0]), std::string("z@x"));
}
} // namespace
int main() {
test_equality_emits_parameterised_sql();
test_and_or_combines_predicates();
test_repeated_where_implies_and();
test_range_emits_inclusive_and_exclusive();
test_in_with_multiple_values();
test_in_with_empty_list_is_always_false();
test_like_pattern_is_bound_not_interpolated();
test_like_contains_escapes_wildcards();
test_is_null_and_is_not_null();
test_not_negates_predicate();
test_order_by_and_limit_offset();
test_no_where_no_clauses_is_plain_select();
test_oatpp_string_value_is_unwrapped();
std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures);
return g_failures ? 1 : 0;
}

View file

@ -0,0 +1,61 @@
// Tests for oatpp-authkit/util/RateLimiter.hpp — constructor validation
// (authkit#16 M-7) and basic token-bucket behaviour.
#include "oatpp-authkit/util/RateLimiter.hpp"
#include <cstdio>
#include <stdexcept>
#include <string>
namespace {
int g_failures = 0;
#define REQUIRE(expr) do { \
if (!(expr)) { \
std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \
++g_failures; \
} \
} while (0)
using namespace oatpp_authkit;
template <class F>
bool throwsInvalidArg(F&& f) {
try { f(); } catch (const std::invalid_argument&) { return true; } catch (...) { return false; }
return false;
}
void test_ctor_validation() {
REQUIRE(throwsInvalidArg([]{ RateLimiter r(0.0, 1.0); })); // capacity < 1
REQUIRE(throwsInvalidArg([]{ RateLimiter r(-5.0, 1.0); })); // negative capacity
REQUIRE(throwsInvalidArg([]{ RateLimiter r(10.0, 0.0); })); // refill 0 → silent disable
REQUIRE(throwsInvalidArg([]{ RateLimiter r(10.0, -1.0); })); // negative refill
REQUIRE(throwsInvalidArg([]{ RateLimiter r(std::nan(""), 1.0); })); // NaN capacity
REQUIRE(throwsInvalidArg([]{ RateLimiter r(10.0, std::nan("")); })); // NaN refill
REQUIRE(throwsInvalidArg([]{ RateLimiter r(1.0/0.0, 1.0); })); // inf capacity
// Valid construction does not throw.
bool ok = true;
try { RateLimiter r(3.0, 0.5); (void)r; } catch (...) { ok = false; }
REQUIRE(ok);
}
void test_burst_then_deny_and_key_isolation() {
RateLimiter rl(3.0, 0.001); // 3 burst, negligible refill within the test
REQUIRE(rl.allow("ip-a"));
REQUIRE(rl.allow("ip-a"));
REQUIRE(rl.allow("ip-a"));
REQUIRE(!rl.allow("ip-a")); // 4th denied
// Different key has its own independent bucket.
REQUIRE(rl.allow("ip-b"));
}
} // namespace
int main() {
test_ctor_validation();
test_burst_then_deny_and_key_isolation();
std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures);
return g_failures ? 1 : 0;
}

View file

@ -0,0 +1,196 @@
// Tests for authkit#15 — RedactedFieldRepository decorator.
#include "oatpp-authkit/repo/RedactedFieldRepository.hpp"
#include "oatpp-authkit/repo/Repository.hpp"
#include "oatpp-authkit/repo/TemporalFieldTraits.hpp"
#include "oatpp-authkit/repo/TemporalRepository.hpp"
#include "oatpp/core/macro/codegen.hpp"
#include "oatpp/core/Types.hpp"
#include <cassert>
#include <cstdio>
#include <memory>
#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)
#include OATPP_CODEGEN_BEGIN(DTO)
namespace {
class CredDto : public oatpp::DTO {
DTO_INIT(CredDto, DTO)
DTO_FIELD(String, id);
DTO_FIELD(String, entity_id);
DTO_FIELD(String, valid_from);
DTO_FIELD(String, valid_until);
DTO_FIELD(String, username);
DTO_FIELD(String, passwordHash);
DTO_FIELD(String, tlsCertDn);
};
} // namespace
#include OATPP_CODEGEN_END(DTO)
OATPP_AUTHKIT_REGISTER_TEMPORAL(CredDto, id, entity_id, valid_from, valid_until)
namespace {
using namespace oatpp_authkit::repo;
// In-memory inner that just records what got saved, for inspection.
class FakeInner : public Repository<CredDto> {
public:
std::vector<oatpp::Object<CredDto>> saved;
oatpp::Object<CredDto> findByEntityId(const oatpp::String&) override { return nullptr; }
oatpp::Vector<oatpp::Object<CredDto>> list() override {
return oatpp::Vector<oatpp::Object<CredDto>>::createShared();
}
void save(const oatpp::Object<CredDto>& dto) override { saved.push_back(dto); }
void softDelete(const oatpp::String&) override {}
};
oatpp::Object<CredDto> makeRow(const std::string& vu,
const std::string& password,
const std::string& certDn) {
auto d = CredDto::createShared();
d->id = "id1";
d->entity_id = "ent1";
d->valid_from = "2026-01-01T00:00:00Z";
d->valid_until = vu;
d->username = "alice";
d->passwordHash = password;
d->tlsCertDn = certDn;
return d;
}
void test_live_row_passes_through_unchanged() {
auto inner = std::make_shared<FakeInner>();
RedactedFieldRepository<CredDto> redacted(
inner, {"passwordHash", "tlsCertDn"});
// Live row: valid_until == SENTINEL.
auto live = makeRow(TemporalRepository<CredDto>::SENTINEL,
"$bcrypt$secret", "CN=alice");
redacted.save(live);
REQUIRE(inner->saved.size() == 1);
auto& got = inner->saved[0];
REQUIRE(got->passwordHash);
REQUIRE(std::string(*got->passwordHash) == "$bcrypt$secret");
REQUIRE(got->tlsCertDn);
REQUIRE(std::string(*got->tlsCertDn) == "CN=alice");
}
void test_historical_row_redacts_named_fields() {
auto inner = std::make_shared<FakeInner>();
RedactedFieldRepository<CredDto> redacted(
inner, {"passwordHash", "tlsCertDn"});
// Historical row: valid_until is a real timestamp, not the sentinel.
auto historical = makeRow("2026-05-06T12:00:00Z",
"$bcrypt$secret", "CN=alice");
redacted.save(historical);
REQUIRE(inner->saved.size() == 1);
auto& got = inner->saved[0];
REQUIRE(!got->passwordHash); // redacted to null
REQUIRE(!got->tlsCertDn); // redacted to null
// Non-redacted fields survive.
REQUIRE(got->username);
REQUIRE(std::string(*got->username) == "alice");
REQUIRE(got->valid_until);
REQUIRE(std::string(*got->valid_until) == "2026-05-06T12:00:00Z");
}
void test_partial_redaction_list() {
auto inner = std::make_shared<FakeInner>();
RedactedFieldRepository<CredDto> redacted(inner, {"passwordHash"});
auto historical = makeRow("2026-05-06T12:00:00Z",
"$bcrypt$secret", "CN=alice");
redacted.save(historical);
auto& got = inner->saved[0];
REQUIRE(!got->passwordHash); // redacted
REQUIRE(got->tlsCertDn); // NOT redacted (not in list)
REQUIRE(std::string(*got->tlsCertDn) == "CN=alice");
}
void test_empty_redaction_list_passes_everything_through() {
auto inner = std::make_shared<FakeInner>();
RedactedFieldRepository<CredDto> redacted(inner, {});
auto historical = makeRow("2026-05-06T12:00:00Z",
"$bcrypt$secret", "CN=alice");
redacted.save(historical);
auto& got = inner->saved[0];
REQUIRE(got->passwordHash);
REQUIRE(std::string(*got->passwordHash) == "$bcrypt$secret");
REQUIRE(got->tlsCertDn);
}
void test_null_valid_until_treated_as_live() {
auto inner = std::make_shared<FakeInner>();
RedactedFieldRepository<CredDto> redacted(
inner, {"passwordHash", "tlsCertDn"});
// valid_until null — treat as live (the temporal decorator hasn't
// set it yet on a fresh insert, before deciding sentinel).
auto fresh = CredDto::createShared();
fresh->id = "id2";
fresh->entity_id = "ent2";
fresh->passwordHash = "$bcrypt$fresh";
fresh->tlsCertDn = "CN=bob";
redacted.save(fresh);
auto& got = inner->saved[0];
REQUIRE(got->passwordHash); // not redacted
REQUIRE(got->tlsCertDn);
}
// authkit#16 M-6: a redaction field name that doesn't exist on the DTO must
// throw at construction — a silent no-op would leave credentials in history.
void test_unknown_field_throws() {
auto inner = std::make_shared<FakeInner>();
bool threw = false;
try {
RedactedFieldRepository<CredDto> bad(inner, {"passwordHash", "passowrdHash" /* typo */});
} catch (const std::invalid_argument&) {
threw = true;
}
REQUIRE(threw);
// Wrong casing / JSON-name instead of C++ identifier also throws.
bool threw2 = false;
try {
RedactedFieldRepository<CredDto> bad2(inner, {"password_hash" /* JSON name, not the DTO_FIELD id */});
} catch (const std::invalid_argument&) {
threw2 = true;
}
REQUIRE(threw2);
// A correct set constructs fine.
RedactedFieldRepository<CredDto> ok(inner, {"passwordHash", "tlsCertDn"});
(void)ok;
}
} // namespace
int main() {
test_live_row_passes_through_unchanged();
test_historical_row_redacts_named_fields();
test_partial_redaction_list();
test_empty_redaction_list_passes_everything_through();
test_null_valid_until_treated_as_live();
test_unknown_field_throws();
std::printf("test_redacted_field_repository: OK\n");
return 0;
}

View file

@ -0,0 +1,435 @@
// Tests for the oatpp-authkit#8 repository decorators (TemporalRepository,
// ScopeGuardRepository). Validates the acceptance criteria from the issue:
// - Temporal save closes the prior version
// - Live read returns only the row with valid_until = sentinel
// - Point-in-time read returns the version live at that time
// - History returns all versions in order
// - Scope guard short-circuits when the predicate returns false
//
// The in-memory backing store keys rows by (entity_id, valid_from), matching
// the upsert contract documented on TemporalRepository<TDto>.
#include "oatpp-authkit/repo/TemporalRepository.hpp"
#include "oatpp-authkit/repo/ScopeGuardRepository.hpp"
#include "oatpp-authkit/repo/Repository.hpp"
#include "oatpp-authkit/repo/TemporalFieldTraits.hpp"
#include "oatpp-authkit/repo/TemporalAt.hpp"
#include "oatpp-authkit/repo/ActorContext.hpp"
#include "oatpp/core/macro/codegen.hpp"
#include "oatpp/core/Types.hpp"
#include <cstdio>
#include <map>
#include <memory>
#include <string>
#include <utility>
#include OATPP_CODEGEN_BEGIN(DTO)
namespace {
class MockTemporalDto : public oatpp::DTO {
DTO_INIT(MockTemporalDto, DTO)
DTO_FIELD(String, id); // per-row PK (version UUID)
DTO_FIELD(String, entity_id);
DTO_FIELD(String, valid_from);
DTO_FIELD(String, valid_until);
DTO_FIELD(String, name);
DTO_FIELD(String, scope); // For ScopeGuardRepository — emulates a property_id-style field.
};
#include OATPP_CODEGEN_END(DTO)
} // namespace
OATPP_AUTHKIT_REGISTER_TEMPORAL(MockTemporalDto, id, entity_id, valid_from, valid_until)
namespace {
int g_failures = 0;
#define REQUIRE(expr) do { \
if (!(expr)) { \
std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \
++g_failures; \
} \
} while (0)
// In-memory adapter: rows keyed by `id` PK (per-row UUID). save() upserts —
// matches the new TemporalRepository inner contract (authkit#13).
// Exposes ALL rows via list() — the temporal decorator filters to live.
class InMemoryAllRows : public oatpp_authkit::repo::Repository<MockTemporalDto> {
std::map<std::string, oatpp::Object<MockTemporalDto>> rows;
public:
oatpp::Object<MockTemporalDto> findByEntityId(const oatpp::String& id) override {
// Not used by TemporalRepository — included for interface completeness.
for (auto& kv : rows) if (kv.second->entity_id && std::string(*kv.second->entity_id) == std::string(*id)) return kv.second;
return nullptr;
}
oatpp::Vector<oatpp::Object<MockTemporalDto>> list() override {
auto v = oatpp::Vector<oatpp::Object<MockTemporalDto>>::createShared();
for (auto& kv : rows) v->push_back(kv.second);
return v;
}
void save(const oatpp::Object<MockTemporalDto>& dto) override {
rows[std::string(*dto->id)] = dto;
}
void softDelete(const oatpp::String& id) override {
for (auto it = rows.begin(); it != rows.end(); ) {
if (it->second->entity_id && std::string(*it->second->entity_id) == std::string(*id)) it = rows.erase(it); else ++it;
}
}
};
// Fixed-time clock for deterministic tests. Returns successive timestamps
// 1000ms apart so point-in-time reads can pick a value strictly between
// version boundaries.
struct StepClock {
int64_t ms{1700000000000LL};
int64_t operator()() { int64_t v = ms; ms += 1000; return v; }
};
// Sequencing idgen so each call returns a fresh string — needed now that
// the decorator allocates both entity_id and per-row PK.
struct SeqIdGen {
int n{0};
oatpp::String operator()() {
char buf[16];
std::snprintf(buf, sizeof(buf), "id%04d", n++);
return oatpp::String(buf);
}
};
void test_save_closes_prior_version_and_inserts_new() {
using namespace oatpp_authkit::repo;
auto inner = std::make_shared<InMemoryAllRows>();
auto clock = std::make_shared<StepClock>();
auto ids = std::make_shared<SeqIdGen>();
TemporalRepository<MockTemporalDto> repo(inner,
[clock]{ return (*clock)(); },
[ids]{ return (*ids)(); });
// First save — entity_id + id auto-allocated, valid_from = now1, valid_until = SENTINEL.
auto v1 = MockTemporalDto::createShared();
v1->name = oatpp::String("alice v1");
repo.save(v1);
REQUIRE(v1->entity_id);
REQUIRE(v1->id);
REQUIRE(std::string(*v1->valid_until)
== TemporalRepository<MockTemporalDto>::SENTINEL);
REQUIRE(inner->list()->size() == 1);
const std::string livePkAfterFirst = std::string(*v1->id);
// Second save — historical copy with new PK, live row updated in place.
auto v2 = MockTemporalDto::createShared();
v2->entity_id = v1->entity_id;
v2->name = oatpp::String("alice v2");
repo.save(v2);
auto allAfter = inner->list();
REQUIRE(allAfter->size() == 2);
int liveCount = 0;
std::string livePkAfterSecond, historicalPk;
for (auto& row : *allAfter) {
if (std::string(*row->valid_until)
== TemporalRepository<MockTemporalDto>::SENTINEL) {
++liveCount;
livePkAfterSecond = std::string(*row->id);
} else {
historicalPk = std::string(*row->id);
}
}
REQUIRE(liveCount == 1); // exactly one live
REQUIRE(livePkAfterSecond == livePkAfterFirst); // stable live PK
REQUIRE(historicalPk != livePkAfterFirst); // historical has fresh PK
REQUIRE(std::string(*v2->id) == livePkAfterFirst); // dto reflects preserved PK
}
void test_live_read_returns_only_sentinel_row() {
using namespace oatpp_authkit::repo;
auto inner = std::make_shared<InMemoryAllRows>();
auto clock = std::make_shared<StepClock>();
TemporalRepository<MockTemporalDto> repo(inner,
[clock]{ return (*clock)(); });
auto v1 = MockTemporalDto::createShared();
v1->entity_id = oatpp::String("abc");
v1->name = oatpp::String("v1");
repo.save(v1);
auto v2 = MockTemporalDto::createShared();
v2->entity_id = oatpp::String("abc");
v2->name = oatpp::String("v2");
repo.save(v2);
auto live = repo.findByEntityId(oatpp::String("abc"));
REQUIRE(live);
REQUIRE(std::string(*live->name) == "v2");
auto liveList = repo.list();
REQUIRE(liveList->size() == 1);
REQUIRE(std::string(*(*liveList)[0]->name) == "v2");
}
void test_point_in_time_read_returns_version_live_at_t() {
using namespace oatpp_authkit::repo;
auto inner = std::make_shared<InMemoryAllRows>();
StepClock clock;
int64_t t1 = clock.ms;
auto repo = std::make_shared<TemporalRepository<MockTemporalDto>>(
inner, [&clock]{ return clock(); });
auto v1 = MockTemporalDto::createShared();
v1->entity_id = oatpp::String("abc");
v1->name = oatpp::String("v1");
repo->save(v1); // valid_from = t1, valid_until = SENTINEL → t2 after save 2
// Pick a point strictly inside v1's interval [t1, t2).
int64_t betweenSaves = t1 + 500; // t2 = t1 + 1000 with our StepClock
auto v2 = MockTemporalDto::createShared();
v2->entity_id = oatpp::String("abc");
v2->name = oatpp::String("v2");
repo->save(v2); // closes v1 at t2; v2 valid_from = t2
// Read as-of betweenSaves — should still see v1.
auto atT2 = repo->findByEntityIdAt(oatpp::String("abc"),
TemporalAt::at(betweenSaves));
REQUIRE(atT2);
REQUIRE(std::string(*atT2->name) == "v1");
// Read as-of t1 — should also see v1.
auto atT1 = repo->findByEntityIdAt(oatpp::String("abc"), TemporalAt::at(t1));
REQUIRE(atT1);
REQUIRE(std::string(*atT1->name) == "v1");
}
void test_history_returns_versions_in_order() {
using namespace oatpp_authkit::repo;
auto inner = std::make_shared<InMemoryAllRows>();
auto clock = std::make_shared<StepClock>();
TemporalRepository<MockTemporalDto> repo(inner,
[clock]{ return (*clock)(); });
for (const char* n : {"v1", "v2", "v3"}) {
auto dto = MockTemporalDto::createShared();
dto->entity_id = oatpp::String("abc");
dto->name = oatpp::String(n);
repo.save(dto);
}
auto h = repo.history(oatpp::String("abc"));
REQUIRE(h->size() == 3);
REQUIRE(std::string(*(*h)[0]->name) == "v1");
REQUIRE(std::string(*(*h)[1]->name) == "v2");
REQUIRE(std::string(*(*h)[2]->name) == "v3");
}
void test_soft_delete_closes_live_without_new_version() {
using namespace oatpp_authkit::repo;
auto inner = std::make_shared<InMemoryAllRows>();
auto clock = std::make_shared<StepClock>();
TemporalRepository<MockTemporalDto> repo(inner,
[clock]{ return (*clock)(); });
auto v = MockTemporalDto::createShared();
v->entity_id = oatpp::String("abc");
v->name = oatpp::String("dead");
repo.save(v);
repo.softDelete(oatpp::String("abc"));
REQUIRE(!repo.findByEntityId(oatpp::String("abc")));
auto remaining = inner->list();
REQUIRE(remaining->size() == 1); // historical row still exists
REQUIRE(std::string(*(*remaining)[0]->valid_until)
!= TemporalRepository<MockTemporalDto>::SENTINEL);
}
// ---- ScopeGuardRepository ----
void test_scope_guard_denies_when_predicate_false() {
using namespace oatpp_authkit::repo;
auto inner = std::make_shared<InMemoryAllRows>();
// Seed inner with two rows in different scopes.
for (const char* sc : {"prop-A", "prop-B"}) {
auto dto = MockTemporalDto::createShared();
dto->id = oatpp::String(std::string("pk-") + sc);
dto->entity_id = oatpp::String(sc); // reuse scope as id for simplicity
dto->valid_from = oatpp::String("2020-01-01T00:00:00Z");
dto->valid_until = oatpp::String("9999-12-31T23:59:59Z");
dto->name = oatpp::String(sc);
dto->scope = oatpp::String(sc);
inner->save(dto);
}
ActorContext actor;
actor.userId = "u1";
actor.allowedScopes = {"prop-A"};
ScopeGuardRepository<MockTemporalDto> guarded(inner,
// Predicate: only allow rows whose scope is in the actor's allowedScopes.
[](const ActorContext& a, const oatpp::Object<MockTemporalDto>& d) {
if (!d || !d->scope) return false;
const std::string s = std::string(*d->scope);
for (auto& as : a.allowedScopes) if (as == s) return true;
return false;
},
[actor]{ return actor; },
[](const oatpp::Object<MockTemporalDto>& d) { return d->entity_id; });
// list filters to allowed rows only.
auto allowed = guarded.list();
REQUIRE(allowed->size() == 1);
REQUIRE(std::string(*(*allowed)[0]->scope) == "prop-A");
// findByEntityId on a denied row throws.
bool threwOnFind = false;
try { (void)guarded.findByEntityId(oatpp::String("prop-B")); }
catch (const ScopeDeniedException&) { threwOnFind = true; }
REQUIRE(threwOnFind);
// findByEntityId on an allowed row returns it.
auto okRow = guarded.findByEntityId(oatpp::String("prop-A"));
REQUIRE(okRow);
// save of a denied scope throws.
auto evil = MockTemporalDto::createShared();
evil->entity_id = oatpp::String("xxx");
evil->valid_from = oatpp::String("2020-01-01T00:00:00Z");
evil->valid_until = oatpp::String("9999-12-31T23:59:59Z");
evil->scope = oatpp::String("prop-B");
bool threwOnSave = false;
try { guarded.save(evil); }
catch (const ScopeDeniedException&) { threwOnSave = true; }
REQUIRE(threwOnSave);
// softDelete of a denied row throws.
bool threwOnDelete = false;
try { guarded.softDelete(oatpp::String("prop-B")); }
catch (const ScopeDeniedException&) { threwOnDelete = true; }
REQUIRE(threwOnDelete);
}
// Scope predicate + entity-id accessor shared by the reparenting / queryable tests.
static bool scopeAllows(const oatpp_authkit::repo::ActorContext& a,
const oatpp::Object<MockTemporalDto>& d) {
if (!d || !d->scope) return false;
const std::string s = std::string(*d->scope);
for (auto& as : a.allowedScopes) if (as == s) return true;
return false;
}
static oatpp::String entityIdOf(const oatpp::Object<MockTemporalDto>& d) { return d->entity_id; }
// An actor scoped to prop-A must NOT be able to reparent an existing prop-B row
// into prop-A by setting scope=prop-A in the body. save() must reject because the
// *existing* row is out of scope, even though the incoming dto looks in-scope.
void test_scope_guard_blocks_reparenting() {
using namespace oatpp_authkit::repo;
auto inner = std::make_shared<InMemoryAllRows>();
// Seed an entity currently owned by prop-B.
auto seeded = MockTemporalDto::createShared();
seeded->id = oatpp::String("pk-ent1");
seeded->entity_id = oatpp::String("ent1");
seeded->valid_from = oatpp::String("2020-01-01T00:00:00Z");
seeded->valid_until = oatpp::String("9999-12-31T23:59:59Z");
seeded->scope = oatpp::String("prop-B");
inner->save(seeded);
ActorContext actor;
actor.userId = "u1";
actor.allowedScopes = {"prop-A"};
ScopeGuardRepository<MockTemporalDto> guarded(
inner, &scopeAllows, [actor]{ return actor; }, &entityIdOf);
// Attempt to claim ent1 by relabelling it prop-A.
auto reparent = MockTemporalDto::createShared();
reparent->entity_id = oatpp::String("ent1");
reparent->scope = oatpp::String("prop-A"); // incoming looks in-scope...
bool blocked = false;
try { guarded.save(reparent); }
catch (const ScopeDeniedException&) { blocked = true; } // ...but existing row is prop-B
REQUIRE(blocked);
// The stored row is untouched.
auto still = inner->findByEntityId(oatpp::String("ent1"));
REQUIRE(still);
REQUIRE(std::string(*still->scope) == "prop-B");
// A genuine insert into the actor's own scope still works (no existing row).
auto fresh = MockTemporalDto::createShared();
fresh->id = oatpp::String("pk-ent2");
fresh->entity_id = oatpp::String("ent2");
fresh->scope = oatpp::String("prop-A");
fresh->valid_until = oatpp::String("9999-12-31T23:59:59Z");
bool ok = true;
try { guarded.save(fresh); } catch (const ScopeDeniedException&) { ok = false; }
REQUIRE(ok);
}
// Minimal IQueryable inner whose query() returns every row, so the test can
// verify ScopeGuardQueryable post-filters results through the predicate.
class InMemoryQueryable : public oatpp_authkit::repo::IQueryable<MockTemporalDto> {
std::map<std::string, oatpp::Object<MockTemporalDto>> rows;
public:
oatpp::Object<MockTemporalDto> findByEntityId(const oatpp::String& id) override {
for (auto& kv : rows)
if (kv.second->entity_id && std::string(*kv.second->entity_id) == std::string(*id)) return kv.second;
return nullptr;
}
oatpp::Vector<oatpp::Object<MockTemporalDto>> list() override {
auto v = oatpp::Vector<oatpp::Object<MockTemporalDto>>::createShared();
for (auto& kv : rows) v->push_back(kv.second);
return v;
}
void save(const oatpp::Object<MockTemporalDto>& dto) override { rows[std::string(*dto->id)] = dto; }
void softDelete(const oatpp::String&) override {}
oatpp::Vector<oatpp::Object<MockTemporalDto>>
query(const oatpp_authkit::repo::Query<MockTemporalDto>&) override {
return list(); // pretend the filter ran; the point is the guard filters scope
}
};
// query() through ScopeGuardQueryable must drop rows outside the actor's scope —
// otherwise the queryable surface bypasses the scope guard entirely.
void test_scope_guard_queryable_filters_query() {
using namespace oatpp_authkit::repo;
auto inner = std::make_shared<InMemoryQueryable>();
for (const char* sc : {"prop-A", "prop-B"}) {
auto dto = MockTemporalDto::createShared();
dto->id = oatpp::String(std::string("pk-") + sc);
dto->entity_id = oatpp::String(sc);
dto->valid_until = oatpp::String("9999-12-31T23:59:59Z");
dto->scope = oatpp::String(sc);
inner->save(dto);
}
ActorContext actor;
actor.userId = "u1";
actor.allowedScopes = {"prop-A"};
ScopeGuardQueryable<MockTemporalDto> guarded(
inner, &scopeAllows, [actor]{ return actor; }, &entityIdOf);
auto result = guarded.query(Query<MockTemporalDto>{});
REQUIRE(result->size() == 1); // prop-B filtered out
REQUIRE(std::string(*(*result)[0]->scope) == "prop-A");
}
} // namespace
int main() {
test_save_closes_prior_version_and_inserts_new();
test_live_read_returns_only_sentinel_row();
test_point_in_time_read_returns_version_live_at_t();
test_history_returns_versions_in_order();
test_soft_delete_closes_live_without_new_version();
test_scope_guard_denies_when_predicate_false();
test_scope_guard_blocks_reparenting();
test_scope_guard_queryable_filters_query();
std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures);
return g_failures ? 1 : 0;
}

View file

@ -0,0 +1,199 @@
// Tests for the oatpp-authkit#7 Repository<T> interface set. Exercises the
// contract through a trivial in-memory fake — confirms the abstract methods
// compile against an oatpp DTO, the mixed-id allocation branch on save() is
// implementable, and findByEntityId/list/softDelete round-trip as documented.
//
// No SQL involvement — that's the concrete adapters' job (out of scope).
#include "oatpp-authkit/repo/Repository.hpp"
#include "oatpp-authkit/repo/IHistoryRepository.hpp"
#include "oatpp-authkit/repo/TemporalAt.hpp"
#include "oatpp-authkit/repo/ActorContext.hpp"
#include "oatpp/core/macro/codegen.hpp"
#include "oatpp/core/Types.hpp"
#include <cstdio>
#include <random>
#include <unordered_map>
#include OATPP_CODEGEN_BEGIN(DTO)
namespace {
class MockDto : public oatpp::DTO {
DTO_INIT(MockDto, DTO)
DTO_FIELD(String, entity_id);
DTO_FIELD(String, name);
};
#include OATPP_CODEGEN_END(DTO)
int g_failures = 0;
#define REQUIRE(expr) do { \
if (!(expr)) { \
std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \
++g_failures; \
} \
} while (0)
// Trivial UUID-ish generator — sufficient for the in-memory fake; concrete
// adapters can use libuuid or similar in production.
std::string generateId() {
static std::mt19937_64 rng{std::random_device{}()};
char buf[33];
std::snprintf(buf, sizeof(buf), "%016llx%016llx",
(unsigned long long)rng(), (unsigned long long)rng());
return std::string(buf);
}
class InMemoryRepo : public oatpp_authkit::repo::Repository<MockDto> {
std::unordered_map<std::string, oatpp::Object<MockDto>> live;
std::unordered_map<std::string, oatpp::Object<MockDto>> deleted;
public:
oatpp::Object<MockDto> findByEntityId(const oatpp::String& id) override {
auto it = live.find(*id);
return it == live.end() ? nullptr : it->second;
}
oatpp::Vector<oatpp::Object<MockDto>> list() override {
auto v = oatpp::Vector<oatpp::Object<MockDto>>::createShared();
for (auto& kv : live) v->push_back(kv.second);
return v;
}
void save(const oatpp::Object<MockDto>& dto) override {
if (!dto->entity_id) {
dto->entity_id = generateId();
}
live[*dto->entity_id] = dto;
}
void softDelete(const oatpp::String& id) override {
auto it = live.find(*id);
if (it != live.end()) {
deleted[*id] = it->second;
live.erase(it);
}
}
};
void test_save_allocates_uuid_when_id_null() {
InMemoryRepo repo;
auto dto = MockDto::createShared();
dto->name = oatpp::String("alice");
REQUIRE(!dto->entity_id); // precondition: id is null
repo.save(dto);
REQUIRE(dto->entity_id); // id was filled in
REQUIRE(std::string(*dto->entity_id).size() > 0);
}
void test_save_uses_supplied_id_when_present() {
InMemoryRepo repo;
auto dto = MockDto::createShared();
dto->entity_id = oatpp::String("supplied-id-42");
dto->name = oatpp::String("bob");
repo.save(dto);
REQUIRE(std::string(*dto->entity_id) == "supplied-id-42");
auto loaded = repo.findByEntityId(oatpp::String("supplied-id-42"));
REQUIRE(loaded);
REQUIRE(std::string(*loaded->name) == "bob");
}
void test_find_by_entity_id_round_trip() {
InMemoryRepo repo;
auto dto = MockDto::createShared();
dto->entity_id = oatpp::String("abc");
dto->name = oatpp::String("carol");
repo.save(dto);
auto found = repo.findByEntityId(oatpp::String("abc"));
REQUIRE(found);
REQUIRE(std::string(*found->name) == "carol");
auto missing = repo.findByEntityId(oatpp::String("does-not-exist"));
REQUIRE(!missing);
}
void test_list_returns_all_live_rows() {
InMemoryRepo repo;
for (const char* n : {"a", "b", "c"}) {
auto dto = MockDto::createShared();
dto->entity_id = oatpp::String(n);
dto->name = oatpp::String(n);
repo.save(dto);
}
auto all = repo.list();
REQUIRE(all->size() == 3);
}
void test_soft_delete_removes_from_live_view() {
InMemoryRepo repo;
auto dto = MockDto::createShared();
dto->entity_id = oatpp::String("delete-me");
dto->name = oatpp::String("doomed");
repo.save(dto);
REQUIRE(repo.findByEntityId(oatpp::String("delete-me")));
repo.softDelete(oatpp::String("delete-me"));
REQUIRE(!repo.findByEntityId(oatpp::String("delete-me")));
REQUIRE(repo.list()->size() == 0);
}
void test_temporal_at_value_type() {
using oatpp_authkit::repo::TemporalAt;
auto live = TemporalAt::live();
REQUIRE(live.kind == TemporalAt::Kind::Live);
auto pin = TemporalAt::at(1700000000000LL);
REQUIRE(pin.kind == TemporalAt::Kind::At);
REQUIRE(pin.timestamp == 1700000000000LL);
}
void test_actor_context_minimal() {
oatpp_authkit::repo::ActorContext ctx;
ctx.userId = "user-1";
ctx.allowedScopes = {"prop-A", "prop-B"};
REQUIRE(ctx.userId == "user-1");
REQUIRE(ctx.allowedScopes.size() == 2);
}
// Compile-time check: a temporal DTO with all three canonical fields builds.
#include OATPP_CODEGEN_BEGIN(DTO)
class TemporalDto : public oatpp::DTO {
DTO_INIT(TemporalDto, DTO)
DTO_FIELD(String, entity_id);
DTO_FIELD(String, valid_from);
DTO_FIELD(String, valid_until);
};
#include OATPP_CODEGEN_END(DTO)
void test_temporal_dto_compiles() {
auto dto = TemporalDto::createShared();
dto->entity_id = oatpp::String("t");
REQUIRE(std::string(*dto->entity_id) == "t");
}
} // namespace
int main() {
test_save_allocates_uuid_when_id_null();
test_save_uses_supplied_id_when_present();
test_find_by_entity_id_round_trip();
test_list_returns_all_live_rows();
test_soft_delete_removes_from_live_view();
test_temporal_at_value_type();
test_actor_context_minimal();
test_temporal_dto_compiles();
std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures);
return g_failures ? 1 : 0;
}

View 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;
}

View file

@ -0,0 +1,242 @@
// Tests for authkit#14 — declarative schema contract (D-replace).
//
// Verifies SchemaBuilder composes decorator contributions into a single
// CREATE TABLE per entity, sidecar tables emit separately, and
// SchemaContract::verify catches missing columns/tables.
//
// No real SQL engine — `exec` collects emitted DDL strings and `probe`
// is driven by a fake "known-rows" set.
#include "oatpp-authkit/repo/SchemaContract.hpp"
#include "oatpp-authkit/repo/TemporalRepository.hpp"
#include "oatpp-authkit/repo/AuditLogRepository.hpp"
#include "oatpp-authkit/repo/ScopeGuardRepository.hpp"
#include "oatpp-authkit/repo/TemporalFieldTraits.hpp"
#include "oatpp/core/macro/codegen.hpp"
#include "oatpp/core/Types.hpp"
#include <cassert>
#include <cstdio>
#include <set>
#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)
#include OATPP_CODEGEN_BEGIN(DTO)
namespace {
class TestDto : public oatpp::DTO {
DTO_INIT(TestDto, DTO)
DTO_FIELD(String, id);
DTO_FIELD(String, entity_id);
DTO_FIELD(String, valid_from);
DTO_FIELD(String, valid_until);
DTO_FIELD(String, name);
};
} // namespace
#include OATPP_CODEGEN_END(DTO)
OATPP_AUTHKIT_REGISTER_TEMPORAL(TestDto, id, entity_id, valid_from, valid_until)
namespace {
using namespace oatpp_authkit::repo;
// A minimal "concrete repo" stand-in that contributes the entity_id +
// id + name columns. Concrete repos in real consumers (e.g. fewo's
// ConcretePersonRepository) would expose kSchema the same way.
class TestConcreteRepo {
public:
inline static constexpr ColumnSpec kColumns[] = {
{"id", "INTEGER PRIMARY KEY AUTOINCREMENT"},
{"entity_id", "TEXT NOT NULL"},
{"name", "TEXT NOT NULL"},
};
inline static constexpr DecoratorSchema kSchema = {
"TestConcreteRepo",
kColumns, sizeof(kColumns) / sizeof(kColumns[0]),
nullptr, 0,
nullptr, 0,
};
};
struct FakeDb {
std::vector<std::string> execLog;
std::set<std::string> knownTrue;
SqlExec exec() { return [this](const std::string& s){ execLog.push_back(s); }; }
SqlProbe probe() { return [this](const std::string& s){ return knownTrue.count(s) > 0; }; }
};
bool contains(const std::string& haystack, const std::string& needle) {
return haystack.find(needle) != std::string::npos;
}
// Test 1: instantiate substitutes {table}.
void test_instantiate() {
REQUIRE(instantiate("SELECT * FROM {table}", "persons") == "SELECT * FROM persons");
REQUIRE(instantiate("ix_{table}_a_{table}_b", "p") == "ix_p_a_p_b");
REQUIRE(instantiate("no placeholder", "tbl") == "no placeholder");
}
// Test 2: SchemaBuilder emits one CREATE per entity table with all
// composed columns and a CREATE INDEX per index spec.
void test_builder_composes_entity_table() {
FakeDb db;
SchemaBuilder<
TestConcreteRepo,
TemporalRepository<TestDto>,
ScopeGuardRepository<TestDto>>::create("persons", db.exec());
// No sidecars from this stack → one CREATE TABLE + one CREATE INDEX.
REQUIRE(db.execLog.size() == 2);
const auto& table = db.execLog[0];
REQUIRE(contains(table, "CREATE TABLE IF NOT EXISTS persons"));
REQUIRE(contains(table, "id INTEGER PRIMARY KEY AUTOINCREMENT"));
REQUIRE(contains(table, "entity_id TEXT NOT NULL"));
REQUIRE(contains(table, "name TEXT NOT NULL"));
REQUIRE(contains(table, "valid_from"));
REQUIRE(contains(table, "valid_until"));
const auto& idx = db.execLog[1];
REQUIRE(contains(idx, "CREATE UNIQUE INDEX IF NOT EXISTS ux_persons_entity_valid_until"));
REQUIRE(contains(idx, "ON persons (entity_id, valid_until)"));
}
// Test 3: AuditLog contributes a sidecar table; entity table is unaffected.
void test_builder_emits_sidecar() {
FakeDb db;
SchemaBuilder<
TestConcreteRepo,
AuditLogRepository<TestDto>>::create("persons", db.exec());
// sidecar (audit_log) emitted first, then entity table.
REQUIRE(db.execLog.size() == 2);
REQUIRE(contains(db.execLog[0], "CREATE TABLE IF NOT EXISTS audit_log"));
REQUIRE(contains(db.execLog[0], "actor_user_id TEXT"));
REQUIRE(contains(db.execLog[0], "timestamp_ms INTEGER NOT NULL"));
REQUIRE(contains(db.execLog[1], "CREATE TABLE IF NOT EXISTS persons"));
// AuditLog adds nothing to the entity table.
REQUIRE(!contains(db.execLog[1], "actor_user_id"));
}
// Test 4: ScopeGuard contributes nothing; full stack emits sidecars from
// AuditLog + entity table with Temporal columns + temporal index.
void test_builder_full_stack() {
FakeDb db;
SchemaBuilder<
TestConcreteRepo,
TemporalRepository<TestDto>,
ScopeGuardRepository<TestDto>,
AuditLogRepository<TestDto>>::create("persons", db.exec());
// audit_log sidecar + persons table + temporal index = 3
REQUIRE(db.execLog.size() == 3);
REQUIRE(contains(db.execLog[0], "audit_log"));
REQUIRE(contains(db.execLog[1], "CREATE TABLE IF NOT EXISTS persons"));
REQUIRE(contains(db.execLog[1], "valid_until"));
REQUIRE(contains(db.execLog[2], "ux_persons_entity_valid_until"));
}
// Defined at namespace scope: local classes can't carry static data members.
struct DupRepo {
inline static constexpr ColumnSpec kCols[] = {
{"valid_from", "TEXT NOT NULL DEFAULT 'override'"},
};
inline static constexpr DecoratorSchema kSchema = {
"DupRepo",
kCols, sizeof(kCols)/sizeof(kCols[0]),
nullptr, 0, nullptr, 0,
};
};
// Test 5: column dedup — if two layers contribute the same column name,
// first wins, no duplicates in the CREATE.
void test_builder_dedups_columns() {
FakeDb db;
SchemaBuilder<
TestConcreteRepo,
TemporalRepository<TestDto>,
DupRepo>::create("persons", db.exec());
// The DupRepo's contribution is silently skipped (Temporal got there first).
REQUIRE(db.execLog.size() == 2);
REQUIRE(!contains(db.execLog[0], "DEFAULT 'override'"));
}
// Test 6: SchemaContract::verify passes when every column is present.
void test_verify_pass() {
FakeDb db;
// Mark every required column as present.
db.knownTrue.insert("SELECT 1 FROM pragma_table_info('persons') WHERE name='id'");
db.knownTrue.insert("SELECT 1 FROM pragma_table_info('persons') WHERE name='entity_id'");
db.knownTrue.insert("SELECT 1 FROM pragma_table_info('persons') WHERE name='name'");
db.knownTrue.insert("SELECT 1 FROM pragma_table_info('persons') WHERE name='valid_from'");
db.knownTrue.insert("SELECT 1 FROM pragma_table_info('persons') WHERE name='valid_until'");
db.knownTrue.insert("SELECT 1 FROM sqlite_master WHERE type='table' AND name='audit_log'");
// Should not throw.
SchemaContract<
TestConcreteRepo,
TemporalRepository<TestDto>,
ScopeGuardRepository<TestDto>,
AuditLogRepository<TestDto>>::verify("persons", db.probe());
}
// Test 7: SchemaContract::verify throws when a required column is missing.
void test_verify_throws_on_missing_column() {
FakeDb db;
db.knownTrue.insert("SELECT 1 FROM pragma_table_info('persons') WHERE name='id'");
db.knownTrue.insert("SELECT 1 FROM pragma_table_info('persons') WHERE name='entity_id'");
db.knownTrue.insert("SELECT 1 FROM pragma_table_info('persons') WHERE name='name'");
// valid_from missing on purpose.
db.knownTrue.insert("SELECT 1 FROM pragma_table_info('persons') WHERE name='valid_until'");
bool threw = false;
try {
SchemaContract<
TestConcreteRepo,
TemporalRepository<TestDto>>::verify("persons", db.probe());
} catch (const SchemaContractViolation& e) {
threw = true;
REQUIRE(contains(e.what(), "valid_from"));
REQUIRE(contains(e.what(), "TemporalRepository"));
}
REQUIRE(threw);
}
// Test 8: SchemaContract::verify throws when a sidecar is missing.
void test_verify_throws_on_missing_sidecar() {
FakeDb db;
// No audit_log table registered.
bool threw = false;
try {
SchemaContract<AuditLogRepository<TestDto>>::verify("persons", db.probe());
} catch (const SchemaContractViolation& e) {
threw = true;
REQUIRE(contains(e.what(), "audit_log"));
REQUIRE(contains(e.what(), "AuditLogRepository"));
}
REQUIRE(threw);
}
} // namespace
int main() {
test_instantiate();
test_builder_composes_entity_table();
test_builder_emits_sidecar();
test_builder_full_stack();
test_builder_dedups_columns();
test_verify_pass();
test_verify_throws_on_missing_column();
test_verify_throws_on_missing_sidecar();
std::printf("test_schema_contract: OK\n");
return 0;
}

View file

@ -0,0 +1,41 @@
// Smoke test for SecurityHeadersInterceptor — confirms the header compiles
// in a consumer translation unit and the constructor surface matches the
// documented API. Behavioural tests against a real IncomingRequest /
// OutgoingResponse pair would need a full oatpp request fixture; pinning
// the API surface here is enough to catch the kinds of breakage this
// header is at risk of (struct field renames, accidental ctor changes).
#include "oatpp-authkit/interceptor/SecurityHeadersInterceptor.hpp"
#include <cstdio>
#include <memory>
int main() {
using oatpp_authkit::CspOverride;
using oatpp_authkit::SecurityHeadersInterceptor;
// Default ctor: strict baseline.
auto strict = std::make_shared<SecurityHeadersInterceptor>();
(void)strict;
// Override ctor: every documented field reachable.
CspOverride o;
o.defaultSrc = "'self'";
o.scriptSrc = "'self' 'unsafe-inline'";
o.styleSrc = "'self' 'unsafe-inline'";
o.imgSrc = "'self' data: https:";
o.connectSrc = "'self' wss:";
o.fontSrc = "'self'";
o.frameAncestors = "'self'";
o.baseUri = "'self'";
o.formAction = "'self'";
o.sendHsts = false;
o.hstsIncludeSubdomains = true;
o.xFrameOptions = "SAMEORIGIN";
o.permissionsPolicy = "geolocation=(self)";
auto relaxed = std::make_shared<SecurityHeadersInterceptor>(std::move(o));
(void)relaxed;
std::printf("SecurityHeadersInterceptor API ok\n");
return 0;
}

View file

@ -0,0 +1,75 @@
// Tests for oatpp-authkit/util/SessionCookie.hpp (authkit#16 M-9).
#include "oatpp-authkit/util/SessionCookie.hpp"
#include <cstdio>
#include <stdexcept>
#include <string>
namespace {
int g_failures = 0;
#define REQUIRE(expr) do { \
if (!(expr)) { \
std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \
++g_failures; \
} \
} while (0)
using namespace oatpp_authkit;
bool has(const std::string& hay, const std::string& needle) {
return hay.find(needle) != std::string::npos;
}
void test_defaults_are_hardened() {
std::string c = buildSetSessionCookie("tok123");
REQUIRE(has(c, "session=tok123"));
REQUIRE(has(c, "Path=/"));
REQUIRE(has(c, "HttpOnly"));
REQUIRE(has(c, "Secure"));
REQUIRE(has(c, "SameSite=Strict"));
REQUIRE(!has(c, "Max-Age")); // session cookie by default
}
void test_options_respected() {
SessionCookieOptions o;
o.name = "__Host-session";
o.secure = false; // dev opt-out
o.sameSite = "Lax";
o.maxAgeSeconds = 3600;
std::string c = buildSetSessionCookie("t", o);
REQUIRE(has(c, "__Host-session=t"));
REQUIRE(!has(c, "Secure"));
REQUIRE(has(c, "SameSite=Lax"));
REQUIRE(has(c, "Max-Age=3600"));
}
void test_clear_cookie_expires_now() {
std::string c = buildClearSessionCookie();
REQUIRE(has(c, "Max-Age=0"));
REQUIRE(has(c, "session="));
}
void test_injection_guard() {
bool threw = false;
try { buildSetSessionCookie("tok\r\nSet-Cookie: evil=1"); }
catch (const std::invalid_argument&) { threw = true; }
REQUIRE(threw);
bool threw2 = false;
try { buildSetSessionCookie("tok; Domain=evil.com"); } // ';' injection
catch (const std::invalid_argument&) { threw2 = true; }
REQUIRE(threw2);
}
} // namespace
int main() {
test_defaults_are_hardened();
test_options_respected();
test_clear_cookie_expires_now();
test_injection_guard();
std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures);
return g_failures ? 1 : 0;
}

View file

@ -0,0 +1,75 @@
// Tests for oatpp-authkit/mail/SmtpTransport.hpp.
//
// Covers the pure, network-free surface:
// - base64Encode against RFC 4648 vectors
// - hasHeaderInjectionChars
// - send() rejects CR/LF/NUL in recipient / from address BEFORE touching
// libcurl (the SMTP header-injection guard) — no live mail server needed,
// the validation short-circuits ahead of curl_easy_init / perform.
#include "oatpp-authkit/mail/SmtpTransport.hpp"
#include <cstdio>
#include <string>
namespace {
int g_failures = 0;
#define REQUIRE(expr) do { \
if (!(expr)) { \
std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \
++g_failures; \
} \
} while (0)
using namespace oatpp_authkit::mail;
void test_base64_rfc4648_vectors() {
REQUIRE(base64Encode("") == "");
REQUIRE(base64Encode("f") == "Zg==");
REQUIRE(base64Encode("fo") == "Zm8=");
REQUIRE(base64Encode("foo") == "Zm9v");
REQUIRE(base64Encode("foob") == "Zm9vYg==");
REQUIRE(base64Encode("fooba") == "Zm9vYmE=");
REQUIRE(base64Encode("foobar") == "Zm9vYmFy");
}
void test_header_injection_detector() {
REQUIRE(!hasHeaderInjectionChars("a@b.com"));
REQUIRE( hasHeaderInjectionChars("a@b.com\r\nBcc: evil@x.com"));
REQUIRE( hasHeaderInjectionChars("a@b.com\n"));
REQUIRE( hasHeaderInjectionChars("a@b.com\r"));
REQUIRE( hasHeaderInjectionChars(std::string("a@b.com\0x", 9))); // embedded NUL
}
void test_send_rejects_crlf_in_addresses() {
SmtpConfig cfg;
cfg.host = "localhost";
cfg.fromAddress = "noreply@example.com";
// CRLF in recipient → rejected with no network call.
std::string r1 = send("victim@example.com\r\nBcc: evil@x.com",
"subject", "<p>hi</p>", {}, cfg);
REQUIRE(r1.find("invalid recipient") != std::string::npos);
// CRLF in from address → rejected.
SmtpConfig cfg2 = cfg;
cfg2.fromAddress = "noreply@example.com\r\nSubject: spoofed";
std::string r2 = send("victim@example.com", "subject", "<p>hi</p>", {}, cfg2);
REQUIRE(r2.find("invalid from") != std::string::npos);
// Empty-config guards still fire (and come before the address checks).
SmtpConfig empty;
REQUIRE(send("a@b.com", "s", "b", {}, empty).find("no host") != std::string::npos);
}
} // namespace
int main() {
test_base64_rfc4648_vectors();
test_header_injection_detector();
test_send_rejects_crlf_in_addresses();
std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures);
return g_failures ? 1 : 0;
}

View file

@ -0,0 +1,127 @@
// Tests for the oatpp-authkit#10 TemporalFieldTraits<T> extension.
//
// Exercises the temporal decorator against a DTO whose column names are
// NOT entity_id / valid_from / valid_until. The trait specialisation
// supplied via OATPP_AUTHKIT_REGISTER_TEMPORAL bridges the canonical
// names used by TemporalRepository to whatever the DTO actually calls
// them — here `id`, `effective_from`, `effective_until`. Same save/close/
// history flow as the existing decorator tests; only the field names move.
#include "oatpp-authkit/repo/TemporalRepository.hpp"
#include "oatpp-authkit/repo/Repository.hpp"
#include "oatpp-authkit/repo/TemporalFieldTraits.hpp"
#include "oatpp-authkit/repo/TemporalAt.hpp"
#include "oatpp/core/macro/codegen.hpp"
#include "oatpp/core/Types.hpp"
#include <cstdio>
#include <map>
#include <memory>
#include <string>
#include <utility>
#include OATPP_CODEGEN_BEGIN(DTO)
namespace {
// DTO with intentionally non-canonical column names. Without the trait,
// TemporalRepository<T> couldn't reach these fields.
class OddNamesDto : public oatpp::DTO {
DTO_INIT(OddNamesDto, DTO)
DTO_FIELD(String, row_pk);
DTO_FIELD(String, id); // entity_id (logical), per the original test intent
DTO_FIELD(String, effective_from);
DTO_FIELD(String, effective_until);
DTO_FIELD(String, payload);
};
#include OATPP_CODEGEN_END(DTO)
} // namespace
OATPP_AUTHKIT_REGISTER_TEMPORAL(OddNamesDto, row_pk, id, effective_from, effective_until)
namespace {
int g_failures = 0;
#define REQUIRE(expr) do { \
if (!(expr)) { \
std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \
++g_failures; \
} \
} while (0)
// Same in-memory adapter shape as the decorator tests — keys rows by
// (id, effective_from), exposes ALL rows via list().
class InMemoryAllRows : public oatpp_authkit::repo::Repository<OddNamesDto> {
std::map<std::string, oatpp::Object<OddNamesDto>> rows; // keyed by row_pk
public:
oatpp::Object<OddNamesDto> findByEntityId(const oatpp::String& id) override {
for (auto& kv : rows) if (kv.second->id && std::string(*kv.second->id) == std::string(*id)) return kv.second;
return nullptr;
}
oatpp::Vector<oatpp::Object<OddNamesDto>> list() override {
auto v = oatpp::Vector<oatpp::Object<OddNamesDto>>::createShared();
for (auto& kv : rows) v->push_back(kv.second);
return v;
}
void save(const oatpp::Object<OddNamesDto>& dto) override {
rows[std::string(*dto->row_pk)] = dto;
}
void softDelete(const oatpp::String& id) override {
for (auto it = rows.begin(); it != rows.end(); ) {
if (it->second->id && std::string(*it->second->id) == std::string(*id)) it = rows.erase(it); else ++it;
}
}
};
struct StepClock {
int64_t ms{1700000000000LL};
int64_t operator()() { int64_t v = ms; ms += 1000; return v; }
};
void test_save_close_and_history_against_renamed_columns() {
using namespace oatpp_authkit::repo;
auto inner = std::make_shared<InMemoryAllRows>();
auto clock = std::make_shared<StepClock>();
TemporalRepository<OddNamesDto> repo(inner,
[clock]{ return (*clock)(); });
// First save — id auto-allocated, effective_from = now, effective_until = SENTINEL.
auto v1 = OddNamesDto::createShared();
v1->payload = oatpp::String("first");
repo.save(v1);
REQUIRE(v1->id);
REQUIRE(std::string(*v1->effective_until)
== TemporalRepository<OddNamesDto>::SENTINEL);
// Second save — close prior, insert new live.
auto v2 = OddNamesDto::createShared();
v2->id = v1->id;
v2->payload = oatpp::String("second");
repo.save(v2);
auto live = repo.findByEntityId(v1->id);
REQUIRE(live);
REQUIRE(std::string(*live->payload) == "second");
// history() returns both versions, oldest first.
auto h = repo.history(v1->id);
REQUIRE(h->size() == 2);
REQUIRE(std::string(*(*h)[0]->payload) == "first");
REQUIRE(std::string(*(*h)[1]->payload) == "second");
// softDelete closes live.
repo.softDelete(v1->id);
REQUIRE(!repo.findByEntityId(v1->id));
}
} // namespace
int main() {
test_save_close_and_history_against_renamed_columns();
std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures);
return g_failures ? 1 : 0;
}

View file

@ -0,0 +1,67 @@
// Tests for oatpp-authkit/util/TokenExtract.hpp — exact-name cookie parsing
// (authkit#16 M-1) and isValidIp.
#include "oatpp-authkit/util/TokenExtract.hpp"
#include <cstdio>
#include <string>
namespace {
int g_failures = 0;
#define REQUIRE(expr) do { \
if (!(expr)) { \
std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \
++g_failures; \
} \
} while (0)
using namespace oatpp_authkit;
void test_cookie_exact_name_match() {
// Basic.
REQUIRE(cookieValue("session=abc", "session") == "abc");
REQUIRE(cookieValue("session=abc; other=1", "session") == "abc");
REQUIRE(cookieValue("other=1; session=abc", "session") == "abc");
REQUIRE(cookieValue("other=1; session=abc; more=2", "session") == "abc");
// OWS trimming around the pair and value.
REQUIRE(cookieValue("a=1; session=abc ; b=2", "session") == "abc");
// The substring trap: a prefixed/suffixed cookie name must NOT match.
REQUIRE(cookieValue("xsession=evil", "session") == "");
REQUIRE(cookieValue("notsession=evil", "session") == "");
REQUIRE(cookieValue("my_session=evil", "session") == "");
// Attacker plants a sibling cookie before the real one: exact match still
// returns the genuine session value, not the shadow.
REQUIRE(cookieValue("xsession=evil; session=real", "session") == "real");
REQUIRE(cookieValue("session=real; xsession=evil", "session") == "real");
// Missing / empty.
REQUIRE(cookieValue("", "session") == "");
REQUIRE(cookieValue("foo=bar", "session") == "");
REQUIRE(cookieValue("session=", "session") == "");
// __Host- prefixed name is matched only as an exact name.
REQUIRE(cookieValue("__Host-session=tok", "__Host-session") == "tok");
REQUIRE(cookieValue("__Host-session=tok", "session") == "");
}
void test_is_valid_ip() {
REQUIRE(isValidIp("192.168.1.1"));
REQUIRE(isValidIp("::1"));
REQUIRE(isValidIp("2001:db8::1"));
REQUIRE(!isValidIp("192.168.1.256"));
REQUIRE(!isValidIp("1.1.1.1; rm -rf"));
REQUIRE(!isValidIp(""));
REQUIRE(!isValidIp(std::string(46, 'a'))); // over length cap
}
} // namespace
int main() {
test_cookie_exact_name_match();
test_is_valid_ip();
std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures);
return g_failures ? 1 : 0;
}

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;
}

88
test/test_user_schema.cpp Normal file
View file

@ -0,0 +1,88 @@
// Tests for authkit#14 PR 4 — temporal users schema.
#include "oatpp-authkit/db/UserDb.hpp"
#include "oatpp-authkit/dto/UserDto.hpp"
#include "oatpp-authkit/repo/ConcreteUserRepository.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_users_temporal_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<
UserSchema,
TemporalRepository<UserDto>>::create("users", exec);
// 1 entity table + 3 schema-side indexes + 1 temporal composite index = 5
REQUIRE(sqls.size() == 5);
REQUIRE(contains(sqls[0], "CREATE TABLE IF NOT EXISTS users"));
REQUIRE(contains(sqls[0], "id TEXT PRIMARY KEY"));
REQUIRE(contains(sqls[0], "entity_id TEXT NOT NULL"));
REQUIRE(contains(sqls[0], "username TEXT NOT NULL"));
REQUIRE(contains(sqls[0], "password_hash TEXT"));
REQUIRE(contains(sqls[0], "role TEXT NOT NULL DEFAULT 'editor'"));
REQUIRE(contains(sqls[0], "valid_from TEXT NOT NULL DEFAULT ''"));
REQUIRE(contains(sqls[0], "valid_until TEXT NOT NULL DEFAULT '9999-12-31T23:59:59Z'"));
// is_active and created_at must NOT appear — those are dropped in
// the temporal shape (Option B).
REQUIRE(!contains(sqls[0], "is_active"));
REQUIRE(!contains(sqls[0], "created_at"));
REQUIRE(contains(sqls[1], "ix_users_entity_id"));
REQUIRE(contains(sqls[2], "ux_users_username_until"));
REQUIRE(contains(sqls[2], "(username, valid_until)"));
REQUIRE(contains(sqls[3], "ix_users_tls_cert_dn"));
REQUIRE(contains(sqls[4], "ux_users_entity_valid_until"));
REQUIRE(contains(sqls[4], "(entity_id, valid_until)"));
}
// ConcreteUserRepository contributes nothing; ensure SchemaBuilder is
// idempotent w.r.t. its presence in the parameter pack.
void test_concrete_user_repo_no_schema() {
using namespace oatpp_authkit::repo;
using namespace oatpp_authkit::db;
using namespace oatpp_authkit::dto;
std::vector<std::string> with_repo;
std::vector<std::string> without_repo;
SchemaBuilder<
UserSchema, ConcreteUserRepository, TemporalRepository<UserDto>>::create(
"users", [&](const std::string& s){ with_repo.push_back(s); });
SchemaBuilder<
UserSchema, TemporalRepository<UserDto>>::create(
"users", [&](const std::string& s){ without_repo.push_back(s); });
REQUIRE(with_repo == without_repo);
}
} // namespace
int main() {
test_users_temporal_create();
test_concrete_user_repo_no_schema();
std::printf("test_user_schema: OK\n");
return 0;
}