feat(kb-open): P0-A open-API auth — API keys, rate limit, centralized authorization

Hashed API-key auth (SHA-256, plaintext shown once), per-key sliding-window rate limit, and a fail-closed filter + scope/KB-binding interceptor enforcing empty-binding=zero-access. Admin CRUD for key lifecycle. Migration V164 across h2/mysql/kingbase.
This commit is contained in:
倪程伟 2026-06-28 14:45:53 +08:00 committed by GitHub
parent 2f46619e5b
commit 2d04ce92ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 2126 additions and 0 deletions

View File

@ -14,6 +14,7 @@ import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import jakarta.servlet.http.HttpServletResponse;
import vip.mate.kbopen.auth.KbOpenApiAuthFilter;
/**
* Spring Security 配置
@ -28,6 +29,7 @@ import jakarta.servlet.http.HttpServletResponse;
public class SecurityConfig {
private final JwtAuthFilter jwtAuthFilter;
private final KbOpenApiAuthFilter kbOpenApiAuthFilter;
/**
* SpringDoc Swagger UI / OpenAPI document paths. These serve the full REST
@ -90,6 +92,9 @@ public class SecurityConfig {
"/api/v1/setup/**",
"/api/v1/channels/webhook/**",
"/api/v1/channels/webchat/**",
// KB Open API: authenticated by KbOpenApiAuthFilter (API key),
// not JWT must be permitAll so the filter is the sole gatekeeper (R1).
"/api/v1/open/kb/**",
"/api/v1/talk/ws",
// Desktop local-tool tunnel the handshake interceptor
// authenticates the ?token= query param itself, so the
@ -120,6 +125,7 @@ public class SecurityConfig {
response.getWriter().write("{\"code\":401,\"msg\":\"Token expired or invalid\",\"data\":null}");
})
)
.addFilterBefore(kbOpenApiAuthFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();

View File

@ -8,6 +8,7 @@ import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import vip.mate.kbopen.auth.KbScopeInterceptor;
/**
* Web MVC 配置跨域拦截器等
@ -20,6 +21,7 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
public class WebMvcConfig implements WebMvcConfigurer {
private final WorkspaceAccessInterceptor workspaceAccessInterceptor;
private final KbScopeInterceptor kbScopeInterceptor;
/** CORS allowed origins, comma-separated. Default "*" for dev, restrict in production. */
@Value("${mateclaw.cors.allowed-origins:*}")
@ -29,6 +31,10 @@ public class WebMvcConfig implements WebMvcConfigurer {
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(workspaceAccessInterceptor)
.addPathPatterns("/api/**");
// KB Open API scope+ownership checks (A1). Runs after KbOpenApiAuthFilter
// injected the KbApiKeyContext into the request attributes.
registry.addInterceptor(kbScopeInterceptor)
.addPathPatterns("/api/v1/open/kb/**");
}
@Override

View File

@ -0,0 +1,39 @@
package vip.mate.kbopen.auth;
import java.util.Set;
/**
* Authentication context injected by {@code KbOpenApiAuthFilter} into each
* request's attributes. Carries the resolved API key identity (keyId,
* workspace, bound KBs, scopes) so that Controller-layer authorization
* (via {@code @RequireKbScope}) can check access without re-querying the DB.
*
* <p>This is intentionally <strong>not</strong> a Spring Security
* {@code Authentication} KB API Keys do not represent a user identity and
* must not inherit user-level permissions. The filter writes to a request
* attribute instead of the {@code SecurityContextHolder}.
*/
public record KbApiKeyContext(
Long keyId,
Long workspaceId,
Set<Long> kbIds,
Set<String> scopes,
int rateLimitPerMin
) {
/** Request attribute key under which the context is stored. */
public static final String ATTR = "kbOpenApiKeyContext";
/** Check whether the key grants the given scope (or the wildcard). */
public boolean hasScope(String scope) {
return scopes.contains("kb:*") || scopes.contains(scope);
}
/**
* Check whether the key is authorized to access the given KB. An empty
* {@code kbIds} set means zero access (R3).
*/
public boolean canAccessKb(Long kbId) {
return kbId != null && kbIds.contains(kbId);
}
}

View File

@ -0,0 +1,46 @@
package vip.mate.kbopen.auth;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Per-key sliding-window rate limiter for KB Open API (R2).
*
* <p>Follows the same pattern as {@code TriggerRateLimiter}: each key keeps a
* 60-second window of request timestamps; a request is admitted iff fewer
* than {@code rateLimitPerMin} entries already live in the window. Local to
* this node for multi-node deployments the cap is per-node, not global.
* v0 accepts this trade because the alternative (DB-backed counters) costs a
* round-trip on every request.
*/
@Component
public class KbApiKeyRateLimiter {
private final Map<Long, Deque<Instant>> windows = new ConcurrentHashMap<>();
private final Duration windowSize = Duration.ofMinutes(1);
/**
* Try to admit a request for {@code keyId} at {@code now}. Returns
* {@code true} when the request fits under {@code limitPerMin};
* {@code false} when the window is full (caller should return 429).
*/
public boolean tryAcquire(long keyId, int limitPerMin, Instant now) {
if (limitPerMin <= 0) return true;
Deque<Instant> window = windows.computeIfAbsent(keyId, k -> new ArrayDeque<>());
Instant cutoff = now.minus(windowSize);
synchronized (window) {
while (!window.isEmpty() && !window.peekFirst().isAfter(cutoff)) {
window.pollFirst();
}
if (window.size() >= limitPerMin) return false;
window.addLast(now);
return true;
}
}
}

View File

@ -0,0 +1,240 @@
package vip.mate.kbopen.auth;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import vip.mate.exception.MateClawException;
import vip.mate.kbopen.auth.model.KbApiKeyBindingEntity;
import vip.mate.kbopen.auth.model.KbApiKeyEntity;
import vip.mate.kbopen.auth.repository.KbApiKeyBindingMapper;
import vip.mate.kbopen.auth.repository.KbApiKeyMapper;
import java.time.LocalDateTime;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Lifecycle management for KB Open API Keys: mint, authenticate, revoke,
* and manage KB bindings.
*
* <p>Plaintext is shown exactly once at creation time the DB stores only
* the SHA-256 hash (via {@link TokenHashUtil}, the shared kernel).
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class KbApiKeyService {
public static final String KEY_PREFIX = "mck_";
private static final int KEY_ENTROPY_BYTES = 32;
private static final int PREFIX_DISPLAY_LEN = 4;
private static final long LAST_USED_DEBOUNCE_SECONDS = 60;
private static final int DEFAULT_RATE_LIMIT = 60;
private final KbApiKeyMapper keyMapper;
private final KbApiKeyBindingMapper bindingMapper;
private final TokenHashUtil tokenHashUtil;
// Mint
/**
* Mint a new API Key bound to the given KBs.
*
* @param workspaceId owning workspace
* @param createdBy user id of the creator
* @param name human-readable label
* @param scopes comma-separated scopes, null/blank = "kb:*"
* @param kbIds KBs this key can access must be non-empty (R3)
* @param expiresAt optional expiry, null = never
* @return the created entity + one-shot plaintext
*/
@Transactional
public CreatedKey create(Long workspaceId, Long createdBy, String name,
String scopes, Set<Long> kbIds, LocalDateTime expiresAt) {
if (kbIds == null || kbIds.isEmpty()) {
// R3: empty binding = zero access, which is useless for an external key.
throw new MateClawException(400, "At least one knowledge base must be bound to the API key");
}
String plaintext = tokenHashUtil.generate(KEY_PREFIX, KEY_ENTROPY_BYTES);
String hash = tokenHashUtil.hash(plaintext);
String displayPrefix = plaintext.substring(0, Math.min(PREFIX_DISPLAY_LEN + KEY_PREFIX.length(), plaintext.length()));
KbApiKeyEntity entity = new KbApiKeyEntity();
entity.setName(name);
entity.setTokenHash(hash);
entity.setPrefix(displayPrefix);
entity.setWorkspaceId(workspaceId);
entity.setCreatedBy(createdBy);
entity.setScopes(scopes != null && !scopes.isBlank() ? scopes : "kb:*");
entity.setEnabled(true);
entity.setExpiresAt(expiresAt);
entity.setRateLimitPerMin(DEFAULT_RATE_LIMIT);
entity.setDeleted(0);
keyMapper.insert(entity);
for (Long kbId : kbIds) {
KbApiKeyBindingEntity binding = new KbApiKeyBindingEntity();
binding.setApiKeyId(entity.getId());
binding.setKbId(kbId);
bindingMapper.insert(binding);
}
log.info("[KbOpenApi] Created key id={} workspaceId={} name={} boundKbs={}",
entity.getId(), workspaceId, name, kbIds.size());
return new CreatedKey(entity.getId(), plaintext, entity);
}
// Authenticate
/**
* Auth-filter hot path: find an enabled, unexpired key whose hash matches
* the SHA-256 of {@code plaintext}, and load its bound KBs + scopes.
*
* @return empty for null/blank, wrong prefix, hash miss, disabled, or expired
*/
public Optional<AuthResult> authenticate(String plaintext) {
if (plaintext == null || plaintext.isBlank() || !plaintext.startsWith(KEY_PREFIX)) {
return Optional.empty();
}
String hash = tokenHashUtil.hash(plaintext);
KbApiKeyEntity entity = keyMapper.selectOne(
new LambdaQueryWrapper<KbApiKeyEntity>()
.eq(KbApiKeyEntity::getTokenHash, hash)
.eq(KbApiKeyEntity::getEnabled, true)
.eq(KbApiKeyEntity::getDeleted, 0)
.last("LIMIT 1"));
if (entity == null) {
return Optional.empty();
}
if (entity.getExpiresAt() != null && entity.getExpiresAt().isBefore(LocalDateTime.now())) {
return Optional.empty();
}
Set<Long> kbIds = loadBoundKbIds(entity.getId());
Set<String> scopes = parseScopes(entity.getScopes());
int rateLimit = entity.getRateLimitPerMin() != null ? entity.getRateLimitPerMin() : DEFAULT_RATE_LIMIT;
KbApiKeyContext context = new KbApiKeyContext(
entity.getId(), entity.getWorkspaceId(), kbIds, scopes, rateLimit);
return Optional.of(new AuthResult(context, entity.getLastUsedAt()));
}
/**
* Resolve a context from a known entity id (used by admin endpoints).
*/
public KbApiKeyEntity getById(Long id) {
return keyMapper.selectById(id);
}
// List / Revoke / Update
public List<KbApiKeyEntity> listByWorkspace(Long workspaceId) {
return keyMapper.selectList(
new LambdaQueryWrapper<KbApiKeyEntity>()
.eq(KbApiKeyEntity::getWorkspaceId, workspaceId)
.eq(KbApiKeyEntity::getDeleted, 0)
.orderByDesc(KbApiKeyEntity::getCreateTime));
}
public Set<Long> loadBoundKbIds(Long apiKeyId) {
List<KbApiKeyBindingEntity> bindings = bindingMapper.selectList(
new LambdaQueryWrapper<KbApiKeyBindingEntity>()
.eq(KbApiKeyBindingEntity::getApiKeyId, apiKeyId));
return bindings.stream()
.map(KbApiKeyBindingEntity::getKbId)
.collect(Collectors.toCollection(LinkedHashSet::new));
}
@Transactional
public void updateBindings(Long apiKeyId, Set<Long> newKbIds) {
if (newKbIds == null || newKbIds.isEmpty()) {
// R3: cannot reduce to zero access.
throw new MateClawException(400, "At least one knowledge base must be bound to the API key");
}
bindingMapper.delete(new LambdaQueryWrapper<KbApiKeyBindingEntity>()
.eq(KbApiKeyBindingEntity::getApiKeyId, apiKeyId));
for (Long kbId : newKbIds) {
KbApiKeyBindingEntity binding = new KbApiKeyBindingEntity();
binding.setApiKeyId(apiKeyId);
binding.setKbId(kbId);
bindingMapper.insert(binding);
}
}
@Transactional
public void update(Long apiKeyId, Long workspaceId, String name, String scopes,
Set<Long> kbIds, LocalDateTime expiresAt) {
KbApiKeyEntity existing = keyMapper.selectById(apiKeyId);
if (existing == null || (existing.getDeleted() != null && existing.getDeleted() == 1)
|| !workspaceId.equals(existing.getWorkspaceId())) {
throw new MateClawException(404, "API key not found: " + apiKeyId);
}
if (name != null) existing.setName(name);
if (scopes != null) existing.setScopes(scopes);
if (expiresAt != null) existing.setExpiresAt(expiresAt);
keyMapper.updateById(existing);
if (kbIds != null) {
updateBindings(apiKeyId, kbIds);
}
}
@Transactional
public void revoke(Long apiKeyId, Long workspaceId) {
KbApiKeyEntity existing = keyMapper.selectById(apiKeyId);
if (existing == null || (existing.getDeleted() != null && existing.getDeleted() == 1)
|| !workspaceId.equals(existing.getWorkspaceId())) {
throw new MateClawException(404, "API key not found: " + apiKeyId);
}
existing.setEnabled(false);
existing.setDeleted(1);
keyMapper.updateById(existing);
log.info("[KbOpenApi] Revoked key id={} workspaceId={}", apiKeyId, workspaceId);
}
// Usage tracking
/**
* Record last-used timestamp, debounced to once per minute per key
* (same pattern as PAT). {@code previousLastUsedAt} is the entity's
* current lastUsedAt value, used to decide whether enough time has passed.
*/
public void recordUse(Long apiKeyId, LocalDateTime previousLastUsedAt) {
if (apiKeyId == null) return;
if (!shouldRecordUse(previousLastUsedAt)) return;
try {
KbApiKeyEntity update = new KbApiKeyEntity();
update.setId(apiKeyId);
update.setLastUsedAt(LocalDateTime.now());
keyMapper.updateById(update);
} catch (Exception e) {
log.debug("[KbOpenApi] last_used_at write failed for key {}: {}", apiKeyId, e.getMessage());
}
}
static boolean shouldRecordUse(LocalDateTime previousLastUsedAt) {
if (previousLastUsedAt == null) return true;
return !previousLastUsedAt.plusSeconds(LAST_USED_DEBOUNCE_SECONDS).isAfter(LocalDateTime.now());
}
// Helpers
private Set<String> parseScopes(String scopes) {
if (scopes == null || scopes.isBlank()) {
return Set.of("kb:*");
}
return Set.of(scopes.split(",")).stream()
.map(String::trim)
.collect(java.util.stream.Collectors.toUnmodifiableSet());
}
/** Return value of {@link #create}. */
public record CreatedKey(Long id, String plaintext, KbApiKeyEntity entity) {}
/** Return value of {@link #authenticate}: context + lastUsedAt for debounce. */
public record AuthResult(KbApiKeyContext context, LocalDateTime lastUsedAt) {}
}

