Pārlūkot izejas kodu

feat(auth): DB-backed KeyStore (bootstrap admin key, grants) + global SettingsStore

Fszontagh 2 mēneši atpakaļ
vecāks
revīzija
cd3a84831b

+ 2 - 0
src/CMakeLists.txt

@@ -6,6 +6,8 @@ add_library(vectorapi_core STATIC
     db_gateway.cpp
     settings.cpp
     apikey.cpp
+    key_store.cpp
+    settings_store.cpp
 )
 target_include_directories(vectorapi_core PUBLIC
     ${CMAKE_CURRENT_SOURCE_DIR}

+ 99 - 0
src/key_store.cpp

@@ -0,0 +1,99 @@
+#include "key_store.hpp"
+#include <spdlog/spdlog.h>
+#include <ctime>
+
+namespace svapi {
+
+KeyStore::KeyStore(DbGateway& db, std::string collection)
+    : db_(db), coll_(std::move(collection)),
+      snap_(std::make_shared<const std::vector<ApiKey>>()) {}
+
+void KeyStore::reload() {
+    smartbotic::database::Client::QueryOptions opts;
+    opts.limit = 100000;
+    auto docs = db_.client().find(coll_, opts);
+    auto v = std::make_shared<std::vector<ApiKey>>();
+    v->reserve(docs.size());
+    for (const auto& d : docs) v->push_back(ApiKey::fromJson(d));
+    std::lock_guard<std::mutex> lk(m_);
+    snap_ = v;
+}
+
+void KeyStore::bootstrap(const std::string& envAdminKey) {
+    if (!db_.client().getCollectionInfo(coll_).has_value())
+        db_.client().createCollection(coll_, 0, /*encrypted*/ true, 0, 0);
+    reload();
+    if (snap_->empty()) {
+        ApiKey k;
+        k.key       = envAdminKey.empty() ? generateApiKey() : envAdminKey;
+        k.label     = "bootstrap-admin";
+        k.projects  = {"*"};
+        k.admin     = true;
+        k.createdAt = static_cast<uint64_t>(std::time(nullptr));
+        db_.client().upsert(coll_, k.toJson(), k.key);
+        reload();
+        if (envAdminKey.empty())
+            spdlog::warn("Generated initial admin API key: {} — store it now; shown only once.", k.key);
+    }
+}
+
+std::optional<ApiKey> KeyStore::lookup(const std::string& key) const {
+    std::lock_guard<std::mutex> lk(m_);
+    for (const auto& k : *snap_)
+        if (k.key == key) return k;
+    return std::nullopt;
+}
+
+std::vector<ApiKey> KeyStore::list() const {
+    std::lock_guard<std::mutex> lk(m_);
+    return *snap_;
+}
+
+ApiKey KeyStore::create(const std::string& label,
+                        std::vector<std::string> projects,
+                        bool admin) {
+    ApiKey k;
+    k.key       = generateApiKey();
+    k.label     = label;
+    k.projects  = std::move(projects);
+    k.admin     = admin;
+    k.createdAt = static_cast<uint64_t>(std::time(nullptr));
+    db_.client().upsert(coll_, k.toJson(), k.key);
+    reload();
+    return k;
+}
+
+bool KeyStore::update(const std::string& key,
+                      const std::optional<std::string>& label,
+                      const std::optional<std::vector<std::string>>& projects,
+                      const std::optional<bool>& admin) {
+    auto doc = db_.client().get(coll_, key);
+    if (!doc) return false;
+    ApiKey k = ApiKey::fromJson(*doc);
+    if (label)    k.label    = *label;
+    if (projects) k.projects = *projects;
+    if (admin)    k.admin    = *admin;
+    db_.client().upsert(coll_, k.toJson(), key);
+    reload();
+    return true;
+}
+
+bool KeyStore::remove(const std::string& key) {
+    bool ok = db_.client().remove(coll_, key);
+    reload();
+    return ok;
+}
+
+void KeyStore::startWatch() {
+    sub_ = db_.client().subscribe(
+        {coll_},
+        [this](const std::string&, const std::string&,
+               const std::string&, const std::optional<nlohmann::json>&) {
+            try { reload(); }
+            catch (const std::exception& e) {
+                spdlog::warn("key reload failed: {}", e.what());
+            }
+        });
+}
+
+} // namespace svapi

+ 55 - 0
src/key_store.hpp

@@ -0,0 +1,55 @@
+#pragma once
+#include "apikey.hpp"
+#include "db_gateway.hpp"
+#include <memory>
+#include <mutex>
+#include <optional>
+#include <string>
+#include <vector>
+
+namespace svapi {
+
+/// DB-backed store for API keys, held in collection `_vectorapi_keys`.
+/// All reads are served from an in-memory snapshot that is refreshed after
+/// every write and optionally kept live via `startWatch()`.
+class KeyStore {
+public:
+    KeyStore(DbGateway& db, std::string collection = "vectorapi_keys");
+
+    /// Ensure the (encrypted) collection exists.  If no keys are present, create
+    /// one admin key and log it once via spdlog.  If envAdminKey is non-empty it
+    /// is used as the key value; otherwise a random key is generated.
+    void bootstrap(const std::string& envAdminKey);
+
+    /// Look up a key by its raw value.  Served from the snapshot — O(n).
+    std::optional<ApiKey> lookup(const std::string& key) const;
+
+    /// Return a copy of the current snapshot.
+    std::vector<ApiKey> list() const;
+
+    /// Persist a new key and refresh the snapshot.
+    ApiKey create(const std::string& label, std::vector<std::string> projects, bool admin);
+
+    /// Patch an existing key.  Pass std::nullopt to leave a field unchanged.
+    /// Returns false when the key does not exist.
+    bool update(const std::string& key, const std::optional<std::string>& label,
+                const std::optional<std::vector<std::string>>& projects,
+                const std::optional<bool>& admin);
+
+    /// Delete a key.  Returns false when the key does not exist.
+    bool remove(const std::string& key);
+
+    /// Subscribe to DB change events so the snapshot stays current automatically.
+    void startWatch();
+
+private:
+    void reload();
+
+    DbGateway&  db_;
+    std::string coll_;
+    mutable std::mutex m_;
+    std::shared_ptr<const std::vector<ApiKey>> snap_;
+    std::shared_ptr<void> sub_;
+};
+
+} // namespace svapi

+ 61 - 0
src/settings_store.cpp

@@ -0,0 +1,61 @@
+#include "settings_store.hpp"
+#include <spdlog/spdlog.h>
+
+namespace svapi {
+
+SettingsStore::SettingsStore(DbGateway& db, std::string collection, std::string docId)
+    : db_(db), coll_(std::move(collection)), docId_(std::move(docId)),
+      snap_(std::make_shared<const Settings>()) {}
+
+void SettingsStore::bootstrap(const std::string& envOpenAiKey) {
+    if (!db_.client().getCollectionInfo(coll_).has_value())
+        db_.client().createCollection(coll_, 0, /*encrypted*/ true, 0, 0);
+
+    Settings s;
+    if (auto doc = db_.client().get(coll_, docId_))
+        s = Settings::fromJson(*doc);
+
+    if (s.openaiApiKey.empty() && !envOpenAiKey.empty())
+        s.openaiApiKey = envOpenAiKey;
+
+    if (!db_.client().exists(coll_, docId_))
+        db_.client().upsert(coll_, s.toJson(), docId_);
+
+    setSnapshot(s);
+}
+
+void SettingsStore::setSnapshot(Settings s) {
+    auto p = std::make_shared<const Settings>(std::move(s));
+    std::lock_guard<std::mutex> lk(m_);
+    snap_ = p;
+}
+
+std::shared_ptr<const Settings> SettingsStore::snapshot() const {
+    std::lock_guard<std::mutex> lk(m_);
+    return snap_;
+}
+
+void SettingsStore::reloadFromDb() {
+    if (auto doc = db_.client().get(coll_, docId_))
+        setSnapshot(Settings::fromJson(*doc));
+}
+
+bool SettingsStore::save(const Settings& s) {
+    db_.client().upsert(coll_, s.toJson(), docId_);
+    reloadFromDb();
+    return true;
+}
+
+void SettingsStore::startWatch() {
+    sub_ = db_.client().subscribe(
+        {coll_},
+        [this](const std::string&, const std::string&,
+               const std::string&, const std::optional<nlohmann::json>&) {
+            try { reloadFromDb(); }
+            catch (const std::exception& e) {
+                spdlog::warn("settings reload failed: {}", e.what());
+            }
+        });
+}
+
+} // namespace svapi

+ 47 - 0
src/settings_store.hpp

@@ -0,0 +1,47 @@
+#pragma once
+#include "db_gateway.hpp"
+#include "settings.hpp"
+#include <memory>
+#include <mutex>
+#include <string>
+
+namespace svapi {
+
+/// DB-backed global settings store (single document in `_vectorapi_settings`).
+/// Reads are served from a mutex-guarded shared_ptr snapshot.
+class SettingsStore {
+public:
+    SettingsStore(DbGateway& db,
+                  std::string collection = "vectorapi_settings",
+                  std::string docId     = "current");
+
+    /// Ensure the (encrypted) collection exists.  Load or seed the settings
+    /// document: if the doc is absent it is created; if envOpenAiKey is
+    /// non-empty and the stored openaiApiKey is empty, the env value is
+    /// written before persisting.  Publishes the initial snapshot.
+    void bootstrap(const std::string& envOpenAiKey);
+
+    /// Return the current settings snapshot (thread-safe, lock-free after copy).
+    std::shared_ptr<const Settings> snapshot() const;
+
+    /// Pull the latest settings from the DB and update the snapshot.
+    void reloadFromDb();
+
+    /// Persist settings and update the snapshot.
+    bool save(const Settings& s);
+
+    /// Subscribe to DB change events so the snapshot stays current automatically.
+    void startWatch();
+
+private:
+    void setSnapshot(Settings s);
+
+    DbGateway&   db_;
+    std::string  coll_;
+    std::string  docId_;
+    mutable std::mutex m_;
+    std::shared_ptr<const Settings> snap_;
+    std::shared_ptr<void> sub_;
+};
+
+} // namespace svapi

+ 4 - 0
tests/CMakeLists.txt

@@ -24,3 +24,7 @@ gtest_discover_tests(test_db_gateway)
 add_executable(test_settings test_settings.cpp)
 target_link_libraries(test_settings PRIVATE vectorapi_core GTest::gtest_main)
 gtest_discover_tests(test_settings)
+
+add_executable(test_stores test_stores.cpp)
+target_link_libraries(test_stores PRIVATE vectorapi_core GTest::gtest_main)
+gtest_discover_tests(test_stores)

+ 72 - 0
tests/test_stores.cpp

@@ -0,0 +1,72 @@
+#include "db_test_util.hpp"
+#include "key_store.hpp"
+#include "settings_store.hpp"
+using namespace svapi;
+
+TEST(KeyStore, BootstrapCreatesAdminKeyOnce) {
+    SVAPI_REQUIRE_DB(db);
+    const std::string coll = "svapitest_keys_a";
+    db->client().dropCollection(coll);
+
+    KeyStore ks(*db, coll);
+    ks.bootstrap(/*envAdminKey*/ "");
+    auto all = ks.list();
+    ASSERT_EQ(all.size(), 1u);
+    EXPECT_TRUE(all[0].admin);
+    ASSERT_FALSE(all[0].projects.empty());
+    EXPECT_EQ(all[0].projects[0], "*");
+    std::string adminKey = all[0].key;
+    EXPECT_TRUE(ks.lookup(adminKey).has_value());
+    EXPECT_FALSE(ks.lookup("nope").has_value());
+
+    // Re-bootstrap: no second key created.
+    KeyStore ks2(*db, coll);
+    ks2.bootstrap("");
+    EXPECT_EQ(ks2.list().size(), 1u);
+
+    db->client().dropCollection(coll);
+}
+
+TEST(KeyStore, EnvSeedsAdminKeyValue) {
+    SVAPI_REQUIRE_DB(db);
+    const std::string coll = "svapitest_keys_b";
+    db->client().dropCollection(coll);
+    KeyStore ks(*db, coll);
+    ks.bootstrap("seed-admin-123");
+    ASSERT_EQ(ks.list().size(), 1u);
+    EXPECT_EQ(ks.list()[0].key, "seed-admin-123");
+    db->client().dropCollection(coll);
+}
+
+TEST(KeyStore, CreateUpdateRemove) {
+    SVAPI_REQUIRE_DB(db);
+    const std::string coll = "svapitest_keys_c";
+    db->client().dropCollection(coll);
+    KeyStore ks(*db, coll); ks.bootstrap("");
+
+    ApiKey made = ks.create("n8n", {"p1"}, /*admin*/ false);
+    EXPECT_FALSE(made.key.empty());
+    ASSERT_TRUE(ks.lookup(made.key).has_value());
+    EXPECT_TRUE(ks.lookup(made.key)->canAccess("p1"));
+    EXPECT_FALSE(ks.lookup(made.key)->canAccess("p2"));
+
+    EXPECT_TRUE(ks.update(made.key, std::nullopt, std::vector<std::string>{"p1", "p2"}, std::nullopt));
+    EXPECT_TRUE(ks.lookup(made.key)->canAccess("p2"));
+
+    EXPECT_TRUE(ks.remove(made.key));
+    EXPECT_FALSE(ks.lookup(made.key).has_value());
+    db->client().dropCollection(coll);
+}
+
+TEST(SettingsStore, BootstrapAndSave) {
+    SVAPI_REQUIRE_DB(db);
+    const std::string coll = "svapitest_settings";
+    db->client().dropCollection(coll);
+    SettingsStore ss(*db, coll, "current");
+    ss.bootstrap(/*envOpenAiKey*/ "sk-env");
+    EXPECT_EQ(ss.snapshot()->openaiApiKey, "sk-env");
+    Settings s = *ss.snapshot(); s.defaultEmbeddingModel = "text-embedding-3-large";
+    EXPECT_TRUE(ss.save(s));
+    EXPECT_EQ(ss.snapshot()->defaultEmbeddingModel, "text-embedding-3-large");
+    db->client().dropCollection(coll);
+}