فهرست منبع

feat(api): project + key management endpoints with admin/grant enforcement

Fszontagh 2 ماه پیش
والد
کامیت
9f81ce1bd9
3فایلهای تغییر یافته به همراه144 افزوده شده و 2 حذف شده
  1. 50 1
      src/handlers/keys.cpp
  2. 45 1
      src/handlers/projects.cpp
  3. 49 0
      tests/test_api_integration.cpp

+ 50 - 1
src/handlers/keys.cpp

@@ -1,2 +1,51 @@
+#include "errors.hpp"
+#include "json_http.hpp"
 #include "server.hpp"
-namespace svapi { void registerKeyRoutes(ApiServer&) {} }
+namespace svapi {
+void registerKeyRoutes(ApiServer& s) {
+    auto& svr = s.raw(); ServerDeps* d = &s.deps();
+
+    svr.Get("/api/v1/keys", [d](const httplib::Request& req, httplib::Response& res) {
+        requireAdmin(requireKey(*d, req));
+        nlohmann::json arr = nlohmann::json::array();
+        for (const auto& k : d->keys.list()) arr.push_back(k.toPublicJson());
+        sendJson(res, 200, {{"keys", arr}});
+    });
+
+    svr.Post("/api/v1/keys", [d](const httplib::Request& req, httplib::Response& res) {
+        requireAdmin(requireKey(*d, req));
+        auto body = bodyJson(req);
+        std::string label = body.value("label", "");
+        std::vector<std::string> projects;
+        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);
+        nlohmann::json out = made.toPublicJson();
+        out["key"] = made.key;            // returned ONCE, in full, on creation
+        sendJson(res, 201, out);
+    });
+
+    svr.Patch(R"(/api/v1/keys/([^/]+))", [d](const httplib::Request& req, httplib::Response& res) {
+        requireAdmin(requireKey(*d, req));
+        std::string key = req.matches[1];
+        auto body = bodyJson(req);
+        std::optional<std::string> label;
+        std::optional<std::vector<std::string>> projects;
+        std::optional<bool> admin;
+        if (body.contains("label")) label = body["label"].get<std::string>();
+        if (body.contains("projects")) projects = body["projects"].get<std::vector<std::string>>();
+        if (body.contains("admin")) admin = body["admin"].get<bool>();
+        if (!d->keys.update(key, label, projects, admin))
+            throw ApiError(ErrCode::NotFound, "not_found", "no such key");
+        sendJson(res, 200, {{"updated", true}});
+    });
+
+    svr.Delete(R"(/api/v1/keys/([^/]+))", [d](const httplib::Request& req, httplib::Response& res) {
+        requireAdmin(requireKey(*d, req));
+        std::string key = req.matches[1];
+        if (!d->keys.remove(key)) throw ApiError(ErrCode::NotFound, "not_found", "no such key");
+        sendJson(res, 200, {{"deleted", true}});
+    });
+}
+}

+ 45 - 1
src/handlers/projects.cpp

@@ -1,2 +1,46 @@
+#include "errors.hpp"
+#include "json_http.hpp"
 #include "server.hpp"
-namespace svapi { void registerProjectRoutes(ApiServer&) {} }
+#include <algorithm>
+namespace svapi {
+void registerProjectRoutes(ApiServer& s) {
+    auto& svr = s.raw(); ServerDeps* d = &s.deps();
+
+    svr.Get("/api/v1/projects", [d](const httplib::Request& req, httplib::Response& res) {
+        ApiKey k = requireKey(*d, req);
+        auto all = d->db.listProjects();
+        nlohmann::json arr = nlohmann::json::array();
+        for (const auto& p : all) if (k.canAccess(p)) arr.push_back(p);
+        sendJson(res, 200, {{"projects", arr}});
+    });
+
+    svr.Post("/api/v1/projects", [d](const httplib::Request& req, httplib::Response& res) {
+        ApiKey k = requireKey(*d, req); requireAdmin(k);
+        auto body = bodyJson(req);
+        std::string name = body.value("name", "");
+        if (name.empty()) throw ApiError(ErrCode::Unprocessable, "validation", "name is required");
+        if (!d->db.ensureProject(name))
+            throw ApiError(ErrCode::Unprocessable, "bad_project", "could not create project (check name rules)");
+        d->registry.ensure(name);
+        sendJson(res, 201, {{"name", name}});
+    });
+
+    svr.Get(R"(/api/v1/projects/([^/]+))", [d](const httplib::Request& req, httplib::Response& res) {
+        ApiKey k = requireKey(*d, req);
+        std::string project = req.matches[1];
+        requireProjectAccess(k, project);
+        auto all = d->db.listProjects();
+        if (std::find(all.begin(), all.end(), project) == all.end())
+            throw ApiError(ErrCode::NotFound, "not_found", "no such project");
+        sendJson(res, 200, {{"name", project}, {"collections", d->registry.list(project).size()}});
+    });
+
+    svr.Delete(R"(/api/v1/projects/([^/]+))", [d](const httplib::Request& req, httplib::Response& res) {
+        ApiKey k = requireKey(*d, req); requireAdmin(k);
+        std::string project = req.matches[1];
+        if (project == "default") throw ApiError(ErrCode::Unprocessable, "protected", "cannot drop the default project");
+        if (!d->db.dropProject(project)) throw ApiError(ErrCode::Unavailable, "db_error", "drop failed");
+        sendJson(res, 200, {{"deleted", project}});
+    });
+}
+}