View File

@ -0,0 +1,107 @@
package vip.mate.kbopen.auth;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import vip.mate.kbopen.auth.KbApiKeyService.AuthResult;
import java.io.IOException;
import java.time.Instant;
import java.util.Optional;
/**
* Authentication filter for {@code /api/v1/open/kb/**} the sole gatekeeper
* for the permitAll open API path.
*
* <p><strong>R1: this filter must reject, never pass-through.</strong> Because
* the path is in the SecurityConfig {@code permitAll} whitelist, there is no
* downstream Spring Security chain to catch an unauthenticated request. A
* missing, malformed, or invalid key <em>must</em> result in an immediate 401
* it cannot fall through to the Controller (which would run without a
* {@link KbApiKeyContext}).
*
* <p><strong>R2: per-key rate limiting.</strong> After successful auth, the
* filter checks the sliding-window limiter. Exceeding
* {@code rateLimitPerMin} returns 429.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class KbOpenApiAuthFilter extends OncePerRequestFilter {
private final KbApiKeyService keyService;
private final KbApiKeyRateLimiter rateLimiter;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// Only guard the open API path. Other paths fall through to normal security.
if (!isOpenApiPath(request)) {
filterChain.doFilter(request, response);
return;
}
String token = extractBearerToken(request);
if (!StringUtils.hasText(token)) {
sendUnauthorized(response, "Missing API key");
return;
}
Optional<AuthResult> authResult = keyService.authenticate(token);
if (authResult.isEmpty()) {
sendUnauthorized(response, "Invalid or expired API key");
return;
}
KbApiKeyContext context = authResult.get().context();
// R2: rate limit check
if (!rateLimiter.tryAcquire(context.keyId(), context.rateLimitPerMin(), Instant.now())) {
sendTooManyRequests(response, context.rateLimitPerMin());
return;
}
// Inject context for downstream @RequireKbScope authorization
request.setAttribute(KbApiKeyContext.ATTR, context);
// Debounced last-used recording
keyService.recordUse(context.keyId(), authResult.get().lastUsedAt());
filterChain.doFilter(request, response);
}
private boolean isOpenApiPath(HttpServletRequest request) {
String uri = request.getRequestURI();
return uri.startsWith("/api/v1/open/kb/");
}
private String extractBearerToken(HttpServletRequest request) {
String bearer = request.getHeader("Authorization");
if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) {
return bearer.substring(7).trim();
}
// TODO: add ?token= SSE fallback once Deep Research SSE endpoint is live.
// EventSource can't set custom headers; for now P0-A has no SSE path so
// query param would leak the key into access / proxy logs (R5).
return null;
}
private void sendUnauthorized(HttpServletResponse response, String message) throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"msg\":\"" + message + "\",\"data\":null}");
}
private void sendTooManyRequests(HttpServletResponse response, int limit) throws IOException {
response.setStatus(429);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":429,\"msg\":\"Rate limit exceeded (" + limit + "/min)\",\"data\":null}");
}
}

View File

@ -0,0 +1,97 @@
package vip.mate.kbopen.auth;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.HandlerMapping;
import java.util.Map;
/**
* Intercepts methods annotated with {@link RequireKbScope} and enforces:
* <ol>
* <li>Scope check the request's {@link KbApiKeyContext} must grant the
* required scope (or {@code kb:*}).</li>
* <li>KB ownership the {@code kbId} path variable must be in the
* context's bound KB set (R3: empty set = zero access).</li>
* </ol>
*
* <p>Modeled after {@code WorkspaceAccessInterceptor}. Both checks return 403
* on failure; the request is rejected before the Controller method runs.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class KbScopeInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) throws Exception {
if (!(handler instanceof HandlerMethod handlerMethod)) {
return true;
}
RequireKbScope annotation = handlerMethod.getMethodAnnotation(RequireKbScope.class);
if (annotation == null) {
return true;
}
KbApiKeyContext context = (KbApiKeyContext) request.getAttribute(KbApiKeyContext.ATTR);
if (context == null) {
// Filter should have already rejected this, but fail-closed just in case.
sendForbidden(response, "Authentication required");
return false;
}
// Layer 2: scope check
String requiredScope = annotation.value();
if (!context.hasScope(requiredScope)) {
log.warn("[KbOpenApi] Scope denied: keyId={} required={} has={}",
context.keyId(), requiredScope, context.scopes());
sendForbidden(response, "Insufficient scope: requires " + requiredScope);
return false;
}
// Layer 3: KB ownership extract kbId from path variable
Long kbId = extractKbId(request);
if (kbId == null) {
// No kbId in path let the controller handle it (e.g. taxonomy may not need one).
return true;
}
if (!context.canAccessKb(kbId)) {
log.warn("[KbOpenApi] KB access denied: keyId={} kbId={} boundKbs={}",
context.keyId(), kbId, context.kbIds());
sendForbidden(response, "Knowledge base not bound to this API key");
return false;
}
return true;
}
@SuppressWarnings("unchecked")
private Long extractKbId(HttpServletRequest request) {
Object attr = request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE);
if (!(attr instanceof Map)) {
return null;
}
Object rawKbId = ((Map<String, String>) attr).get("kbId");
if (rawKbId == null) {
return null;
}
try {
return Long.parseLong(rawKbId.toString());
} catch (NumberFormatException e) {
return null;
}
}
private void sendForbidden(HttpServletResponse response, String message) throws Exception {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":403,\"msg\":\"" + message + "\",\"data\":null}");
}
}

View File

@ -0,0 +1,29 @@
package vip.mate.kbopen.auth;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Declares the minimum scope required by a KB Open API endpoint.
*
* <p>Processed by {@code KbScopeInterceptor} which checks, in order:
* <ol>
* <li>A {@link KbApiKeyContext} exists on the request (filter ran).</li>
* <li>The context's scopes include the annotation value or {@code kb:*}.</li>
* <li>The path's {@code kbId} variable is in the context's bound KB set.</li>
* </ol>
*
* <p>This mirrors the existing {@code @RequireWorkspaceRole} +
* {@code WorkspaceAccessInterceptor} pattern, centralizing authorization so
* it is not hand-written per endpoint (A1 avoids repeating the #438/#439
* Wiki IDOR pattern on the outward-facing API).
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireKbScope {
/** Required scope, e.g. {@code "kb:search"}, {@code "kb:read"}. */
String value();
}

View File

@ -0,0 +1,58 @@
package vip.mate.kbopen.auth;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.HexFormat;
/**
* Shared token hash kernel (A4).
*
* <p>Generates cryptographically random token plaintext, computes its SHA-256
* hash for storage, and verifies a plaintext against a stored hash. This is
* the single source of truth for token hashing so that KB API Keys and (in a
* future refactor) PAT share one implementation instead of drifting apart.
*
* <p>Extracted from {@code PersonalAccessTokenService}'s private hash methods
* the logic is identical; making it a Spring bean lets both services inject
* it without duplicating the SHA-256 boilerplate.
*/
@Component
public class TokenHashUtil {
private final SecureRandom secureRandom = new SecureRandom();
/** Hash a plaintext token to its lowercase SHA-256 hex digest. */
public String hash(String plaintext) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(plaintext.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 unavailable on this JVM", e);
}
}
/**
* Generate a random plaintext token of the given byte-entropy, prefixed
* with {@code prefix} (e.g. {@code "mck_"}).
*/
public String generate(String prefix, int entropyBytes) {
byte[] bytes = new byte[entropyBytes];
secureRandom.nextBytes(bytes);
String body = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
return prefix + body;
}
/** Verify that a plaintext hashes to the expected stored digest. */
public boolean matches(String plaintext, String storedHash) {
if (plaintext == null || storedHash == null) {
return false;
}
return hash(plaintext).equals(storedHash);
}
}

