Compare commits
No commits in common. "main" and "v0.1.0" have entirely different histories.
65 changed files with 81 additions and 8218 deletions
|
|
@ -1,5 +1,5 @@
|
|||
cmake_minimum_required(VERSION 3.14)
|
||||
project(oatpp-authkit VERSION 0.13.0 LANGUAGES CXX)
|
||||
project(oatpp-authkit VERSION 0.1.0 LANGUAGES CXX)
|
||||
|
||||
# Header-only interface library — no compilation, just an include path and
|
||||
# a CMake config package so consumers do:
|
||||
|
|
@ -44,12 +44,3 @@ install(FILES
|
|||
"${CMAKE_CURRENT_BINARY_DIR}/oatpp-authkit-config.cmake"
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/oatpp-authkit-config-version.cmake"
|
||||
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/oatpp-authkit)
|
||||
|
||||
# ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
# Off by default so consumers pulling us in via FetchContent don't pay the
|
||||
# cost. Enable with -DOATPP_AUTHKIT_BUILD_TESTS=ON.
|
||||
option(OATPP_AUTHKIT_BUILD_TESTS "Build oatpp-authkit unit tests" OFF)
|
||||
if(OATPP_AUTHKIT_BUILD_TESTS)
|
||||
enable_testing()
|
||||
add_subdirectory(test)
|
||||
endif()
|
||||
|
|
|
|||
91
README.md
91
README.md
|
|
@ -10,59 +10,9 @@ 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`). 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). |
|
||||
| `util/RateLimiter.hpp` | In-memory token-bucket keyed on an arbitrary string (typically the client IP from `clientIpTrusted`). |
|
||||
| `util/TokenExtract.hpp` | `extractToken` (Cookie/Bearer), `isValidIp` (IPv4/IPv6 via `inet_pton`), `clientIpTrusted` (loopback-gated XFF). |
|
||||
| `startup/RequireEncryptionKey.hpp` | `requireEncryptionKey(envVarName, encryptionEnabled, allowPlaintext)` — refuse startup without a symmetric key unless a dev flag overrides. |
|
||||
| `repo/Repository.hpp` + `IHistoryRepository.hpp` + `TemporalFieldTraits.hpp` + `TemporalAt.hpp` + `ActorContext.hpp` | Pure-abstract `Repository<TDto>` interface set distilled from fewo-webapp's per-entity `*Db` clients. Mixed UUID allocation on `save`, separate `IHistoryRepository<T>` for temporal versions, `TemporalFieldTraits<T>` to map canonical (entity_id, valid_from, valid_until) onto whatever a DTO actually calls them, `ActorContext` placeholder for the scope-guard decorator. |
|
||||
| `repo/TemporalRepository.hpp` | Decorator that wraps any `Repository<TDto>` and turns it into a temporally-versioned one. **Stable-live + historical-copy semantics (authkit#13):** the live row's `id` PK is preserved across updates; each prior version is captured as a fresh row with a new `id`. `softDelete` closes the live row in place; with `ON UPDATE CASCADE` on consumer-side composite child FKs, child rows follow automatically. `findByEntityIdAt(id, at)` returns the version live at a point in time; implements `IHistoryRepository<T>`. Inner adapter is expected to expose all rows (live + historical) and treat `save` as upsert keyed by **`id`** (per-row PK). DTOs register their four temporal columns via `OATPP_AUTHKIT_REGISTER_TEMPORAL(Dto, id, entity_id, valid_from, valid_until)`. |
|
||||
| `repo/ScopeGuardRepository.hpp` | Generic resource-scope decorator. Takes a `bool(ActorContext, TDto)` predicate, 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
|
||||
|
||||
|
|
@ -84,43 +34,6 @@ find_package(oatpp-authkit 0.1 REQUIRED)
|
|||
target_link_libraries(app PRIVATE oatpp::authkit)
|
||||
```
|
||||
|
||||
## Browser-friendly 401/403
|
||||
|
||||
By default `AuthInterceptor` returns `application/json` for every rejection,
|
||||
which is correct for `/api/*` callers but breaks browser navigation: a user
|
||||
following a stale link or an expired password-reset URL sees a raw
|
||||
`{"status":"Unauthorized"}` instead of a real page.
|
||||
|
||||
Override `IAuthPolicy::unauthenticatedRedirect(path)` to redirect browser
|
||||
navigations to a login or landing page while keeping JSON responses for
|
||||
`fetch`/`axios` callers (detected via path prefix `/api/`,
|
||||
`X-Requested-With: XMLHttpRequest`, or an `Accept` header that prefers
|
||||
`application/json`):
|
||||
|
||||
```cpp
|
||||
class AppAuthPolicy : public oatpp_authkit::IAuthPolicy {
|
||||
public:
|
||||
std::optional<std::string>
|
||||
unauthenticatedRedirect(const std::string& path) override {
|
||||
return "/?next=" + oatpp_authkit::AuthInterceptor::urlEncode(path);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
Returning `std::nullopt` (the default) preserves the legacy JSON behaviour
|
||||
for all responses.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
cmake -B build -DOATPP_AUTHKIT_BUILD_TESTS=ON
|
||||
cmake --build build
|
||||
ctest --test-dir build --output-on-failure
|
||||
```
|
||||
|
||||
Tests are off by default so consumers pulling the library in via
|
||||
`FetchContent` don't pay the cost.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- **v0.2** — `AuthInterceptor` + `requireAdmin` ported onto three seams
|
||||
|
|
|
|||
|
|
@ -1,102 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,376 +0,0 @@
|
|||
#ifndef OATPP_AUTHKIT_AUTH_INTERCEPTOR_HPP
|
||||
#define OATPP_AUTHKIT_AUTH_INTERCEPTOR_HPP
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <functional>
|
||||
|
||||
#include "oatpp/web/server/interceptor/RequestInterceptor.hpp"
|
||||
#include "oatpp/web/protocol/http/outgoing/Response.hpp"
|
||||
#include "oatpp/web/protocol/http/outgoing/ResponseFactory.hpp"
|
||||
#include "oatpp/web/protocol/http/Http.hpp"
|
||||
#include "oatpp/parser/json/mapping/ObjectMapper.hpp"
|
||||
|
||||
#include "IAuthBackend.hpp"
|
||||
#include "IAuthPolicy.hpp"
|
||||
#include "IRuntimeConfig.hpp"
|
||||
#include "../util/TokenExtract.hpp"
|
||||
#include "../util/OriginCheck.hpp"
|
||||
#include "../util/RateLimiter.hpp"
|
||||
#include "../dto/InternalDto.hpp"
|
||||
|
||||
namespace oatpp_authkit {
|
||||
|
||||
/** @brief Caller-supplied hash function — SHA-256 on the raw token typically.
|
||||
*
|
||||
* authkit#16 L-7: MUST be a fixed-length cryptographic hash (≥256-bit, e.g.
|
||||
* SHA-256) over a high-entropy token. The store looks the session/API key up
|
||||
* by this hash, so a weak/short/truncating hash weakens matching. Consumers
|
||||
* that compare a secret in memory (rather than via an indexed lookup) should
|
||||
* use `oatpp_authkit::constantTimeEquals` (`util/ConstantTime.hpp`). */
|
||||
using TokenHasher = std::function<std::string(const std::string&)>;
|
||||
|
||||
/**
|
||||
* @brief Generic request interceptor built on IAuthBackend + IAuthPolicy + IRuntimeConfig.
|
||||
*
|
||||
* Order of checks:
|
||||
* 1. Public path → pass.
|
||||
* 2. Setup mode (empty users table + policy->setupModeActive() + loopback bind) → pseudo-admin.
|
||||
* 3. X-SSL-Client-DN header (only trusted when `IRuntimeConfig::certAuthTrusted()`) → cert auth.
|
||||
* 4. Session cookie / Bearer token → backend->resolveBySessionHash / resolveByApiKeyHash.
|
||||
* (Invalid tokens are optionally per-IP rate-limited → 429 when a RateLimiter is supplied.)
|
||||
* 5. CSRF defence (session cookie + mutation): require X-Requested-With AND,
|
||||
* when present, an Origin/Referer whose host matches the request Host.
|
||||
* 6. Readonly roles cannot mutate.
|
||||
*
|
||||
* Bundle data written on success (consumed by requireAdmin / requireUser):
|
||||
* auth_user_id (oatpp::String, decimal int)
|
||||
* auth_user_role (oatpp::String)
|
||||
* auth_username (oatpp::String)
|
||||
*/
|
||||
class AuthInterceptor : public oatpp::web::server::interceptor::RequestInterceptor {
|
||||
private:
|
||||
std::shared_ptr<IAuthBackend> m_backend;
|
||||
std::shared_ptr<IAuthPolicy> m_policy;
|
||||
std::shared_ptr<IRuntimeConfig> m_runtime;
|
||||
TokenHasher m_hashToken;
|
||||
std::shared_ptr<oatpp::data::mapping::ObjectMapper> m_mapper;
|
||||
std::shared_ptr<RateLimiter> m_authLimiter; ///< Optional (authkit#16 M-11): throttles invalid-token attempts per client IP.
|
||||
|
||||
using Status = oatpp::web::protocol::http::Status;
|
||||
using ResponseFactory = oatpp::web::protocol::http::outgoing::ResponseFactory;
|
||||
|
||||
std::shared_ptr<OutgoingResponse> makeJsonError(Status status, const std::string& body) {
|
||||
auto r = ResponseFactory::createResponse(status, body.c_str());
|
||||
r->putHeader("Content-Type", "application/json");
|
||||
return r;
|
||||
}
|
||||
|
||||
/** @brief Build a JsonErrorDto-shaped body via ObjectMapper (#6) — escapes
|
||||
* any user-supplied `msg` so a stray `"`/`\\`/control character doesn't
|
||||
* break the JSON envelope. */
|
||||
std::shared_ptr<OutgoingResponse> makeJsonError(Status status,
|
||||
const std::string& statusName,
|
||||
const std::string& msg) {
|
||||
auto dto = dto::JsonErrorDto::createShared();
|
||||
dto->status = oatpp::String(statusName);
|
||||
dto->code = status.code;
|
||||
if (!msg.empty()) dto->message = oatpp::String(msg);
|
||||
oatpp::String json = m_mapper->writeToString(dto);
|
||||
auto r = ResponseFactory::createResponse(status, json);
|
||||
r->putHeader("Content-Type", "application/json");
|
||||
return r;
|
||||
}
|
||||
|
||||
std::shared_ptr<OutgoingResponse> makeHtmlError(Status status, const std::string& title) {
|
||||
std::string body = "<!doctype html><meta charset=\"utf-8\"><title>"
|
||||
+ title + "</title><h1>" + title + "</h1>";
|
||||
auto r = ResponseFactory::createResponse(status, body.c_str());
|
||||
r->putHeader("Content-Type", "text/html; charset=utf-8");
|
||||
return r;
|
||||
}
|
||||
|
||||
std::shared_ptr<OutgoingResponse> makeRedirect(const std::string& location) {
|
||||
auto r = ResponseFactory::createResponse(Status::CODE_302, "");
|
||||
r->putHeader("Location", location.c_str());
|
||||
r->putHeader("Cache-Control", "no-store");
|
||||
return r;
|
||||
}
|
||||
|
||||
public:
|
||||
/**
|
||||
* @brief Heuristic: does this caller expect a JSON error body?
|
||||
*
|
||||
* True when any of:
|
||||
* - path begins with `/api/` (API surface — always JSON)
|
||||
* - `X-Requested-With: XMLHttpRequest` (jQuery/axios/explicit AJAX)
|
||||
* - `Accept` mentions `application/json` and does NOT prefer `text/html`
|
||||
*
|
||||
* Otherwise treated as a browser navigation that should get HTML or a
|
||||
* redirect. Exposed as a static so the negotiation rule is unit-testable
|
||||
* without spinning up a request.
|
||||
*/
|
||||
static bool wantsJson(const std::string& path,
|
||||
const std::string& xRequestedWith,
|
||||
const std::string& accept)
|
||||
{
|
||||
if (path.size() >= 5 && path.compare(0, 5, "/api/") == 0) return true;
|
||||
if (!xRequestedWith.empty()) return true;
|
||||
bool hasJson = accept.find("application/json") != std::string::npos;
|
||||
bool hasHtml = accept.find("text/html") != std::string::npos;
|
||||
if (hasJson && !hasHtml) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Percent-encode the unreserved subset for use in a `next=` param.
|
||||
* Static + side-effect-free so consumers and tests can reuse it.
|
||||
*/
|
||||
static std::string urlEncode(const std::string& s) {
|
||||
static const char* hex = "0123456789ABCDEF";
|
||||
std::string out;
|
||||
out.reserve(s.size());
|
||||
for (unsigned char c : s) {
|
||||
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
|
||||
(c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~') {
|
||||
out.push_back(static_cast<char>(c));
|
||||
} else {
|
||||
out.push_back('%');
|
||||
out.push_back(hex[c >> 4]);
|
||||
out.push_back(hex[c & 0xF]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private:
|
||||
bool requestWantsJson(const std::shared_ptr<IncomingRequest>& req,
|
||||
const std::string& path)
|
||||
{
|
||||
auto xrw = req->getHeader("X-Requested-With");
|
||||
auto accept = req->getHeader("Accept");
|
||||
return wantsJson(path,
|
||||
xrw ? *xrw : std::string{},
|
||||
accept ? *accept : std::string{});
|
||||
}
|
||||
|
||||
std::shared_ptr<OutgoingResponse> makeUnauthorized(
|
||||
const std::shared_ptr<IncomingRequest>& req, const std::string& path)
|
||||
{
|
||||
if (requestWantsJson(req, path))
|
||||
return makeJsonError(Status::CODE_401, "Unauthorized", "");
|
||||
if (auto loc = m_policy->unauthenticatedRedirect(path))
|
||||
return makeRedirect(*loc);
|
||||
return makeHtmlError(Status::CODE_401, "Unauthorized");
|
||||
}
|
||||
|
||||
std::shared_ptr<OutgoingResponse> makeForbidden(
|
||||
const std::shared_ptr<IncomingRequest>& req, const std::string& path,
|
||||
const std::string& msg = "")
|
||||
{
|
||||
if (requestWantsJson(req, path)) {
|
||||
// #6: route through ObjectMapper so any caller-supplied `msg`
|
||||
// containing `"`/`\\`/control chars is escaped instead of breaking
|
||||
// the response envelope.
|
||||
return makeJsonError(Status::CODE_403, "Forbidden", msg);
|
||||
}
|
||||
if (auto loc = m_policy->unauthenticatedRedirect(path))
|
||||
return makeRedirect(*loc);
|
||||
return makeHtmlError(Status::CODE_403, "Forbidden");
|
||||
}
|
||||
|
||||
void writeBundle(const std::shared_ptr<IncomingRequest>& req, const AuthPrincipal& p) {
|
||||
req->putBundleData("auth_user_id", oatpp::String(std::to_string(p.id).c_str()));
|
||||
req->putBundleData("auth_user_role", oatpp::String(p.role.c_str()));
|
||||
req->putBundleData("auth_username", oatpp::String(p.username.c_str()));
|
||||
}
|
||||
|
||||
/** @brief Neutralise control characters before logging (authkit#16 M-12).
|
||||
* The request path/method are attacker-controlled; a raw CR/LF in the
|
||||
* request target would otherwise forge log lines (CWE-117). */
|
||||
static std::string sanitizeForLog(const std::string& s) {
|
||||
std::string out;
|
||||
const std::size_t cap = 256;
|
||||
out.reserve(s.size() < cap ? s.size() : cap);
|
||||
for (unsigned char c : s) {
|
||||
if (out.size() >= cap) break;
|
||||
out.push_back((c < 0x20 || c == 0x7f) ? '?' : static_cast<char>(c));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static void logEvent(int status, const std::string& method,
|
||||
const std::string& path, const std::string& reason) {
|
||||
OATPP_LOGW("authkit", "[%d] %s %s — %s",
|
||||
status, sanitizeForLog(method).c_str(),
|
||||
sanitizeForLog(path).c_str(), reason.c_str());
|
||||
}
|
||||
|
||||
bool isMutation(const std::string& method) {
|
||||
return method != "GET" && method != "HEAD" && method != "OPTIONS";
|
||||
}
|
||||
bool isReadonly(const std::string& role) {
|
||||
return m_policy->readonlyRoles().count(role) > 0;
|
||||
}
|
||||
|
||||
public:
|
||||
AuthInterceptor(std::shared_ptr<IAuthBackend> backend,
|
||||
std::shared_ptr<IAuthPolicy> policy,
|
||||
std::shared_ptr<IRuntimeConfig> runtime,
|
||||
TokenHasher hashToken,
|
||||
std::shared_ptr<oatpp::data::mapping::ObjectMapper> mapper = nullptr,
|
||||
std::shared_ptr<RateLimiter> authRateLimiter = nullptr)
|
||||
: m_backend(std::move(backend))
|
||||
, m_policy(std::move(policy))
|
||||
, m_runtime(std::move(runtime))
|
||||
, m_hashToken(std::move(hashToken))
|
||||
, m_mapper(mapper ? mapper : oatpp::parser::json::mapping::ObjectMapper::createShared())
|
||||
, m_authLimiter(std::move(authRateLimiter)) {}
|
||||
|
||||
std::shared_ptr<OutgoingResponse> intercept(
|
||||
const std::shared_ptr<IncomingRequest>& request) override
|
||||
{
|
||||
// Periodic expired-session GC — at most once per hour, process-wide.
|
||||
// authkit#16 L-6: this is best-effort cleanup, NOT the expiry gate —
|
||||
// resolveBySessionHash() must itself reject expired sessions (see
|
||||
// IAuthBackend). The timer is a lock-free atomic so concurrent requests
|
||||
// don't race the read-modify-write, and the sweep is exception-isolated
|
||||
// so a transient DB error during GC can't 500 an otherwise-valid request.
|
||||
{
|
||||
using Clock = std::chrono::steady_clock;
|
||||
static std::atomic<std::int64_t> lastCleanupMs{-1};
|
||||
const std::int64_t nowMs = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
Clock::now().time_since_epoch()).count();
|
||||
std::int64_t prev = lastCleanupMs.load(std::memory_order_relaxed);
|
||||
if (prev < 0) {
|
||||
// First request: arm the timer, don't sweep yet.
|
||||
lastCleanupMs.compare_exchange_strong(prev, nowMs);
|
||||
} else if (nowMs - prev >= 3600000) {
|
||||
// Only the thread that wins the CAS performs the sweep.
|
||||
if (lastCleanupMs.compare_exchange_strong(prev, nowMs)) {
|
||||
try { m_backend->deleteExpiredSessions(); } catch (...) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::string path = request->getStartingLine().path.std_str();
|
||||
const std::string method = request->getStartingLine().method.std_str();
|
||||
// Strip query string — request-target includes it, but policy checks
|
||||
// (and access logs) want just the path.
|
||||
auto qpos = path.find('?');
|
||||
if (qpos != std::string::npos) path.resize(qpos);
|
||||
|
||||
if (m_policy->isPublicPath(path)) return nullptr;
|
||||
|
||||
// Setup mode: empty users + policy opts in + loopback bind → pseudo-admin.
|
||||
// authkit#16 M-2: gate on isLoopback() so a stray SETUP_MODE sentinel can
|
||||
// never expose anonymous admin on a public bind, and log the grant (it
|
||||
// was previously silent). hasActiveUsers() must fail closed (see
|
||||
// IAuthBackend) — a swallowed DB error returning false would otherwise
|
||||
// open the entire API.
|
||||
if (m_policy->setupModeActive() && m_runtime->isLoopback()
|
||||
&& !m_backend->hasActiveUsers()) {
|
||||
logEvent(200, method, path, "setup-mode pseudo-admin granted (no users yet)");
|
||||
AuthPrincipal p{0, "setup", "admin"};
|
||||
writeBundle(request, p);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// TLS cert DN — only trusted when the runtime hook says so (#5).
|
||||
// `certAuthTrusted()` defaults to `false` (fail closed); consumers must
|
||||
// opt in explicitly and only behind a proxy that strips the inbound
|
||||
// `X-SSL-Client-DN` header and re-sets it from a verified client cert.
|
||||
//
|
||||
// authkit#16 M-10: the cert path is deliberately NOT CSRF-gated. CSRF is
|
||||
// a browser-cookie problem; cert auth is for non-browser / mTLS clients
|
||||
// that don't auto-attach an ambient credential, so `X-Requested-With` /
|
||||
// Origin checks don't apply. Do not expose cert auth to cookie-bearing
|
||||
// browser sessions.
|
||||
auto certDnH = request->getHeader("X-SSL-Client-DN");
|
||||
if (m_runtime->certAuthTrusted() && certDnH && !certDnH->empty()) {
|
||||
if (auto p = m_backend->resolveByCertDn(std::string(*certDnH))) {
|
||||
writeBundle(request, *p);
|
||||
if (isReadonly(p->role) && isMutation(method)) {
|
||||
logEvent(403, method, path, "readonly cert user mutation");
|
||||
return makeForbidden(request, path);
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
// Session / API key token.
|
||||
std::string token = extractToken(request);
|
||||
if (token.empty()) {
|
||||
logEvent(401, method, path, "no token");
|
||||
return makeUnauthorized(request, path);
|
||||
}
|
||||
std::string hash = m_hashToken(token);
|
||||
|
||||
std::optional<AuthPrincipal> p;
|
||||
bool viaSession = false;
|
||||
if ((p = m_backend->resolveBySessionHash(hash))) {
|
||||
viaSession = true;
|
||||
} else if ((p = m_backend->resolveByApiKeyHash(hash))) {
|
||||
viaSession = false;
|
||||
} else {
|
||||
// authkit#16 M-11: when an optional limiter is wired in, throttle
|
||||
// repeated invalid-token submissions per client IP (token guessing /
|
||||
// credential stuffing) and answer 429 before the 401.
|
||||
if (m_authLimiter) {
|
||||
const std::string ip = clientIpTrusted(request, m_runtime->bindAddress());
|
||||
if (!m_authLimiter->allow("authfail:" + ip)) {
|
||||
logEvent(429, method, path, "auth rate limit (invalid token)");
|
||||
return makeJsonError(Status::CODE_429, "Too Many Requests", "");
|
||||
}
|
||||
}
|
||||
logEvent(401, method, path, "invalid token");
|
||||
return makeUnauthorized(request, path);
|
||||
}
|
||||
|
||||
// CSRF defence-in-depth: session cookie + mutation requires X-Requested-With.
|
||||
if (viaSession && isMutation(method)) {
|
||||
auto xrw = request->getHeader("X-Requested-With");
|
||||
if (!xrw || xrw->empty()) {
|
||||
logEvent(403, method, path, "missing X-Requested-With");
|
||||
return makeForbidden(request, path, "Missing X-Requested-With header");
|
||||
}
|
||||
// authkit#16 M-10: second CSRF layer — when an Origin (or, failing
|
||||
// that, Referer) header is present on a cookie-auth mutation, its
|
||||
// host must match the request Host. Catches cross-site forgeries
|
||||
// even if a permissive CORS policy ever lets X-Requested-With
|
||||
// through. Compared by hostname (port/scheme ignored) to stay
|
||||
// correct behind a TLS-terminating proxy; when neither header is
|
||||
// present we fall back to the X-Requested-With guarantee above.
|
||||
auto host = request->getHeader("Host");
|
||||
auto origin = request->getHeader("Origin");
|
||||
auto referer = request->getHeader("Referer");
|
||||
const std::string hostStr = host ? std::string(*host) : std::string();
|
||||
if (origin && !origin->empty()) {
|
||||
if (!sameOrigin(std::string(*origin), hostStr)) {
|
||||
logEvent(403, method, path, "Origin/Host mismatch");
|
||||
return makeForbidden(request, path, "Cross-origin request rejected");
|
||||
}
|
||||
} else if (referer && !referer->empty()) {
|
||||
if (!sameOrigin(std::string(*referer), hostStr)) {
|
||||
logEvent(403, method, path, "Referer/Host mismatch");
|
||||
return makeForbidden(request, path, "Cross-origin request rejected");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeBundle(request, *p);
|
||||
|
||||
if (isReadonly(p->role) && isMutation(method)) {
|
||||
logEvent(403, method, path, "readonly user mutation");
|
||||
return makeForbidden(request, path);
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace oatpp_authkit
|
||||
|
||||
#endif
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
#ifndef OATPP_AUTHKIT_AUTH_PRINCIPAL_HPP
|
||||
#define OATPP_AUTHKIT_AUTH_PRINCIPAL_HPP
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace oatpp_authkit {
|
||||
|
||||
/**
|
||||
* @brief Library-owned authenticated-user value.
|
||||
*
|
||||
* Intentionally decoupled from any consumer-specific DTO so the library
|
||||
* stays portable. Consumers translate from their own UserDto (or whatever)
|
||||
* into this struct inside their IAuthBackend implementation.
|
||||
*/
|
||||
struct AuthPrincipal {
|
||||
/// Stable numeric id from the user store. NOTE (authkit#16 L-1): this is an
|
||||
/// `int`, so it only round-trips numeric ids. A store keyed on UUIDs / other
|
||||
/// non-numeric ids must not stuff them here — `requireUser` rejects a
|
||||
/// non-numeric bundle id with 401. Carry such identities in `username` (or
|
||||
/// extend this struct) instead.
|
||||
int id{0};
|
||||
std::string username;
|
||||
std::string role; ///< Arbitrary string; policy decides what "admin"/"readonly" mean.
|
||||
};
|
||||
|
||||
} // namespace oatpp_authkit
|
||||
|
||||
#endif
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
#ifndef OATPP_AUTHKIT_AUTH_IAUTH_BACKEND_HPP
|
||||
#define OATPP_AUTHKIT_AUTH_IAUTH_BACKEND_HPP
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
#include "AuthPrincipal.hpp"
|
||||
|
||||
namespace oatpp_authkit {
|
||||
|
||||
/**
|
||||
* @brief Consumer-supplied adapter from library primitives → user store.
|
||||
*
|
||||
* The library never reads the database directly. The interceptor calls
|
||||
* these methods, the concrete implementation (owned by the consumer app)
|
||||
* wraps `UserDb` / `CertificateDb` / whatever and returns library-owned
|
||||
* `AuthPrincipal` structs.
|
||||
*
|
||||
* All methods must be thread-safe (the interceptor is invoked from oatpp
|
||||
* worker threads).
|
||||
*/
|
||||
class IAuthBackend {
|
||||
public:
|
||||
virtual ~IAuthBackend() = default;
|
||||
|
||||
/** @brief Look up an *active, non-expired* session by its hashed token.
|
||||
*
|
||||
* @warning Enforce expiry HERE (authkit#16 L-6): filter on the session's
|
||||
* `expires_at` in this query and return `std::nullopt` for an
|
||||
* expired row. The interceptor's periodic `deleteExpiredSessions`
|
||||
* is best-effort garbage collection that only runs on request
|
||||
* traffic — relying on it for expiry would leave a stale token
|
||||
* valid until the next sweep (or indefinitely on an idle server). */
|
||||
virtual std::optional<AuthPrincipal> resolveBySessionHash(const std::string& hash) = 0;
|
||||
|
||||
/** @brief Look up an API key by its hashed token; also touch `last_used_at`. */
|
||||
virtual std::optional<AuthPrincipal> resolveByApiKeyHash(const std::string& hash) = 0;
|
||||
|
||||
/**
|
||||
* @brief Look up a user by TLS client cert DN. Return nullopt if your
|
||||
* app doesn't support cert auth — the interceptor silently skips
|
||||
* this step.
|
||||
*/
|
||||
virtual std::optional<AuthPrincipal> resolveByCertDn(const std::string& /*dn*/) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
/** @brief True iff at least one active user exists. Used for setup-mode gate.
|
||||
*
|
||||
* @warning Must FAIL CLOSED (authkit#16 M-2): on any uncertainty — a DB
|
||||
* error, a timeout, an empty result you can't trust — return
|
||||
* `true` (or throw), never `false`. A `false` returned on a
|
||||
* swallowed error opens the setup-mode pseudo-admin path and
|
||||
* grants unauthenticated admin to every request. The interceptor
|
||||
* additionally gates setup mode on a loopback bind, but the
|
||||
* authoritative "are we still in first-run setup?" answer is
|
||||
* yours and must not degrade open. */
|
||||
virtual bool hasActiveUsers() = 0;
|
||||
|
||||
/** @brief Delete expired session rows. Called periodically by the interceptor. */
|
||||
virtual void deleteExpiredSessions() = 0;
|
||||
};
|
||||
|
||||
} // namespace oatpp_authkit
|
||||
|
||||
#endif
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
#ifndef OATPP_AUTHKIT_AUTH_IAUTH_POLICY_HPP
|
||||
#define OATPP_AUTHKIT_AUTH_IAUTH_POLICY_HPP
|
||||
|
||||
#include <optional>
|
||||
#include <set>
|
||||
#include <string>
|
||||
|
||||
namespace oatpp_authkit {
|
||||
|
||||
/**
|
||||
* @brief Consumer-supplied policy for public paths, roles, and setup mode.
|
||||
*
|
||||
* Ships with a conservative default impl (no public paths, `admin`/`readonly`
|
||||
* role conventions, setup mode always off). Subclass to add your app's
|
||||
* public-path list (`/guest/*`, `/calendar.ics`, etc.) and to expose the
|
||||
* `SETUP_MODE` sentinel check.
|
||||
*/
|
||||
class IAuthPolicy {
|
||||
public:
|
||||
virtual ~IAuthPolicy() = default;
|
||||
|
||||
/** @brief True iff the given path bypasses auth entirely. */
|
||||
virtual bool isPublicPath(const std::string& /*path*/) { return false; }
|
||||
|
||||
/** @brief Roles that pass an admin-required check. */
|
||||
virtual const std::set<std::string>& adminRoles() {
|
||||
static const std::set<std::string> k{"admin"};
|
||||
return k;
|
||||
}
|
||||
|
||||
/** @brief Roles that may only read (GET/HEAD/OPTIONS); mutations → 403. */
|
||||
virtual const std::set<std::string>& readonlyRoles() {
|
||||
static const std::set<std::string> k{"readonly"};
|
||||
return k;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Setup-mode escape hatch: when true AND the user table is empty,
|
||||
* the interceptor allows unauthenticated requests and injects a
|
||||
* pseudo-admin into the bundle. Consumers typically gate this on
|
||||
* the presence of a `SETUP_MODE` sentinel file.
|
||||
*/
|
||||
virtual bool setupModeActive() { return false; }
|
||||
|
||||
/**
|
||||
* @brief Where to send a browser navigation that hits a 401/403, instead
|
||||
* of the default JSON error body.
|
||||
*
|
||||
* Returning `std::nullopt` (the default) keeps the legacy behaviour:
|
||||
* `AuthInterceptor` always responds with `application/json`. Returning a
|
||||
* URL makes the interceptor emit a `302` with `Location:` set whenever
|
||||
* the request looks like a browser navigation (HTML `Accept`, no
|
||||
* `X-Requested-With`, path outside `/api/`). API callers (`fetch`,
|
||||
* `axios`, anything sending `X-Requested-With: XMLHttpRequest` or
|
||||
* targeting `/api/*`) still receive the JSON 401/403 so client-side
|
||||
* error handling keeps working.
|
||||
*
|
||||
* The same hook covers both 401 (no/invalid auth) and 403 (logged in
|
||||
* but not allowed) — typical wiring is to bounce both to `/` or
|
||||
* `/login?next=...`. Consumers that need to differentiate can branch
|
||||
* inside the override.
|
||||
*/
|
||||
virtual std::optional<std::string>
|
||||
unauthenticatedRedirect(const std::string& /*path*/) { return std::nullopt; }
|
||||
};
|
||||
|
||||
} // namespace oatpp_authkit
|
||||
|
||||
#endif
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
#ifndef OATPP_AUTHKIT_AUTH_IRUNTIME_CONFIG_HPP
|
||||
#define OATPP_AUTHKIT_AUTH_IRUNTIME_CONFIG_HPP
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace oatpp_authkit {
|
||||
|
||||
/**
|
||||
* @brief Runtime config surface the interceptor needs.
|
||||
*
|
||||
* Small enough that consumers typically implement it inline against their
|
||||
* existing Config globals. Provided as an interface rather than a struct
|
||||
* so the values can change at runtime (e.g. bind address flipping during
|
||||
* test setup) without restarting the interceptor.
|
||||
*/
|
||||
class IRuntimeConfig {
|
||||
public:
|
||||
virtual ~IRuntimeConfig() = default;
|
||||
|
||||
/** @brief Host the service is bound to ("127.0.0.1", "::1", "0.0.0.0", ...). */
|
||||
virtual std::string bindAddress() = 0;
|
||||
|
||||
/** @brief Convenience: true iff `bindAddress()` is a loopback literal.
|
||||
*
|
||||
* Used as the *binding* gate (e.g. trusting `X-Forwarded-For` / `X-Real-IP`).
|
||||
* For cert-DN trust, prefer `certAuthTrusted()` — operators with an SSH tunnel
|
||||
* or a misconfigured proxy can forward `X-SSL-Client-DN` from untrusted clients
|
||||
* even when the service binds to loopback.
|
||||
*/
|
||||
virtual bool isLoopback() {
|
||||
const std::string a = bindAddress();
|
||||
return a == "127.0.0.1" || a == "::1" || a == "localhost";
|
||||
}
|
||||
|
||||
/** @brief Whether incoming `X-SSL-Client-DN` headers should be trusted (#5).
|
||||
*
|
||||
* Default: `false` — **fail closed**. `X-SSL-Client-DN` is an ordinary
|
||||
* request header; binding to loopback does NOT guarantee it originates
|
||||
* from a TLS-terminating proxy. An SSH tunnel, a co-located process, or a
|
||||
* reverse proxy that forwards the client-supplied header verbatim can all
|
||||
* present an arbitrary DN to a loopback-bound service, so trusting it by
|
||||
* default is an authentication-bypass primitive. Consumers must opt in
|
||||
* explicitly, and only once the upstream proxy unconditionally strips the
|
||||
* inbound header and re-sets it from a verified client certificate, e.g.:
|
||||
*
|
||||
* bool certAuthTrusted() override {
|
||||
* return isLoopback() && std::getenv("TRUST_CERT_DN") != nullptr;
|
||||
* }
|
||||
*
|
||||
* When this returns `false`, `AuthInterceptor` ignores any inbound
|
||||
* `X-SSL-Client-DN` header and falls through to token / session auth.
|
||||
*/
|
||||
virtual bool certAuthTrusted() {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace oatpp_authkit
|
||||
|
||||
#endif
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
#ifndef OATPP_AUTHKIT_AUTH_REQUIRE_ROLE_HPP
|
||||
#define OATPP_AUTHKIT_AUTH_REQUIRE_ROLE_HPP
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include "oatpp/web/protocol/http/Http.hpp"
|
||||
#include "oatpp/web/server/api/ApiController.hpp"
|
||||
#include "oatpp/core/macro/codegen.hpp"
|
||||
|
||||
#include "IAuthPolicy.hpp"
|
||||
|
||||
namespace oatpp_authkit {
|
||||
|
||||
using IncomingRequest = oatpp::web::protocol::http::incoming::Request;
|
||||
using Status = oatpp::web::protocol::http::Status;
|
||||
|
||||
/**
|
||||
* @brief Pull the authenticated user into local scope inside a controller
|
||||
* endpoint. Throws 401 when no principal is present in the bundle.
|
||||
*
|
||||
* Usage inside an ENDPOINT:
|
||||
* auto me = oatpp_authkit::requireUser(request);
|
||||
* // me.id, me.role, me.username
|
||||
*/
|
||||
inline AuthPrincipal requireUser(const std::shared_ptr<IncomingRequest>& request) {
|
||||
auto id = request->getBundleData<oatpp::String>("auth_user_id");
|
||||
auto role = request->getBundleData<oatpp::String>("auth_user_role");
|
||||
auto username = request->getBundleData<oatpp::String>("auth_username");
|
||||
|
||||
OATPP_ASSERT_HTTP(id && role, Status::CODE_401, "Authentication required");
|
||||
|
||||
AuthPrincipal p;
|
||||
// authkit#16 L-1: parse defensively. The bundle id is normally a decimal
|
||||
// written by AuthInterceptor, but a non-numeric / out-of-range value (or a
|
||||
// future principal id that isn't an int) must surface as a clean 401, not
|
||||
// an uncaught std::invalid_argument/out_of_range escaping the endpoint as a
|
||||
// 500. The OATPP_ASSERT_HTTP is kept OUTSIDE the try so its HttpError isn't
|
||||
// swallowed by the catch.
|
||||
bool idOk = false;
|
||||
{
|
||||
const std::string idStr(*id);
|
||||
try {
|
||||
std::size_t consumed = 0;
|
||||
int parsed = std::stoi(idStr, &consumed);
|
||||
if (consumed == idStr.size()) { p.id = parsed; idOk = true; }
|
||||
} catch (...) { idOk = false; }
|
||||
}
|
||||
OATPP_ASSERT_HTTP(idOk, Status::CODE_401, "Authentication required");
|
||||
p.role = std::string(*role);
|
||||
p.username = username ? std::string(*username) : "";
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Reject the request with 403 unless the authenticated user is in
|
||||
* the policy's admin role set.
|
||||
*/
|
||||
inline AuthPrincipal requireAdmin(const std::shared_ptr<IncomingRequest>& request,
|
||||
IAuthPolicy& policy)
|
||||
{
|
||||
auto me = requireUser(request);
|
||||
OATPP_ASSERT_HTTP(policy.adminRoles().count(me.role) > 0,
|
||||
Status::CODE_403, "Admin required");
|
||||
return me;
|
||||
}
|
||||
|
||||
} // namespace oatpp_authkit
|
||||
|
||||
#endif
|
||||
|
|
@ -1,237 +0,0 @@
|
|||
#ifndef oatpp_authkit_db_AuditLog_hpp
|
||||
#define oatpp_authkit_db_AuditLog_hpp
|
||||
|
||||
#include "oatpp-sqlite/orm.hpp"
|
||||
#include "oatpp/core/macro/codegen.hpp"
|
||||
#include "oatpp/core/macro/component.hpp"
|
||||
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <sstream>
|
||||
|
||||
namespace oatpp_authkit {
|
||||
|
||||
/**
|
||||
* @brief Audit logging service — logs entity mutations to the `audit_log` table.
|
||||
*
|
||||
* Replaces SQLite audit triggers with explicit C++ calls from controllers.
|
||||
* Four operations:
|
||||
* - logCreate(table, entityId)
|
||||
* - logDelete(table, entityId)
|
||||
* - logUpdate(table, entityId) — no-diff form (junction changes, bulk patches)
|
||||
* - logUpdate<Dto>(table, entityId, oldRow, newRow) — computes a JSON field diff
|
||||
*
|
||||
* Schema: consumers copy `AuditLog::CREATE_TABLE_SQL` into their `schema.sql`
|
||||
* (or execute it at startup) so every project that uses `AuditLog` ends up on
|
||||
* the same table shape. That keeps the class name (`AuditLog`), the table
|
||||
* name (`audit_log`), and the column set in one source of truth.
|
||||
*
|
||||
* Usage in controllers:
|
||||
* m_auditLog->logCreate("bookings", entityId, actor);
|
||||
* m_auditLog->logUpdate<BookingDto>("bookings", entityId, oldRow, newRow, actor);
|
||||
*
|
||||
* Note on legacy data (fewo-webapp only): the pre-lift table was named
|
||||
* `command_log`; a one-shot migration (INSERT INTO audit_log SELECT …
|
||||
* FROM command_log; DROP TABLE command_log;) copies the existing rows over.
|
||||
*/
|
||||
class AuditLog {
|
||||
public:
|
||||
|
||||
/**
|
||||
* @brief DDL for the audit_log table + supporting indexes.
|
||||
*
|
||||
* Consumers include this in their schema-init flow (e.g. executing it
|
||||
* at startup) so every project using `AuditLog` has the same table
|
||||
* shape without each project re-declaring the column set.
|
||||
*/
|
||||
static constexpr const char* CREATE_TABLE_SQL = R"SQL(
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
table_name TEXT NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
operation TEXT NOT NULL,
|
||||
changed_fields TEXT,
|
||||
actor TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_table_entity ON audit_log(table_name, entity_id);
|
||||
)SQL";
|
||||
|
||||
#include OATPP_CODEGEN_BEGIN(DbClient)
|
||||
|
||||
/** @brief Minimal DbClient for inserting into audit_log. */
|
||||
class AuditLogDb : public oatpp::orm::DbClient {
|
||||
public:
|
||||
AuditLogDb(const std::shared_ptr<oatpp::orm::Executor>& executor)
|
||||
: oatpp::orm::DbClient(executor) {}
|
||||
|
||||
QUERY(logOp,
|
||||
"INSERT INTO audit_log(table_name, entity_id, operation, changed_fields, actor) "
|
||||
"VALUES (:t, :e, :o, :f, :a);",
|
||||
PARAM(oatpp::String, t),
|
||||
PARAM(oatpp::String, e),
|
||||
PARAM(oatpp::String, o),
|
||||
PARAM(oatpp::String, f),
|
||||
PARAM(oatpp::String, a))
|
||||
};
|
||||
|
||||
#include OATPP_CODEGEN_END(DbClient)
|
||||
|
||||
private:
|
||||
std::shared_ptr<AuditLogDb> m_db;
|
||||
|
||||
/** @brief Fields to skip when computing UPDATE diffs — internal/metadata
|
||||
* plus credentials. authkit#16 L-3: never copy a secret into the long-lived
|
||||
* `audit_log.changed_fields` column (covers both snake_case and camelCase
|
||||
* identifiers since the diff matches on the DTO's C++ field name). */
|
||||
static inline const std::set<std::string> SKIP_FIELDS = {
|
||||
"id", "entity_id", "created_at", "updated_at", "valid_from",
|
||||
"password", "passwordHash", "password_hash",
|
||||
"tlsCertDn", "tls_cert_dn",
|
||||
"apiKey", "api_key", "token", "secret"
|
||||
};
|
||||
|
||||
/** @brief RFC 8259-compliant JSON string escaping. authkit#16 L-3: the
|
||||
* previous version escaped only `\` and `"`, so a control character (e.g.
|
||||
* a newline in a user-supplied name) produced invalid JSON in the audit
|
||||
* trail and allowed newline/log injection into anything re-emitting the
|
||||
* column. */
|
||||
static std::string escapeJson(const std::string& s) {
|
||||
static const char* hex = "0123456789abcdef";
|
||||
std::string out;
|
||||
out.reserve(s.size() + 8);
|
||||
for (unsigned char c : s) {
|
||||
switch (c) {
|
||||
case '\\': out += "\\\\"; break;
|
||||
case '"': out += "\\\""; break;
|
||||
case '\b': out += "\\b"; break;
|
||||
case '\f': out += "\\f"; break;
|
||||
case '\n': out += "\\n"; break;
|
||||
case '\r': out += "\\r"; break;
|
||||
case '\t': out += "\\t"; break;
|
||||
default:
|
||||
if (c < 0x20) {
|
||||
out += "\\u00";
|
||||
out += hex[(c >> 4) & 0xF];
|
||||
out += hex[c & 0xF];
|
||||
} else {
|
||||
out += static_cast<char>(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Serialise an oatpp::Void to JSON: String / Int32 / Int64 /
|
||||
* Float32 / Float64 / Boolean / null. Unknown types serialise as null.
|
||||
*/
|
||||
static std::string valueToJson(const oatpp::Void& value) {
|
||||
if (!value.getPtr()) return "null";
|
||||
|
||||
auto classId = value.getValueType()->classId;
|
||||
|
||||
if (classId == oatpp::String::Class::CLASS_ID) {
|
||||
auto* s = static_cast<const std::string*>(value.getPtr().get());
|
||||
return "\"" + escapeJson(*s) + "\"";
|
||||
}
|
||||
if (classId == oatpp::Int32::Class::CLASS_ID) {
|
||||
return std::to_string(*static_cast<const v_int32*>(value.getPtr().get()));
|
||||
}
|
||||
if (classId == oatpp::Int64::Class::CLASS_ID) {
|
||||
return std::to_string(*static_cast<const v_int64*>(value.getPtr().get()));
|
||||
}
|
||||
if (classId == oatpp::Float64::Class::CLASS_ID) {
|
||||
char buf[64];
|
||||
std::snprintf(buf, sizeof(buf), "%g", *static_cast<const v_float64*>(value.getPtr().get()));
|
||||
return buf;
|
||||
}
|
||||
if (classId == oatpp::Float32::Class::CLASS_ID) {
|
||||
char buf[64];
|
||||
std::snprintf(buf, sizeof(buf), "%g", (double)*static_cast<const v_float32*>(value.getPtr().get()));
|
||||
return buf;
|
||||
}
|
||||
if (classId == oatpp::Boolean::Class::CLASS_ID) {
|
||||
return *static_cast<const bool*>(value.getPtr().get()) ? "true" : "false";
|
||||
}
|
||||
|
||||
return "null";
|
||||
}
|
||||
|
||||
static bool valuesEqual(const oatpp::Void& a, const oatpp::Void& b) {
|
||||
bool aNull = !a.getPtr();
|
||||
bool bNull = !b.getPtr();
|
||||
if (aNull && bNull) return true;
|
||||
if (aNull || bNull) return false;
|
||||
return valueToJson(a) == valueToJson(b);
|
||||
}
|
||||
|
||||
public:
|
||||
|
||||
AuditLog(const std::shared_ptr<oatpp::orm::Executor>& executor)
|
||||
: m_db(std::make_shared<AuditLogDb>(executor)) {}
|
||||
|
||||
/** @brief Log a CREATE (entity inserted). Optional connection pins to a transaction. */
|
||||
void logCreate(const oatpp::String& table, const oatpp::String& entityId,
|
||||
const oatpp::String& actor = nullptr,
|
||||
const oatpp::provider::ResourceHandle<oatpp::orm::Connection>& connection = nullptr) {
|
||||
m_db->logOp(table, entityId, "CREATE", nullptr, actor, connection);
|
||||
}
|
||||
|
||||
/** @brief Log a DELETE (entity removed). Optional connection pins to a transaction. */
|
||||
void logDelete(const oatpp::String& table, const oatpp::String& entityId,
|
||||
const oatpp::String& actor = nullptr,
|
||||
const oatpp::provider::ResourceHandle<oatpp::orm::Connection>& connection = nullptr) {
|
||||
m_db->logOp(table, entityId, "DELETE", nullptr, actor, connection);
|
||||
}
|
||||
|
||||
/** @brief Log an UPDATE without field-level diff (junction changes, bulk patches). */
|
||||
void logUpdate(const oatpp::String& table, const oatpp::String& entityId,
|
||||
const oatpp::String& actor = nullptr,
|
||||
const oatpp::provider::ResourceHandle<oatpp::orm::Connection>& connection = nullptr) {
|
||||
m_db->logOp(table, entityId, "UPDATE", nullptr, actor, connection);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Log an UPDATE with a JSON diff of the fields whose values changed.
|
||||
*
|
||||
* Uses oatpp DTO reflection to produce `{"field": newValue, ...}`. If no
|
||||
* field changed, no row is written.
|
||||
*/
|
||||
template<typename DtoType>
|
||||
void logUpdate(const oatpp::String& table,
|
||||
const oatpp::String& entityId,
|
||||
const oatpp::Object<DtoType>& oldRow,
|
||||
const oatpp::Object<DtoType>& newRow,
|
||||
const oatpp::String& actor = nullptr,
|
||||
const oatpp::provider::ResourceHandle<oatpp::orm::Connection>& connection = nullptr) {
|
||||
std::string json = "{";
|
||||
bool first = true;
|
||||
|
||||
for (auto* prop : oatpp::Object<DtoType>::getPropertiesList()) {
|
||||
std::string fieldName(prop->name);
|
||||
|
||||
if (SKIP_FIELDS.count(fieldName)) continue;
|
||||
|
||||
auto oldVal = prop->get(static_cast<oatpp::BaseObject*>(oldRow.get()));
|
||||
auto newVal = prop->get(static_cast<oatpp::BaseObject*>(newRow.get()));
|
||||
|
||||
if (!valuesEqual(oldVal, newVal)) {
|
||||
if (!first) json += ",";
|
||||
json += "\"" + fieldName + "\":" + valueToJson(newVal);
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
|
||||
json += "}";
|
||||
|
||||
if (!first) {
|
||||
m_db->logOp(table, entityId, "UPDATE", oatpp::String(json), actor, connection);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace oatpp_authkit
|
||||
|
||||
#endif // oatpp_authkit_db_AuditLog_hpp
|
||||
|
|
@ -1,277 +0,0 @@
|
|||
#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
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
#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
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
#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
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
#ifndef OATPP_AUTHKIT_DTO_INTERNAL_DTO_HPP
|
||||
#define OATPP_AUTHKIT_DTO_INTERNAL_DTO_HPP
|
||||
|
||||
#include "oatpp/codegen/dto/base_define.hpp"
|
||||
#include "oatpp/core/macro/codegen.hpp"
|
||||
#include "oatpp/core/Types.hpp"
|
||||
|
||||
#include OATPP_CODEGEN_BEGIN(DTO)
|
||||
|
||||
namespace oatpp_authkit::dto {
|
||||
|
||||
/**
|
||||
* @brief Body shape emitted by JsonErrorHandler and AuthInterceptor::makeJsonError (#6).
|
||||
*
|
||||
* Replaces ad-hoc string concatenation. Going through ObjectMapper
|
||||
* guarantees the embedded `status` / `message` strings are properly
|
||||
* escaped — the previous hand-rolled `JsonErrorHandler::handleError`
|
||||
* embedded `status.description` raw, which would emit invalid JSON for
|
||||
* any `Status{…, "I'm a \"teapot\""}` description.
|
||||
*/
|
||||
class JsonErrorDto : public oatpp::DTO {
|
||||
DTO_INIT(JsonErrorDto, DTO)
|
||||
|
||||
DTO_FIELD(String, status);
|
||||
DTO_FIELD(Int32, code);
|
||||
DTO_FIELD(String, message);
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Outbound WS broadcast for booking/person lifecycle events (#6).
|
||||
*
|
||||
* {"type":"booking_updated","id":"42"}
|
||||
*/
|
||||
class WsEntityEventDto : public oatpp::DTO {
|
||||
DTO_INIT(WsEntityEventDto, DTO)
|
||||
|
||||
DTO_FIELD(String, type);
|
||||
DTO_FIELD(String, id);
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Outbound WS broadcast for presence updates (#6).
|
||||
*
|
||||
* {"type":"presence_update","booking_id":"42","users":["alice","bob"]}
|
||||
*/
|
||||
class WsPresenceUpdateDto : public oatpp::DTO {
|
||||
DTO_INIT(WsPresenceUpdateDto, DTO)
|
||||
|
||||
DTO_FIELD(String, type);
|
||||
DTO_FIELD(String, booking_id);
|
||||
DTO_FIELD(List<String>, users);
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Inbound WS message envelope (#6) — replaces `Listener::jsonStr/jsonInt`.
|
||||
*
|
||||
* The toy regex parsers in the previous implementation mishandled escaped
|
||||
* quotes and nested objects; routing through `ObjectMapper` rejects
|
||||
* malformed inbound payloads cleanly.
|
||||
*/
|
||||
class WsClientMsgDto : public oatpp::DTO {
|
||||
DTO_INIT(WsClientMsgDto, DTO)
|
||||
|
||||
DTO_FIELD(String, type);
|
||||
DTO_FIELD(String, booking_id);
|
||||
DTO_FIELD(String, user);
|
||||
};
|
||||
|
||||
} // namespace oatpp_authkit::dto
|
||||
|
||||
#include OATPP_CODEGEN_END(DTO)
|
||||
|
||||
#endif
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
#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
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
#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
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
#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
|
||||
|
|
@ -3,46 +3,28 @@
|
|||
|
||||
#include "oatpp/web/server/handler/ErrorHandler.hpp"
|
||||
#include "oatpp/web/protocol/http/outgoing/ResponseFactory.hpp"
|
||||
#include "oatpp/parser/json/mapping/ObjectMapper.hpp"
|
||||
|
||||
#include "../dto/InternalDto.hpp"
|
||||
|
||||
namespace oatpp_authkit {
|
||||
|
||||
/**
|
||||
* @brief Custom error handler that returns JSON error responses.
|
||||
*
|
||||
* Replaces oatpp's default plain-text error handler so that
|
||||
* OATPP_ASSERT_HTTP errors are returned as JSON objects matching the
|
||||
* `JsonErrorDto` schema: `{"status": "...", "code": N, "message": "..."}`.
|
||||
*
|
||||
* Routing through `ObjectMapper` (DI'd) replaces the previous hand-rolled
|
||||
* concatenation that embedded `status.description` raw — see #6.
|
||||
* OATPP_ASSERT_HTTP errors are returned as JSON objects matching
|
||||
* the StatusDto schema: {"status": "...", "code": N, "message": "..."}.
|
||||
* This allows the frontend's coreFetch to parse error details reliably.
|
||||
*/
|
||||
class JsonErrorHandler : public oatpp::web::server::handler::ErrorHandler {
|
||||
private:
|
||||
std::shared_ptr<oatpp::data::mapping::ObjectMapper> m_mapper;
|
||||
|
||||
public:
|
||||
|
||||
/**
|
||||
* @param mapper Shared JSON object mapper. Pass nullptr for a
|
||||
* handler-owned default mapper (back-compat path).
|
||||
*/
|
||||
explicit JsonErrorHandler(std::shared_ptr<oatpp::data::mapping::ObjectMapper> mapper = nullptr)
|
||||
: m_mapper(mapper ? mapper : oatpp::parser::json::mapping::ObjectMapper::createShared()) {}
|
||||
|
||||
std::shared_ptr<oatpp::web::protocol::http::outgoing::Response>
|
||||
handleError(const oatpp::web::protocol::http::Status& status,
|
||||
const oatpp::String& message,
|
||||
const Headers& headers) override
|
||||
{
|
||||
auto dto = dto::JsonErrorDto::createShared();
|
||||
dto->status = oatpp::String(std::string(status.description));
|
||||
dto->code = status.code;
|
||||
dto->message = message ? message : oatpp::String("");
|
||||
|
||||
oatpp::String json = m_mapper->writeToString(dto);
|
||||
auto json = oatpp::String(
|
||||
"{\"status\":\"" + std::string(status.description) +
|
||||
"\",\"code\":" + std::to_string(status.code) +
|
||||
",\"message\":\"" + escapeJson(message ? message->c_str() : "") + "\"}"
|
||||
);
|
||||
|
||||
auto response = oatpp::web::protocol::http::outgoing::ResponseFactory::createResponse(
|
||||
status, json
|
||||
|
|
@ -55,8 +37,30 @@ public:
|
|||
|
||||
return response;
|
||||
}
|
||||
|
||||
private:
|
||||
|
||||
static std::string escapeJson(const char* s) {
|
||||
std::string out;
|
||||
for (; *s; ++s) {
|
||||
switch (*s) {
|
||||
case '"': out += "\\\""; break;
|
||||
case '\\': out += "\\\\"; break;
|
||||
case '\n': out += "\\n"; break;
|
||||
case '\r': out += "\\r"; break;
|
||||
case '\t': out += "\\t"; break;
|
||||
default:
|
||||
if (static_cast<unsigned char>(*s) < 0x20) {
|
||||
char buf[8];
|
||||
snprintf(buf, sizeof(buf), "\\u%04x", static_cast<unsigned char>(*s));
|
||||
out += buf;
|
||||
} else {
|
||||
out += *s;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace oatpp_authkit
|
||||
|
||||
#endif // HANDLER_JSON_ERROR_HANDLER_HPP
|
||||
|
|
|
|||
|
|
@ -4,102 +4,40 @@
|
|||
#include "oatpp/web/server/interceptor/RequestInterceptor.hpp"
|
||||
#include "oatpp/web/protocol/http/outgoing/ResponseFactory.hpp"
|
||||
|
||||
namespace oatpp_authkit {
|
||||
|
||||
/**
|
||||
* @brief Request interceptor that rejects oversized or under-declared request bodies.
|
||||
* @brief Request interceptor that rejects requests exceeding a body size limit.
|
||||
*
|
||||
* Behaviour for body-bearing methods (`POST`, `PUT`, `PATCH`):
|
||||
* - missing `Content-Length` → `411 Length Required` (audit #4: closes
|
||||
* chunked-transfer / HTTP/2 bypass that previously sailed through silently)
|
||||
* - malformed `Content-Length` → `400 Bad Request`
|
||||
* - `Transfer-Encoding: chunked` (or any non-identity encoding) → `411`
|
||||
* (we cannot enforce a cap without buffering an unbounded stream; reject
|
||||
* by default rather than fall through to oatpp's much higher ceiling)
|
||||
* - declared length above `maxBytes` → `413 Payload Too Large`
|
||||
*
|
||||
* Methods that don't carry a body (`GET`, `HEAD`, `DELETE`, `OPTIONS`, `TRACE`)
|
||||
* pass through untouched — `Content-Length` absence is normal there.
|
||||
*
|
||||
* Consumers that genuinely need to accept missing/chunked bodies on body-
|
||||
* bearing methods can construct with `requireContentLength = false` to revert
|
||||
* to the legacy fail-open behaviour.
|
||||
* Checks the Content-Length header and returns HTTP 413 (Payload Too Large)
|
||||
* if the declared body size exceeds the configured maximum.
|
||||
*/
|
||||
class BodySizeLimitInterceptor : public oatpp::web::server::interceptor::RequestInterceptor {
|
||||
private:
|
||||
size_t m_maxBytes;
|
||||
bool m_requireContentLength;
|
||||
|
||||
static bool methodCarriesBody(const oatpp::String& method) {
|
||||
if (!method) return false;
|
||||
const std::string m = *method;
|
||||
return m == "POST" || m == "PUT" || m == "PATCH";
|
||||
}
|
||||
|
||||
static std::shared_ptr<OutgoingResponse> jsonResponse(int code, const char* phrase, const char* body) {
|
||||
auto r = oatpp::web::protocol::http::outgoing::ResponseFactory::createResponse(
|
||||
oatpp::web::protocol::http::Status(code, phrase), body);
|
||||
r->putHeader("Content-Type", "application/json");
|
||||
return r;
|
||||
}
|
||||
|
||||
public:
|
||||
/**
|
||||
* @param maxBytes Maximum allowed request body size in bytes.
|
||||
* @param requireContentLength When `true` (default), body-bearing methods
|
||||
* must declare a parseable `Content-Length`;
|
||||
* missing/malformed/chunked → reject. Set
|
||||
* `false` for the legacy lax behaviour.
|
||||
* @param maxBytes Maximum allowed request body size in bytes.
|
||||
*/
|
||||
explicit BodySizeLimitInterceptor(size_t maxBytes, bool requireContentLength = true)
|
||||
: m_maxBytes(maxBytes), m_requireContentLength(requireContentLength) {}
|
||||
explicit BodySizeLimitInterceptor(size_t maxBytes) : m_maxBytes(maxBytes) {}
|
||||
|
||||
std::shared_ptr<OutgoingResponse> intercept(const std::shared_ptr<IncomingRequest>& request) override {
|
||||
const auto& line = request->getStartingLine();
|
||||
if (!methodCarriesBody(line.method.toString())) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
auto transferEncoding = request->getHeader("Transfer-Encoding");
|
||||
if (m_requireContentLength && transferEncoding && !transferEncoding->empty()) {
|
||||
std::string te = *transferEncoding;
|
||||
for (auto& c : te) c = std::tolower(static_cast<unsigned char>(c));
|
||||
if (te.find("identity") == std::string::npos) {
|
||||
return jsonResponse(411, "Length Required",
|
||||
"{\"status\":\"Length Required\"}");
|
||||
}
|
||||
}
|
||||
|
||||
auto contentLength = request->getHeader("Content-Length");
|
||||
if (!contentLength || contentLength->empty()) {
|
||||
if (m_requireContentLength) {
|
||||
return jsonResponse(411, "Length Required",
|
||||
"{\"status\":\"Length Required\"}");
|
||||
if (contentLength && !contentLength->empty()) {
|
||||
try {
|
||||
size_t len = std::stoull(std::string(*contentLength));
|
||||
if (len > m_maxBytes) {
|
||||
auto response = oatpp::web::protocol::http::outgoing::ResponseFactory::createResponse(
|
||||
oatpp::web::protocol::http::Status(413, "Payload Too Large"),
|
||||
"{\"status\":\"Payload Too Large\"}");
|
||||
response->putHeader("Content-Type", "application/json");
|
||||
return response;
|
||||
}
|
||||
} catch (...) {
|
||||
// Malformed Content-Length — let it through, Oat++ will handle it
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
size_t len = 0;
|
||||
try {
|
||||
size_t pos = 0;
|
||||
len = std::stoull(std::string(*contentLength), &pos);
|
||||
if (pos != contentLength->size()) throw std::invalid_argument("trailing");
|
||||
} catch (...) {
|
||||
if (m_requireContentLength) {
|
||||
return jsonResponse(400, "Bad Request",
|
||||
"{\"status\":\"Bad Request\"}");
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
if (len > m_maxBytes) {
|
||||
return jsonResponse(413, "Payload Too Large",
|
||||
"{\"status\":\"Payload Too Large\"}");
|
||||
}
|
||||
return nullptr;
|
||||
return nullptr; // pass through
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace oatpp_authkit
|
||||
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -3,123 +3,37 @@
|
|||
|
||||
#include "oatpp/web/server/interceptor/ResponseInterceptor.hpp"
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace oatpp_authkit {
|
||||
|
||||
/**
|
||||
* @brief Per-directive overrides for the strict baseline CSP.
|
||||
*
|
||||
* Empty string = "use the strict baseline value for this directive".
|
||||
* Set a directive to a non-empty string to relax (or further tighten) it.
|
||||
*
|
||||
* Example — allow Swagger UI's inline scripts on a single subtree only by
|
||||
* wrapping this interceptor and swapping `scriptSrc` for matching paths:
|
||||
*
|
||||
* CspOverride relaxed;
|
||||
* relaxed.scriptSrc = "'self' 'unsafe-inline'";
|
||||
* relaxed.styleSrc = "'self' 'unsafe-inline'";
|
||||
*
|
||||
* The vast majority of consumers should leave this default-constructed.
|
||||
*/
|
||||
struct CspOverride {
|
||||
std::string defaultSrc; // baseline: 'self'
|
||||
std::string scriptSrc; // baseline: 'self'
|
||||
std::string styleSrc; // baseline: 'self'
|
||||
std::string imgSrc; // baseline: 'self' data:
|
||||
std::string connectSrc; // baseline: 'self'
|
||||
std::string fontSrc; // baseline: 'self'
|
||||
std::string frameAncestors; // baseline: 'none'
|
||||
std::string baseUri; // baseline: 'self'
|
||||
std::string formAction; // baseline: 'self'
|
||||
|
||||
/** Set to false to drop the HSTS header entirely (e.g. for non-TLS dev). */
|
||||
bool sendHsts = true;
|
||||
/** Set to true to add `includeSubDomains` to HSTS (off by default — apex-clobbering hazard). */
|
||||
bool hstsIncludeSubdomains = false;
|
||||
|
||||
/** Override X-Frame-Options. Empty = baseline `DENY`. */
|
||||
std::string xFrameOptions;
|
||||
/** Override Permissions-Policy. Empty = baseline (sensors disabled). */
|
||||
std::string permissionsPolicy;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Response interceptor that adds standard security headers to all responses.
|
||||
*
|
||||
* Defaults track `docs/security-baseline.md`:
|
||||
* - `X-Content-Type-Options: nosniff`
|
||||
* - `X-Frame-Options: DENY`
|
||||
* - `Referrer-Policy: strict-origin-when-cross-origin`
|
||||
* - `Strict-Transport-Security: max-age=63072000` (no `includeSubDomains` by default)
|
||||
* - `Permissions-Policy: accelerometer=(), camera=(), …`
|
||||
* - `Content-Security-Policy:`
|
||||
* `default-src 'self'; script-src 'self'; style-src 'self';`
|
||||
* `img-src 'self' data:; connect-src 'self'; font-src 'self';`
|
||||
* `frame-ancestors 'none'; base-uri 'self'; form-action 'self'`
|
||||
*
|
||||
* Construct with a `CspOverride` to relax individual directives without
|
||||
* forking the interceptor — see the struct doc for the typical use.
|
||||
* Headers added:
|
||||
* - X-Content-Type-Options: nosniff — prevents MIME type sniffing
|
||||
* - X-Frame-Options: SAMEORIGIN — prevents clickjacking
|
||||
* - Referrer-Policy: strict-origin-when-cross-origin — limits referrer leakage
|
||||
* - Content-Security-Policy — restricts resource loading sources
|
||||
*/
|
||||
class SecurityHeadersInterceptor : public oatpp::web::server::interceptor::ResponseInterceptor {
|
||||
private:
|
||||
CspOverride m_override;
|
||||
|
||||
static const std::string& orDefault(const std::string& v, const std::string& fallback) {
|
||||
return v.empty() ? fallback : v;
|
||||
}
|
||||
|
||||
public:
|
||||
SecurityHeadersInterceptor() = default;
|
||||
explicit SecurityHeadersInterceptor(CspOverride override) : m_override(std::move(override)) {}
|
||||
|
||||
std::shared_ptr<OutgoingResponse> intercept(
|
||||
const std::shared_ptr<IncomingRequest>& request,
|
||||
const std::shared_ptr<OutgoingResponse>& response) override {
|
||||
|
||||
static const std::string DEF_DEFAULT = "'self'";
|
||||
static const std::string DEF_SCRIPT = "'self'";
|
||||
static const std::string DEF_STYLE = "'self'";
|
||||
static const std::string DEF_IMG = "'self' data:";
|
||||
static const std::string DEF_CONNECT = "'self'";
|
||||
static const std::string DEF_FONT = "'self'";
|
||||
static const std::string DEF_FRAME_ANC = "'none'";
|
||||
static const std::string DEF_BASE = "'self'";
|
||||
static const std::string DEF_FORM = "'self'";
|
||||
static const std::string DEF_XFRAME = "DENY";
|
||||
static const std::string DEF_PERMISSIONS =
|
||||
"accelerometer=(), camera=(), geolocation=(), gyroscope=(),"
|
||||
" magnetometer=(), microphone=(), payment=(), usb=()";
|
||||
|
||||
const std::string csp =
|
||||
"default-src " + orDefault(m_override.defaultSrc, DEF_DEFAULT) + "; "
|
||||
"script-src " + orDefault(m_override.scriptSrc, DEF_SCRIPT) + "; "
|
||||
"style-src " + orDefault(m_override.styleSrc, DEF_STYLE) + "; "
|
||||
"img-src " + orDefault(m_override.imgSrc, DEF_IMG) + "; "
|
||||
"connect-src " + orDefault(m_override.connectSrc, DEF_CONNECT) + "; "
|
||||
"font-src " + orDefault(m_override.fontSrc, DEF_FONT) + "; "
|
||||
"frame-ancestors "+ orDefault(m_override.frameAncestors, DEF_FRAME_ANC) + "; "
|
||||
"base-uri " + orDefault(m_override.baseUri, DEF_BASE) + "; "
|
||||
"form-action " + orDefault(m_override.formAction, DEF_FORM);
|
||||
|
||||
response->putHeader("X-Content-Type-Options", "nosniff");
|
||||
response->putHeader("X-Frame-Options",
|
||||
orDefault(m_override.xFrameOptions, DEF_XFRAME).c_str());
|
||||
response->putHeader("X-Frame-Options", "SAMEORIGIN");
|
||||
response->putHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
response->putHeader("Permissions-Policy",
|
||||
orDefault(m_override.permissionsPolicy, DEF_PERMISSIONS).c_str());
|
||||
response->putHeader("Content-Security-Policy", csp.c_str());
|
||||
|
||||
if (m_override.sendHsts) {
|
||||
const std::string hsts = m_override.hstsIncludeSubdomains
|
||||
? "max-age=63072000; includeSubDomains"
|
||||
: "max-age=63072000";
|
||||
response->putHeader("Strict-Transport-Security", hsts.c_str());
|
||||
}
|
||||
response->putHeader("Content-Security-Policy",
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' https://unpkg.com; "
|
||||
"style-src 'self' 'unsafe-inline' https://unpkg.com; "
|
||||
"img-src 'self' data: https:; "
|
||||
"connect-src 'self' wss: ws:; "
|
||||
"font-src 'self'; "
|
||||
"frame-ancestors 'self'; "
|
||||
"base-uri 'self'; "
|
||||
"form-action 'self'");
|
||||
response->putHeader("Strict-Transport-Security",
|
||||
"max-age=63072000; includeSubDomains");
|
||||
return response;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace oatpp_authkit
|
||||
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -1,171 +0,0 @@
|
|||
#ifndef oatpp_authkit_mail_SmtpTransport_hpp
|
||||
#define oatpp_authkit_mail_SmtpTransport_hpp
|
||||
|
||||
/**
|
||||
* @file SmtpTransport.hpp
|
||||
* @brief Pure libcurl SMTP+MIME transport — lifted from fewo-webapp #454.
|
||||
*
|
||||
* Handles MAIL FROM / RCPT TO / STARTTLS / optional SMTP AUTH / MIME multipart
|
||||
* body + attachments / RFC 2047 encoded Subject. Knows nothing about templates,
|
||||
* DTOs or databases — callers hand over the fully-rendered HTML body, the
|
||||
* subject line, any attachment blobs and an `SmtpConfig` struct. Use a tiny
|
||||
* adapter in the caller to map from whatever DTO/settings row you have to
|
||||
* `SmtpConfig` so this header stays free of project-specific types.
|
||||
*
|
||||
* Consumers: add `#include <oatpp-authkit/mail/SmtpTransport.hpp>`; link
|
||||
* against libcurl (authkit itself is header-only so the consumer's CMake
|
||||
* owns the curl dependency).
|
||||
*/
|
||||
|
||||
#include <curl/curl.h>
|
||||
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace oatpp_authkit::mail {
|
||||
|
||||
/** @brief Plain-struct SMTP config; projects adapt from their own DTO/settings row. */
|
||||
struct SmtpConfig {
|
||||
std::string host;
|
||||
int port = 587;
|
||||
std::string fromAddress;
|
||||
std::string username; // empty = no SMTP AUTH
|
||||
std::string password;
|
||||
};
|
||||
|
||||
/** @brief True if `s` contains CR, LF or NUL — characters that would let a
|
||||
* caller-influenced address smuggle extra SMTP/MIME headers (BCC injection,
|
||||
* added recipients, body injection) when concatenated into a header line. */
|
||||
inline bool hasHeaderInjectionChars(const std::string& s) {
|
||||
return s.find('\r') != std::string::npos
|
||||
|| s.find('\n') != std::string::npos
|
||||
|| s.find('\0') != std::string::npos;
|
||||
}
|
||||
|
||||
/** @brief RFC 4648 Base64 encode — used for RFC 2047 Subject headers. */
|
||||
inline std::string base64Encode(const std::string& data) {
|
||||
static const char* table =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
std::string out;
|
||||
out.reserve(((data.size() + 2) / 3) * 4);
|
||||
for (size_t i = 0; i < data.size(); i += 3) {
|
||||
unsigned int b = (unsigned char)data[i] << 16;
|
||||
if (i + 1 < data.size()) b |= (unsigned char)data[i + 1] << 8;
|
||||
if (i + 2 < data.size()) b |= (unsigned char)data[i + 2];
|
||||
out += table[(b >> 18) & 0x3f];
|
||||
out += table[(b >> 12) & 0x3f];
|
||||
out += (i + 1 < data.size()) ? table[(b >> 6) & 0x3f] : '=';
|
||||
out += (i + 2 < data.size()) ? table[b & 0x3f] : '=';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Send a single email via libcurl SMTP.
|
||||
*
|
||||
* @param to Recipient address.
|
||||
* @param subject Plain UTF-8 subject; wrapped as an RFC 2047 encoded-word so
|
||||
* non-ASCII characters (umlauts etc.) survive.
|
||||
* @param htmlBody text/html body (quoted-printable on the wire).
|
||||
* @param attachments (filename, blob) pairs; `.pdf`/`.ics` extensions get
|
||||
* recognised Content-Type, everything else goes as
|
||||
* application/octet-stream.
|
||||
* @param cfg SMTP configuration.
|
||||
* @return Empty string on success; error message otherwise. Callers typically
|
||||
* log a non-empty result and treat it as a soft failure.
|
||||
*/
|
||||
inline std::string send(
|
||||
const std::string& to,
|
||||
const std::string& subject,
|
||||
const std::string& htmlBody,
|
||||
const std::vector<std::pair<std::string, std::string>>& attachments,
|
||||
const SmtpConfig& cfg)
|
||||
{
|
||||
if (cfg.host.empty()) return "SMTP not configured (no host)";
|
||||
if (cfg.fromAddress.empty()) return "SMTP not configured (no from_address)";
|
||||
|
||||
// Reject control characters in the addresses before they reach the envelope
|
||||
// (MAIL FROM / RCPT TO) and the From:/To: header lines. The subject is safe
|
||||
// — it is RFC 2047 base64 encoded-word wrapped below — but the addresses are
|
||||
// concatenated raw, so a `\r\n` here would inject arbitrary headers.
|
||||
if (hasHeaderInjectionChars(to)) return "invalid recipient address (control characters)";
|
||||
if (hasHeaderInjectionChars(cfg.fromAddress)) return "invalid from address (control characters)";
|
||||
|
||||
CURL* curl = curl_easy_init();
|
||||
if (!curl) return "curl_easy_init failed";
|
||||
|
||||
std::string url = "smtp://" + cfg.host + ":" + std::to_string(cfg.port);
|
||||
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
|
||||
curl_easy_setopt(curl, CURLOPT_MAIL_FROM, ("<" + cfg.fromAddress + ">").c_str());
|
||||
|
||||
struct curl_slist* rcpt = nullptr;
|
||||
rcpt = curl_slist_append(rcpt, to.c_str());
|
||||
curl_easy_setopt(curl, CURLOPT_MAIL_RCPT, rcpt);
|
||||
|
||||
if (!cfg.username.empty()) {
|
||||
curl_easy_setopt(curl, CURLOPT_USERNAME, cfg.username.c_str());
|
||||
curl_easy_setopt(curl, CURLOPT_PASSWORD, cfg.password.c_str());
|
||||
}
|
||||
// authkit#16 L-2: require TLS for non-loopback relays. CURLUSESSL_TRY would
|
||||
// silently fall back to cleartext if STARTTLS is unavailable or stripped by
|
||||
// a MITM, leaking the SMTP AUTH credentials and message body. A local relay
|
||||
// (localhost / pipe transport) stays on TRY with verification relaxed since
|
||||
// there's no network hop to protect.
|
||||
const bool loopbackRelay = (cfg.host == "localhost" || cfg.host == "127.0.0.1");
|
||||
curl_easy_setopt(curl, CURLOPT_USE_SSL, loopbackRelay ? CURLUSESSL_TRY : CURLUSESSL_ALL);
|
||||
if (loopbackRelay) {
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L);
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L);
|
||||
}
|
||||
|
||||
// Keep worker threads from blocking on a dead mail server indefinitely.
|
||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 10L);
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L);
|
||||
|
||||
// Build MIME body: text/html body first, then attachments.
|
||||
curl_mime* mime = curl_mime_init(curl);
|
||||
|
||||
curl_mimepart* bodyPart = curl_mime_addpart(mime);
|
||||
curl_mime_data(bodyPart, htmlBody.c_str(), (curl_off_t)htmlBody.size());
|
||||
curl_mime_type(bodyPart, "text/html; charset=utf-8");
|
||||
curl_mime_encoder(bodyPart, "quoted-printable");
|
||||
|
||||
for (const auto& [fname, fcontent] : attachments) {
|
||||
curl_mimepart* apart = curl_mime_addpart(mime);
|
||||
curl_mime_data(apart, fcontent.c_str(), (curl_off_t)fcontent.size());
|
||||
curl_mime_filename(apart, fname.c_str());
|
||||
curl_mime_encoder(apart, "base64");
|
||||
std::string mtype = "application/octet-stream";
|
||||
if (fname.size() > 4) {
|
||||
std::string ext = fname.substr(fname.size() - 4);
|
||||
if (ext == ".pdf") mtype = "application/pdf";
|
||||
else if (ext == ".ics") mtype = "text/calendar; charset=utf-8";
|
||||
}
|
||||
curl_mime_type(apart, mtype.c_str());
|
||||
}
|
||||
|
||||
curl_easy_setopt(curl, CURLOPT_MIMEPOST, mime);
|
||||
|
||||
// RFC 2047 encoded-word Subject so non-ASCII survives.
|
||||
std::string encodedSubject = "=?UTF-8?B?" + base64Encode(subject) + "?=";
|
||||
struct curl_slist* hdrs = nullptr;
|
||||
hdrs = curl_slist_append(hdrs, ("From: " + cfg.fromAddress).c_str());
|
||||
hdrs = curl_slist_append(hdrs, ("To: " + to).c_str());
|
||||
hdrs = curl_slist_append(hdrs, ("Subject: " + encodedSubject).c_str());
|
||||
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, hdrs);
|
||||
|
||||
CURLcode res = curl_easy_perform(curl);
|
||||
std::string err;
|
||||
if (res != CURLE_OK) err = std::string(curl_easy_strerror(res));
|
||||
|
||||
curl_mime_free(mime);
|
||||
curl_slist_free_all(rcpt);
|
||||
curl_slist_free_all(hdrs);
|
||||
curl_easy_cleanup(curl);
|
||||
return err;
|
||||
}
|
||||
|
||||
} // namespace oatpp_authkit::mail
|
||||
|
||||
#endif // oatpp_authkit_mail_SmtpTransport_hpp
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
#ifndef OATPP_AUTHKIT_REPO_ACTOR_CONTEXT_HPP
|
||||
#define OATPP_AUTHKIT_REPO_ACTOR_CONTEXT_HPP
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace oatpp_authkit::repo {
|
||||
|
||||
/**
|
||||
* @brief Who is performing a repository action, plus what they're scoped to.
|
||||
*
|
||||
* Passed to the scope-guard decorator predicate (added in oatpp-authkit#8) so
|
||||
* resource-level authorisation can be evaluated outside the concrete repo.
|
||||
*
|
||||
* Kept deliberately minimal — consumers extend by composing this struct into
|
||||
* a richer per-app context if needed. The fields here are the union of what
|
||||
* the fewo-webapp property-scope guard needs (user id + a list of allowed
|
||||
* resource ids) and nothing more.
|
||||
*/
|
||||
struct ActorContext {
|
||||
std::string userId;
|
||||
std::vector<std::string> allowedScopes; ///< Opaque ids; consumer decides their meaning (property ids, tenant ids, …).
|
||||
};
|
||||
|
||||
} // namespace oatpp_authkit::repo
|
||||
|
||||
#endif
|
||||
|
|
@ -1,194 +0,0 @@
|
|||
#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
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
#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
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
#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
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
#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
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
#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
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
#ifndef OATPP_AUTHKIT_REPO_I_HISTORY_REPOSITORY_HPP
|
||||
#define OATPP_AUTHKIT_REPO_I_HISTORY_REPOSITORY_HPP
|
||||
|
||||
#include "oatpp/core/Types.hpp"
|
||||
|
||||
namespace oatpp_authkit::repo {
|
||||
|
||||
/**
|
||||
* @brief All historical versions for a temporal entity.
|
||||
*
|
||||
* Kept separate from `Repository<T>` deliberately — non-temporal repos
|
||||
* (caches, lookup tables, anything without `valid_from` / `valid_until`)
|
||||
* don't have a meaningful answer to `history()` and shouldn't be forced
|
||||
* to implement a stub. The temporal decorator in oatpp-authkit#8 is the
|
||||
* canonical implementer.
|
||||
*
|
||||
* Returns versions ordered ascending by `valid_from`, oldest first. An
|
||||
* empty vector means the entity id was never seen.
|
||||
*/
|
||||
template <class TDto>
|
||||
class IHistoryRepository {
|
||||
public:
|
||||
virtual ~IHistoryRepository() = default;
|
||||
|
||||
virtual oatpp::Vector<oatpp::Object<TDto>>
|
||||
history(const oatpp::String& entityId) = 0;
|
||||
};
|
||||
|
||||
} // namespace oatpp_authkit::repo
|
||||
|
||||
#endif
|
||||
|
|
@ -1,379 +0,0 @@
|
|||
#ifndef OATPP_AUTHKIT_REPO_IQUERYABLE_HPP
|
||||
#define OATPP_AUTHKIT_REPO_IQUERYABLE_HPP
|
||||
|
||||
// Optional IQueryable<T> capability for the Repository<T> layer (authkit#9).
|
||||
//
|
||||
// A typed query DSL that emits parameterised SQL plus a bind bag. Bounded to
|
||||
// equality / range / IN / LIKE / NULL / AND / OR / NOT / ORDER BY /
|
||||
// LIMIT / OFFSET — no joins, subqueries, or aggregates. Concrete repos opt
|
||||
// into the capability by deriving from `IQueryable<TDto>` and translating
|
||||
// `Query<T>::toSql()` into their underlying store's prepared statements.
|
||||
|
||||
#include "oatpp-authkit/repo/Repository.hpp"
|
||||
#include "oatpp/core/Types.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <initializer_list>
|
||||
#include <memory>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
#include <variant>
|
||||
#include <vector>
|
||||
|
||||
namespace oatpp_authkit::repo {
|
||||
|
||||
// ─── Schema registration ────────────────────────────────────────────────────
|
||||
//
|
||||
// Concrete DTOs register their column / table names by specialising these
|
||||
// two function templates. The primary templates are intentionally declared
|
||||
// without a definition: forgetting to register a field is a hard compile or
|
||||
// link error rather than a runtime surprise.
|
||||
//
|
||||
// SECURITY INVARIANT (authkit#16 L-8): column and table *identifiers* are
|
||||
// emitted into SQL unparameterised (SQL placeholders can't bind identifiers).
|
||||
// They come ONLY from these compile-time registrations / `Field<&Dto::mem>`,
|
||||
// never from request data — so there is no injection vector. Never construct
|
||||
// an `OrderBySpec`/`Field` column name from a runtime/user string; map a
|
||||
// client sort field to a registered `Field` via an allowlist first. All
|
||||
// *values* (eq/ne/in/like/...) are always bound as `?` parameters.
|
||||
|
||||
template <auto MemPtr>
|
||||
const char* columnName();
|
||||
|
||||
template <typename TDto>
|
||||
const char* tableName();
|
||||
|
||||
#define OATPP_AUTHKIT_REGISTER_FIELD(Dto, mem, colName) \
|
||||
template <> \
|
||||
inline const char* \
|
||||
::oatpp_authkit::repo::columnName<&Dto::mem>() { return colName; }
|
||||
|
||||
#define OATPP_AUTHKIT_REGISTER_TABLE(Dto, name) \
|
||||
template <> \
|
||||
inline const char* \
|
||||
::oatpp_authkit::repo::tableName<Dto>() { return name; }
|
||||
|
||||
// ─── Bind values ────────────────────────────────────────────────────────────
|
||||
|
||||
using BindValue = std::variant<std::monostate, // null
|
||||
std::int64_t,
|
||||
double,
|
||||
std::string,
|
||||
bool>;
|
||||
|
||||
inline BindValue toBindValue(std::nullptr_t) { return BindValue{}; }
|
||||
inline BindValue toBindValue(bool v) { return BindValue{v}; }
|
||||
inline BindValue toBindValue(int v) { return BindValue{static_cast<std::int64_t>(v)}; }
|
||||
inline BindValue toBindValue(long v) { return BindValue{static_cast<std::int64_t>(v)}; }
|
||||
inline BindValue toBindValue(long long v) { return BindValue{static_cast<std::int64_t>(v)}; }
|
||||
inline BindValue toBindValue(unsigned v) { return BindValue{static_cast<std::int64_t>(v)}; }
|
||||
inline BindValue toBindValue(double v) { return BindValue{v}; }
|
||||
inline BindValue toBindValue(float v) { return BindValue{static_cast<double>(v)}; }
|
||||
inline BindValue toBindValue(const char* v) { return BindValue{std::string(v)}; }
|
||||
inline BindValue toBindValue(const std::string& v) { return BindValue{v}; }
|
||||
inline BindValue toBindValue(const oatpp::String& v) {
|
||||
return v ? BindValue{std::string(*v)} : BindValue{};
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Escape LIKE wildcards (`%`, `_`) and the escape char (`\`) in a
|
||||
* user-supplied search term so they're matched literally (authkit#16
|
||||
* L-8). Pair with the `LIKE ? ESCAPE '\'` clause emitted by
|
||||
* `Field::likeContains` / `Field::likePrefix`.
|
||||
*/
|
||||
inline std::string likeEscape(const std::string& term) {
|
||||
std::string out;
|
||||
out.reserve(term.size() + 4);
|
||||
for (char c : term) {
|
||||
if (c == '\\' || c == '%' || c == '_') out.push_back('\\');
|
||||
out.push_back(c);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── AST nodes ──────────────────────────────────────────────────────────────
|
||||
|
||||
class AstNode {
|
||||
public:
|
||||
virtual ~AstNode() = default;
|
||||
virtual void emit(std::ostringstream& sql,
|
||||
std::vector<BindValue>& binds) const = 0;
|
||||
};
|
||||
|
||||
class CompareNode : public AstNode {
|
||||
std::string col_;
|
||||
const char* op_;
|
||||
BindValue val_;
|
||||
public:
|
||||
CompareNode(std::string c, const char* o, BindValue v)
|
||||
: col_(std::move(c)), op_(o), val_(std::move(v)) {}
|
||||
void emit(std::ostringstream& sql,
|
||||
std::vector<BindValue>& binds) const override {
|
||||
sql << col_ << ' ' << op_ << " ?";
|
||||
binds.push_back(val_);
|
||||
}
|
||||
};
|
||||
|
||||
class InNode : public AstNode {
|
||||
std::string col_;
|
||||
std::vector<BindValue> vals_;
|
||||
public:
|
||||
InNode(std::string c, std::vector<BindValue> vs)
|
||||
: col_(std::move(c)), vals_(std::move(vs)) {}
|
||||
void emit(std::ostringstream& sql,
|
||||
std::vector<BindValue>& binds) const override {
|
||||
if (vals_.empty()) { sql << "0"; return; } // empty IN ⇒ always false
|
||||
sql << col_ << " IN (";
|
||||
for (std::size_t i = 0; i < vals_.size(); ++i) {
|
||||
if (i) sql << ", ";
|
||||
sql << "?";
|
||||
binds.push_back(vals_[i]);
|
||||
}
|
||||
sql << ")";
|
||||
}
|
||||
};
|
||||
|
||||
class IsNullNode : public AstNode {
|
||||
std::string col_;
|
||||
bool isNull_;
|
||||
public:
|
||||
IsNullNode(std::string c, bool n) : col_(std::move(c)), isNull_(n) {}
|
||||
void emit(std::ostringstream& sql,
|
||||
std::vector<BindValue>&) const override {
|
||||
sql << col_ << (isNull_ ? " IS NULL" : " IS NOT NULL");
|
||||
}
|
||||
};
|
||||
|
||||
/** @brief `col LIKE ? ESCAPE '\'` — the explicit ESCAPE makes a `\`-escaped
|
||||
* pattern (see `likeEscape`) treat `%`/`_` literally. */
|
||||
class LikeNode : public AstNode {
|
||||
std::string col_;
|
||||
BindValue val_;
|
||||
public:
|
||||
LikeNode(std::string c, BindValue v) : col_(std::move(c)), val_(std::move(v)) {}
|
||||
void emit(std::ostringstream& sql,
|
||||
std::vector<BindValue>& binds) const override {
|
||||
sql << col_ << " LIKE ? ESCAPE '\\'";
|
||||
binds.push_back(val_);
|
||||
}
|
||||
};
|
||||
|
||||
class CombineNode : public AstNode {
|
||||
const char* sep_;
|
||||
std::vector<std::shared_ptr<AstNode>> children_;
|
||||
public:
|
||||
CombineNode(const char* sep,
|
||||
std::vector<std::shared_ptr<AstNode>> kids)
|
||||
: sep_(sep), children_(std::move(kids)) {}
|
||||
void emit(std::ostringstream& sql,
|
||||
std::vector<BindValue>& binds) const override {
|
||||
if (children_.empty()) { sql << "1"; return; }
|
||||
sql << "(";
|
||||
for (std::size_t i = 0; i < children_.size(); ++i) {
|
||||
if (i) sql << ' ' << sep_ << ' ';
|
||||
children_[i]->emit(sql, binds);
|
||||
}
|
||||
sql << ")";
|
||||
}
|
||||
};
|
||||
|
||||
class NotNode : public AstNode {
|
||||
std::shared_ptr<AstNode> child_;
|
||||
public:
|
||||
explicit NotNode(std::shared_ptr<AstNode> c) : child_(std::move(c)) {}
|
||||
void emit(std::ostringstream& sql,
|
||||
std::vector<BindValue>& binds) const override {
|
||||
sql << "NOT (";
|
||||
child_->emit(sql, binds);
|
||||
sql << ")";
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Predicate composition wrapper ──────────────────────────────────────────
|
||||
|
||||
class Predicate {
|
||||
std::shared_ptr<AstNode> node_;
|
||||
public:
|
||||
Predicate() = default;
|
||||
explicit Predicate(std::shared_ptr<AstNode> n) : node_(std::move(n)) {}
|
||||
|
||||
bool empty() const noexcept { return !node_; }
|
||||
std::shared_ptr<AstNode> node() const noexcept { return node_; }
|
||||
|
||||
void emit(std::ostringstream& sql,
|
||||
std::vector<BindValue>& binds) const {
|
||||
if (node_) node_->emit(sql, binds);
|
||||
}
|
||||
|
||||
friend Predicate operator&&(const Predicate& a, const Predicate& b) {
|
||||
if (a.empty()) return b;
|
||||
if (b.empty()) return a;
|
||||
return Predicate{std::make_shared<CombineNode>(
|
||||
"AND", std::vector<std::shared_ptr<AstNode>>{a.node_, b.node_})};
|
||||
}
|
||||
friend Predicate operator||(const Predicate& a, const Predicate& b) {
|
||||
if (a.empty()) return b;
|
||||
if (b.empty()) return a;
|
||||
return Predicate{std::make_shared<CombineNode>(
|
||||
"OR", std::vector<std::shared_ptr<AstNode>>{a.node_, b.node_})};
|
||||
}
|
||||
friend Predicate operator!(const Predicate& a) {
|
||||
if (a.empty()) return a;
|
||||
return Predicate{std::make_shared<NotNode>(a.node_)};
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Field references ───────────────────────────────────────────────────────
|
||||
//
|
||||
// `field<&PersonDto::email>().eq("foo@bar")` resolves the column name through
|
||||
// the `columnName<>` specialisation. Comparison methods return `Predicate`s
|
||||
// that compose with `operator&&` / `||` / `!`.
|
||||
|
||||
template <auto MemPtr>
|
||||
class Field {
|
||||
public:
|
||||
const char* column() const { return columnName<MemPtr>(); }
|
||||
|
||||
template <typename V> Predicate eq(V&& v) const { return mk("=", std::forward<V>(v)); }
|
||||
template <typename V> Predicate ne(V&& v) const { return mk("!=", std::forward<V>(v)); }
|
||||
template <typename V> Predicate lt(V&& v) const { return mk("<", std::forward<V>(v)); }
|
||||
template <typename V> Predicate gt(V&& v) const { return mk(">", std::forward<V>(v)); }
|
||||
template <typename V> Predicate le(V&& v) const { return mk("<=", std::forward<V>(v)); }
|
||||
template <typename V> Predicate ge(V&& v) const { return mk(">=", std::forward<V>(v)); }
|
||||
|
||||
template <typename C>
|
||||
Predicate in(const C& values) const {
|
||||
std::vector<BindValue> bs;
|
||||
for (auto& v : values) bs.push_back(toBindValue(v));
|
||||
return Predicate{std::make_shared<InNode>(column(), std::move(bs))};
|
||||
}
|
||||
template <typename V>
|
||||
Predicate in(std::initializer_list<V> values) const {
|
||||
std::vector<BindValue> bs;
|
||||
for (auto& v : values) bs.push_back(toBindValue(v));
|
||||
return Predicate{std::make_shared<InNode>(column(), std::move(bs))};
|
||||
}
|
||||
|
||||
/** @brief Raw `col LIKE ?` with the pattern bound verbatim. The caller owns
|
||||
* the `%`/`_` wildcards — only pass a TRUSTED pattern here. For a
|
||||
* user-supplied search term use `likeContains` / `likePrefix` (which
|
||||
* escape the metacharacters), or wrap it with `likeEscape`. */
|
||||
Predicate like(const std::string& pat) const {
|
||||
return Predicate{std::make_shared<CompareNode>(
|
||||
column(), "LIKE", BindValue{pat})};
|
||||
}
|
||||
|
||||
/** @brief Substring match of a user-supplied `term` with LIKE wildcards
|
||||
* escaped — emits `col LIKE '%<escaped>%' ESCAPE '\'` (authkit#16 L-8). */
|
||||
Predicate likeContains(const std::string& term) const {
|
||||
return Predicate{std::make_shared<LikeNode>(
|
||||
column(), BindValue{"%" + likeEscape(term) + "%"})};
|
||||
}
|
||||
|
||||
/** @brief Prefix match of a user-supplied `term` with LIKE wildcards
|
||||
* escaped — emits `col LIKE '<escaped>%' ESCAPE '\'`. */
|
||||
Predicate likePrefix(const std::string& term) const {
|
||||
return Predicate{std::make_shared<LikeNode>(
|
||||
column(), BindValue{likeEscape(term) + "%"})};
|
||||
}
|
||||
Predicate isNull() const { return Predicate{std::make_shared<IsNullNode>(column(), true)}; }
|
||||
Predicate isNotNull() const { return Predicate{std::make_shared<IsNullNode>(column(), false)}; }
|
||||
|
||||
private:
|
||||
template <typename V>
|
||||
Predicate mk(const char* op, V&& v) const {
|
||||
return Predicate{std::make_shared<CompareNode>(
|
||||
column(), op, toBindValue(std::forward<V>(v)))};
|
||||
}
|
||||
};
|
||||
|
||||
template <auto MemPtr>
|
||||
inline Field<MemPtr> field() { return Field<MemPtr>{}; }
|
||||
|
||||
// ─── Query builder ──────────────────────────────────────────────────────────
|
||||
|
||||
struct OrderBySpec {
|
||||
std::string column;
|
||||
bool ascending;
|
||||
};
|
||||
|
||||
template <typename TDto>
|
||||
class Query {
|
||||
Predicate where_;
|
||||
std::vector<OrderBySpec> orderBy_;
|
||||
std::int64_t limit_ = -1;
|
||||
std::int64_t offset_ = 0;
|
||||
public:
|
||||
Query& where(Predicate p) {
|
||||
where_ = where_.empty() ? std::move(p) : (where_ && std::move(p));
|
||||
return *this;
|
||||
}
|
||||
template <auto MemPtr>
|
||||
Query& orderBy(Field<MemPtr> f, bool ascending = true) {
|
||||
orderBy_.push_back({f.column(), ascending});
|
||||
return *this;
|
||||
}
|
||||
template <auto MemPtr>
|
||||
Query& orderByDesc(Field<MemPtr> f) { return orderBy(f, false); }
|
||||
|
||||
Query& limit(std::int64_t n) { limit_ = n; return *this; }
|
||||
Query& offset(std::int64_t n) { offset_ = n; return *this; }
|
||||
|
||||
const Predicate& wherePredicate() const { return where_; }
|
||||
const std::vector<OrderBySpec>& orderBySpecs() const { return orderBy_; }
|
||||
std::int64_t limitValue() const { return limit_; }
|
||||
std::int64_t offsetValue() const { return offset_; }
|
||||
|
||||
/**
|
||||
* Render the query as a parameterised `SELECT * FROM <table> ...`.
|
||||
* Concrete repositories take the returned text + bind bag and feed
|
||||
* them into their underlying prepared-statement mechanism.
|
||||
*/
|
||||
struct Sql {
|
||||
std::string text;
|
||||
std::vector<BindValue> binds;
|
||||
};
|
||||
Sql toSql() const {
|
||||
std::ostringstream s;
|
||||
std::vector<BindValue> binds;
|
||||
s << "SELECT * FROM " << tableName<TDto>();
|
||||
if (!where_.empty()) {
|
||||
s << " WHERE ";
|
||||
where_.emit(s, binds);
|
||||
}
|
||||
if (!orderBy_.empty()) {
|
||||
s << " ORDER BY ";
|
||||
for (std::size_t i = 0; i < orderBy_.size(); ++i) {
|
||||
if (i) s << ", ";
|
||||
s << orderBy_[i].column
|
||||
<< (orderBy_[i].ascending ? " ASC" : " DESC");
|
||||
}
|
||||
}
|
||||
if (limit_ >= 0) s << " LIMIT " << limit_;
|
||||
if (offset_ > 0) s << " OFFSET " << offset_;
|
||||
return {s.str(), std::move(binds)};
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Capability interface ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @brief Optional capability for repositories that can resolve a typed `Query`.
|
||||
*
|
||||
* Concrete repos derive from `IQueryable<TDto>` (instead of plain
|
||||
* `Repository<TDto>`) when they want to expose AST-driven filtering.
|
||||
* Decorators stay agnostic — they wrap `Repository<TDto>` and downcast only
|
||||
* when a caller specifically asks for the queryable surface.
|
||||
*/
|
||||
template <typename TDto>
|
||||
class IQueryable : public Repository<TDto> {
|
||||
public:
|
||||
virtual oatpp::Vector<oatpp::Object<TDto>>
|
||||
query(const Query<TDto>& q) = 0;
|
||||
};
|
||||
|
||||
} // namespace oatpp_authkit::repo
|
||||
|
||||
#endif
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
#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
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
#ifndef OATPP_AUTHKIT_REPO_REPOSITORY_HPP
|
||||
#define OATPP_AUTHKIT_REPO_REPOSITORY_HPP
|
||||
|
||||
#include "oatpp/core/Types.hpp"
|
||||
|
||||
namespace oatpp_authkit::repo {
|
||||
|
||||
/**
|
||||
* @brief Pure-abstract per-DTO repository interface.
|
||||
*
|
||||
* Generic on `TDto`, temporal-agnostic. Concrete adapters wrap an
|
||||
* `oatpp::orm::DbClient` (or any other store) and implement the four
|
||||
* methods below. Cross-cutting concerns (temporal versioning, scope
|
||||
* authorisation) are added by stacking decorators from oatpp-authkit#8
|
||||
* around the concrete adapter at construction time.
|
||||
*
|
||||
* @section semantics Method semantics
|
||||
*
|
||||
* - `findByEntityId(entityId)` — Single live row matching `entity_id`.
|
||||
* Returns null `oatpp::Object` when not found. The decorator that adds
|
||||
* point-in-time reads exposes a different method (`findByEntityId(id, at)`)
|
||||
* on its own surface; the abstract here stays narrow.
|
||||
*
|
||||
* - `list()` — All live rows for this entity type, no filtering. Filtered
|
||||
* reads land in the optional `IQueryable<T>` capability tracked by
|
||||
* oatpp-authkit#9; do not bake filter predicates into this base interface.
|
||||
*
|
||||
* - `save(dto)` — Mixed `entity_id` allocation:
|
||||
* - If `dto->entity_id` is null on entry, the implementation generates
|
||||
* a fresh UUID and writes it back to the DTO before persisting.
|
||||
* - If `dto->entity_id` is non-null, it is used as-is.
|
||||
* No upsert semantics are implied at this layer — the temporal decorator
|
||||
* in oatpp-authkit#8 turns "save" into a versioning insert; without that
|
||||
* decorator the concrete repo decides whether `save` is insert-or-update
|
||||
* on its own.
|
||||
*
|
||||
* - `softDelete(entityId)` — Marks the row removed without erasing history.
|
||||
* Concrete repos typically set a `deleted_at` column or its equivalent.
|
||||
*
|
||||
* @section design Design decisions (all settled in the issue body)
|
||||
*
|
||||
* 1. `entity_id` allocation is mixed (caller may supply or leave null).
|
||||
* 2. UnitOfWork / cross-repo transactions are explicitly out of scope.
|
||||
* 3. `Repository<T>` is a virtual-method interface, not a C++20 concept.
|
||||
* 4. History queries live on a separate `IHistoryRepository<T>` so non-
|
||||
* temporal repos don't have to implement them.
|
||||
*/
|
||||
template <class TDto>
|
||||
class Repository {
|
||||
public:
|
||||
virtual ~Repository() = default;
|
||||
|
||||
/** @brief Single live row by stable entity id. Null oatpp::Object when not found. */
|
||||
virtual oatpp::Object<TDto> findByEntityId(const oatpp::String& entityId) = 0;
|
||||
|
||||
/** @brief All live rows for this entity type (no filtering at this layer). */
|
||||
virtual oatpp::Vector<oatpp::Object<TDto>> list() = 0;
|
||||
|
||||
/** @brief Persist DTO; allocate UUID for `entity_id` if null on entry. */
|
||||
virtual void save(const oatpp::Object<TDto>& dto) = 0;
|
||||
|
||||
/** @brief Mark the row removed without erasing it. */
|
||||
virtual void softDelete(const oatpp::String& entityId) = 0;
|
||||
};
|
||||
|
||||
} // namespace oatpp_authkit::repo
|
||||
|
||||
#endif
|
||||
|
|
@ -1,289 +0,0 @@
|
|||
#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
|
||||
|
|
@ -1,210 +0,0 @@
|
|||
#ifndef OATPP_AUTHKIT_REPO_SCOPE_GUARD_REPOSITORY_HPP
|
||||
#define OATPP_AUTHKIT_REPO_SCOPE_GUARD_REPOSITORY_HPP
|
||||
|
||||
#include "oatpp-authkit/repo/Repository.hpp"
|
||||
#include "oatpp-authkit/repo/ActorContext.hpp"
|
||||
#include "oatpp-authkit/repo/SchemaContract.hpp"
|
||||
#include "oatpp-authkit/repo/IQueryable.hpp"
|
||||
|
||||
#include "oatpp/core/Types.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
#include <utility>
|
||||
|
||||
namespace oatpp_authkit::repo {
|
||||
|
||||
/**
|
||||
* @brief Thrown when the scope guard predicate denies an operation.
|
||||
*
|
||||
* Catchers (typically the controller layer) translate this into the
|
||||
* appropriate HTTP error — 403 Forbidden in fewo-webapp's case. The
|
||||
* decorator stays library-portable by throwing a plain exception rather
|
||||
* than coupling to oatpp's `OatppException` hierarchy.
|
||||
*/
|
||||
class ScopeDeniedException : public std::runtime_error {
|
||||
public:
|
||||
using std::runtime_error::runtime_error;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Decorator that gates every repository operation on a predicate.
|
||||
*
|
||||
* Generic — knows nothing about "property" / "tenant" / any consumer-
|
||||
* specific scope concept. The predicate decides; this class just calls it.
|
||||
*
|
||||
* @section semantics Per-method behaviour
|
||||
*
|
||||
* - `findByEntityId(id)`: load from inner; if non-null and predicate
|
||||
* denies, throw `ScopeDeniedException`. (Information-leak vs. clean
|
||||
* error tradeoff: throwing is the safer default — callers that want to
|
||||
* silently 404 instead can catch and translate.)
|
||||
* - `list()`: load from inner; filter out rows the predicate denies.
|
||||
* - `save(dto)`: predicate must pass on the incoming dto AND, for an update,
|
||||
* on the row as it currently stands. Checking only the incoming dto would
|
||||
* let an actor reparent an out-of-scope row into its own scope by setting
|
||||
* the scope field in the request body (BOLA / set-your-own-scope). The
|
||||
* existing-row lookup uses the constructor-injected `entityIdOf` accessor.
|
||||
* - `softDelete(id)`: load from inner; if denied, throw; otherwise delegate.
|
||||
*
|
||||
* The actor is provided via a constructor-injected accessor so a single
|
||||
* `ScopeGuardRepository` instance can serve many requests with different
|
||||
* actors (typically the accessor reads from the per-request authenticated
|
||||
* principal — fewo-webapp's `AuthInterceptor` populates one).
|
||||
*
|
||||
* @note `IQueryable<TDto>` is a *separate* data-access surface. Wrapping an
|
||||
* `IQueryable` repo in this decorator does NOT guard `query()` — a
|
||||
* caller that obtains the inner queryable would bypass the scope
|
||||
* predicate entirely. Use `ScopeGuardQueryable<TDto>` (below) when the
|
||||
* inner exposes the queryable capability.
|
||||
*/
|
||||
template <class TDto>
|
||||
class ScopeGuardRepository : public Repository<TDto> {
|
||||
public:
|
||||
using Predicate = std::function<bool(const ActorContext&, const oatpp::Object<TDto>&)>;
|
||||
using ActorAccess = std::function<ActorContext()>;
|
||||
/// Extracts the stable `entity_id` from a DTO (e.g. `[](auto& d){ return d->entity_id; }`).
|
||||
/// Used to load the existing row on `save()` so an update can't reparent an
|
||||
/// out-of-scope row. Returns null for a not-yet-allocated entity (fresh insert).
|
||||
using EntityIdAccess = std::function<oatpp::String(const oatpp::Object<TDto>&)>;
|
||||
|
||||
/// Declarative schema contribution (authkit#14, D-replace).
|
||||
/// ScopeGuard touches no schema — empty contributions exposed so it
|
||||
/// composes cleanly into `SchemaBuilder<…>` parameter packs.
|
||||
inline static constexpr DecoratorSchema kSchema = {
|
||||
"ScopeGuardRepository",
|
||||
nullptr, 0,
|
||||
nullptr, 0,
|
||||
nullptr, 0,
|
||||
};
|
||||
|
||||
ScopeGuardRepository(std::shared_ptr<Repository<TDto>> inner,
|
||||
Predicate isAllowed,
|
||||
ActorAccess currentActor,
|
||||
EntityIdAccess entityIdOf)
|
||||
: m_inner(std::move(inner))
|
||||
, m_isAllowed(std::move(isAllowed))
|
||||
, m_currentActor(std::move(currentActor))
|
||||
, m_entityIdOf(std::move(entityIdOf))
|
||||
{}
|
||||
|
||||
oatpp::Object<TDto> findByEntityId(const oatpp::String& entityId) override {
|
||||
auto row = m_inner->findByEntityId(entityId);
|
||||
if (!row) return row;
|
||||
if (!m_isAllowed(m_currentActor(), row)) {
|
||||
throw ScopeDeniedException("scope guard denied findByEntityId");
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
oatpp::Vector<oatpp::Object<TDto>> list() override {
|
||||
auto inAll = m_inner->list();
|
||||
auto out = oatpp::Vector<oatpp::Object<TDto>>::createShared();
|
||||
const ActorContext actor = m_currentActor();
|
||||
for (auto& row : *inAll) {
|
||||
if (m_isAllowed(actor, row)) out->push_back(row);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
void save(const oatpp::Object<TDto>& dto) override {
|
||||
const ActorContext actor = m_currentActor();
|
||||
// 1. The incoming DTO must be in scope — you can't write into a scope
|
||||
// you don't own.
|
||||
if (!m_isAllowed(actor, dto)) {
|
||||
throw ScopeDeniedException("scope guard denied save (incoming)");
|
||||
}
|
||||
// 2. If this is an update of an existing entity, the row as it stands
|
||||
// NOW must also be in scope. Otherwise an actor scoped to A could
|
||||
// take an entity currently owned by B and reparent it into A simply
|
||||
// by setting the scope field in the body. A fresh insert has a null
|
||||
// entity_id (or no matching row) and skips this check.
|
||||
if (m_entityIdOf) {
|
||||
auto eid = m_entityIdOf(dto);
|
||||
if (eid) {
|
||||
auto existing = m_inner->findByEntityId(eid);
|
||||
if (existing && !m_isAllowed(actor, existing)) {
|
||||
throw ScopeDeniedException("scope guard denied save (existing row out of scope)");
|
||||
}
|
||||
}
|
||||
}
|
||||
m_inner->save(dto);
|
||||
}
|
||||
|
||||
void softDelete(const oatpp::String& entityId) override {
|
||||
auto row = m_inner->findByEntityId(entityId);
|
||||
if (!row) return; // Nothing to delete; matches Repository<T>::softDelete being a no-op for unknown ids.
|
||||
if (!m_isAllowed(m_currentActor(), row)) {
|
||||
throw ScopeDeniedException("scope guard denied softDelete");
|
||||
}
|
||||
m_inner->softDelete(entityId);
|
||||
}
|
||||
|
||||
private:
|
||||
std::shared_ptr<Repository<TDto>> m_inner;
|
||||
Predicate m_isAllowed;
|
||||
ActorAccess m_currentActor;
|
||||
EntityIdAccess m_entityIdOf;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief `ScopeGuardRepository` for inners that also expose `IQueryable<TDto>`.
|
||||
*
|
||||
* `ScopeGuardRepository` guards only the four `Repository<TDto>` methods; the
|
||||
* `IQueryable::query()` surface is separate, so a scope-guarded `IQueryable`
|
||||
* repo would otherwise leak every row a raw query returns. This decorator
|
||||
* closes that hole: it implements `IQueryable<TDto>`, delegates the CRUD
|
||||
* methods to an embedded `ScopeGuardRepository` (same predicate / actor /
|
||||
* entity-id semantics, including the reparenting check), and post-filters
|
||||
* `query()` results through the predicate exactly like `list()` does.
|
||||
*
|
||||
* Wire this — not the plain `ScopeGuardRepository` — whenever the concrete
|
||||
* inner derives from `IQueryable<TDto>`.
|
||||
*/
|
||||
template <class TDto>
|
||||
class ScopeGuardQueryable : public IQueryable<TDto> {
|
||||
public:
|
||||
using Predicate = typename ScopeGuardRepository<TDto>::Predicate;
|
||||
using ActorAccess = typename ScopeGuardRepository<TDto>::ActorAccess;
|
||||
using EntityIdAccess = typename ScopeGuardRepository<TDto>::EntityIdAccess;
|
||||
|
||||
ScopeGuardQueryable(std::shared_ptr<IQueryable<TDto>> inner,
|
||||
Predicate isAllowed,
|
||||
ActorAccess currentActor,
|
||||
EntityIdAccess entityIdOf)
|
||||
: m_inner(std::move(inner))
|
||||
, m_isAllowed(isAllowed)
|
||||
, m_currentActor(currentActor)
|
||||
, m_guard(m_inner, std::move(isAllowed), std::move(currentActor), std::move(entityIdOf))
|
||||
{}
|
||||
|
||||
oatpp::Object<TDto> findByEntityId(const oatpp::String& entityId) override {
|
||||
return m_guard.findByEntityId(entityId);
|
||||
}
|
||||
oatpp::Vector<oatpp::Object<TDto>> list() override { return m_guard.list(); }
|
||||
void save(const oatpp::Object<TDto>& dto) override { m_guard.save(dto); }
|
||||
void softDelete(const oatpp::String& entityId) override { m_guard.softDelete(entityId); }
|
||||
|
||||
/** @brief Run the inner query, then drop every row the predicate denies. */
|
||||
oatpp::Vector<oatpp::Object<TDto>> query(const Query<TDto>& q) override {
|
||||
auto rows = m_inner->query(q);
|
||||
auto out = oatpp::Vector<oatpp::Object<TDto>>::createShared();
|
||||
if (!rows) return out;
|
||||
const ActorContext actor = m_currentActor();
|
||||
for (auto& row : *rows) {
|
||||
if (m_isAllowed(actor, row)) out->push_back(row);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private:
|
||||
std::shared_ptr<IQueryable<TDto>> m_inner;
|
||||
Predicate m_isAllowed;
|
||||
ActorAccess m_currentActor;
|
||||
ScopeGuardRepository<TDto> m_guard;
|
||||
};
|
||||
|
||||
} // namespace oatpp_authkit::repo
|
||||
|
||||
#endif
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
#ifndef OATPP_AUTHKIT_REPO_TEMPORAL_AT_HPP
|
||||
#define OATPP_AUTHKIT_REPO_TEMPORAL_AT_HPP
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace oatpp_authkit::repo {
|
||||
|
||||
/**
|
||||
* @brief Point-in-time selector for temporal reads.
|
||||
*
|
||||
* Concrete repositories implementing temporal versioning use this to
|
||||
* choose between "live" (`valid_until = sentinel`) and "as-of a specific
|
||||
* timestamp" reads. The interface is decoupled from any particular clock
|
||||
* type; consumers pass milliseconds-since-epoch.
|
||||
*/
|
||||
struct TemporalAt {
|
||||
enum class Kind { Live, At };
|
||||
|
||||
Kind kind{Kind::Live};
|
||||
int64_t timestamp{0}; ///< Milliseconds since epoch; only meaningful when kind == At.
|
||||
|
||||
static TemporalAt live() {
|
||||
return TemporalAt{Kind::Live, 0};
|
||||
}
|
||||
|
||||
static TemporalAt at(int64_t ts) {
|
||||
return TemporalAt{Kind::At, ts};
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace oatpp_authkit::repo
|
||||
|
||||
#endif
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
#ifndef OATPP_AUTHKIT_REPO_TEMPORAL_FIELD_TRAITS_HPP
|
||||
#define OATPP_AUTHKIT_REPO_TEMPORAL_FIELD_TRAITS_HPP
|
||||
|
||||
#include "oatpp/core/Types.hpp"
|
||||
|
||||
namespace oatpp_authkit::repo {
|
||||
|
||||
/**
|
||||
* @brief Trait that tells `TemporalRepository<T>` where `T` keeps its
|
||||
* row PK, entity identity, valid_from and valid_until columns.
|
||||
*
|
||||
* Primary template is intentionally undefined — using
|
||||
* `TemporalFieldTraits<MyDto>` against a DTO that hasn't been registered
|
||||
* is a hard compile error pointing at the call site. Specialise with
|
||||
* `OATPP_AUTHKIT_REGISTER_TEMPORAL` once per temporal DTO.
|
||||
*
|
||||
* Each accessor returns `oatpp::String&` so the repository can both read
|
||||
* and rewrite the value. Field names on the DTO are arbitrary — the
|
||||
* trait is the canonical name, the DTO column is whatever the consumer
|
||||
* picked.
|
||||
*
|
||||
* Four canonical fields:
|
||||
* - `id` : per-row PK (version UUID). Preserved across in-place
|
||||
* updates of the live row; freshly allocated for each
|
||||
* historical copy.
|
||||
* - `entityId` : stable logical identity, shared by every version of
|
||||
* the same logical entity.
|
||||
* - `validFrom` : ISO-8601 timestamp when this version became effective.
|
||||
* - `validUntil` : ISO-8601 timestamp when this version ceased to be
|
||||
* effective; SENTINEL while live.
|
||||
*/
|
||||
template <class TDto>
|
||||
struct TemporalFieldTraits; // intentionally undefined
|
||||
|
||||
} // namespace oatpp_authkit::repo
|
||||
|
||||
/**
|
||||
* Register a temporal DTO with the trait machinery. Place at namespace
|
||||
* scope (typically right after the DTO definition):
|
||||
*
|
||||
* OATPP_AUTHKIT_REGISTER_TEMPORAL(PersonDto,
|
||||
* id, entity_id, valid_from, valid_until)
|
||||
*
|
||||
* The four trailing identifiers are the actual DTO_FIELD member names —
|
||||
* they don't have to match the canonical names. A DTO that uses
|
||||
* `effective_from` / `effective_until` registers exactly the same way.
|
||||
*
|
||||
* `IdMember` is the per-row PK. `EntityIdMember` is the stable logical
|
||||
* identity that's shared by every version of the same entity.
|
||||
*/
|
||||
#define OATPP_AUTHKIT_REGISTER_TEMPORAL(Dto, IdMember, EntityIdMember, FromMember, UntilMember) \
|
||||
namespace oatpp_authkit::repo { \
|
||||
template<> struct TemporalFieldTraits<Dto> { \
|
||||
static ::oatpp::String& id (const ::oatpp::Object<Dto>& d) { return d->IdMember; } \
|
||||
static ::oatpp::String& entityId (const ::oatpp::Object<Dto>& d) { return d->EntityIdMember; } \
|
||||
static ::oatpp::String& validFrom (const ::oatpp::Object<Dto>& d) { return d->FromMember; } \
|
||||
static ::oatpp::String& validUntil (const ::oatpp::Object<Dto>& d) { return d->UntilMember; } \
|
||||
}; \
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
@ -1,332 +0,0 @@
|
|||
#ifndef OATPP_AUTHKIT_REPO_TEMPORAL_REPOSITORY_HPP
|
||||
#define OATPP_AUTHKIT_REPO_TEMPORAL_REPOSITORY_HPP
|
||||
|
||||
#include "oatpp-authkit/repo/Repository.hpp"
|
||||
#include "oatpp-authkit/repo/IHistoryRepository.hpp"
|
||||
#include "oatpp-authkit/repo/TemporalFieldTraits.hpp"
|
||||
#include "oatpp-authkit/repo/TemporalAt.hpp"
|
||||
#include "oatpp-authkit/repo/SchemaContract.hpp"
|
||||
|
||||
#include "oatpp/core/Types.hpp"
|
||||
|
||||
#include <chrono>
|
||||
#include <ctime>
|
||||
#include <cstdio>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <random>
|
||||
#include <string>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace oatpp_authkit::repo {
|
||||
|
||||
/**
|
||||
* @brief Decorator that turns any `Repository<TDto>` into a temporally-versioned one.
|
||||
*
|
||||
* `TDto` must register a `TemporalFieldTraits<TDto>` specialisation (use
|
||||
* the `OATPP_AUTHKIT_REGISTER_TEMPORAL` macro right after the DTO
|
||||
* definition). The trait names the DTO members that hold the canonical
|
||||
* `entity_id`, `valid_from`, `valid_until` columns — actual member names
|
||||
* on the DTO are arbitrary, the trait does the mapping. Forgetting to
|
||||
* register surfaces as a hard compile error at the first trait use.
|
||||
*
|
||||
* @section contract Inner repository contract
|
||||
*
|
||||
* The wrapped inner `Repository<TDto>` is expected to:
|
||||
*
|
||||
* - Treat `save(dto)` as **upsert keyed by `id`** (the per-row PK). If
|
||||
* `dto->id` matches an existing row, UPDATE it in place. Otherwise
|
||||
* INSERT a new row.
|
||||
* - Treat `list()` as **all rows including historical ones** — no filtering
|
||||
* by `valid_until`. This decorator does the live-vs-historical filtering
|
||||
* itself.
|
||||
* - `findByEntityId` and `softDelete` on the inner are **not used by the
|
||||
* decorator**; the decorator overrides them with temporal-aware
|
||||
* implementations.
|
||||
*
|
||||
* @section semantics Decorator semantics
|
||||
*
|
||||
* Write semantics (authkit#13): **stable-live row + historical copy.**
|
||||
* The live row's `id` PK is preserved across every update; only its
|
||||
* mutable columns (and `valid_from`) change. Each prior version is
|
||||
* captured as a fresh row with a new `id`.
|
||||
*
|
||||
* - `save(dto)`:
|
||||
* - If no live row exists for `dto->entity_id` (or `entity_id` is null),
|
||||
* this is a fresh insert: allocate `entity_id` if null, set `id` if
|
||||
* null, `valid_from = now`, `valid_until = SENTINEL`, save.
|
||||
* - Otherwise:
|
||||
* 1. Clone the existing live row in memory (`b`). Give `b` a fresh `id`
|
||||
* and set `b.valid_until = now()`. Save `b` — it's the historical copy.
|
||||
* 2. Set `dto.id = liveRow.id` (preserve the live PK). Set
|
||||
* `dto.valid_from = now()`, `dto.valid_until = SENTINEL`. Save `dto`
|
||||
* — the inner UPDATEs the live row in place by PK.
|
||||
*
|
||||
* FK consequence: child rows referencing the live row via the composite
|
||||
* key `(entity_id, valid_until)` continue to resolve to the same row
|
||||
* identity throughout the operation; no FK deferral required.
|
||||
*
|
||||
* - `findByEntityId(id)` returns the row whose `valid_until == SENTINEL`.
|
||||
* - `findByEntityIdAt(id, at)` returns the version live at that timestamp.
|
||||
* - `list()` returns only live rows.
|
||||
* - `history(id)` returns all versions ordered ascending by `valid_from`.
|
||||
* - `softDelete(id)` closes the live row (sets its `valid_until = now`)
|
||||
* but does not insert a new version. With `ON UPDATE CASCADE` on every
|
||||
* composite child FK, child rows follow automatically.
|
||||
*/
|
||||
template <class TDto>
|
||||
class TemporalRepository
|
||||
: public Repository<TDto>
|
||||
, public IHistoryRepository<TDto>
|
||||
{
|
||||
public:
|
||||
/**
|
||||
* Sentinel valid_until value indicating the row is currently live.
|
||||
* ISO-8601 UTC, lexically greater than any plausible real timestamp,
|
||||
* matches the convention used by fewo-webapp's existing temporal tables.
|
||||
*/
|
||||
static constexpr const char* SENTINEL = "9999-12-31T23:59:59Z";
|
||||
|
||||
/// Declarative schema contribution (authkit#14, D-replace).
|
||||
/// Atlas owns evolution between deploys; this declares what the
|
||||
/// decorator needs the live entity table to look like. The composite
|
||||
/// UNIQUE index makes close-then-insert safe inside a transaction.
|
||||
inline static constexpr ColumnSpec kEntityColumns[] = {
|
||||
{"valid_from", "TEXT NOT NULL DEFAULT ''"},
|
||||
{"valid_until", "TEXT NOT NULL DEFAULT '9999-12-31T23:59:59Z'"},
|
||||
};
|
||||
inline static constexpr IndexSpec kEntityIndexes[] = {
|
||||
{"ux_{table}_entity_valid_until", true, "(entity_id, valid_until)"},
|
||||
};
|
||||
inline static constexpr DecoratorSchema kSchema = {
|
||||
"TemporalRepository",
|
||||
kEntityColumns, sizeof(kEntityColumns) / sizeof(kEntityColumns[0]),
|
||||
kEntityIndexes, sizeof(kEntityIndexes) / sizeof(kEntityIndexes[0]),
|
||||
nullptr, 0,
|
||||
};
|
||||
|
||||
using Clock = std::function<int64_t()>; ///< Returns milliseconds since epoch.
|
||||
using IdGen = std::function<oatpp::String()>;
|
||||
/// Runs a unit of work, ideally inside a DB transaction so the historical
|
||||
/// insert + live update commit or roll back together. The default just
|
||||
/// invokes the callback inline (no cross-statement atomicity); consumers
|
||||
/// that have a connection/transaction handle should pass a runner that
|
||||
/// wraps the callback in `BEGIN … COMMIT` / `ROLLBACK`.
|
||||
using TxRunner = std::function<void(const std::function<void()>&)>;
|
||||
|
||||
/**
|
||||
* @param inner Concrete adapter that exposes all-rows-including-historical.
|
||||
* @param clock Optional injected clock for tests; default uses system_clock.
|
||||
* @param idgen Optional injected id generator for tests; default is a 32-char hex from mt19937_64.
|
||||
* @param txRunner Optional transaction wrapper for the close-then-insert
|
||||
* write pair; default runs the writes inline. A per-instance mutex
|
||||
* already serialises the read-modify-write within this process so
|
||||
* concurrent saves of the same entity can't produce two live rows;
|
||||
* supply a real transaction runner for crash/rollback atomicity.
|
||||
*/
|
||||
explicit TemporalRepository(std::shared_ptr<Repository<TDto>> inner,
|
||||
Clock clock = {},
|
||||
IdGen idgen = {},
|
||||
TxRunner txRunner = {})
|
||||
: m_inner(std::move(inner))
|
||||
, m_clock(clock ? std::move(clock) : defaultClock())
|
||||
, m_idgen(idgen ? std::move(idgen) : defaultIdGen())
|
||||
, m_runTx(txRunner ? std::move(txRunner) : defaultTxRunner())
|
||||
{}
|
||||
|
||||
using F = TemporalFieldTraits<TDto>;
|
||||
|
||||
/** @brief Live row for the given entity_id, or null. */
|
||||
oatpp::Object<TDto> findByEntityId(const oatpp::String& entityId) override {
|
||||
auto all = m_inner->list();
|
||||
for (auto& row : *all) {
|
||||
auto& id = F::entityId(row);
|
||||
auto& vu = F::validUntil(row);
|
||||
if (id && vu
|
||||
&& std::string(*id) == std::string(*entityId)
|
||||
&& std::string(*vu) == SENTINEL) {
|
||||
return row;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
/** @brief Version of `entityId` live at the given point in time. */
|
||||
oatpp::Object<TDto> findByEntityIdAt(const oatpp::String& entityId, const TemporalAt& at) {
|
||||
if (at.kind == TemporalAt::Kind::Live) {
|
||||
return findByEntityId(entityId);
|
||||
}
|
||||
const std::string atIso = isoFromMillis(at.timestamp);
|
||||
auto all = m_inner->list();
|
||||
for (auto& row : *all) {
|
||||
auto& id = F::entityId(row);
|
||||
if (!id || std::string(*id) != std::string(*entityId)) continue;
|
||||
auto& vf = F::validFrom(row);
|
||||
auto& vu = F::validUntil(row);
|
||||
const std::string from = vf ? std::string(*vf) : std::string();
|
||||
const std::string until = vu ? std::string(*vu) : std::string();
|
||||
if (from <= atIso && atIso < until) return row;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
/** @brief All currently-live rows. */
|
||||
oatpp::Vector<oatpp::Object<TDto>> list() override {
|
||||
auto out = oatpp::Vector<oatpp::Object<TDto>>::createShared();
|
||||
auto all = m_inner->list();
|
||||
for (auto& row : *all) {
|
||||
auto& vu = F::validUntil(row);
|
||||
if (vu && std::string(*vu) == SENTINEL) {
|
||||
out->push_back(row);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a new dto. Stable-live + historical-copy semantics: the live
|
||||
* row's `id` PK is preserved across updates; the prior version is
|
||||
* captured as a fresh row with a new `id`. See class doc for details.
|
||||
*/
|
||||
void save(const oatpp::Object<TDto>& dto) override {
|
||||
if (!F::entityId(dto)) F::entityId(dto) = m_idgen();
|
||||
|
||||
// Serialise the read-modify-write so two concurrent saves of the same
|
||||
// entity can't both observe the same live row and each insert a new
|
||||
// SENTINEL row (lost update / two live rows). In-process guard only;
|
||||
// see TxRunner for cross-statement / crash atomicity.
|
||||
std::lock_guard<std::mutex> lock(m_writeMutex);
|
||||
|
||||
const int64_t nowMs = m_clock();
|
||||
const std::string nowIso = isoFromMillis(nowMs);
|
||||
|
||||
auto live = findByEntityId(F::entityId(dto));
|
||||
if (!live) {
|
||||
// Fresh insert.
|
||||
if (!F::id(dto)) F::id(dto) = m_idgen();
|
||||
F::validFrom(dto) = oatpp::String(nowIso);
|
||||
F::validUntil(dto) = oatpp::String(SENTINEL);
|
||||
m_inner->save(dto);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update path: compute both rows, then commit the historical copy and
|
||||
// the in-place live update as one unit of work so a failure between
|
||||
// the two can't leave a closed-but-not-replaced or duplicate-live row.
|
||||
auto historical = cloneDto(live);
|
||||
F::id(historical) = m_idgen();
|
||||
F::validUntil(historical) = oatpp::String(nowIso);
|
||||
|
||||
F::id(dto) = F::id(live); // preserve live PK
|
||||
F::validFrom(dto) = oatpp::String(nowIso);
|
||||
F::validUntil(dto) = oatpp::String(SENTINEL);
|
||||
|
||||
m_runTx([&] {
|
||||
m_inner->save(historical);
|
||||
m_inner->save(dto);
|
||||
});
|
||||
}
|
||||
|
||||
/** @brief Close the live row without inserting a new version. */
|
||||
void softDelete(const oatpp::String& entityId) override {
|
||||
std::lock_guard<std::mutex> lock(m_writeMutex);
|
||||
auto live = findByEntityId(entityId);
|
||||
if (!live) return;
|
||||
F::validUntil(live) = oatpp::String(isoFromMillis(m_clock()));
|
||||
m_inner->save(live);
|
||||
}
|
||||
|
||||
/** @brief All versions for `entityId`, oldest first. */
|
||||
oatpp::Vector<oatpp::Object<TDto>>
|
||||
history(const oatpp::String& entityId) override
|
||||
{
|
||||
std::vector<oatpp::Object<TDto>> bucket;
|
||||
auto all = m_inner->list();
|
||||
for (auto& row : *all) {
|
||||
auto& id = F::entityId(row);
|
||||
if (id && std::string(*id) == std::string(*entityId)) {
|
||||
bucket.push_back(row);
|
||||
}
|
||||
}
|
||||
std::sort(bucket.begin(), bucket.end(),
|
||||
[](const oatpp::Object<TDto>& a, const oatpp::Object<TDto>& b) {
|
||||
auto& af_s = F::validFrom(a);
|
||||
auto& bf_s = F::validFrom(b);
|
||||
const std::string af = af_s ? std::string(*af_s) : std::string();
|
||||
const std::string bf = bf_s ? std::string(*bf_s) : std::string();
|
||||
return af < bf;
|
||||
});
|
||||
auto out = oatpp::Vector<oatpp::Object<TDto>>::createShared();
|
||||
for (auto& r : bucket) out->push_back(r);
|
||||
return out;
|
||||
}
|
||||
|
||||
private:
|
||||
static Clock defaultClock() {
|
||||
return [] {
|
||||
using namespace std::chrono;
|
||||
return duration_cast<milliseconds>(system_clock::now().time_since_epoch()).count();
|
||||
};
|
||||
}
|
||||
|
||||
static TxRunner defaultTxRunner() {
|
||||
return [](const std::function<void()>& work) { work(); };
|
||||
}
|
||||
|
||||
/// Field-wise deep copy via oatpp's DTO reflection. Used to capture
|
||||
/// the live row's content as the historical copy before the live row
|
||||
/// is updated in place.
|
||||
static oatpp::Object<TDto> cloneDto(const oatpp::Object<TDto>& src) {
|
||||
auto dst = TDto::createShared();
|
||||
const auto* dispatcher = static_cast<
|
||||
const oatpp::data::mapping::type::__class::AbstractObject::PolymorphicDispatcher*>(
|
||||
oatpp::Object<TDto>::Class::getType()->polymorphicDispatcher);
|
||||
for (auto* p : dispatcher->getProperties()->getList()) {
|
||||
p->set(static_cast<oatpp::BaseObject*>(dst.get()),
|
||||
p->get(static_cast<oatpp::BaseObject*>(src.get())));
|
||||
}
|
||||
return dst;
|
||||
}
|
||||
|
||||
static IdGen defaultIdGen() {
|
||||
return [] {
|
||||
// authkit#16 L-5: draw 128 bits straight from the platform CSPRNG
|
||||
// (std::random_device → getrandom()/urandom on Linux) on every
|
||||
// call. The old code seeded a mt19937_64 once from a single
|
||||
// random_device sample, making the whole id stream predictable from
|
||||
// observed outputs — a problem if a consumer ever treats entity_id
|
||||
// as an unguessable handle. Consumers needing a hard guarantee can
|
||||
// still inject their own IdGen.
|
||||
static thread_local std::random_device rd;
|
||||
char buf[33];
|
||||
std::snprintf(buf, sizeof(buf), "%08x%08x%08x%08x",
|
||||
(unsigned)rd(), (unsigned)rd(), (unsigned)rd(), (unsigned)rd());
|
||||
return oatpp::String(buf);
|
||||
};
|
||||
}
|
||||
|
||||
static std::string isoFromMillis(int64_t ms) {
|
||||
std::time_t secs = static_cast<std::time_t>(ms / 1000);
|
||||
std::tm tmv{};
|
||||
gmtime_r(&secs, &tmv);
|
||||
char buf[32];
|
||||
std::snprintf(buf, sizeof(buf), "%04d-%02d-%02dT%02d:%02d:%02d.%03lldZ",
|
||||
tmv.tm_year + 1900, tmv.tm_mon + 1, tmv.tm_mday,
|
||||
tmv.tm_hour, tmv.tm_min, tmv.tm_sec,
|
||||
(long long)(ms % 1000));
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
std::shared_ptr<Repository<TDto>> m_inner;
|
||||
Clock m_clock;
|
||||
IdGen m_idgen;
|
||||
TxRunner m_runTx;
|
||||
std::mutex m_writeMutex;
|
||||
};
|
||||
|
||||
} // namespace oatpp_authkit::repo
|
||||
|
||||
#endif
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
#ifndef OATPP_AUTHKIT_SYSTEMD_NOTIFY_HPP
|
||||
#define OATPP_AUTHKIT_SYSTEMD_NOTIFY_HPP
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/un.h>
|
||||
#include <unistd.h>
|
||||
|
||||
namespace oatpp_authkit::systemd {
|
||||
|
||||
/**
|
||||
* @brief Protocol-level sd_notify(3) implementation.
|
||||
*
|
||||
* Speaks the systemd notification protocol by writing a datagram to
|
||||
* $NOTIFY_SOCKET — no libsystemd dependency. Used to signal
|
||||
* `Type=notify` services with `READY=1`, `STATUS=...`, `WATCHDOG=1`.
|
||||
*
|
||||
* No-op (silent return) when NOTIFY_SOCKET is unset — the same binary
|
||||
* can run under systemd or as a plain background process without
|
||||
* conditional logic at the call site.
|
||||
*
|
||||
* Supports Linux abstract-namespace sockets (leading '@' in the env
|
||||
* var, mapped to a leading NUL byte in the sockaddr path).
|
||||
*
|
||||
* Example:
|
||||
* @code
|
||||
* oatpp_authkit::systemd::notify("READY=1\nSTATUS=Accepting connections");
|
||||
* // … later, from a watchdog thread:
|
||||
* oatpp_authkit::systemd::notify("WATCHDOG=1");
|
||||
* @endcode
|
||||
*/
|
||||
inline void notify(const char* state) {
|
||||
const char* sock = std::getenv("NOTIFY_SOCKET");
|
||||
if (!sock || !*sock) return;
|
||||
int fd = ::socket(AF_UNIX, SOCK_DGRAM | SOCK_CLOEXEC, 0);
|
||||
if (fd < 0) return;
|
||||
struct sockaddr_un addr{};
|
||||
addr.sun_family = AF_UNIX;
|
||||
if (sock[0] == '@') {
|
||||
// Linux abstract namespace: leading '@' maps to a NUL byte.
|
||||
addr.sun_path[0] = '\0';
|
||||
std::strncpy(addr.sun_path + 1, sock + 1, sizeof(addr.sun_path) - 2);
|
||||
} else {
|
||||
std::strncpy(addr.sun_path, sock, sizeof(addr.sun_path) - 1);
|
||||
}
|
||||
::sendto(fd, state, std::strlen(state), MSG_NOSIGNAL,
|
||||
(struct sockaddr*)&addr, sizeof(addr));
|
||||
::close(fd);
|
||||
}
|
||||
|
||||
} // namespace oatpp_authkit::systemd
|
||||
|
||||
#endif
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
#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
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
#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
|
||||
|
|
@ -2,14 +2,10 @@
|
|||
#define UTIL_RATE_LIMITER_HPP
|
||||
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <mutex>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace oatpp_authkit {
|
||||
|
||||
/**
|
||||
* @brief Per-key token bucket rate limiter.
|
||||
*
|
||||
|
|
@ -27,22 +23,11 @@ namespace oatpp_authkit {
|
|||
class RateLimiter {
|
||||
public:
|
||||
/**
|
||||
* @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.
|
||||
* @param capacity Maximum burst size (tokens).
|
||||
* @param refillRate Tokens added per second.
|
||||
*/
|
||||
RateLimiter(double capacity, double 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");
|
||||
}
|
||||
: m_capacity(capacity), m_refillRate(refillRate) {}
|
||||
|
||||
/** @brief Try to consume one token for the given key. Returns true if allowed. */
|
||||
bool allow(const std::string& key) {
|
||||
|
|
@ -97,6 +82,4 @@ private:
|
|||
std::unordered_map<std::string, Bucket> m_buckets;
|
||||
};
|
||||
|
||||
} // namespace oatpp_authkit
|
||||
|
||||
#endif // UTIL_RATE_LIMITER_HPP
|
||||
|
|
|
|||
|
|
@ -1,65 +0,0 @@
|
|||
#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
|
||||
|
|
@ -9,44 +9,6 @@ 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.
|
||||
*
|
||||
|
|
@ -57,8 +19,13 @@ inline std::string cookieValue(const std::string& cookieHeader, const std::strin
|
|||
inline std::string extractToken(const std::shared_ptr<IncomingRequest>& request) {
|
||||
auto cookie = request->getHeader("Cookie");
|
||||
if (cookie && !cookie->empty()) {
|
||||
std::string tok = cookieValue(*cookie, "session");
|
||||
if (!tok.empty()) return tok;
|
||||
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);
|
||||
}
|
||||
}
|
||||
auto auth = request->getHeader("Authorization");
|
||||
if (auth && !auth->empty()) {
|
||||
|
|
@ -89,16 +56,6 @@ 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,
|
||||
|
|
|
|||
|
|
@ -1,457 +0,0 @@
|
|||
#pragma once
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include "oatpp-websocket/ConnectionHandler.hpp"
|
||||
#include "oatpp-websocket/WebSocket.hpp"
|
||||
#include "oatpp/parser/json/mapping/ObjectMapper.hpp"
|
||||
#include "Listener.hpp"
|
||||
#include "../dto/InternalDto.hpp"
|
||||
|
||||
namespace oatpp_authkit::ws {
|
||||
|
||||
/**
|
||||
* @brief Per-socket authentication and property-access metadata.
|
||||
*
|
||||
* Populated by WSController during the WebSocket handshake and picked up
|
||||
* by Hub::onAfterCreate via thread_local storage.
|
||||
*/
|
||||
struct SocketInfo {
|
||||
std::string userId;
|
||||
std::string username;
|
||||
std::string role;
|
||||
std::set<std::string> propertyIds; ///< Properties this socket may receive scoped events for. Empty = NONE for non-admins (admins get all via role). See socketHasPropertyAccess.
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Singleton that owns all active WebSocket connections and dispatches
|
||||
* server-push notifications and presence tracking.
|
||||
*
|
||||
* Implements `oatpp::websocket::ConnectionHandler::SocketInstanceListener`
|
||||
* so that it is notified whenever a WebSocket connection is established or
|
||||
* torn down. All state (socket set, presence maps) is protected by a single
|
||||
* static mutex and is therefore safe to access from multiple server threads.
|
||||
*
|
||||
* Only authenticated connections (validated by WSController before the
|
||||
* handshake) are accepted. Each socket stores the user's identity and
|
||||
* property-access set so that booking notifications can be scoped to
|
||||
* authorised recipients.
|
||||
*
|
||||
* @warning CSWSH (authkit#16 M-4): a cookie-authenticated WebSocket upgrade is
|
||||
* exposed to Cross-Site WebSocket Hijacking unless the `Origin` header
|
||||
* is validated at the handshake. The Hub runs *after* the upgrade and
|
||||
* cannot see `Origin`, so the WSController MUST reject disallowed
|
||||
* origins before setting `t_pendingAuth` — use
|
||||
* `oatpp_authkit::sameOrigin(originHeader, hostHeader)` or
|
||||
* `oatpp_authkit::originAllowed(originHeader, allowlist)` from
|
||||
* `util/OriginCheck.hpp`.
|
||||
*
|
||||
* **Server→client change notifications**
|
||||
* @code
|
||||
* {"type":"booking_updated","id":"<uuid>"}
|
||||
* {"type":"booking_created","id":"<uuid>"}
|
||||
* {"type":"booking_deleted","id":"<uuid>"}
|
||||
* {"type":"person_updated","id":"<uuid>"}
|
||||
* {"type":"feature_request_updated","id":"<uuid>"}
|
||||
* @endcode
|
||||
*
|
||||
* **Client→server presence messages** (handled in Listener):
|
||||
* @code
|
||||
* {"type":"presence_open","booking_id":"<uuid>"}
|
||||
* {"type":"presence_close","booking_id":"<uuid>"}
|
||||
* @endcode
|
||||
*
|
||||
* **Server→client presence update** (broadcast whenever presence changes):
|
||||
* @code
|
||||
* {"type":"presence_update","booking_id":"<uuid>","users":["alice","bob"]}
|
||||
* @endcode
|
||||
*/
|
||||
struct HubHousekeeper; // forward-declare for friend (#439)
|
||||
class Listener; // forward-declare for friend (Listener calls Hub::sharedMapper)
|
||||
|
||||
class Hub
|
||||
: public oatpp::websocket::ConnectionHandler::SocketInstanceListener {
|
||||
friend struct HubHousekeeper;
|
||||
friend class Listener;
|
||||
public:
|
||||
using WebSocket = oatpp::websocket::WebSocket;
|
||||
|
||||
/**
|
||||
* @brief Thread-local slot used by WSController to pass authenticated
|
||||
* user context to onAfterCreate (which runs on the same thread).
|
||||
*/
|
||||
static inline thread_local std::optional<SocketInfo> t_pendingAuth;
|
||||
|
||||
public:
|
||||
/** @brief Hard cap on simultaneously-connected WebSocket clients (#439).
|
||||
* When reached, new connections are accepted by oatpp's transport layer
|
||||
* but immediately closed with code 1013 (Try Again Later). */
|
||||
static constexpr std::size_t kMaxSockets = 500;
|
||||
|
||||
/** @brief Idle durations (#439). Any socket that has not sent a frame or
|
||||
* answered a pong within kIdlePing receives a ping; if it does not produce
|
||||
* any traffic within kIdleClose total, it is closed with code 1001. */
|
||||
static constexpr std::chrono::seconds kIdlePing {90};
|
||||
static constexpr std::chrono::seconds kIdleClose{180};
|
||||
|
||||
private:
|
||||
static std::mutex s_mx;
|
||||
static std::unordered_map<const WebSocket*, SocketInfo> s_sockets;
|
||||
/** @brief Last time a frame (any opcode) arrived from the peer, used by the
|
||||
* housekeeper thread to expire silent sockets (#439). */
|
||||
static std::unordered_map<const WebSocket*, std::chrono::steady_clock::time_point> s_lastSeen;
|
||||
|
||||
// Presence: booking entity_id → set of usernames currently editing it
|
||||
static std::map<std::string, std::set<std::string>> s_presence;
|
||||
// Per-socket presence: socket → map of booking entity_id → username
|
||||
static std::map<const WebSocket*, std::map<std::string, std::string>> s_socketPresence;
|
||||
|
||||
/**
|
||||
* @brief Process-wide ObjectMapper for outbound WS frames (#6).
|
||||
*
|
||||
* Lazy-initialised on first use; consumers can override via
|
||||
* `setObjectMapper()` to share the same mapper instance with the rest of
|
||||
* the app. Mapper is thread-safe for concurrent `writeToString` use.
|
||||
*/
|
||||
static std::shared_ptr<oatpp::data::mapping::ObjectMapper>& sharedMapper() {
|
||||
static std::shared_ptr<oatpp::data::mapping::ObjectMapper> m
|
||||
= oatpp::parser::json::mapping::ObjectMapper::createShared();
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Serialise a presence-update notification as a JSON string (#6).
|
||||
*
|
||||
* Routes through ObjectMapper so usernames / booking IDs containing `"`,
|
||||
* `\\`, or control characters are escaped properly. The previous hand-
|
||||
* rolled concatenation produced invalid JSON for any such input.
|
||||
*/
|
||||
static std::string buildPresenceMsg(const std::string& bookingId, const std::set<std::string>& users) {
|
||||
auto dto = dto::WsPresenceUpdateDto::createShared();
|
||||
dto->type = oatpp::String("presence_update");
|
||||
dto->booking_id = oatpp::String(bookingId);
|
||||
dto->users = {};
|
||||
for (const auto& u : users) dto->users->push_back(oatpp::String(u));
|
||||
return std::string(*sharedMapper()->writeToString(dto));
|
||||
}
|
||||
|
||||
/** @brief Build a `{type,id}` event envelope via ObjectMapper (#6). */
|
||||
static std::string buildEntityEvent(const char* type, const std::string& id) {
|
||||
auto dto = dto::WsEntityEventDto::createShared();
|
||||
dto->type = oatpp::String(type);
|
||||
dto->id = oatpp::String(id);
|
||||
return std::string(*sharedMapper()->writeToString(dto));
|
||||
}
|
||||
|
||||
public:
|
||||
/** @brief Replace the process-wide ObjectMapper (#6). Call once at startup
|
||||
* if the host application has its own mapper instance to share. */
|
||||
static void setObjectMapper(std::shared_ptr<oatpp::data::mapping::ObjectMapper> m) {
|
||||
if (m) sharedMapper() = std::move(m);
|
||||
}
|
||||
private:
|
||||
|
||||
/**
|
||||
* @brief Check whether a socket has access to a given property.
|
||||
*
|
||||
* Admins (role == "admin") see everything. For everyone else, access is
|
||||
* granted only if `propertyId` is explicitly in their `propertyIds` set.
|
||||
*
|
||||
* authkit#16 M-3: an empty `propertyIds` now means NO access (fail closed),
|
||||
* not "all". Previously a non-admin whose permission set failed to populate
|
||||
* (DB hiccup, race, or simply no grants yet) would receive every property's
|
||||
* notifications — a cross-tenant leak.
|
||||
*/
|
||||
static bool socketHasPropertyAccess(const SocketInfo& info, const std::string& propertyId) {
|
||||
if (info.role == "admin") return true;
|
||||
return info.propertyIds.find(propertyId) != info.propertyIds.end();
|
||||
}
|
||||
|
||||
public:
|
||||
// --- SocketInstanceListener interface (1.3.0) ---
|
||||
|
||||
/**
|
||||
* @brief Called by oatpp after a new WebSocket connection is established.
|
||||
*
|
||||
* Picks up authenticated user context from the thread_local slot set by
|
||||
* WSController. If no auth context is present, the socket is immediately
|
||||
* closed (should not happen since WSController rejects unauthenticated
|
||||
* upgrade requests).
|
||||
*/
|
||||
void onAfterCreate(const WebSocket& socket,
|
||||
const std::shared_ptr<const ParameterMap>&) override
|
||||
{
|
||||
socket.setListener(std::make_shared<Listener>());
|
||||
|
||||
// authkit#16 L-4: consume the thread-local handoff exactly once, up
|
||||
// front, and clear it unconditionally. If a prior connection's
|
||||
// onAfterCreate ever failed to clear it (or oatpp reuses this worker
|
||||
// thread), a leftover value must NOT attach to this socket — and our
|
||||
// own value must not leak to the next connection on this thread.
|
||||
std::optional<SocketInfo> pending = std::move(t_pendingAuth);
|
||||
t_pendingAuth.reset();
|
||||
|
||||
if (!pending.has_value()) {
|
||||
// Should not happen — WSController validates before handshake.
|
||||
OATPP_LOGW("Hub", "WebSocket connected without auth context — closing");
|
||||
try { socket.sendClose(4001, "Unauthorized"); } catch (...) {}
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> g(s_mx);
|
||||
if (s_sockets.size() >= kMaxSockets) {
|
||||
// #439: refuse extra connections beyond the cap rather than
|
||||
// allowing unbounded growth of s_sockets / presence maps.
|
||||
OATPP_LOGW("Hub", "socket cap %zu hit — rejecting", kMaxSockets);
|
||||
try { socket.sendClose(1013, "Server Busy"); } catch (...) {}
|
||||
return;
|
||||
}
|
||||
s_sockets[&socket] = std::move(*pending);
|
||||
s_lastSeen[&socket] = std::chrono::steady_clock::now();
|
||||
}
|
||||
|
||||
OATPP_LOGD("Hub", "client connected: %s (total=%zu)",
|
||||
s_sockets[&socket].username.c_str(), s_sockets.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Bump the last-seen timestamp for a socket. Called by Listener
|
||||
* on every incoming frame/pong so the idle housekeeper can
|
||||
* distinguish live from dead peers (#439).
|
||||
*/
|
||||
static void touchSocket(const WebSocket* socket) {
|
||||
std::lock_guard<std::mutex> g(s_mx);
|
||||
auto it = s_lastSeen.find(socket);
|
||||
if (it != s_lastSeen.end()) it->second = std::chrono::steady_clock::now();
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Called by oatpp before a WebSocket connection is closed.
|
||||
*/
|
||||
void onBeforeDestroy(const WebSocket& socket) override {
|
||||
{
|
||||
std::lock_guard<std::mutex> g(s_mx);
|
||||
s_sockets.erase(&socket);
|
||||
s_lastSeen.erase(&socket);
|
||||
OATPP_LOGD("Hub", "client disconnected (total=%zu)", s_sockets.size());
|
||||
}
|
||||
presenceCleanup(&socket);
|
||||
}
|
||||
|
||||
// --- Broadcast ---
|
||||
|
||||
/**
|
||||
* @brief Send a JSON string to every connected client. Thread-safe.
|
||||
*/
|
||||
static void broadcast(const std::string& json) {
|
||||
oatpp::String msg = json.c_str();
|
||||
std::lock_guard<std::mutex> g(s_mx);
|
||||
for (auto& [ws, info] : s_sockets) {
|
||||
try { ws->sendOneFrameText(msg); }
|
||||
catch (...) { /* ignore dead sockets */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Send a JSON string only to sockets that have access to the
|
||||
* given property. If propertyId is empty, broadcasts to all.
|
||||
*/
|
||||
static void broadcastToProperty(const std::string& json, const std::string& propertyId) {
|
||||
if (propertyId.empty()) { broadcast(json); return; }
|
||||
oatpp::String msg = json.c_str();
|
||||
std::lock_guard<std::mutex> g(s_mx);
|
||||
for (auto& [ws, info] : s_sockets) {
|
||||
if (socketHasPropertyAccess(info, propertyId)) {
|
||||
try { ws->sendOneFrameText(msg); }
|
||||
catch (...) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Broadcast a booking lifecycle event, scoped to a property.
|
||||
* @param type Event type: `"booking_created"`, `"booking_updated"`, or `"booking_deleted"`.
|
||||
* @param id The booking entity_id affected.
|
||||
* @param propertyId The property this booking belongs to (empty = broadcast to all).
|
||||
*/
|
||||
static void notifyBooking(const char* type, const std::string& id, const std::string& propertyId) {
|
||||
broadcastToProperty(buildEntityEvent(type, id), propertyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Broadcast a booking lifecycle event to all connected clients.
|
||||
*
|
||||
* Legacy overload for call sites that do not have the property ID readily
|
||||
* available. Sends to all authenticated sockets.
|
||||
*/
|
||||
static void notifyBooking(const char* type, const std::string& id) {
|
||||
broadcast(buildEntityEvent(type, id));
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Broadcast a person lifecycle event to all connected clients.
|
||||
*
|
||||
* Persons are cross-cutting (linked to bookings across properties), so
|
||||
* notifications are not property-scoped.
|
||||
*/
|
||||
static void notifyPerson(const char* type, const std::string& id) {
|
||||
broadcast(buildEntityEvent(type, id));
|
||||
}
|
||||
|
||||
// --- Presence ---
|
||||
|
||||
/**
|
||||
* @brief Look up the authenticated username for a socket.
|
||||
* @return The username, or empty string if not found.
|
||||
*/
|
||||
static std::string getSocketUsername(const WebSocket* socket) {
|
||||
std::lock_guard<std::mutex> g(s_mx);
|
||||
auto it = s_sockets.find(socket);
|
||||
if (it != s_sockets.end()) return it->second.username;
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Register that a user has opened the edit modal for a booking.
|
||||
*
|
||||
* Uses the server-validated username from the socket's auth context
|
||||
* instead of trusting the client-sent username.
|
||||
*/
|
||||
static void presenceOpen(const WebSocket* socket, const std::string& bookingId, const std::string& /* clientUser */) {
|
||||
std::string username = getSocketUsername(socket);
|
||||
if (username.empty()) return;
|
||||
std::string msg;
|
||||
{
|
||||
std::lock_guard<std::mutex> g(s_mx);
|
||||
s_presence[bookingId].insert(username);
|
||||
s_socketPresence[socket][bookingId] = username;
|
||||
msg = buildPresenceMsg(bookingId, s_presence[bookingId]);
|
||||
}
|
||||
broadcast(msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Deregister a user from the presence set for a booking.
|
||||
*/
|
||||
static void presenceClose(const WebSocket* socket, const std::string& bookingId) {
|
||||
std::string msg;
|
||||
{
|
||||
std::lock_guard<std::mutex> g(s_mx);
|
||||
auto sockIt = s_socketPresence.find(socket);
|
||||
if (sockIt == s_socketPresence.end()) return;
|
||||
auto bidIt = sockIt->second.find(bookingId);
|
||||
if (bidIt == sockIt->second.end()) return;
|
||||
s_presence[bookingId].erase(bidIt->second);
|
||||
const auto& remaining = s_presence[bookingId];
|
||||
msg = buildPresenceMsg(bookingId, remaining);
|
||||
if (remaining.empty()) s_presence.erase(bookingId);
|
||||
sockIt->second.erase(bidIt);
|
||||
}
|
||||
broadcast(msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Remove all presence entries owned by a disconnecting socket.
|
||||
*/
|
||||
static void presenceCleanup(const WebSocket* socket) {
|
||||
std::vector<std::string> msgs;
|
||||
{
|
||||
std::lock_guard<std::mutex> g(s_mx);
|
||||
auto sockIt = s_socketPresence.find(socket);
|
||||
if (sockIt == s_socketPresence.end()) return;
|
||||
for (auto& [bookingId, username] : sockIt->second) {
|
||||
s_presence[bookingId].erase(username);
|
||||
msgs.push_back(buildPresenceMsg(bookingId, s_presence[bookingId]));
|
||||
if (s_presence[bookingId].empty()) s_presence.erase(bookingId);
|
||||
}
|
||||
s_socketPresence.erase(sockIt);
|
||||
}
|
||||
for (const auto& m : msgs) broadcast(m);
|
||||
}
|
||||
};
|
||||
|
||||
inline std::mutex Hub::s_mx;
|
||||
inline std::unordered_map<const oatpp::websocket::WebSocket*, SocketInfo> Hub::s_sockets;
|
||||
inline std::unordered_map<const oatpp::websocket::WebSocket*, std::chrono::steady_clock::time_point> Hub::s_lastSeen;
|
||||
inline std::map<std::string, std::set<std::string>> Hub::s_presence;
|
||||
inline std::map<const oatpp::websocket::WebSocket*, std::map<std::string, std::string>> Hub::s_socketPresence;
|
||||
|
||||
/**
|
||||
* @brief Background sweeper that pings silent WebSocket peers and closes
|
||||
* ones past the idle-close threshold (#439).
|
||||
*
|
||||
* Started once at static-init time, detached. Wakes every 30 s, iterates
|
||||
* Hub::s_sockets under its mutex to build a work list, then releases
|
||||
* the lock before issuing any pings/closes to avoid holding s_mx across
|
||||
* I/O. The thread runs for the process lifetime; a clean-shutdown signal
|
||||
* would be nice but is not required — oatpp tears the listener down
|
||||
* first and subsequent send{Ping,Close} calls no-op on a dead socket.
|
||||
*/
|
||||
struct HubHousekeeper {
|
||||
std::thread t;
|
||||
HubHousekeeper() {
|
||||
t = std::thread([]{
|
||||
using namespace std::chrono_literals;
|
||||
while (true) {
|
||||
std::this_thread::sleep_for(30s);
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
std::vector<const oatpp::websocket::WebSocket*> toPing, toClose;
|
||||
{
|
||||
std::lock_guard<std::mutex> g(Hub::s_mx);
|
||||
for (auto& kv : Hub::s_lastSeen) {
|
||||
auto dt = now - kv.second;
|
||||
if (dt > Hub::kIdleClose) toClose.push_back(kv.first);
|
||||
else if (dt > Hub::kIdlePing) toPing.push_back(kv.first);
|
||||
}
|
||||
}
|
||||
for (auto* ws : toPing) { try { ws->sendPing(oatpp::String("")); } catch (...) {} }
|
||||
for (auto* ws : toClose) { try { ws->sendClose(1001, "Idle timeout"); } catch (...) {} }
|
||||
}
|
||||
});
|
||||
t.detach();
|
||||
}
|
||||
};
|
||||
inline HubHousekeeper s_wsHubHousekeeper;
|
||||
|
||||
inline void Listener::touchActivity(const WebSocket* socket) { Hub::touchSocket(socket); }
|
||||
|
||||
// Listener::handleMessage defined here (after Hub) to break the circular dependency.
|
||||
//
|
||||
// #6: parse inbound WS frames via ObjectMapper instead of the toy
|
||||
// jsonStr/jsonInt regex parsers — those mishandled escaped quotes,
|
||||
// nested objects, and unicode escapes. ObjectMapper rejects malformed
|
||||
// payloads cleanly (caught here so a bad client frame is just dropped,
|
||||
// never an unhandled exception).
|
||||
inline void Listener::handleMessage(const WebSocket& socket, const std::string& text) {
|
||||
Hub::touchSocket(&socket); // #439: record activity to suppress idle close
|
||||
|
||||
oatpp::Object<dto::WsClientMsgDto> msg;
|
||||
try {
|
||||
msg = Hub::sharedMapper()->readFromString<oatpp::Object<dto::WsClientMsgDto>>(
|
||||
oatpp::String(text));
|
||||
} catch (...) {
|
||||
return; // malformed JSON — drop frame
|
||||
}
|
||||
if (!msg || !msg->type || !msg->booking_id) return;
|
||||
|
||||
const std::string type = std::string(*msg->type);
|
||||
const std::string bookingId = std::string(*msg->booking_id);
|
||||
if (bookingId.empty()) return;
|
||||
|
||||
if (type == "presence_open") {
|
||||
// Client-sent "user" field is ignored; server uses the authenticated username.
|
||||
Hub::presenceOpen(&socket, bookingId, "");
|
||||
} else if (type == "presence_close") {
|
||||
Hub::presenceClose(&socket, bookingId);
|
||||
}
|
||||
}
|
||||
} // namespace oatpp_authkit::ws
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
#pragma once
|
||||
#include "oatpp-websocket/WebSocket.hpp"
|
||||
#include "oatpp/core/data/stream/BufferStream.hpp"
|
||||
|
||||
#include <cctype>
|
||||
#include <string>
|
||||
|
||||
namespace oatpp_authkit::ws {
|
||||
|
||||
/**
|
||||
* @brief Per-connection WebSocket listener.
|
||||
*
|
||||
* One instance is created per accepted WebSocket connection by Hub::onAfterCreate().
|
||||
* Handles ping/pong housekeeping, reassembles fragmented text frames, and
|
||||
* dispatches fully received messages to handleMessage().
|
||||
*
|
||||
* The following client→server presence messages are parsed:
|
||||
* @code
|
||||
* {"type":"presence_open","booking_id":42,"user":"alice"}
|
||||
* {"type":"presence_close","booking_id":42}
|
||||
* @endcode
|
||||
*
|
||||
* @note handleMessage() is defined at the bottom of Hub.hpp (after Hub is
|
||||
* fully declared) to avoid a circular include dependency between
|
||||
* Listener.hpp and Hub.hpp.
|
||||
*/
|
||||
class Listener : public oatpp::websocket::WebSocket::Listener {
|
||||
public:
|
||||
/** @brief Hard cap on a single reassembled WS message (#439). Frames that
|
||||
* push the accumulated buffer past this are dropped and the connection
|
||||
* closed with code 1009 (Message Too Big). */
|
||||
static constexpr std::size_t kMaxMessageBytes = 64 * 1024;
|
||||
|
||||
private:
|
||||
oatpp::data::stream::BufferOutputStream m_buffer{256}; ///< Accumulates frame payloads until end-of-message.
|
||||
bool m_overflowed = false; ///< Set when kMaxMessageBytes was exceeded; drop remainder of the current message.
|
||||
|
||||
/**
|
||||
* @brief Dispatch a fully received text-frame message to Hub presence handlers.
|
||||
*
|
||||
* Defined in Hub.hpp after Hub is fully declared to avoid a circular
|
||||
* include dependency.
|
||||
*
|
||||
* @param socket The WebSocket connection the message arrived on.
|
||||
* @param text The complete UTF-8 text of the message.
|
||||
*/
|
||||
void handleMessage(const WebSocket& socket, const std::string& text);
|
||||
|
||||
public:
|
||||
/**
|
||||
* @brief Respond to a WebSocket ping frame with a pong.
|
||||
* @param socket The connection that sent the ping.
|
||||
* @param msg The ping payload to echo back.
|
||||
*/
|
||||
void onPing(const WebSocket& socket, const oatpp::String& msg) override {
|
||||
socket.sendPong(msg);
|
||||
touchActivity(&socket);
|
||||
}
|
||||
|
||||
/** @brief Bump activity timestamp on pong so the idle sweeper treats the
|
||||
* peer as live even if they never send application traffic (#439). */
|
||||
void onPong(const WebSocket& socket, const oatpp::String&) override {
|
||||
touchActivity(&socket);
|
||||
}
|
||||
|
||||
private:
|
||||
/** @brief Forward declaration; defined in Hub.hpp alongside handleMessage
|
||||
* to break the Hub↔Listener circular include. */
|
||||
static void touchActivity(const WebSocket* socket);
|
||||
public:
|
||||
|
||||
/**
|
||||
* @brief Log the close frame code when the client initiates a close.
|
||||
* @param code The WebSocket close status code.
|
||||
*/
|
||||
void onClose(const WebSocket&, v_uint16 code, const oatpp::String&) override {
|
||||
OATPP_LOGD("WS", "client closed (code=%d)", (int)code);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Accumulate frame chunks and dispatch the message when complete.
|
||||
*
|
||||
* oatpp calls this method once per frame chunk. A `size` of 0 signals
|
||||
* the end of the message; at that point the buffer is flushed and, if
|
||||
* the opcode is a text frame (opcode == 1), handleMessage() is called.
|
||||
*
|
||||
* @param socket The WebSocket connection.
|
||||
* @param opcode WebSocket opcode (1 = text, 2 = binary, etc.).
|
||||
* @param data Pointer to the chunk payload bytes.
|
||||
* @param size Number of bytes in this chunk, or 0 at end of message.
|
||||
*/
|
||||
void readMessage(const WebSocket& socket, v_uint8 opcode,
|
||||
p_char8 data, oatpp::v_io_size size) override {
|
||||
touchActivity(&socket); // #439: any inbound frame counts as activity
|
||||
if (size > 0) {
|
||||
if (m_overflowed) return; // ignore remaining frames of a too-large message
|
||||
if (m_buffer.getCurrentPosition() + (std::size_t)size > kMaxMessageBytes) {
|
||||
// #439: cap a single authenticated client from OOMing the
|
||||
// process by streaming gigabytes into a single text frame.
|
||||
m_overflowed = true;
|
||||
m_buffer.setCurrentPosition(0);
|
||||
OATPP_LOGW("WS", "client exceeded %zu B message cap — closing", kMaxMessageBytes);
|
||||
try { socket.sendClose(1009, "Message Too Big"); } catch (...) {}
|
||||
return;
|
||||
}
|
||||
m_buffer.writeSimple(data, size);
|
||||
} else {
|
||||
// size == 0 signals end of message
|
||||
if (m_overflowed) {
|
||||
m_overflowed = false; // reset for next message (though the socket is closing)
|
||||
m_buffer.setCurrentPosition(0);
|
||||
return;
|
||||
}
|
||||
std::string text = m_buffer.toString();
|
||||
m_buffer.setCurrentPosition(0);
|
||||
if (opcode == 1 && !text.empty()) { // text frame
|
||||
handleMessage(socket, text);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
} // namespace oatpp_authkit::ws
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
# Minimal test harness for oatpp-authkit.
|
||||
#
|
||||
# Adds plain executable tests linked against the INTERFACE library and oatpp.
|
||||
# No third-party test framework — assertions use <cassert> and a tiny REQUIRE
|
||||
# macro so the suite stays portable and dependency-free.
|
||||
|
||||
find_package(oatpp REQUIRED)
|
||||
|
||||
add_executable(test_negotiation test_negotiation.cpp)
|
||||
target_link_libraries(test_negotiation PRIVATE oatpp::authkit oatpp::oatpp)
|
||||
add_test(NAME negotiation COMMAND test_negotiation)
|
||||
|
||||
add_executable(test_token_extract test_token_extract.cpp)
|
||||
target_link_libraries(test_token_extract PRIVATE oatpp::authkit oatpp::oatpp)
|
||||
add_test(NAME token_extract COMMAND test_token_extract)
|
||||
|
||||
add_executable(test_rate_limiter test_rate_limiter.cpp)
|
||||
target_link_libraries(test_rate_limiter PRIVATE oatpp::authkit oatpp::oatpp)
|
||||
add_test(NAME rate_limiter COMMAND test_rate_limiter)
|
||||
|
||||
add_executable(test_origin_check test_origin_check.cpp)
|
||||
target_link_libraries(test_origin_check PRIVATE oatpp::authkit oatpp::oatpp)
|
||||
add_test(NAME origin_check COMMAND test_origin_check)
|
||||
|
||||
add_executable(test_constant_time test_constant_time.cpp)
|
||||
target_link_libraries(test_constant_time PRIVATE oatpp::authkit oatpp::oatpp)
|
||||
add_test(NAME constant_time COMMAND test_constant_time)
|
||||
|
||||
add_executable(test_session_cookie test_session_cookie.cpp)
|
||||
target_link_libraries(test_session_cookie PRIVATE oatpp::authkit oatpp::oatpp)
|
||||
add_test(NAME session_cookie COMMAND test_session_cookie)
|
||||
|
||||
add_executable(test_body_size_limit test_body_size_limit.cpp)
|
||||
target_link_libraries(test_body_size_limit PRIVATE oatpp::authkit oatpp::oatpp)
|
||||
add_test(NAME body_size_limit COMMAND test_body_size_limit)
|
||||
|
||||
add_executable(test_security_headers test_security_headers.cpp)
|
||||
target_link_libraries(test_security_headers PRIVATE oatpp::authkit oatpp::oatpp)
|
||||
add_test(NAME security_headers COMMAND test_security_headers)
|
||||
|
||||
add_executable(test_json_serialization test_json_serialization.cpp)
|
||||
target_link_libraries(test_json_serialization PRIVATE oatpp::authkit oatpp::oatpp)
|
||||
add_test(NAME json_serialization COMMAND test_json_serialization)
|
||||
|
||||
add_executable(test_repository_interface test_repository_interface.cpp)
|
||||
target_link_libraries(test_repository_interface PRIVATE oatpp::authkit oatpp::oatpp)
|
||||
add_test(NAME repository_interface COMMAND test_repository_interface)
|
||||
|
||||
add_executable(test_repository_decorators test_repository_decorators.cpp)
|
||||
target_link_libraries(test_repository_decorators PRIVATE oatpp::authkit oatpp::oatpp)
|
||||
add_test(NAME repository_decorators COMMAND test_repository_decorators)
|
||||
|
||||
add_executable(test_queryable test_queryable.cpp)
|
||||
target_link_libraries(test_queryable PRIVATE oatpp::authkit oatpp::oatpp)
|
||||
add_test(NAME queryable COMMAND test_queryable)
|
||||
|
||||
add_executable(test_temporal_field_traits test_temporal_field_traits.cpp)
|
||||
target_link_libraries(test_temporal_field_traits PRIVATE oatpp::authkit oatpp::oatpp)
|
||||
add_test(NAME temporal_field_traits COMMAND test_temporal_field_traits)
|
||||
|
||||
add_executable(test_audit_log_repository test_audit_log_repository.cpp)
|
||||
target_link_libraries(test_audit_log_repository PRIVATE oatpp::authkit oatpp::oatpp)
|
||||
add_test(NAME audit_log_repository COMMAND test_audit_log_repository)
|
||||
|
||||
add_executable(test_schema_contract test_schema_contract.cpp)
|
||||
target_link_libraries(test_schema_contract PRIVATE oatpp::authkit oatpp::oatpp)
|
||||
add_test(NAME schema_contract COMMAND test_schema_contract)
|
||||
|
||||
add_executable(test_redacted_field_repository test_redacted_field_repository.cpp)
|
||||
target_link_libraries(test_redacted_field_repository PRIVATE oatpp::authkit oatpp::oatpp)
|
||||
add_test(NAME redacted_field_repository COMMAND test_redacted_field_repository)
|
||||
|
||||
# SmtpTransport.hpp pulls in <curl/curl.h> and needs libcurl at link time.
|
||||
# Guard the test so the suite still builds where curl dev headers are absent.
|
||||
find_package(CURL QUIET)
|
||||
if(CURL_FOUND)
|
||||
add_executable(test_smtp_transport test_smtp_transport.cpp)
|
||||
target_link_libraries(test_smtp_transport PRIVATE oatpp::authkit oatpp::oatpp CURL::libcurl)
|
||||
add_test(NAME smtp_transport COMMAND test_smtp_transport)
|
||||
endif()
|
||||
|
||||
# RoleTemplateDb pulls in oatpp-sqlite for its DbClient queries. Linking
|
||||
# the test against oatpp::oatpp-sqlite provides the QUERY codegen
|
||||
# definitions; the test itself doesn't open a real DB, only compiles
|
||||
# against the schema declarations.
|
||||
find_package(oatpp-sqlite QUIET)
|
||||
find_package(Threads QUIET)
|
||||
if(oatpp-sqlite_FOUND AND Threads_FOUND)
|
||||
add_executable(test_role_template_schema test_role_template_schema.cpp)
|
||||
target_link_libraries(test_role_template_schema
|
||||
PRIVATE oatpp::authkit oatpp::oatpp oatpp::oatpp-sqlite Threads::Threads)
|
||||
add_test(NAME role_template_schema COMMAND test_role_template_schema)
|
||||
|
||||
add_executable(test_user_permission_schema test_user_permission_schema.cpp)
|
||||
target_link_libraries(test_user_permission_schema
|
||||
PRIVATE oatpp::authkit oatpp::oatpp oatpp::oatpp-sqlite Threads::Threads)
|
||||
add_test(NAME user_permission_schema COMMAND test_user_permission_schema)
|
||||
|
||||
add_executable(test_user_schema test_user_schema.cpp)
|
||||
target_link_libraries(test_user_schema
|
||||
PRIVATE oatpp::authkit oatpp::oatpp oatpp::oatpp-sqlite Threads::Threads)
|
||||
add_test(NAME user_schema COMMAND test_user_schema)
|
||||
endif()
|
||||
|
|
@ -1,335 +0,0 @@
|
|||
// 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;
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
// Smoke test for BodySizeLimitInterceptor — confirms the header compiles
|
||||
// in a consumer translation unit and the constructor surface matches the
|
||||
// documented API. Behavioural tests against real IncomingRequest objects
|
||||
// would need a full oatpp request fixture; pinning the API surface here is
|
||||
// enough to catch the kinds of breakage this header is at risk of.
|
||||
|
||||
#include "oatpp-authkit/interceptor/BodySizeLimitInterceptor.hpp"
|
||||
|
||||
#include <cstdio>
|
||||
#include <memory>
|
||||
|
||||
int main() {
|
||||
using oatpp_authkit::BodySizeLimitInterceptor;
|
||||
|
||||
// Default: fail-closed.
|
||||
auto strict = std::make_shared<BodySizeLimitInterceptor>(1024);
|
||||
(void)strict;
|
||||
|
||||
// Opt-out: legacy fail-open behaviour.
|
||||
auto lax = std::make_shared<BodySizeLimitInterceptor>(1024, /*requireContentLength=*/false);
|
||||
(void)lax;
|
||||
|
||||
std::printf("BodySizeLimitInterceptor API ok\n");
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
// 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;
|
||||
}
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
// Tests for the #6 ObjectMapper migration — verifies that the JSON envelopes
|
||||
// produced by JsonErrorHandler / Hub::buildEntityEvent / Hub::buildPresenceMsg
|
||||
// escape special characters instead of emitting raw text. The previous
|
||||
// hand-rolled concatenations broke when given a `"`/`\\`/control-char string.
|
||||
|
||||
// Avoid pulling Hub.hpp here — it includes oatpp-websocket, which is a
|
||||
// separate optional dependency not necessarily on the test target's include
|
||||
// path. The escaping behaviour we care about is purely a property of
|
||||
// ObjectMapper round-tripping the InternalDto types, so we exercise the
|
||||
// DTOs directly.
|
||||
#include "oatpp-authkit/handler/JsonErrorHandler.hpp"
|
||||
#include "oatpp-authkit/dto/InternalDto.hpp"
|
||||
|
||||
#include "oatpp/parser/json/mapping/ObjectMapper.hpp"
|
||||
#include "oatpp/web/protocol/http/Http.hpp"
|
||||
|
||||
#include <cstdio>
|
||||
#include <set>
|
||||
#include <string>
|
||||
|
||||
namespace {
|
||||
|
||||
int g_failures = 0;
|
||||
|
||||
#define REQUIRE(expr) do { \
|
||||
if (!(expr)) { \
|
||||
std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \
|
||||
++g_failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
void test_presence_dto_round_trips_special_chars() {
|
||||
auto m = oatpp::parser::json::mapping::ObjectMapper::createShared();
|
||||
auto dto = oatpp_authkit::dto::WsPresenceUpdateDto::createShared();
|
||||
dto->type = oatpp::String("presence_update");
|
||||
dto->booking_id = oatpp::String("id-with-\"-quote");
|
||||
dto->users = {};
|
||||
dto->users->push_back(oatpp::String("al\"ice"));
|
||||
dto->users->push_back(oatpp::String("bo\\b"));
|
||||
auto json = m->writeToString(dto);
|
||||
|
||||
auto rt = m->readFromString<oatpp::Object<oatpp_authkit::dto::WsPresenceUpdateDto>>(json);
|
||||
REQUIRE(rt);
|
||||
REQUIRE(std::string(*rt->booking_id) == "id-with-\"-quote");
|
||||
REQUIRE(rt->users->size() == 2);
|
||||
auto it = rt->users->begin();
|
||||
REQUIRE(std::string(**it++) == "al\"ice");
|
||||
REQUIRE(std::string(**it) == "bo\\b");
|
||||
}
|
||||
|
||||
void test_entity_event_dto_round_trip() {
|
||||
auto m = oatpp::parser::json::mapping::ObjectMapper::createShared();
|
||||
auto dto = oatpp_authkit::dto::WsEntityEventDto::createShared();
|
||||
dto->type = oatpp::String("booking_updated");
|
||||
dto->id = oatpp::String("id-with-\"-and-\\");
|
||||
auto json = m->writeToString(dto);
|
||||
auto rt = m->readFromString<oatpp::Object<oatpp_authkit::dto::WsEntityEventDto>>(json);
|
||||
REQUIRE(rt);
|
||||
REQUIRE(std::string(*rt->id) == "id-with-\"-and-\\");
|
||||
}
|
||||
|
||||
void test_client_msg_dto_rejects_malformed() {
|
||||
auto m = oatpp::parser::json::mapping::ObjectMapper::createShared();
|
||||
bool threw = false;
|
||||
try {
|
||||
m->readFromString<oatpp::Object<oatpp_authkit::dto::WsClientMsgDto>>(
|
||||
oatpp::String("{not json"));
|
||||
} catch (...) { threw = true; }
|
||||
REQUIRE(threw);
|
||||
}
|
||||
|
||||
void test_json_error_dto_round_trip() {
|
||||
auto m = oatpp::parser::json::mapping::ObjectMapper::createShared();
|
||||
auto dto = oatpp_authkit::dto::JsonErrorDto::createShared();
|
||||
dto->status = oatpp::String("I'm a \"teapot\"");
|
||||
dto->code = 418;
|
||||
dto->message = oatpp::String("brew\nfailure");
|
||||
auto json = m->writeToString(dto);
|
||||
|
||||
auto rt = m->readFromString<oatpp::Object<oatpp_authkit::dto::JsonErrorDto>>(json);
|
||||
REQUIRE(rt);
|
||||
REQUIRE(std::string(*rt->status) == "I'm a \"teapot\"");
|
||||
REQUIRE(rt->code == 418);
|
||||
REQUIRE(std::string(*rt->message) == "brew\nfailure");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
test_presence_dto_round_trips_special_chars();
|
||||
test_entity_event_dto_round_trip();
|
||||
test_client_msg_dto_rejects_malformed();
|
||||
test_json_error_dto_round_trip();
|
||||
|
||||
std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures);
|
||||
return g_failures ? 1 : 0;
|
||||
}
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
// Tests for AuthInterceptor::wantsJson + urlEncode (the negotiation primitives
|
||||
// that decide whether a 401/403 returns JSON vs HTML/redirect).
|
||||
//
|
||||
// Kept dependency-free on purpose — the harness exists so future tests have
|
||||
// somewhere to land, not to pull in doctest/Catch2.
|
||||
|
||||
#include "oatpp-authkit/auth/AuthInterceptor.hpp"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
|
||||
namespace {
|
||||
|
||||
int g_failures = 0;
|
||||
|
||||
#define REQUIRE(expr) do { \
|
||||
if (!(expr)) { \
|
||||
std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \
|
||||
++g_failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
using oatpp_authkit::AuthInterceptor;
|
||||
|
||||
void test_wantsJson_api_path() {
|
||||
// /api/* always wants JSON, no matter what Accept says.
|
||||
REQUIRE( AuthInterceptor::wantsJson("/api/users", "", "text/html"));
|
||||
REQUIRE( AuthInterceptor::wantsJson("/api/", "", ""));
|
||||
}
|
||||
|
||||
void test_wantsJson_xrequested_with() {
|
||||
// Explicit AJAX wins regardless of path/Accept.
|
||||
REQUIRE( AuthInterceptor::wantsJson("/admin", "XMLHttpRequest", "text/html"));
|
||||
}
|
||||
|
||||
void test_wantsJson_accept_header() {
|
||||
// application/json without text/html → JSON.
|
||||
REQUIRE( AuthInterceptor::wantsJson("/admin", "", "application/json"));
|
||||
// text/html present → browser navigation.
|
||||
REQUIRE(!AuthInterceptor::wantsJson("/admin", "", "text/html,application/xhtml+xml"));
|
||||
REQUIRE(!AuthInterceptor::wantsJson("/admin", "", "text/html,application/json"));
|
||||
// No Accept → assume browser (HTML/redirect).
|
||||
REQUIRE(!AuthInterceptor::wantsJson("/set-password", "", ""));
|
||||
}
|
||||
|
||||
void test_wantsJson_set_password_browser() {
|
||||
// The motivating regression: a browser following the password-reset link
|
||||
// must NOT be served JSON. (Path is public so it shouldn't reach this in
|
||||
// normal flow, but if auth ever rejects it the user sees HTML/redirect.)
|
||||
REQUIRE(!AuthInterceptor::wantsJson("/set-password",
|
||||
"",
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9"));
|
||||
}
|
||||
|
||||
void test_urlEncode() {
|
||||
REQUIRE(AuthInterceptor::urlEncode("/admin") == "%2Fadmin");
|
||||
REQUIRE(AuthInterceptor::urlEncode("/set-password?t=1")== "%2Fset-password%3Ft%3D1");
|
||||
REQUIRE(AuthInterceptor::urlEncode("abc-_.~123") == "abc-_.~123");
|
||||
REQUIRE(AuthInterceptor::urlEncode(" ") == "%20");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
test_wantsJson_api_path();
|
||||
test_wantsJson_xrequested_with();
|
||||
test_wantsJson_accept_header();
|
||||
test_wantsJson_set_password_browser();
|
||||
test_urlEncode();
|
||||
if (g_failures) {
|
||||
std::fprintf(stderr, "%d test(s) failed\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
std::puts("ok");
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
// 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;
|
||||
}
|
||||
|
|
@ -1,243 +0,0 @@
|
|||
// Tests for the oatpp-authkit#9 IQueryable<T> capability.
|
||||
//
|
||||
// Verifies that the AST emits the expected parameterised SQL and that the
|
||||
// bind bag captures the values in order. No real database is involved —
|
||||
// these tests exercise the SQL emitter and the builder API.
|
||||
|
||||
#include "oatpp-authkit/repo/IQueryable.hpp"
|
||||
|
||||
#include "oatpp/core/macro/codegen.hpp"
|
||||
#include "oatpp/core/Types.hpp"
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <variant>
|
||||
#include <vector>
|
||||
|
||||
#include OATPP_CODEGEN_BEGIN(DTO)
|
||||
|
||||
namespace {
|
||||
|
||||
class MockQueryDto : public oatpp::DTO {
|
||||
DTO_INIT(MockQueryDto, DTO)
|
||||
DTO_FIELD(String, entity_id);
|
||||
DTO_FIELD(String, name);
|
||||
DTO_FIELD(String, email);
|
||||
DTO_FIELD(Int64, age);
|
||||
DTO_FIELD(Boolean, active);
|
||||
};
|
||||
|
||||
#include OATPP_CODEGEN_END(DTO)
|
||||
|
||||
} // namespace
|
||||
|
||||
OATPP_AUTHKIT_REGISTER_TABLE(MockQueryDto, "mock_query")
|
||||
OATPP_AUTHKIT_REGISTER_FIELD(MockQueryDto, entity_id, "entity_id")
|
||||
OATPP_AUTHKIT_REGISTER_FIELD(MockQueryDto, name, "name")
|
||||
OATPP_AUTHKIT_REGISTER_FIELD(MockQueryDto, email, "email")
|
||||
OATPP_AUTHKIT_REGISTER_FIELD(MockQueryDto, age, "age")
|
||||
OATPP_AUTHKIT_REGISTER_FIELD(MockQueryDto, active, "active")
|
||||
|
||||
namespace {
|
||||
|
||||
int g_failures = 0;
|
||||
|
||||
#define REQUIRE(expr) do { \
|
||||
if (!(expr)) { \
|
||||
std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \
|
||||
++g_failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
#define REQUIRE_EQ(a, b) do { \
|
||||
auto _av = (a); auto _bv = (b); \
|
||||
if (!(_av == _bv)) { \
|
||||
std::fprintf(stderr, "FAIL %s:%d %s == %s ('%s' vs '%s')\n", \
|
||||
__FILE__, __LINE__, #a, #b, \
|
||||
std::string(_av).c_str(), std::string(_bv).c_str()); \
|
||||
++g_failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
using namespace oatpp_authkit::repo;
|
||||
|
||||
// Helper: pull a typed value out of a BindValue, or "<null>" / "<wrong>".
|
||||
template <typename T>
|
||||
std::string bindAsString(const BindValue& v) {
|
||||
if (auto* p = std::get_if<T>(&v)) {
|
||||
if constexpr (std::is_same_v<T, std::string>) return *p;
|
||||
else return std::to_string(*p);
|
||||
}
|
||||
if (std::holds_alternative<std::monostate>(v)) return "<null>";
|
||||
return "<wrong-type>";
|
||||
}
|
||||
|
||||
void test_equality_emits_parameterised_sql() {
|
||||
auto sql = Query<MockQueryDto>()
|
||||
.where(field<&MockQueryDto::email>().eq("foo@bar"))
|
||||
.toSql();
|
||||
REQUIRE_EQ(sql.text,
|
||||
std::string("SELECT * FROM mock_query WHERE email = ?"));
|
||||
REQUIRE(sql.binds.size() == 1);
|
||||
REQUIRE_EQ(bindAsString<std::string>(sql.binds[0]),
|
||||
std::string("foo@bar"));
|
||||
}
|
||||
|
||||
void test_and_or_combines_predicates() {
|
||||
auto sql = Query<MockQueryDto>()
|
||||
.where(field<&MockQueryDto::active>().eq(true)
|
||||
&& (field<&MockQueryDto::age>().gt(18)
|
||||
|| field<&MockQueryDto::age>().lt(5)))
|
||||
.toSql();
|
||||
REQUIRE_EQ(sql.text, std::string(
|
||||
"SELECT * FROM mock_query WHERE "
|
||||
"(active = ? AND (age > ? OR age < ?))"));
|
||||
REQUIRE(sql.binds.size() == 3);
|
||||
REQUIRE(std::get<bool>(sql.binds[0]) == true);
|
||||
REQUIRE(std::get<std::int64_t>(sql.binds[1]) == 18);
|
||||
REQUIRE(std::get<std::int64_t>(sql.binds[2]) == 5);
|
||||
}
|
||||
|
||||
void test_repeated_where_implies_and() {
|
||||
auto sql = Query<MockQueryDto>()
|
||||
.where(field<&MockQueryDto::email>().eq("foo@bar"))
|
||||
.where(field<&MockQueryDto::active>().eq(true))
|
||||
.toSql();
|
||||
REQUIRE_EQ(sql.text, std::string(
|
||||
"SELECT * FROM mock_query WHERE "
|
||||
"(email = ? AND active = ?)"));
|
||||
}
|
||||
|
||||
void test_range_emits_inclusive_and_exclusive() {
|
||||
auto sql = Query<MockQueryDto>()
|
||||
.where(field<&MockQueryDto::age>().ge(18)
|
||||
&& field<&MockQueryDto::age>().le(65))
|
||||
.toSql();
|
||||
REQUIRE_EQ(sql.text, std::string(
|
||||
"SELECT * FROM mock_query WHERE (age >= ? AND age <= ?)"));
|
||||
REQUIRE(std::get<std::int64_t>(sql.binds[0]) == 18);
|
||||
REQUIRE(std::get<std::int64_t>(sql.binds[1]) == 65);
|
||||
}
|
||||
|
||||
void test_in_with_multiple_values() {
|
||||
auto sql = Query<MockQueryDto>()
|
||||
.where(field<&MockQueryDto::email>().in({"a@x", "b@x", "c@x"}))
|
||||
.toSql();
|
||||
REQUIRE_EQ(sql.text, std::string(
|
||||
"SELECT * FROM mock_query WHERE email IN (?, ?, ?)"));
|
||||
REQUIRE(sql.binds.size() == 3);
|
||||
REQUIRE_EQ(std::get<std::string>(sql.binds[0]), std::string("a@x"));
|
||||
REQUIRE_EQ(std::get<std::string>(sql.binds[2]), std::string("c@x"));
|
||||
}
|
||||
|
||||
void test_in_with_empty_list_is_always_false() {
|
||||
std::vector<std::string> empty;
|
||||
auto sql = Query<MockQueryDto>()
|
||||
.where(field<&MockQueryDto::email>().in(empty))
|
||||
.toSql();
|
||||
REQUIRE_EQ(sql.text, std::string("SELECT * FROM mock_query WHERE 0"));
|
||||
REQUIRE(sql.binds.empty());
|
||||
}
|
||||
|
||||
void test_like_pattern_is_bound_not_interpolated() {
|
||||
auto sql = Query<MockQueryDto>()
|
||||
.where(field<&MockQueryDto::name>().like("Al%"))
|
||||
.toSql();
|
||||
REQUIRE_EQ(sql.text, std::string(
|
||||
"SELECT * FROM mock_query WHERE name LIKE ?"));
|
||||
REQUIRE_EQ(std::get<std::string>(sql.binds[0]), std::string("Al%"));
|
||||
}
|
||||
|
||||
void test_like_contains_escapes_wildcards() {
|
||||
// authkit#16 L-8: a user term with %/_/\ must be matched literally via an
|
||||
// explicit ESCAPE clause, not treated as wildcards.
|
||||
auto sql = Query<MockQueryDto>()
|
||||
.where(field<&MockQueryDto::name>().likeContains("50%_off\\x"))
|
||||
.toSql();
|
||||
REQUIRE_EQ(sql.text, std::string(
|
||||
"SELECT * FROM mock_query WHERE name LIKE ? ESCAPE '\\'"));
|
||||
REQUIRE_EQ(std::get<std::string>(sql.binds[0]),
|
||||
std::string("%50\\%\\_off\\\\x%"));
|
||||
|
||||
auto pfx = Query<MockQueryDto>()
|
||||
.where(field<&MockQueryDto::name>().likePrefix("a_b"))
|
||||
.toSql();
|
||||
REQUIRE_EQ(pfx.text, std::string(
|
||||
"SELECT * FROM mock_query WHERE name LIKE ? ESCAPE '\\'"));
|
||||
REQUIRE_EQ(std::get<std::string>(pfx.binds[0]), std::string("a\\_b%"));
|
||||
|
||||
// The bare likeEscape helper.
|
||||
REQUIRE_EQ(likeEscape("100%_\\"), std::string("100\\%\\_\\\\"));
|
||||
REQUIRE_EQ(likeEscape("plain"), std::string("plain"));
|
||||
}
|
||||
|
||||
void test_is_null_and_is_not_null() {
|
||||
auto a = Query<MockQueryDto>()
|
||||
.where(field<&MockQueryDto::email>().isNull())
|
||||
.toSql();
|
||||
REQUIRE_EQ(a.text, std::string(
|
||||
"SELECT * FROM mock_query WHERE email IS NULL"));
|
||||
REQUIRE(a.binds.empty());
|
||||
|
||||
auto b = Query<MockQueryDto>()
|
||||
.where(field<&MockQueryDto::email>().isNotNull())
|
||||
.toSql();
|
||||
REQUIRE_EQ(b.text, std::string(
|
||||
"SELECT * FROM mock_query WHERE email IS NOT NULL"));
|
||||
}
|
||||
|
||||
void test_not_negates_predicate() {
|
||||
auto sql = Query<MockQueryDto>()
|
||||
.where(!field<&MockQueryDto::active>().eq(true))
|
||||
.toSql();
|
||||
REQUIRE_EQ(sql.text, std::string(
|
||||
"SELECT * FROM mock_query WHERE NOT (active = ?)"));
|
||||
}
|
||||
|
||||
void test_order_by_and_limit_offset() {
|
||||
auto sql = Query<MockQueryDto>()
|
||||
.where(field<&MockQueryDto::active>().eq(true))
|
||||
.orderBy(field<&MockQueryDto::name>())
|
||||
.orderByDesc(field<&MockQueryDto::age>())
|
||||
.limit(50)
|
||||
.offset(100)
|
||||
.toSql();
|
||||
REQUIRE_EQ(sql.text, std::string(
|
||||
"SELECT * FROM mock_query WHERE active = ? "
|
||||
"ORDER BY name ASC, age DESC LIMIT 50 OFFSET 100"));
|
||||
REQUIRE(std::get<bool>(sql.binds[0]) == true);
|
||||
}
|
||||
|
||||
void test_no_where_no_clauses_is_plain_select() {
|
||||
auto sql = Query<MockQueryDto>().toSql();
|
||||
REQUIRE_EQ(sql.text, std::string("SELECT * FROM mock_query"));
|
||||
REQUIRE(sql.binds.empty());
|
||||
}
|
||||
|
||||
void test_oatpp_string_value_is_unwrapped() {
|
||||
auto sql = Query<MockQueryDto>()
|
||||
.where(field<&MockQueryDto::email>().eq(oatpp::String("z@x")))
|
||||
.toSql();
|
||||
REQUIRE_EQ(std::get<std::string>(sql.binds[0]), std::string("z@x"));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
test_equality_emits_parameterised_sql();
|
||||
test_and_or_combines_predicates();
|
||||
test_repeated_where_implies_and();
|
||||
test_range_emits_inclusive_and_exclusive();
|
||||
test_in_with_multiple_values();
|
||||
test_in_with_empty_list_is_always_false();
|
||||
test_like_pattern_is_bound_not_interpolated();
|
||||
test_like_contains_escapes_wildcards();
|
||||
test_is_null_and_is_not_null();
|
||||
test_not_negates_predicate();
|
||||
test_order_by_and_limit_offset();
|
||||
test_no_where_no_clauses_is_plain_select();
|
||||
test_oatpp_string_value_is_unwrapped();
|
||||
|
||||
std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures);
|
||||
return g_failures ? 1 : 0;
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
// 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;
|
||||
}
|
||||
|
|
@ -1,196 +0,0 @@
|
|||
// 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;
|
||||
}
|
||||
|
|
@ -1,435 +0,0 @@
|
|||
// Tests for the oatpp-authkit#8 repository decorators (TemporalRepository,
|
||||
// ScopeGuardRepository). Validates the acceptance criteria from the issue:
|
||||
// - Temporal save closes the prior version
|
||||
// - Live read returns only the row with valid_until = sentinel
|
||||
// - Point-in-time read returns the version live at that time
|
||||
// - History returns all versions in order
|
||||
// - Scope guard short-circuits when the predicate returns false
|
||||
//
|
||||
// The in-memory backing store keys rows by (entity_id, valid_from), matching
|
||||
// the upsert contract documented on TemporalRepository<TDto>.
|
||||
|
||||
#include "oatpp-authkit/repo/TemporalRepository.hpp"
|
||||
#include "oatpp-authkit/repo/ScopeGuardRepository.hpp"
|
||||
#include "oatpp-authkit/repo/Repository.hpp"
|
||||
#include "oatpp-authkit/repo/TemporalFieldTraits.hpp"
|
||||
#include "oatpp-authkit/repo/TemporalAt.hpp"
|
||||
#include "oatpp-authkit/repo/ActorContext.hpp"
|
||||
|
||||
#include "oatpp/core/macro/codegen.hpp"
|
||||
#include "oatpp/core/Types.hpp"
|
||||
|
||||
#include <cstdio>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
#include OATPP_CODEGEN_BEGIN(DTO)
|
||||
|
||||
namespace {
|
||||
|
||||
class MockTemporalDto : public oatpp::DTO {
|
||||
DTO_INIT(MockTemporalDto, DTO)
|
||||
DTO_FIELD(String, id); // per-row PK (version UUID)
|
||||
DTO_FIELD(String, entity_id);
|
||||
DTO_FIELD(String, valid_from);
|
||||
DTO_FIELD(String, valid_until);
|
||||
DTO_FIELD(String, name);
|
||||
DTO_FIELD(String, scope); // For ScopeGuardRepository — emulates a property_id-style field.
|
||||
};
|
||||
|
||||
#include OATPP_CODEGEN_END(DTO)
|
||||
|
||||
} // namespace
|
||||
OATPP_AUTHKIT_REGISTER_TEMPORAL(MockTemporalDto, id, entity_id, valid_from, valid_until)
|
||||
namespace {
|
||||
|
||||
int g_failures = 0;
|
||||
|
||||
#define REQUIRE(expr) do { \
|
||||
if (!(expr)) { \
|
||||
std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \
|
||||
++g_failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
// In-memory adapter: rows keyed by `id` PK (per-row UUID). save() upserts —
|
||||
// matches the new TemporalRepository inner contract (authkit#13).
|
||||
// Exposes ALL rows via list() — the temporal decorator filters to live.
|
||||
class InMemoryAllRows : public oatpp_authkit::repo::Repository<MockTemporalDto> {
|
||||
std::map<std::string, oatpp::Object<MockTemporalDto>> rows;
|
||||
public:
|
||||
oatpp::Object<MockTemporalDto> findByEntityId(const oatpp::String& id) override {
|
||||
// Not used by TemporalRepository — included for interface completeness.
|
||||
for (auto& kv : rows) if (kv.second->entity_id && std::string(*kv.second->entity_id) == std::string(*id)) return kv.second;
|
||||
return nullptr;
|
||||
}
|
||||
oatpp::Vector<oatpp::Object<MockTemporalDto>> list() override {
|
||||
auto v = oatpp::Vector<oatpp::Object<MockTemporalDto>>::createShared();
|
||||
for (auto& kv : rows) v->push_back(kv.second);
|
||||
return v;
|
||||
}
|
||||
void save(const oatpp::Object<MockTemporalDto>& dto) override {
|
||||
rows[std::string(*dto->id)] = dto;
|
||||
}
|
||||
void softDelete(const oatpp::String& id) override {
|
||||
for (auto it = rows.begin(); it != rows.end(); ) {
|
||||
if (it->second->entity_id && std::string(*it->second->entity_id) == std::string(*id)) it = rows.erase(it); else ++it;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Fixed-time clock for deterministic tests. Returns successive timestamps
|
||||
// 1000ms apart so point-in-time reads can pick a value strictly between
|
||||
// version boundaries.
|
||||
struct StepClock {
|
||||
int64_t ms{1700000000000LL};
|
||||
int64_t operator()() { int64_t v = ms; ms += 1000; return v; }
|
||||
};
|
||||
|
||||
// Sequencing idgen so each call returns a fresh string — needed now that
|
||||
// the decorator allocates both entity_id and per-row PK.
|
||||
struct SeqIdGen {
|
||||
int n{0};
|
||||
oatpp::String operator()() {
|
||||
char buf[16];
|
||||
std::snprintf(buf, sizeof(buf), "id%04d", n++);
|
||||
return oatpp::String(buf);
|
||||
}
|
||||
};
|
||||
|
||||
void test_save_closes_prior_version_and_inserts_new() {
|
||||
using namespace oatpp_authkit::repo;
|
||||
auto inner = std::make_shared<InMemoryAllRows>();
|
||||
auto clock = std::make_shared<StepClock>();
|
||||
auto ids = std::make_shared<SeqIdGen>();
|
||||
TemporalRepository<MockTemporalDto> repo(inner,
|
||||
[clock]{ return (*clock)(); },
|
||||
[ids]{ return (*ids)(); });
|
||||
|
||||
// First save — entity_id + id auto-allocated, valid_from = now1, valid_until = SENTINEL.
|
||||
auto v1 = MockTemporalDto::createShared();
|
||||
v1->name = oatpp::String("alice v1");
|
||||
repo.save(v1);
|
||||
|
||||
REQUIRE(v1->entity_id);
|
||||
REQUIRE(v1->id);
|
||||
REQUIRE(std::string(*v1->valid_until)
|
||||
== TemporalRepository<MockTemporalDto>::SENTINEL);
|
||||
REQUIRE(inner->list()->size() == 1);
|
||||
const std::string livePkAfterFirst = std::string(*v1->id);
|
||||
|
||||
// Second save — historical copy with new PK, live row updated in place.
|
||||
auto v2 = MockTemporalDto::createShared();
|
||||
v2->entity_id = v1->entity_id;
|
||||
v2->name = oatpp::String("alice v2");
|
||||
repo.save(v2);
|
||||
|
||||
auto allAfter = inner->list();
|
||||
REQUIRE(allAfter->size() == 2);
|
||||
int liveCount = 0;
|
||||
std::string livePkAfterSecond, historicalPk;
|
||||
for (auto& row : *allAfter) {
|
||||
if (std::string(*row->valid_until)
|
||||
== TemporalRepository<MockTemporalDto>::SENTINEL) {
|
||||
++liveCount;
|
||||
livePkAfterSecond = std::string(*row->id);
|
||||
} else {
|
||||
historicalPk = std::string(*row->id);
|
||||
}
|
||||
}
|
||||
REQUIRE(liveCount == 1); // exactly one live
|
||||
REQUIRE(livePkAfterSecond == livePkAfterFirst); // stable live PK
|
||||
REQUIRE(historicalPk != livePkAfterFirst); // historical has fresh PK
|
||||
REQUIRE(std::string(*v2->id) == livePkAfterFirst); // dto reflects preserved PK
|
||||
}
|
||||
|
||||
void test_live_read_returns_only_sentinel_row() {
|
||||
using namespace oatpp_authkit::repo;
|
||||
auto inner = std::make_shared<InMemoryAllRows>();
|
||||
auto clock = std::make_shared<StepClock>();
|
||||
TemporalRepository<MockTemporalDto> repo(inner,
|
||||
[clock]{ return (*clock)(); });
|
||||
|
||||
auto v1 = MockTemporalDto::createShared();
|
||||
v1->entity_id = oatpp::String("abc");
|
||||
v1->name = oatpp::String("v1");
|
||||
repo.save(v1);
|
||||
auto v2 = MockTemporalDto::createShared();
|
||||
v2->entity_id = oatpp::String("abc");
|
||||
v2->name = oatpp::String("v2");
|
||||
repo.save(v2);
|
||||
|
||||
auto live = repo.findByEntityId(oatpp::String("abc"));
|
||||
REQUIRE(live);
|
||||
REQUIRE(std::string(*live->name) == "v2");
|
||||
|
||||
auto liveList = repo.list();
|
||||
REQUIRE(liveList->size() == 1);
|
||||
REQUIRE(std::string(*(*liveList)[0]->name) == "v2");
|
||||
}
|
||||
|
||||
void test_point_in_time_read_returns_version_live_at_t() {
|
||||
using namespace oatpp_authkit::repo;
|
||||
auto inner = std::make_shared<InMemoryAllRows>();
|
||||
StepClock clock;
|
||||
int64_t t1 = clock.ms;
|
||||
auto repo = std::make_shared<TemporalRepository<MockTemporalDto>>(
|
||||
inner, [&clock]{ return clock(); });
|
||||
|
||||
auto v1 = MockTemporalDto::createShared();
|
||||
v1->entity_id = oatpp::String("abc");
|
||||
v1->name = oatpp::String("v1");
|
||||
repo->save(v1); // valid_from = t1, valid_until = SENTINEL → t2 after save 2
|
||||
|
||||
// Pick a point strictly inside v1's interval [t1, t2).
|
||||
int64_t betweenSaves = t1 + 500; // t2 = t1 + 1000 with our StepClock
|
||||
|
||||
auto v2 = MockTemporalDto::createShared();
|
||||
v2->entity_id = oatpp::String("abc");
|
||||
v2->name = oatpp::String("v2");
|
||||
repo->save(v2); // closes v1 at t2; v2 valid_from = t2
|
||||
|
||||
// Read as-of betweenSaves — should still see v1.
|
||||
auto atT2 = repo->findByEntityIdAt(oatpp::String("abc"),
|
||||
TemporalAt::at(betweenSaves));
|
||||
REQUIRE(atT2);
|
||||
REQUIRE(std::string(*atT2->name) == "v1");
|
||||
|
||||
// Read as-of t1 — should also see v1.
|
||||
auto atT1 = repo->findByEntityIdAt(oatpp::String("abc"), TemporalAt::at(t1));
|
||||
REQUIRE(atT1);
|
||||
REQUIRE(std::string(*atT1->name) == "v1");
|
||||
}
|
||||
|
||||
void test_history_returns_versions_in_order() {
|
||||
using namespace oatpp_authkit::repo;
|
||||
auto inner = std::make_shared<InMemoryAllRows>();
|
||||
auto clock = std::make_shared<StepClock>();
|
||||
TemporalRepository<MockTemporalDto> repo(inner,
|
||||
[clock]{ return (*clock)(); });
|
||||
|
||||
for (const char* n : {"v1", "v2", "v3"}) {
|
||||
auto dto = MockTemporalDto::createShared();
|
||||
dto->entity_id = oatpp::String("abc");
|
||||
dto->name = oatpp::String(n);
|
||||
repo.save(dto);
|
||||
}
|
||||
|
||||
auto h = repo.history(oatpp::String("abc"));
|
||||
REQUIRE(h->size() == 3);
|
||||
REQUIRE(std::string(*(*h)[0]->name) == "v1");
|
||||
REQUIRE(std::string(*(*h)[1]->name) == "v2");
|
||||
REQUIRE(std::string(*(*h)[2]->name) == "v3");
|
||||
}
|
||||
|
||||
void test_soft_delete_closes_live_without_new_version() {
|
||||
using namespace oatpp_authkit::repo;
|
||||
auto inner = std::make_shared<InMemoryAllRows>();
|
||||
auto clock = std::make_shared<StepClock>();
|
||||
TemporalRepository<MockTemporalDto> repo(inner,
|
||||
[clock]{ return (*clock)(); });
|
||||
|
||||
auto v = MockTemporalDto::createShared();
|
||||
v->entity_id = oatpp::String("abc");
|
||||
v->name = oatpp::String("dead");
|
||||
repo.save(v);
|
||||
|
||||
repo.softDelete(oatpp::String("abc"));
|
||||
|
||||
REQUIRE(!repo.findByEntityId(oatpp::String("abc")));
|
||||
auto remaining = inner->list();
|
||||
REQUIRE(remaining->size() == 1); // historical row still exists
|
||||
REQUIRE(std::string(*(*remaining)[0]->valid_until)
|
||||
!= TemporalRepository<MockTemporalDto>::SENTINEL);
|
||||
}
|
||||
|
||||
// ---- ScopeGuardRepository ----
|
||||
|
||||
void test_scope_guard_denies_when_predicate_false() {
|
||||
using namespace oatpp_authkit::repo;
|
||||
auto inner = std::make_shared<InMemoryAllRows>();
|
||||
|
||||
// Seed inner with two rows in different scopes.
|
||||
for (const char* sc : {"prop-A", "prop-B"}) {
|
||||
auto dto = MockTemporalDto::createShared();
|
||||
dto->id = oatpp::String(std::string("pk-") + sc);
|
||||
dto->entity_id = oatpp::String(sc); // reuse scope as id for simplicity
|
||||
dto->valid_from = oatpp::String("2020-01-01T00:00:00Z");
|
||||
dto->valid_until = oatpp::String("9999-12-31T23:59:59Z");
|
||||
dto->name = oatpp::String(sc);
|
||||
dto->scope = oatpp::String(sc);
|
||||
inner->save(dto);
|
||||
}
|
||||
|
||||
ActorContext actor;
|
||||
actor.userId = "u1";
|
||||
actor.allowedScopes = {"prop-A"};
|
||||
|
||||
ScopeGuardRepository<MockTemporalDto> guarded(inner,
|
||||
// Predicate: only allow rows whose scope is in the actor's allowedScopes.
|
||||
[](const ActorContext& a, const oatpp::Object<MockTemporalDto>& d) {
|
||||
if (!d || !d->scope) return false;
|
||||
const std::string s = std::string(*d->scope);
|
||||
for (auto& as : a.allowedScopes) if (as == s) return true;
|
||||
return false;
|
||||
},
|
||||
[actor]{ return actor; },
|
||||
[](const oatpp::Object<MockTemporalDto>& d) { return d->entity_id; });
|
||||
|
||||
// list filters to allowed rows only.
|
||||
auto allowed = guarded.list();
|
||||
REQUIRE(allowed->size() == 1);
|
||||
REQUIRE(std::string(*(*allowed)[0]->scope) == "prop-A");
|
||||
|
||||
// findByEntityId on a denied row throws.
|
||||
bool threwOnFind = false;
|
||||
try { (void)guarded.findByEntityId(oatpp::String("prop-B")); }
|
||||
catch (const ScopeDeniedException&) { threwOnFind = true; }
|
||||
REQUIRE(threwOnFind);
|
||||
|
||||
// findByEntityId on an allowed row returns it.
|
||||
auto okRow = guarded.findByEntityId(oatpp::String("prop-A"));
|
||||
REQUIRE(okRow);
|
||||
|
||||
// save of a denied scope throws.
|
||||
auto evil = MockTemporalDto::createShared();
|
||||
evil->entity_id = oatpp::String("xxx");
|
||||
evil->valid_from = oatpp::String("2020-01-01T00:00:00Z");
|
||||
evil->valid_until = oatpp::String("9999-12-31T23:59:59Z");
|
||||
evil->scope = oatpp::String("prop-B");
|
||||
bool threwOnSave = false;
|
||||
try { guarded.save(evil); }
|
||||
catch (const ScopeDeniedException&) { threwOnSave = true; }
|
||||
REQUIRE(threwOnSave);
|
||||
|
||||
// softDelete of a denied row throws.
|
||||
bool threwOnDelete = false;
|
||||
try { guarded.softDelete(oatpp::String("prop-B")); }
|
||||
catch (const ScopeDeniedException&) { threwOnDelete = true; }
|
||||
REQUIRE(threwOnDelete);
|
||||
}
|
||||
|
||||
// Scope predicate + entity-id accessor shared by the reparenting / queryable tests.
|
||||
static bool scopeAllows(const oatpp_authkit::repo::ActorContext& a,
|
||||
const oatpp::Object<MockTemporalDto>& d) {
|
||||
if (!d || !d->scope) return false;
|
||||
const std::string s = std::string(*d->scope);
|
||||
for (auto& as : a.allowedScopes) if (as == s) return true;
|
||||
return false;
|
||||
}
|
||||
static oatpp::String entityIdOf(const oatpp::Object<MockTemporalDto>& d) { return d->entity_id; }
|
||||
|
||||
// An actor scoped to prop-A must NOT be able to reparent an existing prop-B row
|
||||
// into prop-A by setting scope=prop-A in the body. save() must reject because the
|
||||
// *existing* row is out of scope, even though the incoming dto looks in-scope.
|
||||
void test_scope_guard_blocks_reparenting() {
|
||||
using namespace oatpp_authkit::repo;
|
||||
auto inner = std::make_shared<InMemoryAllRows>();
|
||||
|
||||
// Seed an entity currently owned by prop-B.
|
||||
auto seeded = MockTemporalDto::createShared();
|
||||
seeded->id = oatpp::String("pk-ent1");
|
||||
seeded->entity_id = oatpp::String("ent1");
|
||||
seeded->valid_from = oatpp::String("2020-01-01T00:00:00Z");
|
||||
seeded->valid_until = oatpp::String("9999-12-31T23:59:59Z");
|
||||
seeded->scope = oatpp::String("prop-B");
|
||||
inner->save(seeded);
|
||||
|
||||
ActorContext actor;
|
||||
actor.userId = "u1";
|
||||
actor.allowedScopes = {"prop-A"};
|
||||
|
||||
ScopeGuardRepository<MockTemporalDto> guarded(
|
||||
inner, &scopeAllows, [actor]{ return actor; }, &entityIdOf);
|
||||
|
||||
// Attempt to claim ent1 by relabelling it prop-A.
|
||||
auto reparent = MockTemporalDto::createShared();
|
||||
reparent->entity_id = oatpp::String("ent1");
|
||||
reparent->scope = oatpp::String("prop-A"); // incoming looks in-scope...
|
||||
bool blocked = false;
|
||||
try { guarded.save(reparent); }
|
||||
catch (const ScopeDeniedException&) { blocked = true; } // ...but existing row is prop-B
|
||||
REQUIRE(blocked);
|
||||
|
||||
// The stored row is untouched.
|
||||
auto still = inner->findByEntityId(oatpp::String("ent1"));
|
||||
REQUIRE(still);
|
||||
REQUIRE(std::string(*still->scope) == "prop-B");
|
||||
|
||||
// A genuine insert into the actor's own scope still works (no existing row).
|
||||
auto fresh = MockTemporalDto::createShared();
|
||||
fresh->id = oatpp::String("pk-ent2");
|
||||
fresh->entity_id = oatpp::String("ent2");
|
||||
fresh->scope = oatpp::String("prop-A");
|
||||
fresh->valid_until = oatpp::String("9999-12-31T23:59:59Z");
|
||||
bool ok = true;
|
||||
try { guarded.save(fresh); } catch (const ScopeDeniedException&) { ok = false; }
|
||||
REQUIRE(ok);
|
||||
}
|
||||
|
||||
// Minimal IQueryable inner whose query() returns every row, so the test can
|
||||
// verify ScopeGuardQueryable post-filters results through the predicate.
|
||||
class InMemoryQueryable : public oatpp_authkit::repo::IQueryable<MockTemporalDto> {
|
||||
std::map<std::string, oatpp::Object<MockTemporalDto>> rows;
|
||||
public:
|
||||
oatpp::Object<MockTemporalDto> findByEntityId(const oatpp::String& id) override {
|
||||
for (auto& kv : rows)
|
||||
if (kv.second->entity_id && std::string(*kv.second->entity_id) == std::string(*id)) return kv.second;
|
||||
return nullptr;
|
||||
}
|
||||
oatpp::Vector<oatpp::Object<MockTemporalDto>> list() override {
|
||||
auto v = oatpp::Vector<oatpp::Object<MockTemporalDto>>::createShared();
|
||||
for (auto& kv : rows) v->push_back(kv.second);
|
||||
return v;
|
||||
}
|
||||
void save(const oatpp::Object<MockTemporalDto>& dto) override { rows[std::string(*dto->id)] = dto; }
|
||||
void softDelete(const oatpp::String&) override {}
|
||||
oatpp::Vector<oatpp::Object<MockTemporalDto>>
|
||||
query(const oatpp_authkit::repo::Query<MockTemporalDto>&) override {
|
||||
return list(); // pretend the filter ran; the point is the guard filters scope
|
||||
}
|
||||
};
|
||||
|
||||
// query() through ScopeGuardQueryable must drop rows outside the actor's scope —
|
||||
// otherwise the queryable surface bypasses the scope guard entirely.
|
||||
void test_scope_guard_queryable_filters_query() {
|
||||
using namespace oatpp_authkit::repo;
|
||||
auto inner = std::make_shared<InMemoryQueryable>();
|
||||
for (const char* sc : {"prop-A", "prop-B"}) {
|
||||
auto dto = MockTemporalDto::createShared();
|
||||
dto->id = oatpp::String(std::string("pk-") + sc);
|
||||
dto->entity_id = oatpp::String(sc);
|
||||
dto->valid_until = oatpp::String("9999-12-31T23:59:59Z");
|
||||
dto->scope = oatpp::String(sc);
|
||||
inner->save(dto);
|
||||
}
|
||||
|
||||
ActorContext actor;
|
||||
actor.userId = "u1";
|
||||
actor.allowedScopes = {"prop-A"};
|
||||
|
||||
ScopeGuardQueryable<MockTemporalDto> guarded(
|
||||
inner, &scopeAllows, [actor]{ return actor; }, &entityIdOf);
|
||||
|
||||
auto result = guarded.query(Query<MockTemporalDto>{});
|
||||
REQUIRE(result->size() == 1); // prop-B filtered out
|
||||
REQUIRE(std::string(*(*result)[0]->scope) == "prop-A");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
test_save_closes_prior_version_and_inserts_new();
|
||||
test_live_read_returns_only_sentinel_row();
|
||||
test_point_in_time_read_returns_version_live_at_t();
|
||||
test_history_returns_versions_in_order();
|
||||
test_soft_delete_closes_live_without_new_version();
|
||||
test_scope_guard_denies_when_predicate_false();
|
||||
test_scope_guard_blocks_reparenting();
|
||||
test_scope_guard_queryable_filters_query();
|
||||
|
||||
std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures);
|
||||
return g_failures ? 1 : 0;
|
||||
}
|
||||
|
|
@ -1,199 +0,0 @@
|
|||
// Tests for the oatpp-authkit#7 Repository<T> interface set. Exercises the
|
||||
// contract through a trivial in-memory fake — confirms the abstract methods
|
||||
// compile against an oatpp DTO, the mixed-id allocation branch on save() is
|
||||
// implementable, and findByEntityId/list/softDelete round-trip as documented.
|
||||
//
|
||||
// No SQL involvement — that's the concrete adapters' job (out of scope).
|
||||
|
||||
#include "oatpp-authkit/repo/Repository.hpp"
|
||||
#include "oatpp-authkit/repo/IHistoryRepository.hpp"
|
||||
#include "oatpp-authkit/repo/TemporalAt.hpp"
|
||||
#include "oatpp-authkit/repo/ActorContext.hpp"
|
||||
|
||||
#include "oatpp/core/macro/codegen.hpp"
|
||||
#include "oatpp/core/Types.hpp"
|
||||
|
||||
#include <cstdio>
|
||||
#include <random>
|
||||
#include <unordered_map>
|
||||
|
||||
#include OATPP_CODEGEN_BEGIN(DTO)
|
||||
|
||||
namespace {
|
||||
|
||||
class MockDto : public oatpp::DTO {
|
||||
DTO_INIT(MockDto, DTO)
|
||||
DTO_FIELD(String, entity_id);
|
||||
DTO_FIELD(String, name);
|
||||
};
|
||||
|
||||
#include OATPP_CODEGEN_END(DTO)
|
||||
|
||||
int g_failures = 0;
|
||||
|
||||
#define REQUIRE(expr) do { \
|
||||
if (!(expr)) { \
|
||||
std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \
|
||||
++g_failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
// Trivial UUID-ish generator — sufficient for the in-memory fake; concrete
|
||||
// adapters can use libuuid or similar in production.
|
||||
std::string generateId() {
|
||||
static std::mt19937_64 rng{std::random_device{}()};
|
||||
char buf[33];
|
||||
std::snprintf(buf, sizeof(buf), "%016llx%016llx",
|
||||
(unsigned long long)rng(), (unsigned long long)rng());
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
class InMemoryRepo : public oatpp_authkit::repo::Repository<MockDto> {
|
||||
std::unordered_map<std::string, oatpp::Object<MockDto>> live;
|
||||
std::unordered_map<std::string, oatpp::Object<MockDto>> deleted;
|
||||
public:
|
||||
oatpp::Object<MockDto> findByEntityId(const oatpp::String& id) override {
|
||||
auto it = live.find(*id);
|
||||
return it == live.end() ? nullptr : it->second;
|
||||
}
|
||||
|
||||
oatpp::Vector<oatpp::Object<MockDto>> list() override {
|
||||
auto v = oatpp::Vector<oatpp::Object<MockDto>>::createShared();
|
||||
for (auto& kv : live) v->push_back(kv.second);
|
||||
return v;
|
||||
}
|
||||
|
||||
void save(const oatpp::Object<MockDto>& dto) override {
|
||||
if (!dto->entity_id) {
|
||||
dto->entity_id = generateId();
|
||||
}
|
||||
live[*dto->entity_id] = dto;
|
||||
}
|
||||
|
||||
void softDelete(const oatpp::String& id) override {
|
||||
auto it = live.find(*id);
|
||||
if (it != live.end()) {
|
||||
deleted[*id] = it->second;
|
||||
live.erase(it);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void test_save_allocates_uuid_when_id_null() {
|
||||
InMemoryRepo repo;
|
||||
auto dto = MockDto::createShared();
|
||||
dto->name = oatpp::String("alice");
|
||||
REQUIRE(!dto->entity_id); // precondition: id is null
|
||||
|
||||
repo.save(dto);
|
||||
|
||||
REQUIRE(dto->entity_id); // id was filled in
|
||||
REQUIRE(std::string(*dto->entity_id).size() > 0);
|
||||
}
|
||||
|
||||
void test_save_uses_supplied_id_when_present() {
|
||||
InMemoryRepo repo;
|
||||
auto dto = MockDto::createShared();
|
||||
dto->entity_id = oatpp::String("supplied-id-42");
|
||||
dto->name = oatpp::String("bob");
|
||||
|
||||
repo.save(dto);
|
||||
|
||||
REQUIRE(std::string(*dto->entity_id) == "supplied-id-42");
|
||||
auto loaded = repo.findByEntityId(oatpp::String("supplied-id-42"));
|
||||
REQUIRE(loaded);
|
||||
REQUIRE(std::string(*loaded->name) == "bob");
|
||||
}
|
||||
|
||||
void test_find_by_entity_id_round_trip() {
|
||||
InMemoryRepo repo;
|
||||
auto dto = MockDto::createShared();
|
||||
dto->entity_id = oatpp::String("abc");
|
||||
dto->name = oatpp::String("carol");
|
||||
repo.save(dto);
|
||||
|
||||
auto found = repo.findByEntityId(oatpp::String("abc"));
|
||||
REQUIRE(found);
|
||||
REQUIRE(std::string(*found->name) == "carol");
|
||||
|
||||
auto missing = repo.findByEntityId(oatpp::String("does-not-exist"));
|
||||
REQUIRE(!missing);
|
||||
}
|
||||
|
||||
void test_list_returns_all_live_rows() {
|
||||
InMemoryRepo repo;
|
||||
for (const char* n : {"a", "b", "c"}) {
|
||||
auto dto = MockDto::createShared();
|
||||
dto->entity_id = oatpp::String(n);
|
||||
dto->name = oatpp::String(n);
|
||||
repo.save(dto);
|
||||
}
|
||||
auto all = repo.list();
|
||||
REQUIRE(all->size() == 3);
|
||||
}
|
||||
|
||||
void test_soft_delete_removes_from_live_view() {
|
||||
InMemoryRepo repo;
|
||||
auto dto = MockDto::createShared();
|
||||
dto->entity_id = oatpp::String("delete-me");
|
||||
dto->name = oatpp::String("doomed");
|
||||
repo.save(dto);
|
||||
REQUIRE(repo.findByEntityId(oatpp::String("delete-me")));
|
||||
|
||||
repo.softDelete(oatpp::String("delete-me"));
|
||||
|
||||
REQUIRE(!repo.findByEntityId(oatpp::String("delete-me")));
|
||||
REQUIRE(repo.list()->size() == 0);
|
||||
}
|
||||
|
||||
void test_temporal_at_value_type() {
|
||||
using oatpp_authkit::repo::TemporalAt;
|
||||
auto live = TemporalAt::live();
|
||||
REQUIRE(live.kind == TemporalAt::Kind::Live);
|
||||
|
||||
auto pin = TemporalAt::at(1700000000000LL);
|
||||
REQUIRE(pin.kind == TemporalAt::Kind::At);
|
||||
REQUIRE(pin.timestamp == 1700000000000LL);
|
||||
}
|
||||
|
||||
void test_actor_context_minimal() {
|
||||
oatpp_authkit::repo::ActorContext ctx;
|
||||
ctx.userId = "user-1";
|
||||
ctx.allowedScopes = {"prop-A", "prop-B"};
|
||||
REQUIRE(ctx.userId == "user-1");
|
||||
REQUIRE(ctx.allowedScopes.size() == 2);
|
||||
}
|
||||
|
||||
// Compile-time check: a temporal DTO with all three canonical fields builds.
|
||||
#include OATPP_CODEGEN_BEGIN(DTO)
|
||||
|
||||
class TemporalDto : public oatpp::DTO {
|
||||
DTO_INIT(TemporalDto, DTO)
|
||||
DTO_FIELD(String, entity_id);
|
||||
DTO_FIELD(String, valid_from);
|
||||
DTO_FIELD(String, valid_until);
|
||||
};
|
||||
|
||||
#include OATPP_CODEGEN_END(DTO)
|
||||
|
||||
void test_temporal_dto_compiles() {
|
||||
auto dto = TemporalDto::createShared();
|
||||
dto->entity_id = oatpp::String("t");
|
||||
REQUIRE(std::string(*dto->entity_id) == "t");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
test_save_allocates_uuid_when_id_null();
|
||||
test_save_uses_supplied_id_when_present();
|
||||
test_find_by_entity_id_round_trip();
|
||||
test_list_returns_all_live_rows();
|
||||
test_soft_delete_removes_from_live_view();
|
||||
test_temporal_at_value_type();
|
||||
test_actor_context_minimal();
|
||||
test_temporal_dto_compiles();
|
||||
|
||||
std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures);
|
||||
return g_failures ? 1 : 0;
|
||||
}
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
// 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;
|
||||
}
|
||||
|
|
@ -1,242 +0,0 @@
|
|||
// 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;
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
// Smoke test for SecurityHeadersInterceptor — confirms the header compiles
|
||||
// in a consumer translation unit and the constructor surface matches the
|
||||
// documented API. Behavioural tests against a real IncomingRequest /
|
||||
// OutgoingResponse pair would need a full oatpp request fixture; pinning
|
||||
// the API surface here is enough to catch the kinds of breakage this
|
||||
// header is at risk of (struct field renames, accidental ctor changes).
|
||||
|
||||
#include "oatpp-authkit/interceptor/SecurityHeadersInterceptor.hpp"
|
||||
|
||||
#include <cstdio>
|
||||
#include <memory>
|
||||
|
||||
int main() {
|
||||
using oatpp_authkit::CspOverride;
|
||||
using oatpp_authkit::SecurityHeadersInterceptor;
|
||||
|
||||
// Default ctor: strict baseline.
|
||||
auto strict = std::make_shared<SecurityHeadersInterceptor>();
|
||||
(void)strict;
|
||||
|
||||
// Override ctor: every documented field reachable.
|
||||
CspOverride o;
|
||||
o.defaultSrc = "'self'";
|
||||
o.scriptSrc = "'self' 'unsafe-inline'";
|
||||
o.styleSrc = "'self' 'unsafe-inline'";
|
||||
o.imgSrc = "'self' data: https:";
|
||||
o.connectSrc = "'self' wss:";
|
||||
o.fontSrc = "'self'";
|
||||
o.frameAncestors = "'self'";
|
||||
o.baseUri = "'self'";
|
||||
o.formAction = "'self'";
|
||||
o.sendHsts = false;
|
||||
o.hstsIncludeSubdomains = true;
|
||||
o.xFrameOptions = "SAMEORIGIN";
|
||||
o.permissionsPolicy = "geolocation=(self)";
|
||||
auto relaxed = std::make_shared<SecurityHeadersInterceptor>(std::move(o));
|
||||
(void)relaxed;
|
||||
|
||||
std::printf("SecurityHeadersInterceptor API ok\n");
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
// 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;
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
// 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;
|
||||
}
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
// Tests for the oatpp-authkit#10 TemporalFieldTraits<T> extension.
|
||||
//
|
||||
// Exercises the temporal decorator against a DTO whose column names are
|
||||
// NOT entity_id / valid_from / valid_until. The trait specialisation
|
||||
// supplied via OATPP_AUTHKIT_REGISTER_TEMPORAL bridges the canonical
|
||||
// names used by TemporalRepository to whatever the DTO actually calls
|
||||
// them — here `id`, `effective_from`, `effective_until`. Same save/close/
|
||||
// history flow as the existing decorator tests; only the field names move.
|
||||
|
||||
#include "oatpp-authkit/repo/TemporalRepository.hpp"
|
||||
#include "oatpp-authkit/repo/Repository.hpp"
|
||||
#include "oatpp-authkit/repo/TemporalFieldTraits.hpp"
|
||||
#include "oatpp-authkit/repo/TemporalAt.hpp"
|
||||
|
||||
#include "oatpp/core/macro/codegen.hpp"
|
||||
#include "oatpp/core/Types.hpp"
|
||||
|
||||
#include <cstdio>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
#include OATPP_CODEGEN_BEGIN(DTO)
|
||||
|
||||
namespace {
|
||||
|
||||
// DTO with intentionally non-canonical column names. Without the trait,
|
||||
// TemporalRepository<T> couldn't reach these fields.
|
||||
class OddNamesDto : public oatpp::DTO {
|
||||
DTO_INIT(OddNamesDto, DTO)
|
||||
DTO_FIELD(String, row_pk);
|
||||
DTO_FIELD(String, id); // entity_id (logical), per the original test intent
|
||||
DTO_FIELD(String, effective_from);
|
||||
DTO_FIELD(String, effective_until);
|
||||
DTO_FIELD(String, payload);
|
||||
};
|
||||
|
||||
#include OATPP_CODEGEN_END(DTO)
|
||||
|
||||
} // namespace
|
||||
|
||||
OATPP_AUTHKIT_REGISTER_TEMPORAL(OddNamesDto, row_pk, id, effective_from, effective_until)
|
||||
|
||||
namespace {
|
||||
|
||||
int g_failures = 0;
|
||||
|
||||
#define REQUIRE(expr) do { \
|
||||
if (!(expr)) { \
|
||||
std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \
|
||||
++g_failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
// Same in-memory adapter shape as the decorator tests — keys rows by
|
||||
// (id, effective_from), exposes ALL rows via list().
|
||||
class InMemoryAllRows : public oatpp_authkit::repo::Repository<OddNamesDto> {
|
||||
std::map<std::string, oatpp::Object<OddNamesDto>> rows; // keyed by row_pk
|
||||
public:
|
||||
oatpp::Object<OddNamesDto> findByEntityId(const oatpp::String& id) override {
|
||||
for (auto& kv : rows) if (kv.second->id && std::string(*kv.second->id) == std::string(*id)) return kv.second;
|
||||
return nullptr;
|
||||
}
|
||||
oatpp::Vector<oatpp::Object<OddNamesDto>> list() override {
|
||||
auto v = oatpp::Vector<oatpp::Object<OddNamesDto>>::createShared();
|
||||
for (auto& kv : rows) v->push_back(kv.second);
|
||||
return v;
|
||||
}
|
||||
void save(const oatpp::Object<OddNamesDto>& dto) override {
|
||||
rows[std::string(*dto->row_pk)] = dto;
|
||||
}
|
||||
void softDelete(const oatpp::String& id) override {
|
||||
for (auto it = rows.begin(); it != rows.end(); ) {
|
||||
if (it->second->id && std::string(*it->second->id) == std::string(*id)) it = rows.erase(it); else ++it;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
struct StepClock {
|
||||
int64_t ms{1700000000000LL};
|
||||
int64_t operator()() { int64_t v = ms; ms += 1000; return v; }
|
||||
};
|
||||
|
||||
void test_save_close_and_history_against_renamed_columns() {
|
||||
using namespace oatpp_authkit::repo;
|
||||
auto inner = std::make_shared<InMemoryAllRows>();
|
||||
auto clock = std::make_shared<StepClock>();
|
||||
TemporalRepository<OddNamesDto> repo(inner,
|
||||
[clock]{ return (*clock)(); });
|
||||
|
||||
// First save — id auto-allocated, effective_from = now, effective_until = SENTINEL.
|
||||
auto v1 = OddNamesDto::createShared();
|
||||
v1->payload = oatpp::String("first");
|
||||
repo.save(v1);
|
||||
REQUIRE(v1->id);
|
||||
REQUIRE(std::string(*v1->effective_until)
|
||||
== TemporalRepository<OddNamesDto>::SENTINEL);
|
||||
|
||||
// Second save — close prior, insert new live.
|
||||
auto v2 = OddNamesDto::createShared();
|
||||
v2->id = v1->id;
|
||||
v2->payload = oatpp::String("second");
|
||||
repo.save(v2);
|
||||
|
||||
auto live = repo.findByEntityId(v1->id);
|
||||
REQUIRE(live);
|
||||
REQUIRE(std::string(*live->payload) == "second");
|
||||
|
||||
// history() returns both versions, oldest first.
|
||||
auto h = repo.history(v1->id);
|
||||
REQUIRE(h->size() == 2);
|
||||
REQUIRE(std::string(*(*h)[0]->payload) == "first");
|
||||
REQUIRE(std::string(*(*h)[1]->payload) == "second");
|
||||
|
||||
// softDelete closes live.
|
||||
repo.softDelete(v1->id);
|
||||
REQUIRE(!repo.findByEntityId(v1->id));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
test_save_close_and_history_against_renamed_columns();
|
||||
std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures);
|
||||
return g_failures ? 1 : 0;
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
// 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;
|
||||
}
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
// 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;
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
// 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue