From 502b8406a6558b366a76f1588a50b0c7152a21b1 Mon Sep 17 00:00:00 2001 From: matevip Date: Sun, 31 May 2026 07:53:48 +0800 Subject: [PATCH] feat(wiki): per-agent pageType read permission gate for wiki tools --- .../java/vip/mate/wiki/job/WikiKbConfig.java | 10 + .../vip/mate/wiki/job/WikiKbConfigParser.java | 2 + .../WikiAgentPageTypePermissionEntity.java | 62 ++++++ .../WikiAgentPageTypePermissionMapper.java | 14 ++ .../WikiPageTypePermissionService.java | 183 ++++++++++++++++++ .../java/vip/mate/wiki/tool/WikiTool.java | 69 ++++++- .../V133__wiki_agent_page_type_permission.sql | 25 +++ .../V133__wiki_agent_page_type_permission.sql | 23 +++ .../WikiPageTypePermissionServiceTest.java | 131 +++++++++++++ 9 files changed, 517 insertions(+), 2 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/wiki/model/WikiAgentPageTypePermissionEntity.java create mode 100644 mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiAgentPageTypePermissionMapper.java create mode 100644 mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageTypePermissionService.java create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V133__wiki_agent_page_type_permission.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V133__wiki_agent_page_type_permission.sql create mode 100644 mateclaw-server/src/test/java/vip/mate/wiki/service/WikiPageTypePermissionServiceTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiKbConfig.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiKbConfig.java index 29fece9a..e1e1a489 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiKbConfig.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiKbConfig.java @@ -47,4 +47,14 @@ public class WikiKbConfig { * {@link vip.mate.wiki.WikiProperties#isUseStructuredRoute()}. */ private Boolean useStructuredRoute; + + /** + * KB-level default read policy applied when an agent has no + * {@code mate_wiki_agent_page_type_permission} rows for this KB. + * {@code "allow_all"} (the default when {@code null}) keeps existing + * behaviour — every agent reads every pageType. {@code "deny_all"} flips + * the default closed so a professional KB can require each readable + * pageType to be granted explicitly per agent. + */ + private String defaultReadPolicy; } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiKbConfigParser.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiKbConfigParser.java index 62792a78..d9300ce5 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiKbConfigParser.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiKbConfigParser.java @@ -45,6 +45,8 @@ public final class WikiKbConfigParser { if ("ingestMode".equals(key)) { config.setIngestMode(value); + } else if ("defaultReadPolicy".equals(key)) { + config.setDefaultReadPolicy(value); } else if ("useStructuredRoute".equals(key)) { config.setUseStructuredRoute(Boolean.valueOf(value)); } else if ("wikiDefaultModelId".equals(key)) { diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiAgentPageTypePermissionEntity.java b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiAgentPageTypePermissionEntity.java new file mode 100644 index 00000000..0f2961e5 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiAgentPageTypePermissionEntity.java @@ -0,0 +1,62 @@ +package vip.mate.wiki.model; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * Per-agent, per-KB, per-pageType permission for wiki tool access. + *

+ * A {@code page_type='*'} row is the agent's KB-wide default; an exact + * {@code page_type} row is more specific and wins over {@code '*'}. When an + * agent has no rows for a KB at all, access falls back to the KB-level + * default read policy (see {@code WikiKbConfig#getDefaultReadPolicy()}). + * + * @author MateClaw Team + */ +@Data +@TableName("mate_wiki_agent_page_type_permission") +public class WikiAgentPageTypePermissionEntity { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + /** Agent the rule applies to. */ + private Long agentId; + + /** Knowledge base the rule applies to. */ + private Long kbId; + + /** Page type name, or {@code *} for the agent's KB-wide default. */ + private String pageType; + + /** Whether the agent may read pages of this type. */ + private Integer canRead; + + /** Whether the agent may create pages of this type. */ + private Integer canCreate; + + /** Whether the agent may update pages of this type. */ + private Integer canUpdate; + + /** Whether the agent may delete pages of this type. */ + private Integer canDelete; + + /** Write resolution: {@code deny} / {@code approval_required} / {@code allow}. */ + private String writePolicy; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + @TableLogic + private Integer deleted; +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiAgentPageTypePermissionMapper.java b/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiAgentPageTypePermissionMapper.java new file mode 100644 index 00000000..2e6e21bc --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiAgentPageTypePermissionMapper.java @@ -0,0 +1,14 @@ +package vip.mate.wiki.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import vip.mate.wiki.model.WikiAgentPageTypePermissionEntity; + +/** + * Mapper for {@link WikiAgentPageTypePermissionEntity}. + * + * @author MateClaw Team + */ +@Mapper +public interface WikiAgentPageTypePermissionMapper extends BaseMapper { +} 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 new file mode 100644 index 00000000..3c3edc3b --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageTypePermissionService.java @@ -0,0 +1,183 @@ +package vip.mate.wiki.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import vip.mate.wiki.job.WikiKbConfig; +import vip.mate.wiki.job.WikiKbConfigParser; +import vip.mate.wiki.model.WikiAgentPageTypePermissionEntity; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; +import vip.mate.wiki.repository.WikiAgentPageTypePermissionMapper; + +import java.util.List; +import java.util.Locale; + +/** + * Resolves whether an agent may read or write wiki pages of a given pageType + * within a knowledge base. + * + *

Matching precedence: an exact {@code page_type} row wins over the + * agent's {@code page_type='*'} default row. The unique key + * {@code (agent_id, kb_id, page_type, deleted)} guarantees at most one of each, + * so resolution is unambiguous without a same-level tie-break. + * + *

Read default: when no row matches the pageType, read access falls + * back to the KB-level {@code defaultReadPolicy} ({@code allow_all} unless the + * KB config sets {@code deny_all}). This keeps existing KBs fully readable + * after upgrade. + * + *

Write default: writes are gated opt-in. When an agent has no rows + * at all for a KB, writes are {@link WriteDecision#ALLOW}ed (preserving current + * behaviour). Once any row exists for that agent+KB, the KB is considered + * locked down: a pageType with no matching row resolves to + * {@link WriteDecision#DENY} (fail-safe). + * + * @author MateClaw Team + */ +@Slf4j +@Service +public class WikiPageTypePermissionService { + + /** Wildcard page_type for the agent's KB-wide default row. */ + public static final String WILDCARD = "*"; + + private final WikiAgentPageTypePermissionMapper permissionMapper; + private final WikiKnowledgeBaseService kbService; + private final ObjectMapper objectMapper; + + public WikiPageTypePermissionService(WikiAgentPageTypePermissionMapper permissionMapper, + WikiKnowledgeBaseService kbService, + ObjectMapper objectMapper) { + this.permissionMapper = permissionMapper; + this.kbService = kbService; + this.objectMapper = objectMapper; + } + + /** Write operations gated by {@link #resolveWrite}. */ + public enum WriteOp { CREATE, UPDATE, DELETE } + + /** Resolution of a write request. */ + public enum WriteDecision { ALLOW, APPROVAL_REQUIRED, DENY } + + /** + * Load the agent's permission view for a KB once, so a list of pages can be + * filtered without re-querying per row. {@code null} agentId yields an + * allow-all view (e.g. internal callers without an agent context). + */ + public Access resolve(Long agentId, Long kbId) { + if (agentId == null || kbId == null) { + return new Access(List.of(), false, false); + } + List rows = permissionMapper.selectList( + new LambdaQueryWrapper() + .eq(WikiAgentPageTypePermissionEntity::getAgentId, agentId) + .eq(WikiAgentPageTypePermissionEntity::getKbId, kbId)); + boolean denyByDefault = isDenyAll(kbId); + return new Access(rows, denyByDefault, !rows.isEmpty()); + } + + /** Convenience single-shot read check. */ + public boolean canRead(Long agentId, Long kbId, String pageType) { + return resolve(agentId, kbId).canRead(pageType); + } + + /** Convenience single-shot write resolution. */ + public WriteDecision resolveWrite(Long agentId, Long kbId, String pageType, WriteOp op) { + return resolve(agentId, kbId).resolveWrite(pageType, op); + } + + private boolean isDenyAll(Long kbId) { + WikiKnowledgeBaseEntity kb = kbService.getById(kbId); + if (kb == null || kb.getConfigContent() == null) { + return false; + } + WikiKbConfig config = WikiKbConfigParser.parse(objectMapper, kb.getConfigContent()); + if (config == null || config.getDefaultReadPolicy() == null) { + return false; + } + return "deny_all".equalsIgnoreCase(config.getDefaultReadPolicy().trim()); + } + + /** + * A resolved per-(agent, KB) permission view. Holds the agent's rows so + * repeated pageType checks (e.g. filtering a page list) hit memory, not DB. + */ + public static final class Access { + + private final List rows; + private final boolean denyReadByDefault; + private final boolean hasAnyRow; + + Access(List rows, boolean denyReadByDefault, boolean hasAnyRow) { + this.rows = rows; + this.denyReadByDefault = denyReadByDefault; + this.hasAnyRow = hasAnyRow; + } + + /** + * Whether the agent may read pages of {@code pageType}. Exact row wins + * over {@code '*'}; absent a matching row, the KB default read policy + * decides. + */ + public boolean canRead(String pageType) { + WikiAgentPageTypePermissionEntity match = match(pageType); + if (match != null) { + return flag(match.getCanRead()); + } + return !denyReadByDefault; + } + + /** + * Resolve a write request. See class javadoc for the opt-in / + * fail-safe defaults. + */ + public WriteDecision resolveWrite(String pageType, WriteOp op) { + WikiAgentPageTypePermissionEntity match = match(pageType); + if (match == null) { + // No rows at all → not gated yet → preserve current behaviour. + // Some rows but none cover this type → KB is locked down. + return hasAnyRow ? WriteDecision.DENY : WriteDecision.ALLOW; + } + boolean opAllowed = switch (op) { + case CREATE -> flag(match.getCanCreate()); + case UPDATE -> flag(match.getCanUpdate()); + case DELETE -> flag(match.getCanDelete()); + }; + if (!opAllowed) { + return WriteDecision.DENY; + } + return mapPolicy(match.getWritePolicy()); + } + + /** Exact page_type row wins; otherwise the wildcard row; else null. */ + private WikiAgentPageTypePermissionEntity match(String pageType) { + String needle = pageType == null ? "" : pageType.trim().toLowerCase(Locale.ROOT); + WikiAgentPageTypePermissionEntity wildcard = null; + for (WikiAgentPageTypePermissionEntity row : rows) { + String type = row.getPageType() == null ? "" : row.getPageType().trim(); + if (WILDCARD.equals(type)) { + wildcard = row; + } else if (type.toLowerCase(Locale.ROOT).equals(needle)) { + return row; + } + } + return wildcard; + } + + private static WriteDecision mapPolicy(String writePolicy) { + if (writePolicy == null) { + return WriteDecision.APPROVAL_REQUIRED; + } + return switch (writePolicy.trim().toLowerCase(Locale.ROOT)) { + case "allow" -> WriteDecision.ALLOW; + case "deny" -> WriteDecision.DENY; + default -> WriteDecision.APPROVAL_REQUIRED; + }; + } + + private static boolean flag(Integer value) { + return value != null && value != 0; + } + } +} 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 f9518cc6..fece36d4 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 @@ -78,6 +78,13 @@ public class WikiTool { @Autowired(required = false) private WikiTransformationAggregator transformationAggregator; + /** + * Per-agent pageType permission gate. When absent (e.g. in isolated unit + * tests), every page is readable — preserving pre-permission behaviour. + */ + @Autowired(required = false) + private WikiPageTypePermissionService pageTypePermissionService; + public WikiTool(WikiPageService pageService, WikiKnowledgeBaseService kbService, WikiRawMaterialService rawService, @@ -171,6 +178,10 @@ public class WikiTool { if (page == null) { return error("Page not found: " + slug); } + if (!canRead(pageTypeAccess(agentId, kbId), page)) { + // Page type not readable by this agent — do not leak its existence. + return error("Page not found: " + slug); + } pageService.trackReference(kbId, slug); @@ -209,16 +220,19 @@ public class WikiTool { if (kbRes.hasError()) return kbRes.errorJson(); kbId = kbRes.kbId(); + WikiPageTypePermissionService.Access access = pageTypeAccess(agentId, kbId); List pages; if (query != null && !query.isBlank()) { List ids = pageService.searchPages(kbId, query).stream() .filter(p -> !"system".equals(p.getPageType())) + .filter(p -> canRead(access, p)) .map(WikiPageEntity::getId).limit(30).toList(); if (ids.isEmpty()) { pages = List.of(); } else { pages = pageService.listSummaries(kbId).stream() .filter(p -> !"system".equals(p.getPageType())) + .filter(p -> canRead(access, p)) .filter(p -> ids.stream().anyMatch(id -> Objects.equals(id, p.getId()))) .map(p -> new WikiPageLite(p.getId(), p.getSlug(), p.getTitle(), p.getSummary(), p.getPageType())) .toList(); @@ -228,6 +242,7 @@ public class WikiTool { // Agents can still wiki_read_page("overview") explicitly. pages = pageService.listSummaries(kbId).stream() .filter(p -> !"system".equals(p.getPageType())) + .filter(p -> canRead(access, p)) .map(p -> new WikiPageLite(p.getId(), p.getSlug(), p.getTitle(), p.getSummary(), p.getPageType())) .toList(); } @@ -274,6 +289,19 @@ public class WikiTool { int k = (topK != null && topK > 0) ? Math.min(topK, 20) : 5; List results = hybridRetriever.search(kbId, query, mode, k); + // Drop hits whose page type this agent may not read, so search cannot + // surface pages a direct read would refuse. + WikiPageTypePermissionService.Access access = pageTypeAccess(agentId, kbId); + if (access != null) { + final Long resolvedKbId = kbId; + results = results.stream() + .filter(r -> { + WikiPageEntity p = pageService.getBySlug(resolvedKbId, r.slug()); + return canRead(access, p); + }) + .toList(); + } + for (PageSearchResult r : results) { pageService.trackReference(kbId, r.slug()); } @@ -389,6 +417,9 @@ public class WikiTool { if (page == null) { return error("Page not found: " + slug); } + if (!canRead(pageTypeAccess(agentId, kbId), page)) { + return error("Page not found: " + slug); + } return JSONUtil.createObj() .set("pageTitle", page.getTitle()) @@ -522,10 +553,13 @@ public class WikiTool { .map(String::trim).filter(s -> !s.isEmpty()).limit(10).toList(); if (slugList.isEmpty()) return error("No valid slugs supplied"); + WikiPageTypePermissionService.Access access = pageTypeAccess(agentId, kbId); JSONArray arr = new JSONArray(); for (String s : slugList) { WikiPageEntity page = pageService.getBySlug(kbId, s); - if (page == null) { + if (page == null || !canRead(access, page)) { + // Unreadable pageType is reported as not-found, same as a missing + // slug, so the agent cannot probe for hidden pages by slug. arr.add(JSONUtil.createObj().set("slug", s).set("found", false)); continue; } @@ -658,8 +692,12 @@ public class WikiTool { int k = (topK != null && topK > 0) ? Math.min(topK, 10) : 5; List results = relationService.relatedPages(kbId, slug, k); + WikiPageTypePermissionService.Access access = pageTypeAccess(agentId, kbId); JSONArray arr = new JSONArray(); for (RelatedPageResult r : results) { + if (access != null && !canRead(access, pageService.getBySlug(kbId, r.slug()))) { + continue; + } arr.add(JSONUtil.createObj() .set("slug", r.slug()) .set("title", r.title()) @@ -669,7 +707,7 @@ public class WikiTool { return JSONUtil.createObj() .set("slug", slug) - .set("relatedCount", results.size()) + .set("relatedCount", arr.size()) .set("pages", arr) .toString(); } @@ -689,6 +727,13 @@ public class WikiTool { kbId = kbRes.kbId(); if (relationService == null) return error("Relation service not available"); + WikiPageTypePermissionService.Access relAccess = pageTypeAccess(agentId, kbId); + if (relAccess != null + && (!canRead(relAccess, pageService.getBySlug(kbId, slugA)) + || !canRead(relAccess, pageService.getBySlug(kbId, slugB)))) { + return slugA + " and " + slugB + " have no detected relation."; + } + RelationExplanation ex = relationService.explain(kbId, slugA, slugB); if (ex.breakdown().isEmpty()) return slugA + " and " + slugB + " have no detected relation."; @@ -937,6 +982,26 @@ public class WikiTool { boolean hasError() { return errorJson != null; } } + /** + * Resolve the agent's pageType read/write permission view for a KB once, + * so a tool call can filter a whole result set without re-querying. Returns + * {@code null} when the permission service is absent (isolated unit tests), + * in which case all reads are allowed. + */ + private WikiPageTypePermissionService.Access pageTypeAccess(Long agentId, Long kbId) { + return pageTypePermissionService == null ? null : pageTypePermissionService.resolve(agentId, kbId); + } + + /** Whether the resolved access permits reading {@code page}. Null-safe. */ + private boolean canRead(WikiPageTypePermissionService.Access access, WikiPageEntity page) { + return access == null || page == null || access.canRead(page.getPageType()); + } + + /** Whether the resolved access permits reading a page of {@code pageType}. Null-safe. */ + private boolean canRead(WikiPageTypePermissionService.Access access, String pageType) { + return access == null || access.canRead(pageType); + } + /** * 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/main/resources/db/migration/h2/V133__wiki_agent_page_type_permission.sql b/mateclaw-server/src/main/resources/db/migration/h2/V133__wiki_agent_page_type_permission.sql new file mode 100644 index 00000000..a903e727 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V133__wiki_agent_page_type_permission.sql @@ -0,0 +1,25 @@ +-- V133: Per-agent, per-KB, per-pageType permission for wiki tools. +-- Read permission filters retrieval/listing; write permission gates the +-- create/compile/delete/archive/enrich/transformation tools. A row with +-- page_type='*' is the agent's KB-wide default; an exact page_type row is +-- more specific and wins over '*'. Unconfigured (no rows) falls back to the +-- KB-level defaultReadPolicy stored in the KB config. + +CREATE TABLE IF NOT EXISTS mate_wiki_agent_page_type_permission ( + id BIGINT NOT NULL PRIMARY KEY, + agent_id BIGINT NOT NULL, + kb_id BIGINT NOT NULL, + page_type VARCHAR(64) NOT NULL, + can_read TINYINT NOT NULL DEFAULT 1, + can_create TINYINT NOT NULL DEFAULT 0, + can_update TINYINT NOT NULL DEFAULT 0, + can_delete TINYINT NOT NULL DEFAULT 0, + write_policy VARCHAR(32) NOT NULL DEFAULT 'approval_required', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_wiki_agent_ptperm + ON mate_wiki_agent_page_type_permission (agent_id, kb_id, page_type, deleted); +CREATE INDEX IF NOT EXISTS idx_wiki_ptperm_agent_kb + ON mate_wiki_agent_page_type_permission (agent_id, kb_id, deleted); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V133__wiki_agent_page_type_permission.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V133__wiki_agent_page_type_permission.sql new file mode 100644 index 00000000..ebff5caf --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V133__wiki_agent_page_type_permission.sql @@ -0,0 +1,23 @@ +-- V133: Per-agent, per-KB, per-pageType permission for wiki tools. +-- Read permission filters retrieval/listing; write permission gates the +-- create/compile/delete/archive/enrich/transformation tools. A row with +-- page_type='*' is the agent's KB-wide default; an exact page_type row is +-- more specific and wins over '*'. Unconfigured (no rows) falls back to the +-- KB-level defaultReadPolicy stored in the KB config. + +CREATE TABLE IF NOT EXISTS mate_wiki_agent_page_type_permission ( + id BIGINT NOT NULL PRIMARY KEY, + agent_id BIGINT NOT NULL, + kb_id BIGINT NOT NULL, + page_type VARCHAR(64) NOT NULL, + can_read TINYINT NOT NULL DEFAULT 1, + can_create TINYINT NOT NULL DEFAULT 0, + can_update TINYINT NOT NULL DEFAULT 0, + can_delete TINYINT NOT NULL DEFAULT 0, + write_policy VARCHAR(32) NOT NULL DEFAULT 'approval_required', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT NOT NULL DEFAULT 0, + UNIQUE KEY uk_wiki_agent_ptperm (agent_id, kb_id, page_type, deleted), + KEY idx_wiki_ptperm_agent_kb (agent_id, kb_id, deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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 new file mode 100644 index 00000000..a39d1373 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiPageTypePermissionServiceTest.java @@ -0,0 +1,131 @@ +package vip.mate.wiki.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import vip.mate.wiki.model.WikiAgentPageTypePermissionEntity; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; +import vip.mate.wiki.repository.WikiAgentPageTypePermissionMapper; + +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.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link WikiPageTypePermissionService} precedence and default + * policy resolution. Mapper and KB service are mocked so no Spring context or + * DB is needed. + */ +class WikiPageTypePermissionServiceTest { + + private static final long AGENT = 1L; + private static final long KB = 7L; + + private WikiPageTypePermissionService service(List rows, String configJson) { + WikiAgentPageTypePermissionMapper mapper = mock(WikiAgentPageTypePermissionMapper.class); + when(mapper.selectList(any())).thenReturn(rows); + WikiKnowledgeBaseService kbService = mock(WikiKnowledgeBaseService.class); + WikiKnowledgeBaseEntity kb = new WikiKnowledgeBaseEntity(); + kb.setId(KB); + kb.setConfigContent(configJson); + when(kbService.getById(KB)).thenReturn(kb); + return new WikiPageTypePermissionService(mapper, kbService, new ObjectMapper()); + } + + 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; + } + + @Test + void noRowsNoConfig_readsAllowed_writesAllowedOptIn() { + WikiPageTypePermissionService s = service(List.of(), null); + assertTrue(s.canRead(AGENT, KB, "concept")); + assertEquals(WikiPageTypePermissionService.WriteDecision.ALLOW, + s.resolveWrite(AGENT, KB, "concept", WikiPageTypePermissionService.WriteOp.CREATE)); + } + + @Test + void noRows_denyAllConfig_readsDenied() { + WikiPageTypePermissionService s = service(List.of(), "{\"defaultReadPolicy\":\"deny_all\"}"); + assertFalse(s.canRead(AGENT, KB, "concept")); + } + + @Test + void exactRowWinsOverWildcard_forRead() { + // wildcard allows read, but the exact 'analysis' row forbids it + List rows = List.of( + row("*", 1, 0, 0, 0, "deny"), + row("analysis", 0, 0, 0, 0, "deny")); + WikiPageTypePermissionService s = service(rows, null); + assertFalse(s.canRead(AGENT, KB, "analysis")); // exact row forbids + assertTrue(s.canRead(AGENT, KB, "concept")); // falls to wildcard allow + } + + @Test + void wildcardAppliesWhenNoExactMatch() { + WikiPageTypePermissionService s = service(List.of(row("*", 0, 0, 0, 0, "deny")), null); + assertFalse(s.canRead(AGENT, KB, "anything")); + } + + @Test + void readIsCaseInsensitiveOnPageType() { + WikiPageTypePermissionService s = service(List.of(row("Episode", 0, 0, 0, 0, "deny")), null); + assertFalse(s.canRead(AGENT, KB, "episode")); + } + + @Test + void writeResolution_perOperationFlagAndPolicy() { + // create allowed but gated by approval; delete flag off → denied + WikiPageTypePermissionService s = service( + List.of(row("episode", 1, 1, 0, 0, "approval_required")), null); + assertEquals(WikiPageTypePermissionService.WriteDecision.APPROVAL_REQUIRED, + s.resolveWrite(AGENT, KB, "episode", WikiPageTypePermissionService.WriteOp.CREATE)); + assertEquals(WikiPageTypePermissionService.WriteDecision.DENY, + s.resolveWrite(AGENT, KB, "episode", WikiPageTypePermissionService.WriteOp.DELETE)); + } + + @Test + void writeAllowPolicyResolvesToAllow() { + WikiPageTypePermissionService s = service( + List.of(row("episode", 1, 1, 1, 1, "allow")), null); + assertEquals(WikiPageTypePermissionService.WriteDecision.ALLOW, + s.resolveWrite(AGENT, KB, "episode", WikiPageTypePermissionService.WriteOp.UPDATE)); + } + + @Test + void rowsExistButTypeUncovered_writeIsFailSafeDeny() { + // KB is gated (a row exists) but no row covers 'concept' and no wildcard + WikiPageTypePermissionService s = service( + List.of(row("episode", 1, 1, 1, 1, "allow")), null); + assertEquals(WikiPageTypePermissionService.WriteDecision.DENY, + s.resolveWrite(AGENT, KB, "concept", WikiPageTypePermissionService.WriteOp.CREATE)); + } + + @Test + void rowsExistButTypeUncovered_readFallsToDefaultPolicy() { + // a row exists for 'episode' only; 'concept' read falls to KB default (allow_all) + WikiPageTypePermissionService s = service( + List.of(row("episode", 0, 0, 0, 0, "deny")), null); + assertTrue(s.canRead(AGENT, KB, "concept")); + } + + @Test + void nullAgent_isAllowAll() { + WikiPageTypePermissionService s = service(List.of(), "{\"defaultReadPolicy\":\"deny_all\"}"); + assertTrue(s.canRead(null, KB, "concept")); + } +}