Răsfoiți Sursa

feat(keys): accept and validate scope on create/patch

KeyStore::create and ::update gain an optional scope parameter.
POST /api/v1/keys and PATCH /api/v1/keys/:id now parse and validate
the scope field (admin+scope rejected, empty rules rejected, unknown
ops rejected) via a file-local parseScope() helper. Integration test
CreateScopedKeyAndReject covers all four validation paths.
Fszontagh 1 lună în urmă
părinte
comite
f61c74267c
4 a modificat fișierele cu 94 adăugiri și 6 ștergeri
  1. 36 2
      src/handlers/keys.cpp
  2. 6 2
      src/key_store.cpp
  3. 6 2
      src/key_store.hpp
  4. 46 0
      tests/test_api_integration.cpp

+ 36 - 2
src/handlers/keys.cpp

@@ -2,6 +2,26 @@
 #include "json_http.hpp"
 #include "server.hpp"
 namespace svapi {
+
+static std::optional<KeyScope> parseScope(const nlohmann::json& body, bool admin) {
+    if (!body.contains("scope")) return std::nullopt;
+    if (admin) throw ApiError(ErrCode::Unprocessable, "validation", "admin keys cannot be scoped");
+    const auto& sj = body["scope"];
+    if (!sj.is_object()) throw ApiError(ErrCode::Unprocessable, "validation", "scope must be an object");
+    if (!sj.contains("rules") || !sj["rules"].is_array() || sj["rules"].empty())
+        throw ApiError(ErrCode::Unprocessable, "validation", "scope.rules must be a non-empty array");
+    for (const auto& r : sj["rules"]) {
+        if (!r.is_object() || r.value("collection", std::string()).empty())
+            throw ApiError(ErrCode::Unprocessable, "validation", "each rule needs a collection");
+        if (!r.contains("ops") || !r["ops"].is_array() || r["ops"].empty())
+            throw ApiError(ErrCode::Unprocessable, "validation", "each rule needs ops");
+        for (const auto& o : r["ops"])
+            if (!o.is_string() || !keyOpFromString(o.get<std::string>()))
+                throw ApiError(ErrCode::Unprocessable, "validation", "unknown op in scope");
+    }
+    return KeyScope::fromJson(sj);
+}
+
 void registerKeyRoutes(ApiServer& s) {
     auto& svr = s.raw(); ServerDeps* d = &s.deps();
 
@@ -20,7 +40,8 @@ void registerKeyRoutes(ApiServer& s) {
         if (body.contains("projects") && body["projects"].is_array())
             projects = body["projects"].get<std::vector<std::string>>();
         bool admin = body.value("admin", false);
-        ApiKey made = d->keys.create(label, projects, admin);
+        auto scope = parseScope(body, admin);
+        ApiKey made = d->keys.create(label, projects, admin, std::move(scope));
         nlohmann::json out = made.toPublicJson();
         out["key"] = made.key;            // returned ONCE, in full, on creation
         sendJson(res, 201, out);
@@ -46,7 +67,20 @@ void registerKeyRoutes(ApiServer& s) {
                 if (adminCount <= 1) throw ApiError(ErrCode::Unprocessable, "last_admin", "cannot remove the last admin key");
             }
         }
-        if (!d->keys.update(id, label, projects, admin))
+        std::optional<std::optional<KeyScope>> scopePatch;
+        if (body.contains("scope")) {
+            // Determine effective admin value: patched value if present, else existing key's admin
+            bool effectiveAdmin = false;
+            if (admin.has_value()) {
+                effectiveAdmin = *admin;
+            } else {
+                for (const auto& k : d->keys.list()) {
+                    if (k.id == id) { effectiveAdmin = k.admin; break; }
+                }
+            }
+            scopePatch = std::optional<std::optional<KeyScope>>{ parseScope(body, effectiveAdmin) };
+        }
+        if (!d->keys.update(id, label, projects, admin, scopePatch))
             throw ApiError(ErrCode::NotFound, "not_found", "no such key");
         sendJson(res, 200, {{"updated", true}});
     });

+ 6 - 2
src/key_store.cpp

