mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(wiki): per-agent pageType read permission gate for wiki tools
This commit is contained in:
parent
f1c55b80e8
commit
502b8406a6
@ -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;
|
||||
}
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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> {
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
@ -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;
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user