fix(security): 收紧 MCP 数据权限并移除默认凭据
This commit is contained in:
@@ -25,7 +25,7 @@ xiaoqu-agent-service/
|
||||
├── pom.xml
|
||||
├── sql/
|
||||
│ ├── V1__init.sql # 自有库 xiaoqu_agent_svc_d 建表(手工执行,未接 flyway)
|
||||
│ └── seed-dev.sql # dev 种子数据(测试 ak + 运营作用域授权)
|
||||
│ └── seed-dev.sql # dev 种子数据(运营作用域授权,不含固定 ak)
|
||||
├── src/main/java/me/iiv/agent/
|
||||
│ ├── AgentServiceApplication.java
|
||||
│ ├── config/ # M0/M1/M2/SvcDataSourceConfig(独立 SqlSessionFactory + MapperScan)、McpToolConfig
|
||||
@@ -38,9 +38,9 @@ xiaoqu-agent-service/
|
||||
│ ├── admin/ # P4 占位包
|
||||
│ ├── entity/m0、m2、svc mapper/m0、m1、m2、svc # codegen 生成物(提交进仓库)
|
||||
├── src/main/resources/
|
||||
│ ├── application.yml # MCP server: protocol=STREAMABLE, type=SYNC, 端点 /mcp;默认 dev profile
|
||||
│ └── application-dev.yml # 四数据源 + Redis,密码用环境变量占位符带默认值
|
||||
└── src/test/java/me/iiv/agent/codegen/CodeGenerator.java # run-once 代码生成器
|
||||
│ ├── application.yml # MCP server: protocol=STREAMABLE, type=SYNC,端点 /mcp
|
||||
│ └── application-dev.yml # 四数据源 + Redis,账号密码必须由环境变量提供
|
||||
└── src/test/java/me/iiv/agent/ # 权限回归测试 + run-once 代码生成器
|
||||
```
|
||||
|
||||
数据源分包约定:`entity/m0|m1|m2|svc` 与 `mapper/...` 一一对应各自的 `SqlSessionFactory`(`@MapperScan` + `sqlSessionFactoryRef`)。m0/m1/m2 只读,svc(自有库)读写。
|
||||
@@ -59,13 +59,13 @@ xiaoqu-agent-service/
|
||||
|
||||
| 方法 | 老系统来源 | 语义 |
|
||||
|---|---|---|
|
||||
| `isAgentAuthority(authorityId)` | `AuthorityServiceImpl.checkAuthorityIsagent` | 权限组 isagent=1 → 代理商视角 |
|
||||
| `isAgentAuthority(authorityId)` | `AuthorityServiceImpl.checkAuthorityIsagent` | isagent=1 → 代理商;isagent=0 → 总部;缺失/非法配置拒绝访问 |
|
||||
| `visibleAgentIds(user)` | `getByIsAgent` 主重载 | isagent!=1 返回 null(不过滤);否则 自己 + 直属下级(supervisor_id 一层)+ m2 warehouse_agent_auth 代管 manager_agent_id |
|
||||
| `isSalesNonLeader(user)` | saleFlag 判定(:703-710) | Redis db5 `Dictionary` hash 的 authDeptId 命中 ",deptId," 且 authDeptPostId 不含 "postId,";字典缺失按不限制处理并告警 |
|
||||
| `isSalesNonLeader(user)` | saleFlag 判定(:703-710) | Redis db5 `Dictionary` hash 的 authDeptId 命中 ",deptId," 且 authDeptPostId 不含 "postId,";字典缺失时拒绝访问 |
|
||||
| `applyPropertyRowFilter` 销售分支 | saleFlag 分支(:716-735,互斥 return) | **1:1 复刻(2026-09-03 决策)**:id IN (create_id=自己 ∪ [代管授权非空时] agent_id IN (下级+代管移除自己) 的物业),空集合则 id IN (0);命中后不再叠加 agent_id/代管物业 OR 结构 |
|
||||
| `isPropertyUser(user)` | enterprise_type=2 规则(§3.3 通用规则,保留备用) | 物业用户;**property.list.query 不使用本规则**(物业用户不查此列表,与老 /property/listByPage 一致,2026-09-03 决策) |
|
||||
|
||||
`agent.list.query` 的行权限照搬老系统 `AgentController.listByPage`:isagent=1 → `supervisor_id = 用户agentId`(只查直属下级);isagent=0 → 不过滤;入参传 `id` → 精确查询(可查到自身,不加 supervisor 限制)。
|
||||
`agent.list.query` 的行权限照搬老系统 `AgentController.listByPage`:isagent=1 默认只查直属下级;传 `id` 时只能精确查询自己或直属下级;isagent=0 可按任意合法 id 查询。
|
||||
|
||||
## MCP 工具
|
||||
|
||||
@@ -83,10 +83,22 @@ xiaoqu-agent-service/
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
# 首次:建自有库 + 种子数据(dev)
|
||||
mysql -h192.168.1.181 -uroot -p < sql/V1__init.sql
|
||||
# 首次:由数据库管理员建自有库;启动一次同步工具后,再创建 dev 作用域
|
||||
mysql -h192.168.1.181 -u<svc_db_admin> -p < sql/V1__init.sql
|
||||
# 启动一次让 ToolSyncService 同步 tool 表后:
|
||||
mysql -h192.168.1.181 -uroot -p xiaoqu_agent_svc_d < sql/seed-dev.sql
|
||||
mysql -h192.168.1.181 -u<svc_db_admin> -p xiaoqu_agent_svc_d < sql/seed-dev.sql
|
||||
|
||||
# 必须显式指定环境及凭据;m0/m1/m2 使用数据库只读账号
|
||||
export SPRING_PROFILES_ACTIVE=dev
|
||||
export M0_DB_USERNAME=<readonly_user>
|
||||
export M0_DB_PASSWORD=<password>
|
||||
export M1_DB_USERNAME=<readonly_user>
|
||||
export M1_DB_PASSWORD=<password>
|
||||
export M2_DB_USERNAME=<readonly_user>
|
||||
export M2_DB_PASSWORD=<password>
|
||||
export SVC_DB_USERNAME=<svc_user>
|
||||
export SVC_DB_PASSWORD=<password>
|
||||
export REDIS_PASSWORD=<password>
|
||||
|
||||
./mvnw clean package -DskipTests
|
||||
java -jar target/xiaoqu-agent-service.jar
|
||||
@@ -96,17 +108,17 @@ java -jar target/xiaoqu-agent-service.jar
|
||||
nohup java -jar target/xiaoqu-agent-service.jar > target/app.log 2>&1 &
|
||||
```
|
||||
|
||||
- 默认 `spring.profiles.active=dev`,端口 **8088**,MCP 端点 `http://localhost:8088/mcp`。
|
||||
- 密码默认值仅供本地 dev,环境变量覆盖:`M0_DB_PASSWORD` / `M1_DB_PASSWORD` / `M2_DB_PASSWORD` / `SVC_DB_PASSWORD` / `REDIS_PASSWORD`。
|
||||
- 不提供默认 profile、数据库账号或密码;缺失配置时不得启动。
|
||||
- dev 端口 **8088**,MCP 端点 `http://localhost:8088/mcp`。
|
||||
|
||||
## 测试 ak(仅 dev,明文只在此处与 sql/seed-dev.sql 出现,服务端只存 SHA-256)
|
||||
## 测试 ak(仅 dev)
|
||||
|
||||
| 用途 | 用户 | ak |
|
||||
|---|---|---|
|
||||
| 总部视角(isagent=0) | 46 代理商管理端 | `xqak_1d21057ad83203dbeb767eb5069701046b27debdc08a733c` |
|
||||
| 代理商视角(isagent=1,agent_id=1,26 个直属下级) | 1000 斤斤计较 | `xqak_7df02ccee2133dc2cf951db12e3958557bdf2d30a91e7f08` |
|
||||
| 无任何 scope(验证拒绝) | 1000 斤斤计较 | `xqak_5bdc0eac8c1cab535f22dc3e9757f72b3127de43a14997a7` |
|
||||
| 销售非负责人(dept 4/post 103,代管 agent 19) | 1727 北京销售1 | `xqak_cb006faf86c0db6f2f47b3eb8872f8b5d495b0c05a6fb52c` |
|
||||
仓库不提供固定 ak。每位开发者在本地生成随机 ak,只把 SHA-256 写入 `agent_api_key`,再用 `token_scope` 关联需要的作用域;明文只保存在本地环境变量中,不提交版本库。仓库历史中曾出现的 dev ak 必须视为已泄露并在数据库中禁用。
|
||||
|
||||
```bash
|
||||
export XQ_API_KEY="xqak_$(openssl rand -hex 24)"
|
||||
printf %s "$XQ_API_KEY" | shasum -a 256
|
||||
```
|
||||
|
||||
## curl 验证(Streamable HTTP 握手)
|
||||
|
||||
@@ -138,7 +150,7 @@ property.list.query 权限隔离实测(2026-09-03,均与直连 SQL 核对一
|
||||
| province=北京市 | total=38 | total=31 |
|
||||
| name 模糊 '%测试%' | — | total=7(与 SQL 一致) |
|
||||
|
||||
agent.list.query 抽测:channel_no='D' + province='北京市' → 总部 11 / 代理商 9(均与 SQL 一致);id=79 精确查 → 1;rows=500 → 截断为 100 并返回 notice。
|
||||
agent.list.query 抽测:channel_no='D' + province='北京市' → 总部 11 / 代理商 9(均与 SQL 一致);rows=500 → 截断为 100 并返回 notice。安全修复后,代理商精确查询仅允许自身或直属下级,权限外 id 返回空结果。
|
||||
|
||||
## 客户端接入(dev 阶段裸跑 HTTP)
|
||||
|
||||
@@ -173,6 +185,8 @@ claude mcp add --transport http xiaoqu http://192.168.4.236:8088/mcp \
|
||||
|
||||
```bash
|
||||
# 生成(依赖已下载后执行;连 dev 库)
|
||||
export CODEGEN_DB_USERNAME=<codegen_user>
|
||||
export CODEGEN_DB_PASSWORD=<password>
|
||||
./mvnw -q dependency:build-classpath -Dmdep.includeScope=test -Dmdep.outputFile=target/cp.txt
|
||||
javac -cp "$(cat target/cp.txt)" -d target/codegen-classes src/test/java/me/iiv/agent/codegen/CodeGenerator.java
|
||||
java -cp "target/codegen-classes:$(cat target/cp.txt)" me.iiv.agent.codegen.CodeGenerator
|
||||
|
||||
@@ -1,37 +1,14 @@
|
||||
-- =============================================================
|
||||
-- dev 种子数据(仅 dev 环境使用!)
|
||||
-- 用户:46=代理商管理端(isagent=0 总部视角, authority_id=1)
|
||||
-- 1000=斤斤计较(isagent=1 代理商视角, authority_id=108, agent_id=1 有 26 个直属下级)
|
||||
-- token:
|
||||
-- HQ user_id=46 xqak_1d21057ad83203dbeb767eb5069701046b27debdc08a733c
|
||||
-- AGENT user_id=1000 xqak_7df02ccee2133dc2cf951db12e3958557bdf2d30a91e7f08
|
||||
-- NOSCOPE user_id=1000 xqak_5bdc0eac8c1cab535f22dc3e9757f72b3127de43a14997a7 (未授权任何 scope,验证拒绝)
|
||||
-- SALES user_id=1727 xqak_cb006faf86c0db6f2f47b3eb8872f8b5d495b0c05a6fb52c (北京销售1,dept_id=4 命中 authDeptId,
|
||||
-- post_id=103 不在 authDeptPostId → 销售非负责人;warehouse_agent_auth 代管 agent 19)
|
||||
-- 依赖:tool 表已由应用启动时 ToolSyncService 同步(sys.ping/agent.count/agent.list.query)
|
||||
-- 仅创建作用域并关联工具,不提交固定 token 或 ak。
|
||||
-- token 请使用本地随机 ak 的 SHA-256 哈希手工创建,详见 README。
|
||||
-- 依赖:tool 表已由应用启动时 ToolSyncService 同步。
|
||||
-- =============================================================
|
||||
|
||||
USE xiaoqu_agent_svc_d;
|
||||
|
||||
START TRANSACTION;
|
||||
|
||||
-- token(ak 只存 SHA-256 哈希;明文只保留在本文件顶部注释,仅 dev)
|
||||
INSERT INTO agent_api_key (user_id, key_prefix, key_hash, memo, status, create_id)
|
||||
SELECT 46, 'xqak_1d21', '9068d5a6406afdb853cdc9f5e45e16a5191255e2feb1aba171cd66ea13d5711c', 'dev 测试:总部视角(isagent=0)', 1, 0
|
||||
WHERE NOT EXISTS (SELECT 1 FROM agent_api_key WHERE key_hash = '9068d5a6406afdb853cdc9f5e45e16a5191255e2feb1aba171cd66ea13d5711c');
|
||||
|
||||
INSERT INTO agent_api_key (user_id, key_prefix, key_hash, memo, status, create_id)
|
||||
SELECT 1000, 'xqak_7df0', '00ab77ba18c11c8870500e1c09a9e25747e4cf0d8f1c88ae255a2beca6eef007', 'dev 测试:代理商视角(isagent=1)', 1, 0
|
||||
WHERE NOT EXISTS (SELECT 1 FROM agent_api_key WHERE key_hash = '00ab77ba18c11c8870500e1c09a9e25747e4cf0d8f1c88ae255a2beca6eef007');
|
||||
|
||||
INSERT INTO agent_api_key (user_id, key_prefix, key_hash, memo, status, create_id)
|
||||
SELECT 1000, 'xqak_5bdc', '85733c4eaab02e956bc555e0f25e5043629f9b8f8ad9ca39046735f5e817ca9c', 'dev 测试:无任何 scope 授权', 1, 0
|
||||
WHERE NOT EXISTS (SELECT 1 FROM agent_api_key WHERE key_hash = '85733c4eaab02e956bc555e0f25e5043629f9b8f8ad9ca39046735f5e817ca9c');
|
||||
|
||||
INSERT INTO agent_api_key (user_id, key_prefix, key_hash, memo, status, create_id)
|
||||
SELECT 1727, 'xqak_cb00', '9fd25466ad4913b0c48e60d853b1bb27ba75bec426a117978e8c95687917bada', 'dev 测试:销售非负责人(北京销售1, dept 4/post 103, 代管 agent 19)', 1, 0
|
||||
WHERE NOT EXISTS (SELECT 1 FROM agent_api_key WHERE key_hash = '9fd25466ad4913b0c48e60d853b1bb27ba75bec426a117978e8c95687917bada');
|
||||
|
||||
-- 作用域:运营
|
||||
INSERT INTO agent_scope (name, code, memo, status, create_id)
|
||||
SELECT '运营', 'operation', 'dev 种子:运营作用域', 1, 0
|
||||
@@ -43,18 +20,7 @@ SELECT s.id, t.id, 0 FROM agent_scope s JOIN tool t
|
||||
WHERE s.code = 'operation' AND t.name IN ('sys.ping', 'agent.count', 'agent.list.query', 'property.list.query')
|
||||
AND NOT EXISTS (SELECT 1 FROM scope_tool st WHERE st.scope_id = s.id AND st.tool_id = t.id);
|
||||
|
||||
-- token-作用域:HQ / AGENT / SALES 三把 token 授权"运营";NOSCOPE 不授权
|
||||
INSERT INTO token_scope (token_id, scope_id, create_id)
|
||||
SELECT k.id, s.id, 0 FROM agent_api_key k JOIN agent_scope s
|
||||
WHERE k.key_hash IN ('9068d5a6406afdb853cdc9f5e45e16a5191255e2feb1aba171cd66ea13d5711c',
|
||||
'00ab77ba18c11c8870500e1c09a9e25747e4cf0d8f1c88ae255a2beca6eef007',
|
||||
'9fd25466ad4913b0c48e60d853b1bb27ba75bec426a117978e8c95687917bada')
|
||||
AND s.code = 'operation'
|
||||
AND NOT EXISTS (SELECT 1 FROM token_scope ts WHERE ts.token_id = k.id AND ts.scope_id = s.id);
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- 校验
|
||||
SELECT k.id, k.user_id, k.key_prefix, k.memo FROM agent_api_key k ORDER BY k.id;
|
||||
SELECT s.id, s.code, t.name FROM agent_scope s JOIN scope_tool st ON s.id = st.scope_id JOIN tool t ON st.tool_id = t.id WHERE s.soft_delete = 0;
|
||||
SELECT ts.token_id, ts.scope_id FROM token_scope ts WHERE ts.soft_delete = 0;
|
||||
|
||||
@@ -51,18 +51,18 @@ public class AgentTools implements ToolObject {
|
||||
/**
|
||||
* 查询代理商列表。行权限照搬老系统 AgentController.listByPage:
|
||||
* 权限组 isagent=1 时只查直属下级(supervisor_id = 用户 agentId);isagent=0 不过滤。
|
||||
* 入参传 id 时精确查询(可查到自身,此时不加 supervisor 限制)。
|
||||
* 入参传 id 时精确查询;代理商只能查询自己或直属下级。
|
||||
* supervisorName 批量 IN 查询富化,无 N+1。
|
||||
*/
|
||||
@XqTool(category = "customer", kind = "read")
|
||||
@Tool(name = "agent.list.query", description = "查询代理商列表(分页)。"
|
||||
+ "行级权限:代理商身份的用户(权限组 isagent=1)只能查到自己名下的直属下级代理;总部身份(isagent=0)可看全部。"
|
||||
+ "传 id 时为精确查询、可查到自身,其余过滤条件失效。"
|
||||
+ "传 id 时为精确查询;代理商只能查询自己或直属下级,总部可查询任意合法 id,其余过滤条件失效。"
|
||||
+ "level 含义:0=总部 1=一级 2=二级;channelNo 含义:Z=总部 F=分公司 D=代理。"
|
||||
+ "分页:page 从 1 开始,rows 默认 20、上限 100,超出自动截断为 100 并在返回的 notice 字段提示。"
|
||||
+ "返回 total(符合条件的总数)、records(本页记录)。")
|
||||
public Map<String, Object> agentListQuery(
|
||||
@ToolParam(required = false, description = "代理商 id,精确查询(可查到自身);传入后忽略其他过滤条件") Long id,
|
||||
@ToolParam(required = false, description = "代理商 id,精确查询;代理商仅限自己或直属下级;传入后忽略其他过滤条件") Long id,
|
||||
@ToolParam(required = false, description = "代理商名称,模糊匹配") String name,
|
||||
@ToolParam(required = false, description = "级别:0=总部 1=一级 2=二级") Integer level,
|
||||
@ToolParam(required = false, description = "渠道编号:Z=总部 F=分公司 D=代理") String channelNo,
|
||||
@@ -78,9 +78,7 @@ public class AgentTools implements ToolObject {
|
||||
|
||||
LambdaQueryWrapper<Agent> wrapper = new LambdaQueryWrapper<Agent>()
|
||||
.eq(Agent::getSoftDelete, 0);
|
||||
if (id != null) {
|
||||
wrapper.eq(Agent::getId, id);
|
||||
} else {
|
||||
if (id == null) {
|
||||
if (StringUtils.hasText(name)) {
|
||||
wrapper.like(Agent::getName, name);
|
||||
}
|
||||
@@ -99,10 +97,8 @@ public class AgentTools implements ToolObject {
|
||||
if (StringUtils.hasText(serialnumber)) {
|
||||
wrapper.eq(Agent::getSerialnumber, serialnumber);
|
||||
}
|
||||
if (permissionService.isAgentAuthority(user.getAuthorityId())) {
|
||||
wrapper.eq(Agent::getSupervisorId, user.getAgentId());
|
||||
}
|
||||
}
|
||||
permissionService.applyAgentListRowFilter(wrapper, user, id);
|
||||
|
||||
Long total = agentMapper.selectCount(wrapper);
|
||||
wrapper.select(Agent::getId, Agent::getName, Agent::getLevel, Agent::getChannelNo,
|
||||
|
||||
@@ -57,13 +57,45 @@ public class PermissionService {
|
||||
this.redisTemplate = redisTemplate;
|
||||
}
|
||||
|
||||
/** 权限组 isagent=1 → 代理商视角(对应 AuthorityServiceImpl.checkAuthorityIsagent) */
|
||||
/** 权限组 isagent=1 → 代理商视角;未知或已删除的权限组拒绝访问。 */
|
||||
public boolean isAgentAuthority(Long authorityId) {
|
||||
if (authorityId == null) {
|
||||
return false;
|
||||
throw new IllegalStateException("用户未配置权限组,拒绝访问业务数据");
|
||||
}
|
||||
Authority authority = authorityMapper.selectById(authorityId);
|
||||
return authority != null && authority.getIsagent() != null && authority.getIsagent() == 1;
|
||||
if (authority == null || !Integer.valueOf(0).equals(authority.getSoftDelete())) {
|
||||
throw new IllegalStateException("用户权限组不存在或已删除,拒绝访问业务数据");
|
||||
}
|
||||
if (Integer.valueOf(1).equals(authority.getIsagent())) {
|
||||
return true;
|
||||
}
|
||||
if (Integer.valueOf(0).equals(authority.getIsagent())) {
|
||||
return false;
|
||||
}
|
||||
throw new IllegalStateException("用户权限组缺少有效的行权限配置,拒绝访问业务数据");
|
||||
}
|
||||
|
||||
/**
|
||||
* 代理商列表权限:总部可按任意 id 查询;代理商只能查询自己或直属下级。
|
||||
* 未传 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 (!agentAuthority) {
|
||||
return;
|
||||
}
|
||||
if (user.getAgentId() == null) {
|
||||
throw new IllegalStateException("代理商用户未关联代理商,拒绝访问业务数据");
|
||||
}
|
||||
if (id == null) {
|
||||
wrapper.eq(Agent::getSupervisorId, user.getAgentId());
|
||||
return;
|
||||
}
|
||||
wrapper.and(w -> w.eq(Agent::getId, user.getAgentId())
|
||||
.or().eq(Agent::getSupervisorId, user.getAgentId()));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,6 +107,9 @@ public class PermissionService {
|
||||
if (!isAgentAuthority(user.getAuthorityId())) {
|
||||
return null;
|
||||
}
|
||||
if (user.getAgentId() == null) {
|
||||
throw new IllegalStateException("代理商用户未关联代理商,拒绝访问业务数据");
|
||||
}
|
||||
Set<Long> ids = new LinkedHashSet<>();
|
||||
ids.addAll(subordinateAndSelfIds(user));
|
||||
ids.addAll(managedAuths(user.getId()).stream()
|
||||
@@ -105,7 +140,7 @@ public class PermissionService {
|
||||
/**
|
||||
* 销售部门非负责人判定(老系统 getByIsAgent 内 saleFlag 逻辑,:703-710):
|
||||
* Redis db5 Dictionary hash 的 authDeptId 命中 ",deptId," 且 authDeptPostId 不命中 "postId,"。
|
||||
* 字典缺失时按"不限制"处理并告警(与老系统 NPE 直接报错的行为不同,属于有意收敛)。
|
||||
* 字典缺失时拒绝访问,避免权限配置异常导致数据范围放宽。
|
||||
*/
|
||||
public boolean isSalesNonLeader(User user) {
|
||||
if (user.getDeptId() == null || user.getPostId() == null) {
|
||||
@@ -114,8 +149,8 @@ public class PermissionService {
|
||||
Object authDeptId = redisTemplate.opsForHash().get(DICTIONARY_KEY, DICT_FIELD_AUTH_DEPT_ID);
|
||||
Object authDeptPostId = redisTemplate.opsForHash().get(DICTIONARY_KEY, DICT_FIELD_AUTH_DEPT_POST_ID);
|
||||
if (authDeptId == null || authDeptPostId == null) {
|
||||
log.warn("Redis Dictionary 缺少 {}/{} 字段,销售部门限制未生效", DICT_FIELD_AUTH_DEPT_ID, DICT_FIELD_AUTH_DEPT_POST_ID);
|
||||
return false;
|
||||
log.error("Redis Dictionary 缺少 {}/{} 字段,拒绝访问业务数据", DICT_FIELD_AUTH_DEPT_ID, DICT_FIELD_AUTH_DEPT_POST_ID);
|
||||
throw new IllegalStateException("销售数据权限配置不可用,拒绝访问业务数据");
|
||||
}
|
||||
return authDeptId.toString().contains("," + user.getDeptId() + ",")
|
||||
&& !authDeptPostId.toString().contains(user.getPostId() + ",");
|
||||
@@ -167,6 +202,9 @@ public class PermissionService {
|
||||
if (!isAgentAuthority(user.getAuthorityId())) {
|
||||
return;
|
||||
}
|
||||
if (user.getAgentId() == null) {
|
||||
throw new IllegalStateException("代理商用户未关联代理商,拒绝访问业务数据");
|
||||
}
|
||||
// ids 组装:下级 + 自己 + 代管(老 :682-702)
|
||||
List<Long> ids = new ArrayList<>(subordinateAndSelfIds(user));
|
||||
List<WarehouseAgentAuth> agentlist = managedAuths(user.getId());
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
# dev 环境:内网 192.168.1.181(沿用 xiaoqu-franchisee pom.xml dev profile)
|
||||
# 密码走环境变量占位符,默认值仅供本地 dev 使用
|
||||
# 账号和密码必须由环境变量提供;m0/m1/m2 应使用数据库只读账号
|
||||
app:
|
||||
datasource:
|
||||
m0: # 权限库 xiaoqu_comples_d(user/agent/authority/propery/agent_manage_district_auth)
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
jdbc-url: jdbc:mysql://192.168.1.181:3306/xiaoqu_comples_d?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
|
||||
username: root
|
||||
password: ${M0_DB_PASSWORD:kaixinjiuhao}
|
||||
username: ${M0_DB_USERNAME}
|
||||
password: ${M0_DB_PASSWORD}
|
||||
m1: # 商城库 xiaoqu_mall_d(按需启用)
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
jdbc-url: jdbc:mysql://192.168.1.181:3306/xiaoqu_mall_d?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
|
||||
username: root
|
||||
password: ${M1_DB_PASSWORD:kaixinjiuhao}
|
||||
username: ${M1_DB_USERNAME}
|
||||
password: ${M1_DB_PASSWORD}
|
||||
m2: # 业务库 xiaoqu_franchisee_d(warehouse_agent_auth 等)
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
jdbc-url: jdbc:mysql://192.168.1.181:3306/xiaoqu_franchisee_d?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
|
||||
username: root
|
||||
password: ${M2_DB_PASSWORD:kaixinjiuhao}
|
||||
username: ${M2_DB_USERNAME}
|
||||
password: ${M2_DB_PASSWORD}
|
||||
svc: # 自有库 xiaoqu_agent_svc_d(token/scope/tool/audit,读写)
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
jdbc-url: jdbc:mysql://192.168.1.181:3306/xiaoqu_agent_svc_d?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
|
||||
username: root
|
||||
password: ${SVC_DB_PASSWORD:kaixinjiuhao}
|
||||
username: ${SVC_DB_USERNAME}
|
||||
password: ${SVC_DB_PASSWORD}
|
||||
|
||||
spring:
|
||||
data:
|
||||
redis:
|
||||
host: 192.168.1.181
|
||||
port: 6379
|
||||
password: ${REDIS_PASSWORD:kaixinjiuhao}
|
||||
password: ${REDIS_PASSWORD}
|
||||
database: 5
|
||||
timeout: 5s
|
||||
|
||||
@@ -4,8 +4,6 @@ server:
|
||||
spring:
|
||||
application:
|
||||
name: xiaoqu-agent-service
|
||||
profiles:
|
||||
active: dev
|
||||
ai:
|
||||
mcp:
|
||||
server:
|
||||
|
||||
@@ -26,8 +26,8 @@ public class CodeGenerator {
|
||||
|
||||
private static final String BASE_URL = "jdbc:mysql://192.168.1.181:3306/";
|
||||
private static final String URL_PARAMS = "?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true";
|
||||
private static final String USERNAME = "root";
|
||||
private static final String PASSWORD = System.getenv().getOrDefault("CODEGEN_DB_PASSWORD", "kaixinjiuhao");
|
||||
private static final String USERNAME = requiredEnv("CODEGEN_DB_USERNAME");
|
||||
private static final String PASSWORD = requiredEnv("CODEGEN_DB_PASSWORD");
|
||||
private static final String OUTPUT_DIR = System.getProperty("user.dir") + "/src/main/java";
|
||||
|
||||
public static void main(String[] args) {
|
||||
@@ -39,6 +39,14 @@ public class CodeGenerator {
|
||||
"agent_api_key", "agent_scope", "tool", "scope_tool", "token_scope", "audit_log", "admin_user");
|
||||
}
|
||||
|
||||
private static String requiredEnv(String name) {
|
||||
String value = System.getenv(name);
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalStateException("缺少环境变量 " + name);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static void generate(String schema, String module, String... tables) {
|
||||
FastAutoGenerator.create(BASE_URL + schema + URL_PARAMS, USERNAME, PASSWORD)
|
||||
.globalConfig(builder -> builder
|
||||
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
package me.iiv.agent.permission;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import me.iiv.agent.entity.m0.Agent;
|
||||
import me.iiv.agent.entity.m0.Authority;
|
||||
import me.iiv.agent.entity.m0.Properyt;
|
||||
import me.iiv.agent.entity.m0.User;
|
||||
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 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.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.springframework.data.redis.core.HashOperations;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
import java.util.Collection;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PermissionServiceTest {
|
||||
|
||||
@Mock
|
||||
AuthorityMapper authorityMapper;
|
||||
@Mock
|
||||
AgentMapper agentMapper;
|
||||
@Mock
|
||||
WarehouseAgentAuthMapper warehouseAgentAuthMapper;
|
||||
@Mock
|
||||
AgentManageDistrictAuthMapper agentManageDistrictAuthMapper;
|
||||
@Mock
|
||||
ProperytMapper properytMapper;
|
||||
@Mock
|
||||
StringRedisTemplate redisTemplate;
|
||||
@Mock
|
||||
HashOperations<String, Object, Object> hashOperations;
|
||||
|
||||
PermissionService permissionService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), Agent.class);
|
||||
permissionService = new PermissionService(authorityMapper, agentMapper,
|
||||
warehouseAgentAuthMapper, agentManageDistrictAuthMapper, properytMapper, redisTemplate);
|
||||
}
|
||||
|
||||
@Test
|
||||
void agentExactQueryKeepsSelfOrDirectSubordinateBoundary() {
|
||||
User user = user(1000L, 108L, 1L);
|
||||
when(authorityMapper.selectById(108L)).thenReturn(authority(108L, 1));
|
||||
|
||||
LambdaQueryWrapper<Agent> wrapper = new LambdaQueryWrapper<>();
|
||||
permissionService.applyAgentListRowFilter(wrapper, user, 79L);
|
||||
|
||||
String sql = wrapper.getSqlSegment();
|
||||
Collection<Object> values = wrapper.getParamNameValuePairs().values();
|
||||
assertTrue(sql.contains("id ="));
|
||||
assertTrue(sql.contains("supervisor_id ="));
|
||||
assertTrue(values.contains(79L));
|
||||
assertTrue(values.contains(1L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void headquartersExactQueryDoesNotAddAgentBoundary() {
|
||||
User user = user(46L, 1L, 0L);
|
||||
when(authorityMapper.selectById(1L)).thenReturn(authority(1L, 0));
|
||||
|
||||
LambdaQueryWrapper<Agent> wrapper = new LambdaQueryWrapper<>();
|
||||
permissionService.applyAgentListRowFilter(wrapper, user, 79L);
|
||||
|
||||
assertTrue(wrapper.getSqlSegment().contains("id ="));
|
||||
assertFalse(wrapper.getSqlSegment().contains("supervisor_id ="));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingAuthorityIsDeniedInsteadOfTreatedAsHeadquarters() {
|
||||
when(authorityMapper.selectById(999L)).thenReturn(null);
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> permissionService.isAgentAuthority(999L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deletedAuthorityIsDeniedInsteadOfTreatedAsHeadquarters() {
|
||||
Authority authority = authority(108L, 1);
|
||||
authority.setSoftDelete(1);
|
||||
when(authorityMapper.selectById(108L)).thenReturn(authority);
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> permissionService.isAgentAuthority(108L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownAuthorityModeIsDeniedInsteadOfTreatedAsHeadquarters() {
|
||||
when(authorityMapper.selectById(108L)).thenReturn(authority(108L, 2));
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> permissionService.isAgentAuthority(108L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void agentUserWithoutAgentIdIsDenied() {
|
||||
User user = user(1000L, 108L, null);
|
||||
when(authorityMapper.selectById(108L)).thenReturn(authority(108L, 1));
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> permissionService.applyAgentListRowFilter(new LambdaQueryWrapper<>(), user, 79L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void agentUserWithoutAgentIdIsDeniedForPropertyList() {
|
||||
User user = user(1000L, 108L, null);
|
||||
when(authorityMapper.selectById(108L)).thenReturn(authority(108L, 1));
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> permissionService.applyPropertyRowFilter(new LambdaQueryWrapper<Properyt>(), user));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingSalesDictionaryIsDeniedInsteadOfDisablingFilter() {
|
||||
User user = user(1000L, 108L, 1L);
|
||||
user.setDeptId(5L);
|
||||
user.setPostId(7L);
|
||||
when(redisTemplate.opsForHash()).thenReturn(hashOperations);
|
||||
when(hashOperations.get("Dictionary", "authDeptId")).thenReturn(null);
|
||||
when(hashOperations.get("Dictionary", "authDeptPostId")).thenReturn(null);
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> permissionService.isSalesNonLeader(user));
|
||||
}
|
||||
|
||||
private static Authority authority(Long id, int isAgent) {
|
||||
Authority authority = new Authority();
|
||||
authority.setId(id);
|
||||
authority.setIsagent(isAgent);
|
||||
authority.setSoftDelete(0);
|
||||
return authority;
|
||||
}
|
||||
|
||||
private static User user(Long id, Long authorityId, Long agentId) {
|
||||
User user = new User();
|
||||
user.setId(id);
|
||||
user.setAuthorityId(authorityId);
|
||||
user.setAgentId(agentId);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package me.iiv.agent.security;
|
||||
|
||||
import me.iiv.agent.entity.m0.User;
|
||||
import me.iiv.agent.entity.svc.AgentApiKey;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
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.mock.web.MockFilterChain;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AkAuthFilterTest {
|
||||
|
||||
@Mock
|
||||
AuthService authService;
|
||||
|
||||
@AfterEach
|
||||
void clearContext() {
|
||||
AuthContextHolder.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingBearerTokenReturnsUnauthorized() throws Exception {
|
||||
AkAuthFilter filter = new AkAuthFilter(authService);
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
assertEquals(401, response.getStatus());
|
||||
assertNull(chain.getRequest());
|
||||
verify(authService, never()).resolve(org.mockito.ArgumentMatchers.anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void validBearerTokenSetsAndClearsContext() throws Exception {
|
||||
AgentApiKey token = new AgentApiKey();
|
||||
token.setId(7L);
|
||||
User user = new User();
|
||||
user.setId(1000L);
|
||||
AuthContext context = new AuthContext(token, user);
|
||||
when(authService.resolve("valid-ak")).thenReturn(context);
|
||||
|
||||
AkAuthFilter filter = new AkAuthFilter(authService);
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp");
|
||||
request.addHeader("Authorization", "Bearer valid-ak");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
assertSame(request, chain.getRequest());
|
||||
assertNull(AuthContextHolder.get());
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package me.iiv.agent.security;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
|
||||
class ConfigurationSecurityTest {
|
||||
|
||||
private static final Pattern PASSWORD_WITH_DEFAULT =
|
||||
Pattern.compile("password:\\s*\\$\\{[^}:]+:[^}]+}");
|
||||
private static final Pattern COMMITTED_AK =
|
||||
Pattern.compile("xqak_[0-9a-f]{20,}");
|
||||
|
||||
@Test
|
||||
void applicationDoesNotActivateDevByDefault() throws IOException {
|
||||
String application = Files.readString(Path.of("src/main/resources/application.yml"));
|
||||
|
||||
assertFalse(application.contains("active: dev"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void devConfigurationHasNoRootUserOrPasswordDefaults() throws IOException {
|
||||
String application = Files.readString(Path.of("src/main/resources/application-dev.yml"));
|
||||
|
||||
assertFalse(application.contains("username: root"));
|
||||
assertFalse(PASSWORD_WITH_DEFAULT.matcher(application).find());
|
||||
}
|
||||
|
||||
@Test
|
||||
void devSeedContainsNoReusablePlaintextAk() throws IOException {
|
||||
String seed = Files.readString(Path.of("sql/seed-dev.sql"));
|
||||
|
||||
assertFalse(COMMITTED_AK.matcher(seed).find());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package me.iiv.agent.security;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import me.iiv.agent.entity.svc.AgentScope;
|
||||
import me.iiv.agent.entity.svc.ScopeTool;
|
||||
import me.iiv.agent.entity.svc.TokenScope;
|
||||
import me.iiv.agent.mapper.svc.AgentScopeMapper;
|
||||
import me.iiv.agent.mapper.svc.ScopeToolMapper;
|
||||
import me.iiv.agent.mapper.svc.TokenScopeMapper;
|
||||
import me.iiv.agent.mapper.svc.ToolMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@SuppressWarnings("unchecked")
|
||||
class ToolGuardTest {
|
||||
|
||||
@Mock
|
||||
TokenScopeMapper tokenScopeMapper;
|
||||
@Mock
|
||||
ScopeToolMapper scopeToolMapper;
|
||||
@Mock
|
||||
ToolMapper toolMapper;
|
||||
@Mock
|
||||
AgentScopeMapper agentScopeMapper;
|
||||
|
||||
@Test
|
||||
void tokenWithoutScopeIsDenied() {
|
||||
when(tokenScopeMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of());
|
||||
ToolGuard guard = guard();
|
||||
|
||||
assertFalse(guard.isAllowed(7L, "agent.list.query"));
|
||||
verify(scopeToolMapper, never()).selectList(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledScopeIsDenied() {
|
||||
when(tokenScopeMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(tokenScope(3L)));
|
||||
when(agentScopeMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of());
|
||||
ToolGuard guard = guard();
|
||||
|
||||
assertFalse(guard.isAllowed(7L, "agent.list.query"));
|
||||
verify(scopeToolMapper, never()).selectList(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void enabledScopeAndToolAreAllowed() {
|
||||
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);
|
||||
ToolGuard guard = guard();
|
||||
|
||||
assertTrue(guard.isAllowed(7L, "agent.list.query"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledToolIsDenied() {
|
||||
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);
|
||||
ToolGuard guard = guard();
|
||||
|
||||
assertFalse(guard.isAllowed(7L, "agent.list.query"));
|
||||
}
|
||||
|
||||
private ToolGuard guard() {
|
||||
return new ToolGuard(tokenScopeMapper, scopeToolMapper, toolMapper, agentScopeMapper);
|
||||
}
|
||||
|
||||
private static TokenScope tokenScope(Long scopeId) {
|
||||
TokenScope tokenScope = new TokenScope();
|
||||
tokenScope.setScopeId(scopeId);
|
||||
return tokenScope;
|
||||
}
|
||||
|
||||
private static AgentScope scope(Long id) {
|
||||
AgentScope scope = new AgentScope();
|
||||
scope.setId(id);
|
||||
return scope;
|
||||
}
|
||||
|
||||
private static ScopeTool scopeTool(Long toolId) {
|
||||
ScopeTool scopeTool = new ScopeTool();
|
||||
scopeTool.setToolId(toolId);
|
||||
return scopeTool;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user