diff --git a/backend/app/mcp_api/token_routes.py b/backend/app/mcp_api/token_routes.py index 5ed96d8..af40d47 100644 --- a/backend/app/mcp_api/token_routes.py +++ b/backend/app/mcp_api/token_routes.py @@ -20,6 +20,11 @@ class TokenInput(BaseModel): scopes: list[str] = Field(default_factory=lambda: ["research:read"], max_length=len(SCOPES)) +class TokenPermissionsInput(BaseModel): + model_config = ConfigDict(extra="forbid") + scopes: list[str] = Field(max_length=len(SCOPES)) + + def token_output(row, account): """Return public metadata only, including whether the current binding is usable.""" def timestamp(value): @@ -69,6 +74,26 @@ async def issue_token(body: TokenInput, request: Request): return {**result, "token": secret} +@router.patch("/{token_id}") +async def update_token_permissions(token_id: str, body: TokenPermissionsInput, request: Request): + """Update owned active-token scopes without rotating or revealing its secret.""" + scopes = set(body.scopes) + if not scopes <= SCOPES or "research:read" not in scopes: + raise HTTPException(422, "权限无效;所有令牌必须包含 research:read") + async with request.app.state.sessions.begin() as db: + row = await db.scalar(select(MCPToken).where( + MCPToken.id == token_id, MCPToken.admin_id == 1, MCPToken.account_id == 1, + ).with_for_update()) + if not row: + raise HTTPException(404, "MCP Key 不存在") + account = await db.get(Account, 1) + if token_output(row, account)["status"] != "active": + raise HTTPException(409, "仅有效的 MCP Key 可以编辑权限") + row.scopes = sorted(scopes) + result = token_output(row, account) + return result + + @router.post("/{token_id}/revoke") async def revoke_token(token_id: str, request: Request): async with request.app.state.sessions.begin() as db: diff --git a/backend/tests/test_mcp_tokens.py b/backend/tests/test_mcp_tokens.py index 0b7a021..f3769e6 100644 --- a/backend/tests/test_mcp_tokens.py +++ b/backend/tests/test_mcp_tokens.py @@ -76,3 +76,37 @@ async def test_token_browser_security_and_validation(app, logged_in): row = await db.scalar(select(MCPToken)) row.expires_at = now() - timedelta(days=1) assert (await client.get("/api/v1/mcp-tokens")).json()["items"][0]["status"] == "expired" + + +async def test_edit_token_permissions(app, logged_in): + async with app.state.sessions.begin() as db: + (await db.get(Account, 1)).wq_user_id = "synthetic-user" + token = (await logged_in.post("/api/v1/mcp-tokens", json={"name": "editable"})).json() + path = f'/api/v1/mcp-tokens/{token["id"]}' + for scopes in [["research:read", "research:write", "backtests:execute"], ["research:read"]]: + response = await logged_in.patch(path, json={"scopes": scopes}) + assert response.status_code == 200 + result = response.json() + assert result["scopes"] == sorted(scopes) + assert result["expires_at"] == token["expires_at"] + assert result["name"] == token["name"] + assert "token" not in result and "token_hash" not in result + async with app.state.sessions() as db: + assert (await authenticate(db, token["token"])).scopes == frozenset(scopes) + for body in [{"scopes": []}, {"scopes": ["admin", "research:read"]}, {"scopes": ["research:write"]}, {"scopes": ["research:read"], "admin_id": 2}, {}]: + assert (await logged_in.patch(path, json=body)).status_code == 422 + body = {"scopes": ["research:read", "research:write"]} + assert (await logged_in.patch(path, json=body, headers={"X-WQ-Request": ""})).status_code == 403 + assert (await logged_in.patch(path, json=body, headers={"Origin": "https://evil.test"})).status_code == 403 + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as outsider: + assert (await outsider.patch(path, json=body, headers={"Authorization": f'Bearer {token["token"]}', "X-WQ-Request": "1"})).status_code == 401 + assert (await logged_in.patch("/api/v1/mcp-tokens/missing", json=body)).status_code == 404 + async with app.state.sessions.begin() as db: + (await db.get(Account, 1)).wq_user_id = "changed-user" + assert (await logged_in.patch(path, json=body)).status_code == 409 + async with app.state.sessions.begin() as db: + (await db.get(Account, 1)).wq_user_id = "synthetic-user" + (await db.get(MCPToken, token["id"])).expires_at = now() - timedelta(days=1) + assert (await logged_in.patch(path, json=body)).status_code == 409 + await logged_in.post(path + "/revoke") + assert (await logged_in.patch(path, json=body)).status_code == 409 diff --git a/frontend/src/pages/MCPKeysPage.tsx b/frontend/src/pages/MCPKeysPage.tsx index ce8611a..80150a7 100644 --- a/frontend/src/pages/MCPKeysPage.tsx +++ b/frontend/src/pages/MCPKeysPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { Banner, Button, Input, Table, Toast } from "@douyinfe/semi-ui-19"; -import { api, formatTime, post } from "../api"; +import { api, formatTime, patch, post } from "../api"; import "./mcp-keys.css"; type Token = { @@ -22,8 +22,16 @@ type TokenPage = { }; const scopes = [ ["research:read", "读取研究数据", "查询目录、历史、运行与结果"], - ["research:refresh", "刷新研究数据", "更新缓存、检查自相关及恢复 WorldQuant 认证"], - ["research:write", "保存研究模板", "保存大模型总结的模板及来源,供后续批量回测"], + [ + "research:refresh", + "刷新研究数据", + "更新缓存、检查自相关及恢复 WorldQuant 认证", + ], + [ + "research:write", + "保存研究模板", + "保存大模型总结的模板及来源,供后续批量回测", + ], ["backtests:execute", "执行回测", "提交新的回测批次"], ["backtests:control", "控制回测", "暂停、继续、停止与恢复采集"], ] as const; @@ -47,6 +55,9 @@ export function MCPKeysPage() { const [created, setCreated] = useState<(Token & { token: string }) | null>( null, ); + const [editing, setEditing] = useState(null); + const [editPermissions, setEditPermissions] = useState([]); + const [editError, setEditError] = useState(""); const [revoking, setRevoking] = useState(null); useEffect(() => { let active = true; @@ -89,6 +100,24 @@ export function MCPKeysPage() { setBusy(false); } } + async function savePermissions(event: React.FormEvent) { + event.preventDefault(); + if (!editing || busy) return; + setBusy(true); + setEditError(""); + try { + await patch(`/mcp-tokens/${editing.id}`, { + scopes: editPermissions, + }); + setEditing(null); + setVersion((v) => v + 1); + Toast.success("MCP Key 权限已更新"); + } catch (e) { + setEditError((e as Error).message); + } finally { + setBusy(false); + } + } async function revoke(id: string) { setBusy(true); try { @@ -238,6 +267,53 @@ export function MCPKeysPage() { )} + {editing && ( +
void savePermissions(event)} + > +

编辑权限:{editing.name}

+

保存后新请求使用更新后的权限,无需更换客户端 Key。

+ {editError && } +
+ 访问权限 +
+ {scopes.map(([value, label, description]) => ( + + ))} +
+
+
+ + +
+ + )}
) : ( - +
+ + +
), }, ]}