From e18e0f1029ab419db9a230aba527a9f24156bcac Mon Sep 17 00:00:00 2001 From: matevip Date: Sun, 31 May 2026 07:59:48 +0800 Subject: [PATCH] feat(wiki): per-agent pageType permission config API and pending-approval recording --- .../mate/wiki/controller/WikiController.java | 38 ++++++++++ .../WikiPageTypePermissionService.java | 62 ++++++++++++++++ .../java/vip/mate/wiki/tool/WikiTool.java | 73 +++++++++++++++++-- .../WikiPageTypePermissionServiceTest.java | 61 ++++++++++++++++ 4 files changed, 227 insertions(+), 7 deletions(-) diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java index 01c522e4..1f9358f0 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java @@ -17,6 +17,7 @@ import vip.mate.workspace.core.annotation.RequireWorkspaceRole; import vip.mate.wiki.WikiProperties; import vip.mate.wiki.model.WikiKnowledgeBaseEntity; import vip.mate.wiki.model.WikiPageEntity; +import vip.mate.wiki.model.WikiAgentPageTypePermissionEntity; import vip.mate.wiki.model.WikiPageTypeProfileEntity; import vip.mate.wiki.model.WikiRawMaterialEntity; import vip.mate.wiki.profile.WikiPageTypeProfileService; @@ -25,6 +26,7 @@ import vip.mate.wiki.service.WikiSourcePathValidator; import vip.mate.wiki.service.WikiKnowledgeBaseService; import vip.mate.wiki.service.WikiLintJobService; import vip.mate.wiki.service.WikiPageService; +import vip.mate.wiki.service.WikiPageTypePermissionService; import vip.mate.wiki.service.WikiProcessingService; import vip.mate.wiki.service.WikiRawMaterialService; import vip.mate.wiki.sse.WikiProgressBus; @@ -60,6 +62,7 @@ public class WikiController { private final WikiProgressBus progressBus; private final AuditEventService auditEventService; private final WikiPageTypeProfileService pageTypeProfileService; + private final WikiPageTypePermissionService pageTypePermissionService; private final WikiSourcePathValidator pathValidator; private final vip.mate.wiki.service.WikiSourceWatcherService sourceWatcherService; private final vip.mate.wiki.pipeline.WikiPipelineDefinitionService pipelineDefinitionService; @@ -427,6 +430,41 @@ public class WikiController { return R.ok(out); } + // ==================== Agent PageType Permissions ==================== + + @RequireWorkspaceRole("viewer") + @Operation(summary = "列出某 Agent 在知识库下的 pageType 权限规则") + @GetMapping("/knowledge-bases/{kbId}/agents/{agentId}/page-type-permissions") + public R> listPageTypePermissions( + @PathVariable Long kbId, @PathVariable Long agentId, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(kbId, workspaceId); + return R.ok(pageTypePermissionService.listRows(agentId, kbId)); + } + + @RequireWorkspaceRole("admin") + @Operation(summary = "新增或更新 Agent 的 pageType 权限规则(按 agent+kb+pageType 去重)") + @PostMapping("/knowledge-bases/{kbId}/agents/{agentId}/page-type-permissions") + public R savePageTypePermission( + @PathVariable Long kbId, @PathVariable Long agentId, + @RequestBody WikiAgentPageTypePermissionEntity body, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(kbId, workspaceId); + body.setKbId(kbId); + body.setAgentId(agentId); + return R.ok(pageTypePermissionService.saveRow(body)); + } + + @RequireWorkspaceRole("admin") + @Operation(summary = "删除一条 Agent pageType 权限规则") + @DeleteMapping("/knowledge-bases/{kbId}/agents/{agentId}/page-type-permissions/{id}") + public R deletePageTypePermission( + @PathVariable Long kbId, @PathVariable Long agentId, @PathVariable Long id, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(kbId, workspaceId); + return R.ok(pageTypePermissionService.deleteRow(id)); + } + // ==================== Raw Materials ==================== @RequireWorkspaceRole("viewer") diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageTypePermissionService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageTypePermissionService.java index 3c3edc3b..ad94c022 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageTypePermissionService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageTypePermissionService.java @@ -87,6 +87,68 @@ public class WikiPageTypePermissionService { return resolve(agentId, kbId).resolveWrite(pageType, op); } + // ==================== Config CRUD (admin surface) ==================== + + /** All permission rows for an agent within a KB, ordered with the wildcard last. */ + public List listRows(Long agentId, Long kbId) { + if (agentId == null || kbId == null) { + return List.of(); + } + return permissionMapper.selectList( + new LambdaQueryWrapper() + .eq(WikiAgentPageTypePermissionEntity::getAgentId, agentId) + .eq(WikiAgentPageTypePermissionEntity::getKbId, kbId) + .orderByAsc(WikiAgentPageTypePermissionEntity::getPageType)); + } + + /** + * Upsert a permission row by the {@code (agent_id, kb_id, page_type)} natural + * key: an existing row for the same triple is updated in place, otherwise a + * new row is inserted. The pageType is normalized to lowercase (the wildcard + * {@code *} is preserved as-is). Returns the persisted row. + */ + public WikiAgentPageTypePermissionEntity saveRow(WikiAgentPageTypePermissionEntity row) { + if (row == null || row.getAgentId() == null || row.getKbId() == null) { + throw new IllegalArgumentException("agentId and kbId are required"); + } + String type = row.getPageType() == null || row.getPageType().isBlank() + ? WILDCARD + : (WILDCARD.equals(row.getPageType().trim()) + ? WILDCARD + : row.getPageType().trim().toLowerCase(Locale.ROOT)); + row.setPageType(type); + // Normalize the write policy so resolution never sees an unexpected token. + if (row.getWritePolicy() != null) { + String wp = row.getWritePolicy().trim().toLowerCase(Locale.ROOT); + row.setWritePolicy(switch (wp) { + case "allow", "deny", "approval_required" -> wp; + default -> "approval_required"; + }); + } + WikiAgentPageTypePermissionEntity existing = permissionMapper.selectOne( + new LambdaQueryWrapper() + .eq(WikiAgentPageTypePermissionEntity::getAgentId, row.getAgentId()) + .eq(WikiAgentPageTypePermissionEntity::getKbId, row.getKbId()) + .eq(WikiAgentPageTypePermissionEntity::getPageType, type) + .last("LIMIT 1")); + if (existing != null) { + row.setId(existing.getId()); + permissionMapper.updateById(row); + } else { + row.setId(null); + permissionMapper.insert(row); + } + return row; + } + + /** Logically delete a permission row by id. Returns true when a row was removed. */ + public boolean deleteRow(Long id) { + if (id == null) { + return false; + } + return permissionMapper.deleteById(id) > 0; + } + private boolean isDenyAll(Long kbId) { WikiKnowledgeBaseEntity kb = kbService.getById(kbId); if (kb == null || kb.getConfigContent() == null) { diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java b/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java index fe8dca56..1b9843e9 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java @@ -11,6 +11,9 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.stereotype.Component; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.agent.context.ChatOriginHolder; +import vip.mate.approval.ApprovalWorkflowService; import vip.mate.wiki.dto.*; import vip.mate.wiki.job.WikiProcessingJobService; import vip.mate.wiki.job.event.WikiJobCreatedEvent; @@ -78,6 +81,16 @@ public class WikiTool { @Autowired(required = false) private WikiTransformationAggregator transformationAggregator; + /** + * Optional approval workflow. When present, an {@code APPROVAL_REQUIRED} + * write records a real pending approval in the operator inbox (keyed to the + * current conversation via {@link ChatOriginHolder}), so the operation is + * visible and auditable rather than silently blocked. Absent in lightweight + * contexts (tests, headless tools) — the write still fails closed. + */ + @Autowired(required = false) + private ApprovalWorkflowService approvalWorkflowService; + /** * Per-agent pageType permission gate. Mandatory: this is a security control, * so it is a required constructor dependency rather than an optional bean — @@ -1094,11 +1107,11 @@ public class WikiTool { * {@code null} when it may proceed. Null-safe: when the permission service * is absent, every write is allowed (pre-permission behaviour). * - *

{@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. + *

{@code APPROVAL_REQUIRED} records a pending approval in the operator + * inbox (when {@link #approvalWorkflowService} is wired) so the request is + * visible and auditable, then fails closed for this turn — the write is not + * performed inline. The approve-then-replay path is driven from the inbox, + * not from inside the tool body, which cannot pause and resume itself. */ private String checkWrite(Long agentId, Long kbId, String pageType, WikiPageTypePermissionService.WriteOp op) { @@ -1114,15 +1127,61 @@ public class WikiTool { + " '" + typeLabel + "' pages in this knowledge base."); } case APPROVAL_REQUIRED -> { - log.info("[WikiTool] write requires approval (blocked): agent={} kb={} type={} op={}", - agentId, kbId, pageType, op); + boolean recorded = recordPendingApproval(agentId, kbId, pageType, op); + log.info("[WikiTool] write requires approval: agent={} kb={} type={} op={} recorded={}", + agentId, kbId, pageType, op, recorded); yield error("Approval required: " + op.name().toLowerCase() + " of '" + typeLabel + "' pages in this knowledge base needs administrator approval. " + + (recorded + ? "A pending approval was created for an administrator to review. " + : "") + "The operation was NOT performed."); } }; } + /** + * Record a pending approval for an {@code APPROVAL_REQUIRED} wiki write, + * keyed to the current conversation via {@link ChatOriginHolder}. Best-effort: + * returns false (and never throws) when the workflow bean is absent or there + * is no conversation context, so the caller can still fail closed cleanly. + */ + private boolean recordPendingApproval(Long agentId, Long kbId, String pageType, + WikiPageTypePermissionService.WriteOp op) { + if (approvalWorkflowService == null) { + return false; + } + ChatOrigin origin = ChatOriginHolder.get(); + String conversationId = origin == null ? null : origin.conversationId(); + if (conversationId == null || conversationId.isBlank()) { + return false; // no conversation to attach the approval to + } + try { + String typeLabel = (pageType == null || pageType.isBlank()) ? "(default)" : pageType; + String args = objectMapper.createObjectNode() + .put("kbId", String.valueOf(kbId)) + .put("pageType", typeLabel) + .put("op", op.name()) + .toString(); + String reason = "Wiki " + op.name().toLowerCase() + " of '" + typeLabel + + "' pages requires administrator approval."; + String userId = origin.requesterId(); + approvalWorkflowService.createPending( + conversationId, + (userId == null || userId.isBlank()) ? null : userId, + "wiki_" + op.name().toLowerCase() + "_page", + args, + reason, + /* toolCallPayload */ null, + /* siblingToolCalls */ null, + agentId == null ? null : String.valueOf(agentId)); + return true; + } catch (Exception e) { + log.warn("[WikiTool] failed to record pending approval: {}", e.getMessage()); + return false; + } + } + /** * Single helper every wiki tool uses. Caller passes the agent id and at * most one of {@code kbId} / {@code kbName}; the helper decides which diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiPageTypePermissionServiceTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiPageTypePermissionServiceTest.java index a39d1373..637b4cc2 100644 --- a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiPageTypePermissionServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiPageTypePermissionServiceTest.java @@ -10,9 +10,12 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** @@ -128,4 +131,62 @@ class WikiPageTypePermissionServiceTest { WikiPageTypePermissionService s = service(List.of(), "{\"defaultReadPolicy\":\"deny_all\"}"); assertTrue(s.canRead(null, KB, "concept")); } + + // ==================== CRUD ==================== + + @Test + void saveRow_insertsWhenAbsent_normalizesTypeAndPolicy() { + WikiAgentPageTypePermissionMapper mapper = mock(WikiAgentPageTypePermissionMapper.class); + when(mapper.selectOne(any())).thenReturn(null); // no existing row + WikiPageTypePermissionService s = new WikiPageTypePermissionService( + mapper, mock(WikiKnowledgeBaseService.class), new ObjectMapper()); + + WikiAgentPageTypePermissionEntity in = row("Episode", 1, 1, 0, 0, "BOGUS"); + in.setId(999L); // must be cleared on insert + WikiAgentPageTypePermissionEntity saved = s.saveRow(in); + + assertEquals("episode", saved.getPageType()); // lowercased + assertEquals("approval_required", saved.getWritePolicy()); // unknown → safe default + assertNull(saved.getId()); // id cleared for insert + verify(mapper).insert((WikiAgentPageTypePermissionEntity) saved); + verify(mapper, never()).updateById((WikiAgentPageTypePermissionEntity) any()); + } + + @Test + void saveRow_updatesInPlaceWhenExisting() { + WikiAgentPageTypePermissionMapper mapper = mock(WikiAgentPageTypePermissionMapper.class); + WikiAgentPageTypePermissionEntity existing = row("episode", 0, 0, 0, 0, "deny"); + existing.setId(42L); + when(mapper.selectOne(any())).thenReturn(existing); + WikiPageTypePermissionService s = new WikiPageTypePermissionService( + mapper, mock(WikiKnowledgeBaseService.class), new ObjectMapper()); + + WikiAgentPageTypePermissionEntity in = row("episode", 1, 1, 1, 1, "allow"); + WikiAgentPageTypePermissionEntity saved = s.saveRow(in); + + assertEquals(42L, saved.getId()); // adopts existing id + verify(mapper).updateById((WikiAgentPageTypePermissionEntity) saved); + verify(mapper, never()).insert((WikiAgentPageTypePermissionEntity) any()); + } + + @Test + void saveRow_blankPageTypeBecomesWildcard() { + WikiAgentPageTypePermissionMapper mapper = mock(WikiAgentPageTypePermissionMapper.class); + when(mapper.selectOne(any())).thenReturn(null); + WikiPageTypePermissionService s = new WikiPageTypePermissionService( + mapper, mock(WikiKnowledgeBaseService.class), new ObjectMapper()); + + WikiAgentPageTypePermissionEntity in = row(" ", 1, 0, 0, 0, "allow"); + assertEquals(WikiPageTypePermissionService.WILDCARD, s.saveRow(in).getPageType()); + } + + @Test + void deleteRow_returnsTrueWhenRowRemoved() { + WikiAgentPageTypePermissionMapper mapper = mock(WikiAgentPageTypePermissionMapper.class); + when(mapper.deleteById(5L)).thenReturn(1); + WikiPageTypePermissionService s = new WikiPageTypePermissionService( + mapper, mock(WikiKnowledgeBaseService.class), new ObjectMapper()); + assertTrue(s.deleteRow(5L)); + assertFalse(s.deleteRow(null)); + } }