feat(mcp): tools/list按token作用域过滤(servlet层重写响应); ToolGuard.allowedToolNames批量查询; AkAuthFilter定序@Order(10)
This commit is contained in:
@@ -55,7 +55,7 @@ xiaoqu-agent-service/
|
||||
- `/mcp` 全路径要求 `Authorization: Bearer <ak>`,缺失/无效/禁用/过期 → 401 JSON。
|
||||
- 判定链(设计文档 §3.2):ak → SHA-256 hex → `agent_api_key` 匹配 → 解析出人(Redis db5 `entity:User:{id}` 热缓存优先,fastjson camelCase JSON 用 Jackson 解析;读不到回源 m0 `user` 表)→ AuthContext 存 ThreadLocal → 工具调用前 scope 校验 → 审计落库。
|
||||
- **ThreadLocal 实测结论**:`spring.ai.mcp.server.type=SYNC`(application.yml 已显式设置)时,Spring AI 1.1.8 的工具回调在 servlet 请求线程(`nio-8088-exec-*`)上同步执行,ThreadLocal 的 AuthContext 与 RequestContextHolder 均有效(已用真实 curl 验证:agent.list.query 按人过滤、audit_log 正确落 user_id/token_id/client_ip)。AkAuthFilter 在请求结束 finally 清理。
|
||||
- **已知限制**:`tools/list` 暂不按 key 过滤工具列表,所有工具可见;`tools/call` 时的 scope 强制校验是底线。
|
||||
- **tools/list 按作用域过滤**(2026-09-04 起):MCP SDK 的工具清单在启动时静态注册,无法按请求过滤,故 `ToolsListScopeFilter` 在 servlet 层拦截 POST /mcp 的 tools/list 响应并重写 `result.tools`(只保留该 ak 作用域并集内、且启用状态的工具)。`tools/call` 路径不变:越权调用仍返回可读拒绝信息并落 denied 审计。
|
||||
- client_type 识别:`X-Client-Type` 头优先,否则 User-Agent 含 kimi→mcp-kimi / claude→mcp-claude / curl→cli,其余 unknown。
|
||||
|
||||
## 行级数据权限引擎(permission/PermissionService)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package me.iiv.agent.mcp;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* tools/list 响应重写:把 SSE(或纯 JSON)响应里 result.tools 按 allowedToolNames 过滤。
|
||||
* 纯函数,便于单测;逐行处理 SSE 的 data 负载,其余字节原样保留;
|
||||
* 解析失败的行一律原样放行(不把链路搞坏)。
|
||||
*/
|
||||
public final class ToolsListResponseRewriter {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private ToolsListResponseRewriter() {
|
||||
}
|
||||
|
||||
public static String rewrite(String body, Set<String> allowedToolNames) {
|
||||
if (body == null || body.isBlank()) {
|
||||
return body;
|
||||
}
|
||||
// 逐行处理:SSE 事件结构(id:/event:/空行)原样保留,只动 data: 负载行
|
||||
String[] lines = body.split("\\n", -1);
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
String line = lines[i];
|
||||
if (line.startsWith("data:")) {
|
||||
lines[i] = rewriteDataLine(line, allowedToolNames);
|
||||
} else if (i == 0 && line.startsWith("{")) {
|
||||
// 非 SSE 的纯 JSON 响应
|
||||
return rewriteDataLine(line, allowedToolNames);
|
||||
}
|
||||
}
|
||||
return String.join("\n", lines);
|
||||
}
|
||||
|
||||
private static String rewriteDataLine(String line, Set<String> allowedToolNames) {
|
||||
boolean sse = line.startsWith("data:");
|
||||
String payload = sse ? line.substring("data:".length()).stripLeading() : line;
|
||||
try {
|
||||
JsonNode node = MAPPER.readTree(payload);
|
||||
if (!filterTools(node, allowedToolNames)) {
|
||||
return line;
|
||||
}
|
||||
return (sse ? "data:" : "") + MAPPER.writeValueAsString(node);
|
||||
} catch (Exception e) {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
|
||||
/** 若节点含 result.tools 数组则按白名单过滤,返回是否命中 tools 负载。 */
|
||||
private static boolean filterTools(JsonNode node, Set<String> allowedToolNames) {
|
||||
if (!(node instanceof ObjectNode object)) {
|
||||
return false;
|
||||
}
|
||||
JsonNode result = object.get("result");
|
||||
if (!(result instanceof ObjectNode resultObject)) {
|
||||
return false;
|
||||
}
|
||||
JsonNode tools = resultObject.get("tools");
|
||||
if (!(tools instanceof ArrayNode toolsArray)) {
|
||||
return false;
|
||||
}
|
||||
ArrayNode filtered = MAPPER.createArrayNode();
|
||||
for (JsonNode tool : toolsArray) {
|
||||
JsonNode name = tool.get("name");
|
||||
if (name != null && allowedToolNames.contains(name.asText())) {
|
||||
filtered.add(tool);
|
||||
}
|
||||
}
|
||||
resultObject.set("tools", filtered);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package me.iiv.agent.mcp;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.ServletInputStream;
|
||||
import jakarta.servlet.ServletOutputStream;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletRequestWrapper;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.servlet.http.HttpServletResponseWrapper;
|
||||
import me.iiv.agent.security.AuthContext;
|
||||
import me.iiv.agent.security.AuthContextHolder;
|
||||
import me.iiv.agent.security.ToolGuard;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.PrintWriter;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* tools/list 按 token 作用域过滤(2026-09-04 决策:避免"看得到却调不了"的二义性)。
|
||||
* MCP SDK 的工具清单在服务启动时静态注册,无法按请求过滤,故在 servlet 层拦截
|
||||
* POST /mcp 中 method=tools/list 的响应,重写 result.tools 负载。
|
||||
* tools/call 路径不变:越权调用仍走 GuardedToolCallback 的可读拒绝 + denied 审计。
|
||||
* 仅拦截 POST(请求-响应式);GET /mcp 的长连接 SSE 流不缓冲。
|
||||
* 必须运行在 AkAuthFilter(@Order(10))之后,才能拿到 AuthContext。
|
||||
*/
|
||||
@Component
|
||||
@Order(20)
|
||||
public class ToolsListScopeFilter extends OncePerRequestFilter {
|
||||
|
||||
private final ToolGuard toolGuard;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
public ToolsListScopeFilter(ToolGuard toolGuard) {
|
||||
this.toolGuard = toolGuard;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
return !(request.getRequestURI().startsWith("/mcp") && "POST".equalsIgnoreCase(request.getMethod()));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
|
||||
throws ServletException, IOException {
|
||||
byte[] body = request.getInputStream().readAllBytes();
|
||||
CachedBodyRequest wrappedRequest = new CachedBodyRequest(request, body);
|
||||
if (!isToolsList(body)) {
|
||||
chain.doFilter(wrappedRequest, response);
|
||||
return;
|
||||
}
|
||||
BufferingResponse wrappedResponse = new BufferingResponse(response);
|
||||
chain.doFilter(wrappedRequest, wrappedResponse);
|
||||
|
||||
byte[] result = wrappedResponse.bodyBytes();
|
||||
AuthContext ctx = AuthContextHolder.get();
|
||||
if (ctx != null && wrappedResponse.status() == 200) {
|
||||
Set<String> allowed = toolGuard.allowedToolNames(ctx.getTokenId());
|
||||
result = ToolsListResponseRewriter
|
||||
.rewrite(new String(result, StandardCharsets.UTF_8), allowed)
|
||||
.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
response.setStatus(wrappedResponse.status());
|
||||
if (wrappedResponse.contentType() != null) {
|
||||
response.setContentType(wrappedResponse.contentType());
|
||||
}
|
||||
response.setContentLength(result.length);
|
||||
response.getOutputStream().write(result);
|
||||
response.flushBuffer();
|
||||
}
|
||||
|
||||
private boolean isToolsList(byte[] body) {
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(body);
|
||||
return "tools/list".equals(node.path("method").asText());
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 可重复读 Body 的请求包装(过滤器预读后继续传给下游)。 */
|
||||
static final class CachedBodyRequest extends HttpServletRequestWrapper {
|
||||
|
||||
private final byte[] body;
|
||||
|
||||
CachedBodyRequest(HttpServletRequest request, byte[] body) {
|
||||
super(request);
|
||||
this.body = body;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServletInputStream getInputStream() {
|
||||
ByteArrayInputStream input = new ByteArrayInputStream(body);
|
||||
return new ServletInputStream() {
|
||||
@Override
|
||||
public boolean isFinished() {
|
||||
return input.available() == 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isReady() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setReadListener(jakarta.servlet.ReadListener readListener) {
|
||||
// 同步读取,无需监听器
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read() {
|
||||
return input.read();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(byte[] b, int off, int len) {
|
||||
return input.read(b, off, len);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public BufferedReader getReader() {
|
||||
return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
/** 全量缓冲响应的包装:下游写完前不向真实响应提交任何字节,便于整体重写。 */
|
||||
static final class BufferingResponse extends HttpServletResponseWrapper {
|
||||
|
||||
private final ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||
private int status = HttpServletResponse.SC_OK;
|
||||
private String contentType;
|
||||
|
||||
BufferingResponse(HttpServletResponse response) {
|
||||
super(response);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServletOutputStream getOutputStream() {
|
||||
return new ServletOutputStream() {
|
||||
@Override
|
||||
public boolean isReady() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setWriteListener(jakarta.servlet.WriteListener writeListener) {
|
||||
// 同步写出,无需监听器
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(int b) {
|
||||
buffer.write(b);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(byte[] b, int off, int len) {
|
||||
buffer.write(b, off, len);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public PrintWriter getWriter() {
|
||||
return new PrintWriter(new java.io.OutputStreamWriter(getOutputStream(), StandardCharsets.UTF_8), true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setStatus(int sc) {
|
||||
this.status = sc;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendError(int sc) {
|
||||
this.status = sc;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendError(int sc, String msg) {
|
||||
this.status = sc;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setContentType(String type) {
|
||||
this.contentType = type;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getContentType() {
|
||||
return contentType;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setContentLength(int len) {
|
||||
// 长度在重写后由外层统一设置
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setContentLengthLong(long len) {
|
||||
// 长度在重写后由外层统一设置
|
||||
}
|
||||
|
||||
@Override
|
||||
public void flushBuffer() {
|
||||
// 缓冲期间禁止提前提交
|
||||
}
|
||||
|
||||
int status() {
|
||||
return status;
|
||||
}
|
||||
|
||||
String contentType() {
|
||||
return contentType;
|
||||
}
|
||||
|
||||
byte[] bodyBytes() throws IOException {
|
||||
return buffer.toByteArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
@@ -17,8 +18,10 @@ import java.util.Map;
|
||||
/**
|
||||
* /mcp 通道 ak 鉴权:Authorization: Bearer <ak> → AuthContext(ThreadLocal)。
|
||||
* 无 ak / ak 无效 → 401 JSON。
|
||||
* @Order(10):必须先于 ToolsListScopeFilter(@Order(20))执行,后者依赖 AuthContext。
|
||||
*/
|
||||
@Component
|
||||
@Order(10)
|
||||
public class AkAuthFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final String MCP_PATH = "/mcp";
|
||||
|
||||
@@ -12,6 +12,7 @@ import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@@ -35,23 +36,28 @@ public class ToolGuard {
|
||||
}
|
||||
|
||||
public boolean isAllowed(Long tokenId, String toolName) {
|
||||
return allowedToolNames(tokenId).contains(toolName);
|
||||
}
|
||||
|
||||
/** token 的作用域并集内、且启用状态的工具名集合(tools/list 过滤用,固定 3 次查询,与工具数无关)。 */
|
||||
public Set<String> allowedToolNames(Long tokenId) {
|
||||
List<Long> scopeIds = enabledScopeIds(tokenId);
|
||||
if (scopeIds.isEmpty()) {
|
||||
return false;
|
||||
return Set.of();
|
||||
}
|
||||
List<ScopeTool> scopeTools = scopeToolMapper.selectList(new LambdaQueryWrapper<ScopeTool>()
|
||||
.eq(ScopeTool::getSoftDelete, 0)
|
||||
.in(ScopeTool::getScopeId, scopeIds));
|
||||
if (scopeTools.isEmpty()) {
|
||||
return false;
|
||||
return Set.of();
|
||||
}
|
||||
List<Long> toolIds = scopeTools.stream().map(ScopeTool::getToolId).distinct().collect(Collectors.toList());
|
||||
Long count = toolMapper.selectCount(new LambdaQueryWrapper<me.iiv.agent.entity.svc.Tool>()
|
||||
.eq(me.iiv.agent.entity.svc.Tool::getSoftDelete, 0)
|
||||
.eq(me.iiv.agent.entity.svc.Tool::getStatus, 1)
|
||||
.eq(me.iiv.agent.entity.svc.Tool::getName, toolName)
|
||||
.in(me.iiv.agent.entity.svc.Tool::getId, toolIds));
|
||||
return count != null && count > 0;
|
||||
return toolMapper.selectList(new LambdaQueryWrapper<me.iiv.agent.entity.svc.Tool>()
|
||||
.eq(me.iiv.agent.entity.svc.Tool::getSoftDelete, 0)
|
||||
.eq(me.iiv.agent.entity.svc.Tool::getStatus, 1)
|
||||
.in(me.iiv.agent.entity.svc.Tool::getId, toolIds)
|
||||
.select(me.iiv.agent.entity.svc.Tool::getName))
|
||||
.stream().map(me.iiv.agent.entity.svc.Tool::getName).collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
/** token 勾选的、且处于启用状态的作用域 id 集合 */
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package me.iiv.agent.mcp;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class ToolsListResponseRewriterTest {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private static String sseEvent(String id, String json) {
|
||||
return "id:" + id + "\nevent:message\ndata:" + json + "\n\n";
|
||||
}
|
||||
|
||||
private static String toolsJson(String... names) {
|
||||
StringBuilder tools = new StringBuilder();
|
||||
for (String name : names) {
|
||||
if (tools.length() > 0) tools.append(',');
|
||||
tools.append("{\"name\":\"").append(name).append("\",\"description\":\"d\"}");
|
||||
}
|
||||
return "{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"tools\":[" + tools + "]}}";
|
||||
}
|
||||
|
||||
private static List<String> toolNames(String body) throws Exception {
|
||||
String data = null;
|
||||
for (String line : body.split("\\n")) {
|
||||
if (line.startsWith("data:")) {
|
||||
data = line.substring(5);
|
||||
}
|
||||
}
|
||||
JsonNode node = MAPPER.readTree(data == null ? body : data);
|
||||
List<String> names = new ArrayList<>();
|
||||
node.path("result").path("tools").forEach(tool -> names.add(tool.path("name").asText()));
|
||||
return names;
|
||||
}
|
||||
|
||||
@Test
|
||||
void filtersToolsInSseEvent() throws Exception {
|
||||
String body = sseEvent("evt-1", toolsJson("agent.list.query", "property.list.query", "sys.ping"));
|
||||
|
||||
String rewritten = ToolsListResponseRewriter.rewrite(body, Set.of("agent.list.query", "sys.ping"));
|
||||
|
||||
assertEquals(List.of("agent.list.query", "sys.ping"), toolNames(rewritten));
|
||||
assertTrue(rewritten.startsWith("id:evt-1\nevent:message\n"), "SSE 元信息行需保留");
|
||||
assertTrue(rewritten.endsWith("\n\n"), "事件末尾空行需保留");
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyAllowanceYieldsEmptyToolList() throws Exception {
|
||||
String body = sseEvent("evt-1", toolsJson("agent.list.query"));
|
||||
|
||||
String rewritten = ToolsListResponseRewriter.rewrite(body, Set.of());
|
||||
|
||||
assertEquals(List.of(), toolNames(rewritten));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonToolsEventsPassThroughUnchanged() {
|
||||
String initEvent = sseEvent("evt-0", "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"protocolVersion\":\"2025-03-26\"}}");
|
||||
String body = initEvent + sseEvent("evt-1", toolsJson("a", "b"));
|
||||
|
||||
String rewritten = ToolsListResponseRewriter.rewrite(body, Set.of("a"));
|
||||
|
||||
assertTrue(rewritten.startsWith(initEvent), "非 tools/list 事件原样保留");
|
||||
}
|
||||
|
||||
@Test
|
||||
void plainJsonResponseIsAlsoFiltered() throws Exception {
|
||||
String body = toolsJson("a", "b");
|
||||
|
||||
String rewritten = ToolsListResponseRewriter.rewrite(body, Set.of("b"));
|
||||
|
||||
assertEquals(List.of("b"), toolNames(rewritten));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unparseableBodyPassesThrough() {
|
||||
String garbage = "not-json-not-sse";
|
||||
assertEquals(garbage, ToolsListResponseRewriter.rewrite(garbage, Set.of("a")));
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,9 @@ import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
@@ -26,6 +28,15 @@ import static org.mockito.Mockito.when;
|
||||
@SuppressWarnings("unchecked")
|
||||
class ToolGuardTest {
|
||||
|
||||
@org.junit.jupiter.api.BeforeAll
|
||||
static void initTableInfo() {
|
||||
// allowedToolNames 的 .select(Tool::getName) 在构建 wrapper 时即解析 lambda 列,需 TableInfo
|
||||
com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
|
||||
new org.apache.ibatis.builder.MapperBuilderAssistant(
|
||||
new com.baomidou.mybatisplus.core.MybatisConfiguration(), ""),
|
||||
me.iiv.agent.entity.svc.Tool.class);
|
||||
}
|
||||
|
||||
@Mock
|
||||
TokenScopeMapper tokenScopeMapper;
|
||||
@Mock
|
||||
@@ -59,7 +70,7 @@ class ToolGuardTest {
|
||||
when(tokenScopeMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(tokenScope(3L)));
|
||||
when(agentScopeMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(scope(3L)));
|
||||
when(scopeToolMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(scopeTool(9L)));
|
||||
when(toolMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(1L);
|
||||
when(toolMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(tool(9L, "agent.list.query")));
|
||||
ToolGuard guard = guard();
|
||||
|
||||
assertTrue(guard.isAllowed(7L, "agent.list.query"));
|
||||
@@ -70,12 +81,24 @@ class ToolGuardTest {
|
||||
when(tokenScopeMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(tokenScope(3L)));
|
||||
when(agentScopeMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(scope(3L)));
|
||||
when(scopeToolMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(scopeTool(9L)));
|
||||
when(toolMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(0L);
|
||||
when(toolMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of());
|
||||
ToolGuard guard = guard();
|
||||
|
||||
assertFalse(guard.isAllowed(7L, "agent.list.query"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void allowedToolNamesReturnsEnabledNamesOnly() {
|
||||
when(tokenScopeMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(tokenScope(3L)));
|
||||
when(agentScopeMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(scope(3L)));
|
||||
when(scopeToolMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(scopeTool(9L)));
|
||||
when(toolMapper.selectList(any(LambdaQueryWrapper.class)))
|
||||
.thenReturn(List.of(tool(9L, "agent.list.query"), tool(10L, "sys.ping")));
|
||||
ToolGuard guard = guard();
|
||||
|
||||
assertEquals(Set.of("agent.list.query", "sys.ping"), guard.allowedToolNames(7L));
|
||||
}
|
||||
|
||||
private ToolGuard guard() {
|
||||
return new ToolGuard(tokenScopeMapper, scopeToolMapper, toolMapper, agentScopeMapper);
|
||||
}
|
||||
@@ -97,4 +120,11 @@ class ToolGuardTest {
|
||||
scopeTool.setToolId(toolId);
|
||||
return scopeTool;
|
||||
}
|
||||
|
||||
private static me.iiv.agent.entity.svc.Tool tool(Long id, String name) {
|
||||
me.iiv.agent.entity.svc.Tool tool = new me.iiv.agent.entity.svc.Tool();
|
||||
tool.setId(id);
|
||||
tool.setName(name);
|
||||
return tool;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ xq CLI ─────┘ (Go 单二进制, 同为 MCP 客户端) ┌──
|
||||
4. 执行查询,按"人"的数据权限规则加行过滤
|
||||
5. 审计落库(人、token、工具、入参摘要、结果、耗时)
|
||||
|
||||
注:`tools/list` 不按作用域过滤(全量已注册工具对所有 ak 可见),第 3 步的强制点在 `tools/call`——越权调用得到的是可读的拒绝信息 + denied 审计,而不是"工具不存在"。
|
||||
注:`tools/list` 按作用域过滤(2026-09-04 决策,避免"看得到却调不了"的二义性):MCP SDK 工具清单在启动时静态注册,无法按请求过滤,故由 `ToolsListScopeFilter` 在 servlet 层重写 tools/list 响应。第 3 步的强制点仍是 `tools/call`——绕过 list 直接调未授权工具,得到的仍是可读的拒绝信息 + denied 审计。
|
||||
|
||||
### 3.3 行级数据权限移植规范(照搬现有系统)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|---|---|
|
||||
| 端点 | `POST http://<host>:8088/mcp`(MCP Streamable HTTP) |
|
||||
| 认证 | Header `Authorization: Bearer xqak_xxx`,ak 在管理后台「用户与 Token」签发,服务端只存 SHA-256 |
|
||||
| 工具可见性 | `tools/list` 返回全部已注册工具(**不按作用域过滤**);作用域在 `tools/call` 时服务端强制校验,越权返回可读拒绝信息并落审计(denied) |
|
||||
| 工具可见性 | `tools/list` 按 ak 作用域过滤,只返回该 ak 作用域并集内且启用状态的工具(2026-09-04 起,服务端 servlet 层重写响应实现);`tools/call` 另有强制校验兜底,越权返回可读拒绝信息并落审计(denied) |
|
||||
| 行级数据权限 | 跟 ak 所属人走,照搬老系统 `getByIsAgent` 语义(见设计文档 §3.3),与作用域无关 |
|
||||
| 分页 | 列表类工具统一 `page`(从 1 开始,默认 1)、`rows`(默认 20,**上限 100**,超出截断为 100 并在返回 `notice` 字段提示) |
|
||||
| 分页出参 | 统一含 `total`(总数)、`page`、`rows`、`records`(本页记录)、`notice`(仅截断时出现) |
|
||||
|
||||
Reference in New Issue
Block a user