feat(kg): 实现查询阶段的用户数据权限过滤

新增功能:
- 查询阶段权限过滤:管理员看全部,普通用户只看自己创建的数据
- 结构实体(User、Org、Field)对所有用户可见
- 业务实体(Dataset、Workflow、Job、LabelTask、KnowledgeSet)按 created_by 过滤
- CONFIDENTIAL 敏感度过滤:需要特定权限才能查看

安全修复(四轮迭代):
P1-1: CONFIDENTIAL 敏感度过滤
- 4 个查询入口统一计算 excludeConfidential
- assertEntityAccess / isEntityAccessible 新增保密数据检查
- buildPermissionPredicate 在 Cypher 中追加 sensitivity 条件

P1-2: 结构实体按类型白名单判定
- 新增常量 STRUCTURAL_ENTITY_TYPES = Set.of("User", "Org", "Field")
- 业务实体必须匹配 created_by(缺失则拒绝)
- Cypher 从 IS NULL OR 改为 type IN ['User', 'Org', 'Field'] OR

P2-1: getNeighborGraph 路径级权限旁路
- 改为 ALL(n IN nodes(p) WHERE ...) 路径全节点过滤
- 与 getShortestPath 保持一致

P2-2: CONFIDENTIAL 大小写归一化
- Cypher 用 toUpper(trim(...)) 比较
- Java 用 equalsIgnoreCase
- 与 data-management-service 保持一致

权限模型:
- 同步阶段:全量同步(保持图谱完整性)
- 查询阶段:根据用户权限过滤结果
- 使用 RequestUserContextHolder 和 ResourceAccessService

代码变更:+642 行,-32 行
测试结果:130 tests, 0 failures
新增 9 个测试用例

已知 P3 问题(非阻断,可后续优化):
- 组件扫描范围偏大
- 测试质量可进一步增强
- 结构实体白名单重复维护
This commit is contained in:
2026-02-18 12:24:09 +08:00
parent ebb4548ca5
commit 75db6daeb5
4 changed files with 642 additions and 32 deletions

View File

