Преглед на файлове

feat(migrations): add upsert_setting and generate_secret operation types

- upsert_setting: settings-collection shortcut that writes {key -> {value}}.
  When only_on_fresh_install is provided and the DB already has any
  settings rows (upgrade path), a missing key gets that value instead of
  value; pre-existing keys are always preserved on upgrade. Fresh
  installs always use value.
- generate_secret: cryptographic-random hex string (default 32 bytes)
  upserted once. If the key already exists, the current value is
  preserved — migrations never rotate secrets.

Fresh-install detection reads the settings collection (not _migrations),
because _migrations entries are written by the runner before the
PersistenceManager starts and so are not captured in the WAL; the
settings collection reflects actual persisted state and is a reliable
"this DB has been used before" signal.

Needed by shadowman migration 033 (CODE integration — office_mode
default + office_wopi_secret generation).
fszontagh преди 3 месеца
родител
ревизия
d50f9a0a44
променени са 2 файла, в които са добавени 127 реда и са изтрити 1 реда
  1. 106 1
      service/src/migrations/migration_runner.cpp
  2. 21 0
      service/src/migrations/migration_runner.hpp

+ 106 - 1
service/src/migrations/migration_runner.cpp

@@ -2,6 +2,7 @@
 
 #include <spdlog/spdlog.h>
 #include <nlohmann/json.hpp>
+#include <openssl/rand.h>
 
 #include <algorithm>
 #include <fstream>
@@ -35,7 +36,26 @@ bool MigrationRunner::runMigrations() {
         return true;
     }
 
-    spdlog::info("Found {} migration files, checking for pending...", files.size());
+    // Snapshot "fresh install" state before applying anything — operations
+    // that need the distinction (e.g. upsert_setting only_on_fresh_install)
+    // must see a stable value across the whole batch.
+    //
+    // We use the `settings` collection rather than `_migrations` because
+    // `_migrations` entries are written by this runner *before* the
+    // PersistenceManager has started, so they are not captured in the WAL
+    // and do not survive across DB restarts. The `settings` collection is
+    // populated by actual user/runtime writes that go through the normal
+    // persistence path, so its presence is a reliable "this DB has been
+    // used before" signal.
+    {
+        Query q;
+        q.limit = 1;
+        auto res = store_.find("settings", q);
+        fresh_install_at_start_ = res.documents.empty();
+    }
+
+    spdlog::info("Found {} migration files, checking for pending... (fresh_install={})",
+                 files.size(), fresh_install_at_start_);
 
     int applied = 0;
     int skipped = 0;
@@ -425,6 +445,91 @@ bool MigrationRunner::applyOperation(const nlohmann::json& operation) {
         return true;  // Success even if collection didn't exist
     }
 
