feat: migrate resource checks to permission layer (#20792)

This commit is contained in:
buua436 authored and GitHub committed 2026-10-13 14:17:43 +08:00
1 parent a8216e5f38
commit 8a95d55d63
174 files changed
+7385 -2951

No files matched your search

+1 -1
View File
@@ -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()
+24 -5
View File
@@ -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)
+26 -1
View File
@@ -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"
+18 -26
View File
@@ -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) {
+16 -5
View File
@@ -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,
+3 -3
View File
@@ -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.
+44
View File
@@ -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.
+4
View File
@@ -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,
+6 -1
View File
@@ -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
View File
@@ -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
}
+8 -5
View File
@@ -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
+2 -2
View File
@@ -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)
+6 -15
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+8 -4
View File
@@ -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
}
+15 -6
View File
@@ -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"`
}
+24 -1
View File
@@ -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) {
+1 -1
View File
@@ -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) {
+8 -2
View File
@@ -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
}
+12 -8
View File
@@ -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
View File
@@ -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
}
+11
View File
@@ -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",
+10 -5
View File
@@ -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)
}
+4 -4
View File
@@ -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)
}
}
+11 -11
View File
@@ -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())
+13 -13
View File
@@ -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"])
}
}
+13 -1
View File
@@ -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
+16 -6
View File
@@ -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 (
+9 -8
View File
@@ -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",
+6 -1
View File
@@ -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
}
+1 -1
View File
@@ -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
View File
@@ -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
}
+9 -3
View File
@@ -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
}
+1 -1
View File
@@ -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
+3 -2
View File
@@ -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
View File
@@ -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
}
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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 -2
View File
@@ -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
+2 -2
View File
@@ -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
+9 -10
View File
@@ -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 {
+4 -4
View File
@@ -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"])
}
}
+36 -14
View File
@@ -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
}
+15 -2
View File
@@ -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()
+22 -12
View File
@@ -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)
}
}
+860 -21
View File
@@ -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
}
+159
View File
@@ -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,
}
}
+70 -3
View File
@@ -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...),
}
}
+103
View File
@@ -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
}
}
+70 -21
View File
@@ -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
View File
@@ -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
+12 -11
View File
@@ -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.")
}
+40 -9
View File
@@ -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
View File
@@ -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
+23 -29
View File
@@ -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
}
+65
View File
@@ -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
View File
@@ -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)
+55 -97
View File
@@ -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)
}
+54 -10
View File
@@ -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")
}
}
+5 -3
View File
@@ -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")
+3 -9
View File
@@ -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))
}
}
+104
View File
@@ -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)
}
+2 -2
View File
@@ -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",
+67 -187
View File
@@ -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{
+55 -26
View File
@@ -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)
}
}
+159 -77
View File
@@ -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 {
+23 -33
View File
@@ -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
+37 -54
View File
@@ -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
View File
@@ -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)
}
+108
View File
@@ -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)
}
+14 -1
View File
@@ -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)
}
}
+50 -34
View File
@@ -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
}
+25 -1
View File
@@ -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: ""},
}
+84 -8
View File
@@ -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)
+4 -7
View File
@@ -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 {
+10 -7
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
}
+3 -7
View File
@@ -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")
+18 -3
View File
@@ -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
}
}
+43
View File
@@ -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
}
+3 -15
View File
@@ -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)
}
}
+3 -2
View File
@@ -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
}
+36 -35
View File
@@ -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