|
|
@@ -0,0 +1,342 @@
|
|
|
+// v2.8.0 — access policy engine tests.
|
|
|
+//
|
|
|
+// The properties that matter most here are the safety ones, because they are
|
|
|
+// what make the feature usable on a live system rather than a footgun:
|
|
|
+//
|
|
|
+// * security OFF is the default and allows everything, so upgrading changes
|
|
|
+// nothing for any existing deployment;
|
|
|
+// * enabling is REFUSED without an admin policy, which makes lockout
|
|
|
+// structurally impossible rather than a matter of operator care;
|
|
|
+// * removing the last admin of a secured project is refused for the same
|
|
|
+// reason;
|
|
|
+// * audit mode evaluates honestly and logs, but allows - the migration path
|
|
|
+// for switching a live project on;
|
|
|
+// * once enforcing, an unlisted principal gets nothing (deny-by-default), and
|
|
|
+// system collections are admin-only, since read access to `_policies` would
|
|
|
+// expose the whole access model and write access would be escalation.
|
|
|
+
|
|
|
+#include <atomic>
|
|
|
+#include <filesystem>
|
|
|
+#include <iostream>
|
|
|
+#include <string>
|
|
|
+#include <unistd.h>
|
|
|
+
|
|
|
+#include <nlohmann/json.hpp>
|
|
|
+
|
|
|
+#include "memory_store.hpp"
|
|
|
+#include "security/policy_manager.hpp"
|
|
|
+
|
|
|
+// setSecurity(enabled=true) is refused while kEnforcementCoverageComplete is
|
|
|
+// false - see policy_manager.hpp. The fixture calls allowEnableForTests() so
|
|
|
+// these tests can still exercise enforcing behaviour; a separate test below
|
|
|
+// asserts the guard itself.
|
|
|
+
|
|
|
+namespace fs = std::filesystem;
|
|
|
+using namespace smartbotic::database;
|
|
|
+
|
|
|
+namespace {
|
|
|
+
|
|
|
+int g_pass = 0;
|
|
|
+int g_fail = 0;
|
|
|
+
|
|
|
+void check(bool cond, const char* msg) {
|
|
|
+ if (cond) {
|
|
|
+ ++g_pass;
|
|
|
+ } else {
|
|
|
+ ++g_fail;
|
|
|
+ std::cerr << "FAIL: " << msg << "\n";
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+struct Fixture {
|
|
|
+ MemoryStore store;
|
|
|
+ PolicyManager pm;
|
|
|
+
|
|
|
+ Fixture() : store(MemoryStore::Config{}), pm(store) {
|
|
|
+ store.start();
|
|
|
+ pm.loadFromStore();
|
|
|
+ // These tests are the engine's own; they must be able to reach
|
|
|
+ // enforcing behaviour even though the deployment-level guard is armed.
|
|
|
+ pm.allowEnableForTests();
|
|
|
+ }
|
|
|
+ ~Fixture() { store.stop(); }
|
|
|
+};
|
|
|
+
|
|
|
+Policy mkPolicy(const std::string& principal, bool admin = false) {
|
|
|
+ Policy p;
|
|
|
+ p.principal = principal;
|
|
|
+ p.admin = admin;
|
|
|
+ return p;
|
|
|
+}
|
|
|
+
|
|
|
+PolicyRule rw(bool r, bool w) {
|
|
|
+ PolicyRule x;
|
|
|
+ x.read = r;
|
|
|
+ x.write = w;
|
|
|
+ return x;
|
|
|
+}
|
|
|
+
|
|
|
+// -------------------------------------------------------------------------
|
|
|
+
|
|
|
+void test_security_off_allows_everything() {
|
|
|
+ Fixture f;
|
|
|
+ // No policies, no security record: the pre-2.8.0 world.
|
|
|
+ auto d = f.pm.authorize("acme", "nobody", "users", Access::Read);
|
|
|
+ check(d.allowed, "security off allows an unknown principal to read");
|
|
|
+ check(f.pm.authorize("acme", "nobody", "users", Access::Write).allowed,
|
|
|
+ "security off allows writes too");
|
|
|
+ check(f.pm.authorize("acme", "nobody", "_policies", Access::Read).allowed,
|
|
|
+ "security off does not even guard system collections");
|
|
|
+ check(!f.pm.anyProjectSecured(), "no project is secured by default");
|
|
|
+ check(f.pm.mayAdminister("acme", "anyone"),
|
|
|
+ "with security off anyone may create the first policy");
|
|
|
+}
|
|
|
+
|
|
|
+void test_enabling_without_admin_is_refused() {
|
|
|
+ Fixture f;
|
|
|
+ std::string err;
|
|
|
+ ProjectSecurity sec;
|
|
|
+ sec.enabled = true;
|
|
|
+
|
|
|
+ check(!f.pm.setSecurity("acme", sec, err),
|
|
|
+ "enabling security with no admin policy must be refused");
|
|
|
+ check(err.find("admin") != std::string::npos,
|
|
|
+ "and the error must say why - no admin policy");
|
|
|
+ check(!f.pm.securityOf("acme").enabled, "security stays off after refusal");
|
|
|
+
|
|
|
+ // Non-admin policies are not enough.
|
|
|
+ auto p = mkPolicy("shadowman");
|
|
|
+ p.collections["users"] = rw(true, false);
|
|
|
+ check(f.pm.setPolicy("acme", p, err), "a non-admin policy can be created");
|
|
|
+ check(!f.pm.setSecurity("acme", sec, err),
|
|
|
+ "a non-admin policy does not satisfy the admin requirement");
|
|
|
+
|
|
|
+ check(f.pm.setPolicy("acme", mkPolicy("ops", /*admin=*/true), err),
|
|
|
+ "an admin policy can be created");
|
|
|
+ check(f.pm.setSecurity("acme", sec, err),
|
|
|
+ "with an admin present, enabling succeeds");
|
|
|
+ check(f.pm.securityOf("acme").enabled, "and security is now on");
|
|
|
+ check(f.pm.anyProjectSecured(), "anyProjectSecured reflects it");
|
|
|
+}
|
|
|
+
|
|
|
+void test_deny_by_default_once_enforcing() {
|
|
|
+ Fixture f;
|
|
|
+ std::string err;
|
|
|
+ f.pm.setPolicy("acme", mkPolicy("ops", true), err);
|
|
|
+ auto reader = mkPolicy("reader");
|
|
|
+ reader.collections["users"] = rw(true, false);
|
|
|
+ f.pm.setPolicy("acme", reader, err);
|
|
|
+ ProjectSecurity sec; sec.enabled = true;
|
|
|
+ f.pm.setSecurity("acme", sec, err);
|
|
|
+
|
|
|
+ check(!f.pm.authorize("acme", "stranger", "users", Access::Read).allowed,
|
|
|
+ "a principal with no policy gets nothing");
|
|
|
+ check(f.pm.authorize("acme", "reader", "users", Access::Read).allowed,
|
|
|
+ "a granted read is allowed");
|
|
|
+ check(!f.pm.authorize("acme", "reader", "users", Access::Write).allowed,
|
|
|
+ "read does not imply write");
|
|
|
+ check(!f.pm.authorize("acme", "reader", "sessions", Access::Read).allowed,
|
|
|
+ "a collection with no rule is denied even for a known principal");
|
|
|
+ check(f.pm.authorize("acme", "ops", "anything", Access::Write).allowed,
|
|
|
+ "admin may write anything in the project");
|
|
|
+
|
|
|
+ // Another project is untouched.
|
|
|
+ check(f.pm.authorize("other", "stranger", "users", Access::Read).allowed,
|
|
|
+ "enabling one project must not affect another");
|
|
|
+}
|
|
|
+
|
|
|
+void test_wildcard_rule_and_exact_match_precedence() {
|
|
|
+ Fixture f;
|
|
|
+ std::string err;
|
|
|
+ f.pm.setPolicy("acme", mkPolicy("ops", true), err);
|
|
|
+ auto p = mkPolicy("svc");
|
|
|
+ p.collections["*"] = rw(true, false);
|
|
|
+ p.collections["secrets"] = rw(false, false);
|
|
|
+ f.pm.setPolicy("acme", p, err);
|
|
|
+ ProjectSecurity sec; sec.enabled = true;
|
|
|
+ f.pm.setSecurity("acme", sec, err);
|
|
|
+
|
|
|
+ check(f.pm.authorize("acme", "svc", "anything", Access::Read).allowed,
|
|
|
+ "the * fallback grants read on an unlisted collection");
|
|
|
+ check(!f.pm.authorize("acme", "svc", "secrets", Access::Read).allowed,
|
|
|
+ "an exact rule overrides the * fallback, even to deny");
|
|
|
+}
|
|
|
+
|
|
|
+void test_system_collections_are_admin_only() {
|
|
|
+ Fixture f;
|
|
|
+ std::string err;
|
|
|
+ f.pm.setPolicy("acme", mkPolicy("ops", true), err);
|
|
|
+ auto p = mkPolicy("svc");
|
|
|
+ p.collections["*"] = rw(true, true);
|
|
|
+ f.pm.setPolicy("acme", p, err);
|
|
|
+ ProjectSecurity sec; sec.enabled = true;
|
|
|
+ f.pm.setSecurity("acme", sec, err);
|
|
|
+
|
|
|
+ check(!f.pm.authorize("acme", "svc", "_policies", Access::Read).allowed,
|
|
|
+ "a * grant must NOT reach _policies - that would expose the access model");
|
|
|
+ check(!f.pm.authorize("acme", "svc", "_policies", Access::Write).allowed,
|
|
|
+ "nor allow writing it - that would be privilege escalation");
|
|
|
+ check(!f.pm.authorize("acme", "svc", "_views", Access::Read).allowed,
|
|
|
+ "no system collection is reachable via *");
|
|
|
+ check(f.pm.authorize("acme", "ops", "_policies", Access::Write).allowed,
|
|
|
+ "an admin may still manage system collections");
|
|
|
+ check(!f.pm.mayAdminister("acme", "svc"), "a non-admin may not administer policy");
|
|
|
+ check(f.pm.mayAdminister("acme", "ops"), "an admin may");
|
|
|
+}
|
|
|
+
|
|
|
+void test_mask_and_row_predicate_are_returned() {
|
|
|
+ Fixture f;
|
|
|
+ std::string err;
|
|
|
+ f.pm.setPolicy("acme", mkPolicy("ops", true), err);
|
|
|
+ auto p = mkPolicy("svc");
|
|
|
+ PolicyRule r = rw(true, false);
|
|
|
+ r.mask = {"ssn", "profile.dob"};
|
|
|
+ Filter tenant;
|
|
|
+ tenant.field = "tenant";
|
|
|
+ tenant.op = FilterOp::EQ;
|
|
|
+ tenant.value = "acme";
|
|
|
+ r.row = {tenant};
|
|
|
+ p.collections["users"] = r;
|
|
|
+ f.pm.setPolicy("acme", p, err);
|
|
|
+ ProjectSecurity sec; sec.enabled = true;
|
|
|
+ f.pm.setSecurity("acme", sec, err);
|
|
|
+
|
|
|
+ auto d = f.pm.authorize("acme", "svc", "users", Access::Read);
|
|
|
+ check(d.allowed, "granted");
|
|
|
+ check(d.mask.size() == 2, "the column mask comes back with the decision");
|
|
|
+ check(d.mask[0] == "ssn", "mask paths preserved");
|
|
|
+ check(d.row.size() == 1 && d.row[0].field == "tenant",
|
|
|
+ "the row predicate comes back so the handler can AND-merge it");
|
|
|
+}
|
|
|
+
|
|
|
+void test_audit_mode_logs_but_allows() {
|
|
|
+ Fixture f;
|
|
|
+ std::string err;
|
|
|
+ f.pm.setPolicy("acme", mkPolicy("ops", true), err);
|
|
|
+ ProjectSecurity sec;
|
|
|
+ sec.enabled = true;
|
|
|
+ sec.mode = SecurityMode::Audit;
|
|
|
+ check(f.pm.setSecurity("acme", sec, err), "audit mode can be enabled");
|
|
|
+
|
|
|
+ auto d = f.pm.authorize("acme", "stranger", "users", Access::Read);
|
|
|
+ check(d.allowed, "audit mode ALLOWS what enforce mode would deny");
|
|
|
+ check(d.audited_denial, "but records that it was really a denial");
|
|
|
+ check(!d.reason.empty(), "and keeps the reason for the operator log");
|
|
|
+
|
|
|
+ // Flip to enforce and the same request is refused.
|
|
|
+ sec.mode = SecurityMode::Enforce;
|
|
|
+ f.pm.setSecurity("acme", sec, err);
|
|
|
+ auto d2 = f.pm.authorize("acme", "stranger", "users", Access::Read);
|
|
|
+ check(!d2.allowed, "enforce mode denies the same request");
|
|
|
+ check(!d2.audited_denial, "and does not mark it as merely audited");
|
|
|
+}
|
|
|
+
|
|
|
+void test_cannot_remove_last_admin_of_secured_project() {
|
|
|
+ Fixture f;
|
|
|
+ std::string err;
|
|
|
+ f.pm.setPolicy("acme", mkPolicy("ops", true), err);
|
|
|
+ ProjectSecurity sec; sec.enabled = true;
|
|
|
+ f.pm.setSecurity("acme", sec, err);
|
|
|
+
|
|
|
+ check(!f.pm.removePolicy("acme", "ops", err),
|
|
|
+ "removing the last admin of a secured project must be refused");
|
|
|
+ check(err.find("last admin") != std::string::npos, "with a clear reason");
|
|
|
+
|
|
|
+ check(f.pm.setPolicy("acme", mkPolicy("ops2", true), err), "add a second admin");
|
|
|
+ check(f.pm.removePolicy("acme", "ops", err),
|
|
|
+ "now the first admin can be removed");
|
|
|
+ check(!f.pm.removePolicy("acme", "ops2", err),
|
|
|
+ "but not the remaining last one");
|
|
|
+}
|
|
|
+
|
|
|
+void test_policies_survive_reload() {
|
|
|
+ Fixture f;
|
|
|
+ std::string err;
|
|
|
+ auto p = mkPolicy("svc");
|
|
|
+ PolicyRule r = rw(true, false);
|
|
|
+ r.mask = {"ssn"};
|
|
|
+ p.collections["users"] = r;
|
|
|
+ f.pm.setPolicy("acme", p, err);
|
|
|
+ f.pm.setPolicy("acme", mkPolicy("ops", true), err);
|
|
|
+ ProjectSecurity sec; sec.enabled = true; sec.mode = SecurityMode::Audit;
|
|
|
+ f.pm.setSecurity("acme", sec, err);
|
|
|
+
|
|
|
+ // Reload from the same store, as a restart would.
|
|
|
+ f.pm.loadFromStore();
|
|
|
+
|
|
|
+ check(f.pm.securityOf("acme").enabled, "enable flag survives reload");
|
|
|
+ check(f.pm.securityOf("acme").mode == SecurityMode::Audit,
|
|
|
+ "mode survives reload");
|
|
|
+ auto got = f.pm.getPolicy("acme", "svc");
|
|
|
+ check(got.has_value(), "policy survives reload");
|
|
|
+ check(got && got->collections.count("users") == 1, "rules survive reload");
|
|
|
+ check(got && got->collections["users"].mask.size() == 1,
|
|
|
+ "the column mask survives reload");
|
|
|
+ check(f.pm.listPolicies("acme").size() == 2, "both policies listed");
|
|
|
+ check(f.pm.listPolicies("other").empty(),
|
|
|
+ "listing is scoped to the project");
|
|
|
+}
|
|
|
+
|
|
|
+void test_file_rules_are_separate_from_collections() {
|
|
|
+ Fixture f;
|
|
|
+ std::string err;
|
|
|
+ f.pm.setPolicy("acme", mkPolicy("ops", true), err);
|
|
|
+ auto p = mkPolicy("svc");
|
|
|
+ p.collections["*"] = rw(true, true);
|
|
|
+ p.files["plugin"] = rw(true, false);
|
|
|
+ f.pm.setPolicy("acme", p, err);
|
|
|
+ ProjectSecurity sec; sec.enabled = true;
|
|
|
+ f.pm.setSecurity("acme", sec, err);
|
|
|
+
|
|
|
+ check(f.pm.authorizeFile("acme", "svc", "plugin", Access::Read).allowed,
|
|
|
+ "a granted file type reads");
|
|
|
+ check(!f.pm.authorizeFile("acme", "svc", "plugin", Access::Write).allowed,
|
|
|
+ "file write is separately gated");
|
|
|
+ check(!f.pm.authorizeFile("acme", "svc", "document", Access::Read).allowed,
|
|
|
+ "an unlisted file type is denied - a collection * does not cover files");
|
|
|
+}
|
|
|
+
|
|
|
+// The deployment guard itself: without the test seam, arming security must be
|
|
|
+// refused while enforcement is not wired into every handler. A project
|
|
|
+// protected on some paths and open on others reports safety it does not have.
|
|
|
+void test_enable_is_refused_while_coverage_incomplete() {
|
|
|
+ MemoryStore store(MemoryStore::Config{});
|
|
|
+ store.start();
|
|
|
+ PolicyManager pm(store); // note: NO allowEnableForTests()
|
|
|
+ pm.loadFromStore();
|
|
|
+ std::string err;
|
|
|
+ pm.setPolicy("acme", mkPolicy("ops", true), err);
|
|
|
+ ProjectSecurity sec; sec.enabled = true;
|
|
|
+
|
|
|
+ if (kEnforcementCoverageComplete) {
|
|
|
+ check(pm.setSecurity("acme", sec, err),
|
|
|
+ "coverage complete: enabling is permitted");
|
|
|
+ } else {
|
|
|
+ check(!pm.setSecurity("acme", sec, err),
|
|
|
+ "coverage incomplete: enabling must be refused even with an admin");
|
|
|
+ check(err.find("not yet wired") != std::string::npos,
|
|
|
+ "and the refusal must say enforcement is incomplete");
|
|
|
+ check(!pm.securityOf("acme").enabled, "security stays off");
|
|
|
+ }
|
|
|
+ store.stop();
|
|
|
+}
|
|
|
+
|
|
|
+} // namespace
|
|
|
+
|
|
|
+int main() {
|
|
|
+ std::cout << "=== test_policy_manager ===\n";
|
|
|
+ test_security_off_allows_everything();
|
|
|
+ test_enabling_without_admin_is_refused();
|
|
|
+ test_deny_by_default_once_enforcing();
|
|
|
+ test_wildcard_rule_and_exact_match_precedence();
|
|
|
+ test_system_collections_are_admin_only();
|
|
|
+ test_mask_and_row_predicate_are_returned();
|
|
|
+ test_audit_mode_logs_but_allows();
|
|
|
+ test_cannot_remove_last_admin_of_secured_project();
|
|
|
+ test_policies_survive_reload();
|
|
|
+ test_file_rules_are_separate_from_collections();
|
|
|
+ test_enable_is_refused_while_coverage_incomplete();
|
|
|
+
|
|
|
+ std::cout << "passed: " << g_pass << ", failed: " << g_fail << "\n";
|
|
|
+ return g_fail == 0 ? 0 : 1;
|
|
|
+}
|