mirror of
https://github.com/logos-co/logos-liblogos.git
synced 2026-08-27 12:51:10 +00:00
enforce access policy based on dependencies (#146)
This commit is contained in:
+1
-1
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user