mirror of
https://github.com/logos-co/logos-liblogos.git
synced 2026-08-27 12:51:10 +00:00
support setting access policy (#145)
This commit is contained in:
@@ -121,8 +121,9 @@ void logos_core_set_persistence_base_path(const char* path);
|
||||
void logos_core_set_module_transports(const char* name, const char* transport_set_json);
|
||||
|
||||
// Inter-module access policy (per-target allowed-caller allowlists).
|
||||
// Should be called before the modules are loaded. NULL/"" clears it.
|
||||
// NOTE: currently a no-op — accepted but not yet enforced (TODO).
|
||||
// Core parses it and registers the per-target restrictions with
|
||||
// capability_module, which then denies token issuance for disallowed
|
||||
// (caller, target) pairs. Call before logos_core_start(); NULL/"" clears.
|
||||
void logos_core_set_access_policy(const char* policy_json);
|
||||
|
||||
// Module management
|
||||
|
||||
+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). Should be called before modules are loaded; NULL/empty clears it. **Currently a no-op** — accepted but not yet enforced (TODO) |
|
||||
| `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_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. Should be called before modules are loaded. NULL or empty clears any previously-set policy. **Currently a no-op** — the policy is accepted but not yet enforced (TODO). |
|
||||
| `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. |
|
||||
|
||||
### Token and Monitoring
|
||||
|
||||
|
||||
Generated
+3
-3
@@ -5,11 +5,11 @@
|
||||
"logos-module-builder": "logos-module-builder"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1778182598,
|
||||
"narHash": "sha256-QoyirL/blmVM2cmXQwbR6rmHufQJ6vaJSCkNORu4qaA=",
|
||||
"lastModified": 1781129198,
|
||||
"narHash": "sha256-vnJCkDQlGKXStnXFGhATzY3dXwwQT4/nnljReFmAzQc=",
|
||||
"owner": "logos-co",
|
||||
"repo": "logos-capability-module",
|
||||
"rev": "e675e9e3a98ee69bb303365c2c626f9237bc1ab5",
|
||||
"rev": "5d438961e2defe72b76028150787d59e586eec8c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -89,6 +89,8 @@ set(LOGOS_CORE_SOURCES
|
||||
logos_core/module_registry.h
|
||||
logos_core/dependency_resolver.cpp
|
||||
logos_core/dependency_resolver.h
|
||||
logos_core/access_policy.cpp
|
||||
logos_core/access_policy.h
|
||||
logos_core/module_manager.cpp
|
||||
logos_core/module_manager.h
|
||||
logos_core/module_runtime.h
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
#include "access_policy.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
namespace LogosCore {
|
||||
|
||||
std::optional<AccessPolicy> parseAccessPolicy(const std::string& json)
|
||||
{
|
||||
nlohmann::json doc;
|
||||
try {
|
||||
doc = nlohmann::json::parse(json);
|
||||
} catch (const std::exception&) {
|
||||
return std::nullopt; // invalid JSON is the only hard failure
|
||||
}
|
||||
|
||||
if (!doc.is_object())
|
||||
return std::nullopt;
|
||||
|
||||
AccessPolicy policy;
|
||||
policy.version = doc.value("version", 0);
|
||||
policy.mode = doc.value("mode", std::string{});
|
||||
|
||||
auto restrictionsIt = doc.find("restrictions");
|
||||
if (restrictionsIt != doc.end() && restrictionsIt->is_object()) {
|
||||
for (auto it = restrictionsIt->begin(); it != restrictionsIt->end(); ++it) {
|
||||
const std::string& target = it.key();
|
||||
if (target.empty())
|
||||
continue;
|
||||
|
||||
AccessRestriction restriction;
|
||||
restriction.target = target;
|
||||
|
||||
const auto& entry = it.value();
|
||||
if (entry.is_object()) {
|
||||
auto callersIt = entry.find("allowedCallers");
|
||||
if (callersIt != entry.end() && callersIt->is_array()) {
|
||||
for (const auto& caller : *callersIt) {
|
||||
if (caller.is_string())
|
||||
restriction.allowedCallers.push_back(
|
||||
caller.get<std::string>());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
policy.restrictions.push_back(std::move(restriction));
|
||||
}
|
||||
}
|
||||
|
||||
return policy;
|
||||
}
|
||||
|
||||
} // namespace LogosCore
|
||||
@@ -0,0 +1,37 @@
|
||||
#ifndef ACCESS_POLICY_H
|
||||
#define ACCESS_POLICY_H
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// Inter-module access policy model + parser (Qt-free). Parses the JSON set
|
||||
// via logos_core_set_access_policy(), e.g.
|
||||
// {"version":1,"mode":"enforce","restrictions":{
|
||||
// "package_manager":{"allowedCallers":["package_manager_ui"]}}}
|
||||
|
||||
namespace LogosCore {
|
||||
|
||||
// One target module and the set of caller modules permitted to reach it.
|
||||
struct AccessRestriction {
|
||||
std::string target;
|
||||
std::vector<std::string> allowedCallers;
|
||||
};
|
||||
|
||||
struct AccessPolicy {
|
||||
int version = 0;
|
||||
std::string mode;
|
||||
std::vector<AccessRestriction> restrictions;
|
||||
|
||||
// Only "enforce" turns restrictions into denials; any other value
|
||||
// leaves the policy informational and core registers nothing.
|
||||
bool enforce() const { return mode == "enforce"; }
|
||||
};
|
||||
|
||||
// Returns nullopt only on invalid JSON. Otherwise tolerant: unknown keys
|
||||
// ignored, missing "restrictions"/"allowedCallers" yield empty lists.
|
||||
std::optional<AccessPolicy> parseAccessPolicy(const std::string& json);
|
||||
|
||||
} // namespace LogosCore
|
||||
|
||||
#endif // ACCESS_POLICY_H
|
||||
@@ -103,16 +103,10 @@ void logos_core_set_module_transports(const char* module_name,
|
||||
}
|
||||
|
||||
void logos_core_set_access_policy(const char* policy_json) {
|
||||
// No-op for now: the policy is accepted but not yet enforced. A NULL
|
||||
// or empty argument is the documented "clear the policy" signal, so
|
||||
// it's explicitly allowed (unlike the module-name setters above, this
|
||||
// does not abort on NULL).
|
||||
//
|
||||
// TODO: Parse `policy_json` (version / mode / per-target
|
||||
// allowedCallers) and enforce the per-target allowed-caller checks
|
||||
// on the inter-module call path. Until then, every call is permitted
|
||||
// regardless of the policy supplied here.
|
||||
(void)policy_json;
|
||||
// NULL/"" clears the policy (see header) — unlike the module-name
|
||||
// setters above, this does not abort on NULL.
|
||||
ModuleManager::setAccessPolicy(
|
||||
policy_json ? std::string(policy_json) : std::string{});
|
||||
}
|
||||
|
||||
void logos_core_refresh_modules()
|
||||
|
||||
@@ -105,10 +105,8 @@ LOGOS_CORE_EXPORT void logos_core_set_persistence_base_path(const char* path);
|
||||
LOGOS_CORE_EXPORT void logos_core_set_module_transports(const char* module_name,
|
||||
const char* transport_set_json);
|
||||
|
||||
// Install the inter-module access policy that governs which caller
|
||||
// modules may invoke which target modules.
|
||||
//
|
||||
// `policy_json` is a JSON document of the shape:
|
||||
// Install the inter-module access policy: which callers may invoke which
|
||||
// targets. `policy_json` shape:
|
||||
//
|
||||
// {
|
||||
// "version": 1,
|
||||
@@ -119,18 +117,13 @@ LOGOS_CORE_EXPORT void logos_core_set_module_transports(const char* module_name,
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// `mode` selects how violations are handled (e.g. "enforce" to deny,
|
||||
// other modes may log only). Each entry under `restrictions` names a
|
||||
// target module and the set of caller modules permitted to reach it;
|
||||
// a target absent from `restrictions` is unrestricted.
|
||||
// A restricted target rejects callers outside its allowlist; a target
|
||||
// absent from `restrictions` is unrestricted. Only `mode` == "enforce"
|
||||
// activates gating (any other value registers nothing). Enforced by
|
||||
// capability_module, which won't issue a token — hence won't allow the
|
||||
// call — for a disallowed caller.
|
||||
//
|
||||
// Should be called before logos_core_start() so the policy is in place
|
||||
// before any module is loaded. NULL or "" clears any previously-set
|
||||
// policy.
|
||||
//
|
||||
// TODO: This is currently a no-op — the policy is accepted but not yet
|
||||
// enforced. Implement parsing of the JSON document and wire the
|
||||
// per-target allowed-caller checks into the inter-module call path.
|
||||
// Must be called before logos_core_start(). NULL or "" clears the policy.
|
||||
LOGOS_CORE_EXPORT void logos_core_set_access_policy(const char* policy_json);
|
||||
|
||||
// Re-scan all module directories and update known modules.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "module_manager.h"
|
||||
#include "module_registry.h"
|
||||
#include "access_policy.h"
|
||||
#include "dependency_resolver.h"
|
||||
#include "runtime_registry.h"
|
||||
#include "composite_runtime.h"
|
||||
@@ -18,6 +19,9 @@
|
||||
#include "logos_transport_config_json.h"
|
||||
#include "token_manager.h"
|
||||
#include "instance_persistence.h"
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QVariant>
|
||||
|
||||
namespace {
|
||||
ModuleRegistry& registryInstance() {
|
||||
@@ -44,6 +48,13 @@ namespace {
|
||||
return path;
|
||||
}
|
||||
|
||||
// Raw access-policy JSON, set before logos_core_start(); pushed to
|
||||
// capability_module once it loads. Guarded by loadMutex().
|
||||
std::string& accessPolicyJson() {
|
||||
static std::string s;
|
||||
return s;
|
||||
}
|
||||
|
||||
LogosCore::RuntimeRegistry& runtimeRegistry() {
|
||||
static LogosCore::RuntimeRegistry reg;
|
||||
static std::once_flag initFlag;
|
||||
@@ -72,6 +83,73 @@ namespace {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Dial capability_module from a long-lived "core" LogosAPI. Prefer the
|
||||
// operator's first configured transport; fall back to the global
|
||||
// default (LocalSocket). Needed because the single-arg getClient()
|
||||
// always uses the global default, which hangs against a tcp-only
|
||||
// capability_module that never bound a LocalSocket.
|
||||
LogosAPIClient* capabilityModuleClient() {
|
||||
static LogosAPI* s_coreApi = nullptr;
|
||||
if (!s_coreApi)
|
||||
s_coreApi = new LogosAPI(std::string("core"));
|
||||
|
||||
if (auto it = moduleTransportsMap().find("capability_module");
|
||||
it != moduleTransportsMap().end() && !it->second.empty()) {
|
||||
const auto ts = logos::transportSetFromJsonString(it->second);
|
||||
if (!ts.empty()) {
|
||||
return s_coreApi->getClient(
|
||||
QStringLiteral("capability_module"), ts.front());
|
||||
}
|
||||
}
|
||||
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).
|
||||
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");
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void notifyCapabilityModule(const std::string& name, const std::string& token) {
|
||||
if (!registryInstance().isLoaded("capability_module"))
|
||||
return;
|
||||
@@ -79,36 +157,7 @@ namespace {
|
||||
TokenManager& tokenManager = TokenManager::instance();
|
||||
std::string capabilityModuleToken = tokenManager.getToken(std::string("capability_module"));
|
||||
|
||||
static LogosAPI* s_coreApi = nullptr;
|
||||
if (!s_coreApi)
|
||||
s_coreApi = new LogosAPI(std::string("core"));
|
||||
|
||||
// Pick the right transport for dialing capability_module. The
|
||||
// single-arg getClient() defaults to the *global* transport
|
||||
// config (LocalSocket), which is wrong when an operator
|
||||
// configured capability_module for tcp / tcp_ssl only — the
|
||||
// parent's RPC then hangs trying to reach a LocalSocket that
|
||||
// capability_module never bound.
|
||||
//
|
||||
// Resolution order matches the operator's intent: prefer the
|
||||
// first transport the operator named (so a `--module-transport
|
||||
// capability_module=local --module-transport
|
||||
// capability_module=tcp` setup uses LocalSocket, but a tcp-only
|
||||
// setup uses tcp). Empty / unset map → fall back to the global
|
||||
// default, which is correct for the default LocalSocket-only
|
||||
// case (no per-module config registered).
|
||||
LogosAPIClient* client = nullptr;
|
||||
if (auto it = moduleTransportsMap().find("capability_module");
|
||||
it != moduleTransportsMap().end() && !it->second.empty()) {
|
||||
const auto ts = logos::transportSetFromJsonString(it->second);
|
||||
if (!ts.empty()) {
|
||||
client = s_coreApi->getClient(
|
||||
QStringLiteral("capability_module"), ts.front());
|
||||
}
|
||||
}
|
||||
if (!client) {
|
||||
client = s_coreApi->getClient(std::string("capability_module"));
|
||||
}
|
||||
LogosAPIClient* client = capabilityModuleClient();
|
||||
|
||||
if (!client->informModuleToken(capabilityModuleToken, name, token)) {
|
||||
spdlog::warn("Failed to register token with capability module for: {}", name);
|
||||
@@ -264,6 +313,17 @@ namespace ModuleManager {
|
||||
moduleTransportsMap()[moduleName] = transportSetJson;
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
void discoverInstalledModules() {
|
||||
registryInstance().discoverInstalledModules();
|
||||
}
|
||||
@@ -346,6 +406,9 @@ namespace ModuleManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Register restrictions now, before any other module can call out.
|
||||
pushAccessRestrictionsToCapabilityModule();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -435,6 +498,7 @@ namespace ModuleManager {
|
||||
// clear() between scenarios) would inherit the previous
|
||||
// run's transport map and bind unexpected ports.
|
||||
moduleTransportsMap().clear();
|
||||
accessPolicyJson().clear(); // same rationale — don't leak across restarts
|
||||
}
|
||||
|
||||
char** getLoadedModulesCStr() {
|
||||
|
||||
@@ -35,6 +35,13 @@ namespace ModuleManager {
|
||||
void setModuleTransports(const std::string& moduleName,
|
||||
const std::string& transportSetJson);
|
||||
|
||||
// Store the inter-module access policy (the raw JSON document set via
|
||||
// logos_core_set_access_policy). Core parses it and registers the
|
||||
// concrete per-target restrictions with capability_module once that
|
||||
// module is loaded (see initializeCapabilityModule). Must be called
|
||||
// BEFORE logos_core_start(). Empty clears any previously set policy.
|
||||
void setAccessPolicy(const std::string& policyJson);
|
||||
|
||||
void discoverInstalledModules();
|
||||
|
||||
std::string processModule(const std::string& modulePath);
|
||||
|
||||
@@ -9,6 +9,7 @@ get_filename_component(LOGOS_CPP_SDK_ROOT "${LOGOS_CPP_SDK_ROOT}" ABSOLUTE)
|
||||
|
||||
add_executable(logos_core_tests
|
||||
test_app_lifecycle.cpp
|
||||
test_access_policy.cpp
|
||||
test_module_manager.cpp
|
||||
test_process_stats.cpp
|
||||
test_dependency_resolver.cpp
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
// =============================================================================
|
||||
// Tests for the access-policy parser (src/logos_core/access_policy.{h,cpp}).
|
||||
//
|
||||
// parseAccessPolicy turns the JSON document set via
|
||||
// logos_core_set_access_policy into a structured AccessPolicy. Core uses it to
|
||||
// register concrete per-target restrictions with capability_module; enforcement
|
||||
// itself lives in capability_module (tested there). These tests pin the parse
|
||||
// contract:
|
||||
// - the exact basecamp/daemon production document parses correctly
|
||||
// - mode != "enforce" is reflected via enforce()==false (core registers nothing)
|
||||
// - tolerant parsing: unknown keys ignored, missing/!object restrictions ->
|
||||
// empty, missing allowedCallers -> target with empty caller list
|
||||
// - the ONLY hard failure is invalid JSON -> std::nullopt
|
||||
// =============================================================================
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "access_policy.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <string>
|
||||
|
||||
using LogosCore::AccessPolicy;
|
||||
using LogosCore::parseAccessPolicy;
|
||||
|
||||
namespace {
|
||||
|
||||
// Find a restriction by target name in a parsed policy, or nullptr.
|
||||
const LogosCore::AccessRestriction* findTarget(const AccessPolicy& p,
|
||||
const std::string& target) {
|
||||
for (const auto& r : p.restrictions)
|
||||
if (r.target == target) return &r;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool callersContain(const LogosCore::AccessRestriction& r, const std::string& caller) {
|
||||
return std::find(r.allowedCallers.begin(), r.allowedCallers.end(), caller)
|
||||
!= r.allowedCallers.end();
|
||||
}
|
||||
|
||||
// The exact document basecamp (app/main.cpp) and the logoscore daemon pass.
|
||||
const char* kProductionPolicy =
|
||||
"{\"version\":1,\"mode\":\"enforce\",\"restrictions\":{"
|
||||
"\"package_manager\":{\"allowedCallers\":[\"package_manager_ui\"]},"
|
||||
"\"package_downloader\":{\"allowedCallers\":[\"package_manager_ui\"]}}}";
|
||||
|
||||
} // namespace
|
||||
|
||||
// ── Production document ──────────────────────────────────────────────────────
|
||||
|
||||
TEST(AccessPolicyParse, ParsesProductionDocument) {
|
||||
auto policy = parseAccessPolicy(kProductionPolicy);
|
||||
ASSERT_TRUE(policy.has_value());
|
||||
|
||||
EXPECT_EQ(policy->version, 1);
|
||||
EXPECT_EQ(policy->mode, "enforce");
|
||||
EXPECT_TRUE(policy->enforce());
|
||||
ASSERT_EQ(policy->restrictions.size(), 2u);
|
||||
|
||||
const auto* pm = findTarget(*policy, "package_manager");
|
||||
ASSERT_NE(pm, nullptr);
|
||||
ASSERT_EQ(pm->allowedCallers.size(), 1u);
|
||||
EXPECT_TRUE(callersContain(*pm, "package_manager_ui"));
|
||||
|
||||
const auto* pd = findTarget(*policy, "package_downloader");
|
||||
ASSERT_NE(pd, nullptr);
|
||||
EXPECT_TRUE(callersContain(*pd, "package_manager_ui"));
|
||||
}
|
||||
|
||||
// ── mode semantics ───────────────────────────────────────────────────────────
|
||||
|
||||
TEST(AccessPolicyParse, NonEnforceModeReportsEnforceFalse) {
|
||||
auto policy = parseAccessPolicy(
|
||||
"{\"version\":1,\"mode\":\"audit\",\"restrictions\":{"
|
||||
"\"package_manager\":{\"allowedCallers\":[\"package_manager_ui\"]}}}");
|
||||
ASSERT_TRUE(policy.has_value());
|
||||
EXPECT_EQ(policy->mode, "audit");
|
||||
EXPECT_FALSE(policy->enforce());
|
||||
// Restrictions still parse — core just won't register them in non-enforce.
|
||||
EXPECT_EQ(policy->restrictions.size(), 1u);
|
||||
}
|
||||
|
||||
TEST(AccessPolicyParse, MissingModeIsNotEnforce) {
|
||||
auto policy = parseAccessPolicy("{\"version\":1,\"restrictions\":{}}");
|
||||
ASSERT_TRUE(policy.has_value());
|
||||
EXPECT_FALSE(policy->enforce());
|
||||
}
|
||||
|
||||
// ── Multiple callers per target ──────────────────────────────────────────────
|
||||
|
||||
TEST(AccessPolicyParse, ParsesMultipleAllowedCallers) {
|
||||
auto policy = parseAccessPolicy(
|
||||
"{\"version\":1,\"mode\":\"enforce\",\"restrictions\":{"
|
||||
"\"target\":{\"allowedCallers\":[\"a\",\"b\",\"c\"]}}}");
|
||||
ASSERT_TRUE(policy.has_value());
|
||||
const auto* t = findTarget(*policy, "target");
|
||||
ASSERT_NE(t, nullptr);
|
||||
EXPECT_EQ(t->allowedCallers.size(), 3u);
|
||||
EXPECT_TRUE(callersContain(*t, "a"));
|
||||
EXPECT_TRUE(callersContain(*t, "b"));
|
||||
EXPECT_TRUE(callersContain(*t, "c"));
|
||||
}
|
||||
|
||||
// ── Tolerant parsing ─────────────────────────────────────────────────────────
|
||||
|
||||
TEST(AccessPolicyParse, EmptyRestrictionsObjectYieldsNoRestrictions) {
|
||||
auto policy = parseAccessPolicy("{\"version\":1,\"mode\":\"enforce\",\"restrictions\":{}}");
|
||||
ASSERT_TRUE(policy.has_value());
|
||||
EXPECT_TRUE(policy->enforce());
|
||||
EXPECT_TRUE(policy->restrictions.empty());
|
||||
}
|
||||
|
||||
TEST(AccessPolicyParse, MissingRestrictionsKeyYieldsNoRestrictions) {
|
||||
auto policy = parseAccessPolicy("{\"version\":1,\"mode\":\"enforce\"}");
|
||||
ASSERT_TRUE(policy.has_value());
|
||||
EXPECT_TRUE(policy->restrictions.empty());
|
||||
}
|
||||
|
||||
TEST(AccessPolicyParse, TargetWithoutAllowedCallersYieldsEmptyCallerList) {
|
||||
// A restricted target with no allowedCallers means "nobody may call it"
|
||||
// once enforced — the parser surfaces it as a target with zero callers.
|
||||
auto policy = parseAccessPolicy(
|
||||
"{\"version\":1,\"mode\":\"enforce\",\"restrictions\":{\"target\":{}}}");
|
||||
ASSERT_TRUE(policy.has_value());
|
||||
const auto* t = findTarget(*policy, "target");
|
||||
ASSERT_NE(t, nullptr);
|
||||
EXPECT_TRUE(t->allowedCallers.empty());
|
||||
}
|
||||
|
||||
TEST(AccessPolicyParse, UnknownTopLevelKeysAreIgnored) {
|
||||
auto policy = parseAccessPolicy(
|
||||
"{\"version\":1,\"mode\":\"enforce\",\"futureField\":42,"
|
||||
"\"restrictions\":{\"target\":{\"allowedCallers\":[\"a\"],\"note\":\"x\"}}}");
|
||||
ASSERT_TRUE(policy.has_value());
|
||||
EXPECT_TRUE(policy->enforce());
|
||||
const auto* t = findTarget(*policy, "target");
|
||||
ASSERT_NE(t, nullptr);
|
||||
EXPECT_EQ(t->allowedCallers.size(), 1u);
|
||||
}
|
||||
|
||||
TEST(AccessPolicyParse, NonStringCallersAreSkipped) {
|
||||
auto policy = parseAccessPolicy(
|
||||
"{\"version\":1,\"mode\":\"enforce\",\"restrictions\":{"
|
||||
"\"target\":{\"allowedCallers\":[\"ok\",123,null,\"also_ok\"]}}}");
|
||||
ASSERT_TRUE(policy.has_value());
|
||||
const auto* t = findTarget(*policy, "target");
|
||||
ASSERT_NE(t, nullptr);
|
||||
EXPECT_EQ(t->allowedCallers.size(), 2u);
|
||||
EXPECT_TRUE(callersContain(*t, "ok"));
|
||||
EXPECT_TRUE(callersContain(*t, "also_ok"));
|
||||
}
|
||||
|
||||
// ── Hard failures ────────────────────────────────────────────────────────────
|
||||
|
||||
TEST(AccessPolicyParse, InvalidJsonReturnsNullopt) {
|
||||
EXPECT_FALSE(parseAccessPolicy("{not valid json").has_value());
|
||||
EXPECT_FALSE(parseAccessPolicy("").has_value());
|
||||
EXPECT_FALSE(parseAccessPolicy("garbage").has_value());
|
||||
}
|
||||
|
||||
TEST(AccessPolicyParse, NonObjectJsonReturnsNullopt) {
|
||||
// Valid JSON, but not a policy object.
|
||||
EXPECT_FALSE(parseAccessPolicy("[1,2,3]").has_value());
|
||||
EXPECT_FALSE(parseAccessPolicy("\"a string\"").has_value());
|
||||
EXPECT_FALSE(parseAccessPolicy("42").has_value());
|
||||
}
|
||||
|
||||
TEST(AccessPolicyParse, VersionDefaultsToZeroWhenAbsent) {
|
||||
auto policy = parseAccessPolicy("{\"mode\":\"enforce\",\"restrictions\":{}}");
|
||||
ASSERT_TRUE(policy.has_value());
|
||||
EXPECT_EQ(policy->version, 0);
|
||||
}
|
||||
@@ -120,14 +120,15 @@ TEST_F(AppLifecycleTest, Start_UsesCustomModulesDirs) {
|
||||
// Access Policy Tests
|
||||
// =============================================================================
|
||||
//
|
||||
// logos_core_set_access_policy is currently a no-op: the policy is
|
||||
// accepted but not yet enforced (see the TODO in logos_core.cpp). These
|
||||
// tests don't assert any behavioural effect — there's nothing to observe
|
||||
// yet — but they pin the contract that already holds: the call accepts a
|
||||
// well-formed policy, an empty string, and NULL (the documented "clear"
|
||||
// signal) without crashing or aborting, and without disturbing unrelated
|
||||
// core state. When enforcement lands, these become the scaffold for
|
||||
// asserting the policy actually gates calls.
|
||||
// logos_core_set_access_policy stores the policy; core parses it and
|
||||
// registers the concrete per-target restrictions with capability_module
|
||||
// once that module loads (inside logos_core_start). The parser is unit-
|
||||
// tested in test_access_policy.cpp, and the enforcement (deny token
|
||||
// issuance for a disallowed caller) is tested in capability_module's own
|
||||
// suite. Here we only pin the C-API setter contract that holds without a
|
||||
// running capability_module: the call accepts a well-formed policy, an
|
||||
// empty string, and NULL (the documented "clear" signal) without crashing
|
||||
// or aborting, and without disturbing unrelated core state.
|
||||
|
||||
TEST_F(AppLifecycleTest, SetAccessPolicy_AcceptsValidPolicyWithoutCrashing) {
|
||||
const char* policy =
|
||||
|
||||
Reference in New Issue
Block a user