diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/KnowledgeGraphServiceConfiguration.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/KnowledgeGraphServiceConfiguration.java index 5b5adb0..8cfdf24 100644 --- a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/KnowledgeGraphServiceConfiguration.java +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/KnowledgeGraphServiceConfiguration.java @@ -12,7 +12,7 @@ import org.springframework.web.client.RestTemplate; import java.time.Duration; @Configuration -@ComponentScan(basePackages = "com.datamate.knowledgegraph") +@ComponentScan(basePackages = {"com.datamate.knowledgegraph", "com.datamate.common.auth"}) @EnableNeo4jRepositories(basePackages = "com.datamate.knowledgegraph.domain.repository") @EnableScheduling public class KnowledgeGraphServiceConfiguration { diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/GraphQueryService.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/GraphQueryService.java index 6f97dac..5a450f3 100644 --- a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/GraphQueryService.java +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/GraphQueryService.java @@ -1,5 +1,6 @@ package com.datamate.knowledgegraph.application; +import com.datamate.common.auth.application.ResourceAccessService; import com.datamate.common.infrastructure.exception.BusinessException; import com.datamate.common.infrastructure.exception.SystemErrorCode; import com.datamate.common.interfaces.PagedResponse; @@ -23,6 +24,13 @@ import java.util.regex.Pattern; *

* 提供图遍历(N 跳邻居、最短路径、子图提取)和全文搜索功能。 * 使用 {@link Neo4jClient} 执行复杂 Cypher 查询。 + *

+ * 查询结果根据用户权限进行过滤: + *

*/ @Service @Slf4j @@ -32,6 +40,9 @@ public class GraphQueryService { private static final String REL_TYPE = "RELATED_TO"; private static final long MAX_SKIP = 100_000L; + /** 结构型实体类型白名单:对所有用户可见,不按 created_by 过滤 */ + private static final Set STRUCTURAL_ENTITY_TYPES = Set.of("User", "Org", "Field"); + private static final Pattern UUID_PATTERN = Pattern.compile( "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" ); @@ -39,6 +50,7 @@ public class GraphQueryService { private final Neo4jClient neo4jClient; private final GraphEntityRepository entityRepository; private final KnowledgeGraphProperties properties; + private final ResourceAccessService resourceAccessService; // ----------------------------------------------------------------------- // N 跳邻居 @@ -52,15 +64,41 @@ public class GraphQueryService { */ public SubgraphVO getNeighborGraph(String graphId, String entityId, int depth, int limit) { validateGraphId(graphId); + String filterUserId = resolveOwnerFilter(); + boolean excludeConfidential = filterUserId != null && !resourceAccessService.canViewConfidential(); - // 校验实体存在 - entityRepository.findByIdAndGraphId(entityId, graphId) + // 校验实体存在 + 权限 + GraphEntity startEntity = entityRepository.findByIdAndGraphId(entityId, graphId) .orElseThrow(() -> BusinessException.of(KnowledgeGraphErrorCode.ENTITY_NOT_FOUND)); + if (filterUserId != null) { + assertEntityAccess(startEntity, filterUserId, excludeConfidential); + } + int clampedDepth = Math.max(1, Math.min(depth, properties.getMaxDepth())); int clampedLimit = Math.max(1, Math.min(limit, properties.getMaxNodesPerQuery())); - // 查询邻居节点(路径变量约束中间节点与关系均属于同一图谱) + // 路径级全节点权限过滤(与 getShortestPath 一致) + String permFilter = ""; + if (filterUserId != null) { + StringBuilder pf = new StringBuilder("AND ALL(n IN nodes(p) WHERE "); + pf.append("(n.type IN ['User', 'Org', 'Field'] OR n.`properties.created_by` = $filterUserId)"); + if (excludeConfidential) { + pf.append(" AND (toUpper(trim(n.`properties.sensitivity`)) IS NULL OR toUpper(trim(n.`properties.sensitivity`)) <> 'CONFIDENTIAL')"); + } + pf.append(") "); + permFilter = pf.toString(); + } + + Map params = new HashMap<>(); + params.put("graphId", graphId); + params.put("entityId", entityId); + params.put("limit", clampedLimit); + if (filterUserId != null) { + params.put("filterUserId", filterUserId); + } + + // 查询邻居节点(路径变量约束中间节点与关系均属于同一图谱,权限过滤覆盖路径全节点) List nodes = neo4jClient .query( "MATCH p = (e:Entity {graph_id: $graphId, id: $entityId})" + @@ -68,11 +106,12 @@ public class GraphQueryService { "WHERE e <> neighbor " + " AND ALL(n IN nodes(p) WHERE n.graph_id = $graphId) " + " AND ALL(r IN relationships(p) WHERE r.graph_id = $graphId) " + + permFilter + "WITH DISTINCT neighbor LIMIT $limit " + "RETURN neighbor.id AS id, neighbor.name AS name, neighbor.type AS type, " + "neighbor.description AS description" ) - .bindAll(Map.of("graphId", graphId, "entityId", entityId, "limit", clampedLimit)) + .bindAll(params) .fetchAs(EntitySummaryVO.class) .mappedBy((ts, record) -> EntitySummaryVO.builder() .id(record.get("id").asString(null)) @@ -92,16 +131,13 @@ public class GraphQueryService { List edges = queryEdgesBetween(graphId, new ArrayList<>(nodeIds)); // 将起始节点加入节点列表 - GraphEntity startEntity = entityRepository.findByIdAndGraphId(entityId, graphId).orElse(null); List allNodes = new ArrayList<>(); - if (startEntity != null) { - allNodes.add(EntitySummaryVO.builder() - .id(startEntity.getId()) - .name(startEntity.getName()) - .type(startEntity.getType()) - .description(startEntity.getDescription()) - .build()); - } + allNodes.add(EntitySummaryVO.builder() + .id(startEntity.getId()) + .name(startEntity.getName()) + .type(startEntity.getType()) + .description(startEntity.getDescription()) + .build()); allNodes.addAll(nodes); return SubgraphVO.builder() @@ -124,22 +160,37 @@ public class GraphQueryService { */ public PathVO getShortestPath(String graphId, String sourceId, String targetId, int maxDepth) { validateGraphId(graphId); + String filterUserId = resolveOwnerFilter(); + boolean excludeConfidential = filterUserId != null && !resourceAccessService.canViewConfidential(); - // 校验两个实体存在 - entityRepository.findByIdAndGraphId(sourceId, graphId) + // 校验两个实体存在 + 权限 + GraphEntity sourceEntity = entityRepository.findByIdAndGraphId(sourceId, graphId) .orElseThrow(() -> BusinessException.of( KnowledgeGraphErrorCode.ENTITY_NOT_FOUND, "源实体不存在")); + + if (filterUserId != null) { + assertEntityAccess(sourceEntity, filterUserId, excludeConfidential); + } + entityRepository.findByIdAndGraphId(targetId, graphId) - .orElseThrow(() -> BusinessException.of( - KnowledgeGraphErrorCode.ENTITY_NOT_FOUND, "目标实体不存在")); + .ifPresentOrElse( + targetEntity -> { + if (filterUserId != null && !sourceId.equals(targetId)) { + assertEntityAccess(targetEntity, filterUserId, excludeConfidential); + } + }, + () -> { throw BusinessException.of( + KnowledgeGraphErrorCode.ENTITY_NOT_FOUND, "目标实体不存在"); } + ); if (sourceId.equals(targetId)) { // 起止相同,返回单节点路径 - GraphEntity entity = entityRepository.findByIdAndGraphId(sourceId, graphId).orElse(null); - EntitySummaryVO node = entity != null - ? EntitySummaryVO.builder().id(entity.getId()).name(entity.getName()) - .type(entity.getType()).description(entity.getDescription()).build() - : EntitySummaryVO.builder().id(sourceId).build(); + EntitySummaryVO node = EntitySummaryVO.builder() + .id(sourceEntity.getId()) + .name(sourceEntity.getName()) + .type(sourceEntity.getType()) + .description(sourceEntity.getDescription()) + .build(); return PathVO.builder() .nodes(List.of(node)) .edges(List.of()) @@ -149,13 +200,32 @@ public class GraphQueryService { int clampedDepth = Math.max(1, Math.min(maxDepth, properties.getMaxDepth())); + String permFilter = ""; + if (filterUserId != null) { + StringBuilder pf = new StringBuilder("AND ALL(n IN nodes(path) WHERE "); + pf.append("(n.type IN ['User', 'Org', 'Field'] OR n.`properties.created_by` = $filterUserId)"); + if (excludeConfidential) { + pf.append(" AND (toUpper(trim(n.`properties.sensitivity`)) IS NULL OR toUpper(trim(n.`properties.sensitivity`)) <> 'CONFIDENTIAL')"); + } + pf.append(") "); + permFilter = pf.toString(); + } + + Map params = new HashMap<>(); + params.put("graphId", graphId); + params.put("sourceId", sourceId); + params.put("targetId", targetId); + if (filterUserId != null) { + params.put("filterUserId", filterUserId); + } + // 使用 Neo4j shortestPath 函数 - // 返回路径上的节点和关系信息 String cypher = "MATCH (s:Entity {graph_id: $graphId, id: $sourceId}), " + " (t:Entity {graph_id: $graphId, id: $targetId}), " + " path = shortestPath((s)-[:" + REL_TYPE + "*1.." + clampedDepth + "]-(t)) " + "WHERE ALL(n IN nodes(path) WHERE n.graph_id = $graphId) " + + permFilter + "RETURN " + " [n IN nodes(path) | {id: n.id, name: n.name, type: n.type, description: n.description}] AS pathNodes, " + " [r IN relationships(path) | {id: r.id, relation_type: r.relation_type, weight: r.weight, " + @@ -163,7 +233,7 @@ public class GraphQueryService { " length(path) AS pathLength"; return neo4jClient.query(cypher) - .bindAll(Map.of("graphId", graphId, "sourceId", sourceId, "targetId", targetId)) + .bindAll(params) .fetchAs(PathVO.class) .mappedBy((ts, record) -> mapPathRecord(record)) .one() @@ -185,6 +255,8 @@ public class GraphQueryService { */ public SubgraphVO getSubgraph(String graphId, List entityIds) { validateGraphId(graphId); + String filterUserId = resolveOwnerFilter(); + boolean excludeConfidential = filterUserId != null && !resourceAccessService.canViewConfidential(); if (entityIds == null || entityIds.isEmpty()) { return SubgraphVO.builder() @@ -203,6 +275,14 @@ public class GraphQueryService { // 查询存在的实体 List entities = entityRepository.findByGraphIdAndIdIn(graphId, entityIds); + + // 权限过滤:非管理员只能看到自己创建的业务实体和结构型实体 + if (filterUserId != null) { + entities = entities.stream() + .filter(e -> isEntityAccessible(e, filterUserId, excludeConfidential)) + .toList(); + } + List nodes = entities.stream() .map(e -> EntitySummaryVO.builder() .id(e.getId()) @@ -247,6 +327,8 @@ public class GraphQueryService { */ public PagedResponse fulltextSearch(String graphId, String query, int page, int size) { validateGraphId(graphId); + String filterUserId = resolveOwnerFilter(); + boolean excludeConfidential = filterUserId != null && !resourceAccessService.canViewConfidential(); if (query == null || query.isBlank()) { return PagedResponse.of(List.of(), 0, 0, 0); @@ -261,22 +343,28 @@ public class GraphQueryService { // 对搜索关键词进行安全处理:转义 Lucene 特殊字符 String safeQuery = escapeLuceneQuery(query); + String permFilter = buildPermissionPredicate("node", filterUserId, excludeConfidential); + + Map searchParams = new HashMap<>(); + searchParams.put("graphId", graphId); + searchParams.put("query", safeQuery); + searchParams.put("skip", skip); + searchParams.put("size", safeSize); + if (filterUserId != null) { + searchParams.put("filterUserId", filterUserId); + } List results = neo4jClient .query( "CALL db.index.fulltext.queryNodes('entity_fulltext', $query) YIELD node, score " + "WHERE node.graph_id = $graphId " + + permFilter + "RETURN node.id AS id, node.name AS name, node.type AS type, " + "node.description AS description, score " + "ORDER BY score DESC " + "SKIP $skip LIMIT $size" ) - .bindAll(Map.of( - "graphId", graphId, - "query", safeQuery, - "skip", skip, - "size", safeSize - )) + .bindAll(searchParams) .fetchAs(SearchHitVO.class) .mappedBy((ts, record) -> SearchHitVO.builder() .id(record.get("id").asString(null)) @@ -288,13 +376,21 @@ public class GraphQueryService { .all() .stream().toList(); + Map countParams = new HashMap<>(); + countParams.put("graphId", graphId); + countParams.put("query", safeQuery); + if (filterUserId != null) { + countParams.put("filterUserId", filterUserId); + } + long total = neo4jClient .query( "CALL db.index.fulltext.queryNodes('entity_fulltext', $query) YIELD node, score " + "WHERE node.graph_id = $graphId " + + permFilter + "RETURN count(*) AS total" ) - .bindAll(Map.of("graphId", graphId, "query", safeQuery)) + .bindAll(countParams) .fetchAs(Long.class) .mappedBy((ts, record) -> record.get("total").asLong()) .one() @@ -304,6 +400,91 @@ public class GraphQueryService { return PagedResponse.of(results, safePage, total, totalPages); } + // ----------------------------------------------------------------------- + // 权限过滤 + // ----------------------------------------------------------------------- + + /** + * 获取 owner 过滤用户 ID。 + * 管理员返回 null(不过滤),普通用户返回当前 userId。 + */ + private String resolveOwnerFilter() { + return resourceAccessService.resolveOwnerFilterUserId(); + } + + /** + * 构建 Cypher 权限过滤条件片段。 + *

+ * 管理员返回空字符串(不过滤); + * 普通用户返回 AND 子句:仅保留结构型实体(User、Org、Field) + * 和 {@code created_by} 等于当前用户的业务实体。 + * 若无保密数据权限,额外过滤 sensitivity=CONFIDENTIAL。 + */ + private static String buildPermissionPredicate(String nodeAlias, String filterUserId, boolean excludeConfidential) { + StringBuilder sb = new StringBuilder(); + if (filterUserId != null) { + sb.append("AND (").append(nodeAlias).append(".type IN ['User', 'Org', 'Field'] OR ") + .append(nodeAlias).append(".`properties.created_by` = $filterUserId) "); + } + if (excludeConfidential) { + sb.append("AND (toUpper(trim(").append(nodeAlias).append(".`properties.sensitivity`)) IS NULL OR ") + .append("toUpper(trim(").append(nodeAlias).append(".`properties.sensitivity`)) <> 'CONFIDENTIAL') "); + } + return sb.toString(); + } + + /** + * 校验非管理员用户对实体的访问权限。 + * 保密数据需要 canViewConfidential 权限; + * 结构型实体(User、Org、Field)对所有用户可见; + * 业务实体必须匹配 created_by。 + */ + private static void assertEntityAccess(GraphEntity entity, String filterUserId, boolean excludeConfidential) { + // 保密数据检查(大小写不敏感,与 data-management 一致) + if (excludeConfidential) { + Object sensitivity = entity.getProperties() != null + ? entity.getProperties().get("sensitivity") : null; + if (sensitivity != null && sensitivity.toString().trim().equalsIgnoreCase("CONFIDENTIAL")) { + throw BusinessException.of(SystemErrorCode.INSUFFICIENT_PERMISSIONS, "无权访问保密数据"); + } + } + // 结构型实体按类型白名单放行 + if (STRUCTURAL_ENTITY_TYPES.contains(entity.getType())) { + return; + } + // 业务实体必须匹配 owner + Object createdBy = entity.getProperties() != null + ? entity.getProperties().get("created_by") : null; + if (createdBy == null || !filterUserId.equals(createdBy.toString())) { + throw BusinessException.of(SystemErrorCode.INSUFFICIENT_PERMISSIONS, "无权访问该实体"); + } + } + + /** + * 判断实体是否对指定用户可访问。 + * 保密数据需要 canViewConfidential 权限; + * 结构型实体(User、Org、Field)对所有用户可见; + * 业务实体必须匹配 created_by。 + */ + private static boolean isEntityAccessible(GraphEntity entity, String filterUserId, boolean excludeConfidential) { + // 保密数据检查(大小写不敏感,与 data-management 一致) + if (excludeConfidential) { + Object sensitivity = entity.getProperties() != null + ? entity.getProperties().get("sensitivity") : null; + if (sensitivity != null && sensitivity.toString().trim().equalsIgnoreCase("CONFIDENTIAL")) { + return false; + } + } + // 结构型实体按类型白名单放行 + if (STRUCTURAL_ENTITY_TYPES.contains(entity.getType())) { + return true; + } + // 业务实体必须匹配 owner + Object createdBy = entity.getProperties() != null + ? entity.getProperties().get("created_by") : null; + return createdBy != null && filterUserId.equals(createdBy.toString()); + } + // ----------------------------------------------------------------------- // 内部方法 // ----------------------------------------------------------------------- diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/GraphSyncStepService.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/GraphSyncStepService.java index f99c322..2f62f3b 100644 --- a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/GraphSyncStepService.java +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/GraphSyncStepService.java @@ -72,6 +72,9 @@ public class GraphSyncStepService { .map(TagDTO::getName).toList(); props.put("tags", tagNames); } + if (dto.getCreatedBy() != null) { + props.put("created_by", dto.getCreatedBy()); + } upsertEntity(graphId, dto.getId(), "Dataset", dto.getName(), dto.getDescription(), props, result); @@ -184,6 +187,9 @@ public class GraphSyncStepService { if (dto.getInputDatasetIds() != null) { props.put("input_dataset_ids", dto.getInputDatasetIds()); } + if (dto.getCreatedBy() != null) { + props.put("created_by", dto.getCreatedBy()); + } upsertEntity(graphId, dto.getId(), "Workflow", dto.getName(), dto.getDescription(), props, result); @@ -348,6 +354,9 @@ public class GraphSyncStepService { if (dto.getSourceDatasetIds() != null) { props.put("source_dataset_ids", dto.getSourceDatasetIds()); } + if (dto.getCreatedBy() != null) { + props.put("created_by", dto.getCreatedBy()); + } upsertEntity(graphId, dto.getId(), "KnowledgeSet", dto.getName(), dto.getDescription(), props, result); diff --git a/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphQueryServiceTest.java b/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphQueryServiceTest.java index 3660237..6fb1efa 100644 --- a/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphQueryServiceTest.java +++ b/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphQueryServiceTest.java @@ -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"); + } + } }