diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentController.java index 78cee52c..b2cb90c5 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentController.java @@ -137,6 +137,18 @@ public class AgentController extends BaseCurdController { return Result.ok(service.updateDraft(agent)); } + /** + * 更新 Agent 可见范围。 + * + * @param agent 包含 Agent ID 和可见范围的请求数据 + * @return 更新后的 Agent + */ + @PostMapping("visibilityScope/update") + @SaCheckPermission("/api/v1/agent/save") + public Result updateVisibilityScope(@JsonBody Agent agent) { + return Result.ok(service.updateVisibilityScope(agent.getId(), agent.getVisibilityScope())); + } + /** * 查询 Agent 列表。 * diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/security/AgentVisibilityQueryHelper.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/security/AgentVisibilityQueryHelper.java index f92e6384..ef5d4620 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/security/AgentVisibilityQueryHelper.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/security/AgentVisibilityQueryHelper.java @@ -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 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 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; + } } diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentService.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentService.java index bd688685..ae4f3738 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentService.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentService.java @@ -35,6 +35,15 @@ public interface AgentService extends IService { */ Agent updateDraft(Agent agent); + /** + * 更新 Agent 的可见范围。 + * + * @param agentId Agent ID + * @param visibilityScope 可见范围编码 + * @return 更新后的 Agent + */ + Agent updateVisibilityScope(BigInteger agentId, String visibilityScope); + /** * 获取已发布运行视图。 * diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentServiceImpl.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentServiceImpl.java index 9e8f1723..3b443da3 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentServiceImpl.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentServiceImpl.java @@ -114,6 +114,29 @@ public class AgentServiceImpl extends ServiceImpl 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 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 运行配置中的文档上下文预算。 * diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/security/AgentVisibilityQueryHelperTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/security/AgentVisibilityQueryHelperTest.java new file mode 100644 index 00000000..e15f2ac3 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/security/AgentVisibilityQueryHelperTest.java @@ -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 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; + } +} diff --git a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/ResourceAccessServiceImpl.java b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/ResourceAccessServiceImpl.java index 55907dfb..200ed6bf 100644 --- a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/ResourceAccessServiceImpl.java +++ b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/ResourceAccessServiceImpl.java @@ -75,7 +75,12 @@ public class ResourceAccessServiceImpl implements ResourceAccessService { && categoryPermissionService.getAccess(resourceType.getCode(), loginAccount).isAllAccess()) { return true; } - if (!categoryPermissionService.canAccessCategory(loginAccount, resourceType.getCode(), resource.getCreatedBy(), resource.getCategoryId())) { + // Agent 的未分类语义为“全部分类可访问”,只跳过分类白名单,不能跳过可见范围校验。 + boolean agentWithoutCategoryRestriction = CategoryResourceType.AGENT == resourceType + && resource.getCategoryId() == null; + if (!agentWithoutCategoryRestriction + && !categoryPermissionService.canAccessCategory( + loginAccount, resourceType.getCode(), resource.getCreatedBy(), resource.getCategoryId())) { return false; } VisibilityScope scope = VisibilityScope.fromOrDefault(resource.getVisibilityScope(), VisibilityScope.PRIVATE); diff --git a/easyflow-modules/easyflow-module-system/src/test/java/tech/easyflow/system/service/impl/ResourceAccessServiceImplTest.java b/easyflow-modules/easyflow-module-system/src/test/java/tech/easyflow/system/service/impl/ResourceAccessServiceImplTest.java index 4a9fb07d..da271888 100644 --- a/easyflow-modules/easyflow-module-system/src/test/java/tech/easyflow/system/service/impl/ResourceAccessServiceImplTest.java +++ b/easyflow-modules/easyflow-module-system/src/test/java/tech/easyflow/system/service/impl/ResourceAccessServiceImplTest.java @@ -129,6 +129,36 @@ public class ResourceAccessServiceImplTest { assertFalse(service.canAccess(account, CategoryResourceType.SKILL, resource, ResourceAction.MANAGE)); } + /** + * 验证未分类公开 Agent 不受角色分类白名单限制,但不会影响其它资源类型。 + */ + @Test + public void unclassifiedPublicAgentShouldBypassCategoryWhitelist() { + LoginAccount account = account(8, 80); + VisibilityResource resource = new TestVisibilityResource( + BigInteger.ONE, BigInteger.valueOf(7), BigInteger.valueOf(90), null, + VisibilityScope.PUBLIC.name()); + + assertTrue(service.canAccess(account, CategoryResourceType.AGENT, resource, ResourceAction.USE)); + Mockito.verify(categoryPermissionService, Mockito.never()).canAccessCategory( + Mockito.any(), Mockito.anyString(), Mockito.any(), Mockito.any()); + } + + /** + * 验证未分类 Agent 仍受可见范围约束,私有 Agent 不会因分类放开而被读取。 + */ + @Test + public void unclassifiedPrivateAgentShouldRemainPrivate() { + LoginAccount account = account(8, 80); + VisibilityResource resource = new TestVisibilityResource( + BigInteger.ONE, BigInteger.valueOf(7), BigInteger.valueOf(90), null, + VisibilityScope.PRIVATE.name()); + + assertFalse(service.canAccess(account, CategoryResourceType.AGENT, resource, ResourceAction.READ)); + Mockito.verify(categoryPermissionService, Mockito.never()).canAccessCategory( + Mockito.any(), Mockito.anyString(), Mockito.any(), Mockito.any()); + } + /** * 验证资源动作不能跨越租户边界,即使资源是公开状态。 */ diff --git a/easyflow-ui-admin/app/src/views/ai/agents/AgentList.test.ts b/easyflow-ui-admin/app/src/views/ai/agents/AgentList.test.ts new file mode 100644 index 00000000..de92b8c9 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/agents/AgentList.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; + +import agentListSource from './AgentList.vue?raw'; +import apiSource from './api.ts?raw'; + +describe('AgentList 可见范围入口', () => { + it('exposes the three scope choices and uses the dedicated partial-update API', () => { + expect(agentListSource).toContain("label: '个人'"); + expect(agentListSource).toContain("label: '部门'"); + expect(agentListSource).toContain("label: '公开'"); + expect(agentListSource).toContain('updateAgentVisibilityScope'); + expect(agentListSource).toContain('agent-publish-chip'); + expect(agentListSource).toContain('agent-publish-chip__dot'); + expect(agentListSource).toContain('