Bladeren bron

feat(settings): global Settings + ApiKey model with per-project grants

Fszontagh 2 maanden geleden
bovenliggende
commit
0ba14baac6
7 gewijzigde bestanden met toevoegingen van 176 en 0 verwijderingen
  1. 2 0
      src/CMakeLists.txt
  2. 47 0
      src/apikey.cpp
  3. 27 0
      src/apikey.hpp
  4. 25 0
      src/settings.cpp
  5. 22 0
      src/settings.hpp
  6. 4 0
      tests/CMakeLists.txt
  7. 49 0
      tests/test_settings.cpp

+ 2 - 0
src/CMakeLists.txt

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

+ 47 - 0
src/apikey.cpp

@@ -0,0 +1,47 @@
+#include "apikey.hpp"
+#include <openssl/rand.h>
+#include <algorithm>
+#include <array>
+#include <stdexcept>
+
+namespace svapi {
+
+ApiKey ApiKey::fromJson(const nlohmann::json& j) {
+    ApiKey k;
+    k.key       = j.value("key", "");
+    k.label     = j.value("label", "");
+    if (j.contains("projects") && j["projects"].is_array())
+        k.projects = j["projects"].get<std::vector<std::string>>();
+    k.admin     = j.value("admin", false);
+    k.createdAt = j.value("created_at", 0ull);
+    return k;
+}
+
+nlohmann::json ApiKey::toJson() const {
+    return {{"key", key}, {"label", label}, {"projects", projects},
+            {"admin", admin}, {"created_at", createdAt}};
+}
+
+nlohmann::json ApiKey::toPublicJson() const {
+    return {{"label", label}, {"projects", projects}, {"admin", admin},
+            {"created_at", createdAt},
+            {"key_prefix", key.substr(0, std::min<size_t>(8, key.size()))}};
+}
+
+bool ApiKey::canAccess(const std::string& project) const {
+    if (admin) return true;
+    for (const auto& p : projects) if (p == "*" || p == project) return true;
+    return false;
+}
+
+std::string generateApiKey() {
+    std::array<unsigned char, 24> buf{};
+    if (RAND_bytes(buf.data(), static_cast<int>(buf.size())) != 1)
+        throw std::runtime_error("RAND_bytes failed");
+    static const char* hex = "0123456789abcdef";
+    std::string out; out.reserve(48);
+    for (unsigned char b : buf) { out.push_back(hex[b >> 4]); out.push_back(hex[b & 0xF]); }
+    return out;
+}
+
+} // namespace svapi

+ 27 - 0
src/apikey.hpp

@@ -0,0 +1,27 @@
+#pragma once
+#include <nlohmann/json.hpp>
+#include <string>
+#include <vector>
+
+namespace svapi {
+
+/// An API key with per-project grants and optional admin privilege.
+struct ApiKey {
+    std::string key;
+    std::string label;
+    std::vector<std::string> projects;  // may contain "*" (all projects)
+    bool     admin     = false;
+    uint64_t createdAt = 0;
+
+    static ApiKey      fromJson(const nlohmann::json& j);
+    nlohmann::json     toJson()       const;  // full (includes key) — for DB storage
+    nlohmann::json     toPublicJson() const;  // masked: label, projects, admin, created_at, key_prefix
+
+    /// Returns true if admin, or projects contains "*", or projects contains project.
+    bool canAccess(const std::string& project) const;
+};
+
+/// Generate a 48-character lowercase hex key (24 random bytes via OpenSSL RAND_bytes).
+std::string generateApiKey();
+
+} // namespace svapi

+ 25 - 0
src/settings.cpp

@@ -0,0 +1,25 @@
+#include "settings.hpp"
+
+namespace svapi {
+
+Settings Settings::fromJson(const nlohmann::json& j) {
+    Settings s;
+    s.openaiApiBase         = j.value("openai_api_base", s.openaiApiBase);
+    s.openaiApiKey          = j.value("openai_api_key", s.openaiApiKey);
+    s.defaultEmbeddingModel = j.value("default_embedding_model", s.defaultEmbeddingModel);
+    if (j.contains("cors_origins") && j["cors_origins"].is_array())
+        s.corsOrigins = j["cors_origins"].get<std::vector<std::string>>();
+    s.sessionTtlMinutes = j.value("session_ttl_minutes", s.sessionTtlMinutes);
+    s.webuiEnabled      = j.value("webui_enabled", s.webuiEnabled);
+    s.defaultProject    = j.value("default_project", s.defaultProject);
+    return s;
+}
+
+nlohmann::json Settings::toJson() const {
+    return {{"openai_api_base", openaiApiBase}, {"openai_api_key", openaiApiKey},
+            {"default_embedding_model", defaultEmbeddingModel}, {"cors_origins", corsOrigins},
+            {"session_ttl_minutes", sessionTtlMinutes}, {"webui_enabled", webuiEnabled},
+            {"default_project", defaultProject}};
+}
+
+} // namespace svapi

