소스 검색

test+docs(auth): scoped-key enforcement matrix (op/collection/origin/429) + API docs

Adds four integration tests for the capability-scoped key MVP:
- ScopedKeyOpAndCollectionMatrix: verifies that only explicitly granted
  (collection, op) pairs succeed; unlisted ops and unlisted collections
  both return 403.
- ScopedKeyOriginEnforced: confirms origin-pinned keys reject wrong or
  missing Origin headers with 403 (fails closed).
- ScopedKeyRateLimited: exercises the per-key rate limiter — 3rd request
  within the same minute returns 429 with a Retry-After header.
- LegacyKeyUnaffected: regression guard that an unscoped admin key retains
  full CRUD access (insert / list / delete).

api_fixture.hpp gains a request() overload that accepts a specific bearer
key and an extra-headers map, enabling Origin and other header assertions
without creating a separate httplib::Client per test.

openapi.json: adds KeyScopeRule and KeyScope component schemas; threads
scope into POST /keys and PATCH /keys/{id} request bodies and the create-
key 201 response; adds 429 responses to insertDocument, upsertVector, and
searchVectors.

llms.txt: documents scoped-key semantics under ## Keys — rules, origins
(fails-closed), rate_limit_per_min (429 + Retry-After), expires_at.
Fszontagh 1 개월 전
부모
커밋
af626e0711
4개의 변경된 파일304개의 추가작업 그리고 10개의 파일을 삭제
  1. 24 3
      api/llms.txt
  2. 54 7
      api/openapi.json
  3. 19 0
      tests/api_fixture.hpp
  4. 207 0
      tests/test_api_integration.cpp

+ 24 - 3
api/llms.txt

@@ -20,11 +20,32 @@ Requires a valid key. Admin-only operations are noted.
 
 All key-management endpoints require an admin key.
 
-- `GET  /api/v1/keys` — List keys. Secret value is masked; returns `id`, `key_prefix`, `label`, `projects`, `admin`, and `created_at`. Use `id` to reference the key in PATCH/DELETE.
-- `POST /api/v1/keys` `{label, projects:[], admin?}` — Generate a new key. Returns the secret key value **once** (store it immediately) plus the stable `id`.
-- `PATCH /api/v1/keys/{id}` `{label?, projects?, admin?}` — Update grants for an existing key, identified by its `id` (not the secret).
+- `GET  /api/v1/keys` — List keys. Secret value is masked; returns `id`, `key_prefix`, `label`, `projects`, `admin`, `created_at`, and `scope` (if present). Use `id` to reference the key in PATCH/DELETE.
+- `POST /api/v1/keys` `{label, projects:[], admin?, scope?}` — Generate a new key. Returns the secret key value **once** (store it immediately) plus the stable `id`. Pass a `scope` object (see below) to create a capability-scoped key.
+- `PATCH /api/v1/keys/{id}` `{label?, projects?, admin?, scope?}` — Update grants for an existing key, identified by its `id` (not the secret). Pass `scope: null` to clear an existing scope.
 - `DELETE /api/v1/keys/{id}` — Revoke a key by `id`. Active sessions using it are immediately invalidated.
 
+### Capability-scoped keys
+
+A non-admin key may carry a `scope` object that restricts what it can do. Scoped keys are designed to be safely embedded in untrusted clients (browser frontends, mobile apps, n8n workflows). A scoped key cannot manage collections or projects regardless of its project grants.
+
+`scope` fields:
+
+- `rules` (required, non-empty array) — Each rule specifies a `collection` name and the permitted `ops` array (`read`, `list`, `search`, `insert`, `update`, `delete`). A request succeeds only if a matching `{collection, op}` rule exists.
+- `origins` (array of strings, default empty) — Allowed `Origin` header values. When non-empty, requests without a matching `Origin` header are rejected with 403 (**fails closed** — no origin header means denied). Use this to pin a key to a specific domain.
+- `rate_limit_per_min` (integer, default 0 = unlimited) — Per-key request rate limit. When the limit is exceeded the server returns **429** with a `Retry-After: 60` header.
+- `expires_at` (integer, default 0 = never) — Unix epoch seconds after which the key is treated as expired; auth returns 401.
+
+Example scope (insert-only, origin-pinned, rate-limited, expiring):
+```json
+{
+  "rules": [{"collection": "designs", "ops": ["insert"]}],
+  "origins": ["https://app.example.com"],
+  "rate_limit_per_min": 60,
+  "expires_at": 1893456000
+}
+```
+
 ## Collections
 
 Project-scoped. A key must be granted the project.

