diff --git a/README.md b/README.md index e588a43..15545ba 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/project.md b/docs/project.md index 7aa5874..184cecf 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). 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 | diff --git a/docs/spec.md b/docs/spec.md index adb80ca..e119ff3 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. 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 diff --git a/flake.lock b/flake.lock index 3fe2a7a..75a2176 100644 --- a/flake.lock +++ b/flake.lock @@ -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": { diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index b2dca4b..f3011f9 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -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 diff --git a/src/logos_core/access_policy.cpp b/src/logos_core/access_policy.cpp new file mode 100644 index 0000000..09bb3a1 --- /dev/null +++ b/src/logos_core/access_policy.cpp @@ -0,0 +1,52 @@ +#include "access_policy.h" + +#include + +namespace LogosCore { + +std::optional 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()); + } + } + } + + policy.restrictions.push_back(std::move(restriction)); + } + } + + return policy; +} + +} // namespace LogosCore diff --git a/src/logos_core/access_policy.h b/src/logos_core/access_policy.h new file mode 100644 index 0000000..b5a29bc --- /dev/null +++ b/src/logos_core/access_policy.h @@ -0,0 +1,37 @@ +#ifndef ACCESS_POLICY_H +#define ACCESS_POLICY_H + +#include +#include +#include + +// 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 allowedCallers; +}; + +struct AccessPolicy { + int version = 0; + std::string mode; + std::vector 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 parseAccessPolicy(const std::string& json); + +} // namespace LogosCore + +#endif // ACCESS_POLICY_H diff --git a/src/logos_core/logos_core.cpp b/src/logos_core/logos_core.cpp index 8efded4..da5ac75 100644 --- a/src/logos_core/logos_core.cpp +++ b/src/logos_core/logos_core.cpp @@ -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() diff --git a/src/logos_core/logos_core.h b/src/logos_core/logos_core.h index 484923e..aaf6f38 100644 --- a/src/logos_core/logos_core.h +++ b/src/logos_core/logos_core.h @@ -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. diff --git a/src/logos_core/module_manager.cpp b/src/logos_core/module_manager.cpp index 44ad4bc..6f5f5b5 100644 --- a/src/logos_core/module_manager.cpp +++ b/src/logos_core/module_manager.cpp @@ -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 +#include +#include 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 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() { diff --git a/src/logos_core/module_manager.h b/src/logos_core/module_manager.h index c71c9b9..717c05d 100644 --- a/src/logos_core/module_manager.h +++ b/src/logos_core/module_manager.h @@ -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); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 824975e..7c90a57 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -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 diff --git a/tests/test_access_policy.cpp b/tests/test_access_policy.cpp new file mode 100644 index 0000000..8ec3492 --- /dev/null +++ b/tests/test_access_policy.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 + +#include "access_policy.h" + +#include +#include + +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); +} diff --git a/tests/test_app_lifecycle.cpp b/tests/test_app_lifecycle.cpp index 8d74beb..e36c3d5 100644 --- a/tests/test_app_lifecycle.cpp +++ b/tests/test_app_lifecycle.cpp @@ -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 =