Procházet zdrojové kódy

release(v2.7.0): row- and column-level security, off by default

Per-project access policy. Security is OFF per project, so upgrading changes
nothing until it is turned on.

Two fixes made while wiring the CLI, both found by the e2e:

- qualify() no longer qualifies `_`-prefixed collections. System collections are
  global - their rows are keyed by a project-qualified id - so `_policies`
  became `default:_policies`, which does not exist. Reads silently found nothing
  while writes succeeded (the server tolerates both spellings on the policy
  write path), which is how a management command can look like it worked and
  then display stale state.

- That fix then opened a hole: an unqualified `_policies` resolved to project
  "default", normally unsecured, so ANY caller could read every project's
  policies - the whole access model. gate() now special-cases system
  collections and requires admin-somewhere instead of per-project evaluation.
  The e2e caught it within one run of the qualify change.

CLI: security, security-set <project> <on|off> [enforce|audit], policies,
policy, policy-set, policy-rm. Management goes through the ordinary document API
on `_policies`, which the server intercepts, so there is no second mechanism to
keep in step.

Docs: integration guide gains an Access Policy section covering the model, the
safe way to arm a live project (audit first), what enforcement covers including
the non-obvious surfaces, and the limits - service-wide operations cannot be
expressed per project, and the real boundary for operator surfaces is listener
separation.

ctest 17/17, policy enforcement e2e 19/19, namespacing 31/31, views and
TLS/auth green.
fszontagh před 1 měsícem
rodič
revize
7a6fac7da7
6 změnil soubory, kde provedl 261 přidání a 1 odebrání
  1. 0 0
      CLAUDE.md
  2. 1 1
      VERSION
  3. 130 0
      cli/main.cpp
  4. 11 0
      client/src/client.cpp
  5. 101 0
      docs/integration-guide.md
  6. 18 0
      service/src/database_grpc_impl.cpp

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
CLAUDE.md


+ 1 - 1
VERSION

@@ -1 +1 @@
-2.6.0
+2.7.0

+ 130 - 0
cli/main.cpp