View File

@ -0,0 +1,33 @@
package vip.mate.kbopen.auth.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.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Many-to-many binding between an API Key and a Knowledge Base.
*
* <p>An empty binding set means <strong>zero access</strong> (R3) unlike
* internal {@code AgentWikiKbBinding} where empty means "all KBs". This
* prevents an external key from silently gaining access to the entire
* workspace or auto-including newly created KBs.
*/
@Data
@TableName("mate_kb_api_key_binding")
public class KbApiKeyBindingEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long apiKeyId;
private Long kbId;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
}

View File

@ -0,0 +1,64 @@
package vip.mate.kbopen.auth.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 com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Knowledge Base Open API Key entity.
*
* <p>Plaintext keys are never persisted; only the SHA-256 hash lives in
* {@link #tokenHash}. The {@code prefix} column stores the first 8 chars of
* the plaintext purely for UI display ({@code "mck_ab12****"}) it does not
* compromise security because the hash is not reversible.
*/
@Data
@TableName("mate_kb_api_key")
public class KbApiKeyEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String name;
/** SHA-256 hex of the plaintext, lowercase. UNIQUE indexed for O(1) auth. */
@JsonIgnore
private String tokenHash;
/** First 8 chars of plaintext ({@code "mck_" + 4 random}), for UI display only. */
private String prefix;
/** Owning workspace — the isolation boundary. */
private Long workspaceId;
/** User who created the key. */
private Long createdBy;
/** Comma-separated scope tokens, e.g. "kb:search,kb:read". Null/empty = kb:*. */
private String scopes;
private Boolean enabled;
private LocalDateTime expiresAt;
private LocalDateTime lastUsedAt;
/** Per-key rate limit, enforced by the auth filter (R2). */
private Integer rateLimitPerMin;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
private Integer deleted;
}

View File

@ -0,0 +1,9 @@
package vip.mate.kbopen.auth.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import vip.mate.kbopen.auth.model.KbApiKeyBindingEntity;
@Mapper
public interface KbApiKeyBindingMapper extends BaseMapper<KbApiKeyBindingEntity> {
}

View File

@ -0,0 +1,9 @@
package vip.mate.kbopen.auth.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import vip.mate.kbopen.auth.model.KbApiKeyEntity;
@Mapper
public interface KbApiKeyMapper extends BaseMapper<KbApiKeyEntity> {
}

View File

