From 2bb5b89f3c644e90c870bb554e630e683c754bcf Mon Sep 17 00:00:00 2001 From: Iuri Matias Date: Thu, 11 Jun 2026 16:33:03 -0400 Subject: [PATCH] enforce access policy based on dependencies (#146) --- docs/project.md | 2 +- docs/spec.md | 2 +- src/logos_core/module_manager.cpp | 168 +++++++++++++++++++++--------- src/logos_core/module_manager.h | 4 + tests/test_module_manager.cpp | 134 ++++++++++++++++++++++++ 5 files changed, 260 insertions(+), 50 deletions(-) diff --git a/docs/project.md b/docs/project.md index 184cecf..ccb709e 100644 --- a/docs/project.md +++ b/docs/project.md @@ -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 | diff --git a/docs/spec.md b/docs/spec.md index e119ff3..7d2b691 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -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 diff --git a/src/logos_core/module_manager.cpp b/src/logos_core/module_manager.cpp index 6f5f5b5..4f9e164 100644 --- a/src/logos_core/module_manager.cpp +++ b/src/logos_core/module_manager.cpp @@ -7,9 +7,12 @@ #include "containers/subprocess/subprocess_container.h" #include "runtimes/runtime_qt/qt_plugin_runtime.h" #include +#include +#include #include #include #include +#include #include #include #include @@ -19,9 +22,6 @@ #include "logos_transport_config_json.h" #include "token_manager.h" #include "instance_persistence.h" -#include -#include -#include 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& parsedEnforcePolicy() { + static std::optional p; + return p; + } + + // Always allowed past the dependency check, so they're never locked out. + const std::vector 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 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& 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()) + 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 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 callers; + std::unordered_set 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 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 computeDerivedAllowedCallers(const std::string& target) { + std::lock_guard lock(loadMutex()); + return computeDerivedAllowedCallersLocked(target); + } } diff --git a/src/logos_core/module_manager.h b/src/logos_core/module_manager.h index 717c05d..0cc39ba 100644 --- a/src/logos_core/module_manager.h +++ b/src/logos_core/module_manager.h @@ -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 computeDerivedAllowedCallers(const std::string& target); + void discoverInstalledModules(); std::string processModule(const std::string& modulePath); diff --git a/tests/test_module_manager.cpp b/tests/test_module_manager.cpp index 06b63b6..b0af8f0 100644 --- a/tests/test_module_manager.cpp +++ b/tests/test_module_manager.cpp @@ -2,6 +2,7 @@ #include "logos_core.h" #include "qt_test_adapter.h" #include +#include #include #include #include @@ -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& deps) { + logos_core_register_module(name.c_str(), ("/fake/" + name).c_str()); + std::vector d; + for (const auto& s : deps) d.push_back(s.c_str()); + logos_core_register_module_dependencies(name.c_str(), d.data(), + static_cast(d.size())); + } + + static std::set derived(const std::string& target) { + auto v = ModuleManager::computeDerivedAllowedCallers(target); + return std::set(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{"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{"core", "core_service"})); +} + +TEST_F(DerivedRestrictionsManagerTest, ZeroDependentsIsTrustedOnly) { + reg("solo", {}); + logos_core_mark_module_loaded("solo"); + ModuleManager::setAccessPolicy(enforceEnvelope()); + + EXPECT_EQ(derived("solo"), (std::set{"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{"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{"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); +}