enforce access policy based on dependencies (#146)

This commit is contained in:
Iuri Matias
2026-06-11 16:33:03 -04:00
committed by GitHub
parent 76416d32fc
commit 2bb5b89f3c
5 changed files with 260 additions and 50 deletions
+1 -1
View File
@@ -313,7 +313,7 @@ The public C API (`logos_core.h`) is the only exported interface. All functions
| `logos_core_add_modules_dir(dir)` | Add a module directory to scan (duplicates ignored) |
| `logos_core_set_persistence_base_path(path)` | Set base directory for module instance persistence |
| `logos_core_set_module_transports(name, json)` | Register a per-module transport set (JSON, see logos-cpp-sdk shape). Forwarded to the child via `--transport-set` so its `LogosAPIProvider` binds every listener instead of only the global default LocalSocket. Must be called before the module is loaded; empty clears the entry |
| `logos_core_set_access_policy(json)` | Install the inter-module access policy (version + mode + per-target `allowedCallers` allowlists). Core parses it and registers the per-target restrictions with capability_module, which denies token issuance (and thus calls) for disallowed callers when `mode` is `enforce`. Call before modules load; NULL/empty clears it |
| `logos_core_set_access_policy(json)` | Install the inter-module access policy (version + mode + per-target `allowedCallers` allowlists). Core parses it and registers the per-target restrictions with capability_module, which denies token issuance (and thus calls) for disallowed callers when `mode` is `enforce`. Under enforce, restrictions are also auto-derived from the dependency graph (a module may only call its declared dependencies; allowed callers = loaded dependents + trusted `core`/`core_service`, re-pushed on load/unload); an explicit entry overrides the derived set for that target. Call before modules load; NULL/empty clears it |
| `logos_core_load_module(name, with_dependencies) → int` | Load a module (1 = success, 0 = failure). When `with_dependencies` is true, resolves the dependency tree and loads in topological order |
| `logos_core_unload_module(name, with_dependents) → int` | Unload a module. When `with_dependents` is true, cascade unloads every loaded transitive dependent leaves-first. Returns 1 only if every step succeeded |
| `logos_core_get_module_dependencies(name, recursive) → char**` | Modules that `name` depends on (forward edges). `recursive=true` walks the forward graph transitively. Unknown names yield an empty array. Caller frees |
+1 -1
View File
@@ -245,7 +245,7 @@ The platform supports two build variants:
| `logos_core_get_module_dependents(name, recursive) → char**` | Return null-terminated array of modules that depend on `name` (reverse edges). With `recursive=true`, walks the reverse dependency graph transitively via BFS. Unknown names yield an empty array. Caller must free. |
| `logos_core_process_module(path) → char*` | Read a module file's metadata and register it as known without loading. Returns the module name or NULL. Caller must free. |
| `logos_core_set_module_transports(name, json)` | Register a per-module `LogosTransportSet` (JSON, see logos-cpp-sdk shape) for the named module. The runtime forwards it to the child via `--transport-set` so the child's `LogosAPIProvider` binds every transport instead of only the global default LocalSocket. Must be called before the module is loaded. NULL or empty clears any previously-registered entry. |
| `logos_core_set_access_policy(json)` | Install the inter-module access policy: a JSON document with `version`, `mode` (e.g. `enforce`), and `restrictions` mapping each target module to its `allowedCallers` allowlist. Core parses it and, once capability_module loads, registers the concrete per-target restrictions with it via `registerRestriction` (authenticated by capability_module's auth token, so only the trusted core channel can register or relax restrictions — a peer module cannot); capability_module then refuses to mint a token (in `requestModule`) for a caller not in a restricted target's allowlist, so the call can never proceed. Only `mode: "enforce"` activates gating. Call before modules load. NULL or empty clears any previously-set policy. |
| `logos_core_set_access_policy(json)` | Install the inter-module access policy: a JSON document with `version`, `mode` (e.g. `enforce`), and `restrictions` mapping each target module to its `allowedCallers` allowlist. Core parses it and, once capability_module loads, registers the concrete per-target restrictions with it via `registerRestriction` (authenticated by capability_module's auth token, so only the trusted core channel can register or relax restrictions — a peer module cannot); capability_module then refuses to mint a token (in `requestModule`) for a caller not in a restricted target's allowlist, so the call can never proceed. Only `mode: "enforce"` activates gating. **Under an enforce policy, restrictions are also derived automatically from the dependency graph** — a module may only call modules it declared as a dependency, so for each loaded target core registers its loaded dependents plus a trusted set (`core`, `core_service`) as the allowed callers (re-pushed on every load/unload). An explicit `restrictions` entry overrides the derived set for that target verbatim. Call before modules load. NULL or empty clears any previously-set policy. |
### Token and Monitoring
+120 -48
View File
@@ -7,9 +7,12 @@
#include "containers/subprocess/subprocess_container.h"
#include "runtimes/runtime_qt/qt_plugin_runtime.h"
#include <spdlog/spdlog.h>
#include <nlohmann/json.hpp>
#include <algorithm>
#include <mutex>
#include <cassert>
#include <cstring>
#include <optional>
#include <unordered_set>
#include <boost/uuid/uuid.hpp>
#include <boost/uuid/uuid_generators.hpp>
@@ -19,9 +22,6 @@
#include "logos_transport_config_json.h"
#include "token_manager.h"
#include "instance_persistence.h"
#include <QString>
#include <QStringList>
#include <QVariant>
namespace {
ModuleRegistry& registryInstance() {
@@ -48,13 +48,25 @@ namespace {
return path;
}
// Raw access-policy JSON, set before logos_core_start(); pushed to
// capability_module once it loads. Guarded by loadMutex().
// Both guarded by loadMutex(). parsedEnforcePolicy is set only in enforce mode.
std::string& accessPolicyJson() {
static std::string s;
return s;
}
std::optional<LogosCore::AccessPolicy>& parsedEnforcePolicy() {
static std::optional<LogosCore::AccessPolicy> p;
return p;
}
// Always allowed past the dependency check, so they're never locked out.
const std::vector<std::string> kTrustedCallers = {"core", "core_service"};
// Never restricted as targets, even if an explicit policy names them.
// TODO: re-eval this; probably is required to restrict core/core_service
const std::vector<std::string> kExemptTargets =
{"capability_module", "core", "core_service"};
LogosCore::RuntimeRegistry& runtimeRegistry() {
static LogosCore::RuntimeRegistry reg;
static std::once_flag initFlag;
@@ -104,52 +116,92 @@ namespace {
return s_coreApi->getClient(std::string("capability_module"));
}
// Parse the stored access policy and register its per-target restrictions
// with capability_module. Only "enforce" mode registers anything; an
// empty/unparseable policy leaves it unrestricted. Best-effort (failures
// logged, not fatal).
// Token authenticates the call. Best-effort; assumes capability_module loaded.
void registerRestrictionRpc(const std::string& target,
const std::vector<std::string>& callers) {
nlohmann::json args = nlohmann::json::array();
args.push_back(TokenManager::instance().getToken(std::string("capability_module")));
args.push_back(target);
args.push_back(callers);
nlohmann::json result = capabilityModuleClient()->invokeRemoteMethod(
std::string("capability_module"),
std::string("registerRestriction"),
args);
if (!result.is_boolean() || !result.get<bool>())
spdlog::warn("Failed to register access restriction for target: {}", target);
else
spdlog::info("Registered access restriction for target: {} ({} allowed callers)",
target, callers.size());
}
// Explicit-policy restrictions, including targets not yet loaded (the
// derived path covers only loaded ones).
void pushAccessRestrictionsToCapabilityModule() {
if (accessPolicyJson().empty())
return;
if (!registryInstance().isLoaded("capability_module"))
return;
auto policy = LogosCore::parseAccessPolicy(accessPolicyJson());
if (!policy) {
spdlog::warn("Access policy is not valid JSON — not enforcing any restrictions");
const auto& policy = parsedEnforcePolicy();
if (!policy)
return;
}
if (!policy->enforce())
return;
const std::string capabilityModuleToken =
TokenManager::instance().getToken(std::string("capability_module"));
LogosAPIClient* client = capabilityModuleClient();
for (const auto& restriction : policy->restrictions) {
QStringList allowedCallers;
for (const auto& caller : restriction.allowedCallers)
allowedCallers.append(QString::fromStdString(caller));
// The token is the trusted-channel proof registerRestriction
// verifies — only core holds it.
const QVariant result = client->invokeRemoteMethod(
QStringLiteral("capability_module"),
QStringLiteral("registerRestriction"),
QVariant(QString::fromStdString(capabilityModuleToken)),
QVariant(QString::fromStdString(restriction.target)),
QVariant(allowedCallers));
if (!result.toBool()) {
spdlog::warn("Failed to register access restriction for target: {}",
restriction.target);
} else {
spdlog::info("Registered access restriction for target: {} ({} allowed callers)",
restriction.target, restriction.allowedCallers.size());
}
if (std::find(kExemptTargets.begin(), kExemptTargets.end(),
restriction.target) != kExemptTargets.end())
continue;
registerRestrictionRpc(restriction.target, restriction.allowedCallers);
}
}
// A module may only call modules it declared as a dependency, so `target`'s
// allowed callers are its loaded dependents plus the trusted set. Empty when
// exempt or no enforce policy (fail-open); explicit policy overrides verbatim.
std::vector<std::string> computeDerivedAllowedCallersLocked(const std::string& target) {
if (std::find(kExemptTargets.begin(), kExemptTargets.end(), target)
!= kExemptTargets.end())
return {};
const auto& policy = parsedEnforcePolicy();
if (!policy)
return {};
for (const auto& r : policy->restrictions)
if (r.target == target)
return r.allowedCallers;
// Deduped; no dependents => trusted only (deny-by-default for peers).
std::vector<std::string> callers;
std::unordered_set<std::string> seen;
auto add = [&](const std::string& c) {
if (seen.insert(c).second)
callers.push_back(c);
};
for (const auto& d : registryInstance().moduleDependents(target, /*recursive=*/false))
if (registryInstance().isLoaded(d))
add(d);
for (const auto& t : kTrustedCallers)
add(t);
return callers;
}
void pushDerivedRestrictionForTarget(const std::string& target) {
if (!registryInstance().isLoaded("capability_module"))
return;
auto callers = computeDerivedAllowedCallersLocked(target);
if (!callers.empty())
registerRestrictionRpc(target, callers);
}
// On load/unload of `name`, re-push the targets whose caller set changed:
// its declared dependencies, plus `name` itself.
void refreshDerivedRestrictionsForDependenciesOf(const std::string& name) {
if (!registryInstance().isLoaded("capability_module"))
return;
for (const auto& dep : registryInstance().moduleDependencies(name, /*recursive=*/false))
pushDerivedRestrictionForTarget(dep);
pushDerivedRestrictionForTarget(name);
}
void notifyCapabilityModule(const std::string& name, const std::string& token) {
if (!registryInstance().isLoaded("capability_module"))
return;
@@ -236,6 +288,8 @@ namespace {
notifyCapabilityModule(name, authToken);
refreshDerivedRestrictionsForDependenciesOf(name);
spdlog::info("Module loaded: {}", name);
return true;
@@ -269,6 +323,9 @@ namespace {
registryInstance().markUnloaded(name);
// markUnloaded keeps the dependency edges, so this still resolves them.
refreshDerivedRestrictionsForDependenciesOf(name);
spdlog::info("Module unloaded: {}", name);
return true;
}
@@ -316,11 +373,16 @@ namespace ModuleManager {
void setAccessPolicy(const std::string& policyJson) {
std::lock_guard<std::mutex> g(loadMutex()); // guards the read at push time
accessPolicyJson() = policyJson;
// Validate eagerly so a malformed policy is flagged at set time, not
// silently at capability_module load. Stored verbatim regardless.
if (!policyJson.empty() && !LogosCore::parseAccessPolicy(policyJson)) {
spdlog::warn("logos_core_set_access_policy: policy is not valid JSON "
"— no restrictions will be enforced");
// Cache the parse only in enforce mode; malformed/non-enforce stays empty.
parsedEnforcePolicy().reset();
if (!policyJson.empty()) {
auto parsed = LogosCore::parseAccessPolicy(policyJson);
if (!parsed) {
spdlog::warn("logos_core_set_access_policy: policy is not valid JSON "
"— no restrictions will be enforced");
} else if (parsed->enforce()) {
parsedEnforcePolicy() = std::move(parsed);
}
}
}
@@ -406,8 +468,12 @@ namespace ModuleManager {
return false;
}
// Register restrictions now, before any other module can call out.
// Register restrictions before any other module can call out: explicit
// entries, then derived for anything already loaded (usually nothing —
// only the exempt capability_module is up here).
pushAccessRestrictionsToCapabilityModule();
for (const auto& loaded : registryInstance().loadedModuleNames())
pushDerivedRestrictionForTarget(loaded);
return true;
}
@@ -499,6 +565,7 @@ namespace ModuleManager {
// run's transport map and bind unexpected ports.
moduleTransportsMap().clear();
accessPolicyJson().clear(); // same rationale — don't leak across restarts
parsedEnforcePolicy().reset();
}
char** getLoadedModulesCStr() {
@@ -553,4 +620,9 @@ namespace ModuleManager {
return toNullTerminatedArray(
getDependents(std::string(name), recursive));
}
std::vector<std::string> computeDerivedAllowedCallers(const std::string& target) {
std::lock_guard lock(loadMutex());
return computeDerivedAllowedCallersLocked(target);
}
}
+4
View File
@@ -42,6 +42,10 @@ namespace ModuleManager {
// BEFORE logos_core_start(). Empty clears any previously set policy.
void setAccessPolicy(const std::string& policyJson);
// Allowed callers core would register for `target` (see the .cpp).
// A pure read with no RPC — exposed so tests can observe the derivation.
std::vector<std::string> computeDerivedAllowedCallers(const std::string& target);
void discoverInstalledModules();
std::string processModule(const std::string& modulePath);
+134
View File
@@ -2,6 +2,7 @@
#include "logos_core.h"
#include "qt_test_adapter.h"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <cstdlib>
#include <cstring>
#include <filesystem>
@@ -1076,3 +1077,136 @@ TEST_F(DependencyQueryTest, GetModuleDependencies_AbortsForNull) {
TEST_F(DependencyQueryTest, GetModuleDependents_AbortsForNull) {
EXPECT_DEATH(logos_core_get_module_dependents(nullptr, false), "");
}
// =============================================================================
// Derived access-restriction computation (graph + policy -> allowed callers)
// =============================================================================
//
// computeDerivedAllowedCallers() is the registry-backed counterpart of the
// pure derivation seam: it reads the live dependency graph + loaded set + the
// access policy and returns what core would register with capability_module for
// a target — without any RPC. We drive it with the test registry adapters
// (register_module / register_module_dependencies / mark_module_loaded) and the
// ModuleManager::setAccessPolicy entry point.
class DerivedRestrictionsManagerTest : public ::testing::Test {
protected:
void SetUp() override { clearModuleState(); }
void TearDown() override {
// Clear the policy so it doesn't leak into other suites.
ModuleManager::setAccessPolicy("");
clearModuleState();
}
// Register `name` with `deps` declared as dependencies.
static void reg(const std::string& name, const std::vector<std::string>& deps) {
logos_core_register_module(name.c_str(), ("/fake/" + name).c_str());
std::vector<const char*> d;
for (const auto& s : deps) d.push_back(s.c_str());
logos_core_register_module_dependencies(name.c_str(), d.data(),
static_cast<int>(d.size()));
}
static std::set<std::string> derived(const std::string& target) {
auto v = ModuleManager::computeDerivedAllowedCallers(target);
return std::set<std::string>(v.begin(), v.end());
}
// Minimal enforce policy with no explicit restrictions — turns derivation on.
static const char* enforceEnvelope() {
return "{\"version\":1,\"mode\":\"enforce\",\"restrictions\":{}}";
}
};
TEST_F(DerivedRestrictionsManagerTest, LoadedDependentPlusTrusted) {
// a depends on b; both loaded. b's allowed callers = {a} trusted.
reg("b", {});
reg("a", {"b"});
logos_core_mark_module_loaded("b");
logos_core_mark_module_loaded("a");
ModuleManager::setAccessPolicy(enforceEnvelope());
EXPECT_EQ(derived("b"),
(std::set<std::string>{"a", "core", "core_service"}));
}
TEST_F(DerivedRestrictionsManagerTest, UnloadedDependentExcluded) {
// a declares b but is NOT loaded — a must not appear in b's callers.
reg("b", {});
reg("a", {"b"});
logos_core_mark_module_loaded("b"); // a left unloaded
ModuleManager::setAccessPolicy(enforceEnvelope());
EXPECT_EQ(derived("b"), (std::set<std::string>{"core", "core_service"}));
}
TEST_F(DerivedRestrictionsManagerTest, ZeroDependentsIsTrustedOnly) {
reg("solo", {});
logos_core_mark_module_loaded("solo");
ModuleManager::setAccessPolicy(enforceEnvelope());
EXPECT_EQ(derived("solo"), (std::set<std::string>{"core", "core_service"}));
}
TEST_F(DerivedRestrictionsManagerTest, NoEnforcePolicyDerivesNothing) {
reg("b", {});
reg("a", {"b"});
logos_core_mark_module_loaded("b");
logos_core_mark_module_loaded("a");
// No policy set at all -> derivation off -> empty.
EXPECT_TRUE(derived("b").empty());
// A non-enforce policy is also inert.
ModuleManager::setAccessPolicy(
"{\"version\":1,\"mode\":\"audit\",\"restrictions\":{}}");
EXPECT_TRUE(derived("b").empty());
}
TEST_F(DerivedRestrictionsManagerTest, ExplicitPolicyOverridesDerived) {
reg("b", {});
reg("a", {"b"});
logos_core_mark_module_loaded("b");
logos_core_mark_module_loaded("a");
// Explicit entry for b names only "x" — replaces the derived {a, trusted}.
ModuleManager::setAccessPolicy(
"{\"version\":1,\"mode\":\"enforce\",\"restrictions\":{"
"\"b\":{\"allowedCallers\":[\"x\"]}}}");
EXPECT_EQ(derived("b"), (std::set<std::string>{"x"}));
}
TEST_F(DerivedRestrictionsManagerTest, ExemptTargetsNeverDerived) {
reg("capability_module", {});
logos_core_mark_module_loaded("capability_module");
ModuleManager::setAccessPolicy(enforceEnvelope());
EXPECT_TRUE(derived("capability_module").empty());
}
TEST_F(DerivedRestrictionsManagerTest, UnloadDropsDependentFromCallers) {
reg("b", {});
reg("a", {"b"});
logos_core_mark_module_loaded("b");
logos_core_mark_module_loaded("a");
ModuleManager::setAccessPolicy(enforceEnvelope());
EXPECT_TRUE(derived("b").count("a"));
// Unloading a (it stays known, dependency edge remains) drops it.
ModuleManager::registry().markUnloaded("a");
EXPECT_FALSE(derived("b").count("a"));
EXPECT_EQ(derived("b"), (std::set<std::string>{"core", "core_service"}));
}
TEST_F(DerivedRestrictionsManagerTest, TrustedDependentNotDuplicated) {
// A loaded dependent that shares a trusted name must appear exactly once in
// the registered list (the set-based `derived()` helper would hide a dup, so
// inspect the raw vector here).
reg("b", {});
reg("core", {"b"});
logos_core_mark_module_loaded("b");
logos_core_mark_module_loaded("core");
ModuleManager::setAccessPolicy(enforceEnvelope());
auto callers = ModuleManager::computeDerivedAllowedCallers("b");
EXPECT_EQ(std::count(callers.begin(), callers.end(), std::string("core")), 1);
}