Переглянути джерело

feat(server): project-aware auth (authn pre-routing + per-handler authz), CORS, meta, login

Fszontagh 2 місяців тому
батько
коміт
68a84eebc8

+ 3 - 0
api/llms.txt

@@ -0,0 +1,3 @@
+# smartbotic-vectorapi
+
+> REST API fronting smartbotic-database (multi-project, multi-key).

+ 1 - 0
api/openapi.json

@@ -0,0 +1 @@
+{ "openapi": "3.1.0", "info": { "title": "smartbotic-vectorapi", "version": "0.1.0" }, "paths": {} }

+ 9 - 0
src/CMakeLists.txt

@@ -11,6 +11,15 @@ add_library(vectorapi_core STATIC
     collection_registry.cpp
     embeddings.cpp
     auth.cpp
+    server.cpp
+    handlers/meta.cpp
+    handlers/webui_auth.cpp
+    handlers/projects.cpp
+    handlers/keys.cpp
+    handlers/collections.cpp
+    handlers/documents.cpp
+    handlers/vectors.cpp
+    handlers/stats.cpp
 )
 target_include_directories(vectorapi_core PUBLIC
     ${CMAKE_CURRENT_SOURCE_DIR}

+ 1 - 0
src/errors.cpp

@@ -6,6 +6,7 @@ int httpStatus(ErrCode code) {
     switch (code) {
         case ErrCode::BadRequest:    return 400;
         case ErrCode::Unauthorized:  return 401;
+        case ErrCode::Forbidden:     return 403;
         case ErrCode::NotFound:      return 404;
         case ErrCode::Unprocessable: return 422;
         case ErrCode::Unavailable:   return 503;

+ 1 - 1
src/errors.hpp

@@ -5,7 +5,7 @@
 
 namespace svapi {
 
-enum class ErrCode { BadRequest, Unauthorized, NotFound, Unprocessable, Unavailable, Internal };
+enum class ErrCode { BadRequest, Unauthorized, Forbidden, NotFound, Unprocessable, Unavailable, Internal };
 
 int httpStatus(ErrCode code);
 nlohmann::json errorBody(const std::string& code, const std::string& message);

+ 2 - 0
src/handlers/collections.cpp

@@ -0,0 +1,2 @@
+#include "server.hpp"
+namespace svapi { void registerCollectionRoutes(ApiServer&) {} }

+ 2 - 0
src/handlers/documents.cpp

@@ -0,0 +1,2 @@
+#include "server.hpp"
+namespace svapi { void registerDocumentRoutes(ApiServer&) {} }

+ 2 - 0
src/handlers/keys.cpp

@@ -0,0 +1,2 @@
+#include "server.hpp"
+namespace svapi { void registerKeyRoutes(ApiServer&) {} }

+ 41 - 0
src/handlers/meta.cpp

@@ -0,0 +1,41 @@
+#include "json_http.hpp"
+#include "server.hpp"
+#include <fstream>
+#include <sstream>
+
+namespace svapi {
+
+void registerMetaRoutes(ApiServer& s) {
+    auto& svr = s.raw();
+    DbGateway* db = &s.deps().db;
+    std::string share = s.deps().shareDir;
+
+    svr.Get("/healthz", [](const httplib::Request&, httplib::Response& res) {
+        sendJson(res, 200, {{"status", "ok"}});
+    });
+
+    svr.Get("/readyz", [db](const httplib::Request&, httplib::Response& res) {
+        if (db->healthy()) sendJson(res, 200, {{"status", "ok"}});
+        else sendJson(res, 503, {{"status", "unavailable"}});
+    });
+
+    svr.Get("/openapi.json", [share](const httplib::Request&, httplib::Response& res) {
+        if (share.empty()) { sendJson(res, 404, {{"error", "not configured"}}); return; }
+        std::ifstream f(share + "/openapi.json");
+        if (!f) { sendJson(res, 404, {{"error", "not found"}}); return; }
+        std::ostringstream ss; ss << f.rdbuf();
+        res.set_content(ss.str(), "application/json");
+        res.status = 200;
+    });
+
+    svr.Get("/llms.txt", [share](const httplib::Request&, httplib::Response& res) {
+        if (share.empty()) { sendJson(res, 404, {{"error", "not configured"}}); return; }
+        std::ifstream f(share + "/llms.txt");
+        if (!f) { sendJson(res, 404, {{"error", "not found"}}); return; }
+        std::ostringstream ss; ss << f.rdbuf();
+        res.set_content(ss.str(), "text/plain");
+        res.status = 200;
+    });
+}
+
+} // namespace svapi

+ 2 - 0
src/handlers/projects.cpp

@@ -0,0 +1,2 @@
+#include "server.hpp"
+namespace svapi { void registerProjectRoutes(ApiServer&) {} }

+ 2 - 0
src/handlers/stats.cpp

@@ -0,0 +1,2 @@
+#include "server.hpp"
+namespace svapi { void registerStatsRoutes(ApiServer&) {} }

+ 2 - 0
src/handlers/vectors.cpp

@@ -0,0 +1,2 @@
+#include "server.hpp"
+namespace svapi { void registerVectorRoutes(ApiServer&) {} }

+ 28 - 0
src/handlers/webui_auth.cpp

@@ -0,0 +1,28 @@
+#include "auth.hpp"
+#include "errors.hpp"
+#include "json_http.hpp"
+#include "server.hpp"
+#include <chrono>
+namespace svapi {
+void registerWebuiAuthRoutes(ApiServer& s) {
+    auto& svr = s.raw(); ServerDeps* d = &s.deps();
+    svr.Post("/ui/login", [d](const httplib::Request& req, httplib::Response& res) {
+        auto body = bodyJson(req);
+        std::string key = body.value("key", "");
+        auto found = d->keys.lookup(key);
+        if (!found) throw ApiError(ErrCode::Unauthorized, "unauthorized", "invalid key");
+        auto snap = d->settings.snapshot();
+        d->sessions.setTtl(std::chrono::minutes(snap->sessionTtlMinutes));
+        std::string tok = d->sessions.create(key, std::chrono::system_clock::now());
+        res.set_header("Set-Cookie", "svapi_session=" + tok +
+            "; HttpOnly; SameSite=Strict; Path=/; Max-Age=" + std::to_string(snap->sessionTtlMinutes * 60));
+        sendJson(res, 200, {{"ok", true}, {"admin", found->admin}, {"projects", found->projects}});
+    });
+    svr.Post("/ui/logout", [d](const httplib::Request& req, httplib::Response& res) {
+        if (auto it = req.headers.find("Cookie"); it != req.headers.end())
+            if (auto c = cookieValue(it->second, "svapi_session")) d->sessions.remove(*c);
+        res.set_header("Set-Cookie", "svapi_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0");
+        sendJson(res, 200, {{"ok", true}});
+    });
+}
+}

+ 76 - 0
src/server.cpp

@@ -0,0 +1,76 @@
+#include "server.hpp"
+#include "errors.hpp"
+#include "json_http.hpp"
+#include <chrono>
+
+namespace svapi {
+
+ApiServer::ApiServer(ServerDeps deps) : d_(std::move(deps)) {}
+
+std::optional<ApiKey> resolveKey(ServerDeps& d, const httplib::Request& req) {
+    if (auto it = req.headers.find("Authorization"); it != req.headers.end())
+        if (auto tok = bearerToken(it->second))
+            if (auto k = d.keys.lookup(*tok)) return k;
+    if (auto it = req.headers.find("Cookie"); it != req.headers.end())
+        if (auto c = cookieValue(it->second, "svapi_session"))
+            if (auto keyVal = d.sessions.keyFor(*c, std::chrono::system_clock::now()))
+                if (auto k = d.keys.lookup(*keyVal)) return k;
+    return std::nullopt;
+}
+ApiKey requireKey(ServerDeps& d, const httplib::Request& req) {
+    if (auto k = resolveKey(d, req)) return *k;
+    throw ApiError(ErrCode::Unauthorized, "unauthorized", "missing or invalid credentials");
+}
+void requireAdmin(const ApiKey& k) {
+    if (!k.admin) throw ApiError(ErrCode::Forbidden, "forbidden", "admin privilege required");
+}
+void requireProjectAccess(const ApiKey& k, const std::string& project) {
+    if (!k.canAccess(project))
+        throw ApiError(ErrCode::Forbidden, "forbidden", "key not granted project '" + project + "'");
+}
+
+void ApiServer::registerRoutes() {
+    svr_.set_exception_handler([](const httplib::Request&, httplib::Response& res, std::exception_ptr ep) {
+        try { std::rethrow_exception(ep); }
+        catch (const ApiError& e) { sendJson(res, httpStatus(e.code), errorBody(e.slug, e.what())); }
+        catch (const std::exception& e) { sendJson(res, 500, errorBody("internal", e.what())); }
+    });
+
+    // Authenticate /api/* (authorization is per-handler).
+    svr_.set_pre_routing_handler([this](const httplib::Request& req, httplib::Response& res) {
+        if (req.method != "OPTIONS" && req.path.rfind("/api/", 0) == 0) {
+            if (!resolveKey(d_, req)) {
+                sendJson(res, 401, errorBody("unauthorized", "missing or invalid credentials"));
+                return httplib::Server::HandlerResponse::Handled;
+            }
+        }
+        return httplib::Server::HandlerResponse::Unhandled;
+    });
+
+    svr_.set_post_routing_handler([this](const httplib::Request&, httplib::Response& res) {
+        auto snap = d_.settings.snapshot();
+        const std::string origin = snap->corsOrigins.empty() ? "*" : snap->corsOrigins.front();
+        res.set_header("Access-Control-Allow-Origin", origin);
+        res.set_header("Access-Control-Allow-Headers", "Authorization, Content-Type");
+        res.set_header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
+    });
+    svr_.Options(R"(.*)", [](const httplib::Request&, httplib::Response& res) { res.status = 204; });
+
+    registerMetaRoutes(*this);
+    registerWebuiAuthRoutes(*this);
+    registerProjectRoutes(*this);
+    registerKeyRoutes(*this);
+    registerCollectionRoutes(*this);
+    registerDocumentRoutes(*this);
+    registerVectorRoutes(*this);
+    registerStatsRoutes(*this);
+
+    if (!d_.webuiDir.empty()) svr_.set_mount_point("/", d_.webuiDir);
+}
+
+int  ApiServer::bindToAnyPort(const std::string& host) { return svr_.bind_to_any_port(host); }
+void ApiServer::listenAfterBind() { svr_.listen_after_bind(); }
+bool ApiServer::listen(const std::string& host, uint16_t port) { return svr_.listen(host, port); }
+void ApiServer::stop() { svr_.stop(); }
+
+} // namespace svapi

+ 55 - 0
src/server.hpp

@@ -0,0 +1,55 @@
+#pragma once
+#include "apikey.hpp"
+#include "auth.hpp"
+#include "collection_registry.hpp"
+#include "db_gateway.hpp"
+#include "key_store.hpp"
+#include "settings_store.hpp"
+#include <httplib.h>
+#include <optional>
+#include <string>
+
+namespace svapi {
+
+struct ServerDeps {
+    DbGateway& db;
+    SettingsStore& settings;
+    KeyStore& keys;
+    CollectionRegistry& registry;
+    SessionStore& sessions;
+    std::string webuiDir;   // static SPA root ("" disables)
+    std::string shareDir;   // openapi.json + llms.txt dir
+};
+
+class ApiServer {
+public:
+    explicit ApiServer(ServerDeps deps);
+    void registerRoutes();
+    int  bindToAnyPort(const std::string& host);
+    void listenAfterBind();
+    bool listen(const std::string& host, uint16_t port);
+    void stop();
+    httplib::Server& raw() { return svr_; }
+    ServerDeps& deps() { return d_; }
+private:
+    ServerDeps d_;
+    httplib::Server svr_;
+};
+
+// Auth helpers (defined in server.cpp).
+std::optional<ApiKey> resolveKey(ServerDeps& d, const httplib::Request& req);
+ApiKey requireKey(ServerDeps& d, const httplib::Request& req);              // throws Unauthorized
+void   requireAdmin(const ApiKey& k);                                      // throws Forbidden
+void   requireProjectAccess(const ApiKey& k, const std::string& project);  // throws Forbidden
+
+// Route registration (handlers/*.cpp).
+void registerMetaRoutes(ApiServer&);
+void registerWebuiAuthRoutes(ApiServer&);
+void registerProjectRoutes(ApiServer&);
+void registerKeyRoutes(ApiServer&);
+void registerCollectionRoutes(ApiServer&);
+void registerDocumentRoutes(ApiServer&);
+void registerVectorRoutes(ApiServer&);
+void registerStatsRoutes(ApiServer&);
+
+} // namespace svapi

+ 5 - 0
tests/CMakeLists.txt

@@ -40,3 +40,8 @@ gtest_discover_tests(test_embeddings)
 add_executable(test_auth test_auth.cpp)
 target_link_libraries(test_auth PRIVATE vectorapi_core GTest::gtest_main)
 gtest_discover_tests(test_auth)
+
+add_executable(test_api_integration test_api_integration.cpp)
+target_link_libraries(test_api_integration PRIVATE vectorapi_core GTest::gtest_main)
+target_compile_definitions(test_api_integration PRIVATE SVAPI_API_DOCS_DIR="${CMAKE_SOURCE_DIR}/api")
+gtest_discover_tests(test_api_integration)

+ 89 - 0
tests/api_fixture.hpp

@@ -0,0 +1,89 @@
+#pragma once
+#include "db_test_util.hpp"
+#include "server.hpp"
+#include <gtest/gtest.h>
+#include <httplib.h>
+#include <chrono>
+#include <memory>
+#include <thread>
+#include <vector>
+
+namespace svapi::testutil {
+
+class ApiFixture : public ::testing::Test {
+protected:
+    std::unique_ptr<DbGateway> db_;
+    std::unique_ptr<CollectionRegistry> registry_;
+    std::unique_ptr<SettingsStore> settings_;
+    std::unique_ptr<KeyStore> keys_;
+    std::unique_ptr<SessionStore> sessions_;
+    std::unique_ptr<ApiServer> server_;
+    std::thread serverThread_;
+    int port_ = 0;
+    std::string adminKey_;
+    std::string project_;   // a project the admin owns; tests create collections under it
+
+    httplib::Server mockOpenAi_;
+    std::thread mockThread_;
+    int mockPort_ = 0, mockDim_ = 4;
+    std::string settingsColl_ = "svapitest_api_settings";
+    std::string keysColl_     = "svapitest_api_keys";
+
+    void SetUp() override {
+        db_ = connectOrNull();
+        if (!db_) GTEST_SKIP() << "smartbotic-database not reachable";
+        project_ = tmpName("apiproj");
+        db_->client().dropCollection(settingsColl_);
+        db_->client().dropCollection(keysColl_);
+        db_->dropProject(project_);
+        db_->ensureProject(project_);
+
+        registry_ = std::make_unique<CollectionRegistry>(*db_);
+        registry_->ensure(project_);
+        settings_ = std::make_unique<SettingsStore>(*db_, settingsColl_, "current");
+        settings_->bootstrap("");
+        keys_ = std::make_unique<KeyStore>(*db_, keysColl_);
+        keys_->bootstrap("");
+        adminKey_ = keys_->list().at(0).key;
+        sessions_ = std::make_unique<SessionStore>(std::chrono::minutes(60));
+
+        mockOpenAi_.Post("/v1/embeddings", [this](const httplib::Request&, httplib::Response& res) {
+            nlohmann::json emb = nlohmann::json::array();
+            for (int i = 0; i < mockDim_; ++i) emb.push_back(0.1f * (i + 1));
+            res.set_content(nlohmann::json{{"data", {{{"embedding", emb}}}}}.dump(), "application/json");
+        });
+        mockPort_ = mockOpenAi_.bind_to_any_port("127.0.0.1");
+        mockThread_ = std::thread([this]{ mockOpenAi_.listen_after_bind(); });
+        Settings s = *settings_->snapshot();
+        s.openaiApiBase = "http://127.0.0.1:" + std::to_string(mockPort_);
+        s.openaiApiKey = "test";
+        settings_->save(s);
+
+        ServerDeps deps{*db_, *settings_, *keys_, *registry_, *sessions_, "", apiDocsDir()};
+        server_ = std::make_unique<ApiServer>(std::move(deps));
+        server_->registerRoutes();
+        port_ = server_->bindToAnyPort("127.0.0.1");
+        serverThread_ = std::thread([this]{ server_->listenAfterBind(); });
+        httplib::Client probe("127.0.0.1", port_);
+        for (int i = 0; i < 200; ++i) { if (probe.Get("/healthz")) break;
+            std::this_thread::sleep_for(std::chrono::milliseconds(10)); }
+    }
+    void TearDown() override {
+        if (server_) server_->stop();
+        if (serverThread_.joinable()) serverThread_.join();
+        mockOpenAi_.stop();
+        if (mockThread_.joinable()) mockThread_.join();
+        if (db_) { db_->client().dropCollection(settingsColl_);
+                   db_->client().dropCollection(keysColl_);
+                   db_->dropProject(project_); }
+    }
+    httplib::Client http(const std::string& bearer) {
+        httplib::Client c("127.0.0.1", port_);
+        c.set_default_headers({{"Authorization", "Bearer " + bearer}});
+        return c;
+    }
+    httplib::Client admin() { return http(adminKey_); }
+    httplib::Client noAuth() { return httplib::Client("127.0.0.1", port_); }
+    static std::string apiDocsDir() { return SVAPI_API_DOCS_DIR; }
+};
+} // namespace svapi::testutil

+ 27 - 0
tests/test_api_integration.cpp

@@ -0,0 +1,27 @@
+#include "api_fixture.hpp"
+using svapi::testutil::ApiFixture;
+
+TEST_F(ApiFixture, HealthzPublic) { auto r = noAuth().Get("/healthz"); ASSERT_TRUE(r); EXPECT_EQ(r->status, 200); }
+TEST_F(ApiFixture, ReadyzOk)      { auto r = noAuth().Get("/readyz");  ASSERT_TRUE(r); EXPECT_EQ(r->status, 200); }
+TEST_F(ApiFixture, MetaDocsPublic) {
+    EXPECT_EQ(noAuth().Get("/openapi.json")->status, 200);
+    EXPECT_EQ(noAuth().Get("/llms.txt")->status, 200);
+}
+TEST_F(ApiFixture, ApiRequiresAuth) {
+    auto r = noAuth().Get("/api/v1/projects");
+    ASSERT_TRUE(r); EXPECT_EQ(r->status, 401);
+}
+TEST_F(ApiFixture, LoginIssuesCookie) {
+    auto login = noAuth().Post("/ui/login", nlohmann::json{{"key", adminKey_}}.dump(), "application/json");
+    ASSERT_TRUE(login); EXPECT_EQ(login->status, 200);
+    ASSERT_TRUE(login->has_header("Set-Cookie"));
+    std::string cookie = login->get_header_value("Set-Cookie");
+    cookie = cookie.substr(0, cookie.find(';'));
+    httplib::Client c("127.0.0.1", port_); c.set_default_headers({{"Cookie", cookie}});
+    auto r = c.Get("/api/v1/projects");
+    ASSERT_TRUE(r); EXPECT_NE(r->status, 401);
+}
+TEST_F(ApiFixture, BadKeyRejected) {
+    auto r = http("not-a-real-key").Get("/api/v1/projects");
+    ASSERT_TRUE(r); EXPECT_EQ(r->status, 401);
+}

+ 2 - 0
tests/test_errors.cpp

@@ -24,3 +24,5 @@ TEST(Errors, ApiErrorCarriesFields) {
     EXPECT_EQ(e.slug, "not_found");
     EXPECT_STREQ(e.what(), "gone");
 }
+
+TEST(Errors, ForbiddenIs403) { EXPECT_EQ(httpStatus(svapi::ErrCode::Forbidden), 403); }