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

@@ -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 {

View File

@@ -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());
}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// 内部方法 // 内部方法
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------

View File

@@ -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);

View File

@@ -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");
}
}
} }