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
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:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user