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

feat(auth): KeyOp + KeyScope model with glob/op/origin/expiry helpers

Fszontagh 1 hónapja
szülő
commit
cb71b17d9c
4 módosított fájl, 177 hozzáadás és 5 törlés
  1. 99 5
      src/apikey.cpp
  2. 25 0
      src/apikey.hpp
  3. 4 0
      tests/CMakeLists.txt
  4. 49 0
      tests/test_apikey.cpp

+ 99 - 5
src/apikey.cpp

@@ -2,10 +2,99 @@
 #include <openssl/rand.h>
 #include <algorithm>
 #include <array>
+#include <optional>
 #include <stdexcept>
 
 namespace svapi {
 
+// ---------------------------------------------------------------------------
+// KeyOp helpers
+// ---------------------------------------------------------------------------
+
+std::optional<KeyOp> keyOpFromString(const std::string& s) {
+    if (s == "read")   return KeyOp::Read;
+    if (s == "list")   return KeyOp::List;
+    if (s == "search") return KeyOp::Search;
+    if (s == "insert") return KeyOp::Insert;
+    if (s == "update") return KeyOp::Update;
+    if (s == "delete") return KeyOp::Delete;
+    return std::nullopt;
+}
+
+std::string keyOpToString(KeyOp op) {
+    switch (op) {
+        case KeyOp::Read:   return "read";
+        case KeyOp::List:   return "list";
+        case KeyOp::Search: return "search";
+        case KeyOp::Insert: return "insert";
+        case KeyOp::Update: return "update";
+        case KeyOp::Delete: return "delete";
+    }
+    return "";
+}
+
+// ---------------------------------------------------------------------------
+// KeyScope helpers
+// ---------------------------------------------------------------------------
+
+static bool collMatches(const std::string& pat, const std::string& coll) {
+    if (!pat.empty() && pat.back() == '*')
+        return coll.compare(0, pat.size() - 1, pat, 0, pat.size() - 1) == 0;
+    return pat == coll;
+}
+
+const KeyScopeRule* KeyScope::findRule(const std::string& collection, KeyOp op) const {
+    for (const auto& r : rules)
+        if (collMatches(r.collection, collection) && r.ops.count(op)) return &r;
+    return nullptr;
+}
+
+bool KeyScope::originAllowed(const std::string& origin) const {
+    if (origins.empty()) return true;
+    for (const auto& o : origins) if (o == origin) return true;
+    return false;
+}
+
+bool KeyScope::expired(uint64_t nowEpochSec) const {
+    return expiresAt != 0 && nowEpochSec > expiresAt;
+}
+
+KeyScope KeyScope::fromJson(const nlohmann::json& j) {
+    KeyScope s;
+    if (j.contains("rules") && j["rules"].is_array()) {
+        for (const auto& rj : j["rules"]) {
+            KeyScopeRule r;
+            r.collection = rj.value("collection", "");
+            r.requireHumanToken = rj.value("require_human_token", false);
+            if (rj.contains("ops") && rj["ops"].is_array())
+                for (const auto& oj : rj["ops"])
+                    if (auto op = keyOpFromString(oj.get<std::string>())) r.ops.insert(*op);
+            s.rules.push_back(std::move(r));
+        }
+    }
+    if (j.contains("origins") && j["origins"].is_array())
+        s.origins = j["origins"].get<std::vector<std::string>>();
+    s.rateLimitPerMin = j.value("rate_limit_per_min", 0u);
+    s.expiresAt       = j.value("expires_at", 0ull);
+    return s;
+}
+
+nlohmann::json KeyScope::toJson() const {
+    nlohmann::json rules_j = nlohmann::json::array();
+    for (const auto& r : rules) {
+        nlohmann::json ops_j = nlohmann::json::array();
+        for (KeyOp op : r.ops) ops_j.push_back(keyOpToString(op));
+        rules_j.push_back({{"collection", r.collection}, {"ops", ops_j},
+                           {"require_human_token", r.requireHumanToken}});
+    }
+    return {{"rules", rules_j}, {"origins", origins},
+            {"rate_limit_per_min", rateLimitPerMin}, {"expires_at", expiresAt}};
+}
+
+// ---------------------------------------------------------------------------
+// ApiKey
+// ---------------------------------------------------------------------------
+
 ApiKey ApiKey::fromJson(const nlohmann::json& j) {
     ApiKey k;
     // "key_id" is the stable non-secret id stored in the document body.
@@ -17,20 +106,25 @@ ApiKey ApiKey::fromJson(const nlohmann::json& j) {
         k.projects = j["projects"].get<std::vector<std::string>>();
     k.admin     = j.value("admin", false);
     k.createdAt = j.value("created_at", 0ull);
+    if (j.contains("scope") && j["scope"].is_object()) k.scope = KeyScope::fromJson(j["scope"]);
     return k;
 }
 
 nlohmann::json ApiKey::toJson() const {
     // "key_id" stores the stable non-secret id in the body.
     // We avoid using "id" as the DB treats that as a reserved/internal field.
-    return {{"key_id", id}, {"key", key}, {"label", label}, {"projects", projects},
-            {"admin", admin}, {"created_at", createdAt}};
+    nlohmann::json j = {{"key_id", id}, {"key", key}, {"label", label}, {"projects", projects},
+                        {"admin", admin}, {"created_at", createdAt}};
+    if (scope) j["scope"] = scope->toJson();
+    return j;
 }
 
 nlohmann::json ApiKey::toPublicJson() const {
-    return {{"id", id}, {"label", label}, {"projects", projects}, {"admin", admin},
-            {"created_at", createdAt},
-            {"key_prefix", key.substr(0, std::min<size_t>(8, key.size()))}};
+    nlohmann::json j = {{"id", id}, {"label", label}, {"projects", projects}, {"admin", admin},
+                        {"created_at", createdAt},
+                        {"key_prefix", key.substr(0, std::min<size_t>(8, key.size()))}};
+    if (scope) j["scope"] = scope->toJson();
+    return j;
 }
 
 bool ApiKey::canAccess(const std::string& project) const {

+ 25 - 0
src/apikey.hpp

@@ -1,10 +1,34 @@
 #pragma once
 #include <nlohmann/json.hpp>
+#include <optional>
+#include <set>
 #include <string>
 #include <vector>
 
 namespace svapi {
 
+enum class KeyOp { Read, List, Search, Insert, Update, Delete };
+std::optional<KeyOp> keyOpFromString(const std::string& s);
+std::string keyOpToString(KeyOp op);
+
+struct KeyScopeRule {
+    std::string collection;            // exact ("presets") or prefix glob ("catalog_*")
+    std::set<KeyOp> ops;
+    bool requireHumanToken = false;
+};
+
+struct KeyScope {
+    std::vector<KeyScopeRule> rules;
+    std::vector<std::string> origins;  // empty = any origin allowed
+    uint32_t rateLimitPerMin = 0;      // 0 = unlimited
+    uint64_t expiresAt       = 0;      // epoch seconds; 0 = never
+    const KeyScopeRule* findRule(const std::string& collection, KeyOp op) const;
+    bool originAllowed(const std::string& origin) const;
+    bool expired(uint64_t nowEpochSec) const;
+    static KeyScope fromJson(const nlohmann::json& j);
+    nlohmann::json  toJson() const;
+};
+
 /// An API key with per-project grants and optional admin privilege.
 struct ApiKey {
     std::string id;       // stable non-secret identifier (used as DB doc id)
@@ -13,6 +37,7 @@ struct ApiKey {
     std::vector<std::string> projects;  // may contain "*" (all projects)
     bool     admin     = false;
     uint64_t createdAt = 0;
+    std::optional<KeyScope> scope;
 
     static ApiKey      fromJson(const nlohmann::json& j);
     nlohmann::json     toJson()       const;  // full (includes key) — for DB storage

+ 4 - 0
tests/CMakeLists.txt

@@ -37,6 +37,10 @@ add_executable(test_embeddings test_embeddings.cpp)
 target_link_libraries(test_embeddings PRIVATE vectorapi_core GTest::gtest_main)
 gtest_discover_tests(test_embeddings)
 
+add_executable(test_apikey test_apikey.cpp)
+target_link_libraries(test_apikey PRIVATE vectorapi_core GTest::gtest_main)
+gtest_discover_tests(test_apikey)
+
 add_executable(test_auth test_auth.cpp)
 target_link_libraries(test_auth PRIVATE vectorapi_core GTest::gtest_main)
 gtest_discover_tests(test_auth)

+ 49 - 0
tests/test_apikey.cpp

@@ -0,0 +1,49 @@
+#include <gtest/gtest.h>
+#include "apikey.hpp"
+using namespace svapi;
+
+TEST(KeyOp, StringRoundTrip) {
+    EXPECT_EQ(keyOpToString(KeyOp::Search), "search");
+    EXPECT_EQ(*keyOpFromString("insert"), KeyOp::Insert);
+    EXPECT_FALSE(keyOpFromString("bogus").has_value());
+}
+TEST(KeyScope, ExactAndGlobRuleMatch) {
+    KeyScope s;
+    s.rules.push_back({"catalog_*", {KeyOp::Read, KeyOp::List}, false});
+    s.rules.push_back({"quotes",    {KeyOp::Insert},           false});
+    EXPECT_NE(s.findRule("catalog_doors", KeyOp::Read), nullptr);
+    EXPECT_EQ(s.findRule("catalog_doors", KeyOp::Insert), nullptr);
+    EXPECT_NE(s.findRule("quotes", KeyOp::Insert), nullptr);
+    EXPECT_EQ(s.findRule("designs", KeyOp::Read), nullptr);
+    EXPECT_EQ(s.findRule("catalogX", KeyOp::Read), nullptr);
+}
+TEST(KeyScope, OriginAllowlist) {
+    KeyScope s; EXPECT_TRUE(s.originAllowed("https://anything"));
+    s.origins = {"https://konfig.megatoloajto.hu"};
+    EXPECT_TRUE(s.originAllowed("https://konfig.megatoloajto.hu"));
+    EXPECT_FALSE(s.originAllowed("https://evil.example"));
+    EXPECT_FALSE(s.originAllowed(""));
+}
+TEST(KeyScope, Expiry) {
+    KeyScope s; EXPECT_FALSE(s.expired(1000));
+    s.expiresAt = 500; EXPECT_TRUE(s.expired(1000)); EXPECT_FALSE(s.expired(499));
+}
+TEST(ApiKey, ScopeRoundTripsThroughJson) {
+    ApiKey k; k.id = "k1"; k.key = "secretvalue"; k.projects = {"megatoloajto"};
+    KeyScope s; s.rules.push_back({"quotes", {KeyOp::Insert}, true});
+    s.origins = {"https://x"}; s.rateLimitPerMin = 120; s.expiresAt = 0;
+    k.scope = s;
+    ApiKey back = ApiKey::fromJson(k.toJson());
+    ASSERT_TRUE(back.scope.has_value());
+    ASSERT_NE(back.scope->findRule("quotes", KeyOp::Insert), nullptr);
+    EXPECT_TRUE(back.scope->findRule("quotes", KeyOp::Insert)->requireHumanToken);
+    EXPECT_EQ(back.scope->rateLimitPerMin, 120u);
+    auto pub = k.toPublicJson();
+    EXPECT_TRUE(pub.contains("scope"));
+    EXPECT_FALSE(pub.contains("key"));
+}
+TEST(ApiKey, NoScopeStaysAbsent) {
+    ApiKey k; k.id = "k2";
+    EXPECT_FALSE(ApiKey::fromJson(k.toJson()).scope.has_value());
+    EXPECT_FALSE(k.toPublicJson().contains("scope"));
+}