+ 54 - 7
api/openapi.json

@@ -33,6 +33,46 @@
         },
         "required": ["name", "kind"]
       },
+      "KeyScopeRule": {
+        "type": "object",
+        "properties": {
+          "collection": { "type": "string", "description": "Collection name the rule applies to" },
+          "ops": {
+            "type": "array",
+            "items": { "type": "string", "enum": ["read","list","search","insert","update","delete"] },
+            "description": "Permitted operations on this collection"
+          },
+          "require_human_token": { "type": "boolean", "description": "Phase 2: require a CAPTCHA token (reserved)" }
+        },
+        "required": ["collection", "ops"]
+      },
+      "KeyScope": {
+        "type": "object",
+        "description": "Capability scope that constrains a non-admin key to specific collections and operations. A scoped key is safe to embed in untrusted clients (e.g. read-only, insert-only, origin-pinned, rate-limited, expiring). Scoped keys cannot manage collections or projects.",
+        "properties": {
+          "rules": {
+            "type": "array",
+            "items": { "$ref": "#/components/schemas/KeyScopeRule" },
+            "minItems": 1,
+            "description": "At least one rule is required. A request is allowed only if a matching rule exists for the (collection, op) pair."
+          },
+          "origins": {
+            "type": "array",
+            "items": { "type": "string" },
+            "description": "Allowed Origin header values. Empty array means any origin is permitted. When non-empty, requests without a matching Origin header are rejected (fails closed)."
+          },
+          "rate_limit_per_min": {
+            "type": "integer",
+            "minimum": 0,
+            "description": "Maximum requests per minute for this key (keyed by key id). 0 means unlimited. Exceeding the limit returns 429 with a Retry-After: 60 header."
+          },
+          "expires_at": {
+            "type": "integer",
+            "description": "Unix epoch seconds after which the key is treated as expired (auth returns 401). 0 means the key never expires."
+          }
+        },
+        "required": ["rules"]
+      },
       "ApiKeyPublic": {
         "type": "object",
         "properties": {
@@ -41,7 +81,8 @@
           "label": { "type": "string" },
           "projects": { "type": "array", "items": { "type": "string" } },
           "admin": { "type": "boolean" },
-          "created_at": { "type": "integer" }
+          "created_at": { "type": "integer" },
+          "scope": { "$ref": "#/components/schemas/KeyScope", "description": "Present only when the key carries a capability scope" }
         }
       }
     }
@@ -201,7 +242,8 @@
                     "items": { "type": "string" },
                     "description": "Project names the key may access; use [\"*\"] for all projects"
                   },
-                  "admin": { "type": "boolean", "default": false }
+                  "admin": { "type": "boolean", "default": false },
+                  "scope": { "$ref": "#/components/schemas/KeyScope", "description": "Optional capability scope. Mutually exclusive with admin:true." }
                 },
                 "required": ["label", "projects"]
               }
@@ -220,7 +262,8 @@
                     "key": { "type": "string", "description": "Secret key value — shown only once" },
                     "label": { "type": "string" },
                     "projects": { "type": "array", "items": { "type": "string" } },
-                    "admin": { "type": "boolean" }
+                    "admin": { "type": "boolean" },
+                    "scope": { "$ref": "#/components/schemas/KeyScope" }
                   }
                 }
               }
@@ -254,7 +297,8 @@
                 "properties": {
                   "label": { "type": "string" },
                   "projects": { "type": "array", "items": { "type": "string" } },
-                  "admin": { "type": "boolean" }
+                  "admin": { "type": "boolean" },
+                  "scope": { "$ref": "#/components/schemas/KeyScope", "description": "Set or replace the capability scope. Omit to leave unchanged. Pass null to clear the scope." }
                 }
               }
             }
@@ -516,7 +560,8 @@
           "401": { "description": "Unauthorized" },
           "403": { "description": "Forbidden" },
           "404": { "description": "Collection not found" },
-          "422": { "description": "Validation error" }
+          "422": { "description": "Validation error" },
+          "429": { "description": "Too Many Requests (rate-limited scoped key)" }
         }
       }
     },
@@ -677,7 +722,8 @@
           "401": { "description": "Unauthorized" },
           "403": { "description": "Forbidden" },
           "404": { "description": "Collection not found" },
-          "422": { "description": "Validation error (wrong dimension, not a vector collection, etc.)" }
+          "422": { "description": "Validation error (wrong dimension, not a vector collection, etc.)" },
+          "429": { "description": "Too Many Requests (rate-limited scoped key)" }
         }
       }
     },
@@ -765,7 +811,8 @@
           "401": { "description": "Unauthorized" },
           "403": { "description": "Forbidden" },
           "404": { "description": "Collection not found" },
-          "422": { "description": "Validation error" }
+          "422": { "description": "Validation error" },
+          "429": { "description": "Too Many Requests (rate-limited scoped key)" }
         }
       }
     },

+ 19 - 0
tests/api_fixture.hpp

@@ -95,5 +95,24 @@ protected:
     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; }
+
+    // Send a single request with a specific key and extra headers (for origin/cache-control tests).
+    // method: "GET", "POST", "PATCH", "PUT", "DELETE".
+    // body/contentType: pass "" for requests without a body.
+    httplib::Result request(const std::string& method, const std::string& path,
+                            const std::string& body, const std::string& contentType,
+                            const std::string& key,
+                            const httplib::Headers& extraHeaders = {}) {
+        httplib::Headers hdrs = {{"Authorization", "Bearer " + key}};
+        for (const auto& [k, v] : extraHeaders) hdrs.emplace(k, v);
+        httplib::Client c("127.0.0.1", port_);
+        c.set_default_headers(hdrs);
+        if (method == "GET")    return c.Get(path.c_str());
+        if (method == "DELETE") return c.Delete(path.c_str());
+        if (method == "POST")   return c.Post(path.c_str(), body, contentType.c_str());
+        if (method == "PUT")    return c.Put(path.c_str(), body, contentType.c_str());
+        if (method == "PATCH")  return c.Patch(path.c_str(), body, contentType.c_str());
+        throw std::invalid_argument("unsupported method: " + method);
+    }
 };
 } // namespace svapi::testutil

+ 207 - 0
tests/test_api_integration.cpp

@@ -564,6 +564,213 @@ TEST_F(ApiFixture, ScopedKeyCannotManageCollections) {
     db_->client().remove(keysColl_, scopedKey.id);
 }
 
+// U5a: scoped key op+collection matrix — only explicitly granted (collection, op) pairs succeed.
+TEST_F(ApiFixture, ScopedKeyOpAndCollectionMatrix) {
+    // Create collections: designs (json), quotes (json), other (json).
+    const std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(admin().Post(base.c_str(),
+        nlohmann::json{{"name","designs"},{"kind","json"}}.dump(), "application/json")->status, 201);
+    ASSERT_EQ(admin().Post(base.c_str(),
+        nlohmann::json{{"name","quotes"},{"kind","json"}}.dump(), "application/json")->status, 201);
+    ASSERT_EQ(admin().Post(base.c_str(),
+        nlohmann::json{{"name","other"},{"kind","json"}}.dump(), "application/json")->status, 201);
+
+    // Build a scoped key: designs=[read,insert], quotes=[insert], origins=[] (any).
+    svapi::ApiKey sk;
+    sk.id        = svapi::generateApiKey();
+    sk.key       = svapi::generateApiKey();
+    sk.label     = "matrix-test";
+    sk.projects  = {project_};
+    sk.admin     = false;
+    sk.createdAt = 0;
+    {
+        svapi::KeyScope sc;
+        svapi::KeyScopeRule r1;
+        r1.collection = "designs";
+        r1.ops        = {svapi::KeyOp::Read, svapi::KeyOp::Insert};
+        svapi::KeyScopeRule r2;
+        r2.collection = "quotes";
+        r2.ops        = {svapi::KeyOp::Insert};
+        sc.rules.push_back(std::move(r1));
+        sc.rules.push_back(std::move(r2));
+        sk.scope = std::move(sc);
+    }
+    db_->client().upsert(keysColl_, sk.toJson(), sk.id);
+    keys_->bootstrap("");
+
+    const std::string jDoc = nlohmann::json{{"data",{{"x",1}}}}.dump();
+    const std::string ct   = "application/json";
+
+    // --- designs: insert allowed → 201 ---
+    auto insDesigns = request("POST", base + "/designs/documents", jDoc, ct, sk.key);
+    ASSERT_TRUE(insDesigns);
+    EXPECT_EQ(insDesigns->status, 201) << "designs insert expected 201; body: " << insDesigns->body;
+    std::string designsId = nlohmann::json::parse(insDesigns->body)["id"];
+
+    // --- designs: read allowed → 200 ---
+    auto rdDesigns = request("GET", base + "/designs/documents/" + designsId, "", ct, sk.key);
+    ASSERT_TRUE(rdDesigns);
+    EXPECT_EQ(rdDesigns->status, 200) << "designs read expected 200; body: " << rdDesigns->body;
+
+    // --- designs: list NOT granted → 403 ---
+    auto listDesigns = request("GET", base + "/designs/documents", "", ct, sk.key);
+    ASSERT_TRUE(listDesigns);
+    EXPECT_EQ(listDesigns->status, 403) << "designs list expected 403; body: " << listDesigns->body;
+
+    // --- designs: delete NOT granted → 403 ---
+    auto delDesigns = request("DELETE", base + "/designs/documents/" + designsId, "", ct, sk.key);
+    ASSERT_TRUE(delDesigns);
+    EXPECT_EQ(delDesigns->status, 403) << "designs delete expected 403; body: " << delDesigns->body;
+
+    // --- quotes: insert allowed → 201 ---
+    auto insQuotes = request("POST", base + "/quotes/documents", jDoc, ct, sk.key);
+    ASSERT_TRUE(insQuotes);
+    EXPECT_EQ(insQuotes->status, 201) << "quotes insert expected 201; body: " << insQuotes->body;
+    std::string quotesId = nlohmann::json::parse(insQuotes->body)["id"];
+
+    // --- quotes: read NOT granted → 403 ---
+    auto rdQuotes = request("GET", base + "/quotes/documents/" + quotesId, "", ct, sk.key);
+    ASSERT_TRUE(rdQuotes);
+    EXPECT_EQ(rdQuotes->status, 403) << "quotes read expected 403; body: " << rdQuotes->body;
+
+    // --- quotes: list NOT granted → 403 ---
+    auto listQuotes = request("GET", base + "/quotes/documents", "", ct, sk.key);
+    ASSERT_TRUE(listQuotes);
+    EXPECT_EQ(listQuotes->status, 403) << "quotes list expected 403; body: " << listQuotes->body;
+
+    // --- other: no rule at all → 403 for insert ---
+    auto insOther = request("POST", base + "/other/documents", jDoc, ct, sk.key);
+    ASSERT_TRUE(insOther);
+    EXPECT_EQ(insOther->status, 403) << "other insert expected 403; body: " << insOther->body;
+
+    // Cleanup
+    admin().Delete((base + "/designs").c_str());
+    admin().Delete((base + "/quotes").c_str());
+    admin().Delete((base + "/other").c_str());
+    db_->client().remove(keysColl_, sk.id);
+}
+
+// U5b: scoped key with an origins list enforces the Origin header — fails closed (no header → 403).
+TEST_F(ApiFixture, ScopedKeyOriginEnforced) {
+    const std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(admin().Post(base.c_str(),
+        nlohmann::json{{"name","origin_coll"},{"kind","json"}}.dump(), "application/json")->status, 201);
+
+    svapi::ApiKey sk;
+    sk.id        = svapi::generateApiKey();
+    sk.key       = svapi::generateApiKey();
+    sk.label     = "origin-test";
+    sk.projects  = {project_};
+    sk.admin     = false;
+    sk.createdAt = 0;
+    {
+        svapi::KeyScope sc;
+        svapi::KeyScopeRule r;
+        r.collection = "origin_coll";
+        r.ops        = {svapi::KeyOp::Insert};
+        sc.rules.push_back(std::move(r));
+        sc.origins   = {"https://app.test"};
+        sk.scope     = std::move(sc);
+    }
+    db_->client().upsert(keysColl_, sk.toJson(), sk.id);
+    keys_->bootstrap("");
+
+    const std::string jDoc = nlohmann::json{{"data",{{"y",2}}}}.dump();
+    const std::string ct   = "application/json";
+    const std::string path = base + "/origin_coll/documents";
+
+    // Correct origin → 201
+    auto good = request("POST", path, jDoc, ct, sk.key, {{"Origin", "https://app.test"}});
+    ASSERT_TRUE(good);
+    EXPECT_EQ(good->status, 201) << "correct origin expected 201; body: " << good->body;
+
+    // Wrong origin → 403
+    auto bad = request("POST", path, jDoc, ct, sk.key, {{"Origin", "https://evil.test"}});
+    ASSERT_TRUE(bad);
+    EXPECT_EQ(bad->status, 403) << "wrong origin expected 403; body: " << bad->body;
+
+    // No Origin header → 403 (fails closed)
+    auto noOrigin = request("POST", path, jDoc, ct, sk.key);
+    ASSERT_TRUE(noOrigin);
+    EXPECT_EQ(noOrigin->status, 403) << "missing origin expected 403; body: " << noOrigin->body;
+
+    // Cleanup
+    admin().Delete((base + "/origin_coll").c_str());
+    db_->client().remove(keysColl_, sk.id);
+}
+
+// U5c: scoped key with rate_limit_per_min:2 → 3rd request in the same minute returns 429.
+// Use a freshly created key so prior tests do not consume this key's bucket in the singleton.
+TEST_F(ApiFixture, ScopedKeyRateLimited) {
+    const std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(admin().Post(base.c_str(),
+        nlohmann::json{{"name","rate_coll"},{"kind","json"}}.dump(), "application/json")->status, 201);
+
+    // Create the key via the API so it gets a fresh id (never seen by the rate limiter singleton).
+    const nlohmann::json scopeBody = {
+        {"rules", {{{"collection","rate_coll"},{"ops",{"insert"}}}}},
+        {"rate_limit_per_min", 2}
+    };
+    auto mkRes = admin().Post("/api/v1/keys",
+        nlohmann::json{{"label","rate-test"},{"projects",{project_}},{"admin",false},
+                       {"scope",scopeBody}}.dump(), "application/json");
+    ASSERT_TRUE(mkRes); ASSERT_EQ(mkRes->status, 201) << mkRes->body;
+    auto mkJson = nlohmann::json::parse(mkRes->body);
+    std::string rkey = mkJson["key"];
+    std::string rid  = mkJson["id"];
+
+    const std::string jDoc = nlohmann::json{{"data",{{"z",3}}}}.dump();
+    const std::string ct   = "application/json";
+    const std::string path = base + "/rate_coll/documents";
+
+    // 1st request → 201
+    auto r1 = request("POST", path, jDoc, ct, rkey);
+    ASSERT_TRUE(r1);
+    EXPECT_EQ(r1->status, 201) << "1st request expected 201; body: " << r1->body;
+
+    // 2nd request → 201
+    auto r2 = request("POST", path, jDoc, ct, rkey);
+    ASSERT_TRUE(r2);
+    EXPECT_EQ(r2->status, 201) << "2nd request expected 201; body: " << r2->body;
+
+    // 3rd request within same minute → 429
+    auto r3 = request("POST", path, jDoc, ct, rkey);
+    ASSERT_TRUE(r3);
+    EXPECT_EQ(r3->status, 429) << "3rd request expected 429 (rate limited); body: " << r3->body;
+    // The Retry-After header must be present
+    EXPECT_FALSE(r3->get_header_value("Retry-After").empty()) << "429 must include Retry-After header";
+
+    // Cleanup
+    admin().Delete(("/api/v1/keys/" + rid).c_str());
+    admin().Delete((base + "/rate_coll").c_str());
+}
+
+// U5d: the fixture's normal UNSCOPED key still does full CRUD on documents (regression guard).
+TEST_F(ApiFixture, LegacyKeyUnaffected) {
+    const std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(admin().Post(base.c_str(),
+        nlohmann::json{{"name","legacy_coll"},{"kind","json"}}.dump(), "application/json")->status, 201);
+
+    const std::string docs = base + "/legacy_coll/documents";
+
+    // Insert
+    auto ins = admin().Post(docs.c_str(),
+        nlohmann::json{{"data",{{"val",42}}}}.dump(), "application/json");
+    ASSERT_TRUE(ins); EXPECT_EQ(ins->status, 201) << ins->body;
+    std::string docId = nlohmann::json::parse(ins->body)["id"];
+
+    // List
+    auto list = admin().Get(docs.c_str());
+    ASSERT_TRUE(list); EXPECT_EQ(list->status, 200) << list->body;
+    EXPECT_GE(nlohmann::json::parse(list->body)["count"].get<int>(), 1);
+
+    // Delete
+    auto del = admin().Delete((docs + "/" + docId).c_str());
+    ASSERT_TRUE(del); EXPECT_EQ(del->status, 200) << del->body;
+
+    admin().Delete((base + "/legacy_coll").c_str());
+}
+
 // U4: admins can create/patch keys with a scope, validated.
 TEST_F(ApiFixture, CreateScopedKeyAndReject) {
     auto c = admin();