mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 20:08:18 +08:00
feat(wiki): gate wiki write/mutate tools by pageType permission
This commit is contained in:
parent
502b8406a6
commit
ec47d19bf8
@ -450,6 +450,14 @@ public class WikiTool {
|
|||||||
if (kbRes.hasError()) return kbRes.errorJson();
|
if (kbRes.hasError()) return kbRes.errorJson();
|
||||||
kbId = kbRes.kbId();
|
kbId = kbRes.kbId();
|
||||||
|
|
||||||
|
// wiki_create_page does not take an explicit pageType, so creation is
|
||||||
|
// governed by the agent's wildcard ('*') write rule for this KB.
|
||||||
|
String createErr = checkWrite(agentId, kbId, null,
|
||||||
|
WikiPageTypePermissionService.WriteOp.CREATE);
|
||||||
|
if (createErr != null) {
|
||||||
|
return createErr;
|
||||||
|
}
|
||||||
|
|
||||||
String slug = title.toLowerCase()
|
String slug = title.toLowerCase()
|
||||||
.replaceAll("[^a-z0-9\\u4e00-\\u9fff]+", "-")
|
.replaceAll("[^a-z0-9\\u4e00-\\u9fff]+", "-")
|
||||||
.replaceAll("^-|-$", "");
|
.replaceAll("^-|-$", "");
|
||||||
@ -501,6 +509,12 @@ public class WikiTool {
|
|||||||
kbId = kbRes.kbId();
|
kbId = kbRes.kbId();
|
||||||
if (compileService == null) return error("Compile service not available");
|
if (compileService == null) return error("Compile service not available");
|
||||||
|
|
||||||
|
String compileErr = checkWrite(agentId, kbId, null,
|
||||||
|
WikiPageTypePermissionService.WriteOp.CREATE);
|
||||||
|
if (compileErr != null) {
|
||||||
|
return compileErr;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
WikiCompileService.CompileResult res = compileService.compilePage(kbId, topic, slug, maxEvidenceChunks);
|
WikiCompileService.CompileResult res = compileService.compilePage(kbId, topic, slug, maxEvidenceChunks);
|
||||||
// RFC-051 follow-up: distinguish "no source material" from a hard error
|
// RFC-051 follow-up: distinguish "no source material" from a hard error
|
||||||
@ -613,6 +627,19 @@ public class WikiTool {
|
|||||||
KbResolution kbRes = resolveKb(agentId, kbName, kbId);
|
KbResolution kbRes = resolveKb(agentId, kbName, kbId);
|
||||||
if (kbRes.hasError()) return kbRes.errorJson();
|
if (kbRes.hasError()) return kbRes.errorJson();
|
||||||
kbId = kbRes.kbId();
|
kbId = kbRes.kbId();
|
||||||
|
// Archiving toggles visibility — gate it as an update, and hide pages
|
||||||
|
// whose type the agent cannot read.
|
||||||
|
WikiPageEntity target = pageService.getBySlug(kbId, slug);
|
||||||
|
if (target != null) {
|
||||||
|
if (!canRead(pageTypeAccess(agentId, kbId), target)) {
|
||||||
|
return error("Page not found: " + slug);
|
||||||
|
}
|
||||||
|
String writeErr = checkWrite(agentId, kbId, target.getPageType(),
|
||||||
|
WikiPageTypePermissionService.WriteOp.UPDATE);
|
||||||
|
if (writeErr != null) {
|
||||||
|
return writeErr;
|
||||||
|
}
|
||||||
|
}
|
||||||
boolean changed;
|
boolean changed;
|
||||||
try {
|
try {
|
||||||
changed = pageService.setArchived(kbId, slug, archive);
|
changed = pageService.setArchived(kbId, slug, archive);
|
||||||
@ -648,6 +675,15 @@ public class WikiTool {
|
|||||||
if (page == null) {
|
if (page == null) {
|
||||||
return error("Page not found: " + slug);
|
return error("Page not found: " + slug);
|
||||||
}
|
}
|
||||||
|
// Unreadable page types must not even be discoverable as delete targets.
|
||||||
|
if (!canRead(pageTypeAccess(agentId, kbId), page)) {
|
||||||
|
return error("Page not found: " + slug);
|
||||||
|
}
|
||||||
|
String writeErr = checkWrite(agentId, kbId, page.getPageType(),
|
||||||
|
WikiPageTypePermissionService.WriteOp.DELETE);
|
||||||
|
if (writeErr != null) {
|
||||||
|
return writeErr;
|
||||||
|
}
|
||||||
|
|
||||||
if ("manual".equals(page.getLastUpdatedBy())) {
|
if ("manual".equals(page.getLastUpdatedBy())) {
|
||||||
return error("Cannot delete manually curated page: " + page.getTitle() + ". Please manage via admin UI.");
|
return error("Cannot delete manually curated page: " + page.getTitle() + ". Please manage via admin UI.");
|
||||||
@ -763,6 +799,14 @@ public class WikiTool {
|
|||||||
|
|
||||||
WikiPageEntity page = pageService.getBySlug(kbId, slug);
|
WikiPageEntity page = pageService.getBySlug(kbId, slug);
|
||||||
if (page == null) return error("Page not found: " + slug);
|
if (page == null) return error("Page not found: " + slug);
|
||||||
|
if (!canRead(pageTypeAccess(agentId, kbId), page)) {
|
||||||
|
return error("Page not found: " + slug);
|
||||||
|
}
|
||||||
|
String enrichErr = checkWrite(agentId, kbId, page.getPageType(),
|
||||||
|
WikiPageTypePermissionService.WriteOp.UPDATE);
|
||||||
|
if (enrichErr != null) {
|
||||||
|
return enrichErr;
|
||||||
|
}
|
||||||
|
|
||||||
Long rawId = 0L;
|
Long rawId = 0L;
|
||||||
try {
|
try {
|
||||||
@ -829,6 +873,13 @@ public class WikiTool {
|
|||||||
return error("Transformations not available");
|
return error("Transformations not available");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A transformation persists a synthesis run/page — gate as a create.
|
||||||
|
String txErr = checkWrite(agentId, kbId, null,
|
||||||
|
WikiPageTypePermissionService.WriteOp.CREATE);
|
||||||
|
if (txErr != null) {
|
||||||
|
return txErr;
|
||||||
|
}
|
||||||
|
|
||||||
WikiKnowledgeBaseEntity kb = kbService.getById(kbId);
|
WikiKnowledgeBaseEntity kb = kbService.getById(kbId);
|
||||||
Long wsId = (kb == null || kb.getWorkspaceId() == null) ? 1L : kb.getWorkspaceId();
|
Long wsId = (kb == null || kb.getWorkspaceId() == null) ? 1L : kb.getWorkspaceId();
|
||||||
|
|
||||||
@ -879,6 +930,15 @@ public class WikiTool {
|
|||||||
|
|
||||||
WikiPageEntity page = pageService.getBySlug(kbId, slug);
|
WikiPageEntity page = pageService.getBySlug(kbId, slug);
|
||||||
if (page == null) return error("Page not found: " + slug);
|
if (page == null) return error("Page not found: " + slug);
|
||||||
|
if (!canRead(pageTypeAccess(agentId, kbId), page)) {
|
||||||
|
return error("Page not found: " + slug);
|
||||||
|
}
|
||||||
|
// Reads the source page and persists a derived run — gate as a create.
|
||||||
|
String txErr = checkWrite(agentId, kbId, null,
|
||||||
|
WikiPageTypePermissionService.WriteOp.CREATE);
|
||||||
|
if (txErr != null) {
|
||||||
|
return txErr;
|
||||||
|
}
|
||||||
|
|
||||||
WikiKnowledgeBaseEntity kb = kbService.getById(kbId);
|
WikiKnowledgeBaseEntity kb = kbService.getById(kbId);
|
||||||
Long wsId = (kb == null || kb.getWorkspaceId() == null) ? 1L : kb.getWorkspaceId();
|
Long wsId = (kb == null || kb.getWorkspaceId() == null) ? 1L : kb.getWorkspaceId();
|
||||||
@ -928,6 +988,13 @@ public class WikiTool {
|
|||||||
return error("Transformations not available");
|
return error("Transformations not available");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Aggregation upserts a synthesis page — gate as a create.
|
||||||
|
String aggErr = checkWrite(agentId, kbId, null,
|
||||||
|
WikiPageTypePermissionService.WriteOp.CREATE);
|
||||||
|
if (aggErr != null) {
|
||||||
|
return aggErr;
|
||||||
|
}
|
||||||
|
|
||||||
WikiKnowledgeBaseEntity kb = kbService.getById(kbId);
|
WikiKnowledgeBaseEntity kb = kbService.getById(kbId);
|
||||||
Long wsId = (kb == null || kb.getWorkspaceId() == null) ? 1L : kb.getWorkspaceId();
|
Long wsId = (kb == null || kb.getWorkspaceId() == null) ? 1L : kb.getWorkspaceId();
|
||||||
|
|
||||||
@ -1002,6 +1069,44 @@ public class WikiTool {
|
|||||||
return access == null || access.canRead(pageType);
|
return access == null || access.canRead(pageType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gate a write/mutate operation by pageType permission. Returns an error
|
||||||
|
* JSON string to short-circuit the tool when the write is not permitted, or
|
||||||
|
* {@code null} when it may proceed. Null-safe: when the permission service
|
||||||
|
* is absent, every write is allowed (pre-permission behaviour).
|
||||||
|
*
|
||||||
|
* <p>{@code APPROVAL_REQUIRED} currently fails closed with an explanatory
|
||||||
|
* message rather than opening a pending approval — the deferred
|
||||||
|
* approve-then-execute flow needs a conversation context the wiki tools do
|
||||||
|
* not yet receive. The permission row still records the intent so the
|
||||||
|
* deferred flow can be wired later without a schema change.
|
||||||
|
*/
|
||||||
|
private String checkWrite(Long agentId, Long kbId, String pageType,
|
||||||
|
WikiPageTypePermissionService.WriteOp op) {
|
||||||
|
if (pageTypePermissionService == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
WikiPageTypePermissionService.WriteDecision decision =
|
||||||
|
pageTypePermissionService.resolveWrite(agentId, kbId, pageType, op);
|
||||||
|
String typeLabel = (pageType == null || pageType.isBlank()) ? "(default)" : pageType;
|
||||||
|
return switch (decision) {
|
||||||
|
case ALLOW -> null;
|
||||||
|
case DENY -> {
|
||||||
|
log.info("[WikiTool] write denied by pageType permission: agent={} kb={} type={} op={}",
|
||||||
|
agentId, kbId, pageType, op);
|
||||||
|
yield error("Not permitted: this agent may not " + op.name().toLowerCase()
|
||||||
|
+ " '" + typeLabel + "' pages in this knowledge base.");
|
||||||
|
}
|
||||||
|
case APPROVAL_REQUIRED -> {
|
||||||
|
log.info("[WikiTool] write requires approval (blocked): agent={} kb={} type={} op={}",
|
||||||
|
agentId, kbId, pageType, op);
|
||||||
|
yield error("Approval required: " + op.name().toLowerCase() + " of '" + typeLabel
|
||||||
|
+ "' pages in this knowledge base needs administrator approval. "
|
||||||
|
+ "The operation was NOT performed.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single helper every wiki tool uses. Caller passes the agent id and at
|
* Single helper every wiki tool uses. Caller passes the agent id and at
|
||||||
* most one of {@code kbId} / {@code kbName}; the helper decides which
|
* most one of {@code kbId} / {@code kbName}; the helper decides which
|
||||||
|
|||||||
@ -0,0 +1,155 @@
|
|||||||
|
package vip.mate.wiki.tool;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
import vip.mate.wiki.model.WikiAgentPageTypePermissionEntity;
|
||||||
|
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
|
||||||
|
import vip.mate.wiki.model.WikiPageEntity;
|
||||||
|
import vip.mate.wiki.repository.WikiAgentPageTypePermissionMapper;
|
||||||
|
import vip.mate.wiki.service.HybridRetriever;
|
||||||
|
import vip.mate.wiki.service.WikiKnowledgeBaseService;
|
||||||
|
import vip.mate.wiki.service.WikiPageService;
|
||||||
|
import vip.mate.wiki.service.WikiPageTypePermissionService;
|
||||||
|
import vip.mate.wiki.service.WikiRawMaterialService;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies the pageType permission gate wired into {@link WikiTool} read and
|
||||||
|
* write tools, using a real {@link WikiPageTypePermissionService} backed by a
|
||||||
|
* mocked mapper so permission rows are controlled directly.
|
||||||
|
*/
|
||||||
|
class WikiToolPermissionTest {
|
||||||
|
|
||||||
|
private static final long AGENT = 11L;
|
||||||
|
private static final long KB = 7L;
|
||||||
|
|
||||||
|
private record Harness(WikiTool tool, WikiPageService pageService,
|
||||||
|
WikiAgentPageTypePermissionMapper permMapper) {}
|
||||||
|
|
||||||
|
private Harness harness(List<WikiAgentPageTypePermissionEntity> rows) {
|
||||||
|
WikiPageService pageService = mock(WikiPageService.class);
|
||||||
|
WikiKnowledgeBaseService kbService = mock(WikiKnowledgeBaseService.class);
|
||||||
|
WikiRawMaterialService rawService = mock(WikiRawMaterialService.class);
|
||||||
|
HybridRetriever retriever = mock(HybridRetriever.class);
|
||||||
|
ObjectMapper om = new ObjectMapper();
|
||||||
|
|
||||||
|
WikiKnowledgeBaseEntity kb = new WikiKnowledgeBaseEntity();
|
||||||
|
kb.setId(KB);
|
||||||
|
when(kbService.findVisibleById(AGENT, KB)).thenReturn(kb);
|
||||||
|
when(kbService.getById(KB)).thenReturn(kb);
|
||||||
|
|
||||||
|
WikiAgentPageTypePermissionMapper permMapper = mock(WikiAgentPageTypePermissionMapper.class);
|
||||||
|
when(permMapper.selectList(any())).thenReturn(rows);
|
||||||
|
WikiPageTypePermissionService permService =
|
||||||
|
new WikiPageTypePermissionService(permMapper, kbService, om);
|
||||||
|
|
||||||
|
WikiTool tool = new WikiTool(pageService, kbService, rawService, retriever, om);
|
||||||
|
ReflectionTestUtils.setField(tool, "pageTypePermissionService", permService);
|
||||||
|
return new Harness(tool, pageService, permMapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
private WikiAgentPageTypePermissionEntity row(String type, int read, int create, int update,
|
||||||
|
int delete, String writePolicy) {
|
||||||
|
WikiAgentPageTypePermissionEntity e = new WikiAgentPageTypePermissionEntity();
|
||||||
|
e.setAgentId(AGENT);
|
||||||
|
e.setKbId(KB);
|
||||||
|
e.setPageType(type);
|
||||||
|
e.setCanRead(read);
|
||||||
|
e.setCanCreate(create);
|
||||||
|
e.setCanUpdate(update);
|
||||||
|
e.setCanDelete(delete);
|
||||||
|
e.setWritePolicy(writePolicy);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
private WikiPageEntity page(String slug, String type) {
|
||||||
|
WikiPageEntity p = new WikiPageEntity();
|
||||||
|
p.setId(100L);
|
||||||
|
p.setKbId(KB);
|
||||||
|
p.setSlug(slug);
|
||||||
|
p.setTitle("T " + slug);
|
||||||
|
p.setContent("body");
|
||||||
|
p.setPageType(type);
|
||||||
|
p.setLastUpdatedBy("ai");
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void readPage_unreadableType_reportsNotFound() {
|
||||||
|
Harness h = harness(List.of(row("*", 0, 0, 0, 0, "deny")));
|
||||||
|
when(h.pageService().getBySlug(KB, "secret")).thenReturn(page("secret", "analysis"));
|
||||||
|
|
||||||
|
String out = h.tool().wiki_read_page(AGENT, "secret", null, null, null, KB);
|
||||||
|
|
||||||
|
assertTrue(out.contains("Page not found"), out);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void readPage_readableType_returnsContent() {
|
||||||
|
Harness h = harness(List.of(row("*", 1, 0, 0, 0, "deny")));
|
||||||
|
when(h.pageService().getBySlug(KB, "ok")).thenReturn(page("ok", "concept"));
|
||||||
|
|
||||||
|
String out = h.tool().wiki_read_page(AGENT, "ok", null, null, null, KB);
|
||||||
|
|
||||||
|
assertTrue(out.contains("\"content\""), out);
|
||||||
|
assertFalse(out.contains("Page not found"), out);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deletePage_denied_doesNotDelete() {
|
||||||
|
// can read, but delete flag off → DENY
|
||||||
|
Harness h = harness(List.of(row("concept", 1, 0, 0, 0, "deny")));
|
||||||
|
when(h.pageService().getBySlug(KB, "p")).thenReturn(page("p", "concept"));
|
||||||
|
|
||||||
|
String out = h.tool().wiki_delete_page(AGENT, "p", null, KB);
|
||||||
|
|
||||||
|
assertTrue(out.contains("Not permitted"), out);
|
||||||
|
verify(h.pageService(), never()).delete(anyLong(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deletePage_approvalRequired_blocksAndDoesNotDelete() {
|
||||||
|
Harness h = harness(List.of(row("concept", 1, 0, 0, 1, "approval_required")));
|
||||||
|
when(h.pageService().getBySlug(KB, "p")).thenReturn(page("p", "concept"));
|
||||||
|
|
||||||
|
String out = h.tool().wiki_delete_page(AGENT, "p", null, KB);
|
||||||
|
|
||||||
|
assertTrue(out.contains("Approval required"), out);
|
||||||
|
verify(h.pageService(), never()).delete(anyLong(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deletePage_allowed_deletes() {
|
||||||
|
Harness h = harness(List.of(row("concept", 1, 1, 1, 1, "allow")));
|
||||||
|
when(h.pageService().getBySlug(KB, "p")).thenReturn(page("p", "concept"));
|
||||||
|
|
||||||
|
String out = h.tool().wiki_delete_page(AGENT, "p", null, KB);
|
||||||
|
|
||||||
|
assertTrue(out.contains("\"ok\":true"), out);
|
||||||
|
verify(h.pageService(), times(1)).delete(eq(KB), eq("p"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createPage_deniedByWildcard_doesNotCreate() {
|
||||||
|
// a row exists for 'episode' only → KB is gated, wildcard create not granted
|
||||||
|
Harness h = harness(List.of(row("episode", 1, 1, 1, 1, "allow")));
|
||||||
|
|
||||||
|
String out = h.tool().wiki_create_page(AGENT, "New Page", "content here", null, KB);
|
||||||
|
|
||||||
|
assertTrue(out.contains("Not permitted"), out);
|
||||||
|
verify(h.pageService(), never()).createPage(anyLong(), any(), any(), any(), any(), any());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user