You've already forked DataMate
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:
@@ -12,7 +12,7 @@ import org.springframework.web.client.RestTemplate;
|
|||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@ComponentScan(basePackages = "com.datamate.knowledgegraph")
|
@ComponentScan(basePackages = {"com.datamate.knowledgegraph", "com.datamate.common.auth"})
|
||||||
@EnableNeo4jRepositories(basePackages = "com.datamate.knowledgegraph.domain.repository")
|
@EnableNeo4jRepositories(basePackages = "com.datamate.knowledgegraph.domain.repository")
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
public class KnowledgeGraphServiceConfiguration {
|
public class KnowledgeGraphServiceConfiguration {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.datamate.knowledgegraph.application;
|
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.BusinessException;
|
||||||
import com.datamate.common.infrastructure.exception.SystemErrorCode;
|
import com.datamate.common.infrastructure.exception.SystemErrorCode;
|
||||||
import com.datamate.common.interfaces.PagedResponse;
|
import com.datamate.common.interfaces.PagedResponse;
|
||||||
@@ -23,6 +24,13 @@ import java.util.regex.Pattern;
|
|||||||
* <p>
|
* <p>
|
||||||
* 提供图遍历(N 跳邻居、最短路径、子图提取)和全文搜索功能。
|
* 提供图遍历(N 跳邻居、最短路径、子图提取)和全文搜索功能。
|
||||||
* 使用 {@link Neo4jClient} 执行复杂 Cypher 查询。
|
* 使用 {@link Neo4jClient} 执行复杂 Cypher 查询。
|
||||||
|
* <p>
|
||||||
|
* 查询结果根据用户权限进行过滤:
|
||||||
|
* <ul>
|
||||||
|
* <li>管理员:不过滤,看到全部数据</li>
|
||||||
|
* <li>普通用户:按 {@code created_by} 过滤,只看到自己创建的业务实体;
|
||||||
|
* 结构型实体(User、Org、Field 等无 created_by 的实体)对所有用户可见</li>
|
||||||
|
* </ul>
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -32,6 +40,9 @@ public class GraphQueryService {
|
|||||||
private static final String REL_TYPE = "RELATED_TO";
|
private static final String REL_TYPE = "RELATED_TO";
|
||||||
private static final long MAX_SKIP = 100_000L;
|
private static final long MAX_SKIP = 100_000L;
|
||||||
|
|
||||||
|
/** 结构型实体类型白名单:对所有用户可见,不按 created_by 过滤 */
|
||||||
|
private static final Set<String> STRUCTURAL_ENTITY_TYPES = Set.of("User", "Org", "Field");
|
||||||
|
|
||||||
private static final Pattern UUID_PATTERN = Pattern.compile(
|
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}$"
|
"^[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 Neo4jClient neo4jClient;
|
||||||
private final GraphEntityRepository entityRepository;
|
private final GraphEntityRepository entityRepository;
|
||||||
private final KnowledgeGraphProperties properties;
|
private final KnowledgeGraphProperties properties;
|
||||||
|
private final ResourceAccessService resourceAccessService;
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// N 跳邻居
|
// N 跳邻居
|
||||||
@@ -52,15 +64,41 @@ public class GraphQueryService {
|
|||||||
*/
|
*/
|
||||||
public SubgraphVO getNeighborGraph(String graphId, String entityId, int depth, int limit) {
|
public SubgraphVO getNeighborGraph(String graphId, String entityId, int depth, int limit) {
|
||||||
validateGraphId(graphId);
|
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));
|
.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 clampedDepth = Math.max(1, Math.min(depth, properties.getMaxDepth()));
|
||||||
int clampedLimit = Math.max(1, Math.min(limit, properties.getMaxNodesPerQuery()));
|
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<String, Object> params = new HashMap<>();
|
||||||
|
params.put("graphId", graphId);
|
||||||
|
params.put("entityId", entityId);
|
||||||
|
params.put("limit", clampedLimit);
|
||||||
|
if (filterUserId != null) {
|
||||||
|
params.put("filterUserId", filterUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询邻居节点(路径变量约束中间节点与关系均属于同一图谱,权限过滤覆盖路径全节点)
|
||||||
List<EntitySummaryVO> nodes = neo4jClient
|
List<EntitySummaryVO> nodes = neo4jClient
|
||||||
.query(
|
.query(
|
||||||
"MATCH p = (e:Entity {graph_id: $graphId, id: $entityId})" +
|
"MATCH p = (e:Entity {graph_id: $graphId, id: $entityId})" +
|
||||||
@@ -68,11 +106,12 @@ public class GraphQueryService {
|
|||||||
"WHERE e <> neighbor " +
|
"WHERE e <> neighbor " +
|
||||||
" AND ALL(n IN nodes(p) WHERE n.graph_id = $graphId) " +
|
" AND ALL(n IN nodes(p) WHERE n.graph_id = $graphId) " +
|
||||||
" AND ALL(r IN relationships(p) WHERE r.graph_id = $graphId) " +
|
" AND ALL(r IN relationships(p) WHERE r.graph_id = $graphId) " +
|
||||||
|
permFilter +
|
||||||
"WITH DISTINCT neighbor LIMIT $limit " +
|
"WITH DISTINCT neighbor LIMIT $limit " +
|
||||||
"RETURN neighbor.id AS id, neighbor.name AS name, neighbor.type AS type, " +
|
"RETURN neighbor.id AS id, neighbor.name AS name, neighbor.type AS type, " +
|
||||||
"neighbor.description AS description"
|
"neighbor.description AS description"
|
||||||
)
|
)
|
||||||
.bindAll(Map.of("graphId", graphId, "entityId", entityId, "limit", clampedLimit))
|
.bindAll(params)
|
||||||
.fetchAs(EntitySummaryVO.class)
|
.fetchAs(EntitySummaryVO.class)
|
||||||
.mappedBy((ts, record) -> EntitySummaryVO.builder()
|
.mappedBy((ts, record) -> EntitySummaryVO.builder()
|
||||||
.id(record.get("id").asString(null))
|
.id(record.get("id").asString(null))
|
||||||
@@ -92,16 +131,13 @@ public class GraphQueryService {
|
|||||||
List<EdgeSummaryVO> edges = queryEdgesBetween(graphId, new ArrayList<>(nodeIds));
|
List<EdgeSummaryVO> edges = queryEdgesBetween(graphId, new ArrayList<>(nodeIds));
|
||||||
|
|
||||||
// 将起始节点加入节点列表
|
// 将起始节点加入节点列表
|
||||||
GraphEntity startEntity = entityRepository.findByIdAndGraphId(entityId, graphId).orElse(null);
|
|
||||||
List<EntitySummaryVO> allNodes = new ArrayList<>();
|
List<EntitySummaryVO> allNodes = new ArrayList<>();
|
||||||
if (startEntity != null) {
|
allNodes.add(EntitySummaryVO.builder()
|
||||||
allNodes.add(EntitySummaryVO.builder()
|
.id(startEntity.getId())
|
||||||
.id(startEntity.getId())
|
.name(startEntity.getName())
|
||||||
.name(startEntity.getName())
|
.type(startEntity.getType())
|
||||||
.type(startEntity.getType())
|
.description(startEntity.getDescription())
|
||||||
.description(startEntity.getDescription())
|
.build());
|
||||||
.build());
|
|
||||||
}
|
|
||||||
allNodes.addAll(nodes);
|
allNodes.addAll(nodes);
|
||||||
|
|
||||||
return SubgraphVO.builder()
|
return SubgraphVO.builder()
|
||||||
@@ -124,22 +160,37 @@ public class GraphQueryService {
|
|||||||
*/
|
*/
|
||||||
public PathVO getShortestPath(String graphId, String sourceId, String targetId, int maxDepth) {
|
public PathVO getShortestPath(String graphId, String sourceId, String targetId, int maxDepth) {
|
||||||
validateGraphId(graphId);
|
validateGraphId(graphId);
|
||||||
|
String filterUserId = resolveOwnerFilter();
|
||||||
|
boolean excludeConfidential = filterUserId != null && !resourceAccessService.canViewConfidential();
|
||||||
|
|
||||||
// 校验两个实体存在
|
// 校验两个实体存在 + 权限
|
||||||
entityRepository.findByIdAndGraphId(sourceId, graphId)
|
GraphEntity sourceEntity = entityRepository.findByIdAndGraphId(sourceId, graphId)
|
||||||
.orElseThrow(() -> BusinessException.of(
|
.orElseThrow(() -> BusinessException.of(
|
||||||
KnowledgeGraphErrorCode.ENTITY_NOT_FOUND, "源实体不存在"));
|
KnowledgeGraphErrorCode.ENTITY_NOT_FOUND, "源实体不存在"));
|
||||||
|
|
||||||
|
if (filterUserId != null) {
|
||||||
|
assertEntityAccess(sourceEntity, filterUserId, excludeConfidential);
|
||||||
|
}
|
||||||
|
|
||||||
entityRepository.findByIdAndGraphId(targetId, graphId)
|
entityRepository.findByIdAndGraphId(targetId, graphId)
|
||||||
.orElseThrow(() -> BusinessException.of(
|
.ifPresentOrElse(
|
||||||
KnowledgeGraphErrorCode.ENTITY_NOT_FOUND, "目标实体不存在"));
|
targetEntity -> {
|
||||||
|
if (filterUserId != null && !sourceId.equals(targetId)) {
|
||||||
|
assertEntityAccess(targetEntity, filterUserId, excludeConfidential);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() -> { throw BusinessException.of(
|
||||||
|
KnowledgeGraphErrorCode.ENTITY_NOT_FOUND, "目标实体不存在"); }
|
||||||
|
);
|
||||||
|
|
||||||
if (sourceId.equals(targetId)) {
|
if (sourceId.equals(targetId)) {
|
||||||
// 起止相同,返回单节点路径
|
// 起止相同,返回单节点路径
|
||||||
GraphEntity entity = entityRepository.findByIdAndGraphId(sourceId, graphId).orElse(null);
|
EntitySummaryVO node = EntitySummaryVO.builder()
|
||||||
EntitySummaryVO node = entity != null
|
.id(sourceEntity.getId())
|
||||||
? EntitySummaryVO.builder().id(entity.getId()).name(entity.getName())
|
.name(sourceEntity.getName())
|
||||||
.type(entity.getType()).description(entity.getDescription()).build()
|
.type(sourceEntity.getType())
|
||||||
: EntitySummaryVO.builder().id(sourceId).build();
|
.description(sourceEntity.getDescription())
|
||||||
|
.build();
|
||||||
return PathVO.builder()
|
return PathVO.builder()
|
||||||
.nodes(List.of(node))
|
.nodes(List.of(node))
|
||||||
.edges(List.of())
|
.edges(List.of())
|
||||||
@@ -149,13 +200,32 @@ public class GraphQueryService {
|
|||||||
|
|
||||||
int clampedDepth = Math.max(1, Math.min(maxDepth, properties.getMaxDepth()));
|
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<String, Object> params = new HashMap<>();
|
||||||
|
params.put("graphId", graphId);
|
||||||
|
params.put("sourceId", sourceId);
|
||||||
|
params.put("targetId", targetId);
|
||||||
|
if (filterUserId != null) {
|
||||||
|
params.put("filterUserId", filterUserId);
|
||||||
|
}
|
||||||
|
|
||||||
// 使用 Neo4j shortestPath 函数
|
// 使用 Neo4j shortestPath 函数
|
||||||
// 返回路径上的节点和关系信息
|
|
||||||
String cypher =
|
String cypher =
|
||||||
"MATCH (s:Entity {graph_id: $graphId, id: $sourceId}), " +
|
"MATCH (s:Entity {graph_id: $graphId, id: $sourceId}), " +
|
||||||
" (t:Entity {graph_id: $graphId, id: $targetId}), " +
|
" (t:Entity {graph_id: $graphId, id: $targetId}), " +
|
||||||
" path = shortestPath((s)-[:" + REL_TYPE + "*1.." + clampedDepth + "]-(t)) " +
|
" path = shortestPath((s)-[:" + REL_TYPE + "*1.." + clampedDepth + "]-(t)) " +
|
||||||
"WHERE ALL(n IN nodes(path) WHERE n.graph_id = $graphId) " +
|
"WHERE ALL(n IN nodes(path) WHERE n.graph_id = $graphId) " +
|
||||||
|
permFilter +
|
||||||
"RETURN " +
|
"RETURN " +
|
||||||
" [n IN nodes(path) | {id: n.id, name: n.name, type: n.type, description: n.description}] AS pathNodes, " +
|
" [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, " +
|
" [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";
|
" length(path) AS pathLength";
|
||||||
|
|
||||||
return neo4jClient.query(cypher)
|
return neo4jClient.query(cypher)
|
||||||
.bindAll(Map.of("graphId", graphId, "sourceId", sourceId, "targetId", targetId))
|
.bindAll(params)
|
||||||
.fetchAs(PathVO.class)
|
.fetchAs(PathVO.class)
|
||||||
.mappedBy((ts, record) -> mapPathRecord(record))
|
.mappedBy((ts, record) -> mapPathRecord(record))
|
||||||
.one()
|
.one()
|
||||||
@@ -185,6 +255,8 @@ public class GraphQueryService {
|
|||||||
*/
|
*/
|
||||||
public SubgraphVO getSubgraph(String graphId, List<String> entityIds) {
|
public SubgraphVO getSubgraph(String graphId, List<String> entityIds) {
|
||||||
validateGraphId(graphId);
|
validateGraphId(graphId);
|
||||||
|
String filterUserId = resolveOwnerFilter();
|
||||||
|
boolean excludeConfidential = filterUserId != null && !resourceAccessService.canViewConfidential();
|
||||||
|
|
||||||
if (entityIds == null || entityIds.isEmpty()) {
|
if (entityIds == null || entityIds.isEmpty()) {
|
||||||
return SubgraphVO.builder()
|
return SubgraphVO.builder()
|
||||||
@@ -203,6 +275,14 @@ public class GraphQueryService {
|
|||||||
|
|
||||||
// 查询存在的实体
|
// 查询存在的实体
|
||||||
List<GraphEntity> entities = entityRepository.findByGraphIdAndIdIn(graphId, entityIds);
|
List<GraphEntity> entities = entityRepository.findByGraphIdAndIdIn(graphId, entityIds);
|
||||||
|
|
||||||
|
// 权限过滤:非管理员只能看到自己创建的业务实体和结构型实体
|
||||||
|
if (filterUserId != null) {
|
||||||
|
entities = entities.stream()
|
||||||
|
.filter(e -> isEntityAccessible(e, filterUserId, excludeConfidential))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
List<EntitySummaryVO> nodes = entities.stream()
|
List<EntitySummaryVO> nodes = entities.stream()
|
||||||
.map(e -> EntitySummaryVO.builder()
|
.map(e -> EntitySummaryVO.builder()
|
||||||
.id(e.getId())
|
.id(e.getId())
|
||||||
@@ -247,6 +327,8 @@ public class GraphQueryService {
|
|||||||
*/
|
*/
|
||||||
public PagedResponse<SearchHitVO> fulltextSearch(String graphId, String query, int page, int size) {
|
public PagedResponse<SearchHitVO> fulltextSearch(String graphId, String query, int page, int size) {
|
||||||
validateGraphId(graphId);
|
validateGraphId(graphId);
|
||||||
|
String filterUserId = resolveOwnerFilter();
|
||||||
|
boolean excludeConfidential = filterUserId != null && !resourceAccessService.canViewConfidential();
|
||||||
|
|
||||||
if (query == null || query.isBlank()) {
|
if (query == null || query.isBlank()) {
|
||||||
return PagedResponse.of(List.of(), 0, 0, 0);
|
return PagedResponse.of(List.of(), 0, 0, 0);
|
||||||
@@ -261,22 +343,28 @@ public class GraphQueryService {
|
|||||||
|
|
||||||
// 对搜索关键词进行安全处理:转义 Lucene 特殊字符
|
// 对搜索关键词进行安全处理:转义 Lucene 特殊字符
|
||||||
String safeQuery = escapeLuceneQuery(query);
|
String safeQuery = escapeLuceneQuery(query);
|
||||||
|
String permFilter = buildPermissionPredicate("node", filterUserId, excludeConfidential);
|
||||||
|
|
||||||
|
Map<String, Object> 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<SearchHitVO> results = neo4jClient
|
List<SearchHitVO> results = neo4jClient
|
||||||
.query(
|
.query(
|
||||||
"CALL db.index.fulltext.queryNodes('entity_fulltext', $query) YIELD node, score " +
|
"CALL db.index.fulltext.queryNodes('entity_fulltext', $query) YIELD node, score " +
|
||||||
"WHERE node.graph_id = $graphId " +
|
"WHERE node.graph_id = $graphId " +
|
||||||
|
permFilter +
|
||||||
"RETURN node.id AS id, node.name AS name, node.type AS type, " +
|
"RETURN node.id AS id, node.name AS name, node.type AS type, " +
|
||||||
"node.description AS description, score " +
|
"node.description AS description, score " +
|
||||||
"ORDER BY score DESC " +
|
"ORDER BY score DESC " +
|
||||||
"SKIP $skip LIMIT $size"
|
"SKIP $skip LIMIT $size"
|
||||||
)
|
)
|
||||||
.bindAll(Map.of(
|
.bindAll(searchParams)
|
||||||
"graphId", graphId,
|
|
||||||
"query", safeQuery,
|
|
||||||
"skip", skip,
|
|
||||||
"size", safeSize
|
|
||||||
))
|
|
||||||
.fetchAs(SearchHitVO.class)
|
.fetchAs(SearchHitVO.class)
|
||||||
.mappedBy((ts, record) -> SearchHitVO.builder()
|
.mappedBy((ts, record) -> SearchHitVO.builder()
|
||||||
.id(record.get("id").asString(null))
|
.id(record.get("id").asString(null))
|
||||||
@@ -288,13 +376,21 @@ public class GraphQueryService {
|
|||||||
.all()
|
.all()
|
||||||
.stream().toList();
|
.stream().toList();
|
||||||
|
|
||||||
|
Map<String, Object> countParams = new HashMap<>();
|
||||||
|
countParams.put("graphId", graphId);
|
||||||
|
countParams.put("query", safeQuery);
|
||||||
|
if (filterUserId != null) {
|
||||||
|
countParams.put("filterUserId", filterUserId);
|
||||||
|
}
|
||||||
|
|
||||||
long total = neo4jClient
|
long total = neo4jClient
|
||||||
.query(
|
.query(
|
||||||
"CALL db.index.fulltext.queryNodes('entity_fulltext', $query) YIELD node, score " +
|
"CALL db.index.fulltext.queryNodes('entity_fulltext', $query) YIELD node, score " +
|
||||||
"WHERE node.graph_id = $graphId " +
|
"WHERE node.graph_id = $graphId " +
|
||||||
|
permFilter +
|
||||||
"RETURN count(*) AS total"
|
"RETURN count(*) AS total"
|
||||||
)
|
)
|
||||||
.bindAll(Map.of("graphId", graphId, "query", safeQuery))
|
.bindAll(countParams)
|
||||||
.fetchAs(Long.class)
|
.fetchAs(Long.class)
|
||||||
.mappedBy((ts, record) -> record.get("total").asLong())
|
.mappedBy((ts, record) -> record.get("total").asLong())
|
||||||
.one()
|
.one()
|
||||||
@@ -304,6 +400,91 @@ public class GraphQueryService {
|
|||||||
return PagedResponse.of(results, safePage, total, totalPages);
|
return PagedResponse.of(results, safePage, total, totalPages);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 权限过滤
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 owner 过滤用户 ID。
|
||||||
|
* 管理员返回 null(不过滤),普通用户返回当前 userId。
|
||||||
|
*/
|
||||||
|
private String resolveOwnerFilter() {
|
||||||
|
return resourceAccessService.resolveOwnerFilterUserId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建 Cypher 权限过滤条件片段。
|
||||||
|
* <p>
|
||||||
|
* 管理员返回空字符串(不过滤);
|
||||||
|
* 普通用户返回 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());
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// 内部方法
|
// 内部方法
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ public class GraphSyncStepService {
|
|||||||
.map(TagDTO::getName).toList();
|
.map(TagDTO::getName).toList();
|
||||||
props.put("tags", tagNames);
|
props.put("tags", tagNames);
|
||||||
}
|
}
|
||||||
|
if (dto.getCreatedBy() != null) {
|
||||||
|
props.put("created_by", dto.getCreatedBy());
|
||||||
|
}
|
||||||
|
|
||||||
upsertEntity(graphId, dto.getId(), "Dataset",
|
upsertEntity(graphId, dto.getId(), "Dataset",
|
||||||
dto.getName(), dto.getDescription(), props, result);
|
dto.getName(), dto.getDescription(), props, result);
|
||||||
@@ -184,6 +187,9 @@ public class GraphSyncStepService {
|
|||||||
if (dto.getInputDatasetIds() != null) {
|
if (dto.getInputDatasetIds() != null) {
|
||||||
props.put("input_dataset_ids", dto.getInputDatasetIds());
|
props.put("input_dataset_ids", dto.getInputDatasetIds());
|
||||||
}
|
}
|
||||||
|
if (dto.getCreatedBy() != null) {
|
||||||
|
props.put("created_by", dto.getCreatedBy());
|
||||||
|
}
|
||||||
|
|
||||||
upsertEntity(graphId, dto.getId(), "Workflow",
|
upsertEntity(graphId, dto.getId(), "Workflow",
|
||||||
dto.getName(), dto.getDescription(), props, result);
|
dto.getName(), dto.getDescription(), props, result);
|
||||||
@@ -348,6 +354,9 @@ public class GraphSyncStepService {
|
|||||||
if (dto.getSourceDatasetIds() != null) {
|
if (dto.getSourceDatasetIds() != null) {
|
||||||
props.put("source_dataset_ids", dto.getSourceDatasetIds());
|
props.put("source_dataset_ids", dto.getSourceDatasetIds());
|
||||||
}
|
}
|
||||||
|
if (dto.getCreatedBy() != null) {
|
||||||
|
props.put("created_by", dto.getCreatedBy());
|
||||||
|
}
|
||||||
|
|
||||||
upsertEntity(graphId, dto.getId(), "KnowledgeSet",
|
upsertEntity(graphId, dto.getId(), "KnowledgeSet",
|
||||||
dto.getName(), dto.getDescription(), props, result);
|
dto.getName(), dto.getDescription(), props, result);
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package com.datamate.knowledgegraph.application;
|
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.BusinessException;
|
||||||
import com.datamate.knowledgegraph.domain.model.GraphEntity;
|
import com.datamate.knowledgegraph.domain.model.GraphEntity;
|
||||||
import com.datamate.knowledgegraph.domain.repository.GraphEntityRepository;
|
import com.datamate.knowledgegraph.domain.repository.GraphEntityRepository;
|
||||||
import com.datamate.knowledgegraph.infrastructure.neo4j.KnowledgeGraphProperties;
|
import com.datamate.knowledgegraph.infrastructure.neo4j.KnowledgeGraphProperties;
|
||||||
import com.datamate.knowledgegraph.interfaces.dto.SubgraphVO;
|
import com.datamate.knowledgegraph.interfaces.dto.SubgraphVO;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Nested;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
@@ -13,7 +15,9 @@ import org.mockito.Mock;
|
|||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.springframework.data.neo4j.core.Neo4jClient;
|
import org.springframework.data.neo4j.core.Neo4jClient;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
@@ -38,6 +42,9 @@ class GraphQueryServiceTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private KnowledgeGraphProperties properties;
|
private KnowledgeGraphProperties properties;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ResourceAccessService resourceAccessService;
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks
|
||||||
private GraphQueryService queryService;
|
private GraphQueryService queryService;
|
||||||
|
|
||||||
@@ -174,4 +181,417 @@ class GraphQueryServiceTest {
|
|||||||
|
|
||||||
assertThat(result.getContent()).isEmpty();
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user