From 6bc738d35984df2c0435e69b5fb6742e96a74c27 Mon Sep 17 00:00:00 2001 From: "jif2.zhang" Date: Mon, 21 Sep 2026 09:40:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Token=20=E6=94=AF=E6=8C=81=E5=A4=9A?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E5=85=B3=E8=81=94=E4=B8=94=E5=85=A8=E9=83=A8?= =?UTF-8?q?=E4=B8=BA=20MCP=20=E4=BB=A4=E7=89=8C=EF=BC=9B=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=E5=85=A8=E9=87=8F=E5=AE=A1=E8=AE=A1=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=8C=89=E9=A1=B9=E7=9B=AE/=E9=94=AE?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/admin.html | 108 +++++++++------ public/index.html | 31 ++++- public/js/api.js | 5 + public/js/app.js | 128 ++++++++++++++---- src/mcp/index.js | 53 ++++---- src/mcp/tools.js | 61 ++++++++- src/persistence/duckdbAdapter.js | 66 ++++++++++ src/routes/configs.js | 218 ++++++++++++++++++++----------- src/services/auditService.js | 63 +++++++++ src/services/tokenService.js | 141 +++++++++++++++----- test/audit.test.js | 67 ++++++++++ test/mcp.test.js | 46 ++++++- test/tokenService.test.js | 95 ++++++++------ 13 files changed, 833 insertions(+), 249 deletions(-) create mode 100644 src/services/auditService.js create mode 100644 test/audit.test.js diff --git a/public/admin.html b/public/admin.html index 5ab01fc..567d362 100644 --- a/public/admin.html +++ b/public/admin.html @@ -117,7 +117,7 @@ - + @@ -211,8 +211,13 @@
- - + + +
+
@@ -229,13 +234,7 @@
-
- -
-

生成后 Token 明文仅展示一次,请及时复制并妥善保管。

+

所有 Token 均可作为 MCP 访问令牌用于 /mcp 端点鉴权。

'; + tbody.innerHTML = ''; return; } tbody.innerHTML = tokens.map(function (t) { @@ -503,11 +544,8 @@ var masked = t.value.substring(0, 6) + "••••••••••••"; return "" + "" + - "" + + "" + '" + - '" + '" + "" + ' - `; tbody.appendChild(tr); @@ -670,9 +671,79 @@ async function copyAddress() { } } -/** - * 绑定所有事件监听 - * @returns {void} +/** + * 打开操作记录弹窗 + * @param {string|null} key - 为空时查当前项目全部记录;否则查指定配置键的记录 + * @returns {Promise} + */ +async function openAudit(key) { + if (!state.currentProject) { + toast("请先选择项目", "error"); + return; + } + document.getElementById("audit-title").textContent = key + ? `(${state.currentProject.name} · ${key})` + : `(${state.currentProject.name})`; + openModal("modal-audit"); + try { + const logs = await API.getAudit(state.currentProject.id, key || ""); + renderAudit(logs); + } catch (err) { + toast("加载操作记录失败: " + err.message, "error"); + } +} + +/** + * 渲染操作记录表格 + * @param {Array} logs - 操作记录列表 + * @returns {void} + */ +function renderAudit(logs) { + const tbody = document.getElementById("audit-tbody"); + if (!logs || !logs.length) { + tbody.innerHTML = ''; + return; + } + const ACTION_NAMES = { read: "读取", create: "新增", update: "更新", delete: "删除" }; + const SOURCE_NAMES = { web: "网页", mcp: "MCP" }; + tbody.innerHTML = logs + .map((l) => { + const act = ACTION_NAMES[l.action] || l.action; + const src = SOURCE_NAMES[l.source] || l.source; + const val = l.value !== undefined && l.value !== null && l.value !== "" ? String(l.value) : "—"; + return ( + "" + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + "" + ); + }) + .join(""); +} + +/** + * 格式化时间:YYYY-MM-DD HH:mm:ss + * @param {string} iso - ISO 时间 + * @returns {string} + */ +function fmtTime(iso) { + if (!iso) return "—"; + const d = new Date(iso); + const pad = (n) => (n < 10 ? "0" + n : n); + return ( + `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` + + `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` + ); +} + +/** + * 绑定所有事件监听 + * @returns {void} */ function bindEvents() { // 项目选择 @@ -784,17 +855,20 @@ function bindEvents() { if (e.key === "Enter") navigateToPath(); }); - // 导出/导入 - document.getElementById("btn-export-config").addEventListener("click", exportConfig); - document.getElementById("btn-import-config").addEventListener("click", () => { - document.getElementById("import-file-input").click(); - }); - document.getElementById("import-file-input").addEventListener("change", (e) => { - if (e.target.files[0]) { - importConfig(e.target.files[0]); - e.target.value = ""; - } - }); +// 导出/导入 + document.getElementById("btn-export-config").addEventListener("click", exportConfig); + document.getElementById("btn-import-config").addEventListener("click", () => { + document.getElementById("import-file-input").click(); + }); + document.getElementById("import-file-input").addEventListener("change", (e) => { + if (e.target.files[0]) { + importConfig(e.target.files[0]); + e.target.value = ""; + } + }); + + // 操作记录 + document.getElementById("btn-audit").addEventListener("click", () => openAudit(null)); // 表格操作(事件委托) document.getElementById("config-tbody").addEventListener("click", (e) => { @@ -805,14 +879,16 @@ function bindEvents() { const config = state.configs.find((c) => c.id === id); if (!config) return; - if (action === "view") { - openConfigModal(config, true); - } else if (action === "edit") { - if (state.mode === "readonly") { - toast("只读模式下不允许编辑", "error"); - return; - } - openConfigModal(config, false); +if (action === "view") { + openConfigModal(config, true); + } else if (action === "edit") { + if (state.mode === "readonly") { + toast("只读模式下不允许编辑", "error"); + return; + } + openConfigModal(config, false); + } else if (action === "audit") { + openAudit(config.key); } else if (action === "delete") { if (state.mode === "readonly") { toast("只读模式下不允许删除", "error"); diff --git a/src/mcp/index.js b/src/mcp/index.js index bc68e9f..a89e37d 100644 --- a/src/mcp/index.js +++ b/src/mcp/index.js @@ -1,9 +1,8 @@ /** * MCP HTTP 传输层 * 以 Streamable HTTP 协议在 /mcp 端点暴露 MCP 服务。 - * 鉴权:基于后台 Token 管理中标记为「MCP 访问」的令牌,要求请求头 Authorization: Bearer 。 + * 鉴权:使用后台 Token 管理中任一生效令牌,要求请求头 Authorization: Bearer 。 */ -const crypto = require("crypto"); const express = require("express"); const { randomUUID } = require("node:crypto"); const { @@ -11,50 +10,39 @@ const { } = require("@modelcontextprotocol/sdk/server/streamableHttp.js"); const { isInitializeRequest } = require("@modelcontextprotocol/sdk/types.js"); const { createMcpServer } = require("./tools"); -const { getMcpTokenValues } = require("../services/tokenService"); +const { resolveActiveToken } = require("../services/tokenService"); +const { getClientIp } = require("../services/auditService"); const router = express.Router(); // sessionId -> transport,用于会话复用 const transports = new Map(); +// sessionId -> ctx(含会话 Token 与请求来源信息,工具审计使用) +const sessionContexts = new Map(); + /** - * 校验请求携带的 Bearer token 是否命中任意一个生效的 MCP 访问令牌 + * 校验请求携带的 Bearer token 是否为生效 Token + * 所有后台 Token 均视为 MCP 访问令牌 * @param {string} [header] - Authorization 请求头 - * @returns {Promise} 是否鉴权通过 + * @returns {Promise} 校验通过返回 Token 对象,否则 null */ -async function isAuthorized(header) { +async function resolveAuthorizedToken(header) { const provided = header && header.startsWith("Bearer ") ? header.slice(7).trim() : ""; - const expectedList = await getMcpTokenValues(); - // 未配置 MCP 令牌时视为未启用鉴权 - if (expectedList.length === 0) { - return true; - } - if (!provided) { - return false; - } - // 与任一生效令牌做常量时间比较,避免时序侧信道 - for (const expected of expectedList) { - if (provided.length === expected.length) { - const a = Buffer.from(provided); - const b = Buffer.from(expected); - if (crypto.timingSafeEqual(a, b)) { - return true; - } - } - } - return false; + return resolveActiveToken(provided); } /** - * 鉴权中间件:token 校验失败返回 401 + * 鉴权中间件:token 校验失败返回 401,通过时在 req 上挂载 mcpToken */ router.use(async (req, res, next) => { - if (!(await isAuthorized(req.headers.authorization))) { + const token = await resolveAuthorizedToken(req.headers.authorization); + if (!token) { return res .status(401) .json({ jsonrpc: "2.0", error: { code: -32001, message: "Unauthorized: invalid or missing token" }, id: null }); } + req.mcpToken = token; next(); }); @@ -70,19 +58,28 @@ async function handleRequest(req, res) { let transport; if (sessionId && transports.get(sessionId)) { transport = transports.get(sessionId); + // 刷新会话上下文:Token 与 IP 以当前请求为准 + const ctx = sessionContexts.get(sessionId); + if (ctx) { + ctx.token = req.mcpToken; + ctx.ip = getClientIp(req); + } } else if (!sessionId && isInitializeRequest(jsonBody)) { + const ctx = { token: req.mcpToken, ip: getClientIp(req), source: "mcp" }; transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (sid) => { transports.set(sid, transport); + sessionContexts.set(sid, ctx); }, }); transport.onclose = () => { if (transport.sessionId) { transports.delete(transport.sessionId); + sessionContexts.delete(transport.sessionId); } }; - const server = createMcpServer(); + const server = createMcpServer(ctx); await server.connect(transport); await transport.handleRequest(req, res, req.body); return; diff --git a/src/mcp/tools.js b/src/mcp/tools.js index d8f0a13..9973094 100644 --- a/src/mcp/tools.js +++ b/src/mcp/tools.js @@ -12,6 +12,8 @@ const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js"); const projectService = require("../services/projectService"); const configService = require("../services/configService"); const { loadSystem, loadMcpConfig } = require("../persistence/duckdbAdapter"); +const { coversProject } = require("../services/tokenService"); +const { log: auditLog } = require("../services/auditService"); // 待确认删除的登记表:token -> { type, projectId, configId, expiresAt } const pendingDeletions = new Map(); @@ -94,6 +96,32 @@ function errText(err) { return err && err.message ? err.message : String(err); } +/** + * 校验当前会话 Token 是否有权限访问指定项目 + * @param {Object} ctx - 会话上下文(含 token) + * @param {string} projectId - 项目 ID + * @throws {Error} 无权访问时抛出 + */ +function assertProjectAccess(ctx, projectId) { + if (!coversProject(ctx && ctx.token, projectId)) { + throw new Error("无权访问该项目"); + } +} + +/** + * 记录一次 MCP 配置操作 + * @param {Object} ctx - 会话上下文 + * @param {Object} entry - 记录内容 { projectId, key, action, value, detail } + */ +function mcpAudit(ctx, entry) { + auditLog({ + source: "mcp", + actor: (ctx && ctx.token && ctx.token.name) || "anonymous", + ip: (ctx && ctx.ip) || "", + ...entry, + }); +} + /** * 构建带删除确认提示的文本结果 * @param {string} summary - 结果概要 @@ -121,9 +149,11 @@ function confirmResult(summary, confirmTool, token, extra) { /** * 创建并返回 MCP 服务器实例(仅定义工具,不绑定传输层) + * @param {Object} [ctx] - 会话上下文 { token, ip, source } * @returns {McpServer} 已注册全部工具的服务实例 */ -function createMcpServer() { +function createMcpServer(ctx) { + ctx = ctx || {}; const server = new McpServer( { name: "registry-config-center", @@ -335,7 +365,11 @@ function createMcpServer() { }, }, async ({ projectId }) => { + assertProjectAccess(ctx, projectId); const configs = await configService.getConfigsByProject(projectId); + for (const c of configs) { + mcpAudit(ctx, { projectId, key: c.key, action: "read", value: c.value }); + } return { content: [{ type: "text", text: JSON.stringify(configs, null, 2) }], structuredContent: { configs }, @@ -353,10 +387,12 @@ function createMcpServer() { }, }, async ({ projectId, configId }) => { + assertProjectAccess(ctx, projectId); const config = await configService.getConfigById(projectId, configId); if (!config) { throw new Error(`配置项不存在:${configId}`); } + mcpAudit(ctx, { projectId, key: config.key, action: "read", value: config.value }); return { content: [{ type: "text", text: JSON.stringify(config, null, 2) }], structuredContent: { config }, @@ -388,8 +424,10 @@ function createMcpServer() { async (args) => { await assertWritable(); const { projectId, ...data } = args; + assertProjectAccess(ctx, projectId); const mode = await currentMode(); const config = await configService.createConfig(projectId, data, mode); + mcpAudit(ctx, { projectId, key: config.key, action: "create", value: config.value }); return { content: [{ type: "text", text: JSON.stringify(config, null, 2) }], structuredContent: { config }, @@ -422,6 +460,8 @@ function createMcpServer() { async (args) => { await assertWritable(); const { projectId, configId, ...data } = args; + assertProjectAccess(ctx, projectId); + const oldConfig = await configService.getConfigById(projectId, configId); const picks = {}; for (const field of [ "key", "value", "type", "description", "defaultValue", "valueRange", @@ -433,6 +473,16 @@ function createMcpServer() { } const mode = await currentMode(); const config = await configService.updateConfig(projectId, configId, picks, mode); + mcpAudit(ctx, { + projectId, + key: config.key, + action: "update", + value: config.value, + detail: { + from: oldConfig ? { key: oldConfig.key, value: oldConfig.value } : null, + to: { key: config.key, value: config.value }, + }, + }); return { content: [{ type: "text", text: JSON.stringify(config, null, 2) }], structuredContent: { config }, @@ -452,6 +502,7 @@ function createMcpServer() { }, async ({ projectId, configId }) => { await assertWritable(); + assertProjectAccess(ctx, projectId); const config = await configService.getConfigById(projectId, configId); if (!config) { throw new Error(`配置项不存在:${configId}`); @@ -493,7 +544,9 @@ function createMcpServer() { isError: true, }; } + assertProjectAccess(ctx, entry.projectId); const mode = await currentMode(); + const doomed = await configService.getConfigById(entry.projectId, entry.configId); const ok = await configService.deleteConfig(entry.projectId, entry.configId, mode); if (!ok) { return { @@ -502,6 +555,12 @@ function createMcpServer() { isError: true, }; } + mcpAudit(ctx, { + projectId: entry.projectId, + key: (doomed && doomed.key) || "", + action: "delete", + value: (doomed && doomed.value) || "", + }); return { content: [{ type: "text", text: `配置项已删除:${entry.configId}` }], structuredContent: { status: "deleted", projectId: entry.projectId, configId: entry.configId }, diff --git a/src/persistence/duckdbAdapter.js b/src/persistence/duckdbAdapter.js index 635ba12..ed09788 100644 --- a/src/persistence/duckdbAdapter.js +++ b/src/persistence/duckdbAdapter.js @@ -63,6 +63,18 @@ async function createTables() { key VARCHAR PRIMARY KEY, data JSON NOT NULL ); + CREATE TABLE IF NOT EXISTS audit_logs ( + id VARCHAR PRIMARY KEY, + project_id VARCHAR NOT NULL, + config_key VARCHAR NOT NULL DEFAULT '', + action VARCHAR NOT NULL, + source VARCHAR NOT NULL, + actor VARCHAR NOT NULL DEFAULT '', + ip VARCHAR NOT NULL DEFAULT '', + value_text VARCHAR NOT NULL DEFAULT '', + detail JSON, + created_at VARCHAR NOT NULL + ); `); } @@ -287,6 +299,58 @@ async function saveApiTokens(tokens) { await insertRows("api_tokens", tokens); } +// ==================== audit logs ==================== + +async function appendAuditLog(entry) { + await init(); + await connection.run( + `INSERT INTO audit_logs (id, project_id, config_key, action, source, actor, ip, value_text, detail, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + entry.id, + entry.projectId || "", + entry.key || "", + entry.action, + entry.source, + entry.actor || "", + entry.ip || "", + entry.value || "", + entry.detail ? JSON.stringify(entry.detail) : null, + entry.createdAt, + ] + ); +} + +/** + * 查询操作记录 + * @param {Object} filter - { projectId, key } + * @returns {Promise} 操作记录,按时间倒序 + */ +async function loadAuditLogs(filter = {}) { + await init(); + const where = []; + const params = []; + if (filter.projectId) { + where.push("project_id = ?"); + params.push(filter.projectId); + } + if (filter.key) { + where.push("config_key = ?"); + params.push(filter.key); + } + const sql = + `SELECT id, project_id AS projectId, config_key AS key, action, source, actor, ip, + value_text AS value, detail, created_at AS createdAt + FROM audit_logs` + + (where.length ? ` WHERE ${where.join(" AND ")}` : "") + + ` ORDER BY created_at DESC, id DESC`; + const reader = await connection.runAndReadAll(sql, params); + return reader.getRowObjects().map((r) => ({ + ...r, + detail: r.detail ? parseData(r.detail) : null, + })); +} + module.exports = { init, loadProjects, @@ -302,4 +366,6 @@ module.exports = { saveUsers, loadApiTokens, saveApiTokens, + appendAuditLog, + loadAuditLogs, }; \ No newline at end of file diff --git a/src/routes/configs.js b/src/routes/configs.js index 213c506..74bbdf1 100644 --- a/src/routes/configs.js +++ b/src/routes/configs.js @@ -2,10 +2,18 @@ * 配置路由 * 处理配置项相关 API 请求 */ -const express = require("express"); -const router = express.Router({ mergeParams: true }); -const configService = require("../services/configService"); -const { loadSystem, saveSystem } = require("../persistence/duckdbAdapter"); +const express = require("express"); +const router = express.Router({ mergeParams: true }); +const configService = require("../services/configService"); +const { loadSystem, saveSystem, loadAuditLogs } = require("../persistence/duckdbAdapter"); +const { log: auditLog, getClientIp } = require("../services/auditService"); + +/** + * 当前访问者信息(Web:记录用户名;未登录则 anonymous) + */ +function webAuditContext(req) { + return { source: "web", actor: (req.user && req.user.username) || "anonymous", ip: getClientIp(req) }; +} /** * 获取当前模式 @@ -18,80 +26,134 @@ async function getCurrentMode() { } catch { return "edit"; } -} - -/** - * GET /api/projects/:projectId/configs - * 获取项目下所有配置项 - */ -router.get("/", async (req, res) => { - try { - const configs = await configService.getConfigsByProject(req.params.projectId); - res.json({ success: true, data: configs }); - } catch (err) { - res.status(400).json({ success: false, message: err.message }); - } -}); - -/** - * GET /api/projects/:projectId/configs/:configId - * 获取单个配置项 - */ -router.get("/:configId", async (req, res) => { - try { - const config = await configService.getConfigById(req.params.projectId, req.params.configId); - if (!config) { - return res.status(404).json({ success: false, message: "配置项不存在" }); - } - res.json({ success: true, data: config }); - } catch (err) { - res.status(400).json({ success: false, message: err.message }); - } -}); - -/** - * POST /api/projects/:projectId/configs - * 新增配置项 - */ -router.post("/", async (req, res) => { - try { - const mode = await getCurrentMode(); - const config = await configService.createConfig(req.params.projectId, req.body, mode); - res.status(201).json({ success: true, data: config }); - } catch (err) { - res.status(400).json({ success: false, message: err.message }); - } -}); - -/** - * PUT /api/projects/:projectId/configs/:configId - * 修改配置项 - */ -router.put("/:configId", async (req, res) => { - try { - const mode = await getCurrentMode(); - const config = await configService.updateConfig(req.params.projectId, req.params.configId, req.body, mode); - res.json({ success: true, data: config }); - } catch (err) { - res.status(400).json({ success: false, message: err.message }); - } -}); - -/** - * DELETE /api/projects/:projectId/configs/:configId - * 删除配置项 - */ -router.delete("/:configId", async (req, res) => { - try { - const mode = await getCurrentMode(); - const result = await configService.deleteConfig(req.params.projectId, req.params.configId, mode); - if (!result) { - return res.status(404).json({ success: false, message: "配置项不存在" }); - } - res.json({ success: true, message: "配置项已删除" }); - } catch (err) { - res.status(400).json({ success: false, message: err.message }); - } +} + +/** + * GET /api/projects/:projectId/configs/audit?key=xxx + * 查询当前项目(或指定配置键)的操作记录 + */ +router.get("/audit", async (req, res) => { + try { + const logs = await loadAuditLogs({ + projectId: req.params.projectId, + key: req.query.key || "", + }); + res.json({ success: true, data: logs }); + } catch (err) { + res.status(400).json({ success: false, message: err.message }); + } +}); + +/** + * GET /api/projects/:projectId/configs + * 获取项目下所有配置项 + */ +router.get("/", async (req, res) => { + try { + const configs = await configService.getConfigsByProject(req.params.projectId); + const ctx = webAuditContext(req); + for (const c of configs) { + auditLog({ ...ctx, projectId: req.params.projectId, key: c.key, action: "read", value: c.value }); + } + res.json({ success: true, data: configs }); + } catch (err) { + res.status(400).json({ success: false, message: err.message }); + } +}); + +/** + * GET /api/projects/:projectId/configs/:configId + * 获取单个配置项 + */ +router.get("/:configId", async (req, res) => { + try { + const config = await configService.getConfigById(req.params.projectId, req.params.configId); + if (!config) { + return res.status(404).json({ success: false, message: "配置项不存在" }); + } + auditLog({ + ...webAuditContext(req), + projectId: req.params.projectId, + key: config.key, + action: "read", + value: config.value, + }); + res.json({ success: true, data: config }); + } catch (err) { + res.status(400).json({ success: false, message: err.message }); + } +}); + +/** + * POST /api/projects/:projectId/configs + * 新增配置项 + */ +router.post("/", async (req, res) => { + try { + const mode = await getCurrentMode(); + const config = await configService.createConfig(req.params.projectId, req.body, mode); + auditLog({ + ...webAuditContext(req), + projectId: req.params.projectId, + key: config.key, + action: "create", + value: config.value, + }); + res.status(201).json({ success: true, data: config }); + } catch (err) { + res.status(400).json({ success: false, message: err.message }); + } +}); + +/** + * PUT /api/projects/:projectId/configs/:configId + * 修改配置项 + */ +router.put("/:configId", async (req, res) => { + try { + const mode = await getCurrentMode(); + const oldConfig = await configService.getConfigById(req.params.projectId, req.params.configId); + const config = await configService.updateConfig(req.params.projectId, req.params.configId, req.body, mode); + auditLog({ + ...webAuditContext(req), + projectId: req.params.projectId, + key: config.key, + action: "update", + value: config.value, + detail: { + from: oldConfig ? { key: oldConfig.key, value: oldConfig.value } : null, + to: { key: config.key, value: config.value }, + }, + }); + res.json({ success: true, data: config }); + } catch (err) { + res.status(400).json({ success: false, message: err.message }); + } +}); + +/** + * DELETE /api/projects/:projectId/configs/:configId + * 删除配置项 + */ +router.delete("/:configId", async (req, res) => { + try { + const mode = await getCurrentMode(); + const oldConfig = await configService.getConfigById(req.params.projectId, req.params.configId); + const result = await configService.deleteConfig(req.params.projectId, req.params.configId, mode); + if (!result) { + return res.status(404).json({ success: false, message: "配置项不存在" }); + } + auditLog({ + ...webAuditContext(req), + projectId: req.params.projectId, + key: oldConfig ? oldConfig.key : "", + action: "delete", + value: oldConfig ? oldConfig.value : "", + }); + res.json({ success: true, message: "配置项已删除" }); + } catch (err) { + res.status(400).json({ success: false, message: err.message }); + } }); module.exports = router; diff --git a/src/services/auditService.js b/src/services/auditService.js new file mode 100644 index 0000000..570536e --- /dev/null +++ b/src/services/auditService.js @@ -0,0 +1,63 @@ +/** + * 操作记录(审计)服务 + * 记录所有对配置的访问:读取/新增/更新/删除 + * 访问方式:mcp(记录 Token 名)/ web(记录用户名),含 IP(支持 nginx 转发) + * 记录写入失败时静默降级,不阻塞主业务操作 + */ +const { appendAuditLog } = require("../persistence/duckdbAdapter"); + +/** + * 生成唯一记录 ID + * @returns {string} 记录 ID + */ +function generateId() { + return "aud-" + Date.now().toString(36) + "-" + Math.random().toString(36).substring(2, 8); +} + +/** + * 从请求中提取客户端 IP(支持 nginx 等代理通过 X-Forwarded-For 转发) + * @param {Object} req - Express 请求对象 + * @returns {string} 客户端 IP + */ +function getClientIp(req) { + if (!req) return ""; + const forwarded = req.headers && req.headers["x-forwarded-for"]; + if (forwarded) { + return String(forwarded).split(",")[0].trim(); + } + return (req.socket && req.socket.remoteAddress) || req.ip || ""; +} + +/** + * 记录一次配置访问 + * @param {Object} entry - 记录内容 + * @param {string} entry.source - 访问方式:mcp / web + * @param {string} entry.actor - 操作者(mcp 记录 Token 名,web 记录用户名) + * @param {string} entry.ip - 客户端 IP + * @param {string} entry.projectId - 项目 ID + * @param {string} entry.key - 配置键 + * @param {string} entry.action - read / create / update / delete + * @param {*} entry.value - 操作值 + * @param {Object} [entry.detail] - 附加信息(如变更前后对比) + * @returns {Promise} + */ +async function log(entry) { + try { + await appendAuditLog({ + id: generateId(), + createdAt: new Date().toISOString(), + projectId: entry.projectId || "", + key: entry.key || "", + action: entry.action, + source: entry.source, + actor: entry.actor || "", + ip: entry.ip || "", + value: entry.value !== undefined && entry.value !== null ? String(entry.value) : "", + detail: entry.detail || null, + }); + } catch (err) { + console.error("[audit] 操作记录写入失败:", err.message); + } +} + +module.exports = { log, getClientIp }; \ No newline at end of file diff --git a/src/services/tokenService.js b/src/services/tokenService.js index 179baa7..f0ae2ba 100644 --- a/src/services/tokenService.js +++ b/src/services/tokenService.js @@ -1,17 +1,70 @@ /** * API Token 服务 - * 负责 API 访问令牌的增删改查与吊销,持久化到 data/api-tokens.json - * 支持 mcpEnabled 标记:标记后该 token 用于 MCP /mcp 端点鉴权(取代旧的 mcp.json / mcp_kv) + * 负责 API 访问令牌的增删改查与吊销,持久化到 DuckDB。 + * 所有 Token 均可用于 MCP /mcp 端点鉴权;Token 名称唯一。 + * 关联项目支持多选:"all" 表示全部项目,否则为若干项目 ID 列表。 */ const { loadApiTokens, saveApiTokens, loadMcpConfig, saveMcpConfig } = require("../persistence/duckdbAdapter"); const { generateToken } = require("./userService"); +/** + * 规整历史 Token 记录:确保存在 projectIds(兼容旧 projectId 字段) + * @param {Object} t - 原始 Token 记录 + * @returns {Object} 规整后的 Token(含 projectIds) + */ +function normalizeToken(t) { + if (t.projectIds && Array.isArray(t.projectIds)) { + return t; + } + const legacy = t.projectId === undefined || t.projectId === null ? "all" : t.projectId; + return { ...t, projectIds: Array.isArray(legacy) ? legacy : [legacy] }; +} + +/** + * 规整提交的项目范围:数组,含 "all" 时归一为 ["all"] + * @param {*} projectIds - 提交的项目范围 + * @returns {Array} + */ +function normalizeProjectIds(projectIds) { + if (Array.isArray(projectIds)) { + if (projectIds.includes("all")) return ["all"]; + return projectIds.filter(Boolean); + } + if (typeof projectIds === "string" && projectIds !== "") { + return projectIds === "all" ? ["all"] : [projectIds]; + } + return ["all"]; +} + +/** + * Token 是否可访问指定项目 + * @param {Object} token - Token 对象 + * @param {string} projectId - 项目 ID + * @returns {boolean} + */ +function coversProject(token, projectId) { + if (!token) return true; + const ids = normalizeToken(token).projectIds; + return !ids.length || ids.includes("all") || ids.includes(projectId); +} + +/** + * 检查 Token 名称是否与其它 Token 重复 + * @param {Array} tokens - Token 列表 + * @param {string} name - 名称 + * @param {string} [excludeId] - 更新时排除自身 + * @returns {boolean} + */ +function nameTaken(tokens, name, excludeId) { + return tokens.some((t) => t.id !== excludeId && t.name === name); +} + /** * 获取所有 Token(完整值,供列表展示) * @returns {Promise} */ async function getAllTokens() { - return loadApiTokens(); + return (await loadApiTokens()).map(normalizeToken); } /** @@ -20,12 +73,13 @@ async function getAllTokens() { * @returns {Promise} */ async function findById(id) { - return (await loadApiTokens()).find((t) => t.id === id) || null; + const t = (await loadApiTokens()).find((t) => t.id === id); + return t ? normalizeToken(t) : null; } /** * 创建 Token - * @param {Object} data - { name, projectId, permission, mcpEnabled, expiresInDays } + * @param {Object} data - { name, projectIds | projectId, permission, expiresInDays } * @returns {Promise} 创建的 Token(含明文 value) */ async function createToken(data) { @@ -33,23 +87,28 @@ async function createToken(data) { if (!data.name || !data.name.trim()) { throw new Error("Token 名称不能为空"); } + const name = data.name.trim(); + if (nameTaken(tokens, name)) { + throw new Error("Token 名称已存在"); + } const tokenValue = `reg_${generateToken(24)}`; const now = new Date(); let expiresAt = null; if (data.expiresInDays && data.expiresInDays > 0) { expiresAt = new Date(now.getTime() + data.expiresInDays * 86400000).toISOString(); } - const token = { + const token = normalizeToken({ id: generateToken(8), - name: data.name.trim(), - projectId: data.projectId || "all", + name, + projectIds: normalizeProjectIds( + data.projectIds !== undefined ? data.projectIds : (data.projectId !== undefined ? data.projectId : "all") + ), permission: data.permission === "readwrite" ? "readwrite" : "readonly", - mcpEnabled: !!data.mcpEnabled, value: tokenValue, enabled: true, createdAt: now.toISOString(), expiresAt, - }; + }); tokens.push(token); await saveApiTokens(tokens); return token; @@ -58,17 +117,24 @@ async function createToken(data) { /** * 更新 Token 元信息 * @param {string} id - * @param {Object} data - { name, projectId, permission, mcpEnabled, enabled } + * @param {Object} data - { name, projectIds | projectId, permission, enabled } * @returns {Promise} */ async function updateToken(id, data) { - const tokens = await loadApiTokens(); + const tokens = (await loadApiTokens()).map(normalizeToken); const idx = tokens.findIndex((t) => t.id === id); if (idx === -1) throw new Error("Token 不存在"); - if (data.name !== undefined) tokens[idx].name = data.name.trim(); - if (data.projectId !== undefined) tokens[idx].projectId = data.projectId; + if (data.name !== undefined) { + const name = data.name.trim(); + if (!name) throw new Error("Token 名称不能为空"); + if (nameTaken(tokens, name, id)) { + throw new Error("Token 名称已存在"); + } + tokens[idx].name = name; + } + if (data.projectIds !== undefined) tokens[idx].projectIds = normalizeProjectIds(data.projectIds); + if (data.projectId !== undefined && data.projectIds === undefined) tokens[idx].projectIds = normalizeProjectIds(data.projectId); if (data.permission !== undefined) tokens[idx].permission = data.permission; - if (data.mcpEnabled !== undefined) tokens[idx].mcpEnabled = !!data.mcpEnabled; if (data.enabled !== undefined) tokens[idx].enabled = !!data.enabled; await saveApiTokens(tokens); return tokens[idx]; @@ -103,50 +169,61 @@ async function deleteToken(id) { } /** - * 根据 token value 校验是否有效(用于 API 鉴权) + * 根据 token value 校验是否有效(用于通用 API 鉴权) * @param {string} value - * @returns {Promise} token 对象或 null + * @returns {Promise} Token 对象或 null */ async function validateToken(value) { + return resolveActiveToken(value); +} + +/** + * 校验 Bearer token 是否为生效 Token(启用且未过期) + * @param {string} value - Token 明文 + * @returns {Promise} 生效的 Token 对象或 null + */ +async function resolveActiveToken(value) { if (!value) return null; - const token = (await loadApiTokens()).find((t) => t.value === value); + const token = (await loadApiTokens()).map(normalizeToken).find((t) => t.value === value); if (!token || !token.enabled) return null; if (token.expiresAt && new Date(token.expiresAt) < new Date()) return null; return token; } /** - * 获取启用了 MCP 访问的生效 Token 值列表(用于 /mcp 端点鉴权) - * @returns {Promise} 生效的 MCP token 明文值 + * 获取全部生效 Token 明文值列表(MCP / 其它鉴权共用,所有 Token 均视为 MCP 令牌) + * @returns {Promise} */ -async function getMcpTokenValues() { +async function getActiveTokenValues() { const now = Date.now(); return (await loadApiTokens()) - .filter((t) => t.mcpEnabled && t.enabled && (!t.expiresAt || new Date(t.expiresAt).getTime() > now)) + .map(normalizeToken) + .filter((t) => t.enabled && (!t.expiresAt || new Date(t.expiresAt).getTime() > now)) .map((t) => t.value); } /** * 一次性迁移历史 MCP 鉴权配置(旧 mcp.json / mcp_kv)到 Token 表 - * 兼容线上已有用旧 token 连接的 MCP 客户端:把旧 token 转为一条启用 mcpEnabled 的 Token,并清除旧配置 + * 兼容线上已有用旧 token 连接的 MCP 客户端:若旧 token 尚未存在于 Token 表,则转为一条 Token 记录并清除旧配置。 + * 所有 Token 均视为 MCP 令牌,无需特殊标记。 * @returns {Promise} */ async function migrateLegacyMcpToken() { - const tokens = await loadApiTokens(); - if (tokens.some((t) => t.mcpEnabled)) { - return; - } + const tokens = (await loadApiTokens()).map(normalizeToken); const mcp = await loadMcpConfig(); if (!mcp || !mcp.token) { return; } + if (tokens.some((t) => t.value === mcp.token)) { + await saveMcpConfig({ enabled: false, token: "" }); + return; + } const now = new Date(); tokens.push({ id: generateToken(8), name: "MCP(历史迁移)", - projectId: "all", + projectIds: ["all"], permission: "readwrite", - mcpEnabled: true, value: mcp.token, enabled: true, createdAt: now.toISOString(), @@ -165,6 +242,8 @@ module.exports = { revokeToken, deleteToken, validateToken, - getMcpTokenValues, + resolveActiveToken, + getActiveTokenValues, + coversProject, migrateLegacyMcpToken, -}; +}; \ No newline at end of file diff --git a/test/audit.test.js b/test/audit.test.js new file mode 100644 index 0000000..1723efb --- /dev/null +++ b/test/audit.test.js @@ -0,0 +1,67 @@ +/** + * 操作记录(审计)单元测试 + * 覆盖:appendAuditLog/loadAuditLogs 读写与过滤、auditService.log 静默容错、getClientIp 支持 nginx 转发 + * 运行:npm test + */ +const test = require("node:test"); +const assert = require("node:assert"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +// 使用临时数据目录,避免污染真实 data/ +const tmpDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "registry-audit-test-")); +process.env.REGISTRY_DATA_DIR = tmpDataDir; + +const duckdbAdapter = require("../src/persistence/duckdbAdapter"); +const { log: auditLog, getClientIp } = require("../src/services/auditService"); + +test.before(async () => { + await duckdbAdapter.init(); +}); + +test("记录一条配置操作并可按项目/键查询", async () => { + await auditLog({ + source: "web", + actor: "admin", + ip: "203.0.113.9", + projectId: "proj-a", + key: "db/host", + action: "update", + value: "db.internal", + detail: { from: { key: "db/host", value: "old" }, to: { key: "db/host", value: "db.internal" } }, + }); + + const byProject = await duckdbAdapter.loadAuditLogs({ projectId: "proj-a" }); + assert.strictEqual(byProject.length, 1); + const byKey = await duckdbAdapter.loadAuditLogs({ projectId: "proj-a", key: "db/host" }); + assert.strictEqual(byKey.length, 1); + // 其它项目/键查不到 + assert.strictEqual((await duckdbAdapter.loadAuditLogs({ projectId: "proj-b" })).length, 0); + assert.strictEqual((await duckdbAdapter.loadAuditLogs({ projectId: "proj-a", key: "other" })).length, 0); + + const rec = byKey[0]; + assert.strictEqual(rec.source, "web"); + assert.strictEqual(rec.actor, "admin"); + assert.strictEqual(rec.ip, "203.0.113.9"); + assert.strictEqual(rec.action, "update"); + assert.strictEqual(rec.value, "db.internal"); + assert.strictEqual(rec.detail.from.value, "old"); + assert.ok(rec.createdAt); +}); + +test("记录按时间倒序返回,MCP 来源也正确记录 Token 名", async () => { + await auditLog({ source: "mcp", actor: "ci-token", ip: "10.0.0.1", projectId: "proj-a", key: "k/1", action: "read", value: "x" }); + await auditLog({ source: "mcp", actor: "ci-token", ip: "10.0.0.1", projectId: "proj-a", key: "k/1", action: "create", value: "x" }); + + const logs = await duckdbAdapter.loadAuditLogs({ projectId: "proj-a", key: "k/1" }); + assert.strictEqual(logs.length, 2); + assert.ok(logs[0].createdAt >= logs[1].createdAt, "最新的应在前"); + assert.ok(logs.every((l) => l.source === "mcp" && l.actor === "ci-token")); +}); + +test("getClientIp 优先取 X-Forwarded-For,其次 remoteAddress", () => { + assert.strictEqual(getClientIp({ headers: { "x-forwarded-for": "203.0.113.50, 10.0.0.5" } }), "203.0.113.50"); + assert.strictEqual(getClientIp({ headers: {}, socket: { remoteAddress: "127.0.0.1" } }), "127.0.0.1"); + assert.strictEqual(getClientIp(null), ""); +}); \ No newline at end of file diff --git a/test/mcp.test.js b/test/mcp.test.js index db08156..bc3d6fa 100644 --- a/test/mcp.test.js +++ b/test/mcp.test.js @@ -9,7 +9,7 @@ const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { createMcpServer } = require("../src/mcp/tools"); -const { init, loadSystem, saveSystem } = require("../src/persistence/duckdbAdapter"); +const { init, loadSystem, saveSystem, loadAuditLogs } = require("../src/persistence/duckdbAdapter"); // 使用临时数据目录,避免污染真实 data/ const tmpDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "registry-mcp-test-")); @@ -35,10 +35,12 @@ test.after(async () => { * 直接调用已注册工具的处理函数 * @param {string} name - 工具名 * @param {Object} args - 工具参数 + * @param {Object} [srv] - MCP 服务器实例(默认使用全局 server) * @returns {Promise} 工具结果 */ -async function callTool(name, args) { - const tool = server._registeredTools[name]; +async function callTool(name, args, srv) { + const target = srv || server; + const tool = target._registeredTools[name]; assert.ok(tool, `工具不存在: ${name}`); try { const result = await tool.handler(args, {}); @@ -153,13 +155,49 @@ test("edit 模式下可以修改配置说明", async () => { assert.strictEqual(updatedObj.value, "localhost", "仅改说明,值不应变化"); // 清理测试项目 - const delProject = await callTool("delete_project", { projectId: project.id }); +const delProject = await callTool("delete_project", { projectId: project.id }); const confirmProject = await callTool("confirm_delete_project", { confirmation_token: delProject.structuredContent.confirmation_token, }); assert.strictEqual(confirmProject.structuredContent.status, "deleted"); }); +test("Token 项目范围限制 MCP 配置访问,越权操作被拒绝且不入记录", async () => { + // 先创建真实项目,再构造仅允许该项目访问的 Token 会话 + const projText = (await callTool("create_project", { name: "MCP-Scope-Proj" })).text; + const project = JSON.parse(projText.match(/\{[\s\S]*\}/)[0]); + const scoped = createMcpServer({ token: { name: "scope-token", projectIds: [project.id] }, ip: "10.1.2.3" }); + + // 无权访问的项目:读取/写入被拒绝 + const deniedRead = await callTool("list_configs", { projectId: "other-proj" }, scoped); + assert.ok(deniedRead.isError); + assert.match(deniedRead.text, /无权访问/); + + const deniedWrite = await callTool("create_config", { projectId: "other-proj", key: "x", description: "x" }, scoped); + assert.ok(deniedWrite.isError); + assert.match(deniedWrite.text, /无权访问/); + + // 有权限:可读可写,并留下审计记录 + const createdReal = await callTool("create_config", { projectId: project.id, key: "sql/dsn", description: "d", value: "v1" }, scoped); + assert.ok(!createdReal.isError); + + const logsReal = await loadAuditLogs({ projectId: project.id, key: "sql/dsn" }); + const createLog = logsReal.find((l) => l.action === "create"); + assert.ok(createLog, "应在 audit_logs 中存在 create 记录"); + assert.strictEqual(createLog.source, "mcp"); + assert.strictEqual(createLog.actor, "scope-token"); + assert.strictEqual(createLog.ip, "10.1.2.3"); + assert.strictEqual(createLog.value, "v1"); + + // 越权操作不产生记录(other-proj 无任何记录) + const deniedLogs = await loadAuditLogs({ projectId: "other-proj" }); + assert.strictEqual(deniedLogs.length, 0); + + // 清理测试项目 + const delProject = await callTool("delete_project", { projectId: project.id }); + await callTool("confirm_delete_project", { confirmation_token: delProject.structuredContent.confirmation_token }); +}); + test("配置删除同样为两步式确认", async () => { const { text: createText } = await callTool("create_project", { name: "MCP-Unittest-Cfg" }); const project = JSON.parse(createText.match(/\{[\s\S]*\}/)[0]); diff --git a/test/tokenService.test.js b/test/tokenService.test.js index 887bc25..bfaa1d6 100644 --- a/test/tokenService.test.js +++ b/test/tokenService.test.js @@ -1,6 +1,6 @@ /** * Token 服务单元测试 - * 覆盖:mcpEnabled 标记、MCP token 列表过滤、历史 MCP 配置迁移 + * 覆盖:多项目关联、名称唯一、全部 Token 均为 MCP 令牌、历史 MCP 配置迁移 * 运行:npm test */ const test = require("node:test"); @@ -20,61 +20,78 @@ test.before(async () => { await init(); }); -test("createToken 保存 mcpEnabled 标记", async () => { - const mcpToken = await tokenService.createToken({ - name: "mcp-key", - projectId: "all", - permission: "readwrite", - mcpEnabled: true, - expiresInDays: 30, +test("创建 Token:支持多个关联项目与全部项目", async () => { + const multi = await tokenService.createToken({ + name: "multi-proj", + projectIds: ["proj-a", "proj-b"], + expiresInDays: 0, }); - assert.strictEqual(mcpToken.mcpEnabled, true); - const plainToken = await tokenService.createToken({ - name: "plain", - mcpEnabled: false, - }); - assert.strictEqual(plainToken.mcpEnabled, false); + assert.deepStrictEqual(multi.projectIds, ["proj-a", "proj-b"]); + assert.strictEqual(tokenService.coversProject(multi, "proj-a"), true); + assert.strictEqual(tokenService.coversProject(multi, "proj-b"), true); + assert.strictEqual(tokenService.coversProject(multi, "proj-c"), false); + + const all = await tokenService.createToken({ name: "all-proj", projectIds: ["all"] }); + assert.deepStrictEqual(all.projectIds, ["all"]); + assert.strictEqual(tokenService.coversProject(all, "任意项目"), true); + + const legacyProjectId = await tokenService.createToken({ name: "legacy-field", projectId: "proj-x" }); + assert.deepStrictEqual(legacyProjectId.projectIds, ["proj-x"]); }); -test("getMcpTokenValues 仅返回生效的 MCP token 明文", async () => { - const values = await tokenService.getMcpTokenValues(); - assert.ok(values.length >= 1); - // 明文值以 reg_ 开头 - assert.ok(values.every((v) => typeof v === "string" && v.startsWith("reg_"))); +test("Token 名称唯一:重复名称创建与更新均报错", async () => { + await tokenService.createToken({ name: "dup-name", projectIds: ["all"] }); + await assert.rejects( + () => tokenService.createToken({ name: "dup-name", projectIds: ["all"] }), + /名称已存在/ + ); + + // 更新为已存在的名称也报错 + const other = await tokenService.createToken({ name: "dup-other", projectIds: ["all"] }); + await assert.rejects( + () => tokenService.updateToken(other.id, { name: "dup-name" }), + /名称已存在/ + ); + + // 更新自身名称不改动、且可改成唯一名 + await tokenService.updateToken(other.id, { name: "dup-other" }); + await tokenService.updateToken(other.id, { name: "dup-renamed" }); + assert.strictEqual((await tokenService.findById(other.id)).name, "dup-renamed"); }); -test("updateToken 可切换 mcpEnabled", async () => { - const plain = await tokenService.createToken({ name: "toggle", mcpEnabled: false }); - const before = await tokenService.getMcpTokenValues(); - assert.ok(!before.includes(plain.value)); - await tokenService.updateToken(plain.id, { mcpEnabled: true }); - const after = await tokenService.getMcpTokenValues(); - assert.ok(after.includes(plain.value)); +test("所有 Token 均为 MCP 令牌:全部生效 Token 明文可获取,通过 resolveActiveToken 鉴权", async () => { + const t = await tokenService.createToken({ name: "mcp-all", projectIds: ["all"] }); + const values = await tokenService.getActiveTokenValues(); + assert.ok(values.includes(t.value), "新 Token 应直接生效于 MCP"); + + const resolved = await tokenService.resolveActiveToken(t.value); + assert.strictEqual(resolved.name, "mcp-all"); + assert.strictEqual(await tokenService.resolveActiveToken("bad-token"), null); }); -test("吊销或过期后 MCP token 不再生效", async () => { - const t = await tokenService.createToken({ name: "will-revoke", mcpEnabled: true, expiresInDays: 30 }); - const ok = await tokenService.getMcpTokenValues(); - assert.ok(ok.includes(t.value)); +test("吊销或过期后 Token 不再生效", async () => { + const t = await tokenService.createToken({ name: "will-revoke", projectIds: ["all"], expiresInDays: 30 }); + assert.ok((await tokenService.getActiveTokenValues()).includes(t.value)); await tokenService.revokeToken(t.id); - const revoked = await tokenService.getMcpTokenValues(); - assert.ok(!revoked.includes(t.value)); + assert.ok(!(await tokenService.getActiveTokenValues()).includes(t.value)); + assert.strictEqual(await tokenService.resolveActiveToken(t.value), null); }); test("migrateLegacyMcpToken 把历史 mcp_kv 配置迁移为 Token 记录", async () => { - // 清理当前 token 表,模拟空 MCP 标记状态 const { loadApiTokens, saveApiTokens, saveMcpConfig, loadMcpConfig } = require("../src/persistence/duckdbAdapter"); - await saveApiTokens([]); + const existing = await loadApiTokens(); await saveMcpConfig({ enabled: true, token: "legacy-mcp-token-abc" }); await tokenService.migrateLegacyMcpToken(); const tokens = await loadApiTokens(); - const migrated = tokens.find((t) => t.mcpEnabled); - assert.ok(migrated, "应生成一条 mcpEnabled 的 Token"); - assert.strictEqual(migrated.value, "legacy-mcp-token-abc"); - const values = await tokenService.getMcpTokenValues(); - assert.ok(values.includes("legacy-mcp-token-abc")); + const migrated = tokens.find((t) => t.value === "legacy-mcp-token-abc"); + assert.ok(migrated, "应生成一条包含旧 token 的记录"); + assert.strictEqual(migrated.projectIds[0], "all"); + assert.ok((await tokenService.getActiveTokenValues()).includes("legacy-mcp-token-abc")); // 旧配置已清空 const mcp = await loadMcpConfig(); assert.strictEqual(mcp.token, ""); + + // 清掉迁移产生的记录,避免影响后续测试 + await saveApiTokens(existing); }); \ No newline at end of file
名称关联项目权限MCP 访问Token名称关联项目权限Token 状态过期时间创建时间操作
暂无 Token
暂无 Token
" + esc(t.name) + "" + esc(t.projectId === "all" ? "全部项目" : t.projectId) + "" + esc(projectNames(t.projectIds)) + "' + (t.permission === "readwrite" ? "读写" : "只读") + "' + (t.mcpEnabled - ? 'MCP' - : '—') + "
' + '' + masked + "" + '' + @@ -517,7 +555,7 @@ "
" + fmtDate(t.expiresAt) + "" + fmtDate(t.createdAt) + "' + - ' " + + ' " + (t.enabled ? '" : "") + @@ -565,12 +603,10 @@ document.getElementById("modal-token-title").textContent = "生成 Token"; document.getElementById("edit-token-id").value = ""; document.getElementById("input-token-name").value = ""; - document.getElementById("input-token-project").value = "all"; document.getElementById("input-token-permission").value = "readonly"; document.getElementById("input-token-expiry").value = "90"; - document.getElementById("input-token-mcp").checked = false; document.getElementById("btn-save-token").textContent = "生成"; - loadProjectsForSelect(); + loadProjectsForSelect().then(function () { setSelectedProjectIds(["all"]); }); openModal("modal-token"); }); @@ -578,11 +614,10 @@ document.getElementById("modal-token-title").textContent = "编辑 Token"; document.getElementById("edit-token-id").value = t.id; document.getElementById("input-token-name").value = t.name; - document.getElementById("input-token-project").value = t.projectId; document.getElementById("input-token-permission").value = t.permission; - document.getElementById("input-token-mcp").checked = !!(t.mcpEnabled); document.getElementById("btn-save-token").textContent = "保存"; - loadProjectsForSelect(); + var ids = t.projectIds; + loadProjectsForSelect().then(function () { setSelectedProjectIds(ids); }); openModal("modal-token"); }; @@ -590,25 +625,20 @@ var id = document.getElementById("edit-token-id").value; var name = document.getElementById("input-token-name").value.trim(); if (!name) { toast("请输入 Token 名称", "error"); return; } + var payload = { + name: name, + projectIds: getSelectedProjectIds(), + permission: document.getElementById("input-token-permission").value, + }; try { if (id) { - await API.updateToken(id, { - name: name, - projectId: document.getElementById("input-token-project").value, - permission: document.getElementById("input-token-permission").value, - mcpEnabled: document.getElementById("input-token-mcp").checked, - }); + await API.updateToken(id, payload); closeModal("modal-token"); toast("Token 已更新", "success"); loadTokens(); } else { - var data = await API.createToken({ - name: name, - projectId: document.getElementById("input-token-project").value, - permission: document.getElementById("input-token-permission").value, - mcpEnabled: document.getElementById("input-token-mcp").checked, - expiresInDays: parseInt(document.getElementById("input-token-expiry").value) || 0, - }); + payload.expiresInDays = parseInt(document.getElementById("input-token-expiry").value) || 0; + var data = await API.createToken(payload); closeModal("modal-token"); document.getElementById("reveal-token-value").textContent = data.value; openModal("modal-token-reveal"); diff --git a/public/index.html b/public/index.html index 4c78641..2136b52 100644 --- a/public/index.html +++ b/public/index.html @@ -45,8 +45,9 @@
数据 - - + + +
@@ -303,7 +304,31 @@ - + + +
${escapeHtml(cfg.type)} ${statusHtml.join("")} ${escapeHtml(cfg.description)} - - - + + + + +
暂无操作记录
${escapeHtml(fmtTime(l.createdAt))}${escapeHtml(src)}${escapeHtml(l.actor || "")}${l.ip ? escapeHtml(l.ip) : "—"}${escapeHtml(act)}${escapeHtml(l.key || "")}${escapeHtml(val.length > 80 ? val.slice(0, 80) + "…" : val)}