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"));
+ }
+}