+ 49 - 0
tests/test_api_integration.cpp

@@ -25,3 +25,52 @@ TEST_F(ApiFixture, BadKeyRejected) {
     auto r = http("not-a-real-key").Get("/api/v1/projects");
     ASSERT_TRUE(r); EXPECT_EQ(r->status, 401);
 }
+
+TEST_F(ApiFixture, ProjectsListCreateGetDelete) {
+    auto c = admin();
+    auto list0 = c.Get("/api/v1/projects");
+    ASSERT_EQ(list0->status, 200);
+    EXPECT_TRUE(nlohmann::json::parse(list0->body).contains("projects"));
+
+    std::string np = "svapitest_created";
+    db_->dropProject(np);  // clean
+    auto cr = c.Post("/api/v1/projects", nlohmann::json{{"name", np}}.dump(), "application/json");
+    ASSERT_TRUE(cr); EXPECT_EQ(cr->status, 201);
+    auto got = c.Get(("/api/v1/projects/" + np).c_str());
+    ASSERT_EQ(got->status, 200);
+    auto del = c.Delete(("/api/v1/projects/" + np).c_str());
+    EXPECT_EQ(del->status, 200);
+    db_->dropProject(np);
+}
+
+TEST_F(ApiFixture, KeysCrudAndScopedAuthz) {
+    auto c = admin();
+    // create a non-admin key scoped to project_
+    auto mk = c.Post("/api/v1/keys",
+        nlohmann::json{{"label","n8n"},{"projects",{project_}},{"admin",false}}.dump(), "application/json");
+    ASSERT_TRUE(mk); ASSERT_EQ(mk->status, 201);
+    std::string scopedKey = nlohmann::json::parse(mk->body)["key"];
+    ASSERT_FALSE(scopedKey.empty());
+
+    // list masks the secret
+    auto list = c.Get("/api/v1/keys");
+    ASSERT_EQ(list->status, 200);
+    for (auto& k : nlohmann::json::parse(list->body)["keys"]) EXPECT_FALSE(k.contains("key"));
+
+    // scoped key can access its project, but not management or another project
+    EXPECT_EQ(http(scopedKey).Get(("/api/v1/projects/" + project_).c_str())->status, 200);
+    EXPECT_EQ(http(scopedKey).Get("/api/v1/keys")->status, 403);              // not admin
+    EXPECT_EQ(http(scopedKey).Get("/api/v1/projects/svapitest_other")->status, 403);  // not granted
+
+    EXPECT_EQ(c.Delete(("/api/v1/keys/" + scopedKey).c_str())->status, 200);
+    EXPECT_EQ(http(scopedKey).Get(("/api/v1/projects/" + project_).c_str())->status, 401);  // revoked
+}
+
+TEST_F(ApiFixture, NonAdminCannotCreateProject) {
+    auto mk = admin().Post("/api/v1/keys",
+        nlohmann::json{{"label","x"},{"projects",{project_}},{"admin",false}}.dump(), "application/json");
+    std::string scoped = nlohmann::json::parse(mk->body)["key"];
+    auto r = http(scoped).Post("/api/v1/projects", nlohmann::json{{"name","svapitest_nope"}}.dump(), "application/json");
+    ASSERT_TRUE(r); EXPECT_EQ(r->status, 403);
+    admin().Delete(("/api/v1/keys/" + scoped).c_str());
+}