Просмотр исходного кода

feat(api): 扩展只读密钥访问权限以支持更多端点 (#704)

- 为多个API端点添加allowReadOnlyAccess标志,允许只读密钥访问
- 受影响的端点包括:getUsers、getUserLimitUsage、getUserStatistics、getUsageLogs、getOverviewData、getActiveSessions
- 更新现有测试以验证只读密钥可访问这些端点
- 新增集成测试全面验证只读访问权限和数据隔离
- 修复Issue #687:只读密钥现在可以访问用户数据相关端点,但仅限查看自身数据

close ding113/claude-code-hub #687
AptS:1547 1 неделя назад
Родитель
Сommit
2900a81dd9

+ 6 - 0
src/app/api/actions/[...route]/route.ts

@@ -96,6 +96,7 @@ const { route: getUsersRoute, handler: getUsersHandler } = createActionRoute(
     description: "获取用户列表 (管理员获取所有用户,普通用户仅获取自己)",
     summary: "获取用户列表",
     tags: ["用户管理"],
+    allowReadOnlyAccess: true,
   }
 );
 app.openapi(getUsersRoute, getUsersHandler);
@@ -231,6 +232,7 @@ const { route: getUserLimitUsageRoute, handler: getUserLimitUsageHandler } = cre
     description: "获取用户限额使用情况",
     summary: "获取用户限额使用情况",
     tags: ["用户管理"],
+    allowReadOnlyAccess: true,
   }
 );
 app.openapi(getUserLimitUsageRoute, getUserLimitUsageHandler);
@@ -783,6 +785,7 @@ const { route: getUserStatisticsRoute, handler: getUserStatisticsHandler } = cre
     description: "获取用户统计数据",
     summary: "根据时间范围获取使用统计 (管理员看所有,用户看自己)",
     tags: ["统计分析"],
+    allowReadOnlyAccess: true,
   }
 );
 app.openapi(getUserStatisticsRoute, getUserStatisticsHandler);
@@ -813,6 +816,7 @@ const { route: getUsageLogsRoute, handler: getUsageLogsHandler } = createActionR
     description: "获取使用日志",
     summary: "查询使用日志,支持多种过滤条件",
     tags: ["使用日志"],
+    allowReadOnlyAccess: true,
   }
 );
 app.openapi(getUsageLogsRoute, getUsageLogsHandler);
@@ -1092,6 +1096,7 @@ const { route: getOverviewDataRoute, handler: getOverviewDataHandler } = createA
     description: "获取首页概览数据",
     summary: "包含并发数、今日统计、活跃用户等",
     tags: ["概览"],
+    allowReadOnlyAccess: true,
   }
 );
 app.openapi(getOverviewDataRoute, getOverviewDataHandler);
@@ -1210,6 +1215,7 @@ const { route: getActiveSessionsRoute, handler: getActiveSessionsHandler } = cre
     description: "获取活跃 Session 列表",
     summary: "获取活跃 Session 列表",
     tags: ["Session 管理"],
+    allowReadOnlyAccess: true,
   }
 );
 app.openapi(getActiveSessionsRoute, getActiveSessionsHandler);

+ 19 - 9
tests/api/my-usage-readonly.test.ts

@@ -163,7 +163,7 @@ describe("my-usage API:只读 Key 自助查询", () => {
     expect(json).toMatchObject({ ok: false });
   });
 