@ -0,0 +1,137 @@
package vip.mate.kbopen.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import vip.mate.auth.model.UserEntity;
import vip.mate.auth.service.AuthService;
import vip.mate.common.result.R;
import vip.mate.exception.MateClawException;
import vip.mate.kbopen.auth.KbApiKeyService;
import vip.mate.kbopen.auth.KbApiKeyService.CreatedKey;
import vip.mate.kbopen.auth.model.KbApiKeyEntity;
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Admin CRUD for KB Open API Keys (JWT-authenticated, workspace-scoped).
*
* <p>Plaintext is returned exactly once on {@link #create}; subsequent lookups
* expose only metadata (id, name, prefix, scopes, bound KBs, lastUsedAt).
*/
@Tag(name = "KB Open API Keys")
@RestController
@RequestMapping("/api/v1/open/keys")
@RequiredArgsConstructor
public class KbApiKeyAdminController {
private final KbApiKeyService keyService;
private final AuthService authService;
@Operation(summary = "List API keys in the current workspace (metadata only)")
@GetMapping
@RequireWorkspaceRole("admin")
public R<List<KbApiKeyEntity>> list(
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
long wsId = workspaceId != null ? workspaceId : 1L;
return R.ok(keyService.listByWorkspace(wsId));
}
@Operation(summary = "Mint a new API key — plaintext shown once, cannot be recovered")
@PostMapping
@RequireWorkspaceRole("admin")
public R<Map<String, Object>> create(
@RequestBody CreateKeyRequest req,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
Authentication auth) {
UserEntity user = requireUser(auth);
long wsId = workspaceId != null ? workspaceId : 1L;
CreatedKey created = keyService.create(
wsId,
user.getId(),
req.name(),
req.scopes(),
req.kbIds(),
req.expiresAt());
return R.ok(Map.of(
"id", created.id(),
"plaintext", created.plaintext(),
"name", req.name() == null ? "" : req.name(),
"prefix", created.entity().getPrefix(),
"scopes", created.entity().getScopes() == null ? "" : created.entity().getScopes(),
"kbIds", req.kbIds(),
"expiresAt", req.expiresAt() == null ? "" : req.expiresAt()));
}
@Operation(summary = "Get key details (metadata only, no plaintext)")
@GetMapping("/{id}")
@RequireWorkspaceRole("admin")
public R<Map<String, Object>> detail(
@PathVariable Long id,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
long wsId = workspaceId != null ? workspaceId : 1L;
KbApiKeyEntity entity = keyService.getById(id);
if (entity == null || (entity.getDeleted() != null && entity.getDeleted() == 1)
|| !entity.getWorkspaceId().equals(wsId)) {
throw new MateClawException(404, "API key not found: " + id);
}
Set<Long> kbIds = keyService.loadBoundKbIds(id);
return R.ok(Map.of(
"id", entity.getId(),
"name", entity.getName(),
"prefix", entity.getPrefix(),
"scopes", entity.getScopes() == null ? "" : entity.getScopes(),
"kbIds", kbIds,
"enabled", entity.getEnabled(),
"rateLimitPerMin", entity.getRateLimitPerMin(),
"lastUsedAt", entity.getLastUsedAt() == null ? "" : entity.getLastUsedAt(),
"expiresAt", entity.getExpiresAt() == null ? "" : entity.getExpiresAt(),
"createTime", entity.getCreateTime() == null ? "" : entity.getCreateTime()));
}
@Operation(summary = "Update key (name / scopes / kb-ids / expiresAt — cannot change plaintext)")
@PutMapping("/{id}")
@RequireWorkspaceRole("admin")
public R<Void> update(
@PathVariable Long id,
@RequestBody UpdateKeyRequest req,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
long wsId = workspaceId != null ? workspaceId : 1L;
keyService.update(id, wsId, req.name(), req.scopes(), req.kbIds(), req.expiresAt());
return R.ok();
}
@Operation(summary = "Revoke (soft-delete) an API key")
@DeleteMapping("/{id}")
@RequireWorkspaceRole("admin")
public R<Void> revoke(
@PathVariable Long id,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
long wsId = workspaceId != null ? workspaceId : 1L;
keyService.revoke(id, wsId);
return R.ok();
}
private UserEntity requireUser(Authentication auth) {
if (auth == null || auth.getName() == null) {
throw new MateClawException("err.auth.unauthenticated", "Authentication required");
}
UserEntity user = authService.findByUsername(auth.getName());
if (user == null) {
throw new MateClawException("err.auth.user_not_found",
"Authenticated user not found: " + auth.getName());
}
return user;
}
public record CreateKeyRequest(String name, String scopes, Set<Long> kbIds, LocalDateTime expiresAt) {}
public record UpdateKeyRequest(String name, String scopes, Set<Long> kbIds, LocalDateTime expiresAt) {}
}

View File

@ -0,0 +1,30 @@
-- V164: Knowledge Base Open API Key (H2 dialect).
-- See mysql/V164 for full design notes.
CREATE TABLE IF NOT EXISTS mate_kb_api_key (
id BIGINT NOT NULL PRIMARY KEY,
name VARCHAR(128) NOT NULL,
token_hash CHAR(64) NOT NULL,
prefix VARCHAR(12) NOT NULL,
workspace_id BIGINT NOT NULL,
created_by BIGINT NOT NULL,
scopes VARCHAR(255),
enabled BOOLEAN DEFAULT TRUE,
expires_at TIMESTAMP NULL,
last_used_at TIMESTAMP NULL,
rate_limit_per_min INT NOT NULL DEFAULT 60,
create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
deleted INT DEFAULT 0
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_kb_api_key_hash ON mate_kb_api_key(token_hash);
CREATE INDEX IF NOT EXISTS idx_kb_api_key_workspace ON mate_kb_api_key(workspace_id);
CREATE TABLE IF NOT EXISTS mate_kb_api_key_binding (
id BIGINT NOT NULL PRIMARY KEY,
api_key_id BIGINT NOT NULL,
kb_id BIGINT NOT NULL,
create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_kb_api_key_binding ON mate_kb_api_key_binding(api_key_id, kb_id);
CREATE INDEX IF NOT EXISTS idx_kb_api_key_binding_kb ON mate_kb_api_key_binding(kb_id);

View File

@ -0,0 +1,30 @@
-- V164: Knowledge Base Open API Key (Kingbase dialect).
-- See mysql/V164 for full design notes.
CREATE TABLE IF NOT EXISTS mate_kb_api_key (
id BIGINT NOT NULL PRIMARY KEY,
name VARCHAR(128) NOT NULL,
token_hash CHAR(64) NOT NULL,
prefix VARCHAR(12) NOT NULL,
workspace_id BIGINT NOT NULL,
created_by BIGINT NOT NULL,
scopes VARCHAR(255),
enabled BOOLEAN DEFAULT TRUE,
expires_at TIMESTAMP NULL,
last_used_at TIMESTAMP NULL,
rate_limit_per_min INT NOT NULL DEFAULT 60,
create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
deleted INT DEFAULT 0
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_kb_api_key_hash ON mate_kb_api_key(token_hash);
CREATE INDEX IF NOT EXISTS idx_kb_api_key_workspace ON mate_kb_api_key(workspace_id);
CREATE TABLE IF NOT EXISTS mate_kb_api_key_binding (
id BIGINT NOT NULL PRIMARY KEY,
api_key_id BIGINT NOT NULL,
kb_id BIGINT NOT NULL,
create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_kb_api_key_binding ON mate_kb_api_key_binding(api_key_id, kb_id);
CREATE INDEX IF NOT EXISTS idx_kb_api_key_binding_kb ON mate_kb_api_key_binding(kb_id);

View File

@ -0,0 +1,36 @@
-- V164: Knowledge Base Open API Key.
-- External API keys for programmatic access to knowledge base retrieval,
-- tracing, and graph traversal. Like PAT (V76), plaintext is never stored —
-- only the SHA-256 hash. A DB compromise reveals ownership, scope, and bound
-- KBs but never the secret needed to authenticate.
--
-- One key can bind multiple KBs (mate_kb_api_key_binding). An empty binding
-- set means "zero access" (NOT "all KBs" — unlike internal AgentWikiKbBinding).
CREATE TABLE IF NOT EXISTS mate_kb_api_key (
id BIGINT NOT NULL PRIMARY KEY,
name VARCHAR(128) NOT NULL,
token_hash CHAR(64) NOT NULL,
prefix VARCHAR(12) NOT NULL,
workspace_id BIGINT NOT NULL,
created_by BIGINT NOT NULL,
scopes VARCHAR(255),
enabled BOOLEAN DEFAULT TRUE,
expires_at TIMESTAMP NULL,
last_used_at TIMESTAMP NULL,
rate_limit_per_min INT NOT NULL DEFAULT 60,
create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted INT DEFAULT 0,
UNIQUE KEY uk_kb_api_key_hash (token_hash),
KEY idx_kb_api_key_workspace (workspace_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS mate_kb_api_key_binding (
id BIGINT NOT NULL PRIMARY KEY,
api_key_id BIGINT NOT NULL,
kb_id BIGINT NOT NULL,
create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_kb_api_key_binding (api_key_id, kb_id),
KEY idx_kb_api_key_binding_kb (kb_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@ -0,0 +1,66 @@
package vip.mate.kbopen.auth;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link KbApiKeyRateLimiter} (R2): sliding-window admission control.
*/
class KbApiKeyRateLimiterTest {
@Test
@DisplayName("admits up to limit, then rejects")
void admitsUpToLimitThenRejects() {
KbApiKeyRateLimiter limiter = new KbApiKeyRateLimiter();
Instant now = Instant.now();
assertThat(limiter.tryAcquire(1L, 3, now)).isTrue();
assertThat(limiter.tryAcquire(1L, 3, now)).isTrue();
assertThat(limiter.tryAcquire(1L, 3, now)).isTrue();
assertThat(limiter.tryAcquire(1L, 3, now)).isFalse(); // 4th rejected
}
@Test
@DisplayName("limits are per-key (different keys have independent windows)")
void perKeyIsolation() {
KbApiKeyRateLimiter limiter = new KbApiKeyRateLimiter();
Instant now = Instant.now();
limiter.tryAcquire(1L, 2, now);
limiter.tryAcquire(1L, 2, now);
// Key 1 is full, but key 2 is independent
assertThat(limiter.tryAcquire(1L, 2, now)).isFalse();
assertThat(limiter.tryAcquire(2L, 2, now)).isTrue();
}
@Test
@DisplayName("expired entries are purged — window recovers over time")
void windowRecoversAfterExpiry() {
KbApiKeyRateLimiter limiter = new KbApiKeyRateLimiter();
Instant now = Instant.now();
limiter.tryAcquire(1L, 2, now);
limiter.tryAcquire(1L, 2, now);
assertThat(limiter.tryAcquire(1L, 2, now)).isFalse();
// 61 seconds later, the window entries have expired
Instant later = now.plusSeconds(61);
assertThat(limiter.tryAcquire(1L, 2, later)).isTrue();
}
@Test
@DisplayName("limit <= 0 disables rate limiting")
void zeroLimitDisables() {
KbApiKeyRateLimiter limiter = new KbApiKeyRateLimiter();
Instant now = Instant.now();
for (int i = 0; i < 100; i++) {
assertThat(limiter.tryAcquire(1L, 0, now)).isTrue();
}
}
}

View File

@ -0,0 +1,233 @@
package vip.mate.kbopen.auth;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import vip.mate.exception.MateClawException;
import vip.mate.kbopen.auth.KbApiKeyService.AuthResult;
import vip.mate.kbopen.auth.KbApiKeyService.CreatedKey;
import vip.mate.kbopen.auth.model.KbApiKeyBindingEntity;
import vip.mate.kbopen.auth.model.KbApiKeyEntity;
import vip.mate.kbopen.auth.repository.KbApiKeyBindingMapper;
import vip.mate.kbopen.auth.repository.KbApiKeyMapper;
import java.time.LocalDateTime;
import java.util.Optional;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Unit tests for {@link KbApiKeyService} covering the three P0-A security
* requirements: R1 (auth lookup), R2 (rate-limit context), R3 (empty binding
* rejection). Mirrors the #438/#439 IDOR test style.
*/
class KbApiKeyServiceTest {
private KbApiKeyMapper keyMapper;
private KbApiKeyBindingMapper bindingMapper;
private TokenHashUtil tokenHashUtil;
private KbApiKeyService service;
@BeforeEach
void setUp() {
keyMapper = mock(KbApiKeyMapper.class);
bindingMapper = mock(KbApiKeyBindingMapper.class);
tokenHashUtil = new TokenHashUtil();
service = new KbApiKeyService(keyMapper, bindingMapper, tokenHashUtil);
}
// R3: empty binding rejection
@Test
@DisplayName("create with empty kbIds → 400 (R3: zero access is useless)")
void createEmptyBindingRejected() {
assertThatThrownBy(() -> service.create(1L, 100L, "test", "kb:*", Set.of(), null))
.isInstanceOf(MateClawException.class)
.hasMessageContaining("At least one");
}
@Test
@DisplayName("create with null kbIds → 400")
void createNullBindingRejected() {
assertThatThrownBy(() -> service.create(1L, 100L, "test", "kb:*", null, null))
.isInstanceOf(MateClawException.class);
}
@Test
@DisplayName("updateBindings to empty → 400 (cannot reduce to zero access)")
void updateBindingsEmptyRejected() {
assertThatThrownBy(() -> service.updateBindings(1L, Set.of()))
.isInstanceOf(MateClawException.class);
}
// Create + authenticate round-trip
@Test
@DisplayName("create returns plaintext once; authenticate resolves it back")
void createAndAuthenticateRoundTrip() {
// Simulate MyBatis-Plus assigning an id on insert
when(keyMapper.insert(any(KbApiKeyEntity.class))).thenAnswer(inv -> {
KbApiKeyEntity e = inv.getArgument(0);
e.setId(42L);
return 1;
});
CreatedKey created = service.create(1L, 100L, "test-key", "kb:search", Set.of(10L, 20L), null);
String plaintext = created.plaintext();
assertThat(plaintext).startsWith(KbApiKeyService.KEY_PREFIX);
assertThat(created.entity().getId()).isEqualTo(42L);
// authenticate() hashes the plaintext and looks it up set up the mapper
// to return the created entity (whose hash matches).
when(keyMapper.selectOne(any())).thenReturn(created.entity());
when(bindingMapper.selectList(any())).thenReturn(java.util.List.of(
bindingFor(42L, 10L),
bindingFor(42L, 20L)));
Optional<AuthResult> result = service.authenticate(plaintext);
assertThat(result).isPresent();
KbApiKeyContext ctx = result.get().context();
assertThat(ctx.keyId()).isEqualTo(42L);
assertThat(ctx.workspaceId()).isEqualTo(1L);
assertThat(ctx.kbIds()).containsExactlyInAnyOrder(10L, 20L);
assertThat(ctx.scopes()).contains("kb:search");
assertThat(ctx.hasScope("kb:search")).isTrue();
assertThat(ctx.hasScope("kb:read")).isFalse();
assertThat(ctx.canAccessKb(10L)).isTrue();
assertThat(ctx.canAccessKb(99L)).isFalse();
}
@Test
@DisplayName("authenticate with wrong prefix → empty (not an mck_ key)")
void authenticateWrongPrefix() {
assertThat(service.authenticate("mc_something")).isEmpty();
assertThat(service.authenticate("eyJ.jwt.token")).isEmpty();
}
@Test
@DisplayName("authenticate with null/blank → empty")
void authenticateNullBlank() {
assertThat(service.authenticate(null)).isEmpty();
assertThat(service.authenticate("")).isEmpty();
assertThat(service.authenticate(" ")).isEmpty();
}
@Test
@DisplayName("authenticate with hash miss → empty")
void authenticateHashMiss() {
when(keyMapper.selectOne(any())).thenReturn(null);
assertThat(service.authenticate("mck_nonexistent_key_value_here")).isEmpty();
}
@Test
@DisplayName("authenticate expired key → empty")
void authenticateExpired() {
String plaintext = tokenHashUtil.generate(KbApiKeyService.KEY_PREFIX, 32);
KbApiKeyEntity entity = entityWithHash(1L, 1L, "kb:*", plaintext);
entity.setExpiresAt(LocalDateTime.now().minusDays(1));
when(keyMapper.selectOne(any())).thenReturn(entity);
when(bindingMapper.selectList(any())).thenReturn(java.util.List.of());
assertThat(service.authenticate(plaintext)).isEmpty();
}
@Test
@DisplayName("authenticate disabled key → empty")
void authenticateDisabled() {
// The query already filters enabled=true, so selectOne returns null
when(keyMapper.selectOne(any())).thenReturn(null);
String plaintext = tokenHashUtil.generate(KbApiKeyService.KEY_PREFIX, 32);
assertThat(service.authenticate(plaintext)).isEmpty();
}
// kb:* wildcard scope
@Test
@DisplayName("kb:* scope grants all individual scopes")
void wildcardScopeGrantsAll() {
String plaintext = tokenHashUtil.generate(KbApiKeyService.KEY_PREFIX, 32);
when(keyMapper.selectOne(any())).thenReturn(entityWithHash(1L, 1L, "kb:*", plaintext));
when(bindingMapper.selectList(any())).thenReturn(java.util.List.of(bindingFor(1L, 10L)));
KbApiKeyContext ctx = service.authenticate(plaintext).get().context();
assertThat(ctx.hasScope("kb:search")).isTrue();
assertThat(ctx.hasScope("kb:read")).isTrue();
assertThat(ctx.hasScope("kb:list")).isTrue();
assertThat(ctx.hasScope("kb:meta")).isTrue();
}
@Test
@DisplayName("null scopes defaults to kb:*")
void nullScopesDefaultsToWildcard() {
String plaintext = tokenHashUtil.generate(KbApiKeyService.KEY_PREFIX, 32);
when(keyMapper.selectOne(any())).thenReturn(entityWithHash(1L, 1L, null, plaintext));
when(bindingMapper.selectList(any())).thenReturn(java.util.List.of(bindingFor(1L, 10L)));
KbApiKeyContext ctx = service.authenticate(plaintext).get().context();
assertThat(ctx.hasScope("kb:search")).isTrue();
}
// Revoke
@Test
@DisplayName("revoke sets enabled=false + deleted=1")
void revokeSoftDeletes() {
KbApiKeyEntity entity = entity(1L, 1L, "kb:*");
when(keyMapper.selectById(1L)).thenReturn(entity);
service.revoke(1L, 1L);
assertThat(entity.getEnabled()).isFalse();
assertThat(entity.getDeleted()).isEqualTo(1);
verify(keyMapper).updateById(entity);
}
@Test
@DisplayName("revoke from wrong workspace → 404")
void revokeWrongWorkspace() {
KbApiKeyEntity entity = entity(1L, 1L, "kb:*"); // belongs to ws 1
when(keyMapper.selectById(1L)).thenReturn(entity);
assertThatThrownBy(() -> service.revoke(1L, 2L)) // caller in ws 2
.isInstanceOf(MateClawException.class)
.hasMessageContaining("not found");
}
// Helpers
/** Create an entity whose tokenHash matches the given plaintext. */
private KbApiKeyEntity entityWithHash(long id, long workspaceId, String scopes, String plaintext) {
KbApiKeyEntity e = entity(id, workspaceId, scopes);
e.setTokenHash(tokenHashUtil.hash(plaintext));
return e;
}
private KbApiKeyEntity entity(long id, long workspaceId, String scopes) {
KbApiKeyEntity e = new KbApiKeyEntity();
e.setId(id);
e.setWorkspaceId(workspaceId);
e.setScopes(scopes);
e.setEnabled(true);
e.setDeleted(0);
e.setRateLimitPerMin(60);
return e;
}
private KbApiKeyBindingEntity bindingFor(long apiKeyId, long kbId) {
KbApiKeyBindingEntity b = new KbApiKeyBindingEntity();
b.setApiKeyId(apiKeyId);
b.setKbId(kbId);
return b;
}
}

851
rfcs/kb-open-api-design.md Normal file
View File

@ -0,0 +1,851 @@
# 知识库开放 API 设计文档
> 状态:草案 v2.1 · 评审修订已合入
> 作者MateClaw Team
> 关联 ISSUE待创建R12动工前在上游开 issue
## 1. 背景与目标
### 1.1 现状
MateClaw 的知识库目前有 3 条触达路径,但**没有直接对外的入口**
| 路径 | 消费方 | 认证 | 局限 |
|---|---|---|---|
| ① Agent 工具(`kb_xxx_search/read/list` | LLM 内部调用 | Agent 绑定隔离 | 不对外,必须经 Agent 对话 |
| ② REST `search-preview` / `pages` | 前端 UI | JWT + workspace 鉴权 | JWT 认证,不对外 |
| ③ WebChat 渠道 | 外部网站 | API Key | 只暴露 Agent 对话,外部无法直接检索知识库 |
外部系统想用知识库,只能套一层 Agent 对话(路径③),无法程序化检索。
### 1.2 目标
把知识库做成一个**开放功能**,提供 OpenAPI 风格的对外服务,支持两类消费者:
1. **其他应用程序**(客服系统、搜索前端、内部业务系统)—— 通过 REST API 程序化检索知识库。
2. **外部 AI Agent**Claude Desktop、Cursor、其他 Agent 平台)—— 后续通过 MCP Server 零代码接入。
### 1.3 核心设计判断
在讨论过程中,我们达成了一个关键判断:**Wiki 的页面在逻辑上就是实体**。项目数据模型里,`pageType` 的第一种取值就叫 `entity`,页面带有 `canonicalName`、`metadataJson`(结构化字段)、`knowledgeLayer`fact/experience 分层标签)。因此:
- **slug 是实体的唯一标识**`get_entity` 就是"查页面并按卡片格式返回"。
- **不需要分"页面中心化"和"实体中心化"两套 API**,统一为一套接口。
- **entityId 概念在对外 API 中消失**——除了 `traverse`(实体关系遍历),这是唯一需要 `WikiEntityRelationEntity` 表的接口。
### 1.4 设计原则
- **复用优先**:底层检索复用 `HybridRetriever`,认证复用 PAT 的 hash 存储范式,鉴权复用 `verifyKBWorkspace` 模式。
- **安全收紧**API Key 采用 SHA-256 hash 存储,支持 scope 细粒度控制。
- **务实简化**traverse 先支撑 1~2 跳高频场景性能优化索引、BFS 引擎)留后续。
---
## 2. 整体架构
```
┌─────────────────────────────────────────┐
│ 外部消费方 │
├────────────────┬────────────────────────┤
│ 其他应用程序 │ 外部 AI Agent (后续MCP) │
│ (HTTP client) │ │
└───────┬────────┴──────────┬─────────────┘
│ REST API │
▼ ▼
┌───────────────────────────────────────────────────────────────┐
│ /api/v1/open/kb/** │
│ (SecurityConfig permitAll 白名单) │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ KbOpenApiAuthFilter (新建, OncePerRequestFilter) │ │
│ │ Authorization: Bearer mck_xxxxx │ │
│ │ → SHA-256 hash 查表 → 校验 enabled/expired/scope │ │
│ │ → 注入 KbApiKeyContext (keyId, kbIds, scopes, rateLimit)│ │
│ │ → 每 key 粗粒度限流(复用 TriggerRateLimiter 范式) │ │
│ └──────────────────────────┬────────────────────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ KbOpenApiController (新建) │ │
│ │ + @RequireKbScope 集中授权A1仿 @RequireWorkspaceRole)│ │
│ │ │ │
│ │ 查主体: GET /pages/{slug} (卡片+下钻mode) │ │
│ │ 搜索: POST /search (granularity控制) │ │
│ │ 走关系: POST /pages/{slug}/traverse (实体关系遍历) │ │
│ │ 溯源: GET /pages/{slug}/trace (页面→chunk→raw) │ │
│ │ 地图: GET /taxonomy (类型枚举) │ │
│ │ 时效: GET /whats-new (变更/stale查询) │ │
│ │ 元信息: GET /stats │ │
│ └──────────────────────────┬────────────────────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 现有服务层(不改动或最小改造) │ │
│ │ HybridRetriever · WikiPageService · WikiEntityGraphService│ │
│ │ PageCitationMapper · WikiResearchService │ │
│ └─────────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────┘
管理端JWT 认证):
POST /api/v1/open/keys 签发 API Key明文仅返回一次
GET /api/v1/open/keys 列出 API Key不返回明文
DELETE /api/v1/open/keys/{id} 撤销 API Key
PUT /api/v1/open/keys/{id} 更新 Keyname/scopes/kb-ids/expiresAt
```
---
## 3. 数据模型
### 3.1 API Key 表 `mate_kb_api_key`
```sql
CREATE TABLE mate_kb_api_key (
id BIGINT PRIMARY KEY, -- 雪花 ID
name VARCHAR(128) NOT NULL, -- 人类可读标签
token_hash VARCHAR(64) NOT NULL UNIQUE, -- SHA-256 hex明文不落库自带索引
prefix VARCHAR(6) NOT NULL, -- 明文前 4 位,用于 UI 展示 "mck_ab****"
workspace_id BIGINT NOT NULL, -- 所属工作区(隔离边界)
created_by BIGINT NOT NULL, -- 签发人 userId
scopes VARCHAR(256), -- "kb:search,kb:read,kb:list"
enabled TINYINT NOT NULL DEFAULT 1, -- 软撤销
expires_at DATETIME, -- 可选硬过期NULL=永不过期
last_used_at DATETIME, -- 最后使用时间(去抖更新)
rate_limit_per_min INT NOT NULL DEFAULT 60, -- 每分钟请求上限P0 强制执行)
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
deleted TINYINT NOT NULL DEFAULT 0
);
-- R10: token_hash 已 UNIQUE自带索引不再额外建 idx_kb_api_key_hash
```
设计要点(对照 PAT / WebChat
- **`token_hash` 存 SHA-256**,明文仅创建时返回一次。**不复用 WebChat 的明文存储**。
- **R8`prefix` 缩短为 4 位**(原 8 位——DB 泄露时缩小爆破空间。UI 展示 `mck_ab****`hash 不可逆prefix 不泄露安全性。
- **`workspace_id`** 是隔离边界:一个 API Key 属于一个工作区,只能访问该工作区内的知识库。
- **`scopes`** 复用 PAT 的逗号分隔格式(`"kb:search,kb:read,kb:list"`)。
- **`rate_limit_per_min`** P0 强制执行R2——search 消耗 embedding token公网可达必须限流。
### 3.2 API Key ↔ 知识库绑定表 `mate_kb_api_key_binding`
```sql
CREATE TABLE mate_kb_api_key_binding (
id BIGINT PRIMARY KEY,
api_key_id BIGINT NOT NULL,
kb_id BIGINT NOT NULL,
create_time DATETIME NOT NULL,
UNIQUE(api_key_id, kb_id)
);
CREATE INDEX idx_kb_api_key_binding_kb ON mate_kb_api_key_binding(kb_id);
-- R10: 按 kb_id 反查Wiki 面板"这个 KB 绑了哪些 Key")需要索引,否则全表扫
```
设计要点:
- **多对多关系**:一个 API Key 可绑定多个知识库(满足"Key 绑定多 KB"需求)。
- 绑定列表定义了这个 Key 的检索范围——调用方传的 `kbId` 必须在绑定列表内,否则 403。
- **R3对外 key 空绑定 = 零访问(不是全量)**。签发时绑定列表不能为空。这与内部 Agent 的 `AgentWikiKbBinding`"无绑定=不收窄"语义**相反**——对外 key 不应静默拿到全工作区、且新建 KB 自动纳入。
### 3.3 API Key 明文格式
```
mck_<43 url-safe base64 chars>
^^
├─ mck = MateClaw Knowledge区别于 PAT 的 mc_ 前缀
└─ JwtAuthFilter 可据此区分 JWT(eyJ) / PAT(mc_) / KB-API-Key(mck_) 三种 token
```
### 3.4 现有数据模型(不新建表,仅引用)
开放 API 的所有数据都来自现有表,只是按"实体/主体"语义做运行时投影:
```
WikiKnowledgeBaseEntity 知识库id, name, workspaceId
WikiPageEntity 主体画像的载体(= 实体):
├─ slug 唯一标识(对外 API 的主键)
├─ pageType fund_manager / risk_analysis / person ...
├─ canonicalName 主体名
├─ knowledgeLayer fact | experience知识分层标签
├─ metadataJson 结构化字段schema 随 KB 变化)
├─ content 全文
├─ sourceRawIds 来源原始材料
└─ stale / version 时效状态
│ PageCitationWithRaw (pageId → chunkId → rawId)
WikiChunkEntity + WikiRawMaterialEntity 原始片段 + 文档(溯源终点)
WikiEntityEntity + WikiEntityRelationEntity 仅 traverse 使用(实体间语义关系)
```
### 3.5 共享 Token 内核A4
> A4 修订KB API Key 与 PAT 都是"hash 存储 + CRUD + 管理 UI",高度同构。把 hash 生成/校验、CRUD、管理 UI 抽成共享"token 内核",避免 P0-A 一半工作量在复制、日后两套漂移。
```
共享 token 内核token-kernel新建或从 PAT 重构提取)
├─ TokenHashUtil hash 生成SecureRandom + SHA-256、校验、前缀管理
├─ TokenCrudTemplate 通用 CRUDcreate/list/revoke/update + 去抖 recordUse
└─ TokenManagementUi 共享管理 UI 组件(列表 + 签发 Modal + 明文一次性展示)
两种 flavor
├─ PAT flavortoken_hash 存 mate_personal_access_token携带 userId用户身份
└─ KB-Key flavortoken_hash 存 mate_kb_api_key携带 workspaceId + kbIds + scopes非用户身份
```
关键约束:
- **KB-key 不含用户身份**(独立 filter不写 SecurityContext这个决策**不变**——共享的是 hash/CRUD/UI 基础设施,不是认证语义。
- P0 实施时,优先评估 PAT 现有代码的可提取程度;若 PAT 重构成本高,可先**共享 `TokenHashUtil`hash 生成/校验)**CRUD/UI 的完整共享作为后续重构。
---
## 4. 认证与鉴权设计
### 4.1 认证 Filter新建 `KbOpenApiAuthFilter`
```
请求: Authorization: Bearer mck_xxxxx
KbOpenApiAuthFilter (OncePerRequestFilter, 仅拦截 /api/v1/open/kb/**)
├─ 1. 提取 Bearer token
├─ 2. 缺失 / 非 "mck_" 前缀 / hash 未命中 / disabled / 过期
│ → 当场返回 401绝不放行
│ R1: 此路径是 permitAll本 filter 是唯一守门人pass-through = 漏洞
├─ 3. 加载绑定 KB 列表 + scopes + rateLimit
├─ 4. 限流检查R2超 rate_limit_per_min → 返回 429
├─ 5. 注入 KbApiKeyContext 到 request attribute
│ { keyId, workspaceId, kbIds(Set), scopes(Set) }
└─ 6. recordUse去抖复用共享 token 内核的 60s 窗口)
```
关键决策:
- **独立 Filter不复用 JwtAuthFilter**。JwtAuthFilter 统一处理 JWT+PAT 并写入 SecurityContext赋用户角色KB API Key 不代表用户身份,不应获得用户级权限,需要独立的认证上下文。
- **R1此路径是 permitAllfilter 是唯一守门人**——缺失/无效 key 必须**当场返回 401**绝不放行。pass-through 只适用于全局 filter如 JwtAuthFilter 有后续 Security 链兜底),不适用于这条只靠自身把关的路径。
- **R2限流在 filter 层做**——search 每次调用消耗 embedding token公网可达 + 无限流 = 成本风险。每 key 粗粒度限流(复用 `TriggerRateLimiter` 范式),超限返回 429。
- **A4hash 生成/校验复用共享 token 内核**(见 §3.5),不复制 PAT 的整段实现。
### 4.2 鉴权:集中式注解 + 两层校验
> A1 修订:授权校验**不逐端点手写**。本仓库刚因"逐端点手写归属校验"连发 #438/#439 修 Wiki IDOR——对外 API 重蹈代价更大。安全关键校验应收一处审计。
**集中授权**:新建 `@RequireKbScope("kb:search")` 注解 + interceptor仿现有 `@RequireWorkspaceRole` + `WorkspaceAccessInterceptor` 模式),集中完成 scope 检查与 KB 归属校验。
```
请求 POST /api/v1/open/kb/{kbId}/search
├─ 层① 认证KbOpenApiAuthFilter 已校验key 有效 + 限流通过)
├─ 层② scope 校验(@RequireKbScope 注解 → interceptor
│ KbApiKeyContext.scopes 包含注解要求的 scope如 "kb:search"
│ → 不满足返回 403
└─ 层③ KB 归属校验interceptor 内,从 path variable 提取 kbId
kbId ∈ KbApiKeyContext.kbIds
→ 不满足返回 403
```
层③ 复用 `verifyKBWorkspace` 的核心逻辑(`kb.getWorkspaceId().equals(workspaceId)`),判断来源为 API Key 的 `workspaceId` 字段。
**R3空绑定语义为"零访问"而非"全量"**——对外 key 不复用内部 Agent 的"无绑定=不收窄"语义。签发时绑定列表不能为空(或空绑定 = 零访问),避免一个 key 静默拿到全工作区、且新建 KB 自动纳入。
### 4.3 Scope 定义
| Scope | 允许的操作 | 说明 |
|---|---|---|
| `kb:search` | POST `/search`、POST `/search/chunks` | 检索 |
| `kb:read` | GET `/pages/{slug}`、GET `/pages/{slug}/trace`、POST `/pages/{slug}/traverse` | 读主体画像 + 溯源 + 关系遍历 |
| `kb:list` | GET `/pages`、GET `/taxonomy` | 列页面 + 地图 |
| `kb:meta` | GET `/stats`、GET `/whats-new` | 元信息 + 时效查询 |
| `kb:*` | 全部 | 通配,简便但权限大 |
签发时可组合,如 `"kb:search,kb:read"`。默认签发 `kb:*`,按需收窄。
> 注:`/research`(异步 Deep Research已移出本版作为独立议题单独设计见 §9 P1.5。R5 已处理:原 scope 表中的 `GET /raw` 幽灵端点已删除,不在本版 9 端点清单内。
---
## 5. API 规范
### 5.1 对外接口总览(`/api/v1/open/kb/**`API Key 认证)
一套接口,共 9 个端点。标 🔧 的需要新增底层 service 方法。R5 已处理:`/raw` 不在本版清单内。
| 方法 | 路径 | Scope | 底层方法 | 状态 |
|---|---|---|---|---|
| GET | `/kb/{kbId}/pages/{slug}` | kb:read | `WikiPageService.getBySlug()` + 🔧 mode 改造 | 改造 |
| POST | `/kb/{kbId}/search` | kb:search | `HybridRetriever.search()` | 现有 |
| POST | `/kb/{kbId}/search/chunks` | kb:search | `HybridRetriever.searchChunks()` | 现有 |
| POST | `/kb/{kbId}/pages/{slug}/traverse` | kb:read | 🔧 `WikiEntityGraphService.traverse()` | 新增 |
| GET | `/kb/{kbId}/pages/{slug}/trace` | kb:read | `PageCitationMapper.listWithRawByPageId()` | 现有 |
| GET | `/kb/{kbId}/taxonomy` | kb:list | 🔧 组装pageType/entityType/relationType 统计) | 新增 |
| GET | `/kb/{kbId}/whats-new` | kb:meta | `WikiPageService.findRecentUpdated()` + stale | 现有 |
| GET | `/kb/{kbId}/stats` | kb:meta | 🔧 `KbStatsDto` 标准化 | 新增 |
| GET | `/kb/{kbId}/pages` | kb:list | `WikiPageService.listByKbId()` | 现有 |
> `/research`(异步 Deep Research + SSE已移出本版作为独立议题设计见 §9 P1.5。R7SSE × API key随之解决——本版无 SSE 端点。
### 5.2 接口示例
#### 查主体画像get_entity = get_page
```
GET /api/v1/open/kb/{kbId}/pages/{slug}
Authorization: Bearer mck_xxxxx
Query: ?mode=summary&fields=登记编码,登记状态
Response 200 (mode=summary, 默认紧凑):
{
"code": 200,
"data": {
"slug": "xx-investment",
"canonicalName": "XX投资管理有限公司",
"pageType": "fund_manager",
"knowledgeLayer": "fact",
"title": "XX投资管理有限公司 - 基本信息登记",
"summary": "中基协登记的管理人注册资本5000万...",
"fields": {
"登记编码": "P123456",
"登记状态": "已注销"
},
"source": { "rawIds": [55, 56], "rawTitles": ["中基协公示页.pdf", "工商信息.docx"] },
"version": 3,
"updatedAt": "2026-06-25T10:00:00"
}
}
Response 200 (mode=full):
{ ..., "content": "# XX投资管理有限公司\n\n## 基本信息\n..." }
Response 200 (mode=section:风险评估):
{ ..., "content": "## 风险评估\n注销原因为异常经营..." }
```
mode 语义:
- **summary**(默认):标题 + 摘要 + fields省 token。`fields` 参数可只取指定字段。
- **full**:页面全文。
- **section:{heading}**:按 heading 切片取部分内容。
#### 搜索(带粒度控制)
```
POST /api/v1/open/kb/{kbId}/search
Authorization: Bearer mck_xxxxx
Request:
{
"query": "已注销的私募管理人",
"pageType": "fund_manager", // 可选,按页面/实体类型过滤
"granularity": "entity", // entity(默认) | section | chunk
"topK": 10
}
Response 200 (granularity=entity):
{
"code": 200,
"data": {
"kbId": 123,
"count": 2,
"results": [
{
"slug": "xx-investment",
"title": "XX投资管理有限公司",
"pageType": "fund_manager",
"knowledgeLayer": "fact",
"summary": "...",
"score": 0.91
}
]
}
}
```
granularity 三档:
- **entity**(默认):页面级聚合,返回 slug + 摘要 + 分层标签(= Agent 友好的主体命中)。
- **chunk**:片段级,返回 ChunkHit深挖时用`/search/chunks`)。
- **section**R9暂不支持P0 不实现。`HybridRetriever` 无中间档产出search 返回页面级 `PageSearchResult`、searchChunks 返回 `ChunkHit`section 的 snippet/score 来源未定义。P0 只支持 entity/chunk 两档。
#### traverse实体关系遍历
```
POST /api/v1/open/kb/{kbId}/pages/{slug}/traverse
Authorization: Bearer mck_xxxxx
Request:
{
"relation": "实控人", // 可选null=所有关系LIKE 模糊匹配)
"depth": 1, // 1 或 2默认 1
"direction": "both", // outgoing | incoming | both默认 both
"limit": 20 // 每跳返回边数上限,默认 20硬上限 50
}
Response 200:
{
"code": 200,
"data": {
"root": {
"slug": "xx-investment",
"entityId": 9001,
"name": "XX投资管理有限公司",
"type": "fund_manager"
},
"edges": [
{
"predicate": "实控人",
"fromId": 9002,
"toId": 9001,
"fromName": "张三",
"toName": "XX投资",
"evidence": "张三持有XX投资51%股权",
"confidence": 0.95,
"sourceHandle": "p:789"
}
],
"nodes": [
{ "entityId": 9002, "name": "张三", "type": "person", "slug": "zhang-san" }
]
}
}
```
#### trace溯源
```
GET /api/v1/open/kb/{kbId}/pages/{slug}/trace
Authorization: Bearer mck_xxxxx
Response 200:
{
"code": 200,
"data": {
"slug": "xx-investment",
"pageType": "fund_manager",
"knowledgeLayer": "fact",
"sources": [
{
"rawId": 55,
"rawTitle": "中基协公示页.pdf",
"citations": [
{ "chunkId": 9001, "snippet": "登记状态:已注销...", "confidence": 0.92, "pageNumber": 1 }
]
}
],
"extractedAt": "2026-06-20T10:30:00",
"pageVersion": 3
}
}
```
#### get_taxonomy地图
```
GET /api/v1/open/kb/{kbId}/taxonomy
Authorization: Bearer mck_xxxxx
Response 200:
{
"code": 200,
"data": {
"pageTypes": [
{ "pageType": "fund_manager", "count": 45, "label": "私募基金管理人" },
{ "pageType": "risk_analysis", "count": 30, "label": "风险评估" }
],
"entityTypes": [
{ "type": "person", "count": 120 },
{ "type": "organization", "count": 56 }
],
"relationTypes": [
{ "predicate": "实控人", "count": 30 },
{ "predicate": "works_for", "count": 56 }
]
}
}
```
#### whats_new时效查询
```
GET /api/v1/open/kb/{kbId}/whats-new?since=2026-06-20&kind=updated
Authorization: Bearer mck_xxxxx
Response 200:
{
"code": 200,
"data": {
"kbId": 123,
"since": "2026-06-20T00:00:00",
"changedPages": [
{ "slug": "xx-investment", "title": "...", "knowledgeLayer": "fact",
"updatedAt": "2026-06-25T10:00:00" }
],
"stalePages": [
{ "slug": "xx-risk", "title": "...", "staleReason": "上游fact页面变更" }
]
}
}
```
### 5.3 错误响应
统一 `R<T>` 包装:
| HTTP | code | 场景 |
|---|---|---|
| 401 | 401 | 缺少/无效/过期 API Key |
| 403 | 403 | scope 不足 / KB 不在绑定范围 / KB 不属于 Key 的工作区 |
| 404 | 404 | kbId / slug 不存在 |
| 429 | 429 | 超过 rate limitP1 |
### 5.4 管理接口(`/api/v1/open/keys/**`JWT 认证)
```
POST /api/v1/open/keys 签发 Key返回明文仅此一次
GET /api/v1/open/keys 列出当前工作区的 Key不返回明文
GET /api/v1/open/keys/{id} Key 详情(不含明文)
DELETE /api/v1/open/keys/{id} 撤销 Key
PUT /api/v1/open/keys/{id} 更新 Keyname / scopes / kb-ids / expiresAt
```
---
## 6. traverse 设计专题
traverse 是整套 API 里唯一需要 `WikiEntityRelationEntity` 表(实体间语义关系)的接口,也是最有独立价值的差异化能力。其余接口都是页面的不同投影。
### 6.1 数据现状
`WikiEntityRelationEntity` 是三元组表:
```
subjectEntityId → predicate → objectEntityId
+ evidence证据原文 ≤500字
+ evidenceChunkId来源 chunk
+ confidence0~1
+ sourcellm-extracted | inferred | manual
```
关键事实:
- **predicate 是自由文本**,由 LLM 抽取并归一化为 snake_case`WikiEntityExtractionService.java:469`)。没有预定义的关系类型枚举。
- **现有的 `ego()` 方法**`WikiEntityGraphService.java:94`)是 traverse 的雏形——但固定 1 跳、不按 predicate 过滤、双向全量取。
- **entityId 与 slug 的映射**slug → pageId → `WikiEntityMentionEntity(pageId=X)` → entityId。
### 6.2 务实版设计
先支撑最高频场景("这个发行人的关联方""实控人名下其他主体"),都是 1~2 跳、按关系类型过滤。
**参数设计**
| 参数 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `relation` | String | null | 关系类型过滤null=所有关系。LIKE 模糊匹配(不标准化) |
| `depth` | int | 1 | 遍历深度,仅支持 1 或 2硬上限 2 防爆炸 |
| `direction` | String | both | outgoing / incoming / both |
| `limit` | int | 20 | 每跳返回边数上限,硬上限 50 |
**slug → entityId 映射**:页面取其关联的 salience 最高实体作为 primary entity一对一假设。R11响应回显实际选中的 `root.entityId` / `root.name`,让调用方知道映射选了哪个实体(同页可能有多个实体,取 salience 最高会静默丢其它)。
**邻居节点呈现**:子图节点返回 entityId + name + type + slugslug 通过 mention 反查,如果该实体有页面的话;没有则为 null
**边溯源**`evidenceChunkId` → firstCitingPage → `sourceHandle` 句柄。
### 6.3 底层实现
```
1. pageService.getBySlug(kbId, slug) → pageId
2. mentionMapper 查 (pageId=pageId) → 取 salience 最高 entityId → primaryEntityId
3. WikiEntityGraphService.traverse(kbId, primaryEntityId, relation, depth, direction, limit)
├─ depth=1: relationMapper 查 (subjectId=X OR objectId=X) + predicate LIKE + direction + LIMIT
└─ depth=2: 先查 1 跳邻居 IDs≤limit→ 批量查这些邻居的边 (IN 查询) + LIMIT
4. 收集邻居 entityId → entityMapper.selectBatchIds → 组装 nodes
5. 邻居 slug 反查: mentionMapper(entityId=X, pageId != null) → pageService → slug
6. 边溯源: evidenceChunkId → firstCitingPage → sourceHandle "p:{pageId}"
```
需要新增 1 个 service 方法:`WikiEntityGraphService.traverse()`。不新建表、不加索引、不改抽取逻辑。
### 6.4 局限性(诚实标注)
| 局限 | 影响 | 后续优化 |
|---|---|---|
| depth ≤ 2 | 超过 2 跳的深链路查询不支持 | 加索引 + BFS 引擎 |
| predicate LIKE 模糊匹配 | `实控人` 可能匹配不到 `actual_controller` | 建关系类型映射表 + 约束抽取 prompt配合 get_taxonomy 先查精确值 |
| 无路径去重 | 2 跳可能出现环形重复边 | BFS 时去重 |
| 性能未优化 | 大 KB万级实体2 跳可能慢 | 加 `(kbId, subjectId)` / `(kbId, objectId)` 复合索引 |
**配合 get_taxonomy 使用**Agent 先调 `/taxonomy` 看库里实际有哪些 predicate拿精确值再传给 traverse比靠猜更可靠。
---
## 7. 前端管理 UI 方案
### 7.1 放置位置Settings 子菜单为主 + Wiki 配置面板只读为辅
**主管理页**`/settings/open-api`Settings ▸ Advanced 分段,紧邻 ACP 端点)
```
设置 ▸ Advanced
├─ ...
├─ ACP 端点 /settings/acp ← 最接近的 CRUD 范例
├─ 开放 API /settings/open-api ← 新增
└─ Token 统计 /settings/token-usage
```
**辅助展示**:在 `WikiConfig.vue`(单个知识库配置面板)增加「开放 API」折叠区只读展示"这个 KB 被哪些 Key 绑定了",提供跳转到设置页的链接。
### 7.2 理由
| 考量 | 分析 |
|---|---|
| 项目惯例 | RFC-090 已把多个功能降级进 Settings所有"配置/治理/接入"类功能收拢在 `/settings` |
| 最接近范例 | `AcpEndpoints.vue` 就是"对外暴露 + 凭证管理",放在 `/settings/acp`,结构完全同构 |
| Key 粒度 | 1 Key → N KB 是跨 KB 的,必须在平台级页面统一管理,单 KB 视角无法管理跨 KB Key |
| 语义辅助 | 在知识库配置面板做只读展示,让用户知道"这个 KB 开了哪些对外接口",但实际操作跳转 Settings |
### 7.3 主管理页界面结构(仿 `AcpEndpoints.vue`
- **列表表格**Key 名称 / `prefix****` 标识 / 绑定 KB 数 / scope / 最后使用 / 状态开关
- **签发 Modal**:名称 + scope 勾选 + 选择绑定 KB多选+ 可选过期时间 → 提交后**一次性弹窗显示明文**
- **编辑 Modal**:修改名称 / scope / 绑定 KB / 过期时间(不能改明文)
- **行内操作**:编辑 / 撤销(软删除)/ 复制 Key 前缀
### 7.4 需要新增的前端文件
| 文件 | 说明 |
|---|---|
| `src/views/Settings/OpenApi/index.vue` | 主管理页(仿 `AcpEndpoints.vue` |
| `src/router/index.ts` | `/settings` children 新增路由(约 `:258` 紧邻 `acp` |
| `src/views/Settings/Layout.vue` | `sections` 的 Advanced 分段末尾加菜单项(`:155` 后) |
| `src/api/index.ts` | 新增 `kbOpenKeyApi`(仿 `acpApi``:403` 起) |
| `src/views/Wiki/components/WikiConfig.vue` | 新增「开放 API」只读折叠区 |
| i18n `zh-CN.ts` / `en-US.ts` | `settings.sections` + `nav` 段文案 |
---
## 8. 与现有体系的关系
### 8.1 复用的组件
| 组件 | 来源 | 复用方式 |
|---|---|---|
| `HybridRetriever.search()` | `wiki/service/HybridRetriever.java:92` | 直接调用不改动granularity 在 Controller 层后处理) |
| `HybridRetriever.searchChunks()` | `wiki/service/HybridRetriever.java:188` | 直接调用,不改动 |
| `WikiPageService.*` | `wiki/service/WikiPageService.java` | 直接调用getBySlug / listByKbId / findRecentUpdated |
| `PageCitationMapper.listWithRawByPageId()` | `wiki/repository/WikiPageCitationMapper.java:28` | 直接调用trace |
| `WikiResearchService.research()` | `wiki/service/WikiResearchService.java:70` | 已移出 P0A3P1.5 开放时复用 |
| SHA-256 hash 逻辑 | `PersonalAccessTokenService.sha256Hex` | 提取为公共工具或复制 |
| `R<T>` 响应包装 | `common/result/R` | 直接使用 |
| `verifyKBWorkspace` 逻辑 | `WikiController:1181` | 逻辑复用,参数来源不同 |
### 8.2 需要新增/改造的组件
| 组件 | 类型 | 说明 |
|---|---|---|
| `WikiEntityGraphService.traverse()` | 🔧 改造 | 新增按 predicate/depth/direction 遍历方法(基于现有 ego 查询模式扩展) |
| `KbStatsDto` + 组装 | 🔧 新建 | 结构化统计 JSONrawCount/chunkCount/pageCount/embeddedChunks 等) |
| `get_taxonomy` 组装 | 🔧 新建 | pageType/entityType/relationType 分组统计 + profile 读取 |
| `whats_new` 组装 | 🔧 新建 | 组合 findRecentUpdated + stale 页面查询 |
| `pages/{slug}` mode 参数 | 🔧 改造 | 新增 summary/full/section:{heading} 模式语义化 |
| `@RequireKbScope` 注解 + interceptor | 🔧 新建 | A1集中授权scope + KB 归属),仿 `@RequireWorkspaceRole` + `WorkspaceAccessInterceptor` |
| `TokenHashUtil` 共享内核 | 🔧 新建/提取 | A4hash 生成/校验共享KB-key 与 PAT 两种 flavor 复用 |
**架构约束A5/A6**
- **A5 对外契约保持"页面中心"**:并非所有 wiki 页面都是实体overview/synthesis/log/risk_analysis…对非实体页面"entity 语义"会退化。对外就叫"页面"entity 仅作为 traverse 的内部细节。**响应一律用显式 DTO绝不直接序列化实体**(同属 IDOR/字段泄露老坑,定为硬约束)。
- **A6 service 层不耦合 HTTP 类型**P0 新增的 service 方法traverse / taxonomy / stats返回纯 DTO**不让 `HttpServletRequest` / `R<T>` 漏进 service 层**P2 MCP 才能零成本套壳。
**不需要新建任何数据库表**API Key 表除外)。全部建立在现有 wiki 数据模型之上。
### 8.3 不复用 WebChat API Key 的原因
| 维度 | WebChat | KB 开放 API | 理由 |
|---|---|---|---|
| 存储 | 明文存 `config_json` | SHA-256 hash 独立表 | 安全 |
| 比较 | 明文 `equals` O(n) | hash 索引查找 O(1) | 安全+性能 |
| 粒度 | 1 key = 1 channel = 1 agent | 1 key = N 个 KB | 需求不同 |
| scope | 无 | `kb:search/read/list/meta` | 最小权限原则 |
| 校验 | 每 endpoint 手写 | 统一 Filter | 可维护性 |
### 8.4 SecurityConfig 改动
```java
// 放行开放 API 路径API Key 认证由 KbOpenApiAuthFilter 负责)
http.requestMatchers("/api/v1/open/kb/**").permitAll()
// 管理端 /api/v1/open/keys/** 仍走 JWT 认证,不加白名单
// R6: CORS — 消费方含浏览器"搜索前端"/api/v1/open/kb/** 需配置 CORS。
// 复用现有 CorsConfigurationSource若已有或新增允许 Authorization 头。
http.cors(...);
```
---
## 9. 分阶段实施计划
### P0开放 API 完整版9 端点)
**目标**:一套完整的知识库开放 API9 个端点),配齐 API Key 生命周期管理 + 限流 + 集中授权。
#### P0-A基础设施认证 + 限流 + 数据模型 + 管理端 + 集中授权)
| 任务 | 说明 |
|---|---|
| 数据模型 | `mate_kb_api_key` + `mate_kb_api_key_binding` 两张表 + 迁移脚本h2/mysql/kingbase |
| `TokenHashUtil` 共享内核A4 | hash 生成/校验KB-key 与 PAT 共享;先共享 hash 层CRUD/UI 完整共享可后续 |
| `KbApiKeyService` | 签发(`mck_` 前缀)、校验、撤销、绑定管理;**R3 空绑定拒绝签发** |
| `KbOpenApiAuthFilter` | 认证 filter**R1 缺失/无效 key 当场 401 不放行****R2 每 key 限流返回 429**;注入 `KbApiKeyContext` |
| `@RequireKbScope` 注解 + interceptorA1 | 集中授权scope 检查 + KB 归属),仿 `@RequireWorkspaceRole` |
| `KbApiKeyAdminController` | 管理端 CRUD`/api/v1/open/keys/**`JWT 认证) |
| SecurityConfig | `/api/v1/open/kb/**` permitAll + **R6 CORS** |
| 测试 | filter 认证401+ 限流429+ scope 校验 + KB 归属(参照 #438/#439 IDOR 测试范式) |
#### P0-B开放 API 接口9 个端点,一次上线)
A2 决策:一次全上,不分批。端点清单见 §5.1。
| 端点 | 底层方法 | 工作量 |
|---|---|---|
| `GET /pages/{slug}` | `WikiPageService` + 🔧 mode 改造 | 中 |
| `POST /search` | `HybridRetriever` + granularity 后处理 | 中 |
| `POST /search/chunks` | `HybridRetriever.searchChunks` | 低 |
| `GET /pages` | `WikiPageService.listByKbId` | 低 |
| `GET /pages/{slug}/trace` | `PageCitationMapper` | 低 |
| `GET /taxonomy` | 🔧 分组统计组装 | 中 |
| `GET /stats` | 🔧 KbStatsDto 组装 | 中 |
| `GET /whats-new` | `findRecentUpdated` + stale | 低 |
| `POST /pages/{slug}/traverse` | 🔧 `WikiEntityGraphService.traverse()` | 中 |
架构约束A5/A6所有新增 service 方法返回纯 DTO不耦合 HTTP 类型,为 P2 MCP 留钩子。
### P1.5Deep Research 开放(独立议题)
> A3 决策:`/research` 移出 P0。它是唯一异步/SSE/多步 LLM/有成本/需 job 生命周期的端点,与其余同步只读端点本质不同。
单独设计内容:
- 限流 + token 计费(`TokenUsageService`
- job 生命周期管理(查询/取消,当前文档未画)
- SSE 鉴权R7浏览器 EventSource 不能设 Authorization 头 → 仅服务端 client 或 query 参数 token
### P1运营能力
| 任务 | 说明 |
|---|---|
| 用量统计 | 复用 `TokenUsageService` 记录 embedding token 消耗 |
| 审计日志 | 记录每次检索的 query / kbId / keyId |
| 前端管理 UI | `/settings/open-api` 主管理页(仿 `AcpEndpoints.vue`+ Wiki 配置面板只读展示 |
| Token 内核完整共享A4 后续) | CRUD/UI 完整共享,消除 KB-key 与 PAT 两套实现 |
| **WebChat API Key 迁移** | **存量明文 key → hash 存储,无感迁移**(见下) |
#### WebChat API Key 迁移P1独立 PR
> 分两步策略KB 开放 API P0 先抽出 `TokenHashUtil` 并验证P0 完成后,单独 PR 把 WebChat 迁过来。
```
当前(明文) 迁移后hash
config_json: config_json:
api_key: "mc_webchat_3f9a..." api_key_hash: "a1b2c3..."SHA-256
api_key_prefix: "mc_we3f9a"(展示用)
```
改动点:
- **生成**`ChannelService.generateWebChatApiKey()` → 复用 `TokenHashUtil`,生成明文 + 计算 hash只存 hash
- **校验**`WebChatController.resolveChannel()` 从遍历明文 equalsO(n))→ hash 索引查找O(1)
- **签发**`enrichWebChatConfig()` 把 hash 塞入 config_json明文只返回一次
- **数据迁移脚本**:遍历所有 webchat 渠道,读 `config_json.api_key` 明文 → 算 hash → 写 `api_key_hash` + 删明文。存量 key 值不变(`mc_webchat_xxx` 继续有效),无感迁移。
迁移完成后三套 token 共用一套内核:
```
TokenHashUtil共享 hash 生成/校验)
├─ PATmc_ 前缀userId 归属
├─ WebChatmc_webchat_ 前缀channelId 归属 ← P1 迁入
└─ KB-Keymck_ 前缀workspaceId + kbIds 归属
```
### P2MCP ServerAgent 原生接入,低优先级)
> 暂不排期,优先确保 REST 接口完善稳定后再考虑。
底层检索与 P0 共用A6 约束保证 service 层可零成本套壳MCP Server 只是新的传输层。
---
## 10. 风险与对策
| 风险 | 影响 | 对策 | 状态 |
|---|---|---|---|
| 语义检索全库扫描(无 ANN 索引) | 大 KB 下检索慢 | P1 加结果缓存;后续评估 pgvector/Milvus | 待优化 |
| API Key 泄露 | 知识库内容被未授权访问 | hash 存储 + scope 收窄 + 可撤销 + 可设过期 | ✅ 已设计 |
| search 成本风险embedding | 泄露 key 刷爆成本 | **P0 每 key 限流**R2复用 TriggerRateLimiter+ topK 硬上限 | ✅ 已设计 |
| /research 成本风险(多步 LLM | 同上 | **移出 P0**A3P1.5 带限流+计费再开 | ✅ 已规避 |
| traverse predicate 自由文本 | 模糊匹配不可靠 | 配合 get_taxonomy 先查精确值;后续建映射表 | 待优化 |
| traverse depth=2 性能 | 大 KB 可能慢 | 限制 depth ≤ 2 + LIMIT后续加索引 | 待优化 |
| 检索计费盲点 | embedding 成本无法追踪 | P1 接入 `TokenUsageService` | 待实现 |
---
## 11. 已确认的决策
| 问题 | 决策 | 备注 |
|---|---|---|
| API Key 是否跨工作区? | **不跨** | 一个 Key 绑定一个工作区 |
| 检索结果是否按 pageType 脱敏? | **不脱敏** | 开放 API 返回全部命中 |
| 前端管理 UI 放哪? | **Settings `/settings/open-api`** | Key 跨 KB需平台级管理 |
| P2 MCP Server 优先级? | **低** | 先确保 REST 完善 |
| 实体 vs 页面 | **实体就是页面** | 统一为一套接口slug 即标识 |
| 溯源粒度 | **页面级** | page → chunk → raw |
| DATA/FACT/EXPERIENCE 标签 | **复用页面 knowledgeLayer** | 标签在页面上 |
| 实体数据来源 | **文档摄入自动抽取** | 复用现有 wiki 处理流水线 |
| get_entity 标识 | **用 slug** | entityId 概念在 API 中消失traverse 内部用) |
| traverse 实现 | **务实版** | depth ≤ 2 + LIKE + LIMIT |
| A3/research | **移出 P0** | 异步/SSE/多步 LLMP1.5 独立设计 |
| A2P0 范围 | **一次全上 9 端点** | 不分批,契约一次冻结 |
| R2search 限流 | **P0 就做** | 每 key 限流(复用 TriggerRateLimiter |
| A4token 内核 | **和 PAT 共享** | 先共享 hash 层CRUD/UI 后续 |
| A1授权方式 | **集中注解** | `@RequireKbScope` + interceptor不逐端点手写 |
| R3空绑定语义 | **零访问** | 对外 key 不复用"无绑定=全量" |
| WebChat 迁移 | **P1 分两步** | KB P0 先验证 TokenHashUtilP1 单独 PR 迁 WebChat含无感数据迁移 |
---
## 12. Review 修订清单(草案 v2 评审产出)
> 来源:对 v2 的一次代码对照评审。v2.1 已合入全部修订。✅ = 已解决并写入文档。
### 🔴 BlockerP0 动工前定稿)— 全部已解决
- [x] **R1 认证 filter 不能 pass-through§4.1**
→ §4.1 已改为:缺失/无效 key **当场返回 401**,绝不放行。
- [x] **R2 昂贵端点不能裸奔进 P0§4.1 / §9 / §10**
→ search 限流拉进 P0§4.1 限流 + §9 P0-A + §10 风险表research 移出A3
- [x] **R3 空绑定默认开放,对外 key 反模式§3.2 / §4.2**
→ §3.2 / §4.2 已改为:对外 key 空绑定 = 零访问,签发时绑定列表不能为空。
### 🟡 Should-fix — 全部已解决
- [x] **R4 响应信封 `code` 对齐§5.2 / §5.3** ✅ → 所有示例已改 `"code": 200`
- [x] **R5 幽灵端点 `/raw`§4.3 / §5.1** ✅ → 已从 scope 表和端点清单删除。
- [x] **R6 CORS§8.4** ✅ → SecurityConfig 已补 CORS 配置。
- [x] **R7 `/research` 的 SSE × API key§9 P1.5** ✅ → research 移出 P0随 A3 解决。
- [x] **R8 `prefix` 缩短§3.1** ✅ → 已从 8 位改为 4 位。
### 🟢 Nits — 全部已解决
- [x] **R9 granularity=`section` 缺底层§5.2** ✅ → P0 只支持 entity/chunksection 标注"暂不支持"。
- [x] **R10 索引§3.1 / §3.2** ✅ → 删除冗余 `idx_kb_api_key_hash`binding 表补 `kb_id` 索引。
- [x] **R11 traverse 一对一假设§6.2** ✅ → 响应回显实际选中的 `root.entityId`/`root.name`。
- [ ] **R12 流程issue** ⏳ → 动工前在上游 mateaix/mateclaw 开 issue实施时第一步
### 架构层修订 — 全部已决策
- [x] **A1 集中授权§4.2 / §8.2** ✅ → `@RequireKbScope` 注解 + interceptor。
- [x] **A2 P0 范围§9** ✅ → 用户决策:一次全上 9 端点(不分批)。
- [x] **A3 `/research` 移出§9 P1.5** ✅ → 独立议题设计。
- [x] **A4 共享 token 内核§3.5 / §8.2** ✅ → 先共享 hash 层CRUD/UI 后续。
- [x] **A5 对外契约保持"页面中心"§8.2** ✅ → 响应一律显式 DTOentity 仅 traverse 内部细节。
- [x] **A6 P2 MCP 钩子§8.2** ✅ → service 层不耦合 HTTP 类型。
### 评审认可的优点
- "实体即页面、slug 即标识"的统一抽象,砍掉一整套实体中心 API§1.3)。
- SHA-256 hash 存储相对 WebChat 明文是实打实的安全改进§8.3)。
- traverse 的局限性诚实标注 + "配合 get_taxonomy 先查精确 predicate"的务实路径§6.4)。
- 底层服务复用充分、分阶段 + 分批 PR 的实施计划清晰§8.1 / §9