Просмотр исходного кода

fix(versions): decrypt and mask version-history reads; fix relation listing, relation-create errors, CLI --version

Five items from a consumer's 2.11.0 feedback, each reproduced against source
first - which corrected two of them.

- GetVersionHistory/GetDocumentVersion never decrypted, so a field matching a
  default sensitive pattern (*.password et al, on by default, independent of the
  collection encrypted flag) read plaintext live and $ENC$ from every version.
  A consumer comparing a document against its published version always saw a
  difference.
- The same two handlers never applied column masks or the row predicate, which
  is the bypass the v2.8.0 gate comment claims to prevent. Latent: security is
  off by default.
- Client::listRelations() scoped to the client's project, so the CLI reported
  'no relations declared' with enforcement active. Added a project overload
  (empty = every project) and pointed the CLI at it.
- An unqualified relation name now adopts its child's project instead of being
  refused with a message that blamed the child and parent.
- CLI: --version on the binary (it used to try to connect), and it reports the
  loaded client library too; find --offset/--eq; help now mentions --unique and
  --limit, which already existed.

tests: ctest 23/23; new test_version_encryption e2e (5 of 8 assertions fail with
the decrypt reverted); test_relation_manager 70 -> 83; relations, namespacing,
index, views and policy e2e all green.
fszontagh 1 месяц назад
Родитель
Сommit
320dfcaf07

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CLAUDE.md


+ 1 - 1
VERSION

@@ -1 +1 @@
-2.11.0
+2.11.1

+ 10 - 0
cli/CMakeLists.txt

@@ -21,3 +21,13 @@ target_link_libraries(smartbotic-db-cli PRIVATE
     OpenSSL::Crypto
     OpenSSL::Crypto
     ${LMDB_LIBRARY}
     ${LMDB_LIBRARY}
 )
 )
+
+# v2.11.1 — the CLI reports its own version and build commit, from the same
+# top-level VERSION file and BUILD_GIT_COMMIT the service uses. Before this
+# `--version` was not a flag at all: it fell through to command dispatch, which
+# tried to CONNECT to a server, so the one question you ask a binary before
+# trusting anything else it says needed a running database to answer.
+target_compile_definitions(smartbotic-db-cli PRIVATE
+    SMARTBOTIC_DB_VERSION_STRING="${SMARTBOTIC_DB_VERSION}"
+    SMARTBOTIC_DB_GIT_COMMIT_STRING="${SMARTBOTIC_DB_GIT_COMMIT}"
+)

+ 60 - 8
cli/main.cpp

