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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 14:03:01 +02:00

379 lines
15 KiB
C++

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