Compare commits

...

13 commits
v0.5.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
49 changed files with 4476 additions and 113 deletions

View file

@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.14)
project(oatpp-authkit VERSION 0.5.0 LANGUAGES CXX)
project(oatpp-authkit VERSION 0.13.0 LANGUAGES CXX)
# Header-only interface library — no compilation, just an include path and
# a CMake config package so consumers do:

View file

@ -10,13 +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/BodySizeLimitInterceptor.hpp` | Reject request bodies above a configurable limit with 413 before they hit your handlers. |
| `handler/JsonErrorHandler.hpp` | Normalises thrown exceptions into `{status, message}` JSON so controllers never leak raw HTML error pages. |
| `util/RateLimiter.hpp` | In-memory token-bucket keyed on an arbitrary string (typically the client IP from `clientIpTrusted`). |
| `util/TokenExtract.hpp` | `extractToken` (Cookie/Bearer), `isValidIp` (IPv4/IPv6 via `inet_pton`), `clientIpTrusted` (loopback-gated XFF). |
| `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) + `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. |
| `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. `save` closes the prior live version and inserts a new one; `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 `(entity_id, valid_from)`. DTOs register their three temporal columns via `OATPP_AUTHKIT_REGISTER_TEMPORAL`. |
| `repo/ScopeGuardRepository.hpp` | Generic resource-scope decorator. Takes a `bool(ActorContext, TDto)` predicate at construction; gates every method on it. Throws `ScopeDeniedException` on deny (catchers translate to 403). Knows nothing about consumer-specific concepts like "property" or "tenant" — the predicate decides. |
| `repo/IQueryable.hpp` | Optional capability for repos that resolve a typed query AST. `field<&Dto::col>().eq(...)` style DSL composes via `&&` / `||` / `!`; `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. Concrete repos opt in by deriving `IQueryable<TDto>`. |
| `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

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

@ -1,7 +1,9 @@
#ifndef OATPP_AUTHKIT_AUTH_INTERCEPTOR_HPP
#define OATPP_AUTHKIT_AUTH_INTERCEPTOR_HPP
#include <atomic>
#include <chrono>
#include <cstdint>
#include <memory>
#include <string>
#include <functional>
@ -16,11 +18,19 @@
#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. */
/** @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&)>;
/**
@ -28,10 +38,12 @@ using TokenHasher = std::function<std::string(const std::string&)>;
*
* Order of checks:
* 1. Public path pass.
* 2. Setup mode (empty users table + policy->setupModeActive()) pseudo-admin.
* 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.
* 5. CSRF defence: sessions reject state-changing requests without X-Requested-With.
* (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):
@ -46,6 +58,7 @@ private:
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;
@ -175,10 +188,25 @@ private:
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, method.c_str(), path.c_str(), reason.c_str());
status, sanitizeForLog(method).c_str(),
sanitizeForLog(path).c_str(), reason.c_str());
}
bool isMutation(const std::string& method) {
@ -193,24 +221,38 @@ public:
std::shared_ptr<IAuthPolicy> policy,
std::shared_ptr<IRuntimeConfig> runtime,
TokenHasher hashToken,
std::shared_ptr<oatpp::data::mapping::ObjectMapper> mapper = nullptr)
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_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 sweep — at most once per hour.
// 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 Clock::time_point lastCleanup = Clock::now();
auto now = Clock::now();
if (std::chrono::duration_cast<std::chrono::hours>(now - lastCleanup).count() >= 1) {
lastCleanup = now;
m_backend->deleteExpiredSessions();
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 (...) {}
}
}
}
@ -223,16 +265,30 @@ public:
if (m_policy->isPublicPath(path)) return nullptr;
// Setup mode: empty users + policy opts in → pseudo-admin.
if (m_policy->setupModeActive() && !m_backend->hasActiveUsers()) {
// 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 `isLoopback()`; consumers can override
// it to gate more strictly (e.g. require an env-var or a TLS-only port).
// `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))) {
@ -260,6 +316,16 @@ public:
} 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);
}
@ -271,6 +337,28 @@ public:
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);

View file

@ -13,7 +13,12 @@ namespace oatpp_authkit {
* into this struct inside their IAuthBackend implementation.
*/
struct AuthPrincipal {
int id{0}; ///< Stable numeric id from the user store.
/// 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.
};

View file

@ -23,7 +23,14 @@ class IAuthBackend {
public:
virtual ~IAuthBackend() = default;
/** @brief Look up an active session by its hashed token. */
/** @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`. */
@ -38,7 +45,16 @@ public:
return std::nullopt;
}
/** @brief True iff at least one active user exists. Used for setup-mode gate. */
/** @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. */

View file

@ -34,8 +34,14 @@ public:
/** @brief Whether incoming `X-SSL-Client-DN` headers should be trusted (#5).
*
* Default: `isLoopback()` preserves the legacy behaviour for consumers
* that haven't overridden anything. Override to gate more strictly, e.g.:
* 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;
@ -45,7 +51,7 @@ public:
* `X-SSL-Client-DN` header and falls through to token / session auth.
*/
virtual bool certAuthTrusted() {
return isLoopback();
return false;
}
};

View file

@ -31,7 +31,22 @@ inline AuthPrincipal requireUser(const std::shared_ptr<IncomingRequest>& request
OATPP_ASSERT_HTTP(id && role, Status::CODE_401, "Authentication required");
AuthPrincipal p;
p.id = std::stoi(std::string(*id));
// 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;

View file

@ -81,18 +81,44 @@ CREATE INDEX IF NOT EXISTS idx_audit_log_table_entity ON audit_log(table_name
private:
std::shared_ptr<AuditLogDb> m_db;
/** @brief Fields to skip when computing UPDATE diffs — internal/metadata. */
/** @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"
"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());
for (char c : s) {
if (c == '\\') out += "\\\\";
else if (c == '"') out += "\\\"";
else out += c;
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;
}

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

@ -34,6 +34,15 @@ struct SmtpConfig {
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 =
@ -76,6 +85,13 @@ inline std::string send(
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";
@ -91,9 +107,14 @@ inline std::string send(
curl_easy_setopt(curl, CURLOPT_USERNAME, cfg.username.c_str());
curl_easy_setopt(curl, CURLOPT_PASSWORD, cfg.password.c_str());
}
curl_easy_setopt(curl, CURLOPT_USE_SSL, CURLUSESSL_TRY);
// Allow self-signed certs on localhost relay — a common dev / pipe-transport setup.
if (cfg.host == "localhost" || cfg.host == "127.0.0.1") {
// 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);
}

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

@ -30,6 +30,14 @@ namespace oatpp_authkit::repo {
// 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();
@ -69,6 +77,22 @@ 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 {
@ -122,6 +146,20 @@ public:
}
};
/** @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_;
@ -218,10 +256,28 @@ public:
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)}; }

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

@ -3,6 +3,8 @@
#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"
@ -39,26 +41,52 @@ public:
* 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 evaluated on the incoming dto; deny throw.
* - `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()>;
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)
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 {
@ -81,8 +109,25 @@ public:
}
void save(const oatpp::Object<TDto>& dto) override {
if (!m_isAllowed(m_currentActor(), dto)) {
throw ScopeDeniedException("scope guard denied save");
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);
}
@ -100,6 +145,64 @@ 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

View file

@ -7,7 +7,7 @@ namespace oatpp_authkit::repo {
/**
* @brief Trait that tells `TemporalRepository<T>` where `T` keeps its
* identity, valid_from and valid_until columns.
* 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
@ -15,9 +15,19 @@ namespace oatpp_authkit::repo {
* `OATPP_AUTHKIT_REGISTER_TEMPORAL` once per temporal DTO.
*
* Each accessor returns `oatpp::String&` so the repository can both read
* and rewrite the value (closing a prior version sets `valid_until` on a
* loaded row). Field names on the DTO are arbitrary the trait is the
* canonical name, the DTO column is whatever the consumer picked.
* 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
@ -29,18 +39,22 @@ struct TemporalFieldTraits; // intentionally undefined
* scope (typically right after the DTO definition):
*
* OATPP_AUTHKIT_REGISTER_TEMPORAL(PersonDto,
* entity_id, valid_from, valid_until)
* id, entity_id, valid_from, valid_until)
*
* The three trailing identifiers are the actual DTO_FIELD member names
* 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, FromMember, UntilMember) \
#define OATPP_AUTHKIT_REGISTER_TEMPORAL(Dto, IdMember, EntityIdMember, FromMember, UntilMember) \
namespace oatpp_authkit::repo { \
template<> struct TemporalFieldTraits<Dto> { \
static ::oatpp::String& entityId (const ::oatpp::Object<Dto>& d) { return d->IdMember; } \
static ::oatpp::String& validFrom (const ::oatpp::Object<Dto>& d) { return d->FromMember; } \
static ::oatpp::String& validUntil (const ::oatpp::Object<Dto>& d) { return d->UntilMember; } \
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; } \
}; \
}

View file

@ -5,6 +5,7 @@
#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"
@ -13,6 +14,7 @@
#include <cstdio>
#include <functional>
#include <memory>
#include <mutex>
#include <random>
#include <string>
#include <type_traits>
@ -35,9 +37,9 @@ namespace oatpp_authkit::repo {
*
* The wrapped inner `Repository<TDto>` is expected to:
*
* - Treat `save(dto)` as **upsert keyed by (entity_id, valid_from)**. New
* `valid_from` insert a new row. Existing `valid_from` update the row
* (this is how `save(closedPrior)` closes a prior version).
* - 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.
@ -47,16 +49,33 @@ namespace oatpp_authkit::repo {
*
* @section semantics Decorator semantics
*
* - `save(dto)`: if `dto->entity_id` is null, allocate one. Look up the
* currently live row for that entity id; if present, copy it, set its
* `valid_until = now`, and `save` it (closes the old version). Then set
* the new dto's `valid_from = now`, `valid_until = SENTINEL`, and `save` it.
* 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.
* - `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
@ -71,20 +90,51 @@ public:
*/
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 = {})
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>;
@ -137,31 +187,52 @@ public:
}
/**
* Close the previous live version (if any) and insert a new live row.
* Mutates `dto` in place to fill in `entity_id` (if null), `valid_from`,
* and `valid_until`.
* 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);
// Close the existing live version (if any).
auto live = findByEntityId(F::entityId(dto));
if (live) {
F::validUntil(live) = oatpp::String(nowIso);
m_inner->save(live);
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;
}
// Insert the new live version.
// 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_inner->save(dto);
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()));
@ -201,12 +272,38 @@ private:
};
}
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 [] {
static thread_local std::mt19937_64 rng{std::random_device{}()};
// 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), "%016llx%016llx",
(unsigned long long)rng(), (unsigned long long)rng());
std::snprintf(buf, sizeof(buf), "%08x%08x%08x%08x",
(unsigned)rd(), (unsigned)rd(), (unsigned)rd(), (unsigned)rd());
return oatpp::String(buf);
};
}
@ -226,6 +323,8 @@ private:
std::shared_ptr<Repository<TDto>> m_inner;
Clock m_clock;
IdGen m_idgen;
TxRunner m_runTx;
std::mutex m_writeMutex;
};
} // namespace oatpp_authkit::repo

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,7 +2,9 @@
#define UTIL_RATE_LIMITER_HPP
#include <chrono>
#include <cmath>
#include <mutex>
#include <stdexcept>
#include <string>
#include <unordered_map>
@ -25,11 +27,22 @@ namespace oatpp_authkit {
class RateLimiter {
public:
/**
* @param capacity Maximum burst size (tokens).
* @param refillRate Tokens added per second.
* @param capacity Maximum burst size (tokens). Must be finite and >= 1.
* @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)
: 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. */
bool allow(const std::string& key) {

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;
/**
* @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.
*
@ -19,13 +57,8 @@ using IncomingRequest = oatpp::web::protocol::http::incoming::Request;
inline std::string extractToken(const std::shared_ptr<IncomingRequest>& request) {
auto cookie = request->getHeader("Cookie");
if (cookie && !cookie->empty()) {
const std::string& c = *cookie;
auto pos = c.find("session=");
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);
}
std::string tok = cookieValue(*cookie, "session");
if (!tok.empty()) return tok;
}
auto auth = request->getHeader("Authorization");
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;
* 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(
const std::shared_ptr<IncomingRequest>& req,

View file

@ -28,7 +28,7 @@ struct SocketInfo {
std::string userId;
std::string username;
std::string role;
std::set<std::string> propertyIds; ///< Empty = all (admin or no restrictions).
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.
};
/**
@ -45,6 +45,15 @@ struct SocketInfo {
* 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>"}
@ -66,10 +75,12 @@ struct SocketInfo {
* @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;
@ -151,12 +162,16 @@ private:
/**
* @brief Check whether a socket has access to a given property.
*
* Admins and users with no explicit permission rows (empty propertyIds)
* have access to all properties.
* 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;
if (info.propertyIds.empty()) return true; // no restrictions
return info.propertyIds.find(propertyId) != info.propertyIds.end();
}
@ -176,7 +191,15 @@ public:
{
socket.setListener(std::make_shared<Listener>());
if (!t_pendingAuth.has_value()) {
// 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 (...) {}
@ -189,14 +212,12 @@ public:
// #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);
t_pendingAuth.reset();
try { socket.sendClose(1013, "Server Busy"); } catch (...) {}
return;
}
s_sockets[&socket] = std::move(*t_pendingAuth);
s_sockets[&socket] = std::move(*pending);
s_lastSeen[&socket] = std::chrono::steady_clock::now();
}
t_pendingAuth.reset();
OATPP_LOGD("Hub", "client connected: %s (total=%zu)",
s_sockets[&socket].username.c_str(), s_sockets.size());

View file

@ -10,6 +10,26 @@ 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)
@ -37,3 +57,47 @@ 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,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,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;
}

View file

@ -148,6 +148,29 @@ void test_like_pattern_is_bound_not_interpolated() {
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())
@ -208,6 +231,7 @@ int main() {
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();

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

@ -31,6 +31,7 @@ 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);
@ -41,7 +42,7 @@ class MockTemporalDto : public oatpp::DTO {
#include OATPP_CODEGEN_END(DTO)
} // namespace
OATPP_AUTHKIT_REGISTER_TEMPORAL(MockTemporalDto, entity_id, valid_from, valid_until)
OATPP_AUTHKIT_REGISTER_TEMPORAL(MockTemporalDto, id, entity_id, valid_from, valid_until)
namespace {
int g_failures = 0;
@ -53,14 +54,15 @@ int g_failures = 0;
} \
} while (0)
// In-memory adapter: rows keyed by (entity_id, valid_from). save() upserts.
// 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::pair<std::string, std::string>, oatpp::Object<MockTemporalDto>> rows;
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.first.first == std::string(*id)) return kv.second;
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 {
@ -69,11 +71,11 @@ public:
return v;
}
void save(const oatpp::Object<MockTemporalDto>& dto) override {
rows[{*dto->entity_id, *dto->valid_from}] = dto;
rows[std::string(*dto->id)] = dto;
}
void softDelete(const oatpp::String& id) override {
for (auto it = rows.begin(); it != rows.end(); ) {
if (it->first.first == std::string(*id)) it = rows.erase(it); else ++it;
if (it->second->entity_id && std::string(*it->second->entity_id) == std::string(*id)) it = rows.erase(it); else ++it;
}
}
};
@ -86,25 +88,39 @@ struct StepClock {
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)(); },
[]{ return oatpp::String("alice"); });
[ids]{ return (*ids)(); });
// First save — entity_id auto-allocated, valid_from = now1, valid_until = SENTINEL.
// 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 — old version's valid_until is closed; new live row inserted.
// 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");
@ -113,11 +129,20 @@ void test_save_closes_prior_version_and_inserts_new() {
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;
== TemporalRepository<MockTemporalDto>::SENTINEL) {
++liveCount;
livePkAfterSecond = std::string(*row->id);
} else {
historicalPk = std::string(*row->id);
}
}
REQUIRE(liveCount == 1); // Only one row is live.
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() {
@ -229,6 +254,7 @@ void test_scope_guard_denies_when_predicate_false() {
// 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");
@ -249,7 +275,8 @@ void test_scope_guard_denies_when_predicate_false() {
for (auto& as : a.allowedScopes) if (as == s) return true;
return false;
},
[actor]{ return actor; });
[actor]{ return actor; },
[](const oatpp::Object<MockTemporalDto>& d) { return d->entity_id; });
// list filters to allowed rows only.
auto allowed = guarded.list();
@ -284,6 +311,113 @@ void test_scope_guard_denies_when_predicate_false() {
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() {
@ -293,6 +427,8 @@ int main() {
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,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,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

@ -29,7 +29,8 @@ namespace {
// TemporalRepository<T> couldn't reach these fields.
class OddNamesDto : public oatpp::DTO {
DTO_INIT(OddNamesDto, DTO)
DTO_FIELD(String, id);
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);
@ -39,7 +40,7 @@ class OddNamesDto : public oatpp::DTO {
} // namespace
OATPP_AUTHKIT_REGISTER_TEMPORAL(OddNamesDto, id, effective_from, effective_until)
OATPP_AUTHKIT_REGISTER_TEMPORAL(OddNamesDto, row_pk, id, effective_from, effective_until)
namespace {
@ -55,10 +56,10 @@ int g_failures = 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::pair<std::string, std::string>, oatpp::Object<OddNamesDto>> rows;
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.first.first == std::string(*id)) return kv.second;
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 {
@ -67,11 +68,11 @@ public:
return v;
}
void save(const oatpp::Object<OddNamesDto>& dto) override {
rows[{*dto->id, *dto->effective_from}] = dto;
rows[std::string(*dto->row_pk)] = dto;
}
void softDelete(const oatpp::String& id) override {
for (auto it = rows.begin(); it != rows.end(); ) {
if (it->first.first == std::string(*id)) it = rows.erase(it); else ++it;
if (it->second->id && std::string(*it->second->id) == std::string(*id)) it = rows.erase(it); else ++it;
}
}
};

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