mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-16 20:34:39 +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()}.
|
* {@link vip.mate.wiki.WikiProperties#isUseStructuredRoute()}.
|
||||||
*/
|
*/
|
||||||
private Boolean useStructuredRoute;
|
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)) {
|
if ("ingestMode".equals(key)) {
|
||||||
config.setIngestMode(value);
|
config.setIngestMode(value);
|
||||||
|
} else if ("defaultReadPolicy".equals(key)) {
|
||||||
|
config.setDefaultReadPolicy(value);
|
||||||
} else if ("useStructuredRoute".equals(key)) {
|
} else if ("useStructuredRoute".equals(key)) {
|
||||||
config.setUseStructuredRoute(Boolean.valueOf(value));
|
config.setUseStructuredRoute(Boolean.valueOf(value));
|
||||||
} else if ("wikiDefaultModelId".equals(key)) {
|
} 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)
|
@Autowired(required = false)
|
||||||
private WikiTransformationAggregator transformationAggregator;
|
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,
|
public WikiTool(WikiPageService pageService,
|
||||||
WikiKnowledgeBaseService kbService,
|
WikiKnowledgeBaseService kbService,
|
||||||
WikiRawMaterialService rawService,
|
WikiRawMaterialService rawService,
|
||||||
@ -171,6 +178,10 @@ public class WikiTool {
|
|||||||
if (page == null) {
|
if (page == null) {
|
||||||
return error("Page not found: " + slug);
|
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);
|
pageService.trackReference(kbId, slug);
|
||||||
|
|
||||||
@ -209,16 +220,19 @@ public class WikiTool {
|
|||||||
if (kbRes.hasError()) return kbRes.errorJson();
|
if (kbRes.hasError()) return kbRes.errorJson();
|
||||||
kbId = kbRes.kbId();
|
kbId = kbRes.kbId();
|
||||||
|
|
||||||
|
WikiPageTypePermissionService.Access access = pageTypeAccess(agentId, kbId);
|
||||||
List<WikiPageLite> pages;
|
List<WikiPageLite> pages;
|
||||||
if (query != null && !query.isBlank()) {
|
if (query != null && !query.isBlank()) {
|
||||||
List<Long> ids = pageService.searchPages(kbId, query).stream()
|
List<Long> ids = pageService.searchPages(kbId, query).stream()
|
||||||
.filter(p -> !"system".equals(p.getPageType()))
|
.filter(p -> !"system".equals(p.getPageType()))
|
||||||
|
.filter(p -> canRead(access, p))
|
||||||
.map(WikiPageEntity::getId).limit(30).toList();
|
.map(WikiPageEntity::getId).limit(30).toList();
|
||||||
if (ids.isEmpty()) {
|
if (ids.isEmpty()) {
|
||||||
pages = List.of();
|
pages = List.of();
|
||||||
} else {
|
} else {
|
||||||
pages = pageService.listSummaries(kbId).stream()
|
pages = pageService.listSummaries(kbId).stream()
|
||||||
.filter(p -> !"system".equals(p.getPageType()))
|
.filter(p -> !"system".equals(p.getPageType()))
|
||||||
|
.filter(p -> canRead(access, p))
|
||||||
.filter(p -> ids.stream().anyMatch(id -> Objects.equals(id, p.getId())))
|
.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()))
|
.map(p -> new WikiPageLite(p.getId(), p.getSlug(), p.getTitle(), p.getSummary(), p.getPageType()))
|
||||||
.toList();
|
.toList();
|
||||||
@ -228,6 +242,7 @@ public class WikiTool {
|
|||||||
// Agents can still wiki_read_page("overview") explicitly.
|
// Agents can still wiki_read_page("overview") explicitly.
|
||||||
pages = pageService.listSummaries(kbId).stream()
|
pages = pageService.listSummaries(kbId).stream()
|
||||||
.filter(p -> !"system".equals(p.getPageType()))
|
.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()))
|
.map(p -> new WikiPageLite(p.getId(), p.getSlug(), p.getTitle(), p.getSummary(), p.getPageType()))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
@ -274,6 +289,19 @@ public class WikiTool {
|
|||||||
int k = (topK != null && topK > 0) ? Math.min(topK, 20) : 5;
|
int k = (topK != null && topK > 0) ? Math.min(topK, 20) : 5;
|
||||||
List<PageSearchResult> results = hybridRetriever.search(kbId, query, mode, k);
|
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) {
|
for (PageSearchResult r : results) {
|
||||||
pageService.trackReference(kbId, r.slug());
|
pageService.trackReference(kbId, r.slug());
|
||||||
}
|
}
|
||||||
@ -389,6 +417,9 @@ public class WikiTool {
|
|||||||
if (page == null) {
|
if (page == null) {
|
||||||
return error("Page not found: " + slug);
|
return error("Page not found: " + slug);
|
||||||
}
|
}
|
||||||
|
if (!canRead(pageTypeAccess(agentId, kbId), page)) {
|
||||||
|
return error("Page not found: " + slug);
|
||||||
|
}
|
||||||
|
|
||||||
return JSONUtil.createObj()
|
return JSONUtil.createObj()
|
||||||
.set("pageTitle", page.getTitle())
|
.set("pageTitle", page.getTitle())
|
||||||
@ -522,10 +553,13 @@ public class WikiTool {
|
|||||||
.map(String::trim).filter(s -> !s.isEmpty()).limit(10).toList();
|
.map(String::trim).filter(s -> !s.isEmpty()).limit(10).toList();
|
||||||
if (slugList.isEmpty()) return error("No valid slugs supplied");
|
if (slugList.isEmpty()) return error("No valid slugs supplied");
|
||||||
|
|
||||||
|
WikiPageTypePermissionService.Access access = pageTypeAccess(agentId, kbId);
|
||||||
JSONArray arr = new JSONArray();
|
JSONArray arr = new JSONArray();
|
||||||
for (String s : slugList) {
|
for (String s : slugList) {
|
||||||
WikiPageEntity page = pageService.getBySlug(kbId, s);
|
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));
|
arr.add(JSONUtil.createObj().set("slug", s).set("found", false));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -658,8 +692,12 @@ public class WikiTool {
|
|||||||
int k = (topK != null && topK > 0) ? Math.min(topK, 10) : 5;
|
int k = (topK != null && topK > 0) ? Math.min(topK, 10) : 5;
|
||||||
List<RelatedPageResult> results = relationService.relatedPages(kbId, slug, k);
|
List<RelatedPageResult> results = relationService.relatedPages(kbId, slug, k);
|
||||||
|
|
||||||
|
WikiPageTypePermissionService.Access access = pageTypeAccess(agentId, kbId);
|
||||||
JSONArray arr = new JSONArray();
|
JSONArray arr = new JSONArray();
|
||||||
for (RelatedPageResult r : results) {
|
for (RelatedPageResult r : results) {
|
||||||
|
if (access != null && !canRead(access, pageService.getBySlug(kbId, r.slug()))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
arr.add(JSONUtil.createObj()
|
arr.add(JSONUtil.createObj()
|
||||||
.set("slug", r.slug())
|
.set("slug", r.slug())
|
||||||
.set("title", r.title())
|
.set("title", r.title())
|
||||||
@ -669,7 +707,7 @@ public class WikiTool {
|
|||||||
|
|
||||||
return JSONUtil.createObj()
|
return JSONUtil.createObj()
|
||||||
.set("slug", slug)
|
.set("slug", slug)
|
||||||
.set("relatedCount", results.size())
|
.set("relatedCount", arr.size())
|
||||||
.set("pages", arr)
|
.set("pages", arr)
|
||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
@ -689,6 +727,13 @@ public class WikiTool {
|
|||||||
kbId = kbRes.kbId();
|
kbId = kbRes.kbId();
|
||||||
if (relationService == null) return error("Relation service not available");
|
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);
|
RelationExplanation ex = relationService.explain(kbId, slugA, slugB);
|
||||||
if (ex.breakdown().isEmpty()) return slugA + " and " + slugB + " have no detected relation.";
|
if (ex.breakdown().isEmpty()) return slugA + " and " + slugB + " have no detected relation.";
|
||||||
|
|
||||||
@ -937,6 +982,26 @@ public class WikiTool {
|
|||||||
boolean hasError() { return errorJson != null; }
|
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
|
* Single helper every wiki tool uses. Caller passes the agent id and at
|
||||||
* most one of {@code kbId} / {@code kbName}; the helper decides which
|
* most one of {@code kbId} / {@code kbName}; the helper decides which
|
||||||
|
|||||||
@ -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