feat: 支持智能体可见范围管理

- 未分类智能体按可见范围绕过分类白名单

- 提供个人、部门、公开范围配置及列表状态标签
This commit is contained in:
2026-07-31 16:45:41 +08:00
parent 527336bfc9
commit 6df3dd9981
13 changed files with 725 additions and 44 deletions

View File

@@ -59,21 +59,36 @@ public class AgentVisibilityQueryHelper {
return;
}
QueryCondition owner = AGENT.CREATED_BY.eq(accountId);
if (access.isRestricted() && access.getCategoryIds().isEmpty()) {
queryWrapper.and(owner);
return;
}
Set<BigInteger> readableDeptIds = account.getDeptId() == null
? Collections.emptySet()
: sysDeptService.getSelfAndAncestorDeptIds(account.getDeptId());
QueryCondition visible = AGENT.VISIBILITY_SCOPE.eq(VisibilityScope.PUBLIC.name());
if (!readableDeptIds.isEmpty()) {
visible = visible.or(AGENT.VISIBILITY_SCOPE.eq(VisibilityScope.DEPT.name())
.and(AGENT.DEPT_ID.in(readableDeptIds)));
}
QueryCondition visible = buildScopeVisibleCondition(readableDeptIds);
if (access.isRestricted()) {
visible = AGENT.CATEGORY_ID.in(access.getCategoryIds()).and(visible);
// Agent 未设置分类时表示不受分类白名单限制,仍需满足其可见范围。
QueryCondition readableCategories = AGENT.CATEGORY_ID.isNull()
.and(buildScopeVisibleCondition(readableDeptIds));
if (!access.getCategoryIds().isEmpty()) {
readableCategories = readableCategories.or(
AGENT.CATEGORY_ID.in(access.getCategoryIds())
.and(buildScopeVisibleCondition(readableDeptIds)));
}
visible = readableCategories;
}
queryWrapper.and(owner.or(visible));
}
/**
* 构建可见范围条件。
*
* @param readableDeptIds 当前账号可读取的部门 ID 集合
* @return 可见范围条件
*/
private QueryCondition buildScopeVisibleCondition(Set<BigInteger> readableDeptIds) {
QueryCondition scopeVisible = AGENT.VISIBILITY_SCOPE.eq(VisibilityScope.PUBLIC.name());
if (!readableDeptIds.isEmpty()) {
scopeVisible = scopeVisible.or(AGENT.VISIBILITY_SCOPE.eq(VisibilityScope.DEPT.name())
.and(AGENT.DEPT_ID.in(readableDeptIds)));
}
return scopeVisible;
}
}

View File

@@ -35,6 +35,15 @@ public interface AgentService extends IService<Agent> {
*/
Agent updateDraft(Agent agent);
/**
* 更新 Agent 的可见范围。
*
* @param agentId Agent ID
* @param visibilityScope 可见范围编码
* @return 更新后的 Agent
*/
Agent updateVisibilityScope(BigInteger agentId, String visibilityScope);
/**
* 获取已发布运行视图。
*

View File

@@ -114,6 +114,29 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
});
}
/**
* {@inheritDoc}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Agent updateVisibilityScope(BigInteger agentId, String visibilityScope) {
if (agentId == null) {
throw new BusinessException("Agent ID 不能为空");
}
VisibilityScope scope = parseVisibilityScope(visibilityScope);
return agentBindingLockExecutor.execute(agentId, () -> {
Agent existing = requireAgentForUpdate(agentId);
resourceAccessService.assertAccess(
CategoryResourceType.AGENT, existing, ResourceAction.MANAGE, "无权限管理该 Agent");
LoginAccount account = requireCurrentLoginAccount();
existing.setVisibilityScope(scope.name());
existing.setModified(new Date());
existing.setModifiedBy(account.getId());
updateById(existing);
return getDetail(existing.getId());
});
}
/**
* {@inheritDoc}
*/
@@ -244,6 +267,20 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
agent.setExecutionConfigJson(normalizeExecutionConfig(agent.getExecutionConfigJson()));
}
/**
* 解析并校验 Agent 可见范围。
*
* @param visibilityScope 可见范围编码
* @return 标准化后的可见范围
*/
private VisibilityScope parseVisibilityScope(String visibilityScope) {
try {
return VisibilityScope.from(visibilityScope);
} catch (IllegalArgumentException error) {
throw new BusinessException(error.getMessage());
}
}
/**
* 规范并校验 Agent 运行配置中的文档上下文预算。
*

View File

@@ -0,0 +1,70 @@
package tech.easyflow.agent.security;
import com.mybatisflex.core.query.QueryWrapper;
import org.junit.Test;
import org.mockito.MockedStatic;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.SysDeptService;
import java.math.BigInteger;
import java.util.Locale;
import java.util.Set;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
/**
* {@link AgentVisibilityQueryHelper} 未分类 Agent 查询权限回归测试。
*/
public class AgentVisibilityQueryHelperTest {
/**
* 验证受限分类角色的读取查询仍包含未分类公开 Agent。
*/
@Test
public void restrictedCategoryQueryShouldIncludeUnclassifiedAgents() {
CategoryPermissionService categoryPermissionService = mock(CategoryPermissionService.class);
SysDeptService sysDeptService = mock(SysDeptService.class);
AgentVisibilityQueryHelper helper = new AgentVisibilityQueryHelper(
categoryPermissionService, sysDeptService);
LoginAccount account = account(7, 42);
when(categoryPermissionService.getCurrentAccess(CategoryResourceType.AGENT.getCode()))
.thenReturn(new RoleCategoryAccessSnapshot(
CategoryResourceType.AGENT.getCode(), account.getId(), false, false,
Set.of(BigInteger.valueOf(99))));
QueryWrapper query = QueryWrapper.create().from(Agent.class);
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
helper.applyReadableAccess(query);
}
String sql = query.toSQL().toLowerCase(Locale.ROOT);
assertTrue("受限分类查询缺少未分类 Agent 分支: " + sql,
sql.contains("category_id") && sql.contains("is null"));
assertTrue("受限分类查询缺少已授权分类分支: " + sql, sql.contains("category_id` = 99"));
assertTrue("未分类 Agent 分支未附加可见范围: " + sql,
sql.matches("(?s).*category_id` is null\\s+and\\s+`visibility_scope` = 'public'.*"));
}
/**
* 创建测试使用的登录账号。
*
* @param accountId 账号 ID
* @param tenantId 租户 ID
* @return 登录账号
*/
private LoginAccount account(long accountId, long tenantId) {
LoginAccount account = new LoginAccount();
account.setId(BigInteger.valueOf(accountId));
account.setTenantId(BigInteger.valueOf(tenantId));
return account;
}
}