- H-1 cert-DN spoofing: IRuntimeConfig::certAuthTrusted() now defaults to false (fail-closed). X-SSL-Client-DN is an ordinary request header; a loopback bind does not prove it came from a TLS-terminating proxy. Consumers must opt in explicitly behind a header-stripping proxy. - H-3 scope reparenting: ScopeGuardRepository::save() now also checks the EXISTING row's scope (via a new required entity-id accessor), so an actor can't claim an out-of-scope row by relabelling it in the request body. - H-2 IQueryable bypass: add ScopeGuardQueryable<T> — filters query() results through the same predicate so the queryable surface can't escape the scope guard. - H-4 TemporalRepository TOCTOU: serialise the read-modify-write with a per-instance mutex (no more duplicate-live / lost-update under concurrent same-entity saves) and add an optional TxRunner so the close-then-insert pair can commit/rollback atomically. - H-5 SMTP header injection: reject CR/LF/NUL in `to`/`fromAddress` before building the envelope and From:/To: header lines. Tests: expand test_repository_decorators (reparenting + queryable filtering), add curl-guarded test_smtp_transport (base64 vectors + CRLF guard). All 15 ctest targets pass. README updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
210 lines
8.8 KiB
C++
210 lines
8.8 KiB
C++
#ifndef OATPP_AUTHKIT_REPO_SCOPE_GUARD_REPOSITORY_HPP
|
|
#define OATPP_AUTHKIT_REPO_SCOPE_GUARD_REPOSITORY_HPP
|
|
|
|
#include "oatpp-authkit/repo/Repository.hpp"
|
|
#include "oatpp-authkit/repo/ActorContext.hpp"
|
|
#include "oatpp-authkit/repo/SchemaContract.hpp"
|
|
#include "oatpp-authkit/repo/IQueryable.hpp"
|
|
|
|
#include "oatpp/core/Types.hpp"
|
|
|
|
#include <functional>
|
|
#include <memory>
|
|
#include <stdexcept>
|
|
#include <utility>
|
|
|
|
namespace oatpp_authkit::repo {
|
|
|
|
/**
|
|
* @brief Thrown when the scope guard predicate denies an operation.
|
|
*
|
|
* Catchers (typically the controller layer) translate this into the
|
|
* appropriate HTTP error — 403 Forbidden in fewo-webapp's case. The
|
|
* decorator stays library-portable by throwing a plain exception rather
|
|
* than coupling to oatpp's `OatppException` hierarchy.
|
|
*/
|
|
class ScopeDeniedException : public std::runtime_error {
|
|
public:
|
|
using std::runtime_error::runtime_error;
|
|
};
|
|
|
|
/**
|
|
* @brief Decorator that gates every repository operation on a predicate.
|
|
*
|
|
* Generic — knows nothing about "property" / "tenant" / any consumer-
|
|
* specific scope concept. The predicate decides; this class just calls it.
|
|
*
|
|
* @section semantics Per-method behaviour
|
|
*
|
|
* - `findByEntityId(id)`: load from inner; if non-null and predicate
|
|
* denies, throw `ScopeDeniedException`. (Information-leak vs. clean
|
|
* error tradeoff: throwing is the safer default — callers that want to
|
|
* silently 404 instead can catch and translate.)
|
|
* - `list()`: load from inner; filter out rows the predicate denies.
|
|
* - `save(dto)`: predicate must pass on the incoming dto AND, for an update,
|
|
* on the row as it currently stands. Checking only the incoming dto would
|
|
* let an actor reparent an out-of-scope row into its own scope by setting
|
|
* the scope field in the request body (BOLA / set-your-own-scope). The
|
|
* existing-row lookup uses the constructor-injected `entityIdOf` accessor.
|
|
* - `softDelete(id)`: load from inner; if denied, throw; otherwise delegate.
|
|
*
|
|
* The actor is provided via a constructor-injected accessor so a single
|
|
* `ScopeGuardRepository` instance can serve many requests with different
|
|
* actors (typically the accessor reads from the per-request authenticated
|
|
* principal — fewo-webapp's `AuthInterceptor` populates one).
|
|
*
|
|
* @note `IQueryable<TDto>` is a *separate* data-access surface. Wrapping an
|
|
* `IQueryable` repo in this decorator does NOT guard `query()` — a
|
|
* caller that obtains the inner queryable would bypass the scope
|
|
* predicate entirely. Use `ScopeGuardQueryable<TDto>` (below) when the
|
|
* inner exposes the queryable capability.
|
|
*/
|
|
template <class TDto>
|
|
class ScopeGuardRepository : public Repository<TDto> {
|
|
public:
|
|
using Predicate = std::function<bool(const ActorContext&, const oatpp::Object<TDto>&)>;
|
|
using ActorAccess = std::function<ActorContext()>;
|
|
/// Extracts the stable `entity_id` from a DTO (e.g. `[](auto& d){ return d->entity_id; }`).
|
|
/// Used to load the existing row on `save()` so an update can't reparent an
|
|
/// out-of-scope row. Returns null for a not-yet-allocated entity (fresh insert).
|
|
using EntityIdAccess = std::function<oatpp::String(const oatpp::Object<TDto>&)>;
|
|
|
|
/// Declarative schema contribution (authkit#14, D-replace).
|
|
/// ScopeGuard touches no schema — empty contributions exposed so it
|
|
/// composes cleanly into `SchemaBuilder<…>` parameter packs.
|
|
inline static constexpr DecoratorSchema kSchema = {
|
|
"ScopeGuardRepository",
|
|
nullptr, 0,
|
|
nullptr, 0,
|
|
nullptr, 0,
|
|
};
|
|
|
|
ScopeGuardRepository(std::shared_ptr<Repository<TDto>> inner,
|
|
Predicate isAllowed,
|
|
ActorAccess currentActor,
|
|
EntityIdAccess entityIdOf)
|
|
: m_inner(std::move(inner))
|
|
, m_isAllowed(std::move(isAllowed))
|
|
, m_currentActor(std::move(currentActor))
|
|
, m_entityIdOf(std::move(entityIdOf))
|
|
{}
|
|
|
|
oatpp::Object<TDto> findByEntityId(const oatpp::String& entityId) override {
|
|
auto row = m_inner->findByEntityId(entityId);
|
|
if (!row) return row;
|
|
if (!m_isAllowed(m_currentActor(), row)) {
|
|
throw ScopeDeniedException("scope guard denied findByEntityId");
|
|
}
|
|
return row;
|
|
}
|
|
|
|
oatpp::Vector<oatpp::Object<TDto>> list() override {
|
|
auto inAll = m_inner->list();
|
|
auto out = oatpp::Vector<oatpp::Object<TDto>>::createShared();
|
|
const ActorContext actor = m_currentActor();
|
|
for (auto& row : *inAll) {
|
|
if (m_isAllowed(actor, row)) out->push_back(row);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
void save(const oatpp::Object<TDto>& dto) override {
|
|
const ActorContext actor = m_currentActor();
|
|
// 1. The incoming DTO must be in scope — you can't write into a scope
|
|
// you don't own.
|
|
if (!m_isAllowed(actor, dto)) {
|
|
throw ScopeDeniedException("scope guard denied save (incoming)");
|
|
}
|
|
// 2. If this is an update of an existing entity, the row as it stands
|
|
// NOW must also be in scope. Otherwise an actor scoped to A could
|
|
// take an entity currently owned by B and reparent it into A simply
|
|
// by setting the scope field in the body. A fresh insert has a null
|
|
// entity_id (or no matching row) and skips this check.
|
|
if (m_entityIdOf) {
|
|
auto eid = m_entityIdOf(dto);
|
|
if (eid) {
|
|
auto existing = m_inner->findByEntityId(eid);
|
|
if (existing && !m_isAllowed(actor, existing)) {
|
|
throw ScopeDeniedException("scope guard denied save (existing row out of scope)");
|
|
}
|
|
}
|
|
}
|
|
m_inner->save(dto);
|
|
}
|
|
|
|
void softDelete(const oatpp::String& entityId) override {
|
|
auto row = m_inner->findByEntityId(entityId);
|
|
if (!row) return; // Nothing to delete; matches Repository<T>::softDelete being a no-op for unknown ids.
|
|
if (!m_isAllowed(m_currentActor(), row)) {
|
|
throw ScopeDeniedException("scope guard denied softDelete");
|
|
}
|
|
m_inner->softDelete(entityId);
|
|
}
|
|
|
|
private:
|
|
std::shared_ptr<Repository<TDto>> m_inner;
|
|
Predicate m_isAllowed;
|
|
ActorAccess m_currentActor;
|
|
EntityIdAccess m_entityIdOf;
|
|
};
|
|
|
|
/**
|
|
* @brief `ScopeGuardRepository` for inners that also expose `IQueryable<TDto>`.
|
|
*
|
|
* `ScopeGuardRepository` guards only the four `Repository<TDto>` methods; the
|
|
* `IQueryable::query()` surface is separate, so a scope-guarded `IQueryable`
|
|
* repo would otherwise leak every row a raw query returns. This decorator
|
|
* closes that hole: it implements `IQueryable<TDto>`, delegates the CRUD
|
|
* methods to an embedded `ScopeGuardRepository` (same predicate / actor /
|
|
* entity-id semantics, including the reparenting check), and post-filters
|
|
* `query()` results through the predicate exactly like `list()` does.
|
|
*
|
|
* Wire this — not the plain `ScopeGuardRepository` — whenever the concrete
|
|
* inner derives from `IQueryable<TDto>`.
|
|
*/
|
|
template <class TDto>
|
|
class ScopeGuardQueryable : public IQueryable<TDto> {
|
|
public:
|
|
using Predicate = typename ScopeGuardRepository<TDto>::Predicate;
|
|
using ActorAccess = typename ScopeGuardRepository<TDto>::ActorAccess;
|
|
using EntityIdAccess = typename ScopeGuardRepository<TDto>::EntityIdAccess;
|
|
|
|
ScopeGuardQueryable(std::shared_ptr<IQueryable<TDto>> inner,
|
|
Predicate isAllowed,
|
|
ActorAccess currentActor,
|
|
EntityIdAccess entityIdOf)
|
|
: m_inner(std::move(inner))
|
|
, m_isAllowed(isAllowed)
|
|
, m_currentActor(currentActor)
|
|
, m_guard(m_inner, std::move(isAllowed), std::move(currentActor), std::move(entityIdOf))
|
|
{}
|
|
|
|
oatpp::Object<TDto> findByEntityId(const oatpp::String& entityId) override {
|
|
return m_guard.findByEntityId(entityId);
|
|
}
|
|
oatpp::Vector<oatpp::Object<TDto>> list() override { return m_guard.list(); }
|
|
void save(const oatpp::Object<TDto>& dto) override { m_guard.save(dto); }
|
|
void softDelete(const oatpp::String& entityId) override { m_guard.softDelete(entityId); }
|
|
|
|
/** @brief Run the inner query, then drop every row the predicate denies. */
|
|
oatpp::Vector<oatpp::Object<TDto>> query(const Query<TDto>& q) override {
|
|
auto rows = m_inner->query(q);
|
|
auto out = oatpp::Vector<oatpp::Object<TDto>>::createShared();
|
|
if (!rows) return out;
|
|
const ActorContext actor = m_currentActor();
|
|
for (auto& row : *rows) {
|
|
if (m_isAllowed(actor, row)) out->push_back(row);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
private:
|
|
std::shared_ptr<IQueryable<TDto>> m_inner;
|
|
Predicate m_isAllowed;
|
|
ActorAccess m_currentActor;
|
|
ScopeGuardRepository<TDto> m_guard;
|
|
};
|
|
|
|
} // namespace oatpp_authkit::repo
|
|
|
|
#endif
|