@@ -71,7 +71,8 @@ std::vector<ApiKey> KeyStore::list() const {
 
 ApiKey KeyStore::create(const std::string& label,
                         std::vector<std::string> projects,
-                        bool admin) {
+                        bool admin,
+                        std::optional<KeyScope> scope) {
     ApiKey k;
     k.id        = generateApiKey();
     k.key       = generateApiKey();
@@ -79,6 +80,7 @@ ApiKey KeyStore::create(const std::string& label,
     k.projects  = std::move(projects);
     k.admin     = admin;
     k.createdAt = static_cast<uint64_t>(std::time(nullptr));
+    k.scope     = std::move(scope);
     db_.client().upsert(coll_, k.toJson(), k.id);
     reload();
     return k;
@@ -87,13 +89,15 @@ ApiKey KeyStore::create(const std::string& label,
 bool KeyStore::update(const std::string& id,
                       const std::optional<std::string>& label,
                       const std::optional<std::vector<std::string>>& projects,
-                      const std::optional<bool>& admin) {
+                      const std::optional<bool>& admin,
+                      const std::optional<std::optional<KeyScope>>& scope) {
     auto doc = db_.client().get(coll_, id);
     if (!doc) return false;
     ApiKey k = ApiKey::fromJson(*doc);
     if (label)    k.label    = *label;
     if (projects) k.projects = *projects;
     if (admin)    k.admin    = *admin;
+    if (scope)    k.scope    = *scope;
     db_.client().upsert(coll_, k.toJson(), id);
     reload();
     return true;

+ 6 - 2
src/key_store.hpp

@@ -28,14 +28,18 @@ public:
     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);
+    ApiKey create(const std::string& label, std::vector<std::string> projects, bool admin,
+                  std::optional<KeyScope> scope = std::nullopt);
 
     /// Patch an existing key identified by its stable id.
     /// Pass std::nullopt to leave a field unchanged.
+    /// For scope: outer nullopt = leave unchanged; outer engaged with inner nullopt = clear scope;
+    /// outer engaged with inner value = set scope.
     /// Returns false when the key does not exist.
     bool update(const std::string& id, const std::optional<std::string>& label,
                 const std::optional<std::vector<std::string>>& projects,
-                const std::optional<bool>& admin);
+                const std::optional<bool>& admin,
+                const std::optional<std::optional<KeyScope>>& scope = std::nullopt);
 
     /// Delete a key identified by its stable id.  Returns false when the key does not exist.
     bool remove(const std::string& id);

+ 46 - 0
tests/test_api_integration.cpp

@@ -563,3 +563,49 @@ TEST_F(ApiFixture, ScopedKeyCannotManageCollections) {
     admin().Delete((base + "/scopedtest").c_str());
     db_->client().remove(keysColl_, scopedKey.id);
 }
+
+// U4: admins can create/patch keys with a scope, validated.
+TEST_F(ApiFixture, CreateScopedKeyAndReject) {
+    auto c = admin();
+    const nlohmann::json validScope = {
+        {"rules", {{{"collection", "quotes"}, {"ops", {"insert"}}}}},
+        {"rate_limit_per_min", 60}
+    };
+
+    // 1. Valid scoped key (non-admin + scope) → 201, response includes scope
+    auto mkRes = c.Post("/api/v1/keys",
+        nlohmann::json{{"label","scoped-key"},{"projects",{project_}},{"admin",false},{"scope",validScope}}.dump(),
+        "application/json");
+    ASSERT_TRUE(mkRes);
+    ASSERT_EQ(mkRes->status, 201) << "valid scoped key create failed: " << mkRes->body;
+    auto mkJson = nlohmann::json::parse(mkRes->body);
+    EXPECT_TRUE(mkJson.contains("scope")) << "response should include scope field";
+    ASSERT_TRUE(mkJson.contains("id"));
+    std::string createdId = mkJson["id"].get<std::string>();
+
+    // Cleanup the created key
+    c.Delete(("/api/v1/keys/" + createdId).c_str());
+
+    // 2. scope + admin:true → 422
+    auto adminScoped = c.Post("/api/v1/keys",
+        nlohmann::json{{"label","bad"},{"projects",{project_}},{"admin",true},{"scope",validScope}}.dump(),
+        "application/json");
+    ASSERT_TRUE(adminScoped);
+    EXPECT_EQ(adminScoped->status, 422) << "admin + scope must be rejected: " << adminScoped->body;
+
+    // 3. scope.rules empty array → 422
+    auto emptyRules = c.Post("/api/v1/keys",
+        nlohmann::json{{"label","bad"},{"projects",{project_}},{"admin",false},
+            {"scope",{{"rules",nlohmann::json::array()}}}}.dump(),
+        "application/json");
+    ASSERT_TRUE(emptyRules);
+    EXPECT_EQ(emptyRules->status, 422) << "empty scope.rules must be rejected: " << emptyRules->body;
+
+    // 4. Unknown op in scope → 422
+    auto badOp = c.Post("/api/v1/keys",
+        nlohmann::json{{"label","bad"},{"projects",{project_}},{"admin",false},
+            {"scope",{{"rules",{{{"collection","quotes"},{"ops",{"frobnicate"}}}}}}}}.dump(),
+        "application/json");
+    ASSERT_TRUE(badOp);
+    EXPECT_EQ(badOp->status, 422) << "unknown op must be rejected: " << badOp->body;
+}