Files
logos-protocol/cpp/module_proxy.h
T
Dario Gabriel LipicarandClaude Opus 5 d872024847 fix(proxy): authorize against a store that can only hold INBOUND tokens
TokenManager is one flat QHash<QString,QString> with no direction tag, and
both directions write into it under a bare module name: the client saves
the OUTBOUND token under the CALLEE's name, the provider saves the INBOUND
token under the CALLER's name. Last write wins. So a reverse lookup there
can name a module we CALL as the module CALLING us — affirmatively wrong,
and worse than answering "unknown".

The per-identity work did not close this. `forIdentity` splits by CALLING
IDENTITY, a different axis, and it returns `&instance()` for every identity
until `isolateIdentity` runs — whose only production caller is
LogosQmlBridge, on the consumer side. For providers the mechanism is inert.

What has kept it from being an auth hole is TOPOLOGY, by accident rather
than design: with a module as a cdylib in its own host process, the glue
splits the directions across two IMAGES — informModuleToken reaches the
host's store, logos_module_accept_token the cdylib's. Any single-image
configuration puts them back in one map: the in-process plugin host that
Basecamp already uses, local/mobile mode, the shared-runtime migration, and
this repo's own test suite.

So ModuleProxy now takes an optional token store and authorizes against
THAT, defaulting to &instance() — every existing two-argument construction
is byte-identical. Its own m_tokens becomes the inbound record and is
documented as the only store that may name a caller, which is the property
caller-identity recovery will need.

Two things found on the way, both worth their own attention:

* m_tokens had ZERO production writers. Its only feeder is
  LogosAPIProvider::saveToken, which nothing in 43 repos calls, so the
  store was permanently empty in production.
* The isAuthorized/getTokenManager split breaks BOTH ways under isolation:
  privately seeded tokens are invisible AND every ambient token is still
  accepted, re-opening the escalation isolation exists to close. The glue
  comment asserts the opposite.

A test changed the design. The first draft recorded the token BEFORE
forwarding to the provider, justified by a re-entrancy window. That test
went red, and lp_module_accept_token turned out to reach module code only
as far as a store write — it calls nothing back. No window, so the record
moved after the provider's verdict.

Proven red-then-green in three builds: with neither mechanism 3 of 7 fail;
with the record but the scan still on instance() exactly one survives, and
that survivor is what makes it a detector for the SCAN rather than the
record; with the anchor read reverted, the anchor test alone fails. Three
tests are pins that hold on both sides and are labelled as such.

The constant-time fold is preserved: only the receiver object changed.

483/483 protocol tests, the module-impl ABI check, and the mingw cross all
pass; downstream logos-qt-sdk is 239/239 with this tree overridden in
(confirmed reaching by the differing store path).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:50:15 -03:00

166 lines
7.8 KiB
C++