@@ -1,11 +1,13 @@
package com.datamate.knowledgegraph.application;
import com.datamate.common.auth.application.ResourceAccessService;
import com.datamate.common.infrastructure.exception.BusinessException;
import com.datamate.knowledgegraph.domain.model.GraphEntity;
import com.datamate.knowledgegraph.domain.repository.GraphEntityRepository;
import com.datamate.knowledgegraph.infrastructure.neo4j.KnowledgeGraphProperties;
import com.datamate.knowledgegraph.interfaces.dto.SubgraphVO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
@@ -13,7 +15,9 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.neo4j.core.Neo4jClient;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
@@ -38,6 +42,9 @@ class GraphQueryServiceTest {
@Mock
private KnowledgeGraphProperties properties;
@Mock
private ResourceAccessService resourceAccessService;
@InjectMocks
private GraphQueryService queryService;
@@ -174,4 +181,417 @@ class GraphQueryServiceTest {
assertThat(result.getContent()).isEmpty();
}
// -----------------------------------------------------------------------
// 权限过滤
// -----------------------------------------------------------------------
@Nested
class PermissionFilteringTest {
private static final String CURRENT_USER_ID = "user-123";
private static final String OTHER_USER_ID = "other-user";
// -- getNeighborGraph 权限 --
@Test
void getNeighborGraph_nonAdmin_otherEntity_throwsInsufficientPermissions() {
when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn(CURRENT_USER_ID);
GraphEntity entity = GraphEntity.builder()
.id(ENTITY_ID).name("Other's Dataset").type("Dataset").graphId(GRAPH_ID)
.properties(new HashMap<>(Map.of("created_by", OTHER_USER_ID)))
.build();
when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID))
.thenReturn(Optional.of(entity));
assertThatThrownBy(() -> queryService.getNeighborGraph(GRAPH_ID, ENTITY_ID, 2, 50))
.isInstanceOf(BusinessException.class);
verifyNoInteractions(neo4jClient);
}
@Test
void getNeighborGraph_admin_otherEntity_noPermissionDenied() {
// 管理员返回 null → 不过滤
when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn(null);
GraphEntity entity = GraphEntity.builder()
.id(ENTITY_ID).name("Other's Dataset").type("Dataset").graphId(GRAPH_ID)
.properties(new HashMap<>(Map.of("created_by", OTHER_USER_ID)))
.build();
when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID))
.thenReturn(Optional.of(entity));
when(properties.getMaxDepth()).thenReturn(3);
when(properties.getMaxNodesPerQuery()).thenReturn(500);
// 管理员不会被权限拦截,会继续到 Neo4jClient 调用
// 由于 Neo4jClient 未完全 mock,会抛出其他异常,不是 BusinessException
try {
queryService.getNeighborGraph(GRAPH_ID, ENTITY_ID, 2, 50);
} catch (BusinessException e) {
throw new AssertionError("Admin should not be blocked by permission check", e);
} catch (Exception ignored) {
// Neo4jClient mock chain 未完成,预期其他异常
}
}
// -- getShortestPath 权限 --
@Test
void getShortestPath_nonAdmin_sourceNotAccessible_throws() {
when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn(CURRENT_USER_ID);
GraphEntity sourceEntity = GraphEntity.builder()
.id(ENTITY_ID).name("Other's Dataset").type("Dataset").graphId(GRAPH_ID)
.properties(new HashMap<>(Map.of("created_by", OTHER_USER_ID)))
.build();
when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID))
.thenReturn(Optional.of(sourceEntity));
assertThatThrownBy(() -> queryService.getShortestPath(GRAPH_ID, ENTITY_ID, ENTITY_ID_2, 3))
.isInstanceOf(BusinessException.class);
verifyNoInteractions(neo4jClient);
}
@Test
void getShortestPath_nonAdmin_targetNotAccessible_throws() {
when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn(CURRENT_USER_ID);
GraphEntity sourceEntity = GraphEntity.builder()
.id(ENTITY_ID).name("My Dataset").type("Dataset").graphId(GRAPH_ID)
.properties(new HashMap<>(Map.of("created_by", CURRENT_USER_ID)))
.build();
GraphEntity targetEntity = GraphEntity.builder()
.id(ENTITY_ID_2).name("Other's Dataset").type("Dataset").graphId(GRAPH_ID)
.properties(new HashMap<>(Map.of("created_by", OTHER_USER_ID)))
.build();
when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID))
.thenReturn(Optional.of(sourceEntity));
when(entityRepository.findByIdAndGraphId(ENTITY_ID_2, GRAPH_ID))
.thenReturn(Optional.of(targetEntity));
assertThatThrownBy(() -> queryService.getShortestPath(GRAPH_ID, ENTITY_ID, ENTITY_ID_2, 3))
.isInstanceOf(BusinessException.class);
verifyNoInteractions(neo4jClient);
}
@Test
void getShortestPath_nonAdmin_sameOwnEntity_returnsSingleNode() {
when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn(CURRENT_USER_ID);
GraphEntity entity = GraphEntity.builder()
.id(ENTITY_ID).name("My Dataset").type("Dataset").graphId(GRAPH_ID)
.properties(new HashMap<>(Map.of("created_by", CURRENT_USER_ID)))
.build();
when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID))
.thenReturn(Optional.of(entity));
var result = queryService.getShortestPath(GRAPH_ID, ENTITY_ID, ENTITY_ID, 3);
assertThat(result.getPathLength()).isEqualTo(0);
assertThat(result.getNodes()).hasSize(1);
assertThat(result.getNodes().get(0).getName()).isEqualTo("My Dataset");
}
@Test
void getShortestPath_nonAdmin_structuralEntity_noPermissionDenied() {
// 结构型实体(无 created_by)对所有用户可见
when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn(CURRENT_USER_ID);
GraphEntity structuralEntity = GraphEntity.builder()
.id(ENTITY_ID).name("Admin User").type("User").graphId(GRAPH_ID)
.properties(new HashMap<>())
.build();
when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID))
.thenReturn(Optional.of(structuralEntity));
// 起止相同 → 返回单节点路径,不需要 Neo4jClient
var result = queryService.getShortestPath(GRAPH_ID, ENTITY_ID, ENTITY_ID, 3);
assertThat(result.getPathLength()).isEqualTo(0);
assertThat(result.getNodes()).hasSize(1);
assertThat(result.getNodes().get(0).getType()).isEqualTo("User");
}
// -- getSubgraph 权限过滤 --
@Test
void getSubgraph_nonAdmin_filtersInaccessibleEntities() {
when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn(CURRENT_USER_ID);
when(properties.getMaxNodesPerQuery()).thenReturn(500);
GraphEntity ownEntity = GraphEntity.builder()
.id(ENTITY_ID).name("My Dataset").type("Dataset").graphId(GRAPH_ID)
.properties(new HashMap<>(Map.of("created_by", CURRENT_USER_ID)))
.build();
GraphEntity otherEntity = GraphEntity.builder()
.id(ENTITY_ID_2).name("Other Dataset").type("Dataset").graphId(GRAPH_ID)
.properties(new HashMap<>(Map.of("created_by", OTHER_USER_ID)))
.build();
when(entityRepository.findByGraphIdAndIdIn(GRAPH_ID, List.of(ENTITY_ID, ENTITY_ID_2)))
.thenReturn(List.of(ownEntity, otherEntity));
SubgraphVO result = queryService.getSubgraph(GRAPH_ID, List.of(ENTITY_ID, ENTITY_ID_2));
// 只返回自己创建的实体(另一个被过滤),单节点无边
assertThat(result.getNodes()).hasSize(1);
assertThat(result.getNodes().get(0).getName()).isEqualTo("My Dataset");
assertThat(result.getEdges()).isEmpty();
assertThat(result.getNodeCount()).isEqualTo(1);
}
@Test
void getSubgraph_nonAdmin_allFiltered_returnsEmptySubgraph() {
when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn(CURRENT_USER_ID);
when(properties.getMaxNodesPerQuery()).thenReturn(500);
GraphEntity otherEntity = GraphEntity.builder()
.id(ENTITY_ID).name("Other Dataset").type("Dataset").graphId(GRAPH_ID)
.properties(new HashMap<>(Map.of("created_by", OTHER_USER_ID)))
.build();
when(entityRepository.findByGraphIdAndIdIn(GRAPH_ID, List.of(ENTITY_ID)))
.thenReturn(List.of(otherEntity));
SubgraphVO result = queryService.getSubgraph(GRAPH_ID, List.of(ENTITY_ID));
assertThat(result.getNodes()).isEmpty();
assertThat(result.getEdges()).isEmpty();
assertThat(result.getNodeCount()).isEqualTo(0);
}
@Test
void getSubgraph_nonAdmin_structuralEntitiesVisible() {
when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn(CURRENT_USER_ID);
when(properties.getMaxNodesPerQuery()).thenReturn(500);
// 结构型实体没有 created_by → 对所有用户可见
GraphEntity structuralEntity = GraphEntity.builder()
.id(ENTITY_ID).name("Default Org").type("Org").graphId(GRAPH_ID)
.properties(new HashMap<>())
.build();
when(entityRepository.findByGraphIdAndIdIn(GRAPH_ID, List.of(ENTITY_ID)))
.thenReturn(List.of(structuralEntity));
SubgraphVO result = queryService.getSubgraph(GRAPH_ID, List.of(ENTITY_ID));
assertThat(result.getNodes()).hasSize(1);
assertThat(result.getNodes().get(0).getType()).isEqualTo("Org");
}
@Test
void getSubgraph_admin_seesAllEntities() {
// 管理员返回 null → 不过滤
when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn(null);
when(properties.getMaxNodesPerQuery()).thenReturn(500);
GraphEntity otherUserEntity = GraphEntity.builder()
.id(ENTITY_ID).name("Other's Dataset").type("Dataset").graphId(GRAPH_ID)
.properties(new HashMap<>(Map.of("created_by", "user-1")))
.build();
when(entityRepository.findByGraphIdAndIdIn(GRAPH_ID, List.of(ENTITY_ID)))
.thenReturn(List.of(otherUserEntity));
SubgraphVO result = queryService.getSubgraph(GRAPH_ID, List.of(ENTITY_ID));
// 管理员看到其他用户的实体(不被过滤)
assertThat(result.getNodes()).hasSize(1);
assertThat(result.getNodes().get(0).getName()).isEqualTo("Other's Dataset");
}
// -- P1-2: 业务实体缺失 created_by(脏数据)被正确拦截 --
@Test
void getNeighborGraph_nonAdmin_businessEntityWithoutCreatedBy_throws() {
when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn(CURRENT_USER_ID);
// 业务实体缺失 created_by → 应被拦截
GraphEntity dirtyEntity = GraphEntity.builder()
.id(ENTITY_ID).name("Dirty Dataset").type("Dataset").graphId(GRAPH_ID)
.properties(new HashMap<>())
.build();
when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID))
.thenReturn(Optional.of(dirtyEntity));
assertThatThrownBy(() -> queryService.getNeighborGraph(GRAPH_ID, ENTITY_ID, 2, 50))
.isInstanceOf(BusinessException.class);
verifyNoInteractions(neo4jClient);
}
@Test
void getSubgraph_nonAdmin_businessEntityWithoutCreatedBy_filtered() {
when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn(CURRENT_USER_ID);
when(properties.getMaxNodesPerQuery()).thenReturn(500);
// 业务实体缺失 created_by → 应被过滤
GraphEntity dirtyEntity = GraphEntity.builder()
.id(ENTITY_ID).name("Dirty Dataset").type("Dataset").graphId(GRAPH_ID)
.properties(new HashMap<>())
.build();
when(entityRepository.findByGraphIdAndIdIn(GRAPH_ID, List.of(ENTITY_ID)))
.thenReturn(List.of(dirtyEntity));
SubgraphVO result = queryService.getSubgraph(GRAPH_ID, List.of(ENTITY_ID));
assertThat(result.getNodes()).isEmpty();
assertThat(result.getNodeCount()).isEqualTo(0);
}
// -- P1-1: CONFIDENTIAL 敏感度过滤 --
@Test
void getNeighborGraph_nonAdmin_confidentialEntity_throwsWithoutPermission() {
when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn(CURRENT_USER_ID);
// canViewConfidential() 默认返回 false(mock 默认值)→ 无保密权限
GraphEntity confidentialEntity = GraphEntity.builder()
.id(ENTITY_ID).name("Secret KS").type("KnowledgeSet").graphId(GRAPH_ID)
.properties(new HashMap<>(Map.of("created_by", CURRENT_USER_ID, "sensitivity", "CONFIDENTIAL")))
.build();
when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID))
.thenReturn(Optional.of(confidentialEntity));
assertThatThrownBy(() -> queryService.getNeighborGraph(GRAPH_ID, ENTITY_ID, 2, 50))
.isInstanceOf(BusinessException.class);
verifyNoInteractions(neo4jClient);
}
@Test
void getNeighborGraph_nonAdmin_confidentialEntity_allowedWithPermission() {
when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn(CURRENT_USER_ID);
when(resourceAccessService.canViewConfidential()).thenReturn(true);
GraphEntity confidentialEntity = GraphEntity.builder()
.id(ENTITY_ID).name("Secret KS").type("KnowledgeSet").graphId(GRAPH_ID)
.properties(new HashMap<>(Map.of("created_by", CURRENT_USER_ID, "sensitivity", "CONFIDENTIAL")))
.build();
when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID))
.thenReturn(Optional.of(confidentialEntity));
when(properties.getMaxDepth()).thenReturn(3);
when(properties.getMaxNodesPerQuery()).thenReturn(500);
// 有保密权限 → 通过安全检查,继续到 Neo4jClient 调用
try {
queryService.getNeighborGraph(GRAPH_ID, ENTITY_ID, 2, 50);
} catch (BusinessException e) {
throw new AssertionError("Should not be blocked by permission check", e);
} catch (Exception ignored) {
// Neo4jClient mock chain 未完成,预期其他异常
}
}
@Test
void getSubgraph_nonAdmin_confidentialEntity_filteredWithoutPermission() {
when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn(CURRENT_USER_ID);
when(properties.getMaxNodesPerQuery()).thenReturn(500);
// canViewConfidential() 默认返回 false → 无保密权限
GraphEntity ownNonConfidential = GraphEntity.builder()
.id(ENTITY_ID).name("Normal KS").type("KnowledgeSet").graphId(GRAPH_ID)
.properties(new HashMap<>(Map.of("created_by", CURRENT_USER_ID)))
.build();
GraphEntity ownConfidential = GraphEntity.builder()
.id(ENTITY_ID_2).name("Secret KS").type("KnowledgeSet").graphId(GRAPH_ID)
.properties(new HashMap<>(Map.of("created_by", CURRENT_USER_ID, "sensitivity", "CONFIDENTIAL")))
.build();
when(entityRepository.findByGraphIdAndIdIn(GRAPH_ID, List.of(ENTITY_ID, ENTITY_ID_2)))
.thenReturn(List.of(ownNonConfidential, ownConfidential));
SubgraphVO result = queryService.getSubgraph(GRAPH_ID, List.of(ENTITY_ID, ENTITY_ID_2));
// CONFIDENTIAL 实体被过滤,只剩普通实体
assertThat(result.getNodes()).hasSize(1);
assertThat(result.getNodes().get(0).getName()).isEqualTo("Normal KS");
}
@Test
void getSubgraph_nonAdmin_confidentialEntity_visibleWithPermission() {
when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn(CURRENT_USER_ID);
when(resourceAccessService.canViewConfidential()).thenReturn(true);
when(properties.getMaxNodesPerQuery()).thenReturn(500);
GraphEntity ownConfidential = GraphEntity.builder()
.id(ENTITY_ID).name("Secret KS").type("KnowledgeSet").graphId(GRAPH_ID)
.properties(new HashMap<>(Map.of("created_by", CURRENT_USER_ID, "sensitivity", "CONFIDENTIAL")))
.build();
when(entityRepository.findByGraphIdAndIdIn(GRAPH_ID, List.of(ENTITY_ID)))
.thenReturn(List.of(ownConfidential));
SubgraphVO result = queryService.getSubgraph(GRAPH_ID, List.of(ENTITY_ID));
// 有保密权限 → 看到 CONFIDENTIAL 实体
assertThat(result.getNodes()).hasSize(1);
assertThat(result.getNodes().get(0).getName()).isEqualTo("Secret KS");
}
// -- P2-2: CONFIDENTIAL 大小写不敏感 --
@Test
void getNeighborGraph_nonAdmin_lowercaseConfidential_throwsWithoutPermission() {
when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn(CURRENT_USER_ID);
GraphEntity entity = GraphEntity.builder()
.id(ENTITY_ID).name("Secret KS").type("KnowledgeSet").graphId(GRAPH_ID)
.properties(new HashMap<>(Map.of("created_by", CURRENT_USER_ID, "sensitivity", "confidential")))
.build();
when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID))
.thenReturn(Optional.of(entity));
assertThatThrownBy(() -> queryService.getNeighborGraph(GRAPH_ID, ENTITY_ID, 2, 50))
.isInstanceOf(BusinessException.class);
verifyNoInteractions(neo4jClient);
}
@Test
void getNeighborGraph_nonAdmin_mixedCaseConfidentialWithSpaces_throwsWithoutPermission() {
when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn(CURRENT_USER_ID);
GraphEntity entity = GraphEntity.builder()
.id(ENTITY_ID).name("Secret KS").type("KnowledgeSet").graphId(GRAPH_ID)
.properties(new HashMap<>(Map.of("created_by", CURRENT_USER_ID, "sensitivity", " Confidential ")))
.build();
when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID))
.thenReturn(Optional.of(entity));
assertThatThrownBy(() -> queryService.getNeighborGraph(GRAPH_ID, ENTITY_ID, 2, 50))
.isInstanceOf(BusinessException.class);
verifyNoInteractions(neo4jClient);
}
@Test
void getSubgraph_nonAdmin_lowercaseConfidential_filteredWithoutPermission() {
when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn(CURRENT_USER_ID);
when(properties.getMaxNodesPerQuery()).thenReturn(500);
GraphEntity normalKs = GraphEntity.builder()
.id(ENTITY_ID).name("Normal KS").type("KnowledgeSet").graphId(GRAPH_ID)
.properties(new HashMap<>(Map.of("created_by", CURRENT_USER_ID)))
.build();
GraphEntity lowercaseConfidential = GraphEntity.builder()
.id(ENTITY_ID_2).name("Secret KS").type("KnowledgeSet").graphId(GRAPH_ID)
.properties(new HashMap<>(Map.of("created_by", CURRENT_USER_ID, "sensitivity", "confidential")))
.build();
when(entityRepository.findByGraphIdAndIdIn(GRAPH_ID, List.of(ENTITY_ID, ENTITY_ID_2)))
.thenReturn(List.of(normalKs, lowercaseConfidential));
SubgraphVO result = queryService.getSubgraph(GRAPH_ID, List.of(ENTITY_ID, ENTITY_ID_2));
assertThat(result.getNodes()).hasSize(1);
assertThat(result.getNodes().get(0).getName()).isEqualTo("Normal KS");
}
}
}