diff --git a/xiaoqu-agent-service/README.md b/xiaoqu-agent-service/README.md index e19244e..da22bd6 100644 --- a/xiaoqu-agent-service/README.md +++ b/xiaoqu-agent-service/README.md @@ -240,6 +240,8 @@ curl -X POST http://localhost:8088/api/open/token/ensure \ - 调用写 `audit_log`(`open.token.ensure` / client_type=open-api,不记录明文)。 - 管理后台支持服务密钥列表、创建、编辑作用域、吊销/启用和轮换;吊销或轮换后旧密钥立即无法通过 `/api/open/**` 鉴权。 +**服务 Token(principalType=service)**:`serviceName` 必填;只能绑定「允许服务主体」的作用域(`agent_scope.service_enabled=1`,REQ-20260921-0004 起由标记驱动,取代原先的 sync 硬编码),旧版不限域密钥不可签发服务 Token。服务 Token 无用户身份:调 SYNC 变化流工具为全量;调在线查询工具按**总部视角全量可见、不做行过滤**(PermissionService 统一分支),走独立限流档 `app.rate-limit.service-query-per-minute`(默认 300/分钟);`warehouse.todo.list.query` 按当前登录人统计,服务身份调用返回 PERMISSION_DENIED。典型用法:运营后台免登录分页拉取全量数据(scope `ops-query`)。 + **签发规则(2026-09-04 起)**:一作用域一 Token。后台签发时选 N 个作用域会生成 N 把 token,每把独立绑定单作用域;编辑存量 token 时作用域为单选。 ## codegen(run-once) diff --git a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md index b131a2c..8733420 100644 --- a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md +++ b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md @@ -10,7 +10,7 @@ | 端点 | `POST http://:8088/mcp`(MCP Streamable HTTP) | | 认证 | Header `Authorization: Bearer xqak_xxx`,Token 由管理后台或受信任 Open API 获取;服务端保存 SHA-256 及加密密文 | | 工具可见性 | `tools/list` 按 ak 作用域过滤,只返回该 ak 作用域并集内且启用状态的工具(2026-09-04 起,服务端 servlet 层重写响应实现);`tools/call` 另有强制校验兜底,越权返回可读拒绝信息并落审计(denied) | -| 行级数据权限 | 跟 ak 所属人走,照搬老系统 `getByIsAgent` 语义(见设计文档 §3.3),与作用域无关 | +| 行级数据权限 | 跟 ak 所属人走,照搬老系统 `getByIsAgent` 语义(见设计文档 §3.3),与作用域无关;**tenant_service 服务主体无用户上下文,按总部视角全量可见**(见 §0.3) | | 分页 | 列表类工具统一 `page`(从 1 开始,默认 1)、`rows`(默认 20,**上限 100**,超出截断为 100 并在返回 `notice` 字段提示) | | 分页出参 | 统一含 `total`(总数)、`page`、`rows`、`records`(本页记录)、`notice`(仅截断时出现) | | 工具声明 | `tools/list` 中每个工具带 MCP annotations:只读(`readOnlyHint=true`)、幂等(`idempotentHint=true`)、非破坏性(`destructiveHint=false`)(REQ-20260911-0011 起) | @@ -48,6 +48,16 @@ 日志与审计不记录 Token/ak,手机号一律脱敏为 `138****5678` 形式。 +### 0.3 服务身份(tenant_service)在线查询(REQ-20260921-0004 起) + +服务 Token(`principalType=service`,无登录身份)除 SYNC 变化流外,可绑定「允许服务主体」的查询作用域(`agent_scope.service_enabled=1`,如运营用 `ops-query`)调在线查询工具: + +- **可见范围**:所有走行级权限的查询工具对服务主体统一按**总部视角全量可见、不做行过滤**(PermissionService 统一分支);用户身份调用行为完全不变。 +- **签发**:`POST /api/open/token/ensure` 传 `principalType=service` + `serviceName`,scopeCode 必须是 service_enabled=1 的作用域,否则 403;服务密钥需事先绑定该作用域。 +- **限流**:服务主体的在线查询走独立 SERVICE-QUERY 档位(`app.rate-limit.service-query-per-minute`,默认 300/分钟),与用户 QUERY、SYNC 档位互不占配额;调 `*.changes.list` 仍走 SYNC 档。 +- **例外**:`warehouse.todo.list.query` 按"当前登录人"统计待办,服务身份无此语义,调用返回 PERMISSION_DENIED,不应绑入服务查询作用域。 +- 审计照常落 `audit_log`(token_id + serviceName 可溯源),密钥泄露时在后台单独轮换/吊销。 + --- ## 1. agent.count diff --git a/xiaoqu-agent-service/front/src/App.vue b/xiaoqu-agent-service/front/src/App.vue index ae9c6ec..9217cc1 100644 --- a/xiaoqu-agent-service/front/src/App.vue +++ b/xiaoqu-agent-service/front/src/App.vue @@ -36,7 +36,7 @@ const clientDialog = ref(false) const clientForm = reactive({ id: null, name: '', memo: '', scopeIds: [] }) const issuedOpenKey = ref(null) const scopeDialog = ref(false) -const scopeForm = reactive({ id: null, name: '', code: '', memo: '' }) +const scopeForm = reactive({ id: null, name: '', code: '', memo: '', serviceEnabled: false }) const scopeToolsDialog = ref(false) const activeScope = ref(null) const selectedToolIds = ref([]) @@ -267,15 +267,15 @@ async function setToolStatus(tool) { async function loadScopes() { scopes.value = await api('/scopes') } function openScope(scope = null) { - Object.assign(scopeForm, scope ? { id: scope.id, name: scope.name, code: scope.code, memo: scope.memo || '' } - : { id: null, name: '', code: '', memo: '' }) + Object.assign(scopeForm, scope ? { id: scope.id, name: scope.name, code: scope.code, memo: scope.memo || '', serviceEnabled: scope.serviceEnabled === 1 } + : { id: null, name: '', code: '', memo: '', serviceEnabled: false }) scopeDialog.value = true } async function saveScope() { if (!scopeForm.name.trim() || !scopeForm.code.trim()) return ElMessage.warning('请填写名称和编码') await run(async () => { - const body = JSON.stringify({ name: scopeForm.name, code: scopeForm.code, memo: scopeForm.memo }) + const body = JSON.stringify({ name: scopeForm.name, code: scopeForm.code, memo: scopeForm.memo, serviceEnabled: scopeForm.serviceEnabled }) if (scopeForm.id) await api(`/scopes/${scopeForm.id}`, { method: 'PUT', body }) else await api('/scopes', { method: 'POST', body }) scopeDialog.value = false; await loadScopes(); ElMessage.success('作用域已保存') @@ -495,6 +495,7 @@ onMounted(async () => { +