mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-16 12:27:53 +08:00
feat(wiki): per-agent pageType permission config API and pending-approval recording
This commit is contained in:
parent
7363ee8668
commit
e18e0f1029
@ -17,6 +17,7 @@ import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
|||||||
import vip.mate.wiki.WikiProperties;
|
import vip.mate.wiki.WikiProperties;
|
||||||
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
|
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
|
||||||
import vip.mate.wiki.model.WikiPageEntity;
|
import vip.mate.wiki.model.WikiPageEntity;
|
||||||
|
import vip.mate.wiki.model.WikiAgentPageTypePermissionEntity;
|
||||||
import vip.mate.wiki.model.WikiPageTypeProfileEntity;
|
import vip.mate.wiki.model.WikiPageTypeProfileEntity;
|
||||||
import vip.mate.wiki.model.WikiRawMaterialEntity;
|
import vip.mate.wiki.model.WikiRawMaterialEntity;
|
||||||
import vip.mate.wiki.profile.WikiPageTypeProfileService;
|
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.WikiKnowledgeBaseService;
|
||||||
import vip.mate.wiki.service.WikiLintJobService;
|
import vip.mate.wiki.service.WikiLintJobService;
|
||||||
import vip.mate.wiki.service.WikiPageService;
|
import vip.mate.wiki.service.WikiPageService;
|
||||||
|
import vip.mate.wiki.service.WikiPageTypePermissionService;
|
||||||
import vip.mate.wiki.service.WikiProcessingService;
|
import vip.mate.wiki.service.WikiProcessingService;
|
||||||
import vip.mate.wiki.service.WikiRawMaterialService;
|
import vip.mate.wiki.service.WikiRawMaterialService;
|
||||||
import vip.mate.wiki.sse.WikiProgressBus;
|
import vip.mate.wiki.sse.WikiProgressBus;
|
||||||
@ -60,6 +62,7 @@ public class WikiController {
|
|||||||
private final WikiProgressBus progressBus;
|
private final WikiProgressBus progressBus;
|
||||||
private final AuditEventService auditEventService;
|
private final AuditEventService auditEventService;
|
||||||
private final WikiPageTypeProfileService pageTypeProfileService;
|
private final WikiPageTypeProfileService pageTypeProfileService;
|
||||||
|
private final WikiPageTypePermissionService pageTypePermissionService;
|
||||||
private final WikiSourcePathValidator pathValidator;
|
private final WikiSourcePathValidator pathValidator;
|
||||||
private final vip.mate.wiki.service.WikiSourceWatcherService sourceWatcherService;
|
private final vip.mate.wiki.service.WikiSourceWatcherService sourceWatcherService;
|
||||||
private final vip.mate.wiki.pipeline.WikiPipelineDefinitionService pipelineDefinitionService;
|
private final vip.mate.wiki.pipeline.WikiPipelineDefinitionService pipelineDefinitionService;
|
||||||
@ -427,6 +430,41 @@ public class WikiController {
|
|||||||
return R.ok(out);
|
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 ====================
|
// ==================== Raw Materials ====================
|
||||||
|
|
||||||
@RequireWorkspaceRole("viewer")
|
@RequireWorkspaceRole("viewer")
|
||||||
|
|||||||
@ -87,6 +87,68 @@ public class WikiPageTypePermissionService {
|
|||||||
return resolve(agentId, kbId).resolveWrite(pageType, op);
|
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) {
|
private boolean isDenyAll(Long kbId) {
|
||||||
WikiKnowledgeBaseEntity kb = kbService.getById(kbId);
|
WikiKnowledgeBaseEntity kb = kbService.getById(kbId);
|
||||||
if (kb == null || kb.getConfigContent() == null) {
|
if (kb == null || kb.getConfigContent() == null) {
|
||||||
|
|||||||
@ -11,6 +11,9 @@ import org.springframework.context.ApplicationEventPublisher;
|
|||||||
import org.springframework.ai.tool.annotation.Tool;
|
import org.springframework.ai.tool.annotation.Tool;
|
||||||
import org.springframework.ai.tool.annotation.ToolParam;
|
import org.springframework.ai.tool.annotation.ToolParam;
|
||||||
import org.springframework.stereotype.Component;
|
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.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;
|
||||||
@ -78,6 +81,16 @@ public class WikiTool {
|
|||||||
@Autowired(required = false)
|
@Autowired(required = false)
|
||||||
private WikiTransformationAggregator transformationAggregator;
|
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,
|
* Per-agent pageType permission gate. Mandatory: this is a security control,
|
||||||
* so it is a required constructor dependency rather than an optional bean —
|
* 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
|
* {@code null} when it may proceed. Null-safe: when the permission service
|
||||||
* is absent, every write is allowed (pre-permission behaviour).
|
* is absent, every write is allowed (pre-permission behaviour).
|
||||||
*
|
*
|
||||||
* <p>{@code APPROVAL_REQUIRED} currently fails closed with an explanatory
|
* <p>{@code APPROVAL_REQUIRED} records a pending approval in the operator
|
||||||
* message rather than opening a pending approval — the deferred
|
* inbox (when {@link #approvalWorkflowService} is wired) so the request is
|
||||||
* approve-then-execute flow needs a conversation context the wiki tools do
|
* visible and auditable, then fails closed for this turn — the write is not
|
||||||
* not yet receive. The permission row still records the intent so the
|
* performed inline. The approve-then-replay path is driven from the inbox,
|
||||||
* deferred flow can be wired later without a schema change.
|
* not from inside the tool body, which cannot pause and resume itself.
|
||||||
*/
|
*/
|
||||||
private String checkWrite(Long agentId, Long kbId, String pageType,
|
private String checkWrite(Long agentId, Long kbId, String pageType,
|
||||||
WikiPageTypePermissionService.WriteOp op) {
|
WikiPageTypePermissionService.WriteOp op) {
|
||||||
@ -1114,15 +1127,61 @@ public class WikiTool {
|
|||||||
+ " '" + typeLabel + "' pages in this knowledge base.");
|
+ " '" + typeLabel + "' pages in this knowledge base.");
|
||||||
}
|
}
|
||||||
case APPROVAL_REQUIRED -> {
|
case APPROVAL_REQUIRED -> {
|
||||||
log.info("[WikiTool] write requires approval (blocked): agent={} kb={} type={} op={}",
|
boolean recorded = recordPendingApproval(agentId, kbId, pageType, op);
|
||||||
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
|
yield error("Approval required: " + op.name().toLowerCase() + " of '" + typeLabel
|
||||||
+ "' pages in this knowledge base needs administrator approval. "
|
+ "' pages in this knowledge base needs administrator approval. "
|
||||||
|
+ (recorded
|
||||||
|
? "A pending approval was created for an administrator to review. "
|
||||||
|
: "")
|
||||||
+ "The operation was NOT performed.");
|
+ "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
|
* 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
|
||||||
|
|||||||
@ -10,9 +10,12 @@ import java.util.List;
|
|||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
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.junit.jupiter.api.Assertions.assertTrue;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -128,4 +131,62 @@ class WikiPageTypePermissionServiceTest {
|
|||||||
WikiPageTypePermissionService s = service(List.of(), "{\"defaultReadPolicy\":\"deny_all\"}");
|
WikiPageTypePermissionService s = service(List.of(), "{\"defaultReadPolicy\":\"deny_all\"}");
|
||||||
assertTrue(s.canRead(null, KB, "concept"));
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user