feat(wiki): per-agent pageType permission config API and pending-approval recording

This commit is contained in:
matevip 2026-05-31 07:59:48 +08:00
parent 7363ee8668
commit e18e0f1029
4 changed files with 227 additions and 7 deletions

View File

@ -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<List<WikiAgentPageTypePermissionEntity>> 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<WikiAgentPageTypePermissionEntity> 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<Boolean> 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")

View File

@ -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<WikiAgentPageTypePermissionEntity> listRows(Long agentId, Long kbId) {
if (agentId == null || kbId == null) {
return List.of();
}
return permissionMapper.selectList(
new LambdaQueryWrapper<WikiAgentPageTypePermissionEntity>()
.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<WikiAgentPageTypePermissionEntity>()
.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) {

View File

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

View File

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