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>
379 lines
15 KiB
C++
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
|