Compare commits
9 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9976efe1de | |||
| fafee1278f | |||
| 2e11408240 | |||
| 52449e4159 | |||
| 9040a9ec48 | |||
| 0bb8bef634 | |||
| 3ccc25f231 | |||
| 606db5a109 | |||
| 792e509b67 |
50 changed files with 3883 additions and 597 deletions
|
|
@ -1,5 +1,5 @@
|
|||
cmake_minimum_required(VERSION 3.14)
|
||||
project(oatpp-authkit VERSION 0.7.0 LANGUAGES CXX)
|
||||
project(oatpp-authkit VERSION 0.13.0 LANGUAGES CXX)
|
||||
|
||||
# Header-only interface library — no compilation, just an include path and
|
||||
# a CMake config package so consumers do:
|
||||
|
|
|
|||
57
README.md
57
README.md
|
|
@ -10,40 +10,59 @@ hardened auth / security stack. Header-only, oatpp 1.3+, C++17.
|
|||
| `interceptor/SecurityHeadersInterceptor.hpp` | CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy. Strict defaults. |
|
||||
| `interceptor/BodySizeLimitInterceptor.hpp` | Reject request bodies above a configurable limit with 413 before they hit your handlers. |
|
||||
| `handler/JsonErrorHandler.hpp` | Normalises thrown exceptions into `{status, message}` JSON so controllers never leak raw HTML error pages. |
|
||||
| `util/RateLimiter.hpp` | In-memory token-bucket keyed on an arbitrary string (typically the client IP from `clientIpTrusted`). |
|
||||
| `util/TokenExtract.hpp` | `extractToken` (Cookie/Bearer), `isValidIp` (IPv4/IPv6 via `inet_pton`), `clientIpTrusted` (loopback-gated XFF). |
|
||||
| `util/RateLimiter.hpp` | In-memory token-bucket keyed on an arbitrary string (typically the client IP from `clientIpTrusted`). The constructor validates its args (`capacity` finite ≥1, `refillRate` finite >0) and throws `std::invalid_argument` otherwise — a zero/negative/NaN rate previously disabled the limiter silently (authkit#16 M-7). |
|
||||
| `util/TokenExtract.hpp` | `extractToken` (Cookie/Bearer) + `cookieValue(header,name)` exact-name cookie parse (authkit#16 M-1 — no substring matching, so a sibling `xsession=` can't shadow `session=`), `isValidIp` (IPv4/IPv6 via `inet_pton`), `clientIpTrusted` (loopback-gated XFF; returns the `"unknown"`/`"invalid"` sentinels off-proxy — treat as one shared rate-limit bucket, M-8). |
|
||||
| `util/OriginCheck.hpp` | `originHostname`, `sameOrigin(originOrReferer, host)`, `originAllowed(origin, allowlist)` — pure CSRF/CSWSH origin helpers (authkit#16 M-4/M-10). Used by `AuthInterceptor` for session mutations; call `sameOrigin`/`originAllowed` in your WSController to block Cross-Site WebSocket Hijacking at the handshake. |
|
||||
| `util/SessionCookie.hpp` | `buildSetSessionCookie(token, opts)` / `buildClearSessionCookie(opts)` — safe-by-default `Set-Cookie` builder (HttpOnly + Secure + SameSite=Strict + Path=/ by default; opt out explicitly). Rejects control chars / `;` in fields (authkit#16 M-9). Returns the header value only; framework-agnostic. |
|
||||
| `util/ConstantTime.hpp` | `constantTimeEquals(a, b)` — branch-free secret comparison for consumers that compare a token/HMAC/hash in memory rather than via an indexed store lookup (authkit#16 L-7). |
|
||||
| `mail/SmtpTransport.hpp` | libcurl SMTP+MIME sender. Requires TLS (`CURLUSESSL_ALL`) for non-loopback relays so credentials/body can't be sent cleartext if STARTTLS is stripped (authkit#16 L-2); a `localhost`/`127.0.0.1` relay stays opportunistic. Rejects CR/LF/NUL in `to`/`fromAddress` (header-injection guard, authkit#16 H-5). |
|
||||
| `startup/RequireEncryptionKey.hpp` | `requireEncryptionKey(envVarName, encryptionEnabled, allowPlaintext)` — refuse startup without a symmetric key unless a dev flag overrides. |
|
||||
| `repo/Repository.hpp` + `IHistoryRepository.hpp` + `TemporalFieldTraits.hpp` + `TemporalAt.hpp` + `ActorContext.hpp` | Pure-abstract `Repository<TDto>` interface set distilled from fewo-webapp's per-entity `*Db` clients. Mixed UUID allocation on `save`, separate `IHistoryRepository<T>` for temporal versions, `TemporalFieldTraits<T>` to map canonical (entity_id, valid_from, valid_until) onto whatever a DTO actually calls them, `ActorContext` placeholder for the scope-guard decorator. |
|
||||
| `repo/TemporalRepository.hpp` | Decorator that wraps any `Repository<TDto>` and turns it into a temporally-versioned one. `save` closes the prior live version and inserts a new one; `findByEntityIdAt(id, at)` returns the version live at a point in time; implements `IHistoryRepository<T>`. Inner adapter is expected to expose all rows (live + historical) and treat `save` as upsert keyed by `(entity_id, valid_from)`. DTOs register their three temporal columns via `OATPP_AUTHKIT_REGISTER_TEMPORAL`. |
|
||||
| `repo/ScopeGuardRepository.hpp` | Generic resource-scope decorator. Takes a `bool(ActorContext, TDto)` predicate at construction; gates every method on it. Throws `ScopeDeniedException` on deny (catchers translate to 403). Knows nothing about consumer-specific concepts like "property" or "tenant" — the predicate decides. |
|
||||
| `repo/IQueryable.hpp` | Optional capability for repos that resolve a typed query AST. `field<&Dto::col>().eq(...)` style DSL composes via `&&` / `||` / `!`; `Query<TDto>::toSql()` emits parameterised SQL plus a bind bag. Bounded surface — equality, range, IN, LIKE, NULL, ORDER BY, LIMIT/OFFSET. No joins, subqueries, or aggregates. Concrete repos opt in by deriving `IQueryable<TDto>`. |
|
||||
| `repo/TemporalRepository.hpp` | Decorator that wraps any `Repository<TDto>` and turns it into a temporally-versioned one. **Stable-live + historical-copy semantics (authkit#13):** the live row's `id` PK is preserved across updates; each prior version is captured as a fresh row with a new `id`. `softDelete` closes the live row in place; with `ON UPDATE CASCADE` on consumer-side composite child FKs, child rows follow automatically. `findByEntityIdAt(id, at)` returns the version live at a point in time; implements `IHistoryRepository<T>`. Inner adapter is expected to expose all rows (live + historical) and treat `save` as upsert keyed by **`id`** (per-row PK). DTOs register their four temporal columns via `OATPP_AUTHKIT_REGISTER_TEMPORAL(Dto, id, entity_id, valid_from, valid_until)`. |
|
||||
| `repo/ScopeGuardRepository.hpp` | Generic resource-scope decorator. Takes a `bool(ActorContext, TDto)` predicate, an actor accessor, and an `entity_id` accessor at construction; gates every method on the predicate. On `save` the predicate must pass on the incoming DTO **and**, for an update, on the row as it currently stands — so an actor can't reparent an out-of-scope row into its own scope by relabelling it in the request body. Throws `ScopeDeniedException` on deny (catchers translate to 403). Knows nothing about consumer-specific concepts like "property" or "tenant" — the predicate decides. **`ScopeGuardQueryable<T>`** (same header) is the variant for `IQueryable` inners: it filters `query()` results through the predicate too, so the queryable surface can't bypass the guard. |
|
||||
| `repo/IQueryable.hpp` | Optional capability for repos that resolve a typed query AST. `field<&Dto::col>().eq(...)` style DSL composes via `&&` / `||` / `!`; `Query<TDto>::toSql()` emits parameterised SQL plus a bind bag. Bounded surface — equality, range, IN, LIKE, NULL, ORDER BY, LIMIT/OFFSET. No joins, subqueries, or aggregates. For user-supplied search terms use `likeContains`/`likePrefix` (or `likeEscape`), which escape `%`/`_`/`\` and emit `LIKE ? ESCAPE '\'` (authkit#16 L-8); raw `like()` binds the pattern verbatim (trusted patterns only). Column/table identifiers come only from compile-time registration — never from request data. Concrete repos opt in by deriving `IQueryable<TDto>`. Wrap a scope-guarded `IQueryable` with `ScopeGuardQueryable<T>` (not the plain `ScopeGuardRepository`) so `query()` is scope-filtered. |
|
||||
| `repo/IAuditSink.hpp` + `repo/AuditLogRepository.hpp` | Cross-cutting audit-trail decorator. Emits an `AuditEvent` (actor, entity type/id, op, timestamp) per mutation through a consumer-supplied `IAuditSink`. Ops are `Create` / `Update` / `Delete` / `Read`; pre-write `findByEntityId` lookup distinguishes Create from Update. Configurable enabled-op set (default `{Create,Update,Delete}` — `Read` is opt-in, `list()` never audited). Sink failures are caught and swallowed unless a `bool(const std::exception&)` handler asks to rethrow. Stacks with `TemporalRepository` and `ScopeGuardRepository`. |
|
||||
| `repo/Prereq.hpp` | Per-decorator migration kit. Each decorator that touches schema bundles its prereq SQL alongside its code: `DecoratorPrereq` for additive (`CREATE TABLE IF NOT EXISTS …`) and `ReshapeStep` for non-idempotent reshape with a `detectSql` probe. `applyDecoratorMigrations<Decorators...>(table, probe, exec, recorder)` walks the listed decorators at startup, runs every PREREQ, runs every reshape step whose probe returns false, optionally records applied steps via `oatpp_authkit_schema_migrations`. Database-agnostic — consumer wires `probe`/`exec` to whatever DbClient they use. |
|
||||
| `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 migrations
|
||||
## Decorator schema contributions
|
||||
|
||||
| Decorator | `PREREQ` | `RESHAPE_STEPS` |
|
||||
|-----------|----------|------------------|
|
||||
| `TemporalRepository<T>` | (none) | `add_valid_from`, `add_valid_until`, `drop_unique_entity_id` (consumer-overridable noop), `composite_unique` — composite `UNIQUE(entity_id, valid_until)` so close-then-insert can run inside a deferred-FK transaction. |
|
||||
| `AuditLogRepository<T>` | `CREATE TABLE IF NOT EXISTS audit_log (…)` — fixed shape, no `{table}` placeholder. | (none) |
|
||||
| `ScopeGuardRepository<T>` | (none) | (none) |
|
||||
| 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/Prereq.hpp"
|
||||
#include "oatpp-authkit/repo/SchemaContract.hpp"
|
||||
|
||||
// probe: returns true iff the SQL yields ≥1 row
|
||||
auto probe = [&](const std::string& sql) { /* run SELECT, return bool */ };
|
||||
auto exec = [&](const std::string& sql) { /* run DDL */ };
|
||||
auto probe = [&](const std::string& sql) { /* run SELECT, return bool */ };
|
||||
|
||||
oatpp_authkit::repo::applyDecoratorMigrations<
|
||||
// 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>>(
|
||||
"persons", probe, exec);
|
||||
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);
|
||||
```
|
||||
|
||||
Re-running on every startup is safe by construction.
|
||||
Atlas wiring (out of scope for this header): point `atlas migrate diff`'s
|
||||
`--dev-url` at a SQLite that `SchemaBuilder` has populated, and `--url`
|
||||
at the live prod DB. Atlas emits versioned migration SQL; the deploy
|
||||
pipeline applies it. The decorator code stays unchanged across schema
|
||||
evolutions.
|
||||
|
||||
## Consume via CMake
|
||||
|
||||
|
|
|
|||
102
docs/MIGRATIONS.md
Normal file
102
docs/MIGRATIONS.md
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
# Schema migrations with oatpp-authkit
|
||||
|
||||
oatpp-authkit (since v0.9.0) ships a declarative schema model: each
|
||||
decorator in a `Repository<TDto>` stack exposes a static
|
||||
`DecoratorSchema kSchema` listing the columns/indexes/sidecar tables it
|
||||
needs. `SchemaBuilder<…>::create(table, exec)` composes the contributions
|
||||
into a single `CREATE TABLE` per entity table. `SchemaContract::verify`
|
||||
asserts the live DB matches at runtime.
|
||||
|
||||
This document covers the **deploy-time** companion: how to evolve a live
|
||||
database between releases when the decorator stack changes. The
|
||||
recommended tool is [Atlas](https://atlasgo.io) (declarative schema-as-
|
||||
code, language-agnostic).
|
||||
|
||||
## The model: dev DB as desired state
|
||||
|
||||
Atlas's "diff-driven migration" workflow is a clean fit:
|
||||
|
||||
1. **Desired state** — a schema produced by running `SchemaBuilder` once
|
||||
against an empty SQLite. The output of all `CREATE TABLE` /
|
||||
`CREATE INDEX` statements *is* the desired state.
|
||||
2. **Current state** — what the production database actually contains.
|
||||
3. **Migration** — `atlas migrate diff` compares (1) and (2) and emits
|
||||
versioned SQL files.
|
||||
4. **Apply** — at deploy time, `atlas migrate apply` runs the new
|
||||
migration files against prod.
|
||||
|
||||
Decorator code never runs ALTER at runtime. It only:
|
||||
|
||||
- declares `kSchema` (compile-time);
|
||||
- runs `SchemaBuilder` against an empty DB (CI) — produces desired state;
|
||||
- runs `SchemaContract::verify` at app startup against the live DB — fails
|
||||
loud if a column/sidecar required by the stack is missing (i.e. the
|
||||
migration didn't run).
|
||||
|
||||
## Wiring it into a consumer's CI
|
||||
|
||||
A consumer of oatpp-authkit (e.g. fewo-webapp, palibu, …) has its own DB
|
||||
schema that combines the authkit-shipped contributions with its own
|
||||
local tables. The schema-snapshot workflow:
|
||||
|
||||
1. **Build a small standalone tool** (`tools/schema_snapshot.cpp`) that
|
||||
instantiates the full set of `SchemaBuilder<…>::create` calls for every
|
||||
entity in the app, writing all DDL to a temporary SQLite.
|
||||
2. **Atlas inspects** the resulting SQLite:
|
||||
```
|
||||
atlas schema inspect --url "sqlite://./tmp_schema.db" --format '{{ hcl . }}' > schema.hcl
|
||||
```
|
||||
3. **Commit `schema.hcl`** to the repo. Diffs are reviewable per change.
|
||||
4. **At deploy**:
|
||||
```
|
||||
atlas migrate diff --to "file://schema.hcl" --dir "file://migrations" \
|
||||
--dev-url "sqlite://file?mode=memory" \
|
||||
--format atlas
|
||||
atlas migrate apply --url "sqlite://prod.db" --dir "file://migrations"
|
||||
```
|
||||
|
||||
The first `migrate diff` emits a versioned migration file; subsequent
|
||||
schema changes (decorator-level or app-level) regenerate the migration
|
||||
list. Each release includes the new migration files; deploy applies them.
|
||||
|
||||
## Atlas-free fallback
|
||||
|
||||
For consumers that don't want Atlas as a dependency, `SchemaBuilder`'s
|
||||
output is plain SQL — pipe it into any migration tool (Flyway, goose,
|
||||
hand-rolled scripts). The C++ side stays unchanged.
|
||||
|
||||
The runtime guarantee — `SchemaContract::verify` throwing on missing
|
||||
columns/sidecars — works regardless of which migration tool you used.
|
||||
|
||||
## Example: a consumer using oatpp-authkit's role_templates module
|
||||
|
||||
The `dto::RoleTemplateDto` + `db::RoleTemplateSchema` +
|
||||
`repo::ConcreteRoleTemplateRepository` + `repo::TemporalRepository<…>`
|
||||
stack ships in oatpp-authkit since v0.10.0. A consumer wires it up like:
|
||||
|
||||
```cpp
|
||||
#include "oatpp-authkit/db/RoleTemplateDb.hpp"
|
||||
#include "oatpp-authkit/repo/ConcreteRoleTemplateRepository.hpp"
|
||||
|
||||
// One-shot at CI: produce desired-state DDL.
|
||||
oatpp_authkit::repo::SchemaBuilder<
|
||||
oatpp_authkit::db::RoleTemplateSchema,
|
||||
oatpp_authkit::repo::TemporalRepository<oatpp_authkit::dto::RoleTemplateDto>
|
||||
>::create("role_templates", exec);
|
||||
|
||||
// Every app startup: assert the live DB matches.
|
||||
oatpp_authkit::repo::SchemaContract<
|
||||
oatpp_authkit::db::RoleTemplateSchema,
|
||||
oatpp_authkit::repo::TemporalRepository<oatpp_authkit::dto::RoleTemplateDto>
|
||||
>::verify("role_templates", probe);
|
||||
|
||||
// Routine repository use.
|
||||
auto rtdb = std::make_shared<oatpp_authkit::db::RoleTemplateDb>(executor);
|
||||
auto repo = oatpp_authkit::repo::makeRoleTemplateRepository(rtdb);
|
||||
auto liveTemplate = repo->findByEntityId("seed00000000000000000000role01rt");
|
||||
```
|
||||
|
||||
Atlas treats the `CREATE TABLE` output of `SchemaBuilder::create` as the
|
||||
desired state for those three tables (`role_templates` +
|
||||
`role_template_fields` + `user_role_assignments`); the consumer's own
|
||||
schema-snapshot tool aggregates these alongside its app-specific tables.
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
#ifndef OATPP_AUTHKIT_AUTH_INTERCEPTOR_HPP
|
||||
#define OATPP_AUTHKIT_AUTH_INTERCEPTOR_HPP
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <functional>
|
||||
|
|
@ -16,11 +18,19 @@
|
|||
#include "IAuthPolicy.hpp"
|
||||
#include "IRuntimeConfig.hpp"
|
||||
#include "../util/TokenExtract.hpp"
|
||||
#include "../util/OriginCheck.hpp"
|
||||
#include "../util/RateLimiter.hpp"
|
||||
#include "../dto/InternalDto.hpp"
|
||||
|
||||
namespace oatpp_authkit {
|
||||
|
||||
/** @brief Caller-supplied hash function — SHA-256 on the raw token typically. */
|
||||
/** @brief Caller-supplied hash function — SHA-256 on the raw token typically.
|
||||
*
|
||||
* authkit#16 L-7: MUST be a fixed-length cryptographic hash (≥256-bit, e.g.
|
||||
* SHA-256) over a high-entropy token. The store looks the session/API key up
|
||||
* by this hash, so a weak/short/truncating hash weakens matching. Consumers
|
||||
* that compare a secret in memory (rather than via an indexed lookup) should
|
||||
* use `oatpp_authkit::constantTimeEquals` (`util/ConstantTime.hpp`). */
|
||||
using TokenHasher = std::function<std::string(const std::string&)>;
|
||||
|
||||
/**
|
||||
|
|
@ -28,10 +38,12 @@ using TokenHasher = std::function<std::string(const std::string&)>;
|
|||
*
|
||||
* Order of checks:
|
||||
* 1. Public path → pass.
|
||||
* 2. Setup mode (empty users table + policy->setupModeActive()) → pseudo-admin.
|
||||
* 2. Setup mode (empty users table + policy->setupModeActive() + loopback bind) → pseudo-admin.
|
||||
* 3. X-SSL-Client-DN header (only trusted when `IRuntimeConfig::certAuthTrusted()`) → cert auth.
|
||||
* 4. Session cookie / Bearer token → backend->resolveBySessionHash / resolveByApiKeyHash.
|
||||
* 5. CSRF defence: sessions reject state-changing requests without X-Requested-With.
|
||||
* (Invalid tokens are optionally per-IP rate-limited → 429 when a RateLimiter is supplied.)
|
||||
* 5. CSRF defence (session cookie + mutation): require X-Requested-With AND,
|
||||
* when present, an Origin/Referer whose host matches the request Host.
|
||||
* 6. Readonly roles cannot mutate.
|
||||
*
|
||||
* Bundle data written on success (consumed by requireAdmin / requireUser):
|
||||
|
|
@ -46,6 +58,7 @@ private:
|
|||
std::shared_ptr<IRuntimeConfig> m_runtime;
|
||||
TokenHasher m_hashToken;
|
||||
std::shared_ptr<oatpp::data::mapping::ObjectMapper> m_mapper;
|
||||
std::shared_ptr<RateLimiter> m_authLimiter; ///< Optional (authkit#16 M-11): throttles invalid-token attempts per client IP.
|
||||
|
||||
using Status = oatpp::web::protocol::http::Status;
|
||||
using ResponseFactory = oatpp::web::protocol::http::outgoing::ResponseFactory;
|
||||
|
|
@ -175,10 +188,25 @@ private:
|
|||
req->putBundleData("auth_username", oatpp::String(p.username.c_str()));
|
||||
}
|
||||
|
||||
/** @brief Neutralise control characters before logging (authkit#16 M-12).
|
||||
* The request path/method are attacker-controlled; a raw CR/LF in the
|
||||
* request target would otherwise forge log lines (CWE-117). */
|
||||
static std::string sanitizeForLog(const std::string& s) {
|
||||
std::string out;
|
||||
const std::size_t cap = 256;
|
||||
out.reserve(s.size() < cap ? s.size() : cap);
|
||||
for (unsigned char c : s) {
|
||||
if (out.size() >= cap) break;
|
||||
out.push_back((c < 0x20 || c == 0x7f) ? '?' : static_cast<char>(c));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static void logEvent(int status, const std::string& method,
|
||||
const std::string& path, const std::string& reason) {
|
||||
OATPP_LOGW("authkit", "[%d] %s %s — %s",
|
||||
status, method.c_str(), path.c_str(), reason.c_str());
|
||||
status, sanitizeForLog(method).c_str(),
|
||||
sanitizeForLog(path).c_str(), reason.c_str());
|
||||
}
|
||||
|
||||
bool isMutation(const std::string& method) {
|
||||
|
|
@ -193,24 +221,38 @@ public:
|
|||
std::shared_ptr<IAuthPolicy> policy,
|
||||
std::shared_ptr<IRuntimeConfig> runtime,
|
||||
TokenHasher hashToken,
|
||||
std::shared_ptr<oatpp::data::mapping::ObjectMapper> mapper = nullptr)
|
||||
std::shared_ptr<oatpp::data::mapping::ObjectMapper> mapper = nullptr,
|
||||
std::shared_ptr<RateLimiter> authRateLimiter = nullptr)
|
||||
: m_backend(std::move(backend))
|
||||
, m_policy(std::move(policy))
|
||||
, m_runtime(std::move(runtime))
|
||||
, m_hashToken(std::move(hashToken))
|
||||
, m_mapper(mapper ? mapper : oatpp::parser::json::mapping::ObjectMapper::createShared()) {}
|
||||
, m_mapper(mapper ? mapper : oatpp::parser::json::mapping::ObjectMapper::createShared())
|
||||
, m_authLimiter(std::move(authRateLimiter)) {}
|
||||
|
||||
std::shared_ptr<OutgoingResponse> intercept(
|
||||
const std::shared_ptr<IncomingRequest>& request) override
|
||||
{
|
||||
// Periodic expired-session sweep — at most once per hour.
|
||||
// Periodic expired-session GC — at most once per hour, process-wide.
|
||||
// authkit#16 L-6: this is best-effort cleanup, NOT the expiry gate —
|
||||
// resolveBySessionHash() must itself reject expired sessions (see
|
||||
// IAuthBackend). The timer is a lock-free atomic so concurrent requests
|
||||
// don't race the read-modify-write, and the sweep is exception-isolated
|
||||
// so a transient DB error during GC can't 500 an otherwise-valid request.
|
||||
{
|
||||
using Clock = std::chrono::steady_clock;
|
||||
static Clock::time_point lastCleanup = Clock::now();
|
||||
auto now = Clock::now();
|
||||
if (std::chrono::duration_cast<std::chrono::hours>(now - lastCleanup).count() >= 1) {
|
||||
lastCleanup = now;
|
||||
m_backend->deleteExpiredSessions();
|
||||
static std::atomic<std::int64_t> lastCleanupMs{-1};
|
||||
const std::int64_t nowMs = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
Clock::now().time_since_epoch()).count();
|
||||
std::int64_t prev = lastCleanupMs.load(std::memory_order_relaxed);
|
||||
if (prev < 0) {
|
||||
// First request: arm the timer, don't sweep yet.
|
||||
lastCleanupMs.compare_exchange_strong(prev, nowMs);
|
||||
} else if (nowMs - prev >= 3600000) {
|
||||
// Only the thread that wins the CAS performs the sweep.
|
||||
if (lastCleanupMs.compare_exchange_strong(prev, nowMs)) {
|
||||
try { m_backend->deleteExpiredSessions(); } catch (...) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -223,16 +265,30 @@ public:
|
|||
|
||||
if (m_policy->isPublicPath(path)) return nullptr;
|
||||
|
||||
// Setup mode: empty users + policy opts in → pseudo-admin.
|
||||
if (m_policy->setupModeActive() && !m_backend->hasActiveUsers()) {
|
||||
// Setup mode: empty users + policy opts in + loopback bind → pseudo-admin.
|
||||
// authkit#16 M-2: gate on isLoopback() so a stray SETUP_MODE sentinel can
|
||||
// never expose anonymous admin on a public bind, and log the grant (it
|
||||
// was previously silent). hasActiveUsers() must fail closed (see
|
||||
// IAuthBackend) — a swallowed DB error returning false would otherwise
|
||||
// open the entire API.
|
||||
if (m_policy->setupModeActive() && m_runtime->isLoopback()
|
||||
&& !m_backend->hasActiveUsers()) {
|
||||
logEvent(200, method, path, "setup-mode pseudo-admin granted (no users yet)");
|
||||
AuthPrincipal p{0, "setup", "admin"};
|
||||
writeBundle(request, p);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// TLS cert DN — only trusted when the runtime hook says so (#5).
|
||||
// `certAuthTrusted()` defaults to `isLoopback()`; consumers can override
|
||||
// it to gate more strictly (e.g. require an env-var or a TLS-only port).
|
||||
// `certAuthTrusted()` defaults to `false` (fail closed); consumers must
|
||||
// opt in explicitly and only behind a proxy that strips the inbound
|
||||
// `X-SSL-Client-DN` header and re-sets it from a verified client cert.
|
||||
//
|
||||
// authkit#16 M-10: the cert path is deliberately NOT CSRF-gated. CSRF is
|
||||
// a browser-cookie problem; cert auth is for non-browser / mTLS clients
|
||||
// that don't auto-attach an ambient credential, so `X-Requested-With` /
|
||||
// Origin checks don't apply. Do not expose cert auth to cookie-bearing
|
||||
// browser sessions.
|
||||
auto certDnH = request->getHeader("X-SSL-Client-DN");
|
||||
if (m_runtime->certAuthTrusted() && certDnH && !certDnH->empty()) {
|
||||
if (auto p = m_backend->resolveByCertDn(std::string(*certDnH))) {
|
||||
|
|
@ -260,6 +316,16 @@ public:
|
|||
} else if ((p = m_backend->resolveByApiKeyHash(hash))) {
|
||||
viaSession = false;
|
||||
} else {
|
||||
// authkit#16 M-11: when an optional limiter is wired in, throttle
|
||||
// repeated invalid-token submissions per client IP (token guessing /
|
||||
// credential stuffing) and answer 429 before the 401.
|
||||
if (m_authLimiter) {
|
||||
const std::string ip = clientIpTrusted(request, m_runtime->bindAddress());
|
||||
if (!m_authLimiter->allow("authfail:" + ip)) {
|
||||
logEvent(429, method, path, "auth rate limit (invalid token)");
|
||||
return makeJsonError(Status::CODE_429, "Too Many Requests", "");
|
||||
}
|
||||
}
|
||||
logEvent(401, method, path, "invalid token");
|
||||
return makeUnauthorized(request, path);
|
||||
}
|
||||
|
|
@ -271,6 +337,28 @@ public:
|
|||
logEvent(403, method, path, "missing X-Requested-With");
|
||||
return makeForbidden(request, path, "Missing X-Requested-With header");
|
||||
}
|
||||
// authkit#16 M-10: second CSRF layer — when an Origin (or, failing
|
||||
// that, Referer) header is present on a cookie-auth mutation, its
|
||||
// host must match the request Host. Catches cross-site forgeries
|
||||
// even if a permissive CORS policy ever lets X-Requested-With
|
||||
// through. Compared by hostname (port/scheme ignored) to stay
|
||||
// correct behind a TLS-terminating proxy; when neither header is
|
||||
// present we fall back to the X-Requested-With guarantee above.
|
||||
auto host = request->getHeader("Host");
|
||||
auto origin = request->getHeader("Origin");
|
||||
auto referer = request->getHeader("Referer");
|
||||
const std::string hostStr = host ? std::string(*host) : std::string();
|
||||
if (origin && !origin->empty()) {
|
||||
if (!sameOrigin(std::string(*origin), hostStr)) {
|
||||
logEvent(403, method, path, "Origin/Host mismatch");
|
||||
return makeForbidden(request, path, "Cross-origin request rejected");
|
||||
}
|
||||
} else if (referer && !referer->empty()) {
|
||||
if (!sameOrigin(std::string(*referer), hostStr)) {
|
||||
logEvent(403, method, path, "Referer/Host mismatch");
|
||||
return makeForbidden(request, path, "Cross-origin request rejected");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeBundle(request, *p);
|
||||
|
|
|
|||
|
|
@ -13,7 +13,12 @@ namespace oatpp_authkit {
|
|||
* into this struct inside their IAuthBackend implementation.
|
||||
*/
|
||||
struct AuthPrincipal {
|
||||
int id{0}; ///< Stable numeric id from the user store.
|
||||
/// Stable numeric id from the user store. NOTE (authkit#16 L-1): this is an
|
||||
/// `int`, so it only round-trips numeric ids. A store keyed on UUIDs / other
|
||||
/// non-numeric ids must not stuff them here — `requireUser` rejects a
|
||||
/// non-numeric bundle id with 401. Carry such identities in `username` (or
|
||||
/// extend this struct) instead.
|
||||
int id{0};
|
||||
std::string username;
|
||||
std::string role; ///< Arbitrary string; policy decides what "admin"/"readonly" mean.
|
||||
};
|
||||
|
|
|
|||
|
|
@ -23,7 +23,14 @@ class IAuthBackend {
|
|||
public:
|
||||
virtual ~IAuthBackend() = default;
|
||||
|
||||
/** @brief Look up an active session by its hashed token. */
|
||||
/** @brief Look up an *active, non-expired* session by its hashed token.
|
||||
*
|
||||
* @warning Enforce expiry HERE (authkit#16 L-6): filter on the session's
|
||||
* `expires_at` in this query and return `std::nullopt` for an
|
||||
* expired row. The interceptor's periodic `deleteExpiredSessions`
|
||||
* is best-effort garbage collection that only runs on request
|
||||
* traffic — relying on it for expiry would leave a stale token
|
||||
* valid until the next sweep (or indefinitely on an idle server). */
|
||||
virtual std::optional<AuthPrincipal> resolveBySessionHash(const std::string& hash) = 0;
|
||||
|
||||
/** @brief Look up an API key by its hashed token; also touch `last_used_at`. */
|
||||
|
|
@ -38,7 +45,16 @@ public:
|
|||
return std::nullopt;
|
||||
}
|
||||
|
||||
/** @brief True iff at least one active user exists. Used for setup-mode gate. */
|
||||
/** @brief True iff at least one active user exists. Used for setup-mode gate.
|
||||
*
|
||||
* @warning Must FAIL CLOSED (authkit#16 M-2): on any uncertainty — a DB
|
||||
* error, a timeout, an empty result you can't trust — return
|
||||
* `true` (or throw), never `false`. A `false` returned on a
|
||||
* swallowed error opens the setup-mode pseudo-admin path and
|
||||
* grants unauthenticated admin to every request. The interceptor
|
||||
* additionally gates setup mode on a loopback bind, but the
|
||||
* authoritative "are we still in first-run setup?" answer is
|
||||
* yours and must not degrade open. */
|
||||
virtual bool hasActiveUsers() = 0;
|
||||
|
||||
/** @brief Delete expired session rows. Called periodically by the interceptor. */
|
||||
|
|
|
|||
|
|
@ -34,8 +34,14 @@ public:
|
|||
|
||||
/** @brief Whether incoming `X-SSL-Client-DN` headers should be trusted (#5).
|
||||
*
|
||||
* Default: `isLoopback()` — preserves the legacy behaviour for consumers
|
||||
* that haven't overridden anything. Override to gate more strictly, e.g.:
|
||||
* Default: `false` — **fail closed**. `X-SSL-Client-DN` is an ordinary
|
||||
* request header; binding to loopback does NOT guarantee it originates
|
||||
* from a TLS-terminating proxy. An SSH tunnel, a co-located process, or a
|
||||
* reverse proxy that forwards the client-supplied header verbatim can all
|
||||
* present an arbitrary DN to a loopback-bound service, so trusting it by
|
||||
* default is an authentication-bypass primitive. Consumers must opt in
|
||||
* explicitly, and only once the upstream proxy unconditionally strips the
|
||||
* inbound header and re-sets it from a verified client certificate, e.g.:
|
||||
*
|
||||
* bool certAuthTrusted() override {
|
||||
* return isLoopback() && std::getenv("TRUST_CERT_DN") != nullptr;
|
||||
|
|
@ -45,7 +51,7 @@ public:
|
|||
* `X-SSL-Client-DN` header and falls through to token / session auth.
|
||||
*/
|
||||
virtual bool certAuthTrusted() {
|
||||
return isLoopback();
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,22 @@ inline AuthPrincipal requireUser(const std::shared_ptr<IncomingRequest>& request
|
|||
OATPP_ASSERT_HTTP(id && role, Status::CODE_401, "Authentication required");
|
||||
|
||||
AuthPrincipal p;
|
||||
p.id = std::stoi(std::string(*id));
|
||||
// authkit#16 L-1: parse defensively. The bundle id is normally a decimal
|
||||
// written by AuthInterceptor, but a non-numeric / out-of-range value (or a
|
||||
// future principal id that isn't an int) must surface as a clean 401, not
|
||||
// an uncaught std::invalid_argument/out_of_range escaping the endpoint as a
|
||||
// 500. The OATPP_ASSERT_HTTP is kept OUTSIDE the try so its HttpError isn't
|
||||
// swallowed by the catch.
|
||||
bool idOk = false;
|
||||
{
|
||||
const std::string idStr(*id);
|
||||
try {
|
||||
std::size_t consumed = 0;
|
||||
int parsed = std::stoi(idStr, &consumed);
|
||||
if (consumed == idStr.size()) { p.id = parsed; idOk = true; }
|
||||
} catch (...) { idOk = false; }
|
||||
}
|
||||
OATPP_ASSERT_HTTP(idOk, Status::CODE_401, "Authentication required");
|
||||
p.role = std::string(*role);
|
||||
p.username = username ? std::string(*username) : "";
|
||||
return p;
|
||||
|
|
|
|||
|
|
@ -81,18 +81,44 @@ CREATE INDEX IF NOT EXISTS idx_audit_log_table_entity ON audit_log(table_name
|
|||
private:
|
||||
std::shared_ptr<AuditLogDb> m_db;
|
||||
|
||||
/** @brief Fields to skip when computing UPDATE diffs — internal/metadata. */
|
||||
/** @brief Fields to skip when computing UPDATE diffs — internal/metadata
|
||||
* plus credentials. authkit#16 L-3: never copy a secret into the long-lived
|
||||
* `audit_log.changed_fields` column (covers both snake_case and camelCase
|
||||
* identifiers since the diff matches on the DTO's C++ field name). */
|
||||
static inline const std::set<std::string> SKIP_FIELDS = {
|
||||
"id", "entity_id", "created_at", "updated_at", "valid_from"
|
||||
"id", "entity_id", "created_at", "updated_at", "valid_from",
|
||||
"password", "passwordHash", "password_hash",
|
||||
"tlsCertDn", "tls_cert_dn",
|
||||
"apiKey", "api_key", "token", "secret"
|
||||
};
|
||||
|
||||
/** @brief RFC 8259-compliant JSON string escaping. authkit#16 L-3: the
|
||||
* previous version escaped only `\` and `"`, so a control character (e.g.
|
||||
* a newline in a user-supplied name) produced invalid JSON in the audit
|
||||
* trail and allowed newline/log injection into anything re-emitting the
|
||||
* column. */
|
||||
static std::string escapeJson(const std::string& s) {
|
||||
static const char* hex = "0123456789abcdef";
|
||||
std::string out;
|
||||
out.reserve(s.size());
|
||||
for (char c : s) {
|
||||
if (c == '\\') out += "\\\\";
|
||||
else if (c == '"') out += "\\\"";
|
||||
else out += c;
|
||||
out.reserve(s.size() + 8);
|
||||
for (unsigned char c : s) {
|
||||
switch (c) {
|
||||
case '\\': out += "\\\\"; break;
|
||||
case '"': out += "\\\""; break;
|
||||
case '\b': out += "\\b"; break;
|
||||
case '\f': out += "\\f"; break;
|
||||
case '\n': out += "\\n"; break;
|
||||
case '\r': out += "\\r"; break;
|
||||
case '\t': out += "\\t"; break;
|
||||
default:
|
||||
if (c < 0x20) {
|
||||
out += "\\u00";
|
||||
out += hex[(c >> 4) & 0xF];
|
||||
out += hex[c & 0xF];
|
||||
} else {
|
||||
out += static_cast<char>(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
|
|||
277
include/oatpp-authkit/db/RoleTemplateDb.hpp
Normal file
277
include/oatpp-authkit/db/RoleTemplateDb.hpp
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
#ifndef OATPP_AUTHKIT_DB_ROLE_TEMPLATE_DB_HPP
|
||||
#define OATPP_AUTHKIT_DB_ROLE_TEMPLATE_DB_HPP
|
||||
|
||||
// DbClient + declarative schema contribution for role templates,
|
||||
// field permissions, and user role assignments (authkit#14 PR 1).
|
||||
//
|
||||
// Lifted from fewo-webapp's `src/db/RoleTemplateDb.hpp`. The queries are
|
||||
// unchanged; new in this header is `RoleTemplateSchema::kSchema`, which
|
||||
// declares the columns/indexes/sidecar tables this module needs in the
|
||||
// declarative `SchemaContract` style introduced in PR 0.
|
||||
|
||||
#include "oatpp-authkit/dto/RoleTemplateDto.hpp"
|
||||
#include "oatpp-authkit/repo/SchemaContract.hpp"
|
||||
|
||||
#include "oatpp-sqlite/orm.hpp"
|
||||
|
||||
#include OATPP_CODEGEN_BEGIN(DbClient)
|
||||
|
||||
namespace oatpp_authkit::db {
|
||||
|
||||
/**
|
||||
* @brief DbClient for role templates / field permissions / user assignments.
|
||||
*
|
||||
* @section schema Schema contract
|
||||
*
|
||||
* `RoleTemplateSchema::kSchema` (defined below) names the three tables
|
||||
* this module owns: `role_templates` (entity), `role_template_fields`
|
||||
* (sidecar with composite-FK), `user_role_assignments` (sidecar with
|
||||
* composite-FK). Composes into a `SchemaBuilder` parameter pack alongside
|
||||
* `TemporalRepository<RoleTemplateDto>` to produce the full schema.
|
||||
*
|
||||
* @section queries Queries
|
||||
*
|
||||
* All temporal CRUD goes through the `TemporalRepository<RoleTemplateDto>`
|
||||
* decorator on top of `ConcreteRoleTemplateRepository`. The DbClient
|
||||
* methods below cover queries that don't fit the basic Repository<T>
|
||||
* contract (effective-permission resolution, cascade soft-delete) and the
|
||||
* raw queries the concrete repo uses internally.
|
||||
*/
|
||||
class RoleTemplateDb : public oatpp::orm::DbClient {
|
||||
public:
|
||||
RoleTemplateDb(const std::shared_ptr<oatpp::orm::Executor>& executor)
|
||||
: oatpp::orm::DbClient(executor) {}
|
||||
|
||||
// ========== Role Templates (basic temporal CRUD) ==========
|
||||
|
||||
/// All live templates, ordered by name. Used by the controller list endpoint.
|
||||
QUERY(getAllTemplates,
|
||||
"SELECT * FROM role_templates "
|
||||
"WHERE valid_from <= datetime('now') AND valid_until > datetime('now') "
|
||||
"ORDER BY name;")
|
||||
|
||||
/// All rows (live + historical) for the temporal decorator's filter.
|
||||
QUERY(getAllTemplatesRaw,
|
||||
"SELECT * FROM role_templates;")
|
||||
|
||||
QUERY(getTemplateByEntityId,
|
||||
"SELECT * FROM role_templates "
|
||||
"WHERE entity_id = :id "
|
||||
"AND valid_from <= datetime('now') AND valid_until > datetime('now');",
|
||||
PARAM(oatpp::String, id))
|
||||
|
||||
/// Upsert keyed by `id` (per-row PK), per the Repository<T> contract
|
||||
/// for temporal stacks. The temporal decorator drives close-then-insert
|
||||
/// via this single method.
|
||||
QUERY(upsertTemplateById,
|
||||
"INSERT INTO role_templates "
|
||||
" (id, entity_id, name, description, is_system, valid_from, valid_until) "
|
||||
"VALUES "
|
||||
" (:dto.id, :dto.entityId, :dto.name, :dto.description, "
|
||||
" :dto.isSystem, :dto.validFrom, :dto.validUntil) "
|
||||
"ON CONFLICT(id) DO UPDATE SET "
|
||||
" entity_id = excluded.entity_id, "
|
||||
" name = excluded.name, "
|
||||
" description = excluded.description, "
|
||||
" is_system = excluded.is_system, "
|
||||
" valid_from = excluded.valid_from, "
|
||||
" valid_until = excluded.valid_until;",
|
||||
PARAM(oatpp::Object<dto::RoleTemplateDto>, dto))
|
||||
|
||||
QUERY(softDeleteTemplate,
|
||||
"UPDATE role_templates SET valid_until = datetime('now') "
|
||||
"WHERE entity_id = :id AND valid_until > datetime('now');",
|
||||
PARAM(oatpp::String, id))
|
||||
|
||||
/// Cascade the soft-delete to link rows. On fresh installs the
|
||||
/// composite FK + ON UPDATE CASCADE handles this automatically; these
|
||||
/// explicit UPDATEs are defensive.
|
||||
QUERY(cascadeSoftDeleteFields,
|
||||
"UPDATE role_template_fields SET template_valid_until = datetime('now') "
|
||||
"WHERE template_id = :id AND template_valid_until > datetime('now');",
|
||||
PARAM(oatpp::String, id))
|
||||
|
||||
QUERY(cascadeSoftDeleteAssignments,
|
||||
"UPDATE user_role_assignments SET template_valid_until = datetime('now') "
|
||||
"WHERE template_id = :id AND template_valid_until > datetime('now');",
|
||||
PARAM(oatpp::String, id))
|
||||
|
||||
// ========== Template Fields ==========
|
||||
|
||||
QUERY(getFieldsForTemplate,
|
||||
"SELECT * FROM role_template_fields "
|
||||
"WHERE template_id = :templateId "
|
||||
"ORDER BY entity_type, field_name;",
|
||||
PARAM(oatpp::String, templateId))
|
||||
|
||||
QUERY(insertField,
|
||||
"INSERT OR REPLACE INTO role_template_fields "
|
||||
" (id, template_id, entity_type, field_name, permission) "
|
||||
"VALUES "
|
||||
" (:dto.id, :dto.templateId, :dto.entityType, :dto.fieldName, "
|
||||
" :dto.permission);",
|
||||
PARAM(oatpp::Object<dto::RoleTemplateFieldDto>, dto))
|
||||
|
||||
QUERY(deleteField,
|
||||
"DELETE FROM role_template_fields WHERE id = :id;",
|
||||
PARAM(oatpp::String, id))
|
||||
|
||||
QUERY(deleteFieldsForTemplate,
|
||||
"DELETE FROM role_template_fields WHERE template_id = :templateId;",
|
||||
PARAM(oatpp::String, templateId))
|
||||
|
||||
// ========== User Assignments ==========
|
||||
|
||||
QUERY(getAllAssignments,
|
||||
"SELECT * FROM user_role_assignments "
|
||||
"WHERE valid_from <= datetime('now') AND valid_until > datetime('now') "
|
||||
"ORDER BY user_id;")
|
||||
|
||||
QUERY(getAssignmentsForUser,
|
||||
"SELECT * FROM user_role_assignments "
|
||||
"WHERE user_id = :userId "
|
||||
"AND valid_from <= datetime('now') AND valid_until > datetime('now');",
|
||||
PARAM(oatpp::String, userId))
|
||||
|
||||
QUERY(insertAssignment,
|
||||
"INSERT INTO user_role_assignments "
|
||||
" (id, entity_id, user_id, template_id, property_id) "
|
||||
"VALUES "
|
||||
" (:dto.id, :dto.entityId, :dto.userId, :dto.templateId, "
|
||||
" :dto.propertyId);",
|
||||
PARAM(oatpp::Object<dto::UserRoleAssignmentDto>, dto))
|
||||
|
||||
QUERY(softDeleteAssignment,
|
||||
"UPDATE user_role_assignments SET valid_until = datetime('now') "
|
||||
"WHERE entity_id = :entityId AND valid_until > datetime('now');",
|
||||
PARAM(oatpp::String, entityId))
|
||||
|
||||
// ========== Field Permission Resolution ==========
|
||||
|
||||
/**
|
||||
* @brief Effective field permissions for a user on one entity type.
|
||||
*
|
||||
* Combines all active template assignments. If a user has multiple
|
||||
* templates (e.g. property-scoped), takes the MAX permission per field
|
||||
* (write > readonly > hidden). Returns only explicitly granted fields
|
||||
* — unlisted fields are denied.
|
||||
*
|
||||
* The composite-temporal joins (`template_valid_until > now()`) make
|
||||
* soft-deleted templates drop out automatically.
|
||||
*/
|
||||
QUERY(getEffectiveFieldPermissions,
|
||||
"SELECT rtf.entity_type, rtf.field_name, MAX(rtf.permission) AS permission "
|
||||
"FROM user_role_assignments ura "
|
||||
"JOIN role_template_fields rtf ON rtf.template_id = ura.template_id "
|
||||
"WHERE ura.user_id = :userId "
|
||||
" AND ura.valid_from <= datetime('now') AND ura.valid_until > datetime('now') "
|
||||
" AND ura.template_valid_until > datetime('now') "
|
||||
" AND rtf.template_valid_until > datetime('now') "
|
||||
" AND rtf.entity_type = :entityType "
|
||||
" AND (ura.property_id IS NULL OR ura.property_id = :propertyId) "
|
||||
"GROUP BY rtf.entity_type, rtf.field_name;",
|
||||
PARAM(oatpp::String, userId),
|
||||
PARAM(oatpp::String, entityType),
|
||||
PARAM(oatpp::String, propertyId))
|
||||
|
||||
QUERY(getAllEffectiveFieldPermissions,
|
||||
"SELECT rtf.entity_type, rtf.field_name, MAX(rtf.permission) AS permission "
|
||||
"FROM user_role_assignments ura "
|
||||
"JOIN role_template_fields rtf ON rtf.template_id = ura.template_id "
|
||||
"WHERE ura.user_id = :userId "
|
||||
" AND ura.valid_from <= datetime('now') AND ura.valid_until > datetime('now') "
|
||||
" AND ura.template_valid_until > datetime('now') "
|
||||
" AND rtf.template_valid_until > datetime('now') "
|
||||
"GROUP BY rtf.entity_type, rtf.field_name;",
|
||||
PARAM(oatpp::String, userId))
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Declarative schema contribution for the role-templates module.
|
||||
*
|
||||
* Three tables: `role_templates` (the entity), plus the two sidecars
|
||||
* `role_template_fields` and `user_role_assignments` that carry the
|
||||
* composite-FK temporal partner column `template_valid_until`.
|
||||
*
|
||||
* Designed to compose into a `SchemaBuilder` parameter pack alongside
|
||||
* `TemporalRepository<RoleTemplateDto>`:
|
||||
*
|
||||
* @code
|
||||
* SchemaBuilder<
|
||||
* RoleTemplateSchema,
|
||||
* TemporalRepository<RoleTemplateDto>>::create("role_templates", exec);
|
||||
* @endcode
|
||||
*
|
||||
* The `id`, `entity_id`, business columns, and a `valid_from`-with-default
|
||||
* are contributed here; the temporal decorator's `kSchema` overlays
|
||||
* `valid_until` with the SENTINEL default + the composite UNIQUE index.
|
||||
*
|
||||
* The sidecar tables are emitted by name (no `{table}` substitution) and
|
||||
* carry the composite FK to `role_templates(entity_id, valid_until)`.
|
||||
*/
|
||||
struct RoleTemplateSchema {
|
||||
inline static constexpr repo::ColumnSpec kRoleTemplateColumns[] = {
|
||||
{"id", "TEXT PRIMARY KEY"},
|
||||
{"entity_id", "TEXT NOT NULL"},
|
||||
{"name", "TEXT NOT NULL"},
|
||||
{"description", "TEXT NOT NULL DEFAULT ''"},
|
||||
{"is_system", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"valid_from", "TEXT NOT NULL DEFAULT (datetime('now'))"},
|
||||
// valid_until is contributed by TemporalRepository's kSchema,
|
||||
// along with the composite UNIQUE(entity_id, valid_until).
|
||||
};
|
||||
inline static constexpr repo::IndexSpec kRoleTemplateIndexes[] = {
|
||||
{"ix_{table}_entity_id", false, "(entity_id)"},
|
||||
};
|
||||
|
||||
inline static constexpr repo::ColumnSpec kFieldColumns[] = {
|
||||
{"id", "TEXT PRIMARY KEY"},
|
||||
{"template_id", "TEXT NOT NULL"},
|
||||
{"template_valid_until", "TEXT NOT NULL DEFAULT '9999-12-31T23:59:59Z'"},
|
||||
{"entity_type", "TEXT NOT NULL"},
|
||||
{"field_name", "TEXT NOT NULL"},
|
||||
{"permission", "TEXT NOT NULL"},
|
||||
{"_fk_to_role_templates",
|
||||
"FOREIGN KEY (template_id, template_valid_until) REFERENCES "
|
||||
"role_templates(entity_id, valid_until) ON UPDATE CASCADE"},
|
||||
};
|
||||
|
||||
inline static constexpr repo::ColumnSpec kAssignmentColumns[] = {
|
||||
{"id", "TEXT PRIMARY KEY"},
|
||||
{"entity_id", "TEXT NOT NULL"},
|
||||
{"user_id", "TEXT NOT NULL"},
|
||||
{"template_id", "TEXT NOT NULL"},
|
||||
{"template_valid_until", "TEXT NOT NULL DEFAULT '9999-12-31T23:59:59Z'"},
|
||||
{"property_id", "TEXT"},
|
||||
{"valid_from", "TEXT NOT NULL DEFAULT (datetime('now'))"},
|
||||
{"valid_until", "TEXT NOT NULL DEFAULT '9999-12-31T23:59:59Z'"},
|
||||
{"_fk_to_role_templates",
|
||||
"FOREIGN KEY (template_id, template_valid_until) REFERENCES "
|
||||
"role_templates(entity_id, valid_until) ON UPDATE CASCADE"},
|
||||
};
|
||||
|
||||
inline static constexpr repo::SidecarTableSpec kSidecars[] = {
|
||||
{"role_template_fields",
|
||||
kFieldColumns,
|
||||
sizeof(kFieldColumns) / sizeof(kFieldColumns[0])},
|
||||
{"user_role_assignments",
|
||||
kAssignmentColumns,
|
||||
sizeof(kAssignmentColumns) / sizeof(kAssignmentColumns[0])},
|
||||
};
|
||||
|
||||
inline static constexpr repo::DecoratorSchema kSchema = {
|
||||
"RoleTemplateSchema",
|
||||
kRoleTemplateColumns,
|
||||
sizeof(kRoleTemplateColumns) / sizeof(kRoleTemplateColumns[0]),
|
||||
kRoleTemplateIndexes,
|
||||
sizeof(kRoleTemplateIndexes) / sizeof(kRoleTemplateIndexes[0]),
|
||||
kSidecars,
|
||||
sizeof(kSidecars) / sizeof(kSidecars[0]),
|
||||
};
|
||||
};
|
||||
|
||||
} // namespace oatpp_authkit::db
|
||||
|
||||
#include OATPP_CODEGEN_END(DbClient)
|
||||
|
||||
#endif
|
||||
140
include/oatpp-authkit/db/UserDb.hpp
Normal file
140
include/oatpp-authkit/db/UserDb.hpp
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
#ifndef OATPP_AUTHKIT_DB_USER_DB_HPP
|
||||
#define OATPP_AUTHKIT_DB_USER_DB_HPP
|
||||
|
||||
// DbClient + declarative schema for temporal `users` (authkit#14 PR 4).
|
||||
|
||||
#include "oatpp-authkit/dto/UserDto.hpp"
|
||||
#include "oatpp-authkit/repo/SchemaContract.hpp"
|
||||
|
||||
#include "oatpp-sqlite/orm.hpp"
|
||||
|
||||
#include OATPP_CODEGEN_BEGIN(DbClient)
|
||||
|
||||
namespace oatpp_authkit::db {
|
||||
|
||||
/**
|
||||
* @brief DbClient for the temporal `users` table.
|
||||
*
|
||||
* Login lookup goes through `findLiveByUsername` — the natural-key
|
||||
* index `ux_users_username_until` makes that an indexed scan. The
|
||||
* temporal decorator on top filters live-vs-historical itself for the
|
||||
* generic `Repository<T>` surface; the dedicated find-by-username here
|
||||
* exists because login doesn't have an `entity_id` to dispatch on.
|
||||
*/
|
||||
class UserDb : public oatpp::orm::DbClient {
|
||||
public:
|
||||
UserDb(const std::shared_ptr<oatpp::orm::Executor>& executor)
|
||||
: oatpp::orm::DbClient(executor) {}
|
||||
|
||||
QUERY(getAllUsersRaw,
|
||||
"SELECT * FROM users;")
|
||||
|
||||
QUERY(getLiveUsers,
|
||||
"SELECT * FROM users "
|
||||
"WHERE valid_from <= datetime('now') AND valid_until > datetime('now') "
|
||||
"ORDER BY username;")
|
||||
|
||||
QUERY(findUserByEntityId,
|
||||
"SELECT * FROM users "
|
||||
"WHERE entity_id = :entityId "
|
||||
" AND valid_from <= datetime('now') AND valid_until > datetime('now');",
|
||||
PARAM(oatpp::String, entityId))
|
||||
|
||||
/// Live row by username — the canonical login lookup path.
|
||||
QUERY(findLiveByUsername,
|
||||
"SELECT * FROM users "
|
||||
"WHERE username = :username "
|
||||
" AND valid_from <= datetime('now') AND valid_until > datetime('now');",
|
||||
PARAM(oatpp::String, username))
|
||||
|
||||
/// Live row by tls_cert_dn — used by mTLS auth.
|
||||
QUERY(findLiveByTlsCertDn,
|
||||
"SELECT * FROM users "
|
||||
"WHERE tls_cert_dn = :dn "
|
||||
" AND valid_from <= datetime('now') AND valid_until > datetime('now');",
|
||||
PARAM(oatpp::String, dn))
|
||||
|
||||
QUERY(upsertUserById,
|
||||
"INSERT INTO users "
|
||||
" (id, entity_id, username, password_hash, role, tls_cert_dn, "
|
||||
" valid_from, valid_until) "
|
||||
"VALUES "
|
||||
" (:dto.id, :dto.entityId, :dto.username, :dto.passwordHash, "
|
||||
" :dto.role, :dto.tlsCertDn, :dto.validFrom, :dto.validUntil) "
|
||||
"ON CONFLICT(id) DO UPDATE SET "
|
||||
" entity_id = excluded.entity_id, "
|
||||
" username = excluded.username, "
|
||||
" password_hash = excluded.password_hash, "
|
||||
" role = excluded.role, "
|
||||
" tls_cert_dn = excluded.tls_cert_dn, "
|
||||
" valid_from = excluded.valid_from, "
|
||||
" valid_until = excluded.valid_until;",
|
||||
PARAM(oatpp::Object<dto::UserDto>, dto))
|
||||
|
||||
QUERY(softDeleteUser,
|
||||
"UPDATE users SET valid_until = datetime('now') "
|
||||
"WHERE entity_id = :entityId AND valid_until > datetime('now');",
|
||||
PARAM(oatpp::String, entityId))
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Declarative schema for `users` (auth-essential columns only).
|
||||
*
|
||||
* Composes with `TemporalRepository<UserDto>` and any consumer-side
|
||||
* `*UserExtensionSchema` that contributes additional columns (email,
|
||||
* profile data, …). The natural-key UNIQUE on `(username, valid_until)`
|
||||
* prevents two live rows from sharing a username while still allowing
|
||||
* historical rows; same for `(tls_cert_dn, valid_until)` (skipped when
|
||||
* `tls_cert_dn IS NULL`, expressed via partial index below).
|
||||
*
|
||||
* @section migration Migration from a non-temporal users table
|
||||
*
|
||||
* Atlas-generated migration handles the structural conversion:
|
||||
*
|
||||
* 1. Rebuild `users` with the new column shape (TEXT id, entity_id,
|
||||
* valid_from, valid_until; drop is_active, created_at).
|
||||
* 2. Backfill: each existing row becomes its own entity:
|
||||
* `entity_id = CAST(old_id AS TEXT)`,
|
||||
* `id = CAST(old_id AS TEXT)`,
|
||||
* `valid_from = COALESCE(old_created_at, datetime('now'))`,
|
||||
* `valid_until = CASE WHEN old_is_active = 1 THEN '<sentinel>'
|
||||
* ELSE datetime('now') END`.
|
||||
* 3. Sessions/certificates FKs that referenced `users.id` (INTEGER) get
|
||||
* rewired to reference `users.entity_id` — that's a consumer-side
|
||||
* rewire, not part of this PR. The migration generated by Atlas
|
||||
* will surface those FK changes for review.
|
||||
*/
|
||||
struct UserSchema {
|
||||
inline static constexpr repo::ColumnSpec kColumns[] = {
|
||||
{"id", "TEXT PRIMARY KEY"},
|
||||
{"entity_id", "TEXT NOT NULL"},
|
||||
{"username", "TEXT NOT NULL"},
|
||||
{"password_hash", "TEXT"},
|
||||
{"role", "TEXT NOT NULL DEFAULT 'editor'"},
|
||||
{"tls_cert_dn", "TEXT"},
|
||||
// valid_from / valid_until come from TemporalRepository.
|
||||
};
|
||||
inline static constexpr repo::IndexSpec kIndexes[] = {
|
||||
{"ix_{table}_entity_id", false, "(entity_id)"},
|
||||
{"ux_{table}_username_until", true, "(username, valid_until)"},
|
||||
// tls_cert_dn UNIQUE is expressed as a partial index; the
|
||||
// SchemaBuilder index emitter doesn't yet support WHERE clauses
|
||||
// on indexes, so a regular index here lets duplicate-NULL rows
|
||||
// through. Consumers can layer a partial UNIQUE in their own
|
||||
// schema contribution if needed.
|
||||
{"ix_{table}_tls_cert_dn", false, "(tls_cert_dn)"},
|
||||
};
|
||||
|
||||
inline static constexpr repo::DecoratorSchema kSchema = {
|
||||
"UserSchema",
|
||||
kColumns, sizeof(kColumns)/sizeof(kColumns[0]),
|
||||
kIndexes, sizeof(kIndexes)/sizeof(kIndexes[0]),
|
||||
nullptr, 0,
|
||||
};
|
||||
};
|
||||
|
||||
} // namespace oatpp_authkit::db
|
||||
|
||||
#include OATPP_CODEGEN_END(DbClient)
|
||||
|
||||
#endif
|
||||
178
include/oatpp-authkit/db/UserPermissionDb.hpp
Normal file
178
include/oatpp-authkit/db/UserPermissionDb.hpp
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
#ifndef OATPP_AUTHKIT_DB_USER_PERMISSION_DB_HPP
|
||||
#define OATPP_AUTHKIT_DB_USER_PERMISSION_DB_HPP
|
||||
|
||||
// DbClient + declarative schema for user_property_permissions and
|
||||
// user_group_permissions (authkit#14 PRs 2 & 3).
|
||||
//
|
||||
// Cross-table effective-permission queries that join consumer-side
|
||||
// tables (e.g. fewo's property_set_members) stay in the consumer — only
|
||||
// the standalone DbClient queries that operate on these two tables move
|
||||
// here.
|
||||
|
||||
#include "oatpp-authkit/dto/UserPermissionDto.hpp"
|
||||
#include "oatpp-authkit/repo/SchemaContract.hpp"
|
||||
|
||||
#include "oatpp-sqlite/orm.hpp"
|
||||
|
||||
#include OATPP_CODEGEN_BEGIN(DbClient)
|
||||
|
||||
namespace oatpp_authkit::db {
|
||||
|
||||
/**
|
||||
* @brief DbClient for user_property_permissions and user_group_permissions.
|
||||
*/
|
||||
class UserPermissionDb : public oatpp::orm::DbClient {
|
||||
public:
|
||||
UserPermissionDb(const std::shared_ptr<oatpp::orm::Executor>& executor)
|
||||
: oatpp::orm::DbClient(executor) {}
|
||||
|
||||
// ---- user_property_permissions ----
|
||||
|
||||
QUERY(getAllPropertyPermissions,
|
||||
"SELECT * FROM user_property_permissions "
|
||||
"WHERE valid_from <= datetime('now') AND valid_until > datetime('now');")
|
||||
|
||||
QUERY(getAllPropertyPermissionsRaw,
|
||||
"SELECT * FROM user_property_permissions;")
|
||||
|
||||
QUERY(getPropertyPermissionsForUser,
|
||||
"SELECT * FROM user_property_permissions "
|
||||
"WHERE user_id = :userId "
|
||||
" AND valid_from <= datetime('now') AND valid_until > datetime('now');",
|
||||
PARAM(oatpp::String, userId))
|
||||
|
||||
QUERY(getPropertyPermissionByEntityId,
|
||||
"SELECT * FROM user_property_permissions "
|
||||
"WHERE entity_id = :entityId "
|
||||
" AND valid_from <= datetime('now') AND valid_until > datetime('now');",
|
||||
PARAM(oatpp::String, entityId))
|
||||
|
||||
QUERY(upsertPropertyPermissionById,
|
||||
"INSERT INTO user_property_permissions "
|
||||
" (id, entity_id, user_id, property_id, permission, valid_from, valid_until) "
|
||||
"VALUES "
|
||||
" (:p.id, :p.entityId, :p.userId, :p.propertyId, :p.permission, "
|
||||
" :p.validFrom, :p.validUntil) "
|
||||
"ON CONFLICT(id) DO UPDATE SET "
|
||||
" entity_id = excluded.entity_id, "
|
||||
" user_id = excluded.user_id, "
|
||||
" property_id = excluded.property_id, "
|
||||
" permission = excluded.permission, "
|
||||
" valid_from = excluded.valid_from, "
|
||||
" valid_until = excluded.valid_until;",
|
||||
PARAM(oatpp::Object<dto::UserPropertyPermissionDto>, p))
|
||||
|
||||
QUERY(softDeletePropertyPermission,
|
||||
"UPDATE user_property_permissions SET valid_until = datetime('now') "
|
||||
"WHERE entity_id = :entityId AND valid_until > datetime('now');",
|
||||
PARAM(oatpp::String, entityId))
|
||||
|
||||
// ---- user_group_permissions ----
|
||||
|
||||
QUERY(getAllGroupPermissions,
|
||||
"SELECT * FROM user_group_permissions "
|
||||
"WHERE valid_from <= datetime('now') AND valid_until > datetime('now');")
|
||||
|
||||
QUERY(getAllGroupPermissionsRaw,
|
||||
"SELECT * FROM user_group_permissions;")
|
||||
|
||||
QUERY(getGroupPermissionsForUser,
|
||||
"SELECT * FROM user_group_permissions "
|
||||
"WHERE user_id = :userId "
|
||||
" AND valid_from <= datetime('now') AND valid_until > datetime('now');",
|
||||
PARAM(oatpp::String, userId))
|
||||
|
||||
QUERY(getGroupPermissionByEntityId,
|
||||
"SELECT * FROM user_group_permissions "
|
||||
"WHERE entity_id = :entityId "
|
||||
" AND valid_from <= datetime('now') AND valid_until > datetime('now');",
|
||||
PARAM(oatpp::String, entityId))
|
||||
|
||||
QUERY(upsertGroupPermissionById,
|
||||
"INSERT INTO user_group_permissions "
|
||||
" (id, entity_id, user_id, set_id, permission, valid_from, valid_until) "
|
||||
"VALUES "
|
||||
" (:p.id, :p.entityId, :p.userId, :p.setId, :p.permission, "
|
||||
" :p.validFrom, :p.validUntil) "
|
||||
"ON CONFLICT(id) DO UPDATE SET "
|
||||
" entity_id = excluded.entity_id, "
|
||||
" user_id = excluded.user_id, "
|
||||
" set_id = excluded.set_id, "
|
||||
" permission = excluded.permission, "
|
||||
" valid_from = excluded.valid_from, "
|
||||
" valid_until = excluded.valid_until;",
|
||||
PARAM(oatpp::Object<dto::UserGroupPermissionDto>, p))
|
||||
|
||||
QUERY(softDeleteGroupPermission,
|
||||
"UPDATE user_group_permissions SET valid_until = datetime('now') "
|
||||
"WHERE entity_id = :entityId AND valid_until > datetime('now');",
|
||||
PARAM(oatpp::String, entityId))
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Declarative schema for `user_property_permissions`.
|
||||
*
|
||||
* Composes with `TemporalRepository<UserPropertyPermissionDto>` to produce
|
||||
* the full table including the temporal `valid_until` + composite UNIQUE
|
||||
* index. The natural-key UNIQUE `(user_id, property_id, valid_until)` is
|
||||
* carried as an explicit index here so duplicate live grants for the
|
||||
* same (user, property) pair are prevented at the DB level.
|
||||
*/
|
||||
struct UserPropertyPermissionSchema {
|
||||
inline static constexpr repo::ColumnSpec kColumns[] = {
|
||||
{"id", "TEXT PRIMARY KEY"},
|
||||
{"entity_id", "TEXT NOT NULL"},
|
||||
{"user_id", "TEXT NOT NULL"},
|
||||
{"property_id", "TEXT NOT NULL"},
|
||||
{"permission", "TEXT NOT NULL DEFAULT 'readonly'"},
|
||||
// valid_from / valid_until come from TemporalRepository.
|
||||
};
|
||||
inline static constexpr repo::IndexSpec kIndexes[] = {
|
||||
{"ix_{table}_entity_id", false, "(entity_id)"},
|
||||
{"ix_{table}_user_id", false, "(user_id)"},
|
||||
{"ux_{table}_user_property_until", true,
|
||||
"(user_id, property_id, valid_until)"},
|
||||
};
|
||||
|
||||
inline static constexpr repo::DecoratorSchema kSchema = {
|
||||
"UserPropertyPermissionSchema",
|
||||
kColumns, sizeof(kColumns)/sizeof(kColumns[0]),
|
||||
kIndexes, sizeof(kIndexes)/sizeof(kIndexes[0]),
|
||||
nullptr, 0,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Declarative schema for `user_group_permissions`.
|
||||
*
|
||||
* Mirrors `UserPropertyPermissionSchema` with `set_id` instead of
|
||||
* `property_id`. The natural-key UNIQUE prevents duplicate live grants
|
||||
* for the same (user, set) pair.
|
||||
*/
|
||||
struct UserGroupPermissionSchema {
|
||||
inline static constexpr repo::ColumnSpec kColumns[] = {
|
||||
{"id", "TEXT PRIMARY KEY"},
|
||||
{"entity_id", "TEXT NOT NULL"},
|
||||
{"user_id", "TEXT NOT NULL"},
|
||||
{"set_id", "TEXT NOT NULL"},
|
||||
{"permission", "TEXT NOT NULL DEFAULT 'readonly'"},
|
||||
};
|
||||
inline static constexpr repo::IndexSpec kIndexes[] = {
|
||||
{"ix_{table}_entity_id", false, "(entity_id)"},
|
||||
{"ix_{table}_user_id", false, "(user_id)"},
|
||||
{"ux_{table}_user_set_until", true, "(user_id, set_id, valid_until)"},
|
||||
};
|
||||
|
||||
inline static constexpr repo::DecoratorSchema kSchema = {
|
||||
"UserGroupPermissionSchema",
|
||||
kColumns, sizeof(kColumns)/sizeof(kColumns[0]),
|
||||
kIndexes, sizeof(kIndexes)/sizeof(kIndexes[0]),
|
||||
nullptr, 0,
|
||||
};
|
||||
};
|
||||
|
||||
} // namespace oatpp_authkit::db
|
||||
|
||||
#include OATPP_CODEGEN_END(DbClient)
|
||||
|
||||
#endif
|
||||
84
include/oatpp-authkit/dto/RoleTemplateDto.hpp
Normal file
84
include/oatpp-authkit/dto/RoleTemplateDto.hpp
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
#ifndef OATPP_AUTHKIT_DTO_ROLE_TEMPLATE_DTO_HPP
|
||||
#define OATPP_AUTHKIT_DTO_ROLE_TEMPLATE_DTO_HPP
|
||||
|
||||
// Role template + field-permission + user-assignment DTOs (authkit#14 PR 1).
|
||||
// Lifted from fewo-webapp's `src/dto/RoleTemplateDto.hpp` (consumer-side
|
||||
// `UserWithPermissionsDto` stays in fewo — it's the /api/auth/me response
|
||||
// shape, application-specific).
|
||||
//
|
||||
// The composite-temporal FK partner field (`templateValidUntil`) was added
|
||||
// by fewo-webapp#459 PR 7 and follows the same convention here: every child
|
||||
// row of a temporal `role_templates` row carries a sidecar that tracks the
|
||||
// parent's `valid_until` via `ON UPDATE CASCADE` on the composite FK.
|
||||
|
||||
#include "oatpp/core/macro/codegen.hpp"
|
||||
#include "oatpp/core/Types.hpp"
|
||||
|
||||
#include OATPP_CODEGEN_BEGIN(DTO)
|
||||
|
||||
namespace oatpp_authkit::dto {
|
||||
|
||||
/**
|
||||
* @brief A role template (e.g. Cleaning, Accountant, Co-Host).
|
||||
*/
|
||||
class RoleTemplateDto : public oatpp::DTO {
|
||||
DTO_INIT(RoleTemplateDto, DTO)
|
||||
|
||||
DTO_FIELD(String, id);
|
||||
DTO_FIELD(String, entityId, "entity_id");
|
||||
DTO_FIELD(String, name);
|
||||
DTO_FIELD(String, description);
|
||||
DTO_FIELD(Int32, isSystem, "is_system");
|
||||
DTO_FIELD(String, validFrom, "valid_from");
|
||||
DTO_FIELD(String, validUntil, "valid_until");
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief A field-level permission within a role template.
|
||||
*
|
||||
* `templateValidUntil` is the composite-temporal FK partner — tracks the
|
||||
* parent `role_templates` row's `valid_until` via ON UPDATE CASCADE so a
|
||||
* soft-delete of the template (which moves its `valid_until` from the
|
||||
* sentinel to `now()`) propagates here without an explicit UPDATE.
|
||||
*/
|
||||
class RoleTemplateFieldDto : public oatpp::DTO {
|
||||
DTO_INIT(RoleTemplateFieldDto, DTO)
|
||||
|
||||
DTO_FIELD(String, id);
|
||||
DTO_FIELD(String, templateId, "template_id");
|
||||
DTO_FIELD(String, templateValidUntil, "template_valid_until");
|
||||
DTO_FIELD(String, entityType, "entity_type");
|
||||
DTO_FIELD(String, fieldName, "field_name");
|
||||
DTO_FIELD(String, permission); ///< 'hidden' | 'readonly' | 'write'
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Assignment of a role template to a user (optionally property-scoped).
|
||||
*
|
||||
* `templateValidUntil` mirrors `RoleTemplateFieldDto::templateValidUntil` —
|
||||
* the composite-FK partner for the temporal FK to `role_templates`.
|
||||
*/
|
||||
class UserRoleAssignmentDto : public oatpp::DTO {
|
||||
DTO_INIT(UserRoleAssignmentDto, DTO)
|
||||
|
||||
DTO_FIELD(String, id);
|
||||
DTO_FIELD(String, entityId, "entity_id");
|
||||
DTO_FIELD(String, userId, "user_id");
|
||||
DTO_FIELD(String, templateId, "template_id");
|
||||
DTO_FIELD(String, templateValidUntil, "template_valid_until");
|
||||
DTO_FIELD(String, propertyId, "property_id"); ///< optional
|
||||
DTO_FIELD(String, validFrom, "valid_from");
|
||||
DTO_FIELD(String, validUntil, "valid_until");
|
||||
};
|
||||
|
||||
} // namespace oatpp_authkit::dto
|
||||
|
||||
#include OATPP_CODEGEN_END(DTO)
|
||||
|
||||
#include "oatpp-authkit/repo/TemporalFieldTraits.hpp"
|
||||
|
||||
OATPP_AUTHKIT_REGISTER_TEMPORAL(
|
||||
oatpp_authkit::dto::RoleTemplateDto,
|
||||
id, entityId, validFrom, validUntil)
|
||||
|
||||
#endif
|
||||
63
include/oatpp-authkit/dto/UserDto.hpp
Normal file
63
include/oatpp-authkit/dto/UserDto.hpp
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
#ifndef OATPP_AUTHKIT_DTO_USER_DTO_HPP
|
||||
#define OATPP_AUTHKIT_DTO_USER_DTO_HPP
|
||||
|
||||
// Temporal `users` DTO (authkit#14 PR 4, Option B).
|
||||
//
|
||||
// Ships the auth-essential columns: id (TEXT PK), entity_id, username,
|
||||
// password_hash, role, tls_cert_dn, plus the temporal triple. Consumers
|
||||
// add application-specific columns (email, profile data, …) by
|
||||
// contributing a second `*Schema` to the SchemaBuilder parameter pack.
|
||||
//
|
||||
// **Migration from non-temporal users**: existing fewo-webapp `users`
|
||||
// rows have `id INTEGER autoinc` and `is_active` flag. Atlas-generated
|
||||
// migration (per docs/MIGRATIONS.md) handles the conversion: each row
|
||||
// becomes its own entity (`entity_id = CAST(id AS TEXT)`), `valid_until
|
||||
// = SENTINEL` for active users and `= datetime('now')` for inactive
|
||||
// ones. Sessions/certificates FKs to `users.id` move to `users.entity_id`
|
||||
// (consumer-side rewire — out of scope for this PR).
|
||||
//
|
||||
// **Password hash temporality**: per owner directive on authkit#14,
|
||||
// password_hash rides the temporal row. A separate issue (filed by this
|
||||
// PR) tracks the redaction policy for historical hashes — likely blank
|
||||
// the hash but keep the row so the change-history is auditable.
|
||||
|
||||
#include "oatpp/core/macro/codegen.hpp"
|
||||
#include "oatpp/core/Types.hpp"
|
||||
|
||||
#include OATPP_CODEGEN_BEGIN(DTO)
|
||||
|
||||
namespace oatpp_authkit::dto {
|
||||
|
||||
/**
|
||||
* @brief Auth-essential view of an application user.
|
||||
*
|
||||
* The `password` write-only field is intentionally absent here — it
|
||||
* arrives via the consumer's auth controller (login / password-set
|
||||
* endpoints) and gets hashed before reaching `password_hash` on this
|
||||
* DTO. Consumers that ship richer user profiles add application-
|
||||
* specific columns through their own DTO + a parallel SchemaContract.
|
||||
*/
|
||||
class UserDto : public oatpp::DTO {
|
||||
DTO_INIT(UserDto, DTO)
|
||||
|
||||
DTO_FIELD(String, id);
|
||||
DTO_FIELD(String, entityId, "entity_id");
|
||||
DTO_FIELD(String, username);
|
||||
DTO_FIELD(String, passwordHash, "password_hash");
|
||||
DTO_FIELD(String, role);
|
||||
DTO_FIELD(String, tlsCertDn, "tls_cert_dn");
|
||||
DTO_FIELD(String, validFrom, "valid_from");
|
||||
DTO_FIELD(String, validUntil, "valid_until");
|
||||
};
|
||||
|
||||
} // namespace oatpp_authkit::dto
|
||||
|
||||
#include OATPP_CODEGEN_END(DTO)
|
||||
|
||||
#include "oatpp-authkit/repo/TemporalFieldTraits.hpp"
|
||||
|
||||
OATPP_AUTHKIT_REGISTER_TEMPORAL(
|
||||
oatpp_authkit::dto::UserDto,
|
||||
id, entityId, validFrom, validUntil)
|
||||
|
||||
#endif
|
||||
71
include/oatpp-authkit/dto/UserPermissionDto.hpp
Normal file
71
include/oatpp-authkit/dto/UserPermissionDto.hpp
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
#ifndef OATPP_AUTHKIT_DTO_USER_PERMISSION_DTO_HPP
|
||||
#define OATPP_AUTHKIT_DTO_USER_PERMISSION_DTO_HPP
|
||||
|
||||
// User property + group permission DTOs (authkit#14 PRs 2 & 3).
|
||||
// Lifted from fewo-webapp's `src/dto/UserPropertyPermissionDto.hpp`.
|
||||
//
|
||||
// Per-property and per-property-set RBAC primitives. The effective-
|
||||
// permission resolver lives in the consumer (fewo-webapp) because it
|
||||
// joins `property_set_members`, which is a consumer-side concept; the
|
||||
// raw tables move here so any oatpp-authkit consumer can reuse them
|
||||
// without copying schema.
|
||||
|
||||
#include "oatpp/core/macro/codegen.hpp"
|
||||
#include "oatpp/core/Types.hpp"
|
||||
|
||||
#include OATPP_CODEGEN_BEGIN(DTO)
|
||||
|
||||
namespace oatpp_authkit::dto {
|
||||
|
||||
/**
|
||||
* @brief Per-property access grant.
|
||||
*
|
||||
* Maps a user to a property with one of `'readonly'` / `'editor'`. Live
|
||||
* rows are temporal — soft-delete sets `valid_until` to `now()`.
|
||||
*/
|
||||
class UserPropertyPermissionDto : public oatpp::DTO {
|
||||
DTO_INIT(UserPropertyPermissionDto, DTO)
|
||||
|
||||
DTO_FIELD(String, id);
|
||||
DTO_FIELD(String, entityId, "entity_id");
|
||||
DTO_FIELD(String, userId, "user_id");
|
||||
DTO_FIELD(String, propertyId, "property_id");
|
||||
DTO_FIELD(String, permission);
|
||||
DTO_FIELD(String, validFrom, "valid_from");
|
||||
DTO_FIELD(String, validUntil, "valid_until");
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Group-level access grant: user → property_set.
|
||||
*
|
||||
* `set_id` references a consumer-defined property-set table. The
|
||||
* effective-permission resolver in the consumer expands group grants to
|
||||
* member properties via its own join.
|
||||
*/
|
||||
class UserGroupPermissionDto : public oatpp::DTO {
|
||||
DTO_INIT(UserGroupPermissionDto, DTO)
|
||||
|
||||
DTO_FIELD(String, id);
|
||||
DTO_FIELD(String, entityId, "entity_id");
|
||||
DTO_FIELD(String, userId, "user_id");
|
||||
DTO_FIELD(String, setId, "set_id");
|
||||
DTO_FIELD(String, permission);
|
||||
DTO_FIELD(String, validFrom, "valid_from");
|
||||
DTO_FIELD(String, validUntil, "valid_until");
|
||||
};
|
||||
|
||||
} // namespace oatpp_authkit::dto
|
||||
|
||||
#include OATPP_CODEGEN_END(DTO)
|
||||
|
||||
#include "oatpp-authkit/repo/TemporalFieldTraits.hpp"
|
||||
|
||||
OATPP_AUTHKIT_REGISTER_TEMPORAL(
|
||||
oatpp_authkit::dto::UserPropertyPermissionDto,
|
||||
id, entityId, validFrom, validUntil)
|
||||
|
||||
OATPP_AUTHKIT_REGISTER_TEMPORAL(
|
||||
oatpp_authkit::dto::UserGroupPermissionDto,
|
||||
id, entityId, validFrom, validUntil)
|
||||
|
||||
#endif
|
||||
|
|
@ -34,6 +34,15 @@ struct SmtpConfig {
|
|||
std::string password;
|
||||
};
|
||||
|
||||
/** @brief True if `s` contains CR, LF or NUL — characters that would let a
|
||||
* caller-influenced address smuggle extra SMTP/MIME headers (BCC injection,
|
||||
* added recipients, body injection) when concatenated into a header line. */
|
||||
inline bool hasHeaderInjectionChars(const std::string& s) {
|
||||
return s.find('\r') != std::string::npos
|
||||
|| s.find('\n') != std::string::npos
|
||||
|| s.find('\0') != std::string::npos;
|
||||
}
|
||||
|
||||
/** @brief RFC 4648 Base64 encode — used for RFC 2047 Subject headers. */
|
||||
inline std::string base64Encode(const std::string& data) {
|
||||
static const char* table =
|
||||
|
|
@ -76,6 +85,13 @@ inline std::string send(
|
|||
if (cfg.host.empty()) return "SMTP not configured (no host)";
|
||||
if (cfg.fromAddress.empty()) return "SMTP not configured (no from_address)";
|
||||
|
||||
// Reject control characters in the addresses before they reach the envelope
|
||||
// (MAIL FROM / RCPT TO) and the From:/To: header lines. The subject is safe
|
||||
// — it is RFC 2047 base64 encoded-word wrapped below — but the addresses are
|
||||
// concatenated raw, so a `\r\n` here would inject arbitrary headers.
|
||||
if (hasHeaderInjectionChars(to)) return "invalid recipient address (control characters)";
|
||||
if (hasHeaderInjectionChars(cfg.fromAddress)) return "invalid from address (control characters)";
|
||||
|
||||
CURL* curl = curl_easy_init();
|
||||
if (!curl) return "curl_easy_init failed";
|
||||
|
||||
|
|
@ -91,9 +107,14 @@ inline std::string send(
|
|||
curl_easy_setopt(curl, CURLOPT_USERNAME, cfg.username.c_str());
|
||||
curl_easy_setopt(curl, CURLOPT_PASSWORD, cfg.password.c_str());
|
||||
}
|
||||
curl_easy_setopt(curl, CURLOPT_USE_SSL, CURLUSESSL_TRY);
|
||||
// Allow self-signed certs on localhost relay — a common dev / pipe-transport setup.
|
||||
if (cfg.host == "localhost" || cfg.host == "127.0.0.1") {
|
||||
// authkit#16 L-2: require TLS for non-loopback relays. CURLUSESSL_TRY would
|
||||
// silently fall back to cleartext if STARTTLS is unavailable or stripped by
|
||||
// a MITM, leaking the SMTP AUTH credentials and message body. A local relay
|
||||
// (localhost / pipe transport) stays on TRY with verification relaxed since
|
||||
// there's no network hop to protect.
|
||||
const bool loopbackRelay = (cfg.host == "localhost" || cfg.host == "127.0.0.1");
|
||||
curl_easy_setopt(curl, CURLOPT_USE_SSL, loopbackRelay ? CURLUSESSL_TRY : CURLUSESSL_ALL);
|
||||
if (loopbackRelay) {
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L);
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
#include "oatpp-authkit/repo/IAuditSink.hpp"
|
||||
#include "oatpp-authkit/repo/ActorContext.hpp"
|
||||
#include "oatpp-authkit/repo/TemporalFieldTraits.hpp"
|
||||
#include "oatpp-authkit/repo/Prereq.hpp"
|
||||
#include "oatpp-authkit/repo/SchemaContract.hpp"
|
||||
|
||||
#include "oatpp/core/Types.hpp"
|
||||
|
||||
|
|
@ -65,20 +65,27 @@ public:
|
|||
using Clock = std::function<std::int64_t()>;
|
||||
using SinkErrorHandler = std::function<bool(const std::exception&)>;
|
||||
|
||||
/// Decorator-local migration kit (authkit#12).
|
||||
/// `{table}` is unused — the audit_log table is fixed across consumers.
|
||||
static constexpr const char* DECORATOR_NAME = "AuditLogRepository";
|
||||
static constexpr DecoratorPrereq PREREQ = {
|
||||
"CREATE TABLE IF NOT EXISTS audit_log ("
|
||||
" 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"
|
||||
")"
|
||||
/// 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]),
|
||||
};
|
||||
static constexpr std::array<ReshapeStep, 0> RESHAPE_STEPS = {};
|
||||
|
||||
AuditLogRepository(std::shared_ptr<Repository<TDto>> inner,
|
||||
std::shared_ptr<IAuditSink> sink,
|
||||
|
|
|
|||
113
include/oatpp-authkit/repo/ConcreteRoleTemplateRepository.hpp
Normal file
113
include/oatpp-authkit/repo/ConcreteRoleTemplateRepository.hpp
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
#ifndef OATPP_AUTHKIT_REPO_CONCRETE_ROLE_TEMPLATE_REPOSITORY_HPP
|
||||
#define OATPP_AUTHKIT_REPO_CONCRETE_ROLE_TEMPLATE_REPOSITORY_HPP
|
||||
|
||||
// Concrete inner adapter of `Repository<RoleTemplateDto>` (authkit#14 PR 1).
|
||||
// Stacks under TemporalRepository<RoleTemplateDto> via `makeRoleTemplateRepository`.
|
||||
|
||||
#include "oatpp-authkit/db/RoleTemplateDb.hpp"
|
||||
#include "oatpp-authkit/dto/RoleTemplateDto.hpp"
|
||||
#include "oatpp-authkit/repo/Repository.hpp"
|
||||
#include "oatpp-authkit/repo/TemporalRepository.hpp"
|
||||
#include "oatpp-authkit/repo/SchemaContract.hpp"
|
||||
|
||||
#include "oatpp/core/Types.hpp"
|
||||
|
||||
#include <memory>
|
||||
|
||||
namespace oatpp_authkit::repo {
|
||||
|
||||
/**
|
||||
* @brief Inner adapter of `Repository<RoleTemplateDto>`, delegating to
|
||||
* `RoleTemplateDb`.
|
||||
*
|
||||
* Per the inner-repository contract documented on `TemporalRepository`:
|
||||
*
|
||||
* - `save(dto)` is upsert keyed by `id` (per-row PK), via
|
||||
* `RoleTemplateDb::upsertTemplateById`. The decorator calls this twice
|
||||
* per update — once for the live row in place, once for the historical
|
||||
* clone with a new `id`.
|
||||
* - `list()` returns ALL rows (live + historical) via `getAllTemplatesRaw`;
|
||||
* `TemporalRepository` filters live-vs-historical itself.
|
||||
* - `findByEntityId` / `softDelete` are not used by `TemporalRepository`
|
||||
* (it overrides them with temporal-aware versions). They're implemented
|
||||
* here so the type satisfies `Repository<RoleTemplateDto>`.
|
||||
*
|
||||
* Schema contribution is deliberately empty — `RoleTemplateSchema`
|
||||
* (defined in `db/RoleTemplateDb.hpp`) owns the table declarations. This
|
||||
* concrete repo only adapts. Stack as:
|
||||
*
|
||||
* @code
|
||||
* SchemaBuilder<
|
||||
* db::RoleTemplateSchema,
|
||||
* TemporalRepository<dto::RoleTemplateDto>>::create("role_templates", exec);
|
||||
* @endcode
|
||||
*/
|
||||
class ConcreteRoleTemplateRepository
|
||||
: public Repository<dto::RoleTemplateDto>
|
||||
{
|
||||
public:
|
||||
/// Empty schema — `db::RoleTemplateSchema` is the schema partner that
|
||||
/// goes into the SchemaBuilder parameter pack.
|
||||
inline static constexpr DecoratorSchema kSchema = {
|
||||
"ConcreteRoleTemplateRepository",
|
||||
nullptr, 0,
|
||||
nullptr, 0,
|
||||
nullptr, 0,
|
||||
};
|
||||
|
||||
explicit ConcreteRoleTemplateRepository(std::shared_ptr<db::RoleTemplateDb> rtdb)
|
||||
: m_db(std::move(rtdb)) {}
|
||||
|
||||
oatpp::Object<dto::RoleTemplateDto>
|
||||
findByEntityId(const oatpp::String& entityId) override
|
||||
{
|
||||
auto res = m_db->getTemplateByEntityId(entityId);
|
||||
if (!res || !res->isSuccess()) return nullptr;
|
||||
auto rows = res->template fetch<oatpp::Vector<oatpp::Object<dto::RoleTemplateDto>>>();
|
||||
if (!rows || rows->empty()) return nullptr;
|
||||
return (*rows)[0];
|
||||
}
|
||||
|
||||
oatpp::Vector<oatpp::Object<dto::RoleTemplateDto>> list() override {
|
||||
auto res = m_db->getAllTemplatesRaw();
|
||||
auto out = oatpp::Vector<oatpp::Object<dto::RoleTemplateDto>>::createShared();
|
||||
if (!res || !res->isSuccess()) return out;
|
||||
auto fetched = res->template fetch<
|
||||
oatpp::Vector<oatpp::Object<dto::RoleTemplateDto>>>();
|
||||
if (!fetched) return out;
|
||||
for (auto& row : *fetched) {
|
||||
if (row) out->push_back(row);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
void save(const oatpp::Object<dto::RoleTemplateDto>& d) override {
|
||||
m_db->upsertTemplateById(d);
|
||||
}
|
||||
|
||||
void softDelete(const oatpp::String& entityId) override {
|
||||
m_db->softDeleteTemplate(entityId);
|
||||
}
|
||||
|
||||
private:
|
||||
std::shared_ptr<db::RoleTemplateDb> m_db;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Compose the role-template repository stack.
|
||||
*
|
||||
* Wraps the concrete repo in `TemporalRepository<RoleTemplateDto>` so
|
||||
* callers get versioning + soft-delete-via-valid-until semantics. No
|
||||
* scope guard is added at this layer — role-template management is
|
||||
* admin-only at the controller level, and there's no per-property scope.
|
||||
*/
|
||||
inline std::shared_ptr<Repository<dto::RoleTemplateDto>>
|
||||
makeRoleTemplateRepository(std::shared_ptr<db::RoleTemplateDb> rtdb)
|
||||
{
|
||||
auto concrete = std::make_shared<ConcreteRoleTemplateRepository>(std::move(rtdb));
|
||||
return std::make_shared<TemporalRepository<dto::RoleTemplateDto>>(concrete);
|
||||
}
|
||||
|
||||
} // namespace oatpp_authkit::repo
|
||||
|
||||
#endif
|
||||
141
include/oatpp-authkit/repo/ConcreteUserPermissionRepository.hpp
Normal file
141
include/oatpp-authkit/repo/ConcreteUserPermissionRepository.hpp
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
#ifndef OATPP_AUTHKIT_REPO_CONCRETE_USER_PERMISSION_REPOSITORY_HPP
|
||||
#define OATPP_AUTHKIT_REPO_CONCRETE_USER_PERMISSION_REPOSITORY_HPP
|
||||
|
||||
// Concrete inner adapters of Repository<UserPropertyPermissionDto> and
|
||||
// Repository<UserGroupPermissionDto> (authkit#14 PRs 2 & 3). Stack each
|
||||
// under TemporalRepository for versioning + soft-delete via valid_until.
|
||||
|
||||
#include "oatpp-authkit/db/UserPermissionDb.hpp"
|
||||
#include "oatpp-authkit/dto/UserPermissionDto.hpp"
|
||||
#include "oatpp-authkit/repo/Repository.hpp"
|
||||
#include "oatpp-authkit/repo/SchemaContract.hpp"
|
||||
#include "oatpp-authkit/repo/TemporalRepository.hpp"
|
||||
|
||||
#include "oatpp/core/Types.hpp"
|
||||
|
||||
#include <memory>
|
||||
|
||||
namespace oatpp_authkit::repo {
|
||||
|
||||
/**
|
||||
* @brief Inner adapter for `Repository<UserPropertyPermissionDto>`,
|
||||
* delegating to `db::UserPermissionDb`.
|
||||
*
|
||||
* Schema lives in `db::UserPropertyPermissionSchema` — this repo
|
||||
* contributes nothing to the schema, only adapts queries.
|
||||
*/
|
||||
class ConcreteUserPropertyPermissionRepository
|
||||
: public Repository<dto::UserPropertyPermissionDto>
|
||||
{
|
||||
public:
|
||||
inline static constexpr DecoratorSchema kSchema = {
|
||||
"ConcreteUserPropertyPermissionRepository",
|
||||
nullptr, 0, nullptr, 0, nullptr, 0,
|
||||
};
|
||||
|
||||
explicit ConcreteUserPropertyPermissionRepository(
|
||||
std::shared_ptr<db::UserPermissionDb> updb)
|
||||
: m_db(std::move(updb)) {}
|
||||
|
||||
oatpp::Object<dto::UserPropertyPermissionDto>
|
||||
findByEntityId(const oatpp::String& entityId) override
|
||||
{
|
||||
auto res = m_db->getPropertyPermissionByEntityId(entityId);
|
||||
if (!res || !res->isSuccess()) return nullptr;
|
||||
auto rows = res->template fetch<
|
||||
oatpp::Vector<oatpp::Object<dto::UserPropertyPermissionDto>>>();
|
||||
if (!rows || rows->empty()) return nullptr;
|
||||
return (*rows)[0];
|
||||
}
|
||||
|
||||
oatpp::Vector<oatpp::Object<dto::UserPropertyPermissionDto>> list() override {
|
||||
auto res = m_db->getAllPropertyPermissionsRaw();
|
||||
auto out = oatpp::Vector<oatpp::Object<dto::UserPropertyPermissionDto>>::createShared();
|
||||
if (!res || !res->isSuccess()) return out;
|
||||
auto fetched = res->template fetch<
|
||||
oatpp::Vector<oatpp::Object<dto::UserPropertyPermissionDto>>>();
|
||||
if (!fetched) return out;
|
||||
for (auto& row : *fetched) if (row) out->push_back(row);
|
||||
return out;
|
||||
}
|
||||
|
||||
void save(const oatpp::Object<dto::UserPropertyPermissionDto>& d) override {
|
||||
m_db->upsertPropertyPermissionById(d);
|
||||
}
|
||||
|
||||
void softDelete(const oatpp::String& entityId) override {
|
||||
m_db->softDeletePropertyPermission(entityId);
|
||||
}
|
||||
|
||||
private:
|
||||
std::shared_ptr<db::UserPermissionDb> m_db;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Inner adapter for `Repository<UserGroupPermissionDto>`,
|
||||
* delegating to `db::UserPermissionDb`.
|
||||
*/
|
||||
class ConcreteUserGroupPermissionRepository
|
||||
: public Repository<dto::UserGroupPermissionDto>
|
||||
{
|
||||
public:
|
||||
inline static constexpr DecoratorSchema kSchema = {
|
||||
"ConcreteUserGroupPermissionRepository",
|
||||
nullptr, 0, nullptr, 0, nullptr, 0,
|
||||
};
|
||||
|
||||
explicit ConcreteUserGroupPermissionRepository(
|
||||
std::shared_ptr<db::UserPermissionDb> updb)
|
||||
: m_db(std::move(updb)) {}
|
||||
|
||||
oatpp::Object<dto::UserGroupPermissionDto>
|
||||
findByEntityId(const oatpp::String& entityId) override
|
||||
{
|
||||
auto res = m_db->getGroupPermissionByEntityId(entityId);
|
||||
if (!res || !res->isSuccess()) return nullptr;
|
||||
auto rows = res->template fetch<
|
||||
oatpp::Vector<oatpp::Object<dto::UserGroupPermissionDto>>>();
|
||||
if (!rows || rows->empty()) return nullptr;
|
||||
return (*rows)[0];
|
||||
}
|
||||
|
||||
oatpp::Vector<oatpp::Object<dto::UserGroupPermissionDto>> list() override {
|
||||
auto res = m_db->getAllGroupPermissionsRaw();
|
||||
auto out = oatpp::Vector<oatpp::Object<dto::UserGroupPermissionDto>>::createShared();
|
||||
if (!res || !res->isSuccess()) return out;
|
||||
auto fetched = res->template fetch<
|
||||
oatpp::Vector<oatpp::Object<dto::UserGroupPermissionDto>>>();
|
||||
if (!fetched) return out;
|
||||
for (auto& row : *fetched) if (row) out->push_back(row);
|
||||
return out;
|
||||
}
|
||||
|
||||
void save(const oatpp::Object<dto::UserGroupPermissionDto>& d) override {
|
||||
m_db->upsertGroupPermissionById(d);
|
||||
}
|
||||
|
||||
void softDelete(const oatpp::String& entityId) override {
|
||||
m_db->softDeleteGroupPermission(entityId);
|
||||
}
|
||||
|
||||
private:
|
||||
std::shared_ptr<db::UserPermissionDb> m_db;
|
||||
};
|
||||
|
||||
inline std::shared_ptr<Repository<dto::UserPropertyPermissionDto>>
|
||||
makeUserPropertyPermissionRepository(std::shared_ptr<db::UserPermissionDb> updb)
|
||||
{
|
||||
auto concrete = std::make_shared<ConcreteUserPropertyPermissionRepository>(std::move(updb));
|
||||
return std::make_shared<TemporalRepository<dto::UserPropertyPermissionDto>>(concrete);
|
||||
}
|
||||
|
||||
inline std::shared_ptr<Repository<dto::UserGroupPermissionDto>>
|
||||
makeUserGroupPermissionRepository(std::shared_ptr<db::UserPermissionDb> updb)
|
||||
{
|
||||
auto concrete = std::make_shared<ConcreteUserGroupPermissionRepository>(std::move(updb));
|
||||
return std::make_shared<TemporalRepository<dto::UserGroupPermissionDto>>(concrete);
|
||||
}
|
||||
|
||||
} // namespace oatpp_authkit::repo
|
||||
|
||||
#endif
|
||||
96
include/oatpp-authkit/repo/ConcreteUserRepository.hpp
Normal file
96
include/oatpp-authkit/repo/ConcreteUserRepository.hpp
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
#ifndef OATPP_AUTHKIT_REPO_CONCRETE_USER_REPOSITORY_HPP
|
||||
#define OATPP_AUTHKIT_REPO_CONCRETE_USER_REPOSITORY_HPP
|
||||
|
||||
// Concrete inner adapter of `Repository<UserDto>` (authkit#14 PR 4).
|
||||
// Stacks under TemporalRepository<UserDto> via `makeUserRepository`.
|
||||
|
||||
#include "oatpp-authkit/db/UserDb.hpp"
|
||||
#include "oatpp-authkit/dto/UserDto.hpp"
|
||||
#include "oatpp-authkit/repo/RedactedFieldRepository.hpp"
|
||||
#include "oatpp-authkit/repo/Repository.hpp"
|
||||
#include "oatpp-authkit/repo/SchemaContract.hpp"
|
||||
#include "oatpp-authkit/repo/TemporalRepository.hpp"
|
||||
|
||||
#include "oatpp/core/Types.hpp"
|
||||
|
||||
#include <memory>
|
||||
|
||||
namespace oatpp_authkit::repo {
|
||||
|
||||
/**
|
||||
* @brief Inner adapter of `Repository<UserDto>`, delegating to `db::UserDb`.
|
||||
*
|
||||
* Empty schema — `db::UserSchema` owns the table declaration.
|
||||
*/
|
||||
class ConcreteUserRepository : public Repository<dto::UserDto> {
|
||||
public:
|
||||
inline static constexpr DecoratorSchema kSchema = {
|
||||
"ConcreteUserRepository",
|
||||
nullptr, 0, nullptr, 0, nullptr, 0,
|
||||
};
|
||||
|
||||
explicit ConcreteUserRepository(std::shared_ptr<db::UserDb> udb)
|
||||
: m_db(std::move(udb)) {}
|
||||
|
||||
oatpp::Object<dto::UserDto> findByEntityId(const oatpp::String& entityId) override {
|
||||
auto res = m_db->findUserByEntityId(entityId);
|
||||
if (!res || !res->isSuccess()) return nullptr;
|
||||
auto rows = res->template fetch<oatpp::Vector<oatpp::Object<dto::UserDto>>>();
|
||||
if (!rows || rows->empty()) return nullptr;
|
||||
return (*rows)[0];
|
||||
}
|
||||
|
||||
oatpp::Vector<oatpp::Object<dto::UserDto>> list() override {
|
||||
auto res = m_db->getAllUsersRaw();
|
||||
auto out = oatpp::Vector<oatpp::Object<dto::UserDto>>::createShared();
|
||||
if (!res || !res->isSuccess()) return out;
|
||||
auto fetched = res->template fetch<oatpp::Vector<oatpp::Object<dto::UserDto>>>();
|
||||
if (!fetched) return out;
|
||||
for (auto& row : *fetched) if (row) out->push_back(row);
|
||||
return out;
|
||||
}
|
||||
|
||||
void save(const oatpp::Object<dto::UserDto>& d) override {
|
||||
m_db->upsertUserById(d);
|
||||
}
|
||||
|
||||
void softDelete(const oatpp::String& entityId) override {
|
||||
m_db->softDeleteUser(entityId);
|
||||
}
|
||||
|
||||
private:
|
||||
std::shared_ptr<db::UserDb> m_db;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Compose the user repository stack with credential redaction
|
||||
* baked in (authkit#15).
|
||||
*
|
||||
* TemporalRepository(
|
||||
* RedactedFieldRepository(
|
||||
* ConcreteUserRepository(udb),
|
||||
* {"passwordHash", "tlsCertDn"}))
|
||||
*
|
||||
* On every password change the prior hash gets blanked on the historical
|
||||
* row before the temporal decorator persists it. `tlsCertDn` follows the
|
||||
* same policy. The audit-trail (when did this user exist, when was their
|
||||
* password rotated) survives in `valid_from`/`valid_until`/`username`/
|
||||
* `role`; only the credential surface is redacted.
|
||||
*
|
||||
* Default redaction list is `{"passwordHash", "tlsCertDn"}` per the
|
||||
* issue thread's Option B. Pass a different list to the overload below
|
||||
* if a consumer wants different behaviour.
|
||||
*/
|
||||
inline std::shared_ptr<Repository<dto::UserDto>>
|
||||
makeUserRepository(std::shared_ptr<db::UserDb> udb,
|
||||
std::vector<std::string> fieldsToRedact =
|
||||
{"passwordHash", "tlsCertDn"}) {
|
||||
auto concrete = std::make_shared<ConcreteUserRepository>(std::move(udb));
|
||||
auto redacted = std::make_shared<RedactedFieldRepository<dto::UserDto>>(
|
||||
concrete, std::move(fieldsToRedact));
|
||||
return std::make_shared<TemporalRepository<dto::UserDto>>(redacted);
|
||||
}
|
||||
|
||||
} // namespace oatpp_authkit::repo
|
||||
|
||||
#endif
|
||||
|
|
@ -30,6 +30,14 @@ namespace oatpp_authkit::repo {
|
|||
// two function templates. The primary templates are intentionally declared
|
||||
// without a definition: forgetting to register a field is a hard compile or
|
||||
// link error rather than a runtime surprise.
|
||||
//
|
||||
// SECURITY INVARIANT (authkit#16 L-8): column and table *identifiers* are
|
||||
// emitted into SQL unparameterised (SQL placeholders can't bind identifiers).
|
||||
// They come ONLY from these compile-time registrations / `Field<&Dto::mem>`,
|
||||
// never from request data — so there is no injection vector. Never construct
|
||||
// an `OrderBySpec`/`Field` column name from a runtime/user string; map a
|
||||
// client sort field to a registered `Field` via an allowlist first. All
|
||||
// *values* (eq/ne/in/like/...) are always bound as `?` parameters.
|
||||
|
||||
template <auto MemPtr>
|
||||
const char* columnName();
|
||||
|
|
@ -69,6 +77,22 @@ inline BindValue toBindValue(const oatpp::String& v) {
|
|||
return v ? BindValue{std::string(*v)} : BindValue{};
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Escape LIKE wildcards (`%`, `_`) and the escape char (`\`) in a
|
||||
* user-supplied search term so they're matched literally (authkit#16
|
||||
* L-8). Pair with the `LIKE ? ESCAPE '\'` clause emitted by
|
||||
* `Field::likeContains` / `Field::likePrefix`.
|
||||
*/
|
||||
inline std::string likeEscape(const std::string& term) {
|
||||
std::string out;
|
||||
out.reserve(term.size() + 4);
|
||||
for (char c : term) {
|
||||
if (c == '\\' || c == '%' || c == '_') out.push_back('\\');
|
||||
out.push_back(c);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── AST nodes ──────────────────────────────────────────────────────────────
|
||||
|
||||
class AstNode {
|
||||
|
|
@ -122,6 +146,20 @@ public:
|
|||
}
|
||||
};
|
||||
|
||||
/** @brief `col LIKE ? ESCAPE '\'` — the explicit ESCAPE makes a `\`-escaped
|
||||
* pattern (see `likeEscape`) treat `%`/`_` literally. */
|
||||
class LikeNode : public AstNode {
|
||||
std::string col_;
|
||||
BindValue val_;
|
||||
public:
|
||||
LikeNode(std::string c, BindValue v) : col_(std::move(c)), val_(std::move(v)) {}
|
||||
void emit(std::ostringstream& sql,
|
||||
std::vector<BindValue>& binds) const override {
|
||||
sql << col_ << " LIKE ? ESCAPE '\\'";
|
||||
binds.push_back(val_);
|
||||
}
|
||||
};
|
||||
|
||||
class CombineNode : public AstNode {
|
||||
const char* sep_;
|
||||
std::vector<std::shared_ptr<AstNode>> children_;
|
||||
|
|
@ -218,10 +256,28 @@ public:
|
|||
return Predicate{std::make_shared<InNode>(column(), std::move(bs))};
|
||||
}
|
||||
|
||||
/** @brief Raw `col LIKE ?` with the pattern bound verbatim. The caller owns
|
||||
* the `%`/`_` wildcards — only pass a TRUSTED pattern here. For a
|
||||
* user-supplied search term use `likeContains` / `likePrefix` (which
|
||||
* escape the metacharacters), or wrap it with `likeEscape`. */
|
||||
Predicate like(const std::string& pat) const {
|
||||
return Predicate{std::make_shared<CompareNode>(
|
||||
column(), "LIKE", BindValue{pat})};
|
||||
}
|
||||
|
||||
/** @brief Substring match of a user-supplied `term` with LIKE wildcards
|
||||
* escaped — emits `col LIKE '%<escaped>%' ESCAPE '\'` (authkit#16 L-8). */
|
||||
Predicate likeContains(const std::string& term) const {
|
||||
return Predicate{std::make_shared<LikeNode>(
|
||||
column(), BindValue{"%" + likeEscape(term) + "%"})};
|
||||
}
|
||||
|
||||
/** @brief Prefix match of a user-supplied `term` with LIKE wildcards
|
||||
* escaped — emits `col LIKE '<escaped>%' ESCAPE '\'`. */
|
||||
Predicate likePrefix(const std::string& term) const {
|
||||
return Predicate{std::make_shared<LikeNode>(
|
||||
column(), BindValue{likeEscape(term) + "%"})};
|
||||
}
|
||||
Predicate isNull() const { return Predicate{std::make_shared<IsNullNode>(column(), true)}; }
|
||||
Predicate isNotNull() const { return Predicate{std::make_shared<IsNullNode>(column(), false)}; }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,174 +0,0 @@
|
|||
#ifndef OATPP_AUTHKIT_REPO_PREREQ_HPP
|
||||
#define OATPP_AUTHKIT_REPO_PREREQ_HPP
|
||||
|
||||
// Per-decorator migration kit (authkit#12). Each decorator that touches a
|
||||
// schema bundles its prereq SQL alongside its code, so the consumer's
|
||||
// startup wiring is "stack the decorators, run the migrations" — no hand-
|
||||
// pasted DDL, no drift between header doc-comments and the actual schema.
|
||||
//
|
||||
// Two migration kinds, both decorator-local, both idempotent:
|
||||
//
|
||||
// 1. Additive prereqs — `PREREQ` (a `DecoratorPrereq`). For decorators
|
||||
// that need extra tables/indexes alongside the entity table. Pure
|
||||
// `CREATE TABLE IF NOT EXISTS …` style; re-running is a no-op by
|
||||
// construction.
|
||||
//
|
||||
// 2. Reshape migrations — `RESHAPE_STEPS` (a `std::array<ReshapeStep, N>`).
|
||||
// For decorators that change the entity's own table shape. Naively
|
||||
// non-idempotent (`ALTER TABLE … ADD COLUMN …` errors on second run),
|
||||
// so each step ships a `detectSql` probe that returns true iff the
|
||||
// step has already been applied. The runner skips applied steps.
|
||||
//
|
||||
// The runner is `applyDecoratorMigrations<Decorators...>(table, probe, exec)`.
|
||||
// It runs every decorator's PREREQ then its RESHAPE_STEPS in declaration
|
||||
// order. Both `PREREQ.sql` and `ReshapeStep.{detectSql,applySql}` may
|
||||
// contain `{table}` placeholders that the runner substitutes.
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace oatpp_authkit::repo {
|
||||
|
||||
/**
|
||||
* @brief Bookkeeping table for applied migration steps. Optional —
|
||||
* the detect-probe is the source of truth, so the runner is safe even
|
||||
* if this table is wiped between invocations. The schema is exposed
|
||||
* for consumers that want observability ("which steps ran when").
|
||||
*/
|
||||
constexpr const char* SCHEMA_MIGRATIONS_TABLE_SQL =
|
||||
"CREATE TABLE IF NOT EXISTS oatpp_authkit_schema_migrations ("
|
||||
" decorator TEXT NOT NULL,"
|
||||
" table_name TEXT NOT NULL,"
|
||||
" step TEXT NOT NULL,"
|
||||
" applied_at TEXT NOT NULL,"
|
||||
" PRIMARY KEY (decorator, table_name, step)"
|
||||
")";
|
||||
|
||||
/**
|
||||
* @brief Additive schema prereq for one decorator. Empty `sql` means the
|
||||
* decorator declares no additive prereq (it's still required to expose
|
||||
* `PREREQ` so the runner doesn't need SFINAE). May contain `{table}`
|
||||
* which the runner substitutes — most additive prereqs name fixed tables
|
||||
* (e.g. `audit_log`) and won't use the placeholder.
|
||||
*/
|
||||
struct DecoratorPrereq {
|
||||
const char* sql{""};
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief One step of a reshape migration. The runner calls `probe(detectSql)`
|
||||
* first; if it returns true the step is treated as already applied and
|
||||
* skipped. Otherwise `exec(applySql)` runs and the step is recorded.
|
||||
*
|
||||
* Both `detectSql` and `applySql` may contain `{table}` placeholders.
|
||||
*
|
||||
* `name` is a stable identifier — it's what gets recorded in
|
||||
* `oatpp_authkit_schema_migrations` and what tests assert against.
|
||||
*/
|
||||
struct ReshapeStep {
|
||||
const char* name{""};
|
||||
const char* detectSql{""};
|
||||
const char* applySql{""};
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Substitutes `{table}` -> `tableName` in `sqlTemplate`. All
|
||||
* occurrences are replaced. Pure string transform — no SQL parsing.
|
||||
*/
|
||||
inline std::string instantiate(std::string_view sqlTemplate,
|
||||
std::string_view tableName) {
|
||||
std::string out(sqlTemplate);
|
||||
static constexpr std::string_view 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 Runs a single statement against the consumer's database. The
|
||||
* consumer wires this to whatever DbClient / ORM they use — the kit stays
|
||||
* database-agnostic by routing all DDL through these two callbacks.
|
||||
*/
|
||||
using SqlExec = std::function<void(const std::string& sql)>;
|
||||
|
||||
/**
|
||||
* @brief Returns true iff the given query yields at least one row. Used
|
||||
* by the runner to skip already-applied reshape steps.
|
||||
*/
|
||||
using SqlProbe = std::function<bool(const std::string& sql)>;
|
||||
|
||||
/**
|
||||
* @brief Optional callback invoked for every step the runner actually
|
||||
* applies (skipped steps are not reported). Consumers wire this to an
|
||||
* INSERT into `oatpp_authkit_schema_migrations` when they want
|
||||
* observability.
|
||||
*/
|
||||
using StepRecorder = std::function<void(const char* decorator,
|
||||
const std::string& table,
|
||||
const char* step)>;
|
||||
|
||||
namespace detail {
|
||||
inline bool nonEmpty(const char* s) { return s && s[0] != '\0'; }
|
||||
} // namespace detail
|
||||
|
||||
/**
|
||||
* @brief Applies one decorator's migrations (PREREQ + RESHAPE_STEPS) to
|
||||
* `tableName`. Idempotent: PREREQ is `CREATE … IF NOT EXISTS`-style by
|
||||
* convention, RESHAPE_STEPS are gated on `probe(detectSql)`.
|
||||
*
|
||||
* @tparam Decorator Type exposing `static constexpr const char* DECORATOR_NAME`,
|
||||
* `static constexpr DecoratorPrereq PREREQ`, and
|
||||
* `static constexpr std::array<ReshapeStep, N> RESHAPE_STEPS`.
|
||||
*/
|
||||
template <typename Decorator>
|
||||
void applyDecoratorMigration(const std::string& tableName,
|
||||
const SqlProbe& probe,
|
||||
const SqlExec& exec,
|
||||
const StepRecorder& record = {}) {
|
||||
if (detail::nonEmpty(Decorator::PREREQ.sql)) {
|
||||
exec(instantiate(Decorator::PREREQ.sql, tableName));
|
||||
if (record) record(Decorator::DECORATOR_NAME, tableName, "PREREQ_SQL");
|
||||
}
|
||||
for (const auto& step : Decorator::RESHAPE_STEPS) {
|
||||
if (!detail::nonEmpty(step.applySql)) continue;
|
||||
if (detail::nonEmpty(step.detectSql) &&
|
||||
probe(instantiate(step.detectSql, tableName))) {
|
||||
continue; // already applied
|
||||
}
|
||||
exec(instantiate(step.applySql, tableName));
|
||||
if (record) record(Decorator::DECORATOR_NAME, tableName, step.name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Variadic convenience: walks every listed decorator and runs its
|
||||
* migrations against the same `tableName`. Order matches the parameter
|
||||
* pack. Typical use:
|
||||
*
|
||||
* @code
|
||||
* applyDecoratorMigrations<
|
||||
* TemporalRepository<PersonDto>,
|
||||
* AuditLogRepository<PersonDto>>(
|
||||
* "persons", probe, exec, recorder);
|
||||
* @endcode
|
||||
*
|
||||
* `ScopeGuardRepository` and other decorators with no schema needs are
|
||||
* fine to include — they expose an empty PREREQ and zero-length
|
||||
* RESHAPE_STEPS, which the runner skips cleanly.
|
||||
*/
|
||||
template <typename... Decorators>
|
||||
void applyDecoratorMigrations(const std::string& tableName,
|
||||
const SqlProbe& probe,
|
||||
const SqlExec& exec,
|
||||
const StepRecorder& record = {}) {
|
||||
(applyDecoratorMigration<Decorators>(tableName, probe, exec, record), ...);
|
||||
}
|
||||
|
||||
} // namespace oatpp_authkit::repo
|
||||
|
||||
#endif
|
||||
141
include/oatpp-authkit/repo/RedactedFieldRepository.hpp
Normal file
141
include/oatpp-authkit/repo/RedactedFieldRepository.hpp
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
#ifndef OATPP_AUTHKIT_REPO_REDACTED_FIELD_REPOSITORY_HPP
|
||||
#define OATPP_AUTHKIT_REPO_REDACTED_FIELD_REPOSITORY_HPP
|
||||
|
||||
// Decorator that nulls out named fields on historical rows (authkit#15).
|
||||
//
|
||||
// Use case: when password_hash and similar credentials ride a temporal
|
||||
// row, every change creates a historical version with the prior secret
|
||||
// preserved. A DB breach then yields every credential the user has ever
|
||||
// had — a known-plaintext oracle for guessing future passwords.
|
||||
//
|
||||
// This decorator sits **between** TemporalRepository and the concrete
|
||||
// repo. TemporalRepository's `save` flow calls inner `save` twice:
|
||||
//
|
||||
// 1. Historical clone with `valid_until = now()` (the row being closed)
|
||||
// 2. Live row with `valid_until = SENTINEL` (the new version)
|
||||
//
|
||||
// We redact configured fields whenever `valid_until != SENTINEL` on
|
||||
// entry — i.e. only on the historical insert. The live row keeps its
|
||||
// values intact.
|
||||
//
|
||||
// Stack:
|
||||
//
|
||||
// TemporalRepository<UserDto>(
|
||||
// RedactedFieldRepository<UserDto>(
|
||||
// ConcreteUserRepository(udb),
|
||||
// {"passwordHash", "tlsCertDn"}))
|
||||
|
||||
#include "oatpp-authkit/repo/Repository.hpp"
|
||||
#include "oatpp-authkit/repo/TemporalFieldTraits.hpp"
|
||||
#include "oatpp-authkit/repo/TemporalRepository.hpp"
|
||||
|
||||
#include "oatpp/core/Types.hpp"
|
||||
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace oatpp_authkit::repo {
|
||||
|
||||
/**
|
||||
* @brief Decorator that redacts named fields on historical rows.
|
||||
*
|
||||
* `TDto` must register a `TemporalFieldTraits<TDto>` specialisation so
|
||||
* the decorator can read `valid_until` to distinguish historical rows
|
||||
* from live ones. Field-name matching uses oatpp's reflective property
|
||||
* dispatcher and matches against the **C++ identifier** name (the first
|
||||
* macro argument to `DTO_FIELD`), not the JSON-qualified column name.
|
||||
*
|
||||
* Schema contribution: empty. The redaction is purely a save-time
|
||||
* transform; no extra columns or indexes are needed.
|
||||
*/
|
||||
template <typename TDto>
|
||||
class RedactedFieldRepository : public Repository<TDto> {
|
||||
public:
|
||||
inline static constexpr DecoratorSchema kSchema = {
|
||||
"RedactedFieldRepository",
|
||||
nullptr, 0, nullptr, 0, nullptr, 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* @param inner Concrete adapter (or any further-inner stack).
|
||||
* @param fieldsToRedact C++ identifier names of DTO fields to null
|
||||
* out on historical writes (e.g. `"passwordHash"`).
|
||||
*/
|
||||
RedactedFieldRepository(std::shared_ptr<Repository<TDto>> inner,
|
||||
std::vector<std::string> fieldsToRedact)
|
||||
: m_inner(std::move(inner))
|
||||
, m_fieldsToRedact(std::move(fieldsToRedact))
|
||||
{
|
||||
// authkit#16 M-6: fail loud if a configured field name doesn't exist on
|
||||
// the DTO. A typo (or passing the JSON column name instead of the C++
|
||||
// identifier) would otherwise silently redact nothing, leaving the
|
||||
// credential in history — the exact breach this decorator prevents.
|
||||
const auto* dispatcher = static_cast<
|
||||
const oatpp::data::mapping::type::__class::AbstractObject::PolymorphicDispatcher*>(
|
||||
oatpp::Object<TDto>::Class::getType()->polymorphicDispatcher);
|
||||
for (const auto& target : m_fieldsToRedact) {
|
||||
bool found = false;
|
||||
for (auto* p : dispatcher->getProperties()->getList()) {
|
||||
if (target == p->name) { found = true; break; }
|
||||
}
|
||||
if (!found) {
|
||||
throw std::invalid_argument(
|
||||
"RedactedFieldRepository: unknown DTO field '" + target +
|
||||
"' (use the C++ identifier from DTO_FIELD, not the JSON name)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
oatpp::Object<TDto> findByEntityId(const oatpp::String& entityId) override {
|
||||
return m_inner->findByEntityId(entityId);
|
||||
}
|
||||
|
||||
oatpp::Vector<oatpp::Object<TDto>> list() override {
|
||||
return m_inner->list();
|
||||
}
|
||||
|
||||
void save(const oatpp::Object<TDto>& dto) override {
|
||||
if (isHistorical(dto)) redactFields(dto);
|
||||
m_inner->save(dto);
|
||||
}
|
||||
|
||||
void softDelete(const oatpp::String& entityId) override {
|
||||
m_inner->softDelete(entityId);
|
||||
}
|
||||
|
||||
private:
|
||||
/// A row is historical iff `valid_until` is non-null and not the
|
||||
/// SENTINEL. The TemporalRepository sets `valid_until = now()` on
|
||||
/// the close-clone and `valid_until = SENTINEL` on the live update.
|
||||
static bool isHistorical(const oatpp::Object<TDto>& dto) {
|
||||
auto& vu = TemporalFieldTraits<TDto>::validUntil(dto);
|
||||
if (!vu) return false;
|
||||
return std::string(*vu) != TemporalRepository<TDto>::SENTINEL;
|
||||
}
|
||||
|
||||
void redactFields(const oatpp::Object<TDto>& dto) const {
|
||||
const auto* dispatcher = static_cast<
|
||||
const oatpp::data::mapping::type::__class::AbstractObject::PolymorphicDispatcher*>(
|
||||
oatpp::Object<TDto>::Class::getType()->polymorphicDispatcher);
|
||||
for (auto* p : dispatcher->getProperties()->getList()) {
|
||||
const std::string name(p->name);
|
||||
for (const auto& target : m_fieldsToRedact) {
|
||||
if (name == target) {
|
||||
p->set(static_cast<oatpp::BaseObject*>(dto.get()),
|
||||
oatpp::Void(nullptr, p->type));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::shared_ptr<Repository<TDto>> m_inner;
|
||||
std::vector<std::string> m_fieldsToRedact;
|
||||
};
|
||||
|
||||
} // namespace oatpp_authkit::repo
|
||||
|
||||
#endif
|
||||
289
include/oatpp-authkit/repo/SchemaContract.hpp
Normal file
289
include/oatpp-authkit/repo/SchemaContract.hpp
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
#ifndef OATPP_AUTHKIT_REPO_SCHEMA_CONTRACT_HPP
|
||||
#define OATPP_AUTHKIT_REPO_SCHEMA_CONTRACT_HPP
|
||||
|
||||
// Declarative schema model for the decorator stack (authkit#14).
|
||||
//
|
||||
// Each decorator (and the concrete repo at the bottom of the stack)
|
||||
// exposes a `static constexpr DecoratorSchema kSchema = {…}` listing the
|
||||
// columns/indexes it contributes to the entity table plus any sidecar
|
||||
// tables it owns. A `SchemaBuilder<Decorators…>::create(table, exec)`
|
||||
// composes all contributions into a single `CREATE TABLE` per entity
|
||||
// table; sidecar tables are emitted separately.
|
||||
//
|
||||
// Schema authority lives **in the C++ decorator code**. Atlas
|
||||
// (atlasgo.io) consumes the result by inspecting an empty SQLite that the
|
||||
// builder has populated and treats that as the desired state for diff/
|
||||
// apply against running prod databases. Decorator code never runs ALTER
|
||||
// at runtime; `SchemaContract::verify` only introspects-and-asserts.
|
||||
//
|
||||
// This replaces the imperative PREREQ + RESHAPE_STEPS kit from
|
||||
// authkit#12 (D-replace per the issue thread).
|
||||
|
||||
#include <cstddef>
|
||||
#include <functional>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace oatpp_authkit::repo {
|
||||
|
||||
/**
|
||||
* @brief One column a decorator contributes to a table.
|
||||
*
|
||||
* `type` is the full SQL type fragment as it would appear in
|
||||
* `CREATE TABLE` after the column name — e.g. `"TEXT NOT NULL"` or
|
||||
* `"TEXT NOT NULL DEFAULT '9999-12-31T23:59:59Z'"`. Kept as a single
|
||||
* string so consumers don't have to decompose constraints; Atlas's
|
||||
* inspector parses it once on the round-trip.
|
||||
*/
|
||||
struct ColumnSpec {
|
||||
const char* name;
|
||||
const char* type;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief One index a decorator contributes to the entity table.
|
||||
*
|
||||
* `nameTemplate` may contain `{table}` — substituted with the live table
|
||||
* name at build time. The decorator-local convention is to prefix
|
||||
* decorator-owned indexes (e.g. `ux_{table}_…`) so multiple decorators
|
||||
* stacking on the same table don't collide.
|
||||
*/
|
||||
struct IndexSpec {
|
||||
const char* nameTemplate;
|
||||
bool unique;
|
||||
const char* columns; ///< e.g. `"(entity_id, valid_until)"`
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief A sidecar table owned by one decorator (e.g. `audit_log`).
|
||||
*
|
||||
* Sidecar tables have fixed names — the placeholder substitution that
|
||||
* applies to entity-table contributions does not apply here. Multiple
|
||||
* decorators referencing the same sidecar (e.g. two repos auditing
|
||||
* through the same `audit_log`) is fine — `SchemaBuilder::create` emits
|
||||
* each sidecar with `CREATE TABLE IF NOT EXISTS`, idempotent.
|
||||
*/
|
||||
struct SidecarTableSpec {
|
||||
const char* name;
|
||||
const ColumnSpec* columns;
|
||||
std::size_t numColumns;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief What one decorator (or concrete repo) contributes to the schema.
|
||||
*
|
||||
* The concrete repo at the bottom of the stack typically contributes
|
||||
* the entity_id + business columns; decorators above contribute their
|
||||
* cross-cutting columns (temporal triple, scope columns, …).
|
||||
*/
|
||||
struct DecoratorSchema {
|
||||
const char* decoratorName;
|
||||
|
||||
const ColumnSpec* entityColumns;
|
||||
std::size_t numEntityColumns;
|
||||
|
||||
const IndexSpec* entityIndexes;
|
||||
std::size_t numEntityIndexes;
|
||||
|
||||
const SidecarTableSpec* sidecarTables;
|
||||
std::size_t numSidecarTables;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Wraps a callable that runs a single SQL statement against the
|
||||
* consumer's database. Decoupled from any specific ORM so authkit stays
|
||||
* portable.
|
||||
*/
|
||||
using SqlExec = std::function<void(const std::string& sql)>;
|
||||
|
||||
/**
|
||||
* @brief Wraps a callable that returns true iff the query yields ≥ 1 row.
|
||||
* Used by `SchemaContract::verify` to introspect column presence.
|
||||
*/
|
||||
using SqlProbe = std::function<bool(const std::string& sql)>;
|
||||
|
||||
/**
|
||||
* @brief Substitutes `{table}` -> `tableName` in `sqlTemplate`.
|
||||
* Pure string replacement — no SQL parsing.
|
||||
*/
|
||||
inline std::string instantiate(const std::string& sqlTemplate,
|
||||
const std::string& tableName) {
|
||||
std::string out(sqlTemplate);
|
||||
static const std::string ph = "{table}";
|
||||
for (std::size_t pos = out.find(ph); pos != std::string::npos;
|
||||
pos = out.find(ph, pos + tableName.size())) {
|
||||
out.replace(pos, ph.size(), tableName);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Thrown by `SchemaContract::verify` when a decorator's required
|
||||
* columns are absent from the live DB. Catchers (typically the consumer's
|
||||
* startup wiring) translate to a fatal startup error.
|
||||
*/
|
||||
class SchemaContractViolation : public std::runtime_error {
|
||||
public:
|
||||
using std::runtime_error::runtime_error;
|
||||
};
|
||||
|
||||
namespace detail {
|
||||
|
||||
inline std::string emitColumnList(const std::vector<ColumnSpec>& cols) {
|
||||
std::string out;
|
||||
bool first = true;
|
||||
for (const auto& c : cols) {
|
||||
if (!first) out += ",\n ";
|
||||
first = false;
|
||||
out += c.name;
|
||||
out += " ";
|
||||
out += c.type;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
inline std::string emitCreateTable(const std::string& tableName,
|
||||
const std::vector<ColumnSpec>& cols) {
|
||||
return "CREATE TABLE IF NOT EXISTS " + tableName + " (\n "
|
||||
+ emitColumnList(cols) + "\n)";
|
||||
}
|
||||
|
||||
inline std::string emitCreateIndex(const std::string& tableName,
|
||||
const IndexSpec& idx) {
|
||||
std::string name = instantiate(idx.nameTemplate, tableName);
|
||||
std::string out = "CREATE ";
|
||||
if (idx.unique) out += "UNIQUE ";
|
||||
out += "INDEX IF NOT EXISTS " + name + " ON " + tableName + " " + idx.columns;
|
||||
return out;
|
||||
}
|
||||
|
||||
inline void appendUniqueColumns(std::vector<ColumnSpec>& acc,
|
||||
std::unordered_set<std::string>& seen,
|
||||
const ColumnSpec* src,
|
||||
std::size_t n) {
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
std::string key(src[i].name ? src[i].name : "");
|
||||
if (seen.insert(key).second) acc.push_back(src[i]);
|
||||
}
|
||||
}
|
||||
|
||||
inline void appendIndexes(std::vector<IndexSpec>& acc,
|
||||
const IndexSpec* src,
|
||||
std::size_t n) {
|
||||
for (std::size_t i = 0; i < n; ++i) acc.push_back(src[i]);
|
||||
}
|
||||
|
||||
inline void emitSidecars(const DecoratorSchema& s, const SqlExec& exec) {
|
||||
for (std::size_t i = 0; i < s.numSidecarTables; ++i) {
|
||||
const auto& t = s.sidecarTables[i];
|
||||
std::vector<ColumnSpec> cols(t.columns, t.columns + t.numColumns);
|
||||
exec(emitCreateTable(t.name, cols));
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace detail
|
||||
|
||||
/**
|
||||
* @brief Composes every decorator's contributions into a single CREATE
|
||||
* per entity table + sidecar CREATEs.
|
||||
*
|
||||
* The parameter pack is the full repository stack, bottom-up. Typical use:
|
||||
*
|
||||
* @code
|
||||
* SchemaBuilder<
|
||||
* ConcretePersonRepository,
|
||||
* TemporalRepository<PersonDto>,
|
||||
* ScopeGuardRepository<PersonDto>,
|
||||
* AuditLogRepository<PersonDto>
|
||||
* >::create("persons", exec);
|
||||
* @endcode
|
||||
*
|
||||
* The builder runs in two passes:
|
||||
*
|
||||
* 1. **Sidecars first**, so any cross-table FK from the entity table
|
||||
* (none today, but future-proofing) can resolve.
|
||||
* 2. **Entity table** with the union of all `entityColumns`. Columns are
|
||||
* deduplicated by name on first appearance; later decorators
|
||||
* contributing the same column name are silently skipped (current
|
||||
* behavior). Indexes are emitted in declaration order.
|
||||
*/
|
||||
template <typename... Decorators>
|
||||
class SchemaBuilder {
|
||||
public:
|
||||
static void create(const std::string& tableName, const SqlExec& exec) {
|
||||
(detail::emitSidecars(Decorators::kSchema, exec), ...);
|
||||
|
||||
std::vector<ColumnSpec> cols;
|
||||
std::unordered_set<std::string> seen;
|
||||
std::vector<IndexSpec> idxs;
|
||||
(detail::appendUniqueColumns(cols, seen,
|
||||
Decorators::kSchema.entityColumns,
|
||||
Decorators::kSchema.numEntityColumns), ...);
|
||||
(detail::appendIndexes(idxs,
|
||||
Decorators::kSchema.entityIndexes,
|
||||
Decorators::kSchema.numEntityIndexes), ...);
|
||||
|
||||
if (!cols.empty()) {
|
||||
exec(detail::emitCreateTable(tableName, cols));
|
||||
}
|
||||
for (const auto& idx : idxs) {
|
||||
exec(detail::emitCreateIndex(tableName, idx));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Runtime introspect-and-assert. On startup, walks the decorator
|
||||
* stack and probes the live DB for every required column. Throws
|
||||
* `SchemaContractViolation` if any are missing — guarantees the running
|
||||
* code can never operate against an under-migrated DB.
|
||||
*
|
||||
* Atlas owns the migration that put the columns there; this only checks
|
||||
* that the migration ran. SQLite-specific probe SQL is used by default
|
||||
* (`pragma_table_info`); consumers on other engines wire their own probe
|
||||
* via the `SqlProbe` callback signature, which receives a fully-formed
|
||||
* `SELECT 1 FROM pragma_table_info('table') WHERE name='col'` query.
|
||||
*/
|
||||
template <typename... Decorators>
|
||||
class SchemaContract {
|
||||
public:
|
||||
static void verify(const std::string& tableName, const SqlProbe& probe) {
|
||||
(verifyOne<Decorators>(tableName, probe), ...);
|
||||
}
|
||||
|
||||
private:
|
||||
template <typename D>
|
||||
static void verifyOne(const std::string& tableName, const SqlProbe& probe) {
|
||||
const auto& s = D::kSchema;
|
||||
for (std::size_t i = 0; i < s.numEntityColumns; ++i) {
|
||||
const char* col = s.entityColumns[i].name;
|
||||
if (!col || !*col) continue;
|
||||
std::string q = "SELECT 1 FROM pragma_table_info('" + tableName +
|
||||
"') WHERE name='" + col + "'";
|
||||
if (!probe(q)) {
|
||||
throw SchemaContractViolation(
|
||||
std::string("schema contract violation: decorator '") +
|
||||
s.decoratorName + "' requires column '" + col +
|
||||
"' on table '" + tableName + "', but it is missing");
|
||||
}
|
||||
}
|
||||
for (std::size_t i = 0; i < s.numSidecarTables; ++i) {
|
||||
const auto& t = s.sidecarTables[i];
|
||||
std::string q = std::string("SELECT 1 FROM sqlite_master WHERE "
|
||||
"type='table' AND name='") + t.name + "'";
|
||||
if (!probe(q)) {
|
||||
throw SchemaContractViolation(
|
||||
std::string("schema contract violation: decorator '") +
|
||||
s.decoratorName + "' requires sidecar table '" + t.name +
|
||||
"', but it is missing");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace oatpp_authkit::repo
|
||||
|
||||
#endif
|
||||
|
|
@ -3,7 +3,8 @@
|
|||
|
||||
#include "oatpp-authkit/repo/Repository.hpp"
|
||||
#include "oatpp-authkit/repo/ActorContext.hpp"
|
||||
#include "oatpp-authkit/repo/Prereq.hpp"
|
||||
#include "oatpp-authkit/repo/SchemaContract.hpp"
|
||||
#include "oatpp-authkit/repo/IQueryable.hpp"
|
||||
|
||||
#include "oatpp/core/Types.hpp"
|
||||
|
||||
|
|
@ -40,34 +41,52 @@ public:
|
|||
* error tradeoff: throwing is the safer default — callers that want to
|
||||
* silently 404 instead can catch and translate.)
|
||||
* - `list()`: load from inner; filter out rows the predicate denies.
|
||||
* - `save(dto)`: predicate evaluated on the incoming dto; deny ⇒ throw.
|
||||
* - `save(dto)`: predicate must pass on the incoming dto AND, for an update,
|
||||
* on the row as it currently stands. Checking only the incoming dto would
|
||||
* let an actor reparent an out-of-scope row into its own scope by setting
|
||||
* the scope field in the request body (BOLA / set-your-own-scope). The
|
||||
* existing-row lookup uses the constructor-injected `entityIdOf` accessor.
|
||||
* - `softDelete(id)`: load from inner; if denied, throw; otherwise delegate.
|
||||
*
|
||||
* The actor is provided via a constructor-injected accessor so a single
|
||||
* `ScopeGuardRepository` instance can serve many requests with different
|
||||
* actors (typically the accessor reads from the per-request authenticated
|
||||
* principal — fewo-webapp's `AuthInterceptor` populates one).
|
||||
*
|
||||
* @note `IQueryable<TDto>` is a *separate* data-access surface. Wrapping an
|
||||
* `IQueryable` repo in this decorator does NOT guard `query()` — a
|
||||
* caller that obtains the inner queryable would bypass the scope
|
||||
* predicate entirely. Use `ScopeGuardQueryable<TDto>` (below) when the
|
||||
* inner exposes the queryable capability.
|
||||
*/
|
||||
template <class TDto>
|
||||
class ScopeGuardRepository : public Repository<TDto> {
|
||||
public:
|
||||
using Predicate = std::function<bool(const ActorContext&, const oatpp::Object<TDto>&)>;
|
||||
using ActorAccess = std::function<ActorContext()>;
|
||||
/// 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>&)>;
|
||||
|
||||
/// Decorator-local migration kit (authkit#12). ScopeGuard touches no
|
||||
/// schema — both PREREQ and RESHAPE_STEPS are empty. Exposed so the
|
||||
/// migration runner can list ScopeGuard alongside other decorators
|
||||
/// without SFINAE.
|
||||
static constexpr const char* DECORATOR_NAME = "ScopeGuardRepository";
|
||||
static constexpr DecoratorPrereq PREREQ = {};
|
||||
static constexpr std::array<ReshapeStep, 0> RESHAPE_STEPS = {};
|
||||
/// Declarative schema contribution (authkit#14, D-replace).
|
||||
/// ScopeGuard touches no schema — empty contributions exposed so it
|
||||
/// composes cleanly into `SchemaBuilder<…>` parameter packs.
|
||||
inline static constexpr DecoratorSchema kSchema = {
|
||||
"ScopeGuardRepository",
|
||||
nullptr, 0,
|
||||
nullptr, 0,
|
||||
nullptr, 0,
|
||||
};
|
||||
|
||||
ScopeGuardRepository(std::shared_ptr<Repository<TDto>> inner,
|
||||
Predicate isAllowed,
|
||||
ActorAccess currentActor)
|
||||
ActorAccess currentActor,
|
||||
EntityIdAccess entityIdOf)
|
||||
: m_inner(std::move(inner))
|
||||
, m_isAllowed(std::move(isAllowed))
|
||||
, m_currentActor(std::move(currentActor))
|
||||
, m_entityIdOf(std::move(entityIdOf))
|
||||
{}
|
||||
|
||||
oatpp::Object<TDto> findByEntityId(const oatpp::String& entityId) override {
|
||||
|
|
@ -90,8 +109,25 @@ public:
|
|||
}
|
||||
|
||||
void save(const oatpp::Object<TDto>& dto) override {
|
||||
if (!m_isAllowed(m_currentActor(), dto)) {
|
||||
throw ScopeDeniedException("scope guard denied save");
|
||||
const ActorContext actor = m_currentActor();
|
||||
// 1. The incoming DTO must be in scope — you can't write into a scope
|
||||
// you don't own.
|
||||
if (!m_isAllowed(actor, dto)) {
|
||||
throw ScopeDeniedException("scope guard denied save (incoming)");
|
||||
}
|
||||
// 2. If this is an update of an existing entity, the row as it stands
|
||||
// NOW must also be in scope. Otherwise an actor scoped to A could
|
||||
// take an entity currently owned by B and reparent it into A simply
|
||||
// by setting the scope field in the body. A fresh insert has a null
|
||||
// entity_id (or no matching row) and skips this check.
|
||||
if (m_entityIdOf) {
|
||||
auto eid = m_entityIdOf(dto);
|
||||
if (eid) {
|
||||
auto existing = m_inner->findByEntityId(eid);
|
||||
if (existing && !m_isAllowed(actor, existing)) {
|
||||
throw ScopeDeniedException("scope guard denied save (existing row out of scope)");
|
||||
}
|
||||
}
|
||||
}
|
||||
m_inner->save(dto);
|
||||
}
|
||||
|
|
@ -109,6 +145,64 @@ private:
|
|||
std::shared_ptr<Repository<TDto>> m_inner;
|
||||
Predicate m_isAllowed;
|
||||
ActorAccess m_currentActor;
|
||||
EntityIdAccess m_entityIdOf;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief `ScopeGuardRepository` for inners that also expose `IQueryable<TDto>`.
|
||||
*
|
||||
* `ScopeGuardRepository` guards only the four `Repository<TDto>` methods; the
|
||||
* `IQueryable::query()` surface is separate, so a scope-guarded `IQueryable`
|
||||
* repo would otherwise leak every row a raw query returns. This decorator
|
||||
* closes that hole: it implements `IQueryable<TDto>`, delegates the CRUD
|
||||
* methods to an embedded `ScopeGuardRepository` (same predicate / actor /
|
||||
* entity-id semantics, including the reparenting check), and post-filters
|
||||
* `query()` results through the predicate exactly like `list()` does.
|
||||
*
|
||||
* Wire this — not the plain `ScopeGuardRepository` — whenever the concrete
|
||||
* inner derives from `IQueryable<TDto>`.
|
||||
*/
|
||||
template <class TDto>
|
||||
class ScopeGuardQueryable : public IQueryable<TDto> {
|
||||
public:
|
||||
using Predicate = typename ScopeGuardRepository<TDto>::Predicate;
|
||||
using ActorAccess = typename ScopeGuardRepository<TDto>::ActorAccess;
|
||||
using EntityIdAccess = typename ScopeGuardRepository<TDto>::EntityIdAccess;
|
||||
|
||||
ScopeGuardQueryable(std::shared_ptr<IQueryable<TDto>> inner,
|
||||
Predicate isAllowed,
|
||||
ActorAccess currentActor,
|
||||
EntityIdAccess entityIdOf)
|
||||
: m_inner(std::move(inner))
|
||||
, m_isAllowed(isAllowed)
|
||||
, m_currentActor(currentActor)
|
||||
, m_guard(m_inner, std::move(isAllowed), std::move(currentActor), std::move(entityIdOf))
|
||||
{}
|
||||
|
||||
oatpp::Object<TDto> findByEntityId(const oatpp::String& entityId) override {
|
||||
return m_guard.findByEntityId(entityId);
|
||||
}
|
||||
oatpp::Vector<oatpp::Object<TDto>> list() override { return m_guard.list(); }
|
||||
void save(const oatpp::Object<TDto>& dto) override { m_guard.save(dto); }
|
||||
void softDelete(const oatpp::String& entityId) override { m_guard.softDelete(entityId); }
|
||||
|
||||
/** @brief Run the inner query, then drop every row the predicate denies. */
|
||||
oatpp::Vector<oatpp::Object<TDto>> query(const Query<TDto>& q) override {
|
||||
auto rows = m_inner->query(q);
|
||||
auto out = oatpp::Vector<oatpp::Object<TDto>>::createShared();
|
||||
if (!rows) return out;
|
||||
const ActorContext actor = m_currentActor();
|
||||
for (auto& row : *rows) {
|
||||
if (m_isAllowed(actor, row)) out->push_back(row);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private:
|
||||
std::shared_ptr<IQueryable<TDto>> m_inner;
|
||||
Predicate m_isAllowed;
|
||||
ActorAccess m_currentActor;
|
||||
ScopeGuardRepository<TDto> m_guard;
|
||||
};
|
||||
|
||||
} // namespace oatpp_authkit::repo
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ namespace oatpp_authkit::repo {
|
|||
|
||||
/**
|
||||
* @brief Trait that tells `TemporalRepository<T>` where `T` keeps its
|
||||
* identity, valid_from and valid_until columns.
|
||||
* row PK, entity identity, valid_from and valid_until columns.
|
||||
*
|
||||
* Primary template is intentionally undefined — using
|
||||
* `TemporalFieldTraits<MyDto>` against a DTO that hasn't been registered
|
||||
|
|
@ -15,9 +15,19 @@ namespace oatpp_authkit::repo {
|
|||
* `OATPP_AUTHKIT_REGISTER_TEMPORAL` once per temporal DTO.
|
||||
*
|
||||
* Each accessor returns `oatpp::String&` so the repository can both read
|
||||
* and rewrite the value (closing a prior version sets `valid_until` on a
|
||||
* loaded row). Field names on the DTO are arbitrary — the trait is the
|
||||
* canonical name, the DTO column is whatever the consumer picked.
|
||||
* and rewrite the value. Field names on the DTO are arbitrary — the
|
||||
* trait is the canonical name, the DTO column is whatever the consumer
|
||||
* picked.
|
||||
*
|
||||
* Four canonical fields:
|
||||
* - `id` : per-row PK (version UUID). Preserved across in-place
|
||||
* updates of the live row; freshly allocated for each
|
||||
* historical copy.
|
||||
* - `entityId` : stable logical identity, shared by every version of
|
||||
* the same logical entity.
|
||||
* - `validFrom` : ISO-8601 timestamp when this version became effective.
|
||||
* - `validUntil` : ISO-8601 timestamp when this version ceased to be
|
||||
* effective; SENTINEL while live.
|
||||
*/
|
||||
template <class TDto>
|
||||
struct TemporalFieldTraits; // intentionally undefined
|
||||
|
|
@ -29,16 +39,20 @@ struct TemporalFieldTraits; // intentionally undefined
|
|||
* scope (typically right after the DTO definition):
|
||||
*
|
||||
* OATPP_AUTHKIT_REGISTER_TEMPORAL(PersonDto,
|
||||
* entity_id, valid_from, valid_until)
|
||||
* id, entity_id, valid_from, valid_until)
|
||||
*
|
||||
* The three trailing identifiers are the actual DTO_FIELD member names —
|
||||
* The four trailing identifiers are the actual DTO_FIELD member names —
|
||||
* they don't have to match the canonical names. A DTO that uses
|
||||
* `effective_from` / `effective_until` registers exactly the same way.
|
||||
*
|
||||
* `IdMember` is the per-row PK. `EntityIdMember` is the stable logical
|
||||
* identity that's shared by every version of the same entity.
|
||||
*/
|
||||
#define OATPP_AUTHKIT_REGISTER_TEMPORAL(Dto, IdMember, FromMember, UntilMember) \
|
||||
#define OATPP_AUTHKIT_REGISTER_TEMPORAL(Dto, IdMember, EntityIdMember, FromMember, UntilMember) \
|
||||
namespace oatpp_authkit::repo { \
|
||||
template<> struct TemporalFieldTraits<Dto> { \
|
||||
static ::oatpp::String& entityId (const ::oatpp::Object<Dto>& d) { return d->IdMember; } \
|
||||
static ::oatpp::String& 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; } \
|
||||
}; \
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
#include "oatpp-authkit/repo/IHistoryRepository.hpp"
|
||||
#include "oatpp-authkit/repo/TemporalFieldTraits.hpp"
|
||||
#include "oatpp-authkit/repo/TemporalAt.hpp"
|
||||
#include "oatpp-authkit/repo/Prereq.hpp"
|
||||
#include "oatpp-authkit/repo/SchemaContract.hpp"
|
||||
|
||||
#include "oatpp/core/Types.hpp"
|
||||
|
||||
|
|
@ -14,6 +14,7 @@
|
|||
#include <cstdio>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <random>
|
||||
#include <string>
|
||||
#include <type_traits>
|
||||
|
|
@ -36,9 +37,9 @@ namespace oatpp_authkit::repo {
|
|||
*
|
||||
* The wrapped inner `Repository<TDto>` is expected to:
|
||||
*
|
||||
* - Treat `save(dto)` as **upsert keyed by (entity_id, valid_from)**. New
|
||||
* `valid_from` ⇒ insert a new row. Existing `valid_from` ⇒ update the row
|
||||
* (this is how `save(closedPrior)` closes a prior version).
|
||||
* - Treat `save(dto)` as **upsert keyed by `id`** (the per-row PK). If
|
||||
* `dto->id` matches an existing row, UPDATE it in place. Otherwise
|
||||
* INSERT a new row.
|
||||
* - Treat `list()` as **all rows including historical ones** — no filtering
|
||||
* by `valid_until`. This decorator does the live-vs-historical filtering
|
||||
* itself.
|
||||
|
|
@ -48,16 +49,33 @@ namespace oatpp_authkit::repo {
|
|||
*
|
||||
* @section semantics Decorator semantics
|
||||
*
|
||||
* - `save(dto)`: if `dto->entity_id` is null, allocate one. Look up the
|
||||
* currently live row for that entity id; if present, copy it, set its
|
||||
* `valid_until = now`, and `save` it (closes the old version). Then set
|
||||
* the new dto's `valid_from = now`, `valid_until = SENTINEL`, and `save` it.
|
||||
* Write semantics (authkit#13): **stable-live row + historical copy.**
|
||||
* The live row's `id` PK is preserved across every update; only its
|
||||
* mutable columns (and `valid_from`) change. Each prior version is
|
||||
* captured as a fresh row with a new `id`.
|
||||
*
|
||||
* - `save(dto)`:
|
||||
* - If no live row exists for `dto->entity_id` (or `entity_id` is null),
|
||||
* this is a fresh insert: allocate `entity_id` if null, set `id` if
|
||||
* null, `valid_from = now`, `valid_until = SENTINEL`, save.
|
||||
* - Otherwise:
|
||||
* 1. Clone the existing live row in memory (`b`). Give `b` a fresh `id`
|
||||
* and set `b.valid_until = now()`. Save `b` — it's the historical copy.
|
||||
* 2. Set `dto.id = liveRow.id` (preserve the live PK). Set
|
||||
* `dto.valid_from = now()`, `dto.valid_until = SENTINEL`. Save `dto`
|
||||
* — the inner UPDATEs the live row in place by PK.
|
||||
*
|
||||
* FK consequence: child rows referencing the live row via the composite
|
||||
* key `(entity_id, valid_until)` continue to resolve to the same row
|
||||
* identity throughout the operation; no FK deferral required.
|
||||
*
|
||||
* - `findByEntityId(id)` returns the row whose `valid_until == SENTINEL`.
|
||||
* - `findByEntityIdAt(id, at)` returns the version live at that timestamp.
|
||||
* - `list()` returns only live rows.
|
||||
* - `history(id)` returns all versions ordered ascending by `valid_from`.
|
||||
* - `softDelete(id)` closes the live row (sets its `valid_until = now`) but
|
||||
* does not insert a new version.
|
||||
* - `softDelete(id)` closes the live row (sets its `valid_until = now`)
|
||||
* but does not insert a new version. With `ON UPDATE CASCADE` on every
|
||||
* composite child FK, child rows follow automatically.
|
||||
*/
|
||||
template <class TDto>
|
||||
class TemporalRepository
|
||||
|
|
@ -72,52 +90,51 @@ public:
|
|||
*/
|
||||
static constexpr const char* SENTINEL = "9999-12-31T23:59:59Z";
|
||||
|
||||
/// Decorator-local migration kit (authkit#12).
|
||||
/// Composite-FK temporal schema: enforces uniqueness on (entity_id,
|
||||
/// valid_until) so close-then-insert can run inside a transaction.
|
||||
static constexpr const char* DECORATOR_NAME = "TemporalRepository";
|
||||
static constexpr DecoratorPrereq PREREQ = {};
|
||||
static constexpr std::array<ReshapeStep, 4> RESHAPE_STEPS = {{
|
||||
{"add_valid_from",
|
||||
"SELECT 1 FROM pragma_table_info('{table}') WHERE name='valid_from'",
|
||||
"ALTER TABLE {table} ADD COLUMN valid_from TEXT NOT NULL DEFAULT ''"},
|
||||
{"add_valid_until",
|
||||
"SELECT 1 FROM pragma_table_info('{table}') WHERE name='valid_until'",
|
||||
"ALTER TABLE {table} ADD COLUMN valid_until TEXT NOT NULL DEFAULT '9999-12-31T23:59:59Z'"},
|
||||
{"drop_unique_entity_id",
|
||||
// Detect that no plain UNIQUE(entity_id) index remains. Whether
|
||||
// one was ever there is consumer-specific — common case is the
|
||||
// index was auto-named "sqlite_autoindex_<table>_1" by SQLite
|
||||
// for an inline `entity_id TEXT UNIQUE`. Detect by checking that
|
||||
// no index named `ux_{table}_entity_only` exists *and* that the
|
||||
// composite index (next step) hasn't been created yet — once the
|
||||
// composite is in place this step's detect probe must pass too.
|
||||
"SELECT 1 FROM sqlite_master WHERE type='index' AND tbl_name='{table}' AND name='ux_{table}_entity_valid_until'",
|
||||
// No-op apply; reshape is owned by the consumer's schema. The
|
||||
// step exists as a hook for consumers that want to drop a
|
||||
// legacy unique index before composite_unique runs. Override at
|
||||
// schema-load time if needed; default is noop on systems where
|
||||
// the original schema didn't carry a UNIQUE(entity_id).
|
||||
"SELECT 1"},
|
||||
{"composite_unique",
|
||||
"SELECT 1 FROM sqlite_master WHERE type='index' AND name='ux_{table}_entity_valid_until'",
|
||||
"CREATE UNIQUE INDEX ux_{table}_entity_valid_until ON {table}(entity_id, valid_until)"}
|
||||
}};
|
||||
/// Declarative schema contribution (authkit#14, D-replace).
|
||||
/// Atlas owns evolution between deploys; this declares what the
|
||||
/// decorator needs the live entity table to look like. The composite
|
||||
/// UNIQUE index makes close-then-insert safe inside a transaction.
|
||||
inline static constexpr ColumnSpec kEntityColumns[] = {
|
||||
{"valid_from", "TEXT NOT NULL DEFAULT ''"},
|
||||
{"valid_until", "TEXT NOT NULL DEFAULT '9999-12-31T23:59:59Z'"},
|
||||
};
|
||||
inline static constexpr IndexSpec kEntityIndexes[] = {
|
||||
{"ux_{table}_entity_valid_until", true, "(entity_id, valid_until)"},
|
||||
};
|
||||
inline static constexpr DecoratorSchema kSchema = {
|
||||
"TemporalRepository",
|
||||
kEntityColumns, sizeof(kEntityColumns) / sizeof(kEntityColumns[0]),
|
||||
kEntityIndexes, sizeof(kEntityIndexes) / sizeof(kEntityIndexes[0]),
|
||||
nullptr, 0,
|
||||
};
|
||||
|
||||
using Clock = std::function<int64_t()>; ///< Returns milliseconds since epoch.
|
||||
using IdGen = std::function<oatpp::String()>;
|
||||
/// Runs a unit of work, ideally inside a DB transaction so the historical
|
||||
/// insert + live update commit or roll back together. The default just
|
||||
/// invokes the callback inline (no cross-statement atomicity); consumers
|
||||
/// that have a connection/transaction handle should pass a runner that
|
||||
/// wraps the callback in `BEGIN … COMMIT` / `ROLLBACK`.
|
||||
using TxRunner = std::function<void(const std::function<void()>&)>;
|
||||
|
||||
/**
|
||||
* @param inner Concrete adapter that exposes all-rows-including-historical.
|
||||
* @param clock Optional injected clock for tests; default uses system_clock.
|
||||
* @param idgen Optional injected id generator for tests; default is a 32-char hex from mt19937_64.
|
||||
* @param txRunner Optional transaction wrapper for the close-then-insert
|
||||
* write pair; default runs the writes inline. A per-instance mutex
|
||||
* already serialises the read-modify-write within this process so
|
||||
* concurrent saves of the same entity can't produce two live rows;
|
||||
* supply a real transaction runner for crash/rollback atomicity.
|
||||
*/
|
||||
explicit TemporalRepository(std::shared_ptr<Repository<TDto>> inner,
|
||||
Clock clock = {},
|
||||
IdGen idgen = {})
|
||||
IdGen idgen = {},
|
||||
TxRunner txRunner = {})
|
||||
: m_inner(std::move(inner))
|
||||
, m_clock(clock ? std::move(clock) : defaultClock())
|
||||
, m_idgen(idgen ? std::move(idgen) : defaultIdGen())
|
||||
, m_runTx(txRunner ? std::move(txRunner) : defaultTxRunner())
|
||||
{}
|
||||
|
||||
using F = TemporalFieldTraits<TDto>;
|
||||
|
|
@ -170,31 +187,52 @@ public:
|
|||
}
|
||||
|
||||
/**
|
||||
* Close the previous live version (if any) and insert a new live row.
|
||||
* Mutates `dto` in place to fill in `entity_id` (if null), `valid_from`,
|
||||
* and `valid_until`.
|
||||
* Save a new dto. Stable-live + historical-copy semantics: the live
|
||||
* row's `id` PK is preserved across updates; the prior version is
|
||||
* captured as a fresh row with a new `id`. See class doc for details.
|
||||
*/
|
||||
void save(const oatpp::Object<TDto>& dto) override {
|
||||
if (!F::entityId(dto)) F::entityId(dto) = m_idgen();
|
||||
|
||||
// Serialise the read-modify-write so two concurrent saves of the same
|
||||
// entity can't both observe the same live row and each insert a new
|
||||
// SENTINEL row (lost update / two live rows). In-process guard only;
|
||||
// see TxRunner for cross-statement / crash atomicity.
|
||||
std::lock_guard<std::mutex> lock(m_writeMutex);
|
||||
|
||||
const int64_t nowMs = m_clock();
|
||||
const std::string nowIso = isoFromMillis(nowMs);
|
||||
|
||||
// Close the existing live version (if any).
|
||||
auto live = findByEntityId(F::entityId(dto));
|
||||
if (live) {
|
||||
F::validUntil(live) = oatpp::String(nowIso);
|
||||
m_inner->save(live);
|
||||
}
|
||||
|
||||
// Insert the new live version.
|
||||
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()));
|
||||
|
|
@ -234,12 +272,38 @@ private:
|
|||
};
|
||||
}
|
||||
|
||||
static TxRunner defaultTxRunner() {
|
||||
return [](const std::function<void()>& work) { work(); };
|
||||
}
|
||||
|
||||
/// Field-wise deep copy via oatpp's DTO reflection. Used to capture
|
||||
/// the live row's content as the historical copy before the live row
|
||||
/// is updated in place.
|
||||
static oatpp::Object<TDto> cloneDto(const oatpp::Object<TDto>& src) {
|
||||
auto dst = TDto::createShared();
|
||||
const auto* dispatcher = static_cast<
|
||||
const oatpp::data::mapping::type::__class::AbstractObject::PolymorphicDispatcher*>(
|
||||
oatpp::Object<TDto>::Class::getType()->polymorphicDispatcher);
|
||||
for (auto* p : dispatcher->getProperties()->getList()) {
|
||||
p->set(static_cast<oatpp::BaseObject*>(dst.get()),
|
||||
p->get(static_cast<oatpp::BaseObject*>(src.get())));
|
||||
}
|
||||
return dst;
|
||||
}
|
||||
|
||||
static IdGen defaultIdGen() {
|
||||
return [] {
|
||||
static thread_local std::mt19937_64 rng{std::random_device{}()};
|
||||
// authkit#16 L-5: draw 128 bits straight from the platform CSPRNG
|
||||
// (std::random_device → getrandom()/urandom on Linux) on every
|
||||
// call. The old code seeded a mt19937_64 once from a single
|
||||
// random_device sample, making the whole id stream predictable from
|
||||
// observed outputs — a problem if a consumer ever treats entity_id
|
||||
// as an unguessable handle. Consumers needing a hard guarantee can
|
||||
// still inject their own IdGen.
|
||||
static thread_local std::random_device rd;
|
||||
char buf[33];
|
||||
std::snprintf(buf, sizeof(buf), "%016llx%016llx",
|
||||
(unsigned long long)rng(), (unsigned long long)rng());
|
||||
std::snprintf(buf, sizeof(buf), "%08x%08x%08x%08x",
|
||||
(unsigned)rd(), (unsigned)rd(), (unsigned)rd(), (unsigned)rd());
|
||||
return oatpp::String(buf);
|
||||
};
|
||||
}
|
||||
|
|
@ -259,6 +323,8 @@ private:
|
|||
std::shared_ptr<Repository<TDto>> m_inner;
|
||||
Clock m_clock;
|
||||
IdGen m_idgen;
|
||||
TxRunner m_runTx;
|
||||
std::mutex m_writeMutex;
|
||||
};
|
||||
|
||||
} // namespace oatpp_authkit::repo
|
||||
|
|
|
|||
36
include/oatpp-authkit/util/ConstantTime.hpp
Normal file
36
include/oatpp-authkit/util/ConstantTime.hpp
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
#ifndef OATPP_AUTHKIT_UTIL_CONSTANT_TIME_HPP
|
||||
#define OATPP_AUTHKIT_UTIL_CONSTANT_TIME_HPP
|
||||
|
||||
// Constant-time comparison (authkit#16 L-7).
|
||||
//
|
||||
// The interceptor looks tokens up by hash in the store (effectively
|
||||
// constant-time via an indexed equality), so it doesn't need this. But a
|
||||
// consumer that ever compares a secret (token, HMAC, hash) in memory must not
|
||||
// use std::string::operator== / memcmp — those short-circuit on the first
|
||||
// mismatching byte and leak, via timing, how much of the secret was guessed.
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
|
||||
namespace oatpp_authkit {
|
||||
|
||||
/**
|
||||
* @brief Compare two byte strings without an early-exit on the first
|
||||
* differing byte. The length difference is folded into the result, so
|
||||
* unequal-length inputs still take time proportional to the longer one
|
||||
* (the length of a fixed-size hash/token is not itself secret).
|
||||
*/
|
||||
inline bool constantTimeEquals(const std::string& a, const std::string& b) {
|
||||
const std::size_t n = a.size() > b.size() ? a.size() : b.size();
|
||||
volatile unsigned char diff = static_cast<unsigned char>(a.size() ^ b.size());
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
const unsigned char ca = (i < a.size()) ? static_cast<unsigned char>(a[i]) : 0;
|
||||
const unsigned char cb = (i < b.size()) ? static_cast<unsigned char>(b[i]) : 0;
|
||||
diff = static_cast<unsigned char>(diff | (ca ^ cb));
|
||||
}
|
||||
return diff == 0;
|
||||
}
|
||||
|
||||
} // namespace oatpp_authkit
|
||||
|
||||
#endif
|
||||
73
include/oatpp-authkit/util/OriginCheck.hpp
Normal file
73
include/oatpp-authkit/util/OriginCheck.hpp
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
#ifndef OATPP_AUTHKIT_UTIL_ORIGIN_CHECK_HPP
|
||||
#define OATPP_AUTHKIT_UTIL_ORIGIN_CHECK_HPP
|
||||
|
||||
// Origin / Referer validation helpers (authkit#16 M-4, M-10).
|
||||
//
|
||||
// Pure, dependency-free string helpers for CSRF defence-in-depth and for
|
||||
// WebSocket Cross-Site-WebSocket-Hijacking (CSWSH) protection. The library
|
||||
// can't enforce these everywhere — the WS upgrade decision lives in the
|
||||
// consumer's WSController — so these primitives let consumers do the check
|
||||
// at the right point, and `AuthInterceptor` uses them for session mutations.
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace oatpp_authkit {
|
||||
|
||||
/**
|
||||
* @brief Extract the lowercased hostname from an `Origin` / `Referer` value or
|
||||
* a `Host` header. Strips scheme, port, path and query.
|
||||
*
|
||||
* "https://app.example.com:8443/x?y" → "app.example.com"
|
||||
* "app.example.com:443" → "app.example.com"
|
||||
*/
|
||||
inline std::string originHostname(const std::string& v) {
|
||||
std::string s = v;
|
||||
auto scheme = s.find("://");
|
||||
if (scheme != std::string::npos) s = s.substr(scheme + 3);
|
||||
auto slash = s.find('/');
|
||||
if (slash != std::string::npos) s = s.substr(0, slash);
|
||||
auto colon = s.find(':');
|
||||
if (colon != std::string::npos) s = s.substr(0, colon);
|
||||
std::transform(s.begin(), s.end(), s.begin(),
|
||||
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Same-origin check by hostname: the `Origin` (or `Referer`) host must
|
||||
* equal the request `Host` host. Port/scheme are intentionally ignored
|
||||
* to avoid false positives behind TLS-terminating reverse proxies
|
||||
* (Origin omits the default port; Host may or may not carry one) — a
|
||||
* cross-*host* request is the unambiguous CSRF/CSWSH signal.
|
||||
*
|
||||
* Returns `true` (don't block) when either input is empty — the caller can't
|
||||
* decide and should fall back to another control (e.g. `X-Requested-With`).
|
||||
*/
|
||||
inline bool sameOrigin(const std::string& originOrReferer, const std::string& hostHeader) {
|
||||
if (originOrReferer.empty() || hostHeader.empty()) return true;
|
||||
return originHostname(originOrReferer) == originHostname(hostHeader);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Allowlist check: the `Origin` host must be one of `allowedHosts`
|
||||
* (each compared by hostname via `originHostname`). Use for WS upgrades
|
||||
* when the allowed origins aren't simply "same host as the request".
|
||||
*
|
||||
* Returns `false` for an empty / unparseable origin — i.e. fail closed.
|
||||
*/
|
||||
inline bool originAllowed(const std::string& origin, const std::vector<std::string>& allowedHosts) {
|
||||
if (origin.empty()) return false;
|
||||
const std::string h = originHostname(origin);
|
||||
if (h.empty()) return false;
|
||||
for (const auto& a : allowedHosts) {
|
||||
if (originHostname(a) == h) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace oatpp_authkit
|
||||
|
||||
#endif
|
||||
|
|
@ -2,7 +2,9 @@
|
|||
#define UTIL_RATE_LIMITER_HPP
|
||||
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <mutex>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
|
|
@ -25,11 +27,22 @@ namespace oatpp_authkit {
|
|||
class RateLimiter {
|
||||
public:
|
||||
/**
|
||||
* @param capacity Maximum burst size (tokens).
|
||||
* @param refillRate Tokens added per second.
|
||||
* @param capacity Maximum burst size (tokens). Must be finite and >= 1.
|
||||
* @param refillRate Tokens added per second. Must be finite and > 0.
|
||||
*
|
||||
* @throws std::invalid_argument on non-finite / out-of-range values
|
||||
* (authkit#16 M-7). A zero/negative `refillRate` previously made
|
||||
* every bucket evict on each sweep (limiter silently disabled →
|
||||
* brute-force bypass), and NaN made `allow()` reject everything
|
||||
* (DoS). Fail loud at construction instead.
|
||||
*/
|
||||
RateLimiter(double capacity, double refillRate)
|
||||
: m_capacity(capacity), m_refillRate(refillRate) {}
|
||||
: m_capacity(capacity), m_refillRate(refillRate) {
|
||||
if (!std::isfinite(capacity) || capacity < 1.0)
|
||||
throw std::invalid_argument("RateLimiter: capacity must be finite and >= 1");
|
||||
if (!std::isfinite(refillRate) || refillRate <= 0.0)
|
||||
throw std::invalid_argument("RateLimiter: refillRate must be finite and > 0");
|
||||
}
|
||||
|
||||
/** @brief Try to consume one token for the given key. Returns true if allowed. */
|
||||
bool allow(const std::string& key) {
|
||||
|
|
|
|||
65
include/oatpp-authkit/util/SessionCookie.hpp
Normal file
65
include/oatpp-authkit/util/SessionCookie.hpp
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
#ifndef OATPP_AUTHKIT_UTIL_SESSION_COOKIE_HPP
|
||||
#define OATPP_AUTHKIT_UTIL_SESSION_COOKIE_HPP
|
||||
|
||||
// Safe-by-default Set-Cookie builder for session tokens (authkit#16 M-9).
|
||||
//
|
||||
// The library reads the session cookie (util/TokenExtract.hpp) but previously
|
||||
// shipped no helper to *write* it, so every consumer hand-rolled `Set-Cookie`
|
||||
// and the security attributes (HttpOnly / Secure / SameSite) were easy to
|
||||
// forget. This builder defaults to the hardened set; opt OUT explicitly.
|
||||
//
|
||||
// Returns the header *value* only (decoupled from any HTTP framework) — the
|
||||
// consumer sets it via e.g. `response->putHeader("Set-Cookie", value)`.
|
||||
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
namespace oatpp_authkit {
|
||||
|
||||
/** @brief Set-Cookie attributes. Defaults are the hardened set. */
|
||||
struct SessionCookieOptions {
|
||||
std::string name = "session"; ///< Cookie name. For `__Host-` prefix guarantees, set "__Host-session" (requires secure=true, path="/", no domain).
|
||||
bool httpOnly = true; ///< Block JS access (document.cookie).
|
||||
bool secure = true; ///< HTTPS-only. Leave true in prod; only disable for plaintext dev.
|
||||
std::string sameSite = "Strict"; ///< "Strict" | "Lax" | "None" (""=omit). "None" requires secure=true per spec.
|
||||
std::string path = "/";
|
||||
long maxAgeSeconds = -1; ///< <0 ⇒ session cookie (no Max-Age); 0 ⇒ expire now (clear).
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Build a `Set-Cookie` header value for a session token.
|
||||
* @throws std::invalid_argument if `token`, `name` or `path` contain control
|
||||
* characters / `;` (header/cookie-injection guard).
|
||||
*/
|
||||
inline std::string buildSetSessionCookie(const std::string& token,
|
||||
const SessionCookieOptions& opt = {}) {
|
||||
auto reject = [](const std::string& s) {
|
||||
for (unsigned char c : s)
|
||||
if (c < 0x20 || c == 0x7f || c == ';') return true;
|
||||
return false;
|
||||
};
|
||||
if (reject(token) || reject(opt.name) || reject(opt.path))
|
||||
throw std::invalid_argument("buildSetSessionCookie: control char / ';' in cookie field");
|
||||
|
||||
std::string c = opt.name + "=" + token;
|
||||
if (!opt.path.empty()) c += "; Path=" + opt.path;
|
||||
if (opt.maxAgeSeconds >= 0) c += "; Max-Age=" + std::to_string(opt.maxAgeSeconds);
|
||||
if (opt.httpOnly) c += "; HttpOnly";
|
||||
if (opt.secure) c += "; Secure";
|
||||
if (!opt.sameSite.empty()) c += "; SameSite=" + opt.sameSite;
|
||||
return c;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Build a `Set-Cookie` value that clears the session cookie (logout).
|
||||
* Same attributes as the original so the browser matches and removes it.
|
||||
*/
|
||||
inline std::string buildClearSessionCookie(const SessionCookieOptions& opt = {}) {
|
||||
SessionCookieOptions o = opt;
|
||||
o.maxAgeSeconds = 0;
|
||||
return buildSetSessionCookie("", o);
|
||||
}
|
||||
|
||||
} // namespace oatpp_authkit
|
||||
|
||||
#endif
|
||||
|
|
@ -9,6 +9,44 @@ namespace oatpp_authkit {
|
|||
|
||||
using IncomingRequest = oatpp::web::protocol::http::incoming::Request;
|
||||
|
||||
/**
|
||||
* @brief Read the value of an exact-named cookie from a `Cookie` header.
|
||||
*
|
||||
* Splits the header on `;`, trims optional whitespace, and matches the cookie
|
||||
* *name* exactly. A naive `header.find("name=")` substring search would also
|
||||
* match `xname=`, `my_name=`, `notname=` etc. and latch onto the first hit —
|
||||
* so an attacker who can plant a sibling cookie (subdomain / less-trusted
|
||||
* same-site host) could shadow the real one, defeating the `__Host-`/
|
||||
* `__Secure-` prefix guarantees the session cookie may rely on. Pure and
|
||||
* side-effect-free so the parsing is unit-testable without a request.
|
||||
*
|
||||
* @return the cookie value (whitespace-trimmed), or "" if not present.
|
||||
*/
|
||||
inline std::string cookieValue(const std::string& cookieHeader, const std::string& name) {
|
||||
std::size_t i = 0;
|
||||
const std::size_t n = cookieHeader.size();
|
||||
while (i < n) {
|
||||
std::size_t semi = cookieHeader.find(';', i);
|
||||
std::size_t end = (semi == std::string::npos) ? n : semi;
|
||||
std::size_t b = i;
|
||||
while (b < end && (cookieHeader[b] == ' ' || cookieHeader[b] == '\t')) ++b;
|
||||
std::size_t eq = cookieHeader.find('=', b);
|
||||
if (eq != std::string::npos && eq < end) {
|
||||
std::string key = cookieHeader.substr(b, eq - b);
|
||||
while (!key.empty() && (key.back() == ' ' || key.back() == '\t')) key.pop_back();
|
||||
if (key == name) {
|
||||
std::size_t vb = eq + 1, ve = end;
|
||||
while (vb < ve && (cookieHeader[vb] == ' ' || cookieHeader[vb] == '\t')) ++vb;
|
||||
while (ve > vb && (cookieHeader[ve - 1] == ' ' || cookieHeader[ve - 1] == '\t')) --ve;
|
||||
return cookieHeader.substr(vb, ve - vb);
|
||||
}
|
||||
}
|
||||
if (semi == std::string::npos) break;
|
||||
i = semi + 1;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Pull the session token from an incoming request.
|
||||
*
|
||||
|
|
@ -19,13 +57,8 @@ using IncomingRequest = oatpp::web::protocol::http::incoming::Request;
|
|||
inline std::string extractToken(const std::shared_ptr<IncomingRequest>& request) {
|
||||
auto cookie = request->getHeader("Cookie");
|
||||
if (cookie && !cookie->empty()) {
|
||||
const std::string& c = *cookie;
|
||||
auto pos = c.find("session=");
|
||||
if (pos != std::string::npos) {
|
||||
pos += 8;
|
||||
auto end = c.find(';', pos);
|
||||
return end == std::string::npos ? c.substr(pos) : c.substr(pos, end - pos);
|
||||
}
|
||||
std::string tok = cookieValue(*cookie, "session");
|
||||
if (!tok.empty()) return tok;
|
||||
}
|
||||
auto auth = request->getHeader("Authorization");
|
||||
if (auth && !auth->empty()) {
|
||||
|
|
@ -56,6 +89,16 @@ inline bool isValidIp(const std::string& s) {
|
|||
*
|
||||
* The `bindAddress` argument carries the host the service is listening on;
|
||||
* pass your runtime config value here.
|
||||
*
|
||||
* @warning Rate-limiting note (authkit#16 M-8): when the service is NOT
|
||||
* loopback-bound (no trusted ingress proxy), or the proxy omits
|
||||
* `X-Forwarded-For`/`X-Real-IP`, this returns the constant sentinel
|
||||
* `"unknown"` (or `"invalid"`) for *every* caller — so a per-IP rate
|
||||
* limiter keyed on it collapses to a single shared bucket and per-IP
|
||||
* brute-force throttling stops isolating attackers. Deploy
|
||||
* loopback-bound behind a proxy that sets `X-Forwarded-For`; treat
|
||||
* `"unknown"`/`"invalid"` as one anonymous bucket and size that
|
||||
* limit conservatively.
|
||||
*/
|
||||
inline std::string clientIpTrusted(
|
||||
const std::shared_ptr<IncomingRequest>& req,
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ struct SocketInfo {
|
|||
std::string userId;
|
||||
std::string username;
|
||||
std::string role;
|
||||
std::set<std::string> propertyIds; ///< Empty = all (admin or no restrictions).
|
||||
std::set<std::string> propertyIds; ///< Properties this socket may receive scoped events for. Empty = NONE for non-admins (admins get all via role). See socketHasPropertyAccess.
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -45,6 +45,15 @@ struct SocketInfo {
|
|||
* property-access set so that booking notifications can be scoped to
|
||||
* authorised recipients.
|
||||
*
|
||||
* @warning CSWSH (authkit#16 M-4): a cookie-authenticated WebSocket upgrade is
|
||||
* exposed to Cross-Site WebSocket Hijacking unless the `Origin` header
|
||||
* is validated at the handshake. The Hub runs *after* the upgrade and
|
||||
* cannot see `Origin`, so the WSController MUST reject disallowed
|
||||
* origins before setting `t_pendingAuth` — use
|
||||
* `oatpp_authkit::sameOrigin(originHeader, hostHeader)` or
|
||||
* `oatpp_authkit::originAllowed(originHeader, allowlist)` from
|
||||
* `util/OriginCheck.hpp`.
|
||||
*
|
||||
* **Server→client change notifications**
|
||||
* @code
|
||||
* {"type":"booking_updated","id":"<uuid>"}
|
||||
|
|
@ -153,12 +162,16 @@ private:
|
|||
/**
|
||||
* @brief Check whether a socket has access to a given property.
|
||||
*
|
||||
* Admins and users with no explicit permission rows (empty propertyIds)
|
||||
* have access to all properties.
|
||||
* Admins (role == "admin") see everything. For everyone else, access is
|
||||
* granted only if `propertyId` is explicitly in their `propertyIds` set.
|
||||
*
|
||||
* authkit#16 M-3: an empty `propertyIds` now means NO access (fail closed),
|
||||
* not "all". Previously a non-admin whose permission set failed to populate
|
||||
* (DB hiccup, race, or simply no grants yet) would receive every property's
|
||||
* notifications — a cross-tenant leak.
|
||||
*/
|
||||
static bool socketHasPropertyAccess(const SocketInfo& info, const std::string& propertyId) {
|
||||
if (info.role == "admin") return true;
|
||||
if (info.propertyIds.empty()) return true; // no restrictions
|
||||
return info.propertyIds.find(propertyId) != info.propertyIds.end();
|
||||
}
|
||||
|
||||
|
|
@ -178,7 +191,15 @@ public:
|
|||
{
|
||||
socket.setListener(std::make_shared<Listener>());
|
||||
|
||||
if (!t_pendingAuth.has_value()) {
|
||||
// authkit#16 L-4: consume the thread-local handoff exactly once, up
|
||||
// front, and clear it unconditionally. If a prior connection's
|
||||
// onAfterCreate ever failed to clear it (or oatpp reuses this worker
|
||||
// thread), a leftover value must NOT attach to this socket — and our
|
||||
// own value must not leak to the next connection on this thread.
|
||||
std::optional<SocketInfo> pending = std::move(t_pendingAuth);
|
||||
t_pendingAuth.reset();
|
||||
|
||||
if (!pending.has_value()) {
|
||||
// Should not happen — WSController validates before handshake.
|
||||
OATPP_LOGW("Hub", "WebSocket connected without auth context — closing");
|
||||
try { socket.sendClose(4001, "Unauthorized"); } catch (...) {}
|
||||
|
|
@ -191,14 +212,12 @@ public:
|
|||
// #439: refuse extra connections beyond the cap rather than
|
||||
// allowing unbounded growth of s_sockets / presence maps.
|
||||
OATPP_LOGW("Hub", "socket cap %zu hit — rejecting", kMaxSockets);
|
||||
t_pendingAuth.reset();
|
||||
try { socket.sendClose(1013, "Server Busy"); } catch (...) {}
|
||||
return;
|
||||
}
|
||||
s_sockets[&socket] = std::move(*t_pendingAuth);
|
||||
s_sockets[&socket] = std::move(*pending);
|
||||
s_lastSeen[&socket] = std::chrono::steady_clock::now();
|
||||
}
|
||||
t_pendingAuth.reset();
|
||||
|
||||
OATPP_LOGD("Hub", "client connected: %s (total=%zu)",
|
||||
s_sockets[&socket].username.c_str(), s_sockets.size());
|
||||
|
|
|
|||
|
|
@ -10,6 +10,26 @@ add_executable(test_negotiation test_negotiation.cpp)
|
|||
target_link_libraries(test_negotiation PRIVATE oatpp::authkit oatpp::oatpp)
|
||||
add_test(NAME negotiation COMMAND test_negotiation)
|
||||
|
||||
add_executable(test_token_extract test_token_extract.cpp)
|
||||
target_link_libraries(test_token_extract PRIVATE oatpp::authkit oatpp::oatpp)
|
||||
add_test(NAME token_extract COMMAND test_token_extract)
|
||||
|
||||
add_executable(test_rate_limiter test_rate_limiter.cpp)
|
||||
target_link_libraries(test_rate_limiter PRIVATE oatpp::authkit oatpp::oatpp)
|
||||
add_test(NAME rate_limiter COMMAND test_rate_limiter)
|
||||
|
||||
add_executable(test_origin_check test_origin_check.cpp)
|
||||
target_link_libraries(test_origin_check PRIVATE oatpp::authkit oatpp::oatpp)
|
||||
add_test(NAME origin_check COMMAND test_origin_check)
|
||||
|
||||
add_executable(test_constant_time test_constant_time.cpp)
|
||||
target_link_libraries(test_constant_time PRIVATE oatpp::authkit oatpp::oatpp)
|
||||
add_test(NAME constant_time COMMAND test_constant_time)
|
||||
|
||||
add_executable(test_session_cookie test_session_cookie.cpp)
|
||||
target_link_libraries(test_session_cookie PRIVATE oatpp::authkit oatpp::oatpp)
|
||||
add_test(NAME session_cookie COMMAND test_session_cookie)
|
||||
|
||||
add_executable(test_body_size_limit test_body_size_limit.cpp)
|
||||
target_link_libraries(test_body_size_limit PRIVATE oatpp::authkit oatpp::oatpp)
|
||||
add_test(NAME body_size_limit COMMAND test_body_size_limit)
|
||||
|
|
@ -42,6 +62,42 @@ 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_decorator_migrations test_decorator_migrations.cpp)
|
||||
target_link_libraries(test_decorator_migrations PRIVATE oatpp::authkit oatpp::oatpp)
|
||||
add_test(NAME decorator_migrations COMMAND test_decorator_migrations)
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ 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);
|
||||
|
|
@ -39,7 +40,7 @@ class AuditDto : public oatpp::DTO {
|
|||
|
||||
} // namespace
|
||||
|
||||
OATPP_AUTHKIT_REGISTER_TEMPORAL(AuditDto, entity_id, valid_from, valid_until)
|
||||
OATPP_AUTHKIT_REGISTER_TEMPORAL(AuditDto, id, entity_id, valid_from, valid_until)
|
||||
|
||||
namespace {
|
||||
|
||||
|
|
|
|||
44
test/test_constant_time.cpp
Normal file
44
test/test_constant_time.cpp
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// Tests for oatpp-authkit/util/ConstantTime.hpp (authkit#16 L-7).
|
||||
// Verifies functional correctness; timing-invariance is a property of the
|
||||
// branch-free implementation, not asserted here.
|
||||
|
||||
#include "oatpp-authkit/util/ConstantTime.hpp"
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
namespace {
|
||||
|
||||
int g_failures = 0;
|
||||
#define REQUIRE(expr) do { \
|
||||
if (!(expr)) { \
|
||||
std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \
|
||||
++g_failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
using namespace oatpp_authkit;
|
||||
|
||||
void test_constant_time_equals() {
|
||||
REQUIRE(constantTimeEquals("", ""));
|
||||
REQUIRE(constantTimeEquals("abc", "abc"));
|
||||
REQUIRE(constantTimeEquals(std::string(64, 'a'), std::string(64, 'a')));
|
||||
|
||||
REQUIRE(!constantTimeEquals("abc", "abd")); // differ at last byte
|
||||
REQUIRE(!constantTimeEquals("abc", "xbc")); // differ at first byte
|
||||
REQUIRE(!constantTimeEquals("abc", "ab")); // length mismatch (prefix)
|
||||
REQUIRE(!constantTimeEquals("ab", "abc"));
|
||||
REQUIRE(!constantTimeEquals("", "a"));
|
||||
|
||||
// Embedded NUL handled (string-length aware, not C-string).
|
||||
REQUIRE(constantTimeEquals(std::string("a\0b", 3), std::string("a\0b", 3)));
|
||||
REQUIRE(!constantTimeEquals(std::string("a\0b", 3), std::string("a\0c", 3)));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
test_constant_time_equals();
|
||||
std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures);
|
||||
return g_failures ? 1 : 0;
|
||||
}
|
||||
|
|
@ -1,238 +0,0 @@
|
|||
// Tests for authkit#12 — per-decorator migration kit.
|
||||
//
|
||||
// Verifies the runner against a tiny in-memory "schema" (a set of applied
|
||||
// step names + a boolean per "table-has-column" probe). No real SQL
|
||||
// engine — the kit's contract is to call `probe`/`exec` in the right
|
||||
// shape, and that's what we test.
|
||||
//
|
||||
// Coverage:
|
||||
// - PREREQ runs once per applyDecoratorMigration call
|
||||
// - RESHAPE_STEPS skip when probe returns true (already applied)
|
||||
// - RESHAPE_STEPS apply when probe returns false (fresh)
|
||||
// - Re-running on a fully-applied schema is a no-op
|
||||
// - Partial-state recovery: probes mark some steps as applied, runner
|
||||
// applies only the rest
|
||||
// - {table} placeholder substitution
|
||||
// - StepRecorder fires for every applied step, never for skipped
|
||||
// - ScopeGuardRepository's empty migrations are a clean no-op
|
||||
|
||||
#include "oatpp-authkit/repo/Prereq.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, 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, entity_id, valid_from, valid_until)
|
||||
|
||||
namespace {
|
||||
|
||||
using namespace oatpp_authkit::repo;
|
||||
|
||||
// Tiny fake schema for the runner to talk to.
|
||||
struct FakeDb {
|
||||
std::set<std::string> appliedDdl; // every exec(sql) lands here
|
||||
std::set<std::string> knownTrue; // probe(sql) returns true iff sql is in this set
|
||||
std::vector<std::string> execLog; // ordered exec history
|
||||
|
||||
SqlExec exec() {
|
||||
return [this](const std::string& sql) {
|
||||
appliedDdl.insert(sql);
|
||||
execLog.push_back(sql);
|
||||
};
|
||||
}
|
||||
SqlProbe probe() {
|
||||
return [this](const std::string& sql) {
|
||||
return knownTrue.count(sql) > 0;
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
struct RecordedStep {
|
||||
std::string decorator;
|
||||
std::string table;
|
||||
std::string step;
|
||||
};
|
||||
|
||||
StepRecorder makeRecorder(std::vector<RecordedStep>& out) {
|
||||
return [&out](const char* d, const std::string& t, const char* s) {
|
||||
out.push_back({d, t, s});
|
||||
};
|
||||
}
|
||||
|
||||
// Test 1: instantiate substitutes {table} everywhere.
|
||||
void test_instantiate() {
|
||||
REQUIRE(instantiate("SELECT * FROM {table}", "persons") == "SELECT * FROM persons");
|
||||
REQUIRE(instantiate("ALTER TABLE {table} ADD COLUMN x; CREATE INDEX i ON {table}(y)",
|
||||
"addr") == "ALTER TABLE addr ADD COLUMN x; CREATE INDEX i ON addr(y)");
|
||||
REQUIRE(instantiate("no placeholder here", "tbl") == "no placeholder here");
|
||||
REQUIRE(instantiate("", "tbl") == "");
|
||||
}
|
||||
|
||||
// Test 2: AuditLog has PREREQ, no RESHAPE — fresh apply records exactly one step.
|
||||
void test_audit_fresh_apply() {
|
||||
FakeDb db;
|
||||
std::vector<RecordedStep> recorded;
|
||||
applyDecoratorMigration<AuditLogRepository<TestDto>>(
|
||||
"ignored", db.probe(), db.exec(), makeRecorder(recorded));
|
||||
REQUIRE(db.execLog.size() == 1);
|
||||
REQUIRE(db.execLog[0].find("CREATE TABLE IF NOT EXISTS audit_log") != std::string::npos);
|
||||
REQUIRE(recorded.size() == 1);
|
||||
REQUIRE(recorded[0].decorator == std::string("AuditLogRepository"));
|
||||
REQUIRE(recorded[0].step == std::string("PREREQ_SQL"));
|
||||
}
|
||||
|
||||
// Test 3: ScopeGuard has neither — runner is a no-op.
|
||||
void test_scopeguard_noop() {
|
||||
FakeDb db;
|
||||
std::vector<RecordedStep> recorded;
|
||||
applyDecoratorMigration<ScopeGuardRepository<TestDto>>(
|
||||
"persons", db.probe(), db.exec(), makeRecorder(recorded));
|
||||
REQUIRE(db.execLog.empty());
|
||||
REQUIRE(recorded.empty());
|
||||
}
|
||||
|
||||
// Test 4: TemporalRepository fresh apply — probe returns false for every
|
||||
// detect, every reshape step runs, recorder fires per step.
|
||||
void test_temporal_fresh_apply() {
|
||||
FakeDb db;
|
||||
std::vector<RecordedStep> recorded;
|
||||
applyDecoratorMigration<TemporalRepository<TestDto>>(
|
||||
"persons", db.probe(), db.exec(), makeRecorder(recorded));
|
||||
// Four reshape steps, all executed (drop_unique_entity_id has a noop
|
||||
// apply but still counts as executed).
|
||||
REQUIRE(db.execLog.size() == 4);
|
||||
REQUIRE(recorded.size() == 4);
|
||||
REQUIRE(recorded[0].step == std::string("add_valid_from"));
|
||||
REQUIRE(recorded[1].step == std::string("add_valid_until"));
|
||||
REQUIRE(recorded[2].step == std::string("drop_unique_entity_id"));
|
||||
REQUIRE(recorded[3].step == std::string("composite_unique"));
|
||||
// {table} substituted in apply SQL.
|
||||
REQUIRE(db.execLog[0].find("ALTER TABLE persons") != std::string::npos);
|
||||
REQUIRE(db.execLog[3].find("ON persons(entity_id, valid_until)") != std::string::npos);
|
||||
}
|
||||
|
||||
// Test 5: re-apply on already-shaped table is fully no-op.
|
||||
void test_temporal_reapply_noop() {
|
||||
FakeDb db;
|
||||
// Pre-populate probe truth set with every detect query — every step
|
||||
// is "already applied".
|
||||
db.knownTrue.insert(instantiate(
|
||||
"SELECT 1 FROM pragma_table_info('{table}') WHERE name='valid_from'", "persons"));
|
||||
db.knownTrue.insert(instantiate(
|
||||
"SELECT 1 FROM pragma_table_info('{table}') WHERE name='valid_until'", "persons"));
|
||||
db.knownTrue.insert(instantiate(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='index' AND tbl_name='{table}' AND name='ux_{table}_entity_valid_until'", "persons"));
|
||||
db.knownTrue.insert(instantiate(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='index' AND name='ux_{table}_entity_valid_until'", "persons"));
|
||||
|
||||
std::vector<RecordedStep> recorded;
|
||||
applyDecoratorMigration<TemporalRepository<TestDto>>(
|
||||
"persons", db.probe(), db.exec(), makeRecorder(recorded));
|
||||
REQUIRE(db.execLog.empty());
|
||||
REQUIRE(recorded.empty());
|
||||
}
|
||||
|
||||
// Test 6: partial state recovery — only the missing step runs.
|
||||
void test_temporal_partial_recovery() {
|
||||
FakeDb db;
|
||||
// valid_from already exists, valid_until doesn't, composite index doesn't.
|
||||
db.knownTrue.insert(instantiate(
|
||||
"SELECT 1 FROM pragma_table_info('{table}') WHERE name='valid_from'", "persons"));
|
||||
|
||||
std::vector<RecordedStep> recorded;
|
||||
applyDecoratorMigration<TemporalRepository<TestDto>>(
|
||||
"persons", db.probe(), db.exec(), makeRecorder(recorded));
|
||||
REQUIRE(db.execLog.size() == 3); // skipped add_valid_from, ran the rest
|
||||
REQUIRE(recorded.size() == 3);
|
||||
REQUIRE(recorded[0].step == std::string("add_valid_until"));
|
||||
REQUIRE(recorded[1].step == std::string("drop_unique_entity_id"));
|
||||
REQUIRE(recorded[2].step == std::string("composite_unique"));
|
||||
}
|
||||
|
||||
// Test 7: variadic stack runs every decorator's migrations in order.
|
||||
void test_stack_runs_in_order() {
|
||||
FakeDb db;
|
||||
std::vector<RecordedStep> recorded;
|
||||
applyDecoratorMigrations<
|
||||
AuditLogRepository<TestDto>,
|
||||
TemporalRepository<TestDto>,
|
||||
ScopeGuardRepository<TestDto>>(
|
||||
"persons", db.probe(), db.exec(), makeRecorder(recorded));
|
||||
// Audit (1) + Temporal (4) + ScopeGuard (0) = 5
|
||||
REQUIRE(recorded.size() == 5);
|
||||
REQUIRE(recorded[0].decorator == std::string("AuditLogRepository"));
|
||||
REQUIRE(recorded[1].decorator == std::string("TemporalRepository"));
|
||||
REQUIRE(recorded[4].decorator == std::string("TemporalRepository"));
|
||||
// ScopeGuard contributed zero — never appears in the log.
|
||||
for (const auto& r : recorded) {
|
||||
REQUIRE(r.decorator != std::string("ScopeGuardRepository"));
|
||||
}
|
||||
}
|
||||
|
||||
// Test 8: schema_migrations table SQL is a CREATE IF NOT EXISTS (idempotent
|
||||
// by construction). Asserting the literal so consumers can rely on the
|
||||
// column shape.
|
||||
void test_schema_migrations_table_sql() {
|
||||
std::string sql(SCHEMA_MIGRATIONS_TABLE_SQL);
|
||||
REQUIRE(sql.find("CREATE TABLE IF NOT EXISTS oatpp_authkit_schema_migrations") != std::string::npos);
|
||||
REQUIRE(sql.find("decorator") != std::string::npos);
|
||||
REQUIRE(sql.find("table_name") != std::string::npos);
|
||||
REQUIRE(sql.find("step") != std::string::npos);
|
||||
REQUIRE(sql.find("applied_at") != std::string::npos);
|
||||
REQUIRE(sql.find("PRIMARY KEY (decorator, table_name, step)") != std::string::npos);
|
||||
}
|
||||
|
||||
// Test 9: missing recorder is fine (default-constructed callable is nullable).
|
||||
void test_recorder_optional() {
|
||||
FakeDb db;
|
||||
applyDecoratorMigrations<
|
||||
AuditLogRepository<TestDto>,
|
||||
TemporalRepository<TestDto>>(
|
||||
"persons", db.probe(), db.exec()); // no recorder
|
||||
REQUIRE(db.execLog.size() == 5);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
test_instantiate();
|
||||
test_audit_fresh_apply();
|
||||
test_scopeguard_noop();
|
||||
test_temporal_fresh_apply();
|
||||
test_temporal_reapply_noop();
|
||||
test_temporal_partial_recovery();
|
||||
test_stack_runs_in_order();
|
||||
test_schema_migrations_table_sql();
|
||||
test_recorder_optional();
|
||||
std::printf("test_decorator_migrations: OK\n");
|
||||
return 0;
|
||||
}
|
||||
59
test/test_origin_check.cpp
Normal file
59
test/test_origin_check.cpp
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
// Tests for oatpp-authkit/util/OriginCheck.hpp (authkit#16 M-4 / M-10).
|
||||
|
||||
#include "oatpp-authkit/util/OriginCheck.hpp"
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
int g_failures = 0;
|
||||
#define REQUIRE(expr) do { \
|
||||
if (!(expr)) { \
|
||||
std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \
|
||||
++g_failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
using namespace oatpp_authkit;
|
||||
|
||||
void test_origin_hostname() {
|
||||
REQUIRE(originHostname("https://app.example.com") == "app.example.com");
|
||||
REQUIRE(originHostname("https://app.example.com:8443/x?y=1") == "app.example.com");
|
||||
REQUIRE(originHostname("app.example.com:443") == "app.example.com");
|
||||
REQUIRE(originHostname("HTTP://App.Example.COM") == "app.example.com");
|
||||
REQUIRE(originHostname("example.com") == "example.com");
|
||||
}
|
||||
|
||||
void test_same_origin() {
|
||||
// Origin host matches Host (port/scheme ignored).
|
||||
REQUIRE(sameOrigin("https://example.com", "example.com"));
|
||||
REQUIRE(sameOrigin("https://example.com:8443", "example.com"));
|
||||
REQUIRE(sameOrigin("https://example.com/page", "example.com")); // Referer form
|
||||
REQUIRE(sameOrigin("https://example.com", "example.com:443"));
|
||||
// Cross-host → blocked.
|
||||
REQUIRE(!sameOrigin("https://evil.com", "example.com"));
|
||||
REQUIRE(!sameOrigin("https://example.com.evil.com", "example.com"));
|
||||
// Empty inputs → can't decide → don't block (caller falls back).
|
||||
REQUIRE(sameOrigin("", "example.com"));
|
||||
REQUIRE(sameOrigin("https://example.com", ""));
|
||||
}
|
||||
|
||||
void test_origin_allowed() {
|
||||
std::vector<std::string> allow = {"app.example.com", "https://admin.example.com"};
|
||||
REQUIRE(originAllowed("https://app.example.com", allow));
|
||||
REQUIRE(originAllowed("https://admin.example.com:8443/x", allow));
|
||||
REQUIRE(!originAllowed("https://evil.com", allow));
|
||||
REQUIRE(!originAllowed("", allow)); // fail closed
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
test_origin_hostname();
|
||||
test_same_origin();
|
||||
test_origin_allowed();
|
||||
std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures);
|
||||
return g_failures ? 1 : 0;
|
||||
}
|
||||
|
|
@ -148,6 +148,29 @@ void test_like_pattern_is_bound_not_interpolated() {
|
|||
REQUIRE_EQ(std::get<std::string>(sql.binds[0]), std::string("Al%"));
|
||||
}
|
||||
|
||||
void test_like_contains_escapes_wildcards() {
|
||||
// authkit#16 L-8: a user term with %/_/\ must be matched literally via an
|
||||
// explicit ESCAPE clause, not treated as wildcards.
|
||||
auto sql = Query<MockQueryDto>()
|
||||
.where(field<&MockQueryDto::name>().likeContains("50%_off\\x"))
|
||||
.toSql();
|
||||
REQUIRE_EQ(sql.text, std::string(
|
||||
"SELECT * FROM mock_query WHERE name LIKE ? ESCAPE '\\'"));
|
||||
REQUIRE_EQ(std::get<std::string>(sql.binds[0]),
|
||||
std::string("%50\\%\\_off\\\\x%"));
|
||||
|
||||
auto pfx = Query<MockQueryDto>()
|
||||
.where(field<&MockQueryDto::name>().likePrefix("a_b"))
|
||||
.toSql();
|
||||
REQUIRE_EQ(pfx.text, std::string(
|
||||
"SELECT * FROM mock_query WHERE name LIKE ? ESCAPE '\\'"));
|
||||
REQUIRE_EQ(std::get<std::string>(pfx.binds[0]), std::string("a\\_b%"));
|
||||
|
||||
// The bare likeEscape helper.
|
||||
REQUIRE_EQ(likeEscape("100%_\\"), std::string("100\\%\\_\\\\"));
|
||||
REQUIRE_EQ(likeEscape("plain"), std::string("plain"));
|
||||
}
|
||||
|
||||
void test_is_null_and_is_not_null() {
|
||||
auto a = Query<MockQueryDto>()
|
||||
.where(field<&MockQueryDto::email>().isNull())
|
||||
|
|
@ -208,6 +231,7 @@ int main() {
|
|||
test_in_with_multiple_values();
|
||||
test_in_with_empty_list_is_always_false();
|
||||
test_like_pattern_is_bound_not_interpolated();
|
||||
test_like_contains_escapes_wildcards();
|
||||
test_is_null_and_is_not_null();
|
||||
test_not_negates_predicate();
|
||||
test_order_by_and_limit_offset();
|
||||
|
|
|
|||
61
test/test_rate_limiter.cpp
Normal file
61
test/test_rate_limiter.cpp
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// Tests for oatpp-authkit/util/RateLimiter.hpp — constructor validation
|
||||
// (authkit#16 M-7) and basic token-bucket behaviour.
|
||||
|
||||
#include "oatpp-authkit/util/RateLimiter.hpp"
|
||||
|
||||
#include <cstdio>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
namespace {
|
||||
|
||||
int g_failures = 0;
|
||||
#define REQUIRE(expr) do { \
|
||||
if (!(expr)) { \
|
||||
std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \
|
||||
++g_failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
using namespace oatpp_authkit;
|
||||
|
||||
template <class F>
|
||||
bool throwsInvalidArg(F&& f) {
|
||||
try { f(); } catch (const std::invalid_argument&) { return true; } catch (...) { return false; }
|
||||
return false;
|
||||
}
|
||||
|
||||
void test_ctor_validation() {
|
||||
REQUIRE(throwsInvalidArg([]{ RateLimiter r(0.0, 1.0); })); // capacity < 1
|
||||
REQUIRE(throwsInvalidArg([]{ RateLimiter r(-5.0, 1.0); })); // negative capacity
|
||||
REQUIRE(throwsInvalidArg([]{ RateLimiter r(10.0, 0.0); })); // refill 0 → silent disable
|
||||
REQUIRE(throwsInvalidArg([]{ RateLimiter r(10.0, -1.0); })); // negative refill
|
||||
REQUIRE(throwsInvalidArg([]{ RateLimiter r(std::nan(""), 1.0); })); // NaN capacity
|
||||
REQUIRE(throwsInvalidArg([]{ RateLimiter r(10.0, std::nan("")); })); // NaN refill
|
||||
REQUIRE(throwsInvalidArg([]{ RateLimiter r(1.0/0.0, 1.0); })); // inf capacity
|
||||
|
||||
// Valid construction does not throw.
|
||||
bool ok = true;
|
||||
try { RateLimiter r(3.0, 0.5); (void)r; } catch (...) { ok = false; }
|
||||
REQUIRE(ok);
|
||||
}
|
||||
|
||||
void test_burst_then_deny_and_key_isolation() {
|
||||
RateLimiter rl(3.0, 0.001); // 3 burst, negligible refill within the test
|
||||
REQUIRE(rl.allow("ip-a"));
|
||||
REQUIRE(rl.allow("ip-a"));
|
||||
REQUIRE(rl.allow("ip-a"));
|
||||
REQUIRE(!rl.allow("ip-a")); // 4th denied
|
||||
|
||||
// Different key has its own independent bucket.
|
||||
REQUIRE(rl.allow("ip-b"));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
test_ctor_validation();
|
||||
test_burst_then_deny_and_key_isolation();
|
||||
std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures);
|
||||
return g_failures ? 1 : 0;
|
||||
}
|
||||
196
test/test_redacted_field_repository.cpp
Normal file
196
test/test_redacted_field_repository.cpp
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
// Tests for authkit#15 — RedactedFieldRepository decorator.
|
||||
|
||||
#include "oatpp-authkit/repo/RedactedFieldRepository.hpp"
|
||||
#include "oatpp-authkit/repo/Repository.hpp"
|
||||
#include "oatpp-authkit/repo/TemporalFieldTraits.hpp"
|
||||
#include "oatpp-authkit/repo/TemporalRepository.hpp"
|
||||
|
||||
#include "oatpp/core/macro/codegen.hpp"
|
||||
#include "oatpp/core/Types.hpp"
|
||||
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#define REQUIRE(cond) do { \
|
||||
if (!(cond)) { std::fprintf(stderr, "REQUIRE failed: %s @ %s:%d\n", \
|
||||
#cond, __FILE__, __LINE__); std::abort(); } } while (0)
|
||||
|
||||
#include OATPP_CODEGEN_BEGIN(DTO)
|
||||
|
||||
namespace {
|
||||
|
||||
class CredDto : public oatpp::DTO {
|
||||
DTO_INIT(CredDto, DTO)
|
||||
DTO_FIELD(String, id);
|
||||
DTO_FIELD(String, entity_id);
|
||||
DTO_FIELD(String, valid_from);
|
||||
DTO_FIELD(String, valid_until);
|
||||
DTO_FIELD(String, username);
|
||||
DTO_FIELD(String, passwordHash);
|
||||
DTO_FIELD(String, tlsCertDn);
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
#include OATPP_CODEGEN_END(DTO)
|
||||
|
||||
OATPP_AUTHKIT_REGISTER_TEMPORAL(CredDto, id, entity_id, valid_from, valid_until)
|
||||
|
||||
namespace {
|
||||
|
||||
using namespace oatpp_authkit::repo;
|
||||
|
||||
// In-memory inner that just records what got saved, for inspection.
|
||||
class FakeInner : public Repository<CredDto> {
|
||||
public:
|
||||
std::vector<oatpp::Object<CredDto>> saved;
|
||||
|
||||
oatpp::Object<CredDto> findByEntityId(const oatpp::String&) override { return nullptr; }
|
||||
oatpp::Vector<oatpp::Object<CredDto>> list() override {
|
||||
return oatpp::Vector<oatpp::Object<CredDto>>::createShared();
|
||||
}
|
||||
void save(const oatpp::Object<CredDto>& dto) override { saved.push_back(dto); }
|
||||
void softDelete(const oatpp::String&) override {}
|
||||
};
|
||||
|
||||
oatpp::Object<CredDto> makeRow(const std::string& vu,
|
||||
const std::string& password,
|
||||
const std::string& certDn) {
|
||||
auto d = CredDto::createShared();
|
||||
d->id = "id1";
|
||||
d->entity_id = "ent1";
|
||||
d->valid_from = "2026-01-01T00:00:00Z";
|
||||
d->valid_until = vu;
|
||||
d->username = "alice";
|
||||
d->passwordHash = password;
|
||||
d->tlsCertDn = certDn;
|
||||
return d;
|
||||
}
|
||||
|
||||
void test_live_row_passes_through_unchanged() {
|
||||
auto inner = std::make_shared<FakeInner>();
|
||||
RedactedFieldRepository<CredDto> redacted(
|
||||
inner, {"passwordHash", "tlsCertDn"});
|
||||
|
||||
// Live row: valid_until == SENTINEL.
|
||||
auto live = makeRow(TemporalRepository<CredDto>::SENTINEL,
|
||||
"$bcrypt$secret", "CN=alice");
|
||||
redacted.save(live);
|
||||
|
||||
REQUIRE(inner->saved.size() == 1);
|
||||
auto& got = inner->saved[0];
|
||||
REQUIRE(got->passwordHash);
|
||||
REQUIRE(std::string(*got->passwordHash) == "$bcrypt$secret");
|
||||
REQUIRE(got->tlsCertDn);
|
||||
REQUIRE(std::string(*got->tlsCertDn) == "CN=alice");
|
||||
}
|
||||
|
||||
void test_historical_row_redacts_named_fields() {
|
||||
auto inner = std::make_shared<FakeInner>();
|
||||
RedactedFieldRepository<CredDto> redacted(
|
||||
inner, {"passwordHash", "tlsCertDn"});
|
||||
|
||||
// Historical row: valid_until is a real timestamp, not the sentinel.
|
||||
auto historical = makeRow("2026-05-06T12:00:00Z",
|
||||
"$bcrypt$secret", "CN=alice");
|
||||
redacted.save(historical);
|
||||
|
||||
REQUIRE(inner->saved.size() == 1);
|
||||
auto& got = inner->saved[0];
|
||||
REQUIRE(!got->passwordHash); // redacted to null
|
||||
REQUIRE(!got->tlsCertDn); // redacted to null
|
||||
// Non-redacted fields survive.
|
||||
REQUIRE(got->username);
|
||||
REQUIRE(std::string(*got->username) == "alice");
|
||||
REQUIRE(got->valid_until);
|
||||
REQUIRE(std::string(*got->valid_until) == "2026-05-06T12:00:00Z");
|
||||
}
|
||||
|
||||
void test_partial_redaction_list() {
|
||||
auto inner = std::make_shared<FakeInner>();
|
||||
RedactedFieldRepository<CredDto> redacted(inner, {"passwordHash"});
|
||||
|
||||
auto historical = makeRow("2026-05-06T12:00:00Z",
|
||||
"$bcrypt$secret", "CN=alice");
|
||||
redacted.save(historical);
|
||||
|
||||
auto& got = inner->saved[0];
|
||||
REQUIRE(!got->passwordHash); // redacted
|
||||
REQUIRE(got->tlsCertDn); // NOT redacted (not in list)
|
||||
REQUIRE(std::string(*got->tlsCertDn) == "CN=alice");
|
||||
}
|
||||
|
||||
void test_empty_redaction_list_passes_everything_through() {
|
||||
auto inner = std::make_shared<FakeInner>();
|
||||
RedactedFieldRepository<CredDto> redacted(inner, {});
|
||||
|
||||
auto historical = makeRow("2026-05-06T12:00:00Z",
|
||||
"$bcrypt$secret", "CN=alice");
|
||||
redacted.save(historical);
|
||||
|
||||
auto& got = inner->saved[0];
|
||||
REQUIRE(got->passwordHash);
|
||||
REQUIRE(std::string(*got->passwordHash) == "$bcrypt$secret");
|
||||
REQUIRE(got->tlsCertDn);
|
||||
}
|
||||
|
||||
void test_null_valid_until_treated_as_live() {
|
||||
auto inner = std::make_shared<FakeInner>();
|
||||
RedactedFieldRepository<CredDto> redacted(
|
||||
inner, {"passwordHash", "tlsCertDn"});
|
||||
|
||||
// valid_until null — treat as live (the temporal decorator hasn't
|
||||
// set it yet on a fresh insert, before deciding sentinel).
|
||||
auto fresh = CredDto::createShared();
|
||||
fresh->id = "id2";
|
||||
fresh->entity_id = "ent2";
|
||||
fresh->passwordHash = "$bcrypt$fresh";
|
||||
fresh->tlsCertDn = "CN=bob";
|
||||
redacted.save(fresh);
|
||||
|
||||
auto& got = inner->saved[0];
|
||||
REQUIRE(got->passwordHash); // not redacted
|
||||
REQUIRE(got->tlsCertDn);
|
||||
}
|
||||
|
||||
// authkit#16 M-6: a redaction field name that doesn't exist on the DTO must
|
||||
// throw at construction — a silent no-op would leave credentials in history.
|
||||
void test_unknown_field_throws() {
|
||||
auto inner = std::make_shared<FakeInner>();
|
||||
bool threw = false;
|
||||
try {
|
||||
RedactedFieldRepository<CredDto> bad(inner, {"passwordHash", "passowrdHash" /* typo */});
|
||||
} catch (const std::invalid_argument&) {
|
||||
threw = true;
|
||||
}
|
||||
REQUIRE(threw);
|
||||
|
||||
// Wrong casing / JSON-name instead of C++ identifier also throws.
|
||||
bool threw2 = false;
|
||||
try {
|
||||
RedactedFieldRepository<CredDto> bad2(inner, {"password_hash" /* JSON name, not the DTO_FIELD id */});
|
||||
} catch (const std::invalid_argument&) {
|
||||
threw2 = true;
|
||||
}
|
||||
REQUIRE(threw2);
|
||||
|
||||
// A correct set constructs fine.
|
||||
RedactedFieldRepository<CredDto> ok(inner, {"passwordHash", "tlsCertDn"});
|
||||
(void)ok;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
test_live_row_passes_through_unchanged();
|
||||
test_historical_row_redacts_named_fields();
|
||||
test_partial_redaction_list();
|
||||
test_empty_redaction_list_passes_everything_through();
|
||||
test_null_valid_until_treated_as_live();
|
||||
test_unknown_field_throws();
|
||||
std::printf("test_redacted_field_repository: OK\n");
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ namespace {
|
|||
|
||||
class MockTemporalDto : public oatpp::DTO {
|
||||
DTO_INIT(MockTemporalDto, DTO)
|
||||
DTO_FIELD(String, id); // per-row PK (version UUID)
|
||||
DTO_FIELD(String, entity_id);
|
||||
DTO_FIELD(String, valid_from);
|
||||
DTO_FIELD(String, valid_until);
|
||||
|
|
@ -41,7 +42,7 @@ class MockTemporalDto : public oatpp::DTO {
|
|||
#include OATPP_CODEGEN_END(DTO)
|
||||
|
||||
} // namespace
|
||||
OATPP_AUTHKIT_REGISTER_TEMPORAL(MockTemporalDto, entity_id, valid_from, valid_until)
|
||||
OATPP_AUTHKIT_REGISTER_TEMPORAL(MockTemporalDto, id, entity_id, valid_from, valid_until)
|
||||
namespace {
|
||||
|
||||
int g_failures = 0;
|
||||
|
|
@ -53,14 +54,15 @@ int g_failures = 0;
|
|||
} \
|
||||
} while (0)
|
||||
|
||||
// In-memory adapter: rows keyed by (entity_id, valid_from). save() upserts.
|
||||
// In-memory adapter: rows keyed by `id` PK (per-row UUID). save() upserts —
|
||||
// matches the new TemporalRepository inner contract (authkit#13).
|
||||
// Exposes ALL rows via list() — the temporal decorator filters to live.
|
||||
class InMemoryAllRows : public oatpp_authkit::repo::Repository<MockTemporalDto> {
|
||||
std::map<std::pair<std::string, std::string>, oatpp::Object<MockTemporalDto>> rows;
|
||||
std::map<std::string, oatpp::Object<MockTemporalDto>> rows;
|
||||
public:
|
||||
oatpp::Object<MockTemporalDto> findByEntityId(const oatpp::String& id) override {
|
||||
// Not used by TemporalRepository — included for interface completeness.
|
||||
for (auto& kv : rows) if (kv.first.first == std::string(*id)) return kv.second;
|
||||
for (auto& kv : rows) if (kv.second->entity_id && std::string(*kv.second->entity_id) == std::string(*id)) return kv.second;
|
||||
return nullptr;
|
||||
}
|
||||
oatpp::Vector<oatpp::Object<MockTemporalDto>> list() override {
|
||||
|
|
@ -69,11 +71,11 @@ public:
|
|||
return v;
|
||||
}
|
||||
void save(const oatpp::Object<MockTemporalDto>& dto) override {
|
||||
rows[{*dto->entity_id, *dto->valid_from}] = dto;
|
||||
rows[std::string(*dto->id)] = dto;
|
||||
}
|
||||
void softDelete(const oatpp::String& id) override {
|
||||
for (auto it = rows.begin(); it != rows.end(); ) {
|
||||
if (it->first.first == std::string(*id)) it = rows.erase(it); else ++it;
|
||||
if (it->second->entity_id && std::string(*it->second->entity_id) == std::string(*id)) it = rows.erase(it); else ++it;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -86,25 +88,39 @@ struct StepClock {
|
|||
int64_t operator()() { int64_t v = ms; ms += 1000; return v; }
|
||||
};
|
||||
|
||||
// Sequencing idgen so each call returns a fresh string — needed now that
|
||||
// the decorator allocates both entity_id and per-row PK.
|
||||
struct SeqIdGen {
|
||||
int n{0};
|
||||
oatpp::String operator()() {
|
||||
char buf[16];
|
||||
std::snprintf(buf, sizeof(buf), "id%04d", n++);
|
||||
return oatpp::String(buf);
|
||||
}
|
||||
};
|
||||
|
||||
void test_save_closes_prior_version_and_inserts_new() {
|
||||
using namespace oatpp_authkit::repo;
|
||||
auto inner = std::make_shared<InMemoryAllRows>();
|
||||
auto clock = std::make_shared<StepClock>();
|
||||
auto ids = std::make_shared<SeqIdGen>();
|
||||
TemporalRepository<MockTemporalDto> repo(inner,
|
||||
[clock]{ return (*clock)(); },
|
||||
[]{ return oatpp::String("alice"); });
|
||||
[ids]{ return (*ids)(); });
|
||||
|
||||
// First save — entity_id auto-allocated, valid_from = now1, valid_until = SENTINEL.
|
||||
// First save — entity_id + id auto-allocated, valid_from = now1, valid_until = SENTINEL.
|
||||
auto v1 = MockTemporalDto::createShared();
|
||||
v1->name = oatpp::String("alice v1");
|
||||
repo.save(v1);
|
||||
|
||||
REQUIRE(v1->entity_id);
|
||||
REQUIRE(v1->id);
|
||||
REQUIRE(std::string(*v1->valid_until)
|
||||
== TemporalRepository<MockTemporalDto>::SENTINEL);
|
||||
REQUIRE(inner->list()->size() == 1);
|
||||
const std::string livePkAfterFirst = std::string(*v1->id);
|
||||
|
||||
// Second save — old version's valid_until is closed; new live row inserted.
|
||||
// Second save — historical copy with new PK, live row updated in place.
|
||||
auto v2 = MockTemporalDto::createShared();
|
||||
v2->entity_id = v1->entity_id;
|
||||
v2->name = oatpp::String("alice v2");
|
||||
|
|
@ -113,11 +129,20 @@ void test_save_closes_prior_version_and_inserts_new() {
|
|||
auto allAfter = inner->list();
|
||||
REQUIRE(allAfter->size() == 2);
|
||||
int liveCount = 0;
|
||||
std::string livePkAfterSecond, historicalPk;
|
||||
for (auto& row : *allAfter) {
|
||||
if (std::string(*row->valid_until)
|
||||
== TemporalRepository<MockTemporalDto>::SENTINEL) ++liveCount;
|
||||
== TemporalRepository<MockTemporalDto>::SENTINEL) {
|
||||
++liveCount;
|
||||
livePkAfterSecond = std::string(*row->id);
|
||||
} else {
|
||||
historicalPk = std::string(*row->id);
|
||||
}
|
||||
REQUIRE(liveCount == 1); // Only one row is live.
|
||||
}
|
||||
REQUIRE(liveCount == 1); // exactly one live
|
||||
REQUIRE(livePkAfterSecond == livePkAfterFirst); // stable live PK
|
||||
REQUIRE(historicalPk != livePkAfterFirst); // historical has fresh PK
|
||||
REQUIRE(std::string(*v2->id) == livePkAfterFirst); // dto reflects preserved PK
|
||||
}
|
||||
|
||||
void test_live_read_returns_only_sentinel_row() {
|
||||
|
|
@ -229,6 +254,7 @@ void test_scope_guard_denies_when_predicate_false() {
|
|||
// Seed inner with two rows in different scopes.
|
||||
for (const char* sc : {"prop-A", "prop-B"}) {
|
||||
auto dto = MockTemporalDto::createShared();
|
||||
dto->id = oatpp::String(std::string("pk-") + sc);
|
||||
dto->entity_id = oatpp::String(sc); // reuse scope as id for simplicity
|
||||
dto->valid_from = oatpp::String("2020-01-01T00:00:00Z");
|
||||
dto->valid_until = oatpp::String("9999-12-31T23:59:59Z");
|
||||
|
|
@ -249,7 +275,8 @@ void test_scope_guard_denies_when_predicate_false() {
|
|||
for (auto& as : a.allowedScopes) if (as == s) return true;
|
||||
return false;
|
||||
},
|
||||
[actor]{ return actor; });
|
||||
[actor]{ return actor; },
|
||||
[](const oatpp::Object<MockTemporalDto>& d) { return d->entity_id; });
|
||||
|
||||
// list filters to allowed rows only.
|
||||
auto allowed = guarded.list();
|
||||
|
|
@ -284,6 +311,113 @@ void test_scope_guard_denies_when_predicate_false() {
|
|||
REQUIRE(threwOnDelete);
|
||||
}
|
||||
|
||||
// Scope predicate + entity-id accessor shared by the reparenting / queryable tests.
|
||||
static bool scopeAllows(const oatpp_authkit::repo::ActorContext& a,
|
||||
const oatpp::Object<MockTemporalDto>& d) {
|
||||
if (!d || !d->scope) return false;
|
||||
const std::string s = std::string(*d->scope);
|
||||
for (auto& as : a.allowedScopes) if (as == s) return true;
|
||||
return false;
|
||||
}
|
||||
static oatpp::String entityIdOf(const oatpp::Object<MockTemporalDto>& d) { return d->entity_id; }
|
||||
|
||||
// An actor scoped to prop-A must NOT be able to reparent an existing prop-B row
|
||||
// into prop-A by setting scope=prop-A in the body. save() must reject because the
|
||||
// *existing* row is out of scope, even though the incoming dto looks in-scope.
|
||||
void test_scope_guard_blocks_reparenting() {
|
||||
using namespace oatpp_authkit::repo;
|
||||
auto inner = std::make_shared<InMemoryAllRows>();
|
||||
|
||||
// Seed an entity currently owned by prop-B.
|
||||
auto seeded = MockTemporalDto::createShared();
|
||||
seeded->id = oatpp::String("pk-ent1");
|
||||
seeded->entity_id = oatpp::String("ent1");
|
||||
seeded->valid_from = oatpp::String("2020-01-01T00:00:00Z");
|
||||
seeded->valid_until = oatpp::String("9999-12-31T23:59:59Z");
|
||||
seeded->scope = oatpp::String("prop-B");
|
||||
inner->save(seeded);
|
||||
|
||||
ActorContext actor;
|
||||
actor.userId = "u1";
|
||||
actor.allowedScopes = {"prop-A"};
|
||||
|
||||
ScopeGuardRepository<MockTemporalDto> guarded(
|
||||
inner, &scopeAllows, [actor]{ return actor; }, &entityIdOf);
|
||||
|
||||
// Attempt to claim ent1 by relabelling it prop-A.
|
||||
auto reparent = MockTemporalDto::createShared();
|
||||
reparent->entity_id = oatpp::String("ent1");
|
||||
reparent->scope = oatpp::String("prop-A"); // incoming looks in-scope...
|
||||
bool blocked = false;
|
||||
try { guarded.save(reparent); }
|
||||
catch (const ScopeDeniedException&) { blocked = true; } // ...but existing row is prop-B
|
||||
REQUIRE(blocked);
|
||||
|
||||
// The stored row is untouched.
|
||||
auto still = inner->findByEntityId(oatpp::String("ent1"));
|
||||
REQUIRE(still);
|
||||
REQUIRE(std::string(*still->scope) == "prop-B");
|
||||
|
||||
// A genuine insert into the actor's own scope still works (no existing row).
|
||||
auto fresh = MockTemporalDto::createShared();
|
||||
fresh->id = oatpp::String("pk-ent2");
|
||||
fresh->entity_id = oatpp::String("ent2");
|
||||
fresh->scope = oatpp::String("prop-A");
|
||||
fresh->valid_until = oatpp::String("9999-12-31T23:59:59Z");
|
||||
bool ok = true;
|
||||
try { guarded.save(fresh); } catch (const ScopeDeniedException&) { ok = false; }
|
||||
REQUIRE(ok);
|
||||
}
|
||||
|
||||
// Minimal IQueryable inner whose query() returns every row, so the test can
|
||||
// verify ScopeGuardQueryable post-filters results through the predicate.
|
||||
class InMemoryQueryable : public oatpp_authkit::repo::IQueryable<MockTemporalDto> {
|
||||
std::map<std::string, oatpp::Object<MockTemporalDto>> rows;
|
||||
public:
|
||||
oatpp::Object<MockTemporalDto> findByEntityId(const oatpp::String& id) override {
|
||||
for (auto& kv : rows)
|
||||
if (kv.second->entity_id && std::string(*kv.second->entity_id) == std::string(*id)) return kv.second;
|
||||
return nullptr;
|
||||
}
|
||||
oatpp::Vector<oatpp::Object<MockTemporalDto>> list() override {
|
||||
auto v = oatpp::Vector<oatpp::Object<MockTemporalDto>>::createShared();
|
||||
for (auto& kv : rows) v->push_back(kv.second);
|
||||
return v;
|
||||
}
|
||||
void save(const oatpp::Object<MockTemporalDto>& dto) override { rows[std::string(*dto->id)] = dto; }
|
||||
void softDelete(const oatpp::String&) override {}
|
||||
oatpp::Vector<oatpp::Object<MockTemporalDto>>
|
||||
query(const oatpp_authkit::repo::Query<MockTemporalDto>&) override {
|
||||
return list(); // pretend the filter ran; the point is the guard filters scope
|
||||
}
|
||||
};
|
||||
|
||||
// query() through ScopeGuardQueryable must drop rows outside the actor's scope —
|
||||
// otherwise the queryable surface bypasses the scope guard entirely.
|
||||
void test_scope_guard_queryable_filters_query() {
|
||||
using namespace oatpp_authkit::repo;
|
||||
auto inner = std::make_shared<InMemoryQueryable>();
|
||||
for (const char* sc : {"prop-A", "prop-B"}) {
|
||||
auto dto = MockTemporalDto::createShared();
|
||||
dto->id = oatpp::String(std::string("pk-") + sc);
|
||||
dto->entity_id = oatpp::String(sc);
|
||||
dto->valid_until = oatpp::String("9999-12-31T23:59:59Z");
|
||||
dto->scope = oatpp::String(sc);
|
||||
inner->save(dto);
|
||||
}
|
||||
|
||||
ActorContext actor;
|
||||
actor.userId = "u1";
|
||||
actor.allowedScopes = {"prop-A"};
|
||||
|
||||
ScopeGuardQueryable<MockTemporalDto> guarded(
|
||||
inner, &scopeAllows, [actor]{ return actor; }, &entityIdOf);
|
||||
|
||||
auto result = guarded.query(Query<MockTemporalDto>{});
|
||||
REQUIRE(result->size() == 1); // prop-B filtered out
|
||||
REQUIRE(std::string(*(*result)[0]->scope) == "prop-A");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
|
|
@ -293,6 +427,8 @@ int main() {
|
|||
test_history_returns_versions_in_order();
|
||||
test_soft_delete_closes_live_without_new_version();
|
||||
test_scope_guard_denies_when_predicate_false();
|
||||
test_scope_guard_blocks_reparenting();
|
||||
test_scope_guard_queryable_filters_query();
|
||||
|
||||
std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures);
|
||||
return g_failures ? 1 : 0;
|
||||
|
|
|
|||
114
test/test_role_template_schema.cpp
Normal file
114
test/test_role_template_schema.cpp
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
// Tests for authkit#14 PR 1 — role_templates schema contribution composes
|
||||
// correctly with the TemporalRepository decorator.
|
||||
|
||||
#include "oatpp-authkit/db/RoleTemplateDb.hpp"
|
||||
#include "oatpp-authkit/dto/RoleTemplateDto.hpp"
|
||||
#include "oatpp-authkit/repo/ConcreteRoleTemplateRepository.hpp"
|
||||
#include "oatpp-authkit/repo/SchemaContract.hpp"
|
||||
#include "oatpp-authkit/repo/TemporalRepository.hpp"
|
||||
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#define REQUIRE(cond) do { \
|
||||
if (!(cond)) { std::fprintf(stderr, "REQUIRE failed: %s @ %s:%d\n", \
|
||||
#cond, __FILE__, __LINE__); std::abort(); } } while (0)
|
||||
|
||||
namespace {
|
||||
|
||||
bool contains(const std::string& haystack, const std::string& needle) {
|
||||
return haystack.find(needle) != std::string::npos;
|
||||
}
|
||||
|
||||
// SchemaBuilder<RoleTemplateSchema, TemporalRepository<RoleTemplateDto>>
|
||||
// emits the three tables + their indexes. Verify the composition.
|
||||
void test_role_templates_full_create() {
|
||||
using namespace oatpp_authkit::repo;
|
||||
using namespace oatpp_authkit::db;
|
||||
using namespace oatpp_authkit::dto;
|
||||
|
||||
std::vector<std::string> sqls;
|
||||
SqlExec exec = [&](const std::string& sql) { sqls.push_back(sql); };
|
||||
|
||||
SchemaBuilder<
|
||||
RoleTemplateSchema,
|
||||
TemporalRepository<RoleTemplateDto>>::create("role_templates", exec);
|
||||
|
||||
// Two sidecars (role_template_fields + user_role_assignments) +
|
||||
// one entity table + one entity_id index + one composite UNIQUE index
|
||||
// = 5
|
||||
REQUIRE(sqls.size() == 5);
|
||||
|
||||
// Sidecar 1: role_template_fields with composite FK
|
||||
REQUIRE(contains(sqls[0], "CREATE TABLE IF NOT EXISTS role_template_fields"));
|
||||
REQUIRE(contains(sqls[0], "template_id TEXT NOT NULL"));
|
||||
REQUIRE(contains(sqls[0], "template_valid_until TEXT NOT NULL DEFAULT '9999-12-31T23:59:59Z'"));
|
||||
REQUIRE(contains(sqls[0],
|
||||
"FOREIGN KEY (template_id, template_valid_until) REFERENCES "
|
||||
"role_templates(entity_id, valid_until) ON UPDATE CASCADE"));
|
||||
|
||||
// Sidecar 2: user_role_assignments with composite FK
|
||||
REQUIRE(contains(sqls[1], "CREATE TABLE IF NOT EXISTS user_role_assignments"));
|
||||
REQUIRE(contains(sqls[1], "user_id TEXT NOT NULL"));
|
||||
REQUIRE(contains(sqls[1],
|
||||
"FOREIGN KEY (template_id, template_valid_until) REFERENCES "
|
||||
"role_templates(entity_id, valid_until) ON UPDATE CASCADE"));
|
||||
|
||||
// Entity table: role_templates with all RoleTemplateSchema columns +
|
||||
// valid_until from TemporalRepository.
|
||||
REQUIRE(contains(sqls[2], "CREATE TABLE IF NOT EXISTS role_templates"));
|
||||
REQUIRE(contains(sqls[2], "id TEXT PRIMARY KEY"));
|
||||
REQUIRE(contains(sqls[2], "entity_id TEXT NOT NULL"));
|
||||
REQUIRE(contains(sqls[2], "name TEXT NOT NULL"));
|
||||
REQUIRE(contains(sqls[2], "is_system INTEGER NOT NULL DEFAULT 0"));
|
||||
REQUIRE(contains(sqls[2], "valid_from TEXT NOT NULL DEFAULT (datetime('now'))"));
|
||||
REQUIRE(contains(sqls[2], "valid_until TEXT NOT NULL DEFAULT '9999-12-31T23:59:59Z'"));
|
||||
|
||||
// Indexes: ix_role_templates_entity_id (RoleTemplateSchema)
|
||||
// ux_role_templates_entity_valid_until (TemporalRepository)
|
||||
REQUIRE(contains(sqls[3], "CREATE INDEX IF NOT EXISTS ix_role_templates_entity_id"));
|
||||
REQUIRE(contains(sqls[3], "ON role_templates (entity_id)"));
|
||||
REQUIRE(contains(sqls[4], "CREATE UNIQUE INDEX IF NOT EXISTS ux_role_templates_entity_valid_until"));
|
||||
REQUIRE(contains(sqls[4], "ON role_templates (entity_id, valid_until)"));
|
||||
}
|
||||
|
||||
// Verify that ConcreteRoleTemplateRepository contributes nothing to the
|
||||
// schema — RoleTemplateSchema owns the table declarations, the concrete
|
||||
// repo only adapts queries. Stacking the concrete repo into the builder
|
||||
// must not duplicate columns.
|
||||
void test_concrete_repo_contributes_no_schema() {
|
||||
using namespace oatpp_authkit::repo;
|
||||
using namespace oatpp_authkit::db;
|
||||
using namespace oatpp_authkit::dto;
|
||||
|
||||
std::vector<std::string> sqls_with;
|
||||
std::vector<std::string> sqls_without;
|
||||
|
||||
SchemaBuilder<
|
||||
RoleTemplateSchema,
|
||||
ConcreteRoleTemplateRepository,
|
||||
TemporalRepository<RoleTemplateDto>>::create(
|
||||
"role_templates",
|
||||
[&](const std::string& s){ sqls_with.push_back(s); });
|
||||
|
||||
SchemaBuilder<
|
||||
RoleTemplateSchema,
|
||||
TemporalRepository<RoleTemplateDto>>::create(
|
||||
"role_templates",
|
||||
[&](const std::string& s){ sqls_without.push_back(s); });
|
||||
|
||||
// Including ConcreteRoleTemplateRepository in the pack changes nothing
|
||||
// — empty kSchema contributes no DDL.
|
||||
REQUIRE(sqls_with == sqls_without);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
test_role_templates_full_create();
|
||||
test_concrete_repo_contributes_no_schema();
|
||||
std::printf("test_role_template_schema: OK\n");
|
||||
return 0;
|
||||
}
|
||||
242
test/test_schema_contract.cpp
Normal file
242
test/test_schema_contract.cpp
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
// Tests for authkit#14 — declarative schema contract (D-replace).
|
||||
//
|
||||
// Verifies SchemaBuilder composes decorator contributions into a single
|
||||
// CREATE TABLE per entity, sidecar tables emit separately, and
|
||||
// SchemaContract::verify catches missing columns/tables.
|
||||
//
|
||||
// No real SQL engine — `exec` collects emitted DDL strings and `probe`
|
||||
// is driven by a fake "known-rows" set.
|
||||
|
||||
#include "oatpp-authkit/repo/SchemaContract.hpp"
|
||||
#include "oatpp-authkit/repo/TemporalRepository.hpp"
|
||||
#include "oatpp-authkit/repo/AuditLogRepository.hpp"
|
||||
#include "oatpp-authkit/repo/ScopeGuardRepository.hpp"
|
||||
#include "oatpp-authkit/repo/TemporalFieldTraits.hpp"
|
||||
|
||||
#include "oatpp/core/macro/codegen.hpp"
|
||||
#include "oatpp/core/Types.hpp"
|
||||
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#define REQUIRE(cond) do { \
|
||||
if (!(cond)) { std::fprintf(stderr, "REQUIRE failed: %s @ %s:%d\n", \
|
||||
#cond, __FILE__, __LINE__); std::abort(); } } while (0)
|
||||
|
||||
#include OATPP_CODEGEN_BEGIN(DTO)
|
||||
|
||||
namespace {
|
||||
|
||||
class TestDto : public oatpp::DTO {
|
||||
DTO_INIT(TestDto, DTO)
|
||||
DTO_FIELD(String, id);
|
||||
DTO_FIELD(String, entity_id);
|
||||
DTO_FIELD(String, valid_from);
|
||||
DTO_FIELD(String, valid_until);
|
||||
DTO_FIELD(String, name);
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
#include OATPP_CODEGEN_END(DTO)
|
||||
|
||||
OATPP_AUTHKIT_REGISTER_TEMPORAL(TestDto, id, entity_id, valid_from, valid_until)
|
||||
|
||||
namespace {
|
||||
|
||||
using namespace oatpp_authkit::repo;
|
||||
|
||||
// A minimal "concrete repo" stand-in that contributes the entity_id +
|
||||
// id + name columns. Concrete repos in real consumers (e.g. fewo's
|
||||
// ConcretePersonRepository) would expose kSchema the same way.
|
||||
class TestConcreteRepo {
|
||||
public:
|
||||
inline static constexpr ColumnSpec kColumns[] = {
|
||||
{"id", "INTEGER PRIMARY KEY AUTOINCREMENT"},
|
||||
{"entity_id", "TEXT NOT NULL"},
|
||||
{"name", "TEXT NOT NULL"},
|
||||
};
|
||||
inline static constexpr DecoratorSchema kSchema = {
|
||||
"TestConcreteRepo",
|
||||
kColumns, sizeof(kColumns) / sizeof(kColumns[0]),
|
||||
nullptr, 0,
|
||||
nullptr, 0,
|
||||
};
|
||||
};
|
||||
|
||||
struct FakeDb {
|
||||
std::vector<std::string> execLog;
|
||||
std::set<std::string> knownTrue;
|
||||
|
||||
SqlExec exec() { return [this](const std::string& s){ execLog.push_back(s); }; }
|
||||
SqlProbe probe() { return [this](const std::string& s){ return knownTrue.count(s) > 0; }; }
|
||||
};
|
||||
|
||||
bool contains(const std::string& haystack, const std::string& needle) {
|
||||
return haystack.find(needle) != std::string::npos;
|
||||
}
|
||||
|
||||
// Test 1: instantiate substitutes {table}.
|
||||
void test_instantiate() {
|
||||
REQUIRE(instantiate("SELECT * FROM {table}", "persons") == "SELECT * FROM persons");
|
||||
REQUIRE(instantiate("ix_{table}_a_{table}_b", "p") == "ix_p_a_p_b");
|
||||
REQUIRE(instantiate("no placeholder", "tbl") == "no placeholder");
|
||||
}
|
||||
|
||||
// Test 2: SchemaBuilder emits one CREATE per entity table with all
|
||||
// composed columns and a CREATE INDEX per index spec.
|
||||
void test_builder_composes_entity_table() {
|
||||
FakeDb db;
|
||||
SchemaBuilder<
|
||||
TestConcreteRepo,
|
||||
TemporalRepository<TestDto>,
|
||||
ScopeGuardRepository<TestDto>>::create("persons", db.exec());
|
||||
|
||||
// No sidecars from this stack → one CREATE TABLE + one CREATE INDEX.
|
||||
REQUIRE(db.execLog.size() == 2);
|
||||
const auto& table = db.execLog[0];
|
||||
REQUIRE(contains(table, "CREATE TABLE IF NOT EXISTS persons"));
|
||||
REQUIRE(contains(table, "id INTEGER PRIMARY KEY AUTOINCREMENT"));
|
||||
REQUIRE(contains(table, "entity_id TEXT NOT NULL"));
|
||||
REQUIRE(contains(table, "name TEXT NOT NULL"));
|
||||
REQUIRE(contains(table, "valid_from"));
|
||||
REQUIRE(contains(table, "valid_until"));
|
||||
|
||||
const auto& idx = db.execLog[1];
|
||||
REQUIRE(contains(idx, "CREATE UNIQUE INDEX IF NOT EXISTS ux_persons_entity_valid_until"));
|
||||
REQUIRE(contains(idx, "ON persons (entity_id, valid_until)"));
|
||||
}
|
||||
|
||||
// Test 3: AuditLog contributes a sidecar table; entity table is unaffected.
|
||||
void test_builder_emits_sidecar() {
|
||||
FakeDb db;
|
||||
SchemaBuilder<
|
||||
TestConcreteRepo,
|
||||
AuditLogRepository<TestDto>>::create("persons", db.exec());
|
||||
|
||||
// sidecar (audit_log) emitted first, then entity table.
|
||||
REQUIRE(db.execLog.size() == 2);
|
||||
REQUIRE(contains(db.execLog[0], "CREATE TABLE IF NOT EXISTS audit_log"));
|
||||
REQUIRE(contains(db.execLog[0], "actor_user_id TEXT"));
|
||||
REQUIRE(contains(db.execLog[0], "timestamp_ms INTEGER NOT NULL"));
|
||||
REQUIRE(contains(db.execLog[1], "CREATE TABLE IF NOT EXISTS persons"));
|
||||
// AuditLog adds nothing to the entity table.
|
||||
REQUIRE(!contains(db.execLog[1], "actor_user_id"));
|
||||
}
|
||||
|
||||
// Test 4: ScopeGuard contributes nothing; full stack emits sidecars from
|
||||
// AuditLog + entity table with Temporal columns + temporal index.
|
||||
void test_builder_full_stack() {
|
||||
FakeDb db;
|
||||
SchemaBuilder<
|
||||
TestConcreteRepo,
|
||||
TemporalRepository<TestDto>,
|
||||
ScopeGuardRepository<TestDto>,
|
||||
AuditLogRepository<TestDto>>::create("persons", db.exec());
|
||||
|
||||
// audit_log sidecar + persons table + temporal index = 3
|
||||
REQUIRE(db.execLog.size() == 3);
|
||||
REQUIRE(contains(db.execLog[0], "audit_log"));
|
||||
REQUIRE(contains(db.execLog[1], "CREATE TABLE IF NOT EXISTS persons"));
|
||||
REQUIRE(contains(db.execLog[1], "valid_until"));
|
||||
REQUIRE(contains(db.execLog[2], "ux_persons_entity_valid_until"));
|
||||
}
|
||||
|
||||
// Defined at namespace scope: local classes can't carry static data members.
|
||||
struct DupRepo {
|
||||
inline static constexpr ColumnSpec kCols[] = {
|
||||
{"valid_from", "TEXT NOT NULL DEFAULT 'override'"},
|
||||
};
|
||||
inline static constexpr DecoratorSchema kSchema = {
|
||||
"DupRepo",
|
||||
kCols, sizeof(kCols)/sizeof(kCols[0]),
|
||||
nullptr, 0, nullptr, 0,
|
||||
};
|
||||
};
|
||||
|
||||
// Test 5: column dedup — if two layers contribute the same column name,
|
||||
// first wins, no duplicates in the CREATE.
|
||||
void test_builder_dedups_columns() {
|
||||
FakeDb db;
|
||||
SchemaBuilder<
|
||||
TestConcreteRepo,
|
||||
TemporalRepository<TestDto>,
|
||||
DupRepo>::create("persons", db.exec());
|
||||
// The DupRepo's contribution is silently skipped (Temporal got there first).
|
||||
REQUIRE(db.execLog.size() == 2);
|
||||
REQUIRE(!contains(db.execLog[0], "DEFAULT 'override'"));
|
||||
}
|
||||
|
||||
// Test 6: SchemaContract::verify passes when every column is present.
|
||||
void test_verify_pass() {
|
||||
FakeDb db;
|
||||
// Mark every required column as present.
|
||||
db.knownTrue.insert("SELECT 1 FROM pragma_table_info('persons') WHERE name='id'");
|
||||
db.knownTrue.insert("SELECT 1 FROM pragma_table_info('persons') WHERE name='entity_id'");
|
||||
db.knownTrue.insert("SELECT 1 FROM pragma_table_info('persons') WHERE name='name'");
|
||||
db.knownTrue.insert("SELECT 1 FROM pragma_table_info('persons') WHERE name='valid_from'");
|
||||
db.knownTrue.insert("SELECT 1 FROM pragma_table_info('persons') WHERE name='valid_until'");
|
||||
db.knownTrue.insert("SELECT 1 FROM sqlite_master WHERE type='table' AND name='audit_log'");
|
||||
|
||||
// Should not throw.
|
||||
SchemaContract<
|
||||
TestConcreteRepo,
|
||||
TemporalRepository<TestDto>,
|
||||
ScopeGuardRepository<TestDto>,
|
||||
AuditLogRepository<TestDto>>::verify("persons", db.probe());
|
||||
}
|
||||
|
||||
// Test 7: SchemaContract::verify throws when a required column is missing.
|
||||
void test_verify_throws_on_missing_column() {
|
||||
FakeDb db;
|
||||
db.knownTrue.insert("SELECT 1 FROM pragma_table_info('persons') WHERE name='id'");
|
||||
db.knownTrue.insert("SELECT 1 FROM pragma_table_info('persons') WHERE name='entity_id'");
|
||||
db.knownTrue.insert("SELECT 1 FROM pragma_table_info('persons') WHERE name='name'");
|
||||
// valid_from missing on purpose.
|
||||
db.knownTrue.insert("SELECT 1 FROM pragma_table_info('persons') WHERE name='valid_until'");
|
||||
|
||||
bool threw = false;
|
||||
try {
|
||||
SchemaContract<
|
||||
TestConcreteRepo,
|
||||
TemporalRepository<TestDto>>::verify("persons", db.probe());
|
||||
} catch (const SchemaContractViolation& e) {
|
||||
threw = true;
|
||||
REQUIRE(contains(e.what(), "valid_from"));
|
||||
REQUIRE(contains(e.what(), "TemporalRepository"));
|
||||
}
|
||||
REQUIRE(threw);
|
||||
}
|
||||
|
||||
// Test 8: SchemaContract::verify throws when a sidecar is missing.
|
||||
void test_verify_throws_on_missing_sidecar() {
|
||||
FakeDb db;
|
||||
// No audit_log table registered.
|
||||
bool threw = false;
|
||||
try {
|
||||
SchemaContract<AuditLogRepository<TestDto>>::verify("persons", db.probe());
|
||||
} catch (const SchemaContractViolation& e) {
|
||||
threw = true;
|
||||
REQUIRE(contains(e.what(), "audit_log"));
|
||||
REQUIRE(contains(e.what(), "AuditLogRepository"));
|
||||
}
|
||||
REQUIRE(threw);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
test_instantiate();
|
||||
test_builder_composes_entity_table();
|
||||
test_builder_emits_sidecar();
|
||||
test_builder_full_stack();
|
||||
test_builder_dedups_columns();
|
||||
test_verify_pass();
|
||||
test_verify_throws_on_missing_column();
|
||||
test_verify_throws_on_missing_sidecar();
|
||||
std::printf("test_schema_contract: OK\n");
|
||||
return 0;
|
||||
}
|
||||
75
test/test_session_cookie.cpp
Normal file
75
test/test_session_cookie.cpp
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
// Tests for oatpp-authkit/util/SessionCookie.hpp (authkit#16 M-9).
|
||||
|
||||
#include "oatpp-authkit/util/SessionCookie.hpp"
|
||||
|
||||
#include <cstdio>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
namespace {
|
||||
|
||||
int g_failures = 0;
|
||||
#define REQUIRE(expr) do { \
|
||||
if (!(expr)) { \
|
||||
std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \
|
||||
++g_failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
using namespace oatpp_authkit;
|
||||
|
||||
bool has(const std::string& hay, const std::string& needle) {
|
||||
return hay.find(needle) != std::string::npos;
|
||||
}
|
||||
|
||||
void test_defaults_are_hardened() {
|
||||
std::string c = buildSetSessionCookie("tok123");
|
||||
REQUIRE(has(c, "session=tok123"));
|
||||
REQUIRE(has(c, "Path=/"));
|
||||
REQUIRE(has(c, "HttpOnly"));
|
||||
REQUIRE(has(c, "Secure"));
|
||||
REQUIRE(has(c, "SameSite=Strict"));
|
||||
REQUIRE(!has(c, "Max-Age")); // session cookie by default
|
||||
}
|
||||
|
||||
void test_options_respected() {
|
||||
SessionCookieOptions o;
|
||||
o.name = "__Host-session";
|
||||
o.secure = false; // dev opt-out
|
||||
o.sameSite = "Lax";
|
||||
o.maxAgeSeconds = 3600;
|
||||
std::string c = buildSetSessionCookie("t", o);
|
||||
REQUIRE(has(c, "__Host-session=t"));
|
||||
REQUIRE(!has(c, "Secure"));
|
||||
REQUIRE(has(c, "SameSite=Lax"));
|
||||
REQUIRE(has(c, "Max-Age=3600"));
|
||||
}
|
||||
|
||||
void test_clear_cookie_expires_now() {
|
||||
std::string c = buildClearSessionCookie();
|
||||
REQUIRE(has(c, "Max-Age=0"));
|
||||
REQUIRE(has(c, "session="));
|
||||
}
|
||||
|
||||
void test_injection_guard() {
|
||||
bool threw = false;
|
||||
try { buildSetSessionCookie("tok\r\nSet-Cookie: evil=1"); }
|
||||
catch (const std::invalid_argument&) { threw = true; }
|
||||
REQUIRE(threw);
|
||||
|
||||
bool threw2 = false;
|
||||
try { buildSetSessionCookie("tok; Domain=evil.com"); } // ';' injection
|
||||
catch (const std::invalid_argument&) { threw2 = true; }
|
||||
REQUIRE(threw2);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
test_defaults_are_hardened();
|
||||
test_options_respected();
|
||||
test_clear_cookie_expires_now();
|
||||
test_injection_guard();
|
||||
std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures);
|
||||
return g_failures ? 1 : 0;
|
||||
}
|
||||
75
test/test_smtp_transport.cpp
Normal file
75
test/test_smtp_transport.cpp
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
// Tests for oatpp-authkit/mail/SmtpTransport.hpp.
|
||||
//
|
||||
// Covers the pure, network-free surface:
|
||||
// - base64Encode against RFC 4648 vectors
|
||||
// - hasHeaderInjectionChars
|
||||
// - send() rejects CR/LF/NUL in recipient / from address BEFORE touching
|
||||
// libcurl (the SMTP header-injection guard) — no live mail server needed,
|
||||
// the validation short-circuits ahead of curl_easy_init / perform.
|
||||
|
||||
#include "oatpp-authkit/mail/SmtpTransport.hpp"
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
namespace {
|
||||
|
||||
int g_failures = 0;
|
||||
|
||||
#define REQUIRE(expr) do { \
|
||||
if (!(expr)) { \
|
||||
std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \
|
||||
++g_failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
using namespace oatpp_authkit::mail;
|
||||
|
||||
void test_base64_rfc4648_vectors() {
|
||||
REQUIRE(base64Encode("") == "");
|
||||
REQUIRE(base64Encode("f") == "Zg==");
|
||||
REQUIRE(base64Encode("fo") == "Zm8=");
|
||||
REQUIRE(base64Encode("foo") == "Zm9v");
|
||||
REQUIRE(base64Encode("foob") == "Zm9vYg==");
|
||||
REQUIRE(base64Encode("fooba") == "Zm9vYmE=");
|
||||
REQUIRE(base64Encode("foobar") == "Zm9vYmFy");
|
||||
}
|
||||
|
||||
void test_header_injection_detector() {
|
||||
REQUIRE(!hasHeaderInjectionChars("a@b.com"));
|
||||
REQUIRE( hasHeaderInjectionChars("a@b.com\r\nBcc: evil@x.com"));
|
||||
REQUIRE( hasHeaderInjectionChars("a@b.com\n"));
|
||||
REQUIRE( hasHeaderInjectionChars("a@b.com\r"));
|
||||
REQUIRE( hasHeaderInjectionChars(std::string("a@b.com\0x", 9))); // embedded NUL
|
||||
}
|
||||
|
||||
void test_send_rejects_crlf_in_addresses() {
|
||||
SmtpConfig cfg;
|
||||
cfg.host = "localhost";
|
||||
cfg.fromAddress = "noreply@example.com";
|
||||
|
||||
// CRLF in recipient → rejected with no network call.
|
||||
std::string r1 = send("victim@example.com\r\nBcc: evil@x.com",
|
||||
"subject", "<p>hi</p>", {}, cfg);
|
||||
REQUIRE(r1.find("invalid recipient") != std::string::npos);
|
||||
|
||||
// CRLF in from address → rejected.
|
||||
SmtpConfig cfg2 = cfg;
|
||||
cfg2.fromAddress = "noreply@example.com\r\nSubject: spoofed";
|
||||
std::string r2 = send("victim@example.com", "subject", "<p>hi</p>", {}, cfg2);
|
||||
REQUIRE(r2.find("invalid from") != std::string::npos);
|
||||
|
||||
// Empty-config guards still fire (and come before the address checks).
|
||||
SmtpConfig empty;
|
||||
REQUIRE(send("a@b.com", "s", "b", {}, empty).find("no host") != std::string::npos);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
test_base64_rfc4648_vectors();
|
||||
test_header_injection_detector();
|
||||
test_send_rejects_crlf_in_addresses();
|
||||
std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures);
|
||||
return g_failures ? 1 : 0;
|
||||
}
|
||||
|
|
@ -29,7 +29,8 @@ namespace {
|
|||
// TemporalRepository<T> couldn't reach these fields.
|
||||
class OddNamesDto : public oatpp::DTO {
|
||||
DTO_INIT(OddNamesDto, DTO)
|
||||
DTO_FIELD(String, id);
|
||||
DTO_FIELD(String, row_pk);
|
||||
DTO_FIELD(String, id); // entity_id (logical), per the original test intent
|
||||
DTO_FIELD(String, effective_from);
|
||||
DTO_FIELD(String, effective_until);
|
||||
DTO_FIELD(String, payload);
|
||||
|
|
@ -39,7 +40,7 @@ class OddNamesDto : public oatpp::DTO {
|
|||
|
||||
} // namespace
|
||||
|
||||
OATPP_AUTHKIT_REGISTER_TEMPORAL(OddNamesDto, id, effective_from, effective_until)
|
||||
OATPP_AUTHKIT_REGISTER_TEMPORAL(OddNamesDto, row_pk, id, effective_from, effective_until)
|
||||
|
||||
namespace {
|
||||
|
||||
|
|
@ -55,10 +56,10 @@ int g_failures = 0;
|
|||
// Same in-memory adapter shape as the decorator tests — keys rows by
|
||||
// (id, effective_from), exposes ALL rows via list().
|
||||
class InMemoryAllRows : public oatpp_authkit::repo::Repository<OddNamesDto> {
|
||||
std::map<std::pair<std::string, std::string>, oatpp::Object<OddNamesDto>> rows;
|
||||
std::map<std::string, oatpp::Object<OddNamesDto>> rows; // keyed by row_pk
|
||||
public:
|
||||
oatpp::Object<OddNamesDto> findByEntityId(const oatpp::String& id) override {
|
||||
for (auto& kv : rows) if (kv.first.first == std::string(*id)) return kv.second;
|
||||
for (auto& kv : rows) if (kv.second->id && std::string(*kv.second->id) == std::string(*id)) return kv.second;
|
||||
return nullptr;
|
||||
}
|
||||
oatpp::Vector<oatpp::Object<OddNamesDto>> list() override {
|
||||
|
|
@ -67,11 +68,11 @@ public:
|
|||
return v;
|
||||
}
|
||||
void save(const oatpp::Object<OddNamesDto>& dto) override {
|
||||
rows[{*dto->id, *dto->effective_from}] = dto;
|
||||
rows[std::string(*dto->row_pk)] = dto;
|
||||
}
|
||||
void softDelete(const oatpp::String& id) override {
|
||||
for (auto it = rows.begin(); it != rows.end(); ) {
|
||||
if (it->first.first == std::string(*id)) it = rows.erase(it); else ++it;
|
||||
if (it->second->id && std::string(*it->second->id) == std::string(*id)) it = rows.erase(it); else ++it;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
67
test/test_token_extract.cpp
Normal file
67
test/test_token_extract.cpp
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
// Tests for oatpp-authkit/util/TokenExtract.hpp — exact-name cookie parsing
|
||||
// (authkit#16 M-1) and isValidIp.
|
||||
|
||||
#include "oatpp-authkit/util/TokenExtract.hpp"
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
namespace {
|
||||
|
||||
int g_failures = 0;
|
||||
#define REQUIRE(expr) do { \
|
||||
if (!(expr)) { \
|
||||
std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \
|
||||
++g_failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
using namespace oatpp_authkit;
|
||||
|
||||
void test_cookie_exact_name_match() {
|
||||
// Basic.
|
||||
REQUIRE(cookieValue("session=abc", "session") == "abc");
|
||||
REQUIRE(cookieValue("session=abc; other=1", "session") == "abc");
|
||||
REQUIRE(cookieValue("other=1; session=abc", "session") == "abc");
|
||||
REQUIRE(cookieValue("other=1; session=abc; more=2", "session") == "abc");
|
||||
|
||||
// OWS trimming around the pair and value.
|
||||
REQUIRE(cookieValue("a=1; session=abc ; b=2", "session") == "abc");
|
||||
|
||||
// The substring trap: a prefixed/suffixed cookie name must NOT match.
|
||||
REQUIRE(cookieValue("xsession=evil", "session") == "");
|
||||
REQUIRE(cookieValue("notsession=evil", "session") == "");
|
||||
REQUIRE(cookieValue("my_session=evil", "session") == "");
|
||||
// Attacker plants a sibling cookie before the real one: exact match still
|
||||
// returns the genuine session value, not the shadow.
|
||||
REQUIRE(cookieValue("xsession=evil; session=real", "session") == "real");
|
||||
REQUIRE(cookieValue("session=real; xsession=evil", "session") == "real");
|
||||
|
||||
// Missing / empty.
|
||||
REQUIRE(cookieValue("", "session") == "");
|
||||
REQUIRE(cookieValue("foo=bar", "session") == "");
|
||||
REQUIRE(cookieValue("session=", "session") == "");
|
||||
|
||||
// __Host- prefixed name is matched only as an exact name.
|
||||
REQUIRE(cookieValue("__Host-session=tok", "__Host-session") == "tok");
|
||||
REQUIRE(cookieValue("__Host-session=tok", "session") == "");
|
||||
}
|
||||
|
||||
void test_is_valid_ip() {
|
||||
REQUIRE(isValidIp("192.168.1.1"));
|
||||
REQUIRE(isValidIp("::1"));
|
||||
REQUIRE(isValidIp("2001:db8::1"));
|
||||
REQUIRE(!isValidIp("192.168.1.256"));
|
||||
REQUIRE(!isValidIp("1.1.1.1; rm -rf"));
|
||||
REQUIRE(!isValidIp(""));
|
||||
REQUIRE(!isValidIp(std::string(46, 'a'))); // over length cap
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
test_cookie_exact_name_match();
|
||||
test_is_valid_ip();
|
||||
std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures);
|
||||
return g_failures ? 1 : 0;
|
||||
}
|
||||
82
test/test_user_permission_schema.cpp
Normal file
82
test/test_user_permission_schema.cpp
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
// Tests for authkit#14 PRs 2 & 3 — user_property_permissions and
|
||||
// user_group_permissions schemas compose correctly with TemporalRepository.
|
||||
|
||||
#include "oatpp-authkit/db/UserPermissionDb.hpp"
|
||||
#include "oatpp-authkit/dto/UserPermissionDto.hpp"
|
||||
#include "oatpp-authkit/repo/ConcreteUserPermissionRepository.hpp"
|
||||
#include "oatpp-authkit/repo/SchemaContract.hpp"
|
||||
#include "oatpp-authkit/repo/TemporalRepository.hpp"
|
||||
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#define REQUIRE(cond) do { \
|
||||
if (!(cond)) { std::fprintf(stderr, "REQUIRE failed: %s @ %s:%d\n", \
|
||||
#cond, __FILE__, __LINE__); std::abort(); } } while (0)
|
||||
|
||||
namespace {
|
||||
|
||||
bool contains(const std::string& haystack, const std::string& needle) {
|
||||
return haystack.find(needle) != std::string::npos;
|
||||
}
|
||||
|
||||
void test_user_property_permissions_create() {
|
||||
using namespace oatpp_authkit::repo;
|
||||
using namespace oatpp_authkit::db;
|
||||
using namespace oatpp_authkit::dto;
|
||||
|
||||
std::vector<std::string> sqls;
|
||||
SqlExec exec = [&](const std::string& sql) { sqls.push_back(sql); };
|
||||
|
||||
SchemaBuilder<
|
||||
UserPropertyPermissionSchema,
|
||||
TemporalRepository<UserPropertyPermissionDto>>::create(
|
||||
"user_property_permissions", exec);
|
||||
|
||||
// 1 entity table + 3 schema-side indexes + 1 temporal index = 5
|
||||
REQUIRE(sqls.size() == 5);
|
||||
|
||||
REQUIRE(contains(sqls[0], "CREATE TABLE IF NOT EXISTS user_property_permissions"));
|
||||
REQUIRE(contains(sqls[0], "user_id TEXT NOT NULL"));
|
||||
REQUIRE(contains(sqls[0], "property_id TEXT NOT NULL"));
|
||||
REQUIRE(contains(sqls[0], "permission TEXT NOT NULL DEFAULT 'readonly'"));
|
||||
REQUIRE(contains(sqls[0], "valid_until TEXT NOT NULL DEFAULT '9999-12-31T23:59:59Z'"));
|
||||
|
||||
// Indexes: 3 from UserPropertyPermissionSchema in order, then 1 from TemporalRepository.
|
||||
REQUIRE(contains(sqls[1], "ix_user_property_permissions_entity_id"));
|
||||
REQUIRE(contains(sqls[2], "ix_user_property_permissions_user_id"));
|
||||
REQUIRE(contains(sqls[3], "ux_user_property_permissions_user_property_until"));
|
||||
REQUIRE(contains(sqls[3], "(user_id, property_id, valid_until)"));
|
||||
REQUIRE(contains(sqls[4], "ux_user_property_permissions_entity_valid_until"));
|
||||
}
|
||||
|
||||
void test_user_group_permissions_create() {
|
||||
using namespace oatpp_authkit::repo;
|
||||
using namespace oatpp_authkit::db;
|
||||
using namespace oatpp_authkit::dto;
|
||||
|
||||
std::vector<std::string> sqls;
|
||||
SqlExec exec = [&](const std::string& sql) { sqls.push_back(sql); };
|
||||
|
||||
SchemaBuilder<
|
||||
UserGroupPermissionSchema,
|
||||
TemporalRepository<UserGroupPermissionDto>>::create(
|
||||
"user_group_permissions", exec);
|
||||
|
||||
REQUIRE(sqls.size() == 5);
|
||||
REQUIRE(contains(sqls[0], "CREATE TABLE IF NOT EXISTS user_group_permissions"));
|
||||
REQUIRE(contains(sqls[0], "set_id TEXT NOT NULL"));
|
||||
REQUIRE(contains(sqls[3], "ux_user_group_permissions_user_set_until"));
|
||||
REQUIRE(contains(sqls[3], "(user_id, set_id, valid_until)"));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
test_user_property_permissions_create();
|
||||
test_user_group_permissions_create();
|
||||
std::printf("test_user_permission_schema: OK\n");
|
||||
return 0;
|
||||
}
|
||||
88
test/test_user_schema.cpp
Normal file
88
test/test_user_schema.cpp
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
// Tests for authkit#14 PR 4 — temporal users schema.
|
||||
|
||||
#include "oatpp-authkit/db/UserDb.hpp"
|
||||
#include "oatpp-authkit/dto/UserDto.hpp"
|
||||
#include "oatpp-authkit/repo/ConcreteUserRepository.hpp"
|
||||
#include "oatpp-authkit/repo/SchemaContract.hpp"
|
||||
#include "oatpp-authkit/repo/TemporalRepository.hpp"
|
||||
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#define REQUIRE(cond) do { \
|
||||
if (!(cond)) { std::fprintf(stderr, "REQUIRE failed: %s @ %s:%d\n", \
|
||||
#cond, __FILE__, __LINE__); std::abort(); } } while (0)
|
||||
|
||||
namespace {
|
||||
|
||||
bool contains(const std::string& haystack, const std::string& needle) {
|
||||
return haystack.find(needle) != std::string::npos;
|
||||
}
|
||||
|
||||
void test_users_temporal_create() {
|
||||
using namespace oatpp_authkit::repo;
|
||||
using namespace oatpp_authkit::db;
|
||||
using namespace oatpp_authkit::dto;
|
||||
|
||||
std::vector<std::string> sqls;
|
||||
SqlExec exec = [&](const std::string& sql) { sqls.push_back(sql); };
|
||||
|
||||
SchemaBuilder<
|
||||
UserSchema,
|
||||
TemporalRepository<UserDto>>::create("users", exec);
|
||||
|
||||
// 1 entity table + 3 schema-side indexes + 1 temporal composite index = 5
|
||||
REQUIRE(sqls.size() == 5);
|
||||
|
||||
REQUIRE(contains(sqls[0], "CREATE TABLE IF NOT EXISTS users"));
|
||||
REQUIRE(contains(sqls[0], "id TEXT PRIMARY KEY"));
|
||||
REQUIRE(contains(sqls[0], "entity_id TEXT NOT NULL"));
|
||||
REQUIRE(contains(sqls[0], "username TEXT NOT NULL"));
|
||||
REQUIRE(contains(sqls[0], "password_hash TEXT"));
|
||||
REQUIRE(contains(sqls[0], "role TEXT NOT NULL DEFAULT 'editor'"));
|
||||
REQUIRE(contains(sqls[0], "valid_from TEXT NOT NULL DEFAULT ''"));
|
||||
REQUIRE(contains(sqls[0], "valid_until TEXT NOT NULL DEFAULT '9999-12-31T23:59:59Z'"));
|
||||
// is_active and created_at must NOT appear — those are dropped in
|
||||
// the temporal shape (Option B).
|
||||
REQUIRE(!contains(sqls[0], "is_active"));
|
||||
REQUIRE(!contains(sqls[0], "created_at"));
|
||||
|
||||
REQUIRE(contains(sqls[1], "ix_users_entity_id"));
|
||||
REQUIRE(contains(sqls[2], "ux_users_username_until"));
|
||||
REQUIRE(contains(sqls[2], "(username, valid_until)"));
|
||||
REQUIRE(contains(sqls[3], "ix_users_tls_cert_dn"));
|
||||
REQUIRE(contains(sqls[4], "ux_users_entity_valid_until"));
|
||||
REQUIRE(contains(sqls[4], "(entity_id, valid_until)"));
|
||||
}
|
||||
|
||||
// ConcreteUserRepository contributes nothing; ensure SchemaBuilder is
|
||||
// idempotent w.r.t. its presence in the parameter pack.
|
||||
void test_concrete_user_repo_no_schema() {
|
||||
using namespace oatpp_authkit::repo;
|
||||
using namespace oatpp_authkit::db;
|
||||
using namespace oatpp_authkit::dto;
|
||||
|
||||
std::vector<std::string> with_repo;
|
||||
std::vector<std::string> without_repo;
|
||||
|
||||
SchemaBuilder<
|
||||
UserSchema, ConcreteUserRepository, TemporalRepository<UserDto>>::create(
|
||||
"users", [&](const std::string& s){ with_repo.push_back(s); });
|
||||
|
||||
SchemaBuilder<
|
||||
UserSchema, TemporalRepository<UserDto>>::create(
|
||||
"users", [&](const std::string& s){ without_repo.push_back(s); });
|
||||
|
||||
REQUIRE(with_repo == without_repo);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
test_users_temporal_create();
|
||||
test_concrete_user_repo_no_schema();
|
||||
std::printf("test_user_schema: OK\n");
|
||||
return 0;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue