feat(open): 服务身份免登录分页查询(REQ-20260921-0004) #26
@@ -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)
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
| 端点 | `POST http://<host>: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
|
||||
|
||||
@@ -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 () => {
|
||||
<el-table-column prop="memo" label="备注" min-width="220" />
|
||||
<el-table-column label="工具数" width="90"><template #default="{ row }">{{ row.toolCount }} 个</template></el-table-column>
|
||||
<el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">{{ row.status === 1 ? '启用' : '已停用' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="服务主体" width="90"><template #default="{ row }"><el-tag v-if="row.serviceEnabled === 1" type="warning" size="small">允许</el-tag><span v-else>—</span></template></el-table-column>
|
||||
<el-table-column label="更新时间" min-width="150"><template #default="{ row }">{{ date(row.modifyTime) }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="255"><template #default="{ row }">
|
||||
<el-button link type="primary" :data-testid="`admin-scope-tools-${row.id}`" @click="openScopeTools(row)">配置工具</el-button>
|
||||
@@ -562,6 +563,7 @@ onMounted(async () => {
|
||||
<el-form-item label="名称"><el-input v-model="scopeForm.name" maxlength="64" aria-label="作用域名称" data-testid="admin-scope-name" /></el-form-item>
|
||||
<el-form-item label="编码"><el-input v-model="scopeForm.code" maxlength="64" placeholder="如 operation_read" aria-label="作用域编码" data-testid="admin-scope-code" /></el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="scopeForm.memo" type="textarea" maxlength="255" aria-label="作用域备注" data-testid="admin-scope-memo" /></el-form-item>
|
||||
<el-form-item label="服务主体"><el-switch v-model="scopeForm.serviceEnabled" data-testid="admin-scope-service-enabled" /><span class="hint" style="margin-left:8px">允许服务 Token(无登录身份)绑定签发,其调用查询工具时全量可见</span></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button data-testid="admin-scope-cancel" @click="scopeDialog = false">取消</el-button><el-button type="primary" data-testid="admin-scope-save" @click="saveScope">保存</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE agent_scope DROP COLUMN service_enabled;
|
||||
@@ -36,8 +36,9 @@ WHERE s.code = 'hardware' AND t.name IN ('hardware.list.query', 'hardware.detail
|
||||
-- 作用域:sync(SYNC 增量同步;changes 工具由 REQ-20260911-0013 注册并绑定到本作用域)
|
||||
-- 个人 Token 禁调 SYNC 工具靠 token_scope 白名单天然生效:个人 Token 不绑定本作用域即 PERMISSION_DENIED,
|
||||
-- 无需任何拦截代码。只为服务 Token(principal_type=service)勾选 sync 作用域。
|
||||
INSERT INTO agent_scope (name, code, memo, status, create_id)
|
||||
SELECT '数据同步', 'sync', 'SYNC 增量同步作用域(5628 注册 *.changes.list 工具后绑定,仅服务 Token 使用)', 1, 0
|
||||
-- service_enabled=1:允许服务主体签发(REQ-20260921-0004 起签发校验由该标记驱动,V7 迁移只覆盖存量行)。
|
||||
INSERT INTO agent_scope (name, code, memo, status, service_enabled, create_id)
|
||||
SELECT '数据同步', 'sync', 'SYNC 增量同步作用域(5628 注册 *.changes.list 工具后绑定,仅服务 Token 使用)', 1, 1, 0
|
||||
WHERE NOT EXISTS (SELECT 1 FROM agent_scope WHERE code = 'sync');
|
||||
|
||||
-- sync 作用域-工具:服务 Token 自检、三条变化流及发送前合同复核
|
||||
@@ -46,6 +47,18 @@ SELECT s.id, t.id, 0 FROM agent_scope s JOIN tool t
|
||||
WHERE s.code = 'sync' AND t.name IN ('integration.context.get', 'users.changes.list', 'properties.changes.list', 'contracts.changes.list', 'contracts.current.batch_get')
|
||||
AND NOT EXISTS (SELECT 1 FROM scope_tool st WHERE st.scope_id = s.id AND st.tool_id = t.id);
|
||||
|
||||
-- 作用域:ops-query(REQ-20260921-0004 服务身份免登录分页查询)
|
||||
-- service_enabled=1 允许 tenant_service 服务 Token 签发;服务主体调查询工具按总部视角全量可见。
|
||||
-- 只放查询类工具;warehouse.todo.list.query 按当前登录人统计,服务身份无语义,不绑。
|
||||
INSERT INTO agent_scope (name, code, memo, status, service_enabled, create_id)
|
||||
SELECT '运营查询', 'ops-query', 'dev 种子:服务身份免登录分页查询作用域(tenant_service 全量可见)', 1, 1, 0
|
||||
WHERE NOT EXISTS (SELECT 1 FROM agent_scope WHERE code = 'ops-query');
|
||||
|
||||
INSERT INTO scope_tool (scope_id, tool_id, create_id)
|
||||
SELECT s.id, t.id, 0 FROM agent_scope s JOIN tool t
|
||||
WHERE s.code = 'ops-query' AND t.name IN ('sys.ping', 'integration.context.get', 'agent.count', 'agent.list.query', 'agent.detail.query', 'property.list.query', 'property.detail.query', 'district.list.query', 'district.detail.get', 'district.paper.statistics.query', 'hardware.list.query', 'hardware.detail.query', 'contract.list.query', 'contract.detail.query', 'mall.order.list.query', 'mall.order.detail.query', 'mall.order.logistics.query', 'install.order.list.query', 'install.order.detail.query', 'delivery.order.list.query', 'delivery.order.detail.query', 'pickup.order.list.query', 'pickup.order.detail.query', 'relocation.order.list.query', 'relocation.order.detail.query', 'withdraw.order.list.query', 'withdraw.order.detail.query', 'warehouse.in.list.query', 'warehouse.in.detail.query', 'warehouse.out.list.query', 'warehouse.out.detail.query', 'warehouse.stock.list.query')
|
||||
AND NOT EXISTS (SELECT 1 FROM scope_tool st WHERE st.scope_id = s.id AND st.tool_id = t.id);
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- 校验
|
||||
|
||||
@@ -214,7 +214,8 @@ public class AdminConsoleController {
|
||||
.eq(ScopeTool::getSoftDelete, 0)).stream()
|
||||
.collect(Collectors.groupingBy(ScopeTool::getScopeId, Collectors.counting()));
|
||||
return scopes.stream().map(scope -> new ScopeView(scope.getId(), scope.getName(), scope.getCode(),
|
||||
scope.getMemo(), scope.getStatus(), scope.getModifyTime(), counts.getOrDefault(scope.getId(), 0L))).toList();
|
||||
scope.getMemo(), scope.getStatus(), scope.getServiceEnabled(), scope.getModifyTime(),
|
||||
counts.getOrDefault(scope.getId(), 0L))).toList();
|
||||
}
|
||||
|
||||
@PostMapping("/scopes")
|
||||
@@ -228,7 +229,7 @@ public class AdminConsoleController {
|
||||
scope.setSoftDelete((byte) 0);
|
||||
scopeMapper.insert(scope);
|
||||
return new ScopeView(scope.getId(), scope.getName(), scope.getCode(), scope.getMemo(),
|
||||
scope.getStatus(), scope.getModifyTime(), 0);
|
||||
scope.getStatus(), scope.getServiceEnabled(), scope.getModifyTime(), 0);
|
||||
}
|
||||
|
||||
@PutMapping("/scopes/{id}")
|
||||
@@ -372,6 +373,7 @@ public class AdminConsoleController {
|
||||
scope.setName(body.name().trim());
|
||||
scope.setCode(body.code().trim());
|
||||
scope.setMemo(body.memo() == null ? null : body.memo().trim());
|
||||
scope.setServiceEnabled(body.serviceEnabled() != null && body.serviceEnabled() ? (byte) 1 : (byte) 0);
|
||||
scope.setModifyId(adminId);
|
||||
}
|
||||
|
||||
@@ -407,8 +409,8 @@ public class AdminConsoleController {
|
||||
public record ToolView(Long id, String name, String category, String description, String kind,
|
||||
Byte status, Date modifyTime, List<String> scopeNames) {}
|
||||
public record ToolPageView(long total, int page, int pageSize, List<ToolView> records) {}
|
||||
public record ScopeRequest(String name, String code, String memo) {}
|
||||
public record ScopeView(Long id, String name, String code, String memo, Byte status,
|
||||
public record ScopeRequest(String name, String code, String memo, Boolean serviceEnabled) {}
|
||||
public record ScopeView(Long id, String name, String code, String memo, Byte status, Byte serviceEnabled,
|
||||
Date modifyTime, long toolCount) {}
|
||||
public record ToolIdsRequest(List<Long> toolIds) {}
|
||||
public record AuditView(Long id, Date createTime, Long userId, String userName, String tokenPrefix,
|
||||
|
||||
@@ -52,6 +52,11 @@ public class AgentScope implements Serializable {
|
||||
*/
|
||||
private Byte status;
|
||||
|
||||
/**
|
||||
* 1=允许服务主体(tenant_service)Token 绑定签发(REQ-20260921-0004)
|
||||
*/
|
||||
private Byte serviceEnabled;
|
||||
|
||||
/**
|
||||
* 创建人
|
||||
*/
|
||||
|
||||
@@ -82,7 +82,7 @@ public class GuardedToolCallback implements ToolCallback {
|
||||
throw denied;
|
||||
}
|
||||
try {
|
||||
rateLimitService.check(ctx.getTokenId(), toolName);
|
||||
rateLimitService.check(ctx, toolName);
|
||||
rejectForbiddenSyncParams(toolName, toolInput);
|
||||
String result = toolContext == null ? delegate.call(toolInput) : delegate.call(toolInput, toolContext);
|
||||
auditService.record(ctx, toolName, toolInput, "success", null, System.currentTimeMillis() - start);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package me.iiv.agent.mcp;
|
||||
|
||||
import me.iiv.agent.entity.svc.AgentApiKey;
|
||||
import me.iiv.agent.security.AuthContext;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@@ -12,7 +14,8 @@ import java.time.Instant;
|
||||
|
||||
/**
|
||||
* 工具调用限流(REQ-20260911-0013 资源隔离):按 Token 分档计数——
|
||||
* 后台数据工具走 SYNC 档位,其余走 QUERY 档位,互不占配额。
|
||||
* 后台数据工具走 SYNC 档位,tenant_service 服务主体的在线查询走 SERVICE-QUERY 档位
|
||||
* (REQ-20260921-0004,与用户查询隔离),其余走 QUERY 档位,互不占配额。
|
||||
* Redis 固定窗口(分钟),超额抛 RATE_LIMITED;Redis 不可用时放行并告警
|
||||
* (限流是保护措施,不应成为可用性单点)。
|
||||
* 配额 0 或负数 = 关闭该档位。
|
||||
@@ -25,25 +28,31 @@ public class RateLimitService {
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
private final int queryPerMinute;
|
||||
private final int syncPerMinute;
|
||||
private final int serviceQueryPerMinute;
|
||||
|
||||
public RateLimitService(StringRedisTemplate redisTemplate,
|
||||
@Value("${app.rate-limit.query-per-minute:600}") int queryPerMinute,
|
||||
@Value("${app.rate-limit.sync-per-minute:120}") int syncPerMinute) {
|
||||
@Value("${app.rate-limit.sync-per-minute:120}") int syncPerMinute,
|
||||
@Value("${app.rate-limit.service-query-per-minute:300}") int serviceQueryPerMinute) {
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.queryPerMinute = queryPerMinute;
|
||||
this.syncPerMinute = syncPerMinute;
|
||||
this.serviceQueryPerMinute = serviceQueryPerMinute;
|
||||
}
|
||||
|
||||
public void check(Long tokenId, String toolName) {
|
||||
if (tokenId == null) {
|
||||
public void check(AuthContext ctx, String toolName) {
|
||||
if (ctx == null || ctx.getTokenId() == null) {
|
||||
return;
|
||||
}
|
||||
Long tokenId = ctx.getTokenId();
|
||||
boolean sync = IntegrationContextService.isSyncDataTool(toolName);
|
||||
int limit = sync ? syncPerMinute : queryPerMinute;
|
||||
boolean service = !sync && ctx.getToken() != null
|
||||
&& AgentApiKey.PRINCIPAL_TYPE_SERVICE.equals(ctx.getToken().getPrincipalType());
|
||||
String tier = sync ? "sync" : service ? "service-query" : "query";
|
||||
int limit = sync ? syncPerMinute : service ? serviceQueryPerMinute : queryPerMinute;
|
||||
if (limit <= 0) {
|
||||
return;
|
||||
}
|
||||
String tier = sync ? "sync" : "query";
|
||||
String key = "xq:rl:" + tier + ":" + tokenId + ":" + Instant.now().getEpochSecond() / 60;
|
||||
try {
|
||||
Long count = redisTemplate.opsForValue().increment(key);
|
||||
|
||||
@@ -8,6 +8,8 @@ import me.iiv.agent.entity.m2.WarehouseOutProcess;
|
||||
import me.iiv.agent.mapper.m2.WarehouseAllocationProcessMapper;
|
||||
import me.iiv.agent.mapper.m2.WarehouseInProcessMapper;
|
||||
import me.iiv.agent.mapper.m2.WarehouseOutProcessMapper;
|
||||
import me.iiv.agent.mcp.McpErrorCode;
|
||||
import me.iiv.agent.mcp.ToolCallException;
|
||||
import me.iiv.agent.mcp.ToolObject;
|
||||
import me.iiv.agent.mcp.XqTool;
|
||||
import me.iiv.agent.security.AuthContextHolder;
|
||||
@@ -84,7 +86,8 @@ public class WarehouseTodoTools implements ToolObject {
|
||||
+ "todoOnly 默认 true(只返回 schedule_state=1 真待办),false 返回当前用户参与过的全部单据。"
|
||||
+ "orderState 按单据状态过滤:出库/入库单 0待审核 1被驳回 2待出库/待入库 3已完成 4已撤销 5作废;"
|
||||
+ "调拨类 0待审核 1被驳回 2待出库 3待入库 4已完成 5已撤销 6作废。"
|
||||
+ "分页 page 默认1,rows 默认20、上限100,超出截断为 100 并在 notice 提示。")
|
||||
+ "分页 page 默认1,rows 默认20、上限100,超出截断为 100 并在 notice 提示。"
|
||||
+ "服务身份(tenant_service)无当前登录人语义,调用返回 PERMISSION_DENIED。")
|
||||
public Map<String, Object> warehouseTodoListQuery(
|
||||
@ToolParam(required = false, description = "单据类型:out/in/allocation/all,默认 all。allocation 涵盖调拨/采购/划转") String docType,
|
||||
@ToolParam(required = false, description = "true=只返回 schedule_state=1 真待办(默认);false=当前用户参与过的全部单据") Boolean todoOnly,
|
||||
@@ -102,6 +105,10 @@ public class WarehouseTodoTools implements ToolObject {
|
||||
int pageSize = rows == null || rows < 1 ? DEFAULT_ROWS : Math.min(rows, MAX_ROWS);
|
||||
|
||||
User user = AuthContextHolder.get().getUser();
|
||||
if (user == null) {
|
||||
throw new ToolCallException(McpErrorCode.PERMISSION_DENIED,
|
||||
"该工具按当前登录人统计待办,仅支持用户身份 Token 调用");
|
||||
}
|
||||
Long userId = user.getId();
|
||||
|
||||
List<TodoStub> stubs = new ArrayList<>();
|
||||
|
||||
@@ -24,6 +24,7 @@ import java.util.Date;
|
||||
* 历史数据无密文的 Token 自动轮换(禁用旧把、签发新把);
|
||||
* 不存在则自动创建永久 Token。两条路径均返回明文。
|
||||
* service 类型(REQ-20260911-0014):要求服务密钥已在后台或配置中绑定该作用域,
|
||||
* 且作用域标记为允许服务主体(agent_scope.service_enabled=1,REQ-20260921-0004 取代 sync 硬编码),
|
||||
* 旧版不限域密钥(app.open.api-key)不可签发服务 Token。
|
||||
*/
|
||||
@Service
|
||||
@@ -32,7 +33,7 @@ public class OpenTokenService {
|
||||
/** 系统签发(Open API 自动创建)的 createId 约定值 */
|
||||
static final long SYSTEM_CREATOR_ID = 0L;
|
||||
static final String AUDIT_TOOL = "open.token.ensure";
|
||||
/** SYNC 作用域编码:服务 Token 只能绑定该作用域,用户 Token 不能绑定(REQ-20260911-0014) */
|
||||
/** SYNC 作用域编码:用户 Token 不能绑定(服务侧放行由 service_enabled 标记驱动) */
|
||||
static final String SYNC_SCOPE_CODE = "sync";
|
||||
|
||||
private final UserMapper userMapper;
|
||||
@@ -70,8 +71,8 @@ public class OpenTokenService {
|
||||
bad(HttpStatus.BAD_REQUEST, "作用域不存在或已停用");
|
||||
}
|
||||
checkScopeBinding(client, scope, service);
|
||||
if (service && !SYNC_SCOPE_CODE.equals(scope.getCode())) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "服务 Token 只能绑定 SYNC 作用域");
|
||||
if (service && !Byte.valueOf((byte) 1).equals(scope.getServiceEnabled())) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "服务 Token 只能绑定允许服务主体的作用域");
|
||||
}
|
||||
if (!service && SYNC_SCOPE_CODE.equals(scope.getCode())) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "SYNC 作用域仅允许服务 Token");
|
||||
|
||||
@@ -19,11 +19,14 @@ import me.iiv.agent.entity.m2.WithdrawOrder;
|
||||
import me.iiv.agent.entity.m2.WarehouseIn;
|
||||
import me.iiv.agent.entity.m2.WarehouseOut;
|
||||
import me.iiv.agent.entity.m2.WarehouseStockManage;
|
||||
import me.iiv.agent.entity.svc.AgentApiKey;
|
||||
import me.iiv.agent.mapper.m0.AgentManageDistrictAuthMapper;
|
||||
import me.iiv.agent.mapper.m0.AgentMapper;
|
||||
import me.iiv.agent.mapper.m0.AuthorityMapper;
|
||||
import me.iiv.agent.mapper.m0.ProperytMapper;
|
||||
import me.iiv.agent.mapper.m2.WarehouseAgentAuthMapper;
|
||||
import me.iiv.agent.security.AuthContext;
|
||||
import me.iiv.agent.security.AuthContextHolder;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
@@ -40,6 +43,8 @@ import java.util.stream.Collectors;
|
||||
* 行级数据权限引擎:移植老系统 WrapperUtils.getByIsAgent 系列语义
|
||||
* (xiaoqu-franchisee utils/WrapperUtils.java :652-731 及 :382-427)。
|
||||
* 输出中性的 id 集合,调用方自行拼 wrapper.in("agent_id", ids);不做跨库 JOIN。
|
||||
* REQ-20260921-0004:tenant_service 服务主体(user==null)按总部视角全量可见,
|
||||
* 各公开入口首行统一走 servicePrincipalNoFilter 分支;用户身份行为不变。
|
||||
*/
|
||||
@Component
|
||||
public class PermissionService {
|
||||
@@ -70,6 +75,23 @@ public class PermissionService {
|
||||
this.redisTemplate = redisTemplate;
|
||||
}
|
||||
|
||||
/**
|
||||
* tenant_service 服务主体判定(REQ-20260921-0004):服务 Token 无用户上下文(user==null),
|
||||
* 按总部视角全量可见、不做行过滤,返回 true。user 非 null 返回 false 走既有用户语义。
|
||||
* user==null 且非服务主体说明鉴权上下文丢失,拒绝访问(不放行)。
|
||||
*/
|
||||
private boolean servicePrincipalNoFilter(User user) {
|
||||
if (user != null) {
|
||||
return false;
|
||||
}
|
||||
AuthContext ctx = AuthContextHolder.get();
|
||||
if (ctx != null && ctx.getToken() != null
|
||||
&& AgentApiKey.PRINCIPAL_TYPE_SERVICE.equals(ctx.getToken().getPrincipalType())) {
|
||||
return true;
|
||||
}
|
||||
throw new IllegalStateException("缺少用户身份上下文,拒绝访问业务数据");
|
||||
}
|
||||
|
||||
/** 权限组 isagent=1 → 代理商视角;未知或已删除的权限组拒绝访问。 */
|
||||
public boolean isAgentAuthority(Long authorityId) {
|
||||
if (authorityId == null) {
|
||||
@@ -93,10 +115,13 @@ public class PermissionService {
|
||||
* 未传 id 时保持老页面语义,只列直属下级。
|
||||
*/
|
||||
public void applyAgentListRowFilter(LambdaQueryWrapper<Agent> wrapper, User user, Long id) {
|
||||
boolean agentAuthority = isAgentAuthority(user.getAuthorityId());
|
||||
if (id != null) {
|
||||
wrapper.eq(Agent::getId, id);
|
||||
}
|
||||
if (servicePrincipalNoFilter(user)) {
|
||||
return;
|
||||
}
|
||||
boolean agentAuthority = isAgentAuthority(user.getAuthorityId());
|
||||
if (!agentAuthority) {
|
||||
return;
|
||||
}
|
||||
@@ -112,11 +137,14 @@ public class PermissionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 可见 agent id 集合。返回 null 表示不过滤(isagent != 1,总部/管理员看全部)。
|
||||
* 可见 agent id 集合。返回 null 表示不过滤(isagent != 1 总部/管理员,或 tenant_service 服务主体,看全部)。
|
||||
* isagent=1 时:自己 agentId + 直属下级代理(agent.supervisor_id=自己,只取一层)
|
||||
* + m2 warehouse_agent_auth 中该用户被授权的 manager_agent_id(代管扩展)。
|
||||
*/
|
||||
public List<Long> visibleAgentIds(User user) {
|
||||
if (servicePrincipalNoFilter(user)) {
|
||||
return null;
|
||||
}
|
||||
if (!isAgentAuthority(user.getAuthorityId())) {
|
||||
return null;
|
||||
}
|
||||
@@ -205,6 +233,9 @@ public class PermissionService {
|
||||
* 列表和详情必须复用本方法,避免详情绕过行权限。
|
||||
*/
|
||||
public void applyDeliveryOrderRowFilter(LambdaQueryWrapper<DeliveryOrder> wrapper, User user) {
|
||||
if (servicePrincipalNoFilter(user)) {
|
||||
return;
|
||||
}
|
||||
if (!isAgentAuthority(user.getAuthorityId())) {
|
||||
return;
|
||||
}
|
||||
@@ -255,6 +286,9 @@ public class PermissionService {
|
||||
* 列表和详情复用本方法,防止详情接口绕过行权限。
|
||||
*/
|
||||
public void applyPickupOrderRowFilter(LambdaQueryWrapper<PickupOrder> wrapper, User user) {
|
||||
if (servicePrincipalNoFilter(user)) {
|
||||
return;
|
||||
}
|
||||
if (!isAgentAuthority(user.getAuthorityId())) {
|
||||
return;
|
||||
}
|
||||
@@ -306,6 +340,9 @@ public class PermissionService {
|
||||
* 销售非负责人按自己创建和代管代理物业过滤。流程参与者及 type=2 的移机/协同人员始终可见。
|
||||
*/
|
||||
public void applyRelocationOrderRowFilter(LambdaQueryWrapper<RelocationOrder> wrapper, User user) {
|
||||
if (servicePrincipalNoFilter(user)) {
|
||||
return;
|
||||
}
|
||||
if (!isAgentAuthority(user.getAuthorityId())) {
|
||||
return;
|
||||
}
|
||||
@@ -366,6 +403,9 @@ public class PermissionService {
|
||||
* 空集塞 0。列表和详情必须复用本方法,老系统 lookDetails 无权限校验,MCP 侧必须补齐。
|
||||
*/
|
||||
public void applyWithdrawOrderRowFilter(LambdaQueryWrapper<WithdrawOrder> wrapper, User user) {
|
||||
if (servicePrincipalNoFilter(user)) {
|
||||
return;
|
||||
}
|
||||
if (!isAgentAuthority(user.getAuthorityId())) {
|
||||
return;
|
||||
}
|
||||
@@ -419,6 +459,9 @@ public class PermissionService {
|
||||
* 与通用物业权限不同,本规则不包含直属下级代理。
|
||||
*/
|
||||
public void applyInstallOrderRowFilter(LambdaQueryWrapper<InstallOrder> wrapper, User user) {
|
||||
if (servicePrincipalNoFilter(user)) {
|
||||
return;
|
||||
}
|
||||
if (!isAgentAuthority(user.getAuthorityId())) {
|
||||
return;
|
||||
}
|
||||
@@ -481,6 +524,9 @@ public class PermissionService {
|
||||
* 代理可看本公司及 warehouse_agent_auth 授权代管公司作为管理公司或所属公司的库存。
|
||||
*/
|
||||
public void applyWarehouseStockRowFilter(LambdaQueryWrapper<WarehouseStockManage> wrapper, User user) {
|
||||
if (servicePrincipalNoFilter(user)) {
|
||||
return;
|
||||
}
|
||||
if (!isAgentAuthority(user.getAuthorityId())) {
|
||||
return;
|
||||
}
|
||||
@@ -578,6 +624,9 @@ public class PermissionService {
|
||||
* 销售非负责人规则 2026-09-03 用户决策:1:1 复刻老代码(不再按 §3.3 收窄为 AND create_id=自己)。
|
||||
*/
|
||||
public void applyPropertyRowFilter(LambdaQueryWrapper<Properyt> wrapper, User user) {
|
||||
if (servicePrincipalNoFilter(user)) {
|
||||
return;
|
||||
}
|
||||
if (!isAgentAuthority(user.getAuthorityId())) {
|
||||
return;
|
||||
}
|
||||
@@ -636,6 +685,9 @@ public class PermissionService {
|
||||
* </ol>
|
||||
*/
|
||||
public void applyOrderRowFilter(LambdaQueryWrapper<MallGoodsOrder> wrapper, User user) {
|
||||
if (servicePrincipalNoFilter(user)) {
|
||||
return;
|
||||
}
|
||||
if (!isAgentAuthority(user.getAuthorityId())) {
|
||||
return;
|
||||
}
|
||||
@@ -697,6 +749,10 @@ public class PermissionService {
|
||||
*/
|
||||
private RowScope resolveRowScope(User user) {
|
||||
RowScope scope = new RowScope();
|
||||
if (servicePrincipalNoFilter(user)) {
|
||||
scope.headquarters = true;
|
||||
return scope;
|
||||
}
|
||||
if (!isAgentAuthority(user.getAuthorityId())) {
|
||||
scope.headquarters = true;
|
||||
return scope;
|
||||
|
||||
@@ -12,6 +12,10 @@ app:
|
||||
- key: xqok_391b373047456e313a999ae0ee81f909b1750d508daab39c
|
||||
name: dev-sync-service
|
||||
scopes: [sync]
|
||||
# dev-ops-service:服务身份免登录分页查询冒烟(REQ-20260921-0004,ops-query 由 seed-dev.sql 建)
|
||||
- key: xqok_c12fb9f16febc8049089111ca4d16773fd4487450fdbb0cd
|
||||
name: dev-ops-service
|
||||
scopes: [ops-query]
|
||||
# ak 明文 AES-GCM 加密密钥(任意长度字符串,SHA-256 派生为密钥),供 ensure 二次获取明文;
|
||||
# dev 明文(同库密码惯例),生产必须改用环境变量且与 dev 不同值
|
||||
token-cipher-key: d70da46f5f0db2016d88d5ee4b1d6af35867b7700b97105fec9b3ad567d474a5
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- 服务身份免登录分页查询(REQ-20260921-0004):作用域级"允许服务主体"标记。
|
||||
-- service_enabled=1 的作用域允许 tenant_service 服务 Token 绑定签发(OpenTokenService 签发校验);
|
||||
-- sync 作用域原硬编码放行改为标记驱动,迁移内置 1 保持存量兼容。
|
||||
ALTER TABLE agent_scope
|
||||
ADD COLUMN service_enabled TINYINT NOT NULL DEFAULT 0 COMMENT '1=允许服务主体(tenant_service)Token 绑定签发' AFTER status;
|
||||
|
||||
UPDATE agent_scope SET service_enabled = 1 WHERE code = 'sync' AND soft_delete = 0;
|
||||
@@ -4,3 +4,4 @@ f8ec43a4fda81382d5675bc59fe7f4339f2e38a5b4241c260c3569e33b43a26d V1__init.sql
|
||||
5704691fa88406931c6aaf72bceaffd37cf6cc6982ce938c824ad8ecb94db1b5 V4__token_principal_type.sql
|
||||
17b270891eea9c831809de16b9c30a8dbdb01fb5c532f9e372e54513b92a65f9 V5__tool_description_length.sql
|
||||
6fbe58d48ba9291ce07a8c76bbdda4c70e2c759e3064df4d26cb863294c98ae8 V6__open_client_management.sql
|
||||
75cee62241c460f7b761738276526693d3666cb1c6400eb4b6a3028171ebdf82 V7__scope_service_enabled.sql
|
||||
|
||||
@@ -19,7 +19,7 @@ class FlywayMigrationTest {
|
||||
|
||||
flyway.migrate();
|
||||
|
||||
assertEquals("6", flyway.info().current().getVersion().getVersion());
|
||||
assertEquals("7", flyway.info().current().getVersion().getVersion());
|
||||
assertEquals(255, passwordHashLength(dataSource));
|
||||
assertEquals(64, columnLength(dataSource, "app_open_client", "key_hash"));
|
||||
}
|
||||
@@ -32,12 +32,14 @@ class FlywayMigrationTest {
|
||||
statement.execute("CREATE TABLE agent_api_key (id BIGINT AUTO_INCREMENT PRIMARY KEY,"
|
||||
+ " user_id BIGINT NOT NULL, key_hash CHAR(64) NOT NULL)");
|
||||
statement.execute("CREATE TABLE tool (id BIGINT AUTO_INCREMENT PRIMARY KEY, description VARCHAR(512))");
|
||||
statement.execute("CREATE TABLE agent_scope (id BIGINT AUTO_INCREMENT PRIMARY KEY, code VARCHAR(64) NOT NULL,"
|
||||
+ " status TINYINT NOT NULL DEFAULT 1, soft_delete TINYINT NOT NULL DEFAULT 0)");
|
||||
}
|
||||
Flyway flyway = flyway(dataSource, true);
|
||||
|
||||
flyway.migrate();
|
||||
|
||||
assertEquals("6", flyway.info().current().getVersion().getVersion());
|
||||
assertEquals("7", flyway.info().current().getVersion().getVersion());
|
||||
assertEquals(255, passwordHashLength(dataSource));
|
||||
assertEquals(64, columnLength(dataSource, "app_open_client", "key_hash"));
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ class GuardedToolCallbackTest {
|
||||
void rateLimitedMapsToRateLimitedCode() throws Exception {
|
||||
when(toolGuard.isAllowed(42L, "agent.count")).thenReturn(true);
|
||||
org.mockito.Mockito.doThrow(new ToolCallException(McpErrorCode.RATE_LIMITED, "触发限流"))
|
||||
.when(rateLimitService).check(42L, "agent.count");
|
||||
.when(rateLimitService).check(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.eq("agent.count"));
|
||||
|
||||
ToolCallException e = assertThrows(ToolCallException.class, () -> callback.call("{}"));
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package me.iiv.agent.mcp;
|
||||
|
||||
import me.iiv.agent.entity.svc.AgentApiKey;
|
||||
import me.iiv.agent.security.AuthContext;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
@@ -31,8 +33,26 @@ class RateLimitServiceTest {
|
||||
ValueOperations<String, String> valueOperations;
|
||||
|
||||
private RateLimitService newService(int queryPerMinute, int syncPerMinute) {
|
||||
return newService(queryPerMinute, syncPerMinute, 300);
|
||||
}
|
||||
|
||||
private RateLimitService newService(int queryPerMinute, int syncPerMinute, int serviceQueryPerMinute) {
|
||||
lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||
return new RateLimitService(redisTemplate, queryPerMinute, syncPerMinute);
|
||||
return new RateLimitService(redisTemplate, queryPerMinute, syncPerMinute, serviceQueryPerMinute);
|
||||
}
|
||||
|
||||
private static AuthContext userCtx(long tokenId) {
|
||||
AgentApiKey token = new AgentApiKey();
|
||||
token.setId(tokenId);
|
||||
token.setPrincipalType(AgentApiKey.PRINCIPAL_TYPE_USER);
|
||||
return new AuthContext(token, null);
|
||||
}
|
||||
|
||||
private static AuthContext serviceCtx(long tokenId) {
|
||||
AgentApiKey token = new AgentApiKey();
|
||||
token.setId(tokenId);
|
||||
token.setPrincipalType(AgentApiKey.PRINCIPAL_TYPE_SERVICE);
|
||||
return new AuthContext(token, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -40,7 +60,7 @@ class RateLimitServiceTest {
|
||||
RateLimitService service = newService(600, 120);
|
||||
when(valueOperations.increment(anyString())).thenReturn(1L);
|
||||
|
||||
assertDoesNotThrow(() -> service.check(42L, "agent.count"));
|
||||
assertDoesNotThrow(() -> service.check(userCtx(42L), "agent.count"));
|
||||
|
||||
verify(redisTemplate).expire(anyString(), any(Duration.class));
|
||||
}
|
||||
@@ -51,7 +71,7 @@ class RateLimitServiceTest {
|
||||
when(valueOperations.increment(anyString())).thenReturn(601L);
|
||||
|
||||
ToolCallException e = assertThrows(ToolCallException.class,
|
||||
() -> service.check(42L, "agent.count"));
|
||||
() -> service.check(userCtx(42L), "agent.count"));
|
||||
|
||||
assertEquals(McpErrorCode.RATE_LIMITED, e.getCode());
|
||||
}
|
||||
@@ -61,9 +81,9 @@ class RateLimitServiceTest {
|
||||
RateLimitService service = newService(600, 120);
|
||||
when(valueOperations.increment(anyString())).thenReturn(1L);
|
||||
|
||||
service.check(42L, "users.changes.list");
|
||||
service.check(42L, "contracts.current.batch_get");
|
||||
service.check(42L, "contract.list.query");
|
||||
service.check(userCtx(42L), "users.changes.list");
|
||||
service.check(userCtx(42L), "contracts.current.batch_get");
|
||||
service.check(userCtx(42L), "contract.list.query");
|
||||
|
||||
ArgumentCaptor<String> keys = ArgumentCaptor.forClass(String.class);
|
||||
verify(valueOperations, org.mockito.Mockito.times(3)).increment(keys.capture());
|
||||
@@ -73,13 +93,40 @@ class RateLimitServiceTest {
|
||||
assertTrue(!keys.getAllValues().get(0).equals(keys.getAllValues().get(2)), "两档位互不占配额");
|
||||
}
|
||||
|
||||
@Test
|
||||
void servicePrincipalQueryUsesServiceQueryTier() {
|
||||
RateLimitService service = newService(600, 120, 300);
|
||||
when(valueOperations.increment(anyString())).thenReturn(1L);
|
||||
|
||||
service.check(serviceCtx(42L), "hardware.list.query");
|
||||
service.check(serviceCtx(42L), "users.changes.list");
|
||||
service.check(userCtx(42L), "hardware.list.query");
|
||||
|
||||
ArgumentCaptor<String> keys = ArgumentCaptor.forClass(String.class);
|
||||
verify(valueOperations, org.mockito.Mockito.times(3)).increment(keys.capture());
|
||||
assertTrue(keys.getAllValues().get(0).contains(":service-query:"), "服务主体在线查询走 SERVICE-QUERY 档位");
|
||||
assertTrue(keys.getAllValues().get(1).contains(":sync:"), "服务主体调 SYNC 工具仍走 SYNC 档位");
|
||||
assertTrue(keys.getAllValues().get(2).contains(":query:"), "用户查询仍走 QUERY 档位");
|
||||
}
|
||||
|
||||
@Test
|
||||
void serviceQueryTierHasOwnLimit() {
|
||||
RateLimitService service = newService(600, 120, 300);
|
||||
when(valueOperations.increment(anyString())).thenReturn(301L);
|
||||
|
||||
ToolCallException e = assertThrows(ToolCallException.class,
|
||||
() -> service.check(serviceCtx(42L), "hardware.list.query"));
|
||||
|
||||
assertEquals(McpErrorCode.RATE_LIMITED, e.getCode(), "SERVICE-QUERY 第 301 次超限");
|
||||
}
|
||||
|
||||
@Test
|
||||
void syncTierHasOwnLimit() {
|
||||
RateLimitService service = newService(600, 120);
|
||||
when(valueOperations.increment(anyString())).thenReturn(121L);
|
||||
|
||||
ToolCallException e = assertThrows(ToolCallException.class,
|
||||
() -> service.check(42L, "contracts.changes.list"));
|
||||
() -> service.check(serviceCtx(42L), "contracts.changes.list"));
|
||||
|
||||
assertEquals(McpErrorCode.RATE_LIMITED, e.getCode(), "SYNC 第 121 次超限");
|
||||
}
|
||||
@@ -89,14 +136,23 @@ class RateLimitServiceTest {
|
||||
RateLimitService service = newService(600, 120);
|
||||
when(valueOperations.increment(anyString())).thenThrow(new RedisSystemException("down", null));
|
||||
|
||||
assertDoesNotThrow(() -> service.check(42L, "agent.count"));
|
||||
assertDoesNotThrow(() -> service.check(userCtx(42L), "agent.count"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void zeroLimitDisablesTier() {
|
||||
RateLimitService service = new RateLimitService(redisTemplate, 0, 0);
|
||||
RateLimitService service = new RateLimitService(redisTemplate, 0, 0, 0);
|
||||
|
||||
assertDoesNotThrow(() -> service.check(42L, "users.changes.list"));
|
||||
assertDoesNotThrow(() -> service.check(userCtx(42L), "users.changes.list"));
|
||||
|
||||
verify(redisTemplate, never()).opsForValue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullContextSkips() {
|
||||
RateLimitService service = newService(600, 120);
|
||||
|
||||
assertDoesNotThrow(() -> service.check(null, "agent.count"));
|
||||
|
||||
verify(redisTemplate, never()).opsForValue();
|
||||
}
|
||||
|
||||
@@ -78,10 +78,21 @@ class OpenTokenServiceTest {
|
||||
sync.setId(9L);
|
||||
sync.setCode("sync");
|
||||
sync.setStatus((byte) 1);
|
||||
sync.setServiceEnabled((byte) 1);
|
||||
sync.setSoftDelete((byte) 0);
|
||||
return sync;
|
||||
}
|
||||
|
||||
private static AgentScope opsQueryScope() {
|
||||
AgentScope ops = new AgentScope();
|
||||
ops.setId(10L);
|
||||
ops.setCode("ops-query");
|
||||
ops.setStatus((byte) 1);
|
||||
ops.setServiceEnabled((byte) 1);
|
||||
ops.setSoftDelete((byte) 0);
|
||||
return ops;
|
||||
}
|
||||
|
||||
private static AdminTokenService.IssuedToken issuedToken(String plaintext) {
|
||||
AdminTokenService.TokenView view = new AdminTokenService.TokenView(
|
||||
12L, "xqak_efgh5678", "open-api 自动签发(operation)", (byte) 1,
|
||||
@@ -302,7 +313,7 @@ class OpenTokenServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void serviceEnsureRejectsNonSyncScope() {
|
||||
void serviceEnsureRejectsScopeNotServiceEnabled() {
|
||||
when(scopeMapper.selectOne(any())).thenReturn(scope);
|
||||
|
||||
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.ensure(
|
||||
@@ -312,6 +323,26 @@ class OpenTokenServiceTest {
|
||||
.issueServiceToken(anyString(), anyString(), anyLong(), isNull(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void serviceEnsureAllowedForServiceEnabledQueryScope() {
|
||||
AgentScope ops = opsQueryScope();
|
||||
when(scopeMapper.selectOne(any())).thenReturn(ops);
|
||||
when(tokenService.findActiveServiceToken("ops", 10L)).thenReturn(null);
|
||||
AdminTokenService.TokenView view = new AdminTokenService.TokenView(
|
||||
13L, "xqak_ops00000", "open-api 服务签发(ops-query)", (byte) 1,
|
||||
"service", "ops", null, null, List.of(10L), List.of("运营查询"));
|
||||
when(tokenService.issueServiceToken(eq("ops"), anyString(), eq(10L), isNull(), eq(0L)))
|
||||
.thenReturn(new AdminTokenService.IssuedToken("xqak_" + "e".repeat(48), view));
|
||||
|
||||
OpenTokenController.EnsureResult result = service.ensure(
|
||||
new OpenTokenController.EnsureRequest(null, null, "ops-query", "service", "ops"),
|
||||
new OpenClientProperties.ResolvedClient("ops", List.of("ops-query"), false));
|
||||
|
||||
assertTrue(result.created());
|
||||
assertEquals("ops-query", result.scopeCode());
|
||||
assertEquals("service", result.principalType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void userEnsureRejectsSyncScope() {
|
||||
when(scopeMapper.selectOne(any())).thenReturn(syncScope());
|
||||
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
package me.iiv.agent.permission;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import me.iiv.agent.entity.m0.Agent;
|
||||
import me.iiv.agent.entity.m0.HardwareView;
|
||||
import me.iiv.agent.entity.m0.Properyt;
|
||||
import me.iiv.agent.entity.svc.AgentApiKey;
|
||||
import me.iiv.agent.mapper.m0.AgentManageDistrictAuthMapper;
|
||||
import me.iiv.agent.mapper.m0.AgentMapper;
|
||||
import me.iiv.agent.mapper.m0.AuthorityMapper;
|
||||
import me.iiv.agent.mapper.m0.ProperytMapper;
|
||||
import me.iiv.agent.mapper.m2.WarehouseAgentAuthMapper;
|
||||
import me.iiv.agent.security.AuthContext;
|
||||
import me.iiv.agent.security.AuthContextHolder;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
|
||||
/**
|
||||
* PermissionService 的 tenant_service 分支(REQ-20260921-0004):
|
||||
* 服务 Token 无用户上下文(user==null)→ 总部视角全量可见,不做行过滤;
|
||||
* 非服务主体出现 null user → 拒绝访问(不放行)。
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PermissionServiceServicePrincipalTest {
|
||||
|
||||
@Mock
|
||||
AuthorityMapper authorityMapper;
|
||||
@Mock
|
||||
AgentMapper agentMapper;
|
||||
@Mock
|
||||
WarehouseAgentAuthMapper warehouseAgentAuthMapper;
|
||||
@Mock
|
||||
AgentManageDistrictAuthMapper agentManageDistrictAuthMapper;
|
||||
@Mock
|
||||
ProperytMapper properytMapper;
|
||||
@Mock
|
||||
StringRedisTemplate redisTemplate;
|
||||
|
||||
PermissionService permissionService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
||||
HardwareView.class);
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
||||
Properyt.class);
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
||||
Agent.class);
|
||||
permissionService = new PermissionService(authorityMapper, agentMapper,
|
||||
warehouseAgentAuthMapper, agentManageDistrictAuthMapper, properytMapper, redisTemplate);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
AuthContextHolder.clear();
|
||||
}
|
||||
|
||||
private static void setServiceContext() {
|
||||
AgentApiKey token = new AgentApiKey();
|
||||
token.setId(99L);
|
||||
token.setPrincipalType(AgentApiKey.PRINCIPAL_TYPE_SERVICE);
|
||||
AuthContextHolder.set(new AuthContext(token, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void servicePrincipalHardwareFilterAddsNoBoundary() {
|
||||
setServiceContext();
|
||||
|
||||
LambdaQueryWrapper<HardwareView> wrapper = new LambdaQueryWrapper<>();
|
||||
permissionService.applyHardwareRowFilter(wrapper, null);
|
||||
|
||||
assertTrue(wrapper.getSqlSegment().isBlank(), "tenant_service 按总部视角全量可见");
|
||||
verifyNoInteractions(authorityMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void servicePrincipalVisibleAgentIdsReturnsNull() {
|
||||
setServiceContext();
|
||||
|
||||
assertNull(permissionService.visibleAgentIds(null), "null = 不过滤(服务主体看全部)");
|
||||
verifyNoInteractions(authorityMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void servicePrincipalPropertyAndAgentListAddNoBoundary() {
|
||||
setServiceContext();
|
||||
|
||||
LambdaQueryWrapper<Properyt> propertyWrapper = new LambdaQueryWrapper<>();
|
||||
permissionService.applyPropertyRowFilter(propertyWrapper, null);
|
||||
assertTrue(propertyWrapper.getSqlSegment().isBlank());
|
||||
|
||||
LambdaQueryWrapper<Agent> agentWrapper = new LambdaQueryWrapper<>();
|
||||
permissionService.applyAgentListRowFilter(agentWrapper, null, null);
|
||||
assertTrue(agentWrapper.getSqlSegment().isBlank());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullUserWithoutServiceContextRejected() {
|
||||
LambdaQueryWrapper<HardwareView> wrapper = new LambdaQueryWrapper<>();
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> permissionService.applyHardwareRowFilter(wrapper, null),
|
||||
"非服务主体的 null user 说明上下文丢失,必须拒绝而不是放行");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullUserWithUserTokenContextRejected() {
|
||||
AgentApiKey token = new AgentApiKey();
|
||||
token.setId(98L);
|
||||
token.setPrincipalType(AgentApiKey.PRINCIPAL_TYPE_USER);
|
||||
AuthContextHolder.set(new AuthContext(token, null));
|
||||
|
||||
LambdaQueryWrapper<HardwareView> wrapper = new LambdaQueryWrapper<>();
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> permissionService.applyHardwareRowFilter(wrapper, null));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user