From 8a95d55d63be510105e8c4e9be87c5912bb3c5f0 Mon Sep 17 00:00:00 2001 From: buua436 Date: Tue, 13 Oct 2026 14:17:43 +0800 Subject: [PATCH] feat: migrate resource checks to permission layer (#20792) --- cmd/ragflow_server.go | 2 +- internal/agent/component/agent_mcp.go | 29 +- internal/agent/component/agent_mcp_test.go | 27 +- internal/agent/component/categorize_test.go | 2 + .../component/retrieval_permission_test.go | 38 +- .../agent/component/universe_a_wrappers.go | 44 +- internal/agent/retrievalbridge/memory.go | 21 +- internal/agent/runtime/retrieval.go | 6 +- internal/agent/tool/canvas_ctx.go | 44 + internal/agent/tool/dataset_navigation.go | 4 + internal/agent/tool/retrieval_nlp.go | 4 + internal/agent/tool/wiki_query.go | 7 +- internal/dao/chat.go | 129 +-- internal/dao/chat_channel.go | 13 +- internal/dao/chat_channel_test.go | 4 +- internal/dao/chat_session.go | 21 +- internal/dao/chat_test.go | 71 +- internal/dao/connector.go | 80 +- internal/dao/kb.go | 19 +- internal/dao/mcp.go | 20 +- internal/dao/memory.go | 54 +- internal/dao/search.go | 106 +- internal/dao/search_test.go | 53 +- internal/dao/skill_space.go | 12 +- internal/handler/agent.go | 21 +- internal/handler/agent_test.go | 25 +- internal/handler/agent_wait_for_user_test.go | 2 +- internal/handler/bot.go | 10 +- internal/handler/bot_test.go | 20 +- internal/handler/chat.go | 23 +- internal/handler/chat_audio_test.go | 11 + internal/handler/chat_channel.go | 15 +- internal/handler/chat_channel_test.go | 8 +- internal/handler/chat_session.go | 22 +- internal/handler/chat_test.go | 26 +- .../handler/compilation_template_group.go | 14 +- internal/handler/connector.go | 22 +- internal/handler/connector_test.go | 17 +- internal/handler/dataset.go | 7 +- internal/handler/document.go | 2 +- internal/handler/file.go | 53 +- internal/handler/file_commit.go | 12 +- internal/handler/file_commit_test.go | 2 +- internal/handler/file_error_test.go | 5 +- internal/handler/mcp.go | 18 +- internal/handler/mcp_retrieval.go | 2 +- internal/handler/mcp_server.go | 2 +- internal/handler/memory.go | 32 +- internal/handler/providers.go | 4 +- internal/handler/search.go | 19 +- internal/handler/search_handler_test.go | 8 +- internal/handler/searchbot.go | 50 +- internal/handler/searchbot_detail_test.go | 17 +- internal/handler/skill_search.go | 34 +- .../permission/chat_channel_database_test.go | 64 ++ internal/permission/database_source.go | 881 ++++++++++++++- internal/permission/database_source_test.go | 1005 ++++++++++++++++- internal/permission/integration_resources.go | 128 +++ internal/permission/model_resources.go | 159 +++ internal/permission/policy_ce.go | 73 +- internal/permission/query_scope.go | 103 ++ internal/permission/types.go | 91 +- internal/service/agent.go | 61 +- internal/service/agent_sessions.go | 23 +- internal/service/agent_test.go | 49 +- internal/service/bot.go | 31 +- internal/service/bot_completion.go | 52 +- internal/service/bot_permission_test.go | 65 ++ internal/service/chat.go | 167 +-- internal/service/chat_channel.go | 152 +-- internal/service/chat_channel_permission.go | 51 + internal/service/chat_channel_test.go | 64 +- internal/service/chat_delete_test.go | 8 +- internal/service/chat_list_test.go | 12 +- internal/service/chat_permission.go | 104 ++ internal/service/chat_pipeline.go | 4 +- internal/service/chat_session.go | 254 ++--- .../service/chat_session_contract_test.go | 81 +- internal/service/chat_session_test.go | 236 ++-- internal/service/chunk/chunk.go | 56 +- .../service/chunk/chunk_permissions_test.go | 20 + internal/service/chunk/chunk_test.go | 91 +- internal/service/connector.go | 190 ++-- internal/service/connector_permission.go | 108 ++ internal/service/connector_syncer_test.go | 15 +- .../service/connector_test_connection_test.go | 5 +- internal/service/dataset/crud.go | 84 +- internal/service/dataset/ingestion.go | 26 +- .../dataset/ingestion_messages_test.go | 5 + internal/service/dataset/list_test.go | 92 +- internal/service/dataset/search.go | 11 +- internal/service/dataset/update_test.go | 17 +- internal/service/document/document_crud.go | 2 +- internal/service/document/document_test.go | 10 +- internal/service/document/file2document.go | 21 +- internal/service/file/access.go | 43 + internal/service/file/file.go | 18 +- .../file/file_browse_permission_test.go | 87 ++ internal/service/file/file_content.go | 5 +- internal/service/file/file_delete.go | 71 +- internal/service/file/file_delete_test.go | 31 +- internal/service/file/file_folder.go | 258 +++-- internal/service/file/file_folder_test.go | 213 ++-- internal/service/file/file_permission_test.go | 51 - internal/service/file/file_test.go | 7 - internal/service/file/file_upload.go | 8 +- internal/service/file_permission.go | 31 - internal/service/mcp.go | 137 ++- internal/service/mcp_permission.go | 38 + internal/service/mcp_test.go | 87 +- internal/service/memory.go | 192 +--- internal/service/memory_extractor.go | 2 +- internal/service/memory_message_service.go | 10 +- .../service/memory_message_service_test.go | 12 +- internal/service/memory_message_test.go | 92 +- internal/service/memory_permission.go | 89 ++ internal/service/memory_saver_adapter.go | 42 +- internal/service/memory_saver_adapter_test.go | 84 ++ internal/service/model_call.go | 15 +- internal/service/model_factory.go | 97 +- internal/service/model_permission.go | 53 + internal/service/model_service.go | 850 ++++++++------ internal/service/model_service_test.go | 206 ++++ internal/service/openai_chat.go | 6 + internal/service/related_question.go | 27 +- internal/service/search.go | 171 +-- internal/service/search_permission.go | 47 + internal/service/search_share_detail_test.go | 5 +- internal/service/search_test.go | 88 +- internal/service/skill_indexer.go | 69 +- internal/service/skill_search.go | 34 +- internal/service/skill_space.go | 119 +- internal/service/skill_space_permission.go | 62 + .../service/skill_space_permission_test.go | 112 ++ internal/service/tenant.go | 39 +- internal/service/tenant_permission.go | 48 + internal/service/tenant_test.go | 98 +- test/testcases/restful_api/test_chats.py | 8 +- .../restful_api/test_openai_compatible.py | 5 +- test/testcases/restful_api/test_searches.py | 4 +- test/testcases/restful_api/test_sessions.py | 32 +- web/src/components/knowledge-base-item.tsx | 17 +- web/src/components/memories-form-field.tsx | 10 +- web/src/hooks/use-file-request.ts | 9 +- .../use-knowledge-request-datasets.test.tsx | 22 +- web/src/hooks/use-knowledge-request.ts | 40 +- web/src/hooks/use-llm-request.tsx | 2 +- web/src/hooks/use-mcp-request.ts | 5 +- web/src/hooks/use-memory-request.test.tsx | 14 +- web/src/hooks/use-memory-request.ts | 16 +- web/src/hooks/use-stale-dataset-validation.ts | 10 +- web/src/interfaces/database/agent.ts | 1 + web/src/interfaces/request/file-manager.ts | 1 + web/src/interfaces/request/knowledge.ts | 4 +- web/src/interfaces/request/llm.ts | 2 +- web/src/interfaces/request/mcp.ts | 5 + .../agent/canvas/node/retrieval-node.tsx | 18 +- .../agent-form/tool-popover/tool-command.tsx | 4 +- .../agent-form/tool-popover/use-update-mcp.ts | 5 +- .../pages/agent/form/extractor-form/index.tsx | 2 +- .../form/extractor-form/use-tag-file-tree.ts | 67 +- .../pages/agent/form/message-form/index.tsx | 6 +- .../pages/agent/form/retrieval-form/next.tsx | 10 +- .../form/tool-form/retrieval-form/index.tsx | 4 +- .../pages/agent/hooks/use-canvas-checklist.ts | 14 +- .../pages/agent/hooks/use-find-mcp-by-id.ts | 4 +- web/src/pages/agent/index.tsx | 5 +- .../dataset/change-parser-dialog.test.tsx | 198 ++++ web/src/pages/dataset/index.tsx | 15 +- .../components/link-data-source-modal.tsx | 7 +- web/src/pages/files/move-dialog.tsx | 2 +- .../pages/user-setting/data-source/hooks.ts | 14 +- web/src/services/knowledge-service.ts | 8 +- web/src/services/mcp-server-service.ts | 8 +- 174 files changed, 7385 insertions(+), 2951 deletions(-) create mode 100644 internal/permission/chat_channel_database_test.go create mode 100644 internal/permission/integration_resources.go create mode 100644 internal/permission/model_resources.go create mode 100644 internal/permission/query_scope.go create mode 100644 internal/service/bot_permission_test.go create mode 100644 internal/service/chat_channel_permission.go create mode 100644 internal/service/chat_permission.go create mode 100644 internal/service/connector_permission.go create mode 100644 internal/service/file/access.go create mode 100644 internal/service/file/file_browse_permission_test.go delete mode 100644 internal/service/file/file_permission_test.go delete mode 100644 internal/service/file_permission.go create mode 100644 internal/service/mcp_permission.go create mode 100644 internal/service/memory_permission.go create mode 100644 internal/service/memory_saver_adapter_test.go create mode 100644 internal/service/model_permission.go create mode 100644 internal/service/search_permission.go create mode 100644 internal/service/skill_space_permission.go create mode 100644 internal/service/skill_space_permission_test.go create mode 100644 internal/service/tenant_permission.go create mode 100644 web/src/pages/dataset/dataset/change-parser-dialog.test.tsx diff --git a/cmd/ragflow_server.go b/cmd/ragflow_server.go index 862e4859a8..db462fe958 100644 --- a/cmd/ragflow_server.go +++ b/cmd/ragflow_server.go @@ -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() diff --git a/internal/agent/component/agent_mcp.go b/internal/agent/component/agent_mcp.go index 09a4078a78..633d781178 100644 --- a/internal/agent/component/agent_mcp.go +++ b/internal/agent/component/agent_mcp.go @@ -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) diff --git a/internal/agent/component/agent_mcp_test.go b/internal/agent/component/agent_mcp_test.go index e95a1c07c7..b0cea77fc3 100644 --- a/internal/agent/component/agent_mcp_test.go +++ b/internal/agent/component/agent_mcp_test.go @@ -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) + } +} diff --git a/internal/agent/component/categorize_test.go b/internal/agent/component/categorize_test.go index 65f7836dd2..6fef38e967 100644 --- a/internal/agent/component/categorize_test.go +++ b/internal/agent/component/categorize_test.go @@ -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) } diff --git a/internal/agent/component/retrieval_permission_test.go b/internal/agent/component/retrieval_permission_test.go index ce4dbe4cc2..76593d6db4 100644 --- a/internal/agent/component/retrieval_permission_test.go +++ b/internal/agent/component/retrieval_permission_test.go @@ -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" diff --git a/internal/agent/component/universe_a_wrappers.go b/internal/agent/component/universe_a_wrappers.go index 3322f8ae10..5f9be5601f 100644 --- a/internal/agent/component/universe_a_wrappers.go +++ b/internal/agent/component/universe_a_wrappers.go @@ -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) { diff --git a/internal/agent/retrievalbridge/memory.go b/internal/agent/retrievalbridge/memory.go index fb57f78e90..ac3787f405 100644 --- a/internal/agent/retrievalbridge/memory.go +++ b/internal/agent/retrievalbridge/memory.go @@ -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, diff --git a/internal/agent/runtime/retrieval.go b/internal/agent/runtime/retrieval.go index 098224c571..b7cf23c23d 100644 --- a/internal/agent/runtime/retrieval.go +++ b/internal/agent/runtime/retrieval.go @@ -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. diff --git a/internal/agent/tool/canvas_ctx.go b/internal/agent/tool/canvas_ctx.go index e8ae840657..6b96701c13 100644 --- a/internal/agent/tool/canvas_ctx.go +++ b/internal/agent/tool/canvas_ctx.go @@ -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 { diff --git a/internal/agent/tool/dataset_navigation.go b/internal/agent/tool/dataset_navigation.go index 3e09fc4f68..c588534748 100644 --- a/internal/agent/tool/dataset_navigation.go +++ b/internal/agent/tool/dataset_navigation.go @@ -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. diff --git a/internal/agent/tool/retrieval_nlp.go b/internal/agent/tool/retrieval_nlp.go index 0aba74cde1..0069425da3 100644 --- a/internal/agent/tool/retrieval_nlp.go +++ b/internal/agent/tool/retrieval_nlp.go @@ -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, diff --git a/internal/agent/tool/wiki_query.go b/internal/agent/tool/wiki_query.go index 7b356ad6cd..7828250cb0 100644 --- a/internal/agent/tool/wiki_query.go +++ b/internal/agent/tool/wiki_query.go @@ -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 } diff --git a/internal/dao/chat.go b/internal/dao/chat.go index 56d055b0e4..f4e78f60dc 100644 --- a/internal/dao/chat.go +++ b/internal/dao/chat.go @@ -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 -} diff --git a/internal/dao/chat_channel.go b/internal/dao/chat_channel.go index 4cc78e50ac..51f6320e55 100644 --- a/internal/dao/chat_channel.go +++ b/internal/dao/chat_channel.go @@ -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 diff --git a/internal/dao/chat_channel_test.go b/internal/dao/chat_channel_test.go index fe1b8ed2ef..c985e1f0fd 100644 --- a/internal/dao/chat_channel_test.go +++ b/internal/dao/chat_channel_test.go @@ -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) diff --git a/internal/dao/chat_session.go b/internal/dao/chat_session.go index ee9b0f4332..2e5f629714 100644 --- a/internal/dao/chat_session.go +++ b/internal/dao/chat_session.go @@ -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 diff --git a/internal/dao/chat_test.go b/internal/dao/chat_test.go index 84ecede3b3..ded4f7e7d2 100644 --- a/internal/dao/chat_test.go +++ b/internal/dao/chat_test.go @@ -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) diff --git a/internal/dao/connector.go b/internal/dao/connector.go index 6b0284c310..3bf1aa4931 100644 --- a/internal/dao/connector.go +++ b/internal/dao/connector.go @@ -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) diff --git a/internal/dao/kb.go b/internal/dao/kb.go index 5dcd312b18..8f1d2467cb 100644 --- a/internal/dao/kb.go +++ b/internal/dao/kb.go @@ -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) diff --git a/internal/dao/mcp.go b/internal/dao/mcp.go index 15511c3cf7..de78bfbeae 100644 --- a/internal/dao/mcp.go +++ b/internal/dao/mcp.go @@ -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{}) diff --git a/internal/dao/memory.go b/internal/dao/memory.go index fd8034a45e..8d1c700780 100644 --- a/internal/dao/memory.go +++ b/internal/dao/memory.go @@ -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) -} diff --git a/internal/dao/search.go b/internal/dao/search.go index c2cb97cfd0..2dd2eb80a1 100644 --- a/internal/dao/search.go +++ b/internal/dao/search.go @@ -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 diff --git a/internal/dao/search_test.go b/internal/dao/search_test.go index b6c2b1531d..b3f0804394 100644 --- a/internal/dao/search_test.go +++ b/internal/dao/search_test.go @@ -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) diff --git a/internal/dao/skill_space.go b/internal/dao/skill_space.go index 66c4c8ab9a..54e1fa953e 100644 --- a/internal/dao/skill_space.go +++ b/internal/dao/skill_space.go @@ -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 } diff --git a/internal/handler/agent.go b/internal/handler/agent.go index 0d519c22b5..b03b008c05 100644 --- a/internal/handler/agent.go +++ b/internal/handler/agent.go @@ -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"` } diff --git a/internal/handler/agent_test.go b/internal/handler/agent_test.go index 97a6354d97..13b8066b3a 100644 --- a/internal/handler/agent_test.go +++ b/internal/handler/agent_test.go @@ -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) { diff --git a/internal/handler/agent_wait_for_user_test.go b/internal/handler/agent_wait_for_user_test.go index 37cfa7c57a..5736847f2e 100644 --- a/internal/handler/agent_wait_for_user_test.go +++ b/internal/handler/agent_wait_for_user_test.go @@ -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) { diff --git a/internal/handler/bot.go b/internal/handler/bot.go index 8c4848c946..ab946081ac 100644 --- a/internal/handler/bot.go +++ b/internal/handler/bot.go @@ -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 } diff --git a/internal/handler/bot_test.go b/internal/handler/bot_test.go index 4300214860..943de77110 100644 --- a/internal/handler/bot_test.go +++ b/internal/handler/bot_test.go @@ -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) } } diff --git a/internal/handler/chat.go b/internal/handler/chat.go index b34abc7451..4c745bf113 100644 --- a/internal/handler/chat.go +++ b/internal/handler/chat.go @@ -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 } diff --git a/internal/handler/chat_audio_test.go b/internal/handler/chat_audio_test.go index db93617423..d732fe1283 100644 --- a/internal/handler/chat_audio_test.go +++ b/internal/handler/chat_audio_test.go @@ -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", diff --git a/internal/handler/chat_channel.go b/internal/handler/chat_channel.go index 9ccaf220bf..1a0e874740 100644 --- a/internal/handler/chat_channel.go +++ b/internal/handler/chat_channel.go @@ -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) } diff --git a/internal/handler/chat_channel_test.go b/internal/handler/chat_channel_test.go index f130db65dc..d27a989162 100644 --- a/internal/handler/chat_channel_test.go +++ b/internal/handler/chat_channel_test.go @@ -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) } } diff --git a/internal/handler/chat_session.go b/internal/handler/chat_session.go index 99cd0bce98..ca968177f5 100644 --- a/internal/handler/chat_session.go +++ b/internal/handler/chat_session.go @@ -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()) diff --git a/internal/handler/chat_test.go b/internal/handler/chat_test.go index eb373bc017..7423016b96 100644 --- a/internal/handler/chat_test.go +++ b/internal/handler/chat_test.go @@ -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"]) } } diff --git a/internal/handler/compilation_template_group.go b/internal/handler/compilation_template_group.go index b9d688741e..5f8d9b9ac0 100644 --- a/internal/handler/compilation_template_group.go +++ b/internal/handler/compilation_template_group.go @@ -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 diff --git a/internal/handler/connector.go b/internal/handler/connector.go index e86f650680..163c7d74e8 100644 --- a/internal/handler/connector.go +++ b/internal/handler/connector.go @@ -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 ( diff --git a/internal/handler/connector_test.go b/internal/handler/connector_test.go index b87de823d0..4ff6d15f9f 100644 --- a/internal/handler/connector_test.go +++ b/internal/handler/connector_test.go @@ -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", diff --git a/internal/handler/dataset.go b/internal/handler/dataset.go index f33378c53b..d4e3408128 100644 --- a/internal/handler/dataset.go +++ b/internal/handler/dataset.go @@ -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 } diff --git a/internal/handler/document.go b/internal/handler/document.go index 2fd5c7006d..abcf08dc05 100644 --- a/internal/handler/document.go +++ b/internal/handler/document.go @@ -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 } diff --git a/internal/handler/file.go b/internal/handler/file.go index a5e2879c86..84670f2bdb 100644 --- a/internal/handler/file.go +++ b/internal/handler/file.go @@ -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 } diff --git a/internal/handler/file_commit.go b/internal/handler/file_commit.go index acc5272f9d..2ba8acb169 100644 --- a/internal/handler/file_commit.go +++ b/internal/handler/file_commit.go @@ -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 } diff --git a/internal/handler/file_commit_test.go b/internal/handler/file_commit_test.go index cf7f63c22b..ff893fd5fd 100644 --- a/internal/handler/file_commit_test.go +++ b/internal/handler/file_commit_test.go @@ -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 diff --git a/internal/handler/file_error_test.go b/internal/handler/file_error_test.go index c4d82fc23d..b19ab9a003 100644 --- a/internal/handler/file_error_test.go +++ b/internal/handler/file_error_test.go @@ -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) diff --git a/internal/handler/mcp.go b/internal/handler/mcp.go index 6937d23fa1..60e7d0bf13 100644 --- a/internal/handler/mcp.go +++ b/internal/handler/mcp.go @@ -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 } diff --git a/internal/handler/mcp_retrieval.go b/internal/handler/mcp_retrieval.go index e7c8e49274..59698256ff 100644 --- a/internal/handler/mcp_retrieval.go +++ b/internal/handler/mcp_retrieval.go @@ -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) diff --git a/internal/handler/mcp_server.go b/internal/handler/mcp_server.go index 7e42e7bf02..385a2aeedd 100644 --- a/internal/handler/mcp_server.go +++ b/internal/handler/mcp_server.go @@ -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 } diff --git a/internal/handler/memory.go b/internal/handler/memory.go index 6f7f665de5..733bf2cb26 100644 --- a/internal/handler/memory.go +++ b/internal/handler/memory.go @@ -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 diff --git a/internal/handler/providers.go b/internal/handler/providers.go index 02fc0cc2fe..1cc8364784 100644 --- a/internal/handler/providers.go +++ b/internal/handler/providers.go @@ -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 diff --git a/internal/handler/search.go b/internal/handler/search.go index a80c18b3fa..bb503bac78 100644 --- a/internal/handler/search.go +++ b/internal/handler/search.go @@ -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 { diff --git a/internal/handler/search_handler_test.go b/internal/handler/search_handler_test.go index e6805281ff..4d36e8c24c 100644 --- a/internal/handler/search_handler_test.go +++ b/internal/handler/search_handler_test.go @@ -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"]) } } diff --git a/internal/handler/searchbot.go b/internal/handler/searchbot.go index e9640182f2..892d37f4f3 100644 --- a/internal/handler/searchbot.go +++ b/internal/handler/searchbot.go @@ -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 } diff --git a/internal/handler/searchbot_detail_test.go b/internal/handler/searchbot_detail_test.go index 39a7db7883..468cc936b9 100644 --- a/internal/handler/searchbot_detail_test.go +++ b/internal/handler/searchbot_detail_test.go @@ -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() diff --git a/internal/handler/skill_search.go b/internal/handler/skill_search.go index 51e5196fb6..bdb1e78107 100644 --- a/internal/handler/skill_search.go +++ b/internal/handler/skill_search.go @@ -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 diff --git a/internal/permission/chat_channel_database_test.go b/internal/permission/chat_channel_database_test.go new file mode 100644 index 0000000000..858793049d --- /dev/null +++ b/internal/permission/chat_channel_database_test.go @@ -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) + } +} diff --git a/internal/permission/database_source.go b/internal/permission/database_source.go index 1ee1d63f54..749218a5c9 100644 --- a/internal/permission/database_source.go +++ b/internal/permission/database_source.go @@ -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 +} diff --git a/internal/permission/database_source_test.go b/internal/permission/database_source_test.go index 59ac6e7d24..065f10e66e 100644 --- a/internal/permission/database_source_test.go +++ b/internal/permission/database_source_test.go @@ -6,6 +6,7 @@ import ( "context" "errors" "net/url" + "strings" "testing" "ragflow/internal/entity" @@ -63,6 +64,16 @@ func TestDatabaseCheckerAuthorizesDatasetFromPersistedRows(t *testing.T) { if err := checker.CheckResource(context.Background(), subject, ref, OperationRead); err != nil { t.Errorf("CheckResource(%+v) error = %v, want nil", ref, err) } + for _, operation := range []Operation{OperationRead, OperationUpdate, OperationDeleteContent, OperationRun, OperationUse} { + if err := checker.CheckResource(context.Background(), subject, ref, operation); err != nil { + t.Errorf("shared dataset CheckResource(%s) error = %v, want nil", operation, err) + } + } + for _, operation := range []Operation{OperationCreate, OperationDelete, OperationShare} { + if err := checker.CheckResource(context.Background(), subject, ref, operation); !errors.Is(err, ErrPermissionDenied) { + t.Errorf("shared dataset CheckResource(%s) error = %v, want permission denied", operation, err) + } + } adminMembership := entity.UserTenant{ ID: "admin-membership", UserID: "admin-1", TenantID: dataset.TenantID, Role: string(RoleAdmin), Status: &valid, @@ -73,6 +84,9 @@ func TestDatabaseCheckerAuthorizesDatasetFromPersistedRows(t *testing.T) { if err := checker.CheckResource(context.Background(), Subject{UserID: adminMembership.UserID}, ref, OperationRead); !errors.Is(err, ErrPermissionDenied) { t.Errorf("CheckResource(admin member) error = %v, want %v", err, ErrPermissionDenied) } + if err := checker.CheckResource(context.Background(), Subject{UserID: ownerMembership.UserID}, ref, OperationDelete); err != nil { + t.Errorf("tenant owner CheckResource(delete dataset) error = %v, want nil", err) + } access, err := checker.ResolveAccess(context.Background(), subject, ref) if err != nil { @@ -146,6 +160,7 @@ func TestDatabaseCheckerScopesDatasetsToMembershipAndVisibility(t *testing.T) { } memberships := []entity.UserTenant{ {ID: "member", UserID: "member-user", TenantID: "member-tenant", Role: string(RoleNormal), Status: &valid}, + {ID: "admin", UserID: "admin-user", TenantID: "member-tenant", Role: string(RoleAdmin), Status: &valid}, {ID: "owner", UserID: "tenant-owner", TenantID: "member-tenant", Role: string(RoleOwner), Status: &valid}, {ID: "other-owner", UserID: "other-owner", TenantID: "other-tenant", Role: string(RoleOwner), Status: &valid}, } @@ -155,7 +170,8 @@ func TestDatabaseCheckerScopesDatasetsToMembershipAndVisibility(t *testing.T) { } } - scope, err := NewDatabaseChecker(db).Scope(context.Background(), Subject{UserID: "member-user"}, ScopeQuery{ + checker := NewDatabaseChecker(db) + scope, err := checker.Scope(context.Background(), Subject{UserID: "member-user"}, ScopeQuery{ Kind: ResourceKindDataset, Operation: OperationRead, }) if err != nil { @@ -164,9 +180,91 @@ func TestDatabaseCheckerScopesDatasetsToMembershipAndVisibility(t *testing.T) { if scope.Mode != ScopeIDs || len(scope.ResourceIDs) != 1 || scope.ResourceIDs[0] != "team-visible" { t.Fatalf("Scope(datasets) = %+v, want only the team-visible dataset", scope) } + + adminScope, err := checker.Scope(context.Background(), Subject{UserID: "admin-user"}, ScopeQuery{ + Kind: ResourceKindDataset, Operation: OperationRead, + }) + if err != nil { + t.Fatalf("Scope(datasets, admin) error = %v", err) + } + if adminScope.Mode != ScopeNone { + t.Fatalf("Scope(datasets, admin) = %+v, want no accessible datasets", adminScope) + } + + queryScope, err := checker.QueryScope(context.Background(), Subject{UserID: "member-user"}, ScopeQuery{ + Kind: ResourceKindDataset, Operation: OperationRead, + }) + if err != nil { + t.Fatalf("QueryScope(datasets) error = %v", err) + } + var queryIDs []string + if err := queryScope.Apply(db.Model(&entity.Knowledgebase{})).Order("knowledgebase.id").Pluck("knowledgebase.id", &queryIDs).Error; err != nil { + t.Fatalf("query authorized datasets: %v", err) + } + if len(queryIDs) != 1 || queryIDs[0] != "team-visible" { + t.Fatalf("QueryScope(datasets) returned %v, want only team-visible", queryIDs) + } + adminQueryScope, err := checker.QueryScope(context.Background(), Subject{UserID: "admin-user"}, ScopeQuery{ + Kind: ResourceKindDataset, Operation: OperationRead, + }) + if err != nil { + t.Fatalf("QueryScope(datasets, admin) error = %v", err) + } + var adminQueryIDs []string + if err := adminQueryScope.Apply(db.Model(&entity.Knowledgebase{})).Order("knowledgebase.id").Pluck("knowledgebase.id", &adminQueryIDs).Error; err != nil { + t.Fatalf("query authorized datasets for admin: %v", err) + } + if len(adminQueryIDs) != 0 { + t.Fatalf("QueryScope(datasets, admin) returned %v, want no datasets", adminQueryIDs) + } + dryRun := db.Session(&gorm.Session{DryRun: true}) + statement := queryScope.Apply(dryRun.Model(&entity.Knowledgebase{})).Find(&[]entity.Knowledgebase{}).Statement + if !strings.Contains(strings.ToUpper(statement.SQL.String()), "EXISTS") { + t.Fatalf("QueryScope should compile authorization into SQL, got: %s", statement.SQL.String()) + } } -func TestDatabaseCheckerSharedCanvasGrantsOwnerOperations(t *testing.T) { +func TestCheckerQueryScopeFallsBackToAuthorizedIDs(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.Exec("CREATE TABLE dialog (id TEXT PRIMARY KEY)").Error; err != nil { + t.Fatalf("create dialog table: %v", err) + } + if err := db.Exec("INSERT INTO dialog (id) VALUES (?), (?)", "visible-chat", "hidden-chat").Error; err != nil { + t.Fatalf("insert chats: %v", err) + } + + chatRef := ResourceRef{Kind: ResourceKindChat, ID: "visible-chat"} + source := &permissionTestSource{ + memberships: map[string]*Membership{ + "member\x00tenant": {UserID: "member", TenantID: "tenant", Role: RoleNormal, Active: true}, + }, + resources: map[ResourceRef]Resource{ + chatRef: { + Ref: chatRef, TenantID: "tenant", OwnerUserID: "tenant", Visibility: VisibilityTenant, + TenantRequirement: TenantMember, OwnerOperations: []Operation{OperationRead}, Active: true, + }, + }, + candidates: []ResourceRef{chatRef}, + } + queryScope, err := NewChecker(source).QueryScope(context.Background(), Subject{UserID: "member"}, ScopeQuery{ + Kind: ResourceKindChat, Operation: OperationRead, + }) + if err != nil { + t.Fatalf("QueryScope(chats) error = %v", err) + } + var ids []string + if err := queryScope.Apply(db.Table("dialog")).Order("dialog.id").Pluck("dialog.id", &ids).Error; err != nil { + t.Fatalf("query chats with fallback scope: %v", err) + } + if len(ids) != 1 || ids[0] != "visible-chat" { + t.Fatalf("QueryScope(chats) returned %v, want only visible-chat", ids) + } +} + +func TestDatabaseCheckerSharedCanvasRestrictsOwnerOperations(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) @@ -203,11 +301,21 @@ func TestDatabaseCheckerSharedCanvasGrantsOwnerOperations(t *testing.T) { teamCanvas := ResourceRef{Kind: ResourceKindCanvas, ID: "team-canvas"} privateCanvas := ResourceRef{Kind: ResourceKindCanvas, ID: "private-canvas"} - for _, operation := range []Operation{OperationRead, OperationUpdate, OperationDelete, OperationShare, OperationRun, OperationUse} { + for _, operation := range []Operation{OperationRead, OperationUpdate, OperationRun, OperationUse} { if err := checker.CheckResource(context.Background(), Subject{UserID: membership.UserID}, teamCanvas, operation); err != nil { t.Errorf("team member CheckResource(%s) error = %v, want nil", operation, err) } } + for _, operation := range []Operation{OperationShare, OperationDelete} { + if err := checker.CheckResource(context.Background(), Subject{UserID: membership.UserID}, teamCanvas, operation); !errors.Is(err, ErrPermissionDenied) { + t.Errorf("team member CheckResource(%s) error = %v, want permission denied", operation, err) + } + } + for _, operation := range []Operation{OperationShare, OperationDelete} { + if err := checker.CheckResource(context.Background(), Subject{UserID: "owner-1"}, teamCanvas, operation); err != nil { + t.Errorf("canvas owner CheckResource(%s) error = %v, want nil", operation, err) + } + } if err := checker.CheckResource(context.Background(), Subject{UserID: adminMembership.UserID}, teamCanvas, OperationRead); err != nil { t.Errorf("tenant admin CheckResource(read) error = %v, want nil", err) } @@ -265,3 +373,894 @@ func TestDatabaseCheckerRestrictsAgentSessionRunToCreator(t *testing.T) { t.Fatalf("Scope(agent sessions) = %+v, want only session-1", scope) } } + +func TestDatabaseCheckerAuthorizesMemoryFromPersistedRows(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.Memory{}, &entity.UserTenant{}); err != nil { + t.Fatalf("migrate permission tables: %v", err) + } + + valid := string(entity.StatusValid) + memories := []entity.Memory{ + {ID: "team-memory", Name: "team", TenantID: "team-1", StorageType: "table", EmbdID: "embedding", LLMID: "chat", Permissions: string(entity.TenantPermissionTeam), MemorySize: 1024, ForgettingPolicy: "FIFO", Temperature: 0.5}, + {ID: "private-memory", Name: "private", TenantID: "owner-1", StorageType: "table", EmbdID: "embedding", LLMID: "chat", Permissions: string(entity.TenantPermissionMe), MemorySize: 1024, ForgettingPolicy: "FIFO", Temperature: 0.5}, + } + for i := range memories { + if err := db.Create(&memories[i]).Error; err != nil { + t.Fatalf("create memory %q: %v", memories[i].ID, err) + } + } + membership := entity.UserTenant{ + ID: "member-team-1", UserID: "member-1", TenantID: "team-1", + Role: string(RoleNormal), Status: &valid, + } + if err := db.Create(&membership).Error; err != nil { + t.Fatalf("create membership: %v", err) + } + + checker := NewDatabaseChecker(db) + teamRef := ResourceRef{Kind: ResourceKindMemory, ID: "team-memory"} + privateRef := ResourceRef{Kind: ResourceKindMemory, ID: "private-memory"} + resources, err := (&databaseSource{db: db}).GetResources(context.Background(), []ResourceRef{teamRef, privateRef}) + if err != nil { + t.Fatalf("load memory authorization facts: %v", err) + } + if len(resources) != 2 { + t.Fatalf("loaded %d memory resources, want 2", len(resources)) + } + for _, resource := range resources { + if resource.TenantID != resource.OwnerUserID { + t.Errorf("memory resource %q has tenant %q and owner %q; current memory schema uses tenant_id as owner identity", resource.Ref.ID, resource.TenantID, resource.OwnerUserID) + } + } + if err := checker.CheckResource(context.Background(), Subject{UserID: membership.UserID}, teamRef, OperationRead); err != nil { + t.Errorf("team member CheckResource(read) error = %v, want nil", err) + } + if err := checker.CheckResource(context.Background(), Subject{UserID: membership.UserID}, teamRef, OperationUpdate); err != nil { + t.Errorf("team member CheckResource(update) error = %v, want nil", err) + } + if err := checker.CheckResource(context.Background(), Subject{UserID: membership.UserID}, teamRef, OperationShare); !errors.Is(err, ErrPermissionDenied) { + t.Errorf("team member CheckResource(share) error = %v, want permission denied", err) + } + if err := checker.CheckResource(context.Background(), Subject{UserID: "outsider"}, teamRef, OperationRead); !errors.Is(err, ErrPermissionDenied) { + t.Errorf("outsider CheckResource(read) error = %v, want permission denied", err) + } + if err := checker.CheckResource(context.Background(), Subject{UserID: "owner-1"}, privateRef, OperationRead); err != nil { + t.Errorf("memory owner CheckResource(read) error = %v, want nil", err) + } + if err := checker.CheckResource(context.Background(), Subject{UserID: membership.UserID}, privateRef, OperationRead); !errors.Is(err, ErrPermissionDenied) { + t.Errorf("team member CheckResource(private read) error = %v, want permission denied", err) + } +} + +func TestDatabaseCheckerScopesMemoriesToOwnerAndTeamMembership(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.Memory{}, &entity.UserTenant{}); err != nil { + t.Fatalf("migrate permission tables: %v", err) + } + + memories := []entity.Memory{ + {ID: "owner-private", Name: "owner", TenantID: "member-1", StorageType: "table", EmbdID: "embedding", LLMID: "chat", Permissions: string(entity.TenantPermissionMe), MemorySize: 1024, ForgettingPolicy: "FIFO", Temperature: 0.5}, + {ID: "team-visible", Name: "team", TenantID: "team-1", StorageType: "table", EmbdID: "embedding", LLMID: "chat", Permissions: string(entity.TenantPermissionTeam), MemorySize: 1024, ForgettingPolicy: "FIFO", Temperature: 0.5}, + {ID: "team-private", Name: "private", TenantID: "team-1", StorageType: "table", EmbdID: "embedding", LLMID: "chat", Permissions: string(entity.TenantPermissionMe), MemorySize: 1024, ForgettingPolicy: "FIFO", Temperature: 0.5}, + {ID: "other-team", Name: "other", TenantID: "other-tenant", StorageType: "table", EmbdID: "embedding", LLMID: "chat", Permissions: string(entity.TenantPermissionTeam), MemorySize: 1024, ForgettingPolicy: "FIFO", Temperature: 0.5}, + } + for i := range memories { + if err := db.Create(&memories[i]).Error; err != nil { + t.Fatalf("create memory %q: %v", memories[i].ID, err) + } + } + valid := string(entity.StatusValid) + memberships := []entity.UserTenant{ + {ID: "member-team-1", UserID: "member-1", TenantID: "team-1", Role: string(RoleNormal), Status: &valid}, + {ID: "owner-other", UserID: "other-owner", TenantID: "other-tenant", Role: string(RoleOwner), Status: &valid}, + } + for i := range memberships { + if err := db.Create(&memberships[i]).Error; err != nil { + t.Fatalf("create membership %q: %v", memberships[i].ID, err) + } + } + + scope, err := NewDatabaseChecker(db).Scope(context.Background(), Subject{UserID: "member-1"}, ScopeQuery{ + Kind: ResourceKindMemory, Operation: OperationRead, + }) + if err != nil { + t.Fatalf("Scope(memories) error = %v", err) + } + if scope.Mode != ScopeIDs || len(scope.ResourceIDs) != 2 { + t.Fatalf("Scope(memories) = %+v, want owner-private and team-visible", scope) + } + got := map[string]bool{} + for _, id := range scope.ResourceIDs { + got[id] = true + } + for _, id := range []string{"owner-private", "team-visible"} { + if !got[id] { + t.Errorf("Scope(memories) missing authorized memory %q", id) + } + } + for _, id := range []string{"team-private", "other-team"} { + if got[id] { + t.Errorf("Scope(memories) includes unauthorized memory %q", id) + } + } +} + +func TestDatabaseCheckerAuthorizesFilesThroughLinkedDatasetAccess(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.File{}, &entity.File2Document{}, &entity.Document{}, &entity.Knowledgebase{}, &entity.UserTenant{}); err != nil { + t.Fatalf("migrate permission tables: %v", err) + } + + valid := string(entity.StatusValid) + datasets := []entity.Knowledgebase{ + {ID: "team-dataset", TenantID: "team-tenant", Name: "Team", EmbdID: "embedding", Permission: string(entity.TenantPermissionTeam), CreatedBy: "team-owner", Status: &valid}, + {ID: "private-dataset", TenantID: "private-tenant", Name: "Private", EmbdID: "embedding", Permission: string(entity.TenantPermissionMe), CreatedBy: "private-owner", Status: &valid}, + } + for i := range datasets { + if err := db.Create(&datasets[i]).Error; err != nil { + t.Fatalf("create dataset %q: %v", datasets[i].ID, err) + } + } + files := []entity.File{ + {ID: "team-file", ParentID: "team-root", TenantID: "file-owner", CreatedBy: "file-creator", Name: "team.pdf", Type: "pdf"}, + {ID: "private-file", ParentID: "private-root", TenantID: "file-owner", CreatedBy: "file-creator", Name: "private.pdf", Type: "pdf"}, + } + for i := range files { + if err := db.Create(&files[i]).Error; err != nil { + t.Fatalf("create file %q: %v", files[i].ID, err) + } + } + documents := []entity.Document{ + {ID: "team-document", KbID: "team-dataset", ParserID: "general", ParserConfig: entity.JSONMap{}, SourceType: "local", Type: "pdf", CreatedBy: "team-owner", Suffix: "pdf", Status: &valid}, + {ID: "private-document", KbID: "private-dataset", ParserID: "general", ParserConfig: entity.JSONMap{}, SourceType: "local", Type: "pdf", CreatedBy: "private-owner", Suffix: "pdf", Status: &valid}, + } + for i := range documents { + if err := db.Create(&documents[i]).Error; err != nil { + t.Fatalf("create document %q: %v", documents[i].ID, err) + } + } + for i, file := range files { + fileID, documentID := file.ID, documents[i].ID + mapping := entity.File2Document{ID: "mapping-" + file.ID, FileID: &fileID, DocumentID: &documentID} + if err := db.Create(&mapping).Error; err != nil { + t.Fatalf("create file-document mapping for %q: %v", file.ID, err) + } + } + memberships := []entity.UserTenant{ + {ID: "team-owner-membership", UserID: "team-owner", TenantID: "team-tenant", Role: string(RoleOwner), Status: &valid}, + {ID: "team-member-membership", UserID: "team-member", TenantID: "team-tenant", Role: string(RoleNormal), Status: &valid}, + {ID: "team-admin-membership", UserID: "team-admin", TenantID: "team-tenant", Role: string(RoleAdmin), Status: &valid}, + {ID: "private-owner-membership", UserID: "private-owner", TenantID: "private-tenant", Role: string(RoleOwner), Status: &valid}, + } + for i := range memberships { + if err := db.Create(&memberships[i]).Error; err != nil { + t.Fatalf("create membership %q: %v", memberships[i].ID, err) + } + } + + checker := NewDatabaseChecker(db) + teamFile := ResourceRef{Kind: ResourceKindFile, ID: "team-file"} + if err := checker.CheckResource(context.Background(), Subject{UserID: "team-member"}, teamFile, OperationRead); err != nil { + t.Errorf("team member CheckResource(file read) error = %v, want nil", err) + } + if err := checker.CheckResource(context.Background(), Subject{UserID: "team-admin"}, teamFile, OperationRead); !errors.Is(err, ErrPermissionDenied) { + t.Errorf("team admin CheckResource(file read) error = %v, want permission denied (dataset access requires a normal member)", err) + } + if err := checker.CheckResource(context.Background(), Subject{UserID: "outsider"}, teamFile, OperationRead); !errors.Is(err, ErrPermissionDenied) { + t.Errorf("outsider CheckResource(file read) error = %v, want permission denied", err) + } + if err := checker.CheckResource(context.Background(), Subject{UserID: "private-owner"}, ResourceRef{Kind: ResourceKindFile, ID: "private-file"}, OperationRead); err != nil { + t.Errorf("private dataset owner CheckResource(file read) error = %v, want nil", err) + } + if err := checker.CheckResource(context.Background(), Subject{UserID: "team-member"}, ResourceRef{Kind: ResourceKindFile, ID: "private-file"}, OperationRead); !errors.Is(err, ErrPermissionDenied) { + t.Errorf("team member CheckResource(private file read) error = %v, want permission denied", err) + } + + resources, err := (&databaseSource{db: db}).GetResources(context.Background(), []ResourceRef{teamFile}) + if err != nil { + t.Fatalf("load file authorization facts: %v", err) + } + if len(resources) != 1 { + t.Fatalf("loaded %d file resources, want 1", len(resources)) + } + resource := resources[0] + if resource.TenantID != "file-owner" || resource.OwnerUserID != "file-owner" || resource.CreatedBy != "file-creator" { + t.Errorf("file authorization facts = %+v, want tenant/owner file-owner and creator file-creator", resource) + } + if resource.Visibility != VisibilityShared || len(resource.SharedWithTenantIDs) != 1 || resource.SharedWithTenantIDs[0] != "team-tenant" || len(resource.SharedWithUserIDs) != 1 || resource.SharedWithUserIDs[0] != "team-owner" { + t.Errorf("file sharing facts = %+v, want team tenant and its owner", resource) + } +} + +func TestDatabaseCheckerScopesFilesAndFoldersToOwnedFolder(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.File{}, &entity.File2Document{}, &entity.Document{}, &entity.Knowledgebase{}, &entity.UserTenant{}); err != nil { + t.Fatalf("migrate permission tables: %v", err) + } + files := []entity.File{ + {ID: "root", ParentID: "root", TenantID: "user-1", CreatedBy: "user-1", Name: "/", Type: "folder"}, + {ID: "child-file", ParentID: "root", TenantID: "user-1", CreatedBy: "user-1", Name: "notes.pdf", Type: "pdf"}, + {ID: "child-folder", ParentID: "root", TenantID: "user-1", CreatedBy: "user-1", Name: "notes", Type: "folder"}, + {ID: "nested-file", ParentID: "child-folder", TenantID: "user-1", CreatedBy: "user-1", Name: "nested.pdf", Type: "pdf"}, + {ID: "other-tenant-file", ParentID: "root", TenantID: "user-2", CreatedBy: "user-2", Name: "private.pdf", Type: "pdf"}, + } + for i := range files { + if err := db.Create(&files[i]).Error; err != nil { + t.Fatalf("create file %q: %v", files[i].ID, err) + } + } + checker := NewDatabaseChecker(db) + parent := ResourceRef{Kind: ResourceKindFolder, ID: "root"} + for _, test := range []struct { + kind ResourceKind + want string + }{ + {kind: ResourceKindFile, want: "child-file"}, + {kind: ResourceKindFolder, want: "child-folder"}, + } { + scope, err := checker.Scope(context.Background(), Subject{UserID: "user-1"}, ScopeQuery{ + Kind: test.kind, Parent: parent, Operation: OperationRead, + }) + if err != nil { + t.Fatalf("Scope(%s) error = %v", test.kind, err) + } + if scope.Mode != ScopeIDs || len(scope.ResourceIDs) != 1 || scope.ResourceIDs[0] != test.want { + t.Errorf("Scope(%s) = %+v, want only %q", test.kind, scope, test.want) + } + } + if err := checker.CheckResource(context.Background(), Subject{UserID: "user-1"}, ResourceRef{Kind: ResourceKindFolder, ID: "child-file"}, OperationRead); !errors.Is(err, ErrResourceNotFound) { + t.Errorf("folder ref to a file CheckResource error = %v, want resource not found", err) + } + if err := checker.CheckResource(context.Background(), Subject{UserID: "user-1"}, ResourceRef{Kind: ResourceKindFile, ID: "child-folder"}, OperationRead); !errors.Is(err, ErrResourceNotFound) { + t.Errorf("file ref to a folder CheckResource error = %v, want resource not found", err) + } + otherParent := ResourceRef{Kind: ResourceKindFolder, ID: "root"} + if scope, err := checker.Scope(context.Background(), Subject{UserID: "user-2"}, ScopeQuery{ + Kind: ResourceKindFile, Parent: otherParent, Operation: OperationRead, + }); err != nil || scope.Mode != ScopeNone { + t.Errorf("Scope(other user's folder) = (%+v, %v), want empty scope", scope, err) + } +} + +func TestDatabaseCheckerKeepsChatsPrivateToTheirOwner(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.Chat{}, &entity.UserTenant{}); err != nil { + t.Fatalf("migrate permission tables: %v", err) + } + valid := string(entity.StatusValid) + invalid := string(entity.StatusInvalid) + chats := []entity.Chat{ + {ID: "team-chat", TenantID: "team-tenant", LLMSetting: entity.JSONMap{}, PromptConfig: entity.JSONMap{}, KBIDs: entity.JSONSlice{}, DoRefer: "1", Status: &valid}, + {ID: "inactive-chat", TenantID: "team-tenant", LLMSetting: entity.JSONMap{}, PromptConfig: entity.JSONMap{}, KBIDs: entity.JSONSlice{}, DoRefer: "1", Status: &invalid}, + {ID: "other-chat", TenantID: "other-tenant", LLMSetting: entity.JSONMap{}, PromptConfig: entity.JSONMap{}, KBIDs: entity.JSONSlice{}, DoRefer: "1", Status: &valid}, + } + for i := range chats { + if err := db.Create(&chats[i]).Error; err != nil { + t.Fatalf("create chat %q: %v", chats[i].ID, err) + } + } + membership := entity.UserTenant{ + ID: "member-team", UserID: "member-1", TenantID: "team-tenant", Role: string(RoleNormal), Status: &valid, + } + if err := db.Create(&membership).Error; err != nil { + t.Fatalf("create membership: %v", err) + } + inviteMembership := entity.UserTenant{ + ID: "invite-team", UserID: "invite-1", TenantID: "team-tenant", Role: string(RoleInvite), Status: &valid, + } + if err := db.Create(&inviteMembership).Error; err != nil { + t.Fatalf("create invite membership: %v", err) + } + + checker := NewDatabaseChecker(db) + teamChat := ResourceRef{Kind: ResourceKindChat, ID: "team-chat"} + member := Subject{UserID: membership.UserID} + for _, operation := range []Operation{OperationRead, OperationCreate, OperationRun, OperationUse} { + if err := checker.CheckResource(context.Background(), member, teamChat, operation); !errors.Is(err, ErrPermissionDenied) { + t.Errorf("team member CheckResource(chat, %s) error = %v, want permission denied", operation, err) + } + } + if err := checker.CheckResource(context.Background(), Subject{UserID: "team-tenant"}, teamChat, OperationUpdate); err != nil { + t.Errorf("chat owner CheckResource(update) error = %v, want nil", err) + } + if err := checker.CheckResource(context.Background(), Subject{UserID: "invite-1"}, teamChat, OperationRead); !errors.Is(err, ErrPermissionDenied) { + t.Errorf("invited user CheckResource(read) error = %v, want permission denied", err) + } + if err := checker.CheckResource(context.Background(), member, ResourceRef{Kind: ResourceKindChat, ID: "inactive-chat"}, OperationRead); !errors.Is(err, ErrResourceNotFound) { + t.Errorf("inactive chat CheckResource(read) error = %v, want resource not found", err) + } + scope, err := checker.Scope(context.Background(), member, ScopeQuery{Kind: ResourceKindChat, Operation: OperationRead}) + if err != nil { + t.Fatalf("Scope(chats) error = %v", err) + } + if scope.Mode != ScopeNone || len(scope.ResourceIDs) != 0 { + t.Fatalf("team member Scope(chats) = %+v, want empty scope", scope) + } + + ownerScope, err := checker.Scope(context.Background(), Subject{UserID: "team-tenant"}, ScopeQuery{Kind: ResourceKindChat, Operation: OperationRead}) + if err != nil { + t.Fatalf("owner Scope(chats) error = %v", err) + } + if ownerScope.Mode != ScopeIDs || len(ownerScope.ResourceIDs) != 1 || ownerScope.ResourceIDs[0] != "team-chat" { + t.Fatalf("owner Scope(chats) = %+v, want only team-chat", ownerScope) + } +} + +func TestDatabaseCheckerScopesChatSessionsAndRestrictsMutations(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.Chat{}, &entity.ChatSession{}, &entity.UserTenant{}); err != nil { + t.Fatalf("migrate permission tables: %v", err) + } + valid := string(entity.StatusValid) + chat := entity.Chat{ + ID: "team-chat", TenantID: "team-tenant", LLMSetting: entity.JSONMap{}, + PromptConfig: entity.JSONMap{}, KBIDs: entity.JSONSlice{}, DoRefer: "1", Status: &valid, + } + if err := db.Create(&chat).Error; err != nil { + t.Fatalf("create chat: %v", err) + } + creatorID := "session-creator" + removedCreatorID := "removed-creator" + sessions := []entity.ChatSession{ + {ID: "creator-session", DialogID: chat.ID, UserID: &creatorID}, + {ID: "removed-creator-session", DialogID: chat.ID, UserID: &removedCreatorID}, + } + for i := range sessions { + if err := db.Create(&sessions[i]).Error; err != nil { + t.Fatalf("create chat session %q: %v", sessions[i].ID, err) + } + } + memberships := []entity.UserTenant{ + {ID: "creator-membership", UserID: creatorID, TenantID: chat.TenantID, Role: string(RoleNormal), Status: &valid}, + {ID: "reader-membership", UserID: "team-reader", TenantID: chat.TenantID, Role: string(RoleNormal), Status: &valid}, + } + for i := range memberships { + if err := db.Create(&memberships[i]).Error; err != nil { + t.Fatalf("create membership %q: %v", memberships[i].ID, err) + } + } + checker := NewDatabaseChecker(db) + creatorSession := ResourceRef{Kind: ResourceKindChatSession, ID: "creator-session"} + reader := Subject{UserID: "team-reader"} + if err := checker.CheckResource(context.Background(), reader, creatorSession, OperationRead); err != nil { + t.Errorf("tenant member CheckResource(session read) error = %v, want nil", err) + } + if err := checker.CheckResource(context.Background(), reader, creatorSession, OperationUpdate); !errors.Is(err, ErrPermissionDenied) { + t.Errorf("tenant member CheckResource(session update) error = %v, want permission denied", err) + } + if err := checker.CheckResource(context.Background(), Subject{UserID: creatorID}, creatorSession, OperationUpdate); err != nil { + t.Errorf("session creator CheckResource(update) error = %v, want nil", err) + } + creatorAccess, err := checker.ResolveAccess(context.Background(), Subject{UserID: creatorID}, creatorSession) + if err != nil { + t.Fatalf("session creator ResolveAccess() error = %v", err) + } + if creatorAccess.Source != AccessSourceCreator { + t.Errorf("session creator access source = %q, want %q", creatorAccess.Source, AccessSourceCreator) + } + if err := checker.CheckResource(context.Background(), Subject{UserID: chat.TenantID}, creatorSession, OperationDelete); err != nil { + t.Errorf("chat owner CheckResource(session delete) error = %v, want nil", err) + } + if err := checker.CheckResource(context.Background(), Subject{UserID: removedCreatorID}, ResourceRef{Kind: ResourceKindChatSession, ID: "removed-creator-session"}, OperationUpdate); !errors.Is(err, ErrPermissionDenied) { + t.Errorf("creator without active membership CheckResource(update) error = %v, want permission denied", err) + } + if err := checker.CheckResource(context.Background(), Subject{UserID: "outsider"}, creatorSession, OperationRead); !errors.Is(err, ErrPermissionDenied) { + t.Errorf("outsider CheckResource(session read) error = %v, want permission denied", err) + } + + scope, err := checker.Scope(context.Background(), reader, ScopeQuery{ + Kind: ResourceKindChatSession, Parent: ResourceRef{Kind: ResourceKindChat, ID: chat.ID}, Operation: OperationRead, + }) + if err != nil { + t.Fatalf("Scope(chat sessions) error = %v", err) + } + if scope.Mode != ScopeIDs || len(scope.ResourceIDs) != 2 { + t.Fatalf("Scope(chat sessions) = %+v, want both sessions visible read-only", scope) + } +} + +func TestDatabaseCheckerKeepsSearchAppsPrivateToTheirCreator(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.Search{}, &entity.UserTenant{}); err != nil { + t.Fatalf("migrate permission tables: %v", err) + } + valid := string(entity.StatusValid) + invalid := string(entity.StatusInvalid) + searchApps := []entity.Search{ + {ID: "team-search", TenantID: "team-tenant", CreatedBy: "team-owner", Name: "Team search", SearchConfig: entity.JSONMap{}, Status: &valid}, + {ID: "member-search", TenantID: "member-1", CreatedBy: "member-1", Name: "Personal search", SearchConfig: entity.JSONMap{}, Status: &valid}, + {ID: "other-search", TenantID: "other-tenant", CreatedBy: "other-owner", Name: "Other search", SearchConfig: entity.JSONMap{}, Status: &valid}, + {ID: "inactive-search", TenantID: "team-tenant", CreatedBy: "team-owner", Name: "Inactive search", SearchConfig: entity.JSONMap{}, Status: &invalid}, + } + for i := range searchApps { + if err := db.Create(&searchApps[i]).Error; err != nil { + t.Fatalf("create search app %q: %v", searchApps[i].ID, err) + } + } + memberships := []entity.UserTenant{ + {ID: "member-team", UserID: "member-1", TenantID: "team-tenant", Role: string(RoleNormal), Status: &valid}, + {ID: "owner-team", UserID: "team-owner", TenantID: "team-tenant", Role: string(RoleOwner), Status: &valid}, + {ID: "invite-team", UserID: "invite-1", TenantID: "team-tenant", Role: string(RoleInvite), Status: &valid}, + } + for i := range memberships { + if err := db.Create(&memberships[i]).Error; err != nil { + t.Fatalf("create membership %q: %v", memberships[i].ID, err) + } + } + + checker := NewDatabaseChecker(db) + teamSearch := ResourceRef{Kind: ResourceKindSearchApp, ID: "team-search"} + member := Subject{UserID: "member-1"} + if err := checker.CheckResource(context.Background(), member, teamSearch, OperationRead); !errors.Is(err, ErrPermissionDenied) { + t.Errorf("tenant member CheckResource(search read) error = %v, want permission denied", err) + } + for _, operation := range []Operation{OperationUpdate, OperationDelete, OperationRun, OperationUse} { + if err := checker.CheckResource(context.Background(), member, teamSearch, operation); !errors.Is(err, ErrPermissionDenied) { + t.Errorf("tenant member CheckResource(search %s) error = %v, want permission denied", operation, err) + } + } + for _, operation := range []Operation{OperationRead, OperationUpdate, OperationDelete, OperationRun, OperationUse} { + if err := checker.CheckResource(context.Background(), Subject{UserID: "team-owner"}, teamSearch, operation); err != nil { + t.Errorf("creator CheckResource(search %s) error = %v, want nil", operation, err) + } + } + if err := checker.CheckResource(context.Background(), Subject{UserID: "invite-1"}, teamSearch, OperationRead); !errors.Is(err, ErrPermissionDenied) { + t.Errorf("invited user CheckResource(search read) error = %v, want permission denied", err) + } + if err := checker.CheckResource(context.Background(), Subject{UserID: "outsider"}, teamSearch, OperationRead); !errors.Is(err, ErrPermissionDenied) { + t.Errorf("outsider CheckResource(search read) error = %v, want permission denied", err) + } + if err := checker.CheckResource(context.Background(), member, ResourceRef{Kind: ResourceKindSearchApp, ID: "inactive-search"}, OperationRead); !errors.Is(err, ErrResourceNotFound) { + t.Errorf("inactive search CheckResource(read) error = %v, want resource not found", err) + } + + resources, err := (&databaseSource{db: db}).GetResources(context.Background(), []ResourceRef{teamSearch}) + if err != nil { + t.Fatalf("load search authorization facts: %v", err) + } + if len(resources) != 1 { + t.Fatalf("loaded %d search resources, want 1", len(resources)) + } + if got := resources[0]; got.TenantID != "team-tenant" || got.CreatedBy != "team-owner" || got.OwnerUserID != "team-owner" { + t.Errorf("search authorization facts = %+v, want tenant team-tenant and creator/owner team-owner", got) + } + + readScope, err := checker.Scope(context.Background(), member, ScopeQuery{Kind: ResourceKindSearchApp, Operation: OperationRead}) + if err != nil { + t.Fatalf("Scope(search read) error = %v", err) + } + if readScope.Mode != ScopeIDs || len(readScope.ResourceIDs) != 1 || readScope.ResourceIDs[0] != "member-search" { + t.Fatalf("Scope(search read) = %+v, want only member-search", readScope) + } + runScope, err := checker.Scope(context.Background(), member, ScopeQuery{Kind: ResourceKindSearchApp, Operation: OperationRun}) + if err != nil { + t.Fatalf("Scope(search run) error = %v", err) + } + if runScope.Mode != ScopeIDs || len(runScope.ResourceIDs) != 1 || runScope.ResourceIDs[0] != "member-search" { + t.Fatalf("Scope(search run) = %+v, want only member-search", runScope) + } +} + +func TestDatabaseCheckerAuthorizesConnectorsAndSyncTasks(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.Connector{}, &entity.Connector2Kb{}, &entity.SyncLogs{}, &entity.Knowledgebase{}, &entity.UserTenant{}); err != nil { + t.Fatalf("migrate permission tables: %v", err) + } + + valid := string(entity.StatusValid) + connectors := []entity.Connector{ + {ID: "personal-connector", TenantID: "team-member", Name: "Personal", Source: "rest", InputType: "poll", Config: entity.JSONMap{}, Status: "5"}, + {ID: "team-connector", TenantID: "team-tenant", Name: "Team", Source: "rest", InputType: "poll", Config: entity.JSONMap{}, Status: "5"}, + {ID: "other-connector", TenantID: "other-tenant", Name: "Other", Source: "rest", InputType: "poll", Config: entity.JSONMap{}, Status: "5"}, + } + for i := range connectors { + if err := db.Create(&connectors[i]).Error; err != nil { + t.Fatalf("create connector %q: %v", connectors[i].ID, err) + } + } + links := []entity.Connector2Kb{ + {ID: "link-1", ConnectorID: "team-connector", KbID: "dataset-1", AutoParse: "1"}, + } + for i := range links { + if err := db.Create(&links[i]).Error; err != nil { + t.Fatalf("create connector-dataset link: %v", err) + } + } + dataset := entity.Knowledgebase{ + ID: "dataset-1", TenantID: "team-tenant", CreatedBy: "team-owner", Name: "Team dataset", + EmbdID: "embedding", Permission: string(entity.TenantPermissionTeam), Status: &valid, + } + if err := db.Create(&dataset).Error; err != nil { + t.Fatalf("create dataset: %v", err) + } + tasks := []entity.SyncLogs{ + {ID: "linked-task", ConnectorID: "team-connector", TaskType: "sync", Status: string(entity.TaskStatusFail), ErrorMsg: "failed", KbID: "dataset-1"}, + {ID: "completed-task", ConnectorID: "team-connector", TaskType: "sync", Status: string(entity.TaskStatusDone), ErrorMsg: "", KbID: "dataset-1"}, + {ID: "prune-task", ConnectorID: "team-connector", TaskType: "prune", Status: "3", ErrorMsg: "", KbID: "dataset-1"}, + {ID: "unlinked-task", ConnectorID: "team-connector", TaskType: "sync", Status: "3", ErrorMsg: "", KbID: "dataset-unlinked"}, + } + for i := range tasks { + if err := db.Create(&tasks[i]).Error; err != nil { + t.Fatalf("create sync task %q: %v", tasks[i].ID, err) + } + } + memberships := []entity.UserTenant{ + {ID: "member-link", UserID: "team-member", TenantID: "team-tenant", Role: string(RoleNormal), Status: &valid}, + {ID: "owner-link", UserID: "team-owner", TenantID: "team-tenant", Role: string(RoleOwner), Status: &valid}, + {ID: "invite-link", UserID: "invited-user", TenantID: "team-tenant", Role: string(RoleInvite), Status: &valid}, + } + for i := range memberships { + if err := db.Create(&memberships[i]).Error; err != nil { + t.Fatalf("create membership %q: %v", memberships[i].ID, err) + } + } + + checker := NewDatabaseChecker(db) + member := Subject{UserID: "team-member"} + teamConnector := ResourceRef{Kind: ResourceKindConnector, ID: "team-connector"} + for _, operation := range []Operation{OperationRun, OperationUse} { + if err := checker.CheckResource(context.Background(), member, teamConnector, operation); err != nil { + t.Errorf("team member CheckResource(connector %s) error = %v, want nil", operation, err) + } + } + for _, operation := range []Operation{OperationRead, OperationUpdate, OperationDelete} { + if err := checker.CheckResource(context.Background(), member, teamConnector, operation); !errors.Is(err, ErrPermissionDenied) { + t.Errorf("team member CheckResource(connector %s) error = %v, want permission denied", operation, err) + } + } + if err := checker.CheckResource(context.Background(), Subject{UserID: "invited-user"}, teamConnector, OperationRead); !errors.Is(err, ErrPermissionDenied) { + t.Errorf("invited user CheckResource(connector read) error = %v, want permission denied", err) + } + if err := checker.CheckResource(context.Background(), Subject{UserID: "outsider"}, teamConnector, OperationRead); !errors.Is(err, ErrPermissionDenied) { + t.Errorf("outsider CheckResource(connector read) error = %v, want permission denied", err) + } + if err := checker.CheckResource(context.Background(), member, ResourceRef{Kind: ResourceKindConnector, ID: "personal-connector"}, OperationRead); err != nil { + t.Errorf("connector owner CheckResource(read) error = %v, want nil", err) + } + + linkedTask := ResourceRef{Kind: ResourceKindSyncTask, ID: "linked-task"} + for _, operation := range []Operation{OperationRead, OperationRun} { + if err := checker.CheckResource(context.Background(), member, linkedTask, operation); err != nil { + t.Errorf("team member CheckResource(sync task %s) error = %v, want nil", operation, err) + } + } + if err := checker.CheckResource(context.Background(), member, linkedTask, OperationDelete); !errors.Is(err, ErrPermissionDenied) { + t.Errorf("team member CheckResource(sync task delete) error = %v, want permission denied", err) + } + if err := checker.CheckResource(context.Background(), member, ResourceRef{Kind: ResourceKindSyncTask, ID: "unlinked-task"}, OperationRead); !errors.Is(err, ErrResourceNotFound) { + t.Errorf("unlinked sync task CheckResource(read) error = %v, want resource not found", err) + } + if err := checker.CheckResource(context.Background(), member, ResourceRef{Kind: ResourceKindSyncTask, ID: "prune-task"}, OperationRun); !errors.Is(err, ErrPermissionDenied) { + t.Errorf("team member CheckResource(prune task run) error = %v, want permission denied", err) + } + if err := checker.CheckResource(context.Background(), member, ResourceRef{Kind: ResourceKindSyncTask, ID: "completed-task"}, OperationRun); !errors.Is(err, ErrPermissionDenied) { + t.Errorf("team member CheckResource(completed task run) error = %v, want permission denied", err) + } + + connectorScope, err := checker.Scope(context.Background(), member, ScopeQuery{Kind: ResourceKindConnector, Operation: OperationRead}) + if err != nil { + t.Fatalf("Scope(connectors) error = %v", err) + } + if connectorScope.Mode != ScopeIDs || len(connectorScope.ResourceIDs) != 1 || connectorScope.ResourceIDs[0] != "personal-connector" { + t.Fatalf("Scope(connectors) = %+v, want personal connector only", connectorScope) + } + taskScope, err := checker.Scope(context.Background(), member, ScopeQuery{ + Kind: ResourceKindSyncTask, Parent: teamConnector, Operation: OperationRead, + }) + if err != nil { + t.Fatalf("Scope(sync tasks) error = %v", err) + } + if taskScope.Mode != ScopeIDs || len(taskScope.ResourceIDs) != 3 { + t.Fatalf("Scope(sync tasks) = %+v, want linked sync and prune task logs", taskScope) + } + if _, err := checker.Scope(context.Background(), member, ScopeQuery{Kind: ResourceKindSyncTask, Operation: OperationRead}); !errors.Is(err, ErrInvalidPermission) { + t.Errorf("Scope(sync tasks without connector) error = %v, want invalid permission", err) + } +} + +func TestDatabaseCheckerAuthorizesTenantModelsAndSeparatesInstanceUseFromRead(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.TenantModelProvider{}, + &entity.TenantModelInstance{}, + &entity.TenantModel{}, + &entity.UserTenant{}, + ); err != nil { + t.Fatalf("migrate permission tables: %v", err) + } + + providers := []entity.TenantModelProvider{ + {ID: "team-provider", ProviderName: "openai", TenantID: "team-tenant"}, + {ID: "other-provider", ProviderName: "openai", TenantID: "other-tenant"}, + } + for i := range providers { + if err := db.Create(&providers[i]).Error; err != nil { + t.Fatalf("create provider %q: %v", providers[i].ID, err) + } + } + instances := []entity.TenantModelInstance{ + {ID: "team-instance", InstanceName: "team-instance", ProviderID: "team-provider", APIKey: "secret-team-key", Status: "active"}, + {ID: "other-instance", InstanceName: "other-instance", ProviderID: "other-provider", APIKey: "secret-other-key", Status: "active"}, + } + for i := range instances { + if err := db.Create(&instances[i]).Error; err != nil { + t.Fatalf("create instance %q: %v", instances[i].ID, err) + } + } + models := []entity.TenantModel{ + {ID: "team-model", ModelName: "chat-model", ProviderID: "team-provider", InstanceID: "team-instance", ModelType: int(entity.ModelTypeChat), Status: "active"}, + {ID: "malformed-model", ModelName: "cross-provider-model", ProviderID: "team-provider", InstanceID: "other-instance", ModelType: int(entity.ModelTypeChat), Status: "active"}, + } + for i := range models { + if err := db.Create(&models[i]).Error; err != nil { + t.Fatalf("create model %q: %v", models[i].ID, err) + } + } + valid := string(entity.StatusValid) + memberships := []entity.UserTenant{ + {ID: "team-owner-membership", UserID: "team-owner", TenantID: "team-tenant", Role: string(RoleOwner), Status: &valid}, + {ID: "team-member-membership", UserID: "team-member", TenantID: "team-tenant", Role: string(RoleNormal), Status: &valid}, + {ID: "team-invite-membership", UserID: "team-invite", TenantID: "team-tenant", Role: string(RoleInvite), Status: &valid}, + {ID: "other-owner-membership", UserID: "other-owner", TenantID: "other-tenant", Role: string(RoleOwner), Status: &valid}, + } + for i := range memberships { + if err := db.Create(&memberships[i]).Error; err != nil { + t.Fatalf("create membership %q: %v", memberships[i].ID, err) + } + } + + checker := NewDatabaseChecker(db) + member := Subject{UserID: "team-member"} + providerRef := ResourceRef{Kind: ResourceKindModelProvider, ID: "team-provider"} + instanceRef := ResourceRef{Kind: ResourceKindModelInstance, ID: "team-instance"} + modelRef := ResourceRef{Kind: ResourceKindModel, ID: "team-model"} + for _, test := range []struct { + name string + subject Subject + ref ResourceRef + operation Operation + wantErr error + }{ + {name: "member reads provider", subject: member, ref: providerRef, operation: OperationRead}, + {name: "member cannot manage provider", subject: member, ref: providerRef, operation: OperationUpdate, wantErr: ErrPermissionDenied}, + {name: "member uses instance without reading credentials", subject: member, ref: instanceRef, operation: OperationUse}, + {name: "member cannot read instance credentials", subject: member, ref: instanceRef, operation: OperationRead, wantErr: ErrPermissionDenied}, + {name: "member reads model", subject: member, ref: modelRef, operation: OperationRead}, + {name: "member uses model", subject: member, ref: modelRef, operation: OperationUse}, + {name: "member cannot manage model", subject: member, ref: modelRef, operation: OperationUpdate, wantErr: ErrPermissionDenied}, + {name: "outsider cannot use model", subject: Subject{UserID: "outsider"}, ref: modelRef, operation: OperationUse, wantErr: ErrPermissionDenied}, + {name: "invite cannot use model", subject: Subject{UserID: "team-invite"}, ref: modelRef, operation: OperationUse, wantErr: ErrPermissionDenied}, + } { + t.Run(test.name, func(t *testing.T) { + err := checker.CheckResource(context.Background(), test.subject, test.ref, test.operation) + if test.wantErr == nil && err != nil { + t.Fatalf("CheckResource(%s) error = %v, want nil", test.name, err) + } + if test.wantErr != nil && !errors.Is(err, test.wantErr) { + t.Fatalf("CheckResource(%s) error = %v, want %v", test.name, err, test.wantErr) + } + }) + } + + owner := Subject{UserID: "team-owner"} + if err := checker.CheckResource(context.Background(), owner, instanceRef, OperationRead); err != nil { + t.Errorf("tenant owner cannot read instance credentials: %v", err) + } + if err := checker.CheckResource(context.Background(), member, ResourceRef{Kind: ResourceKindModel, ID: "malformed-model"}, OperationRead); !errors.Is(err, ErrResourceNotFound) { + t.Errorf("cross-provider model CheckResource error = %v, want resource not found", err) + } + + for _, query := range []struct { + name string + kind ResourceKind + parent ResourceRef + operation Operation + want string + }{ + {name: "providers", kind: ResourceKindModelProvider, want: "team-provider"}, + {name: "usable instances under provider", kind: ResourceKindModelInstance, parent: providerRef, operation: OperationUse, want: "team-instance"}, + {name: "models under instance", kind: ResourceKindModel, parent: instanceRef, want: "team-model"}, + {name: "models across tenant", kind: ResourceKindModel, want: "team-model"}, + } { + t.Run("scope "+query.name, func(t *testing.T) { + operation := query.operation + if operation == "" { + operation = OperationRead + } + scope, err := checker.Scope(context.Background(), member, ScopeQuery{ + Kind: query.kind, Parent: query.parent, Operation: operation, + }) + if err != nil { + t.Fatalf("Scope(%s) error = %v", query.name, err) + } + if scope.Mode != ScopeIDs || len(scope.ResourceIDs) != 1 || scope.ResourceIDs[0] != query.want { + t.Fatalf("Scope(%s) = %+v, want only %q", query.name, scope, query.want) + } + }) + } + if _, err := checker.Scope(context.Background(), member, ScopeQuery{ + Kind: ResourceKindModelInstance, + Parent: ResourceRef{Kind: ResourceKindModel, ID: "team-model"}, + Operation: OperationRead, + }); !errors.Is(err, ErrInvalidPermission) { + t.Errorf("Scope(instances with wrong parent) error = %v, want invalid permission", err) + } +} + +func TestDatabaseCheckerAuthorizesMCPServersAndSkillSpaces(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.MCPServer{}, + &entity.SkillSpace{}, + &entity.File{}, + &entity.UserTenant{}, + ); err != nil { + t.Fatalf("migrate permission tables: %v", err) + } + + valid := string(entity.StatusValid) + memberships := []entity.UserTenant{ + {ID: "team-owner-membership", UserID: "team-owner", TenantID: "team-tenant", Role: string(RoleOwner), Status: &valid}, + {ID: "team-member-membership", UserID: "team-member", TenantID: "team-tenant", Role: string(RoleNormal), Status: &valid}, + {ID: "team-invite-membership", UserID: "team-invite", TenantID: "team-tenant", Role: string(RoleInvite), Status: &valid}, + {ID: "other-owner-membership", UserID: "other-owner", TenantID: "other-tenant", Role: string(RoleOwner), Status: &valid}, + } + for i := range memberships { + if err := db.Create(&memberships[i]).Error; err != nil { + t.Fatalf("create membership %q: %v", memberships[i].ID, err) + } + } + + servers := []entity.MCPServer{ + {ID: "team-mcp", TenantID: "team-tenant", Name: "Team MCP", URL: "https://mcp.example", ServerType: "streamable-http", Headers: entity.JSONMap{"Authorization": "secret"}}, + {ID: "other-mcp", TenantID: "other-tenant", Name: "Other MCP", URL: "https://other.example", ServerType: "streamable-http"}, + } + for i := range servers { + if err := db.Create(&servers[i]).Error; err != nil { + t.Fatalf("create MCP server %q: %v", servers[i].ID, err) + } + } + + folders := []entity.File{ + {ID: "active-folder", ParentID: "root", TenantID: "team-tenant", CreatedBy: "team-owner", Name: "active", Type: "folder", SourceType: "skill_space"}, + {ID: "deleting-folder", ParentID: "root", TenantID: "team-tenant", CreatedBy: "team-owner", Name: "deleting", Type: "folder", SourceType: "skill_space"}, + {ID: "deleted-folder", ParentID: "root", TenantID: "team-tenant", CreatedBy: "team-owner", Name: "deleted", Type: "folder", SourceType: "skill_space"}, + {ID: "wrong-type-file", ParentID: "root", TenantID: "team-tenant", CreatedBy: "team-owner", Name: "not-a-folder", Type: "file", SourceType: "skill_space"}, + {ID: "other-tenant-folder", ParentID: "root", TenantID: "other-tenant", CreatedBy: "other-owner", Name: "other", Type: "folder", SourceType: "skill_space"}, + } + for i := range folders { + if err := db.Create(&folders[i]).Error; err != nil { + t.Fatalf("create folder %q: %v", folders[i].ID, err) + } + } + spaces := []entity.SkillSpace{ + {ID: "active-space", TenantID: "team-tenant", Name: "Active", FolderID: "active-folder", Status: entity.SpaceStatusActive}, + {ID: "deleting-space", TenantID: "team-tenant", Name: "Deleting", FolderID: "deleting-folder", Status: entity.SpaceStatusDeleting}, + {ID: "deleted-space", TenantID: "team-tenant", Name: "Deleted", FolderID: "deleted-folder", Status: entity.SpaceStatusDeleted}, + {ID: "wrong-folder-type-space", TenantID: "team-tenant", Name: "Wrong type", FolderID: "wrong-type-file", Status: entity.SpaceStatusActive}, + {ID: "cross-tenant-folder-space", TenantID: "team-tenant", Name: "Cross tenant", FolderID: "other-tenant-folder", Status: entity.SpaceStatusActive}, + {ID: "missing-folder-space", TenantID: "team-tenant", Name: "Missing folder", FolderID: "missing-folder", Status: entity.SpaceStatusActive}, + } + for i := range spaces { + if err := db.Create(&spaces[i]).Error; err != nil { + t.Fatalf("create skill space %q: %v", spaces[i].ID, err) + } + } + + checker := NewDatabaseChecker(db) + member := Subject{UserID: "team-member"} + owner := Subject{UserID: "team-owner"} + outsider := Subject{UserID: "outsider"} + invite := Subject{UserID: "team-invite"} + mcpRef := ResourceRef{Kind: ResourceKindMCPServer, ID: "team-mcp"} + spaceRef := ResourceRef{Kind: ResourceKindSkillSpace, ID: "active-space"} + for _, test := range []struct { + name string + subject Subject + ref ResourceRef + operation Operation + wantErr error + }{ + {name: "member cannot directly read MCP config", subject: member, ref: mcpRef, operation: OperationRead, wantErr: ErrPermissionDenied}, + {name: "member uses MCP server", subject: member, ref: mcpRef, operation: OperationUse}, + {name: "member cannot manage MCP server", subject: member, ref: mcpRef, operation: OperationUpdate, wantErr: ErrPermissionDenied}, + {name: "owner manages MCP server", subject: owner, ref: mcpRef, operation: OperationUpdate}, + {name: "outsider cannot use MCP server", subject: outsider, ref: mcpRef, operation: OperationUse, wantErr: ErrPermissionDenied}, + {name: "invite cannot use MCP server", subject: invite, ref: mcpRef, operation: OperationUse, wantErr: ErrPermissionDenied}, + {name: "member reads skill space", subject: member, ref: spaceRef, operation: OperationRead}, + {name: "member uses skill space", subject: member, ref: spaceRef, operation: OperationUse}, + {name: "member cannot manage skill space", subject: member, ref: spaceRef, operation: OperationUpdate, wantErr: ErrPermissionDenied}, + {name: "owner manages skill space", subject: owner, ref: spaceRef, operation: OperationDelete}, + {name: "outsider cannot use skill space", subject: outsider, ref: spaceRef, operation: OperationUse, wantErr: ErrPermissionDenied}, + {name: "invite cannot use skill space", subject: invite, ref: spaceRef, operation: OperationUse, wantErr: ErrPermissionDenied}, + {name: "deleting skill space remains addressable", subject: member, ref: ResourceRef{Kind: ResourceKindSkillSpace, ID: "deleting-space"}, operation: OperationRead}, + } { + t.Run(test.name, func(t *testing.T) { + err := checker.CheckResource(context.Background(), test.subject, test.ref, test.operation) + if test.wantErr == nil && err != nil { + t.Fatalf("CheckResource error = %v, want nil", err) + } + if test.wantErr != nil && !errors.Is(err, test.wantErr) { + t.Fatalf("CheckResource error = %v, want %v", err, test.wantErr) + } + }) + } + + for _, id := range []string{"deleted-space", "wrong-folder-type-space", "cross-tenant-folder-space", "missing-folder-space"} { + ref := ResourceRef{Kind: ResourceKindSkillSpace, ID: id} + if err := checker.CheckResource(context.Background(), member, ref, OperationRead); !errors.Is(err, ErrResourceNotFound) { + t.Errorf("CheckResource(%q) error = %v, want resource not found", id, err) + } + } + + resources, err := (&databaseSource{db: db}).GetResources(context.Background(), []ResourceRef{mcpRef, spaceRef}) + if err != nil { + t.Fatalf("load integration authorization facts: %v", err) + } + if len(resources) != 2 { + t.Fatalf("loaded %d integration resources, want 2", len(resources)) + } + for _, resource := range resources { + if resource.TenantID != "team-tenant" || resource.OwnerUserID != "team-owner" { + t.Errorf("resource %v facts = tenant %q, owner %q; want team tenant/owner", resource.Ref, resource.TenantID, resource.OwnerUserID) + } + } + + for _, kind := range []ResourceKind{ResourceKindMCPServer, ResourceKindSkillSpace} { + t.Run("scope "+string(kind), func(t *testing.T) { + scope, err := checker.Scope(context.Background(), member, ScopeQuery{Kind: kind, Operation: OperationRead}) + if err != nil { + t.Fatalf("Scope(%s) error = %v", kind, err) + } + want := []string{} + if kind == ResourceKindSkillSpace { + want = []string{"active-space"} + } + if len(want) == 0 { + if scope.Mode != ScopeNone || len(scope.ResourceIDs) != 0 { + t.Fatalf("Scope(%s) = %+v, want no visible resources", kind, scope) + } + return + } + if scope.Mode != ScopeIDs || len(scope.ResourceIDs) != len(want) || scope.ResourceIDs[0] != want[0] { + t.Fatalf("Scope(%s) = %+v, want IDs %v", kind, scope, want) + } + }) + if _, err := checker.Scope(context.Background(), member, ScopeQuery{ + Kind: kind, Parent: ResourceRef{Kind: ResourceKindCanvas, ID: "unexpected-parent"}, Operation: OperationRead, + }); !errors.Is(err, ErrInvalidPermission) { + t.Errorf("Scope(%s with parent) error = %v, want invalid permission", kind, err) + } + } +} diff --git a/internal/permission/integration_resources.go b/internal/permission/integration_resources.go new file mode 100644 index 0000000000..7aa4b86fc6 --- /dev/null +++ b/internal/permission/integration_resources.go @@ -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 +} diff --git a/internal/permission/model_resources.go b/internal/permission/model_resources.go new file mode 100644 index 0000000000..b026a552d2 --- /dev/null +++ b/internal/permission/model_resources.go @@ -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, + } +} diff --git a/internal/permission/policy_ce.go b/internal/permission/policy_ce.go index b1077710cc..2a43d4fc29 100644 --- a/internal/permission/policy_ce.go +++ b/internal/permission/policy_ce.go @@ -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...), } } diff --git a/internal/permission/query_scope.go b/internal/permission/query_scope.go new file mode 100644 index 0000000000..e00d6e5b8e --- /dev/null +++ b/internal/permission/query_scope.go @@ -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 + } +} diff --git a/internal/permission/types.go b/internal/permission/types.go index cc7992ca3d..04069ff239 100644 --- a/internal/permission/types.go +++ b/internal/permission/types.go @@ -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 { diff --git a/internal/service/agent.go b/internal/service/agent.go index 1d4a458a62..90ba841412 100644 --- a/internal/service/agent.go +++ b/internal/service/agent.go @@ -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 diff --git a/internal/service/agent_sessions.go b/internal/service/agent_sessions.go index 4bb8ccd525..684856b4b8 100644 --- a/internal/service/agent_sessions.go +++ b/internal/service/agent_sessions.go @@ -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.") } diff --git a/internal/service/agent_test.go b/internal/service/agent_test.go index ff1b5adad4..bd18aa3558 100644 --- a/internal/service/agent_test.go +++ b/internal/service/agent_test.go @@ -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") diff --git a/internal/service/bot.go b/internal/service/bot.go index a45f7e6244..2d161f8498 100644 --- a/internal/service/bot.go +++ b/internal/service/bot.go @@ -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 diff --git a/internal/service/bot_completion.go b/internal/service/bot_completion.go index 6f0b5d192c..61897216f2 100644 --- a/internal/service/bot_completion.go +++ b/internal/service/bot_completion.go @@ -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 } diff --git a/internal/service/bot_permission_test.go b/internal/service/bot_permission_test.go new file mode 100644 index 0000000000..ddd61e24e9 --- /dev/null +++ b/internal/service/bot_permission_test.go @@ -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) + } +} diff --git a/internal/service/chat.go b/internal/service/chat.go index 39cf03e318..8ffe9ea023 100644 --- a/internal/service/chat.go +++ b/internal/service/chat.go @@ -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) diff --git a/internal/service/chat_channel.go b/internal/service/chat_channel.go index 425106f32d..a675c040a6 100644 --- a/internal/service/chat_channel.go +++ b/internal/service/chat_channel.go @@ -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 diff --git a/internal/service/chat_channel_permission.go b/internal/service/chat_channel_permission.go new file mode 100644 index 0000000000..91aef6fcba --- /dev/null +++ b/internal/service/chat_channel_permission.go @@ -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) +} diff --git a/internal/service/chat_channel_test.go b/internal/service/chat_channel_test.go index 6d3cce5e1e..ee82feed44 100644 --- a/internal/service/chat_channel_test.go +++ b/internal/service/chat_channel_test.go @@ -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") } } diff --git a/internal/service/chat_delete_test.go b/internal/service/chat_delete_test.go index a7508b0cb9..575b83abcb 100644 --- a/internal/service/chat_delete_test.go +++ b/internal/service/chat_delete_test.go @@ -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") diff --git a/internal/service/chat_list_test.go b/internal/service/chat_list_test.go index 49b283ffcf..ae973b78d1 100644 --- a/internal/service/chat_list_test.go +++ b/internal/service/chat_list_test.go @@ -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)) } } diff --git a/internal/service/chat_permission.go b/internal/service/chat_permission.go new file mode 100644 index 0000000000..9c0bb13112 --- /dev/null +++ b/internal/service/chat_permission.go @@ -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) +} diff --git a/internal/service/chat_pipeline.go b/internal/service/chat_pipeline.go index 877ebd0c48..987a859ac1 100644 --- a/internal/service/chat_pipeline.go +++ b/internal/service/chat_pipeline.go @@ -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", diff --git a/internal/service/chat_session.go b/internal/service/chat_session.go index c15b893445..fef9d0d494 100644 --- a/internal/service/chat_session.go +++ b/internal/service/chat_session.go @@ -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{ diff --git a/internal/service/chat_session_contract_test.go b/internal/service/chat_session_contract_test.go index 1682f8d681..e0aaeed78d 100644 --- a/internal/service/chat_session_contract_test.go +++ b/internal/service/chat_session_contract_test.go @@ -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) } } diff --git a/internal/service/chat_session_test.go b/internal/service/chat_session_test.go index 384e4f5ec6..e017499f00 100644 --- a/internal/service/chat_session_test.go +++ b/internal/service/chat_session_test.go @@ -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 { diff --git a/internal/service/chunk/chunk.go b/internal/service/chunk/chunk.go index 0e45473114..f963df348c 100644 --- a/internal/service/chunk/chunk.go +++ b/internal/service/chunk/chunk.go @@ -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 { diff --git a/internal/service/chunk/chunk_permissions_test.go b/internal/service/chunk/chunk_permissions_test.go index a62f6e0ca6..3588f91063 100644 --- a/internal/service/chunk/chunk_permissions_test.go +++ b/internal/service/chunk/chunk_permissions_test.go @@ -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 diff --git a/internal/service/chunk/chunk_test.go b/internal/service/chunk/chunk_test.go index 7be2bd87f5..89a990a74b 100644 --- a/internal/service/chunk/chunk_test.go +++ b/internal/service/chunk/chunk_test.go @@ -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() diff --git a/internal/service/connector.go b/internal/service/connector.go index dd6ff56906..33e78c3598 100644 --- a/internal/service/connector.go +++ b/internal/service/connector.go @@ -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) } diff --git a/internal/service/connector_permission.go b/internal/service/connector_permission.go new file mode 100644 index 0000000000..4e1e84766c --- /dev/null +++ b/internal/service/connector_permission.go @@ -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) +} diff --git a/internal/service/connector_syncer_test.go b/internal/service/connector_syncer_test.go index b6adb91051..c1c553f2ed 100644 --- a/internal/service/connector_syncer_test.go +++ b/internal/service/connector_syncer_test.go @@ -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", diff --git a/internal/service/connector_test_connection_test.go b/internal/service/connector_test_connection_test.go index 1a341fc4d0..425d299264 100644 --- a/internal/service/connector_test_connection_test.go +++ b/internal/service/connector_test_connection_test.go @@ -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) } } diff --git a/internal/service/dataset/crud.go b/internal/service/dataset/crud.go index 44bc9e093f..844a11ef6f 100644 --- a/internal/service/dataset/crud.go +++ b/internal/service/dataset/crud.go @@ -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 } diff --git a/internal/service/dataset/ingestion.go b/internal/service/dataset/ingestion.go index 14e022bf4a..b9cb7e9be2 100644 --- a/internal/service/dataset/ingestion.go +++ b/internal/service/dataset/ingestion.go @@ -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) } diff --git a/internal/service/dataset/ingestion_messages_test.go b/internal/service/dataset/ingestion_messages_test.go index f9c959dd6b..2360412d8c 100644 --- a/internal/service/dataset/ingestion_messages_test.go +++ b/internal/service/dataset/ingestion_messages_test.go @@ -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: ""}, } diff --git a/internal/service/dataset/list_test.go b/internal/service/dataset/list_test.go index 869de48dd5..730c02f656 100644 --- a/internal/service/dataset/list_test.go +++ b/internal/service/dataset/list_test.go @@ -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) diff --git a/internal/service/dataset/search.go b/internal/service/dataset/search.go index ef2b55f7fb..ec6a895a69 100644 --- a/internal/service/dataset/search.go +++ b/internal/service/dataset/search.go @@ -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 { diff --git a/internal/service/dataset/update_test.go b/internal/service/dataset/update_test.go index a48f42a50a..76ffaa8e53 100644 --- a/internal/service/dataset/update_test.go +++ b/internal/service/dataset/update_test.go @@ -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) } } diff --git a/internal/service/document/document_crud.go b/internal/service/document/document_crud.go index 411494a8a8..38e005727b 100644 --- a/internal/service/document/document_crud.go +++ b/internal/service/document/document_crud.go @@ -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 } diff --git a/internal/service/document/document_test.go b/internal/service/document/document_test.go index 0462529468..d5f22a171f 100644 --- a/internal/service/document/document_test.go +++ b/internal/service/document/document_test.go @@ -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") diff --git a/internal/service/document/file2document.go b/internal/service/document/file2document.go index fa8d04881b..90a2425f80 100644 --- a/internal/service/document/file2document.go +++ b/internal/service/document/file2document.go @@ -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 } } diff --git a/internal/service/file/access.go b/internal/service/file/access.go new file mode 100644 index 0000000000..602031dd43 --- /dev/null +++ b/internal/service/file/access.go @@ -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 +} diff --git a/internal/service/file/file.go b/internal/service/file/file.go index 2bf316119e..d207fd865d 100644 --- a/internal/service/file/file.go +++ b/internal/service/file/file.go @@ -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, } } diff --git a/internal/service/file/file_browse_permission_test.go b/internal/service/file/file_browse_permission_test.go new file mode 100644 index 0000000000..3f8eca2da8 --- /dev/null +++ b/internal/service/file/file_browse_permission_test.go @@ -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) + } +} diff --git a/internal/service/file/file_content.go b/internal/service/file/file_content.go index faba6b6769..8287eed373 100644 --- a/internal/service/file/file_content.go +++ b/internal/service/file/file_content.go @@ -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 } diff --git a/internal/service/file/file_delete.go b/internal/service/file/file_delete.go index 45d4f6dd5a..622f579f2a 100644 --- a/internal/service/file/file_delete.go +++ b/internal/service/file/file_delete.go @@ -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) diff --git a/internal/service/file/file_delete_test.go b/internal/service/file/file_delete_test.go index a1378d5704..7e00722f98 100644 --- a/internal/service/file/file_delete_test.go +++ b/internal/service/file/file_delete_test.go @@ -22,6 +22,9 @@ import ( "testing" "ragflow/internal/common" + "ragflow/internal/dao" + "ragflow/internal/entity" + "ragflow/internal/permission" "ragflow/internal/storage" "go.uber.org/zap" @@ -59,7 +62,7 @@ func TestDeleteFolderLeavesNonemptyBucket(t *testing.T) { t.Cleanup(func() { common.Logger = previousLogger }) service := testFileService() - if err := service.deleteFolderRecursive(t.Context(), folder, "tenant-1"); err != nil { + if err := service.deleteFolderRecursive(t.Context(), folder); err != nil { t.Fatalf("delete folder with nonempty bucket: %v", err) } if !store.ObjExist(t.Context(), folder.ID, "untracked") { @@ -90,7 +93,7 @@ func TestDeleteFolderStopsOnStorageCheckError(t *testing.T) { previous := factory.GetStorage() factory.SetStorage(failingObjectCheckStorage{storage.NewMemoryStorage()}) t.Cleanup(func() { factory.SetStorage(previous) }) - if err := testFileService().deleteFolderRecursive(t.Context(), folder, "tenant-1"); err == nil { + if err := testFileService().deleteFolderRecursive(t.Context(), folder); err == nil { t.Fatal("expected storage check error") } if _, err := testFileService().fileDAO.GetByID(t.Context(), db, "file-1"); err != nil { @@ -110,10 +113,32 @@ func TestDeleteFolderContinuesWhenStorageIsMissing(t *testing.T) { previous := factory.GetStorage() factory.SetStorage(storage.NewMemoryStorage()) t.Cleanup(func() { factory.SetStorage(previous) }) - if err := service.deleteFolderRecursive(t.Context(), folder, "tenant-1"); err != nil { + if err := service.deleteFolderRecursive(t.Context(), folder); err != nil { t.Fatalf("delete folder with missing storage: %v", err) } if _, err := service.fileDAO.GetByID(t.Context(), db, folder.ID); err == nil { t.Fatal("folder record retained after missing storage") } } + +func TestDeleteFilesAuthorizesEveryDescendantBeforeDeleting(t *testing.T) { + db := setupFolderTestDB(t) + for _, file := range []*entity.File{ + {ID: "folder", ParentID: "root", TenantID: "user-1", Name: "folder", Type: FileTypeFolder}, + {ID: "foreign-child", ParentID: "folder", TenantID: "user-2", Name: "private.pdf", Type: "pdf"}, + } { + if err := db.Create(file).Error; err != nil { + t.Fatalf("seed %s: %v", file.ID, err) + } + } + + err := testFileService().DeleteFiles(t.Context(), "user-1", []string{"folder"}) + if !errors.Is(err, permission.ErrPermissionDenied) { + t.Fatalf("DeleteFiles error = %v, want permission denied", err) + } + for _, id := range []string{"folder", "foreign-child"} { + if _, err := dao.NewFileDAO().GetByID(t.Context(), db, id); err != nil { + t.Fatalf("file %q was removed before authorization completed: %v", id, err) + } + } +} diff --git a/internal/service/file/file_folder.go b/internal/service/file/file_folder.go index c788fba740..aee22e9e69 100644 --- a/internal/service/file/file_folder.go +++ b/internal/service/file/file_folder.go @@ -7,6 +7,7 @@ import ( "ragflow/internal/common" "ragflow/internal/dao" "ragflow/internal/entity" + "ragflow/internal/permission" "ragflow/internal/storage" "ragflow/internal/utility" "strings" @@ -18,28 +19,58 @@ func (s *FileService) GetRootFolder(ctx context.Context, tenantID string) (map[s if err != nil { return nil, err } + if err := checkFileAccess(ctx, tenantID, file, permission.OperationRead); err != nil { + return nil, err + } return s.toFileResponse(file), nil } // ListFiles lists files by parent folder ID (matching Python /files endpoint) // This method includes init_dataset_docs initialization when parent_id is empty func (s *FileService) ListFiles(ctx context.Context, tenantID, pfID string, page, pageSize int, terms []dao.OrderTerm, keywords string) (*ListFilesResponse, error) { + return s.listFiles(ctx, tenantID, tenantID, pfID, page, pageSize, terms, keywords, true) +} + +// ListFilesForTenant allows active tenant members to browse the tenant's files. +func (s *FileService) ListFilesForTenant(ctx context.Context, userID, tenantID, pfID string, page, pageSize int, terms []dao.OrderTerm, keywords string) (*ListFilesResponse, error) { + checkFilePermissions := userID == tenantID + if !checkFilePermissions { + if err := permission.NewDatabaseChecker(dao.DB).CheckTenant(ctx, permission.Subject{UserID: userID}, tenantID, permission.TenantMember); err != nil { + return nil, err + } + } + return s.listFiles(ctx, userID, tenantID, pfID, page, pageSize, terms, keywords, checkFilePermissions) +} + +func (s *FileService) listFiles(ctx context.Context, userID, tenantID, pfID string, page, pageSize int, terms []dao.OrderTerm, keywords string, checkFilePermissions bool) (*ListFilesResponse, error) { + if tenantID == "" { + return nil, permission.ErrInvalidSubject + } // If pfID is empty, get root folder and initialize dataset docs if pfID == "" { rootFolder, err := s.fileDAO.GetRootFolder(ctx, dao.DB, tenantID) if err != nil { return nil, fmt.Errorf("failed to get root folder: %w", err) } + if checkFilePermissions { + if err := checkFileAccess(ctx, userID, rootFolder, permission.OperationRead); err != nil { + return nil, err + } + } pfID = rootFolder.ID - // Initialize dataset docs (matching Python init_knowledgebase_docs logic) - if err = s.initDatasetDocs(ctx, pfID, tenantID); err != nil { - return nil, fmt.Errorf("failed to initialize dataset docs: %w", err) - } + // Keep tenant-scoped lookups read-only. The default self-owned file + // listing retains its existing folder initialization. + if tenantID == userID { + // Initialize dataset docs (matching Python init_knowledgebase_docs logic) + if err = s.initDatasetDocs(ctx, pfID, tenantID); err != nil { + return nil, fmt.Errorf("failed to initialize dataset docs: %w", err) + } - // Initialize skills folder (matching Python init_skills_folder logic) - if err = s.initSkillsFolder(ctx, pfID, tenantID); err != nil { - return nil, fmt.Errorf("failed to initialize skills folder: %w", err) + // Initialize skills folder (matching Python init_skills_folder logic) + if err = s.initSkillsFolder(ctx, pfID, tenantID); err != nil { + return nil, fmt.Errorf("failed to initialize skills folder: %w", err) + } } } @@ -48,6 +79,17 @@ func (s *FileService) ListFiles(ctx context.Context, tenantID, pfID string, page if err != nil { return nil, fmt.Errorf("folder not found") } + if folder.Type != FileTypeFolder { + return nil, permission.ErrResourceNotFound + } + if checkFilePermissions { + if err := checkFileAccess(ctx, userID, folder, permission.OperationRead); err != nil { + return nil, err + } + } + if folder.TenantID != tenantID { + return nil, permission.ErrResourceNotFound + } // Get files by parent folder ID excludeSkills := folder.ID == folder.ParentID @@ -61,6 +103,9 @@ func (s *FileService) ListFiles(ctx context.Context, tenantID, pfID string, page if err != nil { return nil, fmt.Errorf("folder not found") } + if parentFolder.TenantID != tenantID { + return nil, permission.ErrResourceNotFound + } // Process files to add additional info, deduplicating by ID as a safety net // against any leftover duplicate rows (e.g. duplicate 'skills' or '.knowledgebase' folders). @@ -220,8 +265,8 @@ func (s *FileService) GetParentFolder(ctx context.Context, userID, fileID string } // Permission check - if !s.checkFilePerm(ctx, s.fileDAO, file, userID) { - return nil, ErrNoAuthorization + if err := checkFileAccess(ctx, userID, file, permission.OperationRead); err != nil { + return nil, err } // Get parent folder @@ -229,21 +274,48 @@ func (s *FileService) GetParentFolder(ctx context.Context, userID, fileID string if err != nil { return nil, err } + if err := checkFileAccess(ctx, userID, parentFolder, permission.OperationRead); err != nil { + return nil, err + } return s.toFileResponse(parentFolder), nil } // GetAllParentFolders gets all parent folders in path with permission check func (s *FileService) GetAllParentFolders(ctx context.Context, userID, fileID string) ([]map[string]interface{}, error) { + return s.getAllParentFolders(ctx, userID, "", fileID, true) +} + +// GetAllParentFoldersForTenant allows active tenant members to resolve file paths. +// The file and every ancestor must belong to tenantID. +func (s *FileService) GetAllParentFoldersForTenant(ctx context.Context, userID, tenantID, fileID string) ([]map[string]interface{}, error) { + if tenantID == "" { + return nil, permission.ErrInvalidSubject + } + checkFilePermissions := userID == tenantID + if !checkFilePermissions { + if err := permission.NewDatabaseChecker(dao.DB).CheckTenant(ctx, permission.Subject{UserID: userID}, tenantID, permission.TenantMember); err != nil { + return nil, err + } + } + return s.getAllParentFolders(ctx, userID, tenantID, fileID, checkFilePermissions) +} + +func (s *FileService) getAllParentFolders(ctx context.Context, userID, tenantID, fileID string, checkFilePermissions bool) ([]map[string]interface{}, error) { // Get file file, err := s.fileDAO.GetByID(ctx, dao.DB, fileID) if err != nil { return nil, err } + if tenantID != "" && file.TenantID != tenantID { + return nil, permission.ErrResourceNotFound + } // Permission check - if !s.checkFilePerm(ctx, s.fileDAO, file, userID) { - return nil, ErrNoAuthorization + if checkFilePermissions { + if err := checkFileAccess(ctx, userID, file, permission.OperationRead); err != nil { + return nil, err + } } // Get all parent folders @@ -251,6 +323,16 @@ func (s *FileService) GetAllParentFolders(ctx context.Context, userID, fileID st if err != nil { return nil, err } + for _, folder := range parentFolders { + if tenantID != "" && folder.TenantID != tenantID { + return nil, permission.ErrResourceNotFound + } + if checkFilePermissions { + if err := checkFileAccess(ctx, userID, folder, permission.OperationRead); err != nil { + return nil, err + } + } + } // Convert to response format result := make([]map[string]interface{}, len(parentFolders)) @@ -305,9 +387,13 @@ func (s *FileService) CreateFolder(ctx context.Context, tenantID, name, parentID parentID = rootFolder.ID } - if !s.fileDAO.IsParentFolderExist(ctx, dao.DB, parentID) { + parentFolder, err := s.fileDAO.GetByID(ctx, dao.DB, parentID) + if err != nil || parentFolder == nil || parentFolder.Type != FileTypeFolder { return nil, fmt.Errorf("parent folder not found") } + if err := checkFileAccess(ctx, tenantID, parentFolder, permission.OperationCreate); err != nil { + return nil, err + } if fileType == "" { fileType = FileTypeVirtual @@ -320,7 +406,6 @@ func (s *FileService) CreateFolder(ctx context.Context, tenantID, name, parentID return s.fileDAO.NameExists(ctx, dao.DB, candidate, parentID, tenantID, "") } var uniqueName string - var err error if fileType == FileTypeFolder { // A folder is not a file: append the counter to the whole name. uniqueName, err = common.UniqueName(name, 255, existsInParent) @@ -345,102 +430,80 @@ func (s *FileService) CreateFolder(ctx context.Context, tenantID, name, parentID // - new_name only: rename in place (no storage operation) // - dest_file_id only: move to new folder (keep names) // - both: move and rename simultaneously -func (s *FileService) MoveFiles(ctx context.Context, uid string, srcFileIDs []string, destFileID string, newName string) (bool, string) { - // 1. Get all source files +func (s *FileService) MoveFiles(ctx context.Context, userID string, srcFileIDs []string, destFileID string, newName string) error { files, err := s.fileDAO.GetByIDs(ctx, dao.DB, srcFileIDs) - if err != nil || len(files) == 0 { - return false, "source files not found" + if err != nil { + return err + } + if len(files) == 0 { + return errors.New("source files not found") + } + filesByID := make(map[string]*entity.File, len(files)) + for _, file := range files { + filesByID[file.ID] = file } - // Create a map for quick lookup - filesMap := make(map[string]*entity.File) - for _, f := range files { - filesMap[f.ID] = f - } - - // 2. Validate all source files for _, fileID := range srcFileIDs { - file, ok := filesMap[fileID] + file, ok := filesByID[fileID] if !ok { - return false, "file or folder not found" + return errors.New("file or folder not found") } if file.TenantID == "" { - return false, "tenant not found" + return errors.New("tenant not found") } - // Permission check - if !s.checkFilePerm(ctx, s.fileDAO, file, uid) { - return false, "no authorization" + if err := s.checkFileTreeAccess(ctx, userID, file, permission.OperationUpdate); err != nil { + return err } } - // 3. Reject selecting an entry together with one of its ancestors: - // moveEntryRecursive relocates and deletes an entire subtree, so a - // descendant selected alongside its ancestor would be processed twice. if ok, msg := s.checkSourcesDisjoint(ctx, files); !ok { - return false, msg + return errors.New(msg) } - // 4. Validate destination folder if provided var destFolder *entity.File if destFileID != "" { destFolder, err = s.fileDAO.GetByID(ctx, dao.DB, destFileID) - if err != nil || destFolder == nil { - return false, "parent folder not found" + if err != nil || destFolder == nil || destFolder.Type != FileTypeFolder { + return errors.New("parent folder not found") } - // Check destination folder permission - if !s.checkFilePerm(ctx, s.fileDAO, destFolder, uid) { - return false, "no authorization to write to destination folder" - } - - if destFolder.Type != FileTypeFolder { - return false, "destination is not a folder" + if err := checkFileAccess(ctx, userID, destFolder, permission.OperationCreate); err != nil { + return err } destAncestors, err := s.fileDAO.GetAllParentFolders(ctx, dao.DB, destFolder.ID) if err != nil { - return false, "parent folder not found" + return errors.New("parent folder not found") } - destAncestorIDs := make(map[string]struct{}, len(destAncestors)) for _, folder := range destAncestors { destAncestorIDs[folder.ID] = struct{}{} } - for _, file := range files { if file.Type != FileTypeFolder { continue } - if file.ID == destFolder.ID { - return false, "cannot move a folder to itself" + return errors.New("cannot move a folder to itself") } - if _, ok := destAncestorIDs[file.ID]; ok { - return false, "cannot move a folder into its own subfolder" + return errors.New("cannot move a folder into its own subfolder") } } } - // 5. Validate new_name if provided if newName != "" { if len(srcFileIDs) > 1 { - return false, "new name can only be used with a single file" + return errors.New("new name can only be used with a single file") } if strings.Contains(newName, "/") { - return false, `Name cannot contain "/"` + return errors.New(`Name cannot contain "/"`) } - file := filesMap[srcFileIDs[0]] - // Check extension for non-folder files - if file.Type != FileTypeFolder { - oldExt := utility.GetFileExtension(file.Name) - newExt := utility.GetFileExtension(newName) - if oldExt != newExt { - return false, "The extension of file can't be changed" - } + file := filesByID[srcFileIDs[0]] + if file.Type != FileTypeFolder && utility.GetFileExtension(file.Name) != utility.GetFileExtension(newName) { + return errors.New("The extension of file can't be changed") } - // Check for duplicate names in the target folder. targetParentID := file.ParentID if destFolder != nil { targetParentID = destFolder.ID @@ -449,68 +512,56 @@ func (s *FileService) MoveFiles(ctx context.Context, uid string, srcFileIDs []st return s.fileDAO.NameExists(ctx, dao.DB, candidate, targetParentID, file.TenantID, file.ID) } if targetParentID == file.ParentID { - // Renaming within the same folder: a case-only change refers to the - // file itself, so the NameAvailable shortcut is safe. available, err := common.NameAvailable(file.Name, newName, existsInTarget) if err != nil { - return false, fmt.Sprintf("failed to query existing files: %v", err) + return fmt.Errorf("failed to query existing files: %w", err) } if !available { - return false, "duplicated file name in the same folder" + return errors.New("duplicated file name in the same folder") } } else { - // Moving into another folder: the source entry is not part of the - // destination namespace, so always check the destination name. taken, err := existsInTarget(newName) if err != nil { - return false, fmt.Sprintf("failed to query existing files: %v", err) + return fmt.Errorf("failed to query existing files: %w", err) } if taken { - return false, "duplicated file name in the same folder" + return errors.New("duplicated file name in the same folder") } } } else if destFolder != nil { - // Plain move (no rename): check for duplicate names in destination folder. for _, file := range files { - var exists bool - exists, err = s.fileDAO.NameExists(ctx, dao.DB, file.Name, destFolder.ID, file.TenantID, file.ID) + exists, err := s.fileDAO.NameExists(ctx, dao.DB, file.Name, destFolder.ID, file.TenantID, file.ID) if err != nil { - return false, fmt.Sprintf("failed to query existing files: %v", err) + return fmt.Errorf("failed to query existing files: %w", err) } if exists { - return false, "Duplicated file name in the same folder." + return errors.New("Duplicated file name in the same folder.") } } } - // 6. Perform the move operation if destFolder != nil { - // Move to destination folder - for _, file := range files { - if err = s.moveEntryRecursive(ctx, file, destFolder, newName); err != nil { - return false, err.Error() + for _, fileID := range srcFileIDs { + if err := s.moveEntryRecursive(ctx, userID, filesByID[fileID], destFolder, newName); err != nil { + return err } } - } else { - // Pure rename: no storage operation needed - if newName == "" { - return false, "new_name is required for rename" - } - if len(srcFileIDs) == 0 { - return false, "Source files not found!" - } - file := filesMap[srcFileIDs[0]] - if err = s.fileDAO.UpdateByID(ctx, dao.DB, file.ID, map[string]interface{}{"name": newName}); err != nil { - return false, "Database error (File rename)!" - } - - // Update names of all linked documents if any exist - if err = s.renameLinkedDocuments(ctx, file.ID, newName); err != nil { - return false, "Database error (Document rename)!" - } + return nil } - - return true, "" + if newName == "" { + return errors.New("new_name is required for rename") + } + if len(srcFileIDs) == 0 { + return errors.New("Source files not found!") + } + file := filesByID[srcFileIDs[0]] + if err := s.fileDAO.UpdateByID(ctx, dao.DB, file.ID, map[string]interface{}{"name": newName}); err != nil { + return errors.New("Database error (File rename)!") + } + if err := s.renameLinkedDocuments(ctx, file.ID, newName); err != nil { + return errors.New("Database error (Document rename)!") + } + return nil } // checkSourcesDisjoint reports false with a message when a selected source @@ -570,7 +621,7 @@ func (s *FileService) renameLinkedDocuments(ctx context.Context, fileID, newName } // moveEntryRecursive recursively moves a file or folder entry -func (s *FileService) moveEntryRecursive(ctx context.Context, sourceFile *entity.File, destFolder *entity.File, overrideName string) error { +func (s *FileService) moveEntryRecursive(ctx context.Context, userID string, sourceFile *entity.File, destFolder *entity.File, overrideName string) error { effectiveName := overrideName if effectiveName == "" { effectiveName = sourceFile.Name @@ -589,6 +640,9 @@ func (s *FileService) moveEntryRecursive(ctx context.Context, sourceFile *entity return fmt.Errorf("cannot move folder into itself") } newFolder = existingFolders[0] + if err := checkFileAccess(ctx, userID, newFolder, permission.OperationCreate); err != nil { + return err + } } else { // Create new folder var err error @@ -604,7 +658,7 @@ func (s *FileService) moveEntryRecursive(ctx context.Context, sourceFile *entity return err } for _, subFile := range subFiles { - if err = s.moveEntryRecursive(ctx, subFile, newFolder, ""); err != nil { + if err = s.moveEntryRecursive(ctx, userID, subFile, newFolder, ""); err != nil { return err } } diff --git a/internal/service/file/file_folder_test.go b/internal/service/file/file_folder_test.go index 8584df9ac0..f56cf90dea 100644 --- a/internal/service/file/file_folder_test.go +++ b/internal/service/file/file_folder_test.go @@ -8,6 +8,7 @@ import ( "ragflow/internal/dao" "ragflow/internal/entity" + "ragflow/internal/permission" "github.com/glebarez/sqlite" "gorm.io/gorm" @@ -84,22 +85,7 @@ func firstCreatedFolderName(t *testing.T, svc *FileService, name, fileType strin } func TestFileService_MoveFiles_RejectsSlashInNewName(t *testing.T) { - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{TranslateError: true}) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - // Keep a single connection so the :memory: database is shared across - // goroutines (sqlite :memory: is otherwise per-connection). - if sqlDB, serr := db.DB(); serr == nil { - sqlDB.SetMaxOpenConns(1) - sqlDB.SetMaxIdleConns(1) - } - if err := db.AutoMigrate(&entity.File{}); err != nil { - t.Fatalf("auto migrate: %v", err) - } - old := dao.DB - dao.DB = db - t.Cleanup(func() { dao.DB = old }) + db := setupFolderTestDB(t) folder := &entity.File{ID: "f1", ParentID: "pf1", TenantID: "tenant1", Name: "old", Type: FileTypeFolder} if err := db.Create(folder).Error; err != nil { @@ -107,9 +93,9 @@ func TestFileService_MoveFiles_RejectsSlashInNewName(t *testing.T) { } svc := testFileService() - ok, msg := svc.MoveFiles(context.Background(), "tenant1", []string{"f1"}, "", "a/b") - if ok || !strings.Contains(msg, `cannot contain "/"`) { - t.Fatalf("MoveFiles rename = %v, %q, want slash validation error", ok, msg) + err := svc.MoveFiles(context.Background(), "tenant1", []string{"f1"}, "", "a/b") + if err == nil || !strings.Contains(err.Error(), `cannot contain "/"`) { + t.Fatalf("MoveFiles rename error = %v, want slash validation error", err) } } @@ -126,14 +112,13 @@ func TestFileService_MoveFilesRejectsDuplicateName(t *testing.T) { svc := testFileService() ctx := context.Background() - ok, msg := svc.MoveFiles(ctx, "user-1", []string{"s1"}, "", "TARGET") - if ok || !strings.Contains(msg, "duplicated file name") { - t.Fatalf("rename to case-variant duplicate = %v, %q, want duplicate error", ok, msg) + err := svc.MoveFiles(ctx, "tenant-1", []string{"s1"}, "", "TARGET") + if err == nil || !strings.Contains(err.Error(), "duplicated file name") { + t.Fatalf("rename to case-variant duplicate error = %v, want duplicate error", err) } - ok, msg = svc.MoveFiles(ctx, "user-1", []string{"s1"}, "", "SOURCE") - if !ok { - t.Fatalf("case-only rename failed: %q", msg) + if err = svc.MoveFiles(ctx, "tenant-1", []string{"s1"}, "", "SOURCE"); err != nil { + t.Fatalf("case-only rename failed: %v", err) } } @@ -154,9 +139,9 @@ func TestFileService_MoveFilesRejectsCaseVariantDuplicateInDestinationFolder(t * } svc := testFileService() - ok, msg := svc.MoveFiles(context.Background(), "user-1", []string{"s1"}, "d1", "REPORT.TXT") - if ok || !strings.Contains(msg, "duplicated file name") { - t.Fatalf("move+case-variant rename = %v, %q, want duplicate error", ok, msg) + err := svc.MoveFiles(context.Background(), "tenant-1", []string{"s1"}, "d1", "REPORT.TXT") + if err == nil || !strings.Contains(err.Error(), "duplicated file name") { + t.Fatalf("move+case-variant rename error = %v, want duplicate error", err) } } @@ -177,9 +162,9 @@ func TestFileService_MoveFilesRejectsCaseVariantDuplicateWithoutRename(t *testin } svc := testFileService() - ok, msg := svc.MoveFiles(context.Background(), "user-1", []string{"s1"}, "d1", "") - if ok || !strings.Contains(msg, "Duplicated file name") { - t.Fatalf("plain move into case-variant duplicate = %v, %q, want duplicate error", ok, msg) + err := svc.MoveFiles(context.Background(), "tenant-1", []string{"s1"}, "d1", "") + if err == nil || !strings.Contains(err.Error(), "Duplicated file name") { + t.Fatalf("plain move into case-variant duplicate error = %v, want duplicate error", err) } } @@ -198,6 +183,8 @@ func setupFolderTestDB(t *testing.T) *gorm.DB { &entity.File{}, &entity.File2Document{}, &entity.Document{}, + &entity.Knowledgebase{}, + &entity.UserTenant{}, ); err != nil { t.Fatalf("failed to migrate: %v", err) } @@ -230,18 +217,113 @@ func TestFileService_AncestryRejectsUnauthorized(t *testing.T) { setupFolderTestDB(t) insertFolderTestFile(t, "file-1", "folder-1", "file.pdf") svc := testFileService() - svc.checkFilePerm = func(context.Context, *dao.FileDAO, *entity.File, string) bool { return false } - for _, call := range []func() error{ func() error { _, err := svc.GetParentFolder(t.Context(), "user-2", "file-1"); return err }, func() error { _, err := svc.GetAllParentFolders(t.Context(), "user-2", "file-1"); return err }, } { - if err := call(); !errors.Is(err, ErrNoAuthorization) { - t.Fatalf("error = %v, want ErrNoAuthorization", err) + if err := call(); !errors.Is(err, permission.ErrPermissionDenied) { + t.Fatalf("error = %v, want permission denied", err) } } } +func TestFileService_GetFileContentRejectsInaccessibleFile(t *testing.T) { + setupFolderTestDB(t) + insertFolderTestFile(t, "file-1", "folder-1", "private.pdf") + + _, err := testFileService().GetFileContent(t.Context(), "user-2", "file-1") + if !errors.Is(err, permission.ErrPermissionDenied) { + t.Fatalf("GetFileContent error = %v, want permission denied", err) + } +} + +func TestFileService_ListFilesRejectsInaccessibleParent(t *testing.T) { + setupFolderTestDB(t) + foreignFolder := &entity.File{ID: "foreign-root", ParentID: "foreign-root", TenantID: "user-2", Name: "/", Type: FileTypeFolder} + if err := dao.DB.Create(foreignFolder).Error; err != nil { + t.Fatalf("seed foreign folder: %v", err) + } + + _, err := testFileService().ListFiles(t.Context(), "user-1", foreignFolder.ID, 1, 15, nil, "") + if !errors.Is(err, permission.ErrPermissionDenied) { + t.Fatalf("ListFiles error = %v, want permission denied", err) + } +} + +func TestFileService_CreateFolderRejectsInaccessibleParent(t *testing.T) { + setupFolderTestDB(t) + foreignFolder := &entity.File{ID: "foreign-root", ParentID: "foreign-root", TenantID: "user-2", Name: "/", Type: FileTypeFolder} + if err := dao.DB.Create(foreignFolder).Error; err != nil { + t.Fatalf("seed foreign folder: %v", err) + } + + _, err := testFileService().CreateFolder(t.Context(), "user-1", "child", foreignFolder.ID, FileTypeFolder) + if !errors.Is(err, permission.ErrPermissionDenied) { + t.Fatalf("CreateFolder error = %v, want permission denied", err) + } + children, err := dao.NewFileDAO().ListByParentID(t.Context(), dao.DB, foreignFolder.ID) + if err != nil { + t.Fatalf("list foreign folder children: %v", err) + } + if len(children) != 0 { + t.Fatalf("created %d children under inaccessible folder, want none", len(children)) + } +} + +func TestFileService_MoveFilesRejectsInaccessibleSource(t *testing.T) { + db := setupFolderTestDB(t) + foreignFile := &entity.File{ID: "foreign-file", ParentID: "foreign-root", TenantID: "user-2", Name: "private.pdf", Type: "pdf"} + if err := db.Create(foreignFile).Error; err != nil { + t.Fatalf("seed foreign file: %v", err) + } + + err := testFileService().MoveFiles(t.Context(), "user-1", []string{foreignFile.ID}, "", "renamed.pdf") + if !errors.Is(err, permission.ErrPermissionDenied) { + t.Fatalf("MoveFiles error = %v, want permission denied", err) + } + stored, err := dao.NewFileDAO().GetByID(t.Context(), db, foreignFile.ID) + if err != nil { + t.Fatalf("get foreign file: %v", err) + } + if stored.Name != "private.pdf" { + t.Fatalf("foreign file name = %q after denied move, want unchanged", stored.Name) + } +} + +func TestFileService_MoveFilesRejectsInaccessibleDestination(t *testing.T) { + db := setupFolderTestDB(t) + insertFolderTestFile(t, "file-1", "root", "report.pdf") + foreignFolder := &entity.File{ID: "foreign-folder", ParentID: "foreign-root", TenantID: "user-2", Name: "private", Type: FileTypeFolder} + if err := db.Create(foreignFolder).Error; err != nil { + t.Fatalf("seed foreign folder: %v", err) + } + + err := testFileService().MoveFiles(t.Context(), "user-1", []string{"file-1"}, foreignFolder.ID, "") + if !errors.Is(err, permission.ErrPermissionDenied) { + t.Fatalf("MoveFiles error = %v, want permission denied", err) + } + stored, err := dao.NewFileDAO().GetByID(t.Context(), db, "file-1") + if err != nil { + t.Fatalf("get source file: %v", err) + } + if stored.ParentID != "root" { + t.Fatalf("source parent = %q after denied move, want root", stored.ParentID) + } +} + +func TestFileService_UploadRejectsInaccessibleParent(t *testing.T) { + db := setupFolderTestDB(t) + foreignFolder := &entity.File{ID: "foreign-folder", ParentID: "foreign-root", TenantID: "user-2", Name: "private", Type: FileTypeFolder} + if err := db.Create(foreignFolder).Error; err != nil { + t.Fatalf("seed foreign folder: %v", err) + } + + _, err := testFileService().UploadFile(t.Context(), "user-1", foreignFolder.ID, nil, 1024) + if !errors.Is(err, permission.ErrPermissionDenied) { + t.Fatalf("UploadFile error = %v, want permission denied", err) + } +} + func insertFolderTestDocument(t *testing.T, id, kbID, name string) { t.Helper() doc := &entity.Document{ @@ -284,9 +366,8 @@ func TestMoveFilesRenameUpdatesAllLinkedDocuments(t *testing.T) { svc := testFileService() ctx := t.Context() - ok, msg := svc.MoveFiles(ctx, "user-1", []string{"file-1"}, "", "new.pdf") - if !ok { - t.Fatalf("MoveFiles failed: %s", msg) + if err := svc.MoveFiles(ctx, "tenant-1", []string{"file-1"}, "", "new.pdf"); err != nil { + t.Fatalf("MoveFiles failed: %v", err) } file, err := dao.NewFileDAO().GetByID(ctx, db, "file-1") @@ -315,23 +396,27 @@ func TestMoveFilesRenameUpdatesAllLinkedDocuments(t *testing.T) { func TestRenameLinkedDocumentsLookupErrorPropagates(t *testing.T) { db := setupFolderTestDB(t) insertFolderTestFile(t, "file-1", "folder-1", "old.pdf") + insertFolderTestDocument(t, "doc-1", "kb-1", "old.pdf") + insertFolderTestFile2Document(t, "f2d-1", "file-1", "doc-1") - // Force the link lookup to fail by dropping its table. - if err := db.Migrator().DropTable(&entity.File2Document{}); err != nil { - t.Fatalf("drop file2document table: %v", err) + // Keep the link lookup available for the permission preflight, but fail the + // linked document update to exercise rename error propagation. + if err := db.Exec(`CREATE TRIGGER fail_document_rename BEFORE UPDATE OF name ON document + BEGIN SELECT RAISE(FAIL, 'rename disabled'); END;`).Error; err != nil { + t.Fatalf("create document rename trigger: %v", err) } svc := testFileService() if err := svc.renameLinkedDocuments(t.Context(), "file-1", "new.pdf"); err == nil { - t.Fatal("renameLinkedDocuments returned nil error on lookup failure") + t.Fatal("renameLinkedDocuments returned nil error on update failure") } - ok, msg := svc.MoveFiles(t.Context(), "user-1", []string{"file-1"}, "", "new.pdf") - if ok { + err := svc.MoveFiles(t.Context(), "tenant-1", []string{"file-1"}, "", "new.pdf") + if err == nil { t.Fatal("MoveFiles succeeded despite file2document lookup failure") } - if !strings.Contains(msg, "Document rename") { - t.Fatalf("MoveFiles message = %q, want it to mention %q", msg, "Document rename") + if !strings.Contains(err.Error(), "Document rename") { + t.Fatalf("MoveFiles error = %v, want it to mention %q", err, "Document rename") } } @@ -359,7 +444,7 @@ func TestMoveEntryRecursiveRenameUpdatesAllLinkedDocuments(t *testing.T) { t.Fatalf("get file: %v", err) } - if err = svc.moveEntryRecursive(ctx, srcFile, destFolder, "new.pdf"); err != nil { + if err = svc.moveEntryRecursive(ctx, "tenant-1", srcFile, destFolder, "new.pdf"); err != nil { t.Fatalf("moveEntryRecursive failed: %v", err) } @@ -429,9 +514,9 @@ func TestMoveFilesRejectsFolderAndSubfolder(t *testing.T) { seedMoveHierarchyDB(t) svc := testFileService() - ok, msg := svc.MoveFiles(context.Background(), "user-1", []string{"A", "B"}, "D", "") - if ok || !strings.Contains(msg, "descendant") { - t.Fatalf("move folder+subfolder = %v, %q, want descendant rejection", ok, msg) + err := svc.MoveFiles(context.Background(), "tenant-1", []string{"A", "B"}, "D", "") + if err == nil || !strings.Contains(err.Error(), "descendant") { + t.Fatalf("move folder+subfolder error = %v, want descendant rejection", err) } requireParent(t, "A", "R") @@ -446,9 +531,9 @@ func TestMoveFilesRejectsFolderAndGrandchild(t *testing.T) { seedMoveHierarchyDB(t) svc := testFileService() - ok, msg := svc.MoveFiles(context.Background(), "user-1", []string{"A", "C"}, "D", "") - if ok || !strings.Contains(msg, "descendant") { - t.Fatalf("move folder+grandchild = %v, %q, want descendant rejection", ok, msg) + err := svc.MoveFiles(context.Background(), "tenant-1", []string{"A", "C"}, "D", "") + if err == nil || !strings.Contains(err.Error(), "descendant") { + t.Fatalf("move folder+grandchild error = %v, want descendant rejection", err) } requireParent(t, "A", "R") @@ -464,9 +549,9 @@ func TestMoveFilesRejectsFolderAndContainedFile(t *testing.T) { insertFolderTestFile(t, "f1", "A", "inside.pdf") svc := testFileService() - ok, msg := svc.MoveFiles(context.Background(), "user-1", []string{"A", "f1"}, "D", "") - if ok || !strings.Contains(msg, "descendant") { - t.Fatalf("move folder+contained file = %v, %q, want descendant rejection", ok, msg) + err := svc.MoveFiles(context.Background(), "tenant-1", []string{"A", "f1"}, "D", "") + if err == nil || !strings.Contains(err.Error(), "descendant") { + t.Fatalf("move folder+contained file error = %v, want descendant rejection", err) } requireParent(t, "A", "R") @@ -482,9 +567,9 @@ func TestMoveFilesRejectsNestedSourcesInEitherOrder(t *testing.T) { svc := testFileService() for _, src := range [][]string{{"A", "B"}, {"B", "A"}} { - ok, msg := svc.MoveFiles(context.Background(), "user-1", src, "D", "") - if ok || !strings.Contains(msg, "descendant") { - t.Fatalf("move %v = %v, %q, want descendant rejection", src, ok, msg) + err := svc.MoveFiles(context.Background(), "tenant-1", src, "D", "") + if err == nil || !strings.Contains(err.Error(), "descendant") { + t.Fatalf("move %v error = %v, want descendant rejection", src, err) } } @@ -510,9 +595,8 @@ func TestMoveFilesAllowsUnrelatedEntries(t *testing.T) { } svc := testFileService() - ok, msg := svc.MoveFiles(context.Background(), "user-1", []string{"A", "X"}, "D", "") - if !ok { - t.Fatalf("move unrelated entries failed: %q", msg) + if err := svc.MoveFiles(context.Background(), "tenant-1", []string{"A", "X"}, "D", ""); err != nil { + t.Fatalf("move unrelated entries failed: %v", err) } if got := countChildren(t, "D"); got != 2 { t.Fatalf("destination children = %d, want 2", got) @@ -527,9 +611,8 @@ func TestMoveFilesSingleFolderWithSubtreeStillMoves(t *testing.T) { seedMoveHierarchyDB(t) svc := testFileService() - ok, msg := svc.MoveFiles(context.Background(), "user-1", []string{"A"}, "D", "") - if !ok { - t.Fatalf("move single folder failed: %q", msg) + if err := svc.MoveFiles(context.Background(), "tenant-1", []string{"A"}, "D", ""); err != nil { + t.Fatalf("move single folder failed: %v", err) } movedA := childByName(t, "D", "A") diff --git a/internal/service/file/file_permission_test.go b/internal/service/file/file_permission_test.go deleted file mode 100644 index 14e48266c2..0000000000 --- a/internal/service/file/file_permission_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package file - -import ( - "testing" - - "github.com/glebarez/sqlite" - "gorm.io/gorm" - - "ragflow/internal/dao" - "ragflow/internal/entity" -) - -func setupPermissionDB(t *testing.T) { - t.Helper() - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{TranslateError: true}) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - if err := db.AutoMigrate( - &entity.File{}, - &entity.File2Document{}, - &entity.Document{}, - &entity.Knowledgebase{}, - ); err != nil { - t.Fatalf("migrate: %v", err) - } - old := dao.DB - dao.DB = db - t.Cleanup(func() { dao.DB = old }) -} - -// checkFileTeamPermissionStub is a minimal permission checker for tests -// that avoids importing the parent service package (which would create a -// cycle in the test binary). -func checkFileTeamPermissionStub(_ *dao.FileDAO, file *entity.File, userID string) bool { - return file.TenantID == userID -} - -func TestCheckFileTeamPermissionStub(t *testing.T) { - setupPermissionDB(t) - - // Direct tenant match short-circuits before any DB lookup. - if !checkFileTeamPermissionStub(nil, &entity.File{TenantID: "u1"}, "u1") { - t.Error("file tenant match should be authorized") - } - - // A file owned by another tenant is denied for a different user. - if checkFileTeamPermissionStub(nil, &entity.File{TenantID: "other"}, "u1") { - t.Error("file with different tenant should be denied") - } -} diff --git a/internal/service/file/file_test.go b/internal/service/file/file_test.go index f7b3394c58..1ce08d36f7 100644 --- a/internal/service/file/file_test.go +++ b/internal/service/file/file_test.go @@ -16,7 +16,6 @@ import ( "unicode/utf8" "ragflow/internal/dao" - "ragflow/internal/entity" "ragflow/internal/storage" "ragflow/internal/utility" ) @@ -34,17 +33,11 @@ type fakeStorage struct { // sptr returns a pointer to the given string. func sptr(s string) *string { return &s } -// testFilePerm controls the permission check returned by testFileService. -// Tests that need to simulate denied access can set it to a function that -// returns false. -var testFilePerm CheckFilePermFunc = func(_ context.Context, _ *dao.FileDAO, _ *entity.File, _ string) bool { return true } - func testFileService() *FileService { return &FileService{ fileDAO: dao.NewFileDAO(), file2DocumentDAO: dao.NewFile2DocumentDAO(), documentService: nil, - checkFilePerm: testFilePerm, } } diff --git a/internal/service/file/file_upload.go b/internal/service/file/file_upload.go index 0d7ba19a57..18b3903c32 100644 --- a/internal/service/file/file_upload.go +++ b/internal/service/file/file_upload.go @@ -9,6 +9,7 @@ import ( "ragflow/internal/common" "ragflow/internal/dao" "ragflow/internal/entity" + "ragflow/internal/permission" "ragflow/internal/storage" "ragflow/internal/utility" "strconv" @@ -40,10 +41,13 @@ func (s *FileService) UploadFile(ctx context.Context, tenantID, parentID string, parentID = rootFolder.ID } - _, err := s.fileDAO.GetByID(ctx, dao.DB, parentID) - if err != nil { + parentFolder, err := s.fileDAO.GetByID(ctx, dao.DB, parentID) + if err != nil || parentFolder == nil || parentFolder.Type != FileTypeFolder { return nil, fmt.Errorf("can't find this folder") } + if err := checkFileAccess(ctx, tenantID, parentFolder, permission.OperationCreate); err != nil { + return nil, err + } maxFileNumPerUser := common.GetEnv(common.EnvMaxFileNumPerUser) if maxFileNumPerUser != "" { diff --git a/internal/service/file_permission.go b/internal/service/file_permission.go deleted file mode 100644 index 5d5fe9257a..0000000000 --- a/internal/service/file_permission.go +++ /dev/null @@ -1,31 +0,0 @@ -package service - -import ( - "context" - "ragflow/internal/dao" - "ragflow/internal/entity" - "ragflow/internal/permission" -) - -// CheckFileTeamPermission reports whether userID may access the given file: either -// the file's owning tenant matches userID, or userID is a team member of any -// dataset the file is linked to. It mirrors Python's check_file_team_permission -// and is shared by FileService and File2DocumentService, which previously each -// carried an identical copy of this logic. -func CheckFileTeamPermission(ctx context.Context, fileDAO *dao.FileDAO, file *entity.File, userID string) bool { - if file.TenantID == userID { - return true - } - - datasetIDs, err := fileDAO.GetDatasetIDByFileID(ctx, dao.DB, file.ID) - if err != nil || len(datasetIDs) == 0 { - return false - } - - for _, datasetID := range datasetIDs { - if CheckDatasetAccess(ctx, permission.Subject{UserID: userID}, datasetID, permission.OperationRead) == nil { - return true - } - } - return false -} diff --git a/internal/service/mcp.go b/internal/service/mcp.go index 8c00128a3e..9d6ce8b905 100644 --- a/internal/service/mcp.go +++ b/internal/service/mcp.go @@ -30,15 +30,16 @@ import ( "ragflow/internal/common" "ragflow/internal/dao" "ragflow/internal/entity" + "ragflow/internal/permission" + permissionresponse "ragflow/internal/permission/response" "ragflow/internal/utility" - - "gorm.io/gorm" ) const ( mcpServerTypeSSE = "sse" mcpServerTypeStreamableHTTP = "streamable-http" mcpServerNameLimit = 255 + mcpServerPreviewID = "preview" defaultMCPFetchTimeoutSec = 10 mcpServerDateFormat = "2006-01-02T15:04:05" maskedMCPSecret = "********" @@ -47,14 +48,12 @@ const ( // MCPService handles MCP server operations. type MCPService struct { mcpServerDAO *dao.MCPServerDAO - tenantDAO *dao.TenantDAO } // NewMCPService creates an MCP service. func NewMCPService() *MCPService { return &MCPService{ mcpServerDAO: dao.NewMCPServerDAO(), - tenantDAO: dao.NewTenantDAO(), } } @@ -104,8 +103,8 @@ type ListMCPServersResponse struct { const maxMCPFetchTimeoutSec = 60 -// CreateMCPServer creates an MCP server owned by a tenant. -func (s *MCPService) CreateMCPServer(ctx context.Context, tenantID string, req CreateMCPServerRequest) (*CreateMCPServerResponse, common.ErrorCode, error) { +// CreateMCPServer creates an MCP server in the caller's owned tenant. +func (s *MCPService) CreateMCPServer(ctx context.Context, userID string, req CreateMCPServerRequest) (*CreateMCPServerResponse, common.ErrorCode, error) { if req.Timeout < 0 || req.Timeout > maxMCPFetchTimeoutSec { return nil, common.CodeDataError, errors.New("invalid timeout") } @@ -116,6 +115,15 @@ func (s *MCPService) CreateMCPServer(ctx context.Context, tenantID string, req C if req.Name == "" || len([]byte(req.Name)) > mcpServerNameLimit { return nil, common.CodeDataError, fmt.Errorf("Invalid MCP name or length is %d which is large than 255.", len([]byte(req.Name))) } + if req.URL == "" { + return nil, common.CodeDataError, errors.New("invalid url") + } + + tenantID, err := requireOwnedTenantID(ctx, userID) + if err != nil { + code, normalized := permissionresponse.Normalize(err) + return nil, code, normalized + } name, err := common.UniqueName(req.Name, mcpServerNameLimit, func(candidate string) (bool, error) { return s.mcpServerDAO.ExistsByNameAndTenant(ctx, dao.DB, candidate, tenantID) @@ -125,14 +133,6 @@ func (s *MCPService) CreateMCPServer(ctx context.Context, tenantID string, req C } req.Name = name - if req.URL == "" { - return nil, common.CodeDataError, errors.New("invalid url") - } - - if _, err = s.tenantDAO.GetByID(ctx, dao.DB, tenantID); err != nil { - return nil, common.CodeDataError, errors.New("tenant not found") - } - headers := safeJSONMap(req.Headers) variables := safeJSONMap(req.Variables) delete(variables, "tools") @@ -354,16 +354,18 @@ func importedMCPHeaders(raw interface{}) (map[string]string, map[string]interfac return headers, values, true } -func (s *MCPService) GetMCPServer(ctx context.Context, tenantID, mcpID string) (*entity.MCPServer, common.ErrorCode, error) { - server, err := s.mcpServerDAO.GetByIDAndTenant(ctx, dao.DB, mcpID, tenantID) +func (s *MCPService) GetMCPServer(ctx context.Context, userID, mcpID string) (*entity.MCPServer, common.ErrorCode, error) { + if err := checkMCPServerPermission(ctx, userID, mcpID, permission.OperationRead); err != nil { + code, normalized := normalizeMCPServerPermissionError(err) + return nil, code, normalized + } + server, err := s.mcpServerDAO.GetByID(ctx, dao.DB, mcpID) if err != nil { - if isMCPServerNotFound(err) { - return nil, common.CodeDataError, mcpServerNotFoundError(mcpID, tenantID) - } return nil, common.CodeServerError, fmt.Errorf("failed to get MCP server %s: %w", mcpID, err) } if server == nil { - return nil, common.CodeDataError, mcpServerNotFoundError(mcpID, tenantID) + code, normalized := normalizeMCPServerPermissionError(permission.ErrResourceNotFound) + return nil, code, normalized } return server, common.CodeSuccess, nil } @@ -376,17 +378,19 @@ func (s *MCPService) ExportMCPServer(ctx context.Context, userID, mcpID string) return newExportMCPServerResponse(server), common.CodeSuccess, nil } -// UpdateMCPServer updates an MCP server owned by a tenant. -func (s *MCPService) UpdateMCPServer(ctx context.Context, tenantID, mcpID string, req UpdateMCPServerRequest) (*entity.MCPServer, common.ErrorCode, error) { - server, err := s.mcpServerDAO.GetByIDAndTenant(ctx, dao.DB, mcpID, tenantID) +// UpdateMCPServer updates an MCP server the caller can manage. +func (s *MCPService) UpdateMCPServer(ctx context.Context, userID, mcpID string, req UpdateMCPServerRequest) (*entity.MCPServer, common.ErrorCode, error) { + if err := checkMCPServerPermission(ctx, userID, mcpID, permission.OperationUpdate); err != nil { + code, normalized := normalizeMCPServerPermissionError(err) + return nil, code, normalized + } + server, err := s.mcpServerDAO.GetByID(ctx, dao.DB, mcpID) if err != nil { - if isMCPServerNotFound(err) { - return nil, common.CodeDataError, mcpServerNotFoundError(mcpID, tenantID) - } return nil, common.CodeServerError, fmt.Errorf("failed to get MCP server %s: %w", mcpID, err) } if server == nil { - return nil, common.CodeDataError, mcpServerNotFoundError(mcpID, tenantID) + code, normalized := normalizeMCPServerPermissionError(permission.ErrResourceNotFound) + return nil, code, normalized } serverType := server.ServerType @@ -414,7 +418,7 @@ func (s *MCPService) UpdateMCPServer(ctx context.Context, tenantID, mcpID string } if serverNameProvided { available, err := common.NameAvailable(server.Name, serverName, func(candidate string) (bool, error) { - return s.mcpServerDAO.ExistsByNameAndTenant(ctx, dao.DB, candidate, tenantID) + return s.mcpServerDAO.ExistsByNameAndTenant(ctx, dao.DB, candidate, server.TenantID) }) if err != nil { return nil, common.CodeServerError, err @@ -476,7 +480,7 @@ func (s *MCPService) UpdateMCPServer(ctx context.Context, tenantID, mcpID string updates := map[string]interface{}{ "id": mcpID, - "tenant_id": tenantID, + "tenant_id": server.TenantID, "headers": headers, "variables": variables, } @@ -497,19 +501,17 @@ func (s *MCPService) UpdateMCPServer(ctx context.Context, tenantID, mcpID string updates["description"] = description } - if _, err = s.mcpServerDAO.UpdateMCPServer(ctx, dao.DB, mcpID, tenantID, updates); err != nil { + if _, err = s.mcpServerDAO.UpdateMCPServer(ctx, dao.DB, mcpID, server.TenantID, updates); err != nil { return nil, common.CodeServerError, err } - updatedServer, err := s.mcpServerDAO.GetByIDAndTenant(ctx, dao.DB, mcpID, tenantID) + updatedServer, err := s.mcpServerDAO.GetByID(ctx, dao.DB, mcpID) if err != nil { - if isMCPServerNotFound(err) { - return nil, common.CodeDataError, mcpServerNotFoundError(mcpID, tenantID) - } return nil, common.CodeServerError, fmt.Errorf("failed to fetch updated MCP server %s: %w", mcpID, err) } if updatedServer == nil { - return nil, common.CodeDataError, mcpServerNotFoundError(mcpID, tenantID) + code, normalized := normalizeMCPServerPermissionError(permission.ErrResourceNotFound) + return nil, code, normalized } return updatedServer, common.CodeSuccess, nil } @@ -524,12 +526,24 @@ func headerOrVariablesChanged(req UpdateMCPServerRequest) bool { return false } -func isMCPServerNotFound(err error) bool { - return errors.Is(err, gorm.ErrRecordNotFound) -} +// ListMCPServers lists MCP servers belonging to a tenant the caller can use. +func (s *MCPService) ListMCPServers(ctx context.Context, userID, requestedTenantID string, ids []string, keywords string, page, pageSize int, orderby string, desc bool) (*ListMCPServersResponse, common.ErrorCode, error) { + tenantID := strings.TrimSpace(requestedTenantID) + if tenantID == "" { + var err error + tenantID, err = requireOwnedTenantID(ctx, userID) + if errors.Is(err, permission.ErrMembershipNotFound) { + return &ListMCPServersResponse{MCPServers: []*MCPServerListItem{}}, common.CodeSuccess, nil + } + if err != nil { + code, normalized := normalizeMCPServerPermissionError(err) + return nil, code, normalized + } + } else if err := permission.NewDatabaseChecker(dao.DB).CheckTenant(ctx, permission.Subject{UserID: userID}, tenantID, permission.TenantMember); err != nil { + code, normalized := normalizeMCPServerPermissionError(err) + return nil, code, normalized + } -// ListMCPServers lists MCP servers owned by a tenant. -func (s *MCPService) ListMCPServers(ctx context.Context, tenantID string, ids []string, keywords string, page, pageSize int, orderby string, desc bool) (*ListMCPServersResponse, common.ErrorCode, error) { servers, total, err := s.mcpServerDAO.ListMCPServers(ctx, dao.DB, tenantID, ids, keywords, orderby, desc) if err != nil { var orderbyErr *dao.InvalidMCPServerOrderByError @@ -567,31 +581,32 @@ func (s *MCPService) ListMCPServers(ctx context.Context, tenantID string, ids [] }, common.CodeSuccess, nil } -// DeleteMCPServer deletes an MCP server owned by a tenant. -func (s *MCPService) DeleteMCPServer(ctx context.Context, tenantID, mcpID string) (bool, common.ErrorCode, error) { +// DeleteMCPServer deletes an MCP server the caller can manage. +func (s *MCPService) DeleteMCPServer(ctx context.Context, userID, mcpID string) (bool, common.ErrorCode, error) { + if err := checkMCPServerPermission(ctx, userID, mcpID, permission.OperationDelete); err != nil { + code, normalized := normalizeMCPServerPermissionError(err) + return false, code, normalized + } server, err := s.mcpServerDAO.GetByID(ctx, dao.DB, mcpID) if err != nil { return false, common.CodeServerError, fmt.Errorf("failed to get MCP server %s: %w", mcpID, err) } - if server == nil || server.TenantID != tenantID { - return false, common.CodeDataError, mcpServerNotFoundError(mcpID, tenantID) + if server == nil { + code, normalized := normalizeMCPServerPermissionError(permission.ErrResourceNotFound) + return false, code, normalized } - deleted, err := s.mcpServerDAO.DeleteMCPServer(ctx, dao.DB, mcpID, tenantID) + deleted, err := s.mcpServerDAO.DeleteMCPServer(ctx, dao.DB, mcpID, server.TenantID) if err != nil { return false, common.CodeServerError, err } if !deleted { - return false, common.CodeDataError, mcpServerNotFoundError(mcpID, tenantID) + code, normalized := normalizeMCPServerPermissionError(permission.ErrResourceNotFound) + return false, code, normalized } return true, common.CodeSuccess, nil } - -func mcpServerNotFoundError(mcpID, tenantID string) error { - return fmt.Errorf("cannot find MCP server %s for user %s", mcpID, tenantID) -} - func isValidMCPServerType(serverType string) bool { return serverType == mcpServerTypeSSE || serverType == mcpServerTypeStreamableHTTP } @@ -666,7 +681,12 @@ type ImportResult struct { } // ImportServers bulk-imports MCP servers from a {"mcpServers": {name: config}} map. -func (s *MCPService) ImportServers(ctx context.Context, tenantID string, servers map[string]map[string]interface{}, timeoutSeconds float64) ([]ImportResult, error) { +func (s *MCPService) ImportServers(ctx context.Context, userID string, servers map[string]map[string]interface{}, timeoutSeconds float64) ([]ImportResult, error) { + tenantID, err := requireOwnedTenantID(ctx, userID) + if err != nil { + _, normalized := permissionresponse.Normalize(err) + return nil, normalized + } if timeoutSeconds <= 0 { timeoutSeconds = defaultMCPFetchTimeoutSec } @@ -794,22 +814,31 @@ type TestServerRequest struct { } // TestServer opens a live MCP session and returns the tools the server advertises. -func (s *MCPService) TestServer(ctx context.Context, tenantID, mcpID string, req *TestServerRequest) ([]map[string]interface{}, error) { +func (s *MCPService) TestServer(ctx context.Context, userID, mcpID string, req *TestServerRequest) ([]map[string]interface{}, error) { if req == nil || req.URL == "" { return nil, fmt.Errorf("%w: Invalid MCP url", ErrMCPInvalidURL) } if !isValidMCPServerType(req.ServerType) { return nil, ErrMCPInvalidType } + if mcpID != mcpServerPreviewID { + if err := checkMCPServerPermission(ctx, userID, mcpID, permission.OperationUpdate); err != nil { + _, normalized := normalizeMCPServerPermissionError(err) + return nil, normalized + } + } hasMask := req.Variables["authorization_token"] == maskedMCPSecret for _, value := range req.Headers { hasMask = hasMask || value == maskedMCPSecret } if hasMask { - server, _, err := s.GetMCPServer(ctx, tenantID, mcpID) + server, err := s.mcpServerDAO.GetByID(ctx, dao.DB, mcpID) if err != nil { return nil, err } + if server == nil { + return nil, permission.ErrResourceNotFound + } preview := *req preview.Variables = maps.Clone(req.Variables) preview.Headers = maps.Clone(req.Headers) diff --git a/internal/service/mcp_permission.go b/internal/service/mcp_permission.go new file mode 100644 index 0000000000..7dbc2d7919 --- /dev/null +++ b/internal/service/mcp_permission.go @@ -0,0 +1,38 @@ +// +// 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 checkMCPServerPermission(ctx context.Context, userID, mcpID string, operation permission.Operation) error { + return permission.NewDatabaseChecker(dao.DB).CheckResource( + ctx, + permission.Subject{UserID: userID}, + permission.ResourceRef{Kind: permission.ResourceKindMCPServer, ID: mcpID}, + operation, + ) +} + +func normalizeMCPServerPermissionError(err error) (common.ErrorCode, error) { + return permissionresponse.NormalizeHidden(err) +} diff --git a/internal/service/mcp_test.go b/internal/service/mcp_test.go index 51735253af..c00f203380 100644 --- a/internal/service/mcp_test.go +++ b/internal/service/mcp_test.go @@ -28,8 +28,10 @@ import ( "testing" "time" + "gorm.io/gorm" "ragflow/internal/common" "ragflow/internal/entity" + "ragflow/internal/permission" "ragflow/internal/utility" ) @@ -62,6 +64,7 @@ func TestUpdateMCPServerRejectsDuplicatedName(t *testing.T) { t.Fatalf("create mcp server: %v", err) } } + addMCPOwnerMembership(t, testDB, tenantID, tenantID) s := NewMCPService() ctx := t.Context() @@ -115,7 +118,13 @@ func TestServerInputValidation(t *testing.T) { } func TestImportServersValidationErrors(t *testing.T) { - s := &MCPService{} + db := setupServiceTestDB(t) + if err := db.AutoMigrate(&entity.MCPServer{}); err != nil { + t.Fatalf("migrate MCP server: %v", err) + } + pushServiceDB(t, db) + addMCPOwnerMembership(t, db, "tenant-1", "tenant-1") + s := NewMCPService() // Missing url and type produce an in-band error per entry rather than // failing the batch. @@ -207,6 +216,7 @@ func TestMCPManagedTokenLifecycle(t *testing.T) { if err := db.Create(&entity.Tenant{ID: "tenant-1"}).Error; err != nil { t.Fatal(err) } + addMCPOwnerMembership(t, db, "tenant-1", "tenant-1") server, captured := newMCPDiscoveryTestServer(t) defer server.Close() withMCPDiscoveryOverrides(t, server) @@ -225,7 +235,7 @@ func TestMCPManagedTokenLifecycle(t *testing.T) { if created.Headers["Authorization"] != maskedMCPSecret { t.Fatal("create response exposed headers") } - listed, _, err := s.ListMCPServers(t.Context(), "tenant-1", nil, "", 0, 0, "create_time", false) + listed, _, err := s.ListMCPServers(t.Context(), "tenant-1", "", nil, "", 0, 0, "create_time", false) if err != nil || len(listed.MCPServers) != 1 || listed.MCPServers[0].Variables["authorization_token"] != maskedMCPSecret { t.Fatalf("list: result=%#v err=%v", listed, err) } @@ -301,6 +311,63 @@ func TestMCPManagedTokenLifecycle(t *testing.T) { } } +func TestMCPServerPermissionsUsePolicyAcrossServiceOperations(t *testing.T) { + db := setupServiceTestDB(t) + if err := db.AutoMigrate(&entity.MCPServer{}); err != nil { + t.Fatalf("migrate MCP server: %v", err) + } + pushServiceDB(t, db) + addMCPOwnerMembership(t, db, "owner", "team") + active := string(entity.StatusValid) + if err := db.Create(&entity.UserTenant{ + ID: "mcp-member-membership", UserID: "member", TenantID: "team", + Role: string(permission.RoleNormal), InvitedBy: "owner", Status: &active, + }).Error; err != nil { + t.Fatalf("create MCP member membership: %v", err) + } + if err := db.Create(&entity.MCPServer{ + ID: "team-server", Name: "shared", TenantID: "team", URL: "https://example.com/mcp", + ServerType: mcpServerTypeStreamableHTTP, + }).Error; err != nil { + t.Fatalf("create MCP server: %v", err) + } + + server, captured := newMCPDiscoveryTestServer(t) + defer server.Close() + withMCPDiscoveryOverrides(t, server) + service := NewMCPService() + ctx := t.Context() + + if _, code, err := service.GetMCPServer(ctx, "member", "team-server"); err == nil || code != common.CodeNotFound || !errors.Is(err, permission.ErrPermissionDenied) { + t.Fatalf("member direct read should be hidden: code=%d err=%v", code, err) + } + listed, code, err := service.ListMCPServers(ctx, "member", "team", nil, "", 0, 0, "create_time", false) + if err != nil || code != common.CodeSuccess || len(listed.MCPServers) != 1 { + t.Fatalf("member list: response=%#v code=%d err=%v", listed, code, err) + } + + name, _ := json.Marshal("renamed") + if _, code, err := service.UpdateMCPServer(ctx, "member", "team-server", UpdateMCPServerRequest{"name": name}); err == nil || code != common.CodeNotFound || !errors.Is(err, permission.ErrPermissionDenied) { + t.Fatalf("member update should be hidden: code=%d err=%v", code, err) + } + if _, code, err := service.DeleteMCPServer(ctx, "member", "team-server"); err == nil || code != common.CodeNotFound || !errors.Is(err, permission.ErrPermissionDenied) { + t.Fatalf("member delete should be hidden: code=%d err=%v", code, err) + } + if _, err := service.TestServer(ctx, "member", "team-server", &TestServerRequest{ + URL: "https://example.com/mcp", ServerType: mcpServerTypeStreamableHTTP, + }); err == nil || !errors.Is(err, permission.ErrPermissionDenied) { + t.Fatalf("member connection test should require update permission: %v", err) + } + if _, code, err := service.CreateMCPServer(ctx, "member", CreateMCPServerRequest{ + Name: "new", URL: server.URL, ServerType: mcpServerTypeStreamableHTTP, + }); err == nil || code != common.CodeForbidden { + t.Fatalf("member create should require an owned tenant: code=%d err=%v", code, err) + } + if len(captured()) != 0 { + t.Fatalf("denied connection/config operations made outbound requests: %d", len(captured())) + } +} + func TestImportServersPreservesExplicitHeadersAndVariables(t *testing.T) { for _, tc := range []struct { name string @@ -332,6 +399,7 @@ func TestImportServersPreservesExplicitHeadersAndVariables(t *testing.T) { t.Fatalf("migrate mcp server: %v", err) } pushServiceDB(t, testDB) + addMCPOwnerMembership(t, testDB, "tenant-1", "tenant-1") srv, captured := newMCPDiscoveryTestServer(t) defer srv.Close() @@ -531,6 +599,21 @@ func TestPaginateMCPServersPositiveValues(t *testing.T) { } } +func addMCPOwnerMembership(t *testing.T, db *gorm.DB, userID, tenantID string) { + t.Helper() + active := string(entity.StatusValid) + if err := db.Create(&entity.UserTenant{ + ID: "mcp-owner-" + tenantID, + UserID: userID, + TenantID: tenantID, + Role: "owner", + InvitedBy: userID, + Status: &active, + }).Error; err != nil { + t.Fatalf("create MCP owner membership: %v", err) + } +} + func makeMCPServers(count int) []*entity.MCPServer { servers := make([]*entity.MCPServer, 0, count) for i := 1; i <= count; i++ { diff --git a/internal/service/memory.go b/internal/service/memory.go index 9d555ae237..7406012347 100644 --- a/internal/service/memory.go +++ b/internal/service/memory.go @@ -37,6 +37,7 @@ import ( "ragflow/internal/dao" "ragflow/internal/engine" enginetypes "ragflow/internal/engine/types" + "ragflow/internal/permission" "ragflow/internal/service/nlp" ) @@ -379,16 +380,14 @@ type MemoryFiltersResponse struct { } func (s *MemoryService) ListMemoryFilters(ctx context.Context, userID string) (*MemoryFiltersResponse, error) { - userTenants, err := NewUserTenantService().GetUserTenantRelationByUserIDWithContext(ctx, userID) + memoryIDs, err := accessibleMemoryIDs(ctx, userID, permission.OperationRead) if err != nil { - return nil, fmt.Errorf("failed to get user tenants: %w", err) + return nil, err } - tenantIDs := make([]string, 0, len(userTenants)+1) - tenantIDs = append(tenantIDs, userID) - for _, tenant := range userTenants { - tenantIDs = append(tenantIDs, tenant.TenantID) + if len(memoryIDs) == 0 { + return &MemoryFiltersResponse{}, nil } - memories, _, err := s.memoryDAO.GetByFilter(ctx, dao.DB, userID, tenantIDs, nil, "", "", 1, 0) + memories, _, err := s.memoryDAO.GetByFilter(ctx, dao.DB, memoryIDs, nil, nil, "", "", 1, 0) if err != nil { return nil, err } @@ -459,26 +458,26 @@ func filterOptionsInOrder(counts map[string]int64, canonicalOrder []string) []Me // // req := &CreateMemoryRequest{Name: "MyMemory", MemoryType: []string{"semantic"}, EmbdID: "embd1", LLMID: "llm1"} // resp, err := service.CreateMemory("tenant123", req) -func (s *MemoryService) CreateMemory(ctx context.Context, tenantID string, req *CreateMemoryRequest) (*CreateMemoryResponse, error) { +func (s *MemoryService) CreateMemory(ctx context.Context, userID string, req *CreateMemoryRequest) (*CreateMemoryResponse, error) { // Resolve tenant model IDs, mirroring Python's ensure_tenant_model_ids_for_params. // Resolution failure is non-fatal (e.g. Builtin models that have no // tenant_model row) — we leave the tenant_*_id fields nil and proceed. modelFactory := NewModelFactory() if req.LLMID != "" && req.TenantLLMID == nil { - info, err := modelFactory.ResolveInfo(ctx, ModelAccess{TenantID: tenantID}, entity.ModelTypeChat, req.LLMID) + info, err := modelFactory.ResolveInfo(ctx, ModelAccess{TenantID: userID}, entity.ModelTypeChat, req.LLMID) if err != nil { common.Warn("CreateMemory: failed to resolve tenant LLM id", - zap.String("tenant_id", tenantID), zap.String("llm_id", req.LLMID), zap.Error(err)) + zap.String("tenant_id", userID), zap.String("llm_id", req.LLMID), zap.Error(err)) } else if info != nil && info.ID != "" { tenantLLMID := info.ID req.TenantLLMID = &tenantLLMID } } if req.EmbdID != "" && req.TenantEmbdID == nil { - info, err := modelFactory.ResolveInfo(ctx, ModelAccess{TenantID: tenantID}, entity.ModelTypeEmbedding, req.EmbdID) + info, err := modelFactory.ResolveInfo(ctx, ModelAccess{TenantID: userID}, entity.ModelTypeEmbedding, req.EmbdID) if err != nil { common.Warn("CreateMemory: failed to resolve tenant embedding id", - zap.String("tenant_id", tenantID), zap.String("embd_id", req.EmbdID), zap.Error(err)) + zap.String("tenant_id", userID), zap.String("embd_id", req.EmbdID), zap.Error(err)) } else if info != nil && info.ID != "" { tenantEmbdID := info.ID req.TenantEmbdID = &tenantEmbdID @@ -509,7 +508,7 @@ func (s *MemoryService) CreateMemory(ctx context.Context, tenantID string, req * } memoryName, err := common.UniqueName(memoryName, MemoryNameLimit, func(candidate string) (bool, error) { - existing, err := s.memoryDAO.GetByNameAndTenant(ctx, dao.DB, candidate, tenantID) + existing, err := s.memoryDAO.GetByNameAndTenant(ctx, dao.DB, candidate, userID) if err != nil { return false, err } @@ -527,7 +526,7 @@ func (s *MemoryService) CreateMemory(ctx context.Context, tenantID string, req * memory := &entity.Memory{ ID: newID, Name: memoryName, - TenantID: tenantID, + TenantID: userID, MemoryType: memoryTypeInt, StorageType: "table", EmbdID: req.EmbdID, @@ -574,9 +573,9 @@ func (s *MemoryService) CreateMemory(ctx context.Context, tenantID string, req * // // req := &UpdateMemoryRequest{Name: ptr("NewName"), MemorySize: ptr(int64(1000000))} // resp, err := service.UpdateMemory("tenant123", "memory456", req) -func (s *MemoryService) UpdateMemory(ctx context.Context, tenantID string, memoryID string, req *UpdateMemoryRequest) (*CreateMemoryResponse, error) { +func (s *MemoryService) UpdateMemory(ctx context.Context, userID string, memoryID string, req *UpdateMemoryRequest) (*CreateMemoryResponse, error) { updateDict := make(map[string]interface{}) - if ok, err := s.memoryDAO.Accessible(ctx, dao.DB, tenantID, memoryID); !ok || err != nil { + if err := checkMemoryPermission(ctx, userID, memoryID, permission.OperationUpdate); err != nil { return nil, err } @@ -609,12 +608,14 @@ func (s *MemoryService) UpdateMemory(ctx context.Context, tenantID string, memor if req.Permissions != nil { perm := TenantPermission(strings.ToLower(strings.TrimSpace(*req.Permissions))) - if currentMemory.TenantID != tenantID && strings.ToLower(strings.TrimSpace(currentMemory.Permissions)) != string(perm) { - return nil, fmt.Errorf("tenant '%s' is not allowed to modify the memory's permission", tenantID) - } if !validPermissions[perm] { return nil, fmt.Errorf("unknown permission '%s'", *req.Permissions) } + if strings.ToLower(strings.TrimSpace(currentMemory.Permissions)) != string(perm) { + if err := checkMemoryPermission(ctx, userID, memoryID, permission.OperationShare); err != nil { + return nil, err + } + } updateDict["permissions"] = perm } @@ -921,8 +922,8 @@ func sameStringSet(a, b []string) bool { // // err := service.DeleteMemory(ctx, "user123", "memory456") func (s *MemoryService) DeleteMemory(ctx context.Context, userID, memoryID string) error { - // Verify the caller has access to this memory - memory, err := s.requireMemoryAccess(ctx, userID, memoryID) + // Verify the caller can delete this memory before removing its stored data. + memory, err := s.memoryWithAccess(ctx, userID, memoryID, permission.OperationDelete) if err != nil { return err } @@ -946,7 +947,7 @@ func (s *MemoryService) DeleteMemory(ctx context.Context, userID, memoryID strin // This mirrors Python memory_api_service.forget_message and keeps the message // record for retention/cleanup policies instead of deleting it immediately. func (s *MemoryService) ForgetMessage(ctx context.Context, userID string, memoryID string, messageID int64) error { - memory, err := s.requireMemoryAccess(ctx, userID, memoryID) + memory, err := s.memoryWithAccess(ctx, userID, memoryID, permission.OperationUpdate) if err != nil { return err } @@ -1006,7 +1007,7 @@ func (s *MemoryService) AddMessage(ctx context.Context, currentUserID string, me } missingMemoryIDs := missingRequestedMemoryIDs(requestedMemoryIDs, accessibleMemoryIDs) - res, err := NewMemoryMessageService(s).QueueSaveToMemoryTask(ctx, accessibleMemoryIDs, msg) + res, err := NewMemoryMessageService(s).queueSaveToMemoryTask(ctx, accessibleMemoryIDs, msg) if err != nil { return false, err.Error(), err } @@ -1024,8 +1025,21 @@ func (s *MemoryService) AddMessage(ctx context.Context, currentUserID string, me return true, "All add to task.", nil } -func (s *MemoryService) saveAgentMessage(ctx context.Context, memoryIDs []string, msg MemoryMessage) (bool, string, error) { - res, err := NewMemoryMessageService(s).QueueSaveToMemoryTask(ctx, splitFilterValues(memoryIDs), msg) +func (s *MemoryService) saveAgentMessage(ctx context.Context, userID string, memoryIDs []string, msg MemoryMessage) (bool, string, error) { + memories, err := s.filterAccessibleMemories(ctx, userID, memoryIDs) + if err != nil { + return false, err.Error(), err + } + if len(memories) == 0 { + return false, "Memory not found.", nil + } + accessibleIDs := make([]string, 0, len(memories)) + for _, memory := range memories { + if memory != nil { + accessibleIDs = append(accessibleIDs, memory.ID) + } + } + res, err := NewMemoryMessageService(s).queueSaveToMemoryTask(ctx, accessibleIDs, msg) if err != nil { return false, err.Error(), err } @@ -1084,7 +1098,7 @@ func memorySaveErrorMessage(res *QueueSaveResult) string { } func (s *MemoryService) UpdateMessageStatus(ctx context.Context, userID, memoryID string, messageID int64, status bool) (bool, error) { - memory, err := s.requireMemoryAccess(ctx, userID, memoryID) + memory, err := s.memoryWithAccess(ctx, userID, memoryID, permission.OperationUpdate) if err != nil { return false, err } @@ -1120,7 +1134,7 @@ func (s *MemoryService) UpdateMessage(ctx context.Context, userID, memoryID stri } func (s *MemoryService) GetMessageContent(ctx context.Context, userID, memoryID string, messageID int64) (map[string]interface{}, error) { - memory, err := s.requireMemoryAccess(ctx, userID, memoryID) + memory, err := s.memoryWithAccess(ctx, userID, memoryID, permission.OperationRead) if err != nil { return nil, err } @@ -1290,51 +1304,14 @@ func (s *MemoryService) filterAccessibleMemories(ctx context.Context, userID str return []*entity.Memory{}, nil } - memories, err := s.memoryDAO.GetByIDs(ctx, dao.DB, memoryIDs) + accessibleIDs, err := filterMemoryIDs(ctx, userID, memoryIDs, permission.OperationUse) if err != nil { return nil, err } - if len(memories) == 0 { + if len(accessibleIDs) == 0 { return []*entity.Memory{}, nil } - - joinedTenantIDs := map[string]struct{}{userID: {}} - needsTeamLookup := false - for _, memory := range memories { - if memory != nil && memory.TenantID != userID && memory.Permissions == string(TenantPermissionTeam) { - needsTeamLookup = true - break - } - } - if needsTeamLookup { - userTenants, err := NewUserTenantService().GetUserTenantRelationByUserIDWithContext(ctx, userID) - if err != nil { - return nil, err - } - for _, tenant := range userTenants { - if tenant != nil && tenant.TenantID != "" { - joinedTenantIDs[tenant.TenantID] = struct{}{} - } - } - } - - accessible := make([]*entity.Memory, 0, len(memories)) - for _, memory := range memories { - if memory == nil { - continue - } - if memory.TenantID == userID { - accessible = append(accessible, memory) - continue - } - if memory.Permissions != string(TenantPermissionTeam) { - continue - } - if _, ok := joinedTenantIDs[memory.TenantID]; ok { - accessible = append(accessible, memory) - } - } - return accessible, nil + return s.memoryDAO.GetByIDs(ctx, dao.DB, accessibleIDs) } func splitFilterValues(values interface{}) []string { @@ -1605,47 +1582,29 @@ func isMessageDocumentNotFound(err error) bool { return errors.Is(err, enginetypes.ErrDocumentNotFound) } -func (s *MemoryService) requireMemoryAccess(ctx context.Context, userID string, memoryID string) (*entity.Memory, error) { +func (s *MemoryService) memoryWithAccess(ctx context.Context, userID, memoryID string, operation permission.Operation) (*entity.Memory, error) { if err := ctx.Err(); err != nil { return nil, err } + if err := checkMemoryPermission(ctx, userID, memoryID, operation); err != nil { + return nil, err + } memory, err := s.memoryDAO.GetByIDWithContext(ctx, dao.DB, memoryID) if err != nil { if dao.IsNotFoundErr(err) { - return nil, &ResourceNotFoundError{Resource: "Memory", ID: memoryID} + return nil, permission.ErrResourceNotFound } return nil, fmt.Errorf("failed to get memory '%s': %w", memoryID, err) } - if memory.TenantID == userID { - return memory, nil - } - if memory.Permissions != string(TenantPermissionTeam) { - return nil, &ResourceNotFoundError{Resource: "Memory", ID: memoryID} - } - - if err := ctx.Err(); err != nil { - return nil, err - } - userTenantService := NewUserTenantService() - userTenants, err := userTenantService.GetUserTenantRelationByUserIDWithContext(ctx, userID) - if err != nil { - return nil, err - } - for _, tenant := range userTenants { - if tenant.TenantID == memory.TenantID { - return memory, nil - } - } - - return nil, &ResourceNotFoundError{Resource: "Memory", ID: memoryID} + return memory, nil } -// ListMemories retrieves a paginated list of memories with optional filters -// When tenantIDs is empty, it retrieves all tenants associated with the user +// ListMemories retrieves a paginated list of memories visible to the caller, +// then applies optional tenant and business filters. // // Parameters: -// - userID: The user ID for tenant filtering when tenantIDs is empty -// - tenantIDs: Array of tenant IDs to filter by (empty means all user's tenants) +// - userID: The authenticated user requesting the memories +// - tenantIDs: Optional tenant IDs to filter the caller's authorized memories 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) @@ -1659,43 +1618,16 @@ func (s *MemoryService) requireMemoryAccess(ctx context.Context, userID string, // Example: // // resp, err := service.ListMemories("user123", []string{}, []string{"semantic"}, "table", "test", 1, 10) -func (s *MemoryService) ListMemories(ctx context.Context, userID string, tenantIDs []string, memoryTypes []string, storageType string, keywords string, page int, pageSize int) (*ListMemoryResponse, error) { - // The tenant filter may only name tenants the caller belongs to: Python's - // list_memory intersects the requested ids with the caller's joined - // tenants and returns an empty page when nothing survives. Without the - // clamp a caller could list another tenant's team-shared memories by - // passing its id in the tenant_id query parameter. - userTenantService := NewUserTenantService() - userTenants, err := userTenantService.GetUserTenantRelationByUserIDWithContext(ctx, userID) +func (s *MemoryService) ListMemories(ctx context.Context, userID string, tenantIDs []string, memoryTypes []string, storageType string, keywords string, page int, pageSize int, canvasID string) (*ListMemoryResponse, error) { + memoryIDs, err := accessibleMemoryIDsForCanvas(ctx, userID, canvasID) if err != nil { - return nil, fmt.Errorf("failed to get user tenants: %w", err) + return nil, err } - joinedIDs := make([]string, 0, len(userTenants)+1) - joinedIDs = append(joinedIDs, userID) - for _, tenant := range userTenants { - joinedIDs = append(joinedIDs, tenant.TenantID) + if len(memoryIDs) == 0 { + return &ListMemoryResponse{MemoryList: []map[string]interface{}{}}, nil } - if len(tenantIDs) == 0 { - tenantIDs = joinedIDs - } else { - joined := make(map[string]struct{}, len(joinedIDs)) - for _, id := range joinedIDs { - joined[id] = struct{}{} - } - allowed := make([]string, 0, len(tenantIDs)) - for _, id := range tenantIDs { - if _, ok := joined[id]; ok { - allowed = append(allowed, id) - } - } - if len(allowed) == 0 { - return &ListMemoryResponse{MemoryList: []map[string]interface{}{}}, nil - } - tenantIDs = allowed - } - - memories, total, err := s.memoryDAO.GetByFilter(ctx, dao.DB, userID, tenantIDs, memoryTypes, storageType, keywords, page, pageSize) + memories, total, err := s.memoryDAO.GetByFilter(ctx, dao.DB, memoryIDs, tenantIDs, memoryTypes, storageType, keywords, page, pageSize) if err != nil { return nil, err } @@ -1783,7 +1715,7 @@ func ResolveTenantModelDisplayName(ctx context.Context, db *gorm.DB, tenantModel // // resp, err := service.GetMemoryConfig(ctx, "user123", "memory456") func (s *MemoryService) GetMemoryConfig(ctx context.Context, userID, memoryID string) (*CreateMemoryResponse, error) { - if _, err := s.requireMemoryAccess(ctx, userID, memoryID); err != nil { + if _, err := s.memoryWithAccess(ctx, userID, memoryID, permission.OperationRead); err != nil { return nil, err } return s.getMemoryConfig(ctx, memoryID) @@ -1800,7 +1732,7 @@ func (s *MemoryService) getMemoryConfig(ctx context.Context, memoryID string) (* } func (s *MemoryService) GetMemoryMessages(ctx context.Context, userID, memoryID string, agentIDs []string, keywords string, page int, pageSize int) (map[string]interface{}, error) { - memory, err := s.requireMemoryAccess(ctx, userID, memoryID) + memory, err := s.memoryWithAccess(ctx, userID, memoryID, permission.OperationRead) if err != nil { return nil, err } diff --git a/internal/service/memory_extractor.go b/internal/service/memory_extractor.go index 3f01fd51e1..e072b6367c 100644 --- a/internal/service/memory_extractor.go +++ b/internal/service/memory_extractor.go @@ -22,7 +22,7 @@ // api/db/joint_services/memory_message_service.py: // handle_save_to_memory_task / save_extracted_to_memory_only / extract_by_llm // -// QueueSaveToMemoryTask persists the raw message and publishes a +// queueSaveToMemoryTask persists the raw message and publishes a // task_type="memory" TaskMessage on the NATS tasks.RAGFLOW subject. The // Ingestor's shared consumer + worker pool dispatches it by TaskType to // HandleSaveToMemoryTask (see internal/ingestion/service/handleAndExecute and diff --git a/internal/service/memory_message_service.go b/internal/service/memory_message_service.go index 84542e23d9..b79593a3ca 100644 --- a/internal/service/memory_message_service.go +++ b/internal/service/memory_message_service.go @@ -34,7 +34,7 @@ // Failed []MemoryFailure // } // -// func (s *MemoryMessageService) QueueSaveToMemoryTask( +// func (s *MemoryMessageService) queueSaveToMemoryTask( // ctx context.Context, // memoryIDs []string, // msg MemoryMessage, @@ -68,7 +68,7 @@ import ( "gorm.io/gorm" ) -// MemoryMessage is the wire shape for QueueSaveToMemoryTask. It +// MemoryMessage is the message payload used to queue memory persistence. It // mirrors the Python `message_dict` built in // agent/component/message.py:_save_to_memory: // @@ -123,8 +123,8 @@ func NewMemoryMessageService(memories *MemoryService) *MemoryMessageService { } } -// QueueSaveToMemoryTask runs the memory-persistence flow for the -// supplied memory_ids + message. See package comment for the +// queueSaveToMemoryTask runs the memory-persistence flow for the +// permission-filtered memory IDs and message. See the package comment for the // step-by-step contract. The function is synchronous — the Python // async version awaits `embed_and_save` and Redis calls; this Go port does the // same work synchronously from the HTTP request path. @@ -133,7 +133,7 @@ func NewMemoryMessageService(memories *MemoryService) *MemoryMessageService { // the per-memory outcomes. The outer error is reserved for // call-level failures (e.g. invalid input); per-memory failures // go into Failed, mirroring the Python tuple shape. -func (s *MemoryMessageService) QueueSaveToMemoryTask(ctx context.Context, memoryIDs []string, msg MemoryMessage) (*QueueSaveResult, error) { +func (s *MemoryMessageService) queueSaveToMemoryTask(ctx context.Context, memoryIDs []string, msg MemoryMessage) (*QueueSaveResult, error) { if len(memoryIDs) == 0 { return &QueueSaveResult{}, nil } diff --git a/internal/service/memory_message_service_test.go b/internal/service/memory_message_service_test.go index aec23997cc..7932489d21 100644 --- a/internal/service/memory_message_service_test.go +++ b/internal/service/memory_message_service_test.go @@ -34,7 +34,7 @@ import ( // a clear error rather than panicking. func TestQueueSaveToMemoryTask_NilService(t *testing.T) { var s *MemoryMessageService - _, err := s.QueueSaveToMemoryTask(t.Context(), []string{"m1"}, MemoryMessage{AgentID: "a1"}) + _, err := s.queueSaveToMemoryTask(t.Context(), []string{"m1"}, MemoryMessage{AgentID: "a1"}) if err == nil { t.Fatal("expected error from nil service") } @@ -47,7 +47,7 @@ func TestQueueSaveToMemoryTask_NilService(t *testing.T) { // short-circuits to an empty result with no error. func TestQueueSaveToMemoryTask_EmptyMemoryList(t *testing.T) { s := &MemoryMessageService{memories: nil} // no lookups happen - res, err := s.QueueSaveToMemoryTask(t.Context(), nil, MemoryMessage{AgentID: "a1"}) + res, err := s.queueSaveToMemoryTask(t.Context(), nil, MemoryMessage{AgentID: "a1"}) if err != nil { t.Fatalf("QueueSaveToMemoryTask: %v", err) } @@ -61,7 +61,7 @@ func TestQueueSaveToMemoryTask_EmptyMemoryList(t *testing.T) { // up front. func TestQueueSaveToMemoryTask_MissingAgentID(t *testing.T) { s := &MemoryMessageService{} - _, err := s.QueueSaveToMemoryTask(t.Context(), []string{"m1"}, MemoryMessage{}) + _, err := s.queueSaveToMemoryTask(t.Context(), []string{"m1"}, MemoryMessage{}) if err == nil { t.Fatal("expected error for missing AgentID") } @@ -78,7 +78,7 @@ func TestQueueSaveToMemoryTaskClassifiesMemoryLookupErrors(t *testing.T) { cleanup := testutil.ReplaceDBForTest(t, db) defer cleanup() - res, err := NewMemoryMessageService(NewMemoryService()).QueueSaveToMemoryTask( + res, err := NewMemoryMessageService(NewMemoryService()).queueSaveToMemoryTask( t.Context(), []string{"missing-memory"}, MemoryMessage{AgentID: "agent-1"}, ) if err != nil { @@ -94,7 +94,7 @@ func TestQueueSaveToMemoryTaskClassifiesMemoryLookupErrors(t *testing.T) { cleanup := testutil.ReplaceDBForTest(t, db) defer cleanup() - res, err := NewMemoryMessageService(NewMemoryService()).QueueSaveToMemoryTask( + res, err := NewMemoryMessageService(NewMemoryService()).queueSaveToMemoryTask( t.Context(), []string{"memory-1"}, MemoryMessage{AgentID: "agent-1"}, ) if err != nil { @@ -131,7 +131,7 @@ func TestQueueSaveToMemoryTaskMarksDurableTaskFailedWhenRawStorageFails(t *testi publisher := &recordingTaskPublisher{} svc.taskPublisher = publisher - res, err := svc.QueueSaveToMemoryTask(t.Context(), []string{"memory-1"}, MemoryMessage{AgentID: "agent-1"}) + res, err := svc.queueSaveToMemoryTask(t.Context(), []string{"memory-1"}, MemoryMessage{AgentID: "agent-1"}) if err != nil { t.Fatalf("QueueSaveToMemoryTask: %v", err) } diff --git a/internal/service/memory_message_test.go b/internal/service/memory_message_test.go index 502b7a7c6a..8f2090191f 100644 --- a/internal/service/memory_message_test.go +++ b/internal/service/memory_message_test.go @@ -20,6 +20,7 @@ import ( "ragflow/internal/engine" enginetypes "ragflow/internal/engine/types" "ragflow/internal/entity" + "ragflow/internal/permission" ) func TestIsMessageDocumentNotFound(t *testing.T) { @@ -115,13 +116,13 @@ func TestValidateMemorySearchModels(t *testing.T) { } } -func TestRequireMemoryAccessReturnsCanceledContext(t *testing.T) { +func TestMemoryWithAccessReturnsCanceledContext(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() err := ctx.Err() - if _, gotErr := NewMemoryService().requireMemoryAccess(ctx, "user-1", "memory-1"); !errors.Is(gotErr, err) { - t.Fatalf("requireMemoryAccess error = %v, want %v", gotErr, err) + if _, gotErr := NewMemoryService().memoryWithAccess(ctx, "user-1", "memory-1", permission.OperationRead); !errors.Is(gotErr, err) { + t.Fatalf("memoryWithAccess error = %v, want %v", gotErr, err) } } @@ -571,7 +572,7 @@ func TestListMemoriesUsesTenantModelIDForDisplayName(t *testing.T) { } ctx := t.Context() - resp, err := NewMemoryService().ListMemories(ctx, "user-1", []string{"user-1"}, nil, "", "", 1, 10) + resp, err := NewMemoryService().ListMemories(ctx, "user-1", []string{"user-1"}, nil, "", "", 1, 10, "") if err != nil { t.Fatalf("ListMemories: %v", err) } @@ -608,7 +609,7 @@ func TestListMemoriesFallsBackToRawModelIDWithoutTenantModelID(t *testing.T) { } ctx := t.Context() - resp, err := NewMemoryService().ListMemories(ctx, "user-1", []string{"user-1"}, nil, "", "", 1, 10) + resp, err := NewMemoryService().ListMemories(ctx, "user-1", []string{"user-1"}, nil, "", "", 1, 10, "") if err != nil { t.Fatalf("ListMemories: %v", err) } @@ -654,7 +655,7 @@ func TestListMemoriesRejectsUnjoinedTenantFilter(t *testing.T) { t.Fatalf("seed memory: %v", err) } - resp, err := NewMemoryService().ListMemories(t.Context(), "user-1", []string{"tenant-2"}, nil, "", "", 1, 10) + resp, err := NewMemoryService().ListMemories(t.Context(), "user-1", []string{"tenant-2"}, nil, "", "", 1, 10, "") if err != nil { t.Fatalf("ListMemories: %v", err) } @@ -717,7 +718,7 @@ func TestListMemoriesKeepsJoinedTenantFilter(t *testing.T) { ctx := t.Context() - resp, err := NewMemoryService().ListMemories(ctx, "user-1", []string{"tenant-2"}, nil, "", "", 1, 10) + resp, err := NewMemoryService().ListMemories(ctx, "user-1", []string{"tenant-2"}, nil, "", "", 1, 10, "") if err != nil { t.Fatalf("ListMemories: %v", err) } @@ -725,7 +726,7 @@ func TestListMemoriesKeepsJoinedTenantFilter(t *testing.T) { t.Fatalf("joined tenant filter must return its team memory, got total=%d list=%v", resp.TotalCount, resp.MemoryList) } - resp, err = NewMemoryService().ListMemories(ctx, "user-1", []string{"tenant-2", "tenant-3"}, nil, "", "", 1, 10) + resp, err = NewMemoryService().ListMemories(ctx, "user-1", []string{"tenant-2", "tenant-3"}, nil, "", "", 1, 10, "") if err != nil { t.Fatalf("ListMemories: %v", err) } @@ -734,6 +735,53 @@ func TestListMemoriesKeepsJoinedTenantFilter(t *testing.T) { } } +func TestListMemoriesForCanvasUsesCallerOwnerIntersection(t *testing.T) { + setupMemoryMessageTestDB(t) + if err := dao.DB.AutoMigrate(&entity.UserCanvas{}); err != nil { + t.Fatalf("migrate canvas table: %v", err) + } + if err := dao.DB.Create(&[]*entity.User{ + {ID: "canvas-owner", Nickname: "Canvas owner"}, + {ID: "caller", Nickname: "Caller"}, + }).Error; err != nil { + t.Fatalf("seed users: %v", err) + } + status := "1" + for _, membership := range []*entity.UserTenant{ + {ID: "owner-membership", UserID: "canvas-owner", TenantID: "canvas-owner", Role: string(permission.RoleOwner), InvitedBy: "canvas-owner", Status: &status}, + {ID: "caller-membership", UserID: "caller", TenantID: "canvas-owner", Role: string(permission.RoleNormal), InvitedBy: "canvas-owner", Status: &status}, + } { + if err := dao.DB.Create(membership).Error; err != nil { + t.Fatalf("seed membership: %v", err) + } + } + canvas := entity.UserCanvas{ + ID: "canvas-1", + UserID: "canvas-owner", + Permission: string(TenantPermissionTeam), + } + if err := dao.DB.Create(&canvas).Error; err != nil { + t.Fatalf("seed canvas: %v", err) + } + for _, memory := range []*entity.Memory{ + {ID: "memory-shared", Name: "Shared", TenantID: "canvas-owner", EmbdID: "embd", LLMID: "llm", Permissions: string(TenantPermissionTeam)}, + {ID: "memory-caller-only", Name: "Caller only", TenantID: "caller", EmbdID: "embd", LLMID: "llm", Permissions: string(TenantPermissionMe)}, + {ID: "memory-owner-only", Name: "Owner only", TenantID: "canvas-owner", EmbdID: "embd", LLMID: "llm", Permissions: string(TenantPermissionMe)}, + } { + if err := dao.DB.Create(memory).Error; err != nil { + t.Fatalf("seed memory %s: %v", memory.ID, err) + } + } + + response, err := NewMemoryService().ListMemories(t.Context(), "caller", nil, nil, "", "", 1, 30, canvas.ID) + if err != nil { + t.Fatalf("ListMemories(canvas): %v", err) + } + if response.TotalCount != 1 || len(response.MemoryList) != 1 || response.MemoryList[0]["id"] != "memory-shared" { + t.Fatalf("canvas memory list = %#v (total=%d), want only memory-shared", response.MemoryList, response.TotalCount) + } +} + func seedMemoryMessages(t *testing.T) { t.Helper() @@ -768,7 +816,7 @@ func seedMemoryMessages(t *testing.T) { } } -func TestSaveAgentMessageBypassesRequestAccessFilter(t *testing.T) { +func TestSaveAgentMessageChecksMemoryAccess(t *testing.T) { setupMemoryMessageTestDB(t) if err := dao.DB.Create(&entity.Memory{ @@ -794,25 +842,27 @@ func TestSaveAgentMessageBypassesRequestAccessFilter(t *testing.T) { } ok, detail, err := svc.AddMessage(t.Context(), "", []string{"mem-owned"}, msg) - if err != nil { - t.Fatalf("AddMessage: %v", err) - } - if ok || detail != "Memory not found." { - t.Fatalf("AddMessage with empty current user = (%v, %q), want permission-filtered not found", ok, detail) + if ok || !errors.Is(err, permission.ErrUnauthenticated) { + t.Fatalf("AddMessage with empty current user = (%v, %q, %v), want unauthenticated error", ok, detail, err) } - ok, detail, err = svc.saveAgentMessage(t.Context(), []string{"mem-owned"}, msg) + ok, detail, err = svc.saveAgentMessage(t.Context(), "user-2", []string{"mem-owned"}, msg) if err != nil { - t.Fatalf("saveAgentMessage: %v", err) + t.Fatalf("saveAgentMessage as outsider: %v", err) + } + if ok || detail != "Memory not found." { + t.Fatalf("saveAgentMessage as outsider = (%v, %q), want hidden memory not found", ok, detail) + } + + ok, detail, err = svc.saveAgentMessage(t.Context(), "user-1", []string{"mem-owned"}, msg) + if err != nil { + t.Fatalf("saveAgentMessage as owner: %v", err) } if ok { - t.Fatal("saveAgentMessage unexpectedly succeeded without a message store") - } - if strings.Contains(detail, "Memory not found") { - t.Fatalf("saveAgentMessage was filtered by request user: %q", detail) + t.Fatal("saveAgentMessage as owner unexpectedly succeeded without a message store") } if !strings.Contains(detail, "message store is not initialized") { - t.Fatalf("saveAgentMessage detail = %q, want message-store failure after memory lookup", detail) + t.Fatalf("saveAgentMessage as owner detail = %q, want message-store failure after memory lookup", detail) } } diff --git a/internal/service/memory_permission.go b/internal/service/memory_permission.go new file mode 100644 index 0000000000..d5b434cb9a --- /dev/null +++ b/internal/service/memory_permission.go @@ -0,0 +1,89 @@ +// +// 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" + "strings" + + "ragflow/internal/dao" + "ragflow/internal/permission" +) + +func checkMemoryPermission(ctx context.Context, userID, memoryID string, operation permission.Operation) error { + return permission.NewDatabaseChecker(dao.DB).CheckResource( + ctx, + permission.Subject{UserID: userID}, + permission.ResourceRef{Kind: permission.ResourceKindMemory, ID: memoryID}, + operation, + ) +} + +func accessibleMemoryIDs(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.ResourceKindMemory, + Operation: operation, + }) + if err != nil { + return nil, err + } + if scope.Mode != permission.ScopeIDs { + return []string{}, nil + } + return scope.ResourceIDs, nil +} + +func accessibleMemoryIDsForCanvas(ctx context.Context, userID, canvasID string) ([]string, error) { + canvasID = strings.TrimSpace(canvasID) + if canvasID == "" { + return accessibleMemoryIDs(ctx, userID, permission.OperationRead) + } + scope, err := permission.NewDatabaseChecker(dao.DB).Scope(ctx, permission.Subject{UserID: userID}, permission.ScopeQuery{ + Kind: permission.ResourceKindMemory, + Operation: permission.OperationUse, + Entry: &permission.ResourceRef{Kind: permission.ResourceKindCanvas, ID: canvasID}, + EntryOp: permission.OperationRun, + }) + if err != nil { + return nil, err + } + if scope.Mode != permission.ScopeIDs { + return []string{}, nil + } + return scope.ResourceIDs, nil +} + +func filterMemoryIDs(ctx context.Context, userID string, memoryIDs []string, operation permission.Operation) ([]string, error) { + refs := make([]permission.ResourceRef, 0, len(memoryIDs)) + for _, memoryID := range memoryIDs { + refs = append(refs, permission.ResourceRef{Kind: permission.ResourceKindMemory, ID: memoryID}) + } + accessible, err := permission.NewDatabaseChecker(dao.DB).FilterResources( + ctx, + permission.Subject{UserID: userID}, + refs, + operation, + ) + if err != nil { + return nil, err + } + ids := make([]string, 0, len(accessible)) + for _, ref := range accessible { + ids = append(ids, ref.ID) + } + return ids, nil +} diff --git a/internal/service/memory_saver_adapter.go b/internal/service/memory_saver_adapter.go index 9db849e272..2ad803ce0e 100644 --- a/internal/service/memory_saver_adapter.go +++ b/internal/service/memory_saver_adapter.go @@ -19,8 +19,12 @@ package service import ( "context" "fmt" + "strings" "ragflow/internal/agent/component" + "ragflow/internal/agent/runtime" + "ragflow/internal/dao" + "ragflow/internal/permission" ) // memorySaverAdapter bridges the component.MemorySaver interface to the @@ -37,13 +41,43 @@ func NewMemorySaverAdapter(svc *MemoryService) component.MemorySaver { return &memorySaverAdapter{svc: svc} } -// Save implements component.MemorySaver. Agent message components already run -// inside an authorized canvas, so this path queues the selected memories -// directly instead of applying REST request access filtering. +// Save implements component.MemorySaver. It checks the runtime user against +// each configured memory before queueing the message. func (a *memorySaverAdapter) Save(ctx context.Context, req component.MemorySaveRequest) error { if a == nil || a.svc == nil { return fmt.Errorf("memory: saver adapter not initialised") } + authorizationUserID := req.UserID + if len(req.MemoryIDs) > 0 { + state, stateErr := runtime.GetStateFromContext(ctx) + if stateErr != nil || state == nil { + return fmt.Errorf("%w: canvas runtime state is required to use memory from an agent", permission.ErrInvalidPermission) + } + canvasID, _ := state.Sys["canvas_id"].(string) + userID, _ := state.Sys["user_id"].(string) + canvasID = strings.TrimSpace(canvasID) + userID = strings.TrimSpace(userID) + authorizationUserID = userID + if canvasID == "" { + return fmt.Errorf("%w: canvas id is required to use memory from an agent", permission.ErrInvalidPermission) + } + if userID == "" { + return permission.ErrUnauthenticated + } + checker := permission.NewDatabaseChecker(dao.DB) + entry := permission.ResourceRef{Kind: permission.ResourceKindCanvas, ID: canvasID} + for _, memoryID := range req.MemoryIDs { + memoryID = strings.TrimSpace(memoryID) + if memoryID == "" { + continue + } + if err := checker.CheckDependency(ctx, permission.Subject{UserID: userID}, entry, + permission.ResourceRef{Kind: permission.ResourceKindMemory, ID: memoryID}, + permission.OperationRun, permission.OperationUse); err != nil { + return fmt.Errorf("memory %q: %w", memoryID, err) + } + } + } msg := MemoryMessage{ UserID: req.UserID, AgentID: req.AgentID, @@ -51,7 +85,7 @@ func (a *memorySaverAdapter) Save(ctx context.Context, req component.MemorySaveR UserInput: req.UserInput, AgentResponse: req.AgentResponse, } - ok, detail, err := a.svc.saveAgentMessage(ctx, req.MemoryIDs, msg) + ok, detail, err := a.svc.saveAgentMessage(ctx, authorizationUserID, req.MemoryIDs, msg) if err != nil { return err } diff --git a/internal/service/memory_saver_adapter_test.go b/internal/service/memory_saver_adapter_test.go new file mode 100644 index 0000000000..f80a1d42d0 --- /dev/null +++ b/internal/service/memory_saver_adapter_test.go @@ -0,0 +1,84 @@ +// +// 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/agent/component" + "ragflow/internal/agent/runtime" + "ragflow/internal/dao" + "ragflow/internal/entity" + "ragflow/internal/permission" +) + +func TestMemorySaverAuthorizesWithAuthenticatedCanvasCaller(t *testing.T) { + setupMemoryMessageTestDB(t) + if err := dao.DB.AutoMigrate(&entity.UserCanvas{}); err != nil { + t.Fatalf("migrate canvas table: %v", err) + } + status := string(entity.StatusValid) + canvas := entity.UserCanvas{ + ID: "canvas-1", + UserID: "canvas-owner", + Permission: string(entity.TenantPermissionTeam), + } + if err := dao.DB.Create(&canvas).Error; err != nil { + t.Fatalf("seed canvas: %v", err) + } + for _, membership := range []*entity.UserTenant{ + {ID: "canvas-owner-membership", UserID: "canvas-owner", TenantID: "canvas-owner", Role: string(permission.RoleOwner), Status: &status}, + {ID: "caller-canvas-membership", UserID: "caller", TenantID: "canvas-owner", Role: string(permission.RoleNormal), Status: &status}, + {ID: "memory-owner-membership", UserID: "canvas-owner", TenantID: "memory-tenant", Role: string(permission.RoleNormal), Status: &status}, + {ID: "memory-tenant-owner-membership", UserID: "memory-tenant", TenantID: "memory-tenant", Role: string(permission.RoleOwner), Status: &status}, + } { + if err := dao.DB.Create(membership).Error; err != nil { + t.Fatalf("seed membership: %v", err) + } + } + memory := entity.Memory{ + ID: "memory-1", + Name: "Shared memory", + TenantID: "memory-tenant", + StorageType: "table", + EmbdID: "embedding", + LLMID: "chat", + Permissions: string(entity.TenantPermissionTeam), + ForgettingPolicy: string(ForgettingPolicyFIFO), + } + if err := dao.DB.Create(&memory).Error; err != nil { + t.Fatalf("seed memory: %v", err) + } + + state := runtime.NewCanvasState("run-1", "session-1") + state.Sys["canvas_id"] = canvas.ID + state.Sys["user_id"] = "caller" + ctx := runtime.WithState(t.Context(), state) + + // req.UserID is the memory-message partition key and must not be trusted as + // the permission subject. The canvas owner can access memory-1, but caller + // cannot, so using req.UserID here would incorrectly authorize this write. + err := NewMemorySaverAdapter(NewMemoryService()).Save(ctx, component.MemorySaveRequest{ + MemoryIDs: []string{memory.ID}, + UserID: "canvas-owner", + AgentID: canvas.ID, + }) + if !errors.Is(err, permission.ErrPermissionDenied) { + t.Fatalf("Save error = %v, want permission denied for authenticated caller", err) + } +} diff --git a/internal/service/model_call.go b/internal/service/model_call.go index c3e0c960ff..3c707b6c17 100644 --- a/internal/service/model_call.go +++ b/internal/service/model_call.go @@ -378,20 +378,7 @@ func populateModelUsage(usage *common.ModelUsage, info *modelModule.ModelInfo, a } func (s *ModelCallService) ownerTenantID(ctx context.Context, userID string) (string, error) { - if strings.TrimSpace(userID) == "" { - return "", errors.New("user id is required") - } - if s.providerService.userTenantDAO == nil { - return "", errors.New("user tenant service is not initialized") - } - tenants, err := s.providerService.userTenantDAO.GetByUserIDAndRole(ctx, dao.DB, userID, "owner") - if err != nil { - return "", err - } - if len(tenants) == 0 || tenants[0] == nil || strings.TrimSpace(tenants[0].TenantID) == "" { - return "", fmt.Errorf("no owner tenant found for user %s", userID) - } - return tenants[0].TenantID, nil + return requireOwnedTenantID(ctx, userID) } func (s *ModelCallService) resolveModelInfo(ctx context.Context, modelRef, userID string) (*ModelInstanceAndProviderInfo, error) { diff --git a/internal/service/model_factory.go b/internal/service/model_factory.go index d631d9472e..325b87faf1 100644 --- a/internal/service/model_factory.go +++ b/internal/service/model_factory.go @@ -29,6 +29,7 @@ import ( "ragflow/internal/dao" "ragflow/internal/entity" modelModule "ragflow/internal/entity/models" + "ragflow/internal/permission" ) // modelTarget is the private result of resolving a model reference. Callers @@ -62,7 +63,6 @@ type ModelFactory struct { modelProviderDAO *dao.TenantModelProviderDAO modelInstanceDAO *dao.TenantModelInstanceDAO modelDAO *dao.TenantModelDAO - userTenantDAO *dao.UserTenantDAO } // NewModelFactory creates a factory backed by the standard model DAOs. @@ -72,7 +72,6 @@ func NewModelFactory() *ModelFactory { modelProviderDAO: dao.NewTenantModelProviderDAO(), modelInstanceDAO: dao.NewTenantModelInstanceDAO(), modelDAO: dao.NewTenantModelDAO(), - userTenantDAO: dao.NewUserTenantDAO(), } } @@ -436,28 +435,6 @@ func maxOutputFromModelInfo(modelInfo *modelModule.Model) int { return *modelInfo.MaxOutput } -// tenantCanReachProviderTenant reports whether userID owns the provider's tenant -// or is a joined member of it. Mirrors Python's tenant_model_service -// get_model_config_by_id tenant check (:342-347). -func (s *ModelFactory) tenantCanReachProviderTenant(ctx context.Context, userID, ownerTenantID string) (bool, error) { - if userID == ownerTenantID { - return true, nil - } - if s == nil || s.userTenantDAO == nil { - return false, fmt.Errorf("%w: user tenant DAO is not initialized", errModelConfigUnavailable) - } - userTenants, err := s.userTenantDAO.GetByUserID(ctx, dao.DB, userID) - if err != nil { - return false, err - } - for _, rel := range userTenants { - if rel != nil && rel.TenantID == ownerTenantID { - return true, nil - } - } - return false, nil -} - func (f *ModelFactory) resolveConfig(ctx context.Context, access ModelAccess, modelType entity.ModelType, modelRef string) (*modelTarget, error) { access = normalizeModelAccess(access) if err := f.authorize(ctx, access); err != nil { @@ -497,14 +474,8 @@ func (f *ModelFactory) authorize(ctx context.Context, access ModelAccess) error if access.UserID == "" || access.UserID == access.TenantID { return nil } - if f.userTenantDAO == nil { - return fmt.Errorf("%w: user tenant DAO is not initialized", errModelConfigUnavailable) - } - if _, err := f.userTenantDAO.FilterByUserIDAndTenantID(ctx, dao.DB, access.UserID, access.TenantID); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fmt.Errorf("%w: user %s has no access to tenant %s", errModelConfigUnavailable, access.UserID, access.TenantID) - } - return fmt.Errorf("check model access: %w", err) + if err := permission.NewDatabaseChecker(dao.DB).CheckTenant(ctx, permission.Subject{UserID: access.UserID}, access.TenantID, permission.TenantMember); err != nil { + return fmt.Errorf("%w: user %s has no access to tenant %s: %w", errModelConfigUnavailable, access.UserID, access.TenantID, err) } return nil } @@ -586,7 +557,7 @@ func (s *ModelFactory) ResolveModelType(ctx context.Context, access ModelAccess, return nil, err } - modelType, err := s.resolveCompositeModelType(ctx, access.TenantID, modelRef) + modelType, err := s.resolveCompositeModelType(ctx, access, modelRef) if err != nil { return nil, err } @@ -687,15 +658,14 @@ func (s *ModelFactory) modelIdentity(ctx context.Context, tenantID, userID strin if providerEntity == nil { return nil, fmt.Errorf("%w: provider id=%s not found for model %q", errModelConfigUnavailable, modelEntity.ProviderID, modelRef) } - allowed := providerEntity.TenantID == tenantID - if !allowed && userID != "" { - var err error - allowed, err = s.tenantCanReachProviderTenant(ctx, userID, providerEntity.TenantID) - if err != nil { - return nil, err + if userID != "" { + if err := checkModelPermission(ctx, userID, permission.ResourceKindModelProvider, providerEntity.ID, permission.OperationRead); err != nil { + return nil, fmt.Errorf("%w: user %s cannot read model provider %q: %w", errModelConfigUnavailable, userID, providerEntity.ProviderName, err) } - } - if !allowed { + if err := checkModelPermission(ctx, userID, permission.ResourceKindModel, modelEntity.ID, permission.OperationUse); err != nil { + return nil, fmt.Errorf("%w: user %s cannot use model %q: %w", errModelConfigUnavailable, userID, modelRef, err) + } + } else if providerEntity.TenantID != tenantID { return nil, fmt.Errorf( "%w: tenant %s has no access to provider owned by tenant %s", errModelConfigUnavailable, @@ -713,6 +683,11 @@ func (s *ModelFactory) modelIdentity(ctx context.Context, tenantID, userID strin if instanceEntity == nil || instanceEntity.ProviderID != providerEntity.ID { return nil, fmt.Errorf("%w: instance id=%s does not belong to provider id=%s", errModelConfigUnavailable, modelEntity.InstanceID, providerEntity.ID) } + if userID != "" { + if err := checkModelPermission(ctx, userID, permission.ResourceKindModelInstance, instanceEntity.ID, permission.OperationUse); err != nil { + return nil, fmt.Errorf("%w: user %s cannot use model instance %q: %w", errModelConfigUnavailable, userID, instanceEntity.InstanceName, err) + } + } if !strings.EqualFold(instanceEntity.Status, "active") { return nil, fmt.Errorf("%w: model instance %q is disabled", errModelConfigUnavailable, instanceEntity.InstanceName) } @@ -735,7 +710,7 @@ func (s *ModelFactory) resolveModel(ctx context.Context, access ModelAccess, mod // explicitly: the underlying "provider name missing in model name: // " reads like a naming-format mistake and sends the operator to // look at the model's name instead of at the row that no longer exists. - composite, compositeErr := s.resolveProviderInstanceModel(ctx, access.TenantID, modelType, modelRef) + composite, compositeErr := s.resolveProviderInstanceModel(ctx, access, modelType, modelRef) if compositeErr != nil && !strings.Contains(modelRef, "@") { return nil, fmt.Errorf("model %q is neither a tenant model id (no tenant_model row) nor a valid composite name — the reference is dangling: %w", modelRef, compositeErr) } @@ -797,7 +772,7 @@ func (s *ModelFactory) resolveTenantModel(ctx context.Context, modelType entity. }, nil } -func (s *ModelFactory) resolveProviderInstanceModel(ctx context.Context, tenantID string, modelType entity.ModelType, modelRef string) (*modelTarget, error) { +func (s *ModelFactory) resolveProviderInstanceModel(ctx context.Context, access ModelAccess, modelType entity.ModelType, modelRef string) (*modelTarget, error) { // TEI builtin embedding short-circuit. if modelType == entity.ModelTypeEmbedding && strings.Contains(common.GetEnv(common.EnvComposeProfiles), "tei-") { teiModel := common.GetEnv(common.EnvTEIModel) @@ -847,7 +822,7 @@ func (s *ModelFactory) resolveProviderInstanceModel(ctx context.Context, tenantI } } - provider, err := s.modelProviderDAO.GetByTenantIDAndProviderName(ctx, dao.DB, tenantID, providerName) + provider, err := s.modelProviderDAO.GetByTenantIDAndProviderName(ctx, dao.DB, access.TenantID, providerName) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fmt.Errorf("%w: provider %q lookup failed: %w", errModelConfigUnavailable, providerName, err) @@ -857,6 +832,11 @@ func (s *ModelFactory) resolveProviderInstanceModel(ctx context.Context, tenantI if provider == nil { return nil, fmt.Errorf("%w: provider %q not found for model %q", errModelConfigUnavailable, providerName, modelRef) } + if access.UserID != "" { + if err = checkModelPermission(ctx, access.UserID, permission.ResourceKindModelProvider, provider.ID, permission.OperationRead); err != nil { + return nil, fmt.Errorf("%w: user %s cannot read model provider %q: %w", errModelConfigUnavailable, access.UserID, providerName, err) + } + } instance, err := s.modelInstanceDAO.GetByProviderIDAndInstanceName(ctx, dao.DB, provider.ID, instanceName) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -870,6 +850,11 @@ func (s *ModelFactory) resolveProviderInstanceModel(ctx context.Context, tenantI if !strings.EqualFold(instance.Status, "active") { return nil, fmt.Errorf("%w: model instance %q is disabled", errModelConfigUnavailable, instanceName) } + if access.UserID != "" { + if err = checkModelPermission(ctx, access.UserID, permission.ResourceKindModelInstance, instance.ID, permission.OperationUse); err != nil { + return nil, fmt.Errorf("%w: user %s cannot use model instance %q: %w", errModelConfigUnavailable, access.UserID, instanceName, err) + } + } extra, err := decodeModelInstanceExtra(instance.Extra) if err != nil { return nil, fmt.Errorf("%w: decode model instance configuration: %v", errModelConfigUnavailable, err) @@ -905,6 +890,11 @@ func (s *ModelFactory) resolveProviderInstanceModel(ctx context.Context, tenantI providerInfo := dao.GetModelProviderManager().FindProvider(providerName) if availableTypes != 0 { + if access.UserID != "" { + if err = checkModelPermission(ctx, access.UserID, permission.ResourceKindModel, modelEntity.ID, permission.OperationUse); err != nil { + return nil, fmt.Errorf("%w: user %s cannot use model %q: %w", errModelConfigUnavailable, access.UserID, modelRef, err) + } + } if providerInfo == nil { return nil, fmt.Errorf("%w: provider %q driver not found", errModelConfigUnavailable, providerName) } @@ -991,7 +981,7 @@ func builtinModelTarget(modelType entity.ModelType, modelName string, driver mod } } -func (s *ModelFactory) resolveCompositeModelType(ctx context.Context, tenantID, modelRef string) (entity.ModelType, error) { +func (s *ModelFactory) resolveCompositeModelType(ctx context.Context, access ModelAccess, modelRef string) (entity.ModelType, error) { if strings.Contains(common.GetEnv(common.EnvComposeProfiles), "tei-") { teiModel := common.GetEnv(common.EnvTEIModel) if modelRef == teiModel { @@ -1016,13 +1006,18 @@ func (s *ModelFactory) resolveCompositeModelType(ctx context.Context, tenantID, return 0, fmt.Errorf("%w: model factory is not initialized", errModelConfigUnavailable) } - provider, err := s.modelProviderDAO.GetByTenantIDAndProviderName(ctx, dao.DB, tenantID, providerName) + provider, err := s.modelProviderDAO.GetByTenantIDAndProviderName(ctx, dao.DB, access.TenantID, providerName) if err != nil { return 0, fmt.Errorf("%w: provider %q lookup failed: %w", errModelConfigUnavailable, providerName, err) } if provider == nil { return 0, fmt.Errorf("%w: provider %q not found for model %q", errModelConfigUnavailable, providerName, modelRef) } + if access.UserID != "" { + if err = checkModelPermission(ctx, access.UserID, permission.ResourceKindModelProvider, provider.ID, permission.OperationRead); err != nil { + return 0, fmt.Errorf("%w: user %s cannot read model provider %q: %w", errModelConfigUnavailable, access.UserID, providerName, err) + } + } instance, err := s.modelInstanceDAO.GetByProviderIDAndInstanceName(ctx, dao.DB, provider.ID, instanceName) if err != nil { return 0, fmt.Errorf("%w: instance %q lookup failed: %w", errModelConfigUnavailable, instanceName, err) @@ -1033,6 +1028,11 @@ func (s *ModelFactory) resolveCompositeModelType(ctx context.Context, tenantID, if !strings.EqualFold(instance.Status, "active") { return 0, fmt.Errorf("%w: model instance %q is disabled", errModelConfigUnavailable, instanceName) } + if access.UserID != "" { + if err = checkModelPermission(ctx, access.UserID, permission.ResourceKindModelInstance, instance.ID, permission.OperationUse); err != nil { + return 0, fmt.Errorf("%w: user %s cannot use model instance %q: %w", errModelConfigUnavailable, access.UserID, instanceName, err) + } + } models, err := s.modelDAO.GetModelsByProviderIDAndInstanceIDAndModelName(ctx, dao.DB, provider.ID, instance.ID, pureModelName) if err != nil { @@ -1044,6 +1044,11 @@ func (s *ModelFactory) resolveCompositeModelType(ctx context.Context, tenantID, continue } if strings.EqualFold(model.Status, "active") { + if access.UserID != "" { + if err = checkModelPermission(ctx, access.UserID, permission.ResourceKindModel, model.ID, permission.OperationUse); err != nil { + return 0, fmt.Errorf("%w: user %s cannot use model %q: %w", errModelConfigUnavailable, access.UserID, modelRef, err) + } + } activeTypes |= entity.ModelType(model.ModelType) } else { inactiveTypes |= entity.ModelType(model.ModelType) diff --git a/internal/service/model_permission.go b/internal/service/model_permission.go new file mode 100644 index 0000000000..ed7c76ce4b --- /dev/null +++ b/internal/service/model_permission.go @@ -0,0 +1,53 @@ +// +// 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 checkModelPermission(ctx context.Context, userID string, kind permission.ResourceKind, id string, operation permission.Operation) error { + return permission.NewDatabaseChecker(dao.DB).CheckResource( + ctx, + permission.Subject{UserID: userID}, + permission.ResourceRef{Kind: kind, ID: id}, + operation, + ) +} + +func accessibleModelResourceIDs(ctx context.Context, userID string, kind permission.ResourceKind, parent permission.ResourceRef, operation permission.Operation, entry *permission.ResourceRef, entryOperation permission.Operation) ([]string, error) { + query := permission.ScopeQuery{Kind: kind, Parent: parent, Operation: operation} + if entry != nil { + query.Entry = entry + query.EntryOp = entryOperation + } + scope, err := permission.NewDatabaseChecker(dao.DB).Scope(ctx, permission.Subject{UserID: userID}, query) + if err != nil { + return nil, err + } + if scope.Mode != permission.ScopeIDs { + return []string{}, nil + } + return scope.ResourceIDs, nil +} +func normalizeModelPermissionError(err error) (common.ErrorCode, error) { + return permissionresponse.NormalizeHidden(err) +} diff --git a/internal/service/model_service.go b/internal/service/model_service.go index defb5503ab..324de19a1a 100644 --- a/internal/service/model_service.go +++ b/internal/service/model_service.go @@ -27,6 +27,8 @@ import ( "ragflow/internal/dao" "ragflow/internal/entity" modelModule "ragflow/internal/entity/models" + "ragflow/internal/permission" + permissionresponse "ragflow/internal/permission/response" "sort" "strings" @@ -68,7 +70,6 @@ func NewModelProviderService() *ModelProviderService { modelInstanceDAO: dao.NewTenantModelInstanceDAO(), modelDAO: dao.NewTenantModelDAO(), tenantDAO: dao.NewTenantDAO(), - userTenantDAO: dao.NewUserTenantDAO(), } } @@ -77,7 +78,6 @@ type ModelProviderService struct { modelInstanceDAO *dao.TenantModelInstanceDAO modelDAO *dao.TenantModelDAO tenantDAO *dao.TenantDAO - userTenantDAO *dao.UserTenantDAO } // CheckConnectionModelInfo CheckConnectionRequest carries the credentials and optional instance selector @@ -99,18 +99,12 @@ type CheckConnectionRequest struct { func (m *ModelProviderService) AddModelProvider(ctx context.Context, providerName, userID string) (common.ErrorCode, error) { providerName = strings.TrimSpace(providerName) - - tenants, err := m.userTenantDAO.GetByUserIDAndRole(ctx, dao.DB, userID, "owner") + tenantID, err := requireOwnedTenantID(ctx, userID) if err != nil { - return common.CodeServerError, err + code, normalizedErr := normalizeModelPermissionError(err) + return code, normalizedErr } - if len(tenants) == 0 { - return common.CodeNotFound, errors.New("user has no tenants") - } - - tenantID := tenants[0].TenantID - existing, err := m.modelProviderDAO.GetByTenantIDAndProviderName(ctx, dao.DB, tenantID, providerName) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return common.CodeServerError, err @@ -134,25 +128,42 @@ func (m *ModelProviderService) AddModelProvider(ctx context.Context, providerNam } func (m *ModelProviderService) ListProvidersOfTenant(ctx context.Context, userID string) ([]map[string]interface{}, common.ErrorCode, error) { - - tenants, err := m.userTenantDAO.GetByUserIDAndRole(ctx, dao.DB, userID, "owner") + ownedTenantID, err := requireOwnedTenantID(ctx, userID) + if err != nil { + if errors.Is(err, permission.ErrMembershipNotFound) { + return []map[string]interface{}{}, common.CodeSuccess, nil + } + code, normalizedErr := normalizeModelPermissionError(err) + return nil, code, normalizedErr + } + providerRows, err := m.modelProviderDAO.GetByTenantID(ctx, dao.DB, ownedTenantID) if err != nil { return nil, common.CodeServerError, err } - - if len(tenants) == 0 { - return nil, common.CodeNotFound, errors.New("user has no tenants") + providerRefs := make([]permission.ResourceRef, 0, len(providerRows)) + for _, provider := range providerRows { + providerRefs = append(providerRefs, permission.ResourceRef{Kind: permission.ResourceKindModelProvider, ID: provider.ID}) } - - tenantID := tenants[0].TenantID - - providerNames, err := m.modelProviderDAO.ListByID(ctx, dao.DB, tenantID) + accessibleProviders, err := permission.NewDatabaseChecker(dao.DB).FilterResources( + ctx, + permission.Subject{UserID: userID}, + providerRefs, + permission.OperationRead, + ) if err != nil { - return nil, common.CodeServerError, err + code, normalizedErr := normalizeModelPermissionError(err) + return nil, code, normalizedErr } - var result []map[string]interface{} - for _, providerName := range providerNames { + result := make([]map[string]interface{}, 0, len(accessibleProviders)) + seenProviderNames := make(map[string]struct{}, len(accessibleProviders)) + for _, providerRef := range accessibleProviders { + providerID := providerRef.ID + providerRow, err := m.modelProviderDAO.GetByID(ctx, dao.DB, providerID) + if err != nil { + return nil, common.CodeServerError, err + } + providerName := providerRow.ProviderName // Mirror Python's list_providers tenant branch: silently skip system-excluded // factory names (e.g. "Builtin", "Youdao", "FastEmbed", "BAAI", // "siliconflow_intl") and any stale entries whose factory is no longer in @@ -160,6 +171,10 @@ func (m *ModelProviderService) ListProvidersOfTenant(ctx context.Context, userID if isExcludedTenantProvider(providerName) { continue } + providerKey := strings.ToLower(providerName) + if _, exists := seenProviderNames[providerKey]; exists { + continue + } provider, err := dao.GetModelProviderManager().GetProviderByName(providerName) if err != nil { // Treat "provider not found in system pool" as a stale tenant entry @@ -178,25 +193,18 @@ func (m *ModelProviderService) ListProvidersOfTenant(ctx context.Context, userID // Set has_instance flag. Mirrors Python's: // provider_obj = TenantModelProviderService.get_by_tenant_id_and_provider_name(tenant_id, name) // has_instance = bool(provider_obj and TenantModelInstanceService.get_all_by_provider_id(provider_obj.id)) - provider["has_instance"] = m.providerHasInstance(ctx, tenantID, providerName) + provider["has_instance"] = m.providerHasInstance(ctx, providerRow.ID) result = append(result, provider) + seenProviderNames[providerKey] = struct{}{} } return result, common.CodeSuccess, nil } -// providerHasInstance checks whether the given tenant's provider has any -// configured instances. Mirrors the Python pattern: -// -// provider_obj = TenantModelProviderService.get_by_tenant_id_and_provider_name(tenant_id, name) -// has_instance = bool(provider_obj and TenantModelInstanceService.get_all_by_provider_id(provider_obj.id)) -func (m *ModelProviderService) providerHasInstance(ctx context.Context, tenantID, providerName string) bool { - providerObj, err := m.modelProviderDAO.GetByTenantIDAndProviderName(ctx, dao.DB, tenantID, providerName) - if err != nil { - return false - } - instances, err := m.modelInstanceDAO.GetAllInstancesByProviderID(ctx, dao.DB, providerObj.ID) +// providerHasInstance checks whether a permission-scoped provider has any configured instances. +func (m *ModelProviderService) providerHasInstance(ctx context.Context, providerID string) bool { + instances, err := m.modelInstanceDAO.GetAllInstancesByProviderID(ctx, dao.DB, providerID) if err != nil { return false } @@ -215,18 +223,12 @@ func isExcludedTenantProvider(name string) bool { } func (m *ModelProviderService) DeleteModelProvider(ctx context.Context, userID, providerName string) (common.ErrorCode, error) { - tenants, err := m.userTenantDAO.GetByUserIDAndRole(ctx, dao.DB, userID, "owner") - if err != nil { - return common.CodeServerError, err - } - if len(tenants) == 0 { - return common.CodeNotFound, errors.New("user has no tenants") - } - tenantID := tenants[0].TenantID - - // Find the provider first. - provider, err := m.modelProviderDAO.GetByTenantIDAndProviderName(ctx, dao.DB, tenantID, providerName) + provider, err := m.getProviderByIDOrName(ctx, userID, providerName, permission.OperationDelete) if err != nil { + if permissionresponse.IsPermissionError(err) { + code, normalizedErr := normalizeModelPermissionError(err) + return code, normalizedErr + } return common.CodeNotFound, fmt.Errorf("provider %s not found", providerName) } @@ -235,6 +237,22 @@ func (m *ModelProviderService) DeleteModelProvider(ctx context.Context, userID, if err != nil { return common.CodeServerError, err } + for _, instance := range instances { + if err = checkModelPermission(ctx, userID, permission.ResourceKindModelInstance, instance.ID, permission.OperationDelete); err != nil { + code, normalizedErr := normalizeModelPermissionError(err) + return code, normalizedErr + } + models, modelErr := m.modelDAO.GetModelsByInstanceID(ctx, dao.DB, instance.ID) + if modelErr != nil { + return common.CodeServerError, modelErr + } + for _, model := range models { + if err = checkModelPermission(ctx, userID, permission.ResourceKindModel, model.ID, permission.OperationDelete); err != nil { + code, normalizedErr := normalizeModelPermissionError(err) + return code, normalizedErr + } + } + } if len(instances) > 0 { instanceIDs := make([]string, len(instances)) for i, inst := range instances { @@ -248,7 +266,7 @@ func (m *ModelProviderService) DeleteModelProvider(ctx context.Context, userID, } } - _, err = m.modelProviderDAO.DeleteByTenantIDAndProviderName(ctx, dao.DB, tenantID, providerName) + _, err = m.modelProviderDAO.DeleteByTenantIDAndProviderName(ctx, dao.DB, provider.TenantID, provider.ProviderName) if err != nil { return common.CodeServerError, err } @@ -259,28 +277,32 @@ func (m *ModelProviderService) DeleteModelProvider(ctx context.Context, userID, func (m *ModelProviderService) ListSupportedModels(ctx context.Context, providerName, instanceName, userID string) ([]map[string]interface{}, error) { providerName = strings.TrimSpace(providerName) - // Get tenant ID from user - tenants, err := m.userTenantDAO.GetByUserIDAndRole(ctx, dao.DB, userID, "owner") + ownedTenantID, err := requireOwnedTenantID(ctx, userID) if err != nil { - return nil, errors.New("fail to get tenant") + if permissionresponse.IsPermissionError(err) { + _, normalizedErr := normalizeModelPermissionError(err) + return nil, normalizedErr + } + return nil, err } - - if len(tenants) == 0 { - return nil, errors.New("user has no tenants") - } - - tenantID := tenants[0].TenantID - - // Check if provider exists - provider, err := m.modelProviderDAO.GetByTenantIDAndProviderName(ctx, dao.DB, tenantID, providerName) + provider, err := m.findProviderByIDOrNameInTenant(ctx, ownedTenantID, providerName) if err != nil { return nil, err } + if err = checkModelPermission(ctx, userID, permission.ResourceKindModelProvider, provider.ID, permission.OperationRead); err != nil { + _, normalizedErr := normalizeModelPermissionError(err) + return nil, normalizedErr + } instance, err := m.modelInstanceDAO.GetByProviderIDAndInstanceName(ctx, dao.DB, provider.ID, instanceName) if err != nil { return nil, err } + if err = checkModelPermission(ctx, userID, permission.ResourceKindModelInstance, instance.ID, permission.OperationUse); err != nil { + _, normalizedErr := normalizeModelPermissionError(err) + return nil, normalizedErr + } + providerName = provider.ProviderName providerInfo := dao.GetModelProviderManager().FindProvider(providerName) if providerInfo == nil { @@ -339,12 +361,57 @@ type CreateInstanceModelInfo struct { Extra map[string]interface{} `json:"extra"` } -func (m *ModelProviderService) getProviderByIDOrName(ctx context.Context, tenantID, providerIDOrName string) (*entity.TenantModelProvider, error) { +func (m *ModelProviderService) getProviderByIDOrName(ctx context.Context, userID, providerIDOrName string, operation permission.Operation) (*entity.TenantModelProvider, error) { + providerIDOrName = strings.TrimSpace(providerIDOrName) + if providerIDOrName == "" { + return nil, gorm.ErrRecordNotFound + } provider, err := m.modelProviderDAO.GetByID(ctx, dao.DB, providerIDOrName) - if err == nil && provider.TenantID == tenantID { + if err == nil { + if err := checkModelPermission(ctx, userID, permission.ResourceKindModelProvider, provider.ID, operation); err != nil { + return nil, err + } return provider, nil } - return m.modelProviderDAO.GetByTenantIDAndProviderName(ctx, dao.DB, tenantID, strings.TrimSpace(providerIDOrName)) + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + providerIDs, scopeErr := accessibleModelResourceIDs(ctx, userID, permission.ResourceKindModelProvider, permission.ResourceRef{}, operation, nil, "") + if scopeErr != nil { + return nil, scopeErr + } + for _, providerID := range providerIDs { + candidate, candidateErr := m.modelProviderDAO.GetByID(ctx, dao.DB, providerID) + if candidateErr != nil { + return nil, candidateErr + } + if strings.EqualFold(candidate.ProviderName, providerIDOrName) { + return candidate, nil + } + } + return nil, gorm.ErrRecordNotFound +} + +func (m *ModelProviderService) findProviderByIDOrNameInTenant(ctx context.Context, tenantID, providerIDOrName string) (*entity.TenantModelProvider, error) { + providerIDOrName = strings.TrimSpace(providerIDOrName) + if providerIDOrName == "" { + return nil, gorm.ErrRecordNotFound + } + providers, err := m.modelProviderDAO.GetByTenantID(ctx, dao.DB, tenantID) + if err != nil { + return nil, err + } + for _, provider := range providers { + if provider.ID == providerIDOrName { + return provider, nil + } + } + for _, provider := range providers { + if strings.EqualFold(provider.ProviderName, providerIDOrName) { + return provider, nil + } + } + return nil, gorm.ErrRecordNotFound } func (m *ModelProviderService) CreateProviderInstance(ctx context.Context, providerIDOrName, instanceName, apiKey, baseURL, region, userID string, modelInfo []CreateInstanceModelInfo) (common.ErrorCode, error) { @@ -353,24 +420,16 @@ func (m *ModelProviderService) CreateProviderInstance(ctx context.Context, provi baseURL = strings.TrimSpace(baseURL) region = strings.TrimSpace(region) - // Get tenant ID from user - tenants, err := m.userTenantDAO.GetByUserIDAndRole(ctx, dao.DB, userID, "owner") - if err != nil { - return common.CodeServerError, err - } - - if len(tenants) == 0 { - return common.CodeNotFound, errors.New("user has no tenants") - } - - tenantID := tenants[0].TenantID - - provider, err := m.getProviderByIDOrName(ctx, tenantID, providerIDOrName) + provider, err := m.getProviderByIDOrName(ctx, userID, providerIDOrName, permission.OperationCreate) if err != nil { + if permissionresponse.IsPermissionError(err) { + code, normalizedErr := normalizeModelPermissionError(err) + return code, normalizedErr + } return common.CodeNotFound, fmt.Errorf("provider '%s' does not exist", providerIDOrName) } providerName := provider.ProviderName - + tenantID := provider.TenantID instanceID := common.GenerateToken() extra := make(map[string]string) @@ -473,20 +532,14 @@ func (m *ModelProviderService) CreateNameOnlyProviderInstance(ctx context.Contex return common.CodeBadRequest, errors.New("instance name cannot be 'default'") } - tenants, err := m.userTenantDAO.GetByUserIDAndRole(ctx, dao.DB, userID, "owner") - if err != nil { - return common.CodeServerError, err - } - if len(tenants) == 0 { - return common.CodeNotFound, errors.New("user has no tenants") - } - tenantID := tenants[0].TenantID - - provider, err := m.getProviderByIDOrName(ctx, tenantID, providerIDOrName) + provider, err := m.getProviderByIDOrName(ctx, userID, providerIDOrName, permission.OperationCreate) if err != nil { + if permissionresponse.IsPermissionError(err) { + code, normalizedErr := normalizeModelPermissionError(err) + return code, normalizedErr + } return common.CodeNotFound, fmt.Errorf("provider '%s' does not exist", providerIDOrName) } - instanceID := common.GenerateToken() tenantModelInstance := &entity.TenantModelInstance{ @@ -567,29 +620,41 @@ func (m *ModelProviderService) addModelToInstance(ctx context.Context, tenantID, func (m *ModelProviderService) ListProviderInstances(ctx context.Context, providerIDOrName, userID string) ([]map[string]interface{}, common.ErrorCode, error) { providerIDOrName = strings.TrimSpace(providerIDOrName) - // Get tenant ID from user - tenants, err := m.userTenantDAO.GetByUserIDAndRole(ctx, dao.DB, userID, "owner") + ownedTenantID, err := requireOwnedTenantID(ctx, userID) if err != nil { + if errors.Is(err, permission.ErrMembershipNotFound) { + return []map[string]interface{}{}, common.CodeSuccess, nil + } + code, normalizedErr := normalizeModelPermissionError(err) + return nil, code, normalizedErr + } + + provider, err := m.findProviderByIDOrNameInTenant(ctx, ownedTenantID, providerIDOrName) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, common.CodeDataError, fmt.Errorf("no provider found for provider '%s'", providerIDOrName) + } return nil, common.CodeServerError, err } - - if len(tenants) == 0 { - return nil, common.CodeNotFound, errors.New("user has no tenants") + providerRef := permission.ResourceRef{Kind: permission.ResourceKindModelProvider, ID: provider.ID} + if err = checkModelPermission(ctx, userID, permission.ResourceKindModelProvider, provider.ID, permission.OperationRead); err != nil { + code, normalizedErr := normalizeModelPermissionError(err) + return nil, code, normalizedErr } - tenantID := tenants[0].TenantID - - // Check if provider exists — try by ID first, then by name. - provider, err := m.getProviderByIDOrName(ctx, tenantID, providerIDOrName) - if err != nil { - return nil, common.CodeDataError, fmt.Errorf("no provider found for provider '%s'", providerIDOrName) - } - - // Check if provider exists instances, err := m.modelInstanceDAO.GetAllInstancesByProviderID(ctx, dao.DB, provider.ID) if err != nil { return nil, common.CodeServerError, err } + instanceIDs, err := accessibleModelResourceIDs(ctx, userID, permission.ResourceKindModelInstance, providerRef, permission.OperationRead, &providerRef, permission.OperationRead) + if err != nil { + code, normalizedErr := normalizeModelPermissionError(err) + return nil, code, normalizedErr + } + accessibleInstances := make(map[string]struct{}, len(instanceIDs)) + for _, instanceID := range instanceIDs { + accessibleInstances[instanceID] = struct{}{} + } // Always emit a non-nil slice so the JSON encoder serializes [] rather // than null when the tenant has no instances on this provider. The @@ -598,6 +663,9 @@ func (m *ModelProviderService) ListProviderInstances(ctx context.Context, provid // crash on a freshly created tenant. result := make([]map[string]interface{}, 0, len(instances)) for _, instance := range instances { + if _, ok := accessibleInstances[instance.ID]; !ok { + continue + } // Parse extra to extract region var extraFields map[string]string if instance.Extra != "" { @@ -627,33 +695,27 @@ func (m *ModelProviderService) ListProviderInstances(ctx context.Context, provid func (m *ModelProviderService) ShowProviderInstance(ctx context.Context, providerName, instanceIDOrName, userID string) (map[string]interface{}, common.ErrorCode, error) { providerName = strings.TrimSpace(providerName) - // Get tenant ID from user - tenants, err := m.userTenantDAO.GetByUserIDAndRole(ctx, dao.DB, userID, "owner") + ownedTenantID, err := requireOwnedTenantID(ctx, userID) if err != nil { + if permissionresponse.IsPermissionError(err) { + code, normalizedErr := normalizeModelPermissionError(err) + return nil, code, normalizedErr + } return nil, common.CodeServerError, err } - - if len(tenants) == 0 { - return nil, common.CodeNotFound, errors.New("user has no tenants") - } - - tenantID := tenants[0].TenantID - - // Check if provider exists. Mirrors Python's: - // provider_obj = TenantModelProviderService.get_by_tenant_id_and_provider_id(tenant_id, provider_id_or_name) - // if not provider_obj: - // provider_obj = TenantModelProviderService.get_by_tenant_id_and_provider_name(tenant_id, provider_id_or_name) - provider, err := m.modelProviderDAO.GetByTenantIDAndProviderName(ctx, dao.DB, tenantID, providerName) + provider, err := m.findProviderByIDOrNameInTenant(ctx, ownedTenantID, providerName) if err != nil { - return nil, common.CodeDataError, fmt.Errorf("no provider found for provider '%s'", providerName) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, common.CodeDataError, fmt.Errorf("no provider found for provider '%s'", providerName) + } + return nil, common.CodeServerError, err + } + if err = checkModelPermission(ctx, userID, permission.ResourceKindModelProvider, provider.ID, permission.OperationRead); err != nil { + code, normalizedErr := normalizeModelPermissionError(err) + return nil, code, normalizedErr } - // Find the instance — try by ID first, then by name. - // Mirrors Python's: - // _, instance_obj = TenantModelInstanceService.get_by_id(instance_id_or_name) - // if instance_obj and instance_obj.provider_id != provider_id: instance_obj = None - // if not instance_obj: - // instance_obj = TenantModelInstanceService.get_by_provider_id_and_instance_name(provider_id, instance_id_or_name) + // Find the instance in the tenant-scoped provider — try ID first, then name. var instance *entity.TenantModelInstance instance, err = m.modelInstanceDAO.GetByID(ctx, dao.DB, instanceIDOrName) if err != nil || instance.ProviderID != provider.ID { @@ -662,6 +724,10 @@ func (m *ModelProviderService) ShowProviderInstance(ctx context.Context, provide if err != nil { return nil, common.CodeDataError, fmt.Errorf("no instance found for provider '%s' and instance '%s'", providerName, instanceIDOrName) } + if err = checkModelPermission(ctx, userID, permission.ResourceKindModelInstance, instance.ID, permission.OperationRead); err != nil { + code, normalizedErr := normalizeModelPermissionError(err) + return nil, code, normalizedErr + } // Parse extra fields. Mirrors Python's: // extra_fields = json.loads(instance_obj.extra) if instance_obj.extra else {} @@ -689,22 +755,13 @@ func (m *ModelProviderService) ShowProviderInstance(ctx context.Context, provide } func (m *ModelProviderService) ShowInstanceBalance(ctx context.Context, providerName, instanceName, userID string) (map[string]interface{}, common.ErrorCode, error) { - - // Get tenant ID from user - tenants, err := m.userTenantDAO.GetByUserIDAndRole(ctx, dao.DB, userID, "owner") - if err != nil { - return nil, common.CodeServerError, err - } - - if len(tenants) == 0 { - return nil, common.CodeNotFound, errors.New("user has no tenants") - } - - tenantID := tenants[0].TenantID - // Check if provider exists - provider, err := m.modelProviderDAO.GetByTenantIDAndProviderName(ctx, dao.DB, tenantID, providerName) + provider, err := m.getProviderByIDOrName(ctx, userID, providerName, permission.OperationRead) if err != nil { + if permissionresponse.IsPermissionError(err) { + code, normalizedErr := normalizeModelPermissionError(err) + return nil, code, normalizedErr + } return nil, common.CodeServerError, err } @@ -712,6 +769,11 @@ func (m *ModelProviderService) ShowInstanceBalance(ctx context.Context, provider if err != nil { return nil, common.CodeServerError, err } + if err = checkModelPermission(ctx, userID, permission.ResourceKindModelInstance, instance.ID, permission.OperationUse); err != nil { + code, normalizedErr := normalizeModelPermissionError(err) + return nil, code, normalizedErr + } + providerName = provider.ProviderName providerInfo := dao.GetModelProviderManager().FindProvider(providerName) if providerInfo == nil { @@ -744,6 +806,36 @@ func (m *ModelProviderService) ShowInstanceBalance(ctx context.Context, provider } func (m *ModelProviderService) CheckConnection(ctx context.Context, providerName, apiKey, region, baseURL, instanceID, userID string, modelInfo []CheckConnectionModelInfo) (common.ErrorCode, error) { + providerName = strings.TrimSpace(providerName) + if instanceID != "" { + instance, err := m.modelInstanceDAO.GetByID(ctx, dao.DB, instanceID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return common.CodeNotFound, errors.New("model instance not found") + } + return common.CodeServerError, err + } + provider, err := m.modelProviderDAO.GetByID(ctx, dao.DB, instance.ProviderID) + if err != nil { + return common.CodeServerError, err + } + if provider == nil { + return common.CodeNotFound, errors.New("model provider not found") + } + if !strings.EqualFold(provider.ProviderName, providerName) { + return common.CodeNotFound, errors.New("model instance not found") + } + if err = checkModelPermission(ctx, userID, permission.ResourceKindModelProvider, provider.ID, permission.OperationRead); err != nil { + code, normalizedErr := normalizeModelPermissionError(err) + return code, normalizedErr + } + if err = checkModelPermission(ctx, userID, permission.ResourceKindModelInstance, instance.ID, permission.OperationUpdate); err != nil { + code, normalizedErr := normalizeModelPermissionError(err) + return code, normalizedErr + } + providerName = provider.ProviderName + } + providerInfo := dao.GetModelProviderManager().FindProvider(providerName) if providerInfo == nil { return common.CodeServerError, fmt.Errorf("provider %s not found", providerName) @@ -784,6 +876,10 @@ func (m *ModelProviderService) CheckConnection(ctx context.Context, providerName // inside the /connection/verify REST endpoint. if instanceID != "" && len(modelVerifyResult) > 0 { if dbErr := m.updateModelVerifyResults(ctx, userID, providerName, instanceID, modelVerifyResult); dbErr != nil { + if permissionresponse.IsPermissionError(dbErr) { + code, normalizedErr := normalizeModelPermissionError(dbErr) + return code, normalizedErr + } common.Logger.Error("failed to persist model verify results", zap.Error(dbErr)) } } @@ -800,17 +896,22 @@ func (m *ModelProviderService) CheckConnection(ctx context.Context, providerName // /api/v1/providers//connection/verify endpoint when instance_id is // present in the request body. func (m *ModelProviderService) updateModelVerifyResults(ctx context.Context, userID, providerName, instanceID string, modelVerifyResult map[string]string) error { - // Resolve tenant from user. - userTenants, err := m.userTenantDAO.GetByUserID(ctx, dao.DB, userID) - if err != nil || len(userTenants) == 0 { - return fmt.Errorf("no tenant found for user %s", userID) - } - tenantID := userTenants[0].TenantID - - // Resolve provider DB record from tenant + provider name. - provider, err := m.modelProviderDAO.GetByTenantIDAndProviderName(ctx, dao.DB, tenantID, providerName) + instance, err := m.modelInstanceDAO.GetByID(ctx, dao.DB, instanceID) if err != nil { - return fmt.Errorf("provider %s not found for tenant %s: %w", providerName, tenantID, err) + return fmt.Errorf("instance %s not found: %w", instanceID, err) + } + if err = checkModelPermission(ctx, userID, permission.ResourceKindModelInstance, instance.ID, permission.OperationUpdate); err != nil { + return err + } + provider, err := m.modelProviderDAO.GetByID(ctx, dao.DB, instance.ProviderID) + if err != nil { + return fmt.Errorf("provider for instance %s not found: %w", instanceID, err) + } + if !strings.EqualFold(provider.ProviderName, providerName) { + return permission.ErrResourceNotFound + } + if err = checkModelPermission(ctx, userID, permission.ResourceKindModelProvider, provider.ID, permission.OperationRead); err != nil { + return err } for modelName, verifyStatus := range modelVerifyResult { @@ -823,6 +924,9 @@ func (m *ModelProviderService) updateModelVerifyResults(ctx context.Context, use } return fmt.Errorf("failed to look up %s for verify update: %w", modelName, err) } + if err = checkModelPermission(ctx, userID, permission.ResourceKindModel, modelObj.ID, permission.OperationUpdate); err != nil { + return err + } extra := make(map[string]interface{}) if modelObj.Extra != "" { @@ -1132,22 +1236,13 @@ func minimalPNG() []byte { } func (m *ModelProviderService) ListTasks(ctx context.Context, providerName, instanceName, userID string) ([]modelModule.ListTaskStatus, common.ErrorCode, error) { - - // Get tenant ID from user - tenants, err := m.userTenantDAO.GetByUserIDAndRole(ctx, dao.DB, userID, "owner") - if err != nil { - return nil, common.CodeServerError, err - } - - if len(tenants) == 0 { - return nil, common.CodeNotFound, errors.New("user has no tenants") - } - - tenantID := tenants[0].TenantID - // Check if provider exists - provider, err := m.modelProviderDAO.GetByTenantIDAndProviderName(ctx, dao.DB, tenantID, providerName) + provider, err := m.getProviderByIDOrName(ctx, userID, providerName, permission.OperationRead) if err != nil { + if permissionresponse.IsPermissionError(err) { + code, normalizedErr := normalizeModelPermissionError(err) + return nil, code, normalizedErr + } return nil, common.CodeServerError, err } @@ -1155,6 +1250,11 @@ func (m *ModelProviderService) ListTasks(ctx context.Context, providerName, inst if err != nil { return nil, common.CodeServerError, err } + if err = checkModelPermission(ctx, userID, permission.ResourceKindModelInstance, instance.ID, permission.OperationUse); err != nil { + code, normalizedErr := normalizeModelPermissionError(err) + return nil, code, normalizedErr + } + providerName = provider.ProviderName providerInfo := dao.GetModelProviderManager().FindProvider(providerName) if providerInfo == nil { @@ -1193,22 +1293,13 @@ func (m *ModelProviderService) ListTasks(ctx context.Context, providerName, inst } func (m *ModelProviderService) ShowTask(ctx context.Context, providerName, instanceName, taskID, userID string) (*modelModule.TaskResponse, common.ErrorCode, error) { - - // Get tenant ID from user - tenants, err := m.userTenantDAO.GetByUserIDAndRole(ctx, dao.DB, userID, "owner") - if err != nil { - return nil, common.CodeServerError, err - } - - if len(tenants) == 0 { - return nil, common.CodeNotFound, errors.New("user has no tenants") - } - - tenantID := tenants[0].TenantID - // Check if provider exists - provider, err := m.modelProviderDAO.GetByTenantIDAndProviderName(ctx, dao.DB, tenantID, providerName) + provider, err := m.getProviderByIDOrName(ctx, userID, providerName, permission.OperationRead) if err != nil { + if permissionresponse.IsPermissionError(err) { + code, normalizedErr := normalizeModelPermissionError(err) + return nil, code, normalizedErr + } return nil, common.CodeServerError, err } @@ -1216,6 +1307,11 @@ func (m *ModelProviderService) ShowTask(ctx context.Context, providerName, insta if err != nil { return nil, common.CodeServerError, err } + if err = checkModelPermission(ctx, userID, permission.ResourceKindModelInstance, instance.ID, permission.OperationUse); err != nil { + code, normalizedErr := normalizeModelPermissionError(err) + return nil, code, normalizedErr + } + providerName = provider.ProviderName providerInfo := dao.GetModelProviderManager().FindProvider(providerName) if providerInfo == nil { @@ -1277,8 +1373,8 @@ func (m *ModelProviderService) ShowTask(ctx context.Context, providerName, insta // to ListTenantDefaultModels (which only enumerates the 6-7 default // tenant fields and returned `[]` for any tenant without defaults), // breaking the front-end's "View Models" list entirely. -func (m *ModelProviderService) ListTenantAddedModels(ctx context.Context, userID, ownerTenantID, modelTypeFilter string) ([]map[string]interface{}, common.ErrorCode, error) { - tenant, code, err := m.resolveModelListTenant(ctx, userID, ownerTenantID) +func (m *ModelProviderService) ListTenantAddedModels(ctx context.Context, userID, requestedTenantID, modelTypeFilter string) ([]map[string]interface{}, common.ErrorCode, error) { + tenant, code, err := m.resolveModelListTenant(ctx, userID, requestedTenantID) if err != nil { return nil, code, err } @@ -1296,11 +1392,13 @@ func (m *ModelProviderService) ListTenantAddedModels(ctx context.Context, userID } // Mirror Python's ensure_*_from_env calls. - _ = m.ensureMineruFromEnv(ctx, tenantID) - _ = m.ensureMonkeyOCRv2FromEnv(ctx, tenantID) - _ = m.ensureMonkeyOCRFromEnv(ctx, tenantID) - _ = m.ensurePaddleOCREnabledFromEnv(ctx, tenantID) - _ = m.ensureOpenDataLoaderFromEnv(ctx, tenantID) + if permission.NewDatabaseChecker(dao.DB).CheckTenant(ctx, permission.Subject{UserID: userID}, tenantID, permission.TenantOwner) == nil { + _ = m.ensureMineruFromEnv(ctx, tenantID) + _ = m.ensureMonkeyOCRv2FromEnv(ctx, tenantID) + _ = m.ensureMonkeyOCRFromEnv(ctx, tenantID) + _ = m.ensurePaddleOCREnabledFromEnv(ctx, tenantID) + _ = m.ensureOpenDataLoaderFromEnv(ctx, tenantID) + } var modelTypeFilterBin entity.ModelType if modelTypeFilter != "" { @@ -1314,6 +1412,25 @@ func (m *ModelProviderService) ListTenantAddedModels(ctx context.Context, userID if len(providers) == 0 { return []map[string]interface{}{}, common.CodeSuccess, nil } + accessibleProviderIDs, err := accessibleModelResourceIDs(ctx, userID, permission.ResourceKindModelProvider, permission.ResourceRef{}, permission.OperationRead, nil, "") + if err != nil { + code, normalizedErr := normalizeModelPermissionError(err) + return nil, code, normalizedErr + } + providerAllowed := make(map[string]struct{}, len(accessibleProviderIDs)) + for _, providerID := range accessibleProviderIDs { + providerAllowed[providerID] = struct{}{} + } + filteredProviders := providers[:0] + for _, provider := range providers { + if _, ok := providerAllowed[provider.ID]; ok { + filteredProviders = append(filteredProviders, provider) + } + } + providers = filteredProviders + if len(providers) == 0 { + return []map[string]interface{}{}, common.CodeSuccess, nil + } providerIDs := make([]string, 0, len(providers)) providerInfoByID := make(map[string]*entity.TenantModelProvider, len(providers)) @@ -1367,7 +1484,9 @@ func (m *ModelProviderService) ListTenantAddedModels(ctx context.Context, userID } if combinedType != 0 { rec.ModelType = int(combinedType) - _ = m.modelDAO.UpdateByID(ctx, dao.DB, rec.ID, map[string]interface{}{"model_type": int(combinedType)}) + if permissionErr := checkModelPermission(ctx, userID, permission.ResourceKindModel, rec.ID, permission.OperationUpdate); permissionErr == nil { + _ = m.modelDAO.UpdateByID(ctx, dao.DB, rec.ID, map[string]interface{}{"model_type": int(combinedType)}) + } } } @@ -1381,6 +1500,19 @@ func (m *ModelProviderService) ListTenantAddedModels(ctx context.Context, userID } else { targetRecords = modelRecords } + modelRefs := make([]permission.ResourceRef, 0, len(targetRecords)) + for _, record := range targetRecords { + modelRefs = append(modelRefs, permission.ResourceRef{Kind: permission.ResourceKindModel, ID: record.ID}) + } + accessibleModels, err := permission.NewDatabaseChecker(dao.DB).FilterResources(ctx, permission.Subject{UserID: userID}, modelRefs, permission.OperationRead) + if err != nil { + code, normalizedErr := normalizeModelPermissionError(err) + return nil, code, normalizedErr + } + accessibleModelIDs := make(map[string]struct{}, len(accessibleModels)) + for _, ref := range accessibleModels { + accessibleModelIDs[ref.ID] = struct{}{} + } // Build model rank map from factory catalog (mirrors Python's model_rank_map). modelRankMap := make(map[string]int) // key: "providerName@modelName" @@ -1399,6 +1531,9 @@ func (m *ModelProviderService) ListTenantAddedModels(ctx context.Context, userID added := make([]map[string]interface{}, 0, len(targetRecords)) for _, modelRecord := range targetRecords { + if _, ok := accessibleModelIDs[modelRecord.ID]; !ok { + continue + } provInfo, provOK := providerInfoByID[modelRecord.ProviderID] instInfo, instOK := instanceInfoByID[modelRecord.InstanceID] if !provOK || !instOK { @@ -1487,37 +1622,29 @@ func (m *ModelProviderService) ListTenantAddedModels(ctx context.Context, userID return added, common.CodeSuccess, nil } -func (m *ModelProviderService) resolveModelListTenant(ctx context.Context, userID, ownerTenantID string) (*entity.Tenant, common.ErrorCode, error) { - if ownerTenantID == "" { - tenants, err := m.userTenantDAO.GetByUserIDAndRole(ctx, dao.DB, userID, "owner") +func (m *ModelProviderService) resolveModelListTenant(ctx context.Context, userID, requestedTenantID string) (*entity.Tenant, common.ErrorCode, error) { + tenantID := requestedTenantID + if tenantID == "" { + var err error + tenantID, err = requireOwnedTenantID(ctx, userID) if err != nil { - return nil, common.CodeServerError, err - } - if len(tenants) == 0 { - return nil, common.CodeSuccess, nil - } - ownerTenantID = tenants[0].TenantID - } else { - relations, err := m.userTenantDAO.GetByUserID(ctx, dao.DB, userID) - if err != nil { - return nil, common.CodeServerError, err - } - allowed := false - for _, rel := range relations { - if rel.TenantID == ownerTenantID { - allowed = true - break + if errors.Is(err, permission.ErrMembershipNotFound) { + return nil, common.CodeSuccess, nil } + code, normalizedErr := normalizeModelPermissionError(err) + return nil, code, normalizedErr } - if !allowed { - return nil, common.CodeAuthenticationError, fmt.Errorf("permission denied") + } else { + if err := permission.NewDatabaseChecker(dao.DB).CheckTenant(ctx, permission.Subject{UserID: userID}, tenantID, permission.TenantMember); err != nil { + code, normalizedErr := normalizeModelPermissionError(err) + return nil, code, normalizedErr } } - tenant, err := m.tenantDAO.GetByID(ctx, dao.DB, ownerTenantID) + tenant, err := m.tenantDAO.GetByID(ctx, dao.DB, tenantID) if err != nil { if dao.IsNotFoundErr(err) { - return nil, common.CodeNotFound, fmt.Errorf("tenant %s not found", ownerTenantID) + return nil, common.CodeNotFound, fmt.Errorf("tenant %s not found", tenantID) } return nil, common.CodeServerError, err } @@ -1733,20 +1860,16 @@ func (m *ModelProviderService) AlterProviderInstance(ctx context.Context, userID region = strings.TrimSpace(region) providerIDOrName = strings.TrimSpace(providerIDOrName) - tenants, err := m.userTenantDAO.GetByUserIDAndRole(ctx, dao.DB, userID, "owner") - if err != nil { - return common.CodeServerError, err - } - if len(tenants) == 0 { - return common.CodeNotFound, errors.New("user has no tenants") - } - tenantID := tenants[0].TenantID - - provider, err := m.getProviderByIDOrName(ctx, tenantID, providerIDOrName) + provider, err := m.getProviderByIDOrName(ctx, userID, providerIDOrName, permission.OperationRead) if err != nil { + if permissionresponse.IsPermissionError(err) { + code, normalizedErr := normalizeModelPermissionError(err) + return code, normalizedErr + } return common.CodeNotFound, fmt.Errorf("provider '%s' does not exist", providerIDOrName) } providerName := provider.ProviderName + tenantID := provider.TenantID // Find the instance — try by ID first, then by name. instance, err := m.modelInstanceDAO.GetByID(ctx, dao.DB, instanceIDOrName) @@ -1756,6 +1879,50 @@ func (m *ModelProviderService) AlterProviderInstance(ctx context.Context, userID if err != nil { return common.CodeNotFound, fmt.Errorf("no instance found for provider '%s' and instance '%s'", providerName, instanceIDOrName) } + if err = checkModelPermission(ctx, userID, permission.ResourceKindModelInstance, instance.ID, permission.OperationUpdate); err != nil { + code, normalizedErr := normalizeModelPermissionError(err) + return code, normalizedErr + } + + // Authorize every model mutation before changing the instance or any model. + existingModels, err := m.modelDAO.GetModelsByInstanceID(ctx, dao.DB, instance.ID) + if err != nil { + return common.CodeServerError, err + } + existingModelMap := make(map[string]*entity.TenantModel, len(existingModels)) + for _, model := range existingModels { + existingModelMap[model.ModelName] = model + } + submittedModelNames := make(map[string]bool) + if modelInfo != nil { + for _, model := range modelInfo { + if model.ModelName != "" { + submittedModelNames[model.ModelName] = true + } + } + } + for name, model := range existingModelMap { + operation := permission.OperationUpdate + if !submittedModelNames[name] { + operation = permission.OperationDelete + } + if err = checkModelPermission(ctx, userID, permission.ResourceKindModel, model.ID, operation); err != nil { + code, normalizedErr := normalizeModelPermissionError(err) + return code, normalizedErr + } + } + for _, model := range modelInfo { + if model.ModelName == "" { + continue + } + if _, exists := existingModelMap[model.ModelName]; !exists { + if err = checkModelPermission(ctx, userID, permission.ResourceKindModelInstance, instance.ID, permission.OperationCreate); err != nil { + code, normalizedErr := normalizeModelPermissionError(err) + return code, normalizedErr + } + break + } + } // Update instance record. instanceUpdates := map[string]interface{}{ @@ -1798,24 +1965,7 @@ func (m *ModelProviderService) AlterProviderInstance(ctx context.Context, userID } // Upsert models: add new ones, update existing ones, remove ones no longer selected. - existingModels, err := m.modelDAO.GetModelsByInstanceID(ctx, dao.DB, instance.ID) - if err != nil { - return common.CodeServerError, err - } - existingModelMap := make(map[string]*entity.TenantModel) - for _, mdl := range existingModels { - existingModelMap[mdl.ModelName] = mdl - } - // Delete models that are no longer in the submitted model_info. - submittedModelNames := make(map[string]bool) - if modelInfo != nil { - for _, mdl := range modelInfo { - if mdl.ModelName != "" { - submittedModelNames[mdl.ModelName] = true - } - } - } var idsToRemove []string for name, mdl := range existingModelMap { if !submittedModelNames[name] { @@ -1883,21 +2033,13 @@ func (m *ModelProviderService) DropProviderInstances(ctx context.Context, provid return common.CodeBadRequest, errors.New("instances is required") } - // Get tenant ID from user - tenants, err := m.userTenantDAO.GetByUserIDAndRole(ctx, dao.DB, userID, "owner") - if err != nil { - return common.CodeServerError, err - } - - if len(tenants) == 0 { - return common.CodeNotFound, errors.New("user has no tenants") - } - - tenantID := tenants[0].TenantID - // Find provider by ID or name - provider, err := m.getProviderByIDOrName(ctx, tenantID, providerIDOrName) + provider, err := m.getProviderByIDOrName(ctx, userID, providerIDOrName, permission.OperationRead) if err != nil { + if permissionresponse.IsPermissionError(err) { + code, normalizedErr := normalizeModelPermissionError(err) + return code, normalizedErr + } if errors.Is(err, gorm.ErrRecordNotFound) { return common.CodeNotFound, fmt.Errorf("no provider found for provider %q", providerIDOrName) } @@ -1932,6 +2074,20 @@ func (m *ModelProviderService) DropProviderInstances(ctx context.Context, provid } } instanceIDs = append(instanceIDs, instance.ID) + if err = checkModelPermission(ctx, userID, permission.ResourceKindModelInstance, instance.ID, permission.OperationDelete); err != nil { + code, normalizedErr := normalizeModelPermissionError(err) + return code, normalizedErr + } + models, modelErr := m.modelDAO.GetModelsByInstanceID(ctx, dao.DB, instance.ID) + if modelErr != nil { + return common.CodeServerError, modelErr + } + for _, model := range models { + if err = checkModelPermission(ctx, userID, permission.ResourceKindModel, model.ID, permission.OperationDelete); err != nil { + code, normalizedErr := normalizeModelPermissionError(err) + return code, normalizedErr + } + } } if len(notExistInstances) > 0 { @@ -1960,21 +2116,13 @@ func (m *ModelProviderService) DropProviderInstances(ctx context.Context, provid } func (m *ModelProviderService) DropInstanceModels(ctx context.Context, providerIDOrName, instanceIDOrName, userID string, modelNames []string) (common.ErrorCode, error) { - // Get tenant ID from user - tenants, err := m.userTenantDAO.GetByUserIDAndRole(ctx, dao.DB, userID, "owner") - if err != nil { - return common.CodeServerError, err - } - - if len(tenants) == 0 { - return common.CodeNotFound, errors.New("user has no tenants") - } - - tenantID := tenants[0].TenantID - // Get provider by ID or name (matches Python's get_by_tenant_id_and_provider_id → get_by_tenant_id_and_provider_name fallback). - provider, err := m.getProviderByIDOrName(ctx, tenantID, providerIDOrName) + provider, err := m.getProviderByIDOrName(ctx, userID, providerIDOrName, permission.OperationRead) if err != nil { + if permissionresponse.IsPermissionError(err) { + code, normalizedErr := normalizeModelPermissionError(err) + return code, normalizedErr + } return common.CodeDataError, fmt.Errorf("no provider found for provider '%s'", providerIDOrName) } @@ -1986,6 +2134,10 @@ func (m *ModelProviderService) DropInstanceModels(ctx context.Context, providerI if err != nil { return common.CodeDataError, fmt.Errorf("no instance found for provider '%s' and instance '%s'", providerIDOrName, instanceIDOrName) } + if err = checkModelPermission(ctx, userID, permission.ResourceKindModelInstance, instance.ID, permission.OperationRead); err != nil { + code, normalizedErr := normalizeModelPermissionError(err) + return code, normalizedErr + } // Fetch all models under this instance and validate all requested names exist. modelObjs, err := m.modelDAO.GetModelsByInstanceID(ctx, dao.DB, instance.ID) @@ -2016,6 +2168,10 @@ func (m *ModelProviderService) DropInstanceModels(ctx context.Context, providerI idsToDelete := make([]string, 0, len(modelNames)) for _, obj := range modelObjs { if _, ok := requestedNames[obj.ModelName]; ok { + if err = checkModelPermission(ctx, userID, permission.ResourceKindModel, obj.ID, permission.OperationDelete); err != nil { + code, normalizedErr := normalizeModelPermissionError(err) + return code, normalizedErr + } idsToDelete = append(idsToDelete, obj.ID) } } @@ -2028,22 +2184,21 @@ func (m *ModelProviderService) DropInstanceModels(ctx context.Context, providerI } func (m *ModelProviderService) ListInstanceModels(ctx context.Context, providerName, instanceName, userID string) ([]map[string]interface{}, error) { - // Get tenant ID from user - tenants, err := m.userTenantDAO.GetByUserIDAndRole(ctx, dao.DB, userID, "owner") + ownedTenantID, err := requireOwnedTenantID(ctx, userID) + if err != nil { + if permissionresponse.IsPermissionError(err) { + _, normalizedErr := normalizeModelPermissionError(err) + return nil, normalizedErr + } + return nil, err + } + provider, err := m.findProviderByIDOrNameInTenant(ctx, ownedTenantID, providerName) if err != nil { return nil, err } - - if len(tenants) == 0 { - return nil, errors.New("user has no tenants") - } - - tenantID := tenants[0].TenantID - - // Find provider by ID or name - provider, err := m.getProviderByIDOrName(ctx, tenantID, providerName) - if err != nil { - return nil, err + if err = checkModelPermission(ctx, userID, permission.ResourceKindModelProvider, provider.ID, permission.OperationRead); err != nil { + _, normalizedErr := normalizeModelPermissionError(err) + return nil, normalizedErr } // Find instance by ID first, then by name @@ -2054,6 +2209,10 @@ func (m *ModelProviderService) ListInstanceModels(ctx context.Context, providerN return nil, err } } + if err = checkModelPermission(ctx, userID, permission.ResourceKindModelInstance, instance.ID, permission.OperationRead); err != nil { + _, normalizedErr := normalizeModelPermissionError(err) + return nil, normalizedErr + } // Get all models for this instance modelObjs, err := m.modelDAO.GetModelsByInstanceID(ctx, dao.DB, instance.ID) @@ -2063,6 +2222,13 @@ func (m *ModelProviderService) ListInstanceModels(ctx context.Context, providerN modelList := make([]map[string]interface{}, 0, len(modelObjs)) for _, model := range modelObjs { + if err = checkModelPermission(ctx, userID, permission.ResourceKindModel, model.ID, permission.OperationRead); err != nil { + if errors.Is(err, permission.ErrPermissionDenied) || errors.Is(err, permission.ErrResourceNotFound) { + continue + } + _, normalizedErr := normalizeModelPermissionError(err) + return nil, normalizedErr + } modelExtra := make(map[string]interface{}) if model.Extra != "" { _ = json.Unmarshal([]byte(model.Extra), &modelExtra) @@ -2124,21 +2290,13 @@ func (m *ModelProviderService) AlterModel(ctx context.Context, providerName, ins } } - // Get tenant ID from user - tenants, err := m.userTenantDAO.GetByUserIDAndRole(ctx, dao.DB, userID, "owner") - if err != nil { - return common.CodeServerError, err - } - - if len(tenants) == 0 { - return common.CodeNotFound, errors.New("user has no tenants") - } - - tenantID := tenants[0].TenantID - // Check if provider exists - provider, err := m.getProviderByIDOrName(ctx, tenantID, providerName) + provider, err := m.getProviderByIDOrName(ctx, userID, providerName, permission.OperationRead) if err != nil { + if permissionresponse.IsPermissionError(err) { + code, normalizedErr := normalizeModelPermissionError(err) + return code, normalizedErr + } return common.CodeServerError, err } @@ -2146,6 +2304,10 @@ func (m *ModelProviderService) AlterModel(ctx context.Context, providerName, ins if err != nil { return common.CodeServerError, err } + if err = checkModelPermission(ctx, userID, permission.ResourceKindModelInstance, instance.ID, permission.OperationRead); err != nil { + code, normalizedErr := normalizeModelPermissionError(err) + return code, normalizedErr + } var model *entity.TenantModel if modelName != "" { @@ -2171,6 +2333,10 @@ func (m *ModelProviderService) AlterModel(ctx context.Context, providerName, ins return common.CodeNotFound, fmt.Errorf("model with ID %q does not belong to provider %q and instance %q", modelID, providerName, instanceName) } } + if err = checkModelPermission(ctx, userID, permission.ResourceKindModel, model.ID, permission.OperationUpdate); err != nil { + code, normalizedErr := normalizeModelPermissionError(err) + return code, normalizedErr + } toUpdate := make(map[string]interface{}) @@ -2267,43 +2433,63 @@ func (m *ModelProviderService) getModelInstanceAndProviderByName(ctx context.Con if providerName == nil || instanceName == nil || modelName == nil { return nil, errors.New("provider name, instance name and model name are required when model id is absent") } - // Get tenant ID from user - tenants, err := m.userTenantDAO.GetByUserIDAndRole(ctx, dao.DB, userID, "owner") + providerIDs, err := accessibleModelResourceIDs(ctx, userID, permission.ResourceKindModelProvider, permission.ResourceRef{}, permission.OperationRead, nil, "") if err != nil { return nil, err } - if len(tenants) == 0 { - return nil, fmt.Errorf("no tenant found for user %s", userID) + var providerEntity *entity.TenantModelProvider + var instanceEntity *entity.TenantModelInstance + var modelEntity *entity.TenantModel + for _, providerID := range providerIDs { + candidateProvider, providerErr := m.modelProviderDAO.GetByID(ctx, dao.DB, providerID) + if providerErr != nil { + return nil, providerErr + } + if !strings.EqualFold(candidateProvider.ProviderName, *providerName) { + continue + } + candidateInstance, instanceErr := m.modelInstanceDAO.GetByProviderIDAndInstanceName(ctx, dao.DB, candidateProvider.ID, *instanceName) + if instanceErr != nil { + if errors.Is(instanceErr, gorm.ErrRecordNotFound) { + continue + } + return nil, instanceErr + } + if checkErr := checkModelPermission(ctx, userID, permission.ResourceKindModelInstance, candidateInstance.ID, permission.OperationUse); checkErr != nil { + if errors.Is(checkErr, permission.ErrPermissionDenied) || errors.Is(checkErr, permission.ErrResourceNotFound) { + continue + } + return nil, checkErr + } + providerEntity = candidateProvider + instanceEntity = candidateInstance + modelEntity, err = m.modelDAO.GetModelByProviderIDAndInstanceIDAndModelName(ctx, dao.DB, providerEntity.ID, instanceEntity.ID, *modelName) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + if errors.Is(err, gorm.ErrRecordNotFound) { + modelEntity = nil + } + if modelEntity != nil { + if checkErr := checkModelPermission(ctx, userID, permission.ResourceKindModel, modelEntity.ID, permission.OperationUse); checkErr != nil { + return nil, checkErr + } + } + break + } + if providerEntity == nil || instanceEntity == nil { + return nil, permission.ErrResourceNotFound } - tenantID := tenants[0].TenantID - - // Check if provider exists - providerEntity, err := m.modelProviderDAO.GetByTenantIDAndProviderName(ctx, dao.DB, tenantID, *providerName) - if err != nil { - return nil, err - } - - instanceEntity, err := m.modelInstanceDAO.GetByProviderIDAndInstanceName(ctx, dao.DB, providerEntity.ID, *instanceName) - if err != nil { - return nil, err - } - - modelEntity, err := m.modelDAO.GetModelByProviderIDAndInstanceIDAndModelName(ctx, dao.DB, providerEntity.ID, instanceEntity.ID, *modelName) - if err != nil { - // Not found model - modelEntity = nil - } - - providerInfo := dao.GetModelProviderManager().FindProvider(*providerName) + providerInfo := dao.GetModelProviderManager().FindProvider(providerEntity.ProviderName) if providerInfo == nil { return nil, errors.New("provider not found") } - modelInfo, err := dao.GetModelProviderManager().GetModelByName(*providerName, *modelName) + modelInfo, err := dao.GetModelProviderManager().GetModelByName(providerEntity.ProviderName, *modelName) if err != nil { - return nil, errors.New(fmt.Sprintf("provider %s model %s not found", *providerName, *modelName)) + return nil, fmt.Errorf("provider %s model %s not found", providerEntity.ProviderName, *modelName) } modelInfo, err = modelInfoWithTenantExtra(modelInfo, modelEntity) if err != nil { @@ -2340,35 +2526,28 @@ func (m *ModelProviderService) getModelInstanceAndProviderByName(ctx context.Con } func (m *ModelProviderService) getModelInstanceAndProviderByID(ctx context.Context, modelID *string, userID string, apiConfig *modelModule.APIConfig) (*ModelInstanceAndProviderInfo, error) { - // Get tenant ID from user - tenants, err := m.userTenantDAO.GetByUserIDAndRole(ctx, dao.DB, userID, "owner") - if err != nil { - return nil, err - } - - if len(tenants) == 0 { - return nil, err - } - - tenantID := tenants[0].TenantID - modelEntity, err := m.modelDAO.GetByID(ctx, dao.DB, *modelID) if err != nil { return nil, err } + if err = checkModelPermission(ctx, userID, permission.ResourceKindModel, modelEntity.ID, permission.OperationUse); err != nil { + return nil, err + } instanceEntity, err := m.modelInstanceDAO.GetByID(ctx, dao.DB, modelEntity.InstanceID) if err != nil { return nil, err } + if err = checkModelPermission(ctx, userID, permission.ResourceKindModelInstance, instanceEntity.ID, permission.OperationUse); err != nil { + return nil, err + } providerEntity, err := m.modelProviderDAO.GetByID(ctx, dao.DB, instanceEntity.ProviderID) if err != nil { return nil, err } - - if providerEntity.TenantID != tenantID { - return nil, errors.New("provider not found") + if err = checkModelPermission(ctx, userID, permission.ResourceKindModelProvider, providerEntity.ID, permission.OperationRead); err != nil { + return nil, err } providerInfo := dao.GetModelProviderManager().FindProvider(providerEntity.ProviderName) @@ -2505,22 +2684,15 @@ func (m *ModelProviderService) AddModel(ctx context.Context, request *AddModelRe return common.CodeBadRequest, errors.New("model_name is required") } - tenants, err := m.userTenantDAO.GetByUserIDAndRole(ctx, dao.DB, userID, "owner") - if err != nil { - return common.CodeServerError, err - } - if len(tenants) == 0 { - return common.CodeNotFound, errors.New("user has no tenants") - } - - tenantID := tenants[0].TenantID - // Get provider by ID or name (matches Python's get_by_tenant_id_and_provider_id → get_by_tenant_id_and_provider_name fallback). - provider, err := m.getProviderByIDOrName(ctx, tenantID, request.ProviderName) + provider, err := m.getProviderByIDOrName(ctx, userID, request.ProviderName, permission.OperationRead) if err != nil { + if permissionresponse.IsPermissionError(err) { + code, normalizedErr := normalizeModelPermissionError(err) + return code, normalizedErr + } return common.CodeDataError, fmt.Errorf("no provider found for provider '%s'", request.ProviderName) } - // Get instance by ID or name (matches Python's get_by_id → get_by_provider_id_and_instance_name fallback). instance, err := m.modelInstanceDAO.GetByID(ctx, dao.DB, request.InstanceName) if err != nil || instance.ProviderID != provider.ID { @@ -2529,6 +2701,10 @@ func (m *ModelProviderService) AddModel(ctx context.Context, request *AddModelRe if err != nil { return common.CodeDataError, fmt.Errorf("no instance found for provider '%s' and instance '%s'", request.ProviderName, request.InstanceName) } + if err = checkModelPermission(ctx, userID, permission.ResourceKindModelInstance, instance.ID, permission.OperationCreate); err != nil { + code, normalizedErr := normalizeModelPermissionError(err) + return code, normalizedErr + } // Check for duplicate model. _, err = m.modelDAO.GetModelByProviderIDAndInstanceIDAndModelName(ctx, dao.DB, provider.ID, instance.ID, modelName) diff --git a/internal/service/model_service_test.go b/internal/service/model_service_test.go index 696f278ffe..eb0109ef3c 100644 --- a/internal/service/model_service_test.go +++ b/internal/service/model_service_test.go @@ -13,6 +13,7 @@ import ( "ragflow/internal/dao" "ragflow/internal/entity" modelModule "ragflow/internal/entity/models" + "ragflow/internal/permission" ) type remoteModelProbeDriver struct { @@ -476,6 +477,211 @@ func TestModelProviderServiceAlterModelStatusByID(t *testing.T) { } } +func TestModelPermissionsAllowMemberUseButHideInstanceConfiguration(t *testing.T) { + db := setupModelProviderServiceTestDB(t) + useModelProviderServiceTestDB(t, db) + seedModelProviderServiceScope(t, db) + activeStatus := "1" + if err := db.Create(&entity.UserTenant{ + ID: "user-tenant-member", + UserID: "member-1", + TenantID: "tenant-1", + Role: string(permission.RoleNormal), + InvitedBy: "user-1", + Status: &activeStatus, + }).Error; err != nil { + t.Fatalf("seed tenant member: %v", err) + } + + ctx := t.Context() + service := NewModelProviderService() + providers, code, err := service.ListProvidersOfTenant(ctx, "member-1") + if err != nil || code != common.CodeSuccess || len(providers) != 0 { + t.Fatalf("ListProvidersOfTenant() = (%v, %v, %d providers), want success and no providers for a non-owner", code, err, len(providers)) + } + + if _, code, err = service.ShowProviderInstance(ctx, "OpenAI", "instance-1", "member-1"); err == nil || code != common.CodeNotFound { + t.Fatalf("ShowProviderInstance() = (%v, %v), want hidden instance denial", code, err) + } + + factory := NewModelFactory() + access := ModelAccess{UserID: "member-1", TenantID: "tenant-1"} + if _, err = factory.resolveConfig(ctx, access, entity.ModelTypeChat, "model-1"); err != nil { + t.Fatalf("member should be able to use the tenant model: %v", err) + } + if _, err = factory.resolveConfig(ctx, access, entity.ModelTypeChat, "gpt-test@default@OpenAI"); err != nil { + t.Fatalf("member should be able to use the composite model reference: %v", err) + } + + code, err = service.AlterModel(ctx, "OpenAI", "default", "gpt-test", "member-1", "", map[string]interface{}{"status": "inactive"}) + if err == nil || code != common.CodeNotFound { + t.Fatalf("AlterModel() = (%v, %v), want hidden model update denial", code, err) + } + var model entity.TenantModel + if err := db.Where("id = ?", "model-1").First(&model).Error; err != nil { + t.Fatalf("reload tenant model: %v", err) + } + if model.Status != "active" { + t.Fatalf("denied update changed model status to %q", model.Status) + } +} + +func TestListProvidersOfTenantExcludesOtherTenants(t *testing.T) { + db := setupModelProviderServiceTestDB(t) + useModelProviderServiceTestDB(t, db) + seedModelProviderServiceScope(t, db) + activeStatus := "1" + rows := []interface{}{ + &entity.UserTenant{ID: "user-tenant-owner-2", UserID: "owner-2", TenantID: "tenant-2", Role: string(permission.RoleOwner), InvitedBy: "owner-2", Status: &activeStatus}, + &entity.UserTenant{ID: "user-tenant-member-2", UserID: "user-1", TenantID: "tenant-2", Role: string(permission.RoleNormal), InvitedBy: "owner-2", Status: &activeStatus}, + &entity.TenantModelProvider{ID: "provider-2", TenantID: "tenant-2", ProviderName: "Anthropic"}, + } + for _, row := range rows { + if err := db.Create(row).Error; err != nil { + t.Fatalf("failed to seed %T: %v", row, err) + } + } + + providers, code, err := NewModelProviderService().ListProvidersOfTenant(t.Context(), "user-1") + if err != nil || code != common.CodeSuccess { + t.Fatalf("ListProvidersOfTenant() = (%v, %v), want success", code, err) + } + if len(providers) != 1 || providers[0]["name"] != "OpenAI" { + t.Fatalf("ListProvidersOfTenant() = %v, want only user-1's own-tenant OpenAI provider", providers) + } +} + +func TestListProviderInstancesUsesOwnedTenantProvider(t *testing.T) { + db := setupModelProviderServiceTestDB(t) + useModelProviderServiceTestDB(t, db) + seedModelProviderServiceScope(t, db) + activeStatus := "1" + rows := []interface{}{ + &entity.UserTenant{ID: "user-tenant-owner-2", UserID: "owner-2", TenantID: "tenant-2", Role: string(permission.RoleOwner), InvitedBy: "owner-2", Status: &activeStatus}, + &entity.UserTenant{ID: "user-tenant-member-2", UserID: "user-1", TenantID: "tenant-2", Role: string(permission.RoleNormal), InvitedBy: "owner-2", Status: &activeStatus}, + &entity.TenantModelProvider{ID: "provider-2", TenantID: "tenant-2", ProviderName: "OpenAI"}, + &entity.TenantModelInstance{ID: "instance-2", ProviderID: "provider-2", InstanceName: "other-default", APIKey: "sk-other", Status: "active", Extra: "{}"}, + } + for _, row := range rows { + if err := db.Create(row).Error; err != nil { + t.Fatalf("failed to seed %T: %v", row, err) + } + } + + service := NewModelProviderService() + instances, code, err := service.ListProviderInstances(t.Context(), "OpenAI", "user-1") + if err != nil || code != common.CodeSuccess { + t.Fatalf("ListProviderInstances() = (%v, %v), want success", code, err) + } + if len(instances) != 1 || instances[0]["id"] != "instance-1" { + t.Fatalf("ListProviderInstances() = %v, want only the instance from user-1's own tenant", instances) + } + + if instances, code, err = service.ListProviderInstances(t.Context(), "provider-2", "user-1"); err == nil || len(instances) != 0 { + t.Fatalf("ListProviderInstances() for another tenant's provider = (%v, %v, %v), want it hidden", instances, code, err) + } +} + +func TestProviderModelListingUsesOwnedTenant(t *testing.T) { + db := setupModelProviderServiceTestDB(t) + useModelProviderServiceTestDB(t, db) + seedModelProviderServiceScope(t, db) + activeStatus := "1" + rows := []interface{}{ + &entity.UserTenant{ID: "user-tenant-owner-2", UserID: "owner-2", TenantID: "tenant-2", Role: string(permission.RoleOwner), InvitedBy: "owner-2", Status: &activeStatus}, + &entity.UserTenant{ID: "user-tenant-member-2", UserID: "user-1", TenantID: "tenant-2", Role: string(permission.RoleNormal), InvitedBy: "owner-2", Status: &activeStatus}, + &entity.TenantModelProvider{ID: "provider-2", TenantID: "tenant-2", ProviderName: "OpenAI"}, + &entity.TenantModelInstance{ID: "instance-2", ProviderID: "provider-2", InstanceName: "default", APIKey: "sk-other", Status: "active", Extra: "{}"}, + &entity.TenantModel{ID: "model-2", ProviderID: "provider-2", InstanceID: "instance-2", ModelName: "foreign-model", ModelType: int(entity.ModelTypeChat), Status: "active"}, + } + for _, row := range rows { + if err := db.Create(row).Error; err != nil { + t.Fatalf("failed to seed %T: %v", row, err) + } + } + + service := NewModelProviderService() + models, err := service.ListInstanceModels(t.Context(), "OpenAI", "default", "user-1") + if err != nil { + t.Fatalf("ListInstanceModels() error = %v, want success", err) + } + if len(models) != 1 || models[0]["name"] != "gpt-test" { + t.Fatalf("ListInstanceModels() = %v, want only user-1's own-tenant model", models) + } + + if _, err = service.ListInstanceModels(t.Context(), "provider-2", "default", "user-1"); !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("ListInstanceModels() for another tenant's provider error = %v, want record not found", err) + } + if _, err = service.ListSupportedModels(t.Context(), "provider-2", "default", "user-1"); !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("ListSupportedModels() for another tenant's provider error = %v, want record not found", err) + } +} + +func TestShowProviderInstanceUsesOwnedTenantProvider(t *testing.T) { + db := setupModelProviderServiceTestDB(t) + useModelProviderServiceTestDB(t, db) + seedModelProviderServiceScope(t, db) + activeStatus := "1" + rows := []interface{}{ + &entity.UserTenant{ID: "user-tenant-owner-2", UserID: "owner-2", TenantID: "tenant-2", Role: string(permission.RoleOwner), InvitedBy: "owner-2", Status: &activeStatus}, + &entity.UserTenant{ID: "user-tenant-member-2", UserID: "user-1", TenantID: "tenant-2", Role: string(permission.RoleNormal), InvitedBy: "owner-2", Status: &activeStatus}, + &entity.TenantModelProvider{ID: "provider-2", TenantID: "tenant-2", ProviderName: "OpenAI"}, + &entity.TenantModelInstance{ID: "instance-2", ProviderID: "provider-2", InstanceName: "default", APIKey: "sk-other", Status: "active", Extra: "{}"}, + } + for _, row := range rows { + if err := db.Create(row).Error; err != nil { + t.Fatalf("failed to seed %T: %v", row, err) + } + } + + service := NewModelProviderService() + instance, code, err := service.ShowProviderInstance(t.Context(), "OpenAI", "instance-1", "user-1") + if err != nil || code != common.CodeSuccess { + t.Fatalf("ShowProviderInstance() = (%v, %v), want success", code, err) + } + if instance["id"] != "instance-1" || instance["provider_id"] != "provider-1" { + t.Fatalf("ShowProviderInstance() = %v, want user-1's own-tenant instance", instance) + } + + if _, code, err = service.ShowProviderInstance(t.Context(), "provider-2", "instance-2", "user-1"); err == nil || code != common.CodeDataError { + t.Fatalf("ShowProviderInstance() for another tenant's provider = (%v, %v), want hidden provider", code, err) + } +} + +func TestCheckConnectionDeniesBeforeCallingProvider(t *testing.T) { + db := setupModelProviderServiceTestDB(t) + useModelProviderServiceTestDB(t, db) + seedModelProviderServiceScope(t, db) + activeStatus := "1" + if err := db.Create(&entity.UserTenant{ + ID: "user-tenant-member", + UserID: "member-1", + TenantID: "tenant-1", + Role: string(permission.RoleNormal), + InvitedBy: "user-1", + Status: &activeStatus, + }).Error; err != nil { + t.Fatalf("seed tenant member: %v", err) + } + + provider := dao.GetModelProviderManager().FindProvider("OpenAI") + if provider == nil { + t.Fatal("OpenAI provider is not configured") + } + probe := &remoteModelProbeDriver{DummyModel: modelModule.NewDummyModel(nil, modelModule.URLSuffix{})} + originalDriver := provider.ModelDriver + provider.ModelDriver = probe + t.Cleanup(func() { provider.ModelDriver = originalDriver }) + + code, err := NewModelProviderService().CheckConnection(t.Context(), "OpenAI", "sk-test", "default", "", "instance-1", "member-1", nil) + if err == nil || code != common.CodeNotFound { + t.Fatalf("CheckConnection() = (%v, %v), want hidden update denial", code, err) + } + if probe.checkCalls != 0 { + t.Fatalf("provider was contacted %d times before authorization", probe.checkCalls) + } +} + func TestModelFactoryResolveModelConfigByID(t *testing.T) { db := setupModelProviderServiceTestDB(t) useModelProviderServiceTestDB(t, db) diff --git a/internal/service/openai_chat.go b/internal/service/openai_chat.go index a181b05a50..c45a6c9ca2 100644 --- a/internal/service/openai_chat.go +++ b/internal/service/openai_chat.go @@ -27,6 +27,7 @@ import ( "time" "ragflow/internal/common" + permissionresponse "ragflow/internal/permission/response" "ragflow/internal/tokenizer" "github.com/gin-gonic/gin" @@ -237,6 +238,11 @@ func (s *OpenAIChatService) OpenAIChatCompletions(c *gin.Context, userID, chatID ctx := c.Request.Context() dialogResp, err := s.chatSvc.GetChat(ctx, userID, chatID) if err != nil { + if permissionresponse.IsPermissionError(err) { + code, permissionErr := permissionresponse.NormalizeHidden(err) + common.ResponseWithCodeData(c, code, nil, permissionErr.Error()) + return + } s.writeDataError(c, err.Error()) return } diff --git a/internal/service/related_question.go b/internal/service/related_question.go index e3417814ef..6f3bcf3673 100644 --- a/internal/service/related_question.go +++ b/internal/service/related_question.go @@ -29,12 +29,15 @@ import ( ) // GenerateRelatedQuestions generates related search questions for chat/searchbot endpoints. -func GenerateRelatedQuestions(ctx context.Context, tenantID, question, searchID string, searchSvc *SearchService, tenantSvc *TenantService, modelFactory *ModelFactory) ([]string, error) { +func GenerateRelatedQuestions(ctx context.Context, userID, question, searchID string, searchSvc *SearchService, tenantSvc *TenantService, modelFactory *ModelFactory) ([]string, error) { if modelFactory == nil { return nil, fmt.Errorf("model factory not configured") } - searchConfig := relatedQuestionsSearchConfig(ctx, searchID, searchSvc) - modelID := relatedQuestionsModelID(ctx, tenantID, searchConfig, tenantSvc) + searchConfig, err := relatedQuestionsSearchConfig(ctx, userID, searchID, searchSvc) + if err != nil { + return nil, err + } + modelID := relatedQuestionsModelID(ctx, userID, searchConfig, tenantSvc) if modelID == "" { return nil, fmt.Errorf("no chat model configured") } @@ -46,7 +49,7 @@ func GenerateRelatedQuestions(ctx context.Context, tenantID, question, searchID {Role: "system", Content: prompt}, {Role: "user", Content: "\nKeywords: " + question + "\nRelated search terms:\n "}, } - chatModel, err := modelFactory.NewChatModel(ctx, ModelAccess{TenantID: tenantID}, modelID) + chatModel, err := modelFactory.NewChatModel(ctx, ModelAccess{TenantID: userID}, modelID) if err != nil { return nil, err } @@ -60,14 +63,18 @@ func GenerateRelatedQuestions(ctx context.Context, tenantID, question, searchID return []string{}, nil } -func relatedQuestionsSearchConfig(ctx context.Context, searchID string, searchSvc *SearchService) map[string]interface{} { - if searchID == "" || searchSvc == nil { - return map[string]interface{}{} +func relatedQuestionsSearchConfig(ctx context.Context, userID, searchID string, searchSvc *SearchService) (map[string]interface{}, error) { + if searchID == "" { + return map[string]interface{}{}, nil } - if detail, err := searchSvc.GetDetail(ctx, searchID); err == nil && detail != nil { - return relatedQuestionsSearchConfigFromDetail(detail) + if searchSvc == nil { + return nil, fmt.Errorf("search service not configured") } - return map[string]interface{}{} + detail, err := searchSvc.GetDetail(ctx, userID, searchID) + if err != nil { + return nil, err + } + return relatedQuestionsSearchConfigFromDetail(detail), nil } func relatedQuestionsSearchConfigFromDetail(detail map[string]interface{}) map[string]interface{} { diff --git a/internal/service/search.go b/internal/service/search.go index c462973d95..668009621e 100644 --- a/internal/service/search.go +++ b/internal/service/search.go @@ -31,7 +31,6 @@ import ( // SearchService search service type SearchService struct { searchDAO *dao.SearchDAO - userTenantDAO *dao.UserTenantDAO datasetDAO *dao.KnowledgebaseDAO tenantService *TenantService } @@ -40,7 +39,6 @@ type SearchService struct { func NewSearchService() *SearchService { return &SearchService{ searchDAO: dao.NewSearchDAO(), - userTenantDAO: dao.NewUserTenantDAO(), datasetDAO: dao.NewKnowledgebaseDAO(), tenantService: NewTenantService(), } @@ -83,31 +81,13 @@ type SearchShareDetail struct { // ListSearches list search apps with advanced filtering (equivalent to list_search_app) func (s *SearchService) ListSearches(ctx context.Context, userID string, keywords string, page, pageSize int, terms []dao.OrderTerm, ownerIDs []string) (*ListSearchAppsResponse, error) { - var searches []*entity.SearchListItem - var total int64 - var err error - - if len(ownerIDs) == 0 { - searches, total, err = s.searchDAO.ListByTenantIDs(ctx, dao.DB, nil, userID, page, pageSize, terms, keywords) - if err != nil { - return nil, err - } - } else { - ownerIDs, err = s.filterAccessibleSearchOwnerIDs(ctx, userID, ownerIDs) - if err != nil { - return nil, err - } - if len(ownerIDs) == 0 { - return &ListSearchAppsResponse{ - SearchApps: []map[string]interface{}{}, - Total: 0, - }, nil - } - - searches, total, err = s.searchDAO.ListByOwnerIDs(ctx, dao.DB, ownerIDs, userID, page, pageSize, terms, keywords) - if err != nil { - return nil, err - } + resourceIDs, err := accessibleSearchAppIDs(ctx, userID, permission.OperationRead) + if err != nil { + return nil, err + } + searches, total, err := s.searchDAO.ListByResourceIDs(ctx, dao.DB, resourceIDs, ownerIDs, page, pageSize, terms, keywords) + if err != nil { + return nil, err } // Convert to response format @@ -122,39 +102,6 @@ func (s *SearchService) ListSearches(ctx context.Context, userID string, keyword }, nil } -func (s *SearchService) filterAccessibleSearchOwnerIDs(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 -} - // toSearchAppResponse converts search model to response format func (s *SearchService) toSearchAppResponse(search *entity.SearchListItem) map[string]interface{} { result := map[string]interface{}{ @@ -246,34 +193,9 @@ func (s *SearchService) CreateSearch(ctx context.Context, userID string, name st } func (s *SearchService) GetSearchDetail(ctx context.Context, userID string, searchID string) (*entity.Search, 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 != nil { - return nil, fmt.Errorf("failed to get user tenants: %w", err) + if err := checkSearchAppPermission(ctx, userID, searchID, permission.OperationRead); err != nil { + return nil, err } - - // Step 2: Check if user has permission to access this search - // Python: for tenant in tenants: if SearchService.query(tenant_id=tenant.tenant_id, id=search_id): break - hasPermission := false - for _, tenant := range tenants { - searches, err := s.searchDAO.QueryByTenantIDAndID(ctx, dao.DB, tenant.TenantID, searchID) - if err != nil { - if ctx.Err() != nil { - return nil, ctx.Err() - } - continue // Try next tenant - } - if len(searches) > 0 { - hasPermission = true - break - } - } - - if !hasPermission { - return nil, fmt.Errorf("has no permission for this operation") - } - - // Step 3: Get search detail (same as Python SearchService.get_detail(search_id)) search, err := s.searchDAO.GetByID(ctx, dao.DB, searchID) if err != nil { return nil, fmt.Errorf("can't find this Search App") @@ -311,32 +233,16 @@ func (s *SearchService) GetSearchShareDetail(ctx context.Context, userID, search // DeleteSearch deletes a search app by ID func (s *SearchService) DeleteSearch(ctx context.Context, userID string, searchID string) error { - // Step 1: Check deletion permission (same as Python SearchService.accessible4deletion) - // Python: cls.model.select().where(cls.model.id == search_id, cls.model.created_by == user_id, cls.model.status == StatusEnum.VALID.value).first() - - status, err := s.searchDAO.Accessible4Deletion(ctx, dao.DB, searchID, userID) - if err != nil { - return fmt.Errorf("failed to check deletion permission: %w", err) + if err := checkSearchAppPermission(ctx, userID, searchID, permission.OperationDelete); err != nil { + return err } - - if !status { - return fmt.Errorf("no authorization") - } - - // Step 2: Execute delete (same as Python SearchService.delete_by_id) - // Python: cls.model.delete().where(cls.model.id == pid).execute() - if err = s.searchDAO.DeleteByID(ctx, dao.DB, userID, searchID); err != nil { + if err := s.searchDAO.DeleteByID(ctx, dao.DB, searchID); err != nil { return fmt.Errorf("failed to delete search App %s: %w", searchID, err) } return nil } -// AccessibleForCompletion check if it is accessible -func (s *SearchService) AccessibleForCompletion(ctx context.Context, userID string, searchID string) (bool, error) { - return s.searchDAO.Accessible4Deletion(ctx, dao.DB, searchID, userID) -} - type SearchCompletionPlan struct { UserID string SearchID string @@ -367,16 +273,12 @@ func (s *SearchService) PrepareCompletion(ctx context.Context, userID, searchID return nil, common.CodeArgumentError, fmt.Errorf("question is required") } - accessible, err := s.AccessibleForCompletion(ctx, userID, searchID) - if err != nil { - return nil, common.CodeServerError, err - } - if !accessible { - return nil, common.CodeAuthenticationError, fmt.Errorf("no authorization") - } - - searchDetail, err := s.GetDetail(ctx, searchID) + searchDetail, err := s.GetDetail(ctx, userID, searchID) if err != nil || searchDetail == nil { + if permissionresponse.IsPermissionError(err) { + code, permissionErr := permissionresponse.NormalizeHidden(err) + return nil, code, permissionErr + } return nil, common.CodeDataError, fmt.Errorf("cannot find search %s", searchID) } searchConfig := searchConfigMapFromValue(searchDetail["search_config"]) @@ -595,30 +497,19 @@ type UpdateSearchRequest struct { } func (s *SearchService) UpdateSearch(ctx context.Context, userID string, searchID string, req *UpdateSearchRequest) (*entity.Search, error) { - // Step 1: Check update permission (same as delete - uses accessible4deletion) - // Only creator can update. A missing or non-owned search is treated as - // unauthorized so the contract returns a clear "no authorization" error. - - accessible, err := s.searchDAO.Accessible4Deletion(ctx, dao.DB, searchID, userID) - if err != nil { - return nil, fmt.Errorf("failed to check deletion permission: %w", err) - } - if !accessible { - return nil, fmt.Errorf("no authorization") + if err := checkSearchAppPermission(ctx, userID, searchID, permission.OperationUpdate); err != nil { + return nil, err } - // Step 2: Get existing search - // Python: search_app = SearchService.query(tenant_id=current_user.id, id=search_id)[0] - search, err := s.searchDAO.GetByTenantIDAndID(ctx, dao.DB, userID, searchID) + search, err := s.searchDAO.GetByID(ctx, dao.DB, searchID) if err != nil { return nil, fmt.Errorf("cannot find search %s", searchID) } - // Step 3: Check for duplicate name. Case-only changes are allowed, matching - // the dataset rename rule. + // Check for duplicate names within the Search App's tenant. trimmedName := req.Name available, err := common.NameAvailable(search.Name, trimmedName, func(candidate string) (bool, error) { - existing, err := s.searchDAO.GetByNameAndTenant(ctx, dao.DB, candidate, userID) + existing, err := s.searchDAO.GetByNameAndTenant(ctx, dao.DB, candidate, search.TenantID) if err != nil { return false, err } @@ -634,8 +525,7 @@ func (s *SearchService) UpdateSearch(ctx context.Context, userID string, searchI return nil, err } - // Step 4: Merge search_config - // Python: req["search_config"] = {**current_config, **new_config} + // Merge the provided search configuration into the stored configuration. currentConfig := search.SearchConfig if currentConfig == nil { currentConfig = make(entity.JSONMap) @@ -650,8 +540,7 @@ func (s *SearchService) UpdateSearch(ctx context.Context, userID string, searchI mergedConfig[k] = v } - // Step 5: Prepare updates (excluding immutable fields) - // Python removes: search_id, tenant_id, created_by, update_time, id + // Keep ownership and identity fields immutable. updates := map[string]interface{}{ "name": trimmedName, "search_config": mergedConfig, @@ -664,14 +553,11 @@ func (s *SearchService) UpdateSearch(ctx context.Context, userID string, searchI updates["avatar"] = *req.Avatar } - // Step 6: Execute update - // Python: SearchService.update_by_id(search_id, req) + // Persist the update and reload the resource. if err = s.searchDAO.UpdateByID(ctx, dao.DB, searchID, updates); err != nil { return nil, fmt.Errorf("failed to update search: %w", err) } - // Step 7: Fetch updated search - // Python: e, updated_search = SearchService.get_by_id(search_id) updatedSearch, err := s.searchDAO.GetByID(ctx, dao.DB, searchID) if err != nil { return nil, fmt.Errorf("failed to fetch updated search: %w", err) @@ -680,8 +566,11 @@ func (s *SearchService) UpdateSearch(ctx context.Context, userID string, searchI return updatedSearch, nil } -// GetDetail gets search details by ID including search_config -func (s *SearchService) GetDetail(ctx context.Context, searchID string) (map[string]interface{}, error) { +// GetDetail returns Search App configuration to a caller that is executing it. +func (s *SearchService) GetDetail(ctx context.Context, userID, searchID string) (map[string]interface{}, error) { + if err := checkSearchAppPermission(ctx, userID, searchID, permission.OperationRun); err != nil { + return nil, err + } search, err := s.searchDAO.GetByID(ctx, dao.DB, searchID) if err != nil { return nil, err diff --git a/internal/service/search_permission.go b/internal/service/search_permission.go new file mode 100644 index 0000000000..b468a02c9a --- /dev/null +++ b/internal/service/search_permission.go @@ -0,0 +1,47 @@ +// +// 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/dao" + "ragflow/internal/permission" +) + +func checkSearchAppPermission(ctx context.Context, userID, searchID string, operation permission.Operation) error { + return permission.NewDatabaseChecker(dao.DB).CheckResource( + ctx, + permission.Subject{UserID: userID}, + permission.ResourceRef{Kind: permission.ResourceKindSearchApp, ID: searchID}, + operation, + ) +} + +func accessibleSearchAppIDs(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.ResourceKindSearchApp, + Operation: operation, + }) + if err != nil { + return nil, err + } + if scope.Mode != permission.ScopeIDs { + return []string{}, nil + } + return scope.ResourceIDs, nil +} diff --git a/internal/service/search_share_detail_test.go b/internal/service/search_share_detail_test.go index cf61842e54..772b038182 100644 --- a/internal/service/search_share_detail_test.go +++ b/internal/service/search_share_detail_test.go @@ -17,11 +17,12 @@ package service import ( - "strings" + "errors" "testing" "ragflow/internal/dao" "ragflow/internal/entity" + "ragflow/internal/permission" ) func setupSearchShareServiceDB(t *testing.T) { @@ -150,7 +151,7 @@ func TestSearchServiceGetSearchShareDetailRejectsUnauthorizedUser(t *testing.T) if err == nil { t.Fatal("expected permission error") } - if !strings.Contains(err.Error(), "has no permission") { + if !errors.Is(err, permission.ErrPermissionDenied) { t.Fatalf("err = %v, want permission error", err) } } diff --git a/internal/service/search_test.go b/internal/service/search_test.go index 296c1c9fbc..c76f0a8263 100644 --- a/internal/service/search_test.go +++ b/internal/service/search_test.go @@ -17,6 +17,7 @@ package service import ( + "errors" "fmt" "math" "strings" @@ -24,6 +25,7 @@ import ( "ragflow/internal/dao" "ragflow/internal/entity" + "ragflow/internal/permission" ) func setupSearchServiceTestDB(t *testing.T) { @@ -76,7 +78,7 @@ func TestSearchServiceUpdateRejectsUnauthorizedSearchID(t *testing.T) { if err == nil { t.Fatal("expected authorization error") } - if err.Error() != "no authorization" { + if !errors.Is(err, permission.ErrResourceNotFound) { t.Fatalf("unexpected error: %v", err) } } @@ -98,8 +100,8 @@ func TestSearchServiceCreateAndUpdateRoundTrip(t *testing.T) { SearchConfig: map[string]interface{}{}, } _, err = NewSearchService().UpdateSearch(ctx, "user-2", created.SearchID, req) - 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) } // The owner can update name + merge config. @@ -193,10 +195,82 @@ func TestSearchServiceListSearchesNicknameFallsBackToTenantID(t *testing.T) { } } -// TestSearchServiceListSearchesPaginatesOwnerBranch covers the owner_ids branch -// end to end: pagination happens in SQL now, so each page must carry only its -// own rows while total still reports every matching search. -func TestSearchServiceListSearchesPaginatesOwnerBranch(t *testing.T) { +func TestSearchServiceListSearchesUsesPermissionScope(t *testing.T) { + setupSearchServiceTestDB(t) + valid := string(entity.StatusValid) + for _, row := range []entity.Search{ + {ID: "team-search", TenantID: "team-tenant", CreatedBy: "team-owner", Name: "Team Search", SearchConfig: entity.JSONMap{}, Status: &valid}, + {ID: "member-search", TenantID: "member-1", CreatedBy: "member-1", Name: "Member Search", SearchConfig: entity.JSONMap{}, Status: &valid}, + {ID: "unshared-search", TenantID: "other-tenant", CreatedBy: "other-owner", Name: "Unshared Search", SearchConfig: entity.JSONMap{}, Status: &valid}, + } { + search := row + if err := dao.DB.Create(&search).Error; err != nil { + t.Fatalf("create search %s: %v", search.ID, err) + } + } + for _, membership := range []entity.UserTenant{ + {ID: "member-team", UserID: "member-1", TenantID: "team-tenant", Role: string(permission.RoleNormal), Status: &valid}, + {ID: "owner-team", UserID: "team-owner", TenantID: "team-tenant", Role: string(permission.RoleOwner), Status: &valid}, + } { + row := membership + if err := dao.DB.Create(&row).Error; err != nil { + t.Fatalf("create membership %s: %v", row.ID, err) + } + } + + result, err := NewSearchService().ListSearches(t.Context(), "member-1", "", 0, 0, nil, nil) + if err != nil { + t.Fatalf("ListSearches failed: %v", err) + } + if result.Total != 1 || len(result.SearchApps) != 1 { + t.Fatalf("ListSearches returned total=%d rows=%d, want only the member-owned app", result.Total, len(result.SearchApps)) + } + ids := map[string]bool{} + for _, app := range result.SearchApps { + ids[app["id"].(string)] = true + } + if ids["team-search"] || !ids["member-search"] || ids["unshared-search"] { + t.Fatalf("ListSearches returned unauthorized scope: %v", ids) + } +} + +func TestSearchServiceSeparatesSearchReadFromRunPermission(t *testing.T) { + setupSearchServiceTestDB(t) + valid := string(entity.StatusValid) + search := entity.Search{ + ID: "team-search", + TenantID: "team-tenant", + CreatedBy: "team-owner", + Name: "Team Search", + SearchConfig: entity.JSONMap{}, + Status: &valid, + } + if err := dao.DB.Create(&search).Error; err != nil { + t.Fatalf("create search: %v", err) + } + membership := entity.UserTenant{ + ID: "member-team", + UserID: "member-1", + TenantID: "team-tenant", + Role: string(permission.RoleNormal), + Status: &valid, + } + if err := dao.DB.Create(&membership).Error; err != nil { + t.Fatalf("create membership: %v", err) + } + + service := NewSearchService() + if _, err := service.GetSearchDetail(t.Context(), "member-1", search.ID); !errors.Is(err, permission.ErrPermissionDenied) { + t.Fatalf("tenant member should not be able to read another user's Search App metadata, got %v", err) + } + if _, err := service.GetDetail(t.Context(), "member-1", search.ID); !errors.Is(err, permission.ErrPermissionDenied) { + t.Fatalf("tenant member should not be able to run another user's Search App, got %v", err) + } +} + +// TestSearchServiceListSearchesPaginatesOwnerFilter verifies that owner_ids +// filtering and pagination happen in SQL while total counts all matching rows. +func TestSearchServiceListSearchesPaginatesOwnerFilter(t *testing.T) { setupSearchServiceTestDB(t) ctx := t.Context() diff --git a/internal/service/skill_indexer.go b/internal/service/skill_indexer.go index 6a0135bef2..b17b941ae3 100644 --- a/internal/service/skill_indexer.go +++ b/internal/service/skill_indexer.go @@ -27,6 +27,7 @@ import ( "ragflow/internal/engine" "ragflow/internal/entity" "ragflow/internal/entity/models" + "ragflow/internal/permission" "ragflow/internal/storage" "ragflow/internal/tokenizer" "strings" @@ -77,7 +78,15 @@ func isElasticsearch(docEngine engine.DocEngine) bool { // Uses skill_id as doc_id for direct mapping, with version control for incremental updates // For ES: xxx fields store original content, xxx_tks fields store RAG-tokenized content (space-separated) // For Infinity: only xxx fields with built-in rag-analyzer -func (s *SkillIndexerService) IndexSkill(ctx context.Context, tenantID, spaceID string, skill SkillInfo, docEngine engine.DocEngine, embdID string) error { +func (s *SkillIndexerService) IndexSkill(ctx context.Context, userID, spaceID string, skill SkillInfo, docEngine engine.DocEngine, embdID string) error { + tenantID, err := resolveSkillSpaceTenantID(ctx, userID, spaceID, permission.OperationUpdate) + if err != nil { + return err + } + return s.indexSkill(ctx, tenantID, spaceID, skill, docEngine, embdID) +} + +func (s *SkillIndexerService) indexSkill(ctx context.Context, tenantID, spaceID string, skill SkillInfo, docEngine engine.DocEngine, embdID string) error { spaceID = normalizeSpaceID(spaceID) config, err := s.configDAO.GetOrCreate(ctx, dao.DB, tenantID, spaceID, embdID) @@ -181,7 +190,7 @@ func (s *SkillIndexerService) IndexSkill(ctx context.Context, tenantID, spaceID // Delete old versions (both new format and old format with version suffix) // This ensures only the latest version is indexed common.Debug(fmt.Sprintf("Deleting old versions of skill if exists: indexName=%s, skillName=%s", indexName, skill.Name)) - if err := s.DeleteSkillByName(ctx, tenantID, spaceID, skill.Name, docEngine); err != nil { + if err := s.deleteSkillByName(ctx, tenantID, spaceID, skill.Name, docEngine); err != nil { common.Debug(fmt.Sprintf("No existing document to delete for skill %s (this is normal for new skills)", skill.Name)) } @@ -200,7 +209,15 @@ func (s *SkillIndexerService) IndexSkill(ctx context.Context, tenantID, spaceID // BatchIndexSkills indexes multiple skills in batch // Optimized to use batch embedding API for better performance -func (s *SkillIndexerService) BatchIndexSkills(ctx context.Context, tenantID, spaceID string, skills []SkillInfo, docEngine engine.DocEngine, embdID string) error { +func (s *SkillIndexerService) BatchIndexSkills(ctx context.Context, userID, spaceID string, skills []SkillInfo, docEngine engine.DocEngine, embdID string) error { + tenantID, err := resolveSkillSpaceTenantID(ctx, userID, spaceID, permission.OperationUpdate) + if err != nil { + return err + } + return s.batchIndexSkills(ctx, tenantID, spaceID, skills, docEngine, embdID) +} + +func (s *SkillIndexerService) batchIndexSkills(ctx context.Context, tenantID, spaceID string, skills []SkillInfo, docEngine engine.DocEngine, embdID string) error { spaceID = normalizeSpaceID(spaceID) if len(skills) == 0 { return nil @@ -268,7 +285,7 @@ func (s *SkillIndexerService) BatchIndexSkills(ctx context.Context, tenantID, sp } } else { // For ES: just ensure index exists - if err := s.EnsureIndex(ctx, tenantID, spaceID, docEngine, embdID); err != nil { + if err := s.initializeIndex(ctx, tenantID, spaceID, docEngine, embdID); err != nil { return fmt.Errorf("failed to ensure index exists: %w", err) } } @@ -282,7 +299,7 @@ func (s *SkillIndexerService) BatchIndexSkills(ctx context.Context, tenantID, sp for i, skill := range skills { // Delete old versions (both new format and old format with version suffix) // This ensures only the latest version is indexed - if err := s.DeleteSkillByName(ctx, tenantID, spaceID, skill.Name, docEngine); err != nil { + if err := s.deleteSkillByName(ctx, tenantID, spaceID, skill.Name, docEngine); err != nil { common.Debug(fmt.Sprintf("No existing document to delete for skill %s (this is normal for new skills)", skill.Name)) } @@ -357,7 +374,11 @@ func (s *SkillIndexerService) BatchIndexSkills(ctx context.Context, tenantID, sp // DeleteSkillIndex deletes a skill's index by skill ID // Returns nil if the document doesn't exist (idempotent delete) -func (s *SkillIndexerService) DeleteSkillIndex(ctx context.Context, tenantID, spaceID, skillID string, docEngine engine.DocEngine) error { +func (s *SkillIndexerService) DeleteSkillIndex(ctx context.Context, userID, spaceID, skillID string, docEngine engine.DocEngine) error { + tenantID, err := resolveSkillSpaceTenantID(ctx, userID, spaceID, permission.OperationUpdate) + if err != nil { + return err + } spaceID = normalizeSpaceID(spaceID) indexName := SkillIndexName(tenantID, spaceID) // ES document ID cannot contain '/' - replace with '_' @@ -374,9 +395,7 @@ func (s *SkillIndexerService) DeleteSkillIndex(ctx context.Context, tenantID, sp return nil } -// DeleteSkillByName deletes a skill's index by skill name -// Deletes all versions: both new format (skillname) and old format (skillname_x.x.x) -func (s *SkillIndexerService) DeleteSkillByName(ctx context.Context, tenantID, spaceID, skillName string, docEngine engine.DocEngine) error { +func (s *SkillIndexerService) deleteSkillByName(ctx context.Context, tenantID, spaceID, skillName string, docEngine engine.DocEngine) error { spaceID = normalizeSpaceID(spaceID) indexName := SkillIndexName(tenantID, spaceID) @@ -390,15 +409,19 @@ func (s *SkillIndexerService) DeleteSkillByName(ctx context.Context, tenantID, s // UpdateSkillVersion updates a skill's index when version changes // Deletes old version and indexes new version -func (s *SkillIndexerService) UpdateSkillVersion(ctx context.Context, tenantID, spaceID string, skill SkillInfo, docEngine engine.DocEngine, embdID string) error { +func (s *SkillIndexerService) UpdateSkillVersion(ctx context.Context, userID, spaceID string, skill SkillInfo, docEngine engine.DocEngine, embdID string) error { + tenantID, err := resolveSkillSpaceTenantID(ctx, userID, spaceID, permission.OperationUpdate) + if err != nil { + return err + } // Delete old version first (upsert behavior) - if err := s.DeleteSkillByName(ctx, tenantID, spaceID, skill.Name, docEngine); err != nil { + if err := s.deleteSkillByName(ctx, tenantID, spaceID, skill.Name, docEngine); err != nil { // Log but don't fail - the document might not exist common.Debug(fmt.Sprintf("No existing index to delete for skill %s", skill.Name)) } // Index new version - return s.IndexSkill(ctx, tenantID, spaceID, skill, docEngine, embdID) + return s.indexSkill(ctx, tenantID, spaceID, skill, docEngine, embdID) } // ReindexAll reindexes all skills for a tenant @@ -409,7 +432,11 @@ func (s *SkillIndexerService) UpdateSkillVersion(ctx context.Context, tenantID, // 2. Traverse all skill folders under the space // 3. For each skill, get the latest version // 4. Reindex all skills -func (s *SkillIndexerService) ReindexAll(ctx context.Context, tenantID, spaceID string, docEngine engine.DocEngine, embdID string) (map[string]interface{}, error) { +func (s *SkillIndexerService) ReindexAll(ctx context.Context, userID, spaceID string, docEngine engine.DocEngine, embdID string) (map[string]interface{}, error) { + tenantID, err := resolveSkillSpaceTenantID(ctx, userID, spaceID, permission.OperationUpdate) + if err != nil { + return nil, err + } spaceID = normalizeSpaceID(spaceID) // Get current config and increment semantic version config, err := s.configDAO.GetOrCreate(ctx, dao.DB, tenantID, spaceID, embdID) @@ -476,7 +503,7 @@ func (s *SkillIndexerService) ReindexAll(ctx context.Context, tenantID, spaceID // Index all skills with new version using batch indexing for better performance if len(skills) > 0 { common.Info(fmt.Sprintf("ReindexAll: batch indexing %d skills", len(skills))) - if err := s.BatchIndexSkills(ctx, tenantID, spaceID, skills, docEngine, embdID); err != nil { + if err := s.batchIndexSkills(ctx, tenantID, spaceID, skills, docEngine, embdID); err != nil { common.Error("ReindexAll: batch indexing failed", err) return nil, fmt.Errorf("failed to batch index skills: %w", err) } @@ -838,7 +865,15 @@ func (s *SkillIndexerService) cleanupOldVersions(ctx context.Context, tenantID, } // InitializeIndex initializes the skill search index for a tenant -func (s *SkillIndexerService) InitializeIndex(ctx context.Context, tenantID, spaceID string, docEngine engine.DocEngine, embdID string) error { +func (s *SkillIndexerService) InitializeIndex(ctx context.Context, userID, spaceID string, docEngine engine.DocEngine, embdID string) error { + tenantID, err := resolveSkillSpaceTenantID(ctx, userID, spaceID, permission.OperationUpdate) + if err != nil { + return err + } + return s.initializeIndex(ctx, tenantID, spaceID, docEngine, embdID) +} + +func (s *SkillIndexerService) initializeIndex(ctx context.Context, tenantID, spaceID string, docEngine engine.DocEngine, embdID string) error { // Check if index exists indexName := SkillIndexName(tenantID, spaceID) @@ -906,8 +941,8 @@ func (s *SkillIndexerService) createIndexWithDimension(ctx context.Context, tena } // EnsureIndex ensures the skill index exists for a tenant -func (s *SkillIndexerService) EnsureIndex(ctx context.Context, tenantID, spaceID string, docEngine engine.DocEngine, embdID string) error { - return s.InitializeIndex(ctx, tenantID, spaceID, docEngine, embdID) +func (s *SkillIndexerService) EnsureIndex(ctx context.Context, userID, spaceID string, docEngine engine.DocEngine, embdID string) error { + return s.InitializeIndex(ctx, userID, spaceID, docEngine, embdID) } // generateEmbedding generates embedding for text using the specified model diff --git a/internal/service/skill_search.go b/internal/service/skill_search.go index 93b283888f..697814f300 100644 --- a/internal/service/skill_search.go +++ b/internal/service/skill_search.go @@ -28,6 +28,7 @@ import ( "ragflow/internal/engine/types" "ragflow/internal/entity" "ragflow/internal/entity/models" + "ragflow/internal/permission" "ragflow/internal/utility" "strings" @@ -55,10 +56,14 @@ type GetConfigRequest struct { } // GetConfig retrieves the search configuration for a tenant -func (s *SkillSearchService) GetConfig(ctx context.Context, tenantID, spaceID, embdID string) (map[string]interface{}, common.ErrorCode, error) { +func (s *SkillSearchService) GetConfig(ctx context.Context, userID, spaceID, embdID string) (map[string]interface{}, common.ErrorCode, error) { spaceID = normalizeSpaceID(spaceID) + tenantID, err := resolveSkillSpaceTenantID(ctx, userID, spaceID, permission.OperationRead) + if err != nil { + code, normalized := normalizeSkillSpacePermissionError(err) + return nil, code, normalized + } var config *entity.SkillSearchConfig - var err error if embdID == "" { // If embd_id is not provided, get the latest config for the tenant @@ -84,6 +89,11 @@ func (s *SkillSearchService) GetConfig(ctx context.Context, tenantID, spaceID, e } else { config, err = s.configDAO.GetByTenantAndEmbdID(ctx, dao.DB, tenantID, spaceID, embdID) if err != nil { + tenantID, err = resolveSkillSpaceTenantID(ctx, userID, spaceID, permission.OperationUpdate) + if err != nil { + code, normalized := normalizeSkillSpacePermissionError(err) + return nil, code, normalized + } // Config not found, create default one config, err = s.configDAO.GetOrCreate(ctx, dao.DB, tenantID, spaceID, embdID) if err != nil { @@ -108,8 +118,14 @@ type UpdateConfigRequest struct { } // UpdateConfig updates the search configuration for a tenant -func (s *SkillSearchService) UpdateConfig(ctx context.Context, req *UpdateConfigRequest) (map[string]interface{}, common.ErrorCode, error) { +func (s *SkillSearchService) UpdateConfig(ctx context.Context, userID string, req *UpdateConfigRequest) (map[string]interface{}, common.ErrorCode, error) { req.SpaceID = normalizeSpaceID(req.SpaceID) + tenantID, err := resolveSkillSpaceTenantID(ctx, userID, req.SpaceID, permission.OperationUpdate) + if err != nil { + code, normalized := normalizeSkillSpacePermissionError(err) + return nil, code, normalized + } + req.TenantID = tenantID // Validate vector_similarity_weight if req.VectorSimilarityWeight < 0 || req.VectorSimilarityWeight > 1 { return nil, common.CodeDataError, errors.New("vector_similarity_weight must be between 0 and 1") @@ -207,8 +223,14 @@ type SearchResponse struct { } // Search performs skill search with the configured strategy -func (s *SkillSearchService) Search(ctx context.Context, req *SearchRequest, docEngine engine.DocEngine) (*SearchResponse, common.ErrorCode, error) { +func (s *SkillSearchService) Search(ctx context.Context, userID string, req *SearchRequest, docEngine engine.DocEngine) (*SearchResponse, common.ErrorCode, error) { req.SpaceID = normalizeSpaceID(req.SpaceID) + tenantID, err := resolveSkillSpaceTenantID(ctx, userID, req.SpaceID, permission.OperationUse) + if err != nil { + code, normalized := normalizeSkillSpacePermissionError(err) + return nil, code, normalized + } + req.TenantID = tenantID if req.Page <= 0 { req.Page = 1 } @@ -707,10 +729,12 @@ func SkillIndexName(tenantID, spaceID string) string { return fmt.Sprintf("skill_%s_%s", uniqueTenant, uniqueSpace) } +const defaultSkillSpaceID = "default" + func normalizeSpaceID(spaceID string) string { spaceID = strings.TrimSpace(spaceID) if spaceID == "" { - return "default" + return defaultSkillSpaceID } return spaceID } diff --git a/internal/service/skill_space.go b/internal/service/skill_space.go index 73b4d8c843..46ed6c862c 100644 --- a/internal/service/skill_space.go +++ b/internal/service/skill_space.go @@ -23,6 +23,7 @@ import ( "ragflow/internal/dao" "ragflow/internal/engine" "ragflow/internal/entity" + "ragflow/internal/permission" "ragflow/internal/service/file" "sync" "time" @@ -49,7 +50,7 @@ func NewSkillSpaceService(dr file.DocRemover) *SkillSpaceService { return &SkillSpaceService{ spaceDAO: dao.NewSkillSpaceDAO(), fileDAO: dao.NewFileDAO(), - fileService: file.NewFileService(CheckFileTeamPermission, dr), + fileService: file.NewFileService(dr), configDAO: dao.NewSkillSearchConfigDAO(), tenantDAO: dao.NewTenantDAO(), skillsFolderCache: make(map[string]string), @@ -58,7 +59,6 @@ func NewSkillSpaceService(dr file.DocRemover) *SkillSpaceService { // CreateSpaceRequest represents the request to create a skills space type CreateSpaceRequest struct { - TenantID string `json:"tenant_id" binding:"required"` Name string `json:"name" binding:"required"` Description string `json:"description"` EmbdID string `json:"embd_id"` @@ -147,14 +147,19 @@ func (s *SkillSpaceService) getSkillsFolderID(ctx context.Context, tenantID stri } // CreateSpace creates a new skills space with associated folder -func (s *SkillSpaceService) CreateSpace(ctx context.Context, req *CreateSpaceRequest) (map[string]interface{}, common.ErrorCode, error) { +func (s *SkillSpaceService) CreateSpace(ctx context.Context, userID string, req *CreateSpaceRequest) (map[string]interface{}, common.ErrorCode, error) { // Validate name if req.Name == "" { return nil, common.CodeDataError, fmt.Errorf("space name is required") } + tenantID, err := requireOwnedTenantID(ctx, userID) + if err != nil { + code, normalized := normalizeSkillSpacePermissionError(err) + return nil, code, normalized + } // Tenant-scoped serialization to prevent concurrent create/delete races - tenantKey := req.TenantID + ":" + req.Name + tenantKey := tenantID + ":" + req.Name mu, _ := s.spaceCreateMu.LoadOrStore(tenantKey, &sync.Mutex{}) tenantMu := mu.(*sync.Mutex) tenantMu.Lock() @@ -167,7 +172,7 @@ func (s *SkillSpaceService) CreateSpace(ctx context.Context, req *CreateSpaceReq // requested one, mirroring the dataset create rule. // skill_space.name is a 128-byte column; keep generated names within it. spaceName, err := common.UniqueName(req.Name, 128, func(candidate string) (bool, error) { - existing, lookupErr := s.spaceDAO.GetByTenantAndName(ctx, dao.DB, req.TenantID, candidate) + existing, lookupErr := s.spaceDAO.GetByTenantAndName(ctx, dao.DB, tenantID, candidate) if lookupErr != nil { if dao.IsNotFoundErr(lookupErr) { return false, nil @@ -181,7 +186,7 @@ func (s *SkillSpaceService) CreateSpace(ctx context.Context, req *CreateSpaceReq } // Check if there's a space with the same name that is currently being deleted - existingSpaceAny, err := s.spaceDAO.GetByTenantAndNameAnyStatus(ctx, dao.DB, req.TenantID, spaceName) + existingSpaceAny, err := s.spaceDAO.GetByTenantAndNameAnyStatus(ctx, dao.DB, tenantID, spaceName) if err == nil && existingSpaceAny != nil && existingSpaceAny.Status == entity.SpaceStatusDeleting { return nil, common.CodeDataError, fmt.Errorf("space with name '%s' is being deleted, please try again later", spaceName) } @@ -189,12 +194,12 @@ func (s *SkillSpaceService) CreateSpace(ctx context.Context, req *CreateSpaceReq // Check if there's a deleted/non-active space with the same name and permanently delete it // This handles the case where a previous creation failed partially // Only delete non-active spaces (status != '1') to prevent TOCTOU race - if err = s.spaceDAO.DeletePermanentByName(ctx, dao.DB, req.TenantID, spaceName); err != nil { + if err = s.spaceDAO.DeletePermanentByName(ctx, dao.DB, tenantID, spaceName); err != nil { common.Warn("Failed to delete permanent space by name", zap.Error(err)) } // Get skills folder ID - skillsFolderID, err := s.getSkillsFolderID(ctx, req.TenantID) + skillsFolderID, err := s.getSkillsFolderID(ctx, tenantID) if err != nil { common.Error("Failed to get skills folder ID", err) return nil, common.CodeOperatingError, err @@ -202,7 +207,7 @@ func (s *SkillSpaceService) CreateSpace(ctx context.Context, req *CreateSpaceReq // Check if there's an existing folder with the same name under skills folder // If exists, delete it to prevent duplicate folder names - existingFolders, err := s.fileDAO.Query(ctx, dao.DB, spaceName, skillsFolderID, req.TenantID) + existingFolders, err := s.fileDAO.Query(ctx, dao.DB, spaceName, skillsFolderID, tenantID) if err != nil { return nil, common.CodeOperatingError, fmt.Errorf("failed to query existing folders: %w", err) } @@ -224,8 +229,8 @@ func (s *SkillSpaceService) CreateSpace(ctx context.Context, req *CreateSpaceReq folder := &entity.File{ ID: folderID, ParentID: skillsFolderID, - TenantID: req.TenantID, - CreatedBy: req.TenantID, + TenantID: tenantID, + CreatedBy: userID, Name: spaceName, Type: "folder", Size: 0, @@ -240,7 +245,7 @@ func (s *SkillSpaceService) CreateSpace(ctx context.Context, req *CreateSpaceReq // Create the space space := &entity.SkillSpace{ ID: spaceID, - TenantID: req.TenantID, + TenantID: tenantID, Name: spaceName, FolderID: folderID, Description: req.Description, @@ -260,18 +265,18 @@ func (s *SkillSpaceService) CreateSpace(ctx context.Context, req *CreateSpaceReq // Create default search config for this space defaultEmbdID := req.EmbdID if defaultEmbdID == "" { - tenant, err := s.tenantDAO.GetByID(ctx, dao.DB, req.TenantID) + tenant, err := s.tenantDAO.GetByID(ctx, dao.DB, tenantID) if err == nil && tenant != nil && tenant.EmbdID != "" { defaultEmbdID = tenant.EmbdID - common.Info("Using tenant default embedding model", zap.String("tenantID", req.TenantID), zap.String("embdID", defaultEmbdID)) + common.Info("Using tenant default embedding model", zap.String("tenantID", tenantID), zap.String("embdID", defaultEmbdID)) } else { - common.Warn("Tenant has no default embedding model, skill search will not work until configured", zap.String("tenantID", req.TenantID)) + common.Warn("Tenant has no default embedding model, skill search will not work until configured", zap.String("tenantID", tenantID)) } } if defaultEmbdID != "" { - if _, err := s.configDAO.GetOrCreate(ctx, dao.DB, req.TenantID, spaceID, defaultEmbdID); err != nil { + if _, err := s.configDAO.GetOrCreate(ctx, dao.DB, tenantID, spaceID, defaultEmbdID); err != nil { common.Warn("Failed to create skill search config for new space", - zap.String("tenantID", req.TenantID), + zap.String("tenantID", tenantID), zap.String("spaceID", spaceID), zap.String("embdID", defaultEmbdID), zap.Error(err)) @@ -281,9 +286,14 @@ func (s *SkillSpaceService) CreateSpace(ctx context.Context, req *CreateSpaceReq return space.ToMap(), common.CodeSuccess, nil } -// ListSpaces lists all skills spaces for a tenant -func (s *SkillSpaceService) ListSpaces(ctx context.Context, tenantID string) (map[string]interface{}, common.ErrorCode, error) { - spaces, err := s.spaceDAO.GetByTenantID(ctx, dao.DB, tenantID) +// ListSpaces lists skill spaces the caller can read. +func (s *SkillSpaceService) ListSpaces(ctx context.Context, userID string) (map[string]interface{}, common.ErrorCode, error) { + spaceIDs, err := accessibleSkillSpaceIDs(ctx, userID, permission.OperationRead) + if err != nil { + code, normalized := normalizeSkillSpacePermissionError(err) + return nil, code, normalized + } + spaces, err := s.spaceDAO.GetByIDs(ctx, dao.DB, spaceIDs) if err != nil { return nil, common.CodeOperatingError, fmt.Errorf("failed to list spaces: %w", err) } @@ -301,35 +311,37 @@ func (s *SkillSpaceService) ListSpaces(ctx context.Context, tenantID string) (ma } // GetSpace retrieves a skills space by ID (includes deleting status for visibility) -func (s *SkillSpaceService) GetSpace(ctx context.Context, spaceID, tenantID string) (map[string]interface{}, common.ErrorCode, error) { +func (s *SkillSpaceService) GetSpace(ctx context.Context, userID, spaceID string) (map[string]interface{}, common.ErrorCode, error) { + if _, err := resolveSkillSpaceTenantID(ctx, userID, spaceID, permission.OperationRead); err != nil { + code, normalized := normalizeSkillSpacePermissionError(err) + return nil, code, normalized + } space, err := s.spaceDAO.GetByIDAnyStatus(ctx, dao.DB, spaceID) if err != nil { - return nil, common.CodeDataError, fmt.Errorf("space not found") - } - - // Verify tenant ownership - if space.TenantID != tenantID { - return nil, common.CodeDataError, fmt.Errorf("space not found") + code, normalized := normalizeSkillSpacePermissionError(permission.ErrResourceNotFound) + return nil, code, normalized } // Return deleted spaces as not found if space.Status == entity.SpaceStatusDeleted { - return nil, common.CodeDataError, fmt.Errorf("space not found") + code, normalized := normalizeSkillSpacePermissionError(permission.ErrResourceNotFound) + return nil, code, normalized } return space.ToMap(), common.CodeSuccess, nil } // UpdateSpace updates a skills space -func (s *SkillSpaceService) UpdateSpace(ctx context.Context, spaceID string, tenantID string, req *UpdateSpaceRequest) (map[string]interface{}, common.ErrorCode, error) { +func (s *SkillSpaceService) UpdateSpace(ctx context.Context, userID, spaceID string, req *UpdateSpaceRequest) (map[string]interface{}, common.ErrorCode, error) { + tenantID, err := resolveSkillSpaceTenantID(ctx, userID, spaceID, permission.OperationUpdate) + if err != nil { + code, normalized := normalizeSkillSpacePermissionError(err) + return nil, code, normalized + } space, err := s.spaceDAO.GetByID(ctx, dao.DB, spaceID) if err != nil { - return nil, common.CodeDataError, fmt.Errorf("space not found") - } - - // Verify tenant ownership - if space.TenantID != tenantID { - return nil, common.CodeDataError, fmt.Errorf("space not found") + code, normalized := normalizeSkillSpacePermissionError(permission.ErrResourceNotFound) + return nil, code, normalized } // Build updates @@ -403,16 +415,17 @@ func (s *SkillSpaceService) UpdateSpace(ctx context.Context, spaceID string, ten // DeleteSpace starts asynchronous deletion of a skills space and returns immediately. // The space status is set to "deleting" and the actual cleanup runs in a background goroutine. -func (s *SkillSpaceService) DeleteSpace(ctx context.Context, spaceID, tenantID string, docEngine engine.DocEngine) (common.ErrorCode, error) { +func (s *SkillSpaceService) DeleteSpace(ctx context.Context, userID, spaceID string, docEngine engine.DocEngine) (common.ErrorCode, error) { + tenantID, err := resolveSkillSpaceTenantID(ctx, userID, spaceID, permission.OperationDelete) + if err != nil { + code, normalized := normalizeSkillSpacePermissionError(err) + return code, normalized + } // Get space regardless of status (could be retrying a failed delete) space, err := s.spaceDAO.GetByIDAnyStatus(ctx, dao.DB, spaceID) if err != nil { - return common.CodeDataError, fmt.Errorf("space not found") - } - - // Verify tenant ownership - if space.TenantID != tenantID { - return common.CodeDataError, fmt.Errorf("space not found") + code, normalized := normalizeSkillSpacePermissionError(permission.ErrResourceNotFound) + return code, normalized } // If already deleting, return success (idempotent) @@ -479,13 +492,13 @@ func (s *SkillSpaceService) asyncDeleteSpace(ctx context.Context, spaceID, folde common.Info("Async deleting space folder via Go FileService", zap.String("folderID", folderID), zap.String("spaceID", spaceID)) ctxFS, cancelFS := context.WithTimeout(ctx, 60*time.Second) defer cancelFS() - success, msg := s.fileService.DeleteFiles(ctxFS, tenantID, []string{folderID}) - if !success { - common.Error(fmt.Sprintf("Failed to delete space folder via Go FileService during async delete, spaceID=%s, msg=%s", spaceID, msg), nil) + err := s.fileService.DeleteFiles(ctxFS, tenantID, []string{folderID}) + if err != nil { + common.Error(fmt.Sprintf("Failed to delete space folder via Go FileService during async delete, spaceID=%s, error=%v", spaceID, err), nil) // Retry once with a delay (same ctxFS, still valid) time.Sleep(5 * time.Second) - if retrySuccess, retryMsg := s.fileService.DeleteFiles(ctxFS, tenantID, []string{folderID}); !retrySuccess { - common.Error(fmt.Sprintf("Retry failed to delete space folder, marking space as deleted anyway, spaceID=%s, msg=%s", spaceID, retryMsg), nil) + if retryErr := s.fileService.DeleteFiles(ctxFS, tenantID, []string{folderID}); retryErr != nil { + common.Error(fmt.Sprintf("Retry failed to delete space folder, marking space as deleted anyway, spaceID=%s, error=%v", spaceID, retryErr), nil) // Mark as deleted even if folder deletion fails - orphaned folders can be cleaned up later } else { common.Info("Successfully deleted space folder on retry via Go FileService", zap.String("folderID", folderID)) @@ -560,15 +573,15 @@ func (s *SkillSpaceService) deleteFolderRecursive(ctx context.Context, folderID } // GetSpaceByFolderID retrieves a skills space by its folder ID -func (s *SkillSpaceService) GetSpaceByFolderID(ctx context.Context, folderID, tenantID string) (map[string]interface{}, common.ErrorCode, error) { +func (s *SkillSpaceService) GetSpaceByFolderID(ctx context.Context, userID, folderID string) (map[string]interface{}, common.ErrorCode, error) { space, err := s.spaceDAO.GetByFolderID(ctx, dao.DB, folderID) if err != nil { - return nil, common.CodeDataError, fmt.Errorf("space not found for folder") + code, normalized := normalizeSkillSpacePermissionError(permission.ErrResourceNotFound) + return nil, code, normalized } - - // Verify tenant ownership - if space.TenantID != tenantID { - return nil, common.CodeDataError, fmt.Errorf("space not found") + if _, err := resolveSkillSpaceTenantID(ctx, userID, space.ID, permission.OperationRead); err != nil { + code, normalized := normalizeSkillSpacePermissionError(err) + return nil, code, normalized } return space.ToMap(), common.CodeSuccess, nil diff --git a/internal/service/skill_space_permission.go b/internal/service/skill_space_permission.go new file mode 100644 index 0000000000..15b091a510 --- /dev/null +++ b/internal/service/skill_space_permission.go @@ -0,0 +1,62 @@ +// +// 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 resolveSkillSpaceTenantID(ctx context.Context, userID, spaceID string, operation permission.Operation) (string, error) { + spaceID = normalizeSpaceID(spaceID) + if spaceID == defaultSkillSpaceID { + return requireOwnedTenantID(ctx, userID) + } + + checker := permission.NewDatabaseChecker(dao.DB) + subject := permission.Subject{UserID: userID} + ref := permission.ResourceRef{Kind: permission.ResourceKindSkillSpace, ID: spaceID} + if err := checker.CheckResource(ctx, subject, ref, operation); err != nil { + return "", err + } + access, err := checker.ResolveAccess(ctx, subject, ref) + if err != nil { + return "", err + } + return access.TenantID, nil +} + +func accessibleSkillSpaceIDs(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.ResourceKindSkillSpace, + Operation: operation, + }) + if err != nil { + return nil, err + } + if scope.Mode != permission.ScopeIDs { + return []string{}, nil + } + return scope.ResourceIDs, nil +} + +func normalizeSkillSpacePermissionError(err error) (common.ErrorCode, error) { + return permissionresponse.NormalizeHidden(err) +} diff --git a/internal/service/skill_space_permission_test.go b/internal/service/skill_space_permission_test.go new file mode 100644 index 0000000000..ce8c010bec --- /dev/null +++ b/internal/service/skill_space_permission_test.go @@ -0,0 +1,112 @@ +// +// 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 TestSkillSpacePermissionsUsePolicyAcrossServiceOperations(t *testing.T) { + db := setupServiceTestDB(t) + if err := db.AutoMigrate(&entity.SkillSpace{}); err != nil { + t.Fatalf("migrate skill spaces: %v", err) + } + pushServiceDB(t, db) + + active := string(entity.StatusValid) + for _, membership := range []entity.UserTenant{ + {ID: "skill-owner-membership", UserID: "owner", TenantID: "team", Role: string(permission.RoleOwner), Status: &active}, + {ID: "skill-member-membership", UserID: "member", TenantID: "team", Role: string(permission.RoleNormal), InvitedBy: "owner", Status: &active}, + {ID: "skill-outsider-membership", UserID: "outsider", TenantID: "other", Role: string(permission.RoleOwner), Status: &active}, + } { + if err := db.Create(&membership).Error; err != nil { + t.Fatalf("create membership %q: %v", membership.ID, err) + } + } + folder := entity.File{ + ID: "skill-folder", ParentID: "root", TenantID: "team", CreatedBy: "owner", + Name: "shared", Type: "folder", SourceType: "skill_space", + } + if err := db.Create(&folder).Error; err != nil { + t.Fatalf("create skill folder: %v", err) + } + space := entity.SkillSpace{ + ID: "shared-space", TenantID: "team", Name: "shared", FolderID: folder.ID, + Status: entity.SpaceStatusActive, + } + if err := db.Create(&space).Error; err != nil { + t.Fatalf("create skill space: %v", err) + } + + service := NewSkillSpaceService(nil) + ctx := t.Context() + + listed, code, err := service.ListSpaces(ctx, "member") + if err != nil || code != common.CodeSuccess { + t.Fatalf("member list: code=%d err=%v", code, err) + } + spaces, ok := listed["spaces"].([]map[string]interface{}) + if !ok || len(spaces) != 1 || spaces[0]["id"] != space.ID { + t.Fatalf("member list result = %#v, want only %q", listed["spaces"], space.ID) + } + + if _, code, err := service.GetSpace(ctx, "member", space.ID); err != nil || code != common.CodeSuccess { + t.Fatalf("member read: code=%d err=%v", code, err) + } + if _, code, err := service.UpdateSpace(ctx, "member", space.ID, &UpdateSpaceRequest{Description: "forbidden"}); err == nil || code != common.CodeNotFound || !errors.Is(err, permission.ErrPermissionDenied) { + t.Fatalf("member update should be hidden: code=%d err=%v", code, err) + } + if code, err := service.DeleteSpace(ctx, "member", space.ID, nil); err == nil || code != common.CodeNotFound || !errors.Is(err, permission.ErrPermissionDenied) { + t.Fatalf("member delete should be hidden: code=%d err=%v", code, err) + } + if _, code, err := service.GetSpace(ctx, "outsider", space.ID); err == nil || code != common.CodeNotFound || !errors.Is(err, permission.ErrPermissionDenied) { + t.Fatalf("outsider read should be hidden: code=%d err=%v", code, err) + } + + if _, code, err := service.CreateSpace(ctx, "member", &CreateSpaceRequest{Name: "denied"}); err == nil || code != common.CodeNotFound { + t.Fatalf("member create should require owner membership: code=%d err=%v", code, err) + } + if tenantID, err := resolveSkillSpaceTenantID(ctx, "owner", "default", permission.OperationUse); err != nil || tenantID != "team" { + t.Fatalf("default skill space tenant = %q, err=%v; want team", tenantID, err) + } + + updated, code, err := service.UpdateSpace(ctx, "owner", space.ID, &UpdateSpaceRequest{Description: "updated"}) + if err != nil || code != common.CodeSuccess || updated["description"] != "updated" { + t.Fatalf("owner update: result=%#v code=%d err=%v", updated, code, err) + } + + indexer := NewSkillIndexerService() + if err := indexer.BatchIndexSkills(ctx, "member", space.ID, nil, nil, ""); !errors.Is(err, permission.ErrPermissionDenied) { + t.Fatalf("member indexing should be denied before engine access, got %v", err) + } + if err := indexer.DeleteSkillIndex(ctx, "member", space.ID, "skill", nil); !errors.Is(err, permission.ErrPermissionDenied) { + t.Fatalf("member index deletion should be denied before engine access, got %v", err) + } + if _, _, err := NewSkillSearchService().UpdateConfig(ctx, "member", &UpdateConfigRequest{ + SpaceID: space.ID, EmbdID: "model", VectorSimilarityWeight: 0.5, + SimilarityThreshold: 0.2, TopK: 10, + }); !errors.Is(err, permission.ErrPermissionDenied) { + t.Fatalf("member config update should be denied, got %v", err) + } + if _, _, err := NewSkillSearchService().Search(ctx, "outsider", &SearchRequest{SpaceID: space.ID}, nil); !errors.Is(err, permission.ErrPermissionDenied) { + t.Fatalf("outsider search should be denied before engine access, got %v", err) + } +} diff --git a/internal/service/tenant.go b/internal/service/tenant.go index d530c88976..03c2081aef 100644 --- a/internal/service/tenant.go +++ b/internal/service/tenant.go @@ -23,6 +23,8 @@ import ( "ragflow/internal/dao" "ragflow/internal/engine" "ragflow/internal/entity" + "ragflow/internal/permission" + permissionresponse "ragflow/internal/permission/response" "strings" ) @@ -723,10 +725,10 @@ type TenantMemberResponse struct { } // ListMembers returns all non-owner members of tenantID. -// Only the tenant owner (userID == tenantID) may call this. func (s *TenantService) ListMembers(ctx context.Context, userID, tenantID string) ([]*TenantMemberResponse, common.ErrorCode, error) { - if userID != tenantID { - return nil, common.CodeAuthenticationError, fmt.Errorf("no authorization") + if err := permission.NewDatabaseChecker(dao.DB).CheckTenant(ctx, permission.Subject{UserID: userID}, tenantID, permission.TenantOwner); err != nil { + code, permissionErr := permissionresponse.Normalize(err) + return nil, code, permissionErr } rows, err := s.userTenantDAO.GetMembersByTenantID(ctx, dao.DB, tenantID) if err != nil { @@ -768,10 +770,10 @@ type AddMemberResponse struct { } // AddMember invites a user (by email) to the tenant. -// Only the tenant owner (userID == tenantID) may call this. func (s *TenantService) AddMember(ctx context.Context, userID, tenantID string, req *AddMemberRequest) (*AddMemberResponse, common.ErrorCode, error) { - if userID != tenantID { - return nil, common.CodeAuthenticationError, fmt.Errorf("no authorization") + if err := permission.NewDatabaseChecker(dao.DB).CheckTenant(ctx, permission.Subject{UserID: userID}, tenantID, permission.TenantOwner); err != nil { + code, permissionErr := permissionresponse.Normalize(err) + return nil, code, permissionErr } if strings.TrimSpace(req.Email) == "" { return nil, common.CodeArgumentError, fmt.Errorf("email is required") @@ -828,19 +830,30 @@ func (s *TenantService) AddMember(ctx context.Context, userID, tenantID string, }, common.CodeSuccess, nil } -// RemoveMember removes a user from the tenant. -// Either the owner (userID == tenantID) or the member themselves (userID == targetUserID) may call this. -// The tenant owner (targetUserID == tenantID) cannot be removed. +// RemoveMember removes a user from the tenant. The owner may remove members, +// and a member may remove themselves. The tenant owner cannot be removed. func (s *TenantService) RemoveMember(ctx context.Context, userID, tenantID, targetUserID string) (common.ErrorCode, error) { - if userID != tenantID && userID != targetUserID { - return common.CodeAuthenticationError, fmt.Errorf("no authorization") + requirement := permission.TenantOwner + if userID == targetUserID { + requirement = permission.TenantMember } - if targetUserID == tenantID { - return common.CodeArgumentError, fmt.Errorf("cannot remove the tenant owner") + if err := permission.NewDatabaseChecker(dao.DB).CheckTenant(ctx, permission.Subject{UserID: userID}, tenantID, requirement); err != nil { + code, permissionErr := permissionresponse.Normalize(err) + return code, permissionErr } if s.userTenantDAO == nil { return common.CodeServerError, fmt.Errorf("userTenantDAO not initialized") } + targetMembership, err := s.userTenantDAO.FilterByUserIDAndTenantID(ctx, dao.DB, targetUserID, tenantID) + if err != nil { + if dao.IsNotFoundErr(err) { + return common.CodeDataError, fmt.Errorf("user is not a tenant member") + } + return common.CodeServerError, fmt.Errorf("failed to load tenant member: %w", err) + } + if targetMembership.Role == TenantRoleOwner { + return common.CodeArgumentError, fmt.Errorf("cannot remove the tenant owner") + } if err := s.userTenantDAO.DeleteByUserAndTenant(ctx, dao.DB, targetUserID, tenantID); err != nil { return common.CodeServerError, fmt.Errorf("failed to remove member: %w", err) } diff --git a/internal/service/tenant_permission.go b/internal/service/tenant_permission.go new file mode 100644 index 0000000000..f2ee5d8b26 --- /dev/null +++ b/internal/service/tenant_permission.go @@ -0,0 +1,48 @@ +// +// 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" + "errors" + "strings" + + "ragflow/internal/dao" + "ragflow/internal/permission" +) + +func requireOwnedTenantID(ctx context.Context, userID string) (string, error) { + if strings.TrimSpace(userID) == "" { + return "", permission.ErrUnauthenticated + } + tenantDAO := dao.NewUserTenantDAO() + tenants, err := tenantDAO.GetByUserIDAndRole(ctx, dao.DB, userID, string(permission.RoleOwner)) + if err != nil { + return "", err + } + checker := permission.NewDatabaseChecker(dao.DB) + for _, tenant := range tenants { + if tenant == nil || strings.TrimSpace(tenant.TenantID) == "" { + continue + } + if err := checker.CheckTenant(ctx, permission.Subject{UserID: userID}, tenant.TenantID, permission.TenantOwner); err == nil { + return tenant.TenantID, nil + } else if !errors.Is(err, permission.ErrMembershipNotFound) { + return "", err + } + } + return "", permission.ErrMembershipNotFound +} diff --git a/internal/service/tenant_test.go b/internal/service/tenant_test.go index 13b9205ff6..0448936052 100644 --- a/internal/service/tenant_test.go +++ b/internal/service/tenant_test.go @@ -32,39 +32,70 @@ import ( //go:linkname daoModelProviderManager ragflow/internal/dao.modelProviderManager var daoModelProviderManager *models.ProviderManager -// TestListMembersAuthCheck verifies that a non-owner (userID != tenantID) gets -// CodeAuthenticationError without hitting the database. +func setupTenantPermissionTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{TranslateError: true}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&entity.UserTenant{}); err != nil { + t.Fatalf("migrate tenant permission table: %v", err) + } + originalDB := dao.DB + dao.DB = db + t.Cleanup(func() { dao.DB = originalDB }) + return db +} + +func seedTenantMembership(t *testing.T, db *gorm.DB, id, userID, tenantID, role string) { + t.Helper() + status := "1" + if err := db.Create(&entity.UserTenant{ + ID: id, UserID: userID, TenantID: tenantID, Role: role, Status: &status, + }).Error; err != nil { + t.Fatalf("seed membership: %v", err) + } +} + +// TestListMembersAuthCheck verifies a tenant member without the owner role is denied. func TestListMembersAuthCheck(t *testing.T) { - s := &TenantService{} + db := setupTenantPermissionTestDB(t) + seedTenantMembership(t, db, "member", "user-abc", "tenant-xyz", "normal") + seedTenantMembership(t, db, "owner", "owner-user", "tenant-xyz", "owner") + s := &TenantService{userTenantDAO: dao.NewUserTenantDAO()} ctx := t.Context() _, code, err := s.ListMembers(ctx, "user-abc", "tenant-xyz") if err == nil { t.Fatal("expected error for non-owner, got nil") } - if code != common.CodeAuthenticationError { - t.Errorf("expected CodeAuthenticationError, got %v", code) + if code != common.CodeForbidden { + t.Errorf("expected CodeForbidden, got %v", code) } } -// TestAddMemberAuthCheck verifies that a non-owner gets CodeAuthenticationError. +// TestAddMemberAuthCheck verifies that a non-owner is denied by tenant policy. func TestAddMemberAuthCheck(t *testing.T) { - s := &TenantService{} + db := setupTenantPermissionTestDB(t) + seedTenantMembership(t, db, "member", "user-abc", "tenant-xyz", "normal") + seedTenantMembership(t, db, "owner", "owner-user", "tenant-xyz", "owner") + s := &TenantService{userTenantDAO: dao.NewUserTenantDAO()} ctx := t.Context() _, code, err := s.AddMember(ctx, "user-abc", "tenant-xyz", &AddMemberRequest{Email: "a@b.com"}) if err == nil { t.Fatal("expected error for non-owner, got nil") } - if code != common.CodeAuthenticationError { - t.Errorf("expected CodeAuthenticationError, got %v", code) + if code != common.CodeForbidden { + t.Errorf("expected CodeForbidden, got %v", code) } } -// TestAddMemberEmailRequired verifies the email validation runs after the auth check. +// TestAddMemberEmailRequired verifies owner authorization runs before payload validation. func TestAddMemberEmailRequired(t *testing.T) { - // When userID == tenantID (owner) but no email, expect CodeArgumentError. - s := &TenantService{} + db := setupTenantPermissionTestDB(t) + seedTenantMembership(t, db, "owner", "owner-user", "tenant-xyz", "owner") + s := &TenantService{userTenantDAO: dao.NewUserTenantDAO()} ctx := t.Context() - _, code, err := s.AddMember(ctx, "owner-id", "owner-id", &AddMemberRequest{Email: ""}) + _, code, err := s.AddMember(ctx, "owner-user", "tenant-xyz", &AddMemberRequest{Email: ""}) if err == nil { t.Fatal("expected error for empty email, got nil") } @@ -73,34 +104,47 @@ func TestAddMemberEmailRequired(t *testing.T) { } } -// TestRemoveMemberAuthCheck verifies that an unrelated user gets CodeAuthenticationError. +// TestRemoveMemberAuthCheck verifies that a user outside the tenant is denied. func TestRemoveMemberAuthCheck(t *testing.T) { - s := &TenantService{} + db := setupTenantPermissionTestDB(t) + seedTenantMembership(t, db, "owner", "owner-user", "tenant-xyz", "owner") + s := &TenantService{userTenantDAO: dao.NewUserTenantDAO()} ctx := t.Context() code, err := s.RemoveMember(ctx, "user-abc", "tenant-xyz", "user-def") if err == nil { t.Fatal("expected error, got nil") } - if code != common.CodeAuthenticationError { - t.Errorf("expected CodeAuthenticationError, got %v", code) + if code != common.CodeForbidden { + t.Errorf("expected CodeForbidden, got %v", code) } } -// TestRemoveMemberSelfAllowed verifies that a user removing themselves passes the auth check. -// The DAO is nil so the operation fails at the data layer, but the auth check must pass first. +// TestRemoveMemberSelfAllowed verifies a tenant member can remove their own membership. func TestRemoveMemberSelfAllowed(t *testing.T) { - s := &TenantService{} - // userID == targetUserID: auth check should pass. + db := setupTenantPermissionTestDB(t) + seedTenantMembership(t, db, "member", "user-abc", "tenant-xyz", "normal") + s := &TenantService{userTenantDAO: dao.NewUserTenantDAO()} ctx := t.Context() code, err := s.RemoveMember(ctx, "user-abc", "tenant-xyz", "user-abc") - if code == common.CodeAuthenticationError { - t.Errorf("self-removal should pass auth check, got CodeAuthenticationError: %v", err) + if code != common.CodeSuccess { + t.Errorf("expected CodeSuccess, got %v: %v", code, err) } - if code != common.CodeServerError { - t.Errorf("expected CodeServerError (DAO not initialized), got %v", code) + if err != nil { + t.Fatalf("self-removal failed: %v", err) } - if err == nil { - t.Error("expected non-nil error when userTenantDAO is nil") + if _, err := dao.NewUserTenantDAO().FilterByUserIDAndTenantID(ctx, db, "user-abc", "tenant-xyz"); !dao.IsNotFoundErr(err) { + t.Fatalf("self-membership remains after removal: %v", err) + } +} + +func TestRemoveMemberCannotRemoveTenantOwner(t *testing.T) { + db := setupTenantPermissionTestDB(t) + seedTenantMembership(t, db, "owner", "owner-user", "tenant-xyz", "owner") + s := &TenantService{userTenantDAO: dao.NewUserTenantDAO()} + + code, err := s.RemoveMember(t.Context(), "owner-user", "tenant-xyz", "owner-user") + if err == nil || code != common.CodeArgumentError { + t.Fatalf("RemoveMember(owner) = (%v, %v), want argument error", code, err) } } diff --git a/test/testcases/restful_api/test_chats.py b/test/testcases/restful_api/test_chats.py index 501987d26b..cf1130b9d4 100644 --- a/test/testcases/restful_api/test_chats.py +++ b/test/testcases/restful_api/test_chats.py @@ -336,8 +336,8 @@ def test_chat_list_default_get_and_separate_lookup_contract(rest_client, clear_c invalid_get_res = rest_client.get("/chats/unknown") assert invalid_get_res.status_code == 200 invalid_get_payload = invalid_get_res.json() - assert invalid_get_payload["code"] == 109, invalid_get_payload - assert invalid_get_payload["message"] == "no authorization", invalid_get_payload + assert invalid_get_payload["code"] == 404, invalid_get_payload + assert invalid_get_payload["message"] == "Resource not found", invalid_get_payload for chat_id, keywords, expected_count in ((ids[0], "list_default_0", 1), (ids[0], "list_default_1", 1), (ids[0], "unknown", 0)): get_res = rest_client.get(f"/chats/{chat_id}") @@ -1403,8 +1403,8 @@ def test_chat_update_mapping_and_validation_branches_p2(rest_client, clear_chats unauthorized = rest_client.patch("/chats/invalid-chat-id", json={"name": "anything"}) assert unauthorized.status_code == 200 unauthorized_payload = unauthorized.json() - assert unauthorized_payload["code"] == 109, unauthorized_payload - assert unauthorized_payload["message"] == "no authorization", unauthorized_payload + assert unauthorized_payload["code"] == 404, unauthorized_payload + assert unauthorized_payload["message"] == "Resource not found", unauthorized_payload quote_res = rest_client.patch(f"/chats/{chat_id}", json={"prompt_config": {"quote": False}}) assert quote_res.status_code == 200 diff --git a/test/testcases/restful_api/test_openai_compatible.py b/test/testcases/restful_api/test_openai_compatible.py index d411f3bba1..d5e9f85fba 100644 --- a/test/testcases/restful_api/test_openai_compatible.py +++ b/test/testcases/restful_api/test_openai_compatible.py @@ -110,9 +110,8 @@ def test_openai_compatible_invalid_chat(rest_client): ) assert res.status_code == 200 payload = res.json() - assert payload["code"] != 0, payload - expected_message = "no authorization" - assert expected_message in payload["message"], payload + assert payload["code"] == 404, payload + assert payload["message"] == "Resource not found", payload @pytest.mark.p3 diff --git a/test/testcases/restful_api/test_searches.py b/test/testcases/restful_api/test_searches.py index a61c527306..b0993b3a7f 100644 --- a/test/testcases/restful_api/test_searches.py +++ b/test/testcases/restful_api/test_searches.py @@ -98,8 +98,8 @@ def test_search_update_invalid_search_id(rest_client): ) assert res.status_code == 200 payload = res.json() - assert payload["code"] == 109, payload - assert "no authorization" in payload["message"].lower(), payload + assert payload["code"] == 404, payload + assert payload["message"] == "Resource not found", payload @pytest.mark.p2 diff --git a/test/testcases/restful_api/test_sessions.py b/test/testcases/restful_api/test_sessions.py index 1fe55bd147..880f60ff28 100644 --- a/test/testcases/restful_api/test_sessions.py +++ b/test/testcases/restful_api/test_sessions.py @@ -145,8 +145,8 @@ def test_session_create_validation_and_deleted_chat_contract(rest_client, create invalid_chat_res = rest_client.post("/chats/invalid_chat_assistant_id/sessions", json={"name": "valid_name"}) assert invalid_chat_res.status_code == 200 invalid_chat_payload = invalid_chat_res.json() - assert invalid_chat_payload["code"] == 109, invalid_chat_payload - assert invalid_chat_payload["message"].lower().strip().rstrip(".") == "no authorization", invalid_chat_payload + assert invalid_chat_payload["code"] == 404, invalid_chat_payload + assert invalid_chat_payload["message"] == "Resource not found", invalid_chat_payload for scenario_name, payload in ( ("valid", {"name": "valid_name"}), @@ -193,8 +193,8 @@ def test_session_create_validation_and_deleted_chat_contract(rest_client, create create_after_delete = rest_client.post(f"/chats/{chat_id}/sessions", json={"name": "after_delete"}) assert create_after_delete.status_code == 200 create_after_delete_payload = create_after_delete.json() - assert create_after_delete_payload["code"] == 109, create_after_delete_payload - assert create_after_delete_payload["message"].lower().strip().rstrip(".") == "no authorization", create_after_delete_payload + assert create_after_delete_payload["code"] == 404, create_after_delete_payload + assert create_after_delete_payload["message"] == "Resource not found", create_after_delete_payload @pytest.mark.p2 @@ -233,8 +233,8 @@ def test_session_delete_requires_auth_and_invalid_target_contract(rest_client, c invalid_chat_res = rest_client.delete("/chats/invalid_chat_assistant_id/sessions", json={"ids": [session_id]}) assert invalid_chat_res.status_code == 200 invalid_chat_payload = invalid_chat_res.json() - assert invalid_chat_payload["code"] == 109, invalid_chat_payload - assert invalid_chat_payload["message"].lower().strip().rstrip(".") == "no authorization", invalid_chat_payload + assert invalid_chat_payload["code"] == 404, invalid_chat_payload + assert invalid_chat_payload["message"] == "Resource not found", invalid_chat_payload @pytest.mark.p3 @@ -393,8 +393,8 @@ def test_session_list_filter_and_deleted_chat_contract(rest_client, create_chat) invalid_chat_res = rest_client.get(f"/chats/{INVALID_ID_32}/sessions") assert invalid_chat_res.status_code == 200 invalid_chat_payload = invalid_chat_res.json() - assert invalid_chat_payload["code"] == 109, invalid_chat_payload - assert invalid_chat_payload["message"].lower().strip().rstrip(".") == "no authorization", invalid_chat_payload + assert invalid_chat_payload["code"] == 404, invalid_chat_payload + assert invalid_chat_payload["message"] == "Resource not found", invalid_chat_payload delete_chat_res = rest_client.delete("/chats", json={"ids": [chat_id]}) assert delete_chat_res.status_code == 200 @@ -404,8 +404,8 @@ def test_session_list_filter_and_deleted_chat_contract(rest_client, create_chat) deleted_list_res = rest_client.get(f"/chats/{chat_id}/sessions", params={"page_size": 30}) assert deleted_list_res.status_code == 200 deleted_list_payload = deleted_list_res.json() - assert deleted_list_payload["code"] == 109, deleted_list_payload - assert deleted_list_payload["message"].lower().strip().rstrip(".") == "no authorization", deleted_list_payload + assert deleted_list_payload["code"] == 404, deleted_list_payload + assert deleted_list_payload["message"] == "Resource not found", deleted_list_payload @pytest.mark.p2 @@ -497,8 +497,8 @@ def test_session_update_requires_auth_and_invalid_target_contract(rest_client, c invalid_chat_res = rest_client.patch(f"/chats/{INVALID_ID_32}/sessions/{session_id}", json={"name": "x"}) assert invalid_chat_res.status_code == 200 invalid_chat_payload = invalid_chat_res.json() - assert invalid_chat_payload["code"] == 109, invalid_chat_payload - assert invalid_chat_payload["message"].lower().strip().rstrip(".") == "no authorization", invalid_chat_payload + assert invalid_chat_payload["code"] == 404, invalid_chat_payload + assert invalid_chat_payload["message"] == "Resource not found", invalid_chat_payload empty_session_res = rest_client.patch(f"/chats/{chat_id}/sessions/", json={"name": "x"}) assert empty_session_res.status_code == 200 @@ -551,8 +551,8 @@ def test_session_update_name_and_param_contract(rest_client, create_chat): update_after_delete_res = rest_client.patch(f"/chats/{chat_id}/sessions/{session_id}", json={"name": "after_delete"}) assert update_after_delete_res.status_code == 200 update_after_delete_payload = update_after_delete_res.json() - assert update_after_delete_payload["code"] == 109, update_after_delete_payload - assert update_after_delete_payload["message"].lower().strip().rstrip(".") == "no authorization", update_after_delete_payload + assert update_after_delete_payload["code"] == 404, update_after_delete_payload + assert update_after_delete_payload["message"] == "Resource not found", update_after_delete_payload @pytest.mark.p2 @@ -751,5 +751,5 @@ def test_chat_completion_validation_errors(rest_client, create_chat): ) assert invalid_chat.status_code == 200 invalid_chat_payload = invalid_chat.json() - assert invalid_chat_payload["code"] == 109, invalid_chat_payload - assert "no authorization" in invalid_chat_payload["message"].lower(), invalid_chat_payload + assert invalid_chat_payload["code"] == 404, invalid_chat_payload + assert invalid_chat_payload["message"] == "Resource not found", invalid_chat_payload diff --git a/web/src/components/knowledge-base-item.tsx b/web/src/components/knowledge-base-item.tsx index f0f3b8b4ef..488ab0c0ff 100644 --- a/web/src/components/knowledge-base-item.tsx +++ b/web/src/components/knowledge-base-item.tsx @@ -45,7 +45,7 @@ function DatasetLabel({ text }: { text: string }) { export function useDisableDifferenceEmbeddingDataset( name: string, - ownerTenantId?: string, + canvasID?: string, ) { const form = useFormContext(); const datasetId = useWatch({ name, control: form.control }); @@ -56,12 +56,7 @@ export function useDisableDifferenceEmbeddingDataset( loading, handleScroll, hasNextPage, - } = useFetchKnowledgeList( - false, - debouncedSearchString, - undefined, - ownerTenantId, - ); + } = useFetchKnowledgeList(false, debouncedSearchString, undefined, canvasID); const selectedDatasetIds = useMemo( () => (Array.isArray(datasetId) ? datasetId : []), [datasetId], @@ -73,7 +68,7 @@ export function useDisableDifferenceEmbeddingDataset( // comes back missing and its badge falls back to the raw id. const { data: selectedDatasets } = useFetchDatasetsByIds( selectedDatasetIds, - ownerTenantId, + canvasID, ); const datasetList = useMemo(() => { @@ -151,13 +146,13 @@ export function KnowledgeBaseFormField({ name = 'dataset_ids', label, required = false, - ownerTenantId, + canvasID, }: { showVariable?: boolean; name?: string; label?: ReactNode; required?: boolean; - ownerTenantId?: string; + canvasID?: string; }) { const { t } = useTranslation(); @@ -168,7 +163,7 @@ export function KnowledgeBaseFormField({ searchString, handleScroll, hasNextPage, - } = useDisableDifferenceEmbeddingDataset(name, ownerTenantId); + } = useDisableDifferenceEmbeddingDataset(name, canvasID); const nextOptions = buildQueryVariableOptionsByShowVariable(showVariable)(); diff --git a/web/src/components/memories-form-field.tsx b/web/src/components/memories-form-field.tsx index 8deed86c56..b5189f4992 100644 --- a/web/src/components/memories-form-field.tsx +++ b/web/src/components/memories-form-field.tsx @@ -27,7 +27,7 @@ type MemoriesFormFieldProps = { label: string; name?: string; required?: boolean; - ownerTenantId?: string; + canvasID?: string; }; function MemoryLabel({ text }: { text: string }) { @@ -40,11 +40,11 @@ function MemoryLabel({ text }: { text: string }) { export function useDisableDifferenceEmbeddingMemory( name: string, - ownerTenantId?: string, + canvasID?: string, ) { const form = useFormContext(); const memoryIds = useWatch({ name, control: form.control }); - const { data: memoryListOrigin } = useFetchAllMemoryList(ownerTenantId); + const { data: memoryListOrigin } = useFetchAllMemoryList(canvasID); const memoryCacheRef = useRef(new Map()); const memoryList = useMemo(() => { @@ -99,10 +99,10 @@ export function MemoriesFormField({ label, name = 'memory_ids', required = false, - ownerTenantId, + canvasID, }: MemoriesFormFieldProps) { const { t } = useTranslation(); - const { options } = useDisableDifferenceEmbeddingMemory(name, ownerTenantId); + const { options } = useDisableDifferenceEmbeddingMemory(name, canvasID); return ( diff --git a/web/src/hooks/use-file-request.ts b/web/src/hooks/use-file-request.ts index 4f73611f63..b0aba7d885 100644 --- a/web/src/hooks/use-file-request.ts +++ b/web/src/hooks/use-file-request.ts @@ -422,11 +422,18 @@ export const useFetchPureFileList = () => { mutationKey: [FileApiAction.FetchPureFileList], gcTime: 0, - mutationFn: async (parentId: string) => { + mutationFn: async ({ + parentId, + tenantId, + }: { + parentId: string; + tenantId?: string; + }) => { const { data } = await fileManagerService.listFile({ parent_id: parentId, page_size: 100, page: 1, + ...(tenantId ? { tenant_id: tenantId } : {}), }); return data; diff --git a/web/src/hooks/use-knowledge-request-datasets.test.tsx b/web/src/hooks/use-knowledge-request-datasets.test.tsx index 6e1adb2a87..b8065452ee 100644 --- a/web/src/hooks/use-knowledge-request-datasets.test.tsx +++ b/web/src/hooks/use-knowledge-request-datasets.test.tsx @@ -54,16 +54,16 @@ describe('useFetchDatasetsByIds', () => { mockListDatasetByIds.mockResolvedValue({ data: { data: [] } }); }); - it('passes the owner tenant through to the by-ids lookup', async () => { - renderHook(() => useFetchDatasetsByIds([DatasetId], 'owner-1'), { + it('passes the canvas id through to the by-ids lookup', async () => { + renderHook(() => useFetchDatasetsByIds([DatasetId], 'canvas-1'), { wrapper: createWrapper(), }); await waitFor(() => expect(mockListDatasetByIds).toHaveBeenCalled()); - expect(mockListDatasetByIds).toHaveBeenCalledWith([DatasetId], 'owner-1'); + expect(mockListDatasetByIds).toHaveBeenCalledWith([DatasetId], 'canvas-1'); }); - it('leaves the owner tenant undefined for own-canvas lookups', async () => { + it('leaves the canvas id undefined for global lookups', async () => { renderHook(() => useFetchDatasetsByIds([DatasetId]), { wrapper: createWrapper(), }); @@ -81,25 +81,29 @@ describe('useFetchKnowledgeList', () => { }); }); - it('adds tenant_id to the list params when an owner tenant is given', async () => { - renderHook(() => useFetchKnowledgeList(false, '', 30, 'owner-1'), { + it('adds canvas_id to the list params when a canvas is given', async () => { + renderHook(() => useFetchKnowledgeList(false, '', 30, 'canvas-1'), { wrapper: createWrapper(), }); await waitFor(() => expect(mockListDataset).toHaveBeenCalled()); expect(mockListDataset).toHaveBeenCalledWith( - expect.objectContaining({ page: 1, page_size: 30, tenant_id: 'owner-1' }), + expect.objectContaining({ + page: 1, + page_size: 30, + canvas_id: 'canvas-1', + }), ); }); - it('omits tenant_id without an owner tenant', async () => { + it('omits canvas_id without a canvas', async () => { renderHook(() => useFetchKnowledgeList(false, 'kw'), { wrapper: createWrapper(), }); await waitFor(() => expect(mockListDataset).toHaveBeenCalled()); const params = mockListDataset.mock.calls[0][0]; - expect(params).not.toHaveProperty('tenant_id'); + expect(params).not.toHaveProperty('canvas_id'); expect(params).toMatchObject({ page: 1, keywords: 'kw' }); }); }); diff --git a/web/src/hooks/use-knowledge-request.ts b/web/src/hooks/use-knowledge-request.ts index ea455ac6b5..2d9bfcac8e 100644 --- a/web/src/hooks/use-knowledge-request.ts +++ b/web/src/hooks/use-knowledge-request.ts @@ -971,29 +971,24 @@ export const KnowledgeListKeys = { shouldFilterListWithoutDocument: boolean, keywords: string, pageSize: number, - ownerTenantId?: string, + canvasID?: string, ) => [ KnowledgeApiAction.FetchKnowledgeList, shouldFilterListWithoutDocument, keywords, pageSize, - ownerTenantId, - ] as const, - byIds: (ids: string[], ownerTenantId?: string) => - [ - KnowledgeApiAction.FetchKnowledgeList, - 'byIds', - ids, - ownerTenantId, + canvasID, ] as const, + byIds: (ids: string[], canvasID?: string) => + [KnowledgeApiAction.FetchKnowledgeList, 'byIds', ids, canvasID] as const, }; export const useFetchKnowledgeList = ( shouldFilterListWithoutDocument: boolean = false, keywords = '', pageSize: number = KNOWLEDGE_LIST_PAGE_SIZE, - ownerTenantId?: string, + canvasID?: string, ): { list: IDataset[]; loading: boolean; @@ -1008,7 +1003,7 @@ export const useFetchKnowledgeList = ( shouldFilterListWithoutDocument, keywords, pageSize, - // ownerTenantId, + canvasID, ), gcTime: 0, initialPageParam: 1, @@ -1018,8 +1013,7 @@ export const useFetchKnowledgeList = ( page, page_size: pageSize, ...(keywords ? { keywords } : {}), - // Viewing a shared canvas: list the canvas owner's datasets. - // ...(ownerTenantId ? { tenant_id: ownerTenantId } : {}), + ...(canvasID ? { canvas_id: canvasID } : {}), }); return { items: (data?.data ?? []) as IDataset[], @@ -1076,24 +1070,22 @@ export const useFetchKnowledgeList = ( * users pick variables alongside datasets); those are not resolvable ids and * are dropped before the request is built. */ -export const useFetchDatasetsByIds = ( - ids: string[], - ownerTenantId?: string, -) => { +export const useFetchDatasetsByIds = (ids: string[], canvasID?: string) => { const sortedIds = useMemo( () => Array.from(new Set(ids.filter(isDatasetId))).sort(), [ids], ); const { data, isFetching: loading } = useQuery({ - queryKey: KnowledgeListKeys.byIds(sortedIds), + queryKey: KnowledgeListKeys.byIds(sortedIds, canvasID), enabled: sortedIds.length > 0, gcTime: 0, - // Hold the previous result across id-list changes so consumers rendering - // from `data` don't flash an empty state while the next lookup is in + // Keep the previous result across id-list changes in the same canvas, but + // never show resources from a different canvas while its lookup is in // flight. - placeholderData: keepPreviousData, + placeholderData: (previousData, previousQuery) => + previousQuery?.queryKey[3] === canvasID ? previousData : undefined, queryFn: async () => { - const { data } = await listDatasetByIds(sortedIds); + const { data } = await listDatasetByIds(sortedIds, canvasID); return (data?.data ?? []) as IDataset[]; }, }); @@ -1109,7 +1101,7 @@ export const useFetchDatasetsByIds = ( */ export const useStaleDatasetIds = ( datasetIds?: string[], - ownerTenantId?: string, + canvasID?: string, ) => { // Variable references (e.g. `sys.query`) never resolve to datasets, so // exclude them up front instead of letting them fall out of the lookup @@ -1120,7 +1112,7 @@ export const useStaleDatasetIds = ( ); const { data: datasets, loading } = useFetchDatasetsByIds( persistedIds, - ownerTenantId, + canvasID, ); const staleDatasetIds = useMemo(() => { diff --git a/web/src/hooks/use-llm-request.tsx b/web/src/hooks/use-llm-request.tsx index 43e3a4a936..831e759c69 100644 --- a/web/src/hooks/use-llm-request.tsx +++ b/web/src/hooks/use-llm-request.tsx @@ -139,7 +139,7 @@ export const useFetchAllAddedModels = ( params.type = modelType; } if (ownerTenantId) { - params.owner_tenant_id = ownerTenantId; + params.tenant_id = ownerTenantId; } const { data } = await llmService.listAllAddedModels({ params }, true); diff --git a/web/src/hooks/use-mcp-request.ts b/web/src/hooks/use-mcp-request.ts index 4b97384876..c6d3972980 100644 --- a/web/src/hooks/use-mcp-request.ts +++ b/web/src/hooks/use-mcp-request.ts @@ -52,7 +52,7 @@ export const enum McpApiAction { TestMcpServer = 'testMcpServer', } -export const useListMcpServer = () => { +export const useListMcpServer = (tenantId?: string, enabled = true) => { const { searchString, setSearchString, handleInputChange } = useHandleSearchChange(); const { pagination, setPagination } = useGetPaginationWithRouter(); @@ -63,16 +63,19 @@ export const useListMcpServer = () => { McpApiAction.ListMcpServer, { debouncedSearchString, + tenantId, ...pagination, }, ], initialData: { total: 0, mcp_servers: [] }, gcTime: 0, + enabled, queryFn: async () => { const { data } = await listMcpServers({ keywords: debouncedSearchString, page_size: pagination.pageSize, page: pagination.current, + ...(tenantId ? { tenant_id: tenantId } : {}), }); return data?.data; }, diff --git a/web/src/hooks/use-memory-request.test.tsx b/web/src/hooks/use-memory-request.test.tsx index 6d1601bdf9..6f971e1dd2 100644 --- a/web/src/hooks/use-memory-request.test.tsx +++ b/web/src/hooks/use-memory-request.test.tsx @@ -48,22 +48,22 @@ describe('useFetchAllMemoryList', () => { }); }); - it('passes tenant_id when an owner tenant is given', async () => { - renderHook(() => useFetchAllMemoryList('owner-1'), { + it('passes canvas_id when a canvas is given', async () => { + renderHook(() => useFetchAllMemoryList('canvas-1'), { wrapper: createWrapper(), }); await waitFor(() => expect(mockGetMemoryList).toHaveBeenCalled()); expect(mockGetMemoryList).toHaveBeenCalledWith( { - params: { page_size: 100, page: 1, tenant_id: 'owner-1' }, + params: { page_size: 100, page: 1, canvas_id: 'canvas-1' }, data: {}, }, true, ); }); - it('omits tenant_id without an owner tenant', async () => { + it('omits canvas_id without a canvas', async () => { renderHook(() => useFetchAllMemoryList(), { wrapper: createWrapper(), }); @@ -78,10 +78,10 @@ describe('useFetchAllMemoryList', () => { ); }); - it('caches owner-scoped lists separately', async () => { + it('caches canvas-scoped lists separately', async () => { const wrapper = createWrapper(); - renderHook(() => useFetchAllMemoryList('owner-1'), { wrapper }); - renderHook(() => useFetchAllMemoryList('owner-2'), { wrapper }); + renderHook(() => useFetchAllMemoryList('canvas-1'), { wrapper }); + renderHook(() => useFetchAllMemoryList('canvas-2'), { wrapper }); await waitFor(() => expect(mockGetMemoryList).toHaveBeenCalledTimes(2)); }); diff --git a/web/src/hooks/use-memory-request.ts b/web/src/hooks/use-memory-request.ts index aa32c7ea02..9cb0aabe9e 100644 --- a/web/src/hooks/use-memory-request.ts +++ b/web/src/hooks/use-memory-request.ts @@ -22,15 +22,21 @@ export const enum MemoryApiAction { FetchMemoryList = 'fetchMemoryList', } -export const useFetchAllMemoryList = (ownerTenantId?: string) => { +export const MemoryKeys = { + list: (canvasID?: string) => + [MemoryApiAction.FetchMemoryList, canvasID] as const, +}; + +export const useFetchAllMemoryList = (canvasID?: string) => { const { data, isLoading, isError, refetch } = useQuery({ - queryKey: [MemoryApiAction.FetchMemoryList, ownerTenantId], + queryKey: MemoryKeys.list(canvasID), queryFn: async () => { - // Viewing a shared canvas: fetch the canvas owner's memories instead. const { data: response } = await memoryService.getMemoryList( { - params: ownerTenantId - ? { page_size: 100, page: 1, tenant_id: ownerTenantId } + // The backend filters this list to resources both canvas owner and + // current user can use. + params: canvasID + ? { page_size: 100, page: 1, canvas_id: canvasID } : { page_size: 100, page: 1 }, data: {}, }, diff --git a/web/src/hooks/use-stale-dataset-validation.ts b/web/src/hooks/use-stale-dataset-validation.ts index b47c547486..663f8b5562 100644 --- a/web/src/hooks/use-stale-dataset-validation.ts +++ b/web/src/hooks/use-stale-dataset-validation.ts @@ -27,21 +27,21 @@ import { useStaleDatasetIds } from './use-knowledge-request'; * persisted ids are looked up: ids picked from the dataset select are valid * by construction, while a persisted id may reference a dataset that has * since been deleted or emptied of chunks — those stale ids are flagged here. - * `ownerTenantId` scopes the lookup to the canvas owner's tenant when viewing - * a shared canvas. + * `canvasID` scopes the lookup to datasets usable by the canvas owner and the + * current user. */ export function useStaleDatasetFormSchema( schema: T, persistedDatasetIds?: string[], { fieldName = 'dataset_ids', - ownerTenantId, - }: { fieldName?: string; ownerTenantId?: string } = {}, + canvasID, + }: { fieldName?: string; canvasID?: string } = {}, ) { const { t } = useTranslation(); const { staleDatasetIds, settled: datasetsFetched } = useStaleDatasetIds( persistedDatasetIds, - ownerTenantId, + canvasID, ); const formSchema = useMemo(() => { diff --git a/web/src/interfaces/database/agent.ts b/web/src/interfaces/database/agent.ts index 7190dc8250..9f778287e3 100644 --- a/web/src/interfaces/database/agent.ts +++ b/web/src/interfaces/database/agent.ts @@ -75,6 +75,7 @@ export declare interface IFlow { update_date: string; update_time: number; user_id: string; + tenant_id?: string; permission: string; nickname: string; operator_permission: number; diff --git a/web/src/interfaces/request/file-manager.ts b/web/src/interfaces/request/file-manager.ts index 3458b915a6..942478775c 100644 --- a/web/src/interfaces/request/file-manager.ts +++ b/web/src/interfaces/request/file-manager.ts @@ -2,6 +2,7 @@ import { IPaginationRequestBody } from './base'; export interface IFileListRequestBody extends IPaginationRequestBody { parent_id?: string; // folder id + tenant_id?: string; } export interface IConnectRequestBody { diff --git a/web/src/interfaces/request/knowledge.ts b/web/src/interfaces/request/knowledge.ts index 5715d9fb4f..686b35cc94 100644 --- a/web/src/interfaces/request/knowledge.ts +++ b/web/src/interfaces/request/knowledge.ts @@ -31,8 +31,8 @@ export interface IFetchKnowledgeListRequestParams { keywords?: string; owner_ids?: string[]; parser_id?: string; - // Viewing a shared canvas: fetch the canvas owner's datasets instead. - tenant_id?: string; + // Filter to datasets usable by both the canvas owner and the current user. + canvas_id?: string; } export interface IFetchDocumentListRequestBody { diff --git a/web/src/interfaces/request/llm.ts b/web/src/interfaces/request/llm.ts index 55db0175eb..700e922a07 100644 --- a/web/src/interfaces/request/llm.ts +++ b/web/src/interfaces/request/llm.ts @@ -64,7 +64,7 @@ export interface IEditInstanceModelRequestBody { export interface IListAllModelsRequestParams { type?: string; - owner_tenant_id?: string; + tenant_id?: string; } export interface IUpdateModelStatusRequestBody { diff --git a/web/src/interfaces/request/mcp.ts b/web/src/interfaces/request/mcp.ts index 4df00c6cb1..f50f31c326 100644 --- a/web/src/interfaces/request/mcp.ts +++ b/web/src/interfaces/request/mcp.ts @@ -1,4 +1,9 @@ import { IExportedMcpServer } from '@/interfaces/database/mcp'; +import { IPaginationRequestBody } from './base'; + +export interface IMcpServerListRequestBody extends IPaginationRequestBody { + tenant_id?: string; +} export interface ITestMcpRequestBody { mcp_id?: string; diff --git a/web/src/pages/agent/canvas/node/retrieval-node.tsx b/web/src/pages/agent/canvas/node/retrieval-node.tsx index d75ee706a1..777746a38f 100644 --- a/web/src/pages/agent/canvas/node/retrieval-node.tsx +++ b/web/src/pages/agent/canvas/node/retrieval-node.tsx @@ -12,8 +12,8 @@ import { get } from 'lodash'; import { TriangleAlert } from 'lucide-react'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router'; import { NodeHandleId, RetrievalFrom } from '../../constant'; -import { useOwnerTenantId } from '../../context'; import { RetrievalFormSchemaType } from '../../form/retrieval-form/next'; import { useGetVariableLabelOrTypeByValue } from '../../hooks/use-get-begin-query'; import { LabelCard } from './card'; @@ -39,28 +39,24 @@ function InnerRetrievalNode({ const persistedDatasetIds = isMemory ? [] : knowledgeBaseIds; - // Names resolve through the canvas owner's datasets/memories — a shared - // canvas references the owner's resources, while an imported dsl.json makes - // the importer the owner. - const ownerTenantId = useOwnerTenantId(); + // Names resolve through resources usable by both the canvas owner and the + // current user. + const { id: canvasID } = useParams(); // Resolve names/avatars for the persisted ids directly: the paginated // knowledge list can miss them (unloaded pages, emptied datasets filtered // out). Shares the byIds query with the staleness check below. const { data: datasets } = useFetchDatasetsByIds( persistedDatasetIds, - ownerTenantId, + canvasID, ); // Mirror the form's stale-dataset validation: a persisted id referencing a // dataset that has since been deleted or emptied of chunks is flagged on // the node as well. Stays empty while the lookup is in flight. - const { staleDatasetIds } = useStaleDatasetIds( - persistedDatasetIds, - ownerTenantId, - ); + const { staleDatasetIds } = useStaleDatasetIds(persistedDatasetIds, canvasID); - const memoryList = useFetchAllMemoryList(ownerTenantId); + const memoryList = useFetchAllMemoryList(canvasID); return ( diff --git a/web/src/pages/agent/form/agent-form/tool-popover/tool-command.tsx b/web/src/pages/agent/form/agent-form/tool-popover/tool-command.tsx index 196f759616..104965e752 100644 --- a/web/src/pages/agent/form/agent-form/tool-popover/tool-command.tsx +++ b/web/src/pages/agent/form/agent-form/tool-popover/tool-command.tsx @@ -9,6 +9,7 @@ import { } from '@/components/ui/command'; import { useListMcpServer } from '@/hooks/use-mcp-request'; import { Operator } from '@/pages/agent/constant'; +import { useOwnerTenantId } from '@/pages/agent/context'; import OperatorIcon from '@/components/operator-icon'; import { t } from 'i18next'; import { lowerFirst } from 'lodash'; @@ -140,7 +141,8 @@ export function ToolCommand({ value, onChange }: ToolCommandProps) { } export function MCPCommand({ onChange, value }: ToolCommandProps) { - const { data } = useListMcpServer(); + const ownerTenantId = useOwnerTenantId(); + const { data } = useListMcpServer(ownerTenantId, Boolean(ownerTenantId)); const { toggleOption, currentValue } = useHandleSelectChange({ onChange, value, diff --git a/web/src/pages/agent/form/agent-form/tool-popover/use-update-mcp.ts b/web/src/pages/agent/form/agent-form/tool-popover/use-update-mcp.ts index 827b9f9e11..cce1d9c230 100644 --- a/web/src/pages/agent/form/agent-form/tool-popover/use-update-mcp.ts +++ b/web/src/pages/agent/form/agent-form/tool-popover/use-update-mcp.ts @@ -1,6 +1,6 @@ import { useListMcpServer } from '@/hooks/use-mcp-request'; import { IAgentForm } from '@/interfaces/database/agent'; -import { AgentFormContext } from '@/pages/agent/context'; +import { AgentFormContext, useOwnerTenantId } from '@/pages/agent/context'; import useGraphStore from '@/pages/agent/store'; import { get } from 'lodash'; import { useCallback, useContext, useMemo } from 'react'; @@ -18,7 +18,8 @@ export function useUpdateAgentNodeMCP() { const { updateNodeForm } = useGraphStore((state) => state); const node = useContext(AgentFormContext); const mcpList = useGetNodeMCP(); - const { data } = useListMcpServer(); + const ownerTenantId = useOwnerTenantId(); + const { data } = useListMcpServer(ownerTenantId, Boolean(ownerTenantId)); const mcpServers = data.mcp_servers; const findMcpTools = useCallback( diff --git a/web/src/pages/agent/form/extractor-form/index.tsx b/web/src/pages/agent/form/extractor-form/index.tsx index 281fbcadf1..df480b28af 100644 --- a/web/src/pages/agent/form/extractor-form/index.tsx +++ b/web/src/pages/agent/form/extractor-form/index.tsx @@ -175,7 +175,7 @@ const ExtractorForm = ({ control: form.control, name: 'tags.tag_file_id', }); - const { treeData, loadData } = useTagFileTree(tagFileId); + const { treeData, loadData } = useTagFileTree(tagFileId, ownerTenantId); const keywordsEnabledRaw = useWatch({ control: form.control, diff --git a/web/src/pages/agent/form/extractor-form/use-tag-file-tree.ts b/web/src/pages/agent/form/extractor-form/use-tag-file-tree.ts index 1c40ab3ea7..5f07e1d7ea 100644 --- a/web/src/pages/agent/form/extractor-form/use-tag-file-tree.ts +++ b/web/src/pages/agent/form/extractor-form/use-tag-file-tree.ts @@ -24,12 +24,16 @@ function toTreeNode(x: IFile): TreeNodeType { }; } -export function useTagFileTree(value?: string) { +export function useTagFileTree(value?: string, ownerTenantId?: string) { const { fetchList } = useFetchPureFileList(); const [treeData, setTreeData] = useState([]); - - const appendFiles = useCallback((files: IFile[]) => { + const ownerTenantIdRef = useRef(ownerTenantId); + ownerTenantIdRef.current = ownerTenantId; + const appendFiles = useCallback((files: IFile[], tenantId?: string) => { + if (ownerTenantIdRef.current !== tenantId) { + return; + } setTreeData((tree) => uniqBy( tree.concat( @@ -48,18 +52,26 @@ export function useTagFileTree(value?: string) { const loadData = useCallback( async ({ id }: TreeNodeType) => { - const ret = await fetchList(id as string); - if (ret.code === 0) { - appendFiles(ret.data.files); + const ret = await fetchList({ + parentId: id as string, + tenantId: ownerTenantId, + }).catch(() => null); + if (ret?.code === 0) { + appendFiles(ret.data.files, ownerTenantId); } }, - [fetchList, appendFiles], + [fetchList, appendFiles, ownerTenantId], ); // Resolve a previously saved selection: the tree is lazy-loaded, so a file // nested in subfolders is not in treeData on mount and its title cannot be // displayed. Walk the ancestor chain top-down and load each level. const resolvedValueRef = useRef(''); + useEffect(() => { + setTreeData([]); + resolvedValueRef.current = ''; + }, [ownerTenantId]); + useEffect(() => { if ( !value || @@ -70,11 +82,22 @@ export function useTagFileTree(value?: string) { } resolvedValueRef.current = value; (async () => { - const { data } = await fileManagerService.getAllParentFolder( - {}, - `${value}/ancestors`, - ); - const folders: IFile[] = data?.data?.parent_folders?.toReversed() ?? []; + const response = await fileManagerService + .getAllParentFolder( + { + ...(ownerTenantId ? { tenant_id: ownerTenantId } : {}), + }, + `${value}/ancestors`, + ) + .catch(() => null); + if (!response) { + return; + } + if (ownerTenantIdRef.current !== ownerTenantId) { + return; + } + const folders: IFile[] = + response.data?.data?.parent_folders?.toReversed() ?? []; // The root folder is its own parent (parent_id === id). Keeping it in // the tree breaks AsyncTreeSelect's root detection (no node would count // as top-level, so nothing renders), so drop it — its children then @@ -82,13 +105,27 @@ export function useTagFileTree(value?: string) { const chain = folders.filter((x) => x.id !== x.parent_id); setTreeData((tree) => uniqBy(tree.concat(chain.map(toTreeNode)), 'id')); for (const folder of folders) { - const ret = await fetchList(folder.id); + // The ancestor response starts with the selected file itself. Only + // folders can be used as parent_id when loading child entries. + if (!isFolderType(folder.type)) { + continue; + } + const ret = await fetchList({ + parentId: folder.id, + tenantId: ownerTenantId, + }).catch(() => null); + if (!ret) { + return; + } + if (ownerTenantIdRef.current !== ownerTenantId) { + return; + } if (ret.code === 0) { - appendFiles(ret.data.files); + appendFiles(ret.data.files, ownerTenantId); } } })(); - }, [value, treeData, fetchList, appendFiles]); + }, [value, treeData, fetchList, appendFiles, ownerTenantId]); return { treeData, loadData }; } diff --git a/web/src/pages/agent/form/message-form/index.tsx b/web/src/pages/agent/form/message-form/index.tsx index 42cfbd12fb..3700b183a2 100644 --- a/web/src/pages/agent/form/message-form/index.tsx +++ b/web/src/pages/agent/form/message-form/index.tsx @@ -16,9 +16,9 @@ import { X } from 'lucide-react'; import { memo } from 'react'; import { useFieldArray, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router'; import { z } from 'zod'; import { ExportFileType } from '../../constant'; -import { useOwnerTenantId } from '../../context'; import { INextOperatorForm } from '../../interface'; import { FormWrapper } from '../components/form-wrapper'; import { PromptEditor } from '../components/prompt-editor'; @@ -29,7 +29,7 @@ import { useWatchFormChange } from './use-watch-change'; function MessageForm({ node }: INextOperatorForm) { const { t } = useTranslation(); - const ownerTenantId = useOwnerTenantId(); + const { id: canvasID } = useParams(); const values = useValues(node); @@ -167,7 +167,7 @@ function MessageForm({ node }: INextOperatorForm) { )} diff --git a/web/src/pages/agent/form/retrieval-form/next.tsx b/web/src/pages/agent/form/retrieval-form/next.tsx index dc2440191c..801c432706 100644 --- a/web/src/pages/agent/form/retrieval-form/next.tsx +++ b/web/src/pages/agent/form/retrieval-form/next.tsx @@ -32,6 +32,7 @@ import { useWatch, } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router'; import { z } from 'zod'; import { RetrievalFrom, initialRetrievalValues } from '../../constant'; import { useOwnerTenantId } from '../../context'; @@ -100,7 +101,7 @@ export type RetrievalFormSchemaType = z.infer; export function MemoryDatasetForm() { const { t } = useTranslation(); const form = useFormContext(); - const ownerTenantId = useOwnerTenantId(); + const { id: canvasID } = useParams(); const retrievalFrom = useWatch({ control: form.control, name: 'retrieval_from', @@ -121,7 +122,7 @@ export function MemoryDatasetForm() { @@ -129,7 +130,7 @@ export function MemoryDatasetForm() { )} @@ -148,6 +149,7 @@ export function useHideKnowledgeGraphField(form: UseFormReturn) { function RetrievalForm({ node }: INextOperatorForm) { const { t } = useTranslation(); const ownerTenantId = useOwnerTenantId(); + const { id: canvasID } = useParams(); const outputList = useMemo(() => { return [ @@ -167,7 +169,7 @@ function RetrievalForm({ node }: INextOperatorForm) { const { formSchema, datasetsFetched } = useStaleDatasetFormSchema( FormSchema, defaultValues?.dataset_ids, - { ownerTenantId }, + { canvasID }, ); const form = useForm({ diff --git a/web/src/pages/agent/form/tool-form/retrieval-form/index.tsx b/web/src/pages/agent/form/tool-form/retrieval-form/index.tsx index 19df2c8843..e500c0059e 100644 --- a/web/src/pages/agent/form/tool-form/retrieval-form/index.tsx +++ b/web/src/pages/agent/form/tool-form/retrieval-form/index.tsx @@ -17,6 +17,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { t } from 'i18next'; import { omit } from 'lodash'; import { useForm } from 'react-hook-form'; +import { useParams } from 'react-router'; import { z } from 'zod'; import { useOwnerTenantId } from '../../../context'; import { DescriptionField } from '../../components/description-field'; @@ -41,10 +42,11 @@ const RetrievalForm = () => { const defaultValues = omit(useValues(), 'top_k'); const ownerTenantId = useOwnerTenantId(); + const { id: canvasID } = useParams(); const { formSchema, datasetsFetched } = useStaleDatasetFormSchema( FormSchema, defaultValues?.dataset_ids, - { ownerTenantId }, + { canvasID }, ); const form = useForm({ diff --git a/web/src/pages/agent/hooks/use-canvas-checklist.ts b/web/src/pages/agent/hooks/use-canvas-checklist.ts index 9a701f5bd5..61222debdb 100644 --- a/web/src/pages/agent/hooks/use-canvas-checklist.ts +++ b/web/src/pages/agent/hooks/use-canvas-checklist.ts @@ -66,7 +66,8 @@ function collectAllDatasetIds(nodes: RAGFlowNodeType[]): string[] { */ export function useCanvasChecklist({ ownerTenantId, -}: { ownerTenantId?: string } = {}) { + canvasID, +}: { ownerTenantId?: string; canvasID?: string } = {}) { const nodes = useGraphStore((state) => state.nodes); const edges = useGraphStore((state) => state.edges); const editedNodeFormIds = useGraphStore((state) => state.editedNodeFormIds); @@ -76,11 +77,10 @@ export function useCanvasChecklist({ data: memoryList, isLoading: memoryLoading, isError: memoryError, - } = useFetchAllMemoryList(ownerTenantId); - // Validate against the canvas owner's resources: a shared canvas runs with - // the owner's models/groups/memories, while an imported dsl.json makes the - // importer the owner — so references pointing anywhere else surface as - // issues. + } = useFetchAllMemoryList(canvasID); + // Model and template-group references use the canvas owner's tenant. + // Dataset and memory references are scoped by canvasID so both the canvas + // owner and current user can use the resource. const { validIds: modelValidIds, isFetched: modelsFetched } = useModelValidIds(ModelTypeMap.llm_id, ownerTenantId); const { @@ -91,7 +91,7 @@ export function useCanvasChecklist({ const datasetIds = useMemo(() => collectAllDatasetIds(nodes), [nodes]); const { staleDatasetIds, settled: datasetsSettled } = useStaleDatasetIds( datasetIds, - ownerTenantId, + canvasID, ); const inputs: CanvasChecklistInputs = useMemo( diff --git a/web/src/pages/agent/hooks/use-find-mcp-by-id.ts b/web/src/pages/agent/hooks/use-find-mcp-by-id.ts index e4c5aed5a3..2c2f8808ba 100644 --- a/web/src/pages/agent/hooks/use-find-mcp-by-id.ts +++ b/web/src/pages/agent/hooks/use-find-mcp-by-id.ts @@ -1,7 +1,9 @@ import { useListMcpServer } from '@/hooks/use-mcp-request'; +import { useOwnerTenantId } from '../context'; export function useFindMcpById() { - const { data } = useListMcpServer(); + const ownerTenantId = useOwnerTenantId(); + const { data } = useListMcpServer(ownerTenantId, Boolean(ownerTenantId)); const findMcpById = (id: string) => data.mcp_servers.find((item) => item.id === id); diff --git a/web/src/pages/agent/index.tsx b/web/src/pages/agent/index.tsx index aba9f76ffe..dafc8aefec 100644 --- a/web/src/pages/agent/index.tsx +++ b/web/src/pages/agent/index.tsx @@ -122,7 +122,8 @@ export default function Agent() { }, [saveGraph]); const { flowDetail: agentDetail } = useFetchDataOnMount(); const { issues, getLatestIssues } = useCanvasChecklist({ - ownerTenantId: agentDetail?.user_id, + ownerTenantId: agentDetail?.tenant_id, + canvasID: id, }); const hasBlockingIssues = useCallback(() => { if (getLatestIssues().length > 0) { @@ -402,7 +403,7 @@ export default function Agent() { - + ({ + useFetchPipelineDslByPipelineId: (_id: string, isBuiltin: boolean) => ({ + dsl: isBuiltin ? mockBuiltinDsl : mockCustomDsl, + loading: false, + }), +})); +jest.mock('@/hooks/use-document-request', () => ({ + useSetDocumentPipelineParser: () => ({ + setDocumentPipelineParser: mockSave, + loading: false, + }), +})); +jest.mock('@/components/parser-select', () => ({ + ParserSelect: ({ onChange }: any) => { + const choosePipeline = () => onChange('pipeline', 'pipeline'); + const chooseBuiltin = () => onChange('builtin', 'general'); + const clearParser = () => onChange(null, ''); + return ( + <> + + + + + ); + }, +})); +jest.mock('@/routes', () => ({ Routes: {} })); +jest.mock('@/components/pipeline-operator-tabs/pipeline-operator-form', () => { + const { useForm } = require('react-hook-form'); + const { + useFormChangeCallback, + } = require('@/pages/agent/hooks/use-form-change-callback'); + const { FormWrapper } = require('@/pages/agent/form/components/form-wrapper'); + return { + __esModule: true, + default: ({ node, onValuesChange }: any) => { + const form = useForm({ defaultValues: node.data.form }); + useFormChangeCallback(form, onValuesChange); + return ( + + + + ); + }, + }; +}); +jest.mock('react-i18next', () => ({ + ...jest.requireActual('react-i18next'), + useTranslation: () => ({ t: (key: string) => key }), +})); + +function Harness({ builtin = false }: { builtin?: boolean }) { + const state = useChangeDocumentParser(); + const openDialog = () => { + state.showChangeParserModal({ + id: 'document', + dataset_id: 'dataset', + chunk_method: builtin ? 'general' : '', + pipeline_id: builtin ? '' : 'pipeline', + parser_config: builtin + ? {} + : { + 'Parser:parse': { pdf: { parse_method: 'deepdoc' } }, + 'Compiler:compile': { + compilation_template_group_id: 'pipeline-template-group', + llm_id: 'document-model', + }, + }, + } as any); + }; + return ( + <> + + {state.changeParserVisible && ( + + )} + + ); +} + +beforeEach(() => { + mockSave.mockClear(); + mockCustomDsl = mockDsl; +}); + +it('saves the built-in parser', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Open' })); + fireEvent.click(screen.getByRole('button', { name: 'common.save' })); + await waitFor(() => expect(mockSave).toHaveBeenCalledTimes(1)); + expect(mockSave.mock.calls[0][0]).toEqual( + expect.objectContaining({ + parserId: 'general', + pipelineId: '', + parse_type: 1, + }), + ); +}); + +it('saves after switching from a built-in parser to a pipeline', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Open' })); + fireEvent.click(screen.getByRole('button', { name: 'Choose pipeline' })); + fireEvent.click(screen.getByRole('button', { name: 'common.save' })); + await waitFor(() => expect(mockSave).toHaveBeenCalledTimes(1)); + expect(mockSave.mock.calls[0][0]).toEqual( + expect.objectContaining({ + parserId: '', + pipelineId: 'pipeline', + parse_type: 2, + }), + ); +}); + +it('saves the repository advanced ingestion pipeline template', async () => { + mockCustomDsl = + require('../../../../../internal/agent/templates/advanced_ingestion_pipeline.json').dsl; + render(); + fireEvent.click(screen.getByRole('button', { name: 'Open' })); + fireEvent.click(screen.getByRole('button', { name: 'Choose pipeline' })); + fireEvent.click(screen.getByRole('button', { name: 'common.save' })); + await waitFor(() => expect(mockSave).toHaveBeenCalledTimes(1)); + expect(mockSave.mock.calls[0][0].pipelineId).toBe('pipeline'); +}); + +it('saves after switching from a pipeline back to a built-in parser', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Open' })); + fireEvent.click(screen.getByRole('button', { name: 'Choose builtin' })); + fireEvent.click(screen.getByRole('button', { name: 'common.save' })); + await waitFor(() => expect(mockSave).toHaveBeenCalledTimes(1)); + expect(mockSave.mock.calls[0][0]).toEqual( + expect.objectContaining({ + parserId: 'general', + pipelineId: '', + parse_type: 1, + }), + ); +}); + +it('still rejects a pipeline selection without a pipeline ID', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Open' })); + fireEvent.click(screen.getByRole('button', { name: 'Choose pipeline' })); + fireEvent.click(screen.getByRole('button', { name: 'Clear parser' })); + fireEvent.click(screen.getByRole('button', { name: 'common.save' })); + expect(await screen.findByText('common.pleaseSelect')).toBeInTheDocument(); + expect(mockSave).not.toHaveBeenCalled(); +}); diff --git a/web/src/pages/dataset/index.tsx b/web/src/pages/dataset/index.tsx index fa9acfe646..afe8c85b43 100644 --- a/web/src/pages/dataset/index.tsx +++ b/web/src/pages/dataset/index.tsx @@ -1,4 +1,5 @@ import { useFetchKnowledgeBaseConfiguration } from '@/hooks/use-knowledge-request'; +import { OwnerTenantIdContext } from '@/pages/agent/context'; import { KnowledgeBaseProvider } from '@/pages/dataset/contexts/knowledge-base-context'; import { Outlet } from 'react-router'; @@ -8,12 +9,14 @@ export default function DatasetWrapper() { const { data, loading } = useFetchKnowledgeBaseConfiguration(); return ( - -
- + + +
+ - -
-
+ +
+
+
); } diff --git a/web/src/pages/dataset/setting/components/link-data-source-modal.tsx b/web/src/pages/dataset/setting/components/link-data-source-modal.tsx index 9cde80eab0..892e4322fe 100644 --- a/web/src/pages/dataset/setting/components/link-data-source-modal.tsx +++ b/web/src/pages/dataset/setting/components/link-data-source-modal.tsx @@ -2,6 +2,7 @@ import { Button } from '@/components/ui/button'; import { SearchInput } from '@/components/ui/input'; import { Modal } from '@/components/ui/modal/modal'; import { IConnector } from '@/interfaces/database/dataset'; +import { useOwnerTenantId } from '@/pages/dataset/contexts/knowledge-base-context'; import { useListDataSource } from '@/pages/user-setting/data-source/hooks'; import { IDataSourceBase } from '@/pages/user-setting/data-source/interface'; import { t } from 'i18next'; @@ -26,7 +27,11 @@ const LinkDataSourceModal = ({ setList(selectedList); }, [selectedList]); - const { categorizedList } = useListDataSource(); + const ownerTenantId = useOwnerTenantId(); + const { categorizedList } = useListDataSource( + ownerTenantId, + Boolean(ownerTenantId), + ); const handleFormSubmit = (values: any) => { console.log(values, selectedList); onSubmit?.(list); diff --git a/web/src/pages/files/move-dialog.tsx b/web/src/pages/files/move-dialog.tsx index dbd4ead161..6bd58ef3d5 100644 --- a/web/src/pages/files/move-dialog.tsx +++ b/web/src/pages/files/move-dialog.tsx @@ -28,7 +28,7 @@ export function MoveDialog({ hideModal, onOk, loading }: IModalProps) { const onLoadData = useCallback( async ({ id }: TreeNodeType) => { - const ret = await fetchList(id as string); + const ret = await fetchList({ parentId: id as string }); if (ret.code === 0) { setTreeData((tree) => { return uniqBy( diff --git a/web/src/pages/user-setting/data-source/hooks.ts b/web/src/pages/user-setting/data-source/hooks.ts index 1d9e8b72fb..8be3fcda73 100644 --- a/web/src/pages/user-setting/data-source/hooks.ts +++ b/web/src/pages/user-setting/data-source/hooks.ts @@ -40,7 +40,10 @@ import { } from './interface'; const DataSourceKeys = { - list: () => ['data-source'] as const, + list: (tenantId?: string) => + tenantId + ? (['data-source', tenantId] as const) + : (['data-source'] as const), detail: (id?: string | null) => ['data-source-detail', id] as const, logs: ( id: string | null, @@ -50,12 +53,15 @@ const DataSourceKeys = { logsPrefix: (id?: string | null) => ['data-source-logs', id] as const, }; -export const useListDataSource = () => { +export const useListDataSource = (tenantId?: string, enabled = true) => { const { dataSourceInfo } = useDataSourceInfo(); const { data: list, isFetching } = useQuery({ - queryKey: DataSourceKeys.list(), + queryKey: DataSourceKeys.list(tenantId), + enabled, queryFn: async () => { - const { data } = await dataSourceService.dataSourceList(); + const { data } = await dataSourceService.dataSourceList( + tenantId ? { tenant_id: tenantId } : undefined, + ); return data.data; }, }); diff --git a/web/src/services/knowledge-service.ts b/web/src/services/knowledge-service.ts index ec67beb4dd..dec23324fd 100644 --- a/web/src/services/knowledge-service.ts +++ b/web/src/services/knowledge-service.ts @@ -358,14 +358,14 @@ export const listDataset = (params?: IFetchKnowledgeListRequestParams) => // Fetch datasets by a set of IDs via the `ids` query param (comma-joined). // Used to echo back already-selected datasets whose names are not present -// in the first page of the paginated list. `ownerTenantId` scopes the lookup -// to the canvas owner's tenant when viewing a shared canvas. -export const listDatasetByIds = (ids: string[], ownerTenantId?: string) => +// in the first page of the paginated list. `canvasID` limits results to +// datasets usable by both the canvas owner and the current user. +export const listDatasetByIds = (ids: string[], canvasID?: string) => request.get(api.kbList, { params: { ids: ids.join(','), page_size: ids.length, - ...(ownerTenantId ? { tenant_id: ownerTenantId } : {}), + ...(canvasID ? { canvas_id: canvasID } : {}), }, }); diff --git a/web/src/services/mcp-server-service.ts b/web/src/services/mcp-server-service.ts index f22a49f49d..9805e14703 100644 --- a/web/src/services/mcp-server-service.ts +++ b/web/src/services/mcp-server-service.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { IPaginationRequestBody } from '@/interfaces/request/base'; +import { IMcpServerListRequestBody } from '@/interfaces/request/mcp'; import api from '@/utils/api'; import request from '@/utils/request'; @@ -34,12 +34,12 @@ const mcpServerService = { export: ({ mcp_id }: { mcp_id: string }) => request.get(api.exportMcpServer(mcp_id)), test: (params: Record) => - request.post(api.testMcpServer(params.mcp_id || params.name || 'preview'), { + request.post(api.testMcpServer(params.mcp_id || 'preview'), { data: params, }), }; export default mcpServerService; -export const listMcpServers = (params?: IPaginationRequestBody, body?: any) => - request.get(api.listMcpServer, { params: { ...params, ...(body || {}) } }); +export const listMcpServers = (params?: IMcpServerListRequestBody) => + request.get(api.listMcpServer, { params });