@@ -70,6 +70,13 @@ void printUsage() {
               << "  " << C_CYAN << "lock" << C_RESET << "                                 Lock database (read-only)\n"
               << "  " << C_CYAN << "unlock" << C_RESET << "                               Unlock database (accept writes)\n"
               << "  " << C_CYAN << "status" << C_RESET << "                               Show read-only + recovery status\n"
+              << C_BOLD << "  Access policy (v2.7.0+)" << C_RESET << "\n"
+              << "  " << C_CYAN << "security" << C_RESET << " [project]                   Show whether a project enforces policy\n"
+              << "  " << C_CYAN << "security-set" << C_RESET << " <project> <on|off> [enforce|audit]\n"
+              << "  " << C_CYAN << "policies" << C_RESET << " [project]                   List principals with a policy\n"
+              << "  " << C_CYAN << "policy" << C_RESET << " <project> <principal>       Show one policy\n"
+              << "  " << C_CYAN << "policy-set" << C_RESET << " <project> <principal> '<json>'\n"
+              << "  " << C_CYAN << "policy-rm" << C_RESET << " <project> <principal>\n"
               << "  " << C_CYAN << "help" << C_RESET << "                                 Show this help\n\n"
               << C_BOLD << "Offline helpers" << C_RESET << " " << C_DIM << "(no server connection required)" << C_RESET << ":\n"
               << "  " << C_CYAN << "generate-auth-key" << C_RESET << "                    Emit a base64 32-byte API key\n"
@@ -543,6 +550,129 @@ bool execCommand(smartbotic::database::Client& client,
             return true;
         }
 
+
+        // ===== v2.7.0 access policy =====
+        //
+        // Policy lives in the `_policies` collection and is managed through the
+        // ordinary document API, which the server intercepts so edits refresh
+        // its cache and the `__security__` record goes through the lockout
+        // guards. These commands are ergonomics over that, not a second
+        // mechanism - which is why there is no policy RPC to keep in step.
+        if (cmd == "policies") {
+            const std::string project = params.empty() ? "default" : params[0];
+            auto rows = client.find("_policies", smartbotic::database::Client::QueryOptions{.limit = 1000});
+            std::cout << C_BOLD << "policies in project '" << project << "'" << C_RESET << "\n";
+            size_t shown = 0;
+            for (const auto& r : rows) {
+                const std::string id = r.value("_id", "");
+                if (id.rfind(project + ":", 0) != 0) continue;
+                const std::string tail = id.substr(project.size() + 1);
+                if (tail == "__security__") continue;
+                std::cout << "  " << C_CYAN << tail << C_RESET
+                          << (r.value("admin", false) ? "  (admin)" : "") << "\n";
+                ++shown;
+            }
+            if (shown == 0) std::cout << C_DIM << "  (none)" << C_RESET << "\n";
+            return true;
+        }
+
+        if (cmd == "policy") {
+            if (params.size() < 2) {
+                printError("usage: policy <project> <principal>");
+                return false;
+            }
+            auto doc = client.get("_policies", params[0] + ":" + params[1]);
+            if (!doc) { printError("no policy for " + params[0] + ":" + params[1]); return false; }
+            printJson(*doc);
+            return true;
+        }
+
+        if (cmd == "policy-set") {
+            if (params.size() < 3) {
+                printError("usage: policy-set <project> <principal> '<json>'\n"
+                           "  e.g. policy-set acme svc "
+                           "'{\"collections\":{\"users\":{\"read\":true,\"mask\":[\"ssn\"]}}}'\n"
+                           "  admin:  policy-set acme ops '{\"admin\":true}'");
+                return false;
+            }
+            try {
+                auto body = json::parse(params[2]);
+                client.upsert("_policies", body, params[0] + ":" + params[1]);
+                std::cout << C_GREEN << "ok" << C_RESET << " policy set for "
+                          << params[0] << ":" << params[1] << "\n";
+                return true;
+            } catch (const std::exception& e) {
+                printError(e.what());
+                return false;
+            }
+        }
+
+        if (cmd == "policy-rm") {
+            if (params.size() < 2) { printError("usage: policy-rm <project> <principal>"); return false; }
+            try {
+                bool ok = client.remove("_policies", params[0] + ":" + params[1]);
+                std::cout << (ok ? "removed\n" : "not found\n");
+                return ok;
+            } catch (const std::exception& e) {
+                // The server refuses to remove the last admin of a secured
+                // project - that refusal is the lockout guard, not an error to
+                // work around.
+                printError(e.what());
+                return false;
+            }
+        }
+
+        if (cmd == "security") {
+            const std::string project = params.empty() ? "default" : params[0];
+            auto doc = client.get("_policies", project + ":__security__");
+            if (!doc) {
+                std::cout << "project '" << project << "': security "
+                          << C_GREEN << "disabled" << C_RESET
+                          << C_DIM << " (default - all access allowed)" << C_RESET << "\n";
+                return true;
+            }
+            const bool on = doc->value("enabled", false);
+            const std::string mode = doc->value("mode", "enforce");
+            std::cout << "project '" << project << "': security "
+                      << (on ? (mode == "audit" ? C_YELLOW : C_RED) : C_GREEN)
+                      << (on ? (mode == "audit" ? "AUDIT" : "ENFORCED") : "disabled")
+                      << C_RESET << "\n";
+            if (on && mode == "audit") {
+                std::cout << C_DIM << "  audit mode logs what it would deny and "
+                             "allows the request - watch the service log, then "
+                             "switch to enforce." << C_RESET << "\n";
+            }
+            return true;
+        }
+
+        if (cmd == "security-set") {
+            if (params.size() < 2) {
+                printError("usage: security-set <project> <on|off> [enforce|audit]\n"
+                           "  Enabling is REFUSED unless some policy in the project has "
+                           "admin=true.\n"
+                           "  Switch a live project on with 'audit' first.");
+                return false;
+            }
+            const bool on = params[1] == "on" || params[1] == "true";
+            const std::string mode = params.size() > 2 ? params[2] : "enforce";
+            if (mode != "enforce" && mode != "audit") {
+                printError("mode must be 'enforce' or 'audit'");
+                return false;
+            }
+            try {
+                json body;
+                body["enabled"] = on;
+                body["mode"] = mode;
+                client.upsert("_policies", body, params[0] + ":__security__");
+                std::cout << C_GREEN << "ok" << C_RESET << " project '" << params[0]
+                          << "' security " << (on ? mode : "disabled") << "\n";
+                return true;
+            } catch (const std::exception& e) {
+                printError(e.what());
+                return false;
+            }
+        }
+
         if (cmd == "lock") {
             if (client.setReadOnly(true)) {
                 std::cout << C_GREEN << "ok" << C_RESET << " Database locked (read-only)\n";

+ 11 - 0
client/src/client.cpp

@@ -72,6 +72,17 @@ public:
         if (collection.find(':') != std::string_view::npos) {
             return std::string(collection);
         }
+        // v2.7.0 — system collections (`_views`, `_collection_meta`,
+        // `_policies`, `_files`) are GLOBAL, not per-project: their rows are
+        // already keyed by a project-qualified id. Qualifying the collection
+        // name too produced `default:_policies`, which does not exist, so
+        // reads silently found nothing while writes succeeded (the server
+        // tolerates both spellings on the policy write path). That asymmetry
+        // is exactly how a management command can look like it worked and
+        // then show stale state.
+        if (!collection.empty() && collection.front() == '_') {
+            return std::string(collection);
+        }
         std::string out;
         out.reserve(config_.project.size() + 1 + collection.size());
         out.append(config_.project);

+ 101 - 0
docs/integration-guide.md

@@ -15,6 +15,7 @@ How to install, configure, and integrate smartbotic-database into your C++ proje
 - [Upgrading from Legacy Packages](#upgrading-from-legacy-packages)
 - [Drop-in configuration (conf.d)](#drop-in-configuration-confd)
 - [Eviction & Memory Pressure](#eviction--memory-pressure)
+- [Access Policy — row and column level security](#access-policy--row-and-column-level-security-270)
 
 ---
 
@@ -394,6 +395,106 @@ this name is always a subset of what the view allows.
 > reader; the protection is against someone reading data files, snapshots, or
 > backups, not against a client.
 
+### Access Policy — row and column level security (2.7.0+)
+
+Per-project row- and column-level access control. **Off by default**: a project
+with no policy behaves exactly as it did before 2.7.0, so upgrading changes
+nothing until you turn it on.
+
+> Before 2.7.0 there was no access control beyond authentication. Every API-key
+> holder had identical, complete access. Views were never a boundary - see the
+> note under "Why AND-merge filters".
+
+#### The model
+
+- **Principal** = the *name* of the API key the request arrived with. Keys became
+  named in 2.7.0:
+  ```json
+  "auth": { "required": true, "keys": [
+      { "name": "shadowman", "key": "<base64>" },
+      { "name": "callerai",  "key": "<base64>" } ] }
+  ```
+  A bare string key still works and maps to the reserved principal `unnamed`. A
+  request with no usable token is the reserved principal `anonymous` - which is
+  grantable, so the local CLI keeps working by explicit policy rather than by an
+  implicit hole. `anonymous` and `unnamed` are refused as key names.
+- **Policy** = one record per (project, principal) granting `read`/`write` per
+  collection or file type, with an optional column `mask` and row predicate.
+- **admin** grants everything within a project plus the right to edit policy.
+
+#### Turning it on safely
+
+Enabling is **refused** unless some policy in the project has `admin: true` -
+lockout is prevented structurally, not by care. Removing the last admin of a
+secured project is refused for the same reason.
+
+Always arm a live project in **audit** mode first. Audit evaluates every policy,
+logs what it *would* deny, and allows the request:
+
+```bash
+smartbotic-db-cli policy-set acme ops '{"admin":true}'
+smartbotic-db-cli policy-set acme svc \
+  '{"collections":{"users":{"read":true,"mask":["ssn"],
+     "row":[{"field":"tenant","op":0,"value":"acme"}]}}}'
+
+smartbotic-db-cli security-set acme on audit     # watch the log
+smartbotic-db-cli security-set acme on enforce   # then commit
+smartbotic-db-cli security acme                  # show current state
+smartbotic-db-cli policies acme                  # list principals
+```
+
+Once enforcing, the project is **deny-by-default**: a principal with no matching
+rule gets nothing.
+
+#### What enforcement covers
+
+Every RPC that reads or writes user data. Notably including the ones that are
+easy to forget:
+
+- **`Subscribe`** is filtered per event, since an empty collection list means
+  "everything" and there is no single name to authorise up front. Masked columns
+  are stripped from event payloads.
+- **Version history** (`GetVersionHistory`, `GetDocumentVersion`,
+  `RestoreVersion`, `RestoreToDate`) - it returns previous document bodies, so
+  leaving it open would bypass a column mask entirely.
+- **Enumeration and counts** - `ListCollections`, `ListViews`, `ListFiles`,
+  `Count`, `GetCollectionInfo`, `GetMemoryStats` filter or refuse. A name or a
+  document count is information about data you may not read. `ListFiles` also
+  recomputes `total_count`, which would otherwise report how many files exist in
+  types you have no grant for.
+- **Batch and set operations**, which are ordinary reads and writes.
+
+Semantics worth knowing:
+
+- A **column mask** removes fields from every document returned, and **filtering
+  on a masked field is refused** - `salary > 100000` would otherwise leak a value
+  you cannot read.
+- A **write touching a masked field is denied**, not silently dropped. Silent
+  field loss is worse than an error.
+- A **row predicate** hides documents outright: `Get` reports not-found rather
+  than "forbidden", because the difference is itself information.
+- **System collections** (`_policies`, `_views`, …) require admin. `_policies`
+  describes the whole access model.
+
+#### Denials are visible to your code
+
+`get`, `exists`, `find`, `findWithMetrics` and `count` **throw** on a denial
+rather than returning empty. This is deliberate: a denial that looked like "no
+data" would leave your application unable to tell "you may not see this" from
+"there is nothing to see", and it would silently take the wrong branch.
+
+#### Limits
+
+- **Service-wide operations cannot be expressed per project.** `GetStats`,
+  `SetReadOnly`, `CreateProject` and `DropProject` require admin of *some*
+  secured project; `ListProjects` filters. The real boundary for operator
+  surfaces is **listener separation** - do not expose an admin listener publicly.
+- `HealthCheck` and `GetReadOnlyStatus` are intentionally open; they expose no
+  user data.
+- **Auth requires TLS.** gRPC aborts if an auth processor is attached to insecure
+  credentials, so `auth.required: true` with `tls.enabled: false` is refused at
+  startup.
+
 ### Collection Configuration
 
 Per-collection runtime settings. Currently supports timestamp precision; extensible for future knobs.

+ 18 - 0
service/src/database_grpc_impl.cpp

@@ -118,6 +118,24 @@ grpc::Status DatabaseGrpcImpl::gate(const grpc::ServerContext* context,
         out = smartbotic::database::Decision{};
         return grpc::Status::OK;
     }
+    // System collections are GLOBAL - their rows are keyed by a
+    // project-qualified id, so the collection name carries no project. Routing
+    // them through the per-project evaluator resolved `_policies` to project
+    // "default", which is normally unsecured, and therefore let anyone read
+    // every project's policies. Caught by test_policy_enforcement.sh the moment
+    // the client stopped qualifying system names.
+    //
+    // Require admin-somewhere instead. `_policies` in particular describes the
+    // whole access model, so it must never be readable by a non-admin.
+    if (!qualified.empty() && qualified.front() == '_') {
+        if (auto st = requireAnyAdmin(context, "system collection access");
+            !st.ok()) {
+            return st;
+        }
+        out = smartbotic::database::Decision{};
+        return grpc::Status::OK;
+    }
+
     smartbotic::database::ProjectCollection pc;
     try {
         pc = smartbotic::database::parseProjectCollection(qualified);

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů