diff --git a/mateclaw-server/src/main/java/vip/mate/config/SecurityConfig.java b/mateclaw-server/src/main/java/vip/mate/config/SecurityConfig.java
index a2f04779c..3db754261 100644
--- a/mateclaw-server/src/main/java/vip/mate/config/SecurityConfig.java
+++ b/mateclaw-server/src/main/java/vip/mate/config/SecurityConfig.java
@@ -14,6 +14,7 @@
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import jakarta.servlet.http.HttpServletResponse;
+import vip.mate.kbopen.auth.KbOpenApiAuthFilter;
/**
* Spring Security 配置
@@ -28,6 +29,7 @@
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 SecurityFilterChain filterChain(
"/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 SecurityFilterChain filterChain(
response.getWriter().write("{\"code\":401,\"msg\":\"Token expired or invalid\",\"data\":null}");
})
)
+ .addFilterBefore(kbOpenApiAuthFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
diff --git a/mateclaw-server/src/main/java/vip/mate/config/WebMvcConfig.java b/mateclaw-server/src/main/java/vip/mate/config/WebMvcConfig.java
index 0b6099c09..84cc30244 100644
--- a/mateclaw-server/src/main/java/vip/mate/config/WebMvcConfig.java
+++ b/mateclaw-server/src/main/java/vip/mate/config/WebMvcConfig.java
@@ -8,6 +8,7 @@
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 @@
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
diff --git a/mateclaw-server/src/main/java/vip/mate/kbopen/auth/KbApiKeyContext.java b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/KbApiKeyContext.java
new file mode 100644
index 000000000..6377730f7
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/KbApiKeyContext.java
@@ -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.
+ *
+ *
This is intentionally not 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 kbIds,
+ Set 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);
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/kbopen/auth/KbApiKeyRateLimiter.java b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/KbApiKeyRateLimiter.java
new file mode 100644
index 000000000..c138ce70d
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/KbApiKeyRateLimiter.java
@@ -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).
+ *
+ * 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> 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 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;
+ }
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/kbopen/auth/KbApiKeyService.java b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/KbApiKeyService.java
new file mode 100644
index 000000000..c0bd0260c
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/KbApiKeyService.java
@@ -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.
+ *
+ * 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 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 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()
+ .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 kbIds = loadBoundKbIds(entity.getId());
+ Set 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 listByWorkspace(Long workspaceId) {
+ return keyMapper.selectList(
+ new LambdaQueryWrapper()
+ .eq(KbApiKeyEntity::getWorkspaceId, workspaceId)
+ .eq(KbApiKeyEntity::getDeleted, 0)
+ .orderByDesc(KbApiKeyEntity::getCreateTime));
+ }
+
+ public Set loadBoundKbIds(Long apiKeyId) {
+ List bindings = bindingMapper.selectList(
+ new LambdaQueryWrapper()
+ .eq(KbApiKeyBindingEntity::getApiKeyId, apiKeyId));
+ return bindings.stream()
+ .map(KbApiKeyBindingEntity::getKbId)
+ .collect(Collectors.toCollection(LinkedHashSet::new));
+ }
+
+ @Transactional
+ public void updateBindings(Long apiKeyId, Set 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()
+ .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 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 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) {}
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/kbopen/auth/KbOpenApiAuthFilter.java b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/KbOpenApiAuthFilter.java
new file mode 100644
index 000000000..d01777c0c
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/KbOpenApiAuthFilter.java
@@ -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.
+ *
+ * R1: this filter must reject, never pass-through. 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 must result in an immediate 401
+ * — it cannot fall through to the Controller (which would run without a
+ * {@link KbApiKeyContext}).
+ *
+ *
R2: per-key rate limiting. 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 = 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}");
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/kbopen/auth/KbScopeInterceptor.java b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/KbScopeInterceptor.java
new file mode 100644
index 000000000..cb238f0eb
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/KbScopeInterceptor.java
@@ -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:
+ *
+ * - Scope check — the request's {@link KbApiKeyContext} must grant the
+ * required scope (or {@code kb:*}).
+ * - KB ownership — the {@code kbId} path variable must be in the
+ * context's bound KB set (R3: empty set = zero access).
+ *
+ *
+ * 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) 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}");
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/kbopen/auth/RequireKbScope.java b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/RequireKbScope.java
new file mode 100644
index 000000000..4425c5438
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/RequireKbScope.java
@@ -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.
+ *
+ * Processed by {@code KbScopeInterceptor} which checks, in order:
+ *
+ * - A {@link KbApiKeyContext} exists on the request (filter ran).
+ * - The context's scopes include the annotation value or {@code kb:*}.
+ * - The path's {@code kbId} variable is in the context's bound KB set.
+ *
+ *
+ * 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();
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/kbopen/auth/TokenHashUtil.java b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/TokenHashUtil.java
new file mode 100644
index 000000000..29b442b2d
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/TokenHashUtil.java
@@ -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).
+ *
+ *
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.
+ *
+ *
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);
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/kbopen/auth/model/KbApiKeyBindingEntity.java b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/model/KbApiKeyBindingEntity.java
new file mode 100644
index 000000000..9ce74f51d
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/model/KbApiKeyBindingEntity.java
@@ -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.
+ *
+ *
An empty binding set means zero access (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;
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/kbopen/auth/model/KbApiKeyEntity.java b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/model/KbApiKeyEntity.java
new file mode 100644
index 000000000..3096cdf3d
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/model/KbApiKeyEntity.java
@@ -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.
+ *
+ *
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;
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/kbopen/auth/repository/KbApiKeyBindingMapper.java b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/repository/KbApiKeyBindingMapper.java
new file mode 100644
index 000000000..d6792c4d5
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/repository/KbApiKeyBindingMapper.java
@@ -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 {
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/kbopen/auth/repository/KbApiKeyMapper.java b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/repository/KbApiKeyMapper.java
new file mode 100644
index 000000000..828a50f24
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/repository/KbApiKeyMapper.java
@@ -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 {
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/kbopen/controller/KbApiKeyAdminController.java b/mateclaw-server/src/main/java/vip/mate/kbopen/controller/KbApiKeyAdminController.java
new file mode 100644
index 000000000..8de3dab83
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/kbopen/controller/KbApiKeyAdminController.java
@@ -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).
+ *
+ * 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(
+ @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