Forráskód Böngészése

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 hónapja
szülő
commit
2911326574

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 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
     ${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 << "info" << C_RESET << " <collection>                    Collection info\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 << "upsert" << C_RESET << " <collection> <id> '<json>'    Insert or update\n"
               << "  " << C_CYAN << "remove" << C_RESET << " <collection> <id>             Delete a document\n"
@@ -81,13 +82,15 @@ void printUsage() {
               << "                    list secondary indexes\n"
               << "  " << C_CYAN << "index-create" << C_RESET << " <collection> <field>"
               << "        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>"
               << "          remove an index\n"
               << "  " << C_CYAN << "index-values" << C_RESET << " <coll> <field> [n] [asc|desc]"
               << "  distinct values + counts\n"
               << C_BOLD << "  Relations / referential integrity (v2.11.0+)" << C_RESET << "\n"
               << "  " << C_CYAN << "relations" << C_RESET
-              << "                             List declared relations\n"
+              << "                             List declared relations (every project; --project narrows)\n"
               << "  " << C_CYAN << "relation" << C_RESET << " <name>"
               << "                    Show one relation's declaration\n"
               << "  " << 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_BOLD << "Options:" << C_RESET << "\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,
-                 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 {
         if (cmd == "collections") {
             auto collections = client.listCollections();
@@ -515,12 +522,34 @@ bool execCommand(smartbotic::database::Client& client,
         }
 
         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;
             opts.limit = 100;
             for (size_t i = 1; i < params.size(); ++i) {
                 if (params[i] == "--limit" && i + 1 < params.size()) {
                     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()) {
                     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
         // per-collection write access. Follows the `indexes` output shape.
         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()) {
-                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;
             }
             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") {
             printUsage();
             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 {
             positional.push_back(arg);
         }
@@ -1236,7 +1288,7 @@ int main(int argc, char* argv[]) {
 
     // Scriptable mode: single command
     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
@@ -1262,7 +1314,7 @@ int main(int argc, char* argv[]) {
 
         auto cmd = tokens[0];
         std::vector<std::string> params(tokens.begin() + 1, tokens.end());
-        execCommand(client, cmd, params);
+        execCommand(client, cmd, params, args.project);
     }
 
     return 0;

+ 7 - 0
client/CMakeLists.txt

@@ -30,6 +30,13 @@ else()
     )
 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
     $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
     $<INSTALL_INTERFACE:include>

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

@@ -863,6 +863,26 @@ public:
      */
     [[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.
      */
@@ -1208,4 +1228,19 @@ private:
     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

+ 25 - 2
client/src/client.cpp

@@ -1526,9 +1526,15 @@ public:
     }
 
     std::vector<Client::RelationDefinition> listRelations() {
-        smartbotic::databasepb::ListRelationsRequest request;
         // 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;
         grpc::ClientContext context;
         setDeadline(context);
@@ -2522,6 +2528,10 @@ std::vector<Client::RelationDefinition> Client::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) {
     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);
 }
 
+// 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

+ 29 - 2
docs/ROADMAP.md

@@ -1,6 +1,6 @@
 # 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.
 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
 
-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.
 
 - 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.
 - 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
 
 - 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
 > 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+)
 
 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 =====
 
+// 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::ServerContext* context,
     const pb::GetVersionHistoryRequest* request,
@@ -937,6 +994,14 @@ grpc::Status DatabaseGrpcImpl::GetVersionHistory(
         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(
         request->collection(), request->id(),
         request->limit(), request->offset());
@@ -945,7 +1010,8 @@ grpc::Status DatabaseGrpcImpl::GetVersionHistory(
     response->set_total_count(result.totalCount);
     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);
     }
 
@@ -963,6 +1029,11 @@ grpc::Status DatabaseGrpcImpl::GetDocumentVersion(
         return st;
     }
 
+    if (!gateVersionRow(request->collection(), request->id(), dec.row)) {
+        response->set_found(false);
+        return grpc::Status::OK;
+    }
+
     auto ver = store_.getDocumentAtVersion(
         request->collection(), request->id(), request->version());
 
@@ -971,6 +1042,7 @@ grpc::Status DatabaseGrpcImpl::GetDocumentVersion(
         return grpc::Status::OK;
     }
 
+    prepareVersionForResponse(*ver, request->collection(), request->id(), dec.mask);
     *response->mutable_version_entry() = toProtoVersionEntry(*ver);
     response->set_found(true);
 

+ 14 - 0
service/src/database_grpc_impl.hpp

@@ -565,6 +565,20 @@ private:
     static void applyMask(smartbotic::database::Document& doc,
                           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
     // 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.

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

@@ -216,6 +216,20 @@ void EncryptionManager::decryptSensitiveFields(Document& doc) {
     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) {
     if (!isEnabled() || !existingDoc.encrypted) {
         return;

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

@@ -57,6 +57,27 @@ public:
      */
     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.
      * 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();
         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;
     }
 

+ 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() {
     std::cout << "=== test_relation_manager ===\n";
     test_relations_are_project_scoped_and_survive_reload();
@@ -540,6 +613,9 @@ int main() {
     test_on_delete_is_validated_not_coerced();
     test_create_relation_migration_op_declares_and_is_idempotent();
     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";
     return g_fail == 0 ? 0 : 1;
 }

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott