mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-11-10 21:32:43 +08:00
feat: migrate resource checks to permission layer (#20792)
This commit is contained in:
1 parent
a8216e5f38
commit
8a95d55d63
174 files changed
+7385
-2951
No files matched your search
@@ -1117,7 +1117,7 @@ func startServer(ctx context.Context, serverName string, arguments *serverArgs)
|
||||
connectorService := service.NewConnectorService()
|
||||
searchService := service.NewSearchService()
|
||||
searchService.SetTenantService(tenantService)
|
||||
fileService := file.NewFileService(service.CheckFileTeamPermission, documentService)
|
||||
fileService := file.NewFileService(documentService)
|
||||
memoryService := service.NewMemoryService()
|
||||
mcpService := service.NewMCPService()
|
||||
modelProviderService := service.NewModelProviderService()
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
einotool "github.com/cloudwego/eino/components/tool"
|
||||
@@ -13,10 +14,11 @@ import (
|
||||
"ragflow/internal/agent/runtime"
|
||||
agenttool "ragflow/internal/agent/tool"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/permission"
|
||||
"ragflow/internal/utility"
|
||||
)
|
||||
|
||||
// buildAgentMCPTools binds the saved tool selection to tenant-owned servers.
|
||||
// buildAgentMCPTools binds the saved tool selection to servers the canvas can use.
|
||||
// Discovery happens when configuring the server; execution uses those schemas.
|
||||
func buildAgentMCPTools(ctx context.Context, db *gorm.DB, raw any, timeout time.Duration) ([]einotool.BaseTool, error) {
|
||||
if raw == nil {
|
||||
@@ -43,14 +45,31 @@ func buildAgentMCPTools(ctx context.Context, db *gorm.DB, raw any, timeout time.
|
||||
if err != nil || state == nil || db == nil {
|
||||
return nil, fmt.Errorf("MCP server %q: missing runtime tenant or database", selection.ID)
|
||||
}
|
||||
tenantID, _ := state.Sys["tenant_id"].(string)
|
||||
if tenantID == "" {
|
||||
return nil, fmt.Errorf("MCP server %q: missing runtime tenant", selection.ID)
|
||||
userID, _ := state.Sys["user_id"].(string)
|
||||
canvasID, _ := state.Sys["canvas_id"].(string)
|
||||
if strings.TrimSpace(userID) == "" || strings.TrimSpace(canvasID) == "" {
|
||||
return nil, fmt.Errorf("MCP server %q: missing runtime user or canvas", selection.ID)
|
||||
}
|
||||
server, err := dao.NewMCPServerDAO().GetByIDAndTenant(ctx, db, selection.ID, tenantID)
|
||||
tenantID, _ := state.Sys["tenant_id"].(string)
|
||||
checker := permission.NewDatabaseChecker(db)
|
||||
err = checker.CheckDependency(
|
||||
ctx,
|
||||
permission.Subject{UserID: userID, TenantID: tenantID},
|
||||
permission.ResourceRef{Kind: permission.ResourceKindCanvas, ID: canvasID},
|
||||
permission.ResourceRef{Kind: permission.ResourceKindMCPServer, ID: selection.ID},
|
||||
permission.OperationRun,
|
||||
permission.OperationUse,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("MCP server %q: authorize canvas dependency: %w", selection.ID, err)
|
||||
}
|
||||
server, err := dao.NewMCPServerDAO().GetByID(ctx, db, selection.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load MCP server %q: %w", selection.ID, err)
|
||||
}
|
||||
if server == nil {
|
||||
return nil, fmt.Errorf("load MCP server %q: %w", selection.ID, permission.ErrResourceNotFound)
|
||||
}
|
||||
headers := make(map[string]string, len(server.Headers))
|
||||
for key, value := range server.Headers {
|
||||
s, ok := value.(string)
|
||||
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/cloudwego/eino/compose"
|
||||
"github.com/cloudwego/eino/flow/agent/react"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
@@ -81,8 +83,11 @@ func TestAgentMCPModelDispatch(t *testing.T) {
|
||||
if err := db.Create(&entity.MCPServer{ID: "mcp", TenantID: "tenant", Name: "route server", URL: mcp.URL, ServerType: utility.TransportStreamableHTTP, Headers: entity.JSONMap{"Authorization": "Bearer ${authorization_token}"}, Variables: entity.JSONMap{"authorization_token": authToken}}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addAgentMCPPermissionFixtures(t, db)
|
||||
state := runtime.NewCanvasState("run", "task")
|
||||
state.Sys["tenant_id"] = "tenant"
|
||||
state.Sys["user_id"] = "tenant"
|
||||
state.Sys["canvas_id"] = "canvas"
|
||||
ctx := withStateForTest(t.Context(), state)
|
||||
p := mergeAgentParam(AgentParam{}, map[string]any{"mcp": []any{map[string]any{"mcp_id": "mcp", "tools": map[string]any{"route": map[string]any{"name": "route", "inputSchema": map[string]any{"type": "object", "properties": map[string]any{"origin": map[string]any{"type": "string"}}, "required": []string{"origin"}}}}}}})
|
||||
tools, err := buildAgentTools(ctx, p)
|
||||
@@ -158,9 +163,10 @@ func TestAgentMCPModelDispatch(t *testing.T) {
|
||||
if !dispatched.Load() || content != "metro answer" {
|
||||
t.Fatalf("dispatch=%v content=%q", dispatched.Load(), content)
|
||||
}
|
||||
state.Sys["user_id"] = "outsider"
|
||||
state.Sys["tenant_id"] = "other-tenant"
|
||||
if _, err := buildAgentTools(ctx, p); err == nil {
|
||||
t.Fatal("cross-tenant MCP access allowed")
|
||||
t.Fatal("MCP dependency access without canvas permission was allowed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,8 +181,11 @@ func TestAgentMCPToolNamesDoNotCollide(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
addAgentMCPPermissionFixtures(t, db)
|
||||
state := runtime.NewCanvasState("run", "task")
|
||||
state.Sys["tenant_id"] = "tenant"
|
||||
state.Sys["user_id"] = "tenant"
|
||||
state.Sys["canvas_id"] = "canvas"
|
||||
ctx := withStateForTest(t.Context(), state)
|
||||
p := AgentParam{Tools: nil, MCP: []any{
|
||||
map[string]any{"mcp_id": "first", "tools": map[string]any{"search": map[string]any{"name": "web_search"}}},
|
||||
@@ -200,3 +209,19 @@ func TestAgentMCPToolNamesDoNotCollide(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addAgentMCPPermissionFixtures(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
active := string(entity.StatusValid)
|
||||
if err := db.Create(&entity.UserTenant{
|
||||
ID: "mcp-owner-membership", UserID: "tenant", TenantID: "tenant",
|
||||
Role: "owner", InvitedBy: "tenant", Status: &active,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create MCP owner membership: %v", err)
|
||||
}
|
||||
if err := db.Create(&entity.UserCanvas{
|
||||
ID: "canvas", UserID: "tenant", Permission: string(entity.TenantPermissionMe),
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create canvas permission fixture: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -717,6 +717,8 @@ func setupComponentTestDB(t *testing.T) *gorm.DB {
|
||||
&entity.TenantModelProvider{},
|
||||
&entity.TenantModelInstance{},
|
||||
&entity.TenantModel{},
|
||||
&entity.UserTenant{},
|
||||
&entity.UserCanvas{},
|
||||
); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestCheckCanvasRetrievalDependencies(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&entity.UserCanvas{}, &entity.Knowledgebase{}, &entity.UserTenant{}); err != nil {
|
||||
if err := db.AutoMigrate(&entity.UserCanvas{}, &entity.Knowledgebase{}, &entity.Memory{}, &entity.UserTenant{}); err != nil {
|
||||
t.Fatalf("migrate permission tables: %v", err)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,19 @@ func TestCheckCanvasRetrievalDependencies(t *testing.T) {
|
||||
ID: "private-dataset", TenantID: "other-owner", CreatedBy: "other-owner",
|
||||
Permission: string(entity.TenantPermissionMe), Status: &valid,
|
||||
}
|
||||
for _, row := range []any{&canvas, &dataset, &privateDataset} {
|
||||
sharedMemory := entity.Memory{
|
||||
ID: "shared-memory", Name: "Shared", TenantID: "canvas-owner",
|
||||
Permissions: string(entity.TenantPermissionTeam),
|
||||
}
|
||||
callerOnlyMemory := entity.Memory{
|
||||
ID: "caller-only-memory", Name: "Caller only", TenantID: "caller",
|
||||
Permissions: string(entity.TenantPermissionMe),
|
||||
}
|
||||
ownerOnlyMemory := entity.Memory{
|
||||
ID: "owner-only-memory", Name: "Owner only", TenantID: "canvas-owner",
|
||||
Permissions: string(entity.TenantPermissionMe),
|
||||
}
|
||||
for _, row := range []any{&canvas, &dataset, &privateDataset, &sharedMemory, &callerOnlyMemory, &ownerOnlyMemory} {
|
||||
if err := db.Create(row).Error; err != nil {
|
||||
t.Fatalf("create permission resource: %v", err)
|
||||
}
|
||||
@@ -59,6 +71,28 @@ func TestCheckCanvasRetrievalDependencies(t *testing.T) {
|
||||
if err := checkCanvasRetrievalDependencies(ctx, db, map[string]any{"dataset_ids": []string{privateDataset.ID}}); !errors.Is(err, permission.ErrPermissionDenied) {
|
||||
t.Fatalf("check private dataset dependency error = %v, want permission denied", err)
|
||||
}
|
||||
if err := checkCanvasRetrievalDependencies(ctx, db, map[string]any{"memory_ids": []string{sharedMemory.ID}}); err != nil {
|
||||
t.Fatalf("check shared memory dependency: %v", err)
|
||||
}
|
||||
if err := checkCanvasRetrievalDependencies(ctx, db, map[string]any{
|
||||
"retrieval_from": "memory",
|
||||
"dataset_ids": []string{privateDataset.ID},
|
||||
"memory_ids": []string{sharedMemory.ID},
|
||||
}); err != nil {
|
||||
t.Fatalf("memory retrieval should ignore inactive dataset bindings: %v", err)
|
||||
}
|
||||
if err := checkCanvasRetrievalDependencies(ctx, db, map[string]any{
|
||||
"retrieval_from": "dataset",
|
||||
"dataset_ids": []string{dataset.ID},
|
||||
"memory_ids": []string{ownerOnlyMemory.ID},
|
||||
}); err != nil {
|
||||
t.Fatalf("dataset retrieval should ignore inactive memory bindings: %v", err)
|
||||
}
|
||||
for _, memoryID := range []string{callerOnlyMemory.ID, ownerOnlyMemory.ID} {
|
||||
if err := checkCanvasRetrievalDependencies(ctx, db, map[string]any{"memory_ids": []string{memoryID}}); !errors.Is(err, permission.ErrPermissionDenied) {
|
||||
t.Fatalf("check non-intersection memory %q error = %v, want permission denied", memoryID, err)
|
||||
}
|
||||
}
|
||||
|
||||
missingCanvasState := runtime.NewCanvasState("run-2", "session-2")
|
||||
missingCanvasState.Sys["user_id"] = "caller"
|
||||
|
||||
@@ -246,35 +246,27 @@ func (c *retrievalComponent) Invoke(ctx context.Context, db *gorm.DB, inputs map
|
||||
}
|
||||
|
||||
func checkCanvasRetrievalDependencies(ctx context.Context, db *gorm.DB, inputs map[string]any) error {
|
||||
state, err := runtime.GetStateFromContext(ctx)
|
||||
if err != nil || state == nil {
|
||||
return nil
|
||||
}
|
||||
canvasID, _ := state.Sys["canvas_id"].(string)
|
||||
userID, _ := state.Sys["user_id"].(string)
|
||||
if canvasID == "" && userID == "" {
|
||||
return nil
|
||||
}
|
||||
if canvasID == "" {
|
||||
return fmt.Errorf("%w: canvas id missing from runtime state", permission.ErrInvalidPermission)
|
||||
}
|
||||
if userID == "" {
|
||||
return permission.ErrUnauthenticated
|
||||
}
|
||||
|
||||
checker := permission.NewDatabaseChecker(db)
|
||||
entry := permission.ResourceRef{Kind: permission.ResourceKindCanvas, ID: canvasID}
|
||||
for _, datasetID := range toStringSlice(inputs["dataset_ids"]) {
|
||||
datasetID = strings.TrimSpace(datasetID)
|
||||
if datasetID == "" {
|
||||
continue
|
||||
if state, err := runtime.GetStateFromContext(ctx); err == nil && state != nil {
|
||||
canvasID, _ := state.Sys["canvas_id"].(string)
|
||||
userID, _ := state.Sys["user_id"].(string)
|
||||
if strings.TrimSpace(canvasID) == "" && strings.TrimSpace(userID) != "" {
|
||||
return fmt.Errorf("%w: canvas id missing from runtime state", permission.ErrInvalidPermission)
|
||||
}
|
||||
dependency := permission.ResourceRef{Kind: permission.ResourceKindDataset, ID: datasetID}
|
||||
if err := checker.CheckDependency(ctx, permission.Subject{UserID: userID}, entry, dependency, permission.OperationRun, permission.OperationUse); err != nil {
|
||||
return fmt.Errorf("dataset %q: %w", datasetID, err)
|
||||
if strings.TrimSpace(canvasID) != "" && strings.TrimSpace(userID) == "" {
|
||||
return permission.ErrUnauthenticated
|
||||
}
|
||||
}
|
||||
return nil
|
||||
datasetIDs := toStringSlice(inputs["dataset_ids"])
|
||||
memoryIDs := toStringSlice(inputs["memory_ids"])
|
||||
retrievalFrom, _ := inputs["retrieval_from"].(string)
|
||||
switch {
|
||||
case strings.EqualFold(strings.TrimSpace(retrievalFrom), "memory"):
|
||||
return agenttool.CheckCanvasDependencies(ctx, db, permission.ResourceKindMemory, memoryIDs)
|
||||
case strings.TrimSpace(retrievalFrom) == "" && len(datasetIDs) == 0 && len(memoryIDs) > 0:
|
||||
return agenttool.CheckCanvasDependencies(ctx, db, permission.ResourceKindMemory, memoryIDs)
|
||||
default:
|
||||
return agenttool.CheckCanvasDependencies(ctx, db, permission.ResourceKindDataset, datasetIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *retrievalComponent) Stream(_ context.Context, _ *gorm.DB, _ map[string]any) (<-chan map[string]any, error) {
|
||||
|
||||
@@ -21,7 +21,9 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"ragflow/internal/agent/runtime"
|
||||
agenttool "ragflow/internal/agent/tool"
|
||||
"ragflow/internal/permission"
|
||||
"ragflow/internal/service"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -42,19 +44,28 @@ func NewMemoryAdapter(svc *service.MemoryService) *MemoryAdapter {
|
||||
// the common RetrievalChunk result shape.
|
||||
func (a *MemoryAdapter) Search(
|
||||
ctx context.Context,
|
||||
_ *gorm.DB,
|
||||
db *gorm.DB,
|
||||
req agenttool.RetrievalRequest,
|
||||
) ([]agenttool.RetrievalChunk, error) {
|
||||
if a == nil || a.svc == nil {
|
||||
return nil, agenttool.ErrMemoryRetrievalServiceMissing
|
||||
}
|
||||
if strings.TrimSpace(req.TenantID) == "" {
|
||||
return nil, fmt.Errorf("memory retrieval: tenant id is required")
|
||||
}
|
||||
memoryIDs := compactStrings(req.MemoryIDs)
|
||||
if len(memoryIDs) == 0 {
|
||||
return nil, fmt.Errorf("memory retrieval: memory_ids is required")
|
||||
}
|
||||
actorID := strings.TrimSpace(req.TenantID)
|
||||
if state, stateErr := runtime.GetStateFromContext(ctx); stateErr == nil && state != nil {
|
||||
if userID, _ := state.Sys["user_id"].(string); strings.TrimSpace(userID) != "" {
|
||||
actorID = strings.TrimSpace(userID)
|
||||
}
|
||||
}
|
||||
if actorID == "" {
|
||||
return nil, fmt.Errorf("memory retrieval: user id is required")
|
||||
}
|
||||
if err := agenttool.CheckCanvasDependencies(ctx, db, permission.ResourceKindMemory, memoryIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keywordWeight := 0.7
|
||||
if req.KeywordsSimilarityWeight != nil {
|
||||
keywordWeight = *req.KeywordsSimilarityWeight
|
||||
@@ -65,7 +76,7 @@ func (a *MemoryAdapter) Search(
|
||||
}
|
||||
messages, _, err := a.svc.SearchMessage(
|
||||
ctx,
|
||||
req.TenantID,
|
||||
actorID,
|
||||
filter,
|
||||
map[string]any{
|
||||
"query": req.Query,
|
||||
|
||||
@@ -82,7 +82,7 @@ type RetrievalRequest struct {
|
||||
// DocScope restricts retrieval to a set of document ids (the doc_id list
|
||||
// routed by the dataset_navigation_by_tree tool). Empty = no doc filter.
|
||||
DocScope []string
|
||||
// TenantID is the calling tenant (== user_id in RAGFlow's data model).
|
||||
// TenantID is the tenant that owns the running canvas.
|
||||
TenantID string
|
||||
// RankFeature is the label_question term→weight map passed through to the
|
||||
// engine so retrieval is biased toward the query's predicted topic class.
|
||||
@@ -154,7 +154,7 @@ type GrepRequest struct {
|
||||
Sort []string // Ordered ascending sort fields.
|
||||
// SelectFields limits the ES _source fields returned per hit.
|
||||
SelectFields []string
|
||||
TenantID string // Calling tenant (== user_id in RAGFlow's data model).
|
||||
TenantID string // Tenant that owns the running canvas.
|
||||
}
|
||||
|
||||
// Bm25Service is the lexical full-text (BM25) search surface used by
|
||||
@@ -173,7 +173,7 @@ type Bm25Request struct {
|
||||
DatasetIDs []string // Knowledge base IDs to restrict to.
|
||||
DocScope []string // Document IDs to restrict to (empty = no doc filter).
|
||||
TopN int // Max chunks per query before merging.
|
||||
TenantID string // Calling tenant (== user_id in RAGFlow's data model).
|
||||
TenantID string // Tenant that owns the running canvas.
|
||||
}
|
||||
|
||||
// ErrRetrievalServiceMissing is returned when no RetrievalService is registered.
|
||||
|
||||
@@ -18,10 +18,54 @@ package tool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"ragflow/internal/agent/runtime"
|
||||
"ragflow/internal/permission"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CheckCanvasDependencies verifies that the canvas caller and canvas owner can
|
||||
// both use every configured resource. Calls outside a canvas have no entry
|
||||
// dependency and are left to their normal resource checks.
|
||||
func CheckCanvasDependencies(ctx context.Context, db *gorm.DB, kind permission.ResourceKind, ids []string) error {
|
||||
state, err := runtime.GetStateFromContext(ctx)
|
||||
if err != nil || state == nil {
|
||||
return nil
|
||||
}
|
||||
canvasID, _ := state.Sys["canvas_id"].(string)
|
||||
userID, _ := state.Sys["user_id"].(string)
|
||||
canvasID = strings.TrimSpace(canvasID)
|
||||
userID = strings.TrimSpace(userID)
|
||||
if canvasID == "" {
|
||||
return nil
|
||||
}
|
||||
if userID == "" {
|
||||
return permission.ErrUnauthenticated
|
||||
}
|
||||
|
||||
checker := permission.NewDatabaseChecker(db)
|
||||
entry := permission.ResourceRef{Kind: permission.ResourceKindCanvas, ID: canvasID}
|
||||
seen := make(map[string]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
dependency := permission.ResourceRef{Kind: kind, ID: id}
|
||||
if err := checker.CheckDependency(ctx, permission.Subject{UserID: userID}, entry, dependency, permission.OperationRun, permission.OperationUse); err != nil {
|
||||
return fmt.Errorf("%s %q: %w", kind, id, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// canvasTenantID derives the tenant id from canvas state, falling back to
|
||||
// user_id. Shared by agentic search and dataset-navigation tools.
|
||||
func canvasTenantID(ctx context.Context) string {
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/permission"
|
||||
"ragflow/internal/service/nav"
|
||||
)
|
||||
|
||||
@@ -153,6 +154,9 @@ func (d *DatasetNavigationByTree) InvokableRun(ctx context.Context, argumentsInJ
|
||||
Error: "dataset navigation requires a tenant and dataset context",
|
||||
}), nil
|
||||
}
|
||||
if err := CheckCanvasDependencies(ctx, dao.DB, permission.ResourceKindDataset, datasetIDs); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Route RELEVANT docs by querying the nav tree with the topic (semantic KNN).
|
||||
// The topic is the routing signal — we must not return arbitrary doc ids.
|
||||
|
||||
@@ -211,6 +211,10 @@ func (a *NLPRetrievalAdapter) Search(ctx context.Context, db *gorm.DB, req Retri
|
||||
}
|
||||
userID = strings.TrimSpace(userID)
|
||||
checker := permission.NewDatabaseChecker(db)
|
||||
if err := CheckCanvasDependencies(ctx, db, permission.ResourceKindDataset, datasets.kbIDs); err != nil {
|
||||
_, permissionErr := permissionresponse.Normalize(err)
|
||||
return nil, permissionErr
|
||||
}
|
||||
for _, datasetID := range datasets.kbIDs {
|
||||
if err := checker.CheckResource(ctx, permission.Subject{UserID: userID}, permission.ResourceRef{
|
||||
Kind: permission.ResourceKindDataset, ID: datasetID,
|
||||
|
||||
@@ -25,6 +25,8 @@ import (
|
||||
einotool "github.com/cloudwego/eino/components/tool"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/permission"
|
||||
"ragflow/internal/service/wikisearch"
|
||||
)
|
||||
|
||||
@@ -34,7 +36,7 @@ import (
|
||||
// keywords. Input keeps query + keywords so the LLM's tool schema matches the
|
||||
// other search tools.
|
||||
//
|
||||
// The tool is scoped to the calling tenant and bound datasets, so results never
|
||||
// The tool is scoped to the canvas tenant and bound datasets, so results never
|
||||
// leak across tenants/KBs. When the wiki-search service is not configured, or the
|
||||
// bound datasets have no wiki artifacts, it returns an empty result so the agent
|
||||
// falls back to general hybrid search.
|
||||
@@ -86,6 +88,9 @@ func (w *WikiQueryTool) InvokableRun(ctx context.Context, argumentsInJSON string
|
||||
if svc == nil || tenantID == "" || len(datasetIDs) == 0 {
|
||||
return emptyWikiResult(), nil
|
||||
}
|
||||
if err := CheckCanvasDependencies(ctx, dao.DB, permission.ResourceKindDataset, datasetIDs); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !svc.AvailableFor(ctx, tenantID, datasetIDs) {
|
||||
return emptyWikiResult(), nil
|
||||
}
|
||||
|
||||
+13
-116
@@ -35,91 +35,15 @@ func NewChatDAO() *ChatDAO {
|
||||
return &ChatDAO{}
|
||||
}
|
||||
|
||||
// ListByTenantID list chats by tenant ID
|
||||
func (dao *ChatDAO) ListByTenantID(ctx context.Context, db *gorm.DB, tenantID string, status string) ([]*entity.Chat, error) {
|
||||
var chats []*entity.Chat
|
||||
|
||||
query := db.WithContext(ctx).Model(&entity.Chat{}).
|
||||
Where("tenant_id = ?", tenantID)
|
||||
|
||||
if status != "" {
|
||||
query = query.Where("status = ?", status)
|
||||
// ListByResourceIDs lists chats that have already been authorized by the
|
||||
// permission layer, with the same filters and pagination as the chat list API.
|
||||
func (dao *ChatDAO) ListByResourceIDs(ctx context.Context, db *gorm.DB, resourceIDs, ownerIDs []string, page, pageSize int, terms []OrderTerm, keywords, id, name string) ([]*entity.ChatListItem, int64, error) {
|
||||
if len(resourceIDs) == 0 || (ownerIDs != nil && len(ownerIDs) == 0) {
|
||||
return []*entity.ChatListItem{}, 0, nil
|
||||
}
|
||||
|
||||
// Order by create_time desc
|
||||
if err := query.Order("create_time DESC").Find(&chats).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return chats, nil
|
||||
}
|
||||
|
||||
// ListByTenantIDs list chats by tenant IDs with pagination and filtering
|
||||
func (dao *ChatDAO) ListByTenantIDs(ctx context.Context, db *gorm.DB, tenantIDs []string, userID string, page, pageSize int, terms []OrderTerm, keywords, id, name string) ([]*entity.ChatListItem, int64, error) {
|
||||
var chats []*entity.ChatListItem
|
||||
var total int64
|
||||
|
||||
// Build query with join to user table for nickname and avatar
|
||||
query := db.WithContext(ctx).Model(&entity.Chat{}).
|
||||
Select(`
|
||||
dialog.*,
|
||||
user.nickname,
|
||||
user.avatar as tenant_avatar
|
||||
`).
|
||||
Joins("LEFT JOIN user ON dialog.tenant_id = user.id")
|
||||
|
||||
if len(tenantIDs) > 0 {
|
||||
query = query.Where("(dialog.tenant_id IN ? OR dialog.tenant_id = ?) AND dialog.status = ?", tenantIDs, userID, "1")
|
||||
} else {
|
||||
query = query.Where("dialog.tenant_id = ? AND dialog.status = ?", userID, "1")
|
||||
}
|
||||
|
||||
// Apply keyword filter
|
||||
if keywords != "" {
|
||||
query = query.Where("LOWER(dialog.name) LIKE ?", "%"+strings.ToLower(keywords)+"%")
|
||||
}
|
||||
|
||||
// Apply the exact filters. An empty value means absent, like keywords.
|
||||
if id != "" {
|
||||
query = query.Where("dialog.id = ?", id)
|
||||
}
|
||||
if name != "" {
|
||||
query = query.Where("dialog.name = ?", name)
|
||||
}
|
||||
|
||||
// Apply ordering. Route the requested terms through chatOrderClause so a user-supplied
|
||||
// query param can never reach Order() verbatim: the helper validates
|
||||
// against chatOrderableColumns (a closed allowlist) and falls back to
|
||||
// "create_time" on a miss.
|
||||
// codeql[go/sql-injection] False positive: chatOrderClause
|
||||
query = query.Order(chatOrderClause(terms))
|
||||
|
||||
// Count total
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
if page > 0 && pageSize > 0 {
|
||||
offset := (page - 1) * pageSize
|
||||
if err := query.Offset(offset).Limit(pageSize).Scan(&chats).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
} else {
|
||||
if err := query.Scan(&chats).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
}
|
||||
|
||||
return chats, total, nil
|
||||
}
|
||||
|
||||
// ListByOwnerIDs list chats by owner IDs with pagination and filtering
|
||||
func (dao *ChatDAO) ListByOwnerIDs(ctx context.Context, db *gorm.DB, ownerIDs []string, userID string, page, pageSize int, terms []OrderTerm, keywords, id, name string) ([]*entity.ChatListItem, int64, error) {
|
||||
var chats []*entity.ChatListItem
|
||||
var total int64
|
||||
|
||||
// Build query with join to user table for nickname and avatar
|
||||
query := db.WithContext(ctx).Model(&entity.Chat{}).
|
||||
Select(`
|
||||
dialog.*,
|
||||
@@ -127,48 +51,30 @@ func (dao *ChatDAO) ListByOwnerIDs(ctx context.Context, db *gorm.DB, ownerIDs []
|
||||
user.avatar as tenant_avatar
|
||||
`).
|
||||
Joins("LEFT JOIN user ON dialog.tenant_id = user.id").
|
||||
Where("(dialog.tenant_id IN ? OR dialog.tenant_id = ?) AND dialog.status = ?", ownerIDs, userID, "1")
|
||||
|
||||
// Apply keyword filter
|
||||
Where("dialog.id IN ? AND dialog.status = ?", resourceIDs, "1")
|
||||
if ownerIDs != nil {
|
||||
query = query.Where("dialog.tenant_id IN ?", ownerIDs)
|
||||
}
|
||||
if keywords != "" {
|
||||
query = query.Where("LOWER(dialog.name) LIKE ?", "%"+strings.ToLower(keywords)+"%")
|
||||
}
|
||||
|
||||
// Apply the exact filters. An empty value means absent, like keywords.
|
||||
if id != "" {
|
||||
query = query.Where("dialog.id = ?", id)
|
||||
}
|
||||
if name != "" {
|
||||
query = query.Where("dialog.name = ?", name)
|
||||
}
|
||||
|
||||
// Filter by owner IDs (additional filter to ensure tenant_id is in ownerIDs)
|
||||
query = query.Where("dialog.tenant_id IN ?", ownerIDs)
|
||||
|
||||
// Apply ordering. Route the requested terms through chatOrderClause so a user-supplied
|
||||
// query param can never reach Order() verbatim: the helper validates
|
||||
// against chatOrderableColumns (a closed allowlist) and falls back to
|
||||
// "create_time" on a miss.
|
||||
// codeql[go/sql-injection] False positive: chatOrderClause
|
||||
query = query.Order(chatOrderClause(terms))
|
||||
|
||||
// Count total
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
if page > 0 && pageSize > 0 {
|
||||
offset := (page - 1) * pageSize
|
||||
if err := query.Offset(offset).Limit(pageSize).Scan(&chats).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
} else {
|
||||
if err := query.Scan(&chats).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
query = query.Offset((page - 1) * pageSize).Limit(pageSize)
|
||||
}
|
||||
if err := query.Scan(&chats).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return chats, total, nil
|
||||
}
|
||||
|
||||
@@ -284,12 +190,3 @@ func (dao *ChatDAO) GetAllDialogIDsByTenantID(ctx context.Context, db *gorm.DB,
|
||||
Pluck("id", &dialogIDs).Error
|
||||
return dialogIDs, err
|
||||
}
|
||||
|
||||
// QueryByTenantIDAndID checks if a chat exists with given tenant_id and id
|
||||
// Reference: Python DialogService.query(tenant_id=tenant.tenant_id, id=chat_id, status=StatusEnum.VALID.value)
|
||||
// Used for permission verification in get_chat API
|
||||
func (dao *ChatDAO) QueryByTenantIDAndID(ctx context.Context, db *gorm.DB, tenantID string, chatID string, status string) ([]*entity.Chat, error) {
|
||||
var chats []*entity.Chat
|
||||
err := db.WithContext(ctx).Where("tenant_id = ? AND id = ? AND status = ?", tenantID, chatID, status).Find(&chats).Error
|
||||
return chats, err
|
||||
}
|
||||
@@ -17,8 +17,8 @@ func (dao *ChatChannelDAO) Create(ctx context.Context, db *gorm.DB, channel *ent
|
||||
return db.WithContext(ctx).Create(channel).Error
|
||||
}
|
||||
|
||||
// GetByID returns the chat channel with the given id, ignoring tenant isolation.
|
||||
// Callers must enforce tenant authorization themselves (see service.accessible).
|
||||
// GetByID returns the chat channel with the given id. Callers must authorize
|
||||
// access through the permission package before exposing it to a user.
|
||||
func (dao *ChatChannelDAO) GetByID(ctx context.Context, db *gorm.DB, id string) (*entity.ChatChannel, error) {
|
||||
var channel entity.ChatChannel
|
||||
if err := db.WithContext(ctx).Take(&channel, "id = ?", id).Error; err != nil {
|
||||
@@ -55,14 +55,17 @@ func (dao *ChatChannelDAO) DeleteByID(ctx context.Context, db *gorm.DB, id strin
|
||||
return db.WithContext(ctx).Where("id = ?", id).Delete(&entity.ChatChannel{}).Error
|
||||
}
|
||||
|
||||
// ListByTenantID List a single record by TenantID
|
||||
func (dao *ChatChannelDAO) ListByTenantID(ctx context.Context, db *gorm.DB, tenantID string) ([]*entity.ChatChannelListResponse, error) {
|
||||
// ListByIDs lists chat channels selected by permission scope.
|
||||
func (dao *ChatChannelDAO) ListByIDs(ctx context.Context, db *gorm.DB, ids []string) ([]*entity.ChatChannelListResponse, error) {
|
||||
results := make([]*entity.ChatChannelListResponse, 0)
|
||||
if len(ids) == 0 {
|
||||
return results, nil
|
||||
}
|
||||
|
||||
err := db.WithContext(ctx).Table("chat_channel").
|
||||
Select("chat_channel.id, chat_channel.name, chat_channel.channel, chat_channel.chat_id, chat_channel.status, dialog.name as dialog_name").
|
||||
Joins("LEFT JOIN dialog ON dialog.id = chat_channel.chat_id").
|
||||
Where("chat_channel.tenant_id = ?", tenantID).
|
||||
Where("chat_channel.id IN ?", ids).
|
||||
Order("chat_channel.create_time DESC").
|
||||
Scan(&results).Error
|
||||
|
||||
|
||||
@@ -213,9 +213,9 @@ func TestChatChannelDAO_ListByTenantID(t *testing.T) {
|
||||
|
||||
// Perform query
|
||||
ctx := t.Context()
|
||||
list, err := dao.ListByTenantID(ctx, db, "tenant-1")
|
||||
list, err := dao.ListByIDs(ctx, db, []string{"chan-1", "chan-2"})
|
||||
if err != nil {
|
||||
t.Fatalf("ListByTenantID failed: %v", err)
|
||||
t.Fatalf("ListByIDs failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify count and order (create_time DESC, so cc2 should be index 0)
|
||||
|
||||
@@ -147,10 +147,13 @@ func (dao *ChatSessionDAO) DeleteByID(ctx context.Context, db *gorm.DB, id strin
|
||||
})
|
||||
}
|
||||
|
||||
// ListByChatID lists chat sessions by chat ID
|
||||
func (dao *ChatSessionDAO) ListByChatID(ctx context.Context, db *gorm.DB, chatID, sessionID, name string, terms []OrderTerm, page, pageSize int, includeHistory ...bool) ([]*entity.ChatSession, error) {
|
||||
// ListByResourceIDs lists only sessions authorized by the permission layer.
|
||||
func (dao *ChatSessionDAO) ListByResourceIDs(ctx context.Context, db *gorm.DB, chatID string, sessionIDs []string, sessionID, name string, terms []OrderTerm, page, pageSize int, includeHistory ...bool) ([]*entity.ChatSession, error) {
|
||||
if len(sessionIDs) == 0 {
|
||||
return []*entity.ChatSession{}, nil
|
||||
}
|
||||
var chatSessions []*entity.ChatSession
|
||||
query := db.WithContext(ctx).Session(&gorm.Session{QueryFields: true}).Where("dialog_id = ?", chatID)
|
||||
query := db.WithContext(ctx).Session(&gorm.Session{QueryFields: true}).Where("dialog_id = ? AND id IN ?", chatID, sessionIDs)
|
||||
if sessionID != "" {
|
||||
query = query.Where("id = ?", sessionID)
|
||||
}
|
||||
@@ -176,18 +179,6 @@ func (dao *ChatSessionDAO) ListByChatID(ctx context.Context, db *gorm.DB, chatID
|
||||
return chatSessions, nil
|
||||
}
|
||||
|
||||
// CheckDialogExists checks if a dialog exists with given tenant_id and dialog_id
|
||||
func (dao *ChatSessionDAO) CheckDialogExists(ctx context.Context, db *gorm.DB, tenantID, chatID string) (bool, error) {
|
||||
var count int64
|
||||
err := db.WithContext(ctx).Model(&entity.Chat{}).
|
||||
Where("tenant_id = ? AND id = ? AND status = ?", tenantID, chatID, common.StatusDialogValid).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// GetDialogByID gets dialog by ID
|
||||
func (dao *ChatSessionDAO) GetDialogByID(ctx context.Context, db *gorm.DB, chatID string) (*entity.Chat, error) {
|
||||
var dialog entity.Chat
|
||||
|
||||
+22
-49
@@ -90,10 +90,10 @@ func assertChatOrder(t *testing.T, rows []*entity.ChatListItem, want []string, o
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatDAOListByTenantIDsOrderByExpressionFallsBack checks that an `orderby`
|
||||
// TestChatDAOListByResourceIDsOrderByExpressionFallsBack checks that an `orderby`
|
||||
// value that is an expression rather than an allowed column name leaves the
|
||||
// rows in the default create_time order.
|
||||
func TestChatDAOListByTenantIDsOrderByExpressionFallsBack(t *testing.T) {
|
||||
func TestChatDAOListByResourceIDsOrderByExpressionFallsBack(t *testing.T) {
|
||||
db := setupChatTestDB(t)
|
||||
pushDB(t, db)
|
||||
seedOrderableChats(t, db)
|
||||
@@ -102,29 +102,9 @@ func TestChatDAOListByTenantIDsOrderByExpressionFallsBack(t *testing.T) {
|
||||
|
||||
for _, orderby := range chatOrderExpressions {
|
||||
t.Run(orderby, func(t *testing.T) {
|
||||
rows, _, err := d.ListByTenantIDs(ctx, db, []string{"t1"}, "t1", 1, 10, []OrderTerm{{Column: orderby}}, "", "", "")
|
||||
rows, _, err := d.ListByResourceIDs(ctx, db, []string{"c-1", "c-2", "c-3"}, nil, 1, 10, []OrderTerm{{Column: orderby}}, "", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListByTenantIDs with orderby %q: %v", orderby, err)
|
||||
}
|
||||
assertChatOrder(t, rows, []string{"c-1", "c-2", "c-3"}, orderby)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatDAOListByOwnerIDsOrderByExpressionFallsBack is the ListByOwnerIDs
|
||||
// counterpart, the branch the endpoint takes when owner_ids is supplied.
|
||||
func TestChatDAOListByOwnerIDsOrderByExpressionFallsBack(t *testing.T) {
|
||||
db := setupChatTestDB(t)
|
||||
pushDB(t, db)
|
||||
seedOrderableChats(t, db)
|
||||
ctx := t.Context()
|
||||
d := NewChatDAO()
|
||||
|
||||
for _, orderby := range chatOrderExpressions {
|
||||
t.Run(orderby, func(t *testing.T) {
|
||||
rows, _, err := d.ListByOwnerIDs(ctx, db, []string{"t1"}, "t1", 1, 10, []OrderTerm{{Column: orderby}}, "", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListByOwnerIDs with orderby %q: %v", orderby, err)
|
||||
t.Fatalf("ListByResourceIDs with orderby %q: %v", orderby, err)
|
||||
}
|
||||
assertChatOrder(t, rows, []string{"c-1", "c-2", "c-3"}, orderby)
|
||||
})
|
||||
@@ -140,15 +120,15 @@ func TestChatDAOListOrderByAllowedColumn(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
d := NewChatDAO()
|
||||
|
||||
rows, _, err := d.ListByTenantIDs(ctx, db, []string{"t1"}, "t1", 1, 10, []OrderTerm{{Column: "name"}}, "", "", "")
|
||||
rows, _, err := d.ListByResourceIDs(ctx, db, []string{"c-1", "c-2", "c-3"}, nil, 1, 10, []OrderTerm{{Column: "name"}}, "", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListByTenantIDs ascending by name: %v", err)
|
||||
t.Fatalf("ListByResourceIDs ascending by name: %v", err)
|
||||
}
|
||||
assertChatOrder(t, rows, []string{"c-3", "c-2", "c-1"}, "name")
|
||||
|
||||
rows, _, err = d.ListByTenantIDs(ctx, db, []string{"t1"}, "t1", 1, 10, []OrderTerm{{Column: "name", Desc: true}}, "", "", "")
|
||||
rows, _, err = d.ListByResourceIDs(ctx, db, []string{"c-1", "c-2", "c-3"}, nil, 1, 10, []OrderTerm{{Column: "name", Desc: true}}, "", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListByTenantIDs descending by name: %v", err)
|
||||
t.Fatalf("ListByResourceIDs descending by name: %v", err)
|
||||
}
|
||||
assertChatOrder(t, rows, []string{"c-1", "c-2", "c-3"}, "name")
|
||||
}
|
||||
@@ -189,9 +169,9 @@ func assertChatIDs(t *testing.T, rows []*entity.ChatListItem, wantIDs []string,
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatDAOListExactIDAndNameFilters checks that the exact id and name
|
||||
// predicates narrow both list branches and that the reported total follows
|
||||
// the filtered rows.
|
||||
// TestChatDAOListExactIDAndNameFilters checks that exact ID and name
|
||||
// predicates narrow the authorized resource scope and that total follows the
|
||||
// filtered rows.
|
||||
func TestChatDAOListExactIDAndNameFilters(t *testing.T) {
|
||||
db := setupChatTestDB(t)
|
||||
pushDB(t, db)
|
||||
@@ -223,17 +203,10 @@ func TestChatDAOListExactIDAndNameFilters(t *testing.T) {
|
||||
{label: "name", name: "alpha", want: []string{"c-3"}},
|
||||
{label: "unknown id", id: "unknown", want: []string{}},
|
||||
} {
|
||||
t.Run("tenant "+tc.label, func(t *testing.T) {
|
||||
rows, total, err := d.ListByTenantIDs(ctx, db, []string{"t1"}, "t1", 1, 10, terms, "", tc.id, tc.name)
|
||||
t.Run(tc.label, func(t *testing.T) {
|
||||
rows, total, err := d.ListByResourceIDs(ctx, db, []string{"c-1", "c-2", "c-3", "c-4"}, nil, 1, 10, terms, "", tc.id, tc.name)
|
||||
if err != nil {
|
||||
t.Fatalf("ListByTenantIDs: %v", err)
|
||||
}
|
||||
assertChatRows(t, rows, total, tc.want)
|
||||
})
|
||||
t.Run("owner "+tc.label, func(t *testing.T) {
|
||||
rows, total, err := d.ListByOwnerIDs(ctx, db, []string{"t1"}, "t1", 1, 10, terms, "", tc.id, tc.name)
|
||||
if err != nil {
|
||||
t.Fatalf("ListByOwnerIDs: %v", err)
|
||||
t.Fatalf("ListByResourceIDs: %v", err)
|
||||
}
|
||||
assertChatRows(t, rows, total, tc.want)
|
||||
})
|
||||
@@ -266,11 +239,11 @@ func seedChatRowsWithCreateTime(t *testing.T, db *gorm.DB, tenantID string, coun
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatDAOListByOwnerIDsPaginatesInSQL checks that page/pageSize reach the
|
||||
// TestChatDAOListByResourceIDsPaginatesInSQL checks that page/pageSize reach the
|
||||
// query as LIMIT/OFFSET while total keeps reporting the full COUNT(*).
|
||||
// ChatService.ListChats returns that total as the response's total field and no
|
||||
// longer slices the returned rows itself.
|
||||
func TestChatDAOListByOwnerIDsPaginatesInSQL(t *testing.T) {
|
||||
func TestChatDAOListByResourceIDsPaginatesInSQL(t *testing.T) {
|
||||
db := setupChatTestDB(t)
|
||||
pushDB(t, db)
|
||||
seedChatRowsWithCreateTime(t, db, "t1", 5)
|
||||
@@ -292,9 +265,9 @@ func TestChatDAOListByOwnerIDsPaginatesInSQL(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rows, total, err := d.ListByOwnerIDs(ctx, db, []string{"t1"}, "t1", tt.page, tt.pageSize, terms, "", "", "")
|
||||
rows, total, err := d.ListByResourceIDs(ctx, db, []string{"c-1", "c-2", "c-3", "c-4", "c-5"}, nil, tt.page, tt.pageSize, terms, "", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListByOwnerIDs(page=%d, pageSize=%d): %v", tt.page, tt.pageSize, err)
|
||||
t.Fatalf("ListByResourceIDs(page=%d, pageSize=%d): %v", tt.page, tt.pageSize, err)
|
||||
}
|
||||
if total != 5 {
|
||||
t.Fatalf("total = %d, want 5: total must count every matching row, not just the page", total)
|
||||
@@ -304,10 +277,10 @@ func TestChatDAOListByOwnerIDsPaginatesInSQL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatDAOListByOwnerIDsKeepsJoinedOwnerColumnsWhenPaginated guards the COUNT
|
||||
// TestChatDAOListByResourceIDsKeepsJoinedOwnerColumnsWhenPaginated guards the COUNT
|
||||
// followed by the paged Scan: the second statement must still select the joined
|
||||
// user columns instead of inheriting count(*) from the first one.
|
||||
func TestChatDAOListByOwnerIDsKeepsJoinedOwnerColumnsWhenPaginated(t *testing.T) {
|
||||
func TestChatDAOListByResourceIDsKeepsJoinedOwnerColumnsWhenPaginated(t *testing.T) {
|
||||
db := setupChatTestDB(t)
|
||||
pushDB(t, db)
|
||||
seedChatRowsWithCreateTime(t, db, "t1", 3)
|
||||
@@ -317,9 +290,9 @@ func TestChatDAOListByOwnerIDsKeepsJoinedOwnerColumnsWhenPaginated(t *testing.T)
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
|
||||
rows, total, err := NewChatDAO().ListByOwnerIDs(ctx, db, []string{"t1"}, "t1", 1, 2, []OrderTerm{{Column: "create_time", Desc: true}}, "", "", "")
|
||||
rows, total, err := NewChatDAO().ListByResourceIDs(ctx, db, []string{"c-1", "c-2", "c-3"}, nil, 1, 2, []OrderTerm{{Column: "create_time", Desc: true}}, "", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListByOwnerIDs: %v", err)
|
||||
t.Fatalf("ListByResourceIDs: %v", err)
|
||||
}
|
||||
if total != 3 {
|
||||
t.Fatalf("total = %d, want 3", total)
|
||||
|
||||
+12
-68
@@ -60,11 +60,9 @@ type ConnectorDatasetListItem struct {
|
||||
Status string `json:"status" gorm:"column:status"`
|
||||
}
|
||||
|
||||
// ListByTenantID list connectors by tenant ID
|
||||
// Only selects id, name, source, status fields (matching Python implementation)
|
||||
// ListByTenantID returns connector summaries for a tenant.
|
||||
func (dao *ConnectorDAO) ListByTenantID(ctx context.Context, db *gorm.DB, tenantID string) ([]*ConnectorListItem, error) {
|
||||
var connectors []*ConnectorListItem
|
||||
|
||||
connectors := make([]*ConnectorListItem, 0)
|
||||
err := db.WithContext(ctx).Model(&entity.Connector{}).
|
||||
Select("id", "name", "source", "status").
|
||||
Where("tenant_id = ?", tenantID).
|
||||
@@ -529,74 +527,20 @@ func syncLogsStatusPriorityOrder() string {
|
||||
)
|
||||
}
|
||||
|
||||
// ListLogsByConnectorID lists sync logs for one connector with pagination.
|
||||
func (dao *ConnectorDAO) ListLogsByConnectorID(ctx context.Context, db *gorm.DB, connectorID string, offset, limit int) ([]*entity.ConnectorSyncLog, int64, error) {
|
||||
// ListLogsByTaskIDs returns logs only for authorized sync task IDs.
|
||||
func (dao *ConnectorDAO) ListLogsByTaskIDs(ctx context.Context, db *gorm.DB, taskIDs []string, datasetID string, offset, limit int) ([]*entity.ConnectorSyncLog, int64, error) {
|
||||
if len(taskIDs) == 0 {
|
||||
return []*entity.ConnectorSyncLog{}, 0, nil
|
||||
}
|
||||
return dao.listLogs(ctx, db, taskIDs, datasetID, offset, limit)
|
||||
}
|
||||
|
||||
func (dao *ConnectorDAO) listLogs(ctx context.Context, db *gorm.DB, taskIDs []string, datasetID string, offset, limit int) ([]*entity.ConnectorSyncLog, int64, error) {
|
||||
baseQuery := db.WithContext(ctx).Model(&entity.SyncLogs{}).
|
||||
Joins("JOIN connector ON sync_logs.connector_id = connector.id").
|
||||
Joins("JOIN connector2kb ON sync_logs.connector_id = connector2kb.connector_id AND sync_logs.kb_id = connector2kb.kb_id").
|
||||
Joins("JOIN knowledgebase ON sync_logs.kb_id = knowledgebase.id").
|
||||
Where("sync_logs.connector_id = ?", connectorID)
|
||||
|
||||
var total int64
|
||||
if err := baseQuery.Distinct("sync_logs.id").Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var logs []*entity.ConnectorSyncLog
|
||||
err := baseQuery.
|
||||
Select(
|
||||
"sync_logs.id",
|
||||
"sync_logs.connector_id",
|
||||
"sync_logs.task_type",
|
||||
"sync_logs.kb_id",
|
||||
"sync_logs.update_date",
|
||||
"sync_logs.new_docs_indexed",
|
||||
"sync_logs.total_docs_indexed",
|
||||
"sync_logs.docs_removed_from_index",
|
||||
"sync_logs.error_msg",
|
||||
"sync_logs.error_count",
|
||||
"sync_logs.time_started",
|
||||
"connector.refresh_freq AS refresh_freq",
|
||||
"connector.prune_freq AS prune_freq",
|
||||
"knowledgebase.name AS kb_name",
|
||||
"sync_logs.status",
|
||||
"sync_logs.update_time",
|
||||
).
|
||||
Distinct().
|
||||
Order(syncLogsStatusPriorityOrder()).
|
||||
Offset(offset).
|
||||
Limit(limit).
|
||||
Scan(&logs).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return logs, total, nil
|
||||
}
|
||||
|
||||
// ListLogs lists sync logs for the given tenant IDs with pagination.
|
||||
// When datasetID is non-empty, only logs of that dataset are returned.
|
||||
func (dao *ConnectorDAO) ListLogs(ctx context.Context, db *gorm.DB, tenantIDs []string, datasetID string, offset, limit int) ([]*entity.ConnectorSyncLog, int64, error) {
|
||||
return dao.listLogs(ctx, db, tenantIDs, true, datasetID, offset, limit)
|
||||
}
|
||||
|
||||
// ListLogsByDatasetID lists sync logs for connectors currently linked to a dataset.
|
||||
func (dao *ConnectorDAO) ListLogsByDatasetID(ctx context.Context, db *gorm.DB, datasetID string, offset, limit int) ([]*entity.ConnectorSyncLog, int64, error) {
|
||||
if datasetID == "" {
|
||||
return []*entity.ConnectorSyncLog{}, 0, nil
|
||||
}
|
||||
return dao.listLogs(ctx, db, nil, false, datasetID, offset, limit)
|
||||
}
|
||||
|
||||
func (dao *ConnectorDAO) listLogs(ctx context.Context, db *gorm.DB, tenantIDs []string, filterTenant bool, datasetID string, offset, limit int) ([]*entity.ConnectorSyncLog, int64, error) {
|
||||
baseQuery := db.WithContext(ctx).Model(&entity.SyncLogs{}).
|
||||
Joins("JOIN connector ON sync_logs.connector_id = connector.id").
|
||||
Joins("JOIN connector2kb ON sync_logs.connector_id = connector2kb.connector_id AND sync_logs.kb_id = connector2kb.kb_id").
|
||||
Joins("JOIN knowledgebase ON sync_logs.kb_id = knowledgebase.id")
|
||||
|
||||
if filterTenant {
|
||||
baseQuery = baseQuery.Where("connector.tenant_id IN ?", tenantIDs)
|
||||
}
|
||||
Where("sync_logs.id IN ?", taskIDs)
|
||||
|
||||
if datasetID != "" {
|
||||
baseQuery = baseQuery.Where("sync_logs.kb_id = ?", datasetID)
|
||||
|
||||
+17
-2
@@ -267,10 +267,25 @@ func (dao *KnowledgebaseDAO) Count(ctx context.Context, db *gorm.DB, filters map
|
||||
|
||||
// ListByResourceIDs retrieves knowledge bases within a permission-filtered ID scope.
|
||||
func (dao *KnowledgebaseDAO) ListByResourceIDs(ctx context.Context, db *gorm.DB, resourceIDs, ownerIDs []string, pageNumber, itemsPerPage int, terms []OrderTerm, keywords, parserID, name string) ([]*entity.KnowledgebaseListItem, int64, error) {
|
||||
if len(resourceIDs) == 0 {
|
||||
return []*entity.KnowledgebaseListItem{}, 0, nil
|
||||
}
|
||||
query := db.WithContext(ctx).Model(&entity.Knowledgebase{}).
|
||||
Where("knowledgebase.id IN ?", resourceIDs)
|
||||
return dao.ListByQuery(ctx, query, ownerIDs, pageNumber, itemsPerPage, terms, keywords, parserID, name)
|
||||
}
|
||||
|
||||
// ListByQuery lists knowledge bases using an already-scoped base query. The
|
||||
// caller can apply a permission.QueryScope before calling this method, keeping
|
||||
// authorization filtering in the same SQL query as count and pagination.
|
||||
func (dao *KnowledgebaseDAO) ListByQuery(ctx context.Context, query *gorm.DB, ownerIDs []string, pageNumber, itemsPerPage int, terms []OrderTerm, keywords, parserID, name string) ([]*entity.KnowledgebaseListItem, int64, error) {
|
||||
if query == nil {
|
||||
return nil, 0, errors.New("dataset list query is nil")
|
||||
}
|
||||
var kbs []*entity.KnowledgebaseListItem
|
||||
var total int64
|
||||
|
||||
query := db.WithContext(ctx).Model(&entity.Knowledgebase{}).
|
||||
query = query.WithContext(ctx).
|
||||
Select(`knowledgebase.id, knowledgebase.avatar, knowledgebase.name,
|
||||
knowledgebase.language, knowledgebase.description, knowledgebase.tenant_id,
|
||||
knowledgebase.permission, knowledgebase.doc_num, knowledgebase.token_num,
|
||||
@@ -280,7 +295,7 @@ func (dao *KnowledgebaseDAO) ListByResourceIDs(ctx context.Context, db *gorm.DB,
|
||||
knowledgebase.tenant_embd_id,
|
||||
user.nickname, user.avatar as tenant_avatar, knowledgebase.update_time`).
|
||||
Joins("LEFT JOIN user ON knowledgebase.tenant_id = user.id").
|
||||
Where("knowledgebase.id IN ? AND knowledgebase.status = ?", resourceIDs, string(entity.StatusValid))
|
||||
Where("knowledgebase.status = ?", string(entity.StatusValid))
|
||||
|
||||
if len(ownerIDs) > 0 {
|
||||
query = query.Where("knowledgebase.tenant_id IN ?", ownerIDs)
|
||||
|
||||
+5
-15
@@ -73,13 +73,16 @@ func (dao *MCPServerDAO) CreateMCPServer(ctx context.Context, db *gorm.DB, serve
|
||||
return db.WithContext(ctx).Create(server).Error
|
||||
}
|
||||
|
||||
// ListMCPServers returns MCP servers for a tenant with optional filtering.
|
||||
// ListMCPServers returns MCP servers for one tenant with optional ID filtering.
|
||||
func (dao *MCPServerDAO) ListMCPServers(ctx context.Context, db *gorm.DB, tenantID string, ids []string, keywords string, orderby string, desc bool) ([]*entity.MCPServer, int64, error) {
|
||||
orderColumn, err := mcpServerOrderColumn(orderby)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var servers []*entity.MCPServer
|
||||
var total int64
|
||||
|
||||
query := db.WithContext(ctx).Model(&entity.MCPServer{}).Where("tenant_id = ?", tenantID)
|
||||
|
||||
if len(ids) > 0 {
|
||||
query = query.Where("id IN ?", ids)
|
||||
}
|
||||
@@ -92,10 +95,6 @@ func (dao *MCPServerDAO) ListMCPServers(ctx context.Context, db *gorm.DB, tenant
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
orderColumn, err := mcpServerOrderColumn(orderby)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
orderDirection := "ASC"
|
||||
if desc {
|
||||
orderDirection = "DESC"
|
||||
@@ -111,15 +110,6 @@ func (dao *MCPServerDAO) ListMCPServers(ctx context.Context, db *gorm.DB, tenant
|
||||
return servers, total, nil
|
||||
}
|
||||
|
||||
// GetByIDAndTenant returns an MCP server owned by a tenant.
|
||||
func (dao *MCPServerDAO) GetByIDAndTenant(ctx context.Context, db *gorm.DB, id, tenantID string) (*entity.MCPServer, error) {
|
||||
var server entity.MCPServer
|
||||
if err := db.WithContext(ctx).Where("id = ? AND tenant_id = ?", id, tenantID).First(&server).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &server, nil
|
||||
}
|
||||
|
||||
// DeleteMCPServer deletes an MCP server owned by a tenant.
|
||||
func (dao *MCPServerDAO) DeleteMCPServer(ctx context.Context, db *gorm.DB, id, tenantID string) (bool, error) {
|
||||
result := db.WithContext(ctx).Where("id = ? AND tenant_id = ?", id, tenantID).Delete(&entity.MCPServer{})
|
||||
|
||||
+12
-42
@@ -292,12 +292,13 @@ func (dao *MemoryDAO) GetWithOwnerNameByID(ctx context.Context, db *gorm.DB, id
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetByFilter retrieves memories with optional filters
|
||||
// Supports filtering by tenant_id, memory_type, storage_type, and keywords
|
||||
// Returns paginated results with owner_name from user table JOIN
|
||||
// GetByFilter retrieves authorized memories with optional business filters.
|
||||
// memoryIDs must come from the permission layer; tenantIDs only narrows that
|
||||
// authorized set. Results include owner_name from the user table.
|
||||
//
|
||||
// Parameters:
|
||||
// - tenantIDs: Array of tenant IDs to filter by (empty means all tenants)
|
||||
// - memoryIDs: Authorized memory IDs (empty means no results)
|
||||
// - tenantIDs: Optional tenant IDs to filter the authorized results by
|
||||
// - memoryTypes: Array of memory type names to filter by (empty means all types)
|
||||
// - storageType: Storage type to filter by (empty means all types)
|
||||
// - keywords: Keywords to search in memory names (empty means no keyword filter)
|
||||
@@ -311,21 +312,19 @@ func (dao *MemoryDAO) GetWithOwnerNameByID(ctx context.Context, db *gorm.DB, id
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// memories, total, err := dao.GetByFilter([]string{"tenant1"}, []string{"semantic"}, "table", "test", 1, 10)
|
||||
func (dao *MemoryDAO) GetByFilter(ctx context.Context, db *gorm.DB, userID string, tenantIDs []string, memoryTypes []string, storageType string, keywords string, page int, pageSize int) ([]*entity.MemoryListItem, int64, error) {
|
||||
var conditions []string
|
||||
var args []interface{}
|
||||
// memories, total, err := dao.GetByFilter(ctx, db, authorizedIDs, nil, []string{"semantic"}, "table", "test", 1, 10)
|
||||
func (dao *MemoryDAO) GetByFilter(ctx context.Context, db *gorm.DB, memoryIDs, tenantIDs, memoryTypes []string, storageType string, keywords string, page int, pageSize int) ([]*entity.MemoryListItem, int64, error) {
|
||||
if len(memoryIDs) == 0 {
|
||||
return []*entity.MemoryListItem{}, 0, nil
|
||||
}
|
||||
conditions := []string{"m.id IN ?"}
|
||||
args := []interface{}{memoryIDs}
|
||||
|
||||
if len(tenantIDs) > 0 {
|
||||
conditions = append(conditions, "m.tenant_id IN ?")
|
||||
args = append(args, tenantIDs)
|
||||
}
|
||||
|
||||
if userID != "" {
|
||||
conditions = append(conditions, "(m.tenant_id = ? OR m.permissions = ?)")
|
||||
args = append(args, userID, "team")
|
||||
}
|
||||
|
||||
if len(memoryTypes) > 0 {
|
||||
memoryTypeInt := CalculateMemoryType(memoryTypes)
|
||||
conditions = append(conditions, "m.memory_type & ? > 0")
|
||||
@@ -390,32 +389,3 @@ func (dao *MemoryDAO) GetByFilter(ctx context.Context, db *gorm.DB, userID strin
|
||||
|
||||
return memories, total, nil
|
||||
}
|
||||
|
||||
// Accessible check if it is possible for user to access the memory
|
||||
func (dao *MemoryDAO) Accessible(ctx context.Context, db *gorm.DB, userID, memoryID string) (bool, error) {
|
||||
memory, err := dao.GetByID(ctx, db, memoryID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if memory.TenantID == userID {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if memory.Permissions != string(entity.TenantPermissionTeam) {
|
||||
return false, fmt.Errorf("user %s have no access to this memory", userID)
|
||||
}
|
||||
|
||||
var count int64
|
||||
err = db.WithContext(ctx).Table("user_tenant").
|
||||
Where("tenant_id = ? AND user_id = ? AND status = ?", memory.TenantID, userID, "1").
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if count > 0 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, fmt.Errorf("user %s have no access to this memory", userID)
|
||||
}
|
||||
+11
-95
@@ -48,10 +48,14 @@ type SearchDetailRow struct {
|
||||
TenantAvatar *string `gorm:"column:tenant_avatar"`
|
||||
}
|
||||
|
||||
// ListByTenantIDs list searches by tenant IDs with pagination and filtering
|
||||
func (dao *SearchDAO) ListByTenantIDs(ctx context.Context, db *gorm.DB, tenantIDs []string, userID string, page, pageSize int, terms []OrderTerm, keywords string) ([]*entity.SearchListItem, int64, error) {
|
||||
// ListByResourceIDs lists only the authorized Search Apps, with optional
|
||||
// tenant-owner filtering and pagination.
|
||||
func (dao *SearchDAO) ListByResourceIDs(ctx context.Context, db *gorm.DB, resourceIDs, ownerIDs []string, page, pageSize int, terms []OrderTerm, keywords string) ([]*entity.SearchListItem, int64, error) {
|
||||
var searches []*entity.SearchListItem
|
||||
var total int64
|
||||
if len(resourceIDs) == 0 {
|
||||
return []*entity.SearchListItem{}, 0, nil
|
||||
}
|
||||
|
||||
// Build query with join to user table for nickname and avatar
|
||||
query := db.WithContext(ctx).Model(&entity.Search{}).
|
||||
@@ -62,10 +66,9 @@ func (dao *SearchDAO) ListByTenantIDs(ctx context.Context, db *gorm.DB, tenantID
|
||||
`).
|
||||
Joins("LEFT JOIN user ON search.tenant_id = user.id")
|
||||
|
||||
if len(tenantIDs) > 0 {
|
||||
query = query.Where("(search.tenant_id IN ? OR search.tenant_id = ?) AND search.status = ?", tenantIDs, userID, "1")
|
||||
} else {
|
||||
query = query.Where("search.tenant_id = ? AND search.status = ?", userID, "1")
|
||||
query = query.Where("search.id IN ? AND search.status = ?", resourceIDs, "1")
|
||||
if len(ownerIDs) > 0 {
|
||||
query = query.Where("search.tenant_id IN ?", ownerIDs)
|
||||
}
|
||||
|
||||
// Apply keyword filter
|
||||
@@ -100,56 +103,6 @@ func (dao *SearchDAO) ListByTenantIDs(ctx context.Context, db *gorm.DB, tenantID
|
||||
return searches, total, nil
|
||||
}
|
||||
|
||||
// ListByOwnerIDs list searches by owner IDs with pagination and filtering
|
||||
func (dao *SearchDAO) ListByOwnerIDs(ctx context.Context, db *gorm.DB, ownerIDs []string, userID string, page, pageSize int, terms []OrderTerm, keywords string) ([]*entity.SearchListItem, int64, error) {
|
||||
var searches []*entity.SearchListItem
|
||||
var total int64
|
||||
|
||||
// Build query with join to user table for nickname and avatar
|
||||
query := db.WithContext(ctx).Model(&entity.Search{}).
|
||||
Select(`
|
||||
search.*,
|
||||
user.nickname,
|
||||
user.avatar as tenant_avatar
|
||||
`).
|
||||
Joins("LEFT JOIN user ON search.tenant_id = user.id").
|
||||
Where("(search.tenant_id IN ? OR search.tenant_id = ?) AND search.status = ?", ownerIDs, userID, "1")
|
||||
|
||||
// Apply keyword filter
|
||||
if keywords != "" {
|
||||
query = query.Where("LOWER(search.name) LIKE ?", "%"+strings.ToLower(keywords)+"%")
|
||||
}
|
||||
|
||||
// Filter by owner IDs (additional filter to ensure tenant_id is in ownerIDs)
|
||||
query = query.Where("search.tenant_id IN ?", ownerIDs)
|
||||
|
||||
// Apply ordering. Route the requested terms through searchOrderClause so a
|
||||
// user-supplied query param can never reach Order() verbatim: the helper
|
||||
// validates against searchOrderableColumns (a closed allowlist) and falls
|
||||
// back to "create_time" on a miss.
|
||||
// codeql[go/sql-injection] False positive: searchOrderClause
|
||||
query = query.Order(searchOrderClause(terms))
|
||||
|
||||
// Count total
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
if page > 0 && pageSize > 0 {
|
||||
offset := (page - 1) * pageSize
|
||||
if err := query.Offset(offset).Limit(pageSize).Scan(&searches).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
} else {
|
||||
if err := query.Scan(&searches).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
}
|
||||
|
||||
return searches, total, nil
|
||||
}
|
||||
|
||||
// GetByID gets search by ID
|
||||
func (dao *SearchDAO) GetByID(ctx context.Context, db *gorm.DB, id string) (*entity.Search, error) {
|
||||
var search entity.Search
|
||||
@@ -201,46 +154,9 @@ func (dao *SearchDAO) Create(ctx context.Context, db *gorm.DB, search *entity.Se
|
||||
return db.WithContext(ctx).Create(search).Error
|
||||
}
|
||||
|
||||
// QueryByTenantIDAndID checks if a search exists with given tenant_id and id
|
||||
// Reference: Python SearchService.query(tenant_id=tenant.tenant_id, id=search_id)
|
||||
// Used for permission verification in detail API
|
||||
func (dao *SearchDAO) QueryByTenantIDAndID(ctx context.Context, db *gorm.DB, tenantID string, searchID string) ([]*entity.Search, error) {
|
||||
var searches []*entity.Search
|
||||
err := db.WithContext(ctx).Where("tenant_id = ? AND id = ? AND status = ?", tenantID, searchID, "1").Find(&searches).Error
|
||||
return searches, err
|
||||
}
|
||||
|
||||
// DeleteByID deletes a search by ID (soft delete by setting status to "0")
|
||||
// Reference: Python common_service.py::delete_by_id
|
||||
func (dao *SearchDAO) DeleteByID(ctx context.Context, db *gorm.DB, tenantID, id string) error {
|
||||
return db.WithContext(ctx).Model(&entity.Search{}).Where("tenant_id = ? AND id = ?", tenantID, id).Update("status", "0").Error
|
||||
}
|
||||
|
||||
// Accessible4Deletion checks if a search can be deleted by a specific user
|
||||
// Reference: Python search_service.py::accessible4deletion
|
||||
// Returns true if the search exists, is valid, and was created by the user.
|
||||
// A missing or non-owned search returns (false, nil) so callers can distinguish
|
||||
// "not authorized" from a genuine database error (which is returned as the error).
|
||||
func (dao *SearchDAO) Accessible4Deletion(ctx context.Context, db *gorm.DB, searchID string, userID string) (bool, error) {
|
||||
var count int64
|
||||
err := db.WithContext(ctx).Model(&entity.Search{}).
|
||||
Where("id = ? AND created_by = ? AND status = ?", searchID, userID, "1").
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// GetByTenantIDAndID gets search by tenant ID and search ID
|
||||
// Reference: Python SearchService.query(tenant_id=tenant_id, id=search_id)
|
||||
func (dao *SearchDAO) GetByTenantIDAndID(ctx context.Context, db *gorm.DB, tenantID string, searchID string) (*entity.Search, error) {
|
||||
var search entity.Search
|
||||
err := db.WithContext(ctx).Where("tenant_id = ? AND id = ? AND status = ?", tenantID, searchID, "1").First(&search).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &search, nil
|
||||
func (dao *SearchDAO) DeleteByID(ctx context.Context, db *gorm.DB, id string) error {
|
||||
return db.WithContext(ctx).Model(&entity.Search{}).Where("id = ? AND status = ?", id, "1").Update("status", "0").Error
|
||||
}
|
||||
|
||||
// UpdateByID updates search by ID
|
||||
|
||||
+26
-27
@@ -69,10 +69,10 @@ func assertSearchOrder(t *testing.T, rows []*entity.SearchListItem, want []strin
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchDAOListByTenantIDsOrderByExpressionFallsBack checks that an
|
||||
// TestSearchDAOListByResourceIDsOrderByExpressionFallsBack checks that an
|
||||
// `orderby` value that is an expression rather than an allowed column name
|
||||
// leaves the rows in the default create_time order.
|
||||
func TestSearchDAOListByTenantIDsOrderByExpressionFallsBack(t *testing.T) {
|
||||
func TestSearchDAOListByResourceIDsOrderByExpressionFallsBack(t *testing.T) {
|
||||
db := setupSearchDetailDAOTestDB(t)
|
||||
pushDB(t, db)
|
||||
seedOrderableSearches(t, db)
|
||||
@@ -81,18 +81,18 @@ func TestSearchDAOListByTenantIDsOrderByExpressionFallsBack(t *testing.T) {
|
||||
|
||||
for _, orderby := range searchOrderExpressions {
|
||||
t.Run(orderby, func(t *testing.T) {
|
||||
rows, _, err := d.ListByTenantIDs(ctx, db, []string{"t1"}, "t1", 1, 10, []OrderTerm{{Column: orderby}}, "")
|
||||
rows, _, err := d.ListByResourceIDs(ctx, db, []string{"s-1", "s-2", "s-3"}, nil, 1, 10, []OrderTerm{{Column: orderby}}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListByTenantIDs with orderby %q: %v", orderby, err)
|
||||
t.Fatalf("ListByResourceIDs with orderby %q: %v", orderby, err)
|
||||
}
|
||||
assertSearchOrder(t, rows, []string{"s-1", "s-2", "s-3"}, orderby)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchDAOListByOwnerIDsOrderByExpressionFallsBack is the ListByOwnerIDs
|
||||
// counterpart, the branch the endpoint takes when owner_ids is supplied.
|
||||
func TestSearchDAOListByOwnerIDsOrderByExpressionFallsBack(t *testing.T) {
|
||||
// TestSearchDAOListByResourceIDsWithOwnerFilterOrderByExpressionFallsBack
|
||||
// covers the same query with an owner_ids filter.
|
||||
func TestSearchDAOListByResourceIDsWithOwnerFilterOrderByExpressionFallsBack(t *testing.T) {
|
||||
db := setupSearchDetailDAOTestDB(t)
|
||||
pushDB(t, db)
|
||||
seedOrderableSearches(t, db)
|
||||
@@ -101,9 +101,9 @@ func TestSearchDAOListByOwnerIDsOrderByExpressionFallsBack(t *testing.T) {
|
||||
|
||||
for _, orderby := range searchOrderExpressions {
|
||||
t.Run(orderby, func(t *testing.T) {
|
||||
rows, _, err := d.ListByOwnerIDs(ctx, db, []string{"t1"}, "t1", 1, 10, []OrderTerm{{Column: orderby}}, "")
|
||||
rows, _, err := d.ListByResourceIDs(ctx, db, []string{"s-1", "s-2", "s-3"}, []string{"t1"}, 1, 10, []OrderTerm{{Column: orderby}}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListByOwnerIDs with orderby %q: %v", orderby, err)
|
||||
t.Fatalf("ListByResourceIDs with orderby %q: %v", orderby, err)
|
||||
}
|
||||
assertSearchOrder(t, rows, []string{"s-1", "s-2", "s-3"}, orderby)
|
||||
})
|
||||
@@ -119,15 +119,15 @@ func TestSearchDAOListOrderByAllowedColumn(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
d := NewSearchDAO()
|
||||
|
||||
rows, _, err := d.ListByTenantIDs(ctx, db, []string{"t1"}, "t1", 1, 10, []OrderTerm{{Column: "name"}}, "")
|
||||
rows, _, err := d.ListByResourceIDs(ctx, db, []string{"s-1", "s-2", "s-3"}, nil, 1, 10, []OrderTerm{{Column: "name"}}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListByTenantIDs ascending by name: %v", err)
|
||||
t.Fatalf("ListByResourceIDs ascending by name: %v", err)
|
||||
}
|
||||
assertSearchOrder(t, rows, []string{"s-3", "s-2", "s-1"}, "name")
|
||||
|
||||
rows, _, err = d.ListByTenantIDs(ctx, db, []string{"t1"}, "t1", 1, 10, []OrderTerm{{Column: "name", Desc: true}}, "")
|
||||
rows, _, err = d.ListByResourceIDs(ctx, db, []string{"s-1", "s-2", "s-3"}, nil, 1, 10, []OrderTerm{{Column: "name", Desc: true}}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListByTenantIDs descending by name: %v", err)
|
||||
t.Fatalf("ListByResourceIDs descending by name: %v", err)
|
||||
}
|
||||
assertSearchOrder(t, rows, []string{"s-1", "s-2", "s-3"}, "name")
|
||||
}
|
||||
@@ -173,11 +173,11 @@ func seedSearchRowsWithCreateTime(t *testing.T, db *gorm.DB, tenantID string, co
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchDAOListByOwnerIDsPaginatesInSQL checks that page/pageSize reach the
|
||||
// TestSearchDAOListByResourceIDsPaginatesInSQL checks that page/pageSize reach the
|
||||
// query as LIMIT/OFFSET while total keeps reporting the full COUNT(*).
|
||||
// SearchService.ListSearches returns that total as the response's total field
|
||||
// and no longer slices the returned rows itself.
|
||||
func TestSearchDAOListByOwnerIDsPaginatesInSQL(t *testing.T) {
|
||||
func TestSearchDAOListByResourceIDsPaginatesInSQL(t *testing.T) {
|
||||
db := setupSearchDetailDAOTestDB(t)
|
||||
pushDB(t, db)
|
||||
seedSearchRowsWithCreateTime(t, db, "t1", 5)
|
||||
@@ -199,9 +199,9 @@ func TestSearchDAOListByOwnerIDsPaginatesInSQL(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rows, total, err := d.ListByOwnerIDs(ctx, db, []string{"t1"}, "t1", tt.page, tt.pageSize, terms, "")
|
||||
rows, total, err := d.ListByResourceIDs(ctx, db, []string{"s-1", "s-2", "s-3", "s-4", "s-5"}, nil, tt.page, tt.pageSize, terms, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListByOwnerIDs(page=%d, pageSize=%d): %v", tt.page, tt.pageSize, err)
|
||||
t.Fatalf("ListByResourceIDs(page=%d, pageSize=%d): %v", tt.page, tt.pageSize, err)
|
||||
}
|
||||
if total != 5 {
|
||||
t.Fatalf("total = %d, want 5: total must count every matching row, not just the page", total)
|
||||
@@ -211,10 +211,10 @@ func TestSearchDAOListByOwnerIDsPaginatesInSQL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchDAOListByOwnerIDsKeepsJoinedOwnerColumnsWhenPaginated guards the
|
||||
// TestSearchDAOListByResourceIDsKeepsJoinedOwnerColumnsWhenPaginated guards the
|
||||
// COUNT followed by the paged Scan: the second statement must still select the
|
||||
// joined user columns instead of inheriting count(*) from the first one.
|
||||
func TestSearchDAOListByOwnerIDsKeepsJoinedOwnerColumnsWhenPaginated(t *testing.T) {
|
||||
func TestSearchDAOListByResourceIDsKeepsJoinedOwnerColumnsWhenPaginated(t *testing.T) {
|
||||
db := setupSearchDetailDAOTestDB(t)
|
||||
pushDB(t, db)
|
||||
seedSearchRowsWithCreateTime(t, db, "t1", 3)
|
||||
@@ -224,9 +224,9 @@ func TestSearchDAOListByOwnerIDsKeepsJoinedOwnerColumnsWhenPaginated(t *testing.
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
|
||||
rows, total, err := NewSearchDAO().ListByOwnerIDs(ctx, db, []string{"t1"}, "t1", 1, 2, []OrderTerm{{Column: "create_time", Desc: true}}, "")
|
||||
rows, total, err := NewSearchDAO().ListByResourceIDs(ctx, db, []string{"s-1", "s-2", "s-3"}, nil, 1, 2, []OrderTerm{{Column: "create_time", Desc: true}}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListByOwnerIDs: %v", err)
|
||||
t.Fatalf("ListByResourceIDs: %v", err)
|
||||
}
|
||||
if total != 3 {
|
||||
t.Fatalf("total = %d, want 3", total)
|
||||
@@ -244,11 +244,10 @@ func TestSearchDAOListByOwnerIDsKeepsJoinedOwnerColumnsWhenPaginated(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchDAOListByOwnerIDsKeepsOwnerFilter pins the owner_ids filter: a
|
||||
// TestSearchDAOListByResourceIDsKeepsOwnerFilter pins the owner_ids filter: a
|
||||
// search owned by the requesting user is still excluded when its tenant_id is
|
||||
// not among ownerIDs, which the endpoint relies on after
|
||||
// SearchService.filterAccessibleSearchOwnerIDs has narrowed the list.
|
||||
func TestSearchDAOListByOwnerIDsKeepsOwnerFilter(t *testing.T) {
|
||||
// not among ownerIDs, even when it is included in the authorized resource IDs.
|
||||
func TestSearchDAOListByResourceIDsKeepsOwnerFilter(t *testing.T) {
|
||||
db := setupSearchDetailDAOTestDB(t)
|
||||
pushDB(t, db)
|
||||
ctx := t.Context()
|
||||
@@ -270,9 +269,9 @@ func TestSearchDAOListByOwnerIDsKeepsOwnerFilter(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
rows, total, err := d.ListByOwnerIDs(ctx, db, []string{"t1"}, "u-self", 0, 0, nil, "")
|
||||
rows, total, err := d.ListByResourceIDs(ctx, db, []string{"search-t1", "search-u-self"}, []string{"t1"}, 0, 0, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListByOwnerIDs: %v", err)
|
||||
t.Fatalf("ListByResourceIDs: %v", err)
|
||||
}
|
||||
if total != 1 {
|
||||
t.Fatalf("total = %d, want 1: only the t1 search has a tenant_id in ownerIDs", total)
|
||||
|
||||
@@ -46,10 +46,14 @@ func (dao *SkillSpaceDAO) GetByID(ctx context.Context, db *gorm.DB, id string) (
|
||||
return &space, nil
|
||||
}
|
||||
|
||||
// GetByTenantID retrieves all skills spaces by tenant ID (active only)
|
||||
func (dao *SkillSpaceDAO) GetByTenantID(ctx context.Context, db *gorm.DB, tenantID string) ([]*entity.SkillSpace, error) {
|
||||
var spaces []*entity.SkillSpace
|
||||
err := db.WithContext(ctx).Where("tenant_id = ? AND status = ?", tenantID, entity.SpaceStatusActive).Order("create_time DESC").Find(&spaces).Error
|
||||
// GetByIDs retrieves active skill spaces by ID.
|
||||
func (dao *SkillSpaceDAO) GetByIDs(ctx context.Context, db *gorm.DB, ids []string) ([]*entity.SkillSpace, error) {
|
||||
spaces := make([]*entity.SkillSpace, 0, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return spaces, nil
|
||||
}
|
||||
err := db.WithContext(ctx).Where("id IN ? AND status = ?", ids, entity.SpaceStatusActive).
|
||||
Order("create_time DESC").Find(&spaces).Error
|
||||
return spaces, err
|
||||
}
|
||||
|
||||
|
||||
@@ -294,8 +294,7 @@ func (h *AgentHandler) CreateAgent(c *gin.Context) {
|
||||
common.ResponseWithCodeData(c, common.CodeArgumentError, nil, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
req.UserID = user.ID
|
||||
row, code, err := h.agentService.CreateAgent(c.Request.Context(), &req)
|
||||
row, code, err := h.agentService.CreateAgent(c.Request.Context(), user.ID, &req)
|
||||
if err != nil {
|
||||
common.ErrorWithCode(c, code, err.Error())
|
||||
return
|
||||
@@ -308,7 +307,7 @@ func (h *AgentHandler) CreateAgent(c *gin.Context) {
|
||||
// @Tags agents
|
||||
// @Produce json
|
||||
// @Param canvas_id path string true "canvas id"
|
||||
// @Success 200 {object} entity.UserCanvas
|
||||
// @Success 200 {object} agentDetailResponse
|
||||
// @Router /api/v1/agents/{canvas_id} [get]
|
||||
func (h *AgentHandler) GetAgent(c *gin.Context) {
|
||||
user, code, msg := GetUser(c)
|
||||
@@ -338,13 +337,23 @@ func (h *AgentHandler) GetAgent(c *gin.Context) {
|
||||
common.ResponseWithCodeData(c, ec, nil, em)
|
||||
return
|
||||
}
|
||||
common.SuccessWithData(c, &agentDetailResponse{UserCanvas: row, LastPublishTime: lastPublishTime}, "success")
|
||||
tenantID := ""
|
||||
if row != nil {
|
||||
tenantID = row.UserID
|
||||
}
|
||||
common.SuccessWithData(c, &agentDetailResponse{
|
||||
UserCanvas: row,
|
||||
TenantID: tenantID,
|
||||
LastPublishTime: lastPublishTime,
|
||||
}, "success")
|
||||
}
|
||||
|
||||
// agentDetailResponse wraps the canvas row with derived fields that Python's
|
||||
// get_agent handler adds to the response.
|
||||
// agentDetailResponse adds the canvas owner's tenant scope and publish time.
|
||||
// user_canvas stores the owner identity in user_id, so that value is exposed
|
||||
// as tenant_id for resource-scoped selectors.
|
||||
type agentDetailResponse struct {
|
||||
*entity.UserCanvas
|
||||
TenantID string `json:"tenant_id"`
|
||||
LastPublishTime *int64 `json:"last_publish_time,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -310,6 +310,29 @@ func TestGetAgentVersionHandler_VersionNotFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAgentUsesAuthenticatedUserID(t *testing.T) {
|
||||
c, w, db := setupGinContextWithUserAndDB(t, http.MethodPost, "/api/v1/agents")
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/agents", strings.NewReader(`{
|
||||
"user_id":"other-user",
|
||||
"title":"Agent",
|
||||
"dsl":{}
|
||||
}`))
|
||||
|
||||
h := NewAgentHandler(t.Context(), service.NewAgentService(), nil)
|
||||
h.CreateAgent(c)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("CreateAgent status = %d, body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var created entity.UserCanvas
|
||||
if err := db.Where("title = ?", "Agent").First(&created).Error; err != nil {
|
||||
t.Fatalf("load created agent: %v", err)
|
||||
}
|
||||
if created.UserID != "user-1" {
|
||||
t.Fatalf("created agent owner = %q, want authenticated user user-1", created.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
// sptr returns a pointer to the given string.
|
||||
// ptr returns a pointer to the given int64.
|
||||
func ptr(v int64) *int64 { return &v }
|
||||
@@ -441,7 +464,7 @@ type fullFakeAgentService struct {
|
||||
func (f *fullFakeAgentService) ListAgents(string, string, int, int, string, bool, []string, string, []string) (*service.ListAgentsResponse, common.ErrorCode, error) {
|
||||
return &service.ListAgentsResponse{}, common.CodeSuccess, nil
|
||||
}
|
||||
func (f *fullFakeAgentService) CreateAgent(context.Context, *service.CreateAgentRequest) (*entity.UserCanvas, common.ErrorCode, error) {
|
||||
func (f *fullFakeAgentService) CreateAgent(context.Context, string, *service.CreateAgentRequest) (*entity.UserCanvas, common.ErrorCode, error) {
|
||||
return nil, common.CodeArgumentError, nil
|
||||
}
|
||||
func (f *fullFakeAgentService) GetAgent(context.Context, string, string) (*entity.UserCanvas, error) {
|
||||
|
||||
@@ -78,7 +78,7 @@ func newWaitFakeAgentService(stub func(call int, root map[string]any) (*runtime.
|
||||
func (f *waitFakeAgentService) ListAgents(string, string, int, int, string, bool, []string, string, []string) (*service.ListAgentsResponse, common.ErrorCode, error) {
|
||||
return &service.ListAgentsResponse{}, common.CodeSuccess, nil
|
||||
}
|
||||
func (f *waitFakeAgentService) CreateAgent(context.Context, *service.CreateAgentRequest) (*entity.UserCanvas, common.ErrorCode, error) {
|
||||
func (f *waitFakeAgentService) CreateAgent(context.Context, string, *service.CreateAgentRequest) (*entity.UserCanvas, common.ErrorCode, error) {
|
||||
return nil, common.CodeArgumentError, nil
|
||||
}
|
||||
func (f *waitFakeAgentService) GetAgent(context.Context, string, string) (*entity.UserCanvas, error) {
|
||||
|
||||
@@ -39,7 +39,7 @@ type BotHandler struct {
|
||||
// botService is the subset of BotService used by these handlers. It
|
||||
// is interface-typed so the test suite can inject a stub.
|
||||
type botService interface {
|
||||
ChatbotInfo(ctx context.Context, tenantID, dialogID string) (
|
||||
ChatbotInfo(ctx context.Context, userID, dialogID string) (
|
||||
title, avatar, prologue, llmID string, hasWebSearch bool, ec common.ErrorCode, err error)
|
||||
AgentbotInputs(ctx context.Context, userID, agentID string) (
|
||||
title, avatar, prologue, mode string, inputs map[string]any,
|
||||
@@ -47,7 +47,7 @@ type botService interface {
|
||||
AgentbotCompletion(ctx context.Context, userID, agentID string, req service.AgentbotCompletionRequest) (
|
||||
<-chan canvas.RunEvent, common.ErrorCode, error)
|
||||
AgentbotLogs(ctx context.Context, userID, agentID, messageID string) (map[string]any, common.ErrorCode, error)
|
||||
ChatbotCompletion(ctx context.Context, tenantID, dialogID string, req service.ChatbotCompletionRequest) (
|
||||
ChatbotCompletion(ctx context.Context, userID, dialogID string, req service.ChatbotCompletionRequest) (
|
||||
<-chan service.ChatbotSSEFrame, common.ErrorCode, error)
|
||||
}
|
||||
|
||||
@@ -74,6 +74,9 @@ func (h *BotHandler) ChatbotInfo(c *gin.Context) {
|
||||
title, avatar, prologue, llmID, hasWebSearch, ec, err := h.botService.ChatbotInfo(
|
||||
c.Request.Context(), user.ID, dialogID)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
common.ResponseWithCodeData(c, ec, nil, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -220,6 +223,9 @@ func (h *BotHandler) ChatbotCompletion(c *gin.Context) {
|
||||
frames, ec, err := h.botService.ChatbotCompletion(
|
||||
c.Request.Context(), user.ID, dialogID, body)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
common.ResponseWithCodeData(c, ec, nil, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import (
|
||||
"ragflow/internal/agent/canvas"
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/permission"
|
||||
"ragflow/internal/service"
|
||||
)
|
||||
|
||||
@@ -193,7 +194,7 @@ func TestChatbotInfo_HasTavilyKey(t *testing.T) {
|
||||
func TestChatbotInfo_ForeignTenant(t *testing.T) {
|
||||
stub := &stubBotService{
|
||||
chatbotInfoFn: func(ctx context.Context, tenantID, dialogID string) (string, string, string, string, bool, common.ErrorCode, error) {
|
||||
return "", "", "", "", false, common.CodeDataError, errors.New("authentication error: no access to this chatbot")
|
||||
return "", "", "", "", false, common.CodeNotFound, permission.ErrPermissionDenied
|
||||
},
|
||||
}
|
||||
r := botTestEngine(stub)
|
||||
@@ -206,8 +207,11 @@ func TestChatbotInfo_ForeignTenant(t *testing.T) {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp.Code != 102 {
|
||||
t.Errorf("code = %d, want 102", resp.Code)
|
||||
if resp.Code != int(common.CodeNotFound) {
|
||||
t.Errorf("code = %d, want %d", resp.Code, common.CodeNotFound)
|
||||
}
|
||||
if resp.Message != "Resource not found" {
|
||||
t.Errorf("message = %q, want hidden resource message", resp.Message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,7 +241,7 @@ func TestChatbotInfo_MissingPrologueField(t *testing.T) {
|
||||
func TestChatbotCompletion_AuthoriseFail(t *testing.T) {
|
||||
stub := &stubBotService{
|
||||
chatbotCompleteFn: func(ctx context.Context, tenantID, dialogID string, req service.ChatbotCompletionRequest) (<-chan service.ChatbotSSEFrame, common.ErrorCode, error) {
|
||||
return nil, common.CodeDataError, errors.New("no access to this chatbot")
|
||||
return nil, common.CodeNotFound, permission.ErrPermissionDenied
|
||||
},
|
||||
}
|
||||
r := botTestEngine(stub)
|
||||
@@ -250,11 +254,11 @@ func TestChatbotCompletion_AuthoriseFail(t *testing.T) {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp.Code != 102 {
|
||||
t.Errorf("code = %d, want 102", resp.Code)
|
||||
if resp.Code != int(common.CodeNotFound) {
|
||||
t.Errorf("code = %d, want %d", resp.Code, common.CodeNotFound)
|
||||
}
|
||||
if !strings.Contains(resp.Message, "no access") {
|
||||
t.Errorf("message = %q, want contains 'no access'", resp.Message)
|
||||
if resp.Message != "Resource not found" {
|
||||
t.Errorf("message = %q, want hidden resource message", resp.Message)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-13
@@ -129,6 +129,9 @@ func (h *ChatHandler) ListChats(c *gin.Context) {
|
||||
// List chats - default to valid status "1" (same as Python StatusEnum.VALID.value)
|
||||
result, err := h.chatService.ListChats(ctx, userID, "1", keywords, id, name, page, pageSize, terms, ownerIDs)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, false) {
|
||||
return
|
||||
}
|
||||
common.ResponseWithHttpCodeData(c, http.StatusInternalServerError, 500, nil, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -207,8 +210,11 @@ func (h *ChatHandler) MindMap(c *gin.Context) {
|
||||
jsonInternalError(c, fmt.Errorf("search service not configured"))
|
||||
return
|
||||
}
|
||||
detail, err := h.searchSvc.GetDetail(ctx, req.SearchID)
|
||||
detail, err := h.searchSvc.GetDetail(ctx, user.ID, req.SearchID)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
jsonInternalError(c, err)
|
||||
return
|
||||
}
|
||||
@@ -259,8 +265,7 @@ func (h *ChatHandler) DeleteChat(c *gin.Context) {
|
||||
|
||||
ctx := c.Request.Context()
|
||||
if err := h.chatService.DeleteChat(ctx, userID, chatID); err != nil {
|
||||
if err.Error() == "no authorization" {
|
||||
common.ResponseWithCodeData(c, common.CodeAuthenticationError, false, "no authorization")
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
common.ErrorWithCode(c, common.CodeDataError, err.Error())
|
||||
@@ -293,8 +298,7 @@ func (h *ChatHandler) BulkDeleteChats(c *gin.Context) {
|
||||
if len(req.IDs) == 0 && !req.DeleteAll {
|
||||
if req.ChatID != "" {
|
||||
if err := h.chatService.DeleteChat(ctx, userID, req.ChatID); err != nil {
|
||||
if err.Error() == "no authorization" {
|
||||
common.ResponseWithCodeData(c, common.CodeAuthenticationError, false, "no authorization")
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, err.Error())
|
||||
@@ -357,10 +361,7 @@ func (h *ChatHandler) GetChat(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
chat, err := h.chatService.GetChat(ctx, userID, chatID)
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
// Check if it's an authorization error
|
||||
if errMsg == "no authorization" {
|
||||
common.ResponseWithCodeData(c, common.CodeAuthenticationError, false, "no authorization")
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
// Not found error
|
||||
@@ -445,10 +446,6 @@ func (h *ChatHandler) updateChatByMethod(c *gin.Context, patch bool) {
|
||||
if respondPermissionErrorIf(c, err, false) {
|
||||
return
|
||||
}
|
||||
if err.Error() == "no authorization" {
|
||||
common.ResponseWithCodeData(c, common.CodeAuthenticationError, false, "no authorization")
|
||||
return
|
||||
}
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -58,6 +58,17 @@ func TestChatAudioSpeechFailureOmitsProviderError(t *testing.T) {
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("failed to create tenant: %v", err)
|
||||
}
|
||||
activeStatus := string(entity.StatusValid)
|
||||
if err = db.Create(&entity.UserTenant{
|
||||
ID: "user-1-membership",
|
||||
UserID: "user-1",
|
||||
TenantID: "user-1",
|
||||
Role: "owner",
|
||||
InvitedBy: "user-1",
|
||||
Status: &activeStatus,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("failed to create tenant owner membership: %v", err)
|
||||
}
|
||||
if err = db.Create(&entity.TenantModel{
|
||||
ID: ttsModelID,
|
||||
ModelName: "FunAudioLLM/CosyVoice2-0.5B",
|
||||
|
||||
@@ -27,7 +27,7 @@ import (
|
||||
|
||||
type ChatChannelService interface {
|
||||
CreateChatChannel(ctx context.Context, userID, name, channelType string, config entity.JSONMap, chatID *string) (*entity.ChatChannel, error)
|
||||
List(ctx context.Context, tenantID string) ([]*entity.ChatChannelListResponse, error)
|
||||
List(ctx context.Context, userID string) ([]*entity.ChatChannelListResponse, error)
|
||||
GetChatChannel(ctx context.Context, userID, channelID string) (*entity.ChatChannel, common.ErrorCode, error)
|
||||
UpdateChatChannel(ctx context.Context, userID, channelID string, req map[string]interface{}) (*entity.ChatChannel, common.ErrorCode, error)
|
||||
GetChatChannelRuntime(ctx context.Context, userID, channelID string) (map[string]any, common.ErrorCode, error)
|
||||
@@ -82,6 +82,9 @@ func (h *ChatChannelHandler) CreateChatChannel(c *gin.Context) {
|
||||
req.ChatID,
|
||||
)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
common.ResponseWithCodeData(c, common.CodeServerError, nil, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -99,6 +102,9 @@ func (h *ChatChannelHandler) ListChatChannel(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
rows, err := h.chatChannelService.List(ctx, user.ID)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
common.ResponseWithCodeData(c, common.CodeServerError, nil, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -164,6 +170,9 @@ func (h *ChatChannelHandler) UpdateChatChannel(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
result, code, err := h.chatChannelService.UpdateChatChannel(ctx, userID, channelID, unwrapChatChannelPayload(request))
|
||||
if code != common.CodeSuccess || err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
writeChatChannelError(c, code, chatChannelErrMsg(code, err))
|
||||
return
|
||||
}
|
||||
@@ -238,10 +247,6 @@ func unwrapChatChannelPayload(payload map[string]interface{}) map[string]interfa
|
||||
}
|
||||
|
||||
func writeChatChannelError(c *gin.Context, code common.ErrorCode, message string) {
|
||||
if code == common.CodeAuthenticationError && message == "no authorization" {
|
||||
common.ResponseWithCodeData(c, code, false, message)
|
||||
return
|
||||
}
|
||||
common.ResponseWithCodeData(c, code, nil, message)
|
||||
}
|
||||
|
||||
|
||||
@@ -234,13 +234,13 @@ func TestChatChannelHandlerListServiceError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatChannelHandlerGetChatChannelUnauthorized(t *testing.T) {
|
||||
func TestChatChannelHandlerGetChatChannelHidesPermissionDenial(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
h := &ChatChannelHandler{
|
||||
chatChannelService: fakeChatChannelService{
|
||||
getFn: func(userID, channelID string) (*entity.ChatChannel, common.ErrorCode, error) {
|
||||
return nil, common.CodeAuthenticationError, errors.New("no authorization")
|
||||
return nil, common.CodeNotFound, errors.New("Resource not found")
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -259,10 +259,10 @@ func TestChatChannelHandlerGetChatChannelUnauthorized(t *testing.T) {
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
if payload["code"] != float64(common.CodeAuthenticationError) {
|
||||
if payload["code"] != float64(common.CodeNotFound) {
|
||||
t.Fatalf("payload=%v", payload)
|
||||
}
|
||||
if payload["data"] != false {
|
||||
if payload["data"] != nil {
|
||||
t.Fatalf("payload=%v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,9 +112,7 @@ func (h *ChatSessionHandler) ListChatSessions(c *gin.Context) {
|
||||
includeHistory := c.DefaultQuery("include_history", "true")
|
||||
result, err := h.chatSessionService.ListChatSessions(ctx, userID, chatID, c.Query("id"), c.Query("name"), terms, page, pageSize, includeHistory != "false" && includeHistory != "False")
|
||||
if err != nil {
|
||||
// Mirror Python: ownership failures return code 109 "no authorization"
|
||||
if strings.Contains(err.Error(), "no authorization") {
|
||||
common.ResponseWithCodeData(c, common.CodeAuthenticationError, false, "no authorization")
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
common.ResponseWithHttpCodeData(c, http.StatusInternalServerError, 500, nil, err.Error())
|
||||
@@ -318,6 +316,9 @@ func (h *ChatSessionHandler) GetSession(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
result, code, err := h.chatSessionService.GetSession(ctx, userID, chatID, sessionID)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
common.ErrorWithCode(c, code, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -361,8 +362,7 @@ func (h *ChatSessionHandler) CreateSession(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
result, code, err := h.chatSessionService.CreateSession(ctx, userID, chatID, req)
|
||||
if err != nil {
|
||||
if code == common.CodeAuthenticationError {
|
||||
common.ResponseWithCodeData(c, code, false, err.Error())
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
common.ErrorWithCode(c, code, err.Error())
|
||||
@@ -409,8 +409,7 @@ func (h *ChatSessionHandler) DeleteSessions(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
result, message, code, err := h.chatSessionService.DeleteSessions(ctx, userID, chatID, req)
|
||||
if err != nil {
|
||||
if code == common.CodeAuthenticationError {
|
||||
common.ResponseWithCodeData(c, code, false, err.Error())
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
common.ErrorWithCode(c, code, err.Error())
|
||||
@@ -440,6 +439,9 @@ func (h *ChatSessionHandler) UpdateSession(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
result, code, err := h.chatSessionService.UpdateSession(ctx, userID, chatID, sessionID, req)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
common.ErrorWithCode(c, code, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -458,8 +460,7 @@ func (h *ChatSessionHandler) DeleteSessionMessage(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
result, code, err := h.chatSessionService.DeleteSessionMessage(ctx, userID, chatID, sessionID, msgID)
|
||||
if err != nil {
|
||||
if code == common.CodeAuthenticationError {
|
||||
common.ResponseWithCodeData(c, code, false, err.Error())
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
common.ErrorWithCode(c, code, err.Error())
|
||||
@@ -495,8 +496,7 @@ func (h *ChatSessionHandler) UpdateMessageFeedback(c *gin.Context) {
|
||||
|
||||
result, code, err := h.chatSessionService.UpdateMessageFeedback(c.Request.Context(), userID, chatID, sessionID, msgID, req)
|
||||
if err != nil {
|
||||
if code == common.CodeAuthenticationError {
|
||||
common.ResponseWithCodeData(c, code, false, err.Error())
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
common.ErrorWithCode(c, code, err.Error())
|
||||
|
||||
@@ -189,13 +189,13 @@ func TestUpdateChatHandlerRejectsNonOwner(t *testing.T) {
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp["code"] != float64(common.CodeAuthenticationError) {
|
||||
t.Fatalf("expected auth error code, got %v", resp["code"])
|
||||
if resp["code"] != float64(common.CodeForbidden) {
|
||||
t.Fatalf("expected permission-denied code, got %v", resp["code"])
|
||||
}
|
||||
if resp["data"] != false {
|
||||
t.Fatalf("expected data=false, got %v", resp["data"])
|
||||
if resp["data"] != nil {
|
||||
t.Fatalf("expected data=null, got %v", resp["data"])
|
||||
}
|
||||
if resp["message"] != "no authorization" {
|
||||
if resp["message"] != "Permission denied" {
|
||||
t.Fatalf("unexpected message: %v", resp["message"])
|
||||
}
|
||||
}
|
||||
@@ -218,13 +218,13 @@ func TestGetChatHandlerRejectsNonOwner(t *testing.T) {
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp["code"] != float64(common.CodeAuthenticationError) {
|
||||
t.Fatalf("expected auth error code 109, got %v", resp["code"])
|
||||
if resp["code"] != float64(common.CodeNotFound) {
|
||||
t.Fatalf("expected hidden resource code, got %v", resp["code"])
|
||||
}
|
||||
if resp["data"] != false {
|
||||
t.Fatalf("expected data=false, got %v", resp["data"])
|
||||
if resp["data"] != nil {
|
||||
t.Fatalf("expected data=null, got %v", resp["data"])
|
||||
}
|
||||
if resp["message"] != "no authorization" {
|
||||
if resp["message"] != "Resource not found" {
|
||||
t.Fatalf("unexpected message: %v", resp["message"])
|
||||
}
|
||||
}
|
||||
@@ -247,10 +247,10 @@ func TestDeleteChatHandlerRejectsNonOwner(t *testing.T) {
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp["code"] != float64(common.CodeAuthenticationError) {
|
||||
t.Fatalf("expected auth error code 109, got %v", resp["code"])
|
||||
if resp["code"] != float64(common.CodeNotFound) {
|
||||
t.Fatalf("expected hidden resource code, got %v", resp["code"])
|
||||
}
|
||||
if resp["message"] != "no authorization" {
|
||||
if resp["message"] != "Resource not found" {
|
||||
t.Fatalf("unexpected message: %v", resp["message"])
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ import (
|
||||
"strconv"
|
||||
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/permission"
|
||||
"ragflow/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -44,6 +46,7 @@ func NewCompilationTemplateGroupHandler(svc *service.CompilationTemplateGroupSer
|
||||
// @Security ApiKeyAuth
|
||||
// @Param keywords query string false "name keyword filter"
|
||||
// @Param scope query string false "scope filter (file|dataset)"
|
||||
// @Param tenant_id query string false "tenant whose groups are listed (defaults to the current user)"
|
||||
// @Param page query int false "page number" default(1)
|
||||
// @Param page_size query int false "items per page" default(30)
|
||||
// @Param orderby query string false "order field" default(create_time)
|
||||
@@ -67,11 +70,20 @@ func (h *CompilationTemplateGroupHandler) List(c *gin.Context) {
|
||||
}
|
||||
keywords := c.Query("keywords")
|
||||
scope := c.Query("scope")
|
||||
tenantID := c.Query("tenant_id")
|
||||
if tenantID == "" {
|
||||
tenantID = user.ID
|
||||
} else if err := permission.NewDatabaseChecker(dao.DB).CheckTenant(
|
||||
c.Request.Context(), permission.Subject{UserID: user.ID}, tenantID, permission.TenantMember,
|
||||
); err != nil {
|
||||
respondPermissionFailure(c, err, false)
|
||||
return
|
||||
}
|
||||
orderby := c.DefaultQuery("orderby", "create_time")
|
||||
desc := c.DefaultQuery("desc", "true") != "false"
|
||||
terms := orderTermsFromQuery(c, orderby, desc)
|
||||
|
||||
groups, err := h.compilationTemplateGroupService.ListSaved(c.Request.Context(), user.ID, keywords, scope, terms)
|
||||
groups, err := h.compilationTemplateGroupService.ListSaved(c.Request.Context(), tenantID, keywords, scope, terms)
|
||||
if err != nil {
|
||||
common.ResponseWithCodeData(c, common.CodeExceptionError, nil, err.Error())
|
||||
return
|
||||
|
||||
@@ -29,12 +29,13 @@ import (
|
||||
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/entity"
|
||||
permissionresponse "ragflow/internal/permission/response"
|
||||
"ragflow/internal/service"
|
||||
syncerconnector "ragflow/internal/syncer/connector"
|
||||
)
|
||||
|
||||
type connectorServiceIface interface {
|
||||
ListConnectors(ctx context.Context, userID string) (*service.ListConnectorsResponse, error)
|
||||
ListConnectors(ctx context.Context, userID, tenantID string) (*service.ListConnectorsResponse, error)
|
||||
CreateConnector(ctx context.Context, userID string, req *service.CreateConnectorRequest) (*entity.Connector, error)
|
||||
GetConnector(ctx context.Context, connectorID, userID string) (*entity.Connector, error)
|
||||
ListLog(ctx context.Context, connectorID, userID string, page, pageSize int) ([]*entity.ConnectorSyncLog, int64, common.ErrorCode, error)
|
||||
@@ -66,12 +67,13 @@ func NewConnectorHandler(connectorService *service.ConnectorService, userService
|
||||
}
|
||||
}
|
||||
|
||||
// ListConnectors list connectors
|
||||
// ListConnectors lists connectors for an owned tenant or a requested tenant the caller belongs to.
|
||||
// @Summary List Connectors
|
||||
// @Description Get list of connectors for the current user (equivalent to Python's list_connector)
|
||||
// @Tags connector
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param tenant_id query string false "tenant whose connectors are listed"
|
||||
// @Success 200 {object} service.ListConnectorsResponse
|
||||
// @Router /connector/list [get]
|
||||
func (h *ConnectorHandler) ListConnectors(c *gin.Context) {
|
||||
@@ -84,8 +86,11 @@ func (h *ConnectorHandler) ListConnectors(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// List connectors
|
||||
result, err := h.connectorService.ListConnectors(ctx, userID)
|
||||
result, err := h.connectorService.ListConnectors(ctx, userID, c.Query("tenant_id"))
|
||||
if err != nil {
|
||||
if connectorErrorResponse(c, err) {
|
||||
return
|
||||
}
|
||||
common.ResponseWithHttpCodeData(c, http.StatusInternalServerError, 500, nil, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -100,10 +105,11 @@ func connectorErrorResponse(c *gin.Context, err error) bool {
|
||||
switch {
|
||||
case err == nil:
|
||||
return false
|
||||
case permissionresponse.IsPermissionError(err):
|
||||
code, permissionErr := permissionresponse.NormalizeHidden(err)
|
||||
common.ErrorWithCode(c, code, permissionErr.Error())
|
||||
case errors.Is(err, service.ErrConnectorIDRequired):
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, err.Error())
|
||||
case errors.Is(err, service.ErrConnectorNoAuth):
|
||||
common.ResponseWithCodeData(c, common.CodeAuthenticationError, false, "no authorization")
|
||||
case errors.Is(err, service.ErrConnectorNotFound):
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, "Can't find this Connector!")
|
||||
case errors.Is(err, service.ErrConnectorTestUnsupported):
|
||||
@@ -384,11 +390,15 @@ func (h *ConnectorHandler) TestConnector(c *gin.Context) {
|
||||
}
|
||||
|
||||
err := h.connectorService.TestConnector(ctx, connectorID, user.ID, request)
|
||||
if permissionresponse.IsPermissionError(err) {
|
||||
connectorErrorResponse(c, err)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrConnectorTestUnsupported) || errors.Is(err, service.ErrConnectorSourceNotImplemented) {
|
||||
connectorErrorResponse(c, err)
|
||||
return
|
||||
}
|
||||
if err != nil && !errors.Is(err, service.ErrConnectorNoAuth) && !errors.Is(err, service.ErrConnectorNotFound) {
|
||||
if err != nil && !errors.Is(err, service.ErrConnectorNotFound) {
|
||||
// Schema/credential validation failures map to DATA_ERROR;
|
||||
// anything unexpected falls back to SERVER_ERROR.
|
||||
var (
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/permission"
|
||||
"ragflow/internal/service"
|
||||
syncerconnector "ragflow/internal/syncer/connector"
|
||||
)
|
||||
@@ -33,7 +34,7 @@ type logListCapture struct {
|
||||
pageSize int
|
||||
}
|
||||
|
||||
func (s fakeConnectorService) ListConnectors(context.Context, string) (*service.ListConnectorsResponse, error) {
|
||||
func (s fakeConnectorService) ListConnectors(context.Context, string, string) (*service.ListConnectorsResponse, error) {
|
||||
return &service.ListConnectorsResponse{}, nil
|
||||
}
|
||||
|
||||
@@ -170,10 +171,9 @@ func TestConnectorHandlerGetConnector(t *testing.T) {
|
||||
{
|
||||
name: "unauthorized",
|
||||
connectorID: "connector-1",
|
||||
service: fakeConnectorService{err: service.ErrConnectorNoAuth},
|
||||
wantCode: common.CodeAuthenticationError,
|
||||
wantData: false,
|
||||
wantMsg: "no authorization",
|
||||
service: fakeConnectorService{err: permission.ErrPermissionDenied},
|
||||
wantCode: common.CodeNotFound,
|
||||
wantMsg: "Resource not found",
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
@@ -342,9 +342,10 @@ func TestConnectorHandlerTestConnector(t *testing.T) {
|
||||
wantCode: common.CodeDataError,
|
||||
},
|
||||
{
|
||||
name: "unauthorized",
|
||||
err: service.ErrConnectorNoAuth,
|
||||
wantCode: common.CodeAuthenticationError,
|
||||
name: "unauthorized",
|
||||
err: permission.ErrPermissionDenied,
|
||||
wantCode: common.CodeNotFound,
|
||||
wantMessage: "Resource not found",
|
||||
},
|
||||
{
|
||||
name: "unsupported source",
|
||||
|
||||
@@ -74,7 +74,7 @@ func (h *DatasetsHandler) ListDatasets(c *gin.Context) {
|
||||
|
||||
if c.Query("type") == "filter" {
|
||||
ctx := c.Request.Context()
|
||||
data, code, err := h.datasetsService.ListDatasetFilters(ctx, user.ID)
|
||||
data, code, err := h.datasetsService.ListDatasetFilters(ctx, user.ID, c.Query("canvas_id"))
|
||||
if err != nil {
|
||||
common.ErrorWithCode(c, code, err.Error())
|
||||
return
|
||||
@@ -214,6 +214,7 @@ func (h *DatasetsHandler) ListDatasets(c *gin.Context) {
|
||||
parserID,
|
||||
user.ID,
|
||||
ids,
|
||||
c.Query("canvas_id"),
|
||||
)
|
||||
if err != nil {
|
||||
common.ErrorWithCode(c, code, err.Error())
|
||||
@@ -378,6 +379,7 @@ var listDatasetsAllowedParams = map[string]bool{
|
||||
"id": true, "ids": true, "name": true, "page": true, "page_size": true,
|
||||
"orderby": true, "desc": true, "sort": true, "include_parsing_status": true,
|
||||
"keywords": true, "owner_ids": true, "parser_id": true, "type": true,
|
||||
"canvas_id": true,
|
||||
}
|
||||
|
||||
var createDatasetAllowedFields = map[string]bool{
|
||||
@@ -1213,6 +1215,9 @@ func (h *DatasetsHandler) SearchDataset(c *gin.Context) {
|
||||
|
||||
resp, err := searchService.SearchDataset(ctx, datasetID, user.ID, &req)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, false) {
|
||||
return
|
||||
}
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -434,7 +434,7 @@ func (h *DocumentHandler) DeleteDocument(c *gin.Context) {
|
||||
common.ResponseWithHttpCodeData(c, http.StatusBadRequest, 1, nil, "document not found!")
|
||||
return
|
||||
}
|
||||
if err := h.datasetService.CheckAccess(ctx, permission.Subject{UserID: user.ID}, doc.KbID, permission.OperationDelete); err != nil {
|
||||
if err := h.datasetService.CheckAccess(ctx, permission.Subject{UserID: user.ID}, doc.KbID, permission.OperationDeleteContent); err != nil {
|
||||
respondPermissionError(c, err, false)
|
||||
return
|
||||
}
|
||||
|
||||
+39
-14
@@ -43,8 +43,7 @@ type FileHandler struct {
|
||||
}
|
||||
|
||||
func respondFileServiceError(c *gin.Context, err error) {
|
||||
if errors.Is(err, file.ErrNoAuthorization) {
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, "no authorization")
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
jsonInternalError(c, err)
|
||||
@@ -66,6 +65,7 @@ func NewFileHandler(fileService *file.FileService, userService *service.UserServ
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param parent_id query string false "parent folder ID (empty means root folder)"
|
||||
// @Param tenant_id query string false "tenant whose files are listed (defaults to the current user)"
|
||||
// @Param keywords query string false "search keywords (case-insensitive)"
|
||||
// @Param page query int false "page number (default: 1, min: 1)"
|
||||
// @Param page_size query int false "items per page (default: 15, min: 1, max: 100)"
|
||||
@@ -83,6 +83,10 @@ func (h *FileHandler) ListFiles(c *gin.Context) {
|
||||
userID := user.ID
|
||||
|
||||
parentID := c.Query("parent_id")
|
||||
tenantID := c.Query("tenant_id")
|
||||
if tenantID == "" {
|
||||
tenantID = userID
|
||||
}
|
||||
keywords := c.Query("keywords")
|
||||
|
||||
page := 1
|
||||
@@ -120,9 +124,9 @@ func (h *FileHandler) ListFiles(c *gin.Context) {
|
||||
terms := orderTermsFromQuery(c, orderby, desc)
|
||||
|
||||
ctx := c.Request.Context()
|
||||
result, err := h.fileService.ListFiles(ctx, userID, parentID, page, pageSize, terms, keywords)
|
||||
result, err := h.fileService.ListFilesForTenant(ctx, userID, tenantID, parentID, page, pageSize, terms, keywords)
|
||||
if err != nil {
|
||||
jsonInternalError(c, err)
|
||||
respondFileServiceError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -149,7 +153,7 @@ func (h *FileHandler) GetRootFolder(c *gin.Context) {
|
||||
// Get root folder
|
||||
rootFolder, err := h.fileService.GetRootFolder(ctx, userID)
|
||||
if err != nil {
|
||||
jsonInternalError(c, err)
|
||||
respondFileServiceError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -233,6 +237,7 @@ func (h *FileHandler) GetAllParentFolders(c *gin.Context) {
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "file ID"
|
||||
// @Param tenant_id query string false "tenant the file belongs to"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/files/{id}/ancestors [get]
|
||||
func (h *FileHandler) GetFileAncestors(c *gin.Context) {
|
||||
@@ -251,7 +256,14 @@ func (h *FileHandler) GetFileAncestors(c *gin.Context) {
|
||||
|
||||
ctx := c.Request.Context()
|
||||
// Get all parent folders with permission check
|
||||
parentFolders, err := h.fileService.GetAllParentFolders(ctx, userID, fileID)
|
||||
tenantID := c.Query("tenant_id")
|
||||
var parentFolders []map[string]interface{}
|
||||
var err error
|
||||
if tenantID == "" {
|
||||
parentFolders, err = h.fileService.GetAllParentFolders(ctx, userID, fileID)
|
||||
} else {
|
||||
parentFolders, err = h.fileService.GetAllParentFoldersForTenant(ctx, userID, tenantID, fileID)
|
||||
}
|
||||
if err != nil {
|
||||
respondFileServiceError(c, err)
|
||||
return
|
||||
@@ -310,7 +322,7 @@ func (h *FileHandler) UploadFile(c *gin.Context) {
|
||||
if parentID == "" {
|
||||
rootFolder, err := h.fileService.GetRootFolder(ctx, userID)
|
||||
if err != nil {
|
||||
jsonInternalError(c, err)
|
||||
respondFileServiceError(c, err)
|
||||
return
|
||||
}
|
||||
parentID = rootFolder["id"].(string)
|
||||
@@ -332,6 +344,9 @@ func (h *FileHandler) UploadFile(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
result, err := h.fileService.UploadFile(ctx, userID, parentID, files, uploadLimit)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
common.ErrorWithCode(c, common.CodeBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -351,7 +366,7 @@ func (h *FileHandler) UploadFile(c *gin.Context) {
|
||||
if parentID == "" {
|
||||
rootFolder, err := h.fileService.GetRootFolder(ctx, userID)
|
||||
if err != nil {
|
||||
jsonInternalError(c, err)
|
||||
respondFileServiceError(c, err)
|
||||
return
|
||||
}
|
||||
parentID = rootFolder["id"].(string)
|
||||
@@ -359,6 +374,9 @@ func (h *FileHandler) UploadFile(c *gin.Context) {
|
||||
|
||||
result, err := h.fileService.CreateFolder(ctx, userID, req.Name, parentID, req.Type)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
common.ErrorWithCode(c, common.CodeBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -397,9 +415,11 @@ func (h *FileHandler) DeleteFiles(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
success, message := h.fileService.DeleteFiles(c.Request.Context(), user.ID, req.IDs)
|
||||
if !success {
|
||||
common.ResponseWithCodeData(c, common.CodeBadRequest, nil, message)
|
||||
if err := h.fileService.DeleteFiles(c.Request.Context(), user.ID, req.IDs); err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
common.ResponseWithCodeData(c, common.CodeBadRequest, nil, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -452,9 +472,11 @@ func (h *FileHandler) MoveFiles(c *gin.Context) {
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
success, message := h.fileService.MoveFiles(ctx, user.ID, req.SrcFileIDs, req.DestFileID, req.NewName)
|
||||
if !success {
|
||||
common.ResponseWithCodeData(c, common.CodeBadRequest, nil, message)
|
||||
if err := h.fileService.MoveFiles(ctx, user.ID, req.SrcFileIDs, req.DestFileID, req.NewName); err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
common.ResponseWithCodeData(c, common.CodeBadRequest, nil, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -488,6 +510,9 @@ func (h *FileHandler) Download(c *gin.Context) {
|
||||
// Get file metadata and check permission
|
||||
file, err := h.fileService.GetFileContent(ctx, userID, fileID)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
common.ResponseWithCodeData(c, common.CodeUnauthorized, nil, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -143,10 +143,13 @@ func (h *FileCommitHandler) resolveDatasetFolderID(ctx context.Context, datasetI
|
||||
func (h *FileCommitHandler) folderAccessible(c *gin.Context, userID, folderID string) bool {
|
||||
ctx := c.Request.Context()
|
||||
folder, err := h.fileDAO.GetByID(ctx, dao.DB, folderID)
|
||||
if err != nil || folder == nil {
|
||||
if err != nil || folder == nil || folder.Type != "folder" {
|
||||
return false
|
||||
}
|
||||
return service.CheckFileTeamPermission(ctx, h.fileDAO, folder, userID)
|
||||
return permission.NewDatabaseChecker(dao.DB).CheckResource(ctx, permission.Subject{UserID: userID}, permission.ResourceRef{
|
||||
Kind: permission.ResourceKindFolder,
|
||||
ID: folder.ID,
|
||||
}, permission.OperationRead) == nil
|
||||
}
|
||||
|
||||
func commitFolderID(c *gin.Context) string {
|
||||
@@ -676,7 +679,10 @@ func (h *FileCommitHandler) GetFileVersionHistory(c *gin.Context) {
|
||||
|
||||
ctx := c.Request.Context()
|
||||
file, err := h.fileDAO.GetByID(ctx, dao.DB, fileID)
|
||||
if err != nil || file == nil || !service.CheckFileTeamPermission(ctx, h.fileDAO, file, user.ID) {
|
||||
if err != nil || file == nil || permission.NewDatabaseChecker(dao.DB).CheckResource(ctx, permission.Subject{UserID: user.ID}, permission.ResourceRef{
|
||||
Kind: permission.ResourceKindFile,
|
||||
ID: fileID,
|
||||
}, permission.OperationRead) != nil {
|
||||
common.ResponseWithCodeData(c, common.CodeNotFound, nil, "File not found")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ func setupFileCommitTestWithHandler(userID string) (*gin.Engine, *mockFileCommit
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&entity.File{}); err != nil {
|
||||
if err := db.AutoMigrate(&entity.File{}, &entity.File2Document{}, &entity.Document{}, &entity.UserTenant{}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
dao.DB = db
|
||||
|
||||
@@ -5,7 +5,8 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"ragflow/internal/service/file"
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/permission"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -17,7 +18,7 @@ func TestRespondFileServiceError(t *testing.T) {
|
||||
err error
|
||||
code int
|
||||
msg string
|
||||
}{{"authorization", file.ErrNoAuthorization, 102, "no authorization"}} {
|
||||
}{{"authorization", permission.ErrPermissionDenied, int(common.CodeNotFound), "Resource not found"}} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
+15
-3
@@ -30,6 +30,7 @@ import (
|
||||
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/entity"
|
||||
permissionresponse "ragflow/internal/permission/response"
|
||||
"ragflow/internal/service"
|
||||
)
|
||||
|
||||
@@ -91,7 +92,7 @@ func (h *MCPHandler) CreateMCPServer(c *gin.Context) {
|
||||
common.SuccessWithData(c, result, "success")
|
||||
}
|
||||
|
||||
// ListMCPServers lists MCP servers for the current user.
|
||||
// ListMCPServers lists MCP servers for an owned tenant or a requested tenant the caller belongs to.
|
||||
func (h *MCPHandler) ListMCPServers(c *gin.Context) {
|
||||
user, errorCode, errorMessage := GetUser(c)
|
||||
if errorCode != common.CodeSuccess {
|
||||
@@ -114,8 +115,9 @@ func (h *MCPHandler) ListMCPServers(c *gin.Context) {
|
||||
desc := strings.ToLower(c.DefaultQuery("desc", "true")) != "false"
|
||||
keywords := c.Query("keywords")
|
||||
mcpIDs := getMCPIDsFromQuery(c)
|
||||
tenantID := c.Query("tenant_id")
|
||||
|
||||
result, code, err := h.mcpService.ListMCPServers(ctx, user.ID, mcpIDs, keywords, page, pageSize, orderby, desc)
|
||||
result, code, err := h.mcpService.ListMCPServers(ctx, user.ID, tenantID, mcpIDs, keywords, page, pageSize, orderby, desc)
|
||||
if err != nil {
|
||||
if code == common.CodeServerError {
|
||||
common.ResponseWithHttpCodeData(c, http.StatusInternalServerError, code, nil, err.Error())
|
||||
@@ -155,7 +157,7 @@ func (h *MCPHandler) GetMCPServer(c *gin.Context) {
|
||||
}
|
||||
|
||||
func mcpDetailError(c *gin.Context, code common.ErrorCode, err error) {
|
||||
if code == common.CodeDataError {
|
||||
if code == common.CodeDataError || code == common.CodeUnauthorized || code == common.CodeForbidden || code == common.CodeNotFound {
|
||||
common.ErrorWithCode(c, code, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -209,6 +211,11 @@ func mcpErrorResponse(c *gin.Context, err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if permissionresponse.IsPermissionError(err) {
|
||||
code, normalized := permissionresponse.NormalizeHidden(err)
|
||||
common.ErrorWithCode(c, code, normalized.Error())
|
||||
return true
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, service.ErrMCPInvalidType),
|
||||
errors.Is(err, service.ErrMCPInvalidName),
|
||||
@@ -324,6 +331,11 @@ func (h *MCPHandler) ImportMCPServers(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
results, err := h.mcpService.ImportServers(ctx, user.ID, servers, timeout)
|
||||
if err != nil {
|
||||
if permissionresponse.IsPermissionError(err) {
|
||||
code, normalized := permissionresponse.Normalize(err)
|
||||
common.ErrorWithCode(c, code, normalized.Error())
|
||||
return
|
||||
}
|
||||
common.ResponseWithHttpCodeData(c, http.StatusInternalServerError, common.CodeBadRequest, nil, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ func mcpRetrieval(ctx context.Context, ds *dataset.DatasetService, userID string
|
||||
ids, err := fetchAllDatasetIDs(func(page, pageSize int) ([]map[string]interface{}, int64, error) {
|
||||
data, total, _, err := ds.ListDatasets(ctx,
|
||||
"", "", page, pageSize, []dao.OrderTerm{{Column: "create_time", Desc: true}},
|
||||
"", nil, "", userID, nil,
|
||||
"", nil, "", userID, nil, "",
|
||||
)
|
||||
return data, total, err
|
||||
}, maxPageSize)
|
||||
|
||||
@@ -119,7 +119,7 @@ func (h *MCPServerHandler) HandleMCP(c *gin.Context) {
|
||||
func mcpListDatasets(ctx context.Context, ds *dataset.DatasetService, userID string, page, pageSize int, orderby string, desc bool) ([]map[string]interface{}, int64, error) {
|
||||
data, total, _, err := ds.ListDatasets(ctx,
|
||||
"", "", page, pageSize, []dao.OrderTerm{{Column: orderby, Desc: desc}},
|
||||
"", nil, "", userID, nil,
|
||||
"", nil, "", userID, nil, "",
|
||||
)
|
||||
return data, total, err
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/permission"
|
||||
permissionresponse "ragflow/internal/permission/response"
|
||||
"ragflow/internal/service"
|
||||
)
|
||||
|
||||
@@ -224,6 +226,9 @@ func (h *MemoryHandler) UpdateMemory(c *gin.Context) {
|
||||
// Call service layer to update memory
|
||||
result, err := h.memoryService.UpdateMemory(ctx, userID, memoryID, &req)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
errMsg := err.Error()
|
||||
// Check if it's a "not found" error
|
||||
if strings.Contains(errMsg, "not found") {
|
||||
@@ -276,6 +281,9 @@ func (h *MemoryHandler) DeleteMemory(c *gin.Context) {
|
||||
// Call service layer to delete memory (with access control)
|
||||
err := h.memoryService.DeleteMemory(ctx, user.ID, memoryID)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
errMsg := err.Error()
|
||||
// Check if it's a "not found" error
|
||||
if strings.Contains(errMsg, "not found") {
|
||||
@@ -373,9 +381,14 @@ func (h *MemoryHandler) ListMemories(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Call service layer to get memory list
|
||||
result, err := h.memoryService.ListMemories(ctx, user.ID, tenantIDs, memoryTypes, storageType, keywords, page, pageSize)
|
||||
result, err := h.memoryService.ListMemories(ctx, user.ID, tenantIDs, memoryTypes, storageType, keywords, page, pageSize, c.Query("canvas_id"))
|
||||
if err != nil {
|
||||
common.ResponseWithCodeData(c, common.CodeServerError, nil, err.Error())
|
||||
code := common.CodeServerError
|
||||
if errors.Is(err, permission.ErrUnauthenticated) || errors.Is(err, permission.ErrPermissionDenied) ||
|
||||
errors.Is(err, permission.ErrMembershipNotFound) || errors.Is(err, permission.ErrResourceNotFound) {
|
||||
code, err = permissionresponse.Normalize(err)
|
||||
}
|
||||
common.ResponseWithCodeData(c, code, nil, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -413,6 +426,9 @@ func (h *MemoryHandler) GetMemoryConfig(c *gin.Context) {
|
||||
// Call service layer to get memory configuration (with access control)
|
||||
result, err := h.memoryService.GetMemoryConfig(ctx, user.ID, memoryID)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
errMsg := err.Error()
|
||||
// Check if it's a "not found" error
|
||||
if strings.Contains(errMsg, "not found") {
|
||||
@@ -487,6 +503,9 @@ func (h *MemoryHandler) GetMemoryMessages(c *gin.Context) {
|
||||
|
||||
data, err := h.memoryService.GetMemoryMessages(c.Request.Context(), userID, memoryID, agentIDs, keywords, page, pageSize)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
if isMemoryServiceNotFound(err) {
|
||||
common.ResponseWithCodeData(c, common.CodeNotFound, nil, err.Error())
|
||||
return
|
||||
@@ -617,6 +636,9 @@ func (h *MemoryHandler) ForgetMessage(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err = h.memoryService.ForgetMessage(c.Request.Context(), user.ID, memoryID, messageID); err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
errMsg := err.Error()
|
||||
if isMemoryServiceNotFound(err) {
|
||||
common.ResponseWithCodeData(c, common.CodeNotFound, nil, errMsg)
|
||||
@@ -705,6 +727,9 @@ func (h *MemoryHandler) UpdateMessage(c *gin.Context) {
|
||||
|
||||
ok, err = h.memoryService.UpdateMessageStatus(c.Request.Context(), userID, memoryID, messageID, status)
|
||||
if err != nil || !ok {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
if isMemoryServiceNotFound(err) {
|
||||
common.ResponseWithCodeData(c, common.CodeNotFound, nil, err.Error())
|
||||
return
|
||||
@@ -749,6 +774,9 @@ func (h *MemoryHandler) GetMessageContent(c *gin.Context) {
|
||||
|
||||
data, err := h.memoryService.GetMessageContent(c.Request.Context(), userID, memoryID, messageID)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
if _, ok := err.(*service.ResourceNotFoundError); ok {
|
||||
common.ResponseWithCodeData(c, common.CodeNotFound, nil, err.Error())
|
||||
return
|
||||
|
||||
@@ -1638,10 +1638,10 @@ func (h *ProviderHandler) ListTenantAddedModels(c *gin.Context) {
|
||||
}
|
||||
|
||||
modelType := c.Query("type")
|
||||
ownerTenantID := c.Query("owner_tenant_id")
|
||||
tenantID := c.Query("tenant_id")
|
||||
ctx := c.Request.Context()
|
||||
|
||||
addedModels, code, err := h.modelProviderService.ListTenantAddedModels(ctx, user.ID, ownerTenantID, modelType)
|
||||
addedModels, code, err := h.modelProviderService.ListTenantAddedModels(ctx, user.ID, tenantID, modelType)
|
||||
if err != nil {
|
||||
common.ErrorWithCode(c, code, err.Error())
|
||||
return
|
||||
|
||||
@@ -133,6 +133,9 @@ func (h *SearchHandler) ListSearches(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
result, err := h.searchService.ListSearches(ctx, userID, keywords, page, pageSize, terms, ownerIDs)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, false) {
|
||||
return
|
||||
}
|
||||
common.ResponseWithHttpCodeData(c, http.StatusInternalServerError, 500, nil, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -217,9 +220,7 @@ func (h *SearchHandler) GetSearch(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
search, err := h.searchService.GetSearchDetail(ctx, userID, searchID)
|
||||
if err != nil {
|
||||
// Check if it's a permission error
|
||||
if err.Error() == "has no permission for this operation" {
|
||||
common.ResponseWithCodeData(c, common.CodeOperatingError, false, "Has no permission for this operation.")
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
// Not found error
|
||||
@@ -276,9 +277,7 @@ func (h *SearchHandler) DeleteSearch(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
err := h.searchService.DeleteSearch(ctx, userID, searchID)
|
||||
if err != nil {
|
||||
// Check if it's an authorization error
|
||||
if err.Error() == "no authorization" {
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, false, "No authorization")
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
// Delete failed error
|
||||
@@ -333,10 +332,11 @@ func (h *SearchHandler) UpdateSearch(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
updatedSearch, err := h.searchService.UpdateSearch(ctx, userID, searchID, &req)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
errMsg := err.Error()
|
||||
switch errMsg {
|
||||
case "no authorization":
|
||||
common.ResponseWithCodeData(c, common.CodeAuthenticationError, false, "no authorization")
|
||||
case "duplicated search name":
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, "Duplicated search name.")
|
||||
default:
|
||||
@@ -392,8 +392,7 @@ func (h *SearchHandler) Completion(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
plan, code, err := searchSvc.PrepareCompletion(ctx, user.ID, c.Param("search_id"), &req)
|
||||
if err != nil {
|
||||
if code == common.CodeAuthenticationError {
|
||||
common.ResponseWithCodeData(c, code, false, err.Error())
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
if code == common.CodeServerError {
|
||||
|
||||
@@ -91,10 +91,10 @@ func TestSearchHandlerUpdateRejectsInvalidSearchID(t *testing.T) {
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp["code"] != float64(common.CodeAuthenticationError) {
|
||||
t.Fatalf("expected code 109, got %v", resp["code"])
|
||||
if resp["code"] != float64(common.CodeNotFound) {
|
||||
t.Fatalf("expected code %d, got %v", common.CodeNotFound, resp["code"])
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(resp["message"].(string)), "no authorization") {
|
||||
t.Fatalf("expected 'no authorization' in message, got %v", resp["message"])
|
||||
if resp["message"] != "Resource not found" {
|
||||
t.Fatalf("message = %v, want hidden resource-not-found response", resp["message"])
|
||||
}
|
||||
}
|
||||
@@ -149,6 +149,9 @@ func (h *SearchBotHandler) Handle(c *gin.Context) {
|
||||
|
||||
questions, err := service.GenerateRelatedQuestions(ctx, user.ID, req.Question, req.SearchID, h.searchSvc, h.tenantSvc, h.modelFactory)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
common.Warn("searchbot related questions failed", zap.String("error", err.Error()))
|
||||
common.ResponseWithCodeData(c, common.CodeOperatingError, nil, err.Error())
|
||||
return
|
||||
@@ -209,6 +212,9 @@ func (h *SearchBotHandler) RetrievalTest(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
result, err := h.chunkSvc.RetrievalTest(ctx, svcReq, user.ID)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
common.Warn("search bot retrieval test failed", zap.String("error", err.Error()))
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, err.Error())
|
||||
return
|
||||
@@ -262,14 +268,24 @@ func (h *SearchBotHandler) Ask(c *gin.Context) {
|
||||
// Resolve chat model ID.
|
||||
modelID := ""
|
||||
options := service.AskStreamOptions{}
|
||||
if req.SearchID != "" && h.searchSvc != nil {
|
||||
if req.SearchID != "" {
|
||||
if h.searchSvc == nil {
|
||||
jsonInternalError(c, fmt.Errorf("search service not configured"))
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
if detail, err := h.searchSvc.GetDetail(ctx, req.SearchID); err == nil {
|
||||
searchConfig := searchConfigFromDetail(detail)
|
||||
options = service.BuildAskStreamOptions(req.SearchID, searchConfig)
|
||||
if chatID, ok := searchConfig["chat_id"].(string); ok && chatID != "" {
|
||||
modelID = chatID
|
||||
detail, err := h.searchSvc.GetDetail(ctx, user.ID, req.SearchID)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
common.ErrorWithCode(c, common.CodeDataError, err.Error())
|
||||
return
|
||||
}
|
||||
searchConfig := searchConfigFromDetail(detail)
|
||||
options = service.BuildAskStreamOptions(req.SearchID, searchConfig)
|
||||
if chatID, ok := searchConfig["chat_id"].(string); ok && chatID != "" {
|
||||
modelID = chatID
|
||||
}
|
||||
}
|
||||
if modelID == "" && h.tenantSvc != nil {
|
||||
@@ -366,8 +382,11 @@ func (h *SearchBotHandler) MindMap(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
detail, err := h.searchSvc.GetDetail(ctx, req.SearchID)
|
||||
detail, err := h.searchSvc.GetDetail(ctx, user.ID, req.SearchID)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
jsonInternalError(c, err)
|
||||
return
|
||||
}
|
||||
@@ -387,6 +406,9 @@ func (h *SearchBotHandler) MindMap(c *gin.Context) {
|
||||
TenantSvc: h.tenantSvc,
|
||||
})
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
common.Warn("searchbot mindmap failed", zap.String("error", err.Error()))
|
||||
common.ResponseWithCodeData(c, common.CodeOperatingError, nil, err.Error())
|
||||
return
|
||||
@@ -411,16 +433,16 @@ func (h *SearchBotHandler) SearchBotDetail(c *gin.Context) {
|
||||
common.ResponseWithCodeData(c, code, nil, "Authentication error: API key is invalid!")
|
||||
return
|
||||
}
|
||||
if h.searchSvc == nil {
|
||||
jsonInternalError(c, fmt.Errorf("search service not configured"))
|
||||
return
|
||||
}
|
||||
detail, err := h.searchSvc.GetSearchShareDetail(ctx, user.ID, searchID)
|
||||
if err != nil {
|
||||
switch err.Error() {
|
||||
case "has no permission for this operation":
|
||||
common.ResponseWithCodeData(c, common.CodeOperatingError, nil, "Has no permission for this operation.")
|
||||
case "can't find this Search App!":
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, "Can't find this Search App!")
|
||||
default:
|
||||
jsonInternalError(c, err)
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
jsonInternalError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -260,8 +260,8 @@ func TestSearchbotDetailRejectsUnauthorizedSearchAccess(t *testing.T) {
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
if resp["code"] != float64(common.CodeOperatingError) {
|
||||
t.Fatalf("code = %v, want %v", resp["code"], common.CodeOperatingError)
|
||||
if resp["code"] != float64(common.CodeNotFound) {
|
||||
t.Fatalf("code = %v, want %v", resp["code"], common.CodeNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,9 +290,22 @@ func TestSearchbotDetailDoesNotExposeInternalErrorText(t *testing.T) {
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("failed to create api token: %v", err)
|
||||
}
|
||||
if err := dao.DB.Create(&entity.Search{
|
||||
ID: "search-1",
|
||||
TenantID: "tenant-1",
|
||||
Name: "Search App",
|
||||
CreatedBy: "tenant-1",
|
||||
SearchConfig: entity.JSONMap{},
|
||||
Status: &status,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("failed to create search: %v", err)
|
||||
}
|
||||
if err := dao.DB.Exec("DROP TABLE user_tenant").Error; err != nil {
|
||||
t.Fatalf("failed to drop user_tenant table: %v", err)
|
||||
}
|
||||
if err := dao.DB.Exec("DROP TABLE search").Error; err != nil {
|
||||
t.Fatalf("failed to drop search table: %v", err)
|
||||
}
|
||||
|
||||
r := newSearchbotDetailRouter()
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -97,10 +97,8 @@ func (h *SkillSearchHandler) UpdateConfig(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
req.TenantID = user.ID
|
||||
|
||||
ctx := c.Request.Context()
|
||||
result, code, err := h.searchService.UpdateConfig(ctx, &req)
|
||||
result, code, err := h.searchService.UpdateConfig(ctx, user.ID, &req)
|
||||
if err != nil {
|
||||
common.ErrorWithCode(c, code, err.Error())
|
||||
return
|
||||
@@ -130,9 +128,7 @@ func (h *SkillSearchHandler) Search(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
req.TenantID = user.ID
|
||||
|
||||
result, code, err := h.searchService.Search(c.Request.Context(), &req, h.docEngine)
|
||||
result, code, err := h.searchService.Search(c.Request.Context(), user.ID, &req, h.docEngine)
|
||||
if err != nil {
|
||||
common.ErrorWithCode(c, code, err.Error())
|
||||
return
|
||||
@@ -195,12 +191,18 @@ func (h *SkillSearchHandler) IndexSkills(c *gin.Context) {
|
||||
|
||||
if h.docEngine.GetType() == "elasticsearch" {
|
||||
if err := h.indexerService.EnsureIndex(c.Request.Context(), user.ID, req.SpaceID, h.docEngine, embdID); err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
common.ResponseWithCodeData(c, common.CodeOperatingError, nil, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.indexerService.BatchIndexSkills(c.Request.Context(), user.ID, req.SpaceID, req.Skills, h.docEngine, embdID); err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
common.Error(fmt.Sprintf("Failed to batch index skills: tenantID=%s, spaceID=%s, error=%v", user.ID, req.SpaceID, err), err)
|
||||
common.ResponseWithCodeData(c, common.CodeOperatingError, nil, err.Error())
|
||||
return
|
||||
@@ -262,6 +264,9 @@ func (h *SkillSearchHandler) Reindex(c *gin.Context) {
|
||||
|
||||
result, err := h.indexerService.ReindexAll(c.Request.Context(), user.ID, req.SpaceID, h.docEngine, embdID)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
common.ResponseWithCodeData(c, common.CodeOperatingError, nil, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -296,6 +301,9 @@ func (h *SkillSearchHandler) DeleteSkillIndex(c *gin.Context) {
|
||||
|
||||
err := h.indexerService.DeleteSkillIndex(c.Request.Context(), user.ID, spaceID, skillID, h.docEngine)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
common.ResponseWithCodeData(c, common.CodeOperatingError, nil, "failed to delete skill index")
|
||||
return
|
||||
}
|
||||
@@ -329,6 +337,9 @@ func (h *SkillSearchHandler) InitializeIndex(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := h.indexerService.InitializeIndex(c.Request.Context(), user.ID, spaceID, h.docEngine, embdID); err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
}
|
||||
common.ResponseWithCodeData(c, common.CodeOperatingError, nil, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -394,8 +405,7 @@ func (h *SkillSearchHandler) CreateSpace(c *gin.Context) {
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
result, code, err := h.spaceService.CreateSpace(ctx, &service.CreateSpaceRequest{
|
||||
TenantID: user.ID,
|
||||
result, code, err := h.spaceService.CreateSpace(ctx, user.ID, &service.CreateSpaceRequest{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
EmbdID: req.EmbdID,
|
||||
@@ -431,7 +441,7 @@ func (h *SkillSearchHandler) GetSpace(c *gin.Context) {
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
result, code, err := h.spaceService.GetSpace(ctx, spaceID, user.ID)
|
||||
result, code, err := h.spaceService.GetSpace(ctx, user.ID, spaceID)
|
||||
if err != nil {
|
||||
common.ErrorWithCode(c, code, err.Error())
|
||||
return
|
||||
@@ -480,7 +490,7 @@ func (h *SkillSearchHandler) UpdateSpace(c *gin.Context) {
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
result, code, err := h.spaceService.UpdateSpace(ctx, spaceID, user.ID, &service.UpdateSpaceRequest{
|
||||
result, code, err := h.spaceService.UpdateSpace(ctx, user.ID, spaceID, &service.UpdateSpaceRequest{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
EmbdID: req.EmbdID,
|
||||
@@ -518,7 +528,7 @@ func (h *SkillSearchHandler) DeleteSpace(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
code, err := h.spaceService.DeleteSpace(c.Request.Context(), spaceID, user.ID, h.docEngine)
|
||||
code, err := h.spaceService.DeleteSpace(c.Request.Context(), user.ID, spaceID, h.docEngine)
|
||||
if err != nil {
|
||||
common.ErrorWithCode(c, code, err.Error())
|
||||
return
|
||||
@@ -556,7 +566,7 @@ func (h *SkillSearchHandler) GetSpaceByFolder(c *gin.Context) {
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
result, code, err := h.spaceService.GetSpaceByFolderID(ctx, folderID, user.ID)
|
||||
result, code, err := h.spaceService.GetSpaceByFolderID(ctx, user.ID, folderID)
|
||||
if err != nil {
|
||||
common.ErrorWithCode(c, code, err.Error())
|
||||
return
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
//go:build !enterprise
|
||||
|
||||
package permission
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"ragflow/internal/entity"
|
||||
)
|
||||
|
||||
func TestDatabaseCheckerScopesChatChannelsToTenantMembers(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+url.QueryEscape(t.Name())+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&entity.ChatChannel{}, &entity.UserTenant{}); err != nil {
|
||||
t.Fatalf("migrate chat-channel permission tables: %v", err)
|
||||
}
|
||||
|
||||
valid := "1"
|
||||
channels := []entity.ChatChannel{
|
||||
{ID: "team-channel", TenantID: "tenant-a", Name: "team", Channel: "slack", Config: entity.JSONMap{}, Status: 1},
|
||||
{ID: "inactive-channel", TenantID: "tenant-a", Name: "inactive", Channel: "slack", Config: entity.JSONMap{}, Status: 1},
|
||||
{ID: "other-channel", TenantID: "tenant-b", Name: "other", Channel: "slack", Config: entity.JSONMap{}, Status: 1},
|
||||
}
|
||||
if err := db.Create(&channels).Error; err != nil {
|
||||
t.Fatalf("create chat channels: %v", err)
|
||||
}
|
||||
if err := db.Model(&entity.ChatChannel{}).Where("id = ?", "inactive-channel").Update("status", 0).Error; err != nil {
|
||||
t.Fatalf("deactivate chat channel: %v", err)
|
||||
}
|
||||
if err := db.Create(&entity.UserTenant{
|
||||
ID: "membership-a", UserID: "member-a", TenantID: "tenant-a", Role: string(RoleNormal), Status: &valid,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create tenant membership: %v", err)
|
||||
}
|
||||
|
||||
checker := NewDatabaseChecker(db)
|
||||
member := Subject{UserID: "member-a"}
|
||||
for _, operation := range []Operation{OperationRead, OperationUpdate, OperationDelete, OperationUse} {
|
||||
if err := checker.CheckResource(context.Background(), member, ResourceRef{Kind: ResourceKindChatChannel, ID: "team-channel"}, operation); err != nil {
|
||||
t.Errorf("member cannot %s team channel: %v", operation, err)
|
||||
}
|
||||
}
|
||||
if err := checker.CheckResource(context.Background(), member, ResourceRef{Kind: ResourceKindChatChannel, ID: "other-channel"}, OperationRead); err != ErrPermissionDenied {
|
||||
t.Errorf("cross-tenant access error = %v, want %v", err, ErrPermissionDenied)
|
||||
}
|
||||
if err := checker.CheckResource(context.Background(), member, ResourceRef{Kind: ResourceKindChatChannel, ID: "inactive-channel"}, OperationRead); err != nil {
|
||||
t.Errorf("inactive channel should remain manageable: %v", err)
|
||||
}
|
||||
|
||||
scope, err := checker.Scope(context.Background(), member, ScopeQuery{Kind: ResourceKindChatChannel, Operation: OperationRead})
|
||||
if err != nil {
|
||||
t.Fatalf("scope chat channels: %v", err)
|
||||
}
|
||||
if scope.Mode != ScopeIDs || len(scope.ResourceIDs) != 2 {
|
||||
t.Fatalf("chat-channel scope = %+v, want both tenant channels", scope)
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,19 @@ func (s *databaseSource) GetResources(ctx context.Context, refs []ResourceRef) (
|
||||
datasetIDs := make([]string, 0, len(refs))
|
||||
canvasIDs := make([]string, 0, len(refs))
|
||||
agentSessionIDs := make([]string, 0, len(refs))
|
||||
memoryIDs := make([]string, 0, len(refs))
|
||||
fileIDs := make([]string, 0, len(refs))
|
||||
searchAppIDs := make([]string, 0, len(refs))
|
||||
chatIDs := make([]string, 0, len(refs))
|
||||
chatSessionIDs := make([]string, 0, len(refs))
|
||||
chatChannelIDs := make([]string, 0, len(refs))
|
||||
connectorIDs := make([]string, 0, len(refs))
|
||||
syncTaskIDs := make([]string, 0, len(refs))
|
||||
modelProviderIDs := make([]string, 0, len(refs))
|
||||
modelInstanceIDs := make([]string, 0, len(refs))
|
||||
tenantModelIDs := make([]string, 0, len(refs))
|
||||
mcpServerIDs := make([]string, 0, len(refs))
|
||||
skillSpaceIDs := make([]string, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
switch ref.Kind {
|
||||
case ResourceKindDataset:
|
||||
@@ -83,6 +96,32 @@ func (s *databaseSource) GetResources(ctx context.Context, refs []ResourceRef) (
|
||||
canvasIDs = append(canvasIDs, ref.ID)
|
||||
case ResourceKindAgentSession:
|
||||
agentSessionIDs = append(agentSessionIDs, ref.ID)
|
||||
case ResourceKindMemory:
|
||||
memoryIDs = append(memoryIDs, ref.ID)
|
||||
case ResourceKindFile, ResourceKindFolder:
|
||||
fileIDs = append(fileIDs, ref.ID)
|
||||
case ResourceKindSearchApp:
|
||||
searchAppIDs = append(searchAppIDs, ref.ID)
|
||||
case ResourceKindChat:
|
||||
chatIDs = append(chatIDs, ref.ID)
|
||||
case ResourceKindChatSession:
|
||||
chatSessionIDs = append(chatSessionIDs, ref.ID)
|
||||
case ResourceKindChatChannel:
|
||||
chatChannelIDs = append(chatChannelIDs, ref.ID)
|
||||
case ResourceKindConnector:
|
||||
connectorIDs = append(connectorIDs, ref.ID)
|
||||
case ResourceKindSyncTask:
|
||||
syncTaskIDs = append(syncTaskIDs, ref.ID)
|
||||
case ResourceKindModelProvider:
|
||||
modelProviderIDs = append(modelProviderIDs, ref.ID)
|
||||
case ResourceKindModelInstance:
|
||||
modelInstanceIDs = append(modelInstanceIDs, ref.ID)
|
||||
case ResourceKindModel:
|
||||
tenantModelIDs = append(tenantModelIDs, ref.ID)
|
||||
case ResourceKindMCPServer:
|
||||
mcpServerIDs = append(mcpServerIDs, ref.ID)
|
||||
case ResourceKindSkillSpace:
|
||||
skillSpaceIDs = append(skillSpaceIDs, ref.ID)
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: unsupported resource kind %q", ErrInvalidPermission, ref.Kind)
|
||||
}
|
||||
@@ -94,6 +133,13 @@ func (s *databaseSource) GetResources(ctx context.Context, refs []ResourceRef) (
|
||||
for _, session := range agentSessionsByID {
|
||||
canvasIDs = append(canvasIDs, session.DialogID)
|
||||
}
|
||||
chatSessionsByID, err := s.loadChatSessions(ctx, chatSessionIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, session := range chatSessionsByID {
|
||||
chatIDs = append(chatIDs, session.DialogID)
|
||||
}
|
||||
var datasetsByID map[string]*entity.Knowledgebase
|
||||
var ownerIDsByTenant map[string]string
|
||||
if len(datasetIDs) > 0 {
|
||||
@@ -107,6 +153,108 @@ func (s *databaseSource) GetResources(ctx context.Context, refs []ResourceRef) (
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
memoriesByID, err := s.loadMemories(ctx, memoryIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filesByID, err := s.loadFiles(ctx, fileIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fileDatasetIDs, err := s.loadFileDatasetIDs(ctx, fileIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
linkedDatasetIDs := make([]string, 0)
|
||||
for _, ids := range fileDatasetIDs {
|
||||
linkedDatasetIDs = append(linkedDatasetIDs, ids...)
|
||||
}
|
||||
linkedDatasetIDs = uniqueStringValues(linkedDatasetIDs)
|
||||
linkedDatasetsByID, linkedOwnerIDsByTenant, err := s.loadDatasets(ctx, linkedDatasetIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
searchAppsByID, err := s.loadSearchApps(ctx, searchAppIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
chatsByID, err := s.loadChats(ctx, chatIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
chatChannelsByID, err := s.loadChatChannels(ctx, chatChannelIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
syncTasksByID, err := s.loadSyncTasks(ctx, syncTaskIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, task := range syncTasksByID {
|
||||
connectorIDs = append(connectorIDs, task.ConnectorID)
|
||||
}
|
||||
connectorIDs = uniqueStringValues(connectorIDs)
|
||||
connectorsByID, err := s.loadConnectors(ctx, connectorIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
syncTaskMappings, err := s.loadSyncTaskMappings(ctx, syncTasksByID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tenantModelsByID, err := s.loadTenantModels(ctx, tenantModelIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, model := range tenantModelsByID {
|
||||
modelProviderIDs = append(modelProviderIDs, model.ProviderID)
|
||||
modelInstanceIDs = append(modelInstanceIDs, model.InstanceID)
|
||||
}
|
||||
modelInstancesByID, err := s.loadModelInstances(ctx, modelInstanceIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, instance := range modelInstancesByID {
|
||||
modelProviderIDs = append(modelProviderIDs, instance.ProviderID)
|
||||
}
|
||||
modelProviderIDs = uniqueStringValues(modelProviderIDs)
|
||||
modelProvidersByID, err := s.loadModelProviders(ctx, modelProviderIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mcpServerTenantsByID, err := s.loadMCPServerTenants(ctx, mcpServerIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
skillSpacesByID, err := s.loadSkillSpaces(ctx, skillSpaceIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
skillSpaceFolderIDs := make([]string, 0, len(skillSpacesByID))
|
||||
integrationTenantIDs := make([]string, 0, len(mcpServerTenantsByID)+len(skillSpacesByID))
|
||||
for _, tenantID := range mcpServerTenantsByID {
|
||||
integrationTenantIDs = append(integrationTenantIDs, tenantID)
|
||||
}
|
||||
for _, space := range skillSpacesByID {
|
||||
skillSpaceFolderIDs = append(skillSpaceFolderIDs, space.FolderID)
|
||||
integrationTenantIDs = append(integrationTenantIDs, space.TenantID)
|
||||
}
|
||||
skillSpaceFoldersByID, err := s.loadSkillSpaceFolders(ctx, skillSpaceFolderIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
integrationOwnerIDsByTenant, err := s.loadTenantOwnerIDs(ctx, integrationTenantIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
modelTenantIDs := make([]string, 0, len(modelProvidersByID))
|
||||
for _, provider := range modelProvidersByID {
|
||||
modelTenantIDs = append(modelTenantIDs, provider.TenantID)
|
||||
}
|
||||
modelOwnerIDsByTenant, err := s.loadTenantOwnerIDs(ctx, modelTenantIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resources := make([]Resource, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
@@ -129,6 +277,117 @@ func (s *databaseSource) GetResources(ctx context.Context, refs []ResourceRef) (
|
||||
continue
|
||||
}
|
||||
resources = append(resources, agentSessionResource(session, canvas))
|
||||
case ResourceKindMemory:
|
||||
if memory, ok := memoriesByID[ref.ID]; ok {
|
||||
resources = append(resources, memoryResource(memory))
|
||||
}
|
||||
case ResourceKindFile, ResourceKindFolder:
|
||||
file, ok := filesByID[ref.ID]
|
||||
if !ok || (ref.Kind == ResourceKindFolder) != (file.Type == "folder") {
|
||||
continue
|
||||
}
|
||||
resources = append(resources, fileResource(ref.Kind, file, fileDatasetIDs[ref.ID], linkedDatasetsByID, linkedOwnerIDsByTenant))
|
||||
case ResourceKindSearchApp:
|
||||
if searchApp, ok := searchAppsByID[ref.ID]; ok {
|
||||
resources = append(resources, searchAppResource(searchApp))
|
||||
}
|
||||
case ResourceKindChat:
|
||||
if chat, ok := chatsByID[ref.ID]; ok {
|
||||
resources = append(resources, chatResource(chat))
|
||||
}
|
||||
case ResourceKindChatChannel:
|
||||
if channel, ok := chatChannelsByID[ref.ID]; ok {
|
||||
resources = append(resources, chatChannelResource(channel))
|
||||
}
|
||||
case ResourceKindChatSession:
|
||||
session, ok := chatSessionsByID[ref.ID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
chat, ok := chatsByID[session.DialogID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
resources = append(resources, chatSessionResource(session, chat))
|
||||
case ResourceKindConnector:
|
||||
if connector, ok := connectorsByID[ref.ID]; ok {
|
||||
resources = append(resources, connectorResource(connector))
|
||||
}
|
||||
case ResourceKindSyncTask:
|
||||
task, ok := syncTasksByID[ref.ID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
connector, ok := connectorsByID[task.ConnectorID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
_, linked := syncTaskMappings[connectorDatasetRef{connectorID: task.ConnectorID, datasetID: task.KbID}]
|
||||
resources = append(resources, syncTaskResource(task, connector, linked))
|
||||
case ResourceKindModelProvider:
|
||||
if provider, ok := modelProvidersByID[ref.ID]; ok {
|
||||
ownerUserID := modelOwnerIDsByTenant[provider.TenantID]
|
||||
if ownerUserID == "" {
|
||||
return nil, fmt.Errorf("%w: tenant %q has no active owner", ErrInvalidPermission, provider.TenantID)
|
||||
}
|
||||
resources = append(resources, modelProviderResource(provider, ownerUserID))
|
||||
}
|
||||
case ResourceKindModelInstance:
|
||||
instance, ok := modelInstancesByID[ref.ID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
provider, ok := modelProvidersByID[instance.ProviderID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ownerUserID := modelOwnerIDsByTenant[provider.TenantID]
|
||||
if ownerUserID == "" {
|
||||
return nil, fmt.Errorf("%w: tenant %q has no active owner", ErrInvalidPermission, provider.TenantID)
|
||||
}
|
||||
resources = append(resources, modelInstanceResource(instance, provider, ownerUserID))
|
||||
case ResourceKindModel:
|
||||
model, ok := tenantModelsByID[ref.ID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
instance, ok := modelInstancesByID[model.InstanceID]
|
||||
if !ok || instance.ProviderID != model.ProviderID {
|
||||
continue
|
||||
}
|
||||
provider, ok := modelProvidersByID[model.ProviderID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ownerUserID := modelOwnerIDsByTenant[provider.TenantID]
|
||||
if ownerUserID == "" {
|
||||
return nil, fmt.Errorf("%w: tenant %q has no active owner", ErrInvalidPermission, provider.TenantID)
|
||||
}
|
||||
resources = append(resources, tenantModelResource(model, provider, ownerUserID))
|
||||
case ResourceKindMCPServer:
|
||||
tenantID, ok := mcpServerTenantsByID[ref.ID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ownerUserID := integrationOwnerIDsByTenant[tenantID]
|
||||
if ownerUserID == "" {
|
||||
return nil, fmt.Errorf("%w: tenant %q has no active owner", ErrInvalidPermission, tenantID)
|
||||
}
|
||||
resources = append(resources, mcpServerResource(ref.ID, tenantID, ownerUserID))
|
||||
case ResourceKindSkillSpace:
|
||||
space, ok := skillSpacesByID[ref.ID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
folder, ok := skillSpaceFoldersByID[space.FolderID]
|
||||
if !ok || folder.TenantID != space.TenantID || folder.Type != "folder" {
|
||||
continue
|
||||
}
|
||||
ownerUserID := integrationOwnerIDsByTenant[space.TenantID]
|
||||
if ownerUserID == "" {
|
||||
return nil, fmt.Errorf("%w: tenant %q has no active owner", ErrInvalidPermission, space.TenantID)
|
||||
}
|
||||
resources = append(resources, skillSpaceResource(space.ID, space.TenantID, ownerUserID))
|
||||
}
|
||||
}
|
||||
return resources, nil
|
||||
@@ -188,6 +447,217 @@ func (s *databaseSource) ListResourceRefs(ctx context.Context, subject Subject,
|
||||
Pluck("id", &ids).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case ResourceKindMemory:
|
||||
if parent.ID != "" || parent.Kind != "" {
|
||||
return nil, fmt.Errorf("%w: memories cannot have a parent scope", ErrInvalidPermission)
|
||||
}
|
||||
var tenantIDs []string
|
||||
if err := s.db.WithContext(ctx).Model(&entity.UserTenant{}).
|
||||
Distinct("tenant_id").
|
||||
Where("user_id = ? AND status = ?", subject.UserID, string(entity.StatusValid)).
|
||||
Pluck("tenant_id", &tenantIDs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ownerIDs := append(tenantIDs, subject.UserID)
|
||||
if err := s.db.WithContext(ctx).Model(&entity.Memory{}).
|
||||
Where("tenant_id IN ?", ownerIDs).
|
||||
Pluck("id", &ids).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case ResourceKindFile, ResourceKindFolder:
|
||||
if parent.Kind != ResourceKindFolder || strings.TrimSpace(parent.ID) == "" {
|
||||
return nil, fmt.Errorf("%w: files and folders require a folder parent scope", ErrInvalidPermission)
|
||||
}
|
||||
parentQuery := s.db.WithContext(ctx).Model(&entity.File{}).
|
||||
Where("id = ? AND tenant_id = ? AND type = ?", parent.ID, subject.UserID, "folder")
|
||||
var parentFile entity.File
|
||||
if err := parentQuery.First(&parentFile).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return []ResourceRef{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
query := s.db.WithContext(ctx).Model(&entity.File{}).
|
||||
Select("id").
|
||||
Where("tenant_id = ? AND parent_id = ? AND id <> ?", parentFile.TenantID, parentFile.ID, parentFile.ID)
|
||||
if kind == ResourceKindFolder {
|
||||
query = query.Where("type = ?", "folder")
|
||||
} else {
|
||||
query = query.Where("type <> ?", "folder")
|
||||
}
|
||||
if err := query.Pluck("id", &ids).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case ResourceKindSearchApp:
|
||||
if parent.ID != "" || parent.Kind != "" {
|
||||
return nil, fmt.Errorf("%w: search apps cannot have a parent scope", ErrInvalidPermission)
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Model(&entity.Search{}).
|
||||
Where("status = ? AND created_by = ?", string(entity.StatusValid), subject.UserID).
|
||||
Pluck("id", &ids).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case ResourceKindChat:
|
||||
if parent.ID != "" || parent.Kind != "" {
|
||||
return nil, fmt.Errorf("%w: chats cannot have a parent scope", ErrInvalidPermission)
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Model(&entity.Chat{}).
|
||||
Where("tenant_id = ? AND status = ?", subject.UserID, string(entity.StatusValid)).
|
||||
Pluck("id", &ids).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case ResourceKindChatSession:
|
||||
if parent.Kind != ResourceKindChat || strings.TrimSpace(parent.ID) == "" {
|
||||
return nil, fmt.Errorf("%w: chat sessions require a chat parent scope", ErrInvalidPermission)
|
||||
}
|
||||
var chatCount int64
|
||||
if err := s.db.WithContext(ctx).Model(&entity.Chat{}).
|
||||
Where("id = ? AND status = ?", parent.ID, string(entity.StatusValid)).
|
||||
Count(&chatCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if chatCount == 0 {
|
||||
return []ResourceRef{}, nil
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Model(&entity.ChatSession{}).
|
||||
Where("dialog_id = ?", parent.ID).
|
||||
Pluck("id", &ids).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case ResourceKindChatChannel:
|
||||
if parent.ID != "" || parent.Kind != "" {
|
||||
return nil, fmt.Errorf("%w: chat channels cannot have a parent scope", ErrInvalidPermission)
|
||||
}
|
||||
tenantIDs, err := s.tenantIDsForSubject(ctx, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(tenantIDs) == 0 {
|
||||
return []ResourceRef{}, nil
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Model(&entity.ChatChannel{}).
|
||||
Where("tenant_id IN ?", tenantIDs).
|
||||
Pluck("id", &ids).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case ResourceKindConnector:
|
||||
if parent.ID != "" || parent.Kind != "" {
|
||||
return nil, fmt.Errorf("%w: connectors cannot have a parent scope", ErrInvalidPermission)
|
||||
}
|
||||
var tenantIDs []string
|
||||
if err := s.db.WithContext(ctx).Model(&entity.UserTenant{}).
|
||||
Distinct("tenant_id").
|
||||
Where("user_id = ? AND status = ?", subject.UserID, string(entity.StatusValid)).
|
||||
Pluck("tenant_id", &tenantIDs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tenantIDs = uniqueStringValues(append(tenantIDs, subject.UserID))
|
||||
if err := s.db.WithContext(ctx).Model(&entity.Connector{}).
|
||||
Where("tenant_id IN ?", tenantIDs).
|
||||
Pluck("id", &ids).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case ResourceKindSyncTask:
|
||||
if parent.Kind != ResourceKindConnector || strings.TrimSpace(parent.ID) == "" {
|
||||
return nil, fmt.Errorf("%w: sync tasks require a connector parent scope", ErrInvalidPermission)
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Model(&entity.SyncLogs{}).
|
||||
Where("sync_logs.connector_id = ? AND EXISTS (SELECT 1 FROM connector2kb JOIN knowledgebase ON knowledgebase.id = connector2kb.kb_id WHERE connector2kb.connector_id = sync_logs.connector_id AND connector2kb.kb_id = sync_logs.kb_id)", parent.ID).
|
||||
Pluck("sync_logs.id", &ids).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case ResourceKindModelProvider:
|
||||
if parent.ID != "" || parent.Kind != "" {
|
||||
return nil, fmt.Errorf("%w: model providers cannot have a parent scope", ErrInvalidPermission)
|
||||
}
|
||||
tenantIDs, err := s.tenantIDsForSubject(ctx, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(tenantIDs) == 0 {
|
||||
return []ResourceRef{}, nil
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Model(&entity.TenantModelProvider{}).
|
||||
Where("tenant_id IN ?", tenantIDs).
|
||||
Pluck("id", &ids).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case ResourceKindModelInstance:
|
||||
if (parent.ID != "" && parent.Kind != ResourceKindModelProvider) || (parent.ID == "" && parent.Kind != "") {
|
||||
return nil, fmt.Errorf("%w: model instances require a model provider parent", ErrInvalidPermission)
|
||||
}
|
||||
query := s.db.WithContext(ctx).Model(&entity.TenantModelInstance{}).Select("tenant_model_instance.id")
|
||||
if parent.ID != "" {
|
||||
query = query.Where("tenant_model_instance.provider_id = ?", parent.ID)
|
||||
} else {
|
||||
tenantIDs, err := s.tenantIDsForSubject(ctx, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(tenantIDs) == 0 {
|
||||
return []ResourceRef{}, nil
|
||||
}
|
||||
query = query.Joins("JOIN tenant_model_provider ON tenant_model_provider.id = tenant_model_instance.provider_id").
|
||||
Where("tenant_model_provider.tenant_id IN ?", tenantIDs)
|
||||
}
|
||||
if err := query.Pluck("tenant_model_instance.id", &ids).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case ResourceKindModel:
|
||||
switch parent.Kind {
|
||||
case "":
|
||||
if parent.ID != "" {
|
||||
return nil, fmt.Errorf("%w: model parent kind is required", ErrInvalidPermission)
|
||||
}
|
||||
case ResourceKindModelProvider, ResourceKindModelInstance:
|
||||
if strings.TrimSpace(parent.ID) == "" {
|
||||
return nil, fmt.Errorf("%w: model parent ID is required", ErrInvalidPermission)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: models require a model provider or model instance parent", ErrInvalidPermission)
|
||||
}
|
||||
query := s.db.WithContext(ctx).Model(&entity.TenantModel{}).Select("tenant_model.id")
|
||||
switch parent.Kind {
|
||||
case ResourceKindModelProvider:
|
||||
query = query.Where("tenant_model.provider_id = ?", parent.ID)
|
||||
case ResourceKindModelInstance:
|
||||
query = query.Where("tenant_model.instance_id = ?", parent.ID)
|
||||
default:
|
||||
tenantIDs, err := s.tenantIDsForSubject(ctx, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(tenantIDs) == 0 {
|
||||
return []ResourceRef{}, nil
|
||||
}
|
||||
query = query.Joins("JOIN tenant_model_provider ON tenant_model_provider.id = tenant_model.provider_id").
|
||||
Where("tenant_model_provider.tenant_id IN ?", tenantIDs)
|
||||
}
|
||||
if err := query.Pluck("tenant_model.id", &ids).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case ResourceKindMCPServer, ResourceKindSkillSpace:
|
||||
if parent.ID != "" || parent.Kind != "" {
|
||||
return nil, fmt.Errorf("%w: %s resources cannot have a parent scope", ErrInvalidPermission, kind)
|
||||
}
|
||||
tenantIDs, err := s.tenantIDsForSubject(ctx, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(tenantIDs) == 0 {
|
||||
return []ResourceRef{}, nil
|
||||
}
|
||||
var query *gorm.DB
|
||||
if kind == ResourceKindMCPServer {
|
||||
query = s.db.WithContext(ctx).Model(&entity.MCPServer{}).
|
||||
Where("tenant_id IN ?", tenantIDs)
|
||||
} else {
|
||||
query = s.db.WithContext(ctx).Model(&entity.SkillSpace{}).
|
||||
Where("tenant_id IN ? AND status = ?", tenantIDs, entity.SpaceStatusActive)
|
||||
}
|
||||
if err := query.Pluck("id", &ids).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: unsupported scope resource kind %q", ErrInvalidPermission, kind)
|
||||
}
|
||||
@@ -199,6 +669,186 @@ func (s *databaseSource) ListResourceRefs(ctx context.Context, subject Subject,
|
||||
return refs, nil
|
||||
}
|
||||
|
||||
func (s *databaseSource) loadConnectors(ctx context.Context, ids []string) (map[string]*entity.Connector, error) {
|
||||
connectorsByID := make(map[string]*entity.Connector)
|
||||
if len(ids) == 0 {
|
||||
return connectorsByID, nil
|
||||
}
|
||||
query := s.db.WithContext(ctx).Model(&entity.Connector{}).Where("id IN ?", ids)
|
||||
if s.lockRows {
|
||||
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
}
|
||||
var connectors []*entity.Connector
|
||||
if err := query.Find(&connectors).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, connector := range connectors {
|
||||
connectorsByID[connector.ID] = connector
|
||||
}
|
||||
return connectorsByID, nil
|
||||
}
|
||||
|
||||
func (s *databaseSource) loadSyncTasks(ctx context.Context, ids []string) (map[string]*entity.SyncLogs, error) {
|
||||
tasksByID := make(map[string]*entity.SyncLogs)
|
||||
if len(ids) == 0 {
|
||||
return tasksByID, nil
|
||||
}
|
||||
query := s.db.WithContext(ctx).Model(&entity.SyncLogs{}).Where("id IN ?", ids)
|
||||
if s.lockRows {
|
||||
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
}
|
||||
var tasks []*entity.SyncLogs
|
||||
if err := query.Find(&tasks).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, task := range tasks {
|
||||
tasksByID[task.ID] = task
|
||||
}
|
||||
return tasksByID, nil
|
||||
}
|
||||
|
||||
type connectorDatasetRef struct {
|
||||
connectorID string
|
||||
datasetID string
|
||||
}
|
||||
|
||||
func (s *databaseSource) loadSyncTaskMappings(ctx context.Context, tasks map[string]*entity.SyncLogs) (map[connectorDatasetRef]struct{}, error) {
|
||||
mappings := make(map[connectorDatasetRef]struct{})
|
||||
connectorIDs := make([]string, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
connectorIDs = append(connectorIDs, task.ConnectorID)
|
||||
}
|
||||
connectorIDs = uniqueStringValues(connectorIDs)
|
||||
if len(connectorIDs) == 0 {
|
||||
return mappings, nil
|
||||
}
|
||||
query := s.db.WithContext(ctx).Model(&entity.Connector2Kb{}).
|
||||
Joins("JOIN knowledgebase ON knowledgebase.id = connector2kb.kb_id").
|
||||
Where("connector2kb.connector_id IN ?", connectorIDs)
|
||||
if s.lockRows {
|
||||
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
}
|
||||
var links []entity.Connector2Kb
|
||||
if err := query.Find(&links).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, link := range links {
|
||||
mappings[connectorDatasetRef{connectorID: link.ConnectorID, datasetID: link.KbID}] = struct{}{}
|
||||
}
|
||||
return mappings, nil
|
||||
}
|
||||
|
||||
func (s *databaseSource) loadFiles(ctx context.Context, ids []string) (map[string]*entity.File, error) {
|
||||
filesByID := make(map[string]*entity.File)
|
||||
if len(ids) == 0 {
|
||||
return filesByID, nil
|
||||
}
|
||||
query := s.db.WithContext(ctx).Model(&entity.File{}).Where("id IN ?", ids)
|
||||
if s.lockRows {
|
||||
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
}
|
||||
var files []*entity.File
|
||||
if err := query.Find(&files).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, file := range files {
|
||||
filesByID[file.ID] = file
|
||||
}
|
||||
return filesByID, nil
|
||||
}
|
||||
|
||||
func (s *databaseSource) loadChats(ctx context.Context, ids []string) (map[string]*entity.Chat, error) {
|
||||
chatsByID := make(map[string]*entity.Chat)
|
||||
if len(ids) == 0 {
|
||||
return chatsByID, nil
|
||||
}
|
||||
query := s.db.WithContext(ctx).Model(&entity.Chat{}).
|
||||
Where("id IN ? AND status = ?", ids, string(entity.StatusValid))
|
||||
if s.lockRows {
|
||||
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
}
|
||||
var chats []*entity.Chat
|
||||
if err := query.Find(&chats).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, chat := range chats {
|
||||
chatsByID[chat.ID] = chat
|
||||
}
|
||||
return chatsByID, nil
|
||||
}
|
||||
|
||||
func (s *databaseSource) loadSearchApps(ctx context.Context, ids []string) (map[string]*entity.Search, error) {
|
||||
searchAppsByID := make(map[string]*entity.Search)
|
||||
if len(ids) == 0 {
|
||||
return searchAppsByID, nil
|
||||
}
|
||||
query := s.db.WithContext(ctx).Model(&entity.Search{}).
|
||||
Where("id IN ? AND status = ?", ids, string(entity.StatusValid))
|
||||
if s.lockRows {
|
||||
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
}
|
||||
var searchApps []*entity.Search
|
||||
if err := query.Find(&searchApps).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, searchApp := range searchApps {
|
||||
searchAppsByID[searchApp.ID] = searchApp
|
||||
}
|
||||
return searchAppsByID, nil
|
||||
}
|
||||
|
||||
func (s *databaseSource) loadChatSessions(ctx context.Context, ids []string) (map[string]*entity.ChatSession, error) {
|
||||
sessionsByID := make(map[string]*entity.ChatSession)
|
||||
if len(ids) == 0 {
|
||||
return sessionsByID, nil
|
||||
}
|
||||
query := s.db.WithContext(ctx).Model(&entity.ChatSession{}).Where("id IN ?", ids)
|
||||
if s.lockRows {
|
||||
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
}
|
||||
var sessions []*entity.ChatSession
|
||||
if err := query.Find(&sessions).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, session := range sessions {
|
||||
sessionsByID[session.ID] = session
|
||||
}
|
||||
return sessionsByID, nil
|
||||
}
|
||||
|
||||
func (s *databaseSource) loadFileDatasetIDs(ctx context.Context, fileIDs []string) (map[string][]string, error) {
|
||||
datasetIDsByFile := make(map[string][]string)
|
||||
if len(fileIDs) == 0 {
|
||||
return datasetIDsByFile, nil
|
||||
}
|
||||
var links []struct {
|
||||
FileID string `gorm:"column:file_id"`
|
||||
DatasetID string `gorm:"column:dataset_id"`
|
||||
}
|
||||
query := s.db.WithContext(ctx).Table("file2document AS f2d").
|
||||
Select("f2d.file_id AS file_id, document.kb_id AS dataset_id").
|
||||
Joins("JOIN document ON document.id = f2d.document_id").
|
||||
Where("f2d.file_id IN ?", fileIDs)
|
||||
if s.lockRows {
|
||||
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
}
|
||||
if err := query.Find(&links).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
datasetIDs := make(map[string]struct{})
|
||||
for _, link := range links {
|
||||
if link.FileID == "" || link.DatasetID == "" {
|
||||
continue
|
||||
}
|
||||
datasetIDsByFile[link.FileID] = append(datasetIDsByFile[link.FileID], link.DatasetID)
|
||||
datasetIDs[link.DatasetID] = struct{}{}
|
||||
}
|
||||
for fileID, ids := range datasetIDsByFile {
|
||||
datasetIDsByFile[fileID] = uniqueStringValues(ids)
|
||||
}
|
||||
return datasetIDsByFile, nil
|
||||
}
|
||||
|
||||
func (s *databaseSource) loadCanvases(ctx context.Context, ids []string) (map[string]*entity.UserCanvas, error) {
|
||||
canvasesByID := make(map[string]*entity.UserCanvas)
|
||||
if len(ids) == 0 {
|
||||
@@ -237,6 +887,25 @@ func (s *databaseSource) loadAgentSessions(ctx context.Context, ids []string) (m
|
||||
return sessionsByID, nil
|
||||
}
|
||||
|
||||
func (s *databaseSource) loadMemories(ctx context.Context, ids []string) (map[string]*entity.Memory, error) {
|
||||
memoriesByID := make(map[string]*entity.Memory)
|
||||
if len(ids) == 0 {
|
||||
return memoriesByID, nil
|
||||
}
|
||||
query := s.db.WithContext(ctx).Model(&entity.Memory{}).Where("id IN ?", ids)
|
||||
if s.lockRows {
|
||||
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
}
|
||||
var memories []*entity.Memory
|
||||
if err := query.Find(&memories).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, memory := range memories {
|
||||
memoriesByID[memory.ID] = memory
|
||||
}
|
||||
return memoriesByID, nil
|
||||
}
|
||||
|
||||
func (s *databaseSource) loadDatasets(ctx context.Context, ids []string) (map[string]*entity.Knowledgebase, map[string]string, error) {
|
||||
datasetsByID := make(map[string]*entity.Knowledgebase)
|
||||
if len(ids) == 0 {
|
||||
@@ -260,22 +929,10 @@ func (s *databaseSource) loadDatasets(ctx context.Context, ids []string) (map[st
|
||||
tenantIDs = append(tenantIDs, dataset.TenantID)
|
||||
}
|
||||
|
||||
ownerQuery := s.db.WithContext(ctx).Model(&entity.UserTenant{}).
|
||||
Where("tenant_id IN ? AND role = ? AND status = ?", tenantIDs, string(RoleOwner), string(entity.StatusValid))
|
||||
if s.lockRows {
|
||||
ownerQuery = ownerQuery.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
}
|
||||
var owners []entity.UserTenant
|
||||
if err := ownerQuery.Find(&owners).Error; err != nil {
|
||||
ownerIDsByTenant, err := s.loadTenantOwnerIDs(ctx, tenantIDs)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
ownerIDsByTenant := make(map[string]string, len(owners))
|
||||
for _, owner := range owners {
|
||||
if existingOwnerID, exists := ownerIDsByTenant[owner.TenantID]; exists && existingOwnerID != owner.UserID {
|
||||
return nil, nil, fmt.Errorf("%w: tenant %q has multiple active owners", ErrInvalidPermission, owner.TenantID)
|
||||
}
|
||||
ownerIDsByTenant[owner.TenantID] = owner.UserID
|
||||
}
|
||||
for _, dataset := range datasets {
|
||||
ownerUserID := ownerIDsByTenant[dataset.TenantID]
|
||||
if ownerUserID == "" {
|
||||
@@ -296,13 +953,15 @@ func datasetResource(dataset *entity.Knowledgebase, ownerIDsByTenant map[string]
|
||||
visibility = VisibilityTenant
|
||||
}
|
||||
return Resource{
|
||||
Ref: ResourceRef{Kind: ResourceKindDataset, ID: dataset.ID},
|
||||
TenantID: dataset.TenantID,
|
||||
CreatedBy: dataset.CreatedBy,
|
||||
OwnerUserID: ownerUserID,
|
||||
Visibility: visibility,
|
||||
OwnerOperations: []Operation{OperationRead, OperationCreate, OperationUpdate, OperationDelete, OperationShare, OperationRun, OperationUse},
|
||||
Active: dataset.Status != nil && *dataset.Status == string(entity.StatusValid),
|
||||
Ref: ResourceRef{Kind: ResourceKindDataset, ID: dataset.ID},
|
||||
TenantID: dataset.TenantID,
|
||||
CreatedBy: dataset.CreatedBy,
|
||||
OwnerUserID: ownerUserID,
|
||||
Visibility: visibility,
|
||||
TenantRequirement: TenantNormalMember,
|
||||
OwnerOperations: []Operation{OperationRead, OperationCreate, OperationUpdate, OperationDelete, OperationDeleteContent, OperationShare, OperationRun, OperationUse},
|
||||
TenantOperations: []Operation{OperationRead, OperationUpdate, OperationDeleteContent, OperationRun, OperationUse},
|
||||
Active: dataset.Status != nil && *dataset.Status == string(entity.StatusValid),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,6 +978,7 @@ func canvasResource(canvas *entity.UserCanvas) Resource {
|
||||
Visibility: visibility,
|
||||
TenantRequirement: TenantMember,
|
||||
OwnerOperations: []Operation{OperationRead, OperationUpdate, OperationDelete, OperationShare, OperationRun, OperationUse},
|
||||
TenantOperations: []Operation{OperationRead, OperationUpdate, OperationRun, OperationUse},
|
||||
Active: true,
|
||||
}
|
||||
}
|
||||
@@ -334,3 +994,182 @@ func agentSessionResource(session *entity.API4Conversation, canvas *entity.UserC
|
||||
Active: true,
|
||||
}
|
||||
}
|
||||
|
||||
func memoryResource(memory *entity.Memory) Resource {
|
||||
visibility := VisibilityPrivate
|
||||
if memory.Permissions == string(entity.TenantPermissionTeam) {
|
||||
visibility = VisibilityTenant
|
||||
}
|
||||
return Resource{
|
||||
Ref: ResourceRef{Kind: ResourceKindMemory, ID: memory.ID},
|
||||
TenantID: memory.TenantID,
|
||||
OwnerUserID: memory.TenantID,
|
||||
Visibility: visibility,
|
||||
TenantRequirement: TenantMember,
|
||||
OwnerOperations: []Operation{OperationRead, OperationCreate, OperationUpdate, OperationDelete, OperationShare, OperationRun, OperationUse},
|
||||
TenantOperations: []Operation{OperationRead, OperationUpdate, OperationDelete, OperationRun, OperationUse},
|
||||
Active: true,
|
||||
}
|
||||
}
|
||||
|
||||
func chatResource(chat *entity.Chat) Resource {
|
||||
return Resource{
|
||||
Ref: ResourceRef{Kind: ResourceKindChat, ID: chat.ID},
|
||||
TenantID: chat.TenantID,
|
||||
OwnerUserID: chat.TenantID,
|
||||
Visibility: VisibilityPrivate,
|
||||
OwnerOperations: []Operation{OperationRead, OperationCreate, OperationUpdate, OperationDelete, OperationRun, OperationUse},
|
||||
Active: chat.Status != nil && *chat.Status == string(entity.StatusValid),
|
||||
}
|
||||
}
|
||||
|
||||
func searchAppResource(searchApp *entity.Search) Resource {
|
||||
return Resource{
|
||||
Ref: ResourceRef{Kind: ResourceKindSearchApp, ID: searchApp.ID},
|
||||
TenantID: searchApp.TenantID,
|
||||
CreatedBy: searchApp.CreatedBy,
|
||||
OwnerUserID: searchApp.CreatedBy,
|
||||
Visibility: VisibilityPrivate,
|
||||
OwnerOperations: []Operation{OperationRead, OperationUpdate, OperationDelete, OperationRun, OperationUse},
|
||||
Active: searchApp.Status != nil && *searchApp.Status == string(entity.StatusValid),
|
||||
}
|
||||
}
|
||||
|
||||
func chatSessionResource(session *entity.ChatSession, chat *entity.Chat) Resource {
|
||||
createdBy := ""
|
||||
if session.UserID != nil {
|
||||
createdBy = *session.UserID
|
||||
}
|
||||
return Resource{
|
||||
Ref: ResourceRef{Kind: ResourceKindChatSession, ID: session.ID},
|
||||
TenantID: chat.TenantID,
|
||||
CreatedBy: createdBy,
|
||||
OwnerUserID: chat.TenantID,
|
||||
Visibility: VisibilityTenant,
|
||||
TenantRequirement: TenantMember,
|
||||
OwnerOperations: []Operation{OperationRead, OperationUpdate, OperationDelete, OperationRun, OperationUse},
|
||||
TenantOperations: []Operation{OperationRead},
|
||||
CreatorOperations: []Operation{OperationRead, OperationUpdate, OperationDelete, OperationRun, OperationUse},
|
||||
Active: true,
|
||||
}
|
||||
}
|
||||
|
||||
func chatChannelResource(channel *entity.ChatChannel) Resource {
|
||||
operations := []Operation{OperationRead, OperationUpdate, OperationDelete, OperationUse}
|
||||
return Resource{
|
||||
Ref: ResourceRef{Kind: ResourceKindChatChannel, ID: channel.ID},
|
||||
TenantID: channel.TenantID,
|
||||
OwnerUserID: channel.TenantID,
|
||||
Visibility: VisibilityTenant,
|
||||
TenantRequirement: TenantMember,
|
||||
OwnerOperations: operations,
|
||||
TenantOperations: operations,
|
||||
Active: true,
|
||||
}
|
||||
}
|
||||
|
||||
func connectorResource(connector *entity.Connector) Resource {
|
||||
ownerOperations := []Operation{OperationRead, OperationUpdate, OperationDelete, OperationRun, OperationUse}
|
||||
return Resource{
|
||||
Ref: ResourceRef{Kind: ResourceKindConnector, ID: connector.ID},
|
||||
TenantID: connector.TenantID,
|
||||
OwnerUserID: connector.TenantID,
|
||||
Visibility: VisibilityTenant,
|
||||
TenantRequirement: TenantMember,
|
||||
OwnerOperations: ownerOperations,
|
||||
TenantOperations: []Operation{OperationRun, OperationUse},
|
||||
Active: true,
|
||||
}
|
||||
}
|
||||
|
||||
func syncTaskResource(task *entity.SyncLogs, connector *entity.Connector, linked bool) Resource {
|
||||
operations := []Operation{OperationRead}
|
||||
if strings.EqualFold(task.TaskType, "sync") && task.Status == string(entity.TaskStatusFail) {
|
||||
operations = append(operations, OperationRun)
|
||||
}
|
||||
return Resource{
|
||||
Ref: ResourceRef{Kind: ResourceKindSyncTask, ID: task.ID},
|
||||
TenantID: connector.TenantID,
|
||||
OwnerUserID: connector.TenantID,
|
||||
Visibility: VisibilityTenant,
|
||||
TenantRequirement: TenantMember,
|
||||
OwnerOperations: operations,
|
||||
TenantOperations: operations,
|
||||
Active: linked,
|
||||
}
|
||||
}
|
||||
|
||||
func fileResource(kind ResourceKind, file *entity.File, datasetIDs []string, datasetsByID map[string]*entity.Knowledgebase, ownerIDsByTenant map[string]string) Resource {
|
||||
sharedWithUserIDs := make([]string, 0, len(datasetIDs))
|
||||
sharedWithTenantIDs := make([]string, 0, len(datasetIDs))
|
||||
for _, datasetID := range datasetIDs {
|
||||
dataset, ok := datasetsByID[datasetID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
datasetAccess := datasetResource(dataset, ownerIDsByTenant)
|
||||
sharedWithUserIDs = append(sharedWithUserIDs, datasetAccess.OwnerUserID)
|
||||
if datasetAccess.Visibility == VisibilityTenant {
|
||||
sharedWithTenantIDs = append(sharedWithTenantIDs, datasetAccess.TenantID)
|
||||
}
|
||||
}
|
||||
sharedWithUserIDs = uniqueStringValues(sharedWithUserIDs)
|
||||
sharedWithTenantIDs = uniqueStringValues(sharedWithTenantIDs)
|
||||
visibility := VisibilityPrivate
|
||||
if len(sharedWithUserIDs) > 0 || len(sharedWithTenantIDs) > 0 {
|
||||
visibility = VisibilityShared
|
||||
}
|
||||
return Resource{
|
||||
Ref: ResourceRef{Kind: kind, ID: file.ID},
|
||||
TenantID: file.TenantID,
|
||||
CreatedBy: file.CreatedBy,
|
||||
OwnerUserID: file.TenantID,
|
||||
Visibility: visibility,
|
||||
OwnerOperations: []Operation{OperationRead, OperationCreate, OperationUpdate, OperationDelete, OperationShare, OperationRun, OperationUse},
|
||||
SharedWithUserIDs: sharedWithUserIDs,
|
||||
SharedWithTenantIDs: sharedWithTenantIDs,
|
||||
Active: true,
|
||||
}
|
||||
}
|
||||
|
||||
func mcpServerResource(id, tenantID, ownerUserID string) Resource {
|
||||
return Resource{
|
||||
Ref: ResourceRef{Kind: ResourceKindMCPServer, ID: id},
|
||||
TenantID: tenantID,
|
||||
OwnerUserID: ownerUserID,
|
||||
Visibility: VisibilityTenant,
|
||||
TenantRequirement: TenantMember,
|
||||
OwnerOperations: []Operation{OperationRead, OperationCreate, OperationUpdate, OperationDelete, OperationUse},
|
||||
TenantOperations: []Operation{OperationUse},
|
||||
Active: true,
|
||||
}
|
||||
}
|
||||
|
||||
func skillSpaceResource(id, tenantID, ownerUserID string) Resource {
|
||||
return Resource{
|
||||
Ref: ResourceRef{Kind: ResourceKindSkillSpace, ID: id},
|
||||
TenantID: tenantID,
|
||||
OwnerUserID: ownerUserID,
|
||||
Visibility: VisibilityTenant,
|
||||
TenantRequirement: TenantMember,
|
||||
OwnerOperations: []Operation{OperationRead, OperationCreate, OperationUpdate, OperationDelete, OperationUse},
|
||||
TenantOperations: []Operation{OperationRead, OperationUse},
|
||||
Active: true,
|
||||
}
|
||||
}
|
||||
|
||||
func uniqueStringValues(values []string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
unique := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[value]; exists {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
unique = append(unique, value)
|
||||
}
|
||||
return unique
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,128 @@
|
||||
//
|
||||
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package permission
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"ragflow/internal/entity"
|
||||
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type skillSpaceAuthorization struct {
|
||||
ID string
|
||||
TenantID string
|
||||
FolderID string
|
||||
}
|
||||
|
||||
type folderAuthorization struct {
|
||||
TenantID string
|
||||
Type string
|
||||
}
|
||||
|
||||
func (s *databaseSource) loadMCPServerTenants(ctx context.Context, ids []string) (map[string]string, error) {
|
||||
tenantIDsByServer := make(map[string]string)
|
||||
ids = uniqueStringValues(ids)
|
||||
if len(ids) == 0 {
|
||||
return tenantIDsByServer, nil
|
||||
}
|
||||
query := s.db.WithContext(ctx).Model(&entity.MCPServer{}).
|
||||
Select("id", "tenant_id").
|
||||
Where("id IN ?", ids)
|
||||
if s.lockRows {
|
||||
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
}
|
||||
var servers []struct {
|
||||
ID string
|
||||
TenantID string
|
||||
}
|
||||
if err := query.Scan(&servers).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, server := range servers {
|
||||
tenantIDsByServer[server.ID] = server.TenantID
|
||||
}
|
||||
return tenantIDsByServer, nil
|
||||
}
|
||||
|
||||
func (s *databaseSource) loadChatChannels(ctx context.Context, ids []string) (map[string]*entity.ChatChannel, error) {
|
||||
channelsByID := make(map[string]*entity.ChatChannel)
|
||||
ids = uniqueStringValues(ids)
|
||||
if len(ids) == 0 {
|
||||
return channelsByID, nil
|
||||
}
|
||||
query := s.db.WithContext(ctx).Where("id IN ?", ids)
|
||||
if s.lockRows {
|
||||
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
}
|
||||
var channels []*entity.ChatChannel
|
||||
if err := query.Find(&channels).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, channel := range channels {
|
||||
channelsByID[channel.ID] = channel
|
||||
}
|
||||
return channelsByID, nil
|
||||
}
|
||||
|
||||
func (s *databaseSource) loadSkillSpaces(ctx context.Context, ids []string) (map[string]skillSpaceAuthorization, error) {
|
||||
spacesByID := make(map[string]skillSpaceAuthorization)
|
||||
ids = uniqueStringValues(ids)
|
||||
if len(ids) == 0 {
|
||||
return spacesByID, nil
|
||||
}
|
||||
query := s.db.WithContext(ctx).Model(&entity.SkillSpace{}).
|
||||
Select("id", "tenant_id", "folder_id").
|
||||
Where("id IN ? AND status IN ?", ids, []string{entity.SpaceStatusActive, entity.SpaceStatusDeleting})
|
||||
if s.lockRows {
|
||||
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
}
|
||||
var spaces []skillSpaceAuthorization
|
||||
if err := query.Scan(&spaces).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, space := range spaces {
|
||||
spacesByID[space.ID] = space
|
||||
}
|
||||
return spacesByID, nil
|
||||
}
|
||||
|
||||
func (s *databaseSource) loadSkillSpaceFolders(ctx context.Context, ids []string) (map[string]folderAuthorization, error) {
|
||||
foldersByID := make(map[string]folderAuthorization)
|
||||
ids = uniqueStringValues(ids)
|
||||
if len(ids) == 0 {
|
||||
return foldersByID, nil
|
||||
}
|
||||
query := s.db.WithContext(ctx).Model(&entity.File{}).
|
||||
Select("id", "tenant_id", "type").
|
||||
Where("id IN ?", ids)
|
||||
if s.lockRows {
|
||||
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
}
|
||||
var folders []struct {
|
||||
ID string
|
||||
TenantID string
|
||||
Type string
|
||||
}
|
||||
if err := query.Scan(&folders).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, folder := range folders {
|
||||
foldersByID[folder.ID] = folderAuthorization{TenantID: folder.TenantID, Type: folder.Type}
|
||||
}
|
||||
return foldersByID, nil
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
//
|
||||
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package permission
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"ragflow/internal/entity"
|
||||
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func (s *databaseSource) loadModelProviders(ctx context.Context, ids []string) (map[string]*entity.TenantModelProvider, error) {
|
||||
providersByID := make(map[string]*entity.TenantModelProvider)
|
||||
if len(ids) == 0 {
|
||||
return providersByID, nil
|
||||
}
|
||||
query := s.db.WithContext(ctx).Model(&entity.TenantModelProvider{}).Where("id IN ?", uniqueStringValues(ids))
|
||||
if s.lockRows {
|
||||
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
}
|
||||
var providers []*entity.TenantModelProvider
|
||||
if err := query.Find(&providers).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, provider := range providers {
|
||||
providersByID[provider.ID] = provider
|
||||
}
|
||||
return providersByID, nil
|
||||
}
|
||||
|
||||
func (s *databaseSource) loadModelInstances(ctx context.Context, ids []string) (map[string]*entity.TenantModelInstance, error) {
|
||||
instancesByID := make(map[string]*entity.TenantModelInstance)
|
||||
if len(ids) == 0 {
|
||||
return instancesByID, nil
|
||||
}
|
||||
query := s.db.WithContext(ctx).Model(&entity.TenantModelInstance{}).Where("id IN ?", uniqueStringValues(ids))
|
||||
if s.lockRows {
|
||||
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
}
|
||||
var instances []*entity.TenantModelInstance
|
||||
if err := query.Find(&instances).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, instance := range instances {
|
||||
instancesByID[instance.ID] = instance
|
||||
}
|
||||
return instancesByID, nil
|
||||
}
|
||||
|
||||
func (s *databaseSource) loadTenantModels(ctx context.Context, ids []string) (map[string]*entity.TenantModel, error) {
|
||||
modelsByID := make(map[string]*entity.TenantModel)
|
||||
if len(ids) == 0 {
|
||||
return modelsByID, nil
|
||||
}
|
||||
query := s.db.WithContext(ctx).Model(&entity.TenantModel{}).Where("id IN ?", uniqueStringValues(ids))
|
||||
if s.lockRows {
|
||||
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
}
|
||||
var models []*entity.TenantModel
|
||||
if err := query.Find(&models).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, model := range models {
|
||||
modelsByID[model.ID] = model
|
||||
}
|
||||
return modelsByID, nil
|
||||
}
|
||||
|
||||
func (s *databaseSource) loadTenantOwnerIDs(ctx context.Context, tenantIDs []string) (map[string]string, error) {
|
||||
ownersByTenant := make(map[string]string)
|
||||
tenantIDs = uniqueStringValues(tenantIDs)
|
||||
if len(tenantIDs) == 0 {
|
||||
return ownersByTenant, nil
|
||||
}
|
||||
query := s.db.WithContext(ctx).Model(&entity.UserTenant{}).
|
||||
Where("tenant_id IN ? AND role = ? AND status = ?", tenantIDs, string(RoleOwner), string(entity.StatusValid))
|
||||
if s.lockRows {
|
||||
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
}
|
||||
var owners []entity.UserTenant
|
||||
if err := query.Find(&owners).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, owner := range owners {
|
||||
if existingOwnerID, exists := ownersByTenant[owner.TenantID]; exists && existingOwnerID != owner.UserID {
|
||||
return nil, fmt.Errorf("%w: tenant %q has multiple active owners", ErrInvalidPermission, owner.TenantID)
|
||||
}
|
||||
ownersByTenant[owner.TenantID] = owner.UserID
|
||||
}
|
||||
return ownersByTenant, nil
|
||||
}
|
||||
|
||||
func (s *databaseSource) tenantIDsForSubject(ctx context.Context, subject Subject) ([]string, error) {
|
||||
var tenantIDs []string
|
||||
err := s.db.WithContext(ctx).Model(&entity.UserTenant{}).
|
||||
Distinct("tenant_id").
|
||||
Where("user_id = ? AND status = ? AND role IN ?", subject.UserID, string(entity.StatusValid), []string{
|
||||
string(RoleOwner), string(RoleAdmin), string(RoleNormal),
|
||||
}).
|
||||
Pluck("tenant_id", &tenantIDs).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return uniqueStringValues(append(tenantIDs, subject.UserID)), nil
|
||||
}
|
||||
|
||||
func modelProviderResource(provider *entity.TenantModelProvider, ownerUserID string) Resource {
|
||||
return Resource{
|
||||
Ref: ResourceRef{Kind: ResourceKindModelProvider, ID: provider.ID},
|
||||
TenantID: provider.TenantID,
|
||||
OwnerUserID: ownerUserID,
|
||||
Visibility: VisibilityTenant,
|
||||
TenantRequirement: TenantMember,
|
||||
OwnerOperations: []Operation{OperationRead, OperationCreate, OperationUpdate, OperationDelete},
|
||||
TenantOperations: []Operation{OperationRead},
|
||||
Active: true,
|
||||
}
|
||||
}
|
||||
|
||||
func modelInstanceResource(instance *entity.TenantModelInstance, provider *entity.TenantModelProvider, ownerUserID string) Resource {
|
||||
return Resource{
|
||||
Ref: ResourceRef{Kind: ResourceKindModelInstance, ID: instance.ID},
|
||||
TenantID: provider.TenantID,
|
||||
OwnerUserID: ownerUserID,
|
||||
Visibility: VisibilityTenant,
|
||||
TenantRequirement: TenantMember,
|
||||
OwnerOperations: []Operation{OperationRead, OperationCreate, OperationUpdate, OperationDelete, OperationUse},
|
||||
TenantOperations: []Operation{OperationUse},
|
||||
Active: true,
|
||||
}
|
||||
}
|
||||
|
||||
func tenantModelResource(model *entity.TenantModel, provider *entity.TenantModelProvider, ownerUserID string) Resource {
|
||||
return Resource{
|
||||
Ref: ResourceRef{Kind: ResourceKindModel, ID: model.ID},
|
||||
TenantID: provider.TenantID,
|
||||
OwnerUserID: ownerUserID,
|
||||
Visibility: VisibilityTenant,
|
||||
TenantRequirement: TenantMember,
|
||||
OwnerOperations: []Operation{OperationRead, OperationCreate, OperationUpdate, OperationDelete, OperationUse},
|
||||
TenantOperations: []Operation{OperationRead, OperationUse},
|
||||
Active: true,
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,10 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"ragflow/internal/entity"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type communityPolicy struct {
|
||||
@@ -166,6 +170,55 @@ func (p *communityPolicy) Scope(ctx context.Context, subject Subject, query Scop
|
||||
return scope, nil
|
||||
}
|
||||
|
||||
func (p *communityPolicy) QueryScope(ctx context.Context, subject Subject, query ScopeQuery) (QueryScope, bool, error) {
|
||||
if query.Kind != ResourceKindDataset || query.Operation != OperationRead || query.Parent.ID != "" || query.Parent.Kind != "" || query.Entry != nil {
|
||||
return QueryScope{}, false, nil
|
||||
}
|
||||
if err := validateSubject(subject); err != nil {
|
||||
return QueryScope{}, true, err
|
||||
}
|
||||
source, ok := p.source.(*databaseSource)
|
||||
if !ok || source.db == nil {
|
||||
return QueryScope{}, false, nil
|
||||
}
|
||||
if err := source.validateDatasetTenants(ctx, subject.UserID); err != nil {
|
||||
return QueryScope{}, true, err
|
||||
}
|
||||
|
||||
return QueryScope{apply: func(query *gorm.DB) *gorm.DB {
|
||||
db := source.db.WithContext(ctx)
|
||||
activeOwner := db.Model(&entity.UserTenant{}).
|
||||
Select("1").
|
||||
Where("user_tenant.tenant_id = knowledgebase.tenant_id AND user_tenant.role = ? AND user_tenant.status = ?", string(RoleOwner), string(entity.StatusValid))
|
||||
callerIsOwner := db.Model(&entity.UserTenant{}).
|
||||
Select("1").
|
||||
Where("user_tenant.tenant_id = knowledgebase.tenant_id AND user_tenant.user_id = ? AND user_tenant.role = ? AND user_tenant.status = ?", subject.UserID, string(RoleOwner), string(entity.StatusValid))
|
||||
callerIsMember := db.Model(&entity.UserTenant{}).
|
||||
Select("1").
|
||||
Where("user_tenant.tenant_id = knowledgebase.tenant_id AND user_tenant.user_id = ? AND user_tenant.role = ? AND user_tenant.status = ?", subject.UserID, string(RoleNormal), string(entity.StatusValid))
|
||||
accessibleDatasets := db.Model(&entity.Knowledgebase{}).
|
||||
Select("knowledgebase.id").
|
||||
Where("knowledgebase.status = ?", string(entity.StatusValid)).
|
||||
Where("EXISTS (?)", activeOwner).
|
||||
Where("(EXISTS (?) OR (knowledgebase.permission = ? AND EXISTS (?)))", callerIsOwner, string(entity.TenantPermissionTeam), callerIsMember)
|
||||
return query.Where("knowledgebase.id IN (?)", accessibleDatasets)
|
||||
}}, true, nil
|
||||
}
|
||||
|
||||
func (s *databaseSource) validateDatasetTenants(ctx context.Context, userID string) error {
|
||||
var tenantIDs []string
|
||||
err := s.db.WithContext(ctx).Model(&entity.Knowledgebase{}).
|
||||
Joins("JOIN user_tenant member ON member.tenant_id = knowledgebase.tenant_id").
|
||||
Where("knowledgebase.status = ? AND member.user_id = ? AND member.status = ?", string(entity.StatusValid), userID, string(entity.StatusValid)).
|
||||
Distinct("knowledgebase.tenant_id").
|
||||
Pluck("knowledgebase.tenant_id", &tenantIDs).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.loadTenantOwnerIDs(ctx, tenantIDs)
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *communityPolicy) FilterResources(ctx context.Context, subject Subject, refs []ResourceRef, operation Operation) ([]ResourceRef, error) {
|
||||
if err := validateSubject(subject); err != nil {
|
||||
return nil, err
|
||||
@@ -303,6 +356,9 @@ func (e *evaluation) resolveResource(ctx context.Context, subject Subject, resou
|
||||
if !validOperations(resource.OwnerOperations) {
|
||||
return Access{}, fmt.Errorf("%w: invalid owner operations", ErrInvalidPermission)
|
||||
}
|
||||
if !validOperations(resource.TenantOperations) || !validOperations(resource.CreatorOperations) {
|
||||
return Access{}, fmt.Errorf("%w: invalid delegated operations", ErrInvalidPermission)
|
||||
}
|
||||
switch resource.Visibility {
|
||||
case VisibilityPrivate, VisibilityTenant, VisibilityShared:
|
||||
default:
|
||||
@@ -326,7 +382,14 @@ func (e *evaluation) resolveResource(ctx context.Context, subject Subject, resou
|
||||
}
|
||||
return Access{}, err
|
||||
}
|
||||
return resourceAccess(resource, AccessSourceTenant), nil
|
||||
if subject.UserID == resource.CreatedBy && resource.CreatorOperations != nil {
|
||||
return resourceAccessWithOperations(resource, AccessSourceCreator, resource.CreatorOperations), nil
|
||||
}
|
||||
operations := resource.TenantOperations
|
||||
if operations == nil {
|
||||
operations = resource.OwnerOperations
|
||||
}
|
||||
return resourceAccessWithOperations(resource, AccessSourceTenant, operations), nil
|
||||
case VisibilityShared:
|
||||
if contains(resource.SharedWithUserIDs, subject.UserID) {
|
||||
return resourceAccess(resource, AccessSourceShared), nil
|
||||
@@ -440,7 +503,7 @@ func validAction(action Action) bool {
|
||||
|
||||
func validOperation(operation Operation) bool {
|
||||
switch operation {
|
||||
case OperationRead, OperationCreate, OperationUpdate, OperationDelete, OperationShare, OperationRun, OperationUse:
|
||||
case OperationRead, OperationCreate, OperationUpdate, OperationDelete, OperationDeleteContent, OperationShare, OperationRun, OperationUse:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -484,11 +547,15 @@ func validOperations(operations []Operation) bool {
|
||||
}
|
||||
|
||||
func resourceAccess(resource Resource, source AccessSource) Access {
|
||||
return resourceAccessWithOperations(resource, source, resource.OwnerOperations)
|
||||
}
|
||||
|
||||
func resourceAccessWithOperations(resource Resource, source AccessSource, operations []Operation) Access {
|
||||
return Access{
|
||||
Resource: resource.Ref,
|
||||
TenantID: resource.TenantID,
|
||||
Source: source,
|
||||
Operations: append([]Operation(nil), resource.OwnerOperations...),
|
||||
Operations: append([]Operation(nil), operations...),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//
|
||||
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package permission
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// QueryScope applies an authorization predicate to a database query. It is
|
||||
// intended for resource listings so authorization, counting, and pagination
|
||||
// happen in the database query rather than after materializing every resource
|
||||
// ID.
|
||||
//
|
||||
// The zero value denies all rows.
|
||||
type QueryScope struct {
|
||||
apply func(*gorm.DB) *gorm.DB
|
||||
}
|
||||
|
||||
// Apply adds this authorization predicate to query. A zero-value scope fails
|
||||
// closed by adding a predicate that matches no rows.
|
||||
func (s QueryScope) Apply(query *gorm.DB) *gorm.DB {
|
||||
if query == nil {
|
||||
return nil
|
||||
}
|
||||
if s.apply == nil {
|
||||
return query.Where("1 = 0")
|
||||
}
|
||||
return s.apply(query)
|
||||
}
|
||||
|
||||
func queryScopeForIDs(kind ResourceKind, ids []string) QueryScope {
|
||||
table, ok := resourceTable(kind)
|
||||
if !ok {
|
||||
return QueryScope{}
|
||||
}
|
||||
resourceIDs := append([]string(nil), ids...)
|
||||
return QueryScope{apply: func(query *gorm.DB) *gorm.DB {
|
||||
if len(resourceIDs) == 0 {
|
||||
return query.Where("1 = 0")
|
||||
}
|
||||
values := make([]interface{}, len(resourceIDs))
|
||||
for i, id := range resourceIDs {
|
||||
values[i] = id
|
||||
}
|
||||
return query.Where(clause.IN{
|
||||
Column: clause.Column{Table: table, Name: "id"},
|
||||
Values: values,
|
||||
})
|
||||
}}
|
||||
}
|
||||
|
||||
func resourceTable(kind ResourceKind) (string, bool) {
|
||||
switch kind {
|
||||
case ResourceKindDataset:
|
||||
return "knowledgebase", true
|
||||
case ResourceKindCanvas:
|
||||
return "user_canvas", true
|
||||
case ResourceKindAgentSession:
|
||||
return "api_4_conversation", true
|
||||
case ResourceKindMemory:
|
||||
return "memory", true
|
||||
case ResourceKindFile, ResourceKindFolder:
|
||||
return "file", true
|
||||
case ResourceKindSearchApp:
|
||||
return "search", true
|
||||
case ResourceKindChat:
|
||||
return "dialog", true
|
||||
case ResourceKindChatSession:
|
||||
return "conversation", true
|
||||
case ResourceKindChatChannel:
|
||||
return "chat_channel", true
|
||||
case ResourceKindConnector:
|
||||
return "connector", true
|
||||
case ResourceKindSyncTask:
|
||||
return "sync_logs", true
|
||||
case ResourceKindModelProvider:
|
||||
return "tenant_model_provider", true
|
||||
case ResourceKindModelInstance:
|
||||
return "tenant_model_instance", true
|
||||
case ResourceKindModel:
|
||||
return "tenant_model", true
|
||||
case ResourceKindMCPServer:
|
||||
return "mcp_server", true
|
||||
case ResourceKindSkillSpace:
|
||||
return "skill_spaces", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
@@ -43,9 +43,23 @@ type Subject struct {
|
||||
type ResourceKind string
|
||||
|
||||
const (
|
||||
ResourceKindDataset ResourceKind = "dataset"
|
||||
ResourceKindCanvas ResourceKind = "canvas"
|
||||
ResourceKindAgentSession ResourceKind = "agent_session"
|
||||
ResourceKindDataset ResourceKind = "dataset"
|
||||
ResourceKindCanvas ResourceKind = "canvas"
|
||||
ResourceKindAgentSession ResourceKind = "agent_session"
|
||||
ResourceKindMemory ResourceKind = "memory"
|
||||
ResourceKindFile ResourceKind = "file"
|
||||
ResourceKindFolder ResourceKind = "folder"
|
||||
ResourceKindSearchApp ResourceKind = "search_app"
|
||||
ResourceKindChat ResourceKind = "chat"
|
||||
ResourceKindChatSession ResourceKind = "chat_session"
|
||||
ResourceKindChatChannel ResourceKind = "chat_channel"
|
||||
ResourceKindConnector ResourceKind = "connector"
|
||||
ResourceKindSyncTask ResourceKind = "sync_task"
|
||||
ResourceKindModelProvider ResourceKind = "model_provider"
|
||||
ResourceKindModelInstance ResourceKind = "model_instance"
|
||||
ResourceKindModel ResourceKind = "model"
|
||||
ResourceKindMCPServer ResourceKind = "mcp_server"
|
||||
ResourceKindSkillSpace ResourceKind = "skill_space"
|
||||
)
|
||||
|
||||
// ResourceRef identifies a persisted resource.
|
||||
@@ -71,13 +85,14 @@ const (
|
||||
type Operation string
|
||||
|
||||
const (
|
||||
OperationRead Operation = "read"
|
||||
OperationCreate Operation = "create"
|
||||
OperationUpdate Operation = "update"
|
||||
OperationDelete Operation = "delete"
|
||||
OperationShare Operation = "share"
|
||||
OperationRun Operation = "run"
|
||||
OperationUse Operation = "use"
|
||||
OperationRead Operation = "read"
|
||||
OperationCreate Operation = "create"
|
||||
OperationUpdate Operation = "update"
|
||||
OperationDelete Operation = "delete"
|
||||
OperationDeleteContent Operation = "delete_content"
|
||||
OperationShare Operation = "share"
|
||||
OperationRun Operation = "run"
|
||||
OperationUse Operation = "use"
|
||||
)
|
||||
|
||||
// TenantRequirement describes the tenant role required by a check.
|
||||
@@ -123,13 +138,17 @@ const (
|
||||
// independent facts: the resource tenant, creator, and permission owner need
|
||||
// not be the same identity.
|
||||
type Resource struct {
|
||||
Ref ResourceRef
|
||||
TenantID string
|
||||
CreatedBy string
|
||||
OwnerUserID string
|
||||
Visibility Visibility
|
||||
TenantRequirement TenantRequirement
|
||||
OwnerOperations []Operation
|
||||
Ref ResourceRef
|
||||
TenantID string
|
||||
CreatedBy string
|
||||
OwnerUserID string
|
||||
Visibility Visibility
|
||||
TenantRequirement TenantRequirement
|
||||
OwnerOperations []Operation
|
||||
// TenantOperations overrides OwnerOperations for tenant members when set.
|
||||
TenantOperations []Operation
|
||||
// CreatorOperations grants extra operations to CreatedBy after tenant access is verified.
|
||||
CreatorOperations []Operation
|
||||
SharedWithUserIDs []string
|
||||
SharedWithTenantIDs []string
|
||||
Active bool
|
||||
@@ -139,9 +158,10 @@ type Resource struct {
|
||||
type AccessSource string
|
||||
|
||||
const (
|
||||
AccessSourceOwner AccessSource = "owner"
|
||||
AccessSourceTenant AccessSource = "tenant"
|
||||
AccessSourceShared AccessSource = "shared"
|
||||
AccessSourceOwner AccessSource = "owner"
|
||||
AccessSourceTenant AccessSource = "tenant"
|
||||
AccessSourceCreator AccessSource = "creator"
|
||||
AccessSourceShared AccessSource = "shared"
|
||||
)
|
||||
|
||||
// Access describes the operations a subject can perform on a resource.
|
||||
@@ -190,7 +210,7 @@ type ScopeQuery struct {
|
||||
|
||||
// Source loads normalized permission facts. GetResources must return the
|
||||
// requested resources with their tenant, creator, permission owner, active
|
||||
// state, visibility, owner operations, and share recipients. ListResourceRefs
|
||||
// state, visibility, operation sets, and share recipients. ListResourceRefs
|
||||
// returns all candidate IDs within the query boundary, including candidates
|
||||
// that policy will reject; Checker authorizes them before returning a scope.
|
||||
type Source interface {
|
||||
@@ -209,6 +229,10 @@ type policy interface {
|
||||
FilterResources(context.Context, Subject, []ResourceRef, Operation) ([]ResourceRef, error)
|
||||
}
|
||||
|
||||
type queryScopePolicy interface {
|
||||
QueryScope(context.Context, Subject, ScopeQuery) (QueryScope, bool, error)
|
||||
}
|
||||
|
||||
// Checker exposes edition-independent permission methods.
|
||||
type Checker struct {
|
||||
policy policy
|
||||
@@ -270,6 +294,31 @@ func (c *Checker) Scope(ctx context.Context, subject Subject, query ScopeQuery)
|
||||
return c.policy.Scope(ctx, subject, query)
|
||||
}
|
||||
|
||||
// QueryScope returns a database-applied authorization predicate for a resource
|
||||
// query. Policies may compile the authorization rules into SQL. When they do
|
||||
// not, the checker falls back to the existing authorized-ID scope and applies
|
||||
// those IDs as an IN predicate.
|
||||
func (c *Checker) QueryScope(ctx context.Context, subject Subject, query ScopeQuery) (QueryScope, error) {
|
||||
if c == nil || c.policy == nil {
|
||||
return QueryScope{}, ErrSourceUnavailable
|
||||
}
|
||||
if queryPolicy, ok := c.policy.(queryScopePolicy); ok {
|
||||
queryScope, supported, err := queryPolicy.QueryScope(ctx, subject, query)
|
||||
if err != nil {
|
||||
return QueryScope{}, err
|
||||
}
|
||||
if supported {
|
||||
return queryScope, nil
|
||||
}
|
||||
}
|
||||
|
||||
scope, err := c.policy.Scope(ctx, subject, query)
|
||||
if err != nil {
|
||||
return QueryScope{}, err
|
||||
}
|
||||
return queryScopeForIDs(query.Kind, scope.ResourceIDs), nil
|
||||
}
|
||||
|
||||
// FilterResources returns only candidate resources that allow operation.
|
||||
func (c *Checker) FilterResources(ctx context.Context, subject Subject, resources []ResourceRef, operation Operation) ([]ResourceRef, error) {
|
||||
if c == nil || c.policy == nil {
|
||||
|
||||
+32
-29
@@ -43,6 +43,7 @@ import (
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/permission"
|
||||
permissionresponse "ragflow/internal/permission/response"
|
||||
"ragflow/internal/tokenizer"
|
||||
|
||||
dslpkg "ragflow/internal/agent/dsl"
|
||||
@@ -904,7 +905,6 @@ func intValuePtr(p *int64) int64 {
|
||||
|
||||
// CreateAgentRequest is the input shape for CreateAgent.
|
||||
type CreateAgentRequest struct {
|
||||
UserID string `json:"user_id"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Permission string `json:"permission"`
|
||||
@@ -918,9 +918,13 @@ type CreateAgentRequest struct {
|
||||
// Returns the standard (T, common.ErrorCode, error) triple so the handler
|
||||
// can map validation/duplicate errors to codes 101/102 without
|
||||
// introducing a separate error type. Missing DSL or title and a
|
||||
// duplicate title under the same owner all surface as specific code
|
||||
// values that the Python agent API contract expects.
|
||||
func (s *AgentService) CreateAgent(ctx context.Context, req *CreateAgentRequest) (*entity.UserCanvas, common.ErrorCode, error) {
|
||||
// duplicate title under the authenticated owner surface as specific codes.
|
||||
func (s *AgentService) CreateAgent(ctx context.Context, userID string, req *CreateAgentRequest) (*entity.UserCanvas, common.ErrorCode, error) {
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID == "" {
|
||||
code, permissionErr := permissionresponse.Normalize(permission.ErrUnauthenticated)
|
||||
return nil, code, permissionErr
|
||||
}
|
||||
if req == nil {
|
||||
return nil, common.CodeArgumentError, errors.New("create agent: nil request")
|
||||
}
|
||||
@@ -941,7 +945,7 @@ func (s *AgentService) CreateAgent(ctx context.Context, req *CreateAgentRequest)
|
||||
}
|
||||
|
||||
uniqueTitle, err := common.UniqueName(title, 255, func(candidate string) (bool, error) {
|
||||
return s.canvasDAO.TitleExists(ctx, dao.DB, req.UserID, req.CanvasCategory, candidate, "")
|
||||
return s.canvasDAO.TitleExists(ctx, dao.DB, userID, req.CanvasCategory, candidate, "")
|
||||
})
|
||||
if err != nil {
|
||||
return nil, common.CodeServerError, fmt.Errorf("check duplicate title: %w", err)
|
||||
@@ -961,7 +965,7 @@ func (s *AgentService) CreateAgent(ctx context.Context, req *CreateAgentRequest)
|
||||
req.DSL = dslpkg.NormalizeForCanvas(req.DSL)
|
||||
row := &entity.UserCanvas{
|
||||
ID: common.GenerateUUID(),
|
||||
UserID: req.UserID,
|
||||
UserID: userID,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
Permission: req.Permission,
|
||||
@@ -1073,8 +1077,8 @@ func (s *AgentService) GetLastPublishTime(ctx context.Context, canvasID string)
|
||||
// UpdateAgent applies a draft patch to user_canvas. Settings updates may omit
|
||||
// dsl; in that case the existing draft DSL must be preserved.
|
||||
//
|
||||
// Canvas sharing grants the same operations as ownership in the community
|
||||
// policy, including changing its visibility.
|
||||
// Shared users may edit canvas content, but only the owner may change its
|
||||
// visibility.
|
||||
func (s *AgentService) UpdateAgent(ctx context.Context, userID, canvasID string, patch map[string]interface{}) error {
|
||||
canvasInstance, err := s.loadCanvasForUser(ctx, userID, canvasID, permission.OperationUpdate)
|
||||
if err != nil {
|
||||
@@ -1158,7 +1162,7 @@ func (s *AgentService) UpdateAgent(ctx context.Context, userID, canvasID string,
|
||||
if err := component.ValidateDynamicEntries(dslMap); err != nil {
|
||||
return fmt.Errorf("update agent %s: %w", canvasID, err)
|
||||
}
|
||||
if err := validateAgentChatModels(ctx, userID, dslMap); err != nil {
|
||||
if err := validateAgentChatModels(ctx, userID, ownerUserID, dslMap); err != nil {
|
||||
return err
|
||||
}
|
||||
updates["dsl"] = entity.JSONMap(dslpkg.NormalizeForCanvas(dslMap))
|
||||
@@ -1441,6 +1445,7 @@ func (s *AgentService) RunAgent(ctx context.Context, userID, canvasID, sessionID
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
agentTenantID := canvasRow.UserID
|
||||
newSession := sessionID == ""
|
||||
if sessionID == "" {
|
||||
sessionID = common.GenerateToken()
|
||||
@@ -1714,7 +1719,7 @@ func (s *AgentService) RunAgent(ctx context.Context, userID, canvasID, sessionID
|
||||
dsl = dslpkg.NormalizeForRun(existingSession.DSL)
|
||||
}
|
||||
}
|
||||
if err := validateAgentChatModels(ctx, userID, dsl); err != nil {
|
||||
if err := validateAgentChatModels(ctx, userID, agentTenantID, dsl); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A trusted boundary may allocate the session id before calling RunAgent so
|
||||
@@ -1776,17 +1781,14 @@ func (s *AgentService) RunAgent(ctx context.Context, userID, canvasID, sessionID
|
||||
if payload, ok := ctx.Value(webhookPayloadKey{}).(map[string]any); ok && payload != nil {
|
||||
root["webhook_payload"] = payload
|
||||
}
|
||||
// Match Python's @add_tenant_id_to_kwargs behavior for runtime
|
||||
// components and model credential lookup: the canvas runs under
|
||||
// the current caller's tenant id. Team-agent access was already
|
||||
// authorized by loadCanvasForUser above; do not replace this with
|
||||
// an arbitrary joined team tenant or LLM credential lookup can miss
|
||||
// the caller's configured provider key.
|
||||
root["tenant_id"] = userID
|
||||
// Runtime model and resource lookup uses the tenant that owns the canvas.
|
||||
// For user_canvas, that tenant ID is stored in user_id (and exposed as
|
||||
// tenant_id by the Agent API). Keep the actual runner separately in
|
||||
// root["user_id"] for caller-scoped permission checks and conversation data.
|
||||
root["tenant_id"] = agentTenantID
|
||||
|
||||
// Preserve the historical RunTracker tenant dimension separately.
|
||||
// Existing tests and log filters expect the joined tenant id in the
|
||||
// run hash, but runtime state must keep tenant_id=userID.
|
||||
// Preserve the historical RunTracker tenant dimension separately from
|
||||
// the canvas tenant used by runtime components.
|
||||
if tenantIDs, terr := s.userTenantDAO.GetTenantIDsByUserID(ctx, dao.DB, userID); terr == nil && len(tenantIDs) > 0 {
|
||||
root["run_tenant_id"] = tenantIDs[0]
|
||||
} else if terr != nil {
|
||||
@@ -1873,9 +1875,10 @@ func (s *AgentService) runReleasedAgent(ctx context.Context, userID, canvasID, s
|
||||
}
|
||||
|
||||
// validateAgentChatModels rejects stale Agent model references before saving or
|
||||
// execution. Agent components always invoke a chat model; model-free canvases
|
||||
// contain no Agent component and pass through.
|
||||
func validateAgentChatModels(ctx context.Context, userID string, dsl map[string]any) error {
|
||||
// execution. It resolves models in the canvas tenant and checks access for the
|
||||
// actual caller. Agent components always invoke a chat model; model-free
|
||||
// canvases contain no Agent component and pass through.
|
||||
func validateAgentChatModels(ctx context.Context, userID, tenantID string, dsl map[string]any) error {
|
||||
c, err := canvas.DecodeFromDSL(dsl)
|
||||
if err != nil {
|
||||
return nil
|
||||
@@ -1889,7 +1892,7 @@ func validateAgentChatModels(ctx context.Context, userID string, dsl map[string]
|
||||
if !ok {
|
||||
modelRef, _ = node.Obj.Params["llm_id"].(string)
|
||||
}
|
||||
if _, err := modelFactory.ResolveInfo(ctx, ModelAccess{UserID: userID, TenantID: userID}, entity.ModelTypeChat, modelRef); err != nil {
|
||||
if _, err := modelFactory.ResolveInfo(ctx, ModelAccess{UserID: userID, TenantID: tenantID}, entity.ModelTypeChat, modelRef); err != nil {
|
||||
if errors.Is(err, errModelConfigUnavailable) || errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New("The configured chat model is missing or unavailable. Please select a valid model.")
|
||||
}
|
||||
@@ -2139,7 +2142,7 @@ func (s *AgentService) buildRunFunc(canvasID string, versionRow *entity.UserCanv
|
||||
if rawFiles, ok := root["files"].([]map[string]interface{}); ok && len(rawFiles) > 0 {
|
||||
// Only used for ParseAgentUploads (read-only); nil DocRemover means
|
||||
// this FileService MUST NOT be used for DeleteFiles.
|
||||
fileSvc := file.NewFileService(CheckFileTeamPermission, nil)
|
||||
fileSvc := file.NewFileService(nil)
|
||||
files, ferr := fileSvc.ParseAgentUploads(ctx, userID, rawFiles, beginLayoutRecognize(c))
|
||||
if ferr != nil {
|
||||
s.markAgentSessionFailed(ctx2, canvasID, userID, sessionID, "parse files: "+ferr.Error())
|
||||
@@ -2883,10 +2886,10 @@ func pythonOutputKeyPriority(key string) int {
|
||||
|
||||
// tenantIDFromRoot returns the optional run-tracker tenant id that
|
||||
// RunAgent populated on the root map. Runtime components use
|
||||
// root["tenant_id"] / state.Sys["tenant_id"] for the caller tenant;
|
||||
// RunTracker keeps the historical joined-tenant dimension separately.
|
||||
// Empty when absent — the RunTracker stores "" as the tenant id, which
|
||||
// the test suite already exercises.
|
||||
// root["tenant_id"] / state.Sys["tenant_id"] for the canvas tenant, while
|
||||
// root["user_id"] / state.Sys["user_id"] retain the actual caller. RunTracker
|
||||
// keeps its historical joined-tenant dimension separately. Empty when absent
|
||||
// — the RunTracker stores "" as the tenant id, which the test suite exercises.
|
||||
func tenantIDFromRoot(root map[string]any) string {
|
||||
if s, ok := root["run_tenant_id"].(string); ok {
|
||||
return s
|
||||
|
||||
@@ -400,11 +400,12 @@ func (s *AgentService) DeleteAgentSessionItem(ctx context.Context, userID, agent
|
||||
if sessionID == "" {
|
||||
return false, common.CodeArgumentError, errors.New("session_id is required")
|
||||
}
|
||||
if err := CheckCanvasPermission(ctx, permission.Subject{UserID: userID}, agentID, permission.OperationDelete); err != nil {
|
||||
subject := permission.Subject{UserID: userID}
|
||||
if err := CheckCanvasPermission(ctx, subject, agentID, permission.OperationUse); err != nil {
|
||||
if errors.Is(err, permission.ErrResourceNotFound) || errors.Is(err, permission.ErrPermissionDenied) {
|
||||
return false, common.CodeOperatingError, errors.New("agent not found or no permission")
|
||||
}
|
||||
return false, common.CodeServerError, fmt.Errorf("failed to check agent deletion permission: %w", err)
|
||||
return false, common.CodeServerError, fmt.Errorf("failed to check agent access: %w", err)
|
||||
}
|
||||
|
||||
conv, cerr := s.api4ConversationDAO.GetBySessionID(ctx, dao.DB, sessionID, agentID)
|
||||
@@ -432,7 +433,7 @@ func (s *AgentService) DeleteAgentSessions(ctx context.Context, userID, agentID
|
||||
return nil, common.CodeArgumentError, errors.New("agent_id is required")
|
||||
}
|
||||
|
||||
if err := CheckCanvasPermission(ctx, permission.Subject{UserID: userID}, agentID, permission.OperationDelete); err != nil {
|
||||
if err := CheckCanvasPermission(ctx, permission.Subject{UserID: userID}, agentID, permission.OperationUse); err != nil {
|
||||
if errors.Is(err, permission.ErrResourceNotFound) || errors.Is(err, permission.ErrPermissionDenied) {
|
||||
return nil, common.CodeDataError, fmt.Errorf("you don't have permission to delete sessions for agent %s", agentID)
|
||||
}
|
||||
@@ -673,6 +674,13 @@ func (s *AgentService) CreateAgentSession(ctx context.Context, req *CreateAgentS
|
||||
}
|
||||
return nil, common.CodeServerError, fmt.Errorf("check canvas access: %w", err)
|
||||
}
|
||||
canvas, err := s.canvasDAO.GetByID(ctx, dao.DB, req.AgentID)
|
||||
if err != nil {
|
||||
if errors.Is(err, dao.ErrUserCanvasNotFound) {
|
||||
return nil, common.CodeOperatingError, errors.New("agent not found or no permission")
|
||||
}
|
||||
return nil, common.CodeServerError, fmt.Errorf("load canvas dsl: %w", err)
|
||||
}
|
||||
messages := req.Messages
|
||||
if len(messages) == 0 {
|
||||
messages = json.RawMessage(`[]`)
|
||||
@@ -684,16 +692,9 @@ func (s *AgentService) CreateAgentSession(ctx context.Context, req *CreateAgentS
|
||||
_ = json.Unmarshal(req.DSL, &dsl)
|
||||
}
|
||||
if len(dsl) == 0 {
|
||||
canvas, gErr := s.canvasDAO.GetByID(ctx, dao.DB, req.AgentID)
|
||||
if gErr != nil {
|
||||
if errors.Is(gErr, dao.ErrUserCanvasNotFound) {
|
||||
return nil, common.CodeOperatingError, errors.New("agent not found or no permission")
|
||||
}
|
||||
return nil, common.CodeServerError, fmt.Errorf("load canvas dsl: %w", gErr)
|
||||
}
|
||||
dsl = canvas.DSL
|
||||
}
|
||||
if err := validateAgentChatModels(ctx, req.UserID, dsl); err != nil {
|
||||
if err := validateAgentChatModels(ctx, req.UserID, canvas.UserID, dsl); err != nil {
|
||||
if errors.Is(err, ErrAgentStorageError) {
|
||||
return nil, common.CodeServerError, errors.New("Internal storage error while accessing the agent.")
|
||||
}
|
||||
|
||||
@@ -1731,7 +1731,7 @@ func TestUpdateAgentAllowsExistingTitleForSameCanvas(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAgentTeamMemberCanUpdateAndShareCanvas(t *testing.T) {
|
||||
func TestUpdateAgentTeamMemberCanUpdateButCannotChangePermission(t *testing.T) {
|
||||
setupAgentSessionServiceTest(t)
|
||||
ctx := t.Context()
|
||||
|
||||
@@ -1786,15 +1786,20 @@ func TestUpdateAgentTeamMemberCanUpdateAndShareCanvas(t *testing.T) {
|
||||
nextPermission := "me"
|
||||
if err := NewAgentService().UpdateAgent(ctx, "member-1", "canvas-team-edit", map[string]interface{}{
|
||||
"permission": nextPermission,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateAgent permission change failed: %v", err)
|
||||
}); err == nil {
|
||||
t.Fatal("UpdateAgent allowed shared member to change canvas permission")
|
||||
}
|
||||
var canvas entity.UserCanvas
|
||||
if err := dao.DB.WithContext(ctx).Where("id = ?", "canvas-team-edit").Take(&canvas).Error; err != nil {
|
||||
t.Fatalf("reload canvas: %v", err)
|
||||
}
|
||||
if canvas.Permission != nextPermission {
|
||||
t.Fatalf("canvas permission = %q, want %q", canvas.Permission, nextPermission)
|
||||
if canvas.Permission != string(entity.TenantPermissionTeam) {
|
||||
t.Fatalf("canvas permission = %q, want unchanged %q", canvas.Permission, entity.TenantPermissionTeam)
|
||||
}
|
||||
if err := NewAgentService().UpdateAgent(ctx, "owner-1", "canvas-team-edit", map[string]interface{}{
|
||||
"permission": nextPermission,
|
||||
}); err != nil {
|
||||
t.Fatalf("canvas owner could not change permission: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1904,10 +1909,9 @@ func TestCreateAgentDedupesDuplicateTitle(t *testing.T) {
|
||||
t.Fatalf("failed to seed agent: %v", err)
|
||||
}
|
||||
|
||||
row, code, err := NewAgentService().CreateAgent(ctx, &CreateAgentRequest{
|
||||
UserID: "user-1",
|
||||
Title: sptr("Dup Title"),
|
||||
DSL: entity.JSONMap{},
|
||||
row, code, err := NewAgentService().CreateAgent(ctx, "user-1", &CreateAgentRequest{
|
||||
Title: sptr("Dup Title"),
|
||||
DSL: entity.JSONMap{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected deduped create to succeed, got %v", err)
|
||||
@@ -1925,6 +1929,16 @@ func TestCreateAgentDedupesDuplicateTitle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAgentRequiresAuthenticatedUserID(t *testing.T) {
|
||||
row, code, err := NewAgentService().CreateAgent(t.Context(), " ", &CreateAgentRequest{
|
||||
Title: sptr("Agent"),
|
||||
DSL: entity.JSONMap{},
|
||||
})
|
||||
if row != nil || code != common.CodeUnauthorized || err == nil {
|
||||
t.Fatalf("CreateAgent without user ID = (%+v, %d, %v), want unauthorized", row, code, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAgentPersistsDSLAsJSONMap(t *testing.T) {
|
||||
setupAgentSessionServiceTest(t)
|
||||
|
||||
@@ -2708,6 +2722,23 @@ func createTeamSharedAgentTestFixtures(t *testing.T, canvasOwner, teammate, sess
|
||||
createAgentSessionTestConversation(t, sessionID, "canvas-1", sessionOwner, 1000)
|
||||
}
|
||||
|
||||
func TestDeleteAgent_SharedCanvasMemberCannotDeleteAgent(t *testing.T) {
|
||||
setupAgentSessionServiceTest(t)
|
||||
createTeamSharedAgentTestFixtures(t, "owner-1", "user-1", "session-1", "owner-1")
|
||||
|
||||
err := NewAgentService().DeleteAgent(t.Context(), "user-1", "canvas-1")
|
||||
if !errors.Is(err, ErrAgentNotOwner) {
|
||||
t.Fatalf("DeleteAgent error = %v, want ErrAgentNotOwner", err)
|
||||
}
|
||||
var count int64
|
||||
if err = dao.DB.Model(&entity.UserCanvas{}).Where("id = ?", "canvas-1").Count(&count).Error; err != nil {
|
||||
t.Fatalf("failed to count agent: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("shared agent should remain, count=%d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAgentSessionItem_SharedCanvasGrantsDeleteAccess(t *testing.T) {
|
||||
setupAgentSessionServiceTest(t)
|
||||
createTeamSharedAgentTestFixtures(t, "owner-1", "user-1", "session-1", "owner-1")
|
||||
|
||||
+17
-14
@@ -17,9 +17,9 @@
|
||||
// BotService is the shared service layer for the public
|
||||
// chatbot/agentbot endpoints (api/v1/chatbots/...,
|
||||
// api/v1/agentbots/...) plus the agent attachment download. It is
|
||||
// intentionally a thin aggregator — it sequences DAO lookups, the
|
||||
// tenant/status authorisation guard, and delegates the heavy work
|
||||
// (LLM call, canvas run) to the existing services.
|
||||
// intentionally a thin aggregator — it sequences permission checks and DAO
|
||||
// lookups, and delegates the heavy work (LLM call, canvas run) to the existing
|
||||
// services.
|
||||
package service
|
||||
|
||||
import (
|
||||
@@ -64,22 +64,25 @@ func NewBotService(agentSvc *AgentService) *BotService {
|
||||
}
|
||||
|
||||
// ChatbotInfo returns the public metadata of a chatbot dialog.
|
||||
//
|
||||
// Mirrors the python `bot_api.py::chatbot_info` handler. The
|
||||
// authorisation check is: dialog must exist, the requester must own
|
||||
// it (TenantID match), and Status must equal common.StatusDialogValid
|
||||
// (the python StatusEnum.VALID.value).
|
||||
func (s *BotService) ChatbotInfo(ctx context.Context, tenantID, dialogID string) (
|
||||
func (s *BotService) ChatbotInfo(ctx context.Context, userID, dialogID string) (
|
||||
title, avatar, prologue, llmID string, hasWebSearch bool, ec common.ErrorCode, err error,
|
||||
) {
|
||||
if err = checkChatPermission(ctx, nil, userID, dialogID, permission.OperationRead); err != nil {
|
||||
ec, err = normalizeChatPermissionError(err)
|
||||
return "", "", "", "", false, ec, err
|
||||
}
|
||||
|
||||
dialog, err := s.chatDAO.GetDialogByID(ctx, dao.DB, dialogID)
|
||||
if err != nil {
|
||||
return "", "", "", "", false, common.CodeDataError, err
|
||||
if dao.IsNotFoundErr(err) {
|
||||
ec, err = normalizeChatPermissionError(permission.ErrResourceNotFound)
|
||||
return "", "", "", "", false, ec, err
|
||||
}
|
||||
return "", "", "", "", false, common.CodeServerError, err
|
||||
}
|
||||
if dialog == nil || dialog.TenantID != tenantID ||
|
||||
dialog.Status == nil || *dialog.Status != common.StatusDialogValid {
|
||||
return "", "", "", "", false, common.CodeDataError,
|
||||
errors.New("authentication error: no access to this chatbot")
|
||||
if dialog == nil {
|
||||
ec, err = normalizeChatPermissionError(permission.ErrResourceNotFound)
|
||||
return "", "", "", "", false, ec, err
|
||||
}
|
||||
pc := dialog.PromptConfig
|
||||
// Defensive lookups mirroring python's
|
||||
|
||||
@@ -48,6 +48,7 @@ import (
|
||||
"ragflow/internal/agent/runtime"
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/permission"
|
||||
)
|
||||
|
||||
// ChatbotSSEFrame is one envelope pushed to the SSE writer by the
|
||||
@@ -290,10 +291,10 @@ func writeSSEJSON(w http.ResponseWriter, payload map[string]any) error {
|
||||
// api/db/services/conversation_service.py::async_iframe_completion,
|
||||
// which delegates to the same async_chat used by regular sessions.
|
||||
//
|
||||
// Authorisation: dialog must exist, belong to the requester's tenant,
|
||||
// and have status == common.StatusDialogValid.
|
||||
// Authorization is checked against the Chat resource before the
|
||||
// completion pipeline or session state is touched.
|
||||
func (s *BotService) ChatbotCompletion(
|
||||
ctx context.Context, tenantID, dialogID string, req ChatbotCompletionRequest,
|
||||
ctx context.Context, userID, dialogID string, req ChatbotCompletionRequest,
|
||||
) (<-chan ChatbotSSEFrame, common.ErrorCode, error) {
|
||||
receivedAt := float64(time.Now().UnixNano()) / 1e9
|
||||
question, err := ResolveCompletionQuestion(req.Question, req.Query, req.Messages)
|
||||
@@ -301,16 +302,22 @@ func (s *BotService) ChatbotCompletion(
|
||||
return nil, common.CodeArgumentError, err
|
||||
}
|
||||
req.Question = question
|
||||
// 1. Load and authorise the dialog.
|
||||
//
|
||||
// ChatSessionDAO.GetDialogByID already filters by status = "1"
|
||||
// so a returned row is valid; we still nil-check defensively
|
||||
// before dereferencing for symmetry with the session path.
|
||||
// 1. Authorize and load the dialog before touching session state.
|
||||
if err := checkChatPermission(ctx, nil, userID, dialogID, permission.OperationRun); err != nil {
|
||||
code, normalizedErr := normalizeChatPermissionError(err)
|
||||
return nil, code, normalizedErr
|
||||
}
|
||||
dialog, err := s.chatDAO.GetDialogByID(ctx, dao.DB, dialogID)
|
||||
if err != nil || dialog == nil ||
|
||||
dialog.TenantID != tenantID ||
|
||||
dialog.Status == nil || *dialog.Status != common.StatusDialogValid {
|
||||
return nil, common.CodeDataError, errors.New("no access to this chatbot")
|
||||
if err != nil {
|
||||
if dao.IsNotFoundErr(err) {
|
||||
code, normalizedErr := normalizeChatPermissionError(permission.ErrResourceNotFound)
|
||||
return nil, code, normalizedErr
|
||||
}
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
if dialog == nil {
|
||||
code, normalizedErr := normalizeChatPermissionError(permission.ErrResourceNotFound)
|
||||
return nil, code, normalizedErr
|
||||
}
|
||||
|
||||
// 2. Resolve or create the session row.
|
||||
@@ -320,20 +327,7 @@ func (s *BotService) ChatbotCompletion(
|
||||
// check the pointer before dereferencing, otherwise the
|
||||
// session-tenant check below nil-derefs. Plan Risk R7.
|
||||
//
|
||||
// UserID vs tenantID (security H3 follow-up):
|
||||
// `entity.API4Conversation.UserID` is a generic user-id slot
|
||||
// in the production Python flow
|
||||
// (api/db/services/conversation_service.py:258 — the python
|
||||
// async_iframe_completion saves `user_id=kwargs.get("user_id", "")`).
|
||||
// The Go BotHandler routes pass `user.ID` through the
|
||||
// "tenantID" parameter (the Go User struct collapses user and
|
||||
// tenant into one identifier — see project AGENTS.md), so
|
||||
// writing `tenantID` here actually stores the requester's
|
||||
// user-id (== tenant-id) in the python user-id slot. The
|
||||
// session-tenant check on the read path compares against the
|
||||
// same value, so write/read stay symmetric. We keep this
|
||||
// behaviour and add the comment so a future reader doesn't
|
||||
// "fix" it to a tenant-id lookup and break the symmetry.
|
||||
// API4 conversation history is owned by the caller's user ID.
|
||||
if req.SessionID == "" {
|
||||
// Seed a new session. An empty question is the opening handshake;
|
||||
// a supplied question continues through the normal generation path.
|
||||
@@ -353,7 +347,7 @@ func (s *BotService) ChatbotCompletion(
|
||||
session := &entity.API4Conversation{
|
||||
ID: common.GenerateUUID(),
|
||||
DialogID: dialogID,
|
||||
UserID: tenantID,
|
||||
UserID: userID,
|
||||
Message: seedMsg,
|
||||
}
|
||||
if err = s.api4ConversationDAO.Create(ctx, dao.DB, session); err != nil {
|
||||
@@ -387,7 +381,7 @@ func (s *BotService) ChatbotCompletion(
|
||||
if err != nil {
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
if session == nil || session.UserID != tenantID {
|
||||
if session == nil || session.UserID != userID {
|
||||
return nil, common.CodeDataError, errors.New("session not found")
|
||||
}
|
||||
|
||||
@@ -438,7 +432,7 @@ func (s *BotService) ChatbotCompletion(
|
||||
if err := s.persistChatbotQuestion(ctx, session, req.Question, messageID, receivedAt); err != nil {
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
results, err := s.pipeline.AsyncChat(ctx, tenantID, dialog, messages, true, kwargs)
|
||||
results, err := s.pipeline.AsyncChat(ctx, userID, dialog, messages, true, kwargs)
|
||||
if err != nil {
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//
|
||||
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/permission"
|
||||
)
|
||||
|
||||
func TestBotServiceChatbotEndpointsUseChatPermissions(t *testing.T) {
|
||||
db := setupServiceTestDB(t)
|
||||
if err := db.AutoMigrate(&entity.Chat{}); err != nil {
|
||||
t.Fatalf("migrate chat: %v", err)
|
||||
}
|
||||
pushServiceDB(t, db)
|
||||
|
||||
status := string(entity.StatusValid)
|
||||
name := "shared bot"
|
||||
if err := db.Create(&entity.Chat{
|
||||
ID: "chat-1",
|
||||
TenantID: "owner-1",
|
||||
Name: &name,
|
||||
LLMID: "model-1",
|
||||
LLMSetting: entity.JSONMap{},
|
||||
PromptType: "simple",
|
||||
PromptConfig: entity.JSONMap{},
|
||||
KBIDs: entity.JSONSlice{},
|
||||
Status: &status,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed chat: %v", err)
|
||||
}
|
||||
|
||||
svc := NewBotService(nil)
|
||||
_, _, _, _, _, code, err := svc.ChatbotInfo(t.Context(), "owner-1", "chat-1")
|
||||
if err != nil || code != common.CodeSuccess {
|
||||
t.Fatalf("ChatbotInfo(owner) = (%d, %v), want success", code, err)
|
||||
}
|
||||
|
||||
_, _, _, _, _, code, err = svc.ChatbotInfo(t.Context(), "outsider", "chat-1")
|
||||
if !errors.Is(err, permission.ErrPermissionDenied) || code != common.CodeNotFound {
|
||||
t.Fatalf("ChatbotInfo(outsider) = (%d, %v), want hidden permission denial", code, err)
|
||||
}
|
||||
|
||||
_, code, err = svc.ChatbotCompletion(t.Context(), "outsider", "chat-1", ChatbotCompletionRequest{Question: "hello"})
|
||||
if !errors.Is(err, permission.ErrPermissionDenied) || code != common.CodeNotFound {
|
||||
t.Fatalf("ChatbotCompletion(outsider) = (%d, %v), want hidden permission denial", code, err)
|
||||
}
|
||||
}
|
||||
+46
-121
@@ -48,19 +48,18 @@ var ReadOnlyFields = map[string]struct{}{
|
||||
|
||||
// ChatService chat service
|
||||
type ChatService struct {
|
||||
chatDAO *dao.ChatDAO
|
||||
kbDAO *dao.KnowledgebaseDAO
|
||||
userTenantDAO *dao.UserTenantDAO
|
||||
tenantDAO *dao.TenantDAO
|
||||
chatDAO *dao.ChatDAO
|
||||
kbDAO *dao.KnowledgebaseDAO
|
||||
tenantDAO *dao.TenantDAO
|
||||
permissionChecker resourceScopeChecker
|
||||
}
|
||||
|
||||
// NewChatService create chat service
|
||||
func NewChatService() *ChatService {
|
||||
return &ChatService{
|
||||
chatDAO: dao.NewChatDAO(),
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
userTenantDAO: dao.NewUserTenantDAO(),
|
||||
tenantDAO: dao.NewTenantDAO(),
|
||||
chatDAO: dao.NewChatDAO(),
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
tenantDAO: dao.NewTenantDAO(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,44 +98,33 @@ type ListChatsResponse struct {
|
||||
|
||||
// ListChats list chats for a user
|
||||
func (s *ChatService) ListChats(ctx context.Context, userID, status, keywords, id, name string, page, pageSize int, terms []dao.OrderTerm, ownerIDs []string) (*ListChatsResponse, error) {
|
||||
var chats []*entity.ChatListItem
|
||||
var total int64
|
||||
var err error
|
||||
|
||||
if len(ownerIDs) == 0 {
|
||||
chats, total, err = s.chatDAO.ListByTenantIDs(
|
||||
ctx,
|
||||
dao.DB,
|
||||
nil,
|
||||
userID,
|
||||
page,
|
||||
pageSize,
|
||||
terms,
|
||||
keywords,
|
||||
id,
|
||||
name,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
chatIDs, err := accessibleChatIDs(ctx, s.permissionChecker, userID, permission.OperationRead)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ownerFilter []string
|
||||
if len(ownerIDs) > 0 {
|
||||
ownerFilter = make([]string, 0, len(ownerIDs))
|
||||
seen := make(map[string]struct{}, len(ownerIDs))
|
||||
for _, ownerID := range ownerIDs {
|
||||
ownerID = strings.TrimSpace(ownerID)
|
||||
if ownerID == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[ownerID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[ownerID] = struct{}{}
|
||||
ownerFilter = append(ownerFilter, ownerID)
|
||||
}
|
||||
} else {
|
||||
var filterOwnerIDs []string
|
||||
filterOwnerIDs, err = s.filterAccessibleChatOwnerIDs(ctx, userID, ownerIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(filterOwnerIDs) == 0 {
|
||||
return &ListChatsResponse{
|
||||
Total: 0,
|
||||
Chats: []*ChatWithKBNames{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
chats, total, err = s.chatDAO.ListByOwnerIDs(ctx, dao.DB, filterOwnerIDs, userID, page, pageSize, terms, keywords, id, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if len(ownerFilter) == 0 {
|
||||
return &ListChatsResponse{Chats: []*ChatWithKBNames{}}, nil
|
||||
}
|
||||
}
|
||||
chats, total, err := s.chatDAO.ListByResourceIDs(ctx, dao.DB, chatIDs, ownerFilter, page, pageSize, terms, keywords, id, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Enrich with knowledge base names
|
||||
chatsWithKBNames := make([]*ChatWithKBNames, 0, len(chats))
|
||||
@@ -157,39 +145,6 @@ func (s *ChatService) ListChats(ctx context.Context, userID, status, keywords, i
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChatService) filterAccessibleChatOwnerIDs(ctx context.Context, userID string, ownerIDs []string) ([]string, error) {
|
||||
tenantIDs, err := s.userTenantDAO.GetTenantIDsByUserID(ctx, dao.DB, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allowed := map[string]struct{}{userID: {}}
|
||||
for _, tenantID := range tenantIDs {
|
||||
tenantID = strings.TrimSpace(tenantID)
|
||||
if tenantID != "" {
|
||||
allowed[tenantID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
filtered := make([]string, 0, len(ownerIDs))
|
||||
seen := make(map[string]struct{}, len(ownerIDs))
|
||||
for _, ownerID := range ownerIDs {
|
||||
ownerID = strings.TrimSpace(ownerID)
|
||||
if ownerID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := allowed[ownerID]; !ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[ownerID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[ownerID] = struct{}{}
|
||||
filtered = append(filtered, ownerID)
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func ownerNickname(nickname *string, tenantID string) string {
|
||||
if nickname != nil && strings.TrimSpace(*nickname) != "" {
|
||||
return *nickname
|
||||
@@ -793,15 +748,11 @@ const (
|
||||
pyDefaultEmptyResponse = "Sorry! No relevant content was found in the knowledge base!"
|
||||
)
|
||||
|
||||
func (s *ChatService) getOwnedValidChat(ctx context.Context, userID, chatID string) (*entity.Chat, error) {
|
||||
chat, err := s.chatDAO.GetByIDAndStatus(ctx, dao.DB, chatID, string(entity.StatusValid))
|
||||
if err != nil {
|
||||
return nil, errors.New("no authorization")
|
||||
func (s *ChatService) getChatForOperation(ctx context.Context, userID, chatID string, operation permission.Operation) (*entity.Chat, error) {
|
||||
if err := checkChatPermission(ctx, s.permissionChecker, userID, chatID, operation); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if chat.TenantID != userID {
|
||||
return nil, errors.New("no authorization")
|
||||
}
|
||||
return chat, nil
|
||||
return s.chatDAO.GetByIDAndStatus(ctx, dao.DB, chatID, string(entity.StatusValid))
|
||||
}
|
||||
|
||||
var chatPersistedFields = map[string]struct{}{
|
||||
@@ -853,13 +804,10 @@ func (s *ChatService) PatchChat(ctx context.Context, userID, chatID string, req
|
||||
}
|
||||
|
||||
func (s *ChatService) updateChatREST(ctx context.Context, userID, chatID string, req map[string]interface{}, patch bool) (map[string]interface{}, error) {
|
||||
currentChat, err := s.getOwnedValidChat(ctx, userID, chatID)
|
||||
currentChat, err := s.getChatForOperation(ctx, userID, chatID, permission.OperationUpdate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = s.tenantDAO.GetByID(ctx, dao.DB, userID); err != nil {
|
||||
return nil, errors.New("tenant not found")
|
||||
}
|
||||
|
||||
if !patch && isTruthy(req["tenant_id"]) {
|
||||
return nil, errors.New("`tenant_id` must not be provided")
|
||||
@@ -1189,7 +1137,7 @@ func (s *ChatService) buildRESTChatResponse(ctx context.Context, chat *entity.Ch
|
||||
|
||||
// DeleteChat soft deletes a single chat owned by the current user.
|
||||
func (s *ChatService) DeleteChat(ctx context.Context, userID, chatID string) error {
|
||||
if _, err := s.getOwnedValidChat(ctx, userID, chatID); err != nil {
|
||||
if _, err := s.getChatForOperation(ctx, userID, chatID, permission.OperationDelete); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.chatDAO.UpdateByID(ctx, dao.DB, chatID, map[string]interface{}{
|
||||
@@ -1236,13 +1184,11 @@ func checkDuplicateChatIDs(ids []string) ([]string, []string) {
|
||||
func (s *ChatService) BulkDeleteChats(ctx context.Context, userID string, req *BulkDeleteChatsRequest) (map[string]interface{}, error) {
|
||||
ids := req.IDs
|
||||
if len(ids) == 0 && req.DeleteAll {
|
||||
chats, err := s.chatDAO.ListByTenantID(ctx, dao.DB, userID, string(entity.StatusValid))
|
||||
chatIDs, err := accessibleChatIDs(ctx, s.permissionChecker, userID, permission.OperationDelete)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, chat := range chats {
|
||||
ids = append(ids, chat.ID)
|
||||
}
|
||||
ids = append(ids, chatIDs...)
|
||||
if len(ids) == 0 {
|
||||
return map[string]interface{}{}, nil
|
||||
}
|
||||
@@ -1254,7 +1200,7 @@ func (s *ChatService) BulkDeleteChats(ctx context.Context, userID string, req *B
|
||||
successCount := 0
|
||||
|
||||
for _, chatID := range uniqueIDs {
|
||||
if _, err := s.getOwnedValidChat(ctx, userID, chatID); err != nil {
|
||||
if _, err := s.getChatForOperation(ctx, userID, chatID, permission.OperationDelete); err != nil {
|
||||
errorsList = append(errorsList, fmt.Sprintf("Chat(%s) not found.", chatID))
|
||||
continue
|
||||
}
|
||||
@@ -1300,34 +1246,13 @@ type GetChatResponse struct {
|
||||
|
||||
// GetChat gets chat detail by ID with permission check
|
||||
func (s *ChatService) GetChat(ctx context.Context, userID string, chatID string) (*GetChatResponse, error) {
|
||||
// Step 1: Get user tenants (same as Python UserTenantService.query(user_id=current_user.id))
|
||||
tenants, err := s.userTenantDAO.GetByUserID(ctx, dao.DB, userID)
|
||||
if err := checkChatPermission(ctx, s.permissionChecker, userID, chatID, permission.OperationRead); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
chat, err := s.chatDAO.GetByIDAndStatus(ctx, dao.DB, chatID, string(entity.StatusValid))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user tenants: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Check if user has permission to access this chat
|
||||
// Python: for tenant in tenants: if DialogService.query(tenant_id=tenant.tenant_id, id=chat_id, status=StatusEnum.VALID.value): break
|
||||
hasPermission := false
|
||||
for _, tenant := range tenants {
|
||||
chats, err := s.chatDAO.QueryByTenantIDAndID(ctx, dao.DB, tenant.TenantID, chatID, "1")
|
||||
if err != nil {
|
||||
continue // Try next tenant
|
||||
}
|
||||
if len(chats) > 0 {
|
||||
hasPermission = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasPermission {
|
||||
return nil, fmt.Errorf("no authorization")
|
||||
}
|
||||
|
||||
// Step 3: Get chat detail (same as Python DialogService.get_by_id(chat_id))
|
||||
chat, err := s.chatDAO.GetByID(ctx, dao.DB, chatID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("chat not found")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Step 4: Build response with kb_names (same as Python _build_chat_response)
|
||||
|
||||
@@ -26,12 +26,12 @@ import (
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/permission"
|
||||
)
|
||||
|
||||
type ChatChannelService struct {
|
||||
chatChannelDAO *dao.ChatChannelDAO
|
||||
chatDAO *dao.ChatDAO
|
||||
userTenantDAO *dao.UserTenantDAO
|
||||
chatSessionSvc *ChatSessionService
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ func NewChatChannelService() *ChatChannelService {
|
||||
return &ChatChannelService{
|
||||
chatChannelDAO: dao.NewChatChannel(),
|
||||
chatDAO: dao.NewChatDAO(),
|
||||
userTenantDAO: dao.NewUserTenantDAO(),
|
||||
chatSessionSvc: NewChatSessionService(),
|
||||
}
|
||||
}
|
||||
@@ -76,46 +75,26 @@ func getChatChannelRuntimeProvider() ChatChannelRuntimeProvider {
|
||||
return chatChannelRuntimeProvider
|
||||
}
|
||||
|
||||
func (s *ChatChannelService) Insert(ctx context.Context, channel *entity.ChatChannel) error {
|
||||
if channel == nil {
|
||||
return errors.New("channel is nil")
|
||||
func (s *ChatChannelService) List(ctx context.Context, userID string) ([]*entity.ChatChannelListResponse, error) {
|
||||
channelIDs, err := accessibleChatChannelIDs(ctx, userID, permission.OperationRead)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if channel.ID == "" {
|
||||
channel.ID = common.GenerateUUID()
|
||||
}
|
||||
if channel.Status == 0 {
|
||||
channel.Status = 1
|
||||
}
|
||||
return s.chatChannelDAO.Create(ctx, dao.DB, channel)
|
||||
return s.chatChannelDAO.ListByIDs(ctx, dao.DB, channelIDs)
|
||||
}
|
||||
|
||||
func (s *ChatChannelService) GetByID(ctx context.Context, id string) (*entity.ChatChannel, error) {
|
||||
if id == "" {
|
||||
return nil, errors.New("id is empty")
|
||||
func (s *ChatChannelService) CreateChatChannel(ctx context.Context, userID, name, channelType string, config entity.JSONMap, chatID *string) (*entity.ChatChannel, error) {
|
||||
if strings.TrimSpace(userID) == "" {
|
||||
return nil, permission.ErrUnauthenticated
|
||||
}
|
||||
return s.chatChannelDAO.GetByID(ctx, dao.DB, id)
|
||||
}
|
||||
|
||||
func (s *ChatChannelService) List(ctx context.Context, tenantID string) ([]*entity.ChatChannelListResponse, error) {
|
||||
return s.chatChannelDAO.ListByTenantID(ctx, dao.DB, tenantID)
|
||||
}
|
||||
|
||||
func (s *ChatChannelService) CreateChatChannel(ctx context.Context, tenantID, name, channelType string, config entity.JSONMap, chatID *string) (*entity.ChatChannel, error) {
|
||||
if chatID != nil && *chatID != "" {
|
||||
dialog, err := s.chatDAO.GetByID(ctx, dao.DB, *chatID)
|
||||
if err != nil {
|
||||
if dao.IsNotFoundErr(err) {
|
||||
return nil, errors.New("can't find this chat assistant")
|
||||
}
|
||||
if err := checkChatPermission(ctx, nil, userID, *chatID, permission.OperationUse); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dialog.TenantID != tenantID {
|
||||
return nil, errors.New("no authorization")
|
||||
}
|
||||
}
|
||||
row := &entity.ChatChannel{
|
||||
ID: common.GenerateUUID(),
|
||||
TenantID: tenantID,
|
||||
TenantID: userID,
|
||||
Name: name,
|
||||
Channel: channelType,
|
||||
Config: config,
|
||||
@@ -123,56 +102,28 @@ func (s *ChatChannelService) CreateChatChannel(ctx context.Context, tenantID, na
|
||||
Status: 1,
|
||||
}
|
||||
|
||||
if err := s.Insert(ctx, row); err != nil {
|
||||
if err := s.chatChannelDAO.Create(ctx, dao.DB, row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
created, err := s.GetByID(ctx, row.ID)
|
||||
created, err := s.chatChannelDAO.GetByID(ctx, dao.DB, row.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load created chat channel: %w", err)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (s *ChatChannelService) accessible(ctx context.Context, userID, channelID string) (*entity.ChatChannel, bool, error) {
|
||||
channel, err := s.chatChannelDAO.GetByID(ctx, dao.DB, channelID)
|
||||
if err != nil {
|
||||
if dao.IsNotFoundErr(err) {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if channel.TenantID == userID {
|
||||
return channel, true, nil
|
||||
}
|
||||
|
||||
tenantIDs, err := s.userTenantDAO.GetTenantIDsByUserID(ctx, dao.DB, userID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
for _, tenantID := range tenantIDs {
|
||||
if tenantID == channel.TenantID {
|
||||
return channel, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return channel, false, nil
|
||||
}
|
||||
|
||||
func (s *ChatChannelService) GetChatChannel(ctx context.Context, userID, channelID string) (*entity.ChatChannel, common.ErrorCode, error) {
|
||||
_, ok, err := s.accessible(ctx, userID, channelID)
|
||||
if err != nil {
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, common.CodeAuthenticationError, errors.New("no authorization")
|
||||
if err := checkChatChannelPermission(ctx, userID, channelID, permission.OperationRead); err != nil {
|
||||
code, permissionErr := normalizeChatChannelPermissionError(err)
|
||||
return nil, code, permissionErr
|
||||
}
|
||||
|
||||
channel, err := s.chatChannelDAO.GetByID(ctx, dao.DB, channelID)
|
||||
if err != nil {
|
||||
if dao.IsNotFoundErr(err) {
|
||||
return nil, common.CodeDataError, errors.New("can't find this chat channel")
|
||||
code, permissionErr := normalizeChatChannelPermissionError(permission.ErrResourceNotFound)
|
||||
return nil, code, permissionErr
|
||||
}
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
@@ -180,16 +131,18 @@ func (s *ChatChannelService) GetChatChannel(ctx context.Context, userID, channel
|
||||
}
|
||||
|
||||
func (s *ChatChannelService) UpdateChatChannel(ctx context.Context, userID, channelID string, req map[string]interface{}) (*entity.ChatChannel, common.ErrorCode, error) {
|
||||
channel, ok, err := s.accessible(ctx, userID, channelID)
|
||||
if err := checkChatChannelPermission(ctx, userID, channelID, permission.OperationUpdate); err != nil {
|
||||
code, permissionErr := normalizeChatChannelPermissionError(err)
|
||||
return nil, code, permissionErr
|
||||
}
|
||||
channel, err := s.chatChannelDAO.GetByID(ctx, dao.DB, channelID)
|
||||
if err != nil {
|
||||
if dao.IsNotFoundErr(err) {
|
||||
code, permissionErr := normalizeChatChannelPermissionError(permission.ErrResourceNotFound)
|
||||
return nil, code, permissionErr
|
||||
}
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, common.CodeAuthenticationError, errors.New("no authorization")
|
||||
}
|
||||
if channel == nil {
|
||||
return nil, common.CodeDataError, errors.New("can't find this chat channel")
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{}
|
||||
|
||||
@@ -222,15 +175,15 @@ func (s *ChatChannelService) UpdateChatChannel(ctx context.Context, userID, chan
|
||||
return nil, common.CodeDataError, errors.New("chat_id must be string or null")
|
||||
}
|
||||
if chatID != "" {
|
||||
dialog, err := s.chatDAO.GetByID(ctx, dao.DB, chatID)
|
||||
if err != nil {
|
||||
if dao.IsNotFoundErr(err) {
|
||||
return nil, common.CodeDataError, errors.New("can't find this chat assistant")
|
||||
}
|
||||
return nil, common.CodeServerError, err
|
||||
if err := checkChatPermission(ctx, nil, userID, chatID, permission.OperationUse); err != nil {
|
||||
code, normalizedErr := normalizeChatPermissionError(err)
|
||||
return nil, code, normalizedErr
|
||||
}
|
||||
if dialog.TenantID != channel.TenantID {
|
||||
return nil, common.CodeAuthenticationError, errors.New("no authorization")
|
||||
if channel.TenantID != userID {
|
||||
if err := checkChatPermission(ctx, nil, channel.TenantID, chatID, permission.OperationUse); err != nil {
|
||||
code, normalizedErr := normalizeChatPermissionError(err)
|
||||
return nil, code, normalizedErr
|
||||
}
|
||||
}
|
||||
}
|
||||
updates["chat_id"] = chatID
|
||||
@@ -246,7 +199,8 @@ func (s *ChatChannelService) UpdateChatChannel(ctx context.Context, userID, chan
|
||||
updated, err := s.chatChannelDAO.GetByID(ctx, dao.DB, channelID)
|
||||
if err != nil {
|
||||
if dao.IsNotFoundErr(err) {
|
||||
return nil, common.CodeDataError, errors.New("can't find this chat channel")
|
||||
code, permissionErr := normalizeChatChannelPermissionError(permission.ErrResourceNotFound)
|
||||
return nil, code, permissionErr
|
||||
}
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
@@ -255,16 +209,18 @@ func (s *ChatChannelService) UpdateChatChannel(ctx context.Context, userID, chan
|
||||
|
||||
// GetChatChannelRuntime returns the live runtime metadata for a WhatsApp chat channel.
|
||||
func (s *ChatChannelService) GetChatChannelRuntime(ctx context.Context, userID, channelID string) (map[string]any, common.ErrorCode, error) {
|
||||
conn, ok, err := s.accessible(ctx, userID, channelID)
|
||||
if err := checkChatChannelPermission(ctx, userID, channelID, permission.OperationUse); err != nil {
|
||||
code, permissionErr := normalizeChatChannelPermissionError(err)
|
||||
return nil, code, permissionErr
|
||||
}
|
||||
conn, err := s.chatChannelDAO.GetByID(ctx, dao.DB, channelID)
|
||||
if err != nil {
|
||||
if dao.IsNotFoundErr(err) {
|
||||
code, permissionErr := normalizeChatChannelPermissionError(permission.ErrResourceNotFound)
|
||||
return nil, code, permissionErr
|
||||
}
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, common.CodeAuthenticationError, errors.New("no authorization")
|
||||
}
|
||||
if conn == nil {
|
||||
return nil, common.CodeDataError, errors.New("can't find this chat channel")
|
||||
}
|
||||
|
||||
if conn.Channel != "whatsapp" {
|
||||
return nil, common.CodeDataError, errors.New("runtime snapshot is only available for WhatsApp")
|
||||
@@ -335,16 +291,18 @@ func (s *ChatChannelService) HandleIncomingMessage(ctx context.Context, msg Chat
|
||||
}
|
||||
|
||||
func (s *ChatChannelService) DeleteChatChannel(ctx context.Context, userID, channelID string) (bool, common.ErrorCode, error) {
|
||||
channel, ok, err := s.accessible(ctx, userID, channelID)
|
||||
if err := checkChatChannelPermission(ctx, userID, channelID, permission.OperationDelete); err != nil {
|
||||
code, permissionErr := normalizeChatChannelPermissionError(err)
|
||||
return false, code, permissionErr
|
||||
}
|
||||
channel, err := s.chatChannelDAO.GetByID(ctx, dao.DB, channelID)
|
||||
if err != nil {
|
||||
if dao.IsNotFoundErr(err) {
|
||||
code, permissionErr := normalizeChatChannelPermissionError(permission.ErrResourceNotFound)
|
||||
return false, code, permissionErr
|
||||
}
|
||||
return false, common.CodeServerError, err
|
||||
}
|
||||
if !ok {
|
||||
return false, common.CodeAuthenticationError, errors.New("no authorization")
|
||||
}
|
||||
if channel == nil {
|
||||
return false, common.CodeAuthenticationError, errors.New("no authorization")
|
||||
}
|
||||
|
||||
if err = s.chatChannelDAO.DeleteByID(ctx, dao.DB, channelID, channel.TenantID); err != nil {
|
||||
return false, common.CodeDataError, err
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/permission"
|
||||
permissionresponse "ragflow/internal/permission/response"
|
||||
)
|
||||
|
||||
func checkChatChannelPermission(ctx context.Context, userID, channelID string, operation permission.Operation) error {
|
||||
return permission.NewDatabaseChecker(dao.DB).CheckResource(
|
||||
ctx,
|
||||
permission.Subject{UserID: userID},
|
||||
permission.ResourceRef{Kind: permission.ResourceKindChatChannel, ID: channelID},
|
||||
operation,
|
||||
)
|
||||
}
|
||||
|
||||
func accessibleChatChannelIDs(ctx context.Context, userID string, operation permission.Operation) ([]string, error) {
|
||||
scope, err := permission.NewDatabaseChecker(dao.DB).Scope(ctx, permission.Subject{UserID: userID}, permission.ScopeQuery{
|
||||
Kind: permission.ResourceKindChatChannel,
|
||||
Operation: operation,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if scope.Mode != permission.ScopeIDs {
|
||||
return []string{}, nil
|
||||
}
|
||||
return scope.ResourceIDs, nil
|
||||
}
|
||||
|
||||
func normalizeChatChannelPermissionError(err error) (common.ErrorCode, error) {
|
||||
return permissionresponse.NormalizeHidden(err)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/permission"
|
||||
)
|
||||
|
||||
func setupChatChannelServiceTestDB(t *testing.T) *gorm.DB {
|
||||
@@ -39,6 +40,7 @@ func createServiceTestDialog(t *testing.T, db *gorm.DB, id, tenantID, name strin
|
||||
t.Helper()
|
||||
|
||||
dialogName := name
|
||||
status := string(entity.StatusValid)
|
||||
dialog := &entity.Chat{
|
||||
ID: id,
|
||||
TenantID: tenantID,
|
||||
@@ -48,6 +50,7 @@ func createServiceTestDialog(t *testing.T, db *gorm.DB, id, tenantID, name strin
|
||||
PromptType: "simple",
|
||||
PromptConfig: entity.JSONMap{"system": "sys"},
|
||||
KBIDs: entity.JSONSlice{"kb-1"},
|
||||
Status: &status,
|
||||
}
|
||||
if err := db.Create(dialog).Error; err != nil {
|
||||
t.Fatalf("failed to create dialog: %v", err)
|
||||
@@ -143,6 +146,25 @@ func TestChatChannelServiceGetChatChannelAllowsJoinedTenant(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatChannelServiceListUsesPermissionScope(t *testing.T) {
|
||||
db := setupChatChannelServiceTestDB(t)
|
||||
createServiceTestChannel(t, db, &entity.ChatChannel{
|
||||
ID: "channel-team", TenantID: "tenant-1", Name: "team", Channel: "wecom", Config: entity.JSONMap{}, Status: 1,
|
||||
})
|
||||
createServiceTestChannel(t, db, &entity.ChatChannel{
|
||||
ID: "channel-other", TenantID: "tenant-2", Name: "other", Channel: "wecom", Config: entity.JSONMap{}, Status: 1,
|
||||
})
|
||||
createServiceTestMembership(t, db, "user-2", "tenant-1")
|
||||
|
||||
channels, err := NewChatChannelService().List(t.Context(), "user-2")
|
||||
if err != nil {
|
||||
t.Fatalf("List() failed: %v", err)
|
||||
}
|
||||
if len(channels) != 1 || channels[0].ID != "channel-team" {
|
||||
t.Fatalf("List() = %+v, want only member-visible channel-team", channels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatChannelServiceUpdateChatChannelSuccess(t *testing.T) {
|
||||
db := setupChatChannelServiceTestDB(t)
|
||||
createServiceTestDialog(t, db, "dialog-1", "tenant-1", "Assistant A")
|
||||
@@ -197,11 +219,33 @@ func TestChatChannelServiceUpdateChatChannelRejectsCrossTenantDialog(t *testing.
|
||||
_, code, err := NewChatChannelService().UpdateChatChannel(ctx, "tenant-1", "cc-1", map[string]interface{}{
|
||||
"chat_id": "dialog-2",
|
||||
})
|
||||
if code != common.CodeAuthenticationError {
|
||||
t.Fatalf("expected authentication error, got %v", code)
|
||||
if code != common.CodeNotFound {
|
||||
t.Fatalf("expected hidden not-found error, got %v", code)
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "no authorization") {
|
||||
t.Fatalf("expected authorization error, got %v", err)
|
||||
if !errors.Is(err, permission.ErrPermissionDenied) {
|
||||
t.Fatalf("expected permission denial, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatChannelServiceUpdateChatChannelRequiresChannelOwnerAccess(t *testing.T) {
|
||||
db := setupChatChannelServiceTestDB(t)
|
||||
createServiceTestDialog(t, db, "dialog-2", "tenant-2", "Assistant B")
|
||||
createServiceTestChannel(t, db, &entity.ChatChannel{
|
||||
ID: "cc-1",
|
||||
TenantID: "tenant-1",
|
||||
Name: "bot-a",
|
||||
Channel: "wecom",
|
||||
Config: entity.JSONMap{"token": "old"},
|
||||
Status: 1,
|
||||
})
|
||||
createServiceTestMembership(t, db, "user-1", "tenant-1")
|
||||
createServiceTestMembership(t, db, "user-1", "tenant-2")
|
||||
|
||||
_, code, err := NewChatChannelService().UpdateChatChannel(t.Context(), "user-1", "cc-1", map[string]interface{}{
|
||||
"chat_id": "dialog-2",
|
||||
})
|
||||
if code != common.CodeNotFound || !errors.Is(err, permission.ErrPermissionDenied) {
|
||||
t.Fatalf("UpdateChatChannel() = (%d, %v), want hidden denial because channel owner cannot use Chat", code, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,11 +264,11 @@ func TestChatChannelServiceDeleteChatChannel(t *testing.T) {
|
||||
|
||||
ctx := t.Context()
|
||||
deleted, code, err := svc.DeleteChatChannel(ctx, "user-2", "cc-1")
|
||||
if code != common.CodeAuthenticationError {
|
||||
t.Fatalf("expected authentication error, got %v", code)
|
||||
if code != common.CodeNotFound || !errors.Is(err, permission.ErrPermissionDenied) {
|
||||
t.Fatalf("expected hidden permission denial, got code=%v err=%v", code, err)
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "no authorization") {
|
||||
t.Fatalf("expected authorization error, got %v", err)
|
||||
if err == nil {
|
||||
t.Fatal("expected authorization error")
|
||||
}
|
||||
if deleted {
|
||||
t.Fatal("expected delete to be rejected")
|
||||
@@ -238,7 +282,7 @@ func TestChatChannelServiceDeleteChatChannel(t *testing.T) {
|
||||
t.Fatalf("unexpected delete result: deleted=%v code=%v err=%v", deleted, code, err)
|
||||
}
|
||||
|
||||
if _, err = svc.GetByID(ctx, "cc-1"); err == nil {
|
||||
if _, err = dao.NewChatChannel().GetByID(ctx, dao.DB, "cc-1"); err == nil {
|
||||
t.Fatal("expected deleted record to be gone")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/permission"
|
||||
)
|
||||
|
||||
func setupChatDeleteServiceTestDB(t *testing.T) *gorm.DB {
|
||||
@@ -21,7 +23,7 @@ func setupChatDeleteServiceTestDB(t *testing.T) *gorm.DB {
|
||||
t.Fatalf("failed to open sqlite: %v", err)
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(&entity.Chat{}); err != nil {
|
||||
if err := db.AutoMigrate(&entity.Chat{}, &entity.UserTenant{}); err != nil {
|
||||
t.Fatalf("failed to migrate test schema: %v", err)
|
||||
}
|
||||
|
||||
@@ -62,8 +64,8 @@ func TestChatServiceDeleteChatRejectsNonOwner(t *testing.T) {
|
||||
svc := NewChatService()
|
||||
ctx := t.Context()
|
||||
err := svc.DeleteChat(ctx, "user-1", "chat-1")
|
||||
if err == nil || err.Error() != "no authorization" {
|
||||
t.Fatalf("expected no authorization, got %v", err)
|
||||
if !errors.Is(err, permission.ErrPermissionDenied) {
|
||||
t.Fatalf("expected permission denied, got %v", err)
|
||||
}
|
||||
|
||||
chat, getErr := svc.chatDAO.GetByID(ctx, dao.DB, "chat-1")
|
||||
|
||||
@@ -99,7 +99,7 @@ func TestChatServiceListChatsDefaultReturnsAllWithCorrectTotal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatServiceListChatsFiltersByOwnerIDs(t *testing.T) {
|
||||
func TestChatServiceListChatsDoesNotExposeAnotherTenantsChats(t *testing.T) {
|
||||
db := setupChatListTestDB(t)
|
||||
|
||||
if err := db.Create(&entity.User{
|
||||
@@ -130,14 +130,8 @@ func TestChatServiceListChatsFiltersByOwnerIDs(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("ListChats failed: %v", err)
|
||||
}
|
||||
if result.Total != 1 || len(result.Chats) != 1 {
|
||||
t.Fatalf("expected one filtered chat, got total=%d len=%d", result.Total, len(result.Chats))
|
||||
}
|
||||
if result.Chats[0].TenantID != "tenant-2" {
|
||||
t.Fatalf("expected tenant-2 chat, got tenant %q", result.Chats[0].TenantID)
|
||||
}
|
||||
if result.Chats[0].Nickname != "team owner" {
|
||||
t.Fatalf("expected nickname team owner, got %q", result.Chats[0].Nickname)
|
||||
if result.Total != 0 || len(result.Chats) != 0 {
|
||||
t.Fatalf("expected another tenant's chat to be hidden, got total=%d len=%d", result.Total, len(result.Chats))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
//
|
||||
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/permission"
|
||||
permissionresponse "ragflow/internal/permission/response"
|
||||
)
|
||||
|
||||
type resourceChecker interface {
|
||||
CheckResource(context.Context, permission.Subject, permission.ResourceRef, permission.Operation) error
|
||||
}
|
||||
|
||||
type resourceScopeChecker interface {
|
||||
resourceChecker
|
||||
Scope(context.Context, permission.Subject, permission.ScopeQuery) (permission.Scope, error)
|
||||
}
|
||||
|
||||
func checkChatPermission(ctx context.Context, checker resourceChecker, userID, chatID string, operation permission.Operation) error {
|
||||
if checker == nil {
|
||||
checker = permission.NewDatabaseChecker(dao.DB)
|
||||
}
|
||||
return checker.CheckResource(
|
||||
ctx,
|
||||
permission.Subject{UserID: userID},
|
||||
permission.ResourceRef{Kind: permission.ResourceKindChat, ID: chatID},
|
||||
operation,
|
||||
)
|
||||
}
|
||||
|
||||
func checkChatSessionPermission(ctx context.Context, checker resourceChecker, userID, sessionID string, operation permission.Operation) error {
|
||||
if checker == nil {
|
||||
checker = permission.NewDatabaseChecker(dao.DB)
|
||||
}
|
||||
return checker.CheckResource(
|
||||
ctx,
|
||||
permission.Subject{UserID: userID},
|
||||
permission.ResourceRef{Kind: permission.ResourceKindChatSession, ID: sessionID},
|
||||
operation,
|
||||
)
|
||||
}
|
||||
|
||||
func accessibleChatIDs(ctx context.Context, checker resourceScopeChecker, userID string, operation permission.Operation) ([]string, error) {
|
||||
if checker == nil {
|
||||
checker = permission.NewDatabaseChecker(dao.DB)
|
||||
}
|
||||
scope, err := checker.Scope(ctx, permission.Subject{UserID: userID}, permission.ScopeQuery{
|
||||
Kind: permission.ResourceKindChat,
|
||||
Operation: operation,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if scope.Mode == permission.ScopeNone {
|
||||
return []string{}, nil
|
||||
}
|
||||
return scope.ResourceIDs, nil
|
||||
}
|
||||
|
||||
func accessibleChatSessionIDs(ctx context.Context, checker resourceScopeChecker, userID, chatID string, operation permission.Operation) ([]string, error) {
|
||||
if checker == nil {
|
||||
checker = permission.NewDatabaseChecker(dao.DB)
|
||||
}
|
||||
chatRef := permission.ResourceRef{Kind: permission.ResourceKindChat, ID: chatID}
|
||||
if err := checkChatPermission(ctx, checker, userID, chatID, permission.OperationRead); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scope, err := checker.Scope(ctx, permission.Subject{UserID: userID}, permission.ScopeQuery{
|
||||
Kind: permission.ResourceKindChatSession,
|
||||
Parent: chatRef,
|
||||
Operation: operation,
|
||||
Entry: &chatRef,
|
||||
EntryOp: permission.OperationRead,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if scope.Mode == permission.ScopeNone {
|
||||
return []string{}, nil
|
||||
}
|
||||
return scope.ResourceIDs, nil
|
||||
}
|
||||
|
||||
func normalizeChatPermissionError(err error) (common.ErrorCode, error) {
|
||||
return permissionresponse.NormalizeHidden(err)
|
||||
}
|
||||
@@ -1908,7 +1908,7 @@ func (s *ChatPipelineService) splitChatAttachments(ctx context.Context, userID s
|
||||
if fileDicts, ok := parseFileDicts(files); ok {
|
||||
// Only used for GetFileContents (read-only); nil DocRemover means
|
||||
// this FileService MUST NOT be used for DeleteFiles.
|
||||
fileSvc := file.NewFileService(CheckFileTeamPermission, nil)
|
||||
fileSvc := file.NewFileService(nil)
|
||||
texts, images, err := fileSvc.GetFileContents(ctx, userID, fileDicts)
|
||||
if err != nil {
|
||||
common.Warn("GetFileContents failed in splitChatAttachments",
|
||||
@@ -2418,7 +2418,7 @@ func splitFileAttachments(ctx context.Context, userID string, files interface{},
|
||||
if fileDicts, ok := parseFileDicts(files); ok {
|
||||
// Only used for GetFileContents (read-only); nil DocRemover means
|
||||
// this FileService MUST NOT be used for DeleteFiles.
|
||||
fileSvc := file.NewFileService(CheckFileTeamPermission, nil)
|
||||
fileSvc := file.NewFileService(nil)
|
||||
texts, images, err := fileSvc.GetFileContents(ctx, userID, fileDicts)
|
||||
if err != nil {
|
||||
common.Warn("GetFileContents failed, falling back to string splitting",
|
||||
|
||||
@@ -36,6 +36,7 @@ import (
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
modelModule "ragflow/internal/entity/models"
|
||||
"ragflow/internal/permission"
|
||||
)
|
||||
|
||||
// Interfaces for testability — satisfied by the concrete DAO/pipeline types.
|
||||
@@ -46,13 +47,8 @@ type chatSessionStore interface {
|
||||
Create(ctx context.Context, db *gorm.DB, conv *entity.ChatSession) error
|
||||
UpdateByID(ctx context.Context, db *gorm.DB, id string, updates map[string]interface{}) error
|
||||
DeleteByID(ctx context.Context, db *gorm.DB, id string) error
|
||||
ListByChatID(ctx context.Context, db *gorm.DB, chatID, sessionID, name string, terms []dao.OrderTerm, page, pageSize int, includeHistory ...bool) ([]*entity.ChatSession, error)
|
||||
ListByResourceIDs(ctx context.Context, db *gorm.DB, chatID string, sessionIDs []string, sessionID, name string, terms []dao.OrderTerm, page, pageSize int, includeHistory ...bool) ([]*entity.ChatSession, error)
|
||||
GetDialogByID(ctx context.Context, db *gorm.DB, chatID string) (*entity.Chat, error)
|
||||
CheckDialogExists(ctx context.Context, db *gorm.DB, tenantID, chatID string) (bool, error)
|
||||
}
|
||||
|
||||
type userTenantStore interface {
|
||||
GetTenantIDsByUserID(ctx context.Context, db *gorm.DB, userID string) ([]string, error)
|
||||
}
|
||||
|
||||
type chatPipelineRunner interface {
|
||||
@@ -85,7 +81,7 @@ type chunkPagerankAdjuster interface {
|
||||
// The RAG pipeline is delegated to ChatPipelineService.
|
||||
type ChatSessionService struct {
|
||||
chatSessionDAO chatSessionStore
|
||||
userTenantDAO userTenantStore
|
||||
permissionChecker resourceScopeChecker
|
||||
pipeline chatPipelineRunner
|
||||
modelFactory chatModelInfoResolver
|
||||
chunkFeedbackApplier chunkFeedbackApplier
|
||||
@@ -96,7 +92,6 @@ type ChatSessionService struct {
|
||||
func NewChatSessionService() *ChatSessionService {
|
||||
return &ChatSessionService{
|
||||
chatSessionDAO: dao.NewChatSessionDAO(),
|
||||
userTenantDAO: dao.NewUserTenantDAO(),
|
||||
pipeline: NewChatPipelineService(),
|
||||
modelFactory: NewModelFactory(),
|
||||
docEngine: engine.Get(),
|
||||
@@ -129,38 +124,10 @@ type ChatSessionPayload struct {
|
||||
|
||||
// ListChatSessions lists chat sessions for a dialog
|
||||
func (s *ChatSessionService) ListChatSessions(ctx context.Context, userID, chatID, sessionID, name string, terms []dao.OrderTerm, page, pageSize int, includeHistory ...bool) (*ListChatSessionsResponse, error) {
|
||||
// Get user's tenants
|
||||
tenantIDs, err := s.userTenantDAO.GetTenantIDsByUserID(ctx, dao.DB, userID)
|
||||
sessionIDs, err := accessibleChatSessionIDs(ctx, s.permissionChecker, userID, chatID, permission.OperationRead)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check if user is the owner of the dialog
|
||||
isOwner := false
|
||||
for _, tenantID := range tenantIDs {
|
||||
var exists bool
|
||||
exists, err = s.chatSessionDAO.CheckDialogExists(ctx, dao.DB, tenantID, chatID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exists {
|
||||
isOwner = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Also check with userID as tenant
|
||||
if !isOwner {
|
||||
var exists bool
|
||||
exists, err = s.chatSessionDAO.CheckDialogExists(ctx, dao.DB, userID, chatID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
isOwner = exists
|
||||
}
|
||||
|
||||
if !isOwner {
|
||||
return nil, errors.New("no authorization")
|
||||
_, normalizedErr := normalizeChatPermissionError(err)
|
||||
return nil, normalizedErr
|
||||
}
|
||||
|
||||
// items_per_page == 0 returns an empty list (mirrors Python's list_sessions).
|
||||
@@ -169,7 +136,7 @@ func (s *ChatSessionService) ListChatSessions(ctx context.Context, userID, chatI
|
||||
}
|
||||
|
||||
// List chat sessions
|
||||
sessions, err := s.chatSessionDAO.ListByChatID(ctx, dao.DB, chatID, sessionID, name, terms, page, pageSize, includeHistory...)
|
||||
sessions, err := s.chatSessionDAO.ListByResourceIDs(ctx, dao.DB, chatID, sessionIDs, sessionID, name, terms, page, pageSize, includeHistory...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -179,12 +146,9 @@ func (s *ChatSessionService) ListChatSessions(ctx context.Context, userID, chatI
|
||||
|
||||
// GetSession returns one chat session after ownership validation.
|
||||
func (s *ChatSessionService) GetSession(ctx context.Context, userID, chatID, sessionID string) (*ChatSessionPayload, common.ErrorCode, error) {
|
||||
ok, err := s.ensureOwnedChat(ctx, userID, chatID)
|
||||
if err != nil {
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, common.CodeAuthenticationError, errors.New("no authorization")
|
||||
if err := checkChatPermission(ctx, s.permissionChecker, userID, chatID, permission.OperationRead); err != nil {
|
||||
code, normalizedErr := normalizeChatPermissionError(err)
|
||||
return nil, code, normalizedErr
|
||||
}
|
||||
|
||||
session, err := s.chatSessionDAO.GetByID(ctx, dao.DB, sessionID)
|
||||
@@ -197,6 +161,10 @@ func (s *ChatSessionService) GetSession(ctx context.Context, userID, chatID, ses
|
||||
if session.DialogID != chatID {
|
||||
return nil, common.CodeDataError, errors.New("session does not belong to this chat")
|
||||
}
|
||||
if err = checkChatSessionPermission(ctx, s.permissionChecker, userID, sessionID, permission.OperationRead); err != nil {
|
||||
code, normalizedErr := normalizeChatPermissionError(err)
|
||||
return nil, code, normalizedErr
|
||||
}
|
||||
|
||||
dialog, err := s.chatSessionDAO.GetDialogByID(ctx, dao.DB, chatID)
|
||||
if err != nil && !isChatSessionNotFound(err) {
|
||||
@@ -208,12 +176,9 @@ func (s *ChatSessionService) GetSession(ctx context.Context, userID, chatID, ses
|
||||
|
||||
// CreateSession create a session in a dialog
|
||||
func (s *ChatSessionService) CreateSession(ctx context.Context, userID, chatID string, req map[string]interface{}) (*ChatSessionPayload, common.ErrorCode, error) {
|
||||
ok, err := s.ensureOwnedChat(ctx, userID, chatID)
|
||||
if err != nil {
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, common.CodeAuthenticationError, errors.New("no authorization")
|
||||
if err := checkChatPermission(ctx, s.permissionChecker, userID, chatID, permission.OperationCreate); err != nil {
|
||||
code, normalizedErr := normalizeChatPermissionError(err)
|
||||
return nil, code, normalizedErr
|
||||
}
|
||||
|
||||
dialog, err := s.chatSessionDAO.GetDialogByID(ctx, dao.DB, chatID)
|
||||
@@ -275,12 +240,9 @@ func (s *ChatSessionService) CreateSession(ctx context.Context, userID, chatID s
|
||||
|
||||
// DeleteSessions delete a session in a dialog
|
||||
func (s *ChatSessionService) DeleteSessions(ctx context.Context, userID, chatID string, req map[string]interface{}) (interface{}, string, common.ErrorCode, error) {
|
||||
ok, err := s.ensureOwnedChat(ctx, userID, chatID)
|
||||
if err != nil {
|
||||
return nil, "", common.CodeServerError, err
|
||||
}
|
||||
if !ok {
|
||||
return false, "no authorization", common.CodeAuthenticationError, errors.New("no authorization")
|
||||
if err := checkChatPermission(ctx, s.permissionChecker, userID, chatID, permission.OperationRead); err != nil {
|
||||
code, normalizedErr := normalizeChatPermissionError(err)
|
||||
return nil, "", code, normalizedErr
|
||||
}
|
||||
|
||||
if len(req) == 0 {
|
||||
@@ -291,10 +253,20 @@ func (s *ChatSessionService) DeleteSessions(ctx context.Context, userID, chatID
|
||||
if !hasIDs || len(sessionIDs) == 0 {
|
||||
deleteAll, _ := req["delete_all"].(bool)
|
||||
if deleteAll {
|
||||
sessions, err := s.chatSessionDAO.ListByChatID(ctx, dao.DB, chatID, "", "", []dao.OrderTerm{{Column: "create_time", Desc: true}}, 0, -1, false)
|
||||
authorizedSessionIDs, err := accessibleChatSessionIDs(ctx, s.permissionChecker, userID, chatID, permission.OperationDelete)
|
||||
if err != nil {
|
||||
code, normalizedErr := normalizeChatPermissionError(err)
|
||||
return nil, "", code, normalizedErr
|
||||
}
|
||||
sessionIDs = authorizedSessionIDs
|
||||
if len(sessionIDs) == 0 {
|
||||
return map[string]interface{}{}, "success", common.CodeSuccess, nil
|
||||
}
|
||||
sessions, err := s.chatSessionDAO.ListByResourceIDs(ctx, dao.DB, chatID, sessionIDs, "", "", []dao.OrderTerm{{Column: "create_time", Desc: true}}, 0, -1, false)
|
||||
if err != nil {
|
||||
return nil, "", common.CodeServerError, err
|
||||
}
|
||||
sessionIDs = sessionIDs[:0]
|
||||
for _, session := range sessions {
|
||||
sessionIDs = append(sessionIDs, session.ID)
|
||||
}
|
||||
@@ -318,12 +290,9 @@ func (s *ChatSessionService) DeleteSessions(ctx context.Context, userID, chatID
|
||||
continue
|
||||
}
|
||||
|
||||
writable, werr := s.ensureSessionWritable(ctx, userID, chatID, session)
|
||||
if werr != nil {
|
||||
return nil, "", common.CodeServerError, werr
|
||||
}
|
||||
if !writable {
|
||||
errorsList = append(errorsList, fmt.Sprintf("No permission to delete the readonly session %s", sid))
|
||||
if werr := checkChatSessionPermission(ctx, s.permissionChecker, userID, sid, permission.OperationDelete); werr != nil {
|
||||
_, normalizedErr := normalizeChatPermissionError(werr)
|
||||
errorsList = append(errorsList, fmt.Sprintf("%s: %s", normalizedErr, sid))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -442,12 +411,9 @@ func checkDuplicateChatSessionIDs(ids []string) ([]string, []string) {
|
||||
|
||||
// UpdateSession updates one chat session after Python-style field validation.
|
||||
func (s *ChatSessionService) UpdateSession(ctx context.Context, userID, chatID, sessionID string, req map[string]interface{}) (*ChatSessionPayload, common.ErrorCode, error) {
|
||||
ok, err := s.ensureOwnedChat(ctx, userID, chatID)
|
||||
if err != nil {
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, common.CodeAuthenticationError, errors.New("no authorization")
|
||||
if err := checkChatPermission(ctx, s.permissionChecker, userID, chatID, permission.OperationRead); err != nil {
|
||||
code, normalizedErr := normalizeChatPermissionError(err)
|
||||
return nil, code, normalizedErr
|
||||
}
|
||||
|
||||
session, err := s.chatSessionDAO.GetBySessionIDAndChatID(ctx, dao.DB, sessionID, chatID)
|
||||
@@ -458,21 +424,18 @@ func (s *ChatSessionService) UpdateSession(ctx context.Context, userID, chatID,
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
|
||||
writable, err := s.ensureSessionWritable(ctx, userID, chatID, session)
|
||||
if err != nil {
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
if !writable {
|
||||
return nil, common.CodeAuthenticationError, errSharedSessionReadonly
|
||||
if err = checkChatSessionPermission(ctx, s.permissionChecker, userID, sessionID, permission.OperationUpdate); err != nil {
|
||||
code, normalizedErr := normalizeChatPermissionError(err)
|
||||
return nil, code, normalizedErr
|
||||
}
|
||||
|
||||
if _, ok = req["message"]; ok {
|
||||
if _, ok := req["message"]; ok {
|
||||
return nil, common.CodeDataError, errors.New("`messages` cannot be changed")
|
||||
}
|
||||
if _, ok = req["messages"]; ok {
|
||||
if _, ok := req["messages"]; ok {
|
||||
return nil, common.CodeDataError, errors.New("`messages` cannot be changed")
|
||||
}
|
||||
if _, ok = req["reference"]; ok {
|
||||
if _, ok := req["reference"]; ok {
|
||||
return nil, common.CodeDataError, errors.New("`reference` cannot be changed")
|
||||
}
|
||||
|
||||
@@ -520,12 +483,9 @@ func (s *ChatSessionService) UpdateSession(ctx context.Context, userID, chatID,
|
||||
}
|
||||
|
||||
func (s *ChatSessionService) DeleteSessionMessage(ctx context.Context, userID, chatID, sessionID, msgID string) (*ChatSessionPayload, common.ErrorCode, error) {
|
||||
ok, err := s.ensureOwnedChat(ctx, userID, chatID)
|
||||
if err != nil {
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, common.CodeAuthenticationError, errors.New("no authorization")
|
||||
if err := checkChatPermission(ctx, s.permissionChecker, userID, chatID, permission.OperationRead); err != nil {
|
||||
code, normalizedErr := normalizeChatPermissionError(err)
|
||||
return nil, code, normalizedErr
|
||||
}
|
||||
|
||||
session, err := s.chatSessionDAO.GetByID(ctx, dao.DB, sessionID)
|
||||
@@ -536,12 +496,9 @@ func (s *ChatSessionService) DeleteSessionMessage(ctx context.Context, userID, c
|
||||
return nil, common.CodeDataError, errors.New("session not found")
|
||||
}
|
||||
|
||||
writable, werr := s.ensureSessionWritable(ctx, userID, chatID, session)
|
||||
if werr != nil {
|
||||
return nil, common.CodeServerError, werr
|
||||
}
|
||||
if !writable {
|
||||
return nil, common.CodeAuthenticationError, errSharedSessionReadonly
|
||||
if werr := checkChatSessionPermission(ctx, s.permissionChecker, userID, sessionID, permission.OperationUpdate); werr != nil {
|
||||
code, normalizedErr := normalizeChatPermissionError(werr)
|
||||
return nil, code, normalizedErr
|
||||
}
|
||||
|
||||
// parseMessages / parseReferenceList return nil for
|
||||
@@ -602,34 +559,9 @@ func (s *ChatSessionService) DeleteSessionMessage(ctx context.Context, userID, c
|
||||
}
|
||||
|
||||
func (s *ChatSessionService) UpdateMessageFeedback(ctx context.Context, userID, chatID, sessionID, msgID string, req map[string]interface{}) (*ChatSessionPayload, common.ErrorCode, error) {
|
||||
|
||||
ownerTenantID := ""
|
||||
tenantIDs, err := s.userTenantDAO.GetTenantIDsByUserID(ctx, dao.DB, userID)
|
||||
if err != nil {
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
for _, tenantID := range tenantIDs {
|
||||
exists, err := s.chatSessionDAO.CheckDialogExists(ctx, dao.DB, tenantID, chatID)
|
||||
if err != nil {
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
if exists {
|
||||
ownerTenantID = tenantID
|
||||
break
|
||||
}
|
||||
}
|
||||
if ownerTenantID == "" {
|
||||
exists, err := s.chatSessionDAO.CheckDialogExists(ctx, dao.DB, userID, chatID)
|
||||
if err != nil {
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
if exists {
|
||||
ownerTenantID = userID
|
||||
}
|
||||
}
|
||||
ok := ownerTenantID != ""
|
||||
if !ok {
|
||||
return nil, common.CodeAuthenticationError, errors.New("no authorization")
|
||||
if err := checkChatPermission(ctx, s.permissionChecker, userID, chatID, permission.OperationRead); err != nil {
|
||||
code, normalizedErr := normalizeChatPermissionError(err)
|
||||
return nil, code, normalizedErr
|
||||
}
|
||||
|
||||
session, err := s.chatSessionDAO.GetByID(ctx, dao.DB, sessionID)
|
||||
@@ -640,10 +572,13 @@ func (s *ChatSessionService) UpdateMessageFeedback(ctx context.Context, userID,
|
||||
return nil, common.CodeDataError, errors.New("session not found")
|
||||
}
|
||||
|
||||
// Shared-session readonly rule: the chat owner (ownerTenantID) or the
|
||||
// session's creator may leave feedback; other team members cannot.
|
||||
if ownerTenantID != userID && (session.UserID == nil || *session.UserID != userID) {
|
||||
return nil, common.CodeAuthenticationError, errSharedSessionReadonly
|
||||
if err = checkChatSessionPermission(ctx, s.permissionChecker, userID, sessionID, permission.OperationUpdate); err != nil {
|
||||
code, normalizedErr := normalizeChatPermissionError(err)
|
||||
return nil, code, normalizedErr
|
||||
}
|
||||
dialog, err := s.chatSessionDAO.GetDialogByID(ctx, dao.DB, chatID)
|
||||
if err != nil {
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
|
||||
thumbRaw, ok := req["thumbup"]
|
||||
@@ -722,7 +657,7 @@ func (s *ChatSessionService) UpdateMessageFeedback(ctx context.Context, userID,
|
||||
applier = s
|
||||
}
|
||||
if priorThumbBool, ok := priorThumb.(bool); ok && priorThumbBool != thumbup {
|
||||
result, _ := applier.applyChunkFeedback(ctx, ownerTenantID, feedbackReference, !priorThumbBool)
|
||||
result, _ := applier.applyChunkFeedback(ctx, dialog.TenantID, feedbackReference, !priorThumbBool)
|
||||
if result != nil {
|
||||
common.Debug("Chunk feedback undo applied",
|
||||
zap.Any("success_count", result["success_count"]),
|
||||
@@ -730,7 +665,7 @@ func (s *ChatSessionService) UpdateMessageFeedback(ctx context.Context, userID,
|
||||
)
|
||||
}
|
||||
}
|
||||
result, _ := applier.applyChunkFeedback(ctx, ownerTenantID, feedbackReference, thumbup)
|
||||
result, _ := applier.applyChunkFeedback(ctx, dialog.TenantID, feedbackReference, thumbup)
|
||||
if result != nil {
|
||||
common.Debug("Chunk feedback applied",
|
||||
zap.Any("success_count", result["success_count"]),
|
||||
@@ -1064,48 +999,6 @@ func floatValue(value interface{}) (float64, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ChatSessionService) ensureOwnedChat(ctx context.Context, userID, chatID string) (bool, error) {
|
||||
tenantIDs, err := s.userTenantDAO.GetTenantIDsByUserID(ctx, dao.DB, userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, tenantID := range tenantIDs {
|
||||
exists, err := s.chatSessionDAO.CheckDialogExists(ctx, dao.DB, tenantID, chatID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if exists {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
exists, err := s.chatSessionDAO.CheckDialogExists(ctx, dao.DB, userID, chatID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
// errSharedSessionReadonly is returned when a caller who can read a chat
|
||||
// shared with their team tries to mutate a session created by someone else.
|
||||
var errSharedSessionReadonly = errors.New("shared session is readonly")
|
||||
|
||||
// ensureSessionWritable enforces the shared-session readonly rule: team
|
||||
// members can read the sessions of a chat shared with their tenant, but a
|
||||
// session may only be mutated by the chat owner (the dialog tenant) or by
|
||||
// the session's creator. Everyone else sees the session readonly.
|
||||
func (s *ChatSessionService) ensureSessionWritable(ctx context.Context, userID, chatID string, session *entity.ChatSession) (bool, error) {
|
||||
owns, err := s.chatSessionDAO.CheckDialogExists(ctx, dao.DB, userID, chatID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if owns {
|
||||
return true, nil
|
||||
}
|
||||
return session.UserID != nil && *session.UserID == userID, nil
|
||||
}
|
||||
|
||||
func (s *ChatSessionService) buildSessionPayload(session *entity.ChatSession, dialog *entity.Chat, includeAvatar bool) *ChatSessionPayload {
|
||||
var avatar *string
|
||||
if includeAvatar {
|
||||
@@ -1289,8 +1182,9 @@ func (s *ChatSessionService) ChatCompletions(
|
||||
var dialog *entity.Chat
|
||||
var session *entity.ChatSession
|
||||
if chatID != "" {
|
||||
if err = s.checkDialogOwnership(ctx, userID, chatID); err != nil {
|
||||
return fail(common.NewCodedError(common.CodeAuthenticationError, "no authorization"))
|
||||
if err = checkChatPermission(ctx, s.permissionChecker, userID, chatID, permission.OperationRun); err != nil {
|
||||
code, normalizedErr := normalizeChatPermissionError(err)
|
||||
return fail(common.NewCodedError(code, normalizedErr.Error()))
|
||||
}
|
||||
dialog, err = s.chatSessionDAO.GetDialogByID(ctx, dao.DB, chatID)
|
||||
if err != nil {
|
||||
@@ -1304,11 +1198,9 @@ func (s *ChatSessionService) ChatCompletions(
|
||||
if session.DialogID != chatID {
|
||||
return fail(common.NewCodedError(common.CodeDataError, "Session does not belong to this chat!"))
|
||||
}
|
||||
// Shared-session readonly rule: only the chat owner (the dialog
|
||||
// tenant) or the session's creator may append to a session.
|
||||
// Team members who can read the shared chat see it readonly.
|
||||
if dialog.TenantID != userID && (session.UserID == nil || *session.UserID != userID) {
|
||||
return fail(common.NewCodedError(common.CodeAuthenticationError, errSharedSessionReadonly.Error()))
|
||||
if err = checkChatSessionPermission(ctx, s.permissionChecker, userID, sessionID, permission.OperationRun); err != nil {
|
||||
code, normalizedErr := normalizeChatPermissionError(err)
|
||||
return fail(common.NewCodedError(code, normalizedErr.Error()))
|
||||
}
|
||||
} else if storeHistoryMessages {
|
||||
session, err = s.createSessionForCompletion(ctx, chatID, dialog, userID)
|
||||
@@ -1668,18 +1560,6 @@ func (s *ChatSessionService) normalizeCompletionMessages(
|
||||
return requestMsg, messageID, nil
|
||||
}
|
||||
|
||||
// checkDialogOwnership checks if the user owns the dialog.
|
||||
func (s *ChatSessionService) checkDialogOwnership(ctx context.Context, userID, chatID string) error {
|
||||
ok, err := s.ensureOwnedChat(ctx, userID, chatID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return errors.New("no authorization")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildDefaultCompletionDialog mirrors Python _build_default_completion_dialog.
|
||||
func (s *ChatSessionService) buildDefaultCompletionDialog(tenantID string) *entity.Chat {
|
||||
return &entity.Chat{
|
||||
|
||||
@@ -17,17 +17,31 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/permission"
|
||||
)
|
||||
|
||||
type emptyChatSessionScopeChecker struct {
|
||||
checkErr error
|
||||
}
|
||||
|
||||
func (c emptyChatSessionScopeChecker) CheckResource(context.Context, permission.Subject, permission.ResourceRef, permission.Operation) error {
|
||||
return c.checkErr
|
||||
}
|
||||
|
||||
func (emptyChatSessionScopeChecker) Scope(context.Context, permission.Subject, permission.ScopeQuery) (permission.Scope, error) {
|
||||
return permission.Scope{Mode: permission.ScopeNone}, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CreateSession contract — mirrors the assertions in
|
||||
// test_session_create_validation_and_deleted_chat_contract that exercise the
|
||||
// Go service layer (name validation -> 102, auth -> 109, truncation, success).
|
||||
// Go service layer (name validation, permission denial, truncation, success).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCreateSession_Success(t *testing.T) {
|
||||
@@ -36,9 +50,9 @@ func TestCreateSession_Success(t *testing.T) {
|
||||
store.dialogs["chat-1"] = &entity.Chat{ID: "chat-1", PromptConfig: entity.JSONMap{"prologue": "hi"}}
|
||||
|
||||
svc := &ChatSessionService{
|
||||
chatSessionDAO: store,
|
||||
userTenantDAO: &fakeTenantStore{},
|
||||
pipeline: &fakePipeline{},
|
||||
chatSessionDAO: store,
|
||||
permissionChecker: store,
|
||||
pipeline: &fakePipeline{},
|
||||
}
|
||||
|
||||
ctx := t.Context()
|
||||
@@ -66,9 +80,9 @@ func TestCreateSession_RejectsEmptyOrNonStringName(t *testing.T) {
|
||||
store.dialogs["chat-1"] = &entity.Chat{ID: "chat-1"}
|
||||
|
||||
svc := &ChatSessionService{
|
||||
chatSessionDAO: store,
|
||||
userTenantDAO: &fakeTenantStore{},
|
||||
pipeline: &fakePipeline{},
|
||||
chatSessionDAO: store,
|
||||
permissionChecker: store,
|
||||
pipeline: &fakePipeline{},
|
||||
}
|
||||
|
||||
ctx := t.Context()
|
||||
@@ -89,9 +103,9 @@ func TestCreateSession_TruncatesLongName(t *testing.T) {
|
||||
store.dialogs["chat-1"] = &entity.Chat{ID: "chat-1"}
|
||||
|
||||
svc := &ChatSessionService{
|
||||
chatSessionDAO: store,
|
||||
userTenantDAO: &fakeTenantStore{},
|
||||
pipeline: &fakePipeline{},
|
||||
chatSessionDAO: store,
|
||||
permissionChecker: store,
|
||||
pipeline: &fakePipeline{},
|
||||
}
|
||||
|
||||
ctx := t.Context()
|
||||
@@ -111,21 +125,36 @@ func TestCreateSession_TruncatesLongName(t *testing.T) {
|
||||
func TestCreateSession_NotOwner(t *testing.T) {
|
||||
store := newFakeSessionStore()
|
||||
svc := &ChatSessionService{
|
||||
chatSessionDAO: store,
|
||||
userTenantDAO: &fakeTenantStore{},
|
||||
pipeline: &fakePipeline{},
|
||||
chatSessionDAO: store,
|
||||
permissionChecker: store,
|
||||
pipeline: &fakePipeline{},
|
||||
}
|
||||
|
||||
ctx := t.Context()
|
||||
_, code, err := svc.CreateSession(ctx, "user-1", "chat-1", map[string]interface{}{"name": "x"})
|
||||
if err == nil || err.Error() != "no authorization" {
|
||||
if err == nil || err.Error() != "Resource not found" {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if code != common.CodeAuthenticationError {
|
||||
if code != common.CodeNotFound {
|
||||
t.Fatalf("code=%v", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListChatSessionsChecksChatPermissionWhenThereAreNoSessions(t *testing.T) {
|
||||
svc := &ChatSessionService{
|
||||
chatSessionDAO: newFakeSessionStore(),
|
||||
permissionChecker: emptyChatSessionScopeChecker{checkErr: permission.ErrPermissionDenied},
|
||||
}
|
||||
|
||||
result, err := svc.ListChatSessions(t.Context(), "outsider", "chat-1", "", "", nil, 1, 20)
|
||||
if result != nil {
|
||||
t.Fatalf("expected no response for unauthorized chat, got %+v", result)
|
||||
}
|
||||
if err == nil || err.Error() != "Resource not found" {
|
||||
t.Fatalf("expected hidden permission error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DeleteSessions contract — mirrors the service-layer assertions in
|
||||
// test_session_delete_basic_scenarios.
|
||||
@@ -138,9 +167,9 @@ func TestDeleteSessions_SuccessByIDs(t *testing.T) {
|
||||
store.sessions["s2"] = &entity.ChatSession{ID: "s2", DialogID: "chat-1"}
|
||||
|
||||
svc := &ChatSessionService{
|
||||
chatSessionDAO: store,
|
||||
userTenantDAO: &fakeTenantStore{},
|
||||
pipeline: &fakePipeline{},
|
||||
chatSessionDAO: store,
|
||||
permissionChecker: store,
|
||||
pipeline: &fakePipeline{},
|
||||
}
|
||||
|
||||
ctx := t.Context()
|
||||
@@ -167,9 +196,9 @@ func TestDeleteSessions_DeleteAllAndInvalidID(t *testing.T) {
|
||||
store.dialogExists["user-1|chat-1"] = true
|
||||
|
||||
svc := &ChatSessionService{
|
||||
chatSessionDAO: store,
|
||||
userTenantDAO: &fakeTenantStore{},
|
||||
pipeline: &fakePipeline{},
|
||||
chatSessionDAO: store,
|
||||
permissionChecker: store,
|
||||
pipeline: &fakePipeline{},
|
||||
}
|
||||
|
||||
// Empty payload -> success with empty result map.
|
||||
@@ -201,17 +230,17 @@ func TestDeleteSessions_DeleteAllAndInvalidID(t *testing.T) {
|
||||
func TestDeleteSessions_NotOwner(t *testing.T) {
|
||||
store := newFakeSessionStore()
|
||||
svc := &ChatSessionService{
|
||||
chatSessionDAO: store,
|
||||
userTenantDAO: &fakeTenantStore{},
|
||||
pipeline: &fakePipeline{},
|
||||
chatSessionDAO: store,
|
||||
permissionChecker: store,
|
||||
pipeline: &fakePipeline{},
|
||||
}
|
||||
|
||||
ctx := t.Context()
|
||||
_, _, code, err := svc.DeleteSessions(ctx, "user-1", "chat-1", map[string]interface{}{"ids": []interface{}{"s1"}})
|
||||
if err == nil || err.Error() != "no authorization" {
|
||||
if err == nil || err.Error() != "Resource not found" {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if code != common.CodeAuthenticationError {
|
||||
if code != common.CodeNotFound {
|
||||
t.Fatalf("code=%v", code)
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"ragflow/internal/engine"
|
||||
"ragflow/internal/entity"
|
||||
modelModule "ragflow/internal/entity/models"
|
||||
"ragflow/internal/permission"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -163,12 +164,19 @@ func (f *fakeSessionStore) DeleteByID(ctx context.Context, db *gorm.DB, id strin
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeSessionStore) ListByChatID(ctx context.Context, db *gorm.DB, chatID, sessionID, name string, terms []dao.OrderTerm, page, pageSize int, includeHistory ...bool) ([]*entity.ChatSession, error) {
|
||||
func (f *fakeSessionStore) ListByResourceIDs(_ context.Context, _ *gorm.DB, chatID string, sessionIDs []string, sessionID, name string, _ []dao.OrderTerm, _, _ int, _ ...bool) ([]*entity.ChatSession, error) {
|
||||
allowedIDs := make(map[string]struct{}, len(sessionIDs))
|
||||
for _, id := range sessionIDs {
|
||||
allowedIDs[id] = struct{}{}
|
||||
}
|
||||
var result []*entity.ChatSession
|
||||
for _, s := range f.sessions {
|
||||
if s.DialogID != chatID {
|
||||
continue
|
||||
}
|
||||
if _, ok := allowedIDs[s.ID]; !ok {
|
||||
continue
|
||||
}
|
||||
if sessionID != "" && s.ID != sessionID {
|
||||
continue
|
||||
}
|
||||
@@ -197,20 +205,91 @@ func (f *fakeSessionStore) GetDialogByID(ctx context.Context, db *gorm.DB, chatI
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (f *fakeSessionStore) CheckDialogExists(ctx context.Context, db *gorm.DB, tenantID, chatID string) (bool, error) {
|
||||
key := tenantID + "|" + chatID
|
||||
return f.dialogExists[key], nil
|
||||
func (f *fakeSessionStore) CheckResource(_ context.Context, subject permission.Subject, ref permission.ResourceRef, operation permission.Operation) error {
|
||||
chatID := ref.ID
|
||||
var session *entity.ChatSession
|
||||
if ref.Kind == permission.ResourceKindChatSession {
|
||||
var exists bool
|
||||
session, exists = f.sessions[ref.ID]
|
||||
if !exists {
|
||||
return permission.ErrResourceNotFound
|
||||
}
|
||||
chatID = session.DialogID
|
||||
} else if ref.Kind != permission.ResourceKindChat {
|
||||
return permission.ErrResourceNotFound
|
||||
}
|
||||
|
||||
ownerIDs := make([]string, 0, 1)
|
||||
for key := range f.dialogExists {
|
||||
ownerID, id, ok := strings.Cut(key, "|")
|
||||
if ok && id == chatID {
|
||||
ownerIDs = append(ownerIDs, ownerID)
|
||||
}
|
||||
}
|
||||
if len(ownerIDs) == 0 {
|
||||
return permission.ErrResourceNotFound
|
||||
}
|
||||
|
||||
member := false
|
||||
owner := false
|
||||
for _, ownerID := range ownerIDs {
|
||||
if ownerID == subject.UserID {
|
||||
owner = true
|
||||
member = true
|
||||
break
|
||||
}
|
||||
member = true // The fixture's dialogExists entry represents tenant visibility.
|
||||
}
|
||||
if !member {
|
||||
return permission.ErrPermissionDenied
|
||||
}
|
||||
|
||||
if ref.Kind == permission.ResourceKindChat {
|
||||
if (operation == permission.OperationUpdate || operation == permission.OperationDelete) && !owner {
|
||||
return permission.ErrPermissionDenied
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if operation == permission.OperationRead {
|
||||
return nil
|
||||
}
|
||||
if owner || (session.UserID != nil && *session.UserID == subject.UserID) {
|
||||
return nil
|
||||
}
|
||||
return permission.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type fakeTenantStore struct {
|
||||
tenantIDs []string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeTenantStore) GetTenantIDsByUserID(ctx context.Context, db *gorm.DB, userID string) ([]string, error) {
|
||||
return f.tenantIDs, f.err
|
||||
func (f *fakeSessionStore) Scope(ctx context.Context, subject permission.Subject, query permission.ScopeQuery) (permission.Scope, error) {
|
||||
if query.Kind != permission.ResourceKindChatSession || query.Parent.Kind != permission.ResourceKindChat {
|
||||
return permission.Scope{}, permission.ErrInvalidPermission
|
||||
}
|
||||
if query.Entry != nil {
|
||||
if err := f.CheckResource(ctx, subject, *query.Entry, query.EntryOp); err != nil {
|
||||
return permission.Scope{}, err
|
||||
}
|
||||
}
|
||||
ids := make([]string, 0)
|
||||
for _, session := range f.sessions {
|
||||
if session.DialogID != query.Parent.ID {
|
||||
continue
|
||||
}
|
||||
if err := f.CheckResource(ctx, subject, permission.ResourceRef{Kind: permission.ResourceKindChatSession, ID: session.ID}, query.Operation); err != nil {
|
||||
if errors.Is(err, permission.ErrPermissionDenied) || errors.Is(err, permission.ErrResourceNotFound) {
|
||||
continue
|
||||
}
|
||||
return permission.Scope{}, err
|
||||
}
|
||||
ids = append(ids, session.ID)
|
||||
}
|
||||
scope := permission.Scope{Kind: query.Kind, Parent: query.Parent, Operation: query.Operation}
|
||||
if len(ids) == 0 {
|
||||
scope.Mode = permission.ScopeNone
|
||||
return scope, nil
|
||||
}
|
||||
scope.Mode = permission.ScopeIDs
|
||||
scope.ResourceIDs = ids
|
||||
return scope, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -357,9 +436,9 @@ func TestListChatSessions_Success(t *testing.T) {
|
||||
store.dialogExists["tenant-1|chat-1"] = true
|
||||
|
||||
svc := &ChatSessionService{
|
||||
chatSessionDAO: store,
|
||||
userTenantDAO: &fakeTenantStore{tenantIDs: []string{"tenant-1"}},
|
||||
pipeline: &fakePipeline{},
|
||||
chatSessionDAO: store,
|
||||
permissionChecker: store,
|
||||
pipeline: &fakePipeline{},
|
||||
}
|
||||
|
||||
ctx := t.Context()
|
||||
@@ -376,14 +455,14 @@ func TestListChatSessions_NotOwner(t *testing.T) {
|
||||
store := newFakeSessionStore()
|
||||
|
||||
svc := &ChatSessionService{
|
||||
chatSessionDAO: store,
|
||||
userTenantDAO: &fakeTenantStore{tenantIDs: []string{"tenant-other"}},
|
||||
pipeline: &fakePipeline{},
|
||||
chatSessionDAO: store,
|
||||
permissionChecker: store,
|
||||
pipeline: &fakePipeline{},
|
||||
}
|
||||
|
||||
ctx := t.Context()
|
||||
_, err := svc.ListChatSessions(ctx, "user-1", "chat-1", "", "", []dao.OrderTerm{{Column: "create_time", Desc: true}}, 1, 30)
|
||||
if err == nil || !strings.Contains(err.Error(), "no authorization") {
|
||||
if err == nil || err.Error() != "Resource not found" {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -410,9 +489,9 @@ func TestGetSession_Success(t *testing.T) {
|
||||
store.dialogExists["user-1|chat-1"] = true
|
||||
|
||||
svc := &ChatSessionService{
|
||||
chatSessionDAO: store,
|
||||
userTenantDAO: &fakeTenantStore{},
|
||||
pipeline: &fakePipeline{},
|
||||
chatSessionDAO: store,
|
||||
permissionChecker: store,
|
||||
pipeline: &fakePipeline{},
|
||||
}
|
||||
|
||||
ctx := t.Context()
|
||||
@@ -452,18 +531,19 @@ func TestGetSession_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetSession_NotOwner(t *testing.T) {
|
||||
store := newFakeSessionStore()
|
||||
svc := &ChatSessionService{
|
||||
chatSessionDAO: newFakeSessionStore(),
|
||||
userTenantDAO: &fakeTenantStore{},
|
||||
pipeline: &fakePipeline{},
|
||||
chatSessionDAO: store,
|
||||
permissionChecker: store,
|
||||
pipeline: &fakePipeline{},
|
||||
}
|
||||
|
||||
ctx := t.Context()
|
||||
_, code, err := svc.GetSession(ctx, "user-1", "chat-1", "session-1")
|
||||
if err == nil || err.Error() != "no authorization" {
|
||||
if err == nil || err.Error() != "Resource not found" {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if code != common.CodeAuthenticationError {
|
||||
if code != common.CodeNotFound {
|
||||
t.Fatalf("code=%v", code)
|
||||
}
|
||||
}
|
||||
@@ -474,9 +554,9 @@ func TestGetSession_WrongChat(t *testing.T) {
|
||||
store.dialogExists["user-1|chat-1"] = true
|
||||
|
||||
svc := &ChatSessionService{
|
||||
chatSessionDAO: store,
|
||||
userTenantDAO: &fakeTenantStore{},
|
||||
pipeline: &fakePipeline{},
|
||||
chatSessionDAO: store,
|
||||
permissionChecker: store,
|
||||
pipeline: &fakePipeline{},
|
||||
}
|
||||
|
||||
ctx := t.Context()
|
||||
@@ -500,9 +580,9 @@ func TestUpdateSession_Success(t *testing.T) {
|
||||
store.dialogExists["user-1|chat-1"] = true
|
||||
|
||||
svc := &ChatSessionService{
|
||||
chatSessionDAO: store,
|
||||
userTenantDAO: &fakeTenantStore{},
|
||||
pipeline: &fakePipeline{},
|
||||
chatSessionDAO: store,
|
||||
permissionChecker: store,
|
||||
pipeline: &fakePipeline{},
|
||||
}
|
||||
|
||||
longName := " " + strings.Repeat("x", 260) + " "
|
||||
@@ -541,9 +621,9 @@ func TestUpdateSession_ValidationErrors(t *testing.T) {
|
||||
store.dialogExists["user-1|chat-1"] = true
|
||||
|
||||
svc := &ChatSessionService{
|
||||
chatSessionDAO: store,
|
||||
userTenantDAO: &fakeTenantStore{},
|
||||
pipeline: &fakePipeline{},
|
||||
chatSessionDAO: store,
|
||||
permissionChecker: store,
|
||||
pipeline: &fakePipeline{},
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
@@ -578,9 +658,9 @@ func TestUpdateSession_NotFound(t *testing.T) {
|
||||
store.dialogExists["user-1|chat-1"] = true
|
||||
|
||||
svc := &ChatSessionService{
|
||||
chatSessionDAO: store,
|
||||
userTenantDAO: &fakeTenantStore{},
|
||||
pipeline: &fakePipeline{},
|
||||
chatSessionDAO: store,
|
||||
permissionChecker: store,
|
||||
pipeline: &fakePipeline{},
|
||||
}
|
||||
|
||||
ctx := t.Context()
|
||||
@@ -618,9 +698,9 @@ func TestDeleteSessionMessage_RemovesMessagePairAndReference(t *testing.T) {
|
||||
store.dialogExists["tenant-1|chat-1"] = true
|
||||
|
||||
svc := &ChatSessionService{
|
||||
chatSessionDAO: store,
|
||||
userTenantDAO: &fakeTenantStore{tenantIDs: []string{"tenant-1"}},
|
||||
pipeline: &fakePipeline{},
|
||||
chatSessionDAO: store,
|
||||
permissionChecker: store,
|
||||
pipeline: &fakePipeline{},
|
||||
}
|
||||
|
||||
ctx := t.Context()
|
||||
@@ -669,12 +749,13 @@ func TestUpdateMessageFeedback_AppliesChunkFeedbackWithResolvedTenantAndContext(
|
||||
]`),
|
||||
}
|
||||
store.dialogExists["tenant-owner|chat-1"] = true
|
||||
store.dialogs["chat-1"] = &entity.Chat{ID: "chat-1", TenantID: "tenant-owner"}
|
||||
docEngine := &fakeFeedbackDocEngine{}
|
||||
svc := &ChatSessionService{
|
||||
chatSessionDAO: store,
|
||||
userTenantDAO: &fakeTenantStore{tenantIDs: []string{"tenant-owner"}},
|
||||
pipeline: &fakePipeline{},
|
||||
docEngine: docEngine,
|
||||
chatSessionDAO: store,
|
||||
permissionChecker: store,
|
||||
pipeline: &fakePipeline{},
|
||||
docEngine: docEngine,
|
||||
}
|
||||
ctx := context.WithValue(t.Context(), feedbackContextKey{}, "request-context")
|
||||
|
||||
@@ -728,12 +809,13 @@ func TestUpdateMessageFeedback_ToggleUsesResolvedTenantForUndoAndApply(t *testin
|
||||
]`),
|
||||
}
|
||||
store.dialogExists["tenant-owner|chat-1"] = true
|
||||
store.dialogs["chat-1"] = &entity.Chat{ID: "chat-1", TenantID: "tenant-owner"}
|
||||
docEngine := &fakeFeedbackDocEngine{}
|
||||
svc := &ChatSessionService{
|
||||
chatSessionDAO: store,
|
||||
userTenantDAO: &fakeTenantStore{tenantIDs: []string{"tenant-owner"}},
|
||||
pipeline: &fakePipeline{},
|
||||
docEngine: docEngine,
|
||||
chatSessionDAO: store,
|
||||
permissionChecker: store,
|
||||
pipeline: &fakePipeline{},
|
||||
docEngine: docEngine,
|
||||
}
|
||||
|
||||
resp, code, err := svc.UpdateMessageFeedback(t.Context(), "user-1", "chat-1", "session-1", "msg-1", map[string]interface{}{
|
||||
@@ -915,9 +997,9 @@ func TestChatCompletions_AppendOnly(t *testing.T) {
|
||||
}
|
||||
|
||||
svc := &ChatSessionService{
|
||||
chatSessionDAO: store,
|
||||
userTenantDAO: &fakeTenantStore{},
|
||||
pipeline: pipeline,
|
||||
chatSessionDAO: store,
|
||||
permissionChecker: store,
|
||||
pipeline: pipeline,
|
||||
}
|
||||
|
||||
ctx := t.Context()
|
||||
@@ -981,7 +1063,7 @@ func TestChatCompletions_AppendOnly(t *testing.T) {
|
||||
AsyncChatResult{Answer: "test answer", Reference: ref},
|
||||
AsyncChatResult{Answer: "test answer", Reference: ref, Final: true},
|
||||
)}
|
||||
svc := &ChatSessionService{chatSessionDAO: store, userTenantDAO: &fakeTenantStore{}, pipeline: pipeline}
|
||||
svc := &ChatSessionService{chatSessionDAO: store, permissionChecker: store, pipeline: pipeline}
|
||||
streamChan := make(chan string, 8)
|
||||
result, err := svc.ChatCompletions(t.Context(), "user-1", "dialog-1", sessionID, payload, "must not replace payload", nil, "", nil, map[string]interface{}{"store_history_messages": false}, legacy, stream, streamChan)
|
||||
if err != nil {
|
||||
@@ -1045,9 +1127,9 @@ func TestChatCompletionsPassesRequestUserIDToPipeline(t *testing.T) {
|
||||
}
|
||||
|
||||
svc := &ChatSessionService{
|
||||
chatSessionDAO: store,
|
||||
userTenantDAO: &fakeTenantStore{tenantIDs: []string{"tenant-owner"}},
|
||||
pipeline: pipeline,
|
||||
chatSessionDAO: store,
|
||||
permissionChecker: store,
|
||||
pipeline: pipeline,
|
||||
}
|
||||
|
||||
_, err := svc.ChatCompletions(
|
||||
@@ -1127,9 +1209,9 @@ func TestChatCompletionsStreamFinalCarriesDecoratedReference(t *testing.T) {
|
||||
}
|
||||
|
||||
svc := &ChatSessionService{
|
||||
chatSessionDAO: store,
|
||||
userTenantDAO: &fakeTenantStore{tenantIDs: []string{"tenant-owner"}},
|
||||
pipeline: pipeline,
|
||||
chatSessionDAO: store,
|
||||
permissionChecker: store,
|
||||
pipeline: pipeline,
|
||||
}
|
||||
|
||||
streamChan := make(chan string, 8)
|
||||
@@ -1227,10 +1309,10 @@ func TestChatCompletionsModelIDOverrideUsesModelResolver(t *testing.T) {
|
||||
modelID := "3d2d824e7e5d11f1a845455b140cef90"
|
||||
|
||||
svc := &ChatSessionService{
|
||||
chatSessionDAO: store,
|
||||
userTenantDAO: &fakeTenantStore{tenantIDs: []string{"tenant-owner"}},
|
||||
pipeline: pipeline,
|
||||
modelFactory: resolver,
|
||||
chatSessionDAO: store,
|
||||
permissionChecker: store,
|
||||
pipeline: pipeline,
|
||||
modelFactory: resolver,
|
||||
}
|
||||
|
||||
_, err := svc.ChatCompletions(
|
||||
@@ -1633,7 +1715,7 @@ func TestAccumulateNonStreamAnswer_AccumulatesDeltasUntilFinal(t *testing.T) {
|
||||
func newSharedChatReadonlyService() (*ChatSessionService, *fakeSessionStore) {
|
||||
store := newFakeSessionStore()
|
||||
// The chat is owned by tenant-owner and team-shared: "user-1" joined
|
||||
// that tenant, so reads pass ensureOwnedChat.
|
||||
// that tenant, so the permission checker allows reads.
|
||||
store.dialogExists["tenant-owner|chat-1"] = true
|
||||
// The session was created by the chat owner, not by user-1.
|
||||
owner := "tenant-owner"
|
||||
@@ -1645,9 +1727,9 @@ func newSharedChatReadonlyService() (*ChatSessionService, *fakeSessionStore) {
|
||||
Message: json.RawMessage(`[{"role":"assistant","content":"Welcome!"}]`),
|
||||
}
|
||||
svc := &ChatSessionService{
|
||||
chatSessionDAO: store,
|
||||
userTenantDAO: &fakeTenantStore{tenantIDs: []string{"tenant-owner"}},
|
||||
pipeline: &fakePipeline{},
|
||||
chatSessionDAO: store,
|
||||
permissionChecker: store,
|
||||
pipeline: &fakePipeline{},
|
||||
}
|
||||
return svc, store
|
||||
}
|
||||
@@ -1656,10 +1738,10 @@ func TestUpdateSession_SharedSessionReadonlyForTeammate(t *testing.T) {
|
||||
svc, store := newSharedChatReadonlyService()
|
||||
|
||||
_, code, err := svc.UpdateSession(t.Context(), "user-1", "chat-1", "session-1", map[string]interface{}{"name": "renamed"})
|
||||
if err == nil || err.Error() != "shared session is readonly" {
|
||||
if err == nil || err.Error() != "Resource not found" {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if code != common.CodeAuthenticationError {
|
||||
if code != common.CodeNotFound {
|
||||
t.Fatalf("code=%v", code)
|
||||
}
|
||||
if len(store.updateCalled) != 0 {
|
||||
@@ -1703,7 +1785,7 @@ func TestDeleteSessions_SharedSessionReadonlyForTeammate(t *testing.T) {
|
||||
svc, store := newSharedChatReadonlyService()
|
||||
|
||||
resp, msg, code, err := svc.DeleteSessions(t.Context(), "user-1", "chat-1", map[string]interface{}{"ids": []interface{}{"session-1"}})
|
||||
if err == nil || !strings.Contains(err.Error(), "readonly") {
|
||||
if err == nil || !strings.Contains(err.Error(), "Resource not found") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if code != common.CodeDataError {
|
||||
@@ -1723,10 +1805,10 @@ func TestDeleteSessionMessage_SharedSessionReadonlyForTeammate(t *testing.T) {
|
||||
svc, _ := newSharedChatReadonlyService()
|
||||
|
||||
_, code, err := svc.DeleteSessionMessage(t.Context(), "user-1", "chat-1", "session-1", "msg-1")
|
||||
if err == nil || err.Error() != "shared session is readonly" {
|
||||
if err == nil || err.Error() != "Resource not found" {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if code != common.CodeAuthenticationError {
|
||||
if code != common.CodeNotFound {
|
||||
t.Fatalf("code=%v", code)
|
||||
}
|
||||
}
|
||||
@@ -1735,10 +1817,10 @@ func TestUpdateMessageFeedback_SharedSessionReadonlyForTeammate(t *testing.T) {
|
||||
svc, _ := newSharedChatReadonlyService()
|
||||
|
||||
_, code, err := svc.UpdateMessageFeedback(t.Context(), "user-1", "chat-1", "session-1", "msg-1", map[string]interface{}{"thumbup": true})
|
||||
if err == nil || err.Error() != "shared session is readonly" {
|
||||
if err == nil || err.Error() != "Resource not found" {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if code != common.CodeAuthenticationError {
|
||||
if code != common.CodeNotFound {
|
||||
t.Fatalf("code=%v", code)
|
||||
}
|
||||
}
|
||||
@@ -1769,7 +1851,7 @@ func TestChatCompletions_SharedSessionReadonlyForTeammate(t *testing.T) {
|
||||
t.Fatalf("expected readonly rejection")
|
||||
}
|
||||
coded := common.NewCodedError(0, "")
|
||||
if !errors.As(err, &coded) || coded.Code != common.CodeAuthenticationError {
|
||||
if !errors.As(err, &coded) || coded.Code != common.CodeNotFound {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if len(store.updateCalled) != 0 {
|
||||
|
||||
@@ -79,7 +79,6 @@ type ChunkService struct {
|
||||
docEngine engine.DocEngine
|
||||
embeddingCache *utility.EmbeddingLRU
|
||||
kbDAO *dao.KnowledgebaseDAO
|
||||
userTenantDAO *dao.UserTenantDAO
|
||||
documentDAO *dao.DocumentDAO
|
||||
taskDAO *dao.TaskDAO
|
||||
ingestionTaskDAO *dao.IngestionTaskDAO
|
||||
@@ -113,7 +112,6 @@ func NewChunkService() *ChunkService {
|
||||
docEngine: engine.Get(),
|
||||
embeddingCache: utility.NewEmbeddingLRU(1000), // default capacity
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
userTenantDAO: dao.NewUserTenantDAO(),
|
||||
documentDAO: dao.NewDocumentDAO(),
|
||||
taskDAO: dao.NewTaskDAO(),
|
||||
ingestionTaskDAO: dao.NewIngestionTaskDAO(),
|
||||
@@ -168,39 +166,27 @@ func (s *ChunkService) RetrievalTest(ctx context.Context, req *service.Retrieval
|
||||
}
|
||||
modelFactory := service.NewModelFactory()
|
||||
|
||||
tenants, err := s.userTenantDAO.GetByUserID(ctx, dao.DB, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user tenants: %w", err)
|
||||
}
|
||||
if len(tenants) == 0 {
|
||||
return nil, fmt.Errorf("user has no accessible tenants")
|
||||
}
|
||||
common.Debug("Retrieved user tenants from database", zap.String("userID", userID), zap.Int("tenantCount", len(tenants)))
|
||||
|
||||
var tenantIDs []string
|
||||
var kbRecords []*entity.Knowledgebase
|
||||
for _, datasetID := range req.Datasets {
|
||||
found := false
|
||||
for _, tenant := range tenants {
|
||||
kb, err := s.kbDAO.GetByIDAndTenantID(ctx, dao.DB, datasetID, tenant.TenantID)
|
||||
if err == nil && kb != nil {
|
||||
if kb.TenantID != userID && kb.Permission != string(entity.TenantPermissionTeam) {
|
||||
continue
|
||||
}
|
||||
common.Debug("Found knowledge base in database",
|
||||
zap.String("datasetID", datasetID),
|
||||
zap.String("tenantID", tenant.TenantID),
|
||||
zap.String("kbName", kb.Name),
|
||||
zap.String("embdID", kb.EmbdID))
|
||||
tenantIDs = append(tenantIDs, tenant.TenantID)
|
||||
kbRecords = append(kbRecords, kb)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
if err := s.checkDatasetAccess(ctx, datasetID, userID, permission.OperationUse); err != nil {
|
||||
_, permissionErr := permissionresponse.NormalizeHidden(err)
|
||||
return nil, permissionErr
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("only owner of dataset is authorized for this operation")
|
||||
kb, err := s.getKnowledgebaseByID(ctx, datasetID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load dataset %s: %w", datasetID, err)
|
||||
}
|
||||
if kb == nil {
|
||||
return nil, fmt.Errorf("dataset %s not found", datasetID)
|
||||
}
|
||||
common.Debug("Found knowledge base in database",
|
||||
zap.String("datasetID", datasetID),
|
||||
zap.String("tenantID", kb.TenantID),
|
||||
zap.String("kbName", kb.Name),
|
||||
zap.String("embdID", kb.EmbdID))
|
||||
tenantIDs = append(tenantIDs, kb.TenantID)
|
||||
kbRecords = append(kbRecords, kb)
|
||||
}
|
||||
|
||||
// Check if all kbs resolve to the same base embedding model
|
||||
@@ -227,10 +213,14 @@ func (s *ChunkService) RetrievalTest(ctx context.Context, req *service.Retrieval
|
||||
|
||||
if req.SearchID != nil && *req.SearchID != "" {
|
||||
// If search_id is set, get meta_data_filter and chat_id from search_config
|
||||
searchDetail, err := s.searchService.GetDetail(ctx, *req.SearchID)
|
||||
if s.searchService == nil {
|
||||
return nil, fmt.Errorf("search service not configured")
|
||||
}
|
||||
searchDetail, err := s.searchService.GetDetail(ctx, userID, *req.SearchID)
|
||||
if err != nil {
|
||||
common.Warn("Failed to get search detail for search_id, proceeding without it", zap.String("searchID", *req.SearchID), zap.Error(err))
|
||||
} else if searchConfig, ok := searchConfigMap(searchDetail["search_config"]); ok && searchConfig != nil {
|
||||
return nil, fmt.Errorf("failed to get search app %s: %w", *req.SearchID, err)
|
||||
}
|
||||
if searchConfig, ok := searchConfigMap(searchDetail["search_config"]); ok && searchConfig != nil {
|
||||
if req.RerankCandidatesCount == nil || *req.RerankCandidatesCount == 0 {
|
||||
rerankCandidatesCount = 100
|
||||
if configuredRerankCandidatesCount, ok := common.GetInt(searchConfig["rerank_candidates_count"]); ok {
|
||||
|
||||
@@ -1,14 +1,34 @@
|
||||
package chunk
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/permission"
|
||||
permissionresponse "ragflow/internal/permission/response"
|
||||
"ragflow/internal/service"
|
||||
)
|
||||
|
||||
func TestRetrievalTestChecksDatasetUsePermission(t *testing.T) {
|
||||
db := setupChunkTestDB(t)
|
||||
pushChunkTestDB(t, db)
|
||||
insertChunkTestKB(t, "kb-1", "owner")
|
||||
if err := db.Model(&entity.Knowledgebase{}).Where("id = ?", "kb-1").
|
||||
Update("permission", string(entity.TenantPermissionMe)).Error; err != nil {
|
||||
t.Fatalf("set dataset private: %v", err)
|
||||
}
|
||||
|
||||
_, err := (&ChunkService{kbDAO: dao.NewKnowledgebaseDAO()}).RetrievalTest(t.Context(), &service.RetrievalTestRequest{
|
||||
Question: "query",
|
||||
Datasets: []string{"kb-1"},
|
||||
}, "outsider")
|
||||
if !permissionresponse.IsPermissionError(err) || !errors.Is(err, permission.ErrPermissionDenied) {
|
||||
t.Fatalf("RetrievalTest() error = %v, want permission denial", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkManagementDatasetPermissions(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
|
||||
@@ -316,10 +316,9 @@ func TestListSortsChunksByDocumentPosition(t *testing.T) {
|
||||
|
||||
engine := &listChunksSearchEngine{}
|
||||
svc := &ChunkService{
|
||||
docEngine: engine,
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
userTenantDAO: dao.NewUserTenantDAO(),
|
||||
documentDAO: dao.NewDocumentDAO(),
|
||||
docEngine: engine,
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
documentDAO: dao.NewDocumentDAO(),
|
||||
}
|
||||
|
||||
ctx := t.Context()
|
||||
@@ -376,10 +375,9 @@ func TestListScopesSearchToDocumentDataset(t *testing.T) {
|
||||
|
||||
engine := &listChunksSearchEngine{}
|
||||
svc := &ChunkService{
|
||||
docEngine: engine,
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
userTenantDAO: dao.NewUserTenantDAO(),
|
||||
documentDAO: dao.NewDocumentDAO(),
|
||||
docEngine: engine,
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
documentDAO: dao.NewDocumentDAO(),
|
||||
}
|
||||
|
||||
ctx := t.Context()
|
||||
@@ -428,9 +426,8 @@ func TestGetScopesChunkLookupToDocumentDataset(t *testing.T) {
|
||||
"content_ltks": []string{"invoice", "body"},
|
||||
}}
|
||||
svc := &ChunkService{
|
||||
docEngine: engine,
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
userTenantDAO: dao.NewUserTenantDAO(),
|
||||
docEngine: engine,
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
}
|
||||
|
||||
resp, err := svc.Get(t.Context(), &service.GetChunkRequest{
|
||||
@@ -485,9 +482,8 @@ func TestGetRejectsChunkOfAnotherDocument(t *testing.T) {
|
||||
"content_with_weight": "other document body",
|
||||
}}
|
||||
svc := &ChunkService{
|
||||
docEngine: engine,
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
userTenantDAO: dao.NewUserTenantDAO(),
|
||||
docEngine: engine,
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
}
|
||||
|
||||
_, err := svc.Get(t.Context(), &service.GetChunkRequest{
|
||||
@@ -521,9 +517,8 @@ func TestGetRequiresDocumentInRouteDataset(t *testing.T) {
|
||||
"content_with_weight": "other dataset document body",
|
||||
}}
|
||||
svc := &ChunkService{
|
||||
docEngine: engine,
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
userTenantDAO: dao.NewUserTenantDAO(),
|
||||
docEngine: engine,
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
}
|
||||
|
||||
_, err := svc.Get(t.Context(), &service.GetChunkRequest{
|
||||
@@ -603,10 +598,9 @@ func TestListBuildsMatchTextExprForKeywords(t *testing.T) {
|
||||
|
||||
engine := &listChunksSearchEngine{}
|
||||
svc := &ChunkService{
|
||||
docEngine: engine,
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
userTenantDAO: dao.NewUserTenantDAO(),
|
||||
documentDAO: dao.NewDocumentDAO(),
|
||||
docEngine: engine,
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
documentDAO: dao.NewDocumentDAO(),
|
||||
}
|
||||
ctx := t.Context()
|
||||
|
||||
@@ -690,9 +684,8 @@ func TestUpdateChunkRejectsChunkFromAnotherDocument(t *testing.T) {
|
||||
existingChunk: map[string]interface{}{"doc_id": "doc-b"},
|
||||
}
|
||||
svc := &ChunkService{
|
||||
docEngine: engine,
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
userTenantDAO: dao.NewUserTenantDAO(),
|
||||
docEngine: engine,
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
}
|
||||
|
||||
err := svc.UpdateChunk(t.Context(), &service.UpdateChunkRequest{
|
||||
@@ -726,9 +719,8 @@ func TestUpdateChunkUpdatesSameDocumentWithDocumentCondition(t *testing.T) {
|
||||
},
|
||||
}
|
||||
svc := &ChunkService{
|
||||
docEngine: engine,
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
userTenantDAO: dao.NewUserTenantDAO(),
|
||||
docEngine: engine,
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
}
|
||||
|
||||
err := svc.UpdateChunk(t.Context(), &service.UpdateChunkRequest{
|
||||
@@ -781,9 +773,8 @@ func TestUpdateChunkStoresTagKwdOnlyWhenSent(t *testing.T) {
|
||||
},
|
||||
}
|
||||
svc := &ChunkService{
|
||||
docEngine: engine,
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
userTenantDAO: dao.NewUserTenantDAO(),
|
||||
docEngine: engine,
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
}
|
||||
|
||||
err := svc.UpdateChunk(t.Context(), &service.UpdateChunkRequest{
|
||||
@@ -835,9 +826,8 @@ func TestUpdateChunkStoresImageAndFlagsImageChunk(t *testing.T) {
|
||||
var storedBucket, storedChunkID, storedMode string
|
||||
var storedBinary []byte
|
||||
svc := &ChunkService{
|
||||
docEngine: engine,
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
userTenantDAO: dao.NewUserTenantDAO(),
|
||||
docEngine: engine,
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
storeChunkImageFunc: func(bucket, chunkID string, imageBinary []byte, mode string) error {
|
||||
storedBucket, storedChunkID, storedMode, storedBinary = bucket, chunkID, mode, imageBinary
|
||||
return nil
|
||||
@@ -895,9 +885,8 @@ func TestUpdateChunkRemoveModeClearsImageAfterIndexUpdate(t *testing.T) {
|
||||
order: order,
|
||||
}
|
||||
svc := &ChunkService{
|
||||
docEngine: engine,
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
userTenantDAO: dao.NewUserTenantDAO(),
|
||||
docEngine: engine,
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
storeChunkImageFunc: func(string, string, []byte, string) error {
|
||||
t.Fatal("remove mode must not store an image")
|
||||
return nil
|
||||
@@ -955,9 +944,8 @@ func TestUpdateChunkMarksWikiDirtyWhenImageRemovalFails(t *testing.T) {
|
||||
}
|
||||
wikiCalls := 0
|
||||
svc := &ChunkService{
|
||||
docEngine: engine,
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
userTenantDAO: dao.NewUserTenantDAO(),
|
||||
docEngine: engine,
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
removeChunkImageFunc: func(string, string) error {
|
||||
return errors.New("storage unavailable")
|
||||
},
|
||||
@@ -1015,9 +1003,8 @@ func TestUpdateChunkRejectsInvalidImageInput(t *testing.T) {
|
||||
},
|
||||
}
|
||||
svc := &ChunkService{
|
||||
docEngine: engine,
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
userTenantDAO: dao.NewUserTenantDAO(),
|
||||
docEngine: engine,
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
storeChunkImageFunc: func(string, string, []byte, string) error {
|
||||
t.Fatal("invalid input must not reach storage")
|
||||
return nil
|
||||
@@ -1624,9 +1611,8 @@ func TestRemoveChunksDecrementsStatsAfterDelete(t *testing.T) {
|
||||
|
||||
engine := &parseTestDocEngine{deleteChunksCount: 3}
|
||||
svc := &ChunkService{
|
||||
docEngine: engine,
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
userTenantDAO: dao.NewUserTenantDAO(),
|
||||
docEngine: engine,
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
}
|
||||
ctx := t.Context()
|
||||
deletedCount, err := svc.RemoveChunks(ctx, &service.RemoveChunksRequest{
|
||||
@@ -1685,9 +1671,8 @@ func TestRemoveChunksSkipsStatsWhenNothingDeleted(t *testing.T) {
|
||||
|
||||
var decrementCalls int
|
||||
svc := &ChunkService{
|
||||
docEngine: &parseTestDocEngine{deleteChunksCount: 0},
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
userTenantDAO: dao.NewUserTenantDAO(),
|
||||
docEngine: &parseTestDocEngine{deleteChunksCount: 0},
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
decrementChunkStatsFunc: func(string, string, int64, int64, float64) error {
|
||||
decrementCalls++
|
||||
return nil
|
||||
@@ -1717,9 +1702,8 @@ func TestRemoveChunksReturnsStatsError(t *testing.T) {
|
||||
insertChunkTestDoc(t, "doc-1", "kb-1")
|
||||
|
||||
svc := &ChunkService{
|
||||
docEngine: &parseTestDocEngine{deleteChunksCount: 2},
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
userTenantDAO: dao.NewUserTenantDAO(),
|
||||
docEngine: &parseTestDocEngine{deleteChunksCount: 2},
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
decrementChunkStatsFunc: func(docID, kbID string, tokenNum, chunkNum int64, duration float64) error {
|
||||
if docID != "doc-1" || kbID != "kb-1" || tokenNum != 0 || chunkNum != 2 || duration != 0 {
|
||||
t.Fatalf("unexpected decrement args doc=%s kb=%s token=%d chunk=%d duration=%v", docID, kbID, tokenNum, chunkNum, duration)
|
||||
@@ -2336,9 +2320,8 @@ func TestSwitchChunksUpdatesDocEngineWithAvailableInt(t *testing.T) {
|
||||
|
||||
engine := &switchChunksEngineMock{}
|
||||
svc := &ChunkService{
|
||||
docEngine: engine,
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
userTenantDAO: dao.NewUserTenantDAO(),
|
||||
docEngine: engine,
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
}
|
||||
|
||||
ctx := t.Context()
|
||||
|
||||
+78
-112
@@ -76,8 +76,6 @@ var (
|
||||
var (
|
||||
// ErrConnectorNotFound is returned when a connector is not found.
|
||||
ErrConnectorNotFound = errors.New("can't find this Connector")
|
||||
// ErrConnectorNoAuth is returned when the caller cannot access the connector or knowledge base.
|
||||
ErrConnectorNoAuth = errors.New("no authorization")
|
||||
// ErrConnectorNotBoundToKB is returned when the connector is not bound to the kb being rebuilt.
|
||||
ErrConnectorNotBoundToKB = errors.New("connector is not bound to this knowledge base")
|
||||
// ErrConnectorIDRequired is returned when a connector ID is missing.
|
||||
@@ -93,8 +91,6 @@ var (
|
||||
// ConnectorService connector service
|
||||
type ConnectorService struct {
|
||||
connectorDAO *dao.ConnectorDAO
|
||||
knowledgebaseDAO *dao.KnowledgebaseDAO
|
||||
userTenantDAO *dao.UserTenantDAO
|
||||
connectorRegistry *syncerconnector.Registry
|
||||
}
|
||||
|
||||
@@ -133,8 +129,6 @@ var getSyncCheckpointDeleter = func() (syncCheckpointDeleter, bool) {
|
||||
func NewConnectorService() *ConnectorService {
|
||||
return &ConnectorService{
|
||||
connectorDAO: dao.NewConnectorDAO(),
|
||||
knowledgebaseDAO: dao.NewKnowledgebaseDAO(),
|
||||
userTenantDAO: dao.NewUserTenantDAO(),
|
||||
connectorRegistry: newConnectorRegistry(),
|
||||
}
|
||||
}
|
||||
@@ -271,21 +265,6 @@ type boxOAuthTokenResponse struct {
|
||||
ErrorDesc string `json:"error_description,omitempty"`
|
||||
}
|
||||
|
||||
// canAccessConnector Test Authentication
|
||||
func (s *ConnectorService) canAccessConnector(ctx context.Context, connector *entity.Connector, userID string) (bool, error) {
|
||||
if connector.TenantID == userID {
|
||||
return true, nil
|
||||
}
|
||||
_, err := s.userTenantDAO.FilterByUserIDAndTenantID(ctx, dao.DB, userID, connector.TenantID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// cancelConnectorTasks Stop connector tasks
|
||||
func (s *ConnectorService) cancelConnectorTasks(ctx context.Context, connectorID string) error {
|
||||
if err := s.connectorDAO.CancelRunningOrScheduledLogs(ctx, dao.DB, connectorID); err != nil {
|
||||
@@ -331,11 +310,14 @@ func (s *ConnectorService) CreateConnector(ctx context.Context, userID string, r
|
||||
return s.connectorDAO.GetByID(ctx, dao.DB, connector.ID)
|
||||
}
|
||||
|
||||
// GetConnector returns one connector when the user can access its tenant.
|
||||
// GetConnector returns one connector when the user has read permission.
|
||||
func (s *ConnectorService) GetConnector(ctx context.Context, connectorID, userID string) (*entity.Connector, error) {
|
||||
if strings.TrimSpace(connectorID) == "" {
|
||||
return nil, ErrConnectorIDRequired
|
||||
}
|
||||
if err := checkConnectorPermission(ctx, userID, connectorID, permission.OperationRead); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
connector, err := s.connectorDAO.GetByID(ctx, dao.DB, connectorID)
|
||||
if err != nil {
|
||||
@@ -346,25 +328,30 @@ func (s *ConnectorService) GetConnector(ctx context.Context, connectorID, userID
|
||||
return nil, ErrConnectorInternal
|
||||
}
|
||||
|
||||
canAccess, err := s.canAccessConnector(ctx, connector, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !canAccess {
|
||||
return nil, ErrConnectorNoAuth
|
||||
}
|
||||
return connector, nil
|
||||
}
|
||||
|
||||
// ListConnectors list connectors for a user
|
||||
func (s *ConnectorService) ListConnectors(ctx context.Context, userID string) (*ListConnectorsResponse, error) {
|
||||
// ListConnectors returns connectors belonging to a tenant the caller can use.
|
||||
func (s *ConnectorService) ListConnectors(ctx context.Context, userID, requestedTenantID string) (*ListConnectorsResponse, error) {
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID == "" {
|
||||
return nil, fmt.Errorf("user_id is required")
|
||||
}
|
||||
|
||||
// Query connectors by tenant ID
|
||||
connectors, err := s.connectorDAO.ListByTenantID(ctx, dao.DB, userID)
|
||||
tenantID := strings.TrimSpace(requestedTenantID)
|
||||
if tenantID == "" {
|
||||
var err error
|
||||
tenantID, err = requireOwnedTenantID(ctx, userID)
|
||||
if errors.Is(err, permission.ErrMembershipNotFound) {
|
||||
return &ListConnectorsResponse{Connectors: []*dao.ConnectorListItem{}}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if err := permission.NewDatabaseChecker(dao.DB).CheckTenant(ctx, permission.Subject{UserID: userID}, tenantID, permission.TenantMember); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
connectors, err := s.connectorDAO.ListByTenantID(ctx, dao.DB, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -374,37 +361,27 @@ func (s *ConnectorService) ListConnectors(ctx context.Context, userID string) (*
|
||||
}, nil
|
||||
}
|
||||
|
||||
// accessible reports whether the user can access the connector's tenant.
|
||||
func (s *ConnectorService) accessible(ctx context.Context, connectorID, userID string) (bool, error) {
|
||||
connector, err := s.connectorDAO.GetByID(ctx, dao.DB, connectorID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, ErrConnectorNotFound
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return s.canAccessConnector(ctx, connector, userID)
|
||||
}
|
||||
|
||||
// TestConnector validates connector settings without persisting or syncing.
|
||||
func (s *ConnectorService) TestConnector(ctx context.Context, connectorID, userID string, config entity.JSONMap) error {
|
||||
var storedConnector *entity.Connector
|
||||
if connectorID != "" {
|
||||
ok, err := s.accessible(ctx, connectorID, userID)
|
||||
if err != nil && !errors.Is(err, ErrConnectorNotFound) {
|
||||
return err
|
||||
permissionErr := checkConnectorPermission(ctx, userID, connectorID, permission.OperationUse)
|
||||
if permissionErr != nil && !errors.Is(permissionErr, permission.ErrResourceNotFound) {
|
||||
return permissionErr
|
||||
}
|
||||
if err == nil && !ok {
|
||||
return ErrConnectorNoAuth
|
||||
}
|
||||
if err == nil {
|
||||
storedConnector, err = s.connectorDAO.GetByID(ctx, dao.DB, connectorID)
|
||||
if err != nil {
|
||||
if errors.Is(permissionErr, permission.ErrResourceNotFound) {
|
||||
if config == nil {
|
||||
return ErrConnectorNotFound
|
||||
}
|
||||
}
|
||||
if errors.Is(err, ErrConnectorNotFound) && config == nil {
|
||||
return ErrConnectorNotFound
|
||||
} else {
|
||||
loadedConnector, err := s.connectorDAO.GetByID(ctx, dao.DB, connectorID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrConnectorNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
storedConnector = loadedConnector
|
||||
}
|
||||
}
|
||||
|
||||
@@ -939,6 +916,10 @@ func (s *ConnectorService) DeleteConnector(ctx context.Context, connectorID, use
|
||||
if connectorID == "" {
|
||||
return false, common.CodeDataError, fmt.Errorf("connector_id is required")
|
||||
}
|
||||
if err := checkConnectorPermission(ctx, userID, connectorID, permission.OperationDelete); err != nil {
|
||||
code, permissionErr := normalizeConnectorPermissionError(err)
|
||||
return false, code, permissionErr
|
||||
}
|
||||
|
||||
connector, err := s.connectorDAO.GetByID(ctx, dao.DB, connectorID)
|
||||
if err != nil {
|
||||
@@ -948,14 +929,6 @@ func (s *ConnectorService) DeleteConnector(ctx context.Context, connectorID, use
|
||||
return false, common.CodeServerError, err
|
||||
}
|
||||
|
||||
canAccess, err := s.canAccessConnector(ctx, connector, userID)
|
||||
if err != nil {
|
||||
return false, common.CodeServerError, err
|
||||
}
|
||||
if !canAccess {
|
||||
return false, common.CodeAuthenticationError, fmt.Errorf("no authorization")
|
||||
}
|
||||
|
||||
if err = s.cancelConnectorTasks(ctx, connector.ID); err != nil {
|
||||
return false, common.CodeServerError, err
|
||||
}
|
||||
@@ -979,6 +952,16 @@ func (s *ConnectorService) UpdateConnector(ctx context.Context, connectorID, use
|
||||
if connectorID == "" {
|
||||
return nil, common.CodeDataError, fmt.Errorf("connector_id is required")
|
||||
}
|
||||
if err := checkConnectorPermission(ctx, userID, connectorID, permission.OperationUpdate); err != nil {
|
||||
code, permissionErr := normalizeConnectorPermissionError(err)
|
||||
return nil, code, permissionErr
|
||||
}
|
||||
if req != nil && (req.Reschedule || isConnectorCancelStatus(req.Status) || isConnectorScheduleStatus(req.Status)) {
|
||||
if err := checkConnectorPermission(ctx, userID, connectorID, permission.OperationRun); err != nil {
|
||||
code, permissionErr := normalizeConnectorPermissionError(err)
|
||||
return nil, code, permissionErr
|
||||
}
|
||||
}
|
||||
|
||||
connector, err := s.connectorDAO.GetByID(ctx, dao.DB, connectorID)
|
||||
if err != nil {
|
||||
@@ -988,14 +971,6 @@ func (s *ConnectorService) UpdateConnector(ctx context.Context, connectorID, use
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
|
||||
canAccess, err := s.canAccessConnector(ctx, connector, userID)
|
||||
if err != nil {
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
if !canAccess {
|
||||
return nil, common.CodeAuthenticationError, fmt.Errorf("no authorization")
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{}
|
||||
if req != nil {
|
||||
if req.PruneFreq != nil {
|
||||
@@ -1070,6 +1045,10 @@ func (s *ConnectorService) RebuildConnector(ctx context.Context, connectorID, us
|
||||
if kbID == "" {
|
||||
return false, common.CodeArgumentError, fmt.Errorf("required argument is missing: kb_id")
|
||||
}
|
||||
if err := checkConnectorPermission(ctx, userID, connectorID, permission.OperationRun); err != nil {
|
||||
code, permissionErr := normalizeConnectorPermissionError(err)
|
||||
return false, code, permissionErr
|
||||
}
|
||||
|
||||
connector, err := s.connectorDAO.GetByID(ctx, dao.DB, connectorID)
|
||||
if err != nil {
|
||||
@@ -1079,14 +1058,6 @@ func (s *ConnectorService) RebuildConnector(ctx context.Context, connectorID, us
|
||||
return false, common.CodeServerError, err
|
||||
}
|
||||
|
||||
canAccess, err := s.canAccessConnector(ctx, connector, userID)
|
||||
if err != nil {
|
||||
return false, common.CodeServerError, err
|
||||
}
|
||||
if !canAccess {
|
||||
return false, common.CodeAuthenticationError, fmt.Errorf("no authorization")
|
||||
}
|
||||
|
||||
// The caller-supplied kb is targeted by delete + re-sync below, so it must
|
||||
// be accessible to the caller and the connector must be bound to it.
|
||||
if err := CheckDatasetAccess(ctx, permission.Subject{UserID: userID}, kbID, permission.OperationRun); err != nil {
|
||||
@@ -1144,23 +1115,18 @@ func (s *ConnectorService) ResumeFailedSync(ctx context.Context, connectorID, us
|
||||
if req.TaskID == "" {
|
||||
return false, common.CodeArgumentError, fmt.Errorf("required argument is missing: task_id")
|
||||
}
|
||||
// get connector
|
||||
connector, err := s.connectorDAO.GetByID(ctx, dao.DB, connectorID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, common.CodeDataError, fmt.Errorf("can't find this Connector")
|
||||
}
|
||||
return false, common.CodeServerError, err
|
||||
if err := checkConnectorPermission(ctx, userID, connectorID, permission.OperationRun); err != nil {
|
||||
code, permissionErr := normalizeConnectorPermissionError(err)
|
||||
return false, code, permissionErr
|
||||
}
|
||||
// check access
|
||||
canAccess, err := s.canAccessConnector(ctx, connector, userID)
|
||||
if err != nil {
|
||||
return false, common.CodeServerError, err
|
||||
if err := checkSyncTaskPermission(ctx, userID, connectorID, req.TaskID, permission.OperationRun); err != nil {
|
||||
code, permissionErr := normalizeConnectorPermissionError(err)
|
||||
return false, code, permissionErr
|
||||
}
|
||||
if !canAccess {
|
||||
return false, common.CodeAuthenticationError, fmt.Errorf("no authorization")
|
||||
if err := CheckDatasetAccess(ctx, permission.Subject{UserID: userID}, req.KbID, permission.OperationRun); err != nil {
|
||||
code, permissionErr := permissionresponse.NormalizeHidden(err)
|
||||
return false, code, permissionErr
|
||||
}
|
||||
|
||||
// load the checkpoint
|
||||
loader, ok := getSyncCheckpointLoader()
|
||||
if !ok {
|
||||
@@ -1257,20 +1223,10 @@ func (s *ConnectorService) ListLog(ctx context.Context, connectorID, userID stri
|
||||
return nil, 0, common.CodeDataError, fmt.Errorf("connector_id is required")
|
||||
}
|
||||
|
||||
connector, err := s.connectorDAO.GetByID(ctx, dao.DB, connectorID)
|
||||
taskIDs, err := accessibleSyncTaskIDs(ctx, userID, connectorID, permission.OperationRead)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, 0, common.CodeDataError, fmt.Errorf("can't find this Connector")
|
||||
}
|
||||
return nil, 0, common.CodeServerError, err
|
||||
}
|
||||
|
||||
canAccess, err := s.canAccessConnector(ctx, connector, userID)
|
||||
if err != nil {
|
||||
return nil, 0, common.CodeServerError, err
|
||||
}
|
||||
if !canAccess {
|
||||
return nil, 0, common.CodeAuthenticationError, fmt.Errorf("no authorization")
|
||||
code, permissionErr := normalizeConnectorPermissionError(err)
|
||||
return nil, 0, code, permissionErr
|
||||
}
|
||||
|
||||
if page < 1 {
|
||||
@@ -1281,7 +1237,7 @@ func (s *ConnectorService) ListLog(ctx context.Context, connectorID, userID stri
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
logs, total, err := s.connectorDAO.ListLogsByConnectorID(ctx, dao.DB, connectorID, offset, pageSize)
|
||||
logs, total, err := s.connectorDAO.ListLogsByTaskIDs(ctx, dao.DB, taskIDs, "", offset, pageSize)
|
||||
if err != nil {
|
||||
return nil, 0, common.CodeServerError, fmt.Errorf("failed to fetch connector logs: %w", err)
|
||||
}
|
||||
@@ -1299,11 +1255,21 @@ func (s *ConnectorService) ListLogs(ctx context.Context, userID, datasetID strin
|
||||
return nil, 0, common.CodeDataError, fmt.Errorf("user_id is required")
|
||||
}
|
||||
|
||||
tenantIDs, err := s.userTenantDAO.GetTenantIDsByUserID(ctx, dao.DB, userID)
|
||||
if datasetID != "" {
|
||||
if err := CheckDatasetAccess(ctx, permission.Subject{UserID: userID}, datasetID, permission.OperationRead); err != nil {
|
||||
code, permissionErr := permissionresponse.NormalizeHidden(err)
|
||||
return nil, 0, code, permissionErr
|
||||
}
|
||||
}
|
||||
connectorIDs, err := accessibleConnectorIDs(ctx, userID, permission.OperationRead)
|
||||
if err != nil {
|
||||
return nil, 0, common.CodeServerError, err
|
||||
}
|
||||
tenantIDs = append(tenantIDs, userID)
|
||||
taskIDs, err := accessibleSyncTaskIDsForConnectors(ctx, userID, connectorIDs, permission.OperationRead)
|
||||
if err != nil {
|
||||
code, permissionErr := normalizeConnectorPermissionError(err)
|
||||
return nil, 0, code, permissionErr
|
||||
}
|
||||
|
||||
offset, limit := 0, pageSize
|
||||
if pageSize <= 0 {
|
||||
@@ -1319,7 +1285,7 @@ func (s *ConnectorService) ListLogs(ctx context.Context, userID, datasetID strin
|
||||
offset = (page - 1) * limit
|
||||
}
|
||||
|
||||
logs, total, err := s.connectorDAO.ListLogs(ctx, dao.DB, tenantIDs, datasetID, offset, limit)
|
||||
logs, total, err := s.connectorDAO.ListLogsByTaskIDs(ctx, dao.DB, taskIDs, datasetID, offset, limit)
|
||||
if err != nil {
|
||||
return nil, 0, common.CodeServerError, fmt.Errorf("failed to fetch sync logs: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//
|
||||
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/permission"
|
||||
permissionresponse "ragflow/internal/permission/response"
|
||||
)
|
||||
|
||||
func checkConnectorPermission(ctx context.Context, userID, connectorID string, operation permission.Operation) error {
|
||||
return permission.NewDatabaseChecker(dao.DB).CheckResource(
|
||||
ctx,
|
||||
permission.Subject{UserID: userID},
|
||||
permission.ResourceRef{Kind: permission.ResourceKindConnector, ID: connectorID},
|
||||
operation,
|
||||
)
|
||||
}
|
||||
|
||||
func accessibleConnectorIDs(ctx context.Context, userID string, operation permission.Operation) ([]string, error) {
|
||||
scope, err := permission.NewDatabaseChecker(dao.DB).Scope(ctx, permission.Subject{UserID: userID}, permission.ScopeQuery{
|
||||
Kind: permission.ResourceKindConnector,
|
||||
Operation: operation,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if scope.Mode != permission.ScopeIDs {
|
||||
return []string{}, nil
|
||||
}
|
||||
return scope.ResourceIDs, nil
|
||||
}
|
||||
|
||||
func checkSyncTaskPermission(ctx context.Context, userID, connectorID, taskID string, operation permission.Operation) error {
|
||||
checker := permission.NewDatabaseChecker(dao.DB)
|
||||
connectorRef := permission.ResourceRef{Kind: permission.ResourceKindConnector, ID: connectorID}
|
||||
scope, err := checker.Scope(ctx, permission.Subject{UserID: userID}, permission.ScopeQuery{
|
||||
Kind: permission.ResourceKindSyncTask,
|
||||
Parent: connectorRef,
|
||||
Operation: operation,
|
||||
Entry: &connectorRef,
|
||||
EntryOp: operation,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, scopedTaskID := range scope.ResourceIDs {
|
||||
if scopedTaskID == taskID {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return permission.ErrResourceNotFound
|
||||
}
|
||||
|
||||
func accessibleSyncTaskIDs(ctx context.Context, userID, connectorID string, operation permission.Operation) ([]string, error) {
|
||||
checker := permission.NewDatabaseChecker(dao.DB)
|
||||
subject := permission.Subject{UserID: userID}
|
||||
connectorRef := permission.ResourceRef{Kind: permission.ResourceKindConnector, ID: connectorID}
|
||||
if err := checker.CheckResource(ctx, subject, connectorRef, permission.OperationRead); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scope, err := checker.Scope(ctx, subject, permission.ScopeQuery{
|
||||
Kind: permission.ResourceKindSyncTask,
|
||||
Parent: connectorRef,
|
||||
Operation: operation,
|
||||
Entry: &connectorRef,
|
||||
EntryOp: permission.OperationRead,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if scope.Mode != permission.ScopeIDs {
|
||||
return []string{}, nil
|
||||
}
|
||||
return scope.ResourceIDs, nil
|
||||
}
|
||||
|
||||
func accessibleSyncTaskIDsForConnectors(ctx context.Context, userID string, connectorIDs []string, operation permission.Operation) ([]string, error) {
|
||||
var taskIDs []string
|
||||
for _, connectorID := range connectorIDs {
|
||||
ids, err := accessibleSyncTaskIDs(ctx, userID, connectorID, operation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
taskIDs = append(taskIDs, ids...)
|
||||
}
|
||||
return taskIDs, nil
|
||||
}
|
||||
|
||||
func normalizeConnectorPermissionError(err error) (common.ErrorCode, error) {
|
||||
return permissionresponse.NormalizeHidden(err)
|
||||
}
|
||||
@@ -116,7 +116,7 @@ func TestUpdateConnectorSchedulePublishesSyncerTask(t *testing.T) {
|
||||
func TestResumeFailedSyncSchedulesOriginalTaskFromCheckpoint(t *testing.T) {
|
||||
db := setupServiceTestDB(t)
|
||||
pushServiceDB(t, db)
|
||||
if err := db.AutoMigrate(&entity.Connector{}, &entity.SyncLogs{}); err != nil {
|
||||
if err := db.AutoMigrate(&entity.Connector{}, &entity.Connector2Kb{}, &entity.Knowledgebase{}, &entity.SyncLogs{}, &entity.UserTenant{}); err != nil {
|
||||
t.Fatalf("migrate connector tables: %v", err)
|
||||
}
|
||||
if err := db.Create(&entity.Connector{
|
||||
@@ -133,6 +133,19 @@ func TestResumeFailedSyncSchedulesOriginalTaskFromCheckpoint(t *testing.T) {
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("insert connector: %v", err)
|
||||
}
|
||||
valid := string(entity.StatusValid)
|
||||
if err := db.Create(&entity.Knowledgebase{
|
||||
ID: "kb-1", TenantID: "user-1", CreatedBy: "user-1", Name: "kb-1", EmbdID: "embd",
|
||||
Permission: string(entity.TenantPermissionMe), Status: &valid,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("insert knowledgebase: %v", err)
|
||||
}
|
||||
if err := db.Create(&entity.Connector2Kb{ID: "conn-1-kb-1", ConnectorID: "conn-1", KbID: "kb-1", AutoParse: "1"}).Error; err != nil {
|
||||
t.Fatalf("insert connector dataset link: %v", err)
|
||||
}
|
||||
if err := db.Create(&entity.UserTenant{ID: "owner-user-1", UserID: "user-1", TenantID: "user-1", Role: string(permission.RoleOwner), Status: &valid}).Error; err != nil {
|
||||
t.Fatalf("insert tenant owner: %v", err)
|
||||
}
|
||||
if err := db.Create(&entity.SyncLogs{
|
||||
ID: "task-1",
|
||||
ConnectorID: "conn-1",
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/permission"
|
||||
syncerconnector "ragflow/internal/syncer/connector"
|
||||
connectormock "ragflow/internal/syncer/connector/mock"
|
||||
)
|
||||
@@ -132,8 +133,8 @@ func TestConnectorServiceTestConnectorRejectsUnauthorizedConnector(t *testing.T)
|
||||
"source": "mock",
|
||||
"config": entity.JSONMap{"ok": true},
|
||||
})
|
||||
if !errors.Is(err, ErrConnectorNoAuth) {
|
||||
t.Fatalf("error = %v, want ErrConnectorNoAuth", err)
|
||||
if !errors.Is(err, permission.ErrPermissionDenied) {
|
||||
t.Fatalf("error = %v, want permission denial", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -501,8 +501,9 @@ func datasetNameAndID(kb *entity.Knowledgebase) string {
|
||||
return fmt.Sprintf("%s (%s)", kb.Name, kb.ID)
|
||||
}
|
||||
|
||||
func (d *DatasetService) ListDatasets(ctx context.Context, id, name string, page, pageSize int, terms []dao.OrderTerm, keywords string, ownerIDs []string, parserID, userID string, ids []string) ([]map[string]interface{}, int64, common.ErrorCode, error) {
|
||||
func (d *DatasetService) ListDatasets(ctx context.Context, id, name string, page, pageSize int, terms []dao.OrderTerm, keywords string, ownerIDs []string, parserID, userID string, ids []string, canvasID string) ([]map[string]interface{}, int64, common.ErrorCode, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
canvasID = strings.TrimSpace(canvasID)
|
||||
if id != "" && len(ids) > 0 {
|
||||
return nil, 0, common.CodeDataError, fmt.Errorf("should not provide both 'id':%s and 'ids':%s", id, pythonStringListRepr(ids))
|
||||
}
|
||||
@@ -548,52 +549,63 @@ func (d *DatasetService) ListDatasets(ctx context.Context, id, name string, page
|
||||
err := dao.DB.Transaction(func(tx *gorm.DB) error {
|
||||
checker := permission.NewDatabaseChecker(tx)
|
||||
subject := permission.Subject{UserID: userID}
|
||||
operation := permission.OperationRead
|
||||
var entry *permission.ResourceRef
|
||||
if canvasID != "" {
|
||||
operation = permission.OperationUse
|
||||
entry = &permission.ResourceRef{Kind: permission.ResourceKindCanvas, ID: canvasID}
|
||||
}
|
||||
checkAccess := func(datasetID string) error {
|
||||
dependency := permission.ResourceRef{Kind: permission.ResourceKindDataset, ID: datasetID}
|
||||
if entry == nil {
|
||||
return checker.CheckResource(ctx, subject, dependency, operation)
|
||||
}
|
||||
return checker.CheckDependency(ctx, subject, *entry, dependency, permission.OperationRun, operation)
|
||||
}
|
||||
listQuery := tx.WithContext(ctx).Model(&entity.Knowledgebase{})
|
||||
var resourceIDs []string
|
||||
|
||||
switch {
|
||||
case id != "":
|
||||
checkErr := checker.CheckResource(ctx, subject, permission.ResourceRef{Kind: permission.ResourceKindDataset, ID: id}, permission.OperationRead)
|
||||
checkErr := checkAccess(id)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
resourceIDs = []string{id}
|
||||
listQuery = listQuery.Where("knowledgebase.id = ?", id)
|
||||
case len(ids) > 0:
|
||||
refs := make([]permission.ResourceRef, 0, len(ids))
|
||||
resourceIDs = make([]string, 0, len(ids))
|
||||
for _, datasetID := range ids {
|
||||
refs = append(refs, permission.ResourceRef{Kind: permission.ResourceKindDataset, ID: datasetID})
|
||||
}
|
||||
accessible, checkErr := checker.FilterResources(ctx, subject, refs, permission.OperationRead)
|
||||
if checkErr != nil {
|
||||
checkErr := checkAccess(datasetID)
|
||||
if checkErr == nil {
|
||||
resourceIDs = append(resourceIDs, datasetID)
|
||||
continue
|
||||
}
|
||||
if errors.Is(checkErr, permission.ErrPermissionDenied) ||
|
||||
errors.Is(checkErr, permission.ErrMembershipNotFound) ||
|
||||
errors.Is(checkErr, permission.ErrResourceNotFound) {
|
||||
deniedIDs = append(deniedIDs, datasetID)
|
||||
continue
|
||||
}
|
||||
return checkErr
|
||||
}
|
||||
accessibleIDs := make(map[string]struct{}, len(accessible))
|
||||
for _, ref := range accessible {
|
||||
accessibleIDs[ref.ID] = struct{}{}
|
||||
}
|
||||
resourceIDs = make([]string, 0, len(accessibleIDs))
|
||||
for _, datasetID := range ids {
|
||||
if _, ok := accessibleIDs[datasetID]; ok {
|
||||
resourceIDs = append(resourceIDs, datasetID)
|
||||
} else {
|
||||
deniedIDs = append(deniedIDs, datasetID)
|
||||
}
|
||||
if len(resourceIDs) == 0 {
|
||||
listQuery = listQuery.Where("1 = 0")
|
||||
} else {
|
||||
listQuery = listQuery.Where("knowledgebase.id IN ?", resourceIDs)
|
||||
}
|
||||
default:
|
||||
scope, checkErr := checker.Scope(ctx, subject, permission.ScopeQuery{
|
||||
Kind: permission.ResourceKindDataset, Operation: permission.OperationRead,
|
||||
})
|
||||
scopeQuery := permission.ScopeQuery{Kind: permission.ResourceKindDataset, Operation: operation}
|
||||
if entry != nil {
|
||||
scopeQuery.Entry = entry
|
||||
scopeQuery.EntryOp = permission.OperationRun
|
||||
}
|
||||
scope, checkErr := checker.QueryScope(ctx, subject, scopeQuery)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
if scope.Mode == permission.ScopeIDs {
|
||||
resourceIDs = scope.ResourceIDs
|
||||
}
|
||||
listQuery = scope.Apply(listQuery)
|
||||
}
|
||||
|
||||
if len(resourceIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
listed, count, err := d.kbDAO.ListByResourceIDs(ctx, tx, resourceIDs, filteredOwnerIDs, page, pageSize, terms, keywords, parserID, name)
|
||||
listed, count, err := d.kbDAO.ListByQuery(ctx, listQuery, filteredOwnerIDs, page, pageSize, terms, keywords, parserID, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -635,12 +647,16 @@ func (d *DatasetService) ListDatasets(ctx context.Context, id, name string, page
|
||||
return data, total, common.CodeSuccess, nil
|
||||
}
|
||||
|
||||
func (d *DatasetService) ListDatasetFilters(ctx context.Context, userID string) (map[string]interface{}, common.ErrorCode, error) {
|
||||
func (d *DatasetService) ListDatasetFilters(ctx context.Context, userID, canvasID string) (map[string]interface{}, common.ErrorCode, error) {
|
||||
owners := make([]*entity.DatasetOwnerFilter, 0)
|
||||
err := dao.DB.Transaction(func(tx *gorm.DB) error {
|
||||
scope, err := permission.NewDatabaseChecker(tx).Scope(ctx, permission.Subject{UserID: userID}, permission.ScopeQuery{
|
||||
Kind: permission.ResourceKindDataset, Operation: permission.OperationRead,
|
||||
})
|
||||
query := permission.ScopeQuery{Kind: permission.ResourceKindDataset, Operation: permission.OperationRead}
|
||||
if canvasID = strings.TrimSpace(canvasID); canvasID != "" {
|
||||
query.Operation = permission.OperationUse
|
||||
query.Entry = &permission.ResourceRef{Kind: permission.ResourceKindCanvas, ID: canvasID}
|
||||
query.EntryOp = permission.OperationRun
|
||||
}
|
||||
scope, err := permission.NewDatabaseChecker(tx).Scope(ctx, permission.Subject{UserID: userID}, query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -245,7 +245,31 @@ func (d *DatasetService) ListSyncLogs(ctx context.Context, datasetID, userID str
|
||||
pageSize = 30
|
||||
}
|
||||
|
||||
logs, total, err := d.connectorDAO.ListLogsByDatasetID(ctx, dao.DB, datasetID, (page-1)*pageSize, pageSize)
|
||||
connectors, err := d.connectorDAO.ListByDatasetID(ctx, dao.DB, datasetID)
|
||||
if err != nil {
|
||||
return nil, common.CodeServerError, fmt.Errorf("list dataset connectors for sync logs: %w", err)
|
||||
}
|
||||
checker := permission.NewDatabaseChecker(dao.DB)
|
||||
subject := permission.Subject{UserID: userID}
|
||||
taskIDs := make([]string, 0)
|
||||
for _, connector := range connectors {
|
||||
connectorRef := permission.ResourceRef{Kind: permission.ResourceKindConnector, ID: connector.ID}
|
||||
scope, scopeErr := checker.Scope(ctx, subject, permission.ScopeQuery{
|
||||
Kind: permission.ResourceKindSyncTask,
|
||||
Parent: connectorRef,
|
||||
Operation: permission.OperationRead,
|
||||
Entry: &connectorRef,
|
||||
EntryOp: permission.OperationUse,
|
||||
})
|
||||
if scopeErr != nil {
|
||||
code, permissionErr := permissionresponse.Normalize(scopeErr)
|
||||
return nil, code, permissionErr
|
||||
}
|
||||
if scope.Mode == permission.ScopeIDs {
|
||||
taskIDs = append(taskIDs, scope.ResourceIDs...)
|
||||
}
|
||||
}
|
||||
logs, total, err := d.connectorDAO.ListLogsByTaskIDs(ctx, dao.DB, taskIDs, datasetID, (page-1)*pageSize, pageSize)
|
||||
if err != nil {
|
||||
return nil, common.CodeServerError, fmt.Errorf("list dataset connector sync logs: %w", err)
|
||||
}
|
||||
|
||||
@@ -122,6 +122,7 @@ func TestListSyncLogsReturnsOnlyCurrentlyLinkedDatasetConnectors(t *testing.T) {
|
||||
connectors := []entity.Connector{
|
||||
{ID: "connector-1", TenantID: "user-1", Name: "linked", Source: "rss", InputType: "poll", Config: entity.JSONMap{}, Status: string(entity.TaskStatusDone)},
|
||||
{ID: "connector-2", TenantID: "user-1", Name: "other", Source: "rss", InputType: "poll", Config: entity.JSONMap{}, Status: string(entity.TaskStatusDone)},
|
||||
{ID: "connector-3", TenantID: "user-2", Name: "inaccessible", Source: "rss", InputType: "poll", Config: entity.JSONMap{}, Status: string(entity.TaskStatusDone)},
|
||||
}
|
||||
if err := db.Create(&connectors).Error; err != nil {
|
||||
t.Fatalf("insert connectors: %v", err)
|
||||
@@ -132,10 +133,14 @@ func TestListSyncLogsReturnsOnlyCurrentlyLinkedDatasetConnectors(t *testing.T) {
|
||||
if err := db.Create(&entity.Connector2Kb{ID: "connector-2-kb-2", ConnectorID: "connector-2", KbID: "kb-2", AutoParse: "1"}).Error; err != nil {
|
||||
t.Fatalf("link connector to kb-2: %v", err)
|
||||
}
|
||||
if err := db.Create(&entity.Connector2Kb{ID: "connector-3-kb-1", ConnectorID: "connector-3", KbID: "kb-1", AutoParse: "1"}).Error; err != nil {
|
||||
t.Fatalf("link inaccessible connector to kb-1: %v", err)
|
||||
}
|
||||
|
||||
logs := []entity.SyncLogs{
|
||||
{ID: "sync-kb-1", ConnectorID: "connector-1", KbID: "kb-1", TaskType: dao.TaskTypeSync, Status: dao.SyncStatusDone, ErrorMsg: ""},
|
||||
{ID: "sync-kb-2", ConnectorID: "connector-2", KbID: "kb-2", TaskType: dao.TaskTypeSync, Status: dao.SyncStatusDone, ErrorMsg: ""},
|
||||
{ID: "sync-inaccessible", ConnectorID: "connector-3", KbID: "kb-1", TaskType: dao.TaskTypeSync, Status: dao.SyncStatusDone, ErrorMsg: ""},
|
||||
// A stale/unlinked row must not appear in the dataset's configured connector logs.
|
||||
{ID: "sync-unlinked", ConnectorID: "connector-2", KbID: "kb-1", TaskType: dao.TaskTypeSync, Status: dao.SyncStatusDone, ErrorMsg: ""},
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestDatasetServiceListDatasetsFiltersByIDs(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
data, total, code, err := testDatasetListService(t).ListDatasets(ctx,
|
||||
"", "", 1, 30, []dao.OrderTerm{{Column: "create_time", Desc: true}},
|
||||
"", nil, "", "tenant-1", []string{"kb-1"},
|
||||
"", nil, "", "tenant-1", []string{"kb-1"}, "",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ListDatasets failed: %v", err)
|
||||
@@ -68,7 +68,7 @@ func TestDatasetServiceListDatasetsIDsAccessibleViaTeamTenant(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
data, total, code, err := testDatasetListService(t).ListDatasets(ctx,
|
||||
"", "", 1, 30, []dao.OrderTerm{{Column: "create_time", Desc: true}},
|
||||
"", nil, "", "user-1", []string{"kb-team"},
|
||||
"", nil, "", "user-1", []string{"kb-team"}, "",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ListDatasets failed: %v", err)
|
||||
@@ -97,7 +97,7 @@ func TestDatasetServiceListDatasetsAndFiltersUseAuthorizedScope(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
data, total, code, err := testDatasetListService(t).ListDatasets(ctx,
|
||||
"", "", 1, 30, []dao.OrderTerm{{Column: "create_time", Desc: true}},
|
||||
"", []string{"owner-1"}, "", "user-1", nil,
|
||||
"", []string{"owner-1"}, "", "user-1", nil, "",
|
||||
)
|
||||
if err != nil || code != common.CodeSuccess {
|
||||
t.Fatalf("ListDatasets = (%#v, %d, %v), want success", data, code, err)
|
||||
@@ -106,7 +106,7 @@ func TestDatasetServiceListDatasetsAndFiltersUseAuthorizedScope(t *testing.T) {
|
||||
t.Fatalf("ListDatasets returned %#v (total=%d), want only the shared dataset", data, total)
|
||||
}
|
||||
|
||||
filters, code, err := testDatasetListService(t).ListDatasetFilters(ctx, "user-1")
|
||||
filters, code, err := testDatasetListService(t).ListDatasetFilters(ctx, "user-1", "")
|
||||
if err != nil || code != common.CodeSuccess {
|
||||
t.Fatalf("ListDatasetFilters = (%#v, %v, %v), want success", filters, code, err)
|
||||
}
|
||||
@@ -119,11 +119,87 @@ func TestDatasetServiceListDatasetsAndFiltersUseAuthorizedScope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetServiceListDatasetsForCanvasUsesCallerOwnerIntersection(t *testing.T) {
|
||||
db := setupDatasetUpdateTestDB(t)
|
||||
pushServiceDB(t, db)
|
||||
insertDatasetUpdateKB(t, "kb-caller-only", "user-1", "Caller only")
|
||||
insertDatasetUpdateKB(t, "kb-shared", "owner-1", "Shared")
|
||||
insertDatasetUpdateKB(t, "kb-owner-only", "owner-1", "Owner only")
|
||||
insertDatasetUpdateTeamMember(t, "user-1", "owner-1")
|
||||
if err := dao.DB.Model(&entity.Knowledgebase{}).
|
||||
Where("id = ?", "kb-shared").
|
||||
Update("permission", string(entity.TenantPermissionTeam)).Error; err != nil {
|
||||
t.Fatalf("share dataset with tenant: %v", err)
|
||||
}
|
||||
canvas := entity.UserCanvas{
|
||||
ID: "canvas-shared",
|
||||
UserID: "owner-1",
|
||||
Permission: string(entity.TenantPermissionTeam),
|
||||
DSL: entity.JSONMap{},
|
||||
}
|
||||
if err := dao.DB.Create(&canvas).Error; err != nil {
|
||||
t.Fatalf("create shared canvas: %v", err)
|
||||
}
|
||||
|
||||
service := testDatasetListService(t)
|
||||
data, total, code, err := service.ListDatasets(t.Context(), "", "", 1, 30,
|
||||
[]dao.OrderTerm{{Column: "create_time", Desc: true}}, "", nil, "", "user-1", nil, "canvas-shared")
|
||||
if err != nil || code != common.CodeSuccess {
|
||||
t.Fatalf("ListDatasets(canvas) = (%#v, %d, %v), want success", data, code, err)
|
||||
}
|
||||
if total != 1 || len(data) != 1 || data[0]["id"] != "kb-shared" {
|
||||
t.Fatalf("canvas dataset list = %#v (total=%d), want only kb-shared", data, total)
|
||||
}
|
||||
|
||||
data, total, code, err = service.ListDatasets(t.Context(), "", "", 1, 30,
|
||||
[]dao.OrderTerm{{Column: "create_time", Desc: true}}, "", nil, "", "user-1",
|
||||
[]string{"kb-caller-only", "kb-shared", "kb-owner-only"}, "canvas-shared")
|
||||
if err != nil || code != common.CodeSuccess {
|
||||
t.Fatalf("ListDatasets(canvas, ids) = (%#v, %d, %v), want success", data, code, err)
|
||||
}
|
||||
if total != 1 || len(data) != 1 || data[0]["id"] != "kb-shared" {
|
||||
t.Fatalf("canvas dataset lookup = %#v (total=%d), want only kb-shared", data, total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetServiceListDatasetsPaginatesInsidePermissionScope(t *testing.T) {
|
||||
db := setupDatasetUpdateTestDB(t)
|
||||
pushServiceDB(t, db)
|
||||
insertDatasetUpdateKB(t, "kb-own", "user-1", "Own")
|
||||
insertDatasetUpdateKB(t, "kb-team", "owner-1", "Shared")
|
||||
insertDatasetUpdateKB(t, "kb-private", "owner-1", "Private")
|
||||
insertDatasetUpdateKB(t, "kb-outside", "other-owner", "Outside")
|
||||
insertDatasetUpdateTeamMember(t, "user-1", "owner-1")
|
||||
if err := dao.DB.Model(&entity.Knowledgebase{}).
|
||||
Where("id = ?", "kb-team").
|
||||
Update("permission", string(entity.TenantPermissionTeam)).Error; err != nil {
|
||||
t.Fatalf("share team dataset: %v", err)
|
||||
}
|
||||
|
||||
service := testDatasetListService(t)
|
||||
terms := []dao.OrderTerm{{Column: "name"}}
|
||||
pageOne, total, code, err := service.ListDatasets(t.Context(), "", "", 1, 1, terms, "", nil, "", "user-1", nil, "")
|
||||
if err != nil || code != common.CodeSuccess {
|
||||
t.Fatalf("ListDatasets(page 1) = (%#v, %d, %v), want success", pageOne, code, err)
|
||||
}
|
||||
if total != 2 || len(pageOne) != 1 || pageOne[0]["id"] != "kb-own" {
|
||||
t.Fatalf("page 1 = %#v (total=%d), want kb-own and two accessible datasets", pageOne, total)
|
||||
}
|
||||
|
||||
pageTwo, secondTotal, code, err := service.ListDatasets(t.Context(), "", "", 2, 1, terms, "", nil, "", "user-1", nil, "")
|
||||
if err != nil || code != common.CodeSuccess {
|
||||
t.Fatalf("ListDatasets(page 2) = (%#v, %d, %v), want success", pageTwo, code, err)
|
||||
}
|
||||
if secondTotal != 2 || len(pageTwo) != 1 || pageTwo[0]["id"] != "kb-team" {
|
||||
t.Fatalf("page 2 = %#v (total=%d), want kb-team and two accessible datasets", pageTwo, secondTotal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetServiceListDatasetFiltersReturnsEmptyOwnerList(t *testing.T) {
|
||||
db := setupDatasetUpdateTestDB(t)
|
||||
pushServiceDB(t, db)
|
||||
|
||||
filters, code, err := testDatasetListService(t).ListDatasetFilters(t.Context(), "user-without-datasets")
|
||||
filters, code, err := testDatasetListService(t).ListDatasetFilters(t.Context(), "user-without-datasets", "")
|
||||
if err != nil || code != common.CodeSuccess {
|
||||
t.Fatalf("ListDatasetFilters = (%#v, %v, %v), want success", filters, code, err)
|
||||
}
|
||||
@@ -158,7 +234,7 @@ func TestDatasetServiceListDatasetsRejectsIDAndIDsTogether(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
_, _, code, err := testDatasetListService(t).ListDatasets(ctx,
|
||||
"kb-1", "", 1, 30, []dao.OrderTerm{{Column: "create_time", Desc: true}},
|
||||
"", nil, "", "tenant-1", []string{"kb-1"},
|
||||
"", nil, "", "tenant-1", []string{"kb-1"}, "",
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected id/ids conflict error")
|
||||
@@ -181,7 +257,7 @@ func TestDatasetServiceListDatasetsFiltersInaccessibleIDs(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
data, total, code, err := testDatasetListService(t).ListDatasets(ctx,
|
||||
"", "", 1, 30, []dao.OrderTerm{{Column: "create_time", Desc: true}},
|
||||
"", nil, "", "user-1", []string{"kb-valid", "kb-private", "kb-missing"},
|
||||
"", nil, "", "user-1", []string{"kb-valid", "kb-private", "kb-missing"}, "",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ListDatasets failed: %v", err)
|
||||
@@ -202,7 +278,7 @@ func TestDatasetServiceListDatasetsReturnsEmptyForAllInaccessibleIDs(t *testing.
|
||||
ctx := t.Context()
|
||||
data, total, code, err := testDatasetListService(t).ListDatasets(ctx,
|
||||
"", "", 1, 30, []dao.OrderTerm{{Column: "create_time", Desc: true}},
|
||||
"", nil, "", "user-1", []string{"kb-private", "kb-missing"},
|
||||
"", nil, "", "user-1", []string{"kb-private", "kb-missing"}, "",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ListDatasets failed: %v", err)
|
||||
|
||||
@@ -144,11 +144,14 @@ func (d *DatasetService) SearchDatasets(ctx context.Context, req *service.Search
|
||||
common.Warn("Search service is not initialized for search_id", zap.String("searchID", searchID))
|
||||
return nil, fmt.Errorf("invalid search_id")
|
||||
}
|
||||
searchDetail, err := d.searchService.GetDetail(ctx, searchID)
|
||||
searchDetail, err := d.searchService.GetDetail(ctx, userID, searchID)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
if permissionresponse.IsPermissionError(err) {
|
||||
return nil, err
|
||||
}
|
||||
common.Warn("Invalid search_id", zap.String("searchID", searchID), zap.Error(err))
|
||||
return nil, fmt.Errorf("invalid search_id")
|
||||
}
|
||||
@@ -156,12 +159,6 @@ func (d *DatasetService) SearchDatasets(ctx context.Context, req *service.Search
|
||||
common.Warn("Invalid search_id", zap.String("searchID", searchID))
|
||||
return nil, fmt.Errorf("invalid search_id")
|
||||
}
|
||||
searchTenantID, ok := searchDetail["tenant_id"].(string)
|
||||
if !ok || searchTenantID != userID {
|
||||
common.Warn("Invalid search_id", zap.String("searchID", searchID))
|
||||
return nil, fmt.Errorf("invalid search_id")
|
||||
}
|
||||
|
||||
if searchConfig, ok := searchDetail["search_config"].(map[string]interface{}); ok && searchConfig != nil {
|
||||
rerankCandidatesCount = 100
|
||||
if scMetadataFilter, ok := searchConfig["meta_data_filter"].(map[string]interface{}); ok {
|
||||
|
||||
@@ -500,7 +500,7 @@ func TestDatasetServiceUpdateDatasetRejectsNonOwner(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetServiceUpdateDatasetAllowsTeamMemberPermissionChange(t *testing.T) {
|
||||
func TestDatasetServiceUpdateDatasetRejectsTeamMemberPermissionChange(t *testing.T) {
|
||||
db := setupDatasetUpdateTestDB(t)
|
||||
pushServiceDB(t, db)
|
||||
insertDatasetUpdateKB(t, "kb-1", "owner-1", "Original")
|
||||
@@ -516,19 +516,22 @@ func TestDatasetServiceUpdateDatasetAllowsTeamMemberPermissionChange(t *testing.
|
||||
_, code, err := testDatasetUpdateService(t).UpdateDataset(ctx, "kb-1", "user-1", service.UpdateDatasetRequest{
|
||||
Permission: &permission,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected shared tenant member to update dataset permission: %v", err)
|
||||
if err == nil {
|
||||
t.Fatal("expected shared tenant member to be denied changing dataset permission")
|
||||
}
|
||||
if code != common.CodeSuccess {
|
||||
t.Fatalf("expected success code, got %d", code)
|
||||
if code != common.CodeForbidden {
|
||||
t.Fatalf("expected forbidden code, got %d", code)
|
||||
}
|
||||
if err.Error() != "Permission denied" {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
persisted, err := dao.NewKnowledgebaseDAO().GetByID(ctx, db, "kb-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get dataset: %v", err)
|
||||
}
|
||||
if persisted.Permission != string(entity.TenantPermissionMe) {
|
||||
t.Fatalf("expected permission to change to %q, got %q", entity.TenantPermissionMe, persisted.Permission)
|
||||
if persisted.Permission != string(entity.TenantPermissionTeam) {
|
||||
t.Fatalf("expected permission to remain %q, got %q", entity.TenantPermissionTeam, persisted.Permission)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -220,7 +220,7 @@ func (s *DocumentService) DeleteDocument(ctx context.Context, id string) error {
|
||||
// Returns the number of successfully deleted documents.
|
||||
func (s *DocumentService) DeleteDocuments(ctx context.Context, ids []string, deleteAll bool, datasetID, userID string) (int, error) {
|
||||
// 1. Check dataset is accessible by the user
|
||||
if err := service.CheckDatasetAccess(ctx, permission.Subject{UserID: userID}, datasetID, permission.OperationDelete); err != nil {
|
||||
if err := service.CheckDatasetAccess(ctx, permission.Subject{UserID: userID}, datasetID, permission.OperationDeleteContent); err != nil {
|
||||
_, permissionErr := permissionresponse.Normalize(err)
|
||||
return 0, permissionErr
|
||||
}
|
||||
|
||||
@@ -4496,14 +4496,10 @@ func TestFileDeleteRemovesLinkedDocument(t *testing.T) {
|
||||
t.Cleanup(func() { factory.SetStorage(originalStorage) })
|
||||
|
||||
docSvc := testDocumentService(t)
|
||||
fileSvc := file.NewFileService(
|
||||
func(_ context.Context, _ *dao.FileDAO, _ *entity.File, _ string) bool { return true },
|
||||
docSvc,
|
||||
)
|
||||
fileSvc := file.NewFileService(docSvc)
|
||||
|
||||
success, msg := fileSvc.DeleteFiles(ctx, "tenant-1", []string{"file-1"})
|
||||
if !success {
|
||||
t.Fatalf("DeleteFiles failed: %s", msg)
|
||||
if err := fileSvc.DeleteFiles(ctx, "user-1", []string{"file-1"}); err != nil {
|
||||
t.Fatalf("DeleteFiles failed: %v", err)
|
||||
}
|
||||
|
||||
_, err := dao.NewDocumentDAO().GetByID(ctx, db, "doc-1")
|
||||
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"ragflow/internal/service"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -30,6 +29,7 @@ import (
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/permission"
|
||||
permissionresponse "ragflow/internal/permission/response"
|
||||
"ragflow/internal/service"
|
||||
"ragflow/internal/utility"
|
||||
)
|
||||
|
||||
@@ -107,6 +107,17 @@ func (s *File2DocumentService) LinkToDatasets(ctx context.Context, userID string
|
||||
}
|
||||
|
||||
// ── 3. Expand folders to leaf files, then deduplicate ─────────────────────
|
||||
checker := permission.NewDatabaseChecker(dao.DB)
|
||||
checkFileRead := func(file *entity.File) error {
|
||||
kind := permission.ResourceKindFile
|
||||
if file.Type == "folder" {
|
||||
kind = permission.ResourceKindFolder
|
||||
}
|
||||
return checker.CheckResource(ctx, permission.Subject{UserID: userID}, permission.ResourceRef{
|
||||
Kind: kind,
|
||||
ID: file.ID,
|
||||
}, permission.OperationRead)
|
||||
}
|
||||
// Mixed folder + direct file inputs (or overlapping folders) can yield the
|
||||
// same leaf file more than once; dedupe so each file is converted exactly
|
||||
// once.
|
||||
@@ -114,6 +125,10 @@ func (s *File2DocumentService) LinkToDatasets(ctx context.Context, userID string
|
||||
for _, id := range req.FileIDs {
|
||||
file := filesSet[id]
|
||||
if file.Type == "folder" {
|
||||
if err := checkFileRead(file); err != nil {
|
||||
_, permissionErr := permissionresponse.Normalize(err)
|
||||
return permissionErr
|
||||
}
|
||||
inner, err := s.getAllInnermostFileIDs(ctx, id)
|
||||
if err != nil {
|
||||
common.Warn("LinkToDatasets: folder expansion failed", zap.String("fileID", id), zap.Error(err))
|
||||
@@ -132,8 +147,8 @@ func (s *File2DocumentService) LinkToDatasets(ctx context.Context, userID string
|
||||
if err != nil || file == nil {
|
||||
return ErrLinkFileNotFound
|
||||
}
|
||||
if !service.CheckFileTeamPermission(ctx, s.fileDAO, file, userID) {
|
||||
_, permissionErr := permissionresponse.Normalize(permission.ErrPermissionDenied)
|
||||
if err := checkFileRead(file); err != nil {
|
||||
_, permissionErr := permissionresponse.Normalize(err)
|
||||
return permissionErr
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package file
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/permission"
|
||||
)
|
||||
|
||||
func checkFileAccess(ctx context.Context, userID string, file *entity.File, operation permission.Operation) error {
|
||||
if file == nil {
|
||||
return permission.ErrResourceNotFound
|
||||
}
|
||||
kind := permission.ResourceKindFile
|
||||
if file.Type == FileTypeFolder {
|
||||
kind = permission.ResourceKindFolder
|
||||
}
|
||||
return permission.NewDatabaseChecker(dao.DB).CheckResource(ctx, permission.Subject{UserID: userID}, permission.ResourceRef{
|
||||
Kind: kind,
|
||||
ID: file.ID,
|
||||
}, operation)
|
||||
}
|
||||
|
||||
func (s *FileService) checkFileTreeAccess(ctx context.Context, userID string, file *entity.File, operation permission.Operation) error {
|
||||
if err := checkFileAccess(ctx, userID, file, operation); err != nil {
|
||||
return err
|
||||
}
|
||||
if file.Type != FileTypeFolder {
|
||||
return nil
|
||||
}
|
||||
|
||||
children, err := s.fileDAO.ListByParentID(ctx, dao.DB, file.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, child := range children {
|
||||
if err := s.checkFileTreeAccess(ctx, userID, child, operation); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -18,16 +18,12 @@ package file
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/utility"
|
||||
)
|
||||
|
||||
// ErrNoAuthorization indicates the current user cannot access the target file.
|
||||
var ErrNoAuthorization = errors.New("no authorization")
|
||||
|
||||
var (
|
||||
// assertURLSafe and pinnedHTTPClient are aliased from utility so tests
|
||||
// can override them for mock injection.
|
||||
@@ -40,28 +36,20 @@ type DocRemover interface {
|
||||
RemoveDocumentKeepFile(ctx context.Context, docID string) error
|
||||
}
|
||||
|
||||
// CheckFilePermFunc is the function signature for file-team permission checks,
|
||||
// injected by the parent adapter so the file subpackage does not need to import
|
||||
// the parent service package.
|
||||
type CheckFilePermFunc func(ctx context.Context, fileDAO *dao.FileDAO, file *entity.File, userID string) bool
|
||||
|
||||
// FileService file service
|
||||
type FileService struct {
|
||||
fileDAO *dao.FileDAO
|
||||
file2DocumentDAO *dao.File2DocumentDAO
|
||||
documentService DocRemover
|
||||
checkFilePerm CheckFilePermFunc
|
||||
}
|
||||
|
||||
// NewFileService create file service. checkFilePerm is always required;
|
||||
// dr may be nil when the caller only uses read/parse methods and never
|
||||
// calls DeleteFiles.
|
||||
func NewFileService(checkFilePerm CheckFilePermFunc, dr DocRemover) *FileService {
|
||||
// NewFileService creates a file service. dr may be nil when file deletion is
|
||||
// not used.
|
||||
func NewFileService(dr DocRemover) *FileService {
|
||||
return &FileService{
|
||||
fileDAO: dao.NewFileDAO(),
|
||||
file2DocumentDAO: dao.NewFile2DocumentDAO(),
|
||||
documentService: dr,
|
||||
checkFilePerm: checkFilePerm,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package file
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/permission"
|
||||
)
|
||||
|
||||
func seedFileBrowsing(t *testing.T) {
|
||||
t.Helper()
|
||||
db := setupFolderTestDB(t)
|
||||
for _, membership := range []entity.UserTenant{
|
||||
{ID: "owner-membership", UserID: "owner", TenantID: "owner", Role: string(permission.RoleOwner)},
|
||||
{ID: "reader-membership", UserID: "reader", TenantID: "owner", Role: string(permission.RoleNormal)},
|
||||
} {
|
||||
if err := db.Create(&membership).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, entry := range []entity.File{
|
||||
{ID: "root", ParentID: "root", TenantID: "owner", CreatedBy: "owner", Name: "/", Type: FileTypeFolder},
|
||||
{ID: "tags", ParentID: "root", TenantID: "owner", CreatedBy: "owner", Name: "tags.txt", Type: "doc"},
|
||||
{ID: "foreign-root", ParentID: "foreign-root", TenantID: "foreign", Name: "/", Type: FileTypeFolder},
|
||||
{ID: "foreign-tags", ParentID: "foreign-root", TenantID: "foreign", Name: "tags.txt", Type: "doc"},
|
||||
} {
|
||||
if err := db.Create(&entry).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantMemberCanBrowseTagFiles(t *testing.T) {
|
||||
seedFileBrowsing(t)
|
||||
svc := testFileService()
|
||||
ctx := t.Context()
|
||||
list, err := svc.ListFilesForTenant(ctx, "reader", "owner", "", 1, 100, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("list shared tenant files: %v", err)
|
||||
}
|
||||
if list.Total != 1 {
|
||||
t.Fatalf("file count = %d, want 1", list.Total)
|
||||
}
|
||||
ancestors, err := svc.GetAllParentFoldersForTenant(ctx, "reader", "owner", "tags")
|
||||
if err != nil {
|
||||
t.Fatalf("resolve shared tenant tag file: %v", err)
|
||||
}
|
||||
if len(ancestors) != 2 {
|
||||
t.Fatalf("ancestor count = %d, want 2", len(ancestors))
|
||||
}
|
||||
if _, err := svc.GetFileContent(ctx, "reader", "tags"); !errors.Is(err, permission.ErrPermissionDenied) {
|
||||
t.Fatalf("read file contents error = %v, want permission denied", err)
|
||||
}
|
||||
if err := svc.MoveFiles(ctx, "reader", []string{"tags"}, "", "new-tags.txt"); !errors.Is(err, permission.ErrPermissionDenied) {
|
||||
t.Fatalf("rename file error = %v, want permission denied", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantFileBrowsingRejectsNonmember(t *testing.T) {
|
||||
seedFileBrowsing(t)
|
||||
svc := testFileService()
|
||||
if _, err := svc.ListFilesForTenant(t.Context(), "outsider", "owner", "", 1, 100, nil, ""); err == nil {
|
||||
t.Fatal("nonmember listed another tenant's files")
|
||||
}
|
||||
if _, err := svc.GetAllParentFoldersForTenant(t.Context(), "outsider", "owner", "tags"); err == nil {
|
||||
t.Fatal("nonmember resolved another tenant's file path")
|
||||
}
|
||||
if err := dao.DB.Model(&entity.UserTenant{}).Where("id = ?", "reader-membership").Update("status", "0").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.ListFilesForTenant(t.Context(), "reader", "owner", "", 1, 100, nil, ""); err == nil {
|
||||
t.Fatal("inactive member listed another tenant's files")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantFileBrowsingRejectsForeignEntries(t *testing.T) {
|
||||
seedFileBrowsing(t)
|
||||
svc := testFileService()
|
||||
if _, err := svc.ListFilesForTenant(t.Context(), "reader", "owner", "foreign-root", 1, 100, nil, ""); !errors.Is(err, permission.ErrResourceNotFound) {
|
||||
t.Fatalf("foreign folder error = %v, want resource not found", err)
|
||||
}
|
||||
if _, err := svc.GetAllParentFoldersForTenant(t.Context(), "reader", "owner", "foreign-tags"); !errors.Is(err, permission.ErrResourceNotFound) {
|
||||
t.Fatalf("foreign file error = %v, want resource not found", err)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/parser/parser"
|
||||
"ragflow/internal/parser/tableutil"
|
||||
"ragflow/internal/permission"
|
||||
"ragflow/internal/storage"
|
||||
"ragflow/internal/utility"
|
||||
"strings"
|
||||
@@ -21,8 +22,8 @@ func (s *FileService) GetFileContent(ctx context.Context, uid, fileID string) (*
|
||||
if err != nil || file == nil {
|
||||
return nil, fmt.Errorf("document not found")
|
||||
}
|
||||
if !s.checkFilePerm(ctx, s.fileDAO, file, uid) {
|
||||
return nil, fmt.Errorf("no authorization")
|
||||
if err := checkFileAccess(ctx, uid, file, permission.OperationRead); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
@@ -2,59 +2,60 @@ package file
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/permission"
|
||||
"ragflow/internal/storage"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// DeleteFiles deletes files by IDs
|
||||
// Returns (success, message) where success is true if all files were deleted
|
||||
func (s *FileService) DeleteFiles(ctx context.Context, uid string, fileIDs []string) (bool, string) {
|
||||
// DeleteFiles deletes files and folders by ID after authorizing every entry in
|
||||
// each selected folder tree.
|
||||
func (s *FileService) DeleteFiles(ctx context.Context, userID string, fileIDs []string) error {
|
||||
files, err := s.fileDAO.GetByIDs(ctx, dao.DB, fileIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filesByID := make(map[string]*entity.File, len(files))
|
||||
for _, file := range files {
|
||||
filesByID[file.ID] = file
|
||||
}
|
||||
|
||||
for _, fileID := range fileIDs {
|
||||
// 1. Get file
|
||||
file, err := s.fileDAO.GetByID(ctx, dao.DB, fileID)
|
||||
if err != nil || file == nil {
|
||||
return false, "File or Folder not found!"
|
||||
file, ok := filesByID[fileID]
|
||||
if !ok {
|
||||
return errors.New("File or Folder not found!")
|
||||
}
|
||||
|
||||
// 2. Check tenant_id
|
||||
if file.TenantID == "" {
|
||||
return false, "Tenant not found!"
|
||||
return errors.New("Tenant not found!")
|
||||
}
|
||||
|
||||
// Block root-folder deletion (root folders have parent_id == id)
|
||||
if file.ParentID == file.ID {
|
||||
return false, "Root folder cannot be deleted."
|
||||
return errors.New("Root folder cannot be deleted.")
|
||||
}
|
||||
|
||||
// 3. Permission check
|
||||
if !s.checkFilePerm(ctx, s.fileDAO, file, uid) {
|
||||
return false, "no authorization"
|
||||
}
|
||||
|
||||
// 4. Skip dataset source files
|
||||
if file.SourceType == FileSourceDataset {
|
||||
continue
|
||||
}
|
||||
|
||||
// 5. Delete based on type
|
||||
if file.Type == FileTypeFolder {
|
||||
if err = s.deleteFolderRecursive(ctx, file, uid); err != nil {
|
||||
return false, fmt.Sprintf("Failed to delete folder: %v", err)
|
||||
}
|
||||
} else {
|
||||
if err = s.deleteSingleFile(ctx, file); err != nil {
|
||||
return false, fmt.Sprintf("Failed to delete file: %v", err)
|
||||
}
|
||||
if err := s.checkFileTreeAccess(ctx, userID, file, permission.OperationDelete); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return true, ""
|
||||
for _, fileID := range fileIDs {
|
||||
file := filesByID[fileID]
|
||||
if file.SourceType == FileSourceDataset {
|
||||
continue
|
||||
}
|
||||
if file.Type == FileTypeFolder {
|
||||
if err := s.deleteFolderRecursive(ctx, file); err != nil {
|
||||
return fmt.Errorf("Failed to delete folder: %w", err)
|
||||
}
|
||||
} else if err := s.deleteSingleFile(ctx, file); err != nil {
|
||||
return fmt.Errorf("Failed to delete file: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteSingleFile deletes a single file (not folder)
|
||||
@@ -124,7 +125,7 @@ func (s *FileService) deleteSingleFileRecords(ctx context.Context, file *entity.
|
||||
|
||||
// deleteFolderRecursive recursively deletes a folder and its contents
|
||||
// Matches Python's _delete_folder_recursive function
|
||||
func (s *FileService) deleteFolderRecursive(ctx context.Context, folder *entity.File, uid string) error {
|
||||
func (s *FileService) deleteFolderRecursive(ctx context.Context, folder *entity.File) error {
|
||||
storageImpl := storage.GetStorageFactory().GetStorage()
|
||||
if storageImpl == nil {
|
||||
return fmt.Errorf("storage is not configured for folder %s", folder.ID)
|
||||
|
||||
Loaded 100 of 174 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user