This commit is contained in:
@@ -20,6 +20,11 @@ class TokenInput(BaseModel):
|
|||||||
scopes: list[str] = Field(default_factory=lambda: ["research:read"], max_length=len(SCOPES))
|
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):
|
def token_output(row, account):
|
||||||
"""Return public metadata only, including whether the current binding is usable."""
|
"""Return public metadata only, including whether the current binding is usable."""
|
||||||
def timestamp(value):
|
def timestamp(value):
|
||||||
@@ -69,6 +74,26 @@ async def issue_token(body: TokenInput, request: Request):
|
|||||||
return {**result, "token": secret}
|
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")
|
@router.post("/{token_id}/revoke")
|
||||||
async def revoke_token(token_id: str, request: Request):
|
async def revoke_token(token_id: str, request: Request):
|
||||||
async with request.app.state.sessions.begin() as db:
|
async with request.app.state.sessions.begin() as db:
|
||||||
|
|||||||
@@ -76,3 +76,37 @@ async def test_token_browser_security_and_validation(app, logged_in):
|
|||||||
row = await db.scalar(select(MCPToken))
|
row = await db.scalar(select(MCPToken))
|
||||||
row.expires_at = now() - timedelta(days=1)
|
row.expires_at = now() - timedelta(days=1)
|
||||||
assert (await client.get("/api/v1/mcp-tokens")).json()["items"][0]["status"] == "expired"
|
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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Banner, Button, Input, Table, Toast } from "@douyinfe/semi-ui-19";
|
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";
|
import "./mcp-keys.css";
|
||||||
|
|
||||||
type Token = {
|
type Token = {
|
||||||
@@ -22,8 +22,16 @@ type TokenPage = {
|
|||||||
};
|
};
|
||||||
const scopes = [
|
const scopes = [
|
||||||
["research:read", "读取研究数据", "查询目录、历史、运行与结果"],
|
["research:read", "读取研究数据", "查询目录、历史、运行与结果"],
|
||||||
["research:refresh", "刷新研究数据", "更新缓存、检查自相关及恢复 WorldQuant 认证"],
|
[
|
||||||
["research:write", "保存研究模板", "保存大模型总结的模板及来源,供后续批量回测"],
|
"research:refresh",
|
||||||
|
"刷新研究数据",
|
||||||
|
"更新缓存、检查自相关及恢复 WorldQuant 认证",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"research:write",
|
||||||
|
"保存研究模板",
|
||||||
|
"保存大模型总结的模板及来源,供后续批量回测",
|
||||||
|
],
|
||||||
["backtests:execute", "执行回测", "提交新的回测批次"],
|
["backtests:execute", "执行回测", "提交新的回测批次"],
|
||||||
["backtests:control", "控制回测", "暂停、继续、停止与恢复采集"],
|
["backtests:control", "控制回测", "暂停、继续、停止与恢复采集"],
|
||||||
] as const;
|
] as const;
|
||||||
@@ -47,6 +55,9 @@ export function MCPKeysPage() {
|
|||||||
const [created, setCreated] = useState<(Token & { token: string }) | null>(
|
const [created, setCreated] = useState<(Token & { token: string }) | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const [editing, setEditing] = useState<Token | null>(null);
|
||||||
|
const [editPermissions, setEditPermissions] = useState<string[]>([]);
|
||||||
|
const [editError, setEditError] = useState("");
|
||||||
const [revoking, setRevoking] = useState<string | null>(null);
|
const [revoking, setRevoking] = useState<string | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
@@ -89,6 +100,24 @@ export function MCPKeysPage() {
|
|||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async function savePermissions(event: React.FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!editing || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setEditError("");
|
||||||
|
try {
|
||||||
|
await patch<Token>(`/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) {
|
async function revoke(id: string) {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
@@ -238,6 +267,53 @@ export function MCPKeysPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
{editing && (
|
||||||
|
<form
|
||||||
|
className="mcp-key-card"
|
||||||
|
aria-label="编辑 Key 权限"
|
||||||
|
onSubmit={(event) => void savePermissions(event)}
|
||||||
|
>
|
||||||
|
<h2>编辑权限:{editing.name}</h2>
|
||||||
|
<p>保存后新请求使用更新后的权限,无需更换客户端 Key。</p>
|
||||||
|
{editError && <Banner type="danger" description={editError} />}
|
||||||
|
<fieldset>
|
||||||
|
<legend>访问权限</legend>
|
||||||
|
<div className="mcp-key-permissions">
|
||||||
|
{scopes.map(([value, label, description]) => (
|
||||||
|
<label key={value}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editPermissions.includes(value)}
|
||||||
|
disabled={value === "research:read" || busy}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditPermissions((old) =>
|
||||||
|
e.target.checked
|
||||||
|
? [...old, value]
|
||||||
|
: old.filter((item) => item !== value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>{label}</strong>
|
||||||
|
<small>
|
||||||
|
{description}
|
||||||
|
{value === "research:read" ? "(必选)" : ""}
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<div className="mcp-key-actions">
|
||||||
|
<Button htmlType="submit" theme="solid" loading={busy}>
|
||||||
|
保存权限
|
||||||
|
</Button>
|
||||||
|
<Button disabled={busy} onClick={() => setEditing(null)}>
|
||||||
|
取消编辑
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
<section
|
<section
|
||||||
className="mcp-key-card"
|
className="mcp-key-card"
|
||||||
aria-label="Key 列表"
|
aria-label="Key 列表"
|
||||||
@@ -317,13 +393,29 @@ export function MCPKeysPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<div>
|
||||||
type="danger"
|
<Button
|
||||||
disabled={busy}
|
disabled={busy || item.status !== "active"}
|
||||||
onClick={() => setRevoking(item.id)}
|
onClick={() => {
|
||||||
>
|
setEditing(item);
|
||||||
撤销
|
setEditPermissions([...item.scopes]);
|
||||||
</Button>
|
setEditError("");
|
||||||
|
setRevoking(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
编辑权限
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="danger"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(null);
|
||||||
|
setRevoking(item.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
撤销
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|||||||
Reference in New Issue
Block a user