+    if (type == "upsert_setting") {
+        // Settings-collection shortcut: writes {value: ...} to
+        // {collection}/{key}. If `only_on_fresh_install` is set AND we
+        // started this run on an already-migrated database AND the key
+        // does not yet exist, use that value instead of `value`. If the
+        // key already exists on an upgrade, leave it alone. On a fresh
+        // install `value` always wins.
+        std::string collection = operation.value("collection", "");
+        std::string key = operation.value("key", "");
+        if (collection.empty() || key.empty() || !operation.contains("value")) {
+            spdlog::error("upsert_setting: missing collection, key, or value");
+            return false;
+        }
+
+        const bool exists = store_.exists(collection, key);
+        std::string value_to_write;
+
+        if (fresh_install_at_start_) {
+            // Fresh install → always use `value`.
+            value_to_write = operation["value"].get<std::string>();
+        } else if (!exists && operation.contains("only_on_fresh_install")) {
+            // Upgrade with missing key and an explicit upgrade-default.
+            value_to_write = operation["only_on_fresh_install"].get<std::string>();
+        } else if (!exists) {
+            // Upgrade with missing key, no upgrade-default supplied →
+            // fall back to `value` so the migration still seeds the key.
+            value_to_write = operation["value"].get<std::string>();
+        } else {
+            // Upgrade, key already set → preserve operator's choice.
+            spdlog::debug("upsert_setting '{}/{}': already set, preserving", collection, key);
+            return true;
+        }
+
+        Document doc;
+        doc.id = key;
+        doc.data = nlohmann::json{{"value", value_to_write}};
+        store_.upsert(collection, doc);
+        spdlog::debug("upsert_setting '{}/{}' = '{}' (fresh_install={})",
+                      collection, key, value_to_write, fresh_install_at_start_);
+        return true;
+    }
+
+    if (type == "generate_secret") {
+        // Cryptographic random hex string, upserted once. Never rotated
+        // by a re-run: if the key already exists, we keep the current
+        // value. This lets the same migration ID remain in the history
+        // without worrying about secret churn.
+        std::string collection = operation.value("collection", "");
+        std::string key = operation.value("key", "");
+        uint32_t bytes = operation.value("bytes", 32U);
+
+        if (collection.empty() || key.empty()) {
+            spdlog::error("generate_secret: missing collection or key");
+            return false;
+        }
+        if (bytes == 0 || bytes > 256) {
+            spdlog::error("generate_secret: bytes out of range (1..256), got {}", bytes);
+            return false;
+        }
+
+        if (store_.exists(collection, key)) {
+            spdlog::debug("generate_secret '{}/{}': already present, preserving", collection, key);
+            return true;
+        }
+
+        std::vector<unsigned char> buf(bytes);
+        if (RAND_bytes(buf.data(), static_cast<int>(bytes)) != 1) {
+            spdlog::error("generate_secret: RAND_bytes failed");
+            return false;
+        }
+
+        std::stringstream hex;
+        hex << std::hex << std::setfill('0');
+        for (auto b : buf) {
+            hex << std::setw(2) << static_cast<int>(b);
+        }
+
+        Document doc;
+        doc.id = key;
+        doc.data = nlohmann::json{{"value", hex.str()}};
+        store_.upsert(collection, doc);
+        spdlog::info("generate_secret '{}/{}': {}-byte secret generated", collection, key, bytes);
+        return true;
+    }
+
     spdlog::error("Unknown operation type: {}", type);
     return false;
 }

+ 21 - 0
service/src/migrations/migration_runner.hpp

@@ -33,6 +33,17 @@ namespace smartbotic::database {
  * - delete_where_id_prefix: Delete every document in a collection whose id
  *   begins with a literal prefix. Useful for cleaning up legacy
  *   namespaced keys (e.g. memories with "doc:" prefix).
+ * - upsert_setting: Settings-collection convenience. Sets
+ *   {collection, key, value} to {"_id": key, "value": value}. If
+ *   `only_on_fresh_install` is present and this is an upgrade (at least
+ *   one migration already applied when the run started), the key is
+ *   treated as insert-if-not-exists with `only_on_fresh_install` as the
+ *   value. If this is a fresh install, `value` wins. If the key already
+ *   exists on upgrade, it is preserved.
+ * - generate_secret: Generate a cryptographically-random hex string of
+ *   `bytes` bytes (default 32) and upsert it at `{collection}/{key}` as
+ *   `{"value": "<hex>"}`. If the key already exists, the secret is
+ *   preserved (never rotated by migrations).
  */
 class MigrationRunner {
 public:
@@ -94,6 +105,16 @@ private:
     MemoryStore& store_;
     Config config_;
 
+    /**
+     * Snapshot at the start of runMigrations() of whether any prior
+     * migration has been applied. Used by operations like
+     * `upsert_setting` with `only_on_fresh_install` to distinguish a
+     * fresh database from an upgrade — sticky for the duration of the
+     * run so the very first migration in a batch can still see "fresh"
+     * even after later migrations start inserting.
+     */
+    bool fresh_install_at_start_ = true;
+
     static constexpr const char* MIGRATIONS_COLLECTION = "_migrations";
 };