@@ -68,6 +68,7 @@ void printUsage() {
               << "  " << C_CYAN << "collections" << C_RESET << "                          List all collections\n"
               << "  " << C_CYAN << "collections" << C_RESET << "                          List all collections\n"
               << "  " << C_CYAN << "info" << C_RESET << " <collection>                    Collection info\n"
               << "  " << C_CYAN << "info" << C_RESET << " <collection>                    Collection info\n"
               << "  " << C_CYAN << "find" << C_RESET << " <collection>                    List documents\n"
               << "  " << C_CYAN << "find" << C_RESET << " <collection>                    List documents\n"
+              << "    " << C_DIM << "[--limit N] [--offset N] [--exists FIELD] [--eq FIELD VALUE]" << C_RESET << "\n"
               << "  " << C_CYAN << "get" << C_RESET << " <collection> <id>                Get a document\n"
               << "  " << C_CYAN << "get" << C_RESET << " <collection> <id>                Get a document\n"
               << "  " << C_CYAN << "upsert" << C_RESET << " <collection> <id> '<json>'    Insert or update\n"
               << "  " << C_CYAN << "upsert" << C_RESET << " <collection> <id> '<json>'    Insert or update\n"
               << "  " << C_CYAN << "remove" << C_RESET << " <collection> <id>             Delete a document\n"
               << "  " << C_CYAN << "remove" << C_RESET << " <collection> <id>             Delete a document\n"
@@ -81,13 +82,15 @@ void printUsage() {
               << "                    list secondary indexes\n"
               << "                    list secondary indexes\n"
               << "  " << C_CYAN << "index-create" << C_RESET << " <collection> <field>"
               << "  " << C_CYAN << "index-create" << C_RESET << " <collection> <field>"
               << "        declare an index and backfill it\n"
               << "        declare an index and backfill it\n"
+              << "    " << C_DIM << "[--unique]   also enforce uniqueness; refused, with examples, if duplicates exist"
+              << C_RESET << "\n"
               << "  " << C_CYAN << "index-drop" << C_RESET << " <collection> <field>"
               << "  " << C_CYAN << "index-drop" << C_RESET << " <collection> <field>"
               << "          remove an index\n"
               << "          remove an index\n"
               << "  " << C_CYAN << "index-values" << C_RESET << " <coll> <field> [n] [asc|desc]"
               << "  " << C_CYAN << "index-values" << C_RESET << " <coll> <field> [n] [asc|desc]"
               << "  distinct values + counts\n"
               << "  distinct values + counts\n"
               << C_BOLD << "  Relations / referential integrity (v2.11.0+)" << C_RESET << "\n"
               << C_BOLD << "  Relations / referential integrity (v2.11.0+)" << C_RESET << "\n"
               << "  " << C_CYAN << "relations" << C_RESET
               << "  " << C_CYAN << "relations" << C_RESET
-              << "                             List declared relations\n"
+              << "                             List declared relations (every project; --project narrows)\n"
               << "  " << C_CYAN << "relation" << C_RESET << " <name>"
               << "  " << C_CYAN << "relation" << C_RESET << " <name>"
               << "                    Show one relation's declaration\n"
               << "                    Show one relation's declaration\n"
               << "  " << C_CYAN << "relation-create" << C_RESET
               << "  " << C_CYAN << "relation-create" << C_RESET
@@ -115,7 +118,8 @@ void printUsage() {
               << "    " << C_DIM << "[--project NAME] [--apply]   STOP THE SERVICE and back up before --apply" << C_RESET << "\n\n"
               << "    " << C_DIM << "[--project NAME] [--apply]   STOP THE SERVICE and back up before --apply" << C_RESET << "\n\n"
               << C_BOLD << "Options:" << C_RESET << "\n"
               << C_BOLD << "Options:" << C_RESET << "\n"
               << "  --address HOST:PORT    Database address (default: localhost:9004)\n"
               << "  --address HOST:PORT    Database address (default: localhost:9004)\n"
-              << "  --project NAME         Operate as this project namespace (default: default)\n";
+              << "  --project NAME         Operate as this project namespace (default: default)\n"
+              << "  --version              Print version and build commit, then exit\n";
 }
 }
 
 
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
@@ -485,7 +489,10 @@ int cmdGenerateTlsCert(const std::vector<std::string>& params) {
 }
 }
 
 
 bool execCommand(smartbotic::database::Client& client,
 bool execCommand(smartbotic::database::Client& client,
-                 const std::string& cmd, const std::vector<std::string>& params) {
+                 const std::string& cmd, const std::vector<std::string>& params,
+                 // v2.11.1 — empty when the operator passed no --project, which
+                 // for `relations` means "every project" rather than "default".
+                 const std::string& explicitProject) {
     try {
     try {
         if (cmd == "collections") {
         if (cmd == "collections") {
             auto collections = client.listCollections();
             auto collections = client.listCollections();
@@ -515,12 +522,34 @@ bool execCommand(smartbotic::database::Client& client,
         }
         }
 
 
         if (cmd == "find") {
         if (cmd == "find") {
-            if (params.empty()) { printError("usage: find <collection> [--limit N] [--exists FIELD]"); return false; }
+            if (params.empty()) {
+                printError("usage: find <collection> [--limit N] [--offset N] [--exists FIELD] [--eq FIELD VALUE]");
+                return false;
+            }
             smartbotic::database::Client::QueryOptions opts;
             smartbotic::database::Client::QueryOptions opts;
             opts.limit = 100;
             opts.limit = 100;
             for (size_t i = 1; i < params.size(); ++i) {
             for (size_t i = 1; i < params.size(); ++i) {
                 if (params[i] == "--limit" && i + 1 < params.size()) {
                 if (params[i] == "--limit" && i + 1 < params.size()) {
                     opts.limit = static_cast<uint32_t>(std::stoul(params[++i]));
                     opts.limit = static_cast<uint32_t>(std::stoul(params[++i]));
+                } else if (params[i] == "--offset" && i + 1 < params.size()) {
+                    // v2.11.1 — paging from the CLI. Without it, walking a
+                    // collection larger than one page meant deleting as you go,
+                    // which is not available when you only want to read.
+                    opts.offset = static_cast<uint32_t>(std::stoul(params[++i]));
+                } else if (params[i] == "--eq" && i + 2 < params.size()) {
+                    // v2.11.1 — the commonest filter, so cleanup and inspection
+                    // do not need a client program. The value is taken as JSON
+                    // when it parses as JSON and as a string otherwise, so
+                    // --eq status completed and --eq attempts 3 both work.
+                    const std::string field = params[++i];
+                    const std::string raw = params[++i];
+                    nlohmann::json val;
+                    try {
+                        val = nlohmann::json::parse(raw);
+                    } catch (const std::exception&) {
+                        val = raw;
+                    }
+                    opts.filters.emplace_back(field, smartbotic::database::Client::FilterOp::EQ, val);
                 } else if (params[i] == "--exists" && i + 1 < params.size()) {
                 } else if (params[i] == "--exists" && i + 1 < params.size()) {
                     opts.filters.emplace_back(params[++i], smartbotic::database::Client::FilterOp::EXISTS, true);
                     opts.filters.emplace_back(params[++i], smartbotic::database::Client::FilterOp::EXISTS, true);
                 }
                 }
@@ -710,9 +739,18 @@ bool execCommand(smartbotic::database::Client& client,
         // relation names another collection's schema, which is not ordinary
         // relation names another collection's schema, which is not ordinary
         // per-collection write access. Follows the `indexes` output shape.
         // per-collection write access. Follows the `indexes` output shape.
         if (cmd == "relations") {
         if (cmd == "relations") {
-            auto list = client.listRelations();
+            // v2.11.1 — an operator asking "what is enforced on this database"
+            // means the whole database. Scoping this to the client's own project
+            // made it print "no relations declared" on an instance with active
+            // enforcement in another project, while `relation <name>` (never
+            // project-scoped) happily showed that same relation. --project
+            // narrows it back down.
+            auto list = client.listRelations(explicitProject);
             if (list.empty()) {
             if (list.empty()) {
-                std::cout << "no relations declared\n";
+                std::cout << (explicitProject.empty()
+                                  ? "no relations declared in any project\n"
+                                  : "no relations declared in project '" +
+                                        explicitProject + "'\n");
                 return true;
                 return true;
             }
             }
             std::cout << C_BOLD << "name                 child.field -> parent                    on_delete    enforced-at-write"
             std::cout << C_BOLD << "name                 child.field -> parent                    on_delete    enforced-at-write"
@@ -1206,6 +1244,20 @@ int main(int argc, char* argv[]) {
         } else if (arg == "--help" || arg == "-h") {
         } else if (arg == "--help" || arg == "-h") {
             printUsage();
             printUsage();
             return 0;
             return 0;
+        } else if (arg == "--version" || arg == "-V") {
+            // Offline, and before anything else: a version check must not need a
+            // reachable server. Same format as the service binary so the two can
+            // be compared at a glance.
+            std::cout << "smartbotic-db-cli version " << SMARTBOTIC_DB_VERSION_STRING
+                      << " (commit " << SMARTBOTIC_DB_GIT_COMMIT_STRING << ")\n"
+                      // The LOADED client library, which can differ from the
+                      // binary's own version if only one package was upgraded.
+                      // That divergence is exactly what an operator is trying to
+                      // diagnose when they ask a binary for its version.
+                      << "libsmartbotic-db-client "
+                      << smartbotic::database::clientLibraryVersion()
+                      << " (commit " << smartbotic::database::clientLibraryCommit() << ")\n";
+            return 0;
         } else {
         } else {
             positional.push_back(arg);
             positional.push_back(arg);
         }
         }
@@ -1236,7 +1288,7 @@ int main(int argc, char* argv[]) {
 
 
     // Scriptable mode: single command
     // Scriptable mode: single command
     if (!args.command.empty()) {
     if (!args.command.empty()) {
-        return execCommand(client, args.command, args.params) ? 0 : 1;
+        return execCommand(client, args.command, args.params, args.project) ? 0 : 1;
     }
     }
 
 
     // Interactive mode
     // Interactive mode
@@ -1262,7 +1314,7 @@ int main(int argc, char* argv[]) {
 
 
         auto cmd = tokens[0];
         auto cmd = tokens[0];
         std::vector<std::string> params(tokens.begin() + 1, tokens.end());
         std::vector<std::string> params(tokens.begin() + 1, tokens.end());
-        execCommand(client, cmd, params);
+        execCommand(client, cmd, params, args.project);
     }
     }
 
 
     return 0;
     return 0;

+ 7 - 0
client/CMakeLists.txt

@@ -30,6 +30,13 @@ else()
     )
     )
 endif()
 endif()
 
 
+# v2.11.1 — clientLibraryVersion()/clientLibraryCommit() report what the loaded
+# .so actually is. Same two macros the service and CLI targets define.
+target_compile_definitions(smartbotic-db-client PRIVATE
+    SMARTBOTIC_DB_VERSION_STRING="${SMARTBOTIC_DB_VERSION}"
+    SMARTBOTIC_DB_GIT_COMMIT_STRING="${SMARTBOTIC_DB_GIT_COMMIT}"
+)
+
 target_include_directories(smartbotic-db-client PUBLIC
 target_include_directories(smartbotic-db-client PUBLIC
     $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
     $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
     $<INSTALL_INTERFACE:include>
     $<INSTALL_INTERFACE:include>

+ 35 - 0
client/include/smartbotic/database/client.hpp

@@ -863,6 +863,26 @@ public:
      */
      */
     [[nodiscard]] std::vector<RelationDefinition> listRelations();
     [[nodiscard]] std::vector<RelationDefinition> listRelations();
 
 
+    /**
+     * v2.11.1 — list relations in an explicit project, or in EVERY project when
+     * `project` is empty (the operator/CLI case the wire contract has always
+     * documented at ListRelationsRequest.project).
+     *
+     * The no-argument overload scopes to this client's own project, which is
+     * right for an application but was the only thing available to
+     * `smartbotic-db-cli relations` - so on a database whose relations live in
+     * a non-default project the CLI printed "no relations declared" while
+     * enforcement was active, and `relation <name>` (never project-scoped)
+     * showed the very relation the listing denied. An operator asking "what is
+     * enforced here" got the answer "nothing", which is the one wrong answer
+     * that invites declaring it a second time.
+     *
+     * An OVERLOAD, not a defaulted parameter or a struct member: no public
+     * struct changes size, so the soname stays at 2 (see the ABI note above
+     * createIndex).
+     */
+    [[nodiscard]] std::vector<RelationDefinition> listRelations(const std::string& project);
+
     /**
     /**
      * Look up a single relation by name. Admin-only.
      * Look up a single relation by name. Admin-only.
      */
      */
@@ -1208,4 +1228,19 @@ private:
     std::unique_ptr<Impl> impl_;
     std::unique_ptr<Impl> impl_;
 };
 };
 
 
+/**
+ * v2.11.1 — the version and build commit of the client library that is actually
+ * LOADED, as a free function so it adds nothing to any class layout.
+ *
+ * Header constants would report what a consumer COMPILED against, which is the
+ * question nobody has trouble answering. This reports what the dynamic linker
+ * bound, which is how you tell a stale `.so` from a stale build - the confusion
+ * that produced a report of "dpkg says 2.9.0 but the binary offers 2.11.0
+ * commands".
+ */
+[[nodiscard]] std::string clientLibraryVersion();
+
+/** Build commit of the loaded client library, or "unknown". */
+[[nodiscard]] std::string clientLibraryCommit();
+
 } // namespace smartbotic::database
 } // namespace smartbotic::database

+ 25 - 2
client/src/client.cpp

@@ -1526,9 +1526,15 @@ public:
     }
     }
 
 
     std::vector<Client::RelationDefinition> listRelations() {
     std::vector<Client::RelationDefinition> listRelations() {
-        smartbotic::databasepb::ListRelationsRequest request;
         // Scope the listing to this client's workspace, like listViews().
         // Scope the listing to this client's workspace, like listViews().
-        request.set_project(config_.project);
+        return listRelationsIn(config_.project);
+    }
+
+    // v2.11.1 — empty `project` means every project, which is what the wire
+    // contract has always said and what an operator tool needs.
+    std::vector<Client::RelationDefinition> listRelationsIn(const std::string& project) {
+        smartbotic::databasepb::ListRelationsRequest request;
+        request.set_project(project);
         smartbotic::databasepb::ListRelationsResponse response;
         smartbotic::databasepb::ListRelationsResponse response;
         grpc::ClientContext context;
         grpc::ClientContext context;
         setDeadline(context);
         setDeadline(context);
@@ -2522,6 +2528,10 @@ std::vector<Client::RelationDefinition> Client::listRelations() {
     return impl_->listRelations();
     return impl_->listRelations();
 }
 }
 
 
+std::vector<Client::RelationDefinition> Client::listRelations(const std::string& project) {
+    return impl_->listRelationsIn(project);
+}
+
 std::optional<Client::RelationDefinition> Client::getRelationInfo(const std::string& name) {
 std::optional<Client::RelationDefinition> Client::getRelationInfo(const std::string& name) {
     return impl_->getRelationInfo(name);
     return impl_->getRelationInfo(name);
 }
 }
@@ -2605,4 +2615,17 @@ Client::FileListResult Client::listFiles(const std::string& file_type,
     return impl_->listFiles(file_type, related_id, limit, offset, checksum, name);
     return impl_->listFiles(file_type, related_id, limit, offset, checksum, name);
 }
 }
 
 
+// v2.11.1 — see the header. The macros are defined on this target from the
+// top-level VERSION file and BUILD_GIT_COMMIT, exactly as the service and CLI
+// get theirs, so all three binaries answer the same question the same way.
+#ifndef SMARTBOTIC_DB_VERSION_STRING
+#define SMARTBOTIC_DB_VERSION_STRING "unknown"
+#endif
+#ifndef SMARTBOTIC_DB_GIT_COMMIT_STRING
+#define SMARTBOTIC_DB_GIT_COMMIT_STRING "unknown"
+#endif
+
+std::string clientLibraryVersion() { return SMARTBOTIC_DB_VERSION_STRING; }
+std::string clientLibraryCommit() { return SMARTBOTIC_DB_GIT_COMMIT_STRING; }
+
 } // namespace smartbotic::database
 } // namespace smartbotic::database

+ 29 - 2
docs/ROADMAP.md

@@ -1,6 +1,6 @@
 # Smartbotic Database - Status and Roadmap
 # Smartbotic Database - Status and Roadmap
 
 
-**Current version: 2.11.0** (see `VERSION`). Last reviewed: 2026-08-10.
+**Current version: 2.11.1** (see `VERSION`). Last reviewed: 2026-08-10.
 
 
 This is the single authoritative statement of what exists and what does not.
 This is the single authoritative statement of what exists and what does not.
 If any other document in this repository disagrees with this one, this one is
 If any other document in this repository disagrees with this one, this one is
@@ -53,7 +53,7 @@ Consequences of that substitution, which trip up readers:
 
 
 ## Shipped
 ## Shipped
 
 
-Every item below is in the installed product as of 2.11.0. `CLAUDE.md` has the
+Every item below is in the installed product as of 2.11.1. `CLAUDE.md` has the
 detail and the failure modes.
 detail and the failure modes.
 
 
 - JSON document store: collections, version history, field-level encryption, TTL
 - JSON document store: collections, version history, field-level encryption, TTL
@@ -163,6 +163,33 @@ all deliberate and none blocking:
   replication follower likewise does not re-arm until restart.
   replication follower likewise does not re-arm until restart.
 - No reserved `_`-prefix check on relation names.
 - No reserved `_`-prefix check on relation names.
 
 
+### 5b. Open after the v2.11.1 consumer feedback round
+
+Raised by a consumer running against 2.11.0 and NOT fixed in 2.11.1, with the
+reason each was left:
+
+- **A child reference nested inside an array of OBJECTS cannot be expressed**
+  (`nodes[].config.credentialId`). Path resolution descends objects only, so
+  there is no way to name "this field of every element". An array of scalars is
+  supported and contributes one posting per element. Supporting the object case
+  needs a path syntax and a reverse-index maintenance story for element-level
+  changes; nobody has asked for it twice yet.
+- **`validate_on_write` is all-or-nothing per relation.** `null` and an absent
+  field are already exempt (they are not references), so "no parent" IS
+  expressible - but an empty STRING is rejected, and a consumer with legitimately
+  empty-string references cannot opt those rows out while validating the rest.
+  The clean answer is for the consumer to write `null`; a per-relation
+  "treat empty string as absent" flag is the fallback if that is impossible.
+- **`RestoreVersion` does not reject a write that touches a masked column**,
+  unlike `Insert`/`Update`/`Upsert` (`rejectMaskedWrite`). Same family as the two
+  version-read holes 2.11.1 closed, and latent for the same reason: no deployment
+  has enabled security. Fix when security is first armed for real.
+- **A version's ROW visibility is decided from the current document.** If a
+  document has moved out of a principal's row predicate, its whole history
+  becomes invisible; if it has moved in, the history it had while invisible
+  becomes readable. Deliberate (a row predicate selects rows, and the row's
+  identity is its current document) but worth restating before security is armed.
+
 ### 6. Smaller known gaps
 ### 6. Smaller known gaps
 
 
 - Policy management has no dedicated RPCs; `_policies` is edited through the
 - Policy management has no dedicated RPCs; `_policies` is edited through the

+ 32 - 0
docs/integration-guide.md

@@ -395,6 +395,38 @@ this name is always a subset of what the view allows.
 > reader; the protection is against someone reading data files, snapshots, or
 > reader; the protection is against someone reading data files, snapshots, or
 > backups, not against a client.
 > backups, not against a client.
 
 
+#### ⚠ Field-level encryption is ON by default and keyed off FIELD NAMES
+
+This surprises people, so it is spelled out here rather than left to be
+discovered. `encryption.enabled` defaults to **`true`**, and the daemon encrypts
+any field whose path matches one of these patterns, in **every** collection -
+independent of the collection-level `encrypted` flag and of
+`CollectionOptions.sensitiveFields`:
+
+```
+*.api_key   *.apiKey   *.password   *.secret   *.token   *.credentials
+```
+
+So a document with `config.password` is stored as `"$ENC$..."` whether or not you
+asked for encryption. Consequences worth knowing:
+
+- **Reads are symmetric.** `Get`, `Find`, `SimilaritySearch`, view reads,
+  `GetVersionHistory` and `GetDocumentVersion` all return plaintext. (Before
+  2.11.1 the two version handlers did **not** decrypt, so a live read returned
+  plaintext while every version of the same document returned `$ENC$...` - which
+  made "does this document differ from its published version?" answer *yes*
+  forever. If you compare documents against versions, require >= 2.11.1.)
+- **The same ciphertext in consecutive versions is correct**, not a fixed IV.
+  When a sensitive field does not change between writes, the stored ciphertext is
+  carried over verbatim instead of being re-encrypted, precisely so version
+  history does not show a spurious difference on every save.
+- **An encrypted field cannot be indexed or filtered on usefully**, since the
+  stored bytes are ciphertext.
+- To turn it off, set `storage.encryption.enabled: false`. Existing `$ENC$`
+  values then read back as the literal ciphertext string - decryption is gated on
+  the manager being enabled - so switch it off before storing such fields, not
+  after.
+
 ### Access Policy — row and column level security (2.7.0+)
 ### Access Policy — row and column level security (2.7.0+)
 
 
 Per-project row- and column-level access control. **Off by default**: a project
 Per-project row- and column-level access control. **Off by default**: a project

+ 73 - 1
service/src/database_grpc_impl.cpp

@@ -926,6 +926,63 @@ grpc::Status DatabaseGrpcImpl::Exists(
 
 
 // ===== Version History Operations =====
 // ===== Version History Operations =====
 
 
+// v2.11.1 — everything a version body owes the caller before it is serialised.
+//
+// The two version handlers gated on Read (v2.8.0) and then returned the STORED
+// body verbatim, which skipped both of the transformations every live read
+// performs:
+//
+//   1. DECRYPTION. Get/Find decrypt; these did not, so a field matching a
+//      sensitiveFieldPatterns entry (`*.password`, `*.secret`, `*.token`,
+//      `*.api_key`, `*.apiKey`, `*.credentials` by default - on by default,
+//      independent of the collection-level `encrypted` flag) read back
+//      plaintext live and "$ENC$..." from every version. Reported by a consumer
+//      whose editor compares a document against its published version: the
+//      comparison was plaintext against ciphertext, so it always differed.
+//      The identical ciphertext in every version is preserveUnchangedEncryption
+//      doing its job - an unchanged sensitive field keeps its stored ciphertext
+//      rather than being re-encrypted under a fresh IV - not a fixed IV.
+//
+//   2. COLUMN MASKS. applyMask was never called here, so on a secured project
+//      history returned columns the principal cannot read from Get - the exact
+//      bypass the v2.8.0 gate comment claims to prevent. Latent only because
+//      security is off by default and no deployment has enabled it yet.
+//
+// The row predicate is handled by the caller (see gateVersionRow) because it
+// decides visibility of the whole document, not the contents of one version.
+void DatabaseGrpcImpl::prepareVersionForResponse(DocumentVersion& ver,
+                                                 const std::string& collection,
+                                                 const std::string& id,
+                                                 const std::vector<std::string>& mask) {
+    encryption_.decryptSensitiveFields(ver, collection, id);
+    if (mask.empty()) return;
+    // Same machinery as applyMask(Document&): a column mask is a view
+    // `exclude` list, same dot paths, same semantics.
+    nlohmann::json data = ver.data();
+    data = applyProjection(data, /*include=*/{}, /*exclude=*/mask);
+    ver.set_data(data);
+}
+
+// v2.11.1 — is this document's HISTORY visible under the policy row predicate?
+//
+// A row predicate selects rows, and the row's identity is its current document,
+// so this asks the same question Get asks and answers it the same way: hidden
+// means not-found, never "exists but forbidden".
+//
+// ⚠ Fail closed when the current document cannot be read (deleted, leaving
+// history behind). There is then nothing to evaluate the predicate against, and
+// "allow because we cannot check" would let a principal read the history of a
+// row it was never permitted to see.
+bool DatabaseGrpcImpl::gateVersionRow(const std::string& collection,
+                                      const std::string& id,
+                                      const std::vector<smartbotic::database::Filter>& row) {
+    if (row.empty()) return true;
+    auto cur = store_.get(collection, id);
+    if (!cur) return false;
+    encryption_.decryptSensitiveFields(*cur);
+    return store_.matchesFilters(*cur, row);
+}
+
 grpc::Status DatabaseGrpcImpl::GetVersionHistory(
 grpc::Status DatabaseGrpcImpl::GetVersionHistory(
     grpc::ServerContext* context,
     grpc::ServerContext* context,
     const pb::GetVersionHistoryRequest* request,
     const pb::GetVersionHistoryRequest* request,
@@ -937,6 +994,14 @@ grpc::Status DatabaseGrpcImpl::GetVersionHistory(
         return st;
         return st;
     }
     }
 
 
+    if (!gateVersionRow(request->collection(), request->id(), dec.row)) {
+        // Nothing about the document, not even its version count.
+        response->set_current_version(0);
+        response->set_total_count(0);
+        response->set_document_deleted(false);
+        return grpc::Status::OK;
+    }
+
     auto result = store_.getVersionHistory(
     auto result = store_.getVersionHistory(
         request->collection(), request->id(),
         request->collection(), request->id(),
         request->limit(), request->offset());
         request->limit(), request->offset());
@@ -945,7 +1010,8 @@ grpc::Status DatabaseGrpcImpl::GetVersionHistory(
     response->set_total_count(result.totalCount);
     response->set_total_count(result.totalCount);
     response->set_document_deleted(result.documentDeleted);
     response->set_document_deleted(result.documentDeleted);
 
 
-    for (const auto& ver : result.versions) {
+    for (auto& ver : result.versions) {
+        prepareVersionForResponse(ver, request->collection(), request->id(), dec.mask);
         *response->add_versions() = toProtoVersionEntry(ver);
         *response->add_versions() = toProtoVersionEntry(ver);
     }
     }
 
 
@@ -963,6 +1029,11 @@ grpc::Status DatabaseGrpcImpl::GetDocumentVersion(
         return st;
         return st;
     }
     }
 
 
+    if (!gateVersionRow(request->collection(), request->id(), dec.row)) {
+        response->set_found(false);
+        return grpc::Status::OK;
+    }
+
     auto ver = store_.getDocumentAtVersion(
     auto ver = store_.getDocumentAtVersion(
         request->collection(), request->id(), request->version());
         request->collection(), request->id(), request->version());
 
 
@@ -971,6 +1042,7 @@ grpc::Status DatabaseGrpcImpl::GetDocumentVersion(
         return grpc::Status::OK;
         return grpc::Status::OK;
     }
     }
 
 
+    prepareVersionForResponse(*ver, request->collection(), request->id(), dec.mask);
     *response->mutable_version_entry() = toProtoVersionEntry(*ver);
     *response->mutable_version_entry() = toProtoVersionEntry(*ver);
     response->set_found(true);
     response->set_found(true);
 
 

+ 14 - 0
service/src/database_grpc_impl.hpp

@@ -565,6 +565,20 @@ private:
     static void applyMask(smartbotic::database::Document& doc,
     static void applyMask(smartbotic::database::Document& doc,
                           const std::vector<std::string>& mask);
                           const std::vector<std::string>& mask);
 
 
+    // v2.11.1 — decrypt a stored version body and strip masked paths from it.
+    // Not static: it needs the encryption manager. See the definition for why
+    // both halves were missing and what that cost.
+    void prepareVersionForResponse(smartbotic::database::DocumentVersion& ver,
+                                   const std::string& collection,
+                                   const std::string& id,
+                                   const std::vector<std::string>& mask);
+
+    // v2.11.1 — is this document's history visible under the row predicate?
+    // Fails closed when the current document cannot be read.
+    bool gateVersionRow(const std::string& collection,
+                        const std::string& id,
+                        const std::vector<smartbotic::database::Filter>& row);
+
     // Reject a write whose body sets a masked path. Silently dropping the field
     // Reject a write whose body sets a masked path. Silently dropping the field
     // would be worse: the caller believes it wrote a value that never landed,
     // would be worse: the caller believes it wrote a value that never landed,
     // and on a masked audit column that is a way to forge history.
     // and on a masked audit column that is a way to forge history.

+ 14 - 0
service/src/encryption/encryption_manager.cpp

@@ -216,6 +216,20 @@ void EncryptionManager::decryptSensitiveFields(Document& doc) {
     doc.set_data(tree);
     doc.set_data(tree);
 }
 }
 
 
+void EncryptionManager::decryptSensitiveFields(DocumentVersion& ver,
+                                               const std::string& collection,
+                                               const std::string& id) {
+    if (!isEnabled() || !ver.encrypted) {
+        return;
+    }
+
+    std::lock_guard lock(mutex_);
+
+    nlohmann::json tree = ver.data();
+    decryptJsonField(tree, "", collection, id);
+    ver.set_data(tree);
+}
+
 void EncryptionManager::preserveUnchangedEncryption(Document& newDoc, const Document& existingDoc) {
 void EncryptionManager::preserveUnchangedEncryption(Document& newDoc, const Document& existingDoc) {
     if (!isEnabled() || !existingDoc.encrypted) {
     if (!isEnabled() || !existingDoc.encrypted) {
         return;
         return;

+ 21 - 0
service/src/encryption/encryption_manager.hpp

@@ -57,6 +57,27 @@ public:
      */
      */
     void decryptSensitiveFields(Document& doc);
     void decryptSensitiveFields(Document& doc);
 
 
+    /**
+     * v2.11.1 — decrypt a stored VERSION body.
+     *
+     * Every live read path decrypts (Get, Find, SimilaritySearch, the view
+     * branches); the two version-history handlers did not, so a field matching
+     * a sensitiveFieldPatterns entry read back plaintext from Get and
+     * "$ENC$..." from every version of the same document. A consumer comparing
+     * a live document against its stored version - the ordinary "are there
+     * unpublished changes?" question - therefore saw a permanent difference it
+     * could do nothing about.
+     *
+     * `collection` and `id` are not carried on DocumentVersion but the
+     * decryptor wants them for its per-field diagnostics, so the caller passes
+     * the ones it already has. Gated on ver.encrypted exactly as the Document
+     * overload is gated on doc.encrypted: a version saved before encryption was
+     * enabled holds plaintext and must be left alone.
+     */
+    void decryptSensitiveFields(DocumentVersion& ver,
+                                const std::string& collection,
+                                const std::string& id);
+
     /**
     /**
      * Preserve existing encrypted values for unchanged sensitive fields.
      * Preserve existing encrypted values for unchanged sensitive fields.
      * Compares plaintext values in newDoc with decrypted values from existingDoc.
      * Compares plaintext values in newDoc with decrypted values from existingDoc.

+ 37 - 3
service/src/relations/relation_manager.cpp

@@ -224,9 +224,43 @@ bool RelationManager::createRelation(const RelationInfo& r, std::string& errorOu
         errorOut = std::string("invalid relation/child/parent name: ") + e.what();
         errorOut = std::string("invalid relation/child/parent name: ") + e.what();
         return false;
         return false;
     }
     }
-    if (rc.project != rp.project || rc.project != rn.project) {
-        errorOut = "relation, child and parent must be in one project (no "
-                   "transaction spans two project envs)";
+    if (rc.project != rp.project) {
+        // v2.11.1 — name the offending arguments and the projects they resolved
+        // to. The old message listed all three names without saying which one
+        // was wrong, and an unqualified argument silently resolves to `default`,
+        // so the reader could not see the mismatch in what they typed.
+        errorOut = "child and parent must be in one project (no transaction "
+                   "spans two project envs): child '" + r.child + "' is in '" +
+                   rc.project + "' but parent '" + r.parent + "' is in '" +
+                   rp.project + "'";
+        return false;
+    }
+
+    // v2.11.1 — an UNQUALIFIED relation name adopts the project its child and
+    // parent already agree on, instead of being rejected.
+    //
+    // A bare name resolves to `default` like every other bare name in the
+    // codebase, so `relation-create executions_workflow <coll in project P> ...`
+    // was refused for a cross-project relation the caller never asked for -
+    // reported by an operator whose error message named the two arguments that
+    // were correct. Adopting is unambiguous precisely because a cross-project
+    // relation is impossible: there is exactly one project it could belong to.
+    // An EXPLICITLY qualified name that disagrees is still refused, since that
+    // is a real statement of intent that cannot be honoured.
+    const bool nameWasQualified = r.name.find(':') != std::string::npos;
+    if (!nameWasQualified && rn.project != rc.project) {
+        try {
+            rn = resolveCollection(rc.project + ":" + rn.collection);
+        } catch (const std::exception& e) {
+            errorOut = std::string("invalid relation name '") + r.name + "': " + e.what();
+            return false;
+        }
+    }
+    if (rn.project != rc.project) {
+        errorOut = "the relation name must be in the same project as its child "
+                   "and parent: name '" + r.name + "' is in '" + rn.project +
+                   "' but child and parent are in '" + rc.project +
+                   "' (use '" + rc.project + ":" + rn.collection + "')";
         return false;
         return false;
     }
     }
 
 

+ 140 - 0
tests/load_test/test_client_namespacing.sh

@@ -0,0 +1,140 @@
+// v2.11.1 — a version read must return what a live read returns.
+//
+// Field-level encryption is ON by default and keys off FIELD NAMES
+// (`*.password`, `*.secret`, `*.token`, `*.api_key`, `*.apiKey`,
+// `*.credentials`), independent of the collection-level `encrypted` flag. Every
+// live read path decrypted; GetVersionHistory and GetDocumentVersion did not. A
+// consumer comparing a document against its published version therefore saw
+// plaintext against "$ENC$..." and concluded, permanently and wrongly, that the
+// document had unpublished changes.
+//
+// Revert either decrypt call in DatabaseGrpcImpl and every assertion below that
+// looks for plaintext fails, because the ciphertext is what the handler returns.
+
+#include <smartbotic/database/client.hpp>
+
+#include <iostream>
+#include <string>
+
+namespace {
+
+int failures = 0;
+int checks = 0;
+
+void check(bool ok, const std::string& what) {
+    ++checks;
+    if (ok) {
+        std::cout << "  ok   " << what << "\n";
+    } else {
+        ++failures;
+        std::cout << "  FAIL " << what << "\n";
+    }
+}
+
+const char* kEnc = "$ENC$";
+
+bool looksEncrypted(const nlohmann::json& v) {
+    return v.is_string() && v.get<std::string>().rfind(kEnc, 0) == 0;
+}
+
+}  // namespace
+
+int main(int argc, char** argv) {
+    if (argc < 3) {
+        std::cerr << "usage: " << argv[0] << " <address> <project>\n";
+        return 2;
+    }
+
+    smartbotic::database::Client::Config cfg;
+    cfg.address = argv[1];
+    cfg.project = argv[2];
+    smartbotic::database::Client client(cfg);
+    if (!client.connect()) {
+        std::cerr << "could not connect to " << cfg.address << "\n";
+        return 2;
+    }
+
+    const std::string coll = "creds";
+    const std::string id = "node1";
+
+    // A nested `password`, which is the shape reported: a workflow node's config.
+    nlohmann::json v1 = {
+        {"name", "smtp"},
+        {"config", {{"host", "mail.example.com"}, {"password", "first-secret"}}}};
+    if (client.upsert(coll, v1, id).first.empty()) {
+        std::cerr << "upsert v1 failed\n";
+        return 2;
+    }
+
+    // A second write, so there is real history AND so the unchanged-field
+    // preservation path (preserveUnchangedEncryption) runs.
+    nlohmann::json v2 = v1;
+    v2["config"]["host"] = "mail2.example.com";
+    if (client.upsert(coll, v2, id).first.empty()) {
+        std::cerr << "upsert v2 failed\n";
+        return 2;
+    }
+
+    // A third write that CHANGES the sensitive value, so a version read has
+    // something to be wrong about beyond "same ciphertext everywhere".
+    nlohmann::json v3 = v2;
+    v3["config"]["password"] = "second-secret";
+    if (client.upsert(coll, v3, id).first.empty()) {
+        std::cerr << "upsert v3 failed\n";
+        return 2;
+    }
+
+    // ---- the live read is the reference ----
+    auto live = client.get(coll, id);
+    check(live.has_value(), "live document is readable");
+    if (!live) return 1;
+    check((*live)["config"]["password"] == "second-secret",
+          "live read returns the plaintext password");
+
+    // ---- history must agree with it ----
+    auto hist = client.getVersionHistory(coll, id, 0, 0);
+    check(!hist.versions.empty(), "history has at least one version");
+
+    bool anyCiphertext = false;
+    bool sawFirstSecret = false;
+    for (const auto& ver : hist.versions) {
+        if (!ver.data.contains("config")) continue;
+        const auto& pw = ver.data["config"]["password"];
+        if (looksEncrypted(pw)) anyCiphertext = true;
+        if (pw == "first-secret") sawFirstSecret = true;
+    }
+    check(!anyCiphertext, "no version body contains a $ENC$ value");
+    check(sawFirstSecret,
+          "an older version reports the password it actually held at the time");
+
+    // ---- the consumer's real question: does the document differ from a version? ----
+    // Comparing the live body against the version that recorded it must find no
+    // difference. This is the assertion the reported bug broke: it was always
+    // true that they differed, because one side was ciphertext.
+    auto newest = client.getDocumentVersion(coll, id, hist.currentVersion);
+    if (newest) {
+        check(!looksEncrypted(newest->data["config"]["password"]),
+              "getDocumentVersion decrypts too (its own code path)");
+    } else {
+        // The newest saved version may be the pre-update body depending on when
+        // history is written; fall back to the highest version we did receive.
+        check(!hist.versions.empty(), "some version was retrievable by number");
+    }
+
+    // A version whose sensitive field was UNCHANGED between writes must still
+    // read as plaintext - that is the preserveUnchangedEncryption path, which
+    // stores the previous ciphertext verbatim rather than re-encrypting.
+    auto unchanged = client.getDocumentVersion(coll, id, 2);
+    if (unchanged && unchanged->data.contains("config")) {
+        check(!looksEncrypted(unchanged->data["config"]["password"]),
+              "a version with an unchanged sensitive field reads as plaintext");
+        check(unchanged->data["config"]["password"] == "first-secret",
+              "and reports the value that was current for that version");
+    }
+
+    client.remove(coll, id);
+
+    std::cout << (failures == 0 ? "PASS" : "FAIL") << ": " << (checks - failures)
+              << "/" << checks << " checks\n";
+    return failures == 0 ? 0 : 1;
+}

+ 73 - 0
tests/load_test/test_version_encryption.sh

@@ -0,0 +1,73 @@
+#!/usr/bin/env bash
+# v2.11.1 — version reads must decrypt, like every live read does.
+#
+# This has to run over real gRPC with encryption ENABLED: the defect lives in
+# two gRPC handlers, not in the encryption manager, so no in-process unit test
+# can reach it. Reverting either decrypt call in DatabaseGrpcImpl makes the
+# driver's plaintext assertions fail.
+#
+# Usage: tests/load_test/test_version_encryption.sh [build_dir]
+
+set -uo pipefail
+
+BUILD="${1:-build}"
+REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+PORT=19413
+PROJECT=encproj
+WORK="$(mktemp -d)"
+DRIVER="$WORK/test_version_encryption"
+SERVER_PID=""
+
+cleanup() {
+    [[ -n "$SERVER_PID" ]] && kill "$SERVER_PID" 2>/dev/null
+    wait "$SERVER_PID" 2>/dev/null
+    rm -rf "$WORK"
+}
+trap cleanup EXIT
+
+fail() { echo "FAIL: $*" >&2; exit 1; }
+
+echo "=== building the driver ==="
+g++ -std=c++20 -O1 -o "$DRIVER" "$REPO/tests/load_test/test_version_encryption.cpp" \
+    -I"$REPO/client/include" -I"$REPO/$BUILD/client" \
+    -L"$REPO/$BUILD/client" -lsmartbotic-db-client -lspdlog -lfmt \
+    || fail "driver did not compile"
+
+mkdir -p "$WORK/data"
+# encryption.enabled TRUE - the whole point. The default sensitive patterns
+# include *.password, so no per-collection option is needed to arm this.
+cat > "$WORK/config.json" <<EOF
+{
+  "storage": {
+    "data_directory": "$WORK/data",
+    "bind_address": "127.0.0.1",
+    "rpc_port": $PORT,
+    "encryption": { "enabled": true, "key_file": "$WORK/data/storage.key" },
+    "memory": { "max_memory_mb": 512 }
+  },
+  "migrations": { "enabled": false }
+}
+EOF
+
+"$REPO/$BUILD/service/smartbotic-database" --config "$WORK/config.json" > "$WORK/boot.log" 2>&1 &
+SERVER_PID=$!
+for _ in $(seq 1 60); do
+    grep -q "READY" "$WORK/boot.log" 2>/dev/null && break
+    kill -0 "$SERVER_PID" 2>/dev/null || { cat "$WORK/boot.log"; fail "server exited during startup"; }
+    sleep 1
+done
+grep -q "READY" "$WORK/boot.log" || { cat "$WORK/boot.log"; fail "server never became ready"; }
+
+export LD_LIBRARY_PATH="$REPO/$BUILD/client:${LD_LIBRARY_PATH:-}"
+"$DRIVER" "127.0.0.1:$PORT" "$PROJECT" || fail "version/encryption symmetry checks failed"
+
+# Belt and braces: the ciphertext must genuinely exist ON DISK, otherwise the
+# driver's assertions could pass simply because nothing was ever encrypted and
+# the test would prove nothing at all.
+if ! grep -rq '\$ENC\$' "$WORK/data" 2>/dev/null; then
+    fail "no ciphertext on disk - encryption never armed, so the checks above were vacuous"
+fi
+echo "  confirmed: ciphertext IS present at rest, so the plaintext reads above are real decryptions"
+
+echo
+echo "ALL VERSION-ENCRYPTION CHECKS PASSED"

+ 76 - 0
tests/test_relation_manager.cpp

@@ -529,6 +529,79 @@ void test_create_relation_migration_op_validates() {
     }
     }
 }
 }
 
 
+
+// v2.11.1 — an operator typed an UNQUALIFIED relation name with a qualified
+// child and parent and was told "relation, child and parent must be in one
+// project", which named the two arguments that were correct. A bare name
+// resolves to `default` like every other bare name, so it could never match a
+// child living anywhere else - and since a cross-project relation is impossible,
+// there is exactly one project the name could have meant.
+void test_unqualified_relation_name_adopts_its_child_project() {
+    Fixture f;
+    RelationManager rm(f.store);
+
+    RelationInfo r;
+    r.name = "exec_wf";                    // bare - would resolve to `default`
+    r.child = "acme:executions";
+    r.childField = "workflowId";
+    r.parent = "acme:workflows";
+    std::string err;
+    check(rm.createRelation(r, err), "a bare name with an acme child is accepted: " + err);
+    check(rm.getRelation("acme:exec_wf").has_value(),
+          "and it is stored in acme, not default");
+    check(!rm.getRelation("default:exec_wf").has_value(),
+          "nothing was created in default");
+    check(rm.listRelations("acme").size() == 1, "acme lists it");
+    check(rm.listRelations("default").empty(), "default does not");
+
+    // Reload, because adoption must happen in the persisted record and not only
+    // in the in-memory cache - otherwise a restart would lose the relation.
+    RelationManager fresh(f.store);
+    fresh.loadFromStore();
+    check(fresh.getRelation("acme:exec_wf").has_value(), "survives a reload in acme");
+}
+
+// An EXPLICITLY qualified name that disagrees is a real statement of intent that
+// cannot be honoured, so it stays refused - and now the message names the
+// argument that is actually wrong plus the project it resolved to.
+void test_explicitly_wrong_project_on_the_name_is_still_refused_and_says_which() {
+    Fixture f;
+    RelationManager rm(f.store);
+
+    RelationInfo r;
+    r.name = "default:exec_wf";            // deliberately the wrong project
+    r.child = "acme:executions";
+    r.childField = "workflowId";
+    r.parent = "acme:workflows";
+    std::string err;
+    check(!rm.createRelation(r, err), "a qualified name in the wrong project is refused");
+    check(err.find("relation name") != std::string::npos,
+          "the message blames the NAME, not the child or parent: " + err);
+    check(err.find("acme:exec_wf") != std::string::npos,
+          "and spells out the name that would have worked: " + err);
+    check(!rm.getRelation("acme:exec_wf").has_value(), "nothing was created");
+}
+
+// The child/parent mismatch message must name both sides and their projects. It
+// previously listed all three arguments and said which project none of them was
+// in, which is what sent an operator looking at the wrong argument.
+void test_child_parent_mismatch_names_both_sides() {
+    Fixture f;
+    RelationManager rm(f.store);
+    RelationInfo r;
+    r.name = "acme:bad";
+    r.child = "acme:executions";
+    r.childField = "workflowId";
+    r.parent = "other:workflows";
+    std::string err;
+    check(!rm.createRelation(r, err), "refused");
+    check(err.find("acme:executions") != std::string::npos &&
+          err.find("other:workflows") != std::string::npos,
+          "both offending arguments are named: " + err);
+    check(err.find("'acme'") != std::string::npos && err.find("'other'") != std::string::npos,
+          "with the projects they resolved to: " + err);
+}
+
 int main() {
 int main() {
     std::cout << "=== test_relation_manager ===\n";
     std::cout << "=== test_relation_manager ===\n";
     test_relations_are_project_scoped_and_survive_reload();
     test_relations_are_project_scoped_and_survive_reload();
@@ -540,6 +613,9 @@ int main() {
     test_on_delete_is_validated_not_coerced();
     test_on_delete_is_validated_not_coerced();
     test_create_relation_migration_op_declares_and_is_idempotent();
     test_create_relation_migration_op_declares_and_is_idempotent();
     test_create_relation_migration_op_validates();
     test_create_relation_migration_op_validates();
+    test_unqualified_relation_name_adopts_its_child_project();
+    test_explicitly_wrong_project_on_the_name_is_still_refused_and_says_which();
+    test_child_parent_mismatch_names_both_sides();
     std::cout << "passed: " << g_pass << ", failed: " << g_fail << "\n";
     std::cout << "passed: " << g_pass << ", failed: " << g_fail << "\n";
     return g_fail == 0 ? 0 : 1;
     return g_fail == 0 ? 0 : 1;
 }
 }

Некоторые файлы не были показаны из-за большого количества измененных файлов