feat(wiki): per-agent pageType read permission gate for wiki tools

This commit is contained in:
matevip 2026-05-31 07:53:48 +08:00
parent f1c55b80e8
commit 502b8406a6
9 changed files with 517 additions and 2 deletions

View File

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

View File

@ -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)) {

View File

@ -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.
* <p>
* 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;
}

View File

@ -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<WikiAgentPageTypePermissionEntity> {
}

View File

@ -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.
*
* <p><b>Matching precedence</b>: 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.
*
* <p><b>Read default</b>: 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.
*
* <p><b>Write default</b>: 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<WikiAgentPageTypePermissionEntity> rows = permissionMapper.selectList(
new LambdaQueryWrapper<WikiAgentPageTypePermissionEntity>()
.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<WikiAgentPageTypePermissionEntity> rows;
private final boolean denyReadByDefault;
private final boolean hasAnyRow;
Access(List<WikiAgentPageTypePermissionEntity> 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;
}
}
}

View File

@ -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<WikiPageLite> pages;
if (query != null && !query.isBlank()) {
List<Long> 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<PageSearchResult> 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<RelatedPageResult> 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

View File

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

View File

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

View File

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