Files
logos-protocol/tests/protocol/test_host_services_grant.cpp
Dario LipicarandClaude Opus 5 988e0ba906 feat: per-client token store, the host-services C ABI, and a container shape-check (#59)
* feat: the host-services C ABI a trust-root module needs

capability_module is the last legacy Qt Q_INVOKABLE provider, and it cannot
become an ordinary `interface: universal` module while the two things it does
have no C entry point: reading the token store, and pushing a token to an
ARBITRARY target. This adds both, plus the grant that gates them. Purely
additive — no existing symbol changes behaviour.

  lp_token_keys()               the module names THIS image's TokenManager
                                holds. NULL means REFUSED, never "empty" — a
                                granted call with no tokens answers "[]", and a
                                known-caller gate needs to tell those apart.
  lp_inform_module_token_to()   routes to LogosAPIClient::informModuleToken_module,
                                the 5-arg form. Note the existing
                                lp_inform_module_token is the WRONG DIRECTION
                                for this: it reaches a consumer path that
                                hardcodes requestObject("capability_module"),
                                i.e. core -> capability, not capability ->
                                target. That 5-arg method had no C entry point.
  lp_grant_host_services()      sets the in-image grant over the closed set
                                {token_registry, token_delivery}. Replaces
                                rather than merges; NULL/""/"[]" clears. An
                                unknown name is rejected wholesale and leaves
                                the existing grant untouched, so a typo can
                                never silently drop a service.

Why the gate is per-IMAGE, which looks like an odd choice until it doesn't:
the host binary and a module's cdylib each link their own copy of this library,
so they have separate process-global state. A gate "simplified" into the host
would be checked against state the calling image can never set, and would read
as ungranted forever. The grant therefore crosses the module-impl C ABI the
same way the auth token already does — hence the logos_module_grant_host_services
declaration added to logos_module_impl.h, whose generated body and host-side
call land in logos-cpp-sdk and logos-module-loader-qt respectively.

MINOR 2 -> 3; MAJOR unchanged, so the equal-MAJOR compatibility rule is
unaffected. 387/387 tests pass, including 6 new ones covering both gates
closed, both opened, clearing re-closing them, and the unknown-name rejection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(tokens): a per-CLIENT token store, selected by origin

TokenManager::instance() is the IMAGE's store, and in a host that loads
plugins in-process it is also an ambient ring: the host writes
`name -> that module's root auth token` for EVERY module it loads. On the
hot path a client asserts no identity at all — invokeRemoteMethod reads
the store first and only mints on a miss — so a plugin asking for target X
finds X's own root token sitting there and presents it. The provider
accepts any token in its image's store, so the call authorizes and no
requestModule is ever logged. Every plugin in that image holds every other
module's authority, and giving a plugin its own ORIGIN STRING changes none
of it, because origin was never consulted on the path taken.

This makes origin SELECT THE STORE rather than merely label the caller.

  TokenManager::forIdentity(x)      the store to present tokens from when I am x
  TokenManager::isolateIdentity(x)  give x a private store (host opt-in)
  isIsolated / isolatedIdentities / bootstrapKeys / seedBootstrapTokens /
  resetIdentity

ADDITIVE BY CONSTRUCTION, not by promise: forIdentity() returns the SAME
OBJECT instance() returns — pointer-identical — for every name until
someone isolates that exact name, so a host that knows nothing about this
is byte-for-byte unchanged. All seven are static member FUNCTIONS: no data
member, no virtual, nothing moc sees. Measured, not asserted: the exported
symbol table of liblogos_protocol.dylib gains exactly 12 names (7 statics +
5 lp_*) and LOSES NONE (736 -> 748). Neither ABI-sensitive private layout
(LogosAPIClient, LogosAPIConsumer) was touched at all.

Construction paths in this repo:
  * LogosAPIClient / LogosAPIConsumer: an explicit store still wins; a NULL
    store now resolves to forIdentity(origin) instead of being a guaranteed
    crash on the first getToken().
  * lp_client_create: &TokenManager::forIdentity(origin), not instance().
    This is the whole answer to that function's frozen signature — the store
    cannot be handed to it, so the origin it already takes must select it.

Bootstrap (constraint 4) survives because a private store is created seeded
with "core" and "capability_module" copied from instance(), and with
NOTHING else — the two keys the first requestModule authenticates with, not
a copy of the ring. resetIdentity() is the unload hook: it clears the
contents and re-seeds, while the store OBJECT stays immortal because a
client holds it by raw pointer from continuations that outlive their caller.

The trust root (constraint 3) is unaffected, and it is checked rather than
argued: lp_token_keys() still reads instance(), isolation only ADDS stores,
and the one thing that moves — an isolated identity's consumer-side CACHE
write — is keyed by TARGET while the known-caller gate consults ORIGIN
names, which the HOST writes and this change never touches.

C ABI grows five additive symbols, each carrying LP_API:
lp_token_isolate_identity, lp_token_identity_is_isolated, lp_token_get_for,
lp_token_save_for, lp_token_reset_identity. Protocol version 0.3.0 -> 0.4.0
(MINOR: additive).

Tests: 439/439 before, 469/469 after. The 30 new cases were validated as
DETECTORS the way this suite requires — against a throwaway build with
forIdentity()'s isolation branch neutered to `if (true)`, i.e. origin as a
label again. 15 go RED there (the walled identity holds the target's root
token; the handshake count is 0 instead of 2; lp_token_keys() lists the
identity's private mint), and the other 15 are pins of behaviour that must
be identical either way. Every escalation case carries an ambient CONTROL
asserting the token IS reachable without isolation.

Hosts are deliberately NOT changed here.

* feat(codec): shape-check the untyped containers

`[any]` and `{tstr:any}` both spell `nlohmann::json` in C++ — LogosList and
LogosMap are aliases of it — so no Codec<T> specialization can tell them apart
and fromJson<T> has nothing to dispatch on. Their SHAPE is still declared,
though, and array-ness / object-ness is the whole of the declared type at that
layer.

jsonRequireArray / jsonRequireObject check exactly that and hand the value on
UNCHANGED, throwing through the codec's own detail::typeError so the message is
the one every other surface already produces ("expected array at arg0, got
string"). The value is not rebuilt from JSON: that would retype nested elements
for no validation gain, which is the same reasoning logos_qt_arg_decode.h gives
for the Qt surface.

This is what logos_codec.h:36 already promised and these two types quietly did
not honour — "shape mismatches throw CodecError … rather than silently
substituting a default, silent defaults are how a mangled value reaches business
logic."

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 22:58:56 -03:00

269 lines
12 KiB
C++

// The trust-root grant (lp_grant_host_services) and the two host services it
// gates: "token_registry" (lp_token_keys) and "token_delivery"
// (lp_inform_module_token_to).
//
// Both exist so capability_module can become an ordinary Qt-free module instead
// of a privileged passenger inside the host. Living in the host is what gave it
// the two things an ordinary module has no C entry point for: reading the key
// set of its own token store (the known-caller gate — it used to reach through
// logosAPI->getTokenManager()->getTokenKeys()), and pushing a token AT another
// module rather than back at core.
//
// What these cases pin above all is WHERE the gate is read. The grant is
// per-IMAGE: a host binary and a module cdylib each link their own copy of
// logos-protocol, so each has its own process-global state — the same reason a
// token the host holds has to travel over logos_module_accept_token before the
// cdylib's own TokenManager has it. A gate "simplified" into the host would be
// checked against state the calling image can never set, and the whole surface
// would fail shut with nothing to point at.
//
// Everything here is closed by default, so the first assertion of every case is
// really the same one: an image nobody granted anything to has exactly the
// pre-0.3 surface.
#include <gtest/gtest.h>
#include "logos_instance.h"
#include "logos_mode.h"
#include "logos_protocol.h"
#include "logos_provider_interface.h"
#include "module_proxy.h"
#include "remote_transport.h"
#include "token_manager.h"
#include <QCoreApplication>
#include <QEventLoop>
#include <QJsonArray>
#include <QString>
#include <QVariantList>
#include <algorithm>
#include <string>
#include <nlohmann/json.hpp>
namespace {
QCoreApplication* ensureApp() {
static int argc = 0;
static char* argv[] = { nullptr };
if (!QCoreApplication::instance())
new QCoreApplication(argc, argv);
return QCoreApplication::instance();
}
// Records the pushes that reach the implementation, so a refusal at the gate
// above it is distinguishable from a delivery that never arrived.
class RecordingProvider : public LogosProviderObject {
public:
QVariant callMethod(const QString& method, const QVariantList&) override {
return QStringLiteral("ran:") + method;
}
bool informModuleToken(const QString& moduleName, const QString& token) override {
++tokenPushes;
lastModule = moduleName;
TokenManager::instance().saveToken(moduleName, token);
return true;
}
QJsonArray getMethods() override { return QJsonArray{}; }
void setEventListener(EventCallback) override {}
void init(void*) override {}
QString providerName() const override { return QStringLiteral("grant_target_module"); }
QString providerVersion() const override { return QStringLiteral("1.0.0"); }
int tokenPushes = 0;
QString lastModule;
};
nlohmann::json parsed(const char* json)
{
return nlohmann::json::parse(json, nullptr, /*allow_exceptions=*/false);
}
bool containsKey(const nlohmann::json& array, const std::string& key)
{
return std::any_of(array.begin(), array.end(),
[&](const nlohmann::json& e) {
return e.is_string() && e.get<std::string>() == key;
});
}
} // namespace
class HostServicesGrantTest : public ::testing::Test {
protected:
void SetUp() override {
ensureApp();
// Other suites in this binary switch the process mode; the delivery
// case below needs the Qt-affine transport it publishes over.
LogosModeConfig::setMode(LogosMode::Remote);
// The grant and the token store are both process-global, so every case
// starts from the ungranted, empty state rather than from whatever the
// previous one left.
ASSERT_EQ(lp_grant_host_services(nullptr), LP_OK);
TokenManager::instance().clearAllTokens();
}
void TearDown() override {
lp_grant_host_services(nullptr);
TokenManager::instance().clearAllTokens();
}
};
// ── token_registry ──────────────────────────────────────────────────────────
TEST_F(HostServicesGrantTest, TokenRegistryIsClosedUntilGranted)
{
TokenManager::instance().saveToken(QStringLiteral("alpha"), QStringLiteral("alpha-tok"));
TokenManager::instance().saveToken(QStringLiteral("beta"), QStringLiteral("beta-tok"));
EXPECT_EQ(lp_token_keys(), nullptr)
<< "the key set must be unreadable until the image is granted";
ASSERT_EQ(lp_grant_host_services(R"(["token_registry"])"), LP_OK);
char* keys = lp_token_keys();
ASSERT_NE(keys, nullptr);
const std::string dump = keys;
lp_string_free(keys);
const nlohmann::json j = parsed(dump.c_str());
ASSERT_TRUE(j.is_array());
EXPECT_EQ(j.size(), 2u);
EXPECT_TRUE(containsKey(j, "alpha"));
EXPECT_TRUE(containsKey(j, "beta"));
// Keys only. The gate answers "whom have I been told about", never "what is
// their token" — a known-caller check must not become a token oracle.
EXPECT_EQ(dump.find("alpha-tok"), std::string::npos);
EXPECT_EQ(dump.find("beta-tok"), std::string::npos);
}
TEST_F(HostServicesGrantTest, GrantedRegistryAnswersEmptyRatherThanRefusing)
{
// NULL is the refusal, so an empty store must NOT return it — otherwise a
// granted trust root cannot tell "I know nobody" from "I am not allowed to
// ask", and its known-caller gate would fail in the wrong direction.
ASSERT_EQ(lp_grant_host_services(R"(["token_registry"])"), LP_OK);
char* keys = lp_token_keys();
ASSERT_NE(keys, nullptr);
EXPECT_STREQ(keys, "[]");
lp_string_free(keys);
}
// ── token_delivery ──────────────────────────────────────────────────────────
TEST_F(HostServicesGrantTest, TokenDeliveryIsClosedUntilGranted)
{
// The gate is read before any argument is, so an ungranted image gets the
// same answer whatever it passes — including a null client, which would
// otherwise be LP_ERR_INVALID_ARG.
EXPECT_EQ(lp_inform_module_token_to(nullptr, "coretok", "target", "peer", "peertok", 100),
LP_ERR_UNSUPPORTED);
// The two services are independent: holding one must not confer the other.
ASSERT_EQ(lp_grant_host_services(R"(["token_registry"])"), LP_OK);
EXPECT_EQ(lp_inform_module_token_to(nullptr, "coretok", "target", "peer", "peertok", 100),
LP_ERR_UNSUPPORTED);
ASSERT_EQ(lp_grant_host_services(R"(["token_delivery"])"), LP_OK);
EXPECT_EQ(lp_token_keys(), nullptr);
// Granted, the arguments are finally what is wrong with this call.
EXPECT_EQ(lp_inform_module_token_to(nullptr, "coretok", "target", "peer", "peertok", 100),
LP_ERR_INVALID_ARG);
}
// The direction that makes this function necessary at all.
//
// lp_inform_module_token hardcodes capability_module as the destination
// (LogosAPIConsumer::informModuleToken), which is the consumer half of the
// exchange; a trust root pushes the other way and had no C entry point for it.
// Nothing named capability_module is published here — the only module in this
// test is grant_target_module, so the push can only be counted if it actually
// reached that module's handshake surface.
TEST_F(HostServicesGrantTest, GrantedDeliveryReachesAModuleThatIsNotCapabilityModule)
{
const QString module = QStringLiteral("grant_target_module");
RemoteTransportHost host(LogosInstance::id(module));
RecordingProvider provider;
ModuleProxy proxy(&provider);
ModuleHandshakeProxy handshake(&proxy);
ASSERT_TRUE(host.publishObject(logos::handshakeObjectName(module), &handshake));
// The trust anchor the publisher seeds before going live; without it
// ModuleProxy's gate refuses every push (see test_handshake_surface.cpp).
TokenManager::instance().saveToken(QStringLiteral("core"), QStringLiteral("coretok"));
lp_client* client = lp_client_create("grant_target_module", "capability_module",
nullptr, nullptr);
ASSERT_NE(client, nullptr);
EXPECT_EQ(lp_inform_module_token_to(client, "coretok", "grant_target_module",
"peer", "peertok", 3000),
LP_ERR_UNSUPPORTED);
EXPECT_EQ(provider.tokenPushes, 0)
<< "a refused call must stop at the gate, not reach the module";
ASSERT_EQ(lp_grant_host_services(R"(["token_delivery"])"), LP_OK);
EXPECT_EQ(lp_inform_module_token_to(client, "coretok", "grant_target_module",
"peer", "peertok", 3000),
LP_OK);
EXPECT_EQ(provider.tokenPushes, 1) << "the push never reached the module";
EXPECT_EQ(provider.lastModule, QStringLiteral("peer"));
lp_client_destroy(client);
QCoreApplication::processEvents(QEventLoop::AllEvents, 50);
}
// ── the grant itself ────────────────────────────────────────────────────────
TEST_F(HostServicesGrantTest, ClearingTheGrantReclosesBothGates)
{
TokenManager::instance().saveToken(QStringLiteral("alpha"), QStringLiteral("alpha-tok"));
ASSERT_EQ(lp_grant_host_services(R"(["token_registry","token_delivery"])"), LP_OK);
char* keys = lp_token_keys();
ASSERT_NE(keys, nullptr);
lp_string_free(keys);
EXPECT_EQ(lp_inform_module_token_to(nullptr, "coretok", "target", "peer", "peertok", 100),
LP_ERR_INVALID_ARG); // past the gate, refused on arguments
ASSERT_EQ(lp_grant_host_services(nullptr), LP_OK);
EXPECT_EQ(lp_token_keys(), nullptr);
EXPECT_EQ(lp_inform_module_token_to(nullptr, "coretok", "target", "peer", "peertok", 100),
LP_ERR_UNSUPPORTED);
// An empty array is the same revoke, spelled in JSON.
ASSERT_EQ(lp_grant_host_services(R"(["token_registry"])"), LP_OK);
ASSERT_EQ(lp_grant_host_services("[]"), LP_OK);
EXPECT_EQ(lp_token_keys(), nullptr);
// A grant REPLACES the previous one rather than accumulating, so a caller
// cannot widen its own privilege one service at a time.
ASSERT_EQ(lp_grant_host_services(R"(["token_registry"])"), LP_OK);
ASSERT_EQ(lp_grant_host_services(R"(["token_delivery"])"), LP_OK);
EXPECT_EQ(lp_token_keys(), nullptr);
}
TEST_F(HostServicesGrantTest, UnknownServiceIsRejectedAndLeavesTheGrantIntact)
{
ASSERT_EQ(lp_grant_host_services(R"(["token_registry"])"), LP_OK);
// Silently ignoring the unknown name would leave the caller running with a
// mistaken idea of what it may do, and the mistake would surface much later
// as an unexplained LP_ERR_UNSUPPORTED from an unrelated call.
EXPECT_EQ(lp_grant_host_services(R"(["token_registry","token_bureau"])"),
LP_ERR_INVALID_ARG);
EXPECT_EQ(lp_grant_host_services(R"(["token_delivery",7])"), LP_ERR_INVALID_ARG);
EXPECT_EQ(lp_grant_host_services(R"({"token_registry":true})"), LP_ERR_INVALID_ARG);
EXPECT_EQ(lp_grant_host_services("not json"), LP_ERR_INVALID_ARG);
// Every rejection above is wholesale: the grant that was already in place is
// neither widened by the valid entries nor cleared by the invalid ones.
char* keys = lp_token_keys();
EXPECT_NE(keys, nullptr) << "a rejected grant must not revoke the existing one";
lp_string_free(keys);
EXPECT_EQ(lp_inform_module_token_to(nullptr, "coretok", "target", "peer", "peertok", 100),
LP_ERR_UNSUPPORTED)
<< "a rejected grant must not confer the services it named";
}