WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
@@ -0,0 +1,27 @@
package router
import (
"github.com/Tencent/WeKnora/internal/handler"
"github.com/Tencent/WeKnora/internal/sandbox"
)
func deploymentCapabilitiesFromRouter(params RouterParams) handler.DeploymentCapabilitiesData {
return handler.BuildDeploymentCapabilities(handler.Edition, handler.DeploymentFeatureAvailability{
Organizations: params.OrganizationHandler != nil,
Agents: params.CustomAgentHandler != nil,
IM: params.IMHandler != nil,
// Match RegisterEmbedChannelRoutes: management routes depend on handler only.
Embed: params.EmbedChannelHandler != nil,
API: params.TenantHandler != nil && params.TenantAPIKeyService != nil,
MCP: params.MCPServiceHandler != nil &&
params.MCPCredentialsHandler != nil &&
params.MCPOAuthHandler != nil,
WebSearch: params.WebSearchHandler != nil &&
params.WebSearchProviderHandler != nil &&
params.WebSearchCredentialsHandler != nil,
VectorStore: params.VectorStoreHandler != nil,
Storage: params.StorageBackendHandler != nil,
Sandbox: params.SandboxConfigHandler != nil,
SandboxDocker: sandbox.DockerBackendEnabled(),
})
}
@@ -0,0 +1,110 @@
package router
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Tencent/WeKnora/internal/handler"
"github.com/gin-gonic/gin"
)
func allDeploymentFeaturesAvailable() handler.DeploymentFeatureAvailability {
return handler.DeploymentFeatureAvailability{
Organizations: true,
Agents: true,
IM: true,
Embed: true,
API: true,
MCP: true,
WebSearch: true,
VectorStore: true,
Storage: true,
Sandbox: true,
}
}
func TestBuildDeploymentCapabilitiesHidesOrganizationsInLite(t *testing.T) {
result := handler.BuildDeploymentCapabilities("lite", allDeploymentFeaturesAvailable())
organization := result.Capabilities["organizations"]
if organization.Supported {
t.Fatal("organizations should be unsupported in lite edition")
}
if organization.Reason != "not_supported_in_lite" {
t.Fatalf("organization reason = %q, want not_supported_in_lite", organization.Reason)
}
if !result.Capabilities["agents"].Supported {
t.Fatal("agents should remain supported in lite edition")
}
}
func TestBuildDeploymentCapabilitiesHidesDockerUnlessOptedIn(t *testing.T) {
result := handler.BuildDeploymentCapabilities("standard", allDeploymentFeaturesAvailable())
docker := result.Capabilities["settings.sandbox.docker"]
if docker.Supported {
t.Fatal("docker sandbox must stay disabled until the deployment opts in")
}
if docker.Reason != "docker_backend_disabled" {
t.Fatalf("docker reason = %q, want docker_backend_disabled", docker.Reason)
}
available := allDeploymentFeaturesAvailable()
available.SandboxDocker = true
enabled := handler.BuildDeploymentCapabilities("standard", available)
if !enabled.Capabilities["settings.sandbox.docker"].Supported {
t.Fatal("SandboxDocker true must expose the docker backend")
}
}
func TestBuildDeploymentCapabilitiesReflectsMissingRoutes(t *testing.T) {
available := allDeploymentFeaturesAvailable()
available.Embed = false
available.MCP = false
result := handler.BuildDeploymentCapabilities("standard", available)
for _, key := range []string{"integrations.embed", "settings.mcp"} {
capability := result.Capabilities[key]
if capability.Supported {
t.Fatalf("%s should be unsupported", key)
}
if capability.Reason != "route_not_registered" {
t.Fatalf("%s reason = %q, want route_not_registered", key, capability.Reason)
}
}
if !result.Capabilities["settings.storage"].Supported {
t.Fatal("an available route should remain supported")
}
}
func TestGetDeploymentCapabilitiesHandlerReturnsSnapshot(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
want := handler.BuildDeploymentCapabilities("standard", allDeploymentFeaturesAvailable())
systemHandler := &handler.SystemHandler{}
systemHandler.BindDeploymentCapabilities(want)
engine.GET("/capabilities", systemHandler.GetDeploymentCapabilities)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/capabilities", nil)
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
}
var body struct {
Code int `json:"code"`
Data handler.DeploymentCapabilitiesData `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatalf("decode response: %v", err)
}
if body.Code != 0 || body.Data.Edition != "standard" {
t.Fatalf("response = %#v", body)
}
if !body.Data.Capabilities["integrations.embed"].Supported {
t.Fatal("embed capability should be returned")
}
}
+886
View File
@@ -0,0 +1,886 @@
package router
import (
"context"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/Tencent/WeKnora/internal/application/service"
filesvc "github.com/Tencent/WeKnora/internal/application/service/file"
"github.com/gin-gonic/gin"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/middleware"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
secutils "github.com/Tencent/WeKnora/internal/utils"
)
// files.go hosts every file-proxy surface the router exposes:
//
// - /files tenant-scoped raw storage proxy
// - /api/v1/knowledge-bases/:id/files KB-scoped proxy (shared-KB images)
// - /api/v1/sessions/:id/messages/:message_id/files
// message-scoped proxy (shared-agent output)
// - /api/v1/files/presigned HMAC-signed anonymous access (IM)
// - /api/v1/files/presigned-preview Admin-only URL diagnostics
// - /r/:token short-lived capability URLs
//
// The handlers differ in how they authenticate and which tenant owns the
// object, but share the same storage plumbing — the helpers directly below
// are that shared plumbing.
// getRouteRegistrar is the minimal registration surface serveFiles* needs;
// both *gin.Engine and *gin.RouterGroup satisfy it, which keeps the file
// routes testable without building a full engine.
type getRouteRegistrar interface {
GET(string, ...gin.HandlerFunc) gin.IRoutes
}
// messageFileLookup is the narrow message-service surface needed by the
// message-scoped file proxy. Keeping it small makes the authorization boundary
// independently testable.
type messageFileLookup interface {
GetMessage(ctx context.Context, sessionID, messageID string) (*types.Message, error)
}
// sharedAgentFileLookup verifies that a source workspace's agent is still
// shared to the caller. Revoking the share therefore also revokes historical
// message-file access.
type sharedAgentFileLookup interface {
GetSharedAgentForTenant(
ctx context.Context,
tenantID uint64,
callerTenantRole types.TenantRole,
agentID string,
sourceTenantID ...uint64,
) (*types.CustomAgent, error)
}
// localStorageBaseDir resolves LOCAL_STORAGE_BASE_DIR with the container
// default.
func localStorageBaseDir() string {
baseDir := strings.TrimSpace(os.Getenv("LOCAL_STORAGE_BASE_DIR"))
if baseDir == "" {
baseDir = "/data/files"
}
return baseDir
}
// localStorageAbsDir is localStorageBaseDir made absolute — the form the
// storage resolvers expect.
func localStorageAbsDir() string {
absDir, _ := filepath.Abs(localStorageBaseDir())
return absDir
}
// parseStorageTarget splits a stored file path into its optional storage
// backend ID and provider scheme (e.g. "backend://3/local://7/x.png" ->
// ("3", "local"); "cos://7/x.png" -> ("", "cos")).
func parseStorageTarget(filePath string) (backendID, provider string) {
backendID, providerPath, scoped := types.ParseStorageBackendPath(filePath)
if !scoped {
providerPath = filePath
}
return backendID, types.ParseProviderScheme(providerPath)
}
// requireFilePathQuery validates the file_path query parameter every proxy
// route accepts. On failure the 400 response has been written and ok=false.
func requireFilePathQuery(c *gin.Context) (string, bool) {
filePath := strings.TrimSpace(c.Query("file_path"))
if filePath == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing required parameter: file_path"})
return "", false
}
if strings.Contains(filePath, "..") {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file path"})
return "", false
}
return filePath, true
}
func authorizeProxyFile(c *gin.Context, catalog interfaces.ResourceCatalog, value string, scope interfaces.ResourceFileScope) bool {
if authorizer, ok := catalog.(interfaces.ResourceFileAuthorizer); ok {
if err := authorizer.AuthorizeFileRead(c.Request.Context(), value, scope); err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden: file not accessible in this scope"})
return false
}
}
return true
}
// resolveCatalogResource maps a logical resource path onto its physical path
// when the catalog knows it, enforcing that the resource belongs to
// ownerTenantID. isResource reports whether the path resolved to a registered
// resource (whose tenant is then authoritative). On !ok the response has been
// written.
func resolveCatalogResource(
c *gin.Context,
catalog interfaces.ResourceCatalog,
filePath string,
ownerTenantID uint64,
) (resolved string, isResource, ok bool) {
if catalog == nil {
return filePath, false, true
}
resolvedPath, resource, err := catalog.ResolvePath(c.Request.Context(), filePath)
if err != nil {
c.Status(http.StatusNotFound)
return "", false, false
}
if resource == nil {
return filePath, false, true
}
if resource.TenantID != ownerTenantID {
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden: resource not accessible"})
return "", false, false
}
return resolvedPath, true, true
}
// resolveFileService picks the file service for (tenant, backendID, provider)
// — via the storage resolver when wired, else directly from the tenant's
// storage config. No fallback; used by the presigned surfaces where a
// missing tenant config must surface as an error.
func resolveFileService(
ctx context.Context,
tenant *types.Tenant,
backendID, provider, absDir string,
storageResolver interfaces.StorageBackendResolver,
) (interfaces.FileService, string, error) {
if storageResolver != nil {
return storageResolver.ResolveFileService(ctx, tenant, backendID, provider, absDir)
}
return filesvc.NewFileServiceFromStorageConfig(provider, tenant.StorageEngineConfig, absDir)
}
// resolveTenantFileServiceWithFallback is resolveFileService plus the
// fallback rule shared by /files and the KB-scoped proxy: when tenant-level
// resolution fails and the requested provider matches the process-global
// STORAGE_TYPE, serve through the global file service instead of failing.
// Returns ok=false (already logged) when no service can be resolved; the
// caller decides the HTTP status.
func resolveTenantFileServiceWithFallback(
ctx context.Context,
logTag string,
tenant *types.Tenant,
backendID, provider, absDir string,
storageResolver interfaces.StorageBackendResolver,
globalFileService interfaces.FileService,
) (fileSvc interfaces.FileService, resolvedProvider string, ok bool) {
var err error
if storageResolver != nil {
fileSvc, resolvedProvider, err = storageResolver.ResolveFileService(ctx, tenant, backendID, provider, absDir)
} else if tenant.StorageEngineConfig != nil {
fileSvc, resolvedProvider, err = filesvc.NewFileServiceFromStorageConfig(provider, tenant.StorageEngineConfig, absDir)
} else {
err = http.ErrMissingFile
}
if err == nil {
return fileSvc, resolvedProvider, true
}
globalStorageType := strings.ToLower(strings.TrimSpace(os.Getenv("STORAGE_TYPE")))
if globalStorageType == "" {
globalStorageType = "local"
}
if provider == globalStorageType && globalFileService != nil {
logger.Warnf(ctx, "[Router] %s tenant storage config missing or invalid, fallback to global file service: tenant_id=%d provider=%s err=%v",
logTag, tenant.ID, provider, err)
return globalFileService, globalStorageType, true
}
logger.Warnf(ctx, "[Router] %s resolve file service failed without fallback: tenant_id=%d provider=%s global_storage_type=%s err=%v",
logTag, tenant.ID, provider, globalStorageType, err)
return nil, "", false
}
// streamStoredFile writes the shared success response of every file proxy:
// safe content type, nosniff, disposition for non-inline types, the route's
// cache policy, then the body (skipped for HEAD). Closes reader.
func streamStoredFile(c *gin.Context, reader io.ReadCloser, contentType string, inline bool, cacheControl, logTag string) {
defer reader.Close()
c.Header("Content-Type", contentType)
c.Header("X-Content-Type-Options", "nosniff")
if !inline {
c.Header("Content-Disposition", "attachment")
}
c.Header("Cache-Control", cacheControl)
c.Status(http.StatusOK)
if c.Request.Method == http.MethodHead {
return
}
if _, err := io.Copy(c.Writer, reader); err != nil {
logger.Warnf(c.Request.Context(), "[Router] %s write response failed: %v", logTag, err)
}
}
// newFileServeHandler builds the file-proxy handler. It reads the tenant from
// the request context (set by whichever auth middleware precedes it), so the
// same handler backs both the authenticated /files route and the embed route
// (where EmbedAuth injects the channel's tenant). Tenant ownership of the
// requested path is enforced via ValidateStoragePathTenant either way.
func newFileServeHandler(
globalFileService interfaces.FileService,
storageResolver interfaces.StorageBackendResolver,
resourceCatalogs ...interfaces.ResourceCatalog,
) gin.HandlerFunc {
resourceCatalog := firstResourceCatalog(resourceCatalogs)
absDir := localStorageAbsDir()
if info, err := os.Stat(absDir); err != nil || !info.IsDir() {
if err := os.MkdirAll(absDir, 0o755); err != nil {
logger.Warnf(context.Background(), "[Router] Cannot create local storage dir %s: %v", absDir, err)
}
}
return func(c *gin.Context) {
filePath, ok := requireFilePathQuery(c)
if !ok {
return
}
tenant, _ := c.Request.Context().Value(types.TenantInfoContextKey).(*types.Tenant)
if tenant == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized: workspace context missing"})
return
}
scope := interfaces.ResourceFileScope{TenantID: tenant.ID}
if channel, embedded := middleware.EmbedChannelFromContext(c.Request.Context()); embedded {
scope.EmbedChannelID = channel.ID
if sessionID := strings.TrimSpace(c.Query("session_id")); sessionID != "" && service.VerifyEmbedSessionHandle(channel, sessionID, c.GetHeader("X-Embed-Session")) {
scope.SessionID = sessionID
principal := types.EmbedSessionPrincipal(channel.TenantID, channel.ID, sessionID)
ctx := context.WithValue(c.Request.Context(), types.UserIDContextKey, principal.StorageID())
c.Request = c.Request.WithContext(types.WithPrincipal(ctx, principal))
}
}
if !authorizeProxyFile(c, resourceCatalog, filePath, scope) {
return
}
filePath, resourceResolved, ok := resolveCatalogResource(c, resourceCatalog, filePath, tenant.ID)
if !ok {
return
}
// A registered resource's tenant is authoritative. Physical provider
// paths remain an internal locator and are not required to encode access
// control metadata (some cloud layouts contain other numeric segments).
if !resourceResolved {
if err := secutils.ValidateStoragePathTenant(filePath, tenant.ID); err != nil {
logger.Warnf(c.Request.Context(),
"[Router] /files denied cross-tenant or invalid path: tenant_id=%d file_path=%q err=%v",
tenant.ID, filePath, err)
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden: file path not accessible"})
return
}
}
backendID, provider := parseStorageTarget(filePath)
fileSvc, resolvedProvider, ok := resolveTenantFileServiceWithFallback(
c.Request.Context(), "/files", tenant, backendID, provider, absDir, storageResolver, globalFileService)
if !ok {
c.Status(http.StatusBadRequest)
return
}
reader, err := fileSvc.GetFile(c.Request.Context(), filePath)
if err != nil {
logger.Warnf(c.Request.Context(), "[Router] /files get file failed: tenant_id=%d provider=%s path=%q err=%v",
tenant.ID, resolvedProvider, filePath, err)
c.Status(http.StatusNotFound)
return
}
contentType, inline := secutils.SafeContentTypeByFilename(filePath)
streamStoredFile(c, reader, contentType, inline, "private, no-store", "/files")
}
}
func serveFiles(r getRouteRegistrar, globalFileService interfaces.FileService, resolvers ...interfaces.StorageBackendResolver) {
var storageResolver interfaces.StorageBackendResolver
if len(resolvers) > 0 {
storageResolver = resolvers[0]
}
serveFilesWithResources(r, globalFileService, storageResolver, nil)
}
// serveFilesWithResources registers the tenant-scoped storage proxy.
// It is registered after auth middleware, so tenant context comes from
// authentication.
//
// Route:
// - GET /files?file_path=<provider://...>
func serveFilesWithResources(
r getRouteRegistrar,
globalFileService interfaces.FileService,
storageResolver interfaces.StorageBackendResolver,
resourceCatalog interfaces.ResourceCatalog,
) {
logger.Infof(context.Background(), "[Router] Serving files from /files")
// /files sits outside the /api/v1 APIKeyGate, so it carries its own
// API-key guard. A KB-restricted key is denied (a raw storage path cannot
// be bounded to its allow-list); full-access keys and tenant-wide retrieve
// keys pass, since the handler still enforces same-tenant paths
// (ValidateStoragePathTenant). Embed routes use their own
// /embed/.../files handler.
r.GET(
"/files",
middleware.AllowFileServeAPIKey(),
newFileServeHandler(globalFileService, storageResolver, resourceCatalog),
)
}
// serveResourceGrants exposes short, revocable capability URLs for clients
// such as IM platforms that cannot attach a WeKnora bearer/embed token.
func serveResourceGrants(
r *gin.Engine,
resourceCatalog interfaces.ResourceCatalog,
tenantService interfaces.TenantService,
globalFileService interfaces.FileService,
storageResolver interfaces.StorageBackendResolver,
) {
if resourceCatalog == nil || tenantService == nil {
return
}
handler := func(c *gin.Context) {
ctx := c.Request.Context()
resource, err := resourceCatalog.ResolveAccessGrant(ctx, c.Param("token"))
if err != nil || resource == nil {
c.Status(http.StatusNotFound)
return
}
tenant, err := tenantService.GetTenantByID(ctx, resource.TenantID)
if err != nil || tenant == nil {
c.Status(http.StatusNotFound)
return
}
// The grant row carries its own backend ID; only the provider scheme
// comes from the physical path.
_, provider := parseStorageTarget(resource.PhysicalPath)
var fileSvc interfaces.FileService
if storageResolver != nil {
fileSvc, _, err = storageResolver.ResolveFileService(
ctx, tenant, resource.StorageBackendID, provider, localStorageBaseDir())
} else {
fileSvc = globalFileService
}
if err != nil || fileSvc == nil {
logger.Warnf(ctx, "[Router] resource grant storage resolution failed: resource_id=%s err=%v", resource.ID, err)
c.Status(http.StatusNotFound)
return
}
reader, err := fileSvc.GetFile(ctx, resource.PhysicalPath)
if err != nil {
c.Status(http.StatusNotFound)
return
}
fileName := resource.OriginalName
if fileName == "" {
fileName = resource.PhysicalPath
}
contentType, inline := secutils.SafeContentTypeByFilename(fileName)
if resource.MimeType != "" && inline {
contentType = resource.MimeType
}
streamStoredFile(c, reader, contentType, inline, "private, max-age=300",
"resource grant (resource_id="+resource.ID+")")
}
r.GET("/r/:token", handler)
r.HEAD("/r/:token", handler)
}
// serveKBScopedFiles registers the KB-scoped file proxy used to render images
// embedded in a knowledge base's content (chunks / wiki pages). Unlike the
// tenant-scoped /files route — which enforces file_path.tenant == caller.tenant
// and therefore cannot serve objects owned by another tenant — this route is
// gated by RequireKBAccess. That guard resolves org-shared / agent-visible KBs
// and rewrites the request context's tenant ID to the KB's *owner* (source)
// tenant, so images stored under the owner tenant (local://<owner>/exports/...)
// become reachable by tenants that legitimately share the KB, while still
// enforcing that the requested path belongs to that owner tenant.
//
// Route:
// - GET /api/v1/knowledge-bases/:id/files?file_path=<provider://...>
func serveKBScopedFiles(
r *gin.RouterGroup,
g *rbacGuards,
tenantService interfaces.TenantService,
globalFileService interfaces.FileService,
storageResolver interfaces.StorageBackendResolver,
resourceCatalogs ...interfaces.ResourceCatalog,
) {
logger.Infof(context.Background(), "[Router] Serving KB-scoped files from /knowledge-bases/:id/files")
// API-key access mirrors /files: KB-restricted keys are denied (an
// arbitrary file_path under the KB owner tenant cannot be bounded to a
// key's allow-list), while full-access and tenant-wide retrieve keys pass
// — KBAccessRead still confines them to KBs they may read (own /
// org-shared / agent-visible), exactly as it does for a JWT Viewer. The
// route is declared to the gate with the retrieve policy so it is reachable
// at all; AllowFileServeAPIKey then applies the stricter not-KB-restricted
// constraint.
g.apiKeyRoute(r, http.MethodGet, "/knowledge-bases/:id/files",
apiKeyRetrieve(apiKeyFullAccess()),
middleware.AllowFileServeAPIKey(),
g.Viewer(),
g.KBAccessRead("id"),
newKBScopedFileServeHandlerWithResources(
tenantService,
globalFileService,
storageResolver,
firstResourceCatalog(resourceCatalogs),
),
)
}
// newKBScopedFileServeHandler builds the handler backing serveKBScopedFiles.
// The effective (owner) tenant is taken from the request context, which
// RequireKBAccess has already rewritten to the KB's source tenant. The owner
// tenant's storage config is loaded via TenantService so the file is fetched
// from the backend that actually holds it — the caller's own storage config is
// irrelevant here.
func newKBScopedFileServeHandler(
tenantService interfaces.TenantService,
globalFileService interfaces.FileService,
resolvers ...interfaces.StorageBackendResolver,
) gin.HandlerFunc {
var storageResolver interfaces.StorageBackendResolver
if len(resolvers) > 0 {
storageResolver = resolvers[0]
}
return newKBScopedFileServeHandlerWithResources(tenantService, globalFileService, storageResolver, nil)
}
func firstResourceCatalog(catalogs []interfaces.ResourceCatalog) interfaces.ResourceCatalog {
if len(catalogs) == 0 {
return nil
}
return catalogs[0]
}
func newKBScopedFileServeHandlerWithResources(
tenantService interfaces.TenantService,
globalFileService interfaces.FileService,
storageResolver interfaces.StorageBackendResolver,
resourceCatalog interfaces.ResourceCatalog,
) gin.HandlerFunc {
absDir := localStorageAbsDir()
return func(c *gin.Context) {
ctx := c.Request.Context()
filePath, ok := requireFilePathQuery(c)
if !ok {
return
}
// RequireKBAccess rewrote the request context tenant ID to the KB's
// owner (source) tenant for shared KBs; for own KBs it equals the
// caller's tenant. Either way it is the tenant that owns this KB's
// storage objects, so the requested path must belong to it.
ownerTenantID, ok := types.TenantIDFromContext(ctx)
if !ok || ownerTenantID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized: workspace context missing"})
return
}
if !authorizeProxyFile(c, resourceCatalog, filePath, interfaces.ResourceFileScope{TenantID: ownerTenantID, KnowledgeBaseID: c.Param("id")}) {
return
}
filePath, _, ok = resolveCatalogResource(c, resourceCatalog, filePath, ownerTenantID)
if !ok {
return
}
if err := secutils.ValidateKBScopedStoragePath(filePath, ownerTenantID); err != nil {
logger.Warnf(ctx, "[Router] /knowledge-bases/:id/files denied path not allowed for KB proxy: owner_tenant_id=%d file_path=%q err=%v",
ownerTenantID, filePath, err)
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden: file path not accessible"})
return
}
tenant, err := tenantService.GetTenantByID(ctx, ownerTenantID)
if err != nil || tenant == nil {
logger.Warnf(ctx, "[Router] /knowledge-bases/:id/files owner tenant lookup failed: owner_tenant_id=%d err=%v",
ownerTenantID, err)
c.Status(http.StatusNotFound)
return
}
backendID, provider := parseStorageTarget(filePath)
fileSvc, resolvedProvider, ok := resolveTenantFileServiceWithFallback(
ctx, "/knowledge-bases/:id/files", tenant, backendID, provider, absDir, storageResolver, globalFileService)
if !ok {
c.Status(http.StatusBadRequest)
return
}
reader, err := fileSvc.GetFile(ctx, filePath)
if err != nil {
logger.Warnf(ctx, "[Router] /knowledge-bases/:id/files get file failed: owner_tenant_id=%d provider=%s path=%q err=%v",
ownerTenantID, resolvedProvider, filePath, err)
c.Status(http.StatusNotFound)
return
}
contentType, inline := secutils.SafeContentTypeByFilename(filePath)
// Cross-tenant shared content — keep it private so shared proxies /
// CDNs do not cache one tenant's view for another.
streamStoredFile(c, reader, contentType, inline, "private, no-store", "/knowledge-bases/:id/files")
}
}
// newMessageScopedFileServeHandler serves resources rendered inside one
// assistant message. The message service first proves that the caller owns the
// containing session. For cross-workspace resources we then require the
// message's agent to still be shared from the resource-owning workspace.
//
// The owner tenant comes from the resource registry whenever possible. This
// also keeps old messages (written before agent_tenant_id was populated)
// readable without accepting a client-provided source workspace ID.
func newMessageScopedFileServeHandler(
messageService messageFileLookup,
agentShareService sharedAgentFileLookup,
tenantService interfaces.TenantService,
globalFileService interfaces.FileService,
storageResolver interfaces.StorageBackendResolver,
resourceCatalog interfaces.ResourceCatalog,
) gin.HandlerFunc {
absDir := localStorageAbsDir()
return func(c *gin.Context) {
ctx := c.Request.Context()
filePath, ok := requireFilePathQuery(c)
if !ok {
return
}
callerTenantID, ok := types.TenantIDFromContext(ctx)
if !ok || callerTenantID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized: workspace context missing"})
return
}
if messageService == nil {
c.Status(http.StatusNotFound)
return
}
message, err := messageService.GetMessage(ctx, c.Param("id"), c.Param("message_id"))
if err != nil || message == nil {
// Do not reveal whether a message exists outside the caller's session.
c.Status(http.StatusNotFound)
return
}
resolvedPath := filePath
var resource *types.StoredResource
if resourceCatalog != nil {
resolvedPath, resource, err = resourceCatalog.ResolvePath(ctx, filePath)
if err != nil {
c.Status(http.StatusNotFound)
return
}
}
ownerTenantID := message.AgentTenantID
if resource != nil {
ownerTenantID = resource.TenantID
// A modern message records its source tenant. A resource from any
// other tenant cannot be smuggled through that message even if the
// caller happens to have another shared agent with the same ID.
if message.AgentTenantID != 0 && message.AgentTenantID != ownerTenantID {
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden: resource not accessible from this message"})
return
}
}
if ownerTenantID == 0 {
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden: message resource workspace missing"})
return
}
if !authorizeProxyFile(c, resourceCatalog, filePath, interfaces.ResourceFileScope{TenantID: ownerTenantID, MessageID: c.Param("message_id"), SessionID: c.Param("id")}) {
return
}
if ownerTenantID != callerTenantID {
if message.AgentID == "" || agentShareService == nil {
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden: shared agent access required"})
return
}
agent, shareErr := agentShareService.GetSharedAgentForTenant(
ctx,
callerTenantID,
types.TenantRoleFromContext(ctx),
message.AgentID,
ownerTenantID,
)
if shareErr != nil || agent == nil || agent.TenantID != ownerTenantID {
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden: shared agent access revoked"})
return
}
}
// Registered resources carry authoritative tenant ownership. Legacy
// provider paths must still encode the authorized owner tenant.
if resource == nil {
if err := secutils.ValidateStoragePathTenant(resolvedPath, ownerTenantID); err != nil {
logger.Warnf(ctx,
"[Router] message files denied cross-tenant or invalid path: owner_tenant_id=%d file_path=%q err=%v",
ownerTenantID, resolvedPath, err)
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden: file path not accessible"})
return
}
}
ownerTenant, err := tenantService.GetTenantByID(ctx, ownerTenantID)
if err != nil || ownerTenant == nil {
c.Status(http.StatusNotFound)
return
}
backendID, provider := parseStorageTarget(resolvedPath)
fileSvc, resolvedProvider, ok := resolveTenantFileServiceWithFallback(
ctx,
"/sessions/:id/messages/:message_id/files",
ownerTenant,
backendID,
provider,
absDir,
storageResolver,
globalFileService,
)
if !ok {
c.Status(http.StatusBadRequest)
return
}
reader, err := fileSvc.GetFile(ctx, resolvedPath)
if err != nil {
logger.Warnf(ctx,
"[Router] message files get file failed: owner_tenant_id=%d provider=%s path=%q err=%v",
ownerTenantID, resolvedProvider, resolvedPath, err)
c.Status(http.StatusNotFound)
return
}
contentType, inline := secutils.SafeContentTypeByFilename(resolvedPath)
if resource != nil && resource.MimeType != "" && inline {
contentType = resource.MimeType
}
streamStoredFile(c, reader, contentType, inline, "private, no-store", "message files")
}
}
// serveMessageScopedFiles registers the authenticated proxy used by the chat
// renderer for assistant-message resources. The chat API-key capability is
// sufficient because GetMessage enforces ownership of the API key's session.
func serveMessageScopedFiles(
r *gin.RouterGroup,
g *rbacGuards,
messageService interfaces.MessageService,
agentShareService interfaces.AgentShareService,
tenantService interfaces.TenantService,
globalFileService interfaces.FileService,
storageResolver interfaces.StorageBackendResolver,
resourceCatalog interfaces.ResourceCatalog,
) {
g.apiKeyRoute(
r,
http.MethodGet,
"/sessions/:id/messages/:message_id/files",
apiKeyChat(apiKeyFullAccess()),
g.Viewer(),
newMessageScopedFileServeHandler(
messageService,
agentShareService,
tenantService,
globalFileService,
storageResolver,
resourceCatalog,
),
)
}
// servePresignedFiles serves files via HMAC-signed URLs without requiring authentication.
// This is used by IM channels to serve images that are embedded in bot replies.
//
// Routes:
// - GET /api/v1/files/presigned?file_path=<provider://...>&tenant_id=<id>&expires=<unix>&sig=<hmac>
// - HEAD /api/v1/files/presigned?... (IM platforms issue HEAD first to validate
// Content-Type / Content-Length before rendering image previews; HEAD must
// succeed or the inline image renders as broken)
//
// Failure paths log client IP + User-Agent + (truncated) file_path so operators
// can correlate an IM platform's fetch against the upstream signing log line.
// Without this it is otherwise impossible to tell whether a "broken image" is
// caused by an expired signature, a stale URL cached by the platform, the
// platform's IP being blocked, or the URL simply never reaching us.
func servePresignedFiles(r *gin.Engine, tenantService interfaces.TenantService, storageResolver interfaces.StorageBackendResolver) {
handler := presignedFileHandler(tenantService, localStorageAbsDir(), storageResolver)
r.GET("/api/v1/files/presigned", handler)
r.HEAD("/api/v1/files/presigned", handler)
}
// presignedFileHandler returns the shared Gin handler used by both GET and HEAD.
// For HEAD requests it returns the same status + headers but does not stream
// the body — this is enough for IM platforms to validate the URL while saving
// us a full read of the backing object.
func presignedFileHandler(tenantService interfaces.TenantService, absDir string, resolvers ...interfaces.StorageBackendResolver) gin.HandlerFunc {
var storageResolver interfaces.StorageBackendResolver
if len(resolvers) > 0 {
storageResolver = resolvers[0]
}
return func(c *gin.Context) {
ctx := c.Request.Context()
clientIP := c.ClientIP()
userAgent := c.Request.UserAgent()
filePath := strings.TrimSpace(c.Query("file_path"))
tenantIDStr := strings.TrimSpace(c.Query("tenant_id"))
expiresStr := strings.TrimSpace(c.Query("expires"))
sig := strings.TrimSpace(c.Query("sig"))
if filePath == "" || tenantIDStr == "" || expiresStr == "" || sig == "" {
logger.Warnf(ctx, "[Router] /files/presigned missing params: client_ip=%s ua=%q file_path=%q tenant_id=%q expires=%q has_sig=%v",
clientIP, userAgent, filePath, tenantIDStr, expiresStr, sig != "")
c.JSON(http.StatusBadRequest, gin.H{"error": "missing required parameters"})
return
}
if strings.Contains(filePath, "..") {
logger.Warnf(ctx, "[Router] /files/presigned rejected path traversal: client_ip=%s file_path=%q", clientIP, filePath)
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file path"})
return
}
tenantID, err := strconv.ParseUint(tenantIDStr, 10, 64)
if err != nil {
logger.Warnf(ctx, "[Router] /files/presigned invalid tenant_id: client_ip=%s tenant_id=%q err=%v", clientIP, tenantIDStr, err)
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tenant_id"})
return
}
// Verify HMAC signature and expiry. Logged at Warn because every 403
// here is a signal worth investigating: either the URL was tampered
// with, the IM platform cached an expired URL, or SYSTEM_AES_KEY was
// rotated without invalidating in-flight links.
if !secutils.VerifyFileURLSig(filePath, tenantID, expiresStr, sig) {
logger.Warnf(ctx, "[Router] /files/presigned sig invalid or expired: client_ip=%s ua=%q tenant_id=%d file_path=%q expires=%s",
clientIP, userAgent, tenantID, filePath, expiresStr)
c.JSON(http.StatusForbidden, gin.H{"error": "invalid or expired signature"})
return
}
tenant, err := tenantService.GetTenantByID(ctx, tenantID)
if err != nil {
logger.Warnf(ctx, "[Router] /files/presigned tenant lookup failed: client_ip=%s tenant_id=%d err=%v", clientIP, tenantID, err)
c.Status(http.StatusNotFound)
return
}
backendID, provider := parseStorageTarget(filePath)
fileSvc, resolvedProvider, err := resolveFileService(ctx, tenant, backendID, provider, absDir, storageResolver)
if err != nil {
logger.Warnf(ctx, "[Router] /files/presigned resolve file service failed: client_ip=%s tenant_id=%d provider=%s err=%v",
clientIP, tenantID, provider, err)
c.Status(http.StatusBadRequest)
return
}
// HEAD short-circuits the body read inside streamStoredFile. We still
// need to confirm the object exists via GetFile: skipping it entirely
// for HEAD would risk reporting 200 for a signed URL that no longer
// points at a real object, making subsequent GETs from the same client
// mysteriously fail.
reader, err := fileSvc.GetFile(ctx, filePath)
if err != nil {
logger.Warnf(ctx, "[Router] /files/presigned get file failed: client_ip=%s tenant_id=%d provider=%s path=%q err=%v",
clientIP, tenantID, resolvedProvider, filePath, err)
c.Status(http.StatusNotFound)
return
}
contentType, inline := secutils.SafeContentTypeByFilename(filePath)
streamStoredFile(c, reader, contentType, inline, "public, max-age=86400", "/files/presigned")
}
}
// servePresignedPreview registers an Admin-only diagnostic endpoint that
// returns the presigned HTTP URL that *would be* generated for a given
// storage path by the calling tenant's current storage config — exactly the
// URL an IM channel would embed in a reply. Operators can paste the result
// into a 4G/mobile browser to verify public reachability without having to
// send a real message through an IM bot.
//
// Route:
// - GET /api/v1/files/presigned-preview?file_path=<provider://...>
func servePresignedPreview(r *gin.Engine, cfg *config.Config, storageResolver interfaces.StorageBackendResolver) {
absDir := localStorageAbsDir()
// This route is registered on the engine root, NOT the /api/v1 group,
// so the APIKeyGate never runs for it. RequireRole short-circuits
// API-key principals (deferring to that absent gate), which would let
// any valid key past the Admin check. Deny API keys explicitly first.
r.GET("/api/v1/files/presigned-preview",
middleware.DenyAPIKeyPrincipal(),
middleware.RequireRole(types.TenantRoleAdmin, cfg),
func(c *gin.Context) {
ctx := c.Request.Context()
filePath, ok := requireFilePathQuery(c)
if !ok {
return
}
tenant, _ := ctx.Value(types.TenantInfoContextKey).(*types.Tenant)
if tenant == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized: workspace context missing"})
return
}
if err := secutils.ValidateStoragePathTenant(filePath, tenant.ID); err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden: file path not accessible"})
return
}
backendID, provider := parseStorageTarget(filePath)
fileSvc, resolvedProvider, err := resolveFileService(ctx, tenant, backendID, provider, absDir, storageResolver)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": err.Error(),
"provider": provider,
"hint": "workspace storage config is missing or incomplete for this provider",
})
return
}
httpURL, err := fileSvc.GetFileURL(ctx, filePath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
"provider": resolvedProvider,
"hint": "GetFileURL failed; for local storage this usually means APP_EXTERNAL_URL is unset",
})
return
}
// Detect the "no-op" case where local storage falls back to the
// provider:// path because APP_EXTERNAL_URL is missing. Surfacing
// this explicitly is the whole point of the endpoint.
rewritten := httpURL != filePath
hint := ""
if !rewritten {
hint = "URL unchanged; for local storage set APP_EXTERNAL_URL to enable presigned HTTP URLs"
}
c.JSON(http.StatusOK, gin.H{
"file_path": filePath,
"provider": resolvedProvider,
"url": httpURL,
"rewritten": rewritten,
"hint": hint,
})
})
}
+635
View File
@@ -0,0 +1,635 @@
package router
import (
"net/http"
"path"
"strings"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/handler"
"github.com/Tencent/WeKnora/internal/middleware"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/gin-gonic/gin"
)
// CHOOSING THE RIGHT GUARD (read this before adding a new route)
// ==============================================================
//
// The four role-only guards (Viewer / Contributor / Admin / Owner) ask
// "what is the caller's role in this tenant?". The two ownership
// guards (OwnedKBOrAdmin / OwnedAgentOrAdmin and the per-sub-resource
// variants) ask "is the caller the creator of THIS resource OR at
// least Admin+?".
//
// Picking the wrong one is the single most common source of RBAC
// bugs in this repo (we caught FAQ/Tag, agent share, KB share, and
// shared-agents/disabled all wired against the wrong axis). Two
// questions decide it:
//
// Q1. Does the resource have a creator?
//
// YES — KB, Agent, Knowledge document, Chunk, WikiPage, FAQ entry,
// KB tag, anything stamped with creator_id / created_by.
// => Mutating routes use OwnedXxxOrAdmin.
// The creator passes regardless of role; everyone else needs
// Admin+. This is what makes "Contributor in my own KB acts
// like Owner; Contributor in someone else's KB acts like
// Viewer" hold uniformly.
//
// NO — Tenant-wide infrastructure: Model, VectorStore, IM channel,
// WebSearchProvider, DataSource, MCPService, WeKnoraCloud
// credentials.
// => Mutating routes use Admin().
// There is no "creator-of-the-vector-store" concept; configuring
// it affects everyone, so only Admin+ may touch it.
//
// ENTRY POINT — Routes that CREATE a new owned resource (POST
// /knowledge-bases, POST /agents).
// => Use Contributor() (or whatever the floor is).
// No resource exists yet, so we can only gate on role. Once
// created, future mutations on /:id flip to OwnedXxxOrAdmin.
//
// Q2. Is the side effect "private to me" or "visible to others"?
//
// PRIVATE — Action only affects the caller's own state (e.g.
// POST /agents/:id/copy creates a copy that belongs to the
// caller; the source agent is untouched).
// => Contributor() is fine.
//
// PUBLIC — Action exposes a resource beyond its current scope or
// changes state visible to other tenants/users (sharing a KB
// to an org, disabling an agent for the whole tenant,
// transferring ownership).
// => OwnedXxxOrAdmin (when the action targets a specific
// owned resource) or Admin (when it's tenant-wide).
// Contributor is wrong here even though the role floor passes:
// "I am a Contributor in this tenant" does not mean "I may
// expose my colleague's KB to the world".
//
// User experience this matrix produces
// ------------------------------------
// The user never sees the guard names. They see this:
//
// - As Owner / Admin: I can manage everything in my tenant.
// - As Contributor: I can manage what I created. Other people's
// resources behave like read-only, regardless of which UI tab.
// - As Viewer: read everything, mutate nothing.
// - Creating new resources (KB, agent, chat session) requires being
// at least Contributor.
// - Configuring tenant infrastructure (models, vector stores, IM,
// etc.) requires Admin+.
//
// If a route makes a Contributor surprised that they CAN'T do
// something they own, the gate is too tight (probably Admin where it
// should be OwnedXxxOrAdmin). If a route makes a Contributor surprised
// they CAN do something to someone else's resource, the gate is too
// loose (Contributor where it should be OwnedXxxOrAdmin). Both
// surprises are bugs.
//
// Sub-resources must align with their parent
// ------------------------------------------
// Chunks/wiki pages/FAQ entries/tags inherit their parent KB's gate.
// The KBCreatorLookupFromKnowledgeID / KBCreatorLookupFromKBPath /
// etc. lookups walk the URL param up to the KB and reuse its
// creator_id. Don't add a new sub-resource with a freshly-invented
// gate (a recurring source of "Contributor everywhere" drift).
//
// rbacGuards is the centralised role-matrix bundle for tenant-level RBAC
// (issue #1303 PR 2). NewRouter constructs it once and threads it into
// each Register* function that registers gated routes.
//
// Each method returns a fresh gin.HandlerFunc; routes call the method
// and inline the guard, so a glance at a route line tells you what
// authority it requires:
//
// kb.PUT("/:id", g.OwnedKBOrAdmin(), handler.UpdateKnowledgeBase)
//
// All guards honour cfg.Tenant.EnableRBAC: when the flag is off they log
// the would-be rejection and let the request through, preserving today's
// "anyone in the tenant can edit anything" behaviour during the rollout
// window. When the flag flips to true, the same code paths start
// rejecting unauthorised callers.
type rbacGuards struct {
cfg *config.Config
// Lookup closures resolve a request's :id into the resource's creator
// user ID. Captured up front so the handler-level methods don't have
// to be exported into every Register* function as well.
kbCreator middleware.CreatorLookup
agentCreator middleware.CreatorLookup
// kbCreatorFromKbIDParam reads :kbId (not :id) for the
// /initialization/* routes whose KB is addressed by :kbId.
kbCreatorFromKbIDParam middleware.CreatorLookup
// Per-KB-ownership lookups for knowledge / chunk / wiki page routes
// (PR 5, #1303). They walk the URL param back to KB.CreatorID so a
// Contributor who owns the KB can edit/delete its sub-resources
// (documents, chunks, wiki pages); a Contributor who merely belongs
// to the tenant gets 403 unless they're also Admin+.
knowledgeKBCreator middleware.CreatorLookup
chunkKBCreator middleware.CreatorLookup
chunkKBCreatorFromID middleware.CreatorLookup // chunk routes that address chunks by :id (no knowledge id in URL)
wikiKBCreator middleware.CreatorLookup
// Services for the KB-access guard (own / org-shared / via shared
// agent). Captured here so route lines can reference g.KBAccess()
// without having to plumb the services through every Register*
// function.
kbService middleware.KBLookup
knowledgeService middleware.KnowledgeLookup
chunkService middleware.ChunkLookup
kbShareService interfaces.KBShareService
agentShareService interfaces.AgentShareService
// apiKeyAuthorizer is the single source of truth for which routes an
// X-API-Key principal may call. Routes opt in via the apiKeyGroup
// helpers below; anything not declared is denied by the gate. See
// middleware.APIKeyRouteAuthorizer.
apiKeyAuthorizer *middleware.APIKeyRouteAuthorizer
}
// newRBACGuards wires the guards from the live configuration and the
// already-built handlers. Called once from NewRouter.
func newRBACGuards(
cfg *config.Config,
kbHandler *handler.KnowledgeBaseHandler,
agentHandler *handler.CustomAgentHandler,
knowledgeHandler *handler.KnowledgeHandler,
chunkHandler *handler.ChunkHandler,
wikiHandler *handler.WikiPageHandler,
kbService interfaces.KnowledgeBaseService,
knowledgeService interfaces.KnowledgeService,
chunkService interfaces.ChunkService,
kbShareService interfaces.KBShareService,
agentShareService interfaces.AgentShareService,
) *rbacGuards {
g := &rbacGuards{cfg: cfg, apiKeyAuthorizer: middleware.NewAPIKeyRouteAuthorizer()}
if kbHandler != nil {
g.kbCreator = kbHandler.KBCreatorLookup
g.kbCreatorFromKbIDParam = kbHandler.KBCreatorLookupFromKbIDParam
}
if agentHandler != nil {
g.agentCreator = agentHandler.AgentCreatorLookup
}
if knowledgeHandler != nil {
g.knowledgeKBCreator = knowledgeHandler.KBCreatorLookupFromKnowledgeID
}
if chunkHandler != nil {
g.chunkKBCreator = chunkHandler.KBCreatorLookupFromKnowledgeIDParam
g.chunkKBCreatorFromID = chunkHandler.KBCreatorLookupFromChunkIDParam
}
if wikiHandler != nil {
g.wikiKBCreator = wikiHandler.KBCreatorLookupFromKBPath
}
g.kbService = kbService
g.knowledgeService = knowledgeService
g.chunkService = chunkService
g.kbShareService = kbShareService
g.agentShareService = agentShareService
return g
}
// Role-only guards — pure RequireRole convenience wrappers, named after
// the matrix entries so route lines stay readable.
func (g *rbacGuards) Viewer() gin.HandlerFunc {
return middleware.RequireRole(types.TenantRoleViewer, g.cfg)
}
func (g *rbacGuards) Contributor() gin.HandlerFunc {
return middleware.RequireRole(types.TenantRoleContributor, g.cfg)
}
func (g *rbacGuards) Admin() gin.HandlerFunc {
return middleware.RequireRole(types.TenantRoleAdmin, g.cfg)
}
func (g *rbacGuards) AdminOrSystemAdmin() gin.HandlerFunc {
return middleware.RequireRoleOrSystemAdmin(types.TenantRoleAdmin, g.cfg)
}
func (g *rbacGuards) Owner() gin.HandlerFunc {
return middleware.RequireRole(types.TenantRoleOwner, g.cfg)
}
// API-key authorization — a SEPARATE authority from the JWT role/ownership
// guards above. Instead of stacking a per-route guard that also had to know
// the caller's ownership, every API-key-accessible route declares one
// APIKeyRoutePolicy via the apiKeyGroup helpers; the gate on /api/v1 enforces
// it and denies any undeclared route by default. JWT sessions ignore all of
// this (they short-circuit the gate).
//
// Policy constructors. API keys do not reuse tenant-member roles: a key is
// either full-access, or it carries explicit capabilities. KB allow-lists are
// pure data filters applied downstream by KBAccess guards and handlers.
func apiKeyAny() middleware.APIKeyRoutePolicy {
return middleware.APIKeyRoutePolicy{}
}
func apiKeyFullAccess() middleware.APIKeyRoutePolicy {
return middleware.APIKeyRoutePolicy{RequireFullAccess: true}
}
func apiKeyPlatform(capabilities ...types.APIKeyCapability) middleware.APIKeyRoutePolicy {
policy := middleware.APIKeyRoutePolicy{PlatformOnly: true}
for _, capability := range capabilities {
policy = policy.WithCapability(capability)
}
return policy
}
// apiKeyRetrieve grants read/search access to knowledge-base data.
func apiKeyRetrieve(base middleware.APIKeyRoutePolicy) middleware.APIKeyRoutePolicy {
return base.WithCapability(types.APIKeyCapabilityRetrieve)
}
// apiKeyChat layers the "chat" capability on top of a base policy: keys that
// carry the chat capability can use the conversation flow (sessions, agent
// listing) without full tenant access.
func apiKeyChat(base middleware.APIKeyRoutePolicy) middleware.APIKeyRoutePolicy {
return base.WithCapability(types.APIKeyCapabilityChat)
}
// apiKeyReadAgents layers the "read_agents" capability on top of a base
// policy so scoped integrations can inspect available agents without chat or
// authoring permissions.
func apiKeyReadAgents(base middleware.APIKeyRoutePolicy) middleware.APIKeyRoutePolicy {
return base.WithCapability(types.APIKeyCapabilityReadAgents)
}
// apiKeyIngest layers the "ingest" capability on top of a base policy so a
// scoped key can write content into its allowed knowledge bases (documents,
// chunks, FAQ, tags, wiki).
func apiKeyIngest(base middleware.APIKeyRoutePolicy) middleware.APIKeyRoutePolicy {
return base.WithCapability(types.APIKeyCapabilityIngest)
}
// apiKeyManageKnowledgeBases layers the "manage_kbs" capability on top of a
// base policy so a scoped key can manage the KB lifecycle (create/copy/
// duplicate/update/delete + config). Existing-KB operations stay bounded by
// the key's allow-list downstream; create has no source to bound.
func apiKeyManageKnowledgeBases(base middleware.APIKeyRoutePolicy) middleware.APIKeyRoutePolicy {
return base.WithCapability(types.APIKeyCapabilityManageKnowledgeBases)
}
// apiKeyManageAgents layers the "manage_agents" capability on top of a base policy.
func apiKeyManageAgents(base middleware.APIKeyRoutePolicy) middleware.APIKeyRoutePolicy {
return base.WithCapability(types.APIKeyCapabilityManageAgents)
}
// apiKeyMessageHistory layers the "message_history" capability on top of a
// base policy so an explicitly granted key can search or inspect tenant chat
// history without being promoted to full Owner.
func apiKeyMessageHistory(base middleware.APIKeyRoutePolicy) middleware.APIKeyRoutePolicy {
return base.WithCapability(types.APIKeyCapabilityMessageHistory)
}
func apiKeyManageModels(base middleware.APIKeyRoutePolicy) middleware.APIKeyRoutePolicy {
return base.WithCapability(types.APIKeyCapabilityManageModels)
}
func apiKeyManageMCPServices(base middleware.APIKeyRoutePolicy) middleware.APIKeyRoutePolicy {
return base.WithCapability(types.APIKeyCapabilityManageMCPServices)
}
func apiKeyManageDataSources(base middleware.APIKeyRoutePolicy) middleware.APIKeyRoutePolicy {
return base.WithCapability(types.APIKeyCapabilityManageDataSources)
}
func apiKeyManageChannels(base middleware.APIKeyRoutePolicy) middleware.APIKeyRoutePolicy {
return base.WithCapability(types.APIKeyCapabilityManageChannels)
}
func apiKeyManageVectorStores(base middleware.APIKeyRoutePolicy) middleware.APIKeyRoutePolicy {
return base.WithCapability(types.APIKeyCapabilityManageVectorStores)
}
// apiKeyManageStorageBackends layers the "manage_storage_backends" capability
// on top of a base policy so a scoped key can manage object/file storage
// backend instances without carrying vector-store or full tenant access.
func apiKeyManageStorageBackends(base middleware.APIKeyRoutePolicy) middleware.APIKeyRoutePolicy {
return base.WithCapability(types.APIKeyCapabilityManageStorageBackends)
}
func apiKeyManageWebSearch(base middleware.APIKeyRoutePolicy) middleware.APIKeyRoutePolicy {
return base.WithCapability(types.APIKeyCapabilityManageWebSearch)
}
func apiKeyRunEvaluations(base middleware.APIKeyRoutePolicy) middleware.APIKeyRoutePolicy {
return base.WithCapability(types.APIKeyCapabilityRunEvaluations)
}
func apiKeyManageMembers(base middleware.APIKeyRoutePolicy) middleware.APIKeyRoutePolicy {
return base.WithCapability(types.APIKeyCapabilityManageMembers)
}
func apiKeyManageSpaces(base middleware.APIKeyRoutePolicy) middleware.APIKeyRoutePolicy {
return base.WithCapability(types.APIKeyCapabilityManageSpaces)
}
func apiKeyManageTenantSettings(base middleware.APIKeyRoutePolicy) middleware.APIKeyRoutePolicy {
return base.WithCapability(types.APIKeyCapabilityManageTenantSettings)
}
// apiKeyRouteGroup wraps a *gin.RouterGroup so route registration also records
// the route's API-key policy into the authorizer. Use g.apiKeyGroup(grp,
// policy) then register the API-key-accessible routes through it; register
// API-key-denied routes on the raw *gin.RouterGroup so they stay undeclared
// (default-deny). Per-route overrides use With().
type apiKeyRouteGroup struct {
g *rbacGuards
grp *gin.RouterGroup
policy middleware.APIKeyRoutePolicy
}
// ensureAPIKeyAuthorizer lazily allocates the authorizer so route
// registration is safe even when rbacGuards is built directly in tests
// (bypassing newRBACGuards). In production it is always pre-allocated.
func (g *rbacGuards) ensureAPIKeyAuthorizer() *middleware.APIKeyRouteAuthorizer {
if g.apiKeyAuthorizer == nil {
g.apiKeyAuthorizer = middleware.NewAPIKeyRouteAuthorizer()
}
return g.apiKeyAuthorizer
}
// apiKeyGroup returns a wrapper that declares `policy` for every route
// registered through it (unless overridden via With).
func (g *rbacGuards) apiKeyGroup(grp *gin.RouterGroup, policy middleware.APIKeyRoutePolicy) *apiKeyRouteGroup {
g.ensureAPIKeyAuthorizer()
return &apiKeyRouteGroup{g: g, grp: grp, policy: policy}
}
// With returns a sibling wrapper on the same gin group but with a different
// policy, for the odd route that differs from its group default (e.g. a read
// search inside an otherwise contributor-gated group).
func (a *apiKeyRouteGroup) With(policy middleware.APIKeyRoutePolicy) *apiKeyRouteGroup {
return &apiKeyRouteGroup{g: a.g, grp: a.grp, policy: policy}
}
func (a *apiKeyRouteGroup) handle(method, rel string, handlers ...gin.HandlerFunc) gin.IRoutes {
full := path.Join(a.grp.BasePath(), rel)
a.g.ensureAPIKeyAuthorizer().Register(method, full, a.policy)
return a.grp.Handle(method, rel, handlers...)
}
func (a *apiKeyRouteGroup) GET(rel string, h ...gin.HandlerFunc) gin.IRoutes {
return a.handle(http.MethodGet, rel, h...)
}
func (a *apiKeyRouteGroup) POST(rel string, h ...gin.HandlerFunc) gin.IRoutes {
return a.handle(http.MethodPost, rel, h...)
}
func (a *apiKeyRouteGroup) PUT(rel string, h ...gin.HandlerFunc) gin.IRoutes {
return a.handle(http.MethodPut, rel, h...)
}
func (a *apiKeyRouteGroup) PATCH(rel string, h ...gin.HandlerFunc) gin.IRoutes {
return a.handle(http.MethodPatch, rel, h...)
}
func (a *apiKeyRouteGroup) DELETE(rel string, h ...gin.HandlerFunc) gin.IRoutes {
return a.handle(http.MethodDelete, rel, h...)
}
// apiKeyRoute declares a single API-key-accessible route directly on a gin
// group (for routes registered outside an apiKeyGroup, e.g. top-level r.POST).
func (g *rbacGuards) apiKeyRoute(
grp *gin.RouterGroup, method, rel string, policy middleware.APIKeyRoutePolicy, handlers ...gin.HandlerFunc,
) gin.IRoutes {
full := path.Join(grp.BasePath(), rel)
g.ensureAPIKeyAuthorizer().Register(method, full, policy)
return grp.Handle(method, rel, handlers...)
}
// assertAPIKeyPoliciesMatchRoutes verifies every declared API-key policy
// resolves to a real registered route. gin's c.FullPath() must match the
// authorizer key verbatim or the gate silently 403s the route for API keys;
// panicking here turns that latent misconfiguration into a startup failure.
func (g *rbacGuards) assertAPIKeyPoliciesMatchRoutes(engine *gin.Engine) {
registered := map[string]struct{}{}
for _, ri := range engine.Routes() {
// Authorizer keys are stored normalized (trailing slash trimmed), so
// normalize gin's reported path the same way. Otherwise a route
// registered with a "/" rel (gin path ".../evaluation/") would look
// missing against the normalized key (".../evaluation") even though
// the gate — which also normalizes c.FullPath() — matches it fine.
p := ri.Path
if len(p) > 1 {
p = strings.TrimRight(p, "/")
}
registered[ri.Method+" "+p] = struct{}{}
}
var missing []string
for method, paths := range g.apiKeyAuthorizer.RegisteredRoutes() {
for _, p := range paths {
if _, ok := registered[method+" "+p]; !ok {
missing = append(missing, method+" "+p)
}
}
}
if len(missing) > 0 {
panic("api-key policy declared for non-existent route(s): " + strings.Join(missing, ", "))
}
}
func (g *rbacGuards) SystemAdmin() gin.HandlerFunc {
return middleware.RequireSystemAdmin(g.cfg)
}
// Ownership-or-role guards. Required role here is the privilege level
// that bypasses the ownership check; Contributors ALWAYS pass when they
// own the resource.
// OwnedKBOrAdmin: KB mutations (update/delete/pin/copy). The original
// creator may proceed; otherwise Admin+ is required. Contributors who
// did not create the KB get 403 (when enforcement is on).
func (g *rbacGuards) OwnedKBOrAdmin() gin.HandlerFunc {
return middleware.RequireOwnershipOrRole(types.TenantRoleAdmin, g.kbCreator, g.cfg)
}
// OwnedKBOrAdminFromKbIDParam is the same matrix as OwnedKBOrAdmin but
// addresses the KB via :kbId (used by /initialization/* routes). KB
// configuration changes — picking the embedding/parser/storage
// engine, materialising indexes — are at least as sensitive as
// updating the KB itself, so they share the "creator OR Admin+" rule.
func (g *rbacGuards) OwnedKBOrAdminFromKbIDParam() gin.HandlerFunc {
return middleware.RequireOwnershipOrRole(types.TenantRoleAdmin, g.kbCreatorFromKbIDParam, g.cfg)
}
// OwnedAgentOrAdmin: same shape as OwnedKBOrAdmin but for CustomAgent.
// Built-in agents (IsBuiltin=true) are tenant-owned; their creator
// lookup returns "" and only Admin+ may mutate them.
func (g *rbacGuards) OwnedAgentOrAdmin() gin.HandlerFunc {
return middleware.RequireOwnershipOrRole(types.TenantRoleAdmin, g.agentCreator, g.cfg)
}
// OwnedKnowledgeKBOrAdmin: per-knowledge mutations (update / delete /
// reparse / image edit) — the URL :id is a knowledge id, the lookup
// walks it back to the owning KB's CreatorID. Same "creator OR Admin+"
// rule as OwnedKBOrAdmin, just one chain hop deeper. PR 5 (#1303).
func (g *rbacGuards) OwnedKnowledgeKBOrAdmin() gin.HandlerFunc {
return middleware.RequireOwnershipOrRole(types.TenantRoleAdmin, g.knowledgeKBCreator, g.cfg)
}
// OwnedChunkKBOrAdmin: chunk mutations addressed via :knowledge_id.
// Reuses the same chain helper as OwnedKnowledgeKBOrAdmin so a
// Contributor with KB ownership can manage all chunks under any of
// their documents. For chunk routes addressed via :id (no knowledge
// id in the URL — only chunks.DELETE("/by-id/:id/questions") today),
// see OwnedChunkKBOrAdminFromChunkID below: same matrix, walks one
// extra hop (chunk_id -> knowledge_id) before reusing this chain.
func (g *rbacGuards) OwnedChunkKBOrAdmin() gin.HandlerFunc {
return middleware.RequireOwnershipOrRole(types.TenantRoleAdmin, g.chunkKBCreator, g.cfg)
}
// OwnedChunkKBOrAdminFromChunkID: chunk mutations addressed via :id
// (the chunk's own id, no knowledge id in the URL). Used by
// chunks.DELETE("/by-id/:id/questions"). Same OwnedKBOrAdmin matrix
// as the rest of the chunk routes — earlier this endpoint stayed at
// flat Contributor because the chunk-id -> knowledge-id -> kb chain
// wasn't wired; that's now plumbed through KBCreatorLookupFromChunkIDParam.
func (g *rbacGuards) OwnedChunkKBOrAdminFromChunkID() gin.HandlerFunc {
return middleware.RequireOwnershipOrRole(types.TenantRoleAdmin, g.chunkKBCreatorFromID, g.cfg)
}
// OwnedWikiKBOrAdmin: wiki page CRUD and maintenance ops. Wiki routes
// use :kb_id directly so the lookup is a single hop into the KB
// service — no knowledge chain. Same matrix as OwnedKBOrAdmin.
func (g *rbacGuards) OwnedWikiKBOrAdmin() gin.HandlerFunc {
return middleware.RequireOwnershipOrRole(types.TenantRoleAdmin, g.wikiKBCreator, g.cfg)
}
// Tenant-access guards. Distinct from the role guards above: these
// answer the orthogonal question "may this caller touch this tenant
// at all", before role membership inside the tenant is even
// considered. Both delegate to middleware/access.go which centralises
// the cross-tenant rules so the router stays declarative.
// CrossTenant gates a route on the caller being an org-level
// superuser (CanAccessAllTenants AND EnableCrossTenantAccess). Used by
// /tenants/all, /tenants/search, POST /tenants, GET /tenants — the
// endpoints that operate across tenants. Replaces the if-blocks that
// used to live inside ListAllTenants/SearchTenants/CreateTenant.
func (g *rbacGuards) CrossTenant() gin.HandlerFunc {
return middleware.RequireCrossTenantAccess(g.cfg)
}
// PathTenantMatch enforces that the URL :id matches the caller's
// active tenant context (cross-tenant superusers bypass). Routes apply
// it at the /tenants/:id group level so every per-tenant endpoint —
// GetTenant / UpdateTenant / DeleteTenant / member
// management / leave — shares the same check. Replaces the
// authorizeTenantAccess helper that used to live inside the tenant
// handler.
func (g *rbacGuards) PathTenantMatch() gin.HandlerFunc {
return middleware.RequirePathTenantMatch(g.cfg)
}
// KB-access guards — orthogonal to the role-and-ownership matrix
// above. They answer "can the caller's tenant operate on THIS KB?"
// taking into account three paths:
//
// 1. Own KB — full access (Admin)
// 2. Org-shared KB (Plan 3) — capped permission
// 3. Visible via shared agent — read-only
//
// On success the resolved (KB + effective tenant id + permission)
// tuple is stashed on c.Keys under middleware.KBAccessContextKey AND
// the request context's tenant ID is rewritten to the effective tenant
// — so handlers downstream just read tenant the way they always did
// (types.MustTenantIDFromContext) without knowing whether the KB is
// owned or shared.
//
// These guards replace the per-handler effectiveCtxForKB /
// validateAndGetKnowledgeBase helpers that used to be re-implemented
// in chunk.go, faq.go, tag.go, knowledge.go and knowledgebase.go;
// the share-fallback logic now lives in exactly one place
// (middleware/kb_access.go).
// KBAccessRead gates a KB-scoped read route on the caller having at
// least Viewer-level access. The agent-share fallback only activates
// at this level — Editor/Admin reads never go through "I just see it
// because someone shared an agent". The kbID is read from the gin
// param named in `param` (typically "id" for /knowledge-bases/:id/...).
func (g *rbacGuards) KBAccessRead(param string) gin.HandlerFunc {
return middleware.RequireKBAccess(
middleware.KBIDFromParam(param),
types.OrgRoleViewer,
g.kbService,
g.kbShareService,
g.agentShareService,
g.cfg,
)
}
// KBAccessWrite gates a KB-scoped mutating route on the caller having
// at least Editor-level access (own KB or org-shared with editor).
// Used by FAQ upsert, tag CRUD, chunk update/delete, etc.
func (g *rbacGuards) KBAccessWrite(param string) gin.HandlerFunc {
return middleware.RequireKBAccess(
middleware.KBIDFromParam(param),
types.OrgRoleEditor,
g.kbService,
g.kbShareService,
g.agentShareService,
g.cfg,
)
}
// KBAccessReadFromKnowledgeIDParam is like KBAccessRead but resolves
// the kb_id by walking a knowledge document (URL `:knowledge_id`)
// back to its parent KB. Used by the chunk routes whose URL addresses
// the chunk via /chunks/:knowledge_id rather than /knowledge-bases/:id.
func (g *rbacGuards) KBAccessReadFromKnowledgeIDParam(param string) gin.HandlerFunc {
return middleware.RequireKBAccess(
middleware.KBIDFromKnowledgeIDParam(param, g.knowledgeService),
types.OrgRoleViewer,
g.kbService,
g.kbShareService,
g.agentShareService,
g.cfg,
)
}
// KBAccessWriteFromKnowledgeIDParam mirrors KBAccessReadFromKnowledgeIDParam
// for mutating routes (Editor minimum).
func (g *rbacGuards) KBAccessWriteFromKnowledgeIDParam(param string) gin.HandlerFunc {
return middleware.RequireKBAccess(
middleware.KBIDFromKnowledgeIDParam(param, g.knowledgeService),
types.OrgRoleEditor,
g.kbService,
g.kbShareService,
g.agentShareService,
g.cfg,
)
}
// KBAccessReadFromChunkIDParam walks chunk_id -> kb_id (using the
// chunk's denormalised KnowledgeBaseID column). Used by
// /chunks/by-id/:id read routes.
func (g *rbacGuards) KBAccessReadFromChunkIDParam(param string) gin.HandlerFunc {
return middleware.RequireKBAccess(
middleware.KBIDFromChunkIDParam(param, g.chunkService),
types.OrgRoleViewer,
g.kbService,
g.kbShareService,
g.agentShareService,
g.cfg,
)
}
// KBAccessWriteFromChunkIDParam — same as KBAccessReadFromChunkIDParam
// but requires Editor minimum. Used by chunk write routes that
// address the chunk via /chunks/by-id/:id.
func (g *rbacGuards) KBAccessWriteFromChunkIDParam(param string) gin.HandlerFunc {
return middleware.RequireKBAccess(
middleware.KBIDFromChunkIDParam(param, g.chunkService),
types.OrgRoleEditor,
g.kbService,
g.kbShareService,
g.agentShareService,
g.cfg,
)
}
+53
View File
@@ -0,0 +1,53 @@
package router
import (
"net/http"
"testing"
"github.com/gin-gonic/gin"
)
// TestAssertAPIKeyPoliciesMatchRoutes_TrailingSlash guards the class of bug
// where a route registered with a "/" rel (gin path ".../x/") was flagged as
// missing against the normalized policy key (".../x"), panicking at startup.
func TestAssertAPIKeyPoliciesMatchRoutes_TrailingSlash(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
v1 := engine.Group("/api/v1")
g := &rbacGuards{}
noop := func(c *gin.Context) {}
// Register through the apiKey helpers using both "" and "/" rel plus a
// nested path, mirroring real route declarations.
empty := g.apiKeyGroup(v1.Group("/knowledge-bases"), apiKeyAny())
empty.GET("", noop)
slash := g.apiKeyGroup(v1.Group("/evaluation"), apiKeyFullAccess())
slash.POST("/", noop)
slash.GET("/", noop)
g.apiKeyRoute(v1, http.MethodGet, "/tenants", apiKeyFullAccess(), noop)
// Must not panic: every declared policy resolves to a real route despite
// the trailing-slash difference on /evaluation.
g.assertAPIKeyPoliciesMatchRoutes(engine)
}
// TestAssertAPIKeyPoliciesMatchRoutes_Missing verifies the self-check still
// panics when a policy points at a genuinely non-existent route.
func TestAssertAPIKeyPoliciesMatchRoutes_Missing(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
g := &rbacGuards{}
// Declare a policy for a path that is never registered on the engine.
g.ensureAPIKeyAuthorizer().Register(http.MethodPost, "/api/v1/does-not-exist", apiKeyFullAccess())
defer func() {
if recover() == nil {
t.Fatal("expected panic for policy on non-existent route")
}
}()
g.assertAPIKeyPoliciesMatchRoutes(engine)
}
+355
View File
@@ -0,0 +1,355 @@
package router
import (
"context"
"os"
"strings"
"time"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
"go.uber.org/dig"
"github.com/Tencent/WeKnora/internal/accesscontrol"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/handler"
"github.com/Tencent/WeKnora/internal/handler/session"
workbenchHandler "github.com/Tencent/WeKnora/internal/handler/workbench"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/middleware"
"github.com/Tencent/WeKnora/internal/resumable"
"github.com/Tencent/WeKnora/internal/tracing/langfuse"
"github.com/Tencent/WeKnora/internal/types/interfaces"
_ "github.com/Tencent/WeKnora/docs" // swagger docs
)
// RouterParams 路由参数
type RouterParams struct {
dig.In
Config *config.Config
FileService interfaces.FileService
UserService interfaces.UserService
KBService interfaces.KnowledgeBaseService
KnowledgeService interfaces.KnowledgeService
ChunkService interfaces.ChunkService
SessionService interfaces.SessionService
MessageService interfaces.MessageService
ModelService interfaces.ModelService
EvaluationService interfaces.EvaluationService
KBShareService interfaces.KBShareService
AgentShareService interfaces.AgentShareService
KBHandler *handler.KnowledgeBaseHandler
KnowledgeHandler *handler.KnowledgeHandler
TenantHandler *handler.TenantHandler
TenantService interfaces.TenantService
TenantAPIKeyService interfaces.TenantAPIKeyService
TenantMemberService interfaces.TenantMemberService
TenantMemberHandler *handler.TenantMemberHandler
TenantInvitationHandler *handler.TenantInvitationHandler
AuditLogHandler *handler.AuditLogHandler
AuditLogService interfaces.AuditLogService
ChunkHandler *handler.ChunkHandler
SessionHandler *session.Handler
MessageHandler *handler.MessageHandler
MessageSuggestionHandler *handler.MessageSuggestionHandler
ModelHandler *handler.ModelHandler
ModelCredentialsHandler *handler.ModelCredentialsHandler
SandboxConfigHandler *handler.SandboxConfigHandler
SandboxSkillHandler *handler.SandboxSkillHandler
MeEnvVarHandler *handler.MeEnvVarHandler
EvaluationHandler *handler.EvaluationHandler
AuthHandler *handler.AuthHandler
InitializationHandler *handler.InitializationHandler
SystemHandler *handler.SystemHandler
MCPServiceHandler *handler.MCPServiceHandler
MCPCredentialsHandler *handler.MCPCredentialsHandler
MCPOAuthHandler *handler.MCPOAuthHandler
WebSearchHandler *handler.WebSearchHandler
WebSearchProviderHandler *handler.WebSearchProviderHandler
WebSearchCredentialsHandler *handler.WebSearchProviderCredentialsHandler
VectorStoreHandler *handler.VectorStoreHandler
StorageBackendHandler *handler.StorageBackendHandler
StorageBackendResolver interfaces.StorageBackendResolver
ResourceCatalog interfaces.ResourceCatalog
FAQHandler *handler.FAQHandler
TagHandler *handler.TagHandler
CustomAgentHandler *handler.CustomAgentHandler
UserFavoriteHandler *handler.UserResourceFavoriteHandler
SkillHandler *handler.SkillHandler
OrganizationHandler *handler.OrganizationHandler
IMHandler *handler.IMHandler
EmbedChannelHandler *handler.EmbedChannelHandler
EmbedChannelService interfaces.EmbedChannelService
RedisClient *redis.Client
DataSourceHandler *handler.DataSourceHandler
DataSourceCredentialsHandler *handler.DataSourceCredentialsHandler
WeKnoraCloudHandler *handler.WeKnoraCloudHandler
WikiPageHandler *handler.WikiPageHandler
MemoryHandler *handler.MemoryHandler
DigitalHumanHandler *handler.DigitalHumanHandler
WorkbenchHandler *workbenchHandler.Handler
NativeGraphHandler *handler.NativeGraphHandler
KnowledgeClassificationHandler *handler.KnowledgeClassificationHandler
AccessControl *accesscontrol.Service
Uploads *resumable.Service
}
// NewRouter 创建新的路由
func NewRouter(params RouterParams) *gin.Engine {
r := gin.New()
r.ContextWithFallback = true
// Trusted proxies: gin defaults to trusting ALL proxies, which makes
// c.ClientIP() honor a client-supplied X-Forwarded-For. Public, unauthed
// embed endpoints rate-limit per (channel, ClientIP), so a spoofed XFF would
// trivially bypass the limiter. Restrict to the fronting proxy network so
// only the real client IP (appended by nginx) is returned. Configurable via
// WEKNORA_TRUSTED_PROXIES (comma-separated CIDRs/IPs).
if err := r.SetTrustedProxies(trustedProxies()); err != nil {
logger.Errorf(context.Background(), "[Router] failed to set trusted proxies: %v", err)
}
// CORS 中间件应放在最前面。
// 注意:通配符 AllowOrigins 下浏览器会拒绝一切带凭据(cookie)的跨域
// 请求(CORS 规范禁止 "*" 与 credentials 组合),因此 AllowCredentials
// 实际只对未来改为回显具体 Origin 时才生效;当前认证全部走显式的
// Authorization / X-API-Key 头,不依赖 ambient 凭据。若引入 cookie
// 认证,必须先把 AllowOrigins 换成受控清单。
r.Use(cors.New(cors.Config{
AllowOrigins: []string{"*"},
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization", "X-API-Key", "X-Request-ID", "X-Tenant-ID", "X-Embed-Session", "X-External-User-ID", "X-External-User-Token", "Upload-Offset", "Upload-Checksum"},
ExposeHeaders: []string{"Content-Length", "Access-Control-Allow-Origin"},
AllowCredentials: true,
MaxAge: 12 * time.Hour,
}))
// 基础中间件(不需要认证)
r.Use(middleware.RequestID())
r.Use(middleware.Language())
r.Use(middleware.Logger())
r.Use(middleware.Recovery())
r.Use(middleware.ErrorHandler())
// 健康检查(不需要认证)
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})
// Swagger API 文档(仅在非生产环境下启用)
// 通过 GIN_MODE 环境变量判断:release 模式下禁用 Swagger
if gin.Mode() != gin.ReleaseMode {
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler,
ginSwagger.DefaultModelsExpandDepth(-1), // 默认折叠 Models
ginSwagger.DocExpansion("list"), // 展开模式: "list"(展开标签), "full"(全部展开), "none"(全部折叠)
ginSwagger.DeepLinking(true), // 启用深度链接
ginSwagger.PersistAuthorization(true), // 持久化认证信息
))
}
// Embed page framing policy: emit a per-channel `frame-ancestors` CSP so the
// embed SPA page (/embed/:channelId) can only be iframed by the channel's
// allowed origins. This is the page-level counterpart to the API Origin
// allowlist enforced in EmbedAuth. Registered before the static handler so
// it runs for the embed HTML response.
if params.EmbedChannelService != nil {
r.Use(embedFrameAncestorsMiddleware(params.EmbedChannelService))
}
// 前端静态文件(仅 Lite 版本内嵌前端)
if handler.Edition == "lite" {
serveFrontendStatic(r)
}
// IM 回调路由(在认证中间件之前注册,使用各平台自身的签名验证)
RegisterIMRoutes(r, params.IMHandler)
// Web embed 公开路由(使用 publish token 鉴权,不走全局 Auth)
RegisterEmbedPublicRoutes(
r,
params.EmbedChannelHandler,
params.EmbedChannelService,
params.TenantService,
params.RedisClient,
params.FileService,
params.StorageBackendResolver,
params.ResourceCatalog,
)
// Short-lived capability URLs for IM and other clients that cannot attach
// WeKnora authentication headers.
serveResourceGrants(r, params.ResourceCatalog, params.TenantService, params.FileService, params.StorageBackendResolver)
// 认证中间件
r.Use(middleware.Auth(params.TenantService, params.UserService, params.TenantMemberService, params.TenantAPIKeyService, params.Config))
// 文件服务:统一代理本地/MinIO/COS/TOS存储后端(需要认证)
if params.AccessControl != nil {
serveFilesWithResources(r.Group("", params.AccessControl.Middleware()), params.FileService, params.StorageBackendResolver, params.ResourceCatalog)
} else {
serveFilesWithResources(r, params.FileService, params.StorageBackendResolver, params.ResourceCatalog)
}
// Presigned file access: no auth required, signature-verified.
servePresignedFiles(r, params.TenantService, params.StorageBackendResolver)
// Diagnostic preview of presigned URLs (Admin only, behind auth middleware).
servePresignedPreview(r, params.Config, params.StorageBackendResolver)
// Langfuse observability — only active when LANGFUSE_* env vars are set.
// The middleware is registered unconditionally; when disabled it's a no-op.
r.Use(langfuse.GinMiddleware())
// Audit log injection — middleware/rbac.go's reject paths and the
// admin-only /tenants/:id/audit-log endpoint pull the service out
// of the gin context. Provider is a no-op when AuditLogService is
// nil (e.g. lite mode without DB), so the rbac path degrades to
// "log to stderr only" instead of crashing.
r.Use(middleware.AuditServiceProvider(params.AuditLogService))
// 需要认证的API路由
v1 := r.Group("/api/v1")
{
// rbacGuards bundles the role-gating middleware factories so each
// Register* function below can attach the right guard without
// taking a *config.Config dependency directly. The guards honour
// cfg.Tenant.EnableRBAC: when false, they log but pass through,
// preserving today's behaviour during the rollout window.
rbacGuards := newRBACGuards(
params.Config,
params.KBHandler,
params.CustomAgentHandler,
params.KnowledgeHandler,
params.ChunkHandler,
params.WikiPageHandler,
params.KBService,
params.KnowledgeService,
params.ChunkService,
params.KBShareService,
params.AgentShareService,
)
// API-key gate: single authority for X-API-Key principals. Runs
// first on every /api/v1 route (JWT sessions pass straight
// through) and denies any route not explicitly declared via the
// apiKeyGroup helpers. Must be attached BEFORE the Register* calls
// so that sub-groups inherit it.
v1.Use(rbacGuards.apiKeyAuthorizer.Middleware())
if params.WorkbenchHandler != nil {
v1.Use(params.WorkbenchHandler.OperationAudit())
}
if params.AccessControl != nil {
v1.Use(params.AccessControl.Middleware())
params.AccessControl.Register(v1)
params.AccessControl.RegisterResourceVersions(v1)
}
RegisterAuthRoutes(v1, params.AuthHandler, rbacGuards)
RegisterTenantRoutes(v1, params.TenantHandler, params.TenantMemberHandler, params.TenantInvitationHandler, params.AuditLogHandler, rbacGuards)
RegisterMyInvitationRoutes(v1, params.TenantInvitationHandler)
RegisterKnowledgeBaseRoutes(v1, params.KBHandler, rbacGuards)
RegisterKnowledgeBaseActivityRoutes(v1, params.AuditLogHandler, rbacGuards)
// KB-scoped image proxy: lets tenants render images embedded in
// org-shared / agent-visible KB content, which the tenant-scoped
// /files route cannot serve because it enforces same-tenant paths.
serveKBScopedFiles(
v1,
rbacGuards,
params.TenantService,
params.FileService,
params.StorageBackendResolver,
params.ResourceCatalog,
)
// Message-scoped image proxy: shared-agent replies belong to the
// caller's session but may reference resources stored in the agent's
// source workspace. Authorization is derived from the persisted message,
// never from a client-provided workspace ID.
serveMessageScopedFiles(
v1,
rbacGuards,
params.MessageService,
params.AgentShareService,
params.TenantService,
params.FileService,
params.StorageBackendResolver,
params.ResourceCatalog,
)
RegisterKnowledgeTagRoutes(v1, params.TagHandler, rbacGuards)
RegisterKnowledgeRoutes(v1, params.KnowledgeHandler, rbacGuards)
RegisterFAQRoutes(v1, params.FAQHandler, rbacGuards)
RegisterChunkRoutes(v1, params.ChunkHandler, rbacGuards)
RegisterSessionRoutes(v1, params.SessionHandler, params.MessageSuggestionHandler, rbacGuards)
RegisterChatRoutes(v1, params.SessionHandler, rbacGuards)
RegisterMessageRoutes(v1, params.MessageHandler, rbacGuards)
RegisterModelRoutes(v1, params.ModelHandler, params.ModelCredentialsHandler, rbacGuards)
RegisterSandboxConfigRoutes(v1, params.SandboxConfigHandler, params.SandboxSkillHandler, rbacGuards)
RegisterMyEnvVarRoutes(v1, params.MeEnvVarHandler)
RegisterEvaluationRoutes(v1, params.EvaluationHandler, rbacGuards)
RegisterInitializationRoutes(v1, params.InitializationHandler, rbacGuards)
params.SystemHandler.BindDeploymentCapabilities(deploymentCapabilitiesFromRouter(params))
RegisterSystemRoutes(v1, params.SystemHandler, rbacGuards)
RegisterSystemAdminRoutes(v1, params.SystemHandler, params.AuditLogHandler, rbacGuards)
RegisterMCPServiceRoutes(v1, params.MCPServiceHandler, params.MCPCredentialsHandler, params.MCPOAuthHandler, rbacGuards)
RegisterWebSearchRoutes(v1, params.WebSearchHandler, rbacGuards)
RegisterWebSearchProviderRoutes(v1, params.WebSearchProviderHandler, params.WebSearchCredentialsHandler, rbacGuards)
RegisterVectorStoreRoutes(v1, params.VectorStoreHandler, rbacGuards)
RegisterStorageBackendRoutes(v1, params.StorageBackendHandler, rbacGuards)
RegisterCustomAgentRoutes(v1, params.CustomAgentHandler, rbacGuards)
RegisterUserFavoriteRoutes(v1, params.UserFavoriteHandler, rbacGuards)
RegisterSkillRoutes(v1, params.SkillHandler, rbacGuards)
RegisterOrganizationRoutes(v1, params.OrganizationHandler, rbacGuards)
RegisterIMChannelRoutes(v1, params.IMHandler, rbacGuards)
RegisterEmbedChannelRoutes(v1, params.EmbedChannelHandler, rbacGuards)
RegisterDataSourceRoutes(v1, params.DataSourceHandler, params.DataSourceCredentialsHandler, rbacGuards)
RegisterWeKnoraCloudRoutes(v1, params.WeKnoraCloudHandler, rbacGuards)
RegisterWikiPageRoutes(v1, params.WikiPageHandler, rbacGuards)
RegisterMemoryRoutes(v1, params.MemoryHandler, rbacGuards)
RegisterWorkbenchRoutes(v1, params.WorkbenchHandler, rbacGuards)
if params.Uploads != nil {
params.Uploads.Register(v1)
}
RegisterNativeGraphRoutes(v1, params.NativeGraphHandler, rbacGuards)
RegisterKnowledgeClassificationRoutes(v1, params.KnowledgeClassificationHandler, rbacGuards)
RegisterDigitalHumanRoutes(v1, params.DigitalHumanHandler, rbacGuards)
RegisterChunkerDebugRoutes(v1, rbacGuards)
// Fail fast if any declared API-key policy points at a route
// template that does not actually exist (typo / path drift). A
// stale template would silently 403 every API key on that route,
// so we panic at startup instead of shipping a dead policy.
rbacGuards.assertAPIKeyPoliciesMatchRoutes(r)
}
return r
}
// trustedProxies returns the proxy CIDRs/IPs whose X-Forwarded-For headers
// gin should trust when resolving the client IP. Defaults to loopback and
// private ranges (covers the bundled nginx in a container network); override
// with WEKNORA_TRUSTED_PROXIES (comma-separated). An explicit empty value
// disables proxy trust entirely so ClientIP() returns the direct peer.
func trustedProxies() []string {
raw, ok := os.LookupEnv("WEKNORA_TRUSTED_PROXIES")
if !ok {
return []string{
"127.0.0.0/8",
"::1/128",
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"fc00::/7",
}
}
proxies := make([]string, 0)
for _, p := range strings.Split(raw, ",") {
if p = strings.TrimSpace(p); p != "" {
proxies = append(proxies, p)
}
}
return proxies
}
@@ -0,0 +1,676 @@
package router
import (
"net/http"
"testing"
"github.com/Tencent/WeKnora/internal/handler"
sessionhandler "github.com/Tencent/WeKnora/internal/handler/session"
"github.com/Tencent/WeKnora/internal/middleware"
"github.com/Tencent/WeKnora/internal/types"
"github.com/gin-gonic/gin"
)
func TestConversationRoutesDeclareChatCapability(t *testing.T) {
gin.SetMode(gin.TestMode)
g := &rbacGuards{}
v1 := gin.New().Group("/api/v1")
RegisterSessionRoutes(v1, &sessionhandler.Handler{}, &handler.MessageSuggestionHandler{}, g)
RegisterChatRoutes(v1, &sessionhandler.Handler{}, g)
RegisterMessageRoutes(v1, &handler.MessageHandler{}, g)
cases := []struct {
method string
path string
}{
{http.MethodPost, "/api/v1/sessions"},
{http.MethodGet, "/api/v1/sessions/:id/messages/:message_id/suggestions"},
{http.MethodPost, "/api/v1/sessions/:session_id/messages/:message_id/suggestions"},
{http.MethodPost, "/api/v1/sessions/:session_id/suggestion-events"},
{http.MethodPost, "/api/v1/knowledge-chat/:session_id"},
{http.MethodPost, "/api/v1/agent-chat/:session_id"},
{http.MethodGet, "/api/v1/messages/:session_id/load"},
{http.MethodDelete, "/api/v1/messages/:session_id/:id"},
}
for _, tc := range cases {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
policy := mustLookupAPIKeyPolicy(t, g, tc.method, tc.path)
if !policy.RequireFullAccess {
t.Fatal("policy should require full access without a matching capability")
}
if !policyHasCapability(policy, types.APIKeyCapabilityChat) {
t.Fatalf("policy capabilities = %#v, want chat", policy.Capabilities)
}
})
}
}
func TestPlatformControlPlaneRoutesDeclarePlatformCapabilities(t *testing.T) {
gin.SetMode(gin.TestMode)
g := &rbacGuards{}
v1 := gin.New().Group("/api/v1")
RegisterSystemAdminRoutes(v1, &handler.SystemHandler{}, nil, g)
cases := []struct {
method string
path string
capability types.APIKeyCapability
}{
{http.MethodGet, "/api/v1/system/admin/settings", types.APIKeyCapabilitySystemSettingsRead},
{http.MethodPut, "/api/v1/system/admin/settings/:key", types.APIKeyCapabilitySystemSettingsManage},
{http.MethodGet, "/api/v1/system/admin/runtime/queues", types.APIKeyCapabilitySystemRuntimeRead},
{http.MethodPost, "/api/v1/system/admin/runtime/queues/:queue/tasks/:task_id/actions/:action", types.APIKeyCapabilitySystemRuntimeManage},
{http.MethodDelete, "/api/v1/system/admin/runtime/queues/:queue/archived", types.APIKeyCapabilitySystemRuntimeManage},
}
for _, tc := range cases {
policy := mustLookupAPIKeyPolicy(t, g, tc.method, tc.path)
if !policy.PlatformOnly {
t.Fatalf("%s %s must be platform-only", tc.method, tc.path)
}
if !policyHasCapability(policy, tc.capability) {
t.Fatalf("%s %s capabilities = %#v, want %s", tc.method, tc.path, policy.Capabilities, tc.capability)
}
}
if _, ok := g.apiKeyAuthorizer.Lookup(http.MethodPost, "/api/v1/system/admin/api-keys"); ok {
t.Fatal("platform API keys must not create other platform API keys")
}
}
func TestPlatformTenantLifecycleRoutesDeclarePlatformCapabilities(t *testing.T) {
gin.SetMode(gin.TestMode)
g := &rbacGuards{}
v1 := gin.New().Group("/api/v1")
RegisterTenantRoutes(
v1,
&handler.TenantHandler{},
&handler.TenantMemberHandler{},
&handler.TenantInvitationHandler{},
nil,
g,
)
cases := []struct {
method string
path string
capability types.APIKeyCapability
}{
{http.MethodGet, "/api/v1/tenants/all", types.APIKeyCapabilitySystemTenantsRead},
{http.MethodGet, "/api/v1/tenants/search", types.APIKeyCapabilitySystemTenantsRead},
{http.MethodPost, "/api/v1/tenants", types.APIKeyCapabilitySystemTenantsManage},
{http.MethodGet, "/api/v1/tenants/:id", types.APIKeyCapabilitySystemTenantsRead},
{http.MethodPut, "/api/v1/tenants/:id", types.APIKeyCapabilitySystemTenantsManage},
{http.MethodDelete, "/api/v1/tenants/:id", types.APIKeyCapabilitySystemTenantsManage},
}
for _, tc := range cases {
policy := mustLookupAPIKeyPolicy(t, g, tc.method, tc.path)
if !policy.PlatformOnly {
t.Fatalf("%s %s must be platform-only", tc.method, tc.path)
}
if !policyHasCapability(policy, tc.capability) {
t.Fatalf("%s %s capabilities = %#v, want %s", tc.method, tc.path, policy.Capabilities, tc.capability)
}
}
}
func TestMessageHistoryRoutesDeclareMessageHistoryCapability(t *testing.T) {
gin.SetMode(gin.TestMode)
g := &rbacGuards{}
v1 := gin.New().Group("/api/v1")
RegisterMessageRoutes(v1, &handler.MessageHandler{}, g)
cases := []struct {
method string
path string
}{
{http.MethodPost, "/api/v1/messages/search"},
{http.MethodGet, "/api/v1/messages/chat-history-stats"},
}
for _, tc := range cases {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
policy := mustLookupAPIKeyPolicy(t, g, tc.method, tc.path)
if !policy.RequireFullAccess {
t.Fatal("policy should require full access without a matching capability")
}
if !policyHasCapability(policy, types.APIKeyCapabilityMessageHistory) {
t.Fatalf("policy capabilities = %#v, want message_history", policy.Capabilities)
}
if policyHasCapability(policy, types.APIKeyCapabilityChat) {
t.Fatalf("message-history route must not be granted by chat: %#v", policy.Capabilities)
}
})
}
}
func TestAgentReadRoutesDeclareReadAgentsCapability(t *testing.T) {
gin.SetMode(gin.TestMode)
g := &rbacGuards{}
v1 := gin.New().Group("/api/v1")
RegisterCustomAgentRoutes(v1, &handler.CustomAgentHandler{}, g)
cases := []struct {
method string
path string
}{
{http.MethodGet, "/api/v1/agents/placeholders"},
{http.MethodGet, "/api/v1/agents/type-presets"},
{http.MethodGet, "/api/v1/agents"},
{http.MethodGet, "/api/v1/agents/:id"},
{http.MethodGet, "/api/v1/agents/:id/suggested-questions"},
}
for _, tc := range cases {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
policy := mustLookupAPIKeyPolicy(t, g, tc.method, tc.path)
if !policy.RequireFullAccess {
t.Fatal("policy should require full access without a matching capability")
}
if !policyHasCapability(policy, types.APIKeyCapabilityReadAgents) {
t.Fatalf("policy capabilities = %#v, want read_agents", policy.Capabilities)
}
if !policyHasCapability(policy, types.APIKeyCapabilityChat) {
t.Fatalf("policy capabilities = %#v, want chat for conversation clients", policy.Capabilities)
}
if !policyHasCapability(policy, types.APIKeyCapabilityManageAgents) {
t.Fatalf("policy capabilities = %#v, want manage_agents for authoring clients", policy.Capabilities)
}
})
}
}
func TestAgentWriteRoutesRequireManageAgentsCapability(t *testing.T) {
gin.SetMode(gin.TestMode)
g := &rbacGuards{}
v1 := gin.New().Group("/api/v1")
RegisterCustomAgentRoutes(v1, &handler.CustomAgentHandler{}, g)
cases := []struct {
method string
path string
}{
{http.MethodPost, "/api/v1/agents"},
{http.MethodPut, "/api/v1/agents/:id"},
{http.MethodDelete, "/api/v1/agents/:id"},
{http.MethodPost, "/api/v1/agents/:id/copy"},
}
for _, tc := range cases {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
policy := mustLookupAPIKeyPolicy(t, g, tc.method, tc.path)
if !policy.RequireFullAccess {
t.Fatal("policy should require full access without a matching capability")
}
if !policyHasCapability(policy, types.APIKeyCapabilityManageAgents) {
t.Fatalf("policy capabilities = %#v, want manage_agents", policy.Capabilities)
}
if policyHasCapability(policy, types.APIKeyCapabilityReadAgents) {
t.Fatalf("agent write route must not be granted by read_agents: %#v", policy.Capabilities)
}
})
}
}
func TestKnowledgeBaseManagementRoutesDeclareManageKBsCapability(t *testing.T) {
gin.SetMode(gin.TestMode)
g := &rbacGuards{}
v1 := gin.New().Group("/api/v1")
RegisterKnowledgeBaseRoutes(v1, &handler.KnowledgeBaseHandler{}, g)
RegisterInitializationRoutes(v1, &handler.InitializationHandler{}, g)
cases := []struct {
method string
path string
}{
{http.MethodPut, "/api/v1/knowledge-bases/:id"},
{http.MethodDelete, "/api/v1/knowledge-bases/:id"},
{http.MethodPost, "/api/v1/initialization/initialize/:kbId"},
{http.MethodPut, "/api/v1/initialization/config/:kbId"},
}
for _, tc := range cases {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
policy := mustLookupAPIKeyPolicy(t, g, tc.method, tc.path)
if !policy.RequireFullAccess {
t.Fatal("policy should require full access without a matching capability")
}
if !policyHasCapability(policy, types.APIKeyCapabilityManageKnowledgeBases) {
t.Fatalf("policy capabilities = %#v, want manage_kbs", policy.Capabilities)
}
if policyHasCapability(policy, types.APIKeyCapabilityIngest) {
t.Fatalf("KB management route must not be granted by ingest: %#v", policy.Capabilities)
}
})
}
}
func TestKnowledgeBaseLifecycleRoutesDeclareManageCapability(t *testing.T) {
gin.SetMode(gin.TestMode)
g := &rbacGuards{}
v1 := gin.New().Group("/api/v1")
RegisterKnowledgeBaseRoutes(v1, &handler.KnowledgeBaseHandler{}, g)
// The whole KB lifecycle (create/copy/duplicate/update/delete) shares one
// policy tier: manage_kbs OR full-access. create/copy/duplicate produce a
// new KB but are still KB-management operations, so manage_kbs admits them
// (the allow-list bounds copy/duplicate/update/delete downstream; ingest
// must never grant any of these).
cases := []struct {
method string
path string
}{
{http.MethodPost, "/api/v1/knowledge-bases"},
{http.MethodPost, "/api/v1/knowledge-bases/copy"},
{http.MethodPost, "/api/v1/knowledge-bases/:id/duplicate"},
}
for _, tc := range cases {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
policy := mustLookupAPIKeyPolicy(t, g, tc.method, tc.path)
if !policy.RequireFullAccess {
t.Fatal("policy should require full access without a matching capability")
}
if !policyHasCapability(policy, types.APIKeyCapabilityManageKnowledgeBases) {
t.Fatalf("policy capabilities = %#v, want manage_kbs", policy.Capabilities)
}
if policyHasCapability(policy, types.APIKeyCapabilityIngest) {
t.Fatalf("KB lifecycle route must not be granted by ingest: %#v", policy.Capabilities)
}
})
}
}
func TestKnowledgeReadRoutesDeclareRetrieveCapability(t *testing.T) {
gin.SetMode(gin.TestMode)
g := &rbacGuards{}
v1 := gin.New().Group("/api/v1")
RegisterKnowledgeBaseRoutes(v1, &handler.KnowledgeBaseHandler{}, g)
RegisterKnowledgeRoutes(v1, &handler.KnowledgeHandler{}, g)
RegisterFAQRoutes(v1, &handler.FAQHandler{}, g)
RegisterKnowledgeTagRoutes(v1, &handler.TagHandler{}, g)
RegisterChatRoutes(v1, &sessionhandler.Handler{}, g)
RegisterInitializationRoutes(v1, &handler.InitializationHandler{}, g)
RegisterWikiPageRoutes(v1, &handler.WikiPageHandler{}, g)
cases := []struct {
method string
path string
}{
{http.MethodGet, "/api/v1/knowledge-bases"},
{http.MethodGet, "/api/v1/knowledge-bases/:id"},
{http.MethodPost, "/api/v1/knowledge-bases/:id/hybrid-search"},
{http.MethodGet, "/api/v1/knowledge-bases/:id/knowledge"},
{http.MethodGet, "/api/v1/knowledge/:id"},
{http.MethodGet, "/api/v1/knowledge/:id/download"},
{http.MethodPost, "/api/v1/knowledge-bases/:id/faq/search"},
{http.MethodGet, "/api/v1/knowledge-bases/:id/tags"},
{http.MethodPost, "/api/v1/knowledge-search"},
{http.MethodGet, "/api/v1/initialization/config/:kbId"},
{http.MethodGet, "/api/v1/knowledgebase/:kb_id/wiki/pages"},
}
for _, tc := range cases {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
policy := mustLookupAPIKeyPolicy(t, g, tc.method, tc.path)
if !policy.RequireFullAccess {
t.Fatal("policy should require full access without a matching capability")
}
if !policyHasCapability(policy, types.APIKeyCapabilityRetrieve) {
t.Fatalf("policy capabilities = %#v, want retrieve", policy.Capabilities)
}
})
}
}
func TestTenantInfrastructureRoutesDeclareSpecificCapabilities(t *testing.T) {
gin.SetMode(gin.TestMode)
g := &rbacGuards{}
v1 := gin.New().Group("/api/v1")
RegisterTenantRoutes(v1, &handler.TenantHandler{}, nil, nil, nil, g)
RegisterModelRoutes(v1, &handler.ModelHandler{}, &handler.ModelCredentialsHandler{}, g)
RegisterEvaluationRoutes(v1, &handler.EvaluationHandler{}, g)
RegisterSystemRoutes(v1, &handler.SystemHandler{}, g)
RegisterMCPServiceRoutes(v1, &handler.MCPServiceHandler{}, &handler.MCPCredentialsHandler{}, &handler.MCPOAuthHandler{}, g)
RegisterWebSearchProviderRoutes(v1, &handler.WebSearchProviderHandler{}, &handler.WebSearchProviderCredentialsHandler{}, g)
RegisterVectorStoreRoutes(v1, &handler.VectorStoreHandler{}, g)
RegisterStorageBackendRoutes(v1, &handler.StorageBackendHandler{}, g)
RegisterSandboxConfigRoutes(v1, &handler.SandboxConfigHandler{}, &handler.SandboxSkillHandler{}, g)
RegisterEmbedChannelRoutes(v1, &handler.EmbedChannelHandler{}, g)
RegisterIMChannelRoutes(v1, &handler.IMHandler{}, g)
RegisterDataSourceRoutes(v1, &handler.DataSourceHandler{}, &handler.DataSourceCredentialsHandler{}, g)
RegisterWeKnoraCloudRoutes(v1, &handler.WeKnoraCloudHandler{}, g)
capabilitiesPolicy := mustLookupAPIKeyPolicy(t, g, http.MethodGet, "/api/v1/system/capabilities")
if capabilitiesPolicy.RequireFullAccess || len(capabilitiesPolicy.Capabilities) != 0 {
t.Fatalf("system capabilities should be readable by any valid API key: %#v", capabilitiesPolicy)
}
cases := []struct {
method string
path string
cap types.APIKeyCapability
}{
{http.MethodGet, "/api/v1/tenants", types.APIKeyCapabilityManageTenantSettings},
{http.MethodGet, "/api/v1/models", types.APIKeyCapabilityManageModels},
{http.MethodDelete, "/api/v1/models/:id", types.APIKeyCapabilityManageModels},
{http.MethodPost, "/api/v1/evaluation", types.APIKeyCapabilityRunEvaluations},
{http.MethodGet, "/api/v1/system/info", types.APIKeyCapabilityManageVectorStores},
{http.MethodGet, "/api/v1/mcp-services", types.APIKeyCapabilityManageMCPServices},
{http.MethodGet, "/api/v1/web-search-providers", types.APIKeyCapabilityManageWebSearch},
{http.MethodGet, "/api/v1/vector-stores", types.APIKeyCapabilityManageVectorStores},
{http.MethodGet, "/api/v1/storage-backends", types.APIKeyCapabilityManageStorageBackends},
{http.MethodGet, "/api/v1/embed-channels", types.APIKeyCapabilityManageChannels},
{http.MethodGet, "/api/v1/im-channels", types.APIKeyCapabilityManageChannels},
{http.MethodGet, "/api/v1/datasource", types.APIKeyCapabilityManageDataSources},
{http.MethodGet, "/api/v1/models/weknoracloud/status", types.APIKeyCapabilityManageModels},
}
for _, tc := range cases {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
policy := mustLookupAPIKeyPolicy(t, g, tc.method, tc.path)
if !policy.RequireFullAccess {
t.Fatal("policy should require full access without a matching capability")
}
if !policyHasCapability(policy, tc.cap) {
t.Fatalf("policy capabilities = %#v, want %s", policy.Capabilities, tc.cap)
}
})
}
}
func TestSandboxConfigRoutesRequireFullAccessOnly(t *testing.T) {
gin.SetMode(gin.TestMode)
g := &rbacGuards{}
v1 := gin.New().Group("/api/v1")
RegisterSandboxConfigRoutes(v1, &handler.SandboxConfigHandler{}, &handler.SandboxSkillHandler{}, g)
cases := []struct {
method string
path string
}{
{http.MethodGet, "/api/v1/sandbox-configs"},
{http.MethodPost, "/api/v1/sandbox-configs"},
{http.MethodPost, "/api/v1/sandbox-configs/templates/query"},
{http.MethodGet, "/api/v1/sandbox-configs/:id"},
{http.MethodPut, "/api/v1/sandbox-configs/:id"},
{http.MethodDelete, "/api/v1/sandbox-configs/:id"},
{http.MethodGet, "/api/v1/sandbox-configs/:id/sandboxes"},
{http.MethodGet, "/api/v1/sandbox-configs/:id/skills"},
{http.MethodPost, "/api/v1/sandbox-configs/:id/skills"},
{http.MethodGet, "/api/v1/sandbox-configs/:id/skills/:skillId"},
{http.MethodGet, "/api/v1/sandbox-configs/:id/skills/:skillId/files"},
{http.MethodGet, "/api/v1/sandbox-configs/:id/skills/:skillId/files/content"},
{http.MethodPost, "/api/v1/sandbox-configs/:id/skills/:skillId/reinstall"},
{http.MethodPost, "/api/v1/sandbox-configs/:id/skills/:skillId/stop"},
{http.MethodPatch, "/api/v1/sandbox-configs/:id/skills/:skillId"},
{http.MethodDelete, "/api/v1/sandbox-configs/:id/skills/:skillId"},
{http.MethodGet, "/api/v1/sandbox-configs/:id/skills/:skillId/install-events"},
// The transcript replays the root shell session that built the image,
// so it must not be reachable by a scoped key either.
{http.MethodGet, "/api/v1/sandbox-configs/:id/skills/:skillId/transcript"},
}
for _, tc := range cases {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
policy := mustLookupAPIKeyPolicy(t, g, tc.method, tc.path)
if !policy.RequireFullAccess {
t.Fatal("sandbox config routes should require full access")
}
if len(policy.Capabilities) != 0 {
t.Fatalf("sandbox config routes must not be granted by a scoped capability: %#v", policy.Capabilities)
}
})
}
}
func TestSkillCatalogWriteRoutesRequireFullAccess(t *testing.T) {
gin.SetMode(gin.TestMode)
g := &rbacGuards{}
v1 := gin.New().Group("/api/v1")
RegisterSkillRoutes(v1, &handler.SkillHandler{}, g)
cases := []struct {
method string
path string
}{
{http.MethodPost, "/api/v1/skills/catalog"},
{http.MethodPost, "/api/v1/skills/catalog/:id/install"},
{http.MethodGet, "/api/v1/skills/catalog/:id/files"},
{http.MethodGet, "/api/v1/skills/catalog/:id/files/content"},
{http.MethodDelete, "/api/v1/skills/catalog/:id"},
}
for _, tc := range cases {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
policy := mustLookupAPIKeyPolicy(t, g, tc.method, tc.path)
if !policy.RequireFullAccess {
t.Fatal("catalog writes bake into sandbox images and must require full access")
}
if len(policy.Capabilities) != 0 {
t.Fatalf("catalog writes must not be granted by a scoped capability: %#v", policy.Capabilities)
}
})
}
}
func TestTenantMemberRoutesDeclareManageMembersCapability(t *testing.T) {
gin.SetMode(gin.TestMode)
g := &rbacGuards{}
v1 := gin.New().Group("/api/v1")
RegisterTenantRoutes(v1, &handler.TenantHandler{}, &handler.TenantMemberHandler{}, &handler.TenantInvitationHandler{}, nil, g)
cases := []struct {
method string
path string
}{
{http.MethodGet, "/api/v1/tenants/:id/members"},
{http.MethodPost, "/api/v1/tenants/:id/members"},
{http.MethodPut, "/api/v1/tenants/:id/members/:user_id"},
{http.MethodDelete, "/api/v1/tenants/:id/members/:user_id"},
{http.MethodGet, "/api/v1/tenants/:id/invitations"},
{http.MethodPost, "/api/v1/tenants/:id/invitations"},
{http.MethodDelete, "/api/v1/tenants/:id/invitations/:inv_id"},
{http.MethodPost, "/api/v1/tenants/:id/invite-links"},
}
for _, tc := range cases {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
policy := mustLookupAPIKeyPolicy(t, g, tc.method, tc.path)
if !policy.RequireFullAccess {
t.Fatal("policy should require full access without a matching capability")
}
if !policyHasCapability(policy, types.APIKeyCapabilityManageMembers) {
t.Fatalf("policy capabilities = %#v, want manage_members", policy.Capabilities)
}
})
}
if _, ok := g.apiKeyAuthorizer.Lookup(http.MethodPost, "/api/v1/tenants/:id/leave"); ok {
t.Fatal("tenant leave route should remain default-deny for API keys")
}
}
func TestOrganizationRoutesDeclareManageSpacesCapability(t *testing.T) {
gin.SetMode(gin.TestMode)
g := &rbacGuards{}
v1 := gin.New().Group("/api/v1")
RegisterOrganizationRoutes(v1, &handler.OrganizationHandler{}, g)
cases := []struct {
method string
path string
}{
{http.MethodPost, "/api/v1/organizations"},
{http.MethodGet, "/api/v1/organizations"},
{http.MethodPost, "/api/v1/organizations/join"},
{http.MethodGet, "/api/v1/organizations/search"},
{http.MethodPut, "/api/v1/organizations/:id"},
{http.MethodPost, "/api/v1/organizations/:id/invite-code"},
{http.MethodGet, "/api/v1/organizations/:id/members"},
{http.MethodPut, "/api/v1/organizations/:id/members/:tenant_id"},
{http.MethodGet, "/api/v1/shared-knowledge-bases"},
{http.MethodGet, "/api/v1/shared-agents"},
{http.MethodPost, "/api/v1/shared-agents/disabled"},
}
for _, tc := range cases {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
policy := mustLookupAPIKeyPolicy(t, g, tc.method, tc.path)
if !policy.RequireFullAccess {
t.Fatal("policy should require full access without a matching capability")
}
if !policyHasCapability(policy, types.APIKeyCapabilityManageSpaces) {
t.Fatalf("policy capabilities = %#v, want manage_spaces", policy.Capabilities)
}
})
}
// KB/agent share management is open to full-access keys (tenant-wide
// authority) but never via a capability.
shareRoutes := []struct {
method string
path string
}{
{http.MethodPost, "/api/v1/knowledge-bases/:id/shares"},
{http.MethodGet, "/api/v1/knowledge-bases/:id/shares"},
{http.MethodPut, "/api/v1/knowledge-bases/:id/shares/:share_id"},
{http.MethodDelete, "/api/v1/knowledge-bases/:id/shares/:share_id"},
{http.MethodPost, "/api/v1/agents/:id/shares"},
{http.MethodGet, "/api/v1/agents/:id/shares"},
{http.MethodDelete, "/api/v1/agents/:id/shares/:share_id"},
}
for _, tc := range shareRoutes {
t.Run("share "+tc.method+" "+tc.path, func(t *testing.T) {
policy := mustLookupAPIKeyPolicy(t, g, tc.method, tc.path)
if !policy.RequireFullAccess {
t.Fatal("share route should require full access for API keys")
}
if len(policy.Capabilities) != 0 {
t.Fatalf("share route must not be granted by any capability: %#v", policy.Capabilities)
}
})
}
}
func TestChunkerPreviewRouteRequiresRetrieveOrIngestCapability(t *testing.T) {
gin.SetMode(gin.TestMode)
g := &rbacGuards{}
v1 := gin.New().Group("/api/v1")
RegisterChunkerDebugRoutes(v1, g)
policy := mustLookupAPIKeyPolicy(t, g, http.MethodPost, "/api/v1/chunker/preview")
if !policy.RequireFullAccess {
t.Fatal("policy should require full access without a matching capability")
}
if !policyHasCapability(policy, types.APIKeyCapabilityRetrieve) {
t.Fatalf("policy capabilities = %#v, want retrieve", policy.Capabilities)
}
if !policyHasCapability(policy, types.APIKeyCapabilityIngest) {
t.Fatalf("policy capabilities = %#v, want ingest", policy.Capabilities)
}
}
// The batch / cross-KB content-write routes bind themselves to a single (or
// source+target) KB and enforce the API key's KB allow-list downstream, so
// they are reachable by an ingest-capable (or full-access) key — matching
// their single-document siblings.
func TestKnowledgeBatchWriteRoutesDeclareIngestCapability(t *testing.T) {
gin.SetMode(gin.TestMode)
g := &rbacGuards{}
v1 := gin.New().Group("/api/v1")
RegisterKnowledgeRoutes(v1, &handler.KnowledgeHandler{}, g)
cases := []struct {
method string
path string
}{
{http.MethodPost, "/api/v1/knowledge/move"},
{http.MethodPost, "/api/v1/knowledge/batch-delete"},
{http.MethodPost, "/api/v1/knowledge/batch-reparse"},
{http.MethodPut, "/api/v1/knowledge/tags"},
}
for _, tc := range cases {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
policy := mustLookupAPIKeyPolicy(t, g, tc.method, tc.path)
if !policy.RequireFullAccess {
t.Fatal("policy should require full access without a matching capability")
}
if !policyHasCapability(policy, types.APIKeyCapabilityIngest) {
t.Fatalf("policy capabilities = %#v, want ingest", policy.Capabilities)
}
})
}
}
func TestKBCloneProgressRouteRequiresRetrieveOrManageKbsCapability(t *testing.T) {
gin.SetMode(gin.TestMode)
g := &rbacGuards{}
v1 := gin.New().Group("/api/v1")
RegisterKnowledgeBaseRoutes(v1, &handler.KnowledgeBaseHandler{}, g)
policy := mustLookupAPIKeyPolicy(t, g, http.MethodGet, "/api/v1/knowledge-bases/copy/progress/:task_id")
if !policy.RequireFullAccess {
t.Fatal("policy should require full access without a matching capability")
}
if !policyHasCapability(policy, types.APIKeyCapabilityRetrieve) {
t.Fatalf("policy capabilities = %#v, want retrieve", policy.Capabilities)
}
if !policyHasCapability(policy, types.APIKeyCapabilityManageKnowledgeBases) {
t.Fatalf("policy capabilities = %#v, want manage_kbs", policy.Capabilities)
}
}
func TestFAQImportProgressRouteRequiresRetrieveOrIngestCapability(t *testing.T) {
gin.SetMode(gin.TestMode)
g := &rbacGuards{}
v1 := gin.New().Group("/api/v1")
RegisterFAQRoutes(v1, &handler.FAQHandler{}, g)
policy := mustLookupAPIKeyPolicy(t, g, http.MethodGet, "/api/v1/faq/import/progress/:task_id")
if !policy.RequireFullAccess {
t.Fatal("policy should require full access without a matching capability")
}
if !policyHasCapability(policy, types.APIKeyCapabilityRetrieve) {
t.Fatalf("policy capabilities = %#v, want retrieve", policy.Capabilities)
}
if !policyHasCapability(policy, types.APIKeyCapabilityIngest) {
t.Fatalf("policy capabilities = %#v, want ingest", policy.Capabilities)
}
}
func mustLookupAPIKeyPolicy(
t *testing.T,
g *rbacGuards,
method string,
path string,
) middleware.APIKeyRoutePolicy {
t.Helper()
policy, ok := g.apiKeyAuthorizer.Lookup(method, path)
if !ok {
t.Fatalf("missing API-key policy for %s %s", method, path)
}
return policy
}
func policyHasCapability(policy middleware.APIKeyRoutePolicy, cap types.APIKeyCapability) bool {
for _, got := range policy.Capabilities {
if got == cap {
return true
}
}
return false
}
@@ -0,0 +1,97 @@
package router
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/Tencent/WeKnora/internal/middleware"
"github.com/Tencent/WeKnora/internal/types"
"github.com/gin-gonic/gin"
)
// TestAPIKeyGateDeniesTenantKeyManagementPaths guards the default-deny
// contract: tenant lifecycle, key management and API-principal config routes
// are registered without apiKeyRoute/apiKeyGroup, so even a full-access API
// key must receive 403 from the gate before JWT-only handlers run.
func TestAPIKeyGateDeniesTenantKeyManagementPaths(t *testing.T) {
gin.SetMode(gin.TestMode)
gate := middleware.NewAPIKeyRouteAuthorizer()
engine := gin.New()
engine.Use(func(c *gin.Context) {
scope := types.TenantAPIKeyScope{FullAccess: true}
c.Request = c.Request.WithContext(types.WithTenantAPIKeyScope(c.Request.Context(), scope))
c.Next()
})
engine.Use(gate.Middleware())
v1 := engine.Group("/api/v1")
tenantByID := v1.Group("/tenants/:id")
{
tenantByID.GET("/api-keys", reachedOK)
tenantByID.POST("/api-keys", reachedOK)
tenantByID.PUT("/api-keys/:key_id", reachedOK)
tenantByID.DELETE("/api-keys/:key_id", reachedOK)
tenantByID.GET("/api-principal-config", reachedOK)
tenantByID.PUT("/api-principal-config", reachedOK)
tenantByID.POST("/api-principal-test-token", reachedOK)
}
tenantRoutes := v1.Group("/tenants")
{
tenantRoutes.POST("", reachedOK)
}
cases := []struct {
method string
path string
}{
{http.MethodGet, "/api/v1/tenants/42/api-keys"},
{http.MethodPost, "/api/v1/tenants/42/api-keys"},
{http.MethodPut, "/api/v1/tenants/42/api-keys/7"},
{http.MethodDelete, "/api/v1/tenants/42/api-keys/7"},
{http.MethodGet, "/api/v1/tenants/42/api-principal-config"},
{http.MethodPut, "/api/v1/tenants/42/api-principal-config"},
{http.MethodPost, "/api/v1/tenants/42/api-principal-test-token"},
{http.MethodPost, "/api/v1/tenants"},
}
for _, tc := range cases {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
w := httptest.NewRecorder()
engine.ServeHTTP(w, httptest.NewRequest(tc.method, tc.path, nil))
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 body=%s", w.Code, w.Body.String())
}
})
}
}
// TestAPIKeyGateAllowsDeclaredTenantKVRead confirms the gate still permits
// routes that are explicitly declared for full-access API keys (sanity check).
func TestAPIKeyGateAllowsDeclaredTenantKVRead(t *testing.T) {
gin.SetMode(gin.TestMode)
g := &rbacGuards{}
v1 := gin.New().Group("/api/v1")
g.apiKeyRoute(v1.Group("/tenants"), http.MethodGet, "/kv/:key", apiKeyFullAccess(), reachedOK)
engine := gin.New()
engine.Use(func(c *gin.Context) {
scope := types.TenantAPIKeyScope{FullAccess: true}
c.Request = c.Request.WithContext(types.WithTenantAPIKeyScope(c.Request.Context(), scope))
c.Next()
})
engine.Use(g.apiKeyAuthorizer.Middleware())
engine.GET("/api/v1/tenants/kv/:key", reachedOK)
w := httptest.NewRecorder()
engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/tenants/kv/my-key", nil))
if w.Code != http.StatusOK {
t.Fatalf("declared owner route status = %d, want 200 body=%s", w.Code, w.Body.String())
}
}
func reachedOK(c *gin.Context) {
c.Status(http.StatusOK)
}
+814
View File
@@ -0,0 +1,814 @@
package router
import (
"context"
"errors"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
var _ interfaces.FileService = (*stubFileService)(nil)
type stubFileService struct {
getFile func(ctx context.Context, filePath string) (io.ReadCloser, error)
}
type stubResourceCatalog struct {
resource *types.StoredResource
}
type authorizingFileCatalog struct {
stubResourceCatalog
authorize func(context.Context, string, interfaces.ResourceFileScope) error
}
func (s *authorizingFileCatalog) AuthorizeFileRead(ctx context.Context, value string, scope interfaces.ResourceFileScope) error {
return s.authorize(ctx, value, scope)
}
func TestFileProxyAuthorizesBeforeOpeningStorage(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("STORAGE_TYPE", "local")
const ref = "resource://AbCdEfGhIjKlMnOpQrStUv"
opened := false
catalog := &authorizingFileCatalog{
stubResourceCatalog: stubResourceCatalog{resource: &types.StoredResource{TenantID: 42, PhysicalPath: "local://42/exports/a.png"}},
authorize: func(ctx context.Context, value string, scope interfaces.ResourceFileScope) error {
if scope.TenantID != 42 || value != ref {
t.Fatal("wrong authorization scope")
}
return errors.New("private file owned by another user")
},
}
engine := gin.New()
serveFilesWithResources(engine, &stubFileService{getFile: func(context.Context, string) (io.ReadCloser, error) {
opened = true
return io.NopCloser(strings.NewReader("private")), nil
}}, nil, catalog)
req := httptest.NewRequest(http.MethodGet, "/files?file_path="+url.QueryEscape(ref), nil)
req = req.WithContext(context.WithValue(req.Context(), types.TenantInfoContextKey, &types.Tenant{ID: 42}))
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if recorder.Code != http.StatusForbidden || opened {
t.Fatalf("status=%d storage_opened=%v", recorder.Code, opened)
}
catAllowed := false
catalog.authorize = func(context.Context, string, interfaces.ResourceFileScope) error { catAllowed = true; return nil }
recorder = httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK || !catAllowed || recorder.Header().Get("Cache-Control") != "private, no-store" {
t.Fatalf("authorized status=%d cache=%s", recorder.Code, recorder.Header().Get("Cache-Control"))
}
}
type stubMessageFileLookup struct {
get func(ctx context.Context, sessionID, messageID string) (*types.Message, error)
}
func (s *stubMessageFileLookup) GetMessage(
ctx context.Context,
sessionID, messageID string,
) (*types.Message, error) {
return s.get(ctx, sessionID, messageID)
}
type stubSharedAgentFileLookup struct {
get func(
ctx context.Context,
tenantID uint64,
callerTenantRole types.TenantRole,
agentID string,
sourceTenantID ...uint64,
) (*types.CustomAgent, error)
}
func (s *stubSharedAgentFileLookup) GetSharedAgentForTenant(
ctx context.Context,
tenantID uint64,
callerTenantRole types.TenantRole,
agentID string,
sourceTenantID ...uint64,
) (*types.CustomAgent, error) {
return s.get(ctx, tenantID, callerTenantRole, agentID, sourceTenantID...)
}
func (s *stubResourceCatalog) Register(
context.Context,
uint64,
string,
interfaces.ResourceRegistration,
) (string, error) {
panic("unexpected Register")
}
func (s *stubResourceCatalog) Resolve(context.Context, string) (*types.StoredResource, error) {
return s.resource, nil
}
func (s *stubResourceCatalog) ResolvePath(_ context.Context, value string) (string, *types.StoredResource, error) {
if _, ok := types.ParseResourcePath(value); ok && s.resource != nil {
return s.resource.PhysicalPath, s.resource, nil
}
return value, nil, nil
}
func (s *stubResourceCatalog) Bind(context.Context, string, string, string, string) error {
panic("unexpected Bind")
}
func (s *stubResourceCatalog) Release(context.Context, string, string, string) (int64, error) {
panic("unexpected Release")
}
func (s *stubResourceCatalog) MarkDeleted(context.Context, string) error {
panic("unexpected MarkDeleted")
}
func (s *stubResourceCatalog) CreateAccessGrant(context.Context, string, time.Duration) (string, error) {
panic("unexpected CreateAccessGrant")
}
func (s *stubResourceCatalog) ResolveAccessGrant(context.Context, string) (*types.StoredResource, error) {
return s.resource, nil
}
func (s *stubFileService) CheckConnectivity(ctx context.Context) error {
return nil
}
func (s *stubFileService) SaveFile(ctx context.Context, file *multipart.FileHeader, tenantID uint64, knowledgeID string) (string, error) {
panic("unexpected call to SaveFile")
}
func (s *stubFileService) SaveBytes(ctx context.Context, data []byte, tenantID uint64, fileName string, temp bool) (string, error) {
panic("unexpected call to SaveBytes")
}
func (s *stubFileService) GetFile(ctx context.Context, filePath string) (io.ReadCloser, error) {
if s.getFile == nil {
panic("unexpected call to GetFile")
}
return s.getFile(ctx, filePath)
}
func (s *stubFileService) GetFileURL(ctx context.Context, filePath string) (string, error) {
panic("unexpected call to GetFileURL")
}
func (s *stubFileService) DeleteFile(ctx context.Context, filePath string) error {
panic("unexpected call to DeleteFile")
}
func (s *stubFileService) CopyFile(ctx context.Context, srcPath string, tenantID uint64, knowledgeID string) (string, error) {
panic("unexpected call to CopyFile")
}
func TestServeFilesFallsBackToGlobalFileService(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("STORAGE_TYPE", "local")
engine := gin.New()
var requestedPath string
serveFiles(engine, &stubFileService{
getFile: func(ctx context.Context, filePath string) (io.ReadCloser, error) {
requestedPath = filePath
return io.NopCloser(strings.NewReader("fallback-body")), nil
},
})
filePath := "local://42/docs/example.txt"
req := httptest.NewRequest(http.MethodGet, "/files?file_path="+url.QueryEscape(filePath), nil)
req = req.WithContext(context.WithValue(req.Context(), types.TenantInfoContextKey, &types.Tenant{ID: 42}))
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if got, want := recorder.Code, http.StatusOK; got != want {
t.Fatalf("status = %d, want %d", got, want)
}
if requestedPath != filePath {
t.Fatalf("requested path = %q, want %q", requestedPath, filePath)
}
if body := recorder.Body.String(); body != "fallback-body" {
t.Fatalf("body = %q, want %q", body, "fallback-body")
}
}
func TestServeFilesResolvesShortResourceReference(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("STORAGE_TYPE", "local")
const ref = "resource://AbCdEfGhIjKlMnOpQrStUv"
const physical = "local://42/exports/a.png"
engine := gin.New()
var requestedPath string
serveFilesWithResources(engine, &stubFileService{getFile: func(_ context.Context, path string) (io.ReadCloser, error) {
requestedPath = path
return io.NopCloser(strings.NewReader("image")), nil
}}, nil, &stubResourceCatalog{resource: &types.StoredResource{TenantID: 42, PhysicalPath: physical}})
req := httptest.NewRequest(http.MethodGet, "/files?file_path="+url.QueryEscape(ref), nil)
req = req.WithContext(context.WithValue(req.Context(), types.TenantInfoContextKey, &types.Tenant{ID: 42}))
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String())
}
if requestedPath != physical {
t.Fatalf("requested path = %q, want %q", requestedPath, physical)
}
}
func TestServeFilesRejectsCrossTenantResourceReference(t *testing.T) {
gin.SetMode(gin.TestMode)
const ref = "resource://AbCdEfGhIjKlMnOpQrStUv"
engine := gin.New()
serveFilesWithResources(engine, &stubFileService{getFile: func(context.Context, string) (io.ReadCloser, error) {
t.Fatal("GetFile should not be called")
return nil, nil
}}, nil, &stubResourceCatalog{resource: &types.StoredResource{TenantID: 7, PhysicalPath: "local://7/exports/a.png"}})
req := httptest.NewRequest(http.MethodGet, "/files?file_path="+url.QueryEscape(ref), nil)
req = req.WithContext(context.WithValue(req.Context(), types.TenantInfoContextKey, &types.Tenant{ID: 42}))
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if recorder.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusForbidden)
}
}
func TestResourceGrantServesShortPublicURL(t *testing.T) {
gin.SetMode(gin.TestMode)
physical := "local://42/exports/a.png"
engine := gin.New()
serveResourceGrants(
engine,
&stubResourceCatalog{resource: &types.StoredResource{
ID: "resource-1",
TenantID: 42,
PhysicalPath: physical,
OriginalName: "a.png",
MimeType: "image/png",
}},
&stubTenantService{get: func(_ context.Context, id uint64) (*types.Tenant, error) {
return &types.Tenant{ID: id}, nil
}},
&stubFileService{getFile: func(_ context.Context, path string) (io.ReadCloser, error) {
if path != physical {
t.Fatalf("path = %q, want %q", path, physical)
}
return io.NopCloser(strings.NewReader("image")), nil
}},
nil,
)
req := httptest.NewRequest(http.MethodGet, "/r/GrantTokenAbCdEfGhIjKlM", nil)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK || recorder.Body.String() != "image" {
t.Fatalf("status=%d body=%q", recorder.Code, recorder.Body.String())
}
if got := recorder.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Fatalf("X-Content-Type-Options = %q", got)
}
}
func TestServeFilesDoesNotFallbackWhenProviderDoesNotMatchGlobalStorage(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("STORAGE_TYPE", "minio")
engine := gin.New()
serveFiles(engine, &stubFileService{
getFile: func(ctx context.Context, filePath string) (io.ReadCloser, error) {
t.Fatalf("GetFile should not be called for mismatched provider, got %q", filePath)
return nil, nil
},
})
req := httptest.NewRequest(http.MethodGet, "/files?file_path="+url.QueryEscape("local://42/docs/example.txt"), nil)
req = req.WithContext(context.WithValue(req.Context(), types.TenantInfoContextKey, &types.Tenant{ID: 42}))
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if got, want := recorder.Code, http.StatusBadRequest; got != want {
t.Fatalf("status = %d, want %d", got, want)
}
}
func TestServeFilesRejectsCrossTenantPath(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("STORAGE_TYPE", "local")
engine := gin.New()
serveFiles(engine, &stubFileService{
getFile: func(ctx context.Context, filePath string) (io.ReadCloser, error) {
t.Fatalf("GetFile should not be called for cross-tenant path, got %q", filePath)
return nil, nil
},
})
req := httptest.NewRequest(http.MethodGet, "/files?file_path="+url.QueryEscape("local://7/knowledge/secret.pdf"), nil)
req = req.WithContext(context.WithValue(req.Context(), types.TenantInfoContextKey, &types.Tenant{ID: 42}))
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if got, want := recorder.Code, http.StatusForbidden; got != want {
t.Fatalf("status = %d, want %d", got, want)
}
}
func TestServeFilesRejectsPathWithoutTenantSegment(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("STORAGE_TYPE", "local")
engine := gin.New()
serveFiles(engine, &stubFileService{
getFile: func(ctx context.Context, filePath string) (io.ReadCloser, error) {
t.Fatalf("GetFile should not be called without tenant segment, got %q", filePath)
return nil, nil
},
})
req := httptest.NewRequest(http.MethodGet, "/files?file_path="+url.QueryEscape("local://docs/example.txt"), nil)
req = req.WithContext(context.WithValue(req.Context(), types.TenantInfoContextKey, &types.Tenant{ID: 42}))
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if got, want := recorder.Code, http.StatusForbidden; got != want {
t.Fatalf("status = %d, want %d", got, want)
}
}
// /files carries its own API-key guard (middleware.AllowFileServeAPIKey):
// full-access and tenant-wide retrieve keys may serve tenant-bounded paths,
// but KB-restricted keys (and keys lacking retrieve) are denied because a raw
// storage path cannot be bounded to a KB allow-list.
func TestServeFilesAPIKeyScopeMatrix(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("STORAGE_TYPE", "local")
const filePath = "local://42/docs/example.txt"
cases := []struct {
name string
scope types.TenantAPIKeyScope
wantCode int
}{
{
name: "full access allowed",
scope: types.TenantAPIKeyScope{FullAccess: true},
wantCode: http.StatusOK,
},
{
name: "tenant-wide retrieve allowed",
scope: types.TenantAPIKeyScope{
Capabilities: types.StringArray{string(types.APIKeyCapabilityRetrieve)},
},
wantCode: http.StatusOK,
},
{
name: "kb-restricted retrieve denied",
scope: types.TenantAPIKeyScope{
KnowledgeBaseIDs: types.StringArray{"kb-1"},
Capabilities: types.StringArray{string(types.APIKeyCapabilityRetrieve)},
},
wantCode: http.StatusForbidden,
},
{
name: "non-retrieve capability denied",
scope: types.TenantAPIKeyScope{
Capabilities: types.StringArray{string(types.APIKeyCapabilityChat)},
},
wantCode: http.StatusForbidden,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
engine := gin.New()
serveFiles(engine, &stubFileService{
getFile: func(_ context.Context, _ string) (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader("body")), nil
},
})
req := httptest.NewRequest(http.MethodGet, "/files?file_path="+url.QueryEscape(filePath), nil)
ctx := context.WithValue(req.Context(), types.TenantInfoContextKey, &types.Tenant{ID: 42})
ctx = types.WithTenantAPIKeyScope(ctx, tc.scope)
req = req.WithContext(ctx)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if got := recorder.Code; got != tc.wantCode {
t.Fatalf("status = %d, want %d body=%s", got, tc.wantCode, recorder.Body.String())
}
})
}
}
// newKBScopedFilesTestEngine wires newKBScopedFileServeHandler behind a
// middleware that injects effectiveTenantID into the request context, mirroring
// what RequireKBAccess does after resolving an org-shared KB to its source
// tenant. This lets the handler be exercised without the full RBAC stack.
func newKBScopedFilesTestEngine(
effectiveTenantID uint64,
tenantSvc interfaces.TenantService,
global interfaces.FileService,
) *gin.Engine {
engine := gin.New()
engine.GET("/knowledge-bases/:id/files",
func(c *gin.Context) {
ctx := context.WithValue(c.Request.Context(), types.TenantIDContextKey, effectiveTenantID)
c.Request = c.Request.WithContext(ctx)
c.Next()
},
newKBScopedFileServeHandler(tenantSvc, global),
)
return engine
}
// A tenant whose owner-tenant (10008) storage objects are requested by a
// borrowing tenant via a shared KB: the effective tenant in context is the
// owner, so the path validates and the file is served.
func TestKBScopedFilesServesOwnerTenantPath(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("STORAGE_TYPE", "local")
const ownerTenantID = uint64(10008)
var requestedPath string
engine := newKBScopedFilesTestEngine(
ownerTenantID,
&stubTenantService{get: func(_ context.Context, id uint64) (*types.Tenant, error) {
return &types.Tenant{ID: id}, nil
}},
&stubFileService{getFile: func(_ context.Context, filePath string) (io.ReadCloser, error) {
requestedPath = filePath
return io.NopCloser(strings.NewReader("shared-body")), nil
}},
)
filePath := "local://10008/exports/img.jpg"
req := httptest.NewRequest(http.MethodGet, "/knowledge-bases/kb-1/files?file_path="+url.QueryEscape(filePath), nil)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if got, want := recorder.Code, http.StatusOK; got != want {
t.Fatalf("status = %d, want %d body=%s", got, want, recorder.Body.String())
}
if requestedPath != filePath {
t.Fatalf("requested path = %q, want %q", requestedPath, filePath)
}
if body := recorder.Body.String(); body != "shared-body" {
t.Fatalf("body = %q, want %q", body, "shared-body")
}
}
// The path must belong to the effective (owner) tenant. A path pointing at a
// different tenant than the resolved KB owner is still rejected, so the guard
// cannot be used to reach arbitrary tenants' files.
func TestKBScopedFilesRejectsPathNotOwnedByKBTenant(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("STORAGE_TYPE", "local")
const ownerTenantID = uint64(10008)
engine := newKBScopedFilesTestEngine(
ownerTenantID,
&stubTenantService{get: func(_ context.Context, id uint64) (*types.Tenant, error) {
t.Fatalf("GetTenantByID should not be called for mismatched path, got %d", id)
return nil, nil
}},
&stubFileService{getFile: func(_ context.Context, filePath string) (io.ReadCloser, error) {
t.Fatalf("GetFile should not be called for mismatched path, got %q", filePath)
return nil, nil
}},
)
req := httptest.NewRequest(http.MethodGet,
"/knowledge-bases/kb-1/files?file_path="+url.QueryEscape("local://9999/exports/other.jpg"), nil)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if got, want := recorder.Code, http.StatusForbidden; got != want {
t.Fatalf("status = %d, want %d", got, want)
}
}
// KB-scoped proxy is for embedded exports/ images only; raw knowledge uploads
// must use /knowledge/:id/download even when the tenant matches.
func TestKBScopedFilesRejectsNonExportsPath(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("STORAGE_TYPE", "local")
const ownerTenantID = uint64(10008)
engine := newKBScopedFilesTestEngine(
ownerTenantID,
&stubTenantService{get: func(_ context.Context, id uint64) (*types.Tenant, error) {
t.Fatalf("GetTenantByID should not be called for non-exports path, got %d", id)
return nil, nil
}},
&stubFileService{getFile: func(_ context.Context, filePath string) (io.ReadCloser, error) {
t.Fatalf("GetFile should not be called for non-exports path, got %q", filePath)
return nil, nil
}},
)
req := httptest.NewRequest(http.MethodGet,
"/knowledge-bases/kb-1/files?file_path="+url.QueryEscape("local://10008/knowledge-id/123.pdf"), nil)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if got, want := recorder.Code, http.StatusForbidden; got != want {
t.Fatalf("status = %d, want %d", got, want)
}
}
func TestKBScopedFilesRequiresFilePath(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := newKBScopedFilesTestEngine(
10008,
&stubTenantService{},
&stubFileService{},
)
req := httptest.NewRequest(http.MethodGet, "/knowledge-bases/kb-1/files", nil)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if got, want := recorder.Code, http.StatusBadRequest; got != want {
t.Fatalf("status = %d, want %d", got, want)
}
}
func newMessageScopedFilesTestEngine(
callerTenantID uint64,
messageService messageFileLookup,
agentShareService sharedAgentFileLookup,
tenantService interfaces.TenantService,
global interfaces.FileService,
resourceCatalog interfaces.ResourceCatalog,
) *gin.Engine {
engine := gin.New()
engine.GET("/sessions/:id/messages/:message_id/files",
func(c *gin.Context) {
ctx := context.WithValue(c.Request.Context(), types.TenantIDContextKey, callerTenantID)
c.Request = c.Request.WithContext(ctx)
c.Next()
},
newMessageScopedFileServeHandler(
messageService,
agentShareService,
tenantService,
global,
nil,
resourceCatalog,
),
)
return engine
}
func TestMessageScopedFilesServesSharedAgentResource(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("STORAGE_TYPE", "local")
const (
callerTenantID = uint64(42)
ownerTenantID = uint64(7)
ref = "resource://AbCdEfGhIjKlMnOpQrStUv"
physical = "local://7/exports/chart.png"
)
var requestedPath string
engine := newMessageScopedFilesTestEngine(
callerTenantID,
&stubMessageFileLookup{get: func(_ context.Context, sessionID, messageID string) (*types.Message, error) {
if sessionID != "session-1" || messageID != "message-1" {
t.Fatalf("unexpected message scope %s/%s", sessionID, messageID)
}
return &types.Message{AgentID: "agent-1", AgentTenantID: ownerTenantID}, nil
}},
&stubSharedAgentFileLookup{get: func(
_ context.Context,
tenantID uint64,
_ types.TenantRole,
agentID string,
sourceTenantID ...uint64,
) (*types.CustomAgent, error) {
if tenantID != callerTenantID || agentID != "agent-1" || len(sourceTenantID) != 1 || sourceTenantID[0] != ownerTenantID {
t.Fatalf("unexpected shared-agent lookup tenant=%d agent=%s source=%v", tenantID, agentID, sourceTenantID)
}
return &types.CustomAgent{ID: agentID, TenantID: ownerTenantID}, nil
}},
&stubTenantService{get: func(_ context.Context, id uint64) (*types.Tenant, error) {
return &types.Tenant{ID: id}, nil
}},
&stubFileService{getFile: func(_ context.Context, filePath string) (io.ReadCloser, error) {
requestedPath = filePath
return io.NopCloser(strings.NewReader("shared-agent-image")), nil
}},
&stubResourceCatalog{resource: &types.StoredResource{
TenantID: ownerTenantID,
PhysicalPath: physical,
MimeType: "image/png",
}},
)
req := httptest.NewRequest(http.MethodGet,
"/sessions/session-1/messages/message-1/files?file_path="+url.QueryEscape(ref), nil)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK || recorder.Body.String() != "shared-agent-image" {
t.Fatalf("status=%d body=%q", recorder.Code, recorder.Body.String())
}
if requestedPath != physical {
t.Fatalf("requested path = %q, want %q", requestedPath, physical)
}
}
func TestMessageScopedFilesServesSameTenantResource(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("STORAGE_TYPE", "local")
const (
tenantID = uint64(42)
ref = "resource://AbCdEfGhIjKlMnOpQrStUv"
physical = "local://42/exports/chart.png"
)
var requestedPath string
engine := newMessageScopedFilesTestEngine(
tenantID,
&stubMessageFileLookup{get: func(_ context.Context, sessionID, messageID string) (*types.Message, error) {
if sessionID != "session-1" || messageID != "message-1" {
t.Fatalf("unexpected message scope %s/%s", sessionID, messageID)
}
return &types.Message{AgentTenantID: tenantID}, nil
}},
&stubSharedAgentFileLookup{get: func(
context.Context, uint64, types.TenantRole, string, ...uint64,
) (*types.CustomAgent, error) {
t.Fatal("shared-agent lookup should not run for same-tenant resources")
return nil, nil
}},
&stubTenantService{get: func(_ context.Context, id uint64) (*types.Tenant, error) {
return &types.Tenant{ID: id}, nil
}},
&stubFileService{getFile: func(_ context.Context, filePath string) (io.ReadCloser, error) {
requestedPath = filePath
return io.NopCloser(strings.NewReader("same-tenant-image")), nil
}},
&stubResourceCatalog{resource: &types.StoredResource{
TenantID: tenantID,
PhysicalPath: physical,
MimeType: "image/png",
}},
)
req := httptest.NewRequest(http.MethodGet,
"/sessions/session-1/messages/message-1/files?file_path="+url.QueryEscape(ref), nil)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK || recorder.Body.String() != "same-tenant-image" {
t.Fatalf("status=%d body=%q", recorder.Code, recorder.Body.String())
}
if requestedPath != physical {
t.Fatalf("requested path = %q, want %q", requestedPath, physical)
}
}
func TestMessageScopedFilesRequiresFilePath(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := newMessageScopedFilesTestEngine(
42,
&stubMessageFileLookup{get: func(context.Context, string, string) (*types.Message, error) {
return &types.Message{AgentTenantID: 42}, nil
}},
&stubSharedAgentFileLookup{},
&stubTenantService{},
&stubFileService{},
&stubResourceCatalog{},
)
req := httptest.NewRequest(http.MethodGet, "/sessions/session-1/messages/message-1/files", nil)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if got, want := recorder.Code, http.StatusBadRequest; got != want {
t.Fatalf("status = %d, want %d", got, want)
}
}
func TestMessageScopedFilesRejectsRevokedSharedAgent(t *testing.T) {
gin.SetMode(gin.TestMode)
const ref = "resource://AbCdEfGhIjKlMnOpQrStUv"
engine := newMessageScopedFilesTestEngine(
42,
&stubMessageFileLookup{get: func(context.Context, string, string) (*types.Message, error) {
return &types.Message{AgentID: "agent-1", AgentTenantID: 7}, nil
}},
&stubSharedAgentFileLookup{get: func(
context.Context, uint64, types.TenantRole, string, ...uint64,
) (*types.CustomAgent, error) {
return nil, nil
}},
&stubTenantService{get: func(context.Context, uint64) (*types.Tenant, error) {
t.Fatal("tenant lookup should not run after share revocation")
return nil, nil
}},
&stubFileService{getFile: func(context.Context, string) (io.ReadCloser, error) {
t.Fatal("GetFile should not run after share revocation")
return nil, nil
}},
&stubResourceCatalog{resource: &types.StoredResource{TenantID: 7, PhysicalPath: "local://7/exports/chart.png"}},
)
req := httptest.NewRequest(http.MethodGet,
"/sessions/session-1/messages/message-1/files?file_path="+url.QueryEscape(ref), nil)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if recorder.Code != http.StatusForbidden {
t.Fatalf("status=%d, want %d", recorder.Code, http.StatusForbidden)
}
}
func TestMessageScopedFilesRejectsResourceOutsideMessageTenant(t *testing.T) {
gin.SetMode(gin.TestMode)
const ref = "resource://AbCdEfGhIjKlMnOpQrStUv"
engine := newMessageScopedFilesTestEngine(
42,
&stubMessageFileLookup{get: func(context.Context, string, string) (*types.Message, error) {
return &types.Message{AgentID: "agent-1", AgentTenantID: 8}, nil
}},
&stubSharedAgentFileLookup{get: func(
context.Context, uint64, types.TenantRole, string, ...uint64,
) (*types.CustomAgent, error) {
t.Fatal("shared-agent lookup should not run for a mismatched resource tenant")
return nil, nil
}},
&stubTenantService{},
&stubFileService{},
&stubResourceCatalog{resource: &types.StoredResource{TenantID: 7, PhysicalPath: "local://7/exports/chart.png"}},
)
req := httptest.NewRequest(http.MethodGet,
"/sessions/session-1/messages/message-1/files?file_path="+url.QueryEscape(ref), nil)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if recorder.Code != http.StatusForbidden {
t.Fatalf("status=%d, want %d", recorder.Code, http.StatusForbidden)
}
}
func TestServeFilesForcesActiveContentDownload(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("STORAGE_TYPE", "local")
engine := gin.New()
serveFiles(engine, &stubFileService{
getFile: func(_ context.Context, _ string) (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader(`<svg onload="alert(1)"></svg>`)), nil
},
})
filePath := "local://42/docs/payload.svg"
req := httptest.NewRequest(http.MethodGet, "/files?file_path="+url.QueryEscape(filePath), nil)
req = req.WithContext(context.WithValue(req.Context(), types.TenantInfoContextKey, &types.Tenant{ID: 42}))
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if got, want := recorder.Code, http.StatusOK; got != want {
t.Fatalf("status = %d, want %d", got, want)
}
if got := recorder.Header().Get("Content-Type"); got != "application/octet-stream" {
t.Fatalf("Content-Type = %q, want application/octet-stream", got)
}
if got := recorder.Header().Get("Content-Disposition"); got != "attachment" {
t.Fatalf("Content-Disposition = %q, want attachment", got)
}
if got := recorder.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Fatalf("X-Content-Type-Options = %q, want nosniff", got)
}
}
@@ -0,0 +1,110 @@
package router
import (
"context"
"net/http"
"net/http/httptest"
"testing"
apprepo "github.com/Tencent/WeKnora/internal/application/repository"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/handler"
"github.com/Tencent/WeKnora/internal/middleware"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type downloadKnowledgeLookup struct {
knowledge *types.Knowledge
}
func (s *downloadKnowledgeLookup) GetKnowledgeByIDOnly(_ context.Context, id string) (*types.Knowledge, error) {
if s.knowledge != nil && s.knowledge.ID == id {
return s.knowledge, nil
}
return nil, apprepo.ErrKnowledgeNotFound
}
type downloadKBShareStub struct {
interfaces.KBShareService
permission types.OrgMemberRole
source uint64
}
func (s *downloadKBShareStub) CheckTenantKBPermission(
_ context.Context,
_ string,
_ uint64,
_ types.TenantRole,
) (types.OrgMemberRole, bool, error) {
return s.permission, true, nil
}
func (s *downloadKBShareStub) GetKBSourceTenant(_ context.Context, _ string) (uint64, error) {
return s.source, nil
}
func newKnowledgeDownloadRouteTestEngine(
t *testing.T,
role types.TenantRole,
knowledge *types.Knowledge,
kb *types.KnowledgeBase,
share interfaces.KBShareService,
) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
enabled := true
guards := &rbacGuards{
cfg: &config.Config{Tenant: &config.TenantConfig{EnableRBAC: &enabled}},
knowledgeService: &downloadKnowledgeLookup{knowledge: knowledge},
kbService: &stubWikiKBLookup{kbs: map[string]*types.KnowledgeBase{kb.ID: kb}},
kbShareService: share,
}
r := gin.New()
r.Use(middleware.ErrorHandler())
r.Use(func(c *gin.Context) {
ctx := context.WithValue(c.Request.Context(), types.TenantIDContextKey, uint64(1))
ctx = context.WithValue(ctx, types.TenantRoleContextKey, role)
c.Request = c.Request.WithContext(ctx)
c.Set(types.TenantIDContextKey.String(), uint64(1))
c.Next()
})
RegisterKnowledgeRoutes(r.Group("/api/v1"), &handler.KnowledgeHandler{}, guards)
return r
}
func TestKnowledgeDownloadRejectsTenantViewer(t *testing.T) {
engine := newKnowledgeDownloadRouteTestEngine(
t,
types.TenantRoleViewer,
&types.Knowledge{ID: "knowledge-own", KnowledgeBaseID: "kb-own", TenantID: 1},
&types.KnowledgeBase{ID: "kb-own", TenantID: 1},
nil,
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/knowledge/knowledge-own/download", nil)
engine.ServeHTTP(rec, req)
require.Equal(t, http.StatusForbidden, rec.Code, "body=%s", rec.Body.String())
}
func TestKnowledgeDownloadRejectsReadOnlySharedKB(t *testing.T) {
engine := newKnowledgeDownloadRouteTestEngine(
t,
types.TenantRoleContributor,
&types.Knowledge{ID: "knowledge-shared", KnowledgeBaseID: "kb-shared", TenantID: 2},
&types.KnowledgeBase{ID: "kb-shared", TenantID: 2},
&downloadKBShareStub{permission: types.OrgRoleViewer, source: 2},
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/knowledge/knowledge-shared/download", nil)
engine.ServeHTTP(rec, req)
require.Equal(t, http.StatusForbidden, rec.Code, "body=%s", rec.Body.String())
}
@@ -0,0 +1,140 @@
package router
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Tencent/WeKnora/internal/accesspolicy"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/handler"
"github.com/Tencent/WeKnora/internal/middleware"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type exportKnowledgeService struct {
interfaces.KnowledgeService
knowledge *types.Knowledge
exported bool
}
func (s *exportKnowledgeService) GetKnowledgeByIDOnly(ctx context.Context, id string) (*types.Knowledge, error) {
return (&downloadKnowledgeLookup{knowledge: s.knowledge}).GetKnowledgeByIDOnly(ctx, id)
}
func (s *exportKnowledgeService) GetKnowledgeFile(context.Context, string) (io.ReadCloser, string, error) {
s.exported = true
return io.NopCloser(strings.NewReader("source document")), "source.txt", nil
}
func (s *exportKnowledgeService) ExportFAQEntries(context.Context, string) ([]byte, error) {
s.exported = true
return []byte("question,answer\nq,a\n"), nil
}
func (s *exportKnowledgeService) ExportFAQEntriesJSON(context.Context, string) ([]byte, error) {
s.exported = true
return []byte(`[{"question":"q","answer":"a"}]`), nil
}
type exportResponseRecorder struct{ *httptest.ResponseRecorder }
func (*exportResponseRecorder) CloseNotify() <-chan bool { return nil }
func TestKnowledgeExportPublicationAndExistingBoundaries(t *testing.T) {
gin.SetMode(gin.TestMode)
retrieveKey := &types.TenantAPIKeyScope{
KeyID: 1, Capabilities: types.StringArray{"retrieve"}, KnowledgeBaseIDs: types.StringArray{"kb-export"},
}
cases := []struct {
name string
role types.TenantRole
global bool
creator string
systemAdmin bool
native bool
foreign bool
grant bool
scope *types.TenantAPIKeyScope
want int
}{
{name: "global contributor", role: types.TenantRoleContributor, global: true, want: http.StatusForbidden},
{name: "global creator", role: types.TenantRoleContributor, global: true, creator: "requester", want: http.StatusForbidden},
{name: "global grant", role: types.TenantRoleContributor, global: true, grant: true, want: http.StatusForbidden},
{name: "global viewer", role: types.TenantRoleViewer, global: true, want: http.StatusForbidden},
{name: "global admin", role: types.TenantRoleAdmin, global: true, want: http.StatusOK},
{name: "global owner role", role: types.TenantRoleOwner, global: true, want: http.StatusOK},
{name: "global system admin", role: types.TenantRoleContributor, systemAdmin: true, global: true, want: http.StatusOK},
{name: "private creator", role: types.TenantRoleContributor, creator: "requester", want: http.StatusOK},
{name: "private granted nonowner", role: types.TenantRoleContributor, creator: "owner", grant: true, want: http.StatusForbidden},
{name: "private granted unknown creator", role: types.TenantRoleContributor, grant: true, want: http.StatusForbidden},
{name: "private granted admin", role: types.TenantRoleAdmin, creator: "owner", grant: true, want: http.StatusOK},
{name: "native without snapshot", role: types.TenantRoleContributor, native: true, want: http.StatusOK},
{name: "foreign private", role: types.TenantRoleContributor, foreign: true, want: http.StatusForbidden},
{name: "foreign admin", role: types.TenantRoleAdmin, foreign: true, global: true, want: http.StatusForbidden},
{name: "foreign system admin", role: types.TenantRoleContributor, systemAdmin: true, foreign: true, global: true, want: http.StatusForbidden},
{name: "scoped retrieve key", role: types.TenantRoleViewer, global: true, scope: retrieveKey, want: http.StatusOK},
{name: "full access key", role: types.TenantRoleViewer, global: true, scope: &types.TenantAPIKeyScope{KeyID: 2, FullAccess: true}, want: http.StatusOK},
{name: "key lacks retrieve", role: types.TenantRoleOwner, global: true, scope: &types.TenantAPIKeyScope{KeyID: 3, Capabilities: types.StringArray{"ingest"}}, want: http.StatusForbidden},
{name: "key outside KB scope", role: types.TenantRoleOwner, global: true, scope: &types.TenantAPIKeyScope{KeyID: 4, Capabilities: types.StringArray{"retrieve"}, KnowledgeBaseIDs: types.StringArray{"other-kb"}}, want: http.StatusForbidden},
{name: "foreign retrieve key", role: types.TenantRoleOwner, foreign: true, global: true, scope: retrieveKey, want: http.StatusForbidden},
}
for _, tc := range cases {
for _, endpoint := range []struct{ name, path string }{
{"document", "/api/v1/knowledge/document-export/download"},
{"FAQ CSV", "/api/v1/knowledge-bases/kb-export/faq/entries/export"},
{"FAQ JSON", "/api/v1/knowledge-bases/kb-export/faq/entries/export?format=json"},
} {
t.Run(tc.name+"/"+endpoint.name, func(t *testing.T) {
enabled := true
cfg := &config.Config{Tenant: &config.TenantConfig{EnableRBAC: &enabled}}
kb := &types.KnowledgeBase{ID: "kb-export", TenantID: 1, CreatorID: tc.creator}
if tc.foreign {
kb.TenantID = 2
}
svc := &exportKnowledgeService{knowledge: &types.Knowledge{ID: "document-export", KnowledgeBaseID: kb.ID, TenantID: kb.TenantID}}
guards := &rbacGuards{
cfg: cfg, knowledgeService: svc,
kbService: &stubWikiKBLookup{kbs: map[string]*types.KnowledgeBase{kb.ID: kb}},
apiKeyAuthorizer: middleware.NewAPIKeyRouteAuthorizer(),
}
r := gin.New()
r.Use(middleware.ErrorHandler())
r.Use(func(c *gin.Context) {
ctx := context.WithValue(c.Request.Context(), types.TenantIDContextKey, uint64(1))
ctx = context.WithValue(ctx, types.UserIDContextKey, "requester")
ctx = context.WithValue(ctx, types.TenantRoleContextKey, tc.role)
ctx = context.WithValue(ctx, types.SystemAdminContextKey, tc.systemAdmin)
if !tc.native {
ctx = accesspolicy.WithSnapshot(ctx, &accesspolicy.Snapshot{
Publications: map[string]bool{"knowledge:" + kb.ID: tc.global},
Grants: map[string]bool{"knowledge:" + kb.ID: tc.grant},
})
}
if tc.scope != nil {
ctx = types.WithTenantAPIKeyScope(ctx, *tc.scope)
}
c.Request = c.Request.WithContext(ctx)
c.Set(types.TenantIDContextKey.String(), uint64(1))
c.Set(types.UserIDContextKey.String(), "requester")
c.Next()
})
r.Use(guards.apiKeyAuthorizer.Middleware())
v1 := r.Group("/api/v1")
RegisterKnowledgeRoutes(v1, handler.NewKnowledgeHandler(cfg, svc, nil, nil, nil, nil, nil), guards)
RegisterFAQRoutes(v1, handler.NewFAQHandler(svc, nil), guards)
rec := &exportResponseRecorder{httptest.NewRecorder()}
r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, endpoint.path, nil))
require.Equal(t, tc.want, rec.Code, "body=%s", rec.Body.String())
require.Equal(t, tc.want == http.StatusOK, svc.exported, "export must not reach the service after rejection")
})
}
}
}
+237
View File
@@ -0,0 +1,237 @@
package router
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
secutils "github.com/Tencent/WeKnora/internal/utils"
)
// stubTenantService is a minimal stub of interfaces.TenantService that only
// implements GetTenantByID. Every other method panics — those code paths
// must not be exercised by presigned routes; the panic surfaces test bugs
// instead of silently returning zero values.
type stubTenantService struct {
get func(ctx context.Context, id uint64) (*types.Tenant, error)
}
var _ interfaces.TenantService = (*stubTenantService)(nil)
func (s *stubTenantService) GetTenantByID(ctx context.Context, id uint64) (*types.Tenant, error) {
if s.get == nil {
return nil, os.ErrNotExist
}
return s.get(ctx, id)
}
func (s *stubTenantService) CreateTenant(context.Context, *types.Tenant) (*types.Tenant, error) {
panic("unexpected")
}
func (s *stubTenantService) GetTenantsByIDs(context.Context, []uint64) (map[uint64]*types.Tenant, error) {
panic("unexpected")
}
func (s *stubTenantService) ListTenants(context.Context) ([]*types.Tenant, error) {
panic("unexpected")
}
func (s *stubTenantService) UpdateTenant(context.Context, *types.Tenant) (*types.Tenant, error) {
panic("unexpected")
}
func (s *stubTenantService) DeleteTenant(context.Context, uint64) error { panic("unexpected") }
func (s *stubTenantService) ListAllTenants(context.Context) ([]*types.Tenant, error) {
panic("unexpected")
}
func (s *stubTenantService) BulkSetStorageQuota(context.Context, int64) (int64, error) {
panic("unexpected")
}
func (s *stubTenantService) SearchTenants(context.Context, string, uint64, int, int) ([]*types.Tenant, int64, error) {
panic("unexpected")
}
func (s *stubTenantService) GetTenantByIDForUser(context.Context, uint64, string) (*types.Tenant, error) {
panic("unexpected")
}
func (s *stubTenantService) GetWeKnoraCloudCredentials(context.Context) *types.WeKnoraCloudCredentials {
panic("unexpected")
}
// setupPresignedTestServer wires presignedFileHandler with a real local file
// service rooted at a temp dir, returning the engine, baseDir, and the
// presigned URL generator helper.
func setupPresignedTestServer(t *testing.T) (engine *gin.Engine, baseDir string, signURL func(filePath string, tenantID uint64, ttl time.Duration) string) {
t.Helper()
gin.SetMode(gin.TestMode)
t.Setenv("SYSTEM_AES_KEY", "weknora-test-aes-key-32bytes!!!")
baseDir = t.TempDir()
tenant := &types.Tenant{
ID: 1,
StorageEngineConfig: &types.StorageEngineConfig{
DefaultProvider: "local",
Local: &types.LocalEngineConfig{},
},
}
stubTS := &stubTenantService{
get: func(_ context.Context, id uint64) (*types.Tenant, error) {
if id == tenant.ID {
return tenant, nil
}
return nil, os.ErrNotExist
},
}
engine = gin.New()
handler := presignedFileHandler(stubTS, baseDir)
engine.GET("/api/v1/files/presigned", handler)
engine.HEAD("/api/v1/files/presigned", handler)
signURL = func(filePath string, tenantID uint64, ttl time.Duration) string {
signed, err := secutils.SignFileURL("https://weknora.example.com", filePath, tenantID, ttl)
if err != nil {
t.Fatalf("SignFileURL: %v", err)
}
// Re-parse so we only keep the query part: the test server is on
// a different host than the canonical signing baseURL.
u, _ := url.Parse(signed)
return "/api/v1/files/presigned?" + u.RawQuery
}
return engine, baseDir, signURL
}
// writeTestFile creates baseDir/<relPath> with the given content and returns
// the matching `local://<relPath>` storage path.
func writeTestFile(t *testing.T, baseDir, relPath, content string) string {
t.Helper()
full := filepath.Join(baseDir, filepath.FromSlash(relPath))
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
return "local://" + relPath
}
func TestPresignedFile_HEAD_Returns200WithoutBody(t *testing.T) {
engine, baseDir, signURL := setupPresignedTestServer(t)
storagePath := writeTestFile(t, baseDir, "1/img.png", "PNG-BYTES")
req := httptest.NewRequest(http.MethodHead, signURL(storagePath, 1, time.Hour), nil)
w := httptest.NewRecorder()
engine.ServeHTTP(w, req)
if got, want := w.Code, http.StatusOK; got != want {
t.Fatalf("status = %d, want %d (body=%q)", got, want, w.Body.String())
}
if got := w.Header().Get("Content-Type"); got != "image/png" {
t.Fatalf("Content-Type = %q, want image/png", got)
}
// HEAD must not stream the body — protects backing storage from a
// full read on every IM preview probe.
if w.Body.Len() != 0 {
t.Fatalf("HEAD response body should be empty, got %d bytes", w.Body.Len())
}
}
func TestPresignedFile_GET_ReturnsContent(t *testing.T) {
engine, baseDir, signURL := setupPresignedTestServer(t)
storagePath := writeTestFile(t, baseDir, "1/img.png", "PNG-BYTES")
req := httptest.NewRequest(http.MethodGet, signURL(storagePath, 1, time.Hour), nil)
w := httptest.NewRecorder()
engine.ServeHTTP(w, req)
if got, want := w.Code, http.StatusOK; got != want {
t.Fatalf("status = %d, want %d", got, want)
}
if got := w.Body.String(); got != "PNG-BYTES" {
t.Fatalf("body = %q, want %q", got, "PNG-BYTES")
}
}
func TestPresignedFile_ForcesActiveContentDownload(t *testing.T) {
engine, baseDir, signURL := setupPresignedTestServer(t)
storagePath := writeTestFile(t, baseDir, "1/payload.svg", `<svg onload="alert(1)"></svg>`)
req := httptest.NewRequest(http.MethodGet, signURL(storagePath, 1, time.Hour), nil)
w := httptest.NewRecorder()
engine.ServeHTTP(w, req)
if got, want := w.Code, http.StatusOK; got != want {
t.Fatalf("status = %d, want %d", got, want)
}
if got := w.Header().Get("Content-Type"); got != "application/octet-stream" {
t.Fatalf("Content-Type = %q, want application/octet-stream", got)
}
if got := w.Header().Get("Content-Disposition"); got != "attachment" {
t.Fatalf("Content-Disposition = %q, want attachment", got)
}
if got := w.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Fatalf("X-Content-Type-Options = %q, want nosniff", got)
}
}
func TestPresignedFile_InvalidSig_403(t *testing.T) {
engine, _, _ := setupPresignedTestServer(t)
// Hand-craft a URL with a tampered signature.
q := url.Values{}
q.Set("file_path", "local://1/img.png")
q.Set("tenant_id", "1")
q.Set("expires", strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10))
q.Set("sig", "deadbeefdeadbeef")
for _, method := range []string{http.MethodGet, http.MethodHead} {
t.Run(method, func(t *testing.T) {
req := httptest.NewRequest(method, "/api/v1/files/presigned?"+q.Encode(), nil)
w := httptest.NewRecorder()
engine.ServeHTTP(w, req)
if got, want := w.Code, http.StatusForbidden; got != want {
t.Fatalf("status = %d, want %d", got, want)
}
})
}
}
func TestPresignedFile_MissingParams_400(t *testing.T) {
engine, _, _ := setupPresignedTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/files/presigned?file_path=local%3A%2F%2F1%2Fimg.png", nil)
w := httptest.NewRecorder()
engine.ServeHTTP(w, req)
if got, want := w.Code, http.StatusBadRequest; got != want {
t.Fatalf("status = %d, want %d", got, want)
}
if !strings.Contains(w.Body.String(), "missing required parameters") {
t.Fatalf("body = %q, want missing-params error", w.Body.String())
}
}
func TestPresignedFile_MissingFile_404(t *testing.T) {
engine, _, signURL := setupPresignedTestServer(t)
// A legitimately signed URL pointing at a file that does not exist.
req := httptest.NewRequest(http.MethodGet, signURL("local://1/nope.png", 1, time.Hour), nil)
w := httptest.NewRecorder()
engine.ServeHTTP(w, req)
if got, want := w.Code, http.StatusNotFound; got != want {
t.Fatalf("status = %d, want %d", got, want)
}
}
+30
View File
@@ -0,0 +1,30 @@
package router
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestFrontendStaticDoesNotInterceptResourceGrant(t *testing.T) {
gin.SetMode(gin.TestMode)
webDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(webDir, "index.html"), []byte("spa"), 0o600))
t.Setenv("WEKNORA_WEB_DIR", webDir)
r := gin.New()
serveFrontendStatic(r)
r.GET("/r/:token", func(c *gin.Context) { c.String(http.StatusOK, "resource") })
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/r/token", nil)
r.ServeHTTP(recorder, request)
require.Equal(t, http.StatusOK, recorder.Code)
require.Equal(t, "resource", recorder.Body.String())
}
+212
View File
@@ -0,0 +1,212 @@
package router
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
apprepo "github.com/Tencent/WeKnora/internal/application/repository"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/handler"
"github.com/Tencent/WeKnora/internal/middleware"
"github.com/Tencent/WeKnora/internal/types"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type stubWikiKBLookup struct {
kbs map[string]*types.KnowledgeBase
}
func (s *stubWikiKBLookup) GetKnowledgeBaseByID(_ context.Context, id string) (*types.KnowledgeBase, error) {
if kb, ok := s.kbs[id]; ok {
return kb, nil
}
return nil, apprepo.ErrKnowledgeBaseNotFound
}
func newWikiRouteTestEngine(t *testing.T, callerTenantID uint64, kbLookup *stubWikiKBLookup) *gin.Engine {
return newKBRouteTestEngine(t, callerTenantID, kbLookup, nil, func(r *gin.RouterGroup, guards *rbacGuards) {
RegisterWikiPageRoutes(r, &handler.WikiPageHandler{}, guards)
})
}
func newInitializationRouteTestEngine(t *testing.T, callerTenantID uint64, kbLookup *stubWikiKBLookup) *gin.Engine {
return newKBRouteTestEngine(t, callerTenantID, kbLookup, nil, func(r *gin.RouterGroup, guards *rbacGuards) {
RegisterInitializationRoutes(r, &handler.InitializationHandler{}, guards)
})
}
func newKBRouteTestEngine(
t *testing.T,
callerTenantID uint64,
kbLookup *stubWikiKBLookup,
apiKeyScope *types.TenantAPIKeyScope,
register func(r *gin.RouterGroup, guards *rbacGuards),
) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
enabled := true
cfg := &config.Config{
Tenant: &config.TenantConfig{EnableRBAC: &enabled},
}
guards := &rbacGuards{
cfg: cfg,
kbService: kbLookup,
}
r := gin.New()
r.Use(middleware.ErrorHandler())
r.Use(func(c *gin.Context) {
ctx := c.Request.Context()
ctx = context.WithValue(ctx, types.TenantIDContextKey, callerTenantID)
role := types.TenantRoleViewer
if apiKeyScope != nil {
ctx = types.WithTenantAPIKeyScope(ctx, *apiKeyScope)
if apiKeyScope.FullAccess {
role = types.TenantRoleOwner
}
}
ctx = context.WithValue(ctx, types.TenantRoleContextKey, role)
c.Request = c.Request.WithContext(ctx)
c.Set(types.TenantIDContextKey.String(), callerTenantID)
c.Next()
})
register(r.Group("/api/v1"), guards)
return r
}
func tenantKBLookupFixture() *stubWikiKBLookup {
return &stubWikiKBLookup{
kbs: map[string]*types.KnowledgeBase{
"kb-allowed": {ID: "kb-allowed", TenantID: 1, Type: types.KnowledgeBaseTypeWiki},
"kb-other": {ID: "kb-other", TenantID: 1, Type: types.KnowledgeBaseTypeWiki},
},
}
}
func TestInitializationConfigRouteDenyCrossTenantKB(t *testing.T) {
kbLookup := &stubWikiKBLookup{
kbs: map[string]*types.KnowledgeBase{
"kb-victim": {ID: "kb-victim", TenantID: 999},
},
}
engine := newInitializationRouteTestEngine(t, 1, kbLookup)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/initialization/config/kb-victim", nil)
engine.ServeHTTP(rec, req)
require.Equal(t, http.StatusForbidden, rec.Code, "body=%s", rec.Body.String())
}
func TestWikiReadRoutesDenyCrossTenantKB(t *testing.T) {
kbLookup := &stubWikiKBLookup{
kbs: map[string]*types.KnowledgeBase{
"kb-victim": {
ID: "kb-victim",
TenantID: 999,
Type: types.KnowledgeBaseTypeWiki,
},
},
}
engine := newWikiRouteTestEngine(t, 1, kbLookup)
paths := []string{
"/api/v1/knowledgebase/kb-victim/wiki/pages",
"/api/v1/knowledgebase/kb-victim/wiki/pages/secret-page",
"/api/v1/knowledgebase/kb-victim/wiki/folders",
"/api/v1/knowledgebase/kb-victim/wiki/index",
"/api/v1/knowledgebase/kb-victim/wiki/graph",
"/api/v1/knowledgebase/kb-victim/wiki/stats",
"/api/v1/knowledgebase/kb-victim/wiki/search?q=test",
"/api/v1/knowledgebase/kb-victim/wiki/lint",
"/api/v1/knowledgebase/kb-victim/wiki/issues",
"/api/v1/knowledgebase/kb-victim/wiki/revisions/secret-page",
}
for _, path := range paths {
t.Run(path, func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, path, nil)
engine.ServeHTTP(rec, req)
require.Equal(t, http.StatusForbidden, rec.Code, "body=%s", rec.Body.String())
})
}
}
func TestWikiOperationLogRouteIsRemoved(t *testing.T) {
engine := newWikiRouteTestEngine(t, 1, tenantKBLookupFixture())
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/knowledgebase/kb-allowed/wiki/log", nil)
engine.ServeHTTP(rec, req)
require.Equal(t, http.StatusNotFound, rec.Code, "body=%s", rec.Body.String())
}
func TestInitializationWriteRoutesDenyOutOfScopeAPIKeyKB(t *testing.T) {
kbLookup := tenantKBLookupFixture()
scope := &types.TenantAPIKeyScope{
KnowledgeBaseIDs: types.StringArray{"kb-allowed"},
Capabilities: types.StringArray{string(types.APIKeyCapabilityManageKnowledgeBases)},
}
engine := newKBRouteTestEngine(t, 1, kbLookup, scope, func(r *gin.RouterGroup, guards *rbacGuards) {
RegisterInitializationRoutes(r, &handler.InitializationHandler{}, guards)
})
cases := []struct {
method string
path string
body string
}{
{http.MethodPut, "/api/v1/initialization/config/kb-other", `{}`},
{http.MethodPost, "/api/v1/initialization/initialize/kb-other", `{}`},
}
for _, tc := range cases {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
req.Header.Set("Content-Type", "application/json")
engine.ServeHTTP(rec, req)
require.Equal(t, http.StatusForbidden, rec.Code, "body=%s", rec.Body.String())
})
}
}
func TestWikiWriteRoutesDenyOutOfScopeAPIKeyKB(t *testing.T) {
kbLookup := tenantKBLookupFixture()
scope := &types.TenantAPIKeyScope{
KnowledgeBaseIDs: types.StringArray{"kb-allowed"},
Capabilities: types.StringArray{string(types.APIKeyCapabilityIngest)},
}
engine := newKBRouteTestEngine(t, 1, kbLookup, scope, func(r *gin.RouterGroup, guards *rbacGuards) {
RegisterWikiPageRoutes(r, &handler.WikiPageHandler{}, guards)
})
cases := []struct {
method string
path string
}{
{http.MethodPost, "/api/v1/knowledgebase/kb-other/wiki/pages"},
{http.MethodPut, "/api/v1/knowledgebase/kb-other/wiki/pages/some-page"},
{http.MethodDelete, "/api/v1/knowledgebase/kb-other/wiki/pages/some-page"},
{http.MethodPost, "/api/v1/knowledgebase/kb-other/wiki/folders"},
{http.MethodPost, "/api/v1/knowledgebase/kb-other/wiki/rebuild-links"},
{http.MethodPost, "/api/v1/knowledgebase/kb-other/wiki/auto-fix"},
{http.MethodPut, "/api/v1/knowledgebase/kb-other/wiki/issues/1/status"},
{http.MethodPost, "/api/v1/knowledgebase/kb-other/wiki/revert"},
}
for _, tc := range cases {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(`{}`))
req.Header.Set("Content-Type", "application/json")
engine.ServeHTTP(rec, req)
require.Equal(t, http.StatusForbidden, rec.Code, "body=%s", rec.Body.String())
})
}
}
+388
View File
@@ -0,0 +1,388 @@
package router
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
"github.com/Tencent/WeKnora/internal/handler"
"github.com/Tencent/WeKnora/internal/middleware"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
// RegisterCustomAgentRoutes registers custom agent routes.
//
// Mutating routes use OwnedAgentOrAdmin: the original creator can edit
// their agent, otherwise Admin+ is required. Built-in agents
// (IsBuiltin=true) have an empty creator and are always Admin+. Reads
// are Viewer+, copy is Contributor+ (the copy is owned by the caller).
func RegisterCustomAgentRoutes(r *gin.RouterGroup, agentHandler *handler.CustomAgentHandler, g *rbacGuards) {
agents := g.apiKeyGroup(r.Group("/agents"), apiKeyFullAccess())
// agentsRead are the agent read endpoints. They stay full-access only for
// plain scoped keys (agent config can carry sensitive model/MCP bindings),
// but read_agents, chat, or manage_agents may read them.
agentsRead := agents.With(apiKeyReadAgents(apiKeyManageAgents(apiKeyChat(apiKeyFullAccess()))))
// agentsWrite are the agent authoring endpoints. Owner by default, but a
// key granted manage_agents may author agents without full Owner.
agentsWrite := agents.With(apiKeyManageAgents(apiKeyFullAccess()))
{
// Get placeholder definitions (must be before /:id to avoid conflict) — Viewer+
agentsRead.GET("/placeholders", g.Viewer(), agentHandler.GetPlaceholders)
// List smart-reasoning agent type presets (rag-qa / wiki-qa / hybrid / custom) — Viewer+
agentsRead.GET("/type-presets", g.Viewer(), agentHandler.GetAgentTypePresets)
// Create custom agent — Contributor+
agentsWrite.POST("", g.Contributor(), agentHandler.CreateAgent)
agentsWrite.POST("/draft", g.Contributor(), agentHandler.DraftAgent)
// List all agents (including built-in) — Viewer+
agentsRead.GET("", g.Viewer(), agentHandler.ListAgents)
// Get agent by ID — Viewer+
agentsRead.GET("/:id", g.Viewer(), agentHandler.GetAgent)
// Update agent — creator OR Admin+
agentsWrite.PUT("/:id", g.OwnedAgentOrAdmin(), agentHandler.UpdateAgent)
// Delete agent — creator OR Admin+
agentsWrite.DELETE("/:id", g.OwnedAgentOrAdmin(), agentHandler.DeleteAgent)
// Copy agent — Contributor+ (copy is owned by the caller)
agentsWrite.POST("/:id/copy", g.Contributor(), agentHandler.CopyAgent)
}
// Registered outside the group to avoid Gin route conflict with /agents/:id/shares in organization routes
g.apiKeyRoute(r, http.MethodGet, "/agents/:id/suggested-questions",
apiKeyReadAgents(apiKeyManageAgents(apiKeyChat(apiKeyFullAccess()))), g.Viewer(), agentHandler.GetSuggestedQuestions)
}
// RegisterUserFavoriteRoutes wires the per-user starred-resource endpoints.
//
// Authorization: the handler always derives (user_id, tenant_id) from the
// auth context — there is no admin-style "see another user's favorites"
// path — so a Viewer floor is the right gate. The endpoints intentionally
// don't follow the OwnedXOrAdmin pattern: favorites aren't owned by the
// resource's creator, they're owned by the user *doing* the starring.
func RegisterUserFavoriteRoutes(r *gin.RouterGroup, h *handler.UserResourceFavoriteHandler, g *rbacGuards) {
// Favorites are per-user; not declared for API keys (default-deny).
favs := r.Group("/user/favorites")
{
favs.GET("", g.Viewer(), h.ListFavorites)
favs.POST("", g.Viewer(), h.AddFavorite)
favs.DELETE("/:type/:id", g.Viewer(), h.RemoveFavorite)
}
}
// RegisterSkillRoutes registers skill routes.
//
// PR 2 currently only exposes a read-only `ListSkills`; gated to
// Viewer+. Future skill upload / enable endpoints must use Admin+ since
// skills run sandboxed code on tenant resources.
func RegisterSkillRoutes(r *gin.RouterGroup, skillHandler *handler.SkillHandler, g *rbacGuards) {
skills := r.Group("/skills")
{
// Usable skills for @ mention / chat — Viewer+
skills.GET("", g.Viewer(), skillHandler.ListSkills)
// Catalog reads are Viewer+ so the agent editor can show uninstalled skills.
skills.GET("/catalog", g.Viewer(), skillHandler.ListCatalog)
// Markdown rules do not execute code. Writes are member-facing; the
// service also enforces per-Agent ownership for the atomic batch.
skills.GET("/instructions", g.Viewer(), skillHandler.ListInstructionSkills)
skills.GET("/instructions/:id", g.Viewer(), skillHandler.GetInstructionSkill)
skills.POST("/instructions/import", g.Contributor(), skillHandler.ImportInstructionSkill)
skills.POST("/instructions/mount", g.Contributor(), skillHandler.MountInstructionSkills)
skills.DELETE("/instructions/:id", g.Contributor(), skillHandler.DeleteInstructionSkill)
}
// Catalog writes bake into sandbox images; scoped API keys cannot hold them.
catalogWrite := g.apiKeyGroup(r.Group("/skills/catalog"), apiKeyFullAccess())
{
catalogWrite.POST("", g.Admin(), skillHandler.RegisterCatalog)
catalogWrite.POST("/:id/install", g.Admin(), skillHandler.InstallCatalog)
catalogWrite.GET("/:id/files", g.Admin(), skillHandler.ListCatalogFiles)
catalogWrite.GET("/:id/files/content", g.Admin(), skillHandler.GetCatalogFile)
catalogWrite.DELETE("/:id", g.Admin(), skillHandler.DeleteCatalog)
}
}
// RegisterOrganizationRoutes registers organization and sharing routes
func RegisterOrganizationRoutes(r *gin.RouterGroup, orgHandler *handler.OrganizationHandler, g *rbacGuards) {
// Organization routes
orgs := g.apiKeyGroup(r.Group("/organizations"), apiKeyManageSpaces(apiKeyFullAccess()))
{
// Create organization (Admin+ in caller's tenant only)
orgs.POST("", g.Admin(), orgHandler.CreateOrganization)
// List my organizations — Viewer+ floor so revoked/non-member
// accounts whose JWT still validates can't enumerate org membership.
orgs.GET("", g.Viewer(), orgHandler.ListMyOrganizations)
// Preview organization by invite code (without joining) — Viewer+
orgs.GET("/preview/:code", g.Viewer(), orgHandler.PreviewByInviteCode)
// Join organization by invite code (Admin+ in caller's tenant only)
orgs.POST("/join", g.Admin(), orgHandler.JoinByInviteCode)
// Submit join request (for organizations that require approval) (Admin+)
orgs.POST("/join-request", g.Admin(), orgHandler.SubmitJoinRequest)
// Search searchable (discoverable) organizations — Viewer+
orgs.GET("/search", g.Viewer(), orgHandler.SearchOrganizations)
// Join searchable organization by ID (no invite code) (Admin+)
orgs.POST("/join-by-id", g.Admin(), orgHandler.JoinByOrganizationID)
// Get organization by ID — Viewer+
orgs.GET("/:id", g.Viewer(), orgHandler.GetOrganization)
// Update organization — Admin+ in caller's tenant.
// Service still gates on "caller's tenant is the org owner";
// the route guard adds a defence-in-depth layer that stops a
// tenant Viewer/Contributor from ever reaching the service.
orgs.PUT("/:id", g.Admin(), orgHandler.UpdateOrganization)
// Delete organization — Admin+ in caller's tenant. Same
// rationale as PUT above; deletion is irreversible so the
// route-layer floor is at least as strict.
orgs.DELETE("/:id", g.Admin(), orgHandler.DeleteOrganization)
// Leave organization (Admin+ in caller's tenant only)
orgs.POST("/:id/leave", g.Admin(), orgHandler.LeaveOrganization)
// Request role upgrade (Admin+ in caller's tenant only).
// An upgrade approval changes the whole tenant's org role, so it
// must not be initiated by a tenant Viewer/Contributor.
orgs.POST("/:id/request-upgrade", g.Admin(), orgHandler.RequestRoleUpgrade)
// Generate invite code — Admin+ in caller's tenant. Issuing an
// invite code is an admin action; the service layer additionally
// requires the caller's tenant to be admin in the org.
orgs.POST("/:id/invite-code", g.Admin(), orgHandler.GenerateInviteCode)
// Search tenants for invite (admin only). Plan 3 changed the
// unit of membership to "tenant"; this endpoint returns
// candidate tenants (with one representative user attached)
// instead of one row per user.
orgs.GET("/:id/search-tenants", g.Admin(), orgHandler.SearchTenantsForInvite)
// Deprecated alias for /:id/search-tenants. Old frontends that
// still hit search-users will receive the tenant-grouped shape;
// the deprecation is documented in the handler.
orgs.GET("/:id/search-users", g.Admin(), orgHandler.SearchUsersForInvite)
// Invite member directly (admin only)
orgs.POST("/:id/invite", g.Admin(), orgHandler.InviteMember)
// List members — Viewer+
orgs.GET("/:id/members", g.Viewer(), orgHandler.ListMembers)
// Update member role (path parameter is the member tenant_id) —
// Admin+ in caller's tenant. Changing another tenant's org role
// is the symmetric counterpart of removing them; both must be
// gated the same way.
orgs.PUT("/:id/members/:tenant_id", g.Admin(), orgHandler.UpdateMemberRole)
// Remove member (path parameter is the member tenant_id).
// Both self-removal (caller's own tenant) and admin-removal-of-other
// take a whole tenant out of the org, so the route must be Admin+
// in the caller's tenant — symmetric with POST /:id/leave above.
orgs.DELETE("/:id/members/:tenant_id", g.Admin(), orgHandler.RemoveMember)
// List join requests (admin only) — caller's tenant must be at
// least Admin to even see the queue (a tenant Viewer has no
// authority to act on it).
orgs.GET("/:id/join-requests", g.Admin(), orgHandler.ListJoinRequests)
// Review join request (admin only)
orgs.PUT("/:id/join-requests/:request_id/review", g.Admin(), orgHandler.ReviewJoinRequest)
// List knowledge bases shared to this organization — Viewer+
orgs.GET("/:id/shares", g.Viewer(), orgHandler.ListOrgShares)
// List agents shared to this organization — Viewer+
orgs.GET("/:id/agent-shares", g.Viewer(), orgHandler.ListOrgAgentShares)
// List all knowledge bases in this organization (including mine) for list-page space view — Viewer+
orgs.GET("/:id/shared-knowledge-bases", g.Viewer(), orgHandler.ListOrganizationSharedKnowledgeBases)
// List all agents in this organization (including mine) for list-page space view — Viewer+
orgs.GET("/:id/shared-agents", g.Viewer(), orgHandler.ListOrganizationSharedAgents)
}
// Knowledge base sharing routes (add to existing kb routes).
// 分享 KB 到组织 = 让组织里所有人能读这个 KB;这跟"修改 KB 元信息"
// 同等敏感,所以挂同款 OwnedKBOrAdmin 矩阵。Viewer 在自己空间里
// 也不能私自把 KB 暴露出去。
// 分享管理不通过 capability 授予(manage_spaces 也不含);仅 full-access
// key(空间级全权)可管理分享,scoped key 保持 default-deny。
kbShares := g.apiKeyGroup(r.Group("/knowledge-bases/:id/shares"), apiKeyFullAccess())
{
// Share knowledge base
kbShares.POST("", g.OwnedKBOrAdmin(), orgHandler.ShareKnowledgeBase)
// List shares — Viewer+ 即可,纯读取
kbShares.GET("", g.Viewer(), orgHandler.ListKBShares)
// Update share permission
kbShares.PUT("/:share_id", g.OwnedKBOrAdmin(), orgHandler.UpdateSharePermission)
// Remove share
kbShares.DELETE("/:share_id", g.OwnedKBOrAdmin(), orgHandler.RemoveShare)
}
// Agent sharing routes — same rationale as KB shares: 分享/取消分享
// 跟修改 agent 同等敏感,挂 OwnedAgentOrAdmin。
//
// GET 走 OwnedAgentOrAdmin 作为 JWT 侧的 owner 校验;service 层
// ListSharesByAgent 现在也强制 tenant 归属(与 ListSharesByKnowledgeBase
// 对齐),这样 full-access API key(会短路路由 guard)也无法跨空间
// 枚举他人 agent 的分享。
// 同 KB 分享:分享管理不通过 capability 授予;仅 full-access key
// (空间级全权)可管理 agent 分享,scoped key 保持 default-deny。
agentShares := g.apiKeyGroup(r.Group("/agents/:id/shares"), apiKeyFullAccess())
{
agentShares.POST("", g.OwnedAgentOrAdmin(), orgHandler.ShareAgent)
agentShares.GET("", g.OwnedAgentOrAdmin(), orgHandler.ListAgentShares)
agentShares.DELETE("/:share_id", g.OwnedAgentOrAdmin(), orgHandler.RemoveAgentShare)
}
// Shared knowledge bases route — Viewer+
g.apiKeyRoute(r, http.MethodGet, "/shared-knowledge-bases", apiKeyManageSpaces(apiKeyFullAccess()), g.Viewer(), orgHandler.ListSharedKnowledgeBases)
// Shared agents route — Viewer+
g.apiKeyRoute(r, http.MethodGet, "/shared-agents", apiKeyManageSpaces(apiKeyFullAccess()), g.Viewer(), orgHandler.ListSharedAgents)
// "Disable by me" 是空间级偏好(写到 tenant_disabled_shared_agents),
// 影响整个空间在会话下拉里看到的 agent 列表。任何 Viewer 改这个表就
// 等于替整个空间做决定 — 必须 Admin+ 才允许调整。
g.apiKeyRoute(r, http.MethodPost, "/shared-agents/disabled", apiKeyManageSpaces(apiKeyFullAccess()), g.Admin(), orgHandler.SetSharedAgentDisabledByMe)
}
// RegisterEmbedPublicRoutes registers anonymous embed endpoints secured by publish tokens.
func RegisterEmbedPublicRoutes(
r *gin.Engine,
embedHandler *handler.EmbedChannelHandler,
embedService interfaces.EmbedChannelService,
tenantService interfaces.TenantService,
redisClient *redis.Client,
fileService interfaces.FileService,
storageResolver interfaces.StorageBackendResolver,
resourceCatalogs ...interfaces.ResourceCatalog,
) {
if embedHandler == nil || embedService == nil {
return
}
embed := r.Group("/api/v1/embed/:channel_id", middleware.EmbedAuth(embedService, tenantService, redisClient))
{
embed.POST("/exchange", embedHandler.ExchangeEmbedSession)
embed.GET("/config", embedHandler.GetEmbedConfig)
embed.GET("/suggested-questions", embedHandler.GetEmbedSuggestedQuestions)
embed.GET("/chunks/:chunk_id", embedHandler.GetEmbedChunk)
embed.POST("/sessions", embedHandler.CreateEmbedSession)
embed.POST("/knowledge-chat/:session_id", embedHandler.EmbedKnowledgeChat)
embed.POST("/agent-chat/:session_id", embedHandler.EmbedAgentChat)
embed.GET("/messages/:session_id/load", embedHandler.EmbedLoadMessages)
embed.POST("/sessions/:session_id/stop", embedHandler.EmbedStopSession)
embed.GET("/sessions/:session_id/messages/:message_id/suggestions", embedHandler.EmbedGetMessageSuggestions)
embed.POST("/sessions/:session_id/messages/:message_id/suggestions", embedHandler.EmbedEnsureMessageSuggestions)
embed.POST("/sessions/:session_id/suggestion-events", embedHandler.EmbedRecordSuggestionEvent)
embed.POST("/sessions/:session_id/events", embedHandler.EmbedRelayWebhookEvent)
embed.POST("/sessions/:session_id/mcp-oauth-resolutions/:pending_id", embedHandler.EmbedResolveMCPOAuth)
embed.POST("/sessions/:session_id/mcp-oauth-resolutions/:pending_id/cancel", embedHandler.EmbedCancelMCPOAuth)
embed.POST("/sessions/:session_id/mcp-services/:id/oauth/authorize-url", embedHandler.EmbedMCPOAuthAuthorizeURL)
embed.GET("/sessions/:session_id/mcp-services/:id/oauth/status", embedHandler.EmbedMCPOAuthStatus)
embed.POST("/sessions/:session_id/tool-approvals/:pending_id", embedHandler.EmbedResolveToolApproval)
// Serve images embedded in bot replies (e.g. chart exports). EmbedAuth
// injects the channel's tenant, and the handler enforces that the
// requested path belongs to that tenant.
embed.GET("/files", newFileServeHandler(fileService, storageResolver, resourceCatalogs...))
}
}
// RegisterEmbedChannelRoutes registers authenticated embed channel management routes.
func RegisterEmbedChannelRoutes(r *gin.RouterGroup, embedHandler *handler.EmbedChannelHandler, g *rbacGuards) {
if embedHandler == nil {
return
}
agentEmbed := g.apiKeyGroup(r.Group("/agents/:id/embed-channels"), apiKeyManageChannels(apiKeyFullAccess()))
{
agentEmbed.POST("", g.Admin(), embedHandler.CreateEmbedChannel)
agentEmbed.GET("", g.Viewer(), embedHandler.ListEmbedChannels)
}
channels := g.apiKeyGroup(r.Group("/embed-channels"), apiKeyManageChannels(apiKeyFullAccess()))
{
channels.GET("", g.Viewer(), embedHandler.ListAllEmbedChannels)
channels.GET("/:channel_id", g.Viewer(), embedHandler.GetEmbedChannel)
channels.PUT("/:channel_id", g.Admin(), embedHandler.UpdateEmbedChannel)
channels.DELETE("/:channel_id", g.Admin(), embedHandler.DeleteEmbedChannel)
channels.POST("/:channel_id/rotate-token", g.Admin(), embedHandler.RotateEmbedToken)
channels.POST("/:channel_id/preview-session", g.Viewer(), embedHandler.IssuePreviewSession)
channels.GET("/:channel_id/stats", g.Viewer(), embedHandler.GetEmbedChannelStats)
}
}
// RegisterIMRoutes registers IM callback routes.
// These are registered BEFORE auth middleware since IM platforms use their own signature verification.
func RegisterIMRoutes(r *gin.Engine, imHandler *handler.IMHandler) {
im := r.Group("/api/v1/im")
{
im.GET("/callback/:channel_id", imHandler.IMCallback)
im.POST("/callback/:channel_id", imHandler.IMCallback)
}
}
// RegisterIMChannelRoutes registers IM channel CRUD routes (requires authentication).
//
// IM channels carry external bot credentials (WeChat/Feishu/Slack/...);
// listing is Viewer+ but any mutation, toggle, or QR-code login flow
// (which can hijack a personal WeChat session) is Admin+.
func RegisterIMChannelRoutes(r *gin.RouterGroup, imHandler *handler.IMHandler, g *rbacGuards) {
// Channel CRUD under agents
agentChannels := g.apiKeyGroup(r.Group("/agents/:id/im-channels"), apiKeyManageChannels(apiKeyFullAccess()))
{
agentChannels.POST("", g.Admin(), imHandler.CreateIMChannel)
agentChannels.GET("", g.Viewer(), imHandler.ListIMChannels)
}
// Channel operations by channel ID
channels := g.apiKeyGroup(r.Group("/im-channels"), apiKeyManageChannels(apiKeyFullAccess()))
{
channels.GET("", g.Viewer(), imHandler.ListAllIMChannels)
channels.PUT("/:id", g.Admin(), imHandler.UpdateIMChannel)
channels.DELETE("/:id", g.Admin(), imHandler.DeleteIMChannel)
channels.POST("/:id/toggle", g.Admin(), imHandler.ToggleIMChannel)
}
// WeChat QR code login (requires authentication) — Admin+: a successful
// scan binds a personal WeChat account to the tenant.
wechatGroup := g.apiKeyGroup(r.Group("/wechat"), apiKeyManageChannels(apiKeyFullAccess()))
{
wechatGroup.POST("/qrcode", g.Admin(), imHandler.WeChatGetQRCode)
wechatGroup.POST("/qrcode/status", g.Admin(), imHandler.WeChatPollQRCodeStatus)
}
}
// embedChannelIDFromPath extracts the channel id from an /embed/:channelID path.
func embedChannelIDFromPath(path string) string {
const prefix = "/embed/"
if !strings.HasPrefix(path, prefix) {
return ""
}
rest := strings.TrimPrefix(path, prefix)
if i := strings.IndexByte(rest, '/'); i >= 0 {
rest = rest[:i]
}
if i := strings.IndexByte(rest, '?'); i >= 0 {
rest = rest[:i]
}
return strings.TrimSpace(rest)
}
// embedFrameAncestorsMiddleware sets a per-channel `frame-ancestors` CSP on the
// embed SPA page so it can only be framed by the channel's allowed origins.
// When the channel declares no origins (or "*"), no restriction is applied,
// matching the API allowlist semantics. Only GET/HEAD page loads are handled.
func embedFrameAncestorsMiddleware(svc interfaces.EmbedChannelService) gin.HandlerFunc {
return func(c *gin.Context) {
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead {
c.Next()
return
}
channelID := embedChannelIDFromPath(c.Request.URL.Path)
if channelID == "" {
c.Next()
return
}
ch, err := svc.LookupEnabledChannel(c.Request.Context(), channelID)
if err != nil || ch == nil {
c.Next()
return
}
origins := ch.AllowedOriginsList()
sources := make([]string, 0, len(origins))
wildcard := false
for _, o := range origins {
o = strings.TrimSpace(o)
if o == "" {
continue
}
if o == "*" {
wildcard = true
break
}
sources = append(sources, o)
}
// No explicit origins or a wildcard => do not constrain framing here.
if wildcard || len(sources) == 0 {
c.Next()
return
}
c.Header("Content-Security-Policy", "frame-ancestors "+strings.Join(sources, " "))
c.Next()
}
}
+345
View File
@@ -0,0 +1,345 @@
package router
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/Tencent/WeKnora/internal/handler"
"github.com/Tencent/WeKnora/internal/middleware"
"github.com/Tencent/WeKnora/internal/types"
)
// RegisterTenantRoutes 注册空间相关的路由
//
// Tenant-internal RBAC for /tenants/:id:
// - GET /:id Viewer+ (read tenant settings)
// - PUT /:id Owner+ (mutate tenant config)
// - DELETE /:id Owner+ (also normally a CanAccessAllTenants op)
// - GET/POST/PUT/DELETE /:id/api-keys Owner+ (scoped API key management)
// - GET /:id/members Viewer+ (any member can see who else is in)
// - POST /:id/members Owner+ (only Owner can add new members)
// - PUT /:id/members/:user_id Owner+ (only Owner can change roles)
// - DELETE /:id/members/:user_id Owner+ (only Owner can remove members)
// - POST /:id/leave Viewer+ (any member can quit on their own)
//
// All /tenants/:id endpoints share g.PathTenantMatch() at the group
// level: middleware/access.go enforces "URL :id == active tenant"
// (with the cross-tenant superuser carve-out) so an Owner-of-A cannot
// drive operations against tenant B by changing the URL. This used to
// be authorizeTenantAccess in tenant.go and resolveTenantIDFromPath in
// tenant_member.go; collapsing it into one route guard means the
// declaration itself documents the rule.
//
// Cross-tenant superuser endpoints (/tenants/all, /tenants/search) use
// g.CrossTenant(): RequireCrossTenantAccess in access.go combines the
// CanAccessAllTenants user attribute with the cluster-wide
// EnableCrossTenantAccess flag, replacing the 12-line if-block that
// previously opened ListAllTenants and SearchTenants.
//
// JWT behavior for POST /tenants and GET /tenants remains unchanged. Platform
// API keys may create tenants through system_tenants_manage; workspace keys
// remain default-denied on tenant-catalog operations.
func RegisterTenantRoutes(
r *gin.RouterGroup,
handler *handler.TenantHandler,
memberHandler *handler.TenantMemberHandler,
invitationHandler *handler.TenantInvitationHandler,
auditLogHandler *handler.AuditLogHandler,
g *rbacGuards,
) {
// Cross-tenant superuser endpoints — promoted from handler if-blocks
// to middleware.RequireCrossTenantAccess at the route layer.
g.apiKeyRoute(r, http.MethodGet, "/tenants/all",
apiKeyPlatform(types.APIKeyCapabilitySystemTenantsRead, types.APIKeyCapabilitySystemTenantsManage),
g.CrossTenant(), handler.ListAllTenants)
g.apiKeyRoute(r, http.MethodGet, "/tenants/search",
apiKeyPlatform(types.APIKeyCapabilitySystemTenantsRead, types.APIKeyCapabilitySystemTenantsManage),
g.CrossTenant(), handler.SearchTenants)
// 空间路由组
tenantRoutes := r.Group("/tenants")
{
// 创建空间对所有已登录用户开放:用户可以为自己再开一个工作区,
// handler 内部会调 EnsureOwner 把调用者写成新空间的 Owner。
// 跨空间超管走同一个端点,但能携带 storage_quota / status 等
// 全字段(见 handler.CreateTenant 内部分支)。
// 安全说明:这里不挂 g.CrossTenant(),因为 self-service 创建
// 不需要跨空间特权;handler 也不读写 X-Tenant-ID 指向的现有
// 空间,所以越过 PathTenantMatch 守卫不会扩大攻击面。
// 创建空间不对 API key 开放(注册在原始 group,默认拒绝)。
g.apiKeyRoute(tenantRoutes, http.MethodPost, "",
apiKeyPlatform(types.APIKeyCapabilitySystemTenantsManage), handler.CreateTenant)
g.apiKeyRoute(tenantRoutes, http.MethodGet, "", apiKeyManageTenantSettings(apiKeyFullAccess()), handler.ListTenants)
// Generic KV configuration management (tenant-level). Tenant ID
// is obtained from authentication context; the URL :key is a
// config key, not a tenant ID, so these stay outside the
// PathTenantMatch group. Tenant-level surface: full-access keys may
// call it, and scoped keys need manage_tenant_settings.
g.apiKeyRoute(tenantRoutes, http.MethodGet, "/kv/:key", apiKeyManageTenantSettings(apiKeyFullAccess()), g.Viewer(), handler.GetTenantKV)
g.apiKeyRoute(tenantRoutes, http.MethodPut, "/kv/:key", apiKeyManageTenantSettings(apiKeyFullAccess()), g.Admin(), handler.UpdateTenantKV)
// Per-tenant endpoints share PathTenantMatch at the group level.
// Most /tenants/:id/* endpoints stay undeclared for API keys by
// default — tenant lifecycle and key/principal management require
// full tenant access or JWT ownership. Member/invitation management
// opts in below through the manage_members capability.
tenantByID := tenantRoutes.Group("/:id", g.PathTenantMatch())
{
g.apiKeyRoute(tenantByID, http.MethodGet, "",
apiKeyPlatform(types.APIKeyCapabilitySystemTenantsRead, types.APIKeyCapabilitySystemTenantsManage),
g.Viewer(), handler.GetTenant)
g.apiKeyRoute(tenantByID, http.MethodPut, "",
apiKeyPlatform(types.APIKeyCapabilitySystemTenantsManage), g.Owner(), handler.UpdateTenant)
g.apiKeyRoute(tenantByID, http.MethodDelete, "",
apiKeyPlatform(types.APIKeyCapabilitySystemTenantsManage), g.Owner(), handler.DeleteTenant)
tenantByID.GET("/api-keys", g.Owner(), handler.ListAPIKeys)
tenantByID.POST("/api-keys", g.Owner(), handler.CreateAPIKey)
tenantByID.PUT("/api-keys/:key_id", g.Owner(), handler.UpdateAPIKey)
tenantByID.DELETE("/api-keys/:key_id", g.Owner(), handler.DeleteAPIKey)
tenantByID.GET("/api-principal-config", g.Owner(), handler.GetAPIPrincipalConfig)
tenantByID.PUT("/api-principal-config", g.Owner(), handler.UpdateAPIPrincipalConfig)
tenantByID.POST("/api-principal-test-token", g.Owner(), handler.CreateAPIPrincipalTestToken)
// Tenant member management (PR 3 of #1303). Listing is
// Viewer+ so any active member can see the roster; mutation
// is Owner+ because membership changes are the highest-impact
// tenant op. /:id/leave is Viewer+ — any member can quit on
// their own; the service still rejects when it would leave
// the tenant without an Owner.
if memberHandler != nil {
g.apiKeyRoute(tenantByID, http.MethodGet, "/members", apiKeyManageMembers(apiKeyFullAccess()), g.Viewer(), memberHandler.ListMembers)
g.apiKeyRoute(tenantByID, http.MethodPost, "/members", apiKeyManageMembers(apiKeyFullAccess()), g.Owner(), memberHandler.AddMember)
g.apiKeyRoute(tenantByID, http.MethodPut, "/members/:user_id", apiKeyManageMembers(apiKeyFullAccess()), g.Owner(), memberHandler.UpdateMemberRole)
g.apiKeyRoute(tenantByID, http.MethodDelete, "/members/:user_id", apiKeyManageMembers(apiKeyFullAccess()), g.Owner(), memberHandler.RemoveMember)
tenantByID.POST("/leave", g.Viewer(), memberHandler.LeaveTenant)
}
// Tenant invitation flow. The UI-driven "Invite Member"
// button hits POST /invitations rather than POST /members,
// so the invitee gets to confirm via /me/invitations
// before any tenant_members row is written. List is
// Viewer+ so any member can see pending invites in the
// management view; create/revoke are Owner+ to match the
// existing /members mutation gates. nil-skip pattern
// mirrors memberHandler above for environments built
// without the invitation dependency wired.
if invitationHandler != nil {
g.apiKeyRoute(tenantByID, http.MethodGet, "/invitations", apiKeyManageMembers(apiKeyFullAccess()), g.Viewer(), invitationHandler.ListTenantInvitations)
g.apiKeyRoute(tenantByID, http.MethodPost, "/invitations", apiKeyManageMembers(apiKeyFullAccess()), g.Owner(), invitationHandler.CreateInvitation)
g.apiKeyRoute(tenantByID, http.MethodDelete, "/invitations/:inv_id", apiKeyManageMembers(apiKeyFullAccess()), g.Owner(), invitationHandler.RevokeInvitation)
// Share-link create lives under /invite-links so the URL
// reads as "create a link" rather than another flavour
// of /invitations; the underlying row still lives in the
// tenant_invitations table and shows up in the GET above.
g.apiKeyRoute(tenantByID, http.MethodPost, "/invite-links", apiKeyManageMembers(apiKeyFullAccess()), g.Owner(), invitationHandler.CreateInviteLink)
}
// Audit log feed (PR 6 of #1303). Admin+ so denied-action
// histories don't surface to ordinary members; the
// PathTenantMatch group already prevents cross-tenant
// reads. nil-skip mirrors the memberHandler pattern above
// for environments wired without the audit dependency.
if auditLogHandler != nil {
tenantByID.GET("/audit-log", g.Admin(), auditLogHandler.ListTenantAuditLog)
}
}
}
}
// RegisterMyInvitationRoutes wires the per-user invitation inbox under
// /me/invitations. The v1 group already applies middleware.Auth so we
// don't need a role gate here — the service enforces "only the invitee
// can accept/decline". The list endpoint mounts under /me to make the
// "show me MY invitations" semantics obvious in URLs and logs (vs the
// tenant-scoped /tenants/:id/invitations which lists ALL invitations
// for the tenant). pending-count is a separate, ultra-light endpoint
// the avatar-row badge polls; splitting it off so polling doesn't
// transfer the full list every cycle.
//
// invitationHandler may be nil in environments built without the
// invitation dependency wired; that's a no-op registration which is
// preferable to a startup crash.
func RegisterMyInvitationRoutes(r *gin.RouterGroup, invitationHandler *handler.TenantInvitationHandler) {
if invitationHandler == nil {
return
}
me := r.Group("/me")
{
me.GET("/invitations", invitationHandler.ListMyInvitations)
me.GET("/invitations/pending-count", invitationHandler.CountMyPendingInvitations)
me.POST("/invitations/:inv_id/accept", invitationHandler.AcceptMyInvitation)
me.POST("/invitations/:inv_id/decline", invitationHandler.DeclineMyInvitation)
// 已登录用户用共享链接 token 加入空间(对应 register-by-invite,但不建新账号)。
me.POST("/invitations/accept-by-token", invitationHandler.AcceptMyInvitationByToken)
}
}
// RegisterMyEnvVarRoutes wires the caller's own environment variables under
// /me/env-vars. The v1 group already applies middleware.Auth, and no role gate
// is added on purpose: these are the caller's own values, and the service
// derives whose they are from the context rather than the request.
//
// This deliberately does not reuse /sandbox-configs/:id/skills*, which is
// Admin+ even for reads (see routes_infra.go): an upload there drives a root
// shell whose output is baked into the image, and the listing names what that
// image carries. This endpoint returns declarations and set/unset status only.
//
// h may be nil in environments built without the dependency wired; a no-op
// registration is preferable to a startup crash, as with the invitation inbox.
func RegisterMyEnvVarRoutes(r *gin.RouterGroup, h *handler.MeEnvVarHandler) {
if h == nil {
return
}
me := r.Group("/me/env-vars")
{
me.GET("", h.List)
me.PUT("/skill", h.SetSkill)
me.DELETE("/skill", h.DeleteSkill)
me.PUT("/sandbox", h.SetSandbox)
me.DELETE("/sandbox", h.DeleteSandbox)
}
}
// RegisterAuthRoutes registers authentication routes
func RegisterAuthRoutes(r *gin.RouterGroup, handler *handler.AuthHandler, g *rbacGuards) {
r.POST("/auth/register", handler.Register)
// Share-link surfaces are unauthenticated and accept a plaintext
// token from the caller; rate-limit by IP to bound brute-force /
// enumeration / abuse traffic. Limiter is shared across both
// endpoints (see middleware/auth_public_ratelimit.go) so total
// budget per IP is intuitive.
publicAuthRL := middleware.PublicAuthRateLimit()
r.POST("/auth/register-by-invite", publicAuthRL, handler.RegisterByInvite)
r.POST("/auth/invitations/lookup", publicAuthRL, handler.LookupInvitationByToken)
r.POST("/auth/login", handler.Login)
r.POST("/auth/auto-setup", handler.AutoSetup)
r.GET("/auth/config", handler.GetAuthConfig)
r.POST("/auth/switch-tenant", handler.SwitchTenant)
r.GET("/auth/oidc/config", handler.GetOIDCConfig)
r.GET("/auth/oidc/url", handler.GetOIDCAuthorizationURL)
r.GET("/auth/oidc/callback", handler.OIDCRedirectCallback)
// /auth/oidc/start:直连 302 跳转到 OIDC 提供方,供前端无法走 JS 拉取 URL 的场景直接发起登录
r.GET("/auth/oidc/start", handler.OIDCStart)
r.POST("/auth/refresh", handler.RefreshToken)
r.GET("/auth/validate", handler.ValidateToken)
r.POST("/auth/logout", handler.Logout)
// auth/me returns only the caller's own identity/profile, so it is safe
// for any valid API key. Chat clients / MCP call it to discover "who am I";
// leaving it default-deny was why scoped keys got a 403 here.
g.apiKeyRoute(r, http.MethodGet, "/auth/me", apiKeyAny(), handler.GetCurrentUser)
r.PUT("/auth/me/preferences", handler.UpdateMyPreferences)
r.POST("/auth/change-password", handler.ChangePassword)
}
// RegisterSystemRoutes registers system information routes
//
// Reads (GetSystemInfo / ListParserEngines / GetStorageEngineStatus)
// are gated to Viewer+ — any tenant member can see "is the parser
// reachable". The /*-check / /reconnect endpoints actively probe
// remote services with tenant credentials and could trigger network
// fanout, so they're Admin+.
func RegisterSystemRoutes(
r *gin.RouterGroup,
handler *handler.SystemHandler,
g *rbacGuards,
) {
systemRoutes := g.apiKeyGroup(r.Group("/system"), apiKeyManageVectorStores(apiKeyFullAccess()))
{
systemRoutes.With(apiKeyAny()).GET("/capabilities", g.Viewer(), handler.GetDeploymentCapabilities)
systemRoutes.GET("/info", g.Viewer(), handler.GetSystemInfo)
systemRoutes.GET("/parser-engines", g.Viewer(), handler.ListParserEngines)
systemRoutes.POST("/parser-engines/check", g.Admin(), handler.CheckParserEngines)
systemRoutes.POST("/docreader/reconnect", g.Admin(), handler.ReconnectDocReader)
systemRoutes.GET("/storage-engine-status", g.Viewer(), handler.GetStorageEngineStatus)
systemRoutes.POST("/storage-engine-check", g.Admin(), handler.CheckStorageEngine)
systemRoutes.POST("/sandbox-check", g.Admin(), handler.CheckSandboxConfig)
}
}
// RegisterSystemAdminRoutes registers system administration routes.
//
// All endpoints under this group are gated to SystemAdmin users (i.e.
// User.IsSystemAdmin == true). These are platform-wide operations
// independent of per-tenant Owner/Admin/Contributor/Viewer roles —
// they let org-level superusers grant/revoke system-admin status and,
// in later milestones, will host global settings, built-in models, and
// cross-tenant observability.
//
// Mounted under /api/v1/system/admin/* so the URL scheme stays aligned
// with the existing /api/v1/system/info family. Front-end clients live
// in frontend/src/api/system/index.ts.
//
// auditLogHandler may be nil in environments wired without the audit
// dependency; the /audit-log subroute is then omitted. This mirrors
// the optional wiring in RegisterTenantRoutes.
func RegisterSystemAdminRoutes(
r *gin.RouterGroup,
handler *handler.SystemHandler,
auditLogHandler *handler.AuditLogHandler,
g *rbacGuards,
) {
// Apply SystemAdmin() at the group level — every route below inherits
// the guard, so adding new endpoints can't accidentally drop the gate.
adminRoutes := r.Group("/system/admin", g.SystemAdmin())
{
// P0: SystemAdmin role management
adminRoutes.POST("/promote", handler.PromoteUserToSystemAdmin)
adminRoutes.POST("/revoke", handler.RevokeSystemAdmin)
adminRoutes.GET("/list", handler.ListSystemAdmins)
adminRoutes.POST("/users/reset-password", handler.ResetUserPassword)
adminRoutes.POST("/users/create", handler.CreateSystemUser)
adminRoutes.GET("/api-keys", handler.ListPlatformAPIKeys)
adminRoutes.POST("/api-keys", handler.CreatePlatformAPIKey)
adminRoutes.DELETE("/api-keys/:key_id", handler.DeletePlatformAPIKey)
// P1: platform-wide system settings (DB-backed runtime tunables).
// Reads return raw model rows / arrays (no `gin.H{"data":...}`
// wrapping), matching the project's axios interceptor convention
// — see frontend/src/utils/request.ts:97.
g.apiKeyRoute(adminRoutes, http.MethodGet, "/settings",
apiKeyPlatform(types.APIKeyCapabilitySystemSettingsRead, types.APIKeyCapabilitySystemSettingsManage),
handler.ListSystemSettings)
g.apiKeyRoute(adminRoutes, http.MethodGet, "/settings/:key",
apiKeyPlatform(types.APIKeyCapabilitySystemSettingsRead, types.APIKeyCapabilitySystemSettingsManage),
handler.GetSystemSetting)
g.apiKeyRoute(adminRoutes, http.MethodPut, "/settings/:key",
apiKeyPlatform(types.APIKeyCapabilitySystemSettingsManage), handler.UpdateSystemSetting)
g.apiKeyRoute(adminRoutes, http.MethodDelete, "/settings/:key",
apiKeyPlatform(types.APIKeyCapabilitySystemSettingsManage), handler.ResetSystemSetting)
// Runtime operations: live asynq queue depths, safe task projections,
// and state-checked task actions for the SystemAdmin dashboard. Lite
// mode returns available=false.
g.apiKeyRoute(adminRoutes, http.MethodGet, "/runtime/queues",
apiKeyPlatform(types.APIKeyCapabilitySystemRuntimeRead, types.APIKeyCapabilitySystemRuntimeManage),
handler.GetRuntimeQueues)
g.apiKeyRoute(adminRoutes, http.MethodGet, "/runtime/queues/:queue/tasks",
apiKeyPlatform(types.APIKeyCapabilitySystemRuntimeRead, types.APIKeyCapabilitySystemRuntimeManage),
handler.ListRuntimeTasks)
g.apiKeyRoute(adminRoutes, http.MethodPost, "/runtime/queues/:queue/tasks/:task_id/actions/:action",
apiKeyPlatform(types.APIKeyCapabilitySystemRuntimeManage), handler.MutateRuntimeTask)
g.apiKeyRoute(adminRoutes, http.MethodDelete, "/runtime/queues/:queue/archived",
apiKeyPlatform(types.APIKeyCapabilitySystemRuntimeManage), handler.PurgeArchivedRuntimeTasks)
// Bulk action — write the current default-quota setting onto
// every existing tenant. Lives under /tenants instead of
// /settings because it changes tenants, not the setting row.
g.apiKeyRoute(adminRoutes, http.MethodPost, "/tenants/apply-default-storage-quota",
apiKeyPlatform(types.APIKeyCapabilitySystemTenantsManage),
handler.ApplyDefaultStorageQuotaToAllTenants)
// Platform-wide audit feed (tenant_id=0 rows). Covers
// system.setting_changed / system.admin_promoted /
// system.admin_revoked etc. — events written by the routes
// above. Without this endpoint those audit rows would have
// no UI surface (per-tenant ListTenantAuditLog filters them
// out by tenant_id). Optional: skip when audit deps are
// absent, matching RegisterTenantRoutes' /audit-log handling.
if auditLogHandler != nil {
g.apiKeyRoute(adminRoutes, http.MethodGet, "/audit-log",
apiKeyPlatform(types.APIKeyCapabilitySystemAuditRead), auditLogHandler.ListSystemAuditLog)
}
}
}
+122
View File
@@ -0,0 +1,122 @@
package router
import (
"github.com/gin-gonic/gin"
"github.com/Tencent/WeKnora/internal/handler"
"github.com/Tencent/WeKnora/internal/handler/session"
)
// RegisterMessageRoutes 注册消息相关的路由。
//
// Per-session ownership is already enforced inside each handler (the
// user must own the session). We add Viewer+ here so non-members
// (e.g. revoked accounts retained in the tenant for audit) cannot
// reach the endpoints at all once RBAC is on.
func RegisterMessageRoutes(r *gin.RouterGroup, handler *handler.MessageHandler, g *rbacGuards) {
// Message history is tenant-wide and not attributable to a KB, so it is
// a full-access surface for API keys by default. The narrow
// exceptions are explicit capabilities:
// - chat: load/delete messages inside the caller's own session, where
// ownership is enforced by the message service.
// - message_history: search/read tenant chat-history metadata without
// granting every other full-access API.
messages := g.apiKeyGroup(r.Group("/messages"), apiKeyFullAccess())
chatMessages := messages.With(apiKeyChat(apiKeyFullAccess()))
historyMessages := messages.With(apiKeyMessageHistory(apiKeyFullAccess()))
{
historyMessages.POST("/search", g.Viewer(), handler.SearchMessages)
historyMessages.GET("/chat-history-stats", g.Viewer(), handler.GetChatHistoryKBStats)
chatMessages.GET("/:session_id/load", g.Viewer(), handler.LoadMessages)
chatMessages.DELETE("/:session_id/:id", g.Viewer(), handler.DeleteMessage)
}
}
// RegisterSessionRoutes 注册路由。
//
// Sessions are per-user resources; the handler enforces user ownership.
// We gate at Viewer+ to keep non-members out once RBAC is on, matching
// the message routes above. A future refactor can introduce
// per-session ownership in the middleware layer the same way KB/agent
// routes do today.
func RegisterSessionRoutes(
r *gin.RouterGroup,
handler *session.Handler,
suggestionHandler *handler.MessageSuggestionHandler,
g *rbacGuards,
) {
// Sessions are per-user chat state, not knowledge-base content. The
// chat capability lets a scoped key run the full conversation flow
// (create/manage its own sessions) without full tenant access.
sessions := g.apiKeyGroup(r.Group("/sessions", g.Viewer()), apiKeyChat(apiKeyFullAccess()))
{
sessions.POST("", handler.CreateSession)
sessions.DELETE("/batch", handler.BatchDeleteSessions)
sessions.GET("/:id", handler.GetSession)
sessions.GET("", handler.GetSessionsByTenant)
sessions.PUT("/:id", handler.UpdateSession)
sessions.DELETE("/:id", handler.DeleteSession)
sessions.DELETE("/:id/messages", handler.ClearSessionMessages)
sessions.POST("/:session_id/generate_title", handler.GenerateTitle)
sessions.POST("/:session_id/attachments", handler.UploadTemporaryDocument)
sessions.GET("/:id/attachments", handler.ListTemporaryDocuments)
sessions.GET("/:id/attachments/:attachment_id", handler.GetTemporaryDocument)
sessions.GET("/:id/attachments/:attachment_id/preview", handler.PreviewTemporaryDocument)
sessions.DELETE("/:id/attachments/:attachment_id", handler.DeleteTemporaryDocument)
sessions.POST("/:session_id/stop", handler.StopSession)
// POST and DELETE share this path but gin maintains a separate radix tree
// per HTTP verb, and the existing trees use different wildcard names
// (POST uses :session_id, DELETE uses :id). Use whatever matches each
// tree to avoid "wildcard conflicts" panic at route registration.
sessions.POST("/:session_id/pin", handler.PinSession)
sessions.DELETE("/:id/pin", handler.UnpinSession)
// 继续接收活跃流
sessions.GET("/continue-stream/:session_id", handler.ContinueStream)
if suggestionHandler != nil {
// Gin requires wildcard names to be identical within the same HTTP-method
// radix tree. Existing GET session routes use :id, so keep that name here.
sessions.GET("/:id/messages/:message_id/suggestions", suggestionHandler.Get)
sessions.POST("/:session_id/messages/:message_id/suggestions", suggestionHandler.Ensure)
sessions.POST("/:session_id/suggestion-events", suggestionHandler.RecordEvent)
}
// Skill-generated file artifacts. The list endpoints only expose
// metadata; the actual bytes are streamed via /artifacts/:index/download
// so the storage URL never appears on the wire.
//
// NOTE: gin builds a separate radix tree per HTTP verb but every
// path in the same tree must share the same wildcard name. The GET
// tree already binds :id via /sessions/:id (GetSession); reusing
// :id here (instead of :session_id) avoids the
// "wildcard conflicts" panic at route registration. The handlers
// read the URL param via c.Param("session_id") with a fallback to
// c.Param("id") for exactly this reason.
sessions.GET("/:id/artifacts", handler.ListSessionArtifacts)
sessions.GET("/:id/messages/:message_id/artifacts", handler.ListMessageArtifacts)
sessions.GET("/:id/messages/:message_id/artifacts/:index/download", handler.DownloadMessageArtifact)
}
}
// RegisterChatRoutes 注册路由。Chat endpoints are tenant-member usage
// surfaces; Viewer+ is sufficient because per-session/per-agent
// authorisation is enforced inside the handlers.
func RegisterChatRoutes(r *gin.RouterGroup, handler *session.Handler, g *rbacGuards) {
// These POST routes append messages and run generation, so a scoped key
// needs the explicit chat capability unless it has full tenant access.
knowledgeChat := g.apiKeyGroup(r.Group("/knowledge-chat", g.Viewer()), apiKeyChat(apiKeyFullAccess()))
{
knowledgeChat.POST("/:session_id", handler.KnowledgeQA)
}
// Agent-based chat
agentChat := g.apiKeyGroup(r.Group("/agent-chat", g.Viewer()), apiKeyChat(apiKeyFullAccess()))
{
agentChat.POST("/:session_id", handler.AgentQA)
}
// 新增知识检索接口,不需要session_id
knowledgeSearch := g.apiKeyGroup(r.Group("/knowledge-search", g.Viewer()), apiKeyRetrieve(apiKeyFullAccess()))
{
knowledgeSearch.POST("", handler.SearchKnowledge)
}
}
+31
View File
@@ -0,0 +1,31 @@
package router
import (
"github.com/Tencent/WeKnora/internal/handler"
"github.com/gin-gonic/gin"
)
// RegisterDigitalHumanRoutes must be called on the authenticated /api/v1 group.
// Service/repository additionally enforce the real user's tenant and ownership.
func RegisterDigitalHumanRoutes(r *gin.RouterGroup, h *handler.DigitalHumanHandler, g *rbacGuards) {
if h == nil {
return
}
group := g.apiKeyGroup(r.Group("/digital-humans"), apiKeyFullAccess())
group.GET("", g.Viewer(), h.List)
group.POST("", g.Contributor(), h.Create)
group.GET("/:id", g.Viewer(), h.Get)
group.PUT("/:id", g.Contributor(), h.Update)
group.DELETE("/:id", g.Contributor(), h.Delete)
group.POST("/:id/review", g.Contributor(), h.Review)
group.POST("/:id/bindings", g.Contributor(), h.Bind)
group.DELETE("/:id/bindings/:agent_id", g.Contributor(), h.Unbind)
group.POST("/:id/distill", g.Contributor(), h.Distill)
group.POST("/:id/trial", g.Contributor(), h.Trial)
group.GET("/:id/jobs/:job_id", g.Viewer(), h.Job)
group.GET("/:id/wechat/channels", g.Contributor(), h.WeChatChannels)
group.POST("/:id/wechat/qrcode", g.Contributor(), h.WeChatQRCode)
group.POST("/:id/wechat/qrcode/status", g.Contributor(), h.WeChatPoll)
group.PUT("/:id/wechat/channels/:channel_id", g.Contributor(), h.SetWeChatChannelEnabled)
group.DELETE("/:id/wechat/channels/:channel_id", g.Contributor(), h.DeleteWeChatChannel)
}
+335
View File
@@ -0,0 +1,335 @@
package router
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/Tencent/WeKnora/internal/handler"
)
// Models are tenant-wide infrastructure (LLM credentials, embeddings,
// rerankers); Viewer+ for reads, Admin+ for any mutation. Credential
// subresource writes are also Admin+ since secrets are tenant-scoped.
func RegisterModelRoutes(
r *gin.RouterGroup,
handler *handler.ModelHandler,
credHandler *handler.ModelCredentialsHandler,
g *rbacGuards,
) {
// 模型路由组。空间级基础设施:仅完全访问(Owner)API key 可访问。
models := g.apiKeyGroup(r.Group("/models"), apiKeyManageModels(apiKeyFullAccess()))
{
// 获取模型厂商列表 — Viewer+
models.GET("/providers", g.Viewer(), handler.ListModelProviders)
// 创建模型 — Admin+
models.POST("", g.Admin(), handler.CreateModel)
// 获取模型列表 — Viewer+
models.GET("", g.Viewer(), handler.ListModels)
// 调试已保存模型会发起真实上游调用并产生费用 — Admin+
models.POST("/:id/debug", g.Admin(), handler.DebugModel)
// 获取单个模型 — Viewer+
models.GET("/:id", g.Viewer(), handler.GetModel)
// 更新模型 — Admin+;内置模型仍由服务层额外限定为 SystemAdmin。
models.PUT("/:id", g.AdminOrSystemAdmin(), handler.UpdateModel)
// 删除模型 — Admin+
models.DELETE("/:id", g.Admin(), handler.DeleteModel)
// Per-field credential subresource (see internal/handler/model_credentials.go) — Admin+
models.PUT("/:id/credentials", g.AdminOrSystemAdmin(), credHandler.Put)
models.DELETE("/:id/credentials/:field", g.AdminOrSystemAdmin(), credHandler.DeleteField)
}
}
// Sandbox configs are workspace infrastructure that hold provider credentials.
// Scoped API keys cannot safely receive partial authority over them yet because
// mutation can strand remote sandboxes.
func RegisterSandboxConfigRoutes(
r *gin.RouterGroup,
h *handler.SandboxConfigHandler,
skills *handler.SandboxSkillHandler,
g *rbacGuards,
) {
configs := g.apiKeyGroup(r.Group("/sandbox-configs"), apiKeyFullAccess())
{
configs.GET("", g.Viewer(), h.List)
configs.PUT("/workspace-policy", g.Admin(), h.SetWorkspacePolicy)
configs.POST("/templates/query", g.Admin(), h.QueryTemplates)
configs.POST("", g.Admin(), h.Create)
configs.GET("/:id", g.Viewer(), h.Get)
configs.PUT("/:id", g.Admin(), h.Update)
configs.DELETE("/:id", g.Admin(), h.Delete)
configs.GET("/:id/sandboxes", g.Admin(), h.Inventory)
// Skills are Admin+ throughout, reads included: an upload drives a
// root shell whose output is baked into the image every session of
// this config boots, and the listing names what that image carries.
configs.GET("/:id/skills", g.Admin(), skills.List)
configs.POST("/:id/skills", g.Admin(), skills.Upload)
configs.GET("/:id/skills/:skillId", g.Admin(), skills.Get)
configs.GET("/:id/skills/:skillId/files", g.Admin(), skills.ListFiles)
configs.GET("/:id/skills/:skillId/files/content", g.Admin(), skills.GetFile)
configs.POST("/:id/skills/:skillId/reinstall", g.Admin(), skills.Reinstall)
configs.POST("/:id/skills/:skillId/stop", g.Admin(), skills.Stop)
configs.PATCH("/:id/skills/:skillId", g.Admin(), skills.Patch)
configs.DELETE("/:id/skills/:skillId", g.Admin(), skills.Delete)
configs.GET("/:id/skills/:skillId/install-events", g.Admin(), skills.InstallEvents)
configs.GET("/:id/skills/:skillId/transcript", g.Admin(), skills.InstallTranscript)
}
}
// RegisterEvaluationRoutes registers evaluation endpoints. Running an
// evaluation drives LLM calls (cost) and reads from KBs across the
// tenant; gate to Admin+ until product asks for a finer-grained
// matrix.
func RegisterEvaluationRoutes(r *gin.RouterGroup, handler *handler.EvaluationHandler, g *rbacGuards) {
evaluationRoutes := g.apiKeyGroup(r.Group("/evaluation"), apiKeyRunEvaluations(apiKeyFullAccess()))
{
evaluationRoutes.POST("", g.Admin(), handler.Evaluation)
evaluationRoutes.GET("", g.Viewer(), handler.GetEvaluationResult)
}
}
func RegisterInitializationRoutes(r *gin.RouterGroup, handler *handler.InitializationHandler, g *rbacGuards) {
// 初始化接口
// GetCurrentConfigByKB 是只读,Viewer+ 即可(KB 受限 key 可读其范围内的 KB)。
g.apiKeyRoute(r, http.MethodGet, "/initialization/config/:kbId",
apiKeyRetrieve(apiKeyFullAccess()), g.Viewer(), g.KBAccessRead("kbId"), handler.GetCurrentConfigByKB)
// InitializeByKB / UpdateKBConfig 都是改 KB 的核心模型/storage 配置 —
// 跟 PUT /knowledge-bases/:id 同等敏感,挂同款 OwnedKB 矩阵 + KBAccessWrite
//(API-key 主体短路 Owned* 守卫,KB allow-list 只能靠 KBAccess 兜底)。
g.apiKeyRoute(r, http.MethodPost, "/initialization/initialize/:kbId",
apiKeyManageKnowledgeBases(apiKeyFullAccess()), g.OwnedKBOrAdminFromKbIDParam(), g.KBAccessWrite("kbId"), handler.InitializeByKB)
g.apiKeyRoute(r, http.MethodPut, "/initialization/config/:kbId",
apiKeyManageKnowledgeBases(apiKeyFullAccess()), g.OwnedKBOrAdminFromKbIDParam(), g.KBAccessWrite("kbId"), handler.UpdateKBConfig)
// Ollama / 远程 API / 抽取等系统级检测/下载操作。这些不绑某个 KB,
// 会改空间级模型配置或拉远端模型;JWT 侧只读探测 Viewer+、变更 Admin+。
// 对 API key 均为空间级:full-access key 可用,scoped key 需要 manage_models。
g.apiKeyRoute(r, http.MethodGet, "/initialization/ollama/status", apiKeyManageModels(apiKeyFullAccess()), g.Viewer(), handler.CheckOllamaStatus)
g.apiKeyRoute(r, http.MethodGet, "/initialization/ollama/models", apiKeyManageModels(apiKeyFullAccess()), g.Viewer(), handler.ListOllamaModels)
g.apiKeyRoute(r, http.MethodPost, "/initialization/ollama/models/check", apiKeyManageModels(apiKeyFullAccess()), g.Admin(), handler.CheckOllamaModels)
g.apiKeyRoute(r, http.MethodPost, "/initialization/ollama/models/download", apiKeyManageModels(apiKeyFullAccess()), g.Admin(), handler.DownloadOllamaModel)
g.apiKeyRoute(r, http.MethodGet, "/initialization/ollama/download/progress/:taskId", apiKeyManageModels(apiKeyFullAccess()), g.Viewer(), handler.GetDownloadProgress)
g.apiKeyRoute(r, http.MethodGet, "/initialization/ollama/download/tasks", apiKeyManageModels(apiKeyFullAccess()), g.Viewer(), handler.ListDownloadTasks)
// 远程API相关接口
g.apiKeyRoute(r, http.MethodPost, "/initialization/remote/check", apiKeyManageModels(apiKeyFullAccess()), g.Admin(), handler.CheckRemoteModel)
g.apiKeyRoute(r, http.MethodPost, "/initialization/embedding/test", apiKeyManageModels(apiKeyFullAccess()), g.Admin(), handler.TestEmbeddingModel)
g.apiKeyRoute(r, http.MethodPost, "/initialization/rerank/check", apiKeyManageModels(apiKeyFullAccess()), g.Admin(), handler.CheckRerankModel)
g.apiKeyRoute(r, http.MethodPost, "/initialization/asr/check", apiKeyManageModels(apiKeyFullAccess()), g.Admin(), handler.CheckASRModel)
g.apiKeyRoute(r, http.MethodPost, "/initialization/multimodal/test", apiKeyManageModels(apiKeyFullAccess()), g.Admin(), handler.TestMultimodalFunction)
g.apiKeyRoute(r, http.MethodPost, "/initialization/extract/text-relation", apiKeyManageModels(apiKeyFullAccess()), g.Admin(), handler.ExtractTextRelations)
g.apiKeyRoute(r, http.MethodPost, "/initialization/extract/fabri-tag", apiKeyManageModels(apiKeyFullAccess()), g.Admin(), handler.FabriTag)
g.apiKeyRoute(r, http.MethodPost, "/initialization/extract/fabri-text", apiKeyManageModels(apiKeyFullAccess()), g.Admin(), handler.FabriText)
}
// RegisterMCPServiceRoutes registers MCP service routes.
//
// MCP services are tenant-level integrations (external tool servers); we
// gate reads to Viewer+ and any mutation/test to Admin+. Tool-approval
// resolution is also Admin+ since approving a pending tool call grants
// the agent permission to execute side-effecting external commands.
// Credential subresource writes are Admin+ as well since secrets are
// tenant-scoped.
func RegisterMCPServiceRoutes(
r *gin.RouterGroup,
handler *handler.MCPServiceHandler,
credHandler *handler.MCPCredentialsHandler,
oauthHandler *handler.MCPOAuthHandler,
g *rbacGuards,
) {
// MCP OAuth provider redirect. Registered OUTSIDE the /mcp-services group
// to avoid a static-vs-":id" route conflict, and left unauthenticated
// (allow-listed in middleware/auth.go) because the third-party browser
// redirect carries no WeKnora bearer — the single-use state authenticates.
r.GET("/mcp-oauth/callback", oauthHandler.Callback)
mcpServices := g.apiKeyGroup(r.Group("/mcp-services"), apiKeyManageMCPServices(apiKeyFullAccess()))
{
// Create MCP service — Admin+
mcpServices.POST("", g.Admin(), handler.CreateMCPService)
// List MCP services — Viewer+
mcpServices.GET("", g.Viewer(), handler.ListMCPServices)
// Get MCP service by ID — Viewer+
mcpServices.GET("/:id", g.Viewer(), handler.GetMCPService)
// Update MCP service — Admin+
mcpServices.PUT("/:id", g.Admin(), handler.UpdateMCPService)
// Delete MCP service — Admin+
mcpServices.DELETE("/:id", g.Admin(), handler.DeleteMCPService)
// Test MCP service connection — Admin+ (probes external infra)
mcpServices.POST("/:id/test", g.Admin(), handler.TestMCPService)
// Get MCP service tools — Viewer+
mcpServices.GET("/:id/tools", g.Viewer(), handler.GetMCPServiceTools)
// Get MCP service resources — Viewer+
mcpServices.GET("/:id/resources", g.Viewer(), handler.GetMCPServiceResources)
// Per-field credential subresource: secrets never travel via the main
// PUT body. See internal/handler/mcp_credentials.go for the contract. — Admin+
mcpServices.PUT("/:id/credentials", g.Admin(), credHandler.Put)
mcpServices.DELETE("/:id/credentials/:field", g.Admin(), credHandler.DeleteField)
// MCP tool human approval (issue #1173) — Viewer+ to read, Admin+ to set policy
mcpServices.GET("/:id/tool-approvals", g.Viewer(), handler.ListMCPToolApprovals)
mcpServices.PUT("/:id/tool-approvals/:tool_name", g.Admin(), handler.SetMCPToolApproval)
// Per-user OAuth authorization flow. Viewer+ may authorize/inspect/
// revoke their own token; the callback is the separate public route
// registered above.
mcpServices.POST("/:id/oauth/authorize-url", g.Viewer(), oauthHandler.AuthorizeURL)
mcpServices.GET("/:id/oauth/status", g.Viewer(), oauthHandler.Status)
mcpServices.DELETE("/:id/oauth/token", g.Viewer(), oauthHandler.Revoke)
}
// /agent tool-approval + OAuth resolution are interactive human flows;
// not declared for API keys (default-deny).
agentTool := r.Group("/agent")
{
// Resolving a pending tool-approval is gated to tenant members
// (Viewer+). The approval card surfaces inside an agent chat the
// caller initiated — restricting it to Admin+ blocks the only
// people who actually have context to approve, so the gate is
// kept at "anyone in the tenant" instead.
agentTool.POST("/tool-approvals/:pending_id", g.Viewer(), handler.ResolveToolApproval)
// Resume an agent run paused on an in-conversation MCP OAuth prompt.
// Same tenant-member (Viewer+) gating rationale as tool-approvals.
agentTool.POST("/mcp-oauth-resolutions/:pending_id", g.Viewer(), oauthHandler.ResolveMCPOAuth)
agentTool.POST("/mcp-oauth-resolutions/:pending_id/cancel", g.Viewer(), oauthHandler.CancelMCPOAuth)
}
}
// RegisterWebSearchRoutes registers web search routes
func RegisterWebSearchRoutes(r *gin.RouterGroup, webSearchHandler *handler.WebSearchHandler, g *rbacGuards) {
// Web search providers — Viewer+ (read-only listing of provider catalog).
webSearch := r.Group("/web-search")
{
webSearch.GET("/providers", g.Viewer(), webSearchHandler.GetProviders)
}
}
// RegisterWebSearchProviderRoutes registers CRUD routes for web search
// provider configurations.
//
// Provider rows hold external service credentials (Bing, Tavily, Google,
// etc.); reads are Viewer+, all mutations / connection tests (which
// probe external systems with stored credentials) and the per-field
// credential subresource are Admin+.
func RegisterWebSearchProviderRoutes(
r *gin.RouterGroup,
h *handler.WebSearchProviderHandler,
credHandler *handler.WebSearchProviderCredentialsHandler,
g *rbacGuards,
) {
providers := g.apiKeyGroup(r.Group("/web-search-providers"), apiKeyManageWebSearch(apiKeyFullAccess()))
{
// List available provider types (metadata for UI forms) — Viewer+
providers.GET("/types", g.Viewer(), h.ListProviderTypes)
// Test with raw credentials (no persistence) — Admin+
providers.POST("/test", g.Admin(), h.TestProviderRaw)
// CRUD
providers.POST("", g.Admin(), h.CreateProvider)
providers.GET("", g.Viewer(), h.ListProviders)
providers.GET("/:id", g.Viewer(), h.GetProvider)
providers.PUT("/:id", g.Admin(), h.UpdateProvider)
providers.DELETE("/:id", g.Admin(), h.DeleteProvider)
// Per-field credential subresource — Admin+
providers.PUT("/:id/credentials", g.Admin(), credHandler.Put)
providers.DELETE("/:id/credentials/:field", g.Admin(), credHandler.DeleteField)
// Test existing saved provider — Admin+
providers.POST("/:id/test", g.Admin(), h.TestProviderByID)
}
}
// RegisterVectorStoreRoutes registers CRUD routes for vector store configurations.
//
// Vector stores are tenant-level infrastructure; reads are Viewer+, all
// writes (and connection tests, which probe external systems with stored
// credentials) are Admin+.
func RegisterVectorStoreRoutes(r *gin.RouterGroup, h *handler.VectorStoreHandler, g *rbacGuards) {
stores := g.apiKeyGroup(r.Group("/vector-stores"), apiKeyManageVectorStores(apiKeyFullAccess()))
{
// List available engine types (metadata for UI forms) — Viewer+
stores.GET("/types", g.Viewer(), h.ListStoreTypes)
// Test with raw credentials (no persistence) — Admin+
stores.POST("/test", g.Admin(), h.TestStoreRaw)
// CRUD
stores.POST("", g.Admin(), h.CreateStore)
stores.GET("", g.Viewer(), h.ListStores)
stores.GET("/:id", g.Viewer(), h.GetStore)
stores.PUT("/:id", g.Admin(), h.UpdateStore)
stores.DELETE("/:id", g.Admin(), h.DeleteStore)
// Test existing saved or env store — Admin+
stores.POST("/:id/test", g.Admin(), h.TestStoreByID)
}
}
// RegisterStorageBackendRoutes manages concrete object/file storage instances.
func RegisterStorageBackendRoutes(r *gin.RouterGroup, h *handler.StorageBackendHandler, g *rbacGuards) {
backends := g.apiKeyGroup(r.Group("/storage-backends"), apiKeyManageStorageBackends(apiKeyFullAccess()))
{
backends.GET("/types", g.Viewer(), h.Types)
backends.POST("/test", g.Admin(), h.TestRaw)
backends.POST("", g.Admin(), h.Create)
backends.GET("", g.Viewer(), h.List)
backends.GET("/:id", g.Viewer(), h.Get)
backends.PUT("/:id", g.Admin(), h.Update)
backends.DELETE("/:id", g.Admin(), h.Delete)
backends.POST("/:id/test", g.Admin(), h.TestByID)
backends.PUT("/:id/default", g.Admin(), h.SetDefault)
}
}
// RegisterDataSourceRoutes 注册数据源相关的路由
//
// Data sources hold external service credentials (Feishu/Notion/Yuque)
// and trigger sync jobs that mutate KB content tenant-wide. Reads are
// Viewer+; everything else (CRUD, validation, sync control, credential
// subresource) is Admin+.
func RegisterDataSourceRoutes(
r *gin.RouterGroup,
handler *handler.DataSourceHandler,
credHandler *handler.DataSourceCredentialsHandler,
g *rbacGuards,
) {
// Data source routes
ds := g.apiKeyGroup(r.Group("/datasource"), apiKeyManageDataSources(apiKeyFullAccess()))
{
// Get available connector types — Viewer+
ds.GET("/types", g.Viewer(), handler.GetAvailableConnectors)
// Validate credentials without persistence (for "Test Connection" button) — Admin+
ds.POST("/validate-credentials", g.Admin(), handler.ValidateCredentials)
// CRUD operations
ds.POST("", g.Admin(), handler.CreateDataSource)
ds.GET("", g.Viewer(), handler.ListDataSources)
ds.GET("/:id", g.Viewer(), handler.GetDataSource)
ds.PUT("/:id", g.Admin(), handler.UpdateDataSource)
ds.DELETE("/:id", g.Admin(), handler.DeleteDataSource)
// Credential subresource. Single logical field "credentials" because
// connector credentials are a per-connector atomic map (see
// internal/handler/datasource_credentials.go). — Admin+
ds.PUT("/:id/credentials", g.Admin(), credHandler.Put)
ds.DELETE("/:id/credentials/:field", g.Admin(), credHandler.DeleteField)
// Connection and resource management — Admin+
ds.POST("/:id/validate", g.Admin(), handler.ValidateConnection)
ds.GET("/:id/resources", g.Admin(), handler.ListAvailableResources)
ds.POST("/:id/resource-ancestors", g.Admin(), handler.ResolveResourceAncestors)
// Sync management — Admin+
ds.POST("/:id/sync", g.Admin(), handler.ManualSync)
ds.POST("/:id/pause", g.Admin(), handler.PauseDataSource)
ds.POST("/:id/resume", g.Admin(), handler.ResumeDataSource)
// Sync logs — Viewer+ (read-only audit trail)
ds.GET("/:id/logs", g.Viewer(), handler.GetSyncLogs)
ds.GET("/logs/:log_id", g.Viewer(), handler.GetSyncLog)
}
}
// RegisterWeKnoraCloudRoutes 注册 WeKnoraCloud 初始化路由
// RegisterWeKnoraCloudRoutes registers the WeKnoraCloud credential
// management endpoints. SaveCredentials persists external SaaS keys
// for the tenant (Admin+), Status is a low-risk readiness probe (Viewer+).
func RegisterWeKnoraCloudRoutes(r *gin.RouterGroup, handler *handler.WeKnoraCloudHandler, g *rbacGuards) {
g.apiKeyRoute(r, http.MethodPost, "/weknoracloud/credentials", apiKeyManageModels(apiKeyFullAccess()), g.Admin(), handler.SaveCredentials)
g.apiKeyRoute(r, http.MethodGet, "/models/weknoracloud/status", apiKeyManageModels(apiKeyFullAccess()), g.Viewer(), handler.Status)
}
+335
View File
@@ -0,0 +1,335 @@
package router
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/Tencent/WeKnora/internal/handler"
"github.com/Tencent/WeKnora/internal/middleware"
)
// RegisterChunkerDebugRoutes wires the read-only chunker preview endpoint
// used by the KB editor's debug panel. Stateless — uses no service deps.
//
// Viewer+ floor: the endpoint surfaces inside the tenant UI, so any
// authenticated tenant member can call it; revoked accounts whose JWT
// has not yet expired are kept out by the role check, matching the
// rest of the RBAC matrix in this file.
func RegisterChunkerDebugRoutes(r *gin.RouterGroup, g *rbacGuards) {
g.apiKeyRoute(r, http.MethodPost, "/chunker/preview", apiKeyRetrieve(apiKeyIngest(apiKeyFullAccess())), g.Viewer(), handler.PreviewChunking)
}
// RegisterChunkRoutes 注册分块相关的路由
//
// Mutating routes addressed via :knowledge_id inherit per-KB ownership
// from the owning knowledge entry's KB (PR 5, #1303); the chain hop is
// shared with RegisterKnowledgeRoutes via OwnedChunkKBOrAdmin so the
// same "creator-of-the-KB OR Admin+" rule applies to chunk edits.
func RegisterChunkRoutes(r *gin.RouterGroup, handler *handler.ChunkHandler, g *rbacGuards) {
// 分块路由组。Scoped API key 需要 ingest 能力写内容,retrieve 能力读内容;
// 两者仍受 KB 白名单约束。
chunks := g.apiKeyGroup(r.Group("/chunks"), apiKeyIngest(apiKeyFullAccess()))
chunkRead := chunks.With(apiKeyRetrieve(apiKeyFullAccess()))
{
// 获取分块列表 — Viewer+ 且对父 KB 有 read 权限(own / shared / via shared agent)
chunkRead.GET("/:knowledge_id", g.Viewer(), g.KBAccessReadFromKnowledgeIDParam("knowledge_id"), handler.ListKnowledgeChunks)
// 通过chunk_id获取单个chunk(不需要knowledge_id) — Viewer+ 且对父 KB 有 read 权限
chunkRead.GET("/by-id/:id", g.Viewer(), g.KBAccessReadFromChunkIDParam("id"), handler.GetChunkByIDOnly)
chunkRead.GET("/:knowledge_id/:id/revisions", g.Viewer(), g.KBAccessReadFromKnowledgeIDParam("knowledge_id"), handler.ListChunkRevisions)
// 删除分块 — KB owner OR Admin+,且对父 KB 有 write 权限
chunks.DELETE("/:knowledge_id/:id", g.OwnedChunkKBOrAdmin(), g.KBAccessWriteFromKnowledgeIDParam("knowledge_id"), handler.DeleteChunk)
// 删除知识下的所有分块 — KB owner OR Admin+,且对父 KB 有 write 权限
chunks.DELETE("/:knowledge_id", g.OwnedChunkKBOrAdmin(), g.KBAccessWriteFromKnowledgeIDParam("knowledge_id"), handler.DeleteChunksByKnowledgeID)
// 更新分块信息 — KB owner OR Admin+,且对父 KB 有 write 权限
chunks.PUT("/:knowledge_id/:id", g.OwnedChunkKBOrAdmin(), g.KBAccessWriteFromKnowledgeIDParam("knowledge_id"), handler.UpdateChunk)
chunks.POST("/:knowledge_id/:id/revert", g.OwnedChunkKBOrAdmin(), g.KBAccessWriteFromKnowledgeIDParam("knowledge_id"), handler.RevertChunk)
// 删除单个生成的问题(通过分块 id) — 与其它 chunk mutation 一致:
// KB owner OR Admin+。早期这里因为链路 (chunk_id -> knowledge_id ->
// kb -> creator_id) 还没接通,被临时降级成 Contributor,导致一个
// 「能编辑所有 chunk 的同样规则在这条路由上反而更宽松」的不一致。
// 现在通过 KBCreatorLookupFromChunkIDParam 把那一跳补上,统一矩阵。
chunks.DELETE("/by-id/:id/questions", g.OwnedChunkKBOrAdminFromChunkID(), g.KBAccessWriteFromChunkIDParam("id"), handler.DeleteGeneratedQuestion)
chunks.PUT("/by-id/:id/questions", g.OwnedChunkKBOrAdminFromChunkID(), g.KBAccessWriteFromChunkIDParam("id"), handler.UpsertGeneratedQuestion)
chunks.POST("/by-id/:id/questions/regenerate", g.OwnedChunkKBOrAdminFromChunkID(), g.KBAccessWriteFromChunkIDParam("id"), handler.RegenerateGeneratedQuestions)
}
}
// RegisterKnowledgeRoutes 注册知识相关的路由
//
// Per-KB ownership applies on the per-:id mutating routes (PR 5,
// #1303): the URL :id is a knowledge id, OwnedKnowledgeKBOrAdmin
// walks it back to KB.CreatorID so a Contributor who owns the KB can
// edit/delete any of its documents while a non-owner Contributor gets
// 403. KB-scoped upload routes (`/knowledge-bases/:id/knowledge/...`)
// reuse OwnedKBOrAdmin because the URL :id is the KB id directly.
// Cross-:id batch operations stay Contributor-gated — they don't have
// a single owning KB to check against.
func RegisterKnowledgeRoutes(r *gin.RouterGroup, handler *handler.KnowledgeHandler, g *rbacGuards) {
// 知识库下的知识路由组(URL :id is the KB id)。Scoped API key 需要
// ingest 能力才能写内容,且仍受 KB 范围限制;清空 KB 只允许 full-access key。
kb := g.apiKeyGroup(r.Group("/knowledge-bases/:id/knowledge"), apiKeyIngest(apiKeyFullAccess()))
kbRead := kb.With(apiKeyRetrieve(apiKeyFullAccess()))
{
kb.POST("/file", g.OwnedKBOrAdmin(), g.KBAccessWrite("id"), handler.CreateKnowledgeFromFile)
kb.POST("/url", g.OwnedKBOrAdmin(), g.KBAccessWrite("id"), handler.CreateKnowledgeFromURL)
kb.POST("/manual", g.OwnedKBOrAdmin(), g.KBAccessWrite("id"), handler.CreateManualKnowledge)
kbRead.GET("", g.Viewer(), g.KBAccessRead("id"), handler.ListKnowledge)
kbRead.GET("/folders", g.Viewer(), g.KBAccessRead("id"), handler.ListKnowledgeFolders)
kb.PUT("/folders", g.OwnedKBOrAdmin(), g.KBAccessWrite("id"), handler.RenameKnowledgeFolder)
// Clearing all contents under a KB is a destructive op; gate
// behind Admin instead of Contributor.
kb.With(apiKeyFullAccess()).DELETE("", g.Admin(), g.KBAccessWrite("id"), handler.ClearKnowledgeBaseContents)
}
// 知识路由组(URL :id is a knowledge id; the guard walks it to the parent KB)
kgrp := r.Group("/knowledge")
k := g.apiKeyGroup(kgrp, apiKeyIngest(apiKeyFullAccess()))
kRead := k.With(apiKeyRetrieve(apiKeyFullAccess()))
{
// Cross-knowledge endpoints (no :id) can't be gated on a single
// KB via the URL — they accept a kb_id (or source/target KB) in the
// body and the handler fans out the access check itself. /batch and
// /search are read routes (retrieve). /move, /batch-delete,
// /batch-reparse and /tags are content writes that each bound
// themselves to a single (or source+target) KB and enforce the API
// key's KB allow-list downstream — MoveKnowledge via
// requireTenantAPIKeyKnowledgeBases(source,target); the batch ops via
// validateKnowledgeBaseAccessWithKBID (requireTenantAPIKeyKnowledgeBase)
// plus a per-item "belongs to the authorized KB" check in the handler
// and service. They are therefore declared for API keys under the
// ingest capability, matching their single-document siblings
// (k.DELETE("/:id"), k.POST("/:id/reparse"), k.PUT("/:id")).
kRead.GET("/batch", g.Viewer(), handler.GetKnowledgeBatch)
kRead.GET("/:id", g.Viewer(), g.KBAccessReadFromKnowledgeIDParam("id"), handler.GetKnowledge)
kRead.GET("/:id/stages", g.Viewer(), g.KBAccessReadFromKnowledgeIDParam("id"), handler.GetKnowledgeSpans)
kRead.GET("/:id/spans", g.Viewer(), g.KBAccessReadFromKnowledgeIDParam("id"), handler.GetKnowledgeSpans)
k.DELETE("/:id", g.OwnedKnowledgeKBOrAdmin(), g.KBAccessWriteFromKnowledgeIDParam("id"), handler.DeleteKnowledge)
k.PUT("/:id", g.OwnedKnowledgeKBOrAdmin(), g.KBAccessWriteFromKnowledgeIDParam("id"), handler.UpdateKnowledge)
k.POST("/:id/regenerate-summary", g.OwnedKnowledgeKBOrAdmin(), g.KBAccessWriteFromKnowledgeIDParam("id"), handler.RegenerateKnowledgeSummary)
k.PUT("/manual/:id", g.OwnedKnowledgeKBOrAdmin(), g.KBAccessWriteFromKnowledgeIDParam("id"), handler.UpdateManualKnowledge)
k.POST("/:id/reparse", g.OwnedKnowledgeKBOrAdmin(), g.KBAccessWriteFromKnowledgeIDParam("id"), handler.ReparseKnowledge)
k.POST("/:id/cancel-parse", g.OwnedKnowledgeKBOrAdmin(), g.KBAccessWriteFromKnowledgeIDParam("id"), handler.CancelKnowledgeParse)
// Downloading exposes the original source file, so it has a stricter
// boundary than viewing parsed content or previewing it: tenant Viewers
// cannot download from their own workspace, and org-shared Viewer access
// cannot download from the source workspace. API keys still follow the
// retrieve capability declared by kRead; role guards intentionally defer
// machine-principal authorization to the API-key gate.
kRead.GET("/:id/download", g.Contributor(), g.KBAccessWriteFromKnowledgeIDParam("id"), middleware.RequireKnowledgeExport(), handler.DownloadKnowledgeFile)
kRead.GET("/:id/preview", g.Viewer(), g.KBAccessReadFromKnowledgeIDParam("id"), handler.PreviewKnowledgeFile)
k.PUT("/image/:id/:chunk_id", g.OwnedKnowledgeKBOrAdmin(), g.KBAccessWriteFromKnowledgeIDParam("id"), handler.UpdateImageInfo)
kRead.GET("/search", g.Viewer(), handler.SearchKnowledge)
kRead.GET("/move/progress/:task_id", g.Viewer(), handler.GetKnowledgeMoveProgress)
// Batch / cross-KB content writes: JWT Contributor+, or an API key
// with the ingest capability (or full access). Each handler binds the
// operation to a single (move: source+target) KB and rejects any KB
// or knowledge id outside the key's allow-list, so a scoped ingest key
// can only touch KBs it is already permitted to write.
k.PUT("/tags", g.Contributor(), handler.UpdateKnowledgeTagBatch)
k.POST("/batch-reparse", g.Contributor(), handler.BatchReparseKnowledge)
k.POST("/batch-delete", g.Contributor(), handler.BatchDeleteKnowledge)
k.POST("/folder", g.Contributor(), handler.MoveKnowledgeToFolder)
k.POST("/move", g.Contributor(), middleware.RequireKnowledgeVersionTransfer(), handler.MoveKnowledge)
}
}
// RegisterFAQRoutes 注册 FAQ 相关路由
//
// FAQ entries are KB content: reads are Viewer+, all mutations
// (create / update / upsert / delete / batch field+tag updates,
// import display flag) are Contributor+. Search is read-only.
func RegisterFAQRoutes(r *gin.RouterGroup, handler *handler.FAQHandler, g *rbacGuards) {
if handler == nil {
return
}
// FAQ entries 是 KB 的子资源(FAQ-type KB 的内容主体)。修改 FAQ
// 等价于修改 KB 内容,必须遵循 KB 的"creator OR Admin+"矩阵 ——
// 跟 chunks / wiki pages 保持一致。Viewer+ 可以读,Contributor 不能
// 改不属于自己的 KB 的 FAQ。
faq := g.apiKeyGroup(r.Group("/knowledge-bases/:id/faq"), apiKeyIngest(apiKeyFullAccess()))
faqRead := faq.With(apiKeyRetrieve(apiKeyFullAccess()))
{
// KBAccessRead/Write resolve own/shared/agent-visible access and
// rewrite the request's tenant context — handler no longer
// carries an effectiveCtxForKB helper.
faqRead.GET("/entries", g.Viewer(), g.KBAccessRead("id"), handler.ListEntries)
faqRead.GET("/entries/export", g.Viewer(), g.KBAccessRead("id"), middleware.RequireKnowledgeExport(), handler.ExportEntries)
faqRead.GET("/entries/:entry_id", g.Viewer(), g.KBAccessRead("id"), handler.GetEntry)
faq.POST("/entries", g.OwnedKBOrAdmin(), g.KBAccessWrite("id"), handler.UpsertEntries)
faq.POST("/entry", g.OwnedKBOrAdmin(), g.KBAccessWrite("id"), handler.CreateEntry)
faq.PUT("/entries/:entry_id", g.OwnedKBOrAdmin(), g.KBAccessWrite("id"), handler.UpdateEntry)
faq.POST("/entries/:entry_id/similar-questions", g.OwnedKBOrAdmin(), g.KBAccessWrite("id"), handler.AddSimilarQuestions)
// Unified batch update API - supports is_enabled, is_recommended, tag_id
faq.PUT("/entries/fields", g.OwnedKBOrAdmin(), g.KBAccessWrite("id"), handler.UpdateEntryFieldsBatch)
faq.PUT("/entries/tags", g.OwnedKBOrAdmin(), g.KBAccessWrite("id"), handler.UpdateEntryTagBatch)
faq.DELETE("/entries", g.OwnedKBOrAdmin(), g.KBAccessWrite("id"), handler.DeleteEntries)
// Search is a read route: scoped API keys may call it with retrieve
// even though POST is otherwise an unsafe method.
faqRead.POST("/search", g.Viewer(), g.KBAccessRead("id"), handler.SearchFAQ)
// FAQ import result display status
faq.PUT("/import/last-result/display", g.OwnedKBOrAdmin(), g.KBAccessWrite("id"), handler.UpdateLastImportResultDisplayStatus)
}
// FAQ import progress route (outside of knowledge-base scope) — Viewer+.
// Scoped API keys that can ingest (they start the import) or retrieve may
// poll their own import/dry-run progress. The task is tenant-scoped by
// requireTaskProgressTenant, so a key only ever sees its own tenant's
// tasks. Declared through apiKeyRoute so the APIKeyGate doesn't fail-closed
// and 403 the poller with "scope does not allow this operation".
g.apiKeyRoute(r, http.MethodGet, "/faq/import/progress/:task_id",
apiKeyRetrieve(apiKeyIngest(apiKeyFullAccess())), g.Viewer(), handler.GetImportProgress)
}
// RegisterKnowledgeBaseRoutes 注册知识库相关的路由
func RegisterKnowledgeBaseRoutes(r *gin.RouterGroup, handler *handler.KnowledgeBaseHandler, g *rbacGuards) {
// 知识库路由组。API-key 可达性按能力分两档,全部通过 apiKeyGroup 声明,
// 不要再用裸 kbgrp.Handle 注册(那会绕过网关、对所有 key 静默默认拒绝):
//
// 1. 读取(list/detail/search/progress/move-targets)—— retrieve OR full-access(kb)
// 2. KB 生命周期管理(create/copy/duplicate/update/delete)
// —— manage_kbs OR full-access(kbManagement)
//
// 第 2 档整条 KB 生命周期共用同一策略:manage_kbs 是「管理知识库」capability,
// 建/拷/改/删都是它的分内事。KB 的 allow-list 仍在下游生效——copy/duplicate/
// update/delete 的目标 KB 会被 allow-list 兜住;create 无源可约束,限定 allow-list
// 的 key 建出的新 KB 落在其 allow-list 之外(同租户、无越权,只是建完自己管不到),
// 空 allow-list 的 key 则是全租户 KB 管理、新建天然在范围内。KB 内容写入(文档/
// 分块/FAQ/Tag/Wiki)由对应子路由的 ingest 能力控制,不在本组。
kbgrp := r.Group("/knowledge-bases")
kb := g.apiKeyGroup(kbgrp, apiKeyRetrieve(apiKeyFullAccess()))
kbManagement := kb.With(apiKeyManageKnowledgeBases(apiKeyFullAccess()))
{
// 创建知识库 — JWT Contributor+;API key 需 manage_kbs 或 full-access。
kbManagement.POST("", g.Contributor(), handler.CreateKnowledgeBase)
// 获取知识库列表 — Viewer+ for JWT callers; retrieve-capable API keys pass via the gate.
kb.GET("", g.Viewer(), handler.ListKnowledgeBases)
// 获取知识库详情 — Viewer+ 且对 KB 有 read 权限
kb.GET("/:id", g.Viewer(), g.KBAccessRead("id"), handler.GetKnowledgeBase)
// 更新/删除知识库 — 两层正交鉴权,缺一不可:
// OwnedKBOrAdmin 管「租户内」归属:非创建者的 Contributor 改不了
// 同事的 KB(跨租户 KB 在此走 lookup=NotFound → 交给
// 下游处理,不在此拦)。
// KBAccessWrite 管「跨租户」访问级:自有 KB 或被组织共享(editor)。
// handler 内再按 permission/所有者租户做最终判定 —— 尤其 DeleteKnowledgeBase
// 以调用者「自身」租户(c.Keys,未被 KBAccess 改写)校验 kb.TenantID,
// 把删除锁死为「所有者租户 + Admin」,共享 editor 无法删除源 KB。
kbManagement.PUT("/:id", g.OwnedKBOrAdmin(), g.KBAccessWrite("id"), handler.UpdateKnowledgeBase)
kbManagement.DELETE("/:id", g.OwnedKBOrAdmin(), g.KBAccessWrite("id"), handler.DeleteKnowledgeBase)
// 置顶/取消置顶知识库 — 创建者本人 OR Admin+ 且对 KB 有 write 权限
// Pin state is now per-(user, kb) (migration 000050). Anyone with
// at least Viewer-level read access to the KB — including users
// who reached it via a shared agent — may pin it for themselves;
// no edit permission is required. The OwnedKBOrAdmin guard was
// removed accordingly. The route still requires KB read access
// so callers can't poke at KBs they can't see.
kb.PUT("/:id/pin", g.Viewer(), g.KBAccessRead("id"), handler.TogglePinKnowledgeBase)
// 混合搜索 — Viewer+ 且对 KB 有 read 权限 (read-only)
// POST is preferred; GET with JSON body is kept for backward compatibility (#1727).
kb.POST("/:id/hybrid-search", g.Viewer(), g.KBAccessRead("id"), handler.HybridSearch)
kb.GET("/:id/hybrid-search", g.Viewer(), g.KBAccessRead("id"), handler.HybridSearch)
// 拷贝知识库 — 产出新 KB,与 create 同档:JWT Contributor+,API key 需 manage_kbs 或 full-access。
// 源 KB 通过 body 里的 source_id 传入(非 :id 路径参数),无法套用基于路径参数
// 的 KBAccessRead,故源/目标 KB 的租户归属与 allow-list 校验在 handler 内完成
// (requireTenantAPIKeyKnowledgeBases 会把 source_id/target_id 兜进 allow-list)。
// 副本归调用者所有,不需要原 KB 的所有权。
kbManagement.POST("/copy", g.Contributor(), middleware.RequireKnowledgeVersionTransfer(), handler.CopyKnowledgeBase)
// 创建知识库副本 — 产出新 KB,与 create 同档:JWT Contributor+,API key 需 manage_kbs 或 full-access;
// 且对源 KB 有 read 权限(KBAccessRead 会对限定 key 兜住源 KB)。只创建新的 KB 设置记录,不复制内容/索引/分享。
kbManagement.POST("/:id/duplicate", g.Contributor(), g.KBAccessRead("id"), handler.DuplicateKnowledgeBase)
// 获取知识库复制进度 — Viewer+;只读。manage_kbs(发起 copy 的 key)或
// retrieve 均可轮询;任务按租户隔离(requireTaskProgressTenant),key 只能
// 查本租户任务。
kb.With(apiKeyRetrieve(apiKeyManageKnowledgeBases(apiKeyFullAccess()))).
GET("/copy/progress/:task_id", g.Viewer(), handler.GetKBCloneProgress)
// 获取可移动目标知识库列表 — Viewer+ 且对 KB 有 read 权限
kb.GET("/:id/move-targets", g.Viewer(), g.KBAccessRead("id"), handler.ListMoveTargets)
}
}
// RegisterKnowledgeBaseActivityRoutes exposes the read-only per-KB activity
// feed. It intentionally stays JWT-only: audit history is a sensitive owner
// surface and no existing workspace API-key capability grants audit access.
func RegisterKnowledgeBaseActivityRoutes(r *gin.RouterGroup, auditHandler *handler.AuditLogHandler, g *rbacGuards) {
if auditHandler == nil {
return
}
r.GET("/knowledge-bases/:id/activity",
g.OwnedKBOrAdmin(), g.KBAccessRead("id"), auditHandler.ListKnowledgeBaseActivity)
}
// RegisterKnowledgeTagRoutes 注册知识库标签相关路由。
//
// Tags are KB metadata: Viewer reads, Contributor writes. Per-KB
// ownership granularity for tags is out of scope for PR 2; this is
// purely role-based.
func RegisterKnowledgeTagRoutes(r *gin.RouterGroup, tagHandler *handler.TagHandler, g *rbacGuards) {
if tagHandler == nil {
return
}
// Tags 是 KB 的子资源 — 创建/编辑/删除标签会改变 KB 内容的检索分类
// 行为,应该与 KB 主体的"creator OR Admin+"矩阵一致,避免一个无
// 关 Contributor 在他人 KB 里乱建/删标签影响 KB owner 的内容组织。
kbTags := g.apiKeyGroup(r.Group("/knowledge-bases/:id/tags"), apiKeyIngest(apiKeyFullAccess()))
kbTagsRead := kbTags.With(apiKeyRetrieve(apiKeyFullAccess()))
{
// KBAccessRead/Write resolve own/shared/agent-visible access and
// rewrite the request's tenant context to the effective tenant
// for the duration of the handler — so the handler no longer
// needs its own effectiveCtxForKB helper.
kbTagsRead.GET("", g.Viewer(), g.KBAccessRead("id"), tagHandler.ListTags)
kbTags.POST("", g.OwnedKBOrAdmin(), g.KBAccessWrite("id"), tagHandler.CreateTag)
kbTags.PUT("/:tag_id", g.OwnedKBOrAdmin(), g.KBAccessWrite("id"), tagHandler.UpdateTag)
kbTags.DELETE("/:tag_id", g.OwnedKBOrAdmin(), g.KBAccessWrite("id"), tagHandler.DeleteTag)
}
}
// RegisterWikiPageRoutes registers wiki page related routes.
//
// Wiki pages are KB content (wiki mode): reads are Viewer+ and gated by
// KBAccessRead (own / org-shared / via shared agent), matching FAQ /
// chunk / tag read routes. Content mutations (create/update/delete) and
// maintenance actions (rebuild-links, auto-fix, change issue status)
// honour per-KB ownership via OwnedWikiKBOrAdmin (PR 5, #1303): the URL
// :kb_id resolves directly to the owning KB so a Contributor who owns
// the KB can manage its wiki, while a non-owner Contributor gets 403.
func RegisterWikiPageRoutes(r *gin.RouterGroup, wikiHandler *handler.WikiPageHandler, g *rbacGuards) {
wiki := g.apiKeyGroup(r.Group("/knowledgebase/:kb_id/wiki"), apiKeyIngest(apiKeyFullAccess()))
wikiRead := wiki.With(apiKeyRetrieve(apiKeyFullAccess()))
{
// Page CRUD
wikiRead.GET("/pages", g.Viewer(), g.KBAccessRead("kb_id"), wikiHandler.ListPages)
wiki.POST("/pages", g.OwnedWikiKBOrAdmin(), g.KBAccessWrite("kb_id"), wikiHandler.CreatePage)
wiki.PUT("/move-page", g.OwnedWikiKBOrAdmin(), g.KBAccessWrite("kb_id"), wikiHandler.MovePage)
wikiRead.GET("/pages/*slug", g.Viewer(), g.KBAccessRead("kb_id"), wikiHandler.GetPage)
wiki.PUT("/pages/*slug", g.OwnedWikiKBOrAdmin(), g.KBAccessWrite("kb_id"), wikiHandler.UpdatePage)
wiki.DELETE("/pages/*slug", g.OwnedWikiKBOrAdmin(), g.KBAccessWrite("kb_id"), wikiHandler.DeletePage)
// Revision history (slug is a catch-all like /pages; revert carries
// the slug in the body for the same reason move-page does)
wikiRead.GET("/revisions/*slug", g.Viewer(), g.KBAccessRead("kb_id"), wikiHandler.ListRevisions)
wiki.POST("/revert", g.OwnedWikiKBOrAdmin(), g.KBAccessWrite("kb_id"), wikiHandler.RevertPage)
// Folder tree (directory nodes)
wikiRead.GET("/folders", g.Viewer(), g.KBAccessRead("kb_id"), wikiHandler.ListFolders)
wiki.POST("/folders", g.OwnedWikiKBOrAdmin(), g.KBAccessWrite("kb_id"), wikiHandler.CreateFolder)
wiki.PUT("/folders/:folder_id", g.OwnedWikiKBOrAdmin(), g.KBAccessWrite("kb_id"), wikiHandler.UpdateFolder)
wiki.DELETE("/folders/:folder_id", g.OwnedWikiKBOrAdmin(), g.KBAccessWrite("kb_id"), wikiHandler.DeleteFolder)
// Special pages
wikiRead.GET("/index", g.Viewer(), g.KBAccessRead("kb_id"), wikiHandler.GetIndex)
// Graph and stats
wikiRead.GET("/graph", g.Viewer(), g.KBAccessRead("kb_id"), wikiHandler.GetGraph)
wikiRead.GET("/stats", g.Viewer(), g.KBAccessRead("kb_id"), wikiHandler.GetStats)
// Search and maintenance
wikiRead.GET("/search", g.Viewer(), g.KBAccessRead("kb_id"), wikiHandler.SearchPages)
wiki.POST("/rebuild-links", g.OwnedWikiKBOrAdmin(), g.KBAccessWrite("kb_id"), wikiHandler.RebuildLinks)
wikiRead.GET("/lint", g.Viewer(), g.KBAccessRead("kb_id"), wikiHandler.Lint)
wiki.POST("/auto-fix", g.OwnedWikiKBOrAdmin(), g.KBAccessWrite("kb_id"), wikiHandler.AutoFix)
// Issues
wikiRead.GET("/issues", g.Viewer(), g.KBAccessRead("kb_id"), wikiHandler.ListIssues)
wiki.PUT("/issues/:issue_id/status", g.OwnedWikiKBOrAdmin(), g.KBAccessWrite("kb_id"), wikiHandler.UpdateIssueStatus)
}
}
@@ -0,0 +1,18 @@
package router
import (
"github.com/Tencent/WeKnora/internal/handler"
"github.com/gin-gonic/gin"
)
func RegisterKnowledgeClassificationRoutes(r *gin.RouterGroup, h *handler.KnowledgeClassificationHandler, g *rbacGuards) {
if h == nil {
return
}
group := r.Group("/knowledge-bases/:id/classification-jobs", g.OwnedKBOrAdmin(), g.KBAccessWrite("id"))
// Human review is required; intentionally unavailable to machine API keys.
group.GET("", h.List)
group.POST("", h.Create)
group.POST("/:job_id/apply", h.Apply)
group.POST("/:job_id/retry", h.Retry)
}
+41
View File
@@ -0,0 +1,41 @@
package router
import (
"github.com/gin-gonic/gin"
"github.com/Tencent/WeKnora/internal/handler"
)
// RegisterMemoryRoutes registers the personal long-term memory endpoints.
//
// There is no admin surface here and no subject parameter anywhere in the
// paths: the memory space is derived from the caller's principal, so these
// routes can only ever read or write the caller's own memories. That is why
// Viewer is the only role gate they need. An API key must be full-access,
// since a memory space belongs to a person and a scoped integration key has
// no business inheriting one.
func RegisterMemoryRoutes(r *gin.RouterGroup, memoryHandler *handler.MemoryHandler, g *rbacGuards) {
if memoryHandler == nil {
return
}
memoryGroup := g.apiKeyGroup(r.Group("/memory", g.Viewer()), apiKeyFullAccess())
{
memoryGroup.GET("/settings", memoryHandler.GetSettings)
memoryGroup.PUT("/settings", memoryHandler.UpdateSettings)
memoryGroup.POST("/extraction/retry", memoryHandler.RetryExtraction)
memoryGroup.GET("/items", memoryHandler.ListItems)
memoryGroup.POST("/items", memoryHandler.CreateItem)
memoryGroup.DELETE("/items", memoryHandler.Clear)
memoryGroup.PUT("/items/:id", memoryHandler.UpdateItem)
memoryGroup.DELETE("/items/:id", memoryHandler.DeleteItem)
memoryGroup.POST("/items/:id/confirm", memoryHandler.ConfirmItem)
memoryGroup.POST("/items/:id/reject", memoryHandler.RejectItem)
memoryGroup.GET("/topics", memoryHandler.ListTopics)
memoryGroup.DELETE("/topics/:id", memoryHandler.DeleteTopic)
memoryGroup.POST("/topics/:id/promote", memoryHandler.PromoteTopic)
memoryGroup.GET("/documents", memoryHandler.ListDocuments)
memoryGroup.DELETE("/documents/:id", memoryHandler.DeleteDocument)
memoryGroup.GET("/export", memoryHandler.Export)
memoryGroup.POST("/consolidate", memoryHandler.Consolidate)
}
}
+14
View File
@@ -0,0 +1,14 @@
package router
import (
"github.com/Tencent/WeKnora/internal/handler"
"github.com/gin-gonic/gin"
"net/http"
)
func RegisterNativeGraphRoutes(r *gin.RouterGroup, h *handler.NativeGraphHandler, g *rbacGuards) {
if h == nil {
return
}
g.apiKeyRoute(r, http.MethodGet, "/knowledge-bases/:id/evidence-graph", apiKeyRetrieve(apiKeyFullAccess()), g.Viewer(), g.KBAccessRead("id"), h.Browse)
}
+41
View File
@@ -0,0 +1,41 @@
package router
import (
"github.com/Tencent/WeKnora/internal/handler/workbench"
"github.com/Tencent/WeKnora/internal/middleware"
"github.com/Tencent/WeKnora/internal/types"
"github.com/gin-gonic/gin"
)
// RegisterWorkbenchRoutes exposes native 私域Agent orchestration resources.
// It deliberately sits behind the same tenant RBAC middleware as native WeKnora
// KBs and agents; no legacy API or shared admin token is involved.
func RegisterWorkbenchRoutes(r *gin.RouterGroup, h *workbench.Handler, g *rbacGuards) {
if h == nil {
return
}
wb := r.Group("/workbench")
// Viewer may read their own resources; mutating actions require Contributor.
wb.GET("/modules", middleware.RequireRole(types.TenantRoleViewer, g.cfg), h.Modules)
wb.GET("/resources/:kind", middleware.RequireRole(types.TenantRoleViewer, g.cfg), h.List)
wb.GET("/resources/:kind/:id", middleware.RequireRole(types.TenantRoleViewer, g.cfg), h.Get)
wb.GET("/resources/:kind/:id/export", middleware.RequireRole(types.TenantRoleViewer, g.cfg), h.Export)
wb.GET("/resources/:kind/:id/jobs", middleware.RequireRole(types.TenantRoleViewer, g.cfg), h.Jobs)
wb.POST("/resources/:kind", middleware.RequireRole(types.TenantRoleContributor, g.cfg), h.Create)
wb.PUT("/resources/:kind/:id", middleware.RequireRole(types.TenantRoleContributor, g.cfg), h.Update)
wb.DELETE("/resources/:kind/:id", middleware.RequireRole(types.TenantRoleContributor, g.cfg), h.Delete)
wb.POST("/resources/:kind/:id/actions/:action", middleware.RequireRole(types.TenantRoleContributor, g.cfg), h.Action)
wb.GET("/jobs/:id", middleware.RequireRole(types.TenantRoleViewer, g.cfg), h.Job)
wb.POST("/jobs/:id/:action", middleware.RequireRole(types.TenantRoleContributor, g.cfg), h.ControlJob)
wb.GET("/notifications", middleware.RequireRole(types.TenantRoleViewer, g.cfg), h.Notifications)
wb.POST("/notifications/read-all", middleware.RequireRole(types.TenantRoleViewer, g.cfg), h.ReadAll)
wb.GET("/balance", middleware.RequireRole(types.TenantRoleViewer, g.cfg), h.Balance)
wb.GET("/operations/accounts", middleware.RequireRole(types.TenantRoleAdmin, g.cfg), h.Accounts)
wb.POST("/operations/grants", middleware.RequireRole(types.TenantRoleAdmin, g.cfg), h.GrantBalance)
wb.GET("/operations/quotas", middleware.RequireRole(types.TenantRoleAdmin, g.cfg), h.Quotas)
wb.PUT("/operations/quotas/:userID", middleware.RequireRole(types.TenantRoleAdmin, g.cfg), h.SaveQuota)
wb.DELETE("/operations/quotas/:id", middleware.RequireRole(types.TenantRoleAdmin, g.cfg), h.DeleteQuota)
wb.GET("/operations/records/:recordKind", middleware.RequireRole(types.TenantRoleViewer, g.cfg), h.OperationRecords)
wb.POST("/operations/consumption", middleware.RequireRole(types.TenantRoleContributor, g.cfg), h.UpdateConsumption)
wb.POST("/operations/memories/:id", middleware.RequireRole(types.TenantRoleContributor, g.cfg), h.ReviewMemory)
}
+67
View File
@@ -0,0 +1,67 @@
package router
import (
"context"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"github.com/Tencent/WeKnora/internal/logger"
)
// serveFrontendStatic registers a middleware that serves the frontend SPA
// from the ./web directory if it exists. Must be called BEFORE auth middleware
// so static files are served without authentication.
func serveFrontendStatic(r *gin.Engine) {
webDir := os.Getenv("WEKNORA_WEB_DIR")
if webDir == "" {
webDir = "./web"
}
absDir, _ := filepath.Abs(webDir)
indexPath := filepath.Join(absDir, "index.html")
if _, err := os.Stat(indexPath); err != nil {
return
}
logger.Infof(context.Background(), "[Router] Serving frontend static files from %s", absDir)
fs := http.Dir(absDir)
fileServer := http.FileServer(fs)
r.Use(func(c *gin.Context) {
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead {
c.Next()
return
}
path := c.Request.URL.Path
if strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/health") || strings.HasPrefix(path, "/swagger/") ||
strings.HasPrefix(path, "/r/") || path == "/files" {
c.Next()
return
}
fullPath := filepath.Join(absDir, path)
if info, err := os.Stat(fullPath); err == nil && !info.IsDir() {
setFrontendCacheHeaders(c.Writer, path)
fileServer.ServeHTTP(c.Writer, c.Request)
c.Abort()
return
}
setFrontendCacheHeaders(c.Writer, "/index.html")
c.File(indexPath)
c.Abort()
})
}
// setFrontendCacheHeaders sets Cache-Control headers for frontend static resources.
// Vite 构建产物中 /assets/* 的文件名带 hash,可长期缓存;其余(index.html、config.js、favicon 等)
// 每次都需 revalidate,避免前端升级后用户看到旧版本。
func setFrontendCacheHeaders(w http.ResponseWriter, path string) {
if strings.HasPrefix(path, "/assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
return
}
w.Header().Set("Cache-Control", "no-cache, must-revalidate")
}
+232
View File
@@ -0,0 +1,232 @@
package router
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/google/uuid"
"github.com/hibiken/asynq"
"go.uber.org/dig"
)
// SyncTaskExecutor executes tasks synchronously (in a goroutine) without Redis.
// Used in Lite mode as a drop-in replacement for *asynq.Client.
type SyncTaskExecutor struct {
mu sync.RWMutex
handlers map[string]func(context.Context, *asynq.Task) error
taskIDs map[string]time.Time
activeKnowledge map[string]int
}
func NewSyncTaskExecutor() *SyncTaskExecutor {
return &SyncTaskExecutor{
handlers: make(map[string]func(context.Context, *asynq.Task) error),
taskIDs: make(map[string]time.Time),
activeKnowledge: make(map[string]int),
}
}
// RegisterHandler registers a handler for a given task type pattern.
func (e *SyncTaskExecutor) RegisterHandler(pattern string, handler func(context.Context, *asynq.Task) error) {
e.mu.Lock()
defer e.mu.Unlock()
e.handlers[pattern] = handler
}
// HasActiveTaskID includes execution and retry backoff in Lite mode, where the
// queue inspector has no external backend to inspect.
func (e *SyncTaskExecutor) HasActiveTaskID(id string) bool {
e.mu.RLock()
defer e.mu.RUnlock()
expiry, exists := e.taskIDs[id]
return exists && expiry.IsZero()
}
func (e *SyncTaskExecutor) HasActiveKnowledgeTask(id string) bool {
e.mu.RLock()
defer e.mu.RUnlock()
return e.activeKnowledge[id] > 0
}
// Enqueue satisfies interfaces.TaskEnqueuer.
// Instead of queuing to Redis, it dispatches the task to a goroutine.
// Supports ProcessIn (delay) and MaxRetry options for parity with asynq.
func (e *SyncTaskExecutor) Enqueue(task *asynq.Task, opts ...asynq.Option) (*asynq.TaskInfo, error) {
e.mu.RLock()
handler, ok := e.handlers[task.Type()]
e.mu.RUnlock()
if !ok {
return nil, fmt.Errorf("sync task executor: no handler registered for type %q", task.Type())
}
var delay time.Duration
var taskID string
var retention time.Duration
maxRetry := 25 // asynq default
maxRetrySet := false
for _, opt := range opts {
switch opt.Type() {
case asynq.TaskIDOpt:
taskID, _ = opt.Value().(string)
case asynq.RetentionOpt:
retention, _ = opt.Value().(time.Duration)
case asynq.ProcessInOpt:
if d, ok := opt.Value().(time.Duration); ok {
delay = d
}
case asynq.MaxRetryOpt:
if n, ok := opt.Value().(int); ok {
maxRetry = n
maxRetrySet = true
}
}
}
// Callers that explicitly pass MaxRetry(0) want no retries.
// Without the flag we can't distinguish "not set" from "set to 0".
if maxRetrySet && maxRetry < 0 {
maxRetry = 0
}
reservedID := taskID != ""
if reservedID {
e.mu.Lock()
now := time.Now()
for id, expiry := range e.taskIDs {
if !expiry.IsZero() && expiry.Before(now) {
delete(e.taskIDs, id)
}
}
if _, exists := e.taskIDs[taskID]; exists {
e.mu.Unlock()
return nil, asynq.ErrTaskIDConflict
}
e.taskIDs[taskID] = time.Time{}
e.mu.Unlock()
} else {
taskID = uuid.New().String()
}
info := &asynq.TaskInfo{
ID: taskID,
Queue: "sync",
Type: task.Type(),
}
var payload struct {
KnowledgeID string `json:"knowledge_id"`
}
_ = json.Unmarshal(task.Payload(), &payload)
if payload.KnowledgeID != "" {
e.mu.Lock()
e.activeKnowledge[payload.KnowledgeID]++
e.mu.Unlock()
}
go func() {
defer func() {
if payload.KnowledgeID != "" {
e.mu.Lock()
e.activeKnowledge[payload.KnowledgeID]--
if e.activeKnowledge[payload.KnowledgeID] == 0 {
delete(e.activeKnowledge, payload.KnowledgeID)
}
e.mu.Unlock()
}
if reservedID {
e.mu.Lock()
if retention > 0 {
e.taskIDs[taskID] = time.Now().Add(retention)
} else {
delete(e.taskIDs, taskID)
}
e.mu.Unlock()
}
}()
if delay > 0 {
time.Sleep(delay)
}
// Tag as a background worker execution so the per-model concurrency
// governor throttles Lite-mode ingestion/enrichment LLM calls, mirroring
// the asynq backgroundTaskMiddleware in the Redis path.
ctx := types.WithBackgroundTask(context.Background())
start := time.Now()
logger.Infof(ctx, "[SyncTask] Executing task type=%s id=%s", task.Type(), taskID)
var lastErr error
for attempt := 0; attempt <= maxRetry; attempt++ {
if attempt > 0 {
backoff := time.Duration(attempt) * 5 * time.Second
if backoff > 30*time.Second {
backoff = 30 * time.Second
}
logger.Infof(ctx, "[SyncTask] Retrying task type=%s id=%s attempt=%d/%d backoff=%s",
task.Type(), taskID, attempt, maxRetry, backoff)
time.Sleep(backoff)
}
attemptCtx := types.WithTaskRetryMetadata(ctx, attempt, maxRetry)
lastErr = handler(attemptCtx, task)
if lastErr == nil {
logger.Infof(ctx, "[SyncTask] Task completed type=%s id=%s elapsed=%v",
task.Type(), taskID, time.Since(start))
return
}
}
logger.Errorf(ctx, "[SyncTask] Task failed (exhausted retries) type=%s id=%s elapsed=%v err=%v",
task.Type(), taskID, time.Since(start), lastErr)
}()
return info, nil
}
type SyncTaskParams struct {
dig.In
Executor *SyncTaskExecutor
KnowledgeService interfaces.KnowledgeService
KnowledgeBaseService interfaces.KnowledgeBaseService
TagService interfaces.KnowledgeTagService
DataSourceService interfaces.DataSourceService
ChunkExtractor interfaces.TaskHandler `name:"chunkExtractor"`
DataTableSummary interfaces.TaskHandler `name:"dataTableSummary"`
ImageMultimodal interfaces.TaskHandler `name:"imageMultimodal"`
KnowledgePostProcess interfaces.TaskHandler `name:"knowledgePostProcess"`
KnowledgeAutoTag interfaces.TaskHandler `name:"knowledgeAutoTag"`
WikiIngest interfaces.TaskHandler `name:"wikiIngest"`
TemporaryDocument interfaces.TemporaryDocumentService
MemoryService interfaces.MemoryService
}
// RegisterSyncHandlers registers all task handlers on the SyncTaskExecutor.
// Used in Lite mode instead of RunAsynqServer.
func RegisterSyncHandlers(params SyncTaskParams) {
params.Executor.RegisterHandler(types.TypeChunkExtract, params.ChunkExtractor.Handle)
params.Executor.RegisterHandler(types.TypeDataTableSummary, params.DataTableSummary.Handle)
params.Executor.RegisterHandler(types.TypeDocumentProcess, params.KnowledgeService.ProcessDocument)
params.Executor.RegisterHandler(types.TypeTemporaryDocumentProcess, params.TemporaryDocument.Process)
params.Executor.RegisterHandler(types.TypeManualProcess, params.KnowledgeService.ProcessManualUpdate)
params.Executor.RegisterHandler(types.TypeFAQImport, params.KnowledgeService.ProcessFAQImport)
params.Executor.RegisterHandler(types.TypeQuestionGeneration, params.KnowledgeService.ProcessQuestionGeneration)
params.Executor.RegisterHandler(types.TypeSummaryGeneration, params.KnowledgeService.ProcessSummaryGeneration)
params.Executor.RegisterHandler(types.TypeKBClone, params.KnowledgeService.ProcessKBClone)
params.Executor.RegisterHandler(types.TypeKnowledgeMove, params.KnowledgeService.ProcessKnowledgeMove)
params.Executor.RegisterHandler(types.TypeKnowledgeListDelete, params.KnowledgeService.ProcessKnowledgeListDelete)
params.Executor.RegisterHandler(types.TypeKnowledgeListReparse, params.KnowledgeService.ProcessKnowledgeListReparse)
params.Executor.RegisterHandler(types.TypeIndexDelete, params.TagService.ProcessIndexDelete)
params.Executor.RegisterHandler(types.TypeKBDelete, params.KnowledgeBaseService.ProcessKBDelete)
params.Executor.RegisterHandler(types.TypeImageMultimodal, params.ImageMultimodal.Handle)
params.Executor.RegisterHandler(types.TypeKnowledgePostProcess, params.KnowledgePostProcess.Handle)
params.Executor.RegisterHandler(types.TypeKnowledgeAutoTag, params.KnowledgeAutoTag.Handle)
params.Executor.RegisterHandler(types.TypeDataSourceSync, params.DataSourceService.ProcessSync)
params.Executor.RegisterHandler(types.TypeWikiIngest, params.WikiIngest.Handle)
params.Executor.RegisterHandler(types.TypeWikiFinalize, params.WikiIngest.Handle)
params.Executor.RegisterHandler(types.TypeMemoryExtract, params.MemoryService.Handle)
logger.Infof(context.Background(), "[SyncTask] All task handlers registered (Lite mode, no Redis)")
}
+90
View File
@@ -0,0 +1,90 @@
package router
import (
"context"
"errors"
"testing"
"time"
"github.com/Tencent/WeKnora/internal/types"
"github.com/hibiken/asynq"
)
func TestSyncTaskExecutorInjectsRetryMetadata(t *testing.T) {
executor := NewSyncTaskExecutor()
observed := make(chan [2]int, 1)
executor.RegisterHandler("test:retry-metadata", func(ctx context.Context, _ *asynq.Task) error {
retried, maxRetry, ok := types.TaskRetryMetadataFromContext(ctx)
if !ok {
observed <- [2]int{-1, -1}
return nil
}
observed <- [2]int{retried, maxRetry}
return nil
})
task := asynq.NewTask("test:retry-metadata", nil)
if _, err := executor.Enqueue(task, asynq.MaxRetry(3)); err != nil {
t.Fatalf("enqueue: %v", err)
}
select {
case got := <-observed:
if got != [2]int{0, 3} {
t.Fatalf("retry metadata = %v, want [0 3]", got)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for sync task")
}
}
func TestSyncTaskExecutorFencesReservedTaskID(t *testing.T) {
executor := NewSyncTaskExecutor()
started, release := make(chan struct{}), make(chan struct{})
executor.RegisterHandler("test:reserved-id", func(context.Context, *asynq.Task) error {
close(started)
<-release
return nil
})
task := asynq.NewTask("test:reserved-id", []byte(`{"knowledge_id":"document"}`))
opts := []asynq.Option{asynq.TaskID("resumable:document"), asynq.Retention(time.Hour), asynq.MaxRetry(0)}
info, err := executor.Enqueue(task, opts...)
if err != nil || info.ID != "resumable:document" {
t.Fatalf("reserved task: %v %v", info, err)
}
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("task did not start")
}
if _, err := executor.Enqueue(task, opts...); !errors.Is(err, asynq.ErrTaskIDConflict) {
t.Fatalf("concurrent reserved task was accepted: %v", err)
}
if !executor.HasActiveTaskID(info.ID) {
t.Fatal("active reserved task was not detected")
}
if !executor.HasActiveKnowledgeTask("document") {
t.Fatal("active knowledge task was not detected")
}
close(release)
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
executor.mu.RLock()
expiry := executor.taskIDs[info.ID]
executor.mu.RUnlock()
if !expiry.IsZero() {
if executor.HasActiveTaskID(info.ID) {
t.Fatal("retained task reported active")
}
if executor.HasActiveKnowledgeTask("document") {
t.Fatal("completed knowledge task reported active")
}
if _, err := executor.Enqueue(task, opts...); !errors.Is(err, asynq.ErrTaskIDConflict) {
t.Fatalf("retained task was accepted: %v", err)
}
return
}
time.Sleep(time.Millisecond)
}
t.Fatal("completed task was not retained")
}
+466
View File
@@ -0,0 +1,466 @@
package router
import (
"context"
"encoding/json"
"errors"
"log"
"os"
"strconv"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/application/service"
"github.com/Tencent/WeKnora/internal/common"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/middleware/asynqdl"
"github.com/Tencent/WeKnora/internal/tracing/langfuse"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/hibiken/asynq"
"go.uber.org/dig"
)
type AsynqTaskParams struct {
dig.In
// Dedicated servers provide minimum capacity for parse, post-process,
// enrichment, maintenance, and Wiki work. SharedServer is the elastic tier:
// it also subscribes to core/enrichment queues so either stage can borrow
// otherwise-idle capacity without consuming the other stage's guarantee.
CoreServer *asynq.Server `name:"coreAsynqServer"`
PostProcessServer *asynq.Server `name:"postProcessAsynqServer"`
EnrichmentServer *asynq.Server `name:"enrichmentAsynqServer"`
MaintenanceServer *asynq.Server `name:"maintenanceAsynqServer"`
SharedServer *asynq.Server `name:"sharedAsynqServer"`
WikiServer *asynq.Server `name:"wikiAsynqServer"`
KnowledgeService interfaces.KnowledgeService
KnowledgeBaseService interfaces.KnowledgeBaseService
TagService interfaces.KnowledgeTagService
DataSourceService interfaces.DataSourceService
ChunkExtractor interfaces.TaskHandler `name:"chunkExtractor"`
DataTableSummary interfaces.TaskHandler `name:"dataTableSummary"`
ImageMultimodal interfaces.TaskHandler `name:"imageMultimodal"`
KnowledgePostProcess interfaces.TaskHandler `name:"knowledgePostProcess"`
KnowledgeAutoTag interfaces.TaskHandler `name:"knowledgeAutoTag"`
WikiIngest interfaces.TaskHandler `name:"wikiIngest"`
TemporaryDocument interfaces.TemporaryDocumentService
MemoryService interfaces.MemoryService
DeadLetterRepo interfaces.TaskDeadLetterRepository
SpanTracker service.SpanTracker
}
// defaultRedisOpTimeout is the previous hard-coded read timeout. The 100ms
// floor was tight enough to cause spurious i/o timeout errors during bursty
// workloads (large batch uploads, multimodal counter DECRs under load), so we
// raise the default to 500ms while still allowing operators to tune via env.
const defaultRedisOpTimeoutMs = 500
// readRedisOpTimeoutMs reads WEKNORA_REDIS_OP_TIMEOUT_MS, falling back to
// defaultRedisOpTimeoutMs on missing/invalid input. Kept as a separate helper
// so both ReadTimeout and WriteTimeout share the same source of truth.
func readRedisOpTimeoutMs() int {
if v := strings.TrimSpace(os.Getenv("WEKNORA_REDIS_OP_TIMEOUT_MS")); v != "" {
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
return parsed
}
}
return defaultRedisOpTimeoutMs
}
func getAsynqRedisClientOpt() *asynq.RedisClientOpt {
db := 0
if dbStr := os.Getenv("REDIS_DB"); dbStr != "" {
if parsed, err := strconv.Atoi(dbStr); err == nil {
db = parsed
}
}
timeoutMs := readRedisOpTimeoutMs()
opt := &asynq.RedisClientOpt{
Addr: os.Getenv("REDIS_ADDR"),
Username: os.Getenv("REDIS_USERNAME"),
Password: os.Getenv("REDIS_PASSWORD"),
ReadTimeout: time.Duration(timeoutMs) * time.Millisecond,
// Writes are typically more sensitive to congestion than reads
// (RESP pipelining, BRPOPLPUSH on Asynq dequeue), so we keep
// WriteTimeout slightly larger to absorb head-of-line stalls.
WriteTimeout: time.Duration(timeoutMs*2) * time.Millisecond,
DB: db,
TLSConfig: common.RedisTLSConfig(),
}
return opt
}
func NewAsyncqClient() (*asynq.Client, error) {
opt := getAsynqRedisClientOpt()
client := asynq.NewClient(opt)
err := client.Ping()
if err != nil {
return nil, err
}
return client, nil
}
// wikiIngestRetryDelay is a fixed, short backoff for wiki ingest lock
// conflicts. Must be slightly longer than the active-lock TTL's worst-case
// "just got set" window so the retry is highly likely to succeed without
// burning through retries; but short enough that users don't feel the stall.
const wikiIngestRetryDelay = 15 * time.Second
// asynqRetryDelayFunc customizes per-task retry backoff.
//
// Default asynq backoff is exponential (≈10s, 40s, 90s, 2.5m, ...), which
// is appropriate for transient errors like remote HTTP failures. But for
// wiki ingest lock conflicts (ErrWikiIngestConcurrent), exponential
// backoff is harmful: a freshly orphaned lock expires in ≤60s, so a 15s
// fixed retry virtually guarantees the next attempt succeeds. Without
// this override, a crash-restart cycle can leave a KB unable to make
// progress for 7–10 minutes while the orphan lock expires AND the retry
// schedule catches up.
func asynqRetryDelayFunc(n int, e error, t *asynq.Task) time.Duration {
if errors.Is(e, service.ErrWikiIngestConcurrent) {
return wikiIngestRetryDelay
}
return asynq.DefaultRetryDelayFunc(n, e, t)
}
// Worker defaults live in types so server construction and runtime reporting
// cannot drift. The upstream budget is divided without increasing historical
// total capacity; Wiki remains separate because its model-heavy workload has a
// different provider-capacity profile.
// newAsynqServer builds an asynq server bound to a specific queue set and
// concurrency. Every worker pool uses it so Redis options and retry-delay
// policy stay consistent across the topology.
func newAsynqServer(concurrency int, queues map[string]int) *asynq.Server {
opt := getAsynqRedisClientOpt()
return asynq.NewServer(
opt,
asynq.Config{
Concurrency: concurrency,
Queues: queues,
RetryDelayFunc: asynqRetryDelayFunc,
},
)
}
// backgroundTaskMiddleware tags every task's context as a background worker
// execution (types.WithBackgroundTask) so the per-model chat concurrency
// governor applies to ingestion/enrichment LLM calls but not to interactive
// user-facing chat.
func backgroundTaskMiddleware() asynq.MiddlewareFunc {
return func(next asynq.Handler) asynq.Handler {
return asynq.HandlerFunc(func(ctx context.Context, t *asynq.Task) error {
return next.ProcessTask(types.WithBackgroundTask(ctx), t)
})
}
}
func resolveWorkerPoolConcurrency(svc interfaces.SystemSettingService) types.WorkerPoolConcurrency {
if svc == nil {
return types.DefaultWorkerPoolConcurrency()
}
ctx := context.Background()
return types.ResolveWorkerPoolConcurrency(func(key, env string, fallback int) int {
value := svc.GetInt(ctx, key, env, int64(fallback))
return int(value)
})
}
// NewCoreAsynqServer runs document and manual parsing with guaranteed capacity.
func NewCoreAsynqServer(svc interfaces.SystemSettingService) *asynq.Server {
allocation := resolveWorkerPoolConcurrency(svc)
log.Printf("asynq core-pool server starting with concurrency=%d total_upstream=%d redis_op_timeout=%dms",
allocation.Core, allocation.UpstreamTotal(), readRedisOpTimeoutMs())
return newAsynqServer(allocation.Core, types.QueueWeightsForPool(types.WorkerPoolCore))
}
// NewPostProcessAsynqServer reserves capacity for the lightweight but
// latency-sensitive fan-out stage. It cannot be trapped behind a burst of
// long-running document parses in QueueDefault.
func NewPostProcessAsynqServer(svc interfaces.SystemSettingService) *asynq.Server {
allocation := resolveWorkerPoolConcurrency(svc)
log.Printf("asynq postprocess-pool server starting with concurrency=%d total_upstream=%d",
allocation.PostProcess, allocation.UpstreamTotal())
return newAsynqServer(allocation.PostProcess, types.QueueWeightsForPool(types.WorkerPoolPostProcess))
}
// NewEnrichmentAsynqServer runs high-fanout summary, multimodal, graph, and
// question generation without consuming core parsing capacity.
func NewEnrichmentAsynqServer(svc interfaces.SystemSettingService) *asynq.Server {
allocation := resolveWorkerPoolConcurrency(svc)
log.Printf("asynq enrichment-pool server starting with concurrency=%d total_upstream=%d",
allocation.Enrichment, allocation.UpstreamTotal())
return newAsynqServer(allocation.Enrichment, types.QueueWeightsForPool(types.WorkerPoolEnrichment))
}
// NewMaintenanceAsynqServer runs connector sync, cleanup, deletion, and batch
// dispatch work. QueueMaintenance keeps the legacy Redis name "low", so old
// tasks are drained safely during rolling upgrades.
func NewMaintenanceAsynqServer(svc interfaces.SystemSettingService) *asynq.Server {
allocation := resolveWorkerPoolConcurrency(svc)
log.Printf("asynq maintenance-pool server starting with concurrency=%d total_upstream=%d",
allocation.Maintenance, allocation.UpstreamTotal())
return newAsynqServer(allocation.Maintenance, types.QueueWeightsForPool(types.WorkerPoolMaintenance))
}
// NewSharedAsynqServer is the elastic tier. Asynq dequeue is atomic, so it is
// safe for this server and the dedicated servers to subscribe to the same
// queues: every task is still executed by exactly one worker.
func NewSharedAsynqServer(svc interfaces.SystemSettingService) *asynq.Server {
allocation := resolveWorkerPoolConcurrency(svc)
log.Printf("asynq shared-pool server starting with concurrency=%d total_upstream=%d",
allocation.Shared, allocation.UpstreamTotal())
return newAsynqServer(allocation.Shared, types.QueueWeightsForSharedPool())
}
// NewWikiAsynqServer builds the dedicated wiki pool: QueueWiki only. It runs
// the shared handler mux but only ever pulls wiki tasks, so its concurrency
// budget (WEKNORA_WIKI_ASYNQ_CONCURRENCY, default 8) is spent exclusively on
// wiki generation. This is the hard capacity isolation that prevents the parse
// pipeline from starving wiki (and vice-versa) during concurrent uploads.
func NewWikiAsynqServer(svc interfaces.SystemSettingService) *asynq.Server {
concurrency := resolveWorkerPoolConcurrency(svc).Wiki
log.Printf("asynq wiki-pool server starting with concurrency=%d", concurrency)
return newAsynqServer(concurrency, types.QueueWeightsForPool(types.WorkerPoolWiki))
}
func RunAsynqServer(params AsynqTaskParams) *asynq.ServeMux {
// Create a new mux and register all handlers
mux := asynq.NewServeMux()
// Install the dead-letter middleware FIRST so it sees the raw error
// returned by the handler, before any other middleware that might
// transform it. The middleware records one task_dead_letters row per
// task that exhausts its retry budget — operators can then SQL-query
// failures by task type, scope, or tenant without scraping logs.
// Best-effort: a DB failure is logged and swallowed; the original task
// error always propagates upstream to asynq for retry/archival.
//
// The callback flips Knowledge.parse_status to "failed" the moment a
// document-related task exhausts its retry budget. Without this hook,
// a permanently-failing task left its parent knowledge stranded in
// "processing" until housekeeping cron caught it minutes later — the
// UI signal users actually see.
knowledgeFailer := newDeadLetterKnowledgeFailer(params.KnowledgeService, params.SpanTracker)
mux.Use(asynqdl.MiddlewareWithCallback(params.DeadLetterRepo, knowledgeFailer))
// Mark every asynq worker execution as a background task so the chat
// concurrency governor throttles ingestion/enrichment LLM traffic while
// leaving user-facing interactive chat ungated. Installed early so all
// downstream handlers (and their model calls) inherit the flag.
mux.Use(backgroundTaskMiddleware())
// Install Langfuse middleware BEFORE handler registration so every task
// type is automatically wrapped. When Langfuse is disabled the middleware
// is a pass-through; when enabled it resumes the upstream HTTP trace (if
// the payload carries one) or opens a standalone trace, then wraps the
// handler execution in a SPAN so all child generations (embedding / VLM /
// chat / rerank / ASR) nest correctly in the Langfuse UI.
mux.Use(langfuse.AsynqMiddleware())
// Register extract handlers - router will dispatch to appropriate handler
mux.HandleFunc(types.TypeChunkExtract, params.ChunkExtractor.Handle)
mux.HandleFunc(types.TypeDataTableSummary, params.DataTableSummary.Handle)
// Register document processing handler
mux.HandleFunc(types.TypeDocumentProcess, params.KnowledgeService.ProcessDocument)
mux.HandleFunc(types.TypeTemporaryDocumentProcess, params.TemporaryDocument.Process)
// Register manual knowledge processing handler (cleanup + re-indexing)
mux.HandleFunc(types.TypeManualProcess, params.KnowledgeService.ProcessManualUpdate)
// Register FAQ import handler (includes dry run mode)
mux.HandleFunc(types.TypeFAQImport, params.KnowledgeService.ProcessFAQImport)
// Register question generation handler
mux.HandleFunc(types.TypeQuestionGeneration, params.KnowledgeService.ProcessQuestionGeneration)
// Register summary generation handler
mux.HandleFunc(types.TypeSummaryGeneration, params.KnowledgeService.ProcessSummaryGeneration)
// Register KB clone handler
mux.HandleFunc(types.TypeKBClone, params.KnowledgeService.ProcessKBClone)
// Register knowledge move handler
mux.HandleFunc(types.TypeKnowledgeMove, params.KnowledgeService.ProcessKnowledgeMove)
// Register knowledge list delete handler
mux.HandleFunc(types.TypeKnowledgeListDelete, params.KnowledgeService.ProcessKnowledgeListDelete)
// Register knowledge list reparse handler
mux.HandleFunc(types.TypeKnowledgeListReparse, params.KnowledgeService.ProcessKnowledgeListReparse)
// Register index delete handler
mux.HandleFunc(types.TypeIndexDelete, params.TagService.ProcessIndexDelete)
// Register KB delete handler
mux.HandleFunc(types.TypeKBDelete, params.KnowledgeBaseService.ProcessKBDelete)
// Register image multimodal handler
mux.HandleFunc(types.TypeImageMultimodal, params.ImageMultimodal.Handle)
// Register knowledge post process handler
mux.HandleFunc(types.TypeKnowledgePostProcess, params.KnowledgePostProcess.Handle)
mux.HandleFunc(types.TypeKnowledgeAutoTag, params.KnowledgeAutoTag.Handle)
// Register data source sync handler
mux.HandleFunc(types.TypeDataSourceSync, params.DataSourceService.ProcessSync)
// Register wiki ingest handler + the debounced KB-global finalize handler.
// Both route to the same dispatch (WikiIngest.Handle switches on task type)
// and both land on QueueWiki, so the dedicated wiki pool serves them.
mux.HandleFunc(types.TypeWikiIngest, params.WikiIngest.Handle)
mux.HandleFunc(types.TypeWikiFinalize, params.WikiIngest.Handle)
// Register long-term memory distillation handler
mux.HandleFunc(types.TypeMemoryExtract, params.MemoryService.Handle)
// Run the same mux on every pool. Shared and dedicated servers intentionally
// overlap, but Redis dequeue is atomic, so each task still executes once.
runPool := func(name string, srv *asynq.Server) {
go func() {
if err := srv.Run(mux); err != nil {
log.Fatalf("could not run %s asynq server: %v", name, err)
}
}()
}
runPool("core-pool", params.CoreServer)
runPool("postprocess-pool", params.PostProcessServer)
runPool("enrichment-pool", params.EnrichmentServer)
runPool("maintenance-pool", params.MaintenanceServer)
runPool("shared-pool", params.SharedServer)
runPool("wiki-pool", params.WikiServer)
return mux
}
// deadLetterKnowledgePayload extracts only the field we need from any
// document-related asynq payload. Kept narrow so we don't accidentally
// depend on the full payload schema and survive future field churn.
type deadLetterKnowledgePayload struct {
KnowledgeID string `json:"knowledge_id,omitempty"`
// Attempt threads through DocumentProcess / ManualProcess /
// KnowledgePostProcess payloads (added when span tracking shipped)
// — extracted here so the dead-letter callback can also close the
// matching root span as failed. Older in-flight payloads without
// this field decode as 0 and the tracker call no-ops.
Attempt int `json:"attempt,omitempty"`
}
// taskTypesAffectingKnowledgeStatus enumerates the asynq task types whose
// dead-letter event should flip the parent Knowledge to "failed". Only
// terminal task types are listed here:
//
// - TypeDocumentProcess: the entry point of the parsing pipeline.
// - TypeImageMultimodal: a single image hitting dead-letter would have
// been counted by isFinalAsynqAttempt (see image_multimodal.go), so
// the parent might still complete via remaining images. We DO NOT mark
// the parent failed for this case — finalize-on-last-attempt already
// ensures progress.
// - TypeKnowledgePostProcess: terminal stage; failure here strands the
// knowledge in "processing".
// - TypeManualProcess: same shape as DocumentProcess for re-indexing.
//
// Question/Summary generation are NOT included: they run after parse_status
// has already become "completed" and have their own status fields.
var taskTypesAffectingKnowledgeStatus = map[string]struct{}{
types.TypeDocumentProcess: {},
types.TypeKnowledgePostProcess: {},
types.TypeManualProcess: {},
}
type deadLetterKnowledgeListDeletePayload struct {
KnowledgeIDs []string `json:"knowledge_ids,omitempty"`
}
// newDeadLetterKnowledgeFailer returns the callback wired into the asynq
// dead-letter middleware. When a document-related task exhausts its retry
// budget, this callback marks the corresponding Knowledge row as failed so
// the UI surfaces the error instead of a perpetual spinner.
//
// All work is best-effort: missing payload, missing knowledge_id, or DB
// errors are logged and swallowed. The dead-letter record is the source of
// truth — this is purely a UX shortcut so users don't wait for the
// housekeeping cron's next sweep.
func newDeadLetterKnowledgeFailer(ks interfaces.KnowledgeService, tracker service.SpanTracker) asynqdl.OnDeadLetter {
if ks == nil {
return nil
}
repo := ks.GetRepository()
if repo == nil {
return nil
}
return func(ctx context.Context, t *asynq.Task, taskErr error) {
if t == nil {
return
}
if t.Type() == types.TypeKnowledgeListDelete {
markKnowledgeListDeleteFailed(ctx, repo, t, taskErr)
return
}
if _, ok := taskTypesAffectingKnowledgeStatus[t.Type()]; !ok {
return
}
var probe deadLetterKnowledgePayload
if err := json.Unmarshal(t.Payload(), &probe); err != nil || probe.KnowledgeID == "" {
return
}
errMsg := "task " + t.Type() + " exhausted retries: " + taskErr.Error()
// 8KB is the same cap the dead-letter row uses for last_error.
if len(errMsg) > 8192 {
errMsg = errMsg[:8192]
}
// Single UPDATE so we never end up with parse_status=failed but
// stale error_message (or vice versa) when the second write
// fails.
if err := repo.UpdateKnowledgeColumns(ctx, probe.KnowledgeID, map[string]interface{}{
"parse_status": types.ParseStatusFailed,
"error_message": errMsg,
}); err != nil {
logger.Warnf(ctx, "dead-letter callback: failed to mark knowledge %s as failed: %v", probe.KnowledgeID, err)
return
}
// Close the matching root span so the timeline stops showing
// "进行中" after dead-letter exhaustion. Best-effort: nil
// tracker / missing attempt / missing root all no-op cleanly.
if tracker != nil && probe.Attempt > 0 {
tracker.FinalizeAttempt(ctx, probe.KnowledgeID, probe.Attempt,
types.SpanStatusFailed, nil, "TASK_TIMEOUT", errMsg)
}
logger.Infof(ctx, "dead-letter callback: marked knowledge %s as failed (task=%s)", probe.KnowledgeID, t.Type())
}
}
func markKnowledgeListDeleteFailed(
ctx context.Context,
repo interfaces.KnowledgeRepository,
t *asynq.Task,
taskErr error,
) {
var payload deadLetterKnowledgeListDeletePayload
if err := json.Unmarshal(t.Payload(), &payload); err != nil || len(payload.KnowledgeIDs) == 0 {
return
}
errMsg := "delete task exhausted retries: " + taskErr.Error()
if len(errMsg) > 8192 {
errMsg = errMsg[:8192]
}
for _, knowledgeID := range payload.KnowledgeIDs {
if knowledgeID == "" {
continue
}
updated, err := repo.UpdateActiveDeletingKnowledgeColumns(ctx, knowledgeID, map[string]interface{}{
"parse_status": types.ParseStatusFailed,
"error_message": errMsg,
})
if err != nil {
logger.Warnf(ctx, "dead-letter callback: failed to mark delete failure for knowledge %s: %v", knowledgeID, err)
continue
}
if !updated {
logger.Infof(ctx, "dead-letter callback: skipped marking knowledge %s after delete task exhaustion because it is no longer active deleting", knowledgeID)
continue
}
logger.Infof(ctx, "dead-letter callback: marked knowledge %s as failed after delete task exhausted retries", knowledgeID)
}
}
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
package router
import (
"errors"
"strings"
"github.com/hibiken/asynq"
)
const (
asynqQueueNotFoundPrefix = `NOT_FOUND: queue "`
asynqQueueNotFoundSuffix = `" does not exist`
)
// isAsynqQueueNotFound handles both the public sentinel and the internal error
// leaked by Inspector.GetQueueInfo in asynq v0.26.0.
func isAsynqQueueNotFound(err error) bool {
if err == nil {
return false
}
if errors.Is(err, asynq.ErrQueueNotFound) {
return true
}
message := err.Error()
start := strings.LastIndex(message, asynqQueueNotFoundPrefix)
if start < 0 || (start > 0 && !strings.HasSuffix(message[:start], ": ")) {
return false
}
queueAndSuffix := message[start+len(asynqQueueNotFoundPrefix):]
if !strings.HasSuffix(queueAndSuffix, asynqQueueNotFoundSuffix) {
return false
}
queue := strings.TrimSuffix(queueAndSuffix, asynqQueueNotFoundSuffix)
return queue != "" && !strings.Contains(queue, `"`)
}
@@ -0,0 +1,37 @@
package router
import (
"errors"
"fmt"
"testing"
"github.com/hibiken/asynq"
)
func TestIsAsynqQueueNotFound(t *testing.T) {
internalQueueNotFound := errors.New(`NOT_FOUND: queue "default" does not exist`)
tests := []struct {
name string
err error
want bool
}{
{name: "nil", err: nil, want: false},
{name: "public sentinel", err: asynq.ErrQueueNotFound, want: true},
{name: "wrapped public sentinel", err: fmt.Errorf("inspect queue: %w", asynq.ErrQueueNotFound), want: true},
{name: "internal queue error", err: internalQueueNotFound, want: true},
{name: "wrapped internal queue error", err: fmt.Errorf("inspect queue: %w", internalQueueNotFound), want: true},
{name: "task not found", err: errors.New(`NOT_FOUND: task "default" does not exist`), want: false},
{name: "different code", err: errors.New(`UNKNOWN: queue "default" does not exist`), want: false},
{name: "different reason", err: errors.New(`NOT_FOUND: queue "default" is unavailable`), want: false},
{name: "empty queue name", err: errors.New(`NOT_FOUND: queue "" does not exist`), want: false},
{name: "trailing context", err: errors.New(`NOT_FOUND: queue "default" does not exist: retry later`), want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isAsynqQueueNotFound(tt.err); got != tt.want {
t.Fatalf("isAsynqQueueNotFound(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
@@ -0,0 +1,348 @@
package router
import (
"context"
"errors"
"fmt"
"testing"
"time"
"github.com/Tencent/WeKnora/internal/types"
"github.com/alicebob/miniredis/v2"
"github.com/hibiken/asynq"
"github.com/redis/go-redis/v9"
)
func TestMatchesKnowledgeBase(t *testing.T) {
knowledgeIDs := map[string]struct{}{
"knowledge-1": {},
"knowledge-2": {},
}
dataSourceIDs := map[string]struct{}{"datasource-1": {}}
tests := []struct {
name string
taskType string
payload string
want bool
}{
{name: "knowledge base id", taskType: types.TypeDocumentProcess, payload: `{"knowledge_base_id":"kb-1"}`, want: true},
{name: "legacy kb id", taskType: types.TypeFAQImport, payload: `{"kb_id":"kb-1"}`, want: true},
{name: "clone source", taskType: types.TypeKBClone, payload: `{"source_id":"kb-1"}`, want: true},
{name: "clone target", taskType: types.TypeKBClone, payload: `{"target_id":"kb-1"}`, want: true},
{name: "source id is task specific", taskType: types.TypeDocumentProcess, payload: `{"source_id":"kb-1"}`},
{name: "move source", taskType: types.TypeKnowledgeMove, payload: `{"source_kb_id":"kb-1"}`, want: true},
{name: "move target", taskType: types.TypeKnowledgeMove, payload: `{"target_kb_id":"kb-1"}`, want: true},
{name: "move fields are task specific", taskType: types.TypeDocumentProcess, payload: `{"source_kb_id":"kb-1"}`},
{name: "single knowledge", taskType: types.TypeChunkExtract, payload: `{"knowledge_id":"knowledge-1"}`, want: true},
{
name: "knowledge collection",
taskType: types.TypeKnowledgeListReparse,
payload: `{"knowledge_ids":["other","knowledge-2"]}`,
want: true,
},
{
name: "unrelated",
taskType: types.TypeDocumentProcess,
payload: `{"knowledge_base_id":"other","knowledge_id":"other"}`,
},
{name: "malformed payload", taskType: types.TypeDocumentProcess, payload: `{`},
{name: "preserve kb delete by kb", taskType: types.TypeKBDelete, payload: `{"knowledge_base_id":"kb-1"}`},
{name: "preserve kb delete by knowledge", taskType: types.TypeKBDelete, payload: `{"knowledge_id":"knowledge-1"}`},
{name: "preserve index delete by kb", taskType: types.TypeIndexDelete, payload: `{"knowledge_base_id":"kb-1"}`},
{
name: "preserve index delete by knowledge", taskType: types.TypeIndexDelete,
payload: `{"knowledge_id":"knowledge-1"}`,
},
{
name: "data source sync", taskType: types.TypeDataSourceSync,
payload: `{"data_source_id":"datasource-1"}`, want: true,
},
{
name: "other data source sync", taskType: types.TypeDataSourceSync,
payload: `{"data_source_id":"datasource-other"}`,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := matchesKnowledgeBase(
test.taskType, []byte(test.payload), "kb-1", knowledgeIDs, dataSourceIDs,
)
if got != test.want {
t.Fatalf("matchesKnowledgeBase() = %v, want %v", got, test.want)
}
})
}
}
func TestMatchesKnowledgePreservesPerKnowledgeAllowList(t *testing.T) {
if !matchesKnowledge(types.TypeDocumentProcess, []byte(`{"knowledge_id":"knowledge-1"}`), "knowledge-1") {
t.Fatal("document task should remain cancellable by knowledge ID")
}
if matchesKnowledge(types.TypeKBClone, []byte(`{"knowledge_id":"knowledge-1"}`), "knowledge-1") {
t.Fatal("KB clone must not become cancellable through the per-knowledge API")
}
}
func TestCancelTasksForKnowledgeBaseRescansMutatedPages(t *testing.T) {
server := miniredis.RunT(t)
redisClient := redis.NewClient(&redis.Options{Addr: server.Addr()})
asynqClient := asynq.NewClientFromRedisClient(redisClient)
t.Cleanup(func() {
_ = asynqClient.Close()
_ = redisClient.Close()
})
inspector := &asynqTaskInspector{
inspector: asynq.NewInspectorFromRedisClient(redisClient),
redis: redisClient,
}
const matchingPending = 205
for i := 0; i < matchingPending; i++ {
enqueueTask(t, asynqClient, types.TypeDocumentProcess,
fmt.Sprintf(`{"knowledge_base_id":"kb-delete","knowledge_id":"knowledge-%d"}`, i),
fmt.Sprintf("matching-%03d", i),
)
}
for i := 0; i < 5; i++ {
enqueueTask(t, asynqClient, types.TypeDocumentProcess,
`{"knowledge_base_id":"kb-keep","knowledge_id":"keep"}`,
fmt.Sprintf("survivor-%03d", i),
)
}
enqueueTask(t, asynqClient, types.TypeKBDelete,
`{"knowledge_base_id":"kb-delete"}`, "kb-delete-cleanup",
)
enqueueTask(t, asynqClient, types.TypeIndexDelete,
`{"knowledge_base_id":"kb-delete"}`, "index-delete-cleanup",
)
enqueueTask(t, asynqClient, types.TypeDataSourceSync,
`{"data_source_id":"datasource-delete"}`, "datasource-sync",
)
scheduleTask(t, asynqClient, types.TypeFAQImport,
`{"kb_id":"kb-delete"}`, "scheduled-kb-match",
)
scheduleTask(t, asynqClient, types.TypeKnowledgeListReparse,
`{"knowledge_ids":["knowledge-associated"]}`, "scheduled-knowledge-match",
)
scheduleTask(t, asynqClient, types.TypeFAQImport,
`{"kb_id":"kb-keep"}`, "scheduled-survivor",
)
deleted, cancelled, err := inspector.CancelTasksForKnowledgeBase(
context.Background(), "kb-delete", []string{"knowledge-associated"}, []string{"datasource-delete"},
)
if err != nil {
t.Fatalf("cancel tasks: %v", err)
}
if want := matchingPending + 3; deleted != want {
t.Fatalf("deleted = %d, want %d", deleted, want)
}
if cancelled != 0 {
t.Fatalf("cancelled active = %d, want 0", cancelled)
}
pending, err := inspector.inspector.ListPendingTasks(
types.QueueDefault, asynq.PageSize(listPageSize), asynq.Page(1),
)
if err != nil {
t.Fatalf("list pending: %v", err)
}
if len(pending) != 7 {
t.Fatalf("pending survivors = %d, want 7", len(pending))
}
if !hasTaskID(pending, "kb-delete-cleanup") {
t.Fatal("kb:delete cleanup task was removed")
}
if !hasTaskID(pending, "index-delete-cleanup") {
t.Fatal("index:delete cleanup task was removed")
}
scheduled, err := inspector.inspector.ListScheduledTasks(
types.QueueDefault, asynq.PageSize(listPageSize), asynq.Page(1),
)
if err != nil {
t.Fatalf("list scheduled: %v", err)
}
if len(scheduled) != 1 || scheduled[0].ID != "scheduled-survivor" {
t.Fatalf("scheduled survivors = %v, want scheduled-survivor", taskIDs(scheduled))
}
}
func TestCancelTasksForKnowledgeBaseRemovesCancelledActiveRetry(t *testing.T) {
server := miniredis.RunT(t)
redisClient := redis.NewClient(&redis.Options{Addr: server.Addr()})
asynqClient := asynq.NewClientFromRedisClient(redisClient)
t.Cleanup(func() {
_ = asynqClient.Close()
_ = redisClient.Close()
})
inspector := &asynqTaskInspector{
inspector: asynq.NewInspectorFromRedisClient(redisClient),
redis: redisClient,
}
worker := asynq.NewServerFromRedisClient(redisClient, asynq.Config{
Concurrency: 1,
Queues: map[string]int{types.QueueDefault: 1},
TaskCheckInterval: 10 * time.Millisecond,
DelayedTaskCheckInterval: time.Hour,
RetryDelayFunc: func(_ int, _ error, _ *asynq.Task) time.Duration {
return time.Hour
},
ShutdownTimeout: time.Second,
LogLevel: asynq.FatalLevel,
})
handlerStarted := make(chan struct{})
handlerReturned := make(chan struct{})
mux := asynq.NewServeMux()
mux.HandleFunc(types.TypeDocumentProcess, func(ctx context.Context, _ *asynq.Task) error {
close(handlerStarted)
<-ctx.Done()
close(handlerReturned)
return ctx.Err()
})
if err := worker.Start(mux); err != nil {
t.Fatalf("start asynq worker: %v", err)
}
t.Cleanup(worker.Shutdown)
waitForAsynqCancellationSubscriber(t, redisClient)
const taskID = "active-kb-match"
if _, err := asynqClient.Enqueue(
asynq.NewTask(types.TypeDocumentProcess, []byte(`{"knowledge_base_id":"kb-delete"}`)),
asynq.Queue(types.QueueDefault),
asynq.TaskID(taskID),
asynq.MaxRetry(3),
); err != nil {
t.Fatalf("enqueue active task: %v", err)
}
select {
case <-handlerStarted:
case <-time.After(5 * time.Second):
t.Fatal("handler did not start")
}
waitForTaskState(t, inspector.inspector, taskID, asynq.TaskStateActive)
deleted, cancelled, err := inspector.CancelTasksForKnowledgeBase(
context.Background(), "kb-delete", nil, nil,
)
if err != nil {
t.Fatalf("cancel active task: %v", err)
}
if cancelled != 1 {
t.Fatalf("cancelled active = %d, want 1", cancelled)
}
if deleted != 1 {
t.Fatalf("deleted after active cancellation = %d, want 1", deleted)
}
select {
case <-handlerReturned:
case <-time.After(5 * time.Second):
t.Fatal("cancelled handler did not return")
}
waitForTaskToLeaveLiveStates(t, inspector.inspector, taskID)
}
func enqueueTask(t *testing.T, client *asynq.Client, taskType, payload, taskID string) {
t.Helper()
if _, err := client.Enqueue(
asynq.NewTask(taskType, []byte(payload)),
asynq.Queue(types.QueueDefault),
asynq.TaskID(taskID),
); err != nil {
t.Fatalf("enqueue %s: %v", taskID, err)
}
}
func scheduleTask(t *testing.T, client *asynq.Client, taskType, payload, taskID string) {
t.Helper()
if _, err := client.Enqueue(
asynq.NewTask(taskType, []byte(payload)),
asynq.Queue(types.QueueDefault),
asynq.TaskID(taskID),
asynq.ProcessAt(time.Now().Add(time.Hour)),
); err != nil {
t.Fatalf("schedule %s: %v", taskID, err)
}
}
func hasTaskID(tasks []*asynq.TaskInfo, taskID string) bool {
for _, task := range tasks {
if task.ID == taskID {
return true
}
}
return false
}
func taskIDs(tasks []*asynq.TaskInfo) []string {
ids := make([]string, 0, len(tasks))
for _, task := range tasks {
ids = append(ids, task.ID)
}
return ids
}
func waitForTaskState(t *testing.T, inspector *asynq.Inspector, taskID string, want asynq.TaskState) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
var lastState asynq.TaskState
for time.Now().Before(deadline) {
task, err := inspector.GetTaskInfo(types.QueueDefault, taskID)
if err == nil {
lastState = task.State
if task.State == want {
return
}
} else if !errors.Is(err, asynq.ErrTaskNotFound) {
t.Fatalf("get task %s: %v", taskID, err)
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("task %s state = %v, want %v", taskID, lastState, want)
}
func waitForAsynqCancellationSubscriber(t *testing.T, redisClient *redis.Client) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
subscribers, err := redisClient.PubSubNumSub(context.Background(), "asynq:cancel").Result()
if err != nil {
t.Fatalf("query cancellation subscribers: %v", err)
}
if subscribers["asynq:cancel"] > 0 {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("asynq cancellation subscriber did not start")
}
func waitForTaskToLeaveLiveStates(t *testing.T, inspector *asynq.Inspector, taskID string) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
var lastState asynq.TaskState
for time.Now().Before(deadline) {
task, err := inspector.GetTaskInfo(types.QueueDefault, taskID)
if errors.Is(err, asynq.ErrTaskNotFound) {
return
}
if err != nil {
t.Fatalf("get task %s: %v", taskID, err)
}
lastState = task.State
switch task.State {
case asynq.TaskStatePending, asynq.TaskStateScheduled, asynq.TaskStateRetry, asynq.TaskStateActive:
time.Sleep(10 * time.Millisecond)
default:
return
}
}
t.Fatalf("task %s remained in live state %v", taskID, lastState)
}
@@ -0,0 +1,293 @@
package router
import (
"bytes"
"context"
"encoding/json"
"os"
"strings"
"testing"
"time"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/alicebob/miniredis/v2"
"github.com/hibiken/asynq"
"github.com/redis/go-redis/v9"
)
func TestQueueStatsDoesNotWarnForQueuesThatDoNotExist(t *testing.T) {
server := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
t.Cleanup(func() { _ = client.Close() })
inspector := &asynqTaskInspector{
inspector: asynq.NewInspectorFromRedisClient(client),
redis: client,
}
var logs bytes.Buffer
logger.SetOutput(&logs)
t.Cleanup(func() { logger.SetOutput(os.Stdout) })
stats, supported, err := inspector.QueueStats(context.Background())
if err != nil || !supported {
t.Fatalf("QueueStats: supported=%v err=%v", supported, err)
}
if got, want := len(stats), len(types.QueueDefinitions()); got != want {
t.Fatalf("QueueStats returned %d rows, want %d", got, want)
}
for _, stat := range stats {
if stat.Size != 0 || stat.Pending != 0 || stat.Active != 0 {
t.Fatalf("missing queue %q should have zero depth: %+v", stat.Name, stat)
}
}
if strings.Contains(logs.String(), "[TaskInspector] queue info") {
t.Fatalf("missing queues should not emit warnings:\n%s", logs.String())
}
}
func TestProjectRuntimeTaskRedactsPayloadAndBuildsSafeActions(t *testing.T) {
payload, err := json.Marshal(map[string]any{
"tenant_id": 42,
"knowledge_base_id": "kb-1",
"knowledge_id": "knowledge-1",
"file_url": "secret://signed-document-url",
})
if err != nil {
t.Fatal(err)
}
started := time.Unix(1_700_000_000, 0)
info, err := projectRuntimeTask(&asynq.TaskInfo{
ID: "task-1", Queue: types.QueueDefault, Type: types.TypeDocumentProcess,
Payload: payload, State: asynq.TaskStateActive, MaxRetry: 3, Retried: 1,
}, runtimeWorkerMetadata{started: started, worker: "worker-a:123"})
if err != nil {
t.Fatalf("project task: %v", err)
}
if info.State != types.RuntimeTaskActive || info.TenantID != 42 ||
info.KnowledgeBaseID != "kb-1" || info.KnowledgeID != "knowledge-1" {
t.Fatalf("safe routing metadata missing: %+v", info)
}
if info.StartedAt == nil || !info.StartedAt.Equal(started) || info.Worker != "worker-a:123" {
t.Fatalf("worker metadata missing: %+v", info)
}
if len(info.AllowedActions) != 1 || info.AllowedActions[0] != types.RuntimeTaskActionCancel {
t.Fatalf("active document actions = %v", info.AllowedActions)
}
}
func TestProjectRuntimeTaskActionsFollowCurrentState(t *testing.T) {
payload := []byte(`{"tenant_id":7,"knowledge_id":"knowledge-7"}`)
cases := []struct {
state asynq.TaskState
want []types.RuntimeTaskAction
}{
{asynq.TaskStateScheduled, []types.RuntimeTaskAction{types.RuntimeTaskActionCancel, types.RuntimeTaskActionRunNow}},
{asynq.TaskStateRetry, []types.RuntimeTaskAction{types.RuntimeTaskActionCancel, types.RuntimeTaskActionRunNow}},
{asynq.TaskStateArchived, []types.RuntimeTaskAction{types.RuntimeTaskActionRunNow, types.RuntimeTaskActionDelete}},
{asynq.TaskStateCompleted, []types.RuntimeTaskAction{}},
}
for _, tc := range cases {
info, err := projectRuntimeTask(&asynq.TaskInfo{
ID: "task", Queue: types.QueueDefault, Type: types.TypeDocumentProcess,
Payload: payload, State: tc.state,
}, runtimeWorkerMetadata{})
if err != nil {
t.Fatalf("state %v: %v", tc.state, err)
}
if len(info.AllowedActions) != len(tc.want) {
t.Fatalf("state %v actions = %v, want %v", tc.state, info.AllowedActions, tc.want)
}
for i := range tc.want {
if info.AllowedActions[i] != tc.want[i] {
t.Fatalf("state %v actions = %v, want %v", tc.state, info.AllowedActions, tc.want)
}
}
}
}
func TestProjectRuntimeTaskUsesAllowListedBatchMetadata(t *testing.T) {
payload := []byte(`{
"tenant_id":9,
"task_id":"move-1",
"source_kb_id":"source-kb",
"target_kb_id":"target-kb",
"knowledge_ids":["a","b"],
"created_at":1700000000,
"content":"must-not-be-projected"
}`)
info, err := projectRuntimeTask(&asynq.TaskInfo{
ID: "task-move", Queue: types.QueueMaintenance, Type: types.TypeKnowledgeMove,
Payload: payload, State: asynq.TaskStatePending,
}, runtimeWorkerMetadata{})
if err != nil {
t.Fatal(err)
}
if info.TaskID != "move-1" || info.SourceKBID != "source-kb" ||
info.TargetKBID != "target-kb" || info.KnowledgeCount != 2 || info.EnqueuedAt == nil {
t.Fatalf("batch projection mismatch: %+v", info)
}
if len(info.AllowedActions) != 0 {
t.Fatalf("generic maintenance task must not expose raw deletion: %v", info.AllowedActions)
}
}
func TestRuntimeTaskCursorUsesStateTimeOrderAndSurvivingAnchors(t *testing.T) {
server := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
t.Cleanup(func() { _ = client.Close() })
inspector := &asynqTaskInspector{redis: client}
ctx := context.Background()
pendingKey, _ := runtimeTaskStateKey(types.QueueDefault, types.RuntimeTaskPending)
if err := client.LPush(ctx, pendingKey, "old", "middle", "new").Err(); err != nil {
t.Fatal(err)
}
ids, err := inspector.listRuntimeTaskIDs(ctx, types.QueueDefault, types.RuntimeTaskPending, nil, 2)
if err != nil {
t.Fatal(err)
}
if got, want := ids, []string{"new", "middle"}; !equalStrings(got, want) {
t.Fatalf("pending order = %v, want %v", got, want)
}
if err = client.LRem(ctx, pendingKey, 0, "middle").Err(); err != nil {
t.Fatal(err)
}
ids, err = inspector.listRuntimeTaskIDs(
ctx, types.QueueDefault, types.RuntimeTaskPending, []string{"new", "middle"}, 2,
)
if err != nil {
t.Fatal(err)
}
if got, want := ids, []string{"old"}; !equalStrings(got, want) {
t.Fatalf("anchor fallback order = %v, want %v", got, want)
}
scheduledKey, _ := runtimeTaskStateKey(types.QueueDefault, types.RuntimeTaskScheduled)
if err = client.ZAdd(ctx, scheduledKey,
redis.Z{Score: 30, Member: "later"},
redis.Z{Score: 10, Member: "next"},
redis.Z{Score: 20, Member: "after-next"},
).Err(); err != nil {
t.Fatal(err)
}
ids, err = inspector.listRuntimeTaskIDs(ctx, types.QueueDefault, types.RuntimeTaskScheduled, nil, 3)
if err != nil {
t.Fatal(err)
}
if got, want := ids, []string{"next", "after-next", "later"}; !equalStrings(got, want) {
t.Fatalf("scheduled order = %v, want %v", got, want)
}
archivedKey, _ := runtimeTaskStateKey(types.QueueDefault, types.RuntimeTaskArchived)
if err = client.ZAdd(ctx, archivedKey,
redis.Z{Score: 10, Member: "old-failure"},
redis.Z{Score: 30, Member: "new-failure"},
redis.Z{Score: 20, Member: "middle-failure"},
).Err(); err != nil {
t.Fatal(err)
}
ids, err = inspector.listRuntimeTaskIDs(ctx, types.QueueDefault, types.RuntimeTaskArchived, nil, 3)
if err != nil {
t.Fatal(err)
}
if got, want := ids, []string{"new-failure", "middle-failure", "old-failure"}; !equalStrings(got, want) {
t.Fatalf("archived order = %v, want %v", got, want)
}
}
func TestRuntimeTaskCursorIsBoundToQueueAndState(t *testing.T) {
raw, err := encodeRuntimeTaskCursor(
types.QueueDefault,
types.RuntimeTaskArchived,
[]string{"task-1", "task-2"},
)
if err != nil {
t.Fatal(err)
}
anchors, err := decodeRuntimeTaskCursor(raw, types.QueueDefault, types.RuntimeTaskArchived)
if err != nil {
t.Fatal(err)
}
if !equalStrings(anchors, []string{"task-1", "task-2"}) {
t.Fatalf("anchors = %v", anchors)
}
if _, err = decodeRuntimeTaskCursor(raw, types.QueueDefault, types.RuntimeTaskRetry); err == nil {
t.Fatal("cursor from another state should be rejected")
}
if _, err = decodeRuntimeTaskCursor("not-base64", types.QueueDefault, types.RuntimeTaskArchived); err == nil {
t.Fatal("malformed cursor should be rejected")
}
}
func TestListRuntimeTasksPaginatesNewestPendingTasksWithoutOverlap(t *testing.T) {
server := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
t.Cleanup(func() { _ = client.Close() })
asynqClient := asynq.NewClientFromRedisClient(client)
inspector := &asynqTaskInspector{
inspector: asynq.NewInspectorFromRedisClient(client),
redis: client,
}
for _, id := range []string{"old", "middle", "new"} {
_, err := asynqClient.Enqueue(
asynq.NewTask(types.TypeDocumentProcess, []byte(`{"tenant_id":1,"knowledge_id":"knowledge-1"}`)),
asynq.Queue(types.QueueDefault),
asynq.TaskID(id),
)
if err != nil {
t.Fatalf("enqueue %s: %v", id, err)
}
}
pendingKey, _ := runtimeTaskStateKey(types.QueueDefault, types.RuntimeTaskPending)
if err := client.LInsert(context.Background(), pendingKey, "AFTER", "new", "already-gone").Err(); err != nil {
t.Fatalf("insert stale task id: %v", err)
}
first, supported, err := inspector.ListRuntimeTasks(
context.Background(), types.QueueDefault, types.RuntimeTaskPending, "", 2,
)
if err != nil || !supported {
t.Fatalf("first page: supported=%v err=%v", supported, err)
}
if got, want := runtimeTaskIDs(first.Tasks), []string{"new", "middle"}; !equalStrings(got, want) {
t.Fatalf("first page = %v, want %v", got, want)
}
if !first.HasMore || first.NextCursor == "" {
t.Fatalf("first page cursor missing: %+v", first)
}
second, supported, err := inspector.ListRuntimeTasks(
context.Background(), types.QueueDefault, types.RuntimeTaskPending, first.NextCursor, 2,
)
if err != nil || !supported {
t.Fatalf("second page: supported=%v err=%v", supported, err)
}
if got, want := runtimeTaskIDs(second.Tasks), []string{"old"}; !equalStrings(got, want) {
t.Fatalf("second page = %v, want %v", got, want)
}
if second.HasMore || second.NextCursor != "" {
t.Fatalf("unexpected continuation after final page: %+v", second)
}
}
func runtimeTaskIDs(tasks []types.RuntimeTaskInfo) []string {
ids := make([]string, 0, len(tasks))
for _, task := range tasks {
ids = append(ids, task.ID)
}
return ids
}
func equalStrings(left, right []string) bool {
if len(left) != len(right) {
return false
}
for i := range left {
if left[i] != right[i] {
return false
}
}
return true
}