+ 22 - 0
src/settings.hpp

@@ -0,0 +1,22 @@
+#pragma once
+#include <nlohmann/json.hpp>
+#include <string>
+#include <vector>
+
+namespace svapi {
+
+/// Global service settings — no api_key field (keys live in KeyStore).
+struct Settings {
+    std::string openaiApiBase            = "https://api.openai.com";
+    std::string openaiApiKey;
+    std::string defaultEmbeddingModel    = "text-embedding-3-small";
+    std::vector<std::string> corsOrigins = {"*"};
+    uint32_t    sessionTtlMinutes        = 720;
+    bool        webuiEnabled             = true;
+    std::string defaultProject           = "default";
+
+    static Settings fromJson(const nlohmann::json& j);
+    nlohmann::json  toJson() const;
+};
+
+} // namespace svapi

+ 4 - 0
tests/CMakeLists.txt

@@ -20,3 +20,7 @@ gtest_discover_tests(test_filters)
 add_executable(test_db_gateway test_db_gateway.cpp)
 target_link_libraries(test_db_gateway PRIVATE vectorapi_core GTest::gtest_main)
 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)

+ 49 - 0
tests/test_settings.cpp

@@ -0,0 +1,49 @@
+#include <gtest/gtest.h>
+#include "settings.hpp"
+#include "apikey.hpp"
+#include <cctype>
+using namespace svapi;
+
+TEST(Settings, DefaultsAndRoundTrip) {
+    Settings s = Settings::fromJson(nlohmann::json::object());
+    EXPECT_EQ(s.openaiApiBase, "https://api.openai.com");
+    EXPECT_EQ(s.defaultEmbeddingModel, "text-embedding-3-small");
+    EXPECT_EQ(s.sessionTtlMinutes, 720u);
+    EXPECT_TRUE(s.webuiEnabled);
+    EXPECT_EQ(s.defaultProject, "default");
+    EXPECT_EQ(Settings::fromJson(s.toJson()).toJson(), s.toJson());
+}
+
+TEST(ApiKey, JsonRoundTripAndPublicMasks) {
+    ApiKey k = ApiKey::fromJson(nlohmann::json::parse(
+        R"({"key":"abcdef0123456789","label":"n8n","projects":["p1","p2"],"admin":false,"created_at":7})"));
+    EXPECT_EQ(k.key, "abcdef0123456789");
+    EXPECT_EQ(k.projects.size(), 2u);
+    EXPECT_FALSE(k.admin);
+    EXPECT_EQ(k.createdAt, 7u);
+    EXPECT_EQ(ApiKey::fromJson(k.toJson()).toJson(), k.toJson());
+    auto pub = k.toPublicJson();
+    EXPECT_FALSE(pub.contains("key"));           // secret not exposed
+    EXPECT_EQ(pub["label"], "n8n");
+    EXPECT_EQ(pub["projects"].size(), 2u);
+    EXPECT_TRUE(pub.contains("key_prefix"));      // short hint only
+}
+
+TEST(ApiKey, CanAccess) {
+    ApiKey adminK; adminK.admin = true;
+    EXPECT_TRUE(adminK.canAccess("anything"));
+
+    ApiKey star; star.projects = {"*"};
+    EXPECT_TRUE(star.canAccess("anything"));
+
+    ApiKey scoped; scoped.projects = {"p1", "p2"};
+    EXPECT_TRUE(scoped.canAccess("p1"));
+    EXPECT_FALSE(scoped.canAccess("p3"));
+}
+
+TEST(ApiKey, GenerateIsHexAndUnique) {
+    std::string a = generateApiKey(), b = generateApiKey();
+    EXPECT_GE(a.size(), 32u);
+    EXPECT_NE(a, b);
+    for (char c : a) EXPECT_TRUE(std::isxdigit(static_cast<unsigned char>(c)));
+}