#ifndef MODULE_PROXY_H
#define MODULE_PROXY_H
#include <QObject>
#include <QVariant>
#include <QVariantList>
#include <QHash>
#include <QString>
#include <QJsonArray>
#include <QPointer>
#include <functional>
#include <utility>
#include "token_manager.h"
class LogosProviderObject;
namespace logos {
// The name a module's handshake surface is published under.
//
// A module's initializer is synchronous and routinely calls out — including
// capability_module's requestModule, which capability answers by pushing a
// token back to that same module. The module's BUSINESS object is published
// only after the initializer returns (so a caller keeps waiting at acquire
// until the module is genuinely ready, which is the long-standing contract).
// That left the push unsatisfiable: capability waited for a source that could
// not appear until the initializer returned, and the initializer could not
// return until capability answered.
//
// The handshake object is published BEFORE the initializer runs and carries
// token delivery only. capability can therefore always reach a module, while
// callers of real methods still block at acquire exactly as they always have.
inline QString handshakeObjectName(const QString& moduleName)
{
return moduleName + QStringLiteral("__handshake");
}
} // namespace logos
/**
* @brief ModuleProxy wraps a LogosProviderObject and exposes it as a QObject
* so that Qt Remote Objects can publish it.
*
* All method dispatch, introspection, and event forwarding is delegated
* to the underlying LogosProviderObject*. For legacy QObject-based plugins,
* that provider is a QtProviderObject adapter; for new-API plugins it is
* the plugin's own LogosProviderObject subclass.
*/
class ModuleProxy : public QObject
{
Q_OBJECT
public:
// A host-installed extra authorizer. Returns true if `token` is valid for a
// call arriving over `transportProtocol` ("local" | "tcp" | "tcp_ssl").
// Consulted IN ADDITION to the built-in issued-token scan, so installing one
// only ever grants access to tokens the built-in scan wouldn't (e.g. the
// daemon backs it with TokenStore::lookupByToken to make operator-issued
// named tokens work, with per-token expiry and local_only enforced by the
// transport it's handed).
using TokenValidator = std::function<bool(const QString& token,
const QString& transportProtocol)>;
// `token_store` is the store this proxy AUTHORIZES AGAINST — the host
// anchors plus whatever else the host seeded for this provider's identity.
// It must be the same store the provider's own informModuleToken writes to,
// which for the Qt stack is LogosAPI::getTokenManager() ==
// TokenManager::forIdentity(<this module's name>), NOT the ambient
// instance(). Those are the same object until a host isolates the identity;
// after that they diverge and hardcoding instance() means two different
// failures at once — every token the host seeded privately is invisible
// (inbound calls rejected with no diagnostic), and every token in the
// ambient ring is still accepted (the escalation isolation exists to close).
//
// Defaulted to &TokenManager::instance() so every existing two-argument
// construction keeps scanning exactly what it scanned before. A null
// pointer means the same thing.
explicit ModuleProxy(LogosProviderObject* provider, QObject* parent = nullptr,
TokenManager* token_store = nullptr);
~ModuleProxy();
void setTokenValidator(TokenValidator validator);
// Two explicit Q_INVOKABLE overloads rather than one with a defaulted
// transport arg: the Qt meta-object system matches by full parameter list
// and does not apply C++ default arguments, so the existing QtRO/local
// 3-arg call must remain a real 3-arg method. It forwards to the
// transport-aware 4-arg form with "local" (RemoteTransportHost is always
// local); remote hosts that know their wire (PlainTransportHost) call the
// 4-arg form so a transport-sensitive validator (local_only tokens) can
// enforce it.
Q_INVOKABLE QVariant callRemoteMethod(const QString& authToken, const QString& methodName, const QVariantList& args = QVariantList());
Q_INVOKABLE QVariant callRemoteMethod(const QString& authToken, const QString& methodName, const QVariantList& args, const QString& transportProtocol);
Q_INVOKABLE bool informModuleToken(const QString& authToken, const QString& moduleName, const QString& token);
bool saveToken(const QString& from_module_name, const QString& token);
// getPluginInterface() returns the module's whole interface (methods AND
// events, each tagged with a "type"); getPluginMethods()/getPluginEvents()
// are the type-filtered views. All three derive from the provider's single
// getMethods() call — there is no separate getEvents() vtable method, which
// is what keeps the provider ABI stable across SDK versions.
Q_INVOKABLE QJsonArray getPluginMethods();
Q_INVOKABLE QJsonArray getPluginEvents();
Q_INVOKABLE QJsonArray getPluginInterface();
signals:
void eventResponse(const QString& eventName, const QVariantList& data);
private:
// Returns true when authToken matches a token THIS module has been told about
// (via saveToken / informModuleToken), or one held in this proxy's token
// store, OR the host-installed validator accepts it for `transportProtocol`.
// Empty/unknown tokens are rejected. The built-in comparison is constant-time
// and never early-outs, so neither a correct prefix nor the number of issued
// tokens leaks through timing.
bool isAuthorized(const QString& authToken, const QString& transportProtocol) const;
LogosProviderObject* m_provider;
// THE INBOUND STORE: caller name -> the token that caller may present to us.
// Direction-pure by construction — the only writers are saveToken() and
// informModuleToken(), both of which key by the CALLER — which is what makes
// it the only store here that can honestly NAME a caller.
//
// Do not reverse-look-up m_store for that. TokenManager is direction-MIXED:
// LogosAPIClient writes the token we will PRESENT to a callee under the
// CALLEE's name (logos_api_client.cpp:176), while inbound tokens are written
// under the CALLER's name. A hit there may name a module we CALL as the
// module CALLING us, which is affirmatively wrong and worse than unknown.
QHash<QString, QString> m_tokens;
// Never null after construction; see the constructor comment.
//
// A new data member here is safe in a way one in LogosAPI is not (see the
// warning at logos_api.h:329). Nothing hands a ModuleProxy across an image
// boundary: it is constructed by the Qt host (LogosAPIProvider) and reached
// only through QMetaObject dispatch or, from a module cdylib, not at all —
// the type that crosses is the LogosProviderObject vtable, which is
// untouched.
TokenManager* m_store;
TokenValidator m_validator;
};
/**
* @brief The token-delivery-only surface described by logos::handshakeObjectName.
*
* Deliberately tiny: it exposes informModuleToken and nothing else, so
* publishing it early cannot expose business methods on a module that has not
* finished initializing. It forwards to the ModuleProxy that owns it, so a
* token delivered here lands in exactly the same store the business object
* consults later.
*/
class ModuleHandshakeProxy : public QObject
{
Q_OBJECT
public:
explicit ModuleHandshakeProxy(ModuleProxy* proxy, QObject* parent = nullptr);
Q_INVOKABLE bool informModuleToken(const QString& authToken,
const QString& moduleName,
const QString& token);
private:
QPointer<ModuleProxy> m_proxy;
};
#endif // MODULE_PROXY_H