-  test("只读 Key:允许访问 my-usage 端点,但禁止访问其他 WebUI API", async () => {
+  test("只读 Key:允许访问 my-usage 端点和其他 allowReadOnlyAccess 端点", async () => {
     const unique = `my-usage-readonly-${Date.now()}-${Math.random().toString(16).slice(2)}`;
     const user = await createTestUser(`Test ${unique}`);
     createdUserIds.push(user.id);
@@ -197,15 +197,20 @@ describe("my-usage API:只读 Key 自助查询", () => {
     expect(quota.response.status).toBe(200);
     expect(quota.json).toMatchObject({ ok: true });
 
-    // 禁止访问需要 WebUI 权限的 actions(默认 validateKey 会拒绝 canLoginWebUi=false 的 key)
+    // Issue #687 fix: getUsers 和 getUsageLogs 现在也支持 allowReadOnlyAccess
+    // 普通用户只能看到自己的数据
     const usersApi = await callActionsRoute({
       method: "POST",
       pathname: "/api/actions/users/getUsers",
       authToken: readonlyKey.key,
       body: {},
     });
-    expect(usersApi.response.status).toBe(401);
-    expect(usersApi.json).toMatchObject({ ok: false });
+    expect(usersApi.response.status).toBe(200);
+    expect(usersApi.json).toMatchObject({ ok: true });
+    // 验证只返回自己的数据
+    const usersData = (usersApi.json as { ok: boolean; data: Array<{ id: number }> }).data;
+    expect(usersData.length).toBe(1);
+    expect(usersData[0].id).toBe(user.id);
 
     const usageLogsApi = await callActionsRoute({
       method: "POST",
@@ -213,11 +218,11 @@ describe("my-usage API:只读 Key 自助查询", () => {
       authToken: readonlyKey.key,
       body: {},
     });
-    expect(usageLogsApi.response.status).toBe(401);
-    expect(usageLogsApi.json).toMatchObject({ ok: false });
+    expect(usageLogsApi.response.status).toBe(200);
+    expect(usageLogsApi.json).toMatchObject({ ok: true });
   });
 
-  test("Bearer-only:仅 Authorization 也应可查询 my-usage,但仍禁止访问 WebUI API", async () => {
+  test("Bearer-only:仅 Authorization 也应可查询 my-usage 和其他 allowReadOnlyAccess 端点", async () => {
     const unique = `my-usage-bearer-${Date.now()}-${Math.random().toString(16).slice(2)}`;
     const user = await createTestUser(`Test ${unique}`);
     createdUserIds.push(user.id);
@@ -255,14 +260,19 @@ describe("my-usage API:只读 Key 自助查询", () => {
     expect(stats.json).toMatchObject({ ok: true });
     expect((stats.json as any).data.calls).toBe(1);
 
+    // Issue #687 fix: getUsers 现在也支持 allowReadOnlyAccess
     const usersApi = await callActionsRoute({
       method: "POST",
       pathname: "/api/actions/users/getUsers",
       headers: { Authorization: currentAuthorization },
       body: {},
     });
-    expect(usersApi.response.status).toBe(401);
-    expect(usersApi.json).toMatchObject({ ok: false });
+    expect(usersApi.response.status).toBe(200);
+    expect(usersApi.json).toMatchObject({ ok: true });
+    // 验证只返回自己的数据
+    const usersData = (usersApi.json as { ok: boolean; data: Array<{ id: number }> }).data;
+    expect(usersData.length).toBe(1);
+    expect(usersData[0].id).toBe(user.id);
   });
 
   test("今日统计:应与 message_request 数据一致,并排除 warmup 与其他 Key 数据", async () => {

+ 358 - 0
tests/integration/readonly-access-endpoints.test.ts

@@ -0,0 +1,358 @@
+import { afterAll, beforeEach, describe, expect, test, vi } from "vitest";
+import { inArray } from "drizzle-orm";
+import { db } from "@/drizzle/db";
+import { keys, users } from "@/drizzle/schema";
+import { callActionsRoute } from "../test-utils";
+
+/**
+ * Issue #687: allowReadOnlyAccess endpoints test
+ *
+ * Test that endpoints with allowReadOnlyAccess: true can be accessed
+ * by API keys with canLoginWebUi=false (readonly keys).
+ *
+ * These endpoints have business logic that already supports regular users
+ * (returning only their own data), so they should allow readonly access.
+ */
+
+let currentAuthToken: string | undefined;
+let currentAuthorization: string | undefined;
+
+vi.mock("next/headers", () => ({
+  cookies: () => ({
+    get: (name: string) => {
+      if (name !== "auth-token") return undefined;
+      return currentAuthToken ? { value: currentAuthToken } : undefined;
+    },
+    set: vi.fn(),
+    delete: vi.fn(),
+    has: (name: string) => name === "auth-token" && Boolean(currentAuthToken),
+  }),
+  headers: () => ({
+    get: (name: string) => {
+      if (name.toLowerCase() !== "authorization") return null;
+      return currentAuthorization ?? null;
+    },
+  }),
+}));
+
+type TestKey = { id: number; userId: number; key: string; name: string };
+type TestUser = { id: number; name: string };
+
+async function createTestUser(name: string): Promise<TestUser> {
+  const [row] = await db
+    .insert(users)
+    .values({ name })
+    .returning({ id: users.id, name: users.name });
+
+  if (!row) {
+    throw new Error("Failed to create test user");
+  }
+  return row;
+}
+
+async function createTestKey(params: {
+  userId: number;
+  key: string;
+  name: string;
+  canLoginWebUi: boolean;
+}): Promise<TestKey> {
+  const [row] = await db
+    .insert(keys)
+    .values({
+      userId: params.userId,
+      key: params.key,
+      name: params.name,
+      canLoginWebUi: params.canLoginWebUi,
+      dailyResetMode: "rolling",
+      dailyResetTime: "00:00",
+    })
+    .returning({ id: keys.id, userId: keys.userId, key: keys.key, name: keys.name });
+
+  if (!row) {
+    throw new Error("Failed to create test key");
+  }
+  return row;
+}
+
+describe("allowReadOnlyAccess endpoints (Issue #687)", () => {
+  const createdUserIds: number[] = [];
+  const createdKeyIds: number[] = [];
+
+  afterAll(async () => {
+    const now = new Date();
+    if (createdKeyIds.length > 0) {
+      await db
+        .update(keys)
+        .set({ deletedAt: now, updatedAt: now })
+        .where(inArray(keys.id, createdKeyIds));
+    }
+    if (createdUserIds.length > 0) {
+      await db
+        .update(users)
+        .set({ deletedAt: now, updatedAt: now })
+        .where(inArray(users.id, createdUserIds));
+    }
+  });
+
+  beforeEach(() => {
+    currentAuthToken = undefined;
+    currentAuthorization = undefined;
+  });
+
+  test("readonly key (canLoginWebUi=false) can access getUsers endpoint", async () => {
+    const unique = `readonly-getusers-${Date.now()}-${Math.random().toString(16).slice(2)}`;
+    const user = await createTestUser(`Test ${unique}`);
+    createdUserIds.push(user.id);
+
+    const readonlyKey = await createTestKey({
+      userId: user.id,
+      key: `test-readonly-key-${unique}`,
+      name: `readonly-${unique}`,
+      canLoginWebUi: false,
+    });
+    createdKeyIds.push(readonlyKey.id);
+
+    currentAuthToken = readonlyKey.key;
+
+    const { response, json } = await callActionsRoute({
+      method: "POST",
+      pathname: "/api/actions/users/getUsers",
+      authToken: readonlyKey.key,
+      body: {},
+    });
+
+    expect(response.status).toBe(200);
+    expect(json).toMatchObject({ ok: true });
+
+    // Regular user should only see their own data
+    const data = (json as { ok: boolean; data: Array<{ id: number }> }).data;
+    expect(data.length).toBe(1);
+    expect(data[0].id).toBe(user.id);
+  });
+
+  test("readonly key can access getUserLimitUsage for own user", async () => {
+    const unique = `readonly-userlimit-${Date.now()}-${Math.random().toString(16).slice(2)}`;
+    const user = await createTestUser(`Test ${unique}`);
+    createdUserIds.push(user.id);
+
+    const readonlyKey = await createTestKey({
+      userId: user.id,
+      key: `test-readonly-key-${unique}`,
+      name: `readonly-${unique}`,
+      canLoginWebUi: false,
+    });
+    createdKeyIds.push(readonlyKey.id);
+
+    currentAuthToken = readonlyKey.key;
+
+    const { response, json } = await callActionsRoute({
+      method: "POST",
+      pathname: "/api/actions/users/getUserLimitUsage",
+      authToken: readonlyKey.key,
+      body: { userId: user.id },
+    });
+
+    expect(response.status).toBe(200);
+    expect(json).toMatchObject({ ok: true });
+  });
+
+  // Note: getKeys and getKeyLimitUsage are intentionally NOT allowReadOnlyAccess
+  // because a readonly key should not be able to see other keys under the same user
+
+  test("readonly key can access getUserStatistics", async () => {
+    const unique = `readonly-stats-${Date.now()}-${Math.random().toString(16).slice(2)}`;
+    const user = await createTestUser(`Test ${unique}`);
+    createdUserIds.push(user.id);
+
+    const readonlyKey = await createTestKey({
+      userId: user.id,
+      key: `test-readonly-key-${unique}`,
+      name: `readonly-${unique}`,
+      canLoginWebUi: false,
+    });
+    createdKeyIds.push(readonlyKey.id);
+
+    currentAuthToken = readonlyKey.key;
+
+    const { response, json } = await callActionsRoute({
+      method: "POST",
+      pathname: "/api/actions/statistics/getUserStatistics",
+      authToken: readonlyKey.key,
+      body: { timeRange: "today" },
+    });
+
+    expect(response.status).toBe(200);
+    expect(json).toMatchObject({ ok: true });
+  });
+
+  test("readonly key can access getUsageLogs", async () => {
+    const unique = `readonly-logs-${Date.now()}-${Math.random().toString(16).slice(2)}`;
+    const user = await createTestUser(`Test ${unique}`);
+    createdUserIds.push(user.id);
+
+    const readonlyKey = await createTestKey({
+      userId: user.id,
+      key: `test-readonly-key-${unique}`,
+      name: `readonly-${unique}`,
+      canLoginWebUi: false,
+    });
+    createdKeyIds.push(readonlyKey.id);
+
+    currentAuthToken = readonlyKey.key;
+
+    const { response, json } = await callActionsRoute({
+      method: "POST",
+      pathname: "/api/actions/usage-logs/getUsageLogs",
+      authToken: readonlyKey.key,
+      body: {},
+    });
+
+    expect(response.status).toBe(200);
+    expect(json).toMatchObject({ ok: true });
+  });
+
+  test("readonly key can access getOverviewData", async () => {
+    const unique = `readonly-overview-${Date.now()}-${Math.random().toString(16).slice(2)}`;
+    const user = await createTestUser(`Test ${unique}`);
+    createdUserIds.push(user.id);
+
+    const readonlyKey = await createTestKey({
+      userId: user.id,
+      key: `test-readonly-key-${unique}`,
+      name: `readonly-${unique}`,
+      canLoginWebUi: false,
+    });
+    createdKeyIds.push(readonlyKey.id);
+
+    currentAuthToken = readonlyKey.key;
+
+    const { response, json } = await callActionsRoute({
+      method: "POST",
+      pathname: "/api/actions/overview/getOverviewData",
+      authToken: readonlyKey.key,
+      body: {},
+    });
+
+    expect(response.status).toBe(200);
+    expect(json).toMatchObject({ ok: true });
+  });
+
+  test("readonly key can access getActiveSessions", async () => {
+    const unique = `readonly-sessions-${Date.now()}-${Math.random().toString(16).slice(2)}`;
+    const user = await createTestUser(`Test ${unique}`);
+    createdUserIds.push(user.id);
+
+    const readonlyKey = await createTestKey({
+      userId: user.id,
+      key: `test-readonly-key-${unique}`,
+      name: `readonly-${unique}`,
+      canLoginWebUi: false,
+    });
+    createdKeyIds.push(readonlyKey.id);
+
+    currentAuthToken = readonlyKey.key;
+
+    const { response, json } = await callActionsRoute({
+      method: "POST",
+      pathname: "/api/actions/active-sessions/getActiveSessions",
+      authToken: readonlyKey.key,
+      body: {},
+    });
+
+    expect(response.status).toBe(200);
+    expect(json).toMatchObject({ ok: true });
+  });
+
+  test("readonly key cannot access other user's data", async () => {
+    const unique = `readonly-isolation-${Date.now()}-${Math.random().toString(16).slice(2)}`;
+
+    // Create user A with readonly key
+    const userA = await createTestUser(`Test ${unique}-A`);
+    createdUserIds.push(userA.id);
+    const keyA = await createTestKey({
+      userId: userA.id,
+      key: `test-readonly-key-A-${unique}`,
+      name: `readonly-A-${unique}`,
+      canLoginWebUi: false,
+    });
+    createdKeyIds.push(keyA.id);
+
+    // Create user B
+    const userB = await createTestUser(`Test ${unique}-B`);
+    createdUserIds.push(userB.id);
+    const keyB = await createTestKey({
+      userId: userB.id,
+      key: `test-readonly-key-B-${unique}`,
+      name: `readonly-B-${unique}`,
+      canLoginWebUi: false,
+    });
+    createdKeyIds.push(keyB.id);
+
+    currentAuthToken = keyA.key;
+
+    // User A trying to access User B's limit usage should fail
+    const { response, json } = await callActionsRoute({
+      method: "POST",
+      pathname: "/api/actions/users/getUserLimitUsage",
+      authToken: keyA.key,
+      body: { userId: userB.id },
+    });
+
+    expect(response.status).toBe(200);
+    expect(json).toMatchObject({ ok: false });
+  });
+
+  test("readonly key cannot access admin-only endpoints", async () => {
+    const unique = `readonly-admin-${Date.now()}-${Math.random().toString(16).slice(2)}`;
+    const user = await createTestUser(`Test ${unique}`);
+    createdUserIds.push(user.id);
+
+    const readonlyKey = await createTestKey({
+      userId: user.id,
+      key: `test-readonly-key-${unique}`,
+      name: `readonly-${unique}`,
+      canLoginWebUi: false,
+    });
+    createdKeyIds.push(readonlyKey.id);
+
+    currentAuthToken = readonlyKey.key;
+
+    // Sensitive words management is admin-only
+    const { response, json } = await callActionsRoute({
+      method: "POST",
+      pathname: "/api/actions/sensitive-words/listSensitiveWords",
+      authToken: readonlyKey.key,
+      body: {},
+    });
+
+    // Should be rejected (either 401 or 403)
+    expect([401, 403]).toContain(response.status);
+    expect(json).toMatchObject({ ok: false });
+  });
+
+  test("Bearer token authentication works for readonly endpoints", async () => {
+    const unique = `readonly-bearer-${Date.now()}-${Math.random().toString(16).slice(2)}`;
+    const user = await createTestUser(`Test ${unique}`);
+    createdUserIds.push(user.id);
+
+    const readonlyKey = await createTestKey({
+      userId: user.id,
+      key: `test-readonly-key-${unique}`,
+      name: `readonly-${unique}`,
+      canLoginWebUi: false,
+    });
+    createdKeyIds.push(readonlyKey.id);
+
+    currentAuthorization = `Bearer ${readonlyKey.key}`;
+
+    const { response, json } = await callActionsRoute({
+      method: "POST",
+      pathname: "/api/actions/users/getUsers",
+      headers: { Authorization: currentAuthorization },
+      body: {},
+    });
+
+    expect(response.status).toBe(200);
+    expect(json).toMatchObject({ ok: true });
+  });
+});