mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 19:45:08 +08:00
fix(wiki): close IDOR in WikiRelationController & WikiEntityController (cross-KB id binding)
Every endpoint now binds its independent id param to an authorized KB: rawId/chunkId resolve-then-workspace-check, pageId is asserted to belong to the path kbId, and slugs stay kbId-scoped. Adds unit tests for same-KB/cross-KB/unknown cases.
This commit is contained in:
parent
04197d7ba9
commit
2f46619e5b
@ -5,14 +5,19 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import vip.mate.exception.MateClawException;
|
||||||
import vip.mate.wiki.dto.WikiEntityGraphView;
|
import vip.mate.wiki.dto.WikiEntityGraphView;
|
||||||
import vip.mate.wiki.dto.WikiEntityView;
|
import vip.mate.wiki.dto.WikiEntityView;
|
||||||
|
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
|
||||||
import vip.mate.wiki.service.WikiEntityExtractionService;
|
import vip.mate.wiki.service.WikiEntityExtractionService;
|
||||||
import vip.mate.wiki.service.WikiEntityGraphService;
|
import vip.mate.wiki.service.WikiEntityGraphService;
|
||||||
|
import vip.mate.wiki.service.WikiKnowledgeBaseService;
|
||||||
import vip.mate.wiki.service.WikiProcessingService;
|
import vip.mate.wiki.service.WikiProcessingService;
|
||||||
|
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@ -30,27 +35,37 @@ public class WikiEntityController {
|
|||||||
|
|
||||||
private final WikiEntityGraphService graphService;
|
private final WikiEntityGraphService graphService;
|
||||||
private final WikiEntityExtractionService extractionService;
|
private final WikiEntityExtractionService extractionService;
|
||||||
|
private final WikiKnowledgeBaseService kbService;
|
||||||
|
|
||||||
/** List entities in a KB, optionally filtered by type, ranked by salience. */
|
/** List entities in a KB, optionally filtered by type, ranked by salience. */
|
||||||
|
@RequireWorkspaceRole("viewer")
|
||||||
@GetMapping("/kb/{kbId}/entities")
|
@GetMapping("/kb/{kbId}/entities")
|
||||||
public List<WikiEntityView> listEntities(@PathVariable Long kbId,
|
public List<WikiEntityView> listEntities(@PathVariable Long kbId,
|
||||||
@RequestParam(required = false) String type,
|
@RequestParam(required = false) String type,
|
||||||
@RequestParam(defaultValue = "100") int limit) {
|
@RequestParam(defaultValue = "100") int limit,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
verifyKBWorkspace(kbId, workspaceId);
|
||||||
return graphService.listEntities(kbId, type, limit);
|
return graphService.listEntities(kbId, type, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whole-KB entity graph: top entities by salience plus the edges among them. */
|
/** Whole-KB entity graph: top entities by salience plus the edges among them. */
|
||||||
|
@RequireWorkspaceRole("viewer")
|
||||||
@GetMapping("/kb/{kbId}/entity-graph")
|
@GetMapping("/kb/{kbId}/entity-graph")
|
||||||
public WikiEntityGraphView kbEntityGraph(@PathVariable Long kbId,
|
public WikiEntityGraphView kbEntityGraph(@PathVariable Long kbId,
|
||||||
@RequestParam(defaultValue = "150") int limit) {
|
@RequestParam(defaultValue = "150") int limit,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
verifyKBWorkspace(kbId, workspaceId);
|
||||||
return graphService.graph(kbId, limit);
|
return graphService.graph(kbId, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ego-graph around a single entity: neighbors, edges, and mentioning pages. */
|
/** Ego-graph around a single entity: neighbors, edges, and mentioning pages. */
|
||||||
|
@RequireWorkspaceRole("viewer")
|
||||||
@GetMapping("/kb/{kbId}/entities/{entityId}/graph")
|
@GetMapping("/kb/{kbId}/entities/{entityId}/graph")
|
||||||
public WikiEntityGraphView entityGraph(@PathVariable Long kbId,
|
public WikiEntityGraphView entityGraph(@PathVariable Long kbId,
|
||||||
@PathVariable Long entityId,
|
@PathVariable Long entityId,
|
||||||
@RequestParam(defaultValue = "50") int limit) {
|
@RequestParam(defaultValue = "50") int limit,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
verifyKBWorkspace(kbId, workspaceId);
|
||||||
return graphService.ego(kbId, entityId, limit);
|
return graphService.ego(kbId, entityId, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -60,9 +75,12 @@ public class WikiEntityController {
|
|||||||
*
|
*
|
||||||
* @param force when true, re-extract chunks that already have mentions
|
* @param force when true, re-extract chunks that already have mentions
|
||||||
*/
|
*/
|
||||||
|
@RequireWorkspaceRole("member")
|
||||||
@PostMapping("/kb/{kbId}/entities/extract")
|
@PostMapping("/kb/{kbId}/entities/extract")
|
||||||
public Map<String, Object> extract(@PathVariable Long kbId,
|
public Map<String, Object> extract(@PathVariable Long kbId,
|
||||||
@RequestParam(defaultValue = "false") boolean force) {
|
@RequestParam(defaultValue = "false") boolean force,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
verifyKBWorkspace(kbId, workspaceId);
|
||||||
WikiProcessingService.WIKI_EXECUTOR.submit(() -> {
|
WikiProcessingService.WIKI_EXECUTOR.submit(() -> {
|
||||||
try {
|
try {
|
||||||
int count = extractionService.extractForKb(kbId, force);
|
int count = extractionService.extractForKb(kbId, force);
|
||||||
@ -73,4 +91,17 @@ public class WikiEntityController {
|
|||||||
});
|
});
|
||||||
return Map.of("status", "started", "kbId", kbId);
|
return Map.of("status", "started", "kbId", kbId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Workspace Verification ====================
|
||||||
|
|
||||||
|
private void verifyKBWorkspace(Long kbId, Long headerWorkspaceId) {
|
||||||
|
WikiKnowledgeBaseEntity kb = kbService.getById(kbId);
|
||||||
|
if (kb == null) {
|
||||||
|
throw new MateClawException(404, "Knowledge base not found");
|
||||||
|
}
|
||||||
|
long wsId = headerWorkspaceId != null ? headerWorkspaceId : 1L;
|
||||||
|
if (kb.getWorkspaceId() != null && !kb.getWorkspaceId().equals(wsId)) {
|
||||||
|
throw new MateClawException("err.common.wrong_workspace", 403, "资源不属于当前工作区");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,14 +6,20 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.context.ApplicationEventPublisher;
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import vip.mate.exception.MateClawException;
|
||||||
import vip.mate.wiki.dto.*;
|
import vip.mate.wiki.dto.*;
|
||||||
import vip.mate.wiki.job.WikiProcessingJobService;
|
import vip.mate.wiki.job.WikiProcessingJobService;
|
||||||
import vip.mate.wiki.job.event.WikiJobCreatedEvent;
|
import vip.mate.wiki.job.event.WikiJobCreatedEvent;
|
||||||
import vip.mate.wiki.repository.WikiProcessingJobMapper;
|
import vip.mate.wiki.repository.WikiProcessingJobMapper;
|
||||||
import vip.mate.wiki.job.model.WikiProcessingJobEntity;
|
import vip.mate.wiki.job.model.WikiProcessingJobEntity;
|
||||||
|
import vip.mate.wiki.model.WikiChunkEntity;
|
||||||
|
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
|
||||||
import vip.mate.wiki.model.WikiPageEntity;
|
import vip.mate.wiki.model.WikiPageEntity;
|
||||||
|
import vip.mate.wiki.model.WikiRawMaterialEntity;
|
||||||
|
import vip.mate.wiki.repository.WikiChunkMapper;
|
||||||
import vip.mate.wiki.repository.WikiPageCitationMapper;
|
import vip.mate.wiki.repository.WikiPageCitationMapper;
|
||||||
import vip.mate.wiki.service.*;
|
import vip.mate.wiki.service.*;
|
||||||
|
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
||||||
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -37,52 +43,88 @@ public class WikiRelationController {
|
|||||||
private final ApplicationEventPublisher eventPublisher;
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final WikiEmbeddingService embeddingService;
|
private final WikiEmbeddingService embeddingService;
|
||||||
|
private final WikiKnowledgeBaseService kbService;
|
||||||
|
private final WikiRawMaterialService rawService;
|
||||||
|
private final WikiChunkMapper chunkMapper;
|
||||||
|
|
||||||
// ==================== RFC-029: Relations ====================
|
// ==================== RFC-029: Relations ====================
|
||||||
|
|
||||||
|
@RequireWorkspaceRole("viewer")
|
||||||
@GetMapping("/kb/{kbId}/pages/{slug}/related")
|
@GetMapping("/kb/{kbId}/pages/{slug}/related")
|
||||||
public List<RelatedPageResult> relatedPages(
|
public List<RelatedPageResult> relatedPages(
|
||||||
@PathVariable Long kbId,
|
@PathVariable Long kbId,
|
||||||
@PathVariable String slug,
|
@PathVariable String slug,
|
||||||
@RequestParam(defaultValue = "5") int topK) {
|
@RequestParam(defaultValue = "5") int topK,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
verifyKBWorkspace(kbId, workspaceId);
|
||||||
return relationService.relatedPages(kbId, slug, Math.min(topK, 20));
|
return relationService.relatedPages(kbId, slug, Math.min(topK, 20));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RequireWorkspaceRole("viewer")
|
||||||
@GetMapping("/kb/{kbId}/pages/{slugA}/relation/{slugB}")
|
@GetMapping("/kb/{kbId}/pages/{slugA}/relation/{slugB}")
|
||||||
public RelationExplanation explainRelation(
|
public RelationExplanation explainRelation(
|
||||||
@PathVariable Long kbId,
|
@PathVariable Long kbId,
|
||||||
@PathVariable String slugA,
|
@PathVariable String slugA,
|
||||||
@PathVariable String slugB) {
|
@PathVariable String slugB,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
verifyKBWorkspace(kbId, workspaceId);
|
||||||
return relationService.explain(kbId, slugA, slugB);
|
return relationService.explain(kbId, slugA, slugB);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RequireWorkspaceRole("viewer")
|
||||||
@GetMapping("/raw/{rawId}/pages")
|
@GetMapping("/raw/{rawId}/pages")
|
||||||
public List<WikiPageLite> pagesByRawId(@PathVariable Long rawId) {
|
public List<WikiPageLite> pagesByRawId(
|
||||||
|
@PathVariable Long rawId,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
verifyRawWorkspace(rawId, workspaceId);
|
||||||
return relationService.pagesByRawId(rawId);
|
return relationService.pagesByRawId(rawId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RequireWorkspaceRole("viewer")
|
||||||
@GetMapping("/chunks/{chunkId}/pages")
|
@GetMapping("/chunks/{chunkId}/pages")
|
||||||
public List<WikiPageLite> pagesByChunkId(@PathVariable Long chunkId) {
|
public List<WikiPageLite> pagesByChunkId(
|
||||||
|
@PathVariable Long chunkId,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
verifyChunkWorkspace(chunkId, workspaceId);
|
||||||
return relationService.pagesByChunkId(chunkId);
|
return relationService.pagesByChunkId(chunkId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== RFC-029: Citations ====================
|
// ==================== RFC-029: Citations ====================
|
||||||
|
|
||||||
|
@RequireWorkspaceRole("viewer")
|
||||||
@GetMapping("/kb/{kbId}/pages/{pageId}/citations")
|
@GetMapping("/kb/{kbId}/pages/{pageId}/citations")
|
||||||
public List<PageCitationWithRaw> pageCitations(
|
public List<PageCitationWithRaw> pageCitations(
|
||||||
@PathVariable Long kbId,
|
@PathVariable Long kbId,
|
||||||
@PathVariable Long pageId) {
|
@PathVariable Long pageId,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
verifyKBWorkspace(kbId, workspaceId);
|
||||||
|
// Guard against a partial IDOR: kbId is workspace-checked above, but
|
||||||
|
// pageId is an independent path variable that could point at a page in
|
||||||
|
// another KB. Require the resolved page to actually belong to this kbId
|
||||||
|
// (same pattern as the getJobs(rawId) cross-KB filter).
|
||||||
|
WikiPageEntity page = pageService.getById(pageId);
|
||||||
|
if (page == null || !kbId.equals(page.getKbId())) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
return citationMapper.listWithRawByPageId(pageId);
|
return citationMapper.listWithRawByPageId(pageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== RFC-030: Jobs ====================
|
// ==================== RFC-030: Jobs ====================
|
||||||
|
|
||||||
|
@RequireWorkspaceRole("viewer")
|
||||||
@GetMapping("/kb/{kbId}/jobs")
|
@GetMapping("/kb/{kbId}/jobs")
|
||||||
public List<WikiProcessingJobEntity> getJobs(
|
public List<WikiProcessingJobEntity> getJobs(
|
||||||
@PathVariable Long kbId,
|
@PathVariable Long kbId,
|
||||||
@RequestParam(required = false) Long rawId) {
|
@RequestParam(required = false) Long rawId,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
verifyKBWorkspace(kbId, workspaceId);
|
||||||
if (rawId != null) {
|
if (rawId != null) {
|
||||||
return jobMapper.findLatestByRawId(rawId)
|
return jobMapper.findLatestByRawId(rawId)
|
||||||
|
// Guard against a partial IDOR: kbId is workspace-checked
|
||||||
|
// above, but rawId is an independent query param that could
|
||||||
|
// point at another KB's material. Require the resolved job
|
||||||
|
// to actually belong to this kbId.
|
||||||
|
.filter(j -> kbId.equals(j.getKbId()))
|
||||||
.map(List::of).orElse(List.of());
|
.map(List::of).orElse(List.of());
|
||||||
}
|
}
|
||||||
return jobMapper.listQueued(kbId, 20);
|
return jobMapper.listQueued(kbId, 20);
|
||||||
@ -90,8 +132,12 @@ public class WikiRelationController {
|
|||||||
|
|
||||||
// ==================== RFC-030/033: KB Stats ====================
|
// ==================== RFC-030/033: KB Stats ====================
|
||||||
|
|
||||||
|
@RequireWorkspaceRole("viewer")
|
||||||
@GetMapping("/kb/{kbId}/stats")
|
@GetMapping("/kb/{kbId}/stats")
|
||||||
public Map<String, Object> kbStats(@PathVariable Long kbId) {
|
public Map<String, Object> kbStats(
|
||||||
|
@PathVariable Long kbId,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
verifyKBWorkspace(kbId, workspaceId);
|
||||||
int pageCount = pageService.countByKbId(kbId);
|
int pageCount = pageService.countByKbId(kbId);
|
||||||
// Count enriched pages (those containing [[wikilinks]])
|
// Count enriched pages (those containing [[wikilinks]])
|
||||||
long enrichedCount = pageService.listByKbIdWithContent(kbId).stream()
|
long enrichedCount = pageService.listByKbIdWithContent(kbId).stream()
|
||||||
@ -128,8 +174,13 @@ public class WikiRelationController {
|
|||||||
|
|
||||||
// ==================== RFC-031: Enrichment & Repair ====================
|
// ==================== RFC-031: Enrichment & Repair ====================
|
||||||
|
|
||||||
|
@RequireWorkspaceRole("member")
|
||||||
@PostMapping("/kb/{kbId}/pages/{slug}/enrich")
|
@PostMapping("/kb/{kbId}/pages/{slug}/enrich")
|
||||||
public Map<String, Object> enrichPage(@PathVariable Long kbId, @PathVariable String slug) {
|
public Map<String, Object> enrichPage(
|
||||||
|
@PathVariable Long kbId,
|
||||||
|
@PathVariable String slug,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
verifyKBWorkspace(kbId, workspaceId);
|
||||||
WikiPageEntity page = pageService.getBySlug(kbId, slug);
|
WikiPageEntity page = pageService.getBySlug(kbId, slug);
|
||||||
if (page == null) return Map.of("error", "Page not found: " + slug);
|
if (page == null) return Map.of("error", "Page not found: " + slug);
|
||||||
|
|
||||||
@ -146,8 +197,13 @@ public class WikiRelationController {
|
|||||||
return Map.of("jobId", job.getId());
|
return Map.of("jobId", job.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RequireWorkspaceRole("member")
|
||||||
@PostMapping("/kb/{kbId}/pages/{slug}/repair")
|
@PostMapping("/kb/{kbId}/pages/{slug}/repair")
|
||||||
public Map<String, Object> repairPage(@PathVariable Long kbId, @PathVariable String slug) {
|
public Map<String, Object> repairPage(
|
||||||
|
@PathVariable Long kbId,
|
||||||
|
@PathVariable String slug,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
verifyKBWorkspace(kbId, workspaceId);
|
||||||
WikiPageEntity page = pageService.getBySlug(kbId, slug);
|
WikiPageEntity page = pageService.getBySlug(kbId, slug);
|
||||||
if (page == null) return Map.of("error", "Page not found: " + slug);
|
if (page == null) return Map.of("error", "Page not found: " + slug);
|
||||||
|
|
||||||
@ -166,13 +222,54 @@ public class WikiRelationController {
|
|||||||
|
|
||||||
// ==================== RFC-032: Search preview ====================
|
// ==================== RFC-032: Search preview ====================
|
||||||
|
|
||||||
|
@RequireWorkspaceRole("viewer")
|
||||||
@PostMapping("/kb/{kbId}/search-preview")
|
@PostMapping("/kb/{kbId}/search-preview")
|
||||||
public List<PageSearchResult> searchPreview(
|
public List<PageSearchResult> searchPreview(
|
||||||
@PathVariable Long kbId,
|
@PathVariable Long kbId,
|
||||||
@RequestBody Map<String, Object> body) {
|
@RequestBody Map<String, Object> body,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
verifyKBWorkspace(kbId, workspaceId);
|
||||||
String query = (String) body.getOrDefault("query", "");
|
String query = (String) body.getOrDefault("query", "");
|
||||||
String mode = (String) body.getOrDefault("mode", "hybrid");
|
String mode = (String) body.getOrDefault("mode", "hybrid");
|
||||||
int topK = body.containsKey("topK") ? ((Number) body.get("topK")).intValue() : 5;
|
int topK = body.containsKey("topK") ? ((Number) body.get("topK")).intValue() : 5;
|
||||||
return hybridRetriever.search(kbId, query, mode, Math.min(topK, 20));
|
return hybridRetriever.search(kbId, query, mode, Math.min(topK, 20));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Workspace Verification ====================
|
||||||
|
|
||||||
|
private void verifyKBWorkspace(Long kbId, Long headerWorkspaceId) {
|
||||||
|
WikiKnowledgeBaseEntity kb = kbService.getById(kbId);
|
||||||
|
if (kb == null) {
|
||||||
|
throw new MateClawException(404, "Knowledge base not found");
|
||||||
|
}
|
||||||
|
long wsId = headerWorkspaceId != null ? headerWorkspaceId : 1L;
|
||||||
|
if (kb.getWorkspaceId() != null && !kb.getWorkspaceId().equals(wsId)) {
|
||||||
|
throw new MateClawException("err.common.wrong_workspace", 403, "资源不属于当前工作区");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the owning KB of a raw material and check it belongs to the
|
||||||
|
* caller's workspace. Raw materials don't carry workspaceId directly;
|
||||||
|
* they reference a KB which does.
|
||||||
|
*/
|
||||||
|
private void verifyRawWorkspace(Long rawId, Long headerWorkspaceId) {
|
||||||
|
WikiRawMaterialEntity raw = rawService.getById(rawId);
|
||||||
|
if (raw == null || raw.getKbId() == null) {
|
||||||
|
throw new MateClawException(404, "Raw material not found");
|
||||||
|
}
|
||||||
|
verifyKBWorkspace(raw.getKbId(), headerWorkspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the owning KB of a chunk and check it belongs to the caller's
|
||||||
|
* workspace. Like raw materials, chunks reference a KB, not a workspace.
|
||||||
|
*/
|
||||||
|
private void verifyChunkWorkspace(Long chunkId, Long headerWorkspaceId) {
|
||||||
|
WikiChunkEntity chunk = chunkMapper.selectById(chunkId);
|
||||||
|
if (chunk == null || chunk.getKbId() == null) {
|
||||||
|
throw new MateClawException(404, "Chunk not found");
|
||||||
|
}
|
||||||
|
verifyKBWorkspace(chunk.getKbId(), headerWorkspaceId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,102 @@
|
|||||||
|
package vip.mate.wiki.controller;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import vip.mate.exception.MateClawException;
|
||||||
|
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
|
||||||
|
import vip.mate.wiki.service.WikiEntityExtractionService;
|
||||||
|
import vip.mate.wiki.service.WikiEntityGraphService;
|
||||||
|
import vip.mate.wiki.service.WikiKnowledgeBaseService;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies the IDOR guard added for ISSUE #438 on
|
||||||
|
* {@link WikiEntityController}: entity-graph endpoints must reject requests
|
||||||
|
* whose target KB belongs to a different workspace than the caller's
|
||||||
|
* {@code X-Workspace-Id} header.
|
||||||
|
*/
|
||||||
|
class WikiEntityControllerIdorTest {
|
||||||
|
|
||||||
|
private WikiKnowledgeBaseService kbService;
|
||||||
|
private WikiEntityController controller;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
kbService = mock(WikiKnowledgeBaseService.class);
|
||||||
|
controller = new WikiEntityController(
|
||||||
|
mock(WikiEntityGraphService.class),
|
||||||
|
mock(WikiEntityExtractionService.class),
|
||||||
|
kbService);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("listEntities on another workspace's KB → 403")
|
||||||
|
void listEntitiesCrossWorkspaceRejected() {
|
||||||
|
when(kbService.getById(10L)).thenReturn(kb(10L, 2L));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> controller.listEntities(10L, null, 100, 1L))
|
||||||
|
.isInstanceOf(MateClawException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("kbEntityGraph on another workspace's KB → 403")
|
||||||
|
void kbEntityGraphCrossWorkspaceRejected() {
|
||||||
|
when(kbService.getById(10L)).thenReturn(kb(10L, 2L));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> controller.kbEntityGraph(10L, 150, 1L))
|
||||||
|
.isInstanceOf(MateClawException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("entityGraph (ego) on another workspace's KB → 403")
|
||||||
|
void entityEgoGraphCrossWorkspaceRejected() {
|
||||||
|
when(kbService.getById(10L)).thenReturn(kb(10L, 2L));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> controller.entityGraph(10L, 77L, 50, 1L))
|
||||||
|
.isInstanceOf(MateClawException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("extract (member-level write) on another workspace's KB → 403")
|
||||||
|
void extractCrossWorkspaceRejected() {
|
||||||
|
when(kbService.getById(10L)).thenReturn(kb(10L, 2L));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> controller.extract(10L, false, 1L))
|
||||||
|
.isInstanceOf(MateClawException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("unknown kbId → 404")
|
||||||
|
void unknownKbReturns404() {
|
||||||
|
when(kbService.getById(999L)).thenReturn(null);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> controller.listEntities(999L, null, 100, 1L))
|
||||||
|
.isInstanceOf(MateClawException.class)
|
||||||
|
.hasMessageContaining("not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("KB in the caller's workspace passes the guard (no MateClawException)")
|
||||||
|
void sameWorkspaceAllowed() {
|
||||||
|
when(kbService.getById(10L)).thenReturn(kb(10L, 1L));
|
||||||
|
|
||||||
|
// Guard passed → no exception. (graphService is stubbed to return an
|
||||||
|
// empty list so the method returns normally.)
|
||||||
|
assertThatCode(() -> controller.listEntities(10L, null, 100, 1L))
|
||||||
|
.doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- helpers ----------------
|
||||||
|
|
||||||
|
private static WikiKnowledgeBaseEntity kb(long id, long workspaceId) {
|
||||||
|
WikiKnowledgeBaseEntity entity = new WikiKnowledgeBaseEntity();
|
||||||
|
entity.setId(id);
|
||||||
|
entity.setWorkspaceId(workspaceId);
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,299 @@
|
|||||||
|
package vip.mate.wiki.controller;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
|
import vip.mate.exception.MateClawException;
|
||||||
|
import vip.mate.wiki.dto.PageSearchResult;
|
||||||
|
import vip.mate.wiki.job.WikiProcessingJobService;
|
||||||
|
import vip.mate.wiki.job.model.WikiProcessingJobEntity;
|
||||||
|
import vip.mate.wiki.model.WikiChunkEntity;
|
||||||
|
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
|
||||||
|
import vip.mate.wiki.model.WikiPageEntity;
|
||||||
|
import vip.mate.wiki.model.WikiRawMaterialEntity;
|
||||||
|
import vip.mate.wiki.repository.WikiChunkMapper;
|
||||||
|
import vip.mate.wiki.repository.WikiPageCitationMapper;
|
||||||
|
import vip.mate.wiki.repository.WikiProcessingJobMapper;
|
||||||
|
import vip.mate.wiki.service.HybridRetriever;
|
||||||
|
import vip.mate.wiki.service.WikiEmbeddingService;
|
||||||
|
import vip.mate.wiki.service.WikiKnowledgeBaseService;
|
||||||
|
import vip.mate.wiki.service.WikiPageService;
|
||||||
|
import vip.mate.wiki.service.WikiRawMaterialService;
|
||||||
|
import vip.mate.wiki.service.WikiRelationService;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies the IDOR guard added for ISSUE #438: {@link WikiRelationController}
|
||||||
|
* endpoints must reject requests whose target KB belongs to a different
|
||||||
|
* workspace than the caller's {@code X-Workspace-Id} header.
|
||||||
|
*
|
||||||
|
* <p>These are unit-level checks of the {@code verifyKBWorkspace} /
|
||||||
|
* {@code verifyRawWorkspace} / {@code verifyChunkWorkspace} helpers — the
|
||||||
|
* same cross-check pattern that closed the WebChat approval IDOR (#415).
|
||||||
|
* The {@code @RequireWorkspaceRole} annotation layer is validated separately
|
||||||
|
* via the interceptor; here we assert the resource-ownership guard.
|
||||||
|
*/
|
||||||
|
class WikiRelationControllerIdorTest {
|
||||||
|
|
||||||
|
private WikiKnowledgeBaseService kbService;
|
||||||
|
private WikiRawMaterialService rawService;
|
||||||
|
private WikiChunkMapper chunkMapper;
|
||||||
|
private WikiProcessingJobMapper jobMapper;
|
||||||
|
private HybridRetriever hybridRetriever;
|
||||||
|
private WikiPageService pageService;
|
||||||
|
private WikiPageCitationMapper citationMapper;
|
||||||
|
private WikiRelationController controller;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
kbService = mock(WikiKnowledgeBaseService.class);
|
||||||
|
rawService = mock(WikiRawMaterialService.class);
|
||||||
|
chunkMapper = mock(WikiChunkMapper.class);
|
||||||
|
jobMapper = mock(WikiProcessingJobMapper.class);
|
||||||
|
hybridRetriever = mock(HybridRetriever.class);
|
||||||
|
pageService = mock(WikiPageService.class);
|
||||||
|
citationMapper = mock(WikiPageCitationMapper.class);
|
||||||
|
when(hybridRetriever.search(anyLong(), anyString(), anyString(), anyInt()))
|
||||||
|
.thenReturn(List.<PageSearchResult>of());
|
||||||
|
controller = new WikiRelationController(
|
||||||
|
mock(WikiRelationService.class),
|
||||||
|
mock(WikiProcessingJobService.class),
|
||||||
|
jobMapper,
|
||||||
|
pageService,
|
||||||
|
citationMapper,
|
||||||
|
hybridRetriever,
|
||||||
|
mock(ApplicationEventPublisher.class),
|
||||||
|
new ObjectMapper(),
|
||||||
|
mock(WikiEmbeddingService.class),
|
||||||
|
kbService,
|
||||||
|
rawService,
|
||||||
|
chunkMapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- kbId endpoints ----------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("search-preview in the caller's workspace succeeds")
|
||||||
|
void searchPreviewSameWorkspaceAllowed() {
|
||||||
|
when(kbService.getById(10L)).thenReturn(kb(10L, 1L));
|
||||||
|
|
||||||
|
assertThatCode(() ->
|
||||||
|
controller.searchPreview(10L, Map.of("query", "x"), 1L))
|
||||||
|
.doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("search-preview on another workspace's KB → 403")
|
||||||
|
void searchPreviewCrossWorkspaceRejected() {
|
||||||
|
when(kbService.getById(10L)).thenReturn(kb(10L, 2L)); // KB belongs to ws 2
|
||||||
|
|
||||||
|
assertThatThrownBy(() ->
|
||||||
|
controller.searchPreview(10L, Map.of("query", "x"), 1L))
|
||||||
|
.isInstanceOf(MateClawException.class)
|
||||||
|
.hasMessageContaining("工作区");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("stats on another workspace's KB → 403")
|
||||||
|
void statsCrossWorkspaceRejected() {
|
||||||
|
when(kbService.getById(10L)).thenReturn(kb(10L, 2L));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> controller.kbStats(10L, 1L))
|
||||||
|
.isInstanceOf(MateClawException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("enrich (member-level write) on another workspace's KB → 403")
|
||||||
|
void enrichCrossWorkspaceRejected() {
|
||||||
|
when(kbService.getById(10L)).thenReturn(kb(10L, 2L));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> controller.enrichPage(10L, "some-slug", 1L))
|
||||||
|
.isInstanceOf(MateClawException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("repair on another workspace's KB → 403")
|
||||||
|
void repairCrossWorkspaceRejected() {
|
||||||
|
when(kbService.getById(10L)).thenReturn(kb(10L, 2L));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> controller.repairPage(10L, "some-slug", 1L))
|
||||||
|
.isInstanceOf(MateClawException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("unknown kbId → 404 (does not leak existence via workspace mismatch)")
|
||||||
|
void unknownKbReturns404() {
|
||||||
|
when(kbService.getById(999L)).thenReturn(null);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> controller.kbStats(999L, 1L))
|
||||||
|
.isInstanceOf(MateClawException.class)
|
||||||
|
.hasMessageContaining("not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- getJobs rawId cross-KB filter ----------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("getJobs: rawId belonging to the same KB is returned")
|
||||||
|
void getJobsRawIdSameKbReturned() {
|
||||||
|
when(kbService.getById(10L)).thenReturn(kb(10L, 1L));
|
||||||
|
WikiProcessingJobEntity job = new WikiProcessingJobEntity();
|
||||||
|
job.setId(1L);
|
||||||
|
job.setKbId(10L); // same KB as the path → allowed
|
||||||
|
when(jobMapper.findLatestByRawId(50L)).thenReturn(Optional.of(job));
|
||||||
|
|
||||||
|
List<?> result = controller.getJobs(10L, 50L, 1L);
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("getJobs: rawId pointing at another KB's job is filtered out")
|
||||||
|
void getJobsRawIdCrossKbFiltered() {
|
||||||
|
when(kbService.getById(10L)).thenReturn(kb(10L, 1L)); // caller's KB
|
||||||
|
WikiProcessingJobEntity foreignJob = new WikiProcessingJobEntity();
|
||||||
|
foreignJob.setId(2L);
|
||||||
|
foreignJob.setKbId(99L); // job belongs to a different KB → dropped
|
||||||
|
when(jobMapper.findLatestByRawId(50L)).thenReturn(Optional.of(foreignJob));
|
||||||
|
|
||||||
|
List<?> result = controller.getJobs(10L, 50L, 1L);
|
||||||
|
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("null X-Workspace-Id header falls back to default ws=1")
|
||||||
|
void missingHeaderFallsBackToDefaultWorkspace() {
|
||||||
|
// KB in default workspace (id=1), no header → should pass the guard.
|
||||||
|
when(kbService.getById(10L)).thenReturn(kb(10L, 1L));
|
||||||
|
|
||||||
|
assertThatCode(() ->
|
||||||
|
controller.searchPreview(10L, Map.of("query", "x"), null))
|
||||||
|
.doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("KB with null workspaceId is not rejected (legacy / shared KBs)")
|
||||||
|
void nullWorkspaceKbAllowed() {
|
||||||
|
WikiKnowledgeBaseEntity legacy = new WikiKnowledgeBaseEntity();
|
||||||
|
legacy.setId(10L);
|
||||||
|
legacy.setWorkspaceId(null); // pre-workspace KB
|
||||||
|
when(kbService.getById(10L)).thenReturn(legacy);
|
||||||
|
|
||||||
|
assertThatCode(() ->
|
||||||
|
controller.searchPreview(10L, Map.of("query", "x"), 99L))
|
||||||
|
.doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- pageCitations cross-KB filter (review on #439) ----------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("pageCitations: pageId belonging to the same KB is returned")
|
||||||
|
void pageCitationsSameKbReturned() {
|
||||||
|
when(kbService.getById(10L)).thenReturn(kb(10L, 1L));
|
||||||
|
WikiPageEntity page = new WikiPageEntity();
|
||||||
|
page.setId(77L);
|
||||||
|
page.setKbId(10L); // same KB as the path → allowed
|
||||||
|
when(pageService.getById(77L)).thenReturn(page);
|
||||||
|
when(citationMapper.listWithRawByPageId(77L)).thenReturn(List.of());
|
||||||
|
|
||||||
|
var result = controller.pageCitations(10L, 77L, 1L);
|
||||||
|
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("pageCitations: pageId pointing at another KB is filtered out")
|
||||||
|
void pageCitationsCrossKbFiltered() {
|
||||||
|
when(kbService.getById(10L)).thenReturn(kb(10L, 1L)); // caller's KB
|
||||||
|
WikiPageEntity foreignPage = new WikiPageEntity();
|
||||||
|
foreignPage.setId(88L);
|
||||||
|
foreignPage.setKbId(99L); // page belongs to a different KB → dropped
|
||||||
|
when(pageService.getById(88L)).thenReturn(foreignPage);
|
||||||
|
|
||||||
|
var result = controller.pageCitations(10L, 88L, 1L);
|
||||||
|
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("pageCitations: unknown pageId → empty list")
|
||||||
|
void pageCitationsUnknownPageReturnsEmpty() {
|
||||||
|
when(kbService.getById(10L)).thenReturn(kb(10L, 1L));
|
||||||
|
when(pageService.getById(999L)).thenReturn(null);
|
||||||
|
|
||||||
|
var result = controller.pageCitations(10L, 999L, 1L);
|
||||||
|
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- rawId / chunkId endpoints (resolve owning KB) ----------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("pagesByRawId resolves KB and rejects cross-workspace")
|
||||||
|
void pagesByRawIdCrossWorkspaceRejected() {
|
||||||
|
WikiRawMaterialEntity raw = new WikiRawMaterialEntity();
|
||||||
|
raw.setId(50L);
|
||||||
|
raw.setKbId(10L);
|
||||||
|
when(rawService.getById(50L)).thenReturn(raw);
|
||||||
|
when(kbService.getById(10L)).thenReturn(kb(10L, 2L)); // KB in ws 2
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> controller.pagesByRawId(50L, 1L))
|
||||||
|
.isInstanceOf(MateClawException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("pagesByChunkId resolves KB and rejects cross-workspace")
|
||||||
|
void pagesByChunkIdCrossWorkspaceRejected() {
|
||||||
|
WikiChunkEntity chunk = new WikiChunkEntity();
|
||||||
|
chunk.setId(60L);
|
||||||
|
chunk.setKbId(10L);
|
||||||
|
when(chunkMapper.selectById(60L)).thenReturn(chunk);
|
||||||
|
when(kbService.getById(10L)).thenReturn(kb(10L, 2L)); // KB in ws 2
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> controller.pagesByChunkId(60L, 1L))
|
||||||
|
.isInstanceOf(MateClawException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("unknown rawId → 404")
|
||||||
|
void unknownRawReturns404() {
|
||||||
|
when(rawService.getById(999L)).thenReturn(null);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> controller.pagesByRawId(999L, 1L))
|
||||||
|
.isInstanceOf(MateClawException.class)
|
||||||
|
.hasMessageContaining("not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("unknown chunkId → 404")
|
||||||
|
void unknownChunkReturns404() {
|
||||||
|
when(chunkMapper.selectById(999L)).thenReturn(null);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> controller.pagesByChunkId(999L, 1L))
|
||||||
|
.isInstanceOf(MateClawException.class)
|
||||||
|
.hasMessageContaining("not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- helpers ----------------
|
||||||
|
|
||||||
|
private static WikiKnowledgeBaseEntity kb(long id, long workspaceId) {
|
||||||
|
WikiKnowledgeBaseEntity entity = new WikiKnowledgeBaseEntity();
|
||||||
|
entity.setId(id);
|
||||||
|
entity.setWorkspaceId(workspaceId);
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user