mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-10-12 02:16:39 +00:00
refa: centralize Go resource permissions (#20657)
This commit is contained in:
@@ -21,11 +21,13 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/permission"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -585,14 +587,28 @@ func (s *Service) ListUserDatasets(ctx context.Context, email string) ([]map[str
|
||||
return nil, fmt.Errorf("failed to get user: %w", err)
|
||||
}
|
||||
|
||||
tenantIDs, err := s.userTenantDAO.GetTenantIDsByUserID(ctx, dao.DB, user.ID)
|
||||
scope, err := permission.NewDatabaseChecker(dao.DB).Scope(ctx, permission.Subject{UserID: user.ID}, permission.ScopeQuery{
|
||||
Kind: permission.ResourceKindDataset, Operation: permission.OperationRead,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user tenants: %w", err)
|
||||
return nil, fmt.Errorf("failed to resolve user dataset permissions: %w", err)
|
||||
}
|
||||
|
||||
datasets, err := s.kbDAO.GetAllByTenantIDs(ctx, dao.DB, tenantIDs, user.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user datasets: %w", err)
|
||||
var datasets []*entity.Knowledgebase
|
||||
if scope.Mode == permission.ScopeIDs {
|
||||
datasets, err = s.kbDAO.GetByIDs(ctx, dao.DB, scope.ResourceIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user datasets: %w", err)
|
||||
}
|
||||
sort.SliceStable(datasets, func(i, j int) bool {
|
||||
if datasets[i].CreateTime == nil {
|
||||
return datasets[j].CreateTime != nil
|
||||
}
|
||||
if datasets[j].CreateTime == nil {
|
||||
return false
|
||||
}
|
||||
return *datasets[i].CreateTime < *datasets[j].CreateTime
|
||||
})
|
||||
}
|
||||
|
||||
result := make([]map[string]interface{}, 0, len(datasets))
|
||||
|
||||
@@ -78,6 +78,13 @@ func setupAdminUserDetailTest(t *testing.T) *Handler {
|
||||
if err := db.Create(&membership).Error; err != nil {
|
||||
t.Fatalf("create membership: %v", err)
|
||||
}
|
||||
ownerMemberships := []entity.UserTenant{
|
||||
{ID: "membership-owner-user", UserID: user.ID, TenantID: user.ID, Role: "owner", InvitedBy: user.ID, Status: &statusValid},
|
||||
{ID: "membership-owner-tenant-2", UserID: "tenant-2", TenantID: "tenant-2", Role: "owner", InvitedBy: "tenant-2", Status: &statusValid},
|
||||
}
|
||||
if err := db.Create(&ownerMemberships).Error; err != nil {
|
||||
t.Fatalf("create owner memberships: %v", err)
|
||||
}
|
||||
|
||||
datasets := []entity.Knowledgebase{
|
||||
{ID: "dataset-own", TenantID: user.ID, Name: "Own dataset", EmbdID: "embedding", Permission: "me", CreatedBy: user.ID, Status: &statusValid},
|
||||
|
||||
@@ -385,6 +385,57 @@ func TestRetrieval_ResolveDatasetIDByTenantName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetrieval_ResolveDatasetIDByUserPermission(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
visibility string
|
||||
wantID string
|
||||
}{
|
||||
{name: "team-visible", visibility: string(entity.TenantPermissionTeam), wantID: "kb-team"},
|
||||
{name: "private-denied", visibility: string(entity.TenantPermissionMe)},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&entity.Knowledgebase{}, &entity.UserTenant{}); err != nil {
|
||||
t.Fatalf("failed to migrate permission tables: %v", err)
|
||||
}
|
||||
origDB := dao.DB
|
||||
dao.DB = db
|
||||
t.Cleanup(func() { dao.DB = origDB })
|
||||
|
||||
status := string(entity.StatusValid)
|
||||
datasetID := "kb-team"
|
||||
if tc.visibility == string(entity.TenantPermissionMe) {
|
||||
datasetID = "kb-private"
|
||||
}
|
||||
dataset := entity.Knowledgebase{
|
||||
ID: datasetID, Name: "named-dataset", TenantID: "tenant-1", EmbdID: "embedding",
|
||||
Permission: tc.visibility, CreatedBy: "owner", Status: &status,
|
||||
}
|
||||
if err := db.Create(&dataset).Error; err != nil {
|
||||
t.Fatalf("failed to seed dataset: %v", err)
|
||||
}
|
||||
memberships := []entity.UserTenant{
|
||||
{ID: "owner-membership", UserID: "owner", TenantID: "tenant-1", Role: "owner", InvitedBy: "owner", Status: &status},
|
||||
{ID: "member-membership", UserID: "member", TenantID: "tenant-1", Role: "normal", InvitedBy: "owner", Status: &status},
|
||||
}
|
||||
if err := db.Create(&memberships).Error; err != nil {
|
||||
t.Fatalf("failed to seed memberships: %v", err)
|
||||
}
|
||||
|
||||
state := runtime.NewCanvasState("run-1", "task-1")
|
||||
state.Sys["user_id"] = "member"
|
||||
ctx := runtime.WithState(t.Context(), state)
|
||||
if got := resolveRetrievalDatasetID(ctx, db, "named-dataset"); got != tc.wantID {
|
||||
t.Fatalf("resolveRetrievalDatasetID = %q, want %q", got, tc.wantID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetrieval_StructuredInputPreservesQueryWhenDatasetIDsAlreadyPresent(t *testing.T) {
|
||||
c, err := newRetrievalComponent(nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -34,7 +34,7 @@ import (
|
||||
agenttool "ragflow/internal/agent/tool"
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/permission"
|
||||
"ragflow/internal/storage"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -460,19 +460,30 @@ func resolveRetrievalDatasetID(ctx context.Context, db *gorm.DB, kbName string)
|
||||
}
|
||||
}
|
||||
if userID, _ := state.Sys["user_id"].(string); userID != "" {
|
||||
if kbs, lookupErr := dao.NewKnowledgebaseDAO().GetKBByNameAndUserID(ctx, db, kbName, userID); lookupErr == nil && len(kbs) > 0 {
|
||||
for _, kb := range kbs {
|
||||
if kb == nil || kb.Status == nil || *kb.Status != string(entity.StatusValid) {
|
||||
continue
|
||||
}
|
||||
common.Debug("agent retrieval component: resolved dataset id by user visibility")
|
||||
return kb.ID
|
||||
}
|
||||
} else if lookupErr != nil {
|
||||
common.Warn("agent retrieval component: resolve dataset id by name failed",
|
||||
kbs, lookupErr := dao.NewKnowledgebaseDAO().GetByNameInUserTenants(ctx, db, kbName, userID)
|
||||
if lookupErr != nil {
|
||||
common.Warn("agent retrieval component: resolve dataset id by tenant membership failed",
|
||||
zap.Error(lookupErr))
|
||||
} else {
|
||||
common.Debug("agent retrieval component: user visibility lookup missed")
|
||||
refs := make([]permission.ResourceRef, 0, len(kbs))
|
||||
for _, kb := range kbs {
|
||||
if kb != nil {
|
||||
refs = append(refs, permission.ResourceRef{Kind: permission.ResourceKindDataset, ID: kb.ID})
|
||||
}
|
||||
}
|
||||
accessible, accessErr := permission.NewDatabaseChecker(db).FilterResources(
|
||||
ctx,
|
||||
permission.Subject{UserID: userID},
|
||||
refs,
|
||||
permission.OperationUse,
|
||||
)
|
||||
if accessErr != nil {
|
||||
common.Warn("agent retrieval component: check dataset use permission failed",
|
||||
zap.Error(accessErr))
|
||||
} else if len(accessible) > 0 {
|
||||
common.Debug("agent retrieval component: resolved dataset id by permission")
|
||||
return accessible[0].ID
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -73,6 +73,8 @@ import (
|
||||
"ragflow/internal/engine"
|
||||
"ragflow/internal/entity"
|
||||
modelModule "ragflow/internal/entity/models"
|
||||
"ragflow/internal/permission"
|
||||
permissionresponse "ragflow/internal/permission/response"
|
||||
"ragflow/internal/service/nlp"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -201,6 +203,22 @@ func (a *NLPRetrievalAdapter) Search(ctx context.Context, db *gorm.DB, req Retri
|
||||
if len(datasets.tenantIDs) != 1 {
|
||||
return nil, fmt.Errorf("retrieval: datasets span multiple tenants")
|
||||
}
|
||||
// Dataset authorization must use the caller identity established by the
|
||||
// agent run, not req.UserID, which is a configurable per-user memory filter.
|
||||
userID := ""
|
||||
if state, stateErr := runtime.GetStateFromContext(ctx); stateErr == nil && state != nil {
|
||||
userID, _ = state.Sys["user_id"].(string)
|
||||
}
|
||||
userID = strings.TrimSpace(userID)
|
||||
checker := permission.NewDatabaseChecker(db)
|
||||
for _, datasetID := range datasets.kbIDs {
|
||||
if err := checker.CheckResource(ctx, permission.Subject{UserID: userID}, permission.ResourceRef{
|
||||
Kind: permission.ResourceKindDataset, ID: datasetID,
|
||||
}, permission.OperationUse); err != nil {
|
||||
_, permissionErr := permissionresponse.Normalize(err)
|
||||
return nil, permissionErr
|
||||
}
|
||||
}
|
||||
if err := validateEmbeddingModels(ctx, db, datasets.kbs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -32,10 +32,13 @@ import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"ragflow/internal/agent/runtime"
|
||||
"ragflow/internal/entity"
|
||||
modelModule "ragflow/internal/entity/models"
|
||||
"ragflow/internal/permission"
|
||||
"ragflow/internal/service/nlp"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -412,6 +415,45 @@ func TestNLPRetrievalAdapter_SearchRejectsDatasetsFromMultipleTenants(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestNLPRetrievalAdapter_SearchRequiresDatasetUsePermission(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&entity.Knowledgebase{}, &entity.UserTenant{}); err != nil {
|
||||
t.Fatalf("migrate permission tables: %v", err)
|
||||
}
|
||||
status := string(entity.StatusValid)
|
||||
dataset := entity.Knowledgebase{
|
||||
ID: "kb-private", TenantID: "tenant-1", Name: "private", EmbdID: "embedding@provider",
|
||||
Permission: string(entity.TenantPermissionMe), CreatedBy: "owner", Status: &status,
|
||||
}
|
||||
if err := db.Create(&dataset).Error; err != nil {
|
||||
t.Fatalf("create dataset: %v", err)
|
||||
}
|
||||
memberships := []entity.UserTenant{
|
||||
{ID: "owner-membership", UserID: "owner", TenantID: "tenant-1", Role: string(permission.RoleOwner), InvitedBy: "owner", Status: &status},
|
||||
{ID: "member-membership", UserID: "member", TenantID: "tenant-1", Role: string(permission.RoleNormal), InvitedBy: "owner", Status: &status},
|
||||
}
|
||||
if err := db.Create(&memberships).Error; err != nil {
|
||||
t.Fatalf("create memberships: %v", err)
|
||||
}
|
||||
|
||||
adapter := &NLPRetrievalAdapter{
|
||||
svc: &nlp.RetrievalService{},
|
||||
kbDAO: fakeKnowledgebaseLookup{kbs: []*entity.Knowledgebase{&dataset}},
|
||||
}
|
||||
state := runtime.NewCanvasState("run-1", "session-1")
|
||||
state.Sys["user_id"] = "member"
|
||||
ctx := runtime.WithState(t.Context(), state)
|
||||
_, err = adapter.Search(ctx, db, RetrievalRequest{
|
||||
Query: "hello", DatasetIDs: []string{dataset.ID}, UserID: "owner",
|
||||
})
|
||||
if !errors.Is(err, permission.ErrPermissionDenied) {
|
||||
t.Fatalf("Search error = %v, want permission denial for runtime caller despite req.UserID=owner", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNLPRetrievalAdapter_ResolveDatasetsFromDatasetIDs(t *testing.T) {
|
||||
a := &NLPRetrievalAdapter{
|
||||
kbDAO: fakeKnowledgebaseLookup{
|
||||
|
||||
+18
-152
@@ -226,9 +226,8 @@ func (dao *KnowledgebaseDAO) Count(ctx context.Context, db *gorm.DB, filters map
|
||||
return count, err
|
||||
}
|
||||
|
||||
// GetByTenantIDs retrieves knowledge bases by tenant IDs with pagination
|
||||
// This matches the Python get_by_tenant_ids method
|
||||
func (dao *KnowledgebaseDAO) GetByTenantIDs(ctx context.Context, db *gorm.DB, tenantIDs []string, userID string, pageNumber, itemsPerPage int, terms []OrderTerm, keywords, parserID, id, name string, ids []string) ([]*entity.KnowledgebaseListItem, int64, error) {
|
||||
// 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) {
|
||||
var kbs []*entity.KnowledgebaseListItem
|
||||
var total int64
|
||||
|
||||
@@ -242,15 +241,10 @@ func (dao *KnowledgebaseDAO) GetByTenantIDs(ctx context.Context, db *gorm.DB, te
|
||||
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.tenant_id IN ? AND knowledgebase.permission = ?) OR knowledgebase.tenant_id = ?) AND knowledgebase.status = ?",
|
||||
tenantIDs, string(entity.TenantPermissionTeam), userID, string(entity.StatusValid))
|
||||
Where("knowledgebase.id IN ? AND knowledgebase.status = ?", resourceIDs, string(entity.StatusValid))
|
||||
|
||||
if id != "" {
|
||||
query = query.Where("knowledgebase.id = ?", id)
|
||||
}
|
||||
|
||||
if len(ids) > 0 {
|
||||
query = query.Where("knowledgebase.id IN ?", ids)
|
||||
if len(ownerIDs) > 0 {
|
||||
query = query.Where("knowledgebase.tenant_id IN ?", ownerIDs)
|
||||
}
|
||||
|
||||
if name != "" {
|
||||
@@ -290,34 +284,23 @@ func (dao *KnowledgebaseDAO) GetByTenantIDs(ctx context.Context, db *gorm.DB, te
|
||||
return kbs, total, nil
|
||||
}
|
||||
|
||||
// GetOwnerFilter returns owner counts for datasets visible to a user.
|
||||
func (dao *KnowledgebaseDAO) GetOwnerFilter(ctx context.Context, db *gorm.DB, tenantIDs []string, userID string) ([]*entity.DatasetOwnerFilter, error) {
|
||||
// GetOwnerFilterByResourceIDs groups permission-filtered datasets by owner tenant.
|
||||
func (dao *KnowledgebaseDAO) GetOwnerFilterByResourceIDs(ctx context.Context, db *gorm.DB, resourceIDs []string) ([]*entity.DatasetOwnerFilter, error) {
|
||||
owners := make([]*entity.DatasetOwnerFilter, 0)
|
||||
if len(resourceIDs) == 0 {
|
||||
return owners, nil
|
||||
}
|
||||
|
||||
err := db.WithContext(ctx).Model(&entity.Knowledgebase{}).
|
||||
Select("knowledgebase.tenant_id as id, user.nickname as label, COUNT(knowledgebase.id) as count").
|
||||
Joins("LEFT JOIN user ON knowledgebase.tenant_id = user.id").
|
||||
Where("((knowledgebase.tenant_id IN ? AND knowledgebase.permission = ?) OR knowledgebase.tenant_id = ?) AND knowledgebase.status = ?",
|
||||
tenantIDs, string(entity.TenantPermissionTeam), userID, string(entity.StatusValid)).
|
||||
Where("knowledgebase.id IN ? AND knowledgebase.status = ?", resourceIDs, string(entity.StatusValid)).
|
||||
Group("knowledgebase.tenant_id, user.nickname").
|
||||
Scan(&owners).Error
|
||||
|
||||
return owners, err
|
||||
}
|
||||
|
||||
// GetAllByTenantIDs retrieves all permitted knowledge bases by tenant IDs
|
||||
// This matches the Python get_all_kb_by_tenant_ids method
|
||||
func (dao *KnowledgebaseDAO) GetAllByTenantIDs(ctx context.Context, db *gorm.DB, tenantIDs []string, userID string) ([]*entity.Knowledgebase, error) {
|
||||
var kbs []*entity.Knowledgebase
|
||||
|
||||
err := db.WithContext(ctx).Where(
|
||||
"((tenant_id IN ? AND permission = ?) OR tenant_id = ?) AND status = ?",
|
||||
tenantIDs, string(entity.TenantPermissionTeam), userID, string(entity.StatusValid),
|
||||
).Order("create_time ASC").Find(&kbs).Error
|
||||
|
||||
return kbs, err
|
||||
}
|
||||
|
||||
// GetDetail retrieves detailed knowledge base information with joined pipeline data
|
||||
// This matches the Python get_detail method
|
||||
func (dao *KnowledgebaseDAO) GetDetail(ctx context.Context, db *gorm.DB, kbID string) (*entity.KnowledgebaseDetail, error) {
|
||||
@@ -345,71 +328,7 @@ func (dao *KnowledgebaseDAO) GetDetail(ctx context.Context, db *gorm.DB, kbID st
|
||||
return &detail, nil
|
||||
}
|
||||
|
||||
// Accessible checks if a knowledge base is accessible by a user.
|
||||
// This matches the Python accessible method:
|
||||
// 1. KB must exist and be VALID
|
||||
// 2. If user is the owner tenant, return true
|
||||
// 3. Non-owners require the explicit "team" permission
|
||||
// 4. Team members must have a valid user_tenant relationship
|
||||
func (dao *KnowledgebaseDAO) Accessible(ctx context.Context, db *gorm.DB, datasetID, userID string) bool {
|
||||
var kb entity.Knowledgebase
|
||||
err := db.WithContext(ctx).Where("id = ? AND status = ?", datasetID, string(entity.StatusValid)).First(&kb).Error
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// User is the owner tenant itself
|
||||
if kb.TenantID == userID {
|
||||
return true
|
||||
}
|
||||
|
||||
// Fail closed for unknown, missing, or private permissions.
|
||||
if kb.Permission != string(entity.TenantPermissionTeam) {
|
||||
return false
|
||||
}
|
||||
|
||||
var count int64
|
||||
err = db.WithContext(ctx).Table("user_tenant").
|
||||
Where("tenant_id = ? AND user_id = ? AND status = ?", kb.TenantID, userID, string(entity.StatusValid)).
|
||||
Count(&count).Error
|
||||
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// GetAccessibleIDs returns the subset of ids that are visible to the user:
|
||||
// team-permission KBs owned by any joined tenant, plus the user's own KBs.
|
||||
// This matches the Python get_accessible_ids method.
|
||||
func (dao *KnowledgebaseDAO) GetAccessibleIDs(ctx context.Context, db *gorm.DB, joinedTenantIDs []string, userID string, ids []string) ([]string, error) {
|
||||
accessibleIDs := make([]string, 0, len(ids))
|
||||
err := db.WithContext(ctx).Model(&entity.Knowledgebase{}).
|
||||
Where("id IN ? AND ((tenant_id IN ? AND permission = ?) OR tenant_id = ?) AND status = ?",
|
||||
ids, joinedTenantIDs, string(entity.TenantPermissionTeam), userID, string(entity.StatusValid)).
|
||||
Pluck("id", &accessibleIDs).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return accessibleIDs, nil
|
||||
}
|
||||
|
||||
// Accessible4Deletion checks if a knowledge base can be deleted by a user
|
||||
// This matches the Python accessible4deletion method
|
||||
func (dao *KnowledgebaseDAO) Accessible4Deletion(ctx context.Context, db *gorm.DB, kbID, userID string) bool {
|
||||
var count int64
|
||||
err := db.WithContext(ctx).Model(&entity.Knowledgebase{}).
|
||||
Where("id = ? AND created_by = ? AND status = ?", kbID, userID, string(entity.StatusValid)).
|
||||
Count(&count).Error
|
||||
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// DuplicateName generates a unique name by appending parentheses if name already exists
|
||||
// This matches the Python duplicate_name function behavior
|
||||
// DuplicateName generates a unique name by appending parentheses if name already exists.
|
||||
func (dao *KnowledgebaseDAO) DuplicateName(ctx context.Context, db *gorm.DB, name, tenantID string) string {
|
||||
const maxRetries = 1000
|
||||
|
||||
@@ -511,72 +430,19 @@ func (dao *KnowledgebaseDAO) GetFieldMap(ctx context.Context, db *gorm.DB, ids [
|
||||
return conf, nil
|
||||
}
|
||||
|
||||
// GetKBByIDAndUserID retrieves a knowledge base by ID and user ID with tenant join
|
||||
// This matches the Python get_kb_by_id method
|
||||
func (dao *KnowledgebaseDAO) GetKBByIDAndUserID(ctx context.Context, db *gorm.DB, kbID, userID string) ([]*entity.Knowledgebase, error) {
|
||||
// GetByNameInUserTenants returns dataset candidates whose tenant the user belongs to.
|
||||
// Callers must authorize each candidate before using it.
|
||||
func (dao *KnowledgebaseDAO) GetByNameInUserTenants(ctx context.Context, db *gorm.DB, kbName, userID string) ([]*entity.Knowledgebase, error) {
|
||||
var kbs []*entity.Knowledgebase
|
||||
err := db.WithContext(ctx).Model(&entity.Knowledgebase{}).
|
||||
Joins("JOIN user_tenant ON user_tenant.tenant_id = knowledgebase.tenant_id").
|
||||
Where("knowledgebase.id = ? AND user_tenant.user_id = ?", kbID, userID).
|
||||
Limit(1).
|
||||
Where("knowledgebase.name = ? AND user_tenant.user_id = ? AND knowledgebase.status = ? AND user_tenant.status = ?",
|
||||
kbName, userID, string(entity.StatusValid), string(entity.StatusValid)).
|
||||
Order("knowledgebase.create_time ASC").
|
||||
Find(&kbs).Error
|
||||
return kbs, err
|
||||
}
|
||||
|
||||
// GetKBByNameAndUserID retrieves a knowledge base by name and user ID with tenant join
|
||||
// This matches the Python get_kb_by_name method
|
||||
func (dao *KnowledgebaseDAO) GetKBByNameAndUserID(ctx context.Context, db *gorm.DB, kbName, userID string) ([]*entity.Knowledgebase, error) {
|
||||
var kbs []*entity.Knowledgebase
|
||||
err := db.WithContext(ctx).Model(&entity.Knowledgebase{}).
|
||||
Joins("JOIN user_tenant ON user_tenant.tenant_id = knowledgebase.tenant_id").
|
||||
Where("knowledgebase.name = ? AND user_tenant.user_id = ?", kbName, userID).
|
||||
Limit(1).
|
||||
Find(&kbs).Error
|
||||
return kbs, err
|
||||
}
|
||||
|
||||
// GetList retrieves knowledge bases with filtering by ID and name
|
||||
// This matches the Python get_list method
|
||||
func (dao *KnowledgebaseDAO) GetList(ctx context.Context, db *gorm.DB, tenantIDs []string, userID string, pageNumber, itemsPerPage int, terms []OrderTerm, id, name string) ([]*entity.Knowledgebase, int64, error) {
|
||||
var kbs []*entity.Knowledgebase
|
||||
var total int64
|
||||
|
||||
query := db.WithContext(ctx).Model(&entity.Knowledgebase{}).
|
||||
Where("((tenant_id IN ? AND permission = ?) OR tenant_id = ?) AND status = ?",
|
||||
tenantIDs, string(entity.TenantPermissionTeam), userID, string(entity.StatusValid))
|
||||
|
||||
if id != "" {
|
||||
query = query.Where("id = ?", id)
|
||||
}
|
||||
if name != "" {
|
||||
query = query.Where("name = ?", name)
|
||||
}
|
||||
|
||||
// Route the requested terms through knowledgebaseOrderClause so a user-supplied query
|
||||
// param can never reach Order() verbatim: the helper validates against
|
||||
// knowledgebaseOrderableColumns (a closed allowlist) and falls back to
|
||||
// "create_time" on a miss.
|
||||
// codeql[go/sql-injection] False positive: knowledgebaseOrderClause
|
||||
query = query.Order(knowledgebaseOrderClause(terms))
|
||||
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if pageNumber > 0 && itemsPerPage > 0 {
|
||||
offset := (pageNumber - 1) * itemsPerPage
|
||||
if err := query.Offset(offset).Limit(itemsPerPage).Find(&kbs).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
} else {
|
||||
if err := query.Find(&kbs).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
}
|
||||
|
||||
return kbs, total, nil
|
||||
}
|
||||
|
||||
// DeleteByTenantID deletes all knowledge bases by tenant ID (hard delete)
|
||||
func (dao *KnowledgebaseDAO) DeleteByTenantID(ctx context.Context, db *gorm.DB, tenantID string) (int64, error) {
|
||||
result := db.WithContext(ctx).Unscoped().Where("tenant_id = ?", tenantID).Delete(&entity.Knowledgebase{})
|
||||
|
||||
+9
-98
@@ -69,56 +69,6 @@ func stringPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
func createKBAccessFixture(t *testing.T, db *gorm.DB, id, permission, status string) {
|
||||
t.Helper()
|
||||
kb := &entity.Knowledgebase{ID: id, TenantID: "owner", CreatedBy: "owner", Name: id, EmbdID: "embd", Permission: permission, Status: stringPtr(status)}
|
||||
if err := db.Create(kb).Error; err != nil {
|
||||
t.Fatalf("failed to create KB: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func createUserTenant(t *testing.T, db *gorm.DB, userID, tenantID, status string) {
|
||||
t.Helper()
|
||||
row := &entity.UserTenant{ID: userID + "-" + tenantID, UserID: userID, TenantID: tenantID, Role: "normal", InvitedBy: "owner", Status: stringPtr(status)}
|
||||
if err := db.Create(row).Error; err != nil {
|
||||
t.Fatalf("failed to create user_tenant: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnowledgebaseDAO_Accessible(t *testing.T) {
|
||||
db := setupKBTestDB(t)
|
||||
dao := NewKnowledgebaseDAO()
|
||||
ctx := t.Context()
|
||||
|
||||
createKBAccessFixture(t, db, "private", string(entity.TenantPermissionMe), string(entity.StatusValid))
|
||||
createKBAccessFixture(t, db, "team", string(entity.TenantPermissionTeam), string(entity.StatusValid))
|
||||
createKBAccessFixture(t, db, "unknown", "", string(entity.StatusValid))
|
||||
createKBAccessFixture(t, db, "invalid", string(entity.TenantPermissionTeam), string(entity.StatusInvalid))
|
||||
createUserTenant(t, db, "member", "owner", string(entity.StatusValid))
|
||||
createUserTenant(t, db, "revoked", "owner", string(entity.StatusInvalid))
|
||||
|
||||
tests := []struct {
|
||||
name, kbID, userID string
|
||||
want bool
|
||||
}{
|
||||
{"owner private", "private", "owner", true},
|
||||
{"member private", "private", "member", false},
|
||||
{"member team", "team", "member", true},
|
||||
{"revoked member team", "team", "revoked", false},
|
||||
{"unrelated team", "team", "other", false},
|
||||
{"member unknown permission", "unknown", "member", false},
|
||||
{"invalid KB", "invalid", "owner", false},
|
||||
{"missing KB", "missing", "owner", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := dao.Accessible(ctx, db, tt.kbID, tt.userID); got != tt.want {
|
||||
t.Fatalf("Accessible(%q, %q) = %v, want %v", tt.kbID, tt.userID, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnowledgebaseDAO_DecreaseDocumentNum(t *testing.T) {
|
||||
db := setupKBTestDB(t)
|
||||
pushDB(t, db)
|
||||
@@ -177,19 +127,13 @@ func TestKnowledgebaseDAO_DecreaseDocumentNum_ZeroDecrement(t *testing.T) {
|
||||
}
|
||||
|
||||
// knowledgebaseQualifiedOrderExpressions are clause fragments rather than
|
||||
// column names. GetByTenantIDs prefixes the value with "knowledgebase.", so
|
||||
// column names. ListByResourceIDs prefixes the value with "knowledgebase.", so
|
||||
// these stand in for the `orderby` query parameter that reaches it from the
|
||||
// dataset list request (issue #14268).
|
||||
var knowledgebaseQualifiedOrderExpressions = []string{
|
||||
"name, (SELECT inner_kb.id FROM knowledgebase AS inner_kb WHERE inner_kb.id = knowledgebase.id)",
|
||||
}
|
||||
|
||||
// knowledgebaseOrderExpressions are the unprefixed counterparts used by
|
||||
// GetList.
|
||||
var knowledgebaseOrderExpressions = []string{
|
||||
"(SELECT inner_kb.name FROM knowledgebase AS inner_kb WHERE inner_kb.id = knowledgebase.id)",
|
||||
}
|
||||
|
||||
// seedOrderableKnowledgebases creates three datasets whose create_time order is
|
||||
// the reverse of their name order, so a test can tell which column the ORDER BY
|
||||
// clause actually used.
|
||||
@@ -219,10 +163,10 @@ func assertKnowledgebaseIDOrder(t *testing.T, got, want []string, orderby string
|
||||
}
|
||||
}
|
||||
|
||||
// TestKnowledgebaseDAOGetByTenantIDsOrderByExpressionFallsBack checks that an
|
||||
// TestKnowledgebaseDAOListByResourceIDsOrderByExpressionFallsBack checks that an
|
||||
// `orderby` value that is not an allowed column name leaves the rows in the
|
||||
// default create_time order.
|
||||
func TestKnowledgebaseDAOGetByTenantIDsOrderByExpressionFallsBack(t *testing.T) {
|
||||
func TestKnowledgebaseDAOListByResourceIDsOrderByExpressionFallsBack(t *testing.T) {
|
||||
db := setupKBTestDB(t)
|
||||
if err := db.AutoMigrate(&entity.User{}); err != nil {
|
||||
t.Fatalf("failed to migrate user: %v", err)
|
||||
@@ -234,9 +178,9 @@ func TestKnowledgebaseDAOGetByTenantIDsOrderByExpressionFallsBack(t *testing.T)
|
||||
|
||||
for _, orderby := range knowledgebaseQualifiedOrderExpressions {
|
||||
t.Run(orderby, func(t *testing.T) {
|
||||
rows, _, err := d.GetByTenantIDs(ctx, db, []string{"t1"}, "t1", 1, 10, []OrderTerm{{Column: orderby}}, "", "", "", "", nil)
|
||||
rows, _, err := d.ListByResourceIDs(ctx, db, []string{"kb-1", "kb-2", "kb-3"}, nil, 1, 10, []OrderTerm{{Column: orderby}}, "", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("GetByTenantIDs with orderby %q: %v", orderby, err)
|
||||
t.Fatalf("ListByResourceIDs with orderby %q: %v", orderby, err)
|
||||
}
|
||||
ids := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
@@ -247,32 +191,8 @@ func TestKnowledgebaseDAOGetByTenantIDsOrderByExpressionFallsBack(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// TestKnowledgebaseDAOGetListOrderByExpressionFallsBack is the GetList
|
||||
// counterpart, which builds the clause without the table prefix.
|
||||
func TestKnowledgebaseDAOGetListOrderByExpressionFallsBack(t *testing.T) {
|
||||
db := setupKBTestDB(t)
|
||||
pushDB(t, db)
|
||||
seedOrderableKnowledgebases(t, db)
|
||||
ctx := t.Context()
|
||||
d := NewKnowledgebaseDAO()
|
||||
|
||||
for _, orderby := range knowledgebaseOrderExpressions {
|
||||
t.Run(orderby, func(t *testing.T) {
|
||||
rows, _, err := d.GetList(ctx, db, []string{"t1"}, "t1", 1, 10, []OrderTerm{{Column: orderby}}, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("GetList with orderby %q: %v", orderby, err)
|
||||
}
|
||||
ids := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
ids = append(ids, row.ID)
|
||||
}
|
||||
assertKnowledgebaseIDOrder(t, ids, []string{"kb-1", "kb-2", "kb-3"}, orderby)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestKnowledgebaseDAOOrderByAllowedColumn checks that the allowlist still
|
||||
// honors a real column in both directions on both list paths.
|
||||
// TestKnowledgebaseDAOOrderByAllowedColumn checks that the list query honors a
|
||||
// real column in both directions.
|
||||
func TestKnowledgebaseDAOOrderByAllowedColumn(t *testing.T) {
|
||||
db := setupKBTestDB(t)
|
||||
if err := db.AutoMigrate(&entity.User{}); err != nil {
|
||||
@@ -283,9 +203,9 @@ func TestKnowledgebaseDAOOrderByAllowedColumn(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
d := NewKnowledgebaseDAO()
|
||||
|
||||
rows, _, err := d.GetByTenantIDs(ctx, db, []string{"t1"}, "t1", 1, 10, []OrderTerm{{Column: "name"}}, "", "", "", "", nil)
|
||||
rows, _, err := d.ListByResourceIDs(ctx, db, []string{"kb-1", "kb-2", "kb-3"}, nil, 1, 10, []OrderTerm{{Column: "name"}}, "", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("GetByTenantIDs ascending by name: %v", err)
|
||||
t.Fatalf("ListByResourceIDs ascending by name: %v", err)
|
||||
}
|
||||
ids := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
@@ -293,13 +213,4 @@ func TestKnowledgebaseDAOOrderByAllowedColumn(t *testing.T) {
|
||||
}
|
||||
assertKnowledgebaseIDOrder(t, ids, []string{"kb-3", "kb-2", "kb-1"}, "name")
|
||||
|
||||
listed, _, err := d.GetList(ctx, db, []string{"t1"}, "t1", 1, 10, []OrderTerm{{Column: "name", Desc: true}}, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("GetList descending by name: %v", err)
|
||||
}
|
||||
listedIDs := make([]string, 0, len(listed))
|
||||
for _, row := range listed {
|
||||
listedIDs = append(listedIDs, row.ID)
|
||||
}
|
||||
assertKnowledgebaseIDOrder(t, listedIDs, []string{"kb-1", "kb-2", "kb-3"}, "name")
|
||||
}
|
||||
|
||||
@@ -238,10 +238,6 @@ func fileOrderClause(terms []OrderTerm) string {
|
||||
return orderClause(fileOrderableColumns, terms, defaultOrderColumn)
|
||||
}
|
||||
|
||||
func knowledgebaseOrderClause(terms []OrderTerm) string {
|
||||
return orderClause(knowledgebaseOrderableColumns, terms, defaultOrderColumn)
|
||||
}
|
||||
|
||||
func knowledgebaseQualifiedOrderClause(terms []OrderTerm) string {
|
||||
return qualifiedOrderClause("knowledgebase", knowledgebaseOrderableColumns, terms, defaultOrderColumn)
|
||||
}
|
||||
|
||||
@@ -442,6 +442,9 @@ func (h *ChatHandler) updateChatByMethod(c *gin.Context, patch bool) {
|
||||
result, err = h.chatService.UpdateChat(ctx, user.ID, chatID, req)
|
||||
}
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, false) {
|
||||
return
|
||||
}
|
||||
if err.Error() == "no authorization" {
|
||||
common.ResponseWithCodeData(c, common.CodeAuthenticationError, false, "no authorization")
|
||||
return
|
||||
|
||||
@@ -169,6 +169,9 @@ func (h *ChunkHandler) Get(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
resp, err := h.chunkService.Get(ctx, req, user.ID)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, false) {
|
||||
return
|
||||
}
|
||||
common.ResponseWithHttpCodeData(c, http.StatusInternalServerError, 500, nil, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -257,6 +260,9 @@ func (h *ChunkHandler) ListChunks(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
resp, err := h.chunkService.List(ctx, &req, user.ID)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, false) {
|
||||
return
|
||||
}
|
||||
common.ResponseWithHttpCodeData(c, http.StatusInternalServerError, common.CodeServerError, nil, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -389,6 +395,9 @@ func (h *ChunkHandler) List(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
resp, err := h.chunkService.List(ctx, &req, user.ID)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, false) {
|
||||
return
|
||||
}
|
||||
common.ResponseWithHttpCodeData(c, http.StatusInternalServerError, 500, nil, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -448,6 +457,9 @@ func (h *ChunkHandler) SwitchChunks(c *gin.Context) {
|
||||
|
||||
ctx := c.Request.Context()
|
||||
if err = h.chunkService.SwitchChunks(ctx, userID, datasetID, documentID, availableInt, chunkIDs); err != nil {
|
||||
if respondPermissionErrorIf(c, err, false) {
|
||||
return
|
||||
}
|
||||
common.ResponseWithHttpCodeData(c, http.StatusInternalServerError, common.CodeServerError, nil, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -644,6 +656,9 @@ func (h *ChunkHandler) UpdateChunk(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
err = h.chunkService.UpdateChunk(ctx, &req, user.ID)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, false) {
|
||||
return
|
||||
}
|
||||
var coded interface {
|
||||
Code() common.ErrorCode
|
||||
}
|
||||
@@ -699,6 +714,9 @@ func (h *ChunkHandler) RemoveChunks(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
deletedCount, err := h.chunkService.RemoveChunks(ctx, &req, user.ID)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, false) {
|
||||
return
|
||||
}
|
||||
common.ResponseWithHttpCodeData(c, http.StatusInternalServerError, 500, nil, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -20,10 +20,43 @@ import (
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
permissionresponse "ragflow/internal/permission/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func permissionErrorResult(err error, hideResource bool) (common.ErrorCode, string) {
|
||||
normalize := permissionresponse.Normalize
|
||||
if hideResource {
|
||||
normalize = permissionresponse.NormalizeHidden
|
||||
}
|
||||
code, normalizedErr := normalize(err)
|
||||
return code, normalizedErr.Error()
|
||||
}
|
||||
|
||||
func respondPermissionError(c *gin.Context, err error, hideResource bool) {
|
||||
code, message := permissionErrorResult(err, hideResource)
|
||||
common.ResponseWithCodeData(c, code, nil, message)
|
||||
}
|
||||
|
||||
func respondPermissionErrorIf(c *gin.Context, err error, hideResource bool) bool {
|
||||
if !permissionresponse.IsPermissionError(err) {
|
||||
return false
|
||||
}
|
||||
respondPermissionError(c, err, hideResource)
|
||||
return true
|
||||
}
|
||||
|
||||
func respondPermissionFailure(c *gin.Context, err error, hideResource bool) {
|
||||
code, message := permissionErrorResult(err, hideResource)
|
||||
common.ErrorWithCode(c, code, message)
|
||||
}
|
||||
|
||||
func respondHTTPPermissionError(c *gin.Context, err error, hideResource bool) {
|
||||
code, message := permissionErrorResult(err, hideResource)
|
||||
common.ResponseWithHttpCodeData(c, int(code), code, nil, message)
|
||||
}
|
||||
|
||||
// sortTermsFromQuery reads the `sort` parameter, a comma separated list of
|
||||
// `column:direction` terms such as `name:asc,create_time:desc`. A handler that
|
||||
// rejects an unrecognised `orderby` or `desc` reads this first and skips that
|
||||
|
||||
@@ -45,6 +45,7 @@ func setupCompilationStatusHandlerDB(t *testing.T) *gorm.DB {
|
||||
if err := db.AutoMigrate(
|
||||
&entity.Knowledgebase{},
|
||||
&entity.KnowledgeCompileDataset{},
|
||||
&entity.UserTenant{},
|
||||
); err != nil {
|
||||
t.Fatalf("failed to migrate test schema: %v", err)
|
||||
}
|
||||
@@ -69,6 +70,17 @@ func insertCompilationStatusHandlerKB(t *testing.T, kbID, ownerID string) {
|
||||
if err := dao.DB.Create(kb).Error; err != nil {
|
||||
t.Fatalf("insert kb: %v", err)
|
||||
}
|
||||
active := string(entity.StatusValid)
|
||||
if err := dao.DB.Create(&entity.UserTenant{
|
||||
ID: ownerID + "-membership",
|
||||
UserID: ownerID,
|
||||
TenantID: ownerID,
|
||||
Role: "owner",
|
||||
InvitedBy: ownerID,
|
||||
Status: &active,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("insert tenant owner: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newCompilationStatusHandlerRouter() *gin.Engine {
|
||||
@@ -182,8 +194,7 @@ func TestCompilationStatusHandler_FullOutput(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestCompilationStatusHandler_Unauthorized verifies a user who does not own the
|
||||
// dataset is rejected with a data error (HTTP 200 + non-zero code, matching the
|
||||
// handler's ErrorWithCode contract).
|
||||
// dataset is rejected with the standardized forbidden response.
|
||||
func TestCompilationStatusHandler_Unauthorized(t *testing.T) {
|
||||
db := setupCompilationStatusHandlerDB(t)
|
||||
// KB is owned by user-1; the router sets user to user-1, so this test must
|
||||
@@ -202,10 +213,10 @@ func TestCompilationStatusHandler_Unauthorized(t *testing.T) {
|
||||
}
|
||||
|
||||
_, body := getCompilationStatus(t, newCompilationStatusHandlerRouter(), "kb-status-forbidden")
|
||||
if body.Code != int(common.CodeDataError) {
|
||||
t.Fatalf("code=%d want %d", body.Code, common.CodeDataError)
|
||||
if body.Code != int(common.CodeForbidden) {
|
||||
t.Fatalf("code=%d want %d", body.Code, common.CodeForbidden)
|
||||
}
|
||||
if body.Message != "no authorization" {
|
||||
t.Fatalf("message=%q want %q", body.Message, "no authorization")
|
||||
if body.Message != "Permission denied" {
|
||||
t.Fatalf("message=%q want %q", body.Message, "Permission denied")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/permission"
|
||||
"ragflow/internal/service"
|
||||
dataset "ragflow/internal/service/dataset"
|
||||
)
|
||||
@@ -265,7 +266,10 @@ func (h *DatasetsHandler) CreateDataset(c *gin.Context) {
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
result, code, err := h.datasetsService.CreateDataset(ctx, &req, user.ID)
|
||||
result, code, err := h.datasetsService.CreateDataset(ctx, &req, permission.Subject{
|
||||
UserID: user.ID,
|
||||
TenantID: user.ID,
|
||||
})
|
||||
if err != nil {
|
||||
common.ErrorWithCode(c, code, err.Error())
|
||||
return
|
||||
@@ -706,7 +710,10 @@ func (h *DatasetsHandler) DeleteDatasets(c *gin.Context) {
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
result, code, err := h.datasetsService.DeleteDatasets(ctx, ids, req.DeleteAll, user.ID)
|
||||
result, code, err := h.datasetsService.DeleteDatasets(ctx, ids, req.DeleteAll, permission.Subject{
|
||||
UserID: user.ID,
|
||||
TenantID: user.ID,
|
||||
})
|
||||
if err != nil {
|
||||
common.ErrorWithCode(c, code, err.Error())
|
||||
return
|
||||
@@ -1010,8 +1017,8 @@ func (h *DatasetsHandler) ListMetadataFlattened(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
// Check access for each dataset
|
||||
for _, datasetID := range datasetIDs {
|
||||
if !h.datasetsService.Accessible(ctx, datasetID, user.ID) {
|
||||
common.ResponseWithCodeData(c, common.CodeAuthenticationError, nil, "No authorization for dataset: "+datasetID)
|
||||
if err := h.datasetsService.CheckAccess(ctx, permission.Subject{UserID: user.ID}, datasetID, permission.OperationRead); err != nil {
|
||||
respondPermissionError(c, err, false)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1137,6 +1144,9 @@ func (h *DatasetsHandler) SearchDatasets(c *gin.Context) {
|
||||
|
||||
resp, err := searchService.SearchDatasets(ctx, &req, user.ID)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, false) {
|
||||
return
|
||||
}
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/permission"
|
||||
"ragflow/internal/service"
|
||||
dataset "ragflow/internal/service/dataset"
|
||||
"ragflow/internal/service/file"
|
||||
@@ -71,8 +72,8 @@ func (h *DatasetArtifactHandler) datasetOwner(c *gin.Context, datasetID string)
|
||||
common.ErrorWithCode(c, common.CodeNotFound, "dataset not found")
|
||||
return nil, "", ""
|
||||
}
|
||||
if !h.datasetSvc.Accessible(c.Request.Context(), datasetID, user.ID) {
|
||||
common.ErrorWithCode(c, common.CodeForbidden, "no permission to access this dataset")
|
||||
if err := h.datasetSvc.CheckAccess(c.Request.Context(), permission.Subject{UserID: user.ID}, datasetID, permission.OperationRead); err != nil {
|
||||
respondPermissionFailure(c, err, false)
|
||||
return nil, "", ""
|
||||
}
|
||||
return user, kb.TenantID, msg
|
||||
|
||||
@@ -49,6 +49,15 @@ func insertListDatasetsTestKB(t *testing.T, id, tenantID, name string) {
|
||||
t.Helper()
|
||||
|
||||
status := string(entity.StatusValid)
|
||||
if err := dao.DB.Where("user_id = ? AND tenant_id = ?", tenantID, tenantID).FirstOrCreate(&entity.UserTenant{
|
||||
ID: "ut-" + tenantID,
|
||||
UserID: tenantID,
|
||||
TenantID: tenantID,
|
||||
Role: "owner",
|
||||
Status: &status,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("insert tenant owner: %v", err)
|
||||
}
|
||||
kb := &entity.Knowledgebase{
|
||||
ID: id,
|
||||
TenantID: tenantID,
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/permission"
|
||||
"ragflow/internal/service"
|
||||
)
|
||||
|
||||
@@ -276,6 +277,26 @@ func TestDatasetsHandlerSearchDatasetsPropagatesServiceError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetsHandlerSearchDatasetsNormalizesPermissionError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
fake := &fakeSearchDatasetsService{err: permission.ErrPermissionDenied}
|
||||
h := &DatasetsHandler{searchDatasetsService: fake}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/datasets/search", strings.NewReader(`{"question":"hello","dataset_ids":["ds-1"]}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
c.Set("user", &entity.User{ID: "user-1"})
|
||||
|
||||
h.SearchDatasets(c)
|
||||
|
||||
body := decodeSearchResponse(t, rec)
|
||||
if body["code"] != float64(common.CodeForbidden) || body["message"] != "Permission denied" {
|
||||
t.Fatalf("response=%v want forbidden permission error", body)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeSearchResponse(t *testing.T, rec *httptest.ResponseRecorder) map[string]interface{} {
|
||||
t.Helper()
|
||||
if !json.Valid(rec.Body.Bytes()) {
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"ragflow/internal/engine"
|
||||
"ragflow/internal/entity"
|
||||
modelModule "ragflow/internal/entity/models"
|
||||
"ragflow/internal/permission"
|
||||
"ragflow/internal/service"
|
||||
"ragflow/internal/service/graph"
|
||||
"ragflow/internal/service/nlp"
|
||||
@@ -43,8 +44,8 @@ import (
|
||||
|
||||
// KBServiceIface abstracts KnowledgebaseService for the Dify handler.
|
||||
type KBServiceIface interface {
|
||||
GetByID(ctx context.Context, kbID string) (*entity.Knowledgebase, error)
|
||||
Accessible(ctx context.Context, kbID, userID string) bool
|
||||
GetKnowledgebaseByID(ctx context.Context, kbID string) (*entity.Knowledgebase, error)
|
||||
CheckAccess(ctx context.Context, subject permission.Subject, datasetID string, operation permission.Operation) error
|
||||
}
|
||||
|
||||
// ModelCreator supplies typed model instances to Dify retrieval.
|
||||
@@ -205,7 +206,7 @@ func (h *DifyRetrievalHandler) Retrieval(c *gin.Context) {
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
kb, err := h.kbSvc.GetByID(ctx, req.KnowledgeID)
|
||||
kb, err := h.kbSvc.GetKnowledgebaseByID(ctx, req.KnowledgeID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
common.ResponseWithHttpCodeData(c, http.StatusNotFound, common.CodeNotFound, nil, "Knowledge base not found!")
|
||||
@@ -215,8 +216,8 @@ func (h *DifyRetrievalHandler) Retrieval(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if !h.kbSvc.Accessible(ctx, req.KnowledgeID, user.ID) {
|
||||
common.ResponseWithHttpCodeData(c, http.StatusUnauthorized, common.CodeAuthenticationError, nil, "No authorization")
|
||||
if err := h.kbSvc.CheckAccess(ctx, permission.Subject{UserID: user.ID}, req.KnowledgeID, permission.OperationUse); err != nil {
|
||||
respondHTTPPermissionError(c, err, false)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import (
|
||||
"ragflow/internal/engine/types"
|
||||
"ragflow/internal/entity"
|
||||
modelModule "ragflow/internal/entity/models"
|
||||
"ragflow/internal/permission"
|
||||
"ragflow/internal/service"
|
||||
"ragflow/internal/service/nlp"
|
||||
|
||||
@@ -42,24 +43,24 @@ import (
|
||||
|
||||
type mockKBService struct {
|
||||
KBServiceIface
|
||||
getByIDFn func(ctx context.Context, kbID string) (*entity.Knowledgebase, error)
|
||||
accessibleFn func(ctx context.Context, kbID, userID string) bool
|
||||
getKnowledgebaseByIDFn func(ctx context.Context, kbID string) (*entity.Knowledgebase, error)
|
||||
checkAccessFn func(ctx context.Context, subject permission.Subject, datasetID string, operation permission.Operation) error
|
||||
}
|
||||
|
||||
func (m *mockKBService) GetByID(ctx context.Context, kbID string) (*entity.Knowledgebase, error) {
|
||||
if m.getByIDFn != nil {
|
||||
return m.getByIDFn(ctx, kbID)
|
||||
func (m *mockKBService) GetKnowledgebaseByID(ctx context.Context, kbID string) (*entity.Knowledgebase, error) {
|
||||
if m.getKnowledgebaseByIDFn != nil {
|
||||
return m.getKnowledgebaseByIDFn(ctx, kbID)
|
||||
}
|
||||
return &entity.Knowledgebase{
|
||||
ID: kbID, TenantID: "tenant1", EmbdID: "text-embedding",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockKBService) Accessible(ctx context.Context, kbID, userID string) bool {
|
||||
if m.accessibleFn != nil {
|
||||
return m.accessibleFn(ctx, kbID, userID)
|
||||
func (m *mockKBService) CheckAccess(ctx context.Context, subject permission.Subject, datasetID string, operation permission.Operation) error {
|
||||
if m.checkAccessFn != nil {
|
||||
return m.checkAccessFn(ctx, subject, datasetID, operation)
|
||||
}
|
||||
return true
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockModelFactory struct{}
|
||||
@@ -351,7 +352,7 @@ func TestDifyRetrieval_MissingArgs(t *testing.T) {
|
||||
func TestDifyRetrieval_KBNotFound(t *testing.T) {
|
||||
h, r := setupDifyTest("user1")
|
||||
h.kbSvc = &mockKBService{
|
||||
getByIDFn: func(ctx context.Context, kbID string) (*entity.Knowledgebase, error) {
|
||||
getKnowledgebaseByIDFn: func(ctx context.Context, kbID string) (*entity.Knowledgebase, error) {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
},
|
||||
}
|
||||
@@ -380,15 +381,20 @@ func TestDifyRetrieval_NoAuth(t *testing.T) {
|
||||
func TestDifyRetrieval_Unauthorized(t *testing.T) {
|
||||
h, r := setupDifyTest("user1")
|
||||
h.kbSvc = &mockKBService{
|
||||
accessibleFn: func(ctx context.Context, kbID, userID string) bool { return false },
|
||||
checkAccessFn: func(ctx context.Context, subject permission.Subject, datasetID string, operation permission.Operation) error {
|
||||
if operation != permission.OperationUse {
|
||||
t.Errorf("dataset operation = %q, want %q", operation, permission.OperationUse)
|
||||
}
|
||||
return permission.ErrPermissionDenied
|
||||
},
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
body := `{"knowledge_id": "kb1", "query": "test"}`
|
||||
req, _ := http.NewRequest("POST", "/api/v1/dify/retrieval", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", w.Code)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("expected 403, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,7 +448,7 @@ func strPtr(s string) *string { return &s }
|
||||
func TestDifyRetrieval_KBDBError(t *testing.T) {
|
||||
h, r := setupDifyTest("user1")
|
||||
h.kbSvc = &mockKBService{
|
||||
getByIDFn: func(ctx context.Context, kbID string) (*entity.Knowledgebase, error) {
|
||||
getKnowledgebaseByIDFn: func(ctx context.Context, kbID string) (*entity.Knowledgebase, error) {
|
||||
return nil, errors.New("connection refused")
|
||||
},
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import (
|
||||
"net/url"
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/permission"
|
||||
"ragflow/internal/utility"
|
||||
"reflect"
|
||||
"strconv"
|
||||
@@ -143,8 +144,8 @@ func (h *DocumentHandler) GetDocumentByID(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if !h.datasetService.Accessible(ctx, doc.KbID, user.ID) {
|
||||
common.ResponseWithHttpCodeData(c, http.StatusForbidden, common.CodeForbidden, nil, "no permission to access this dataset")
|
||||
if err := h.datasetService.CheckAccess(ctx, permission.Subject{UserID: user.ID}, doc.KbID, permission.OperationRead); err != nil {
|
||||
respondHTTPPermissionError(c, err, false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -378,8 +379,8 @@ func (h *DocumentHandler) UpdateDocument(c *gin.Context) {
|
||||
common.ResponseWithHttpCodeData(c, http.StatusBadRequest, 1, nil, "document not found!")
|
||||
return
|
||||
}
|
||||
if !h.datasetService.Accessible(ctx, doc.KbID, user.ID) {
|
||||
common.ResponseWithCodeData(c, common.CodeAuthenticationError, nil, "no authorization")
|
||||
if err := h.datasetService.CheckAccess(ctx, permission.Subject{UserID: user.ID}, doc.KbID, permission.OperationUpdate); err != nil {
|
||||
respondPermissionError(c, err, false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -433,8 +434,8 @@ func (h *DocumentHandler) DeleteDocument(c *gin.Context) {
|
||||
common.ResponseWithHttpCodeData(c, http.StatusBadRequest, 1, nil, "document not found!")
|
||||
return
|
||||
}
|
||||
if !h.datasetService.Accessible(ctx, doc.KbID, user.ID) {
|
||||
common.ResponseWithCodeData(c, common.CodeAuthenticationError, nil, "no authorization")
|
||||
if err := h.datasetService.CheckAccess(ctx, permission.Subject{UserID: user.ID}, doc.KbID, permission.OperationDelete); err != nil {
|
||||
respondPermissionError(c, err, false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -511,6 +512,9 @@ func (h *DocumentHandler) DeleteDocuments(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
deleted, err := h.documentService.DeleteDocuments(ctx, ids, req.DeleteAll, datasetID, userID)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, false) {
|
||||
return
|
||||
}
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -606,8 +610,8 @@ func (h *DocumentHandler) ListDocuments(c *gin.Context) {
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
if !h.datasetService.Accessible(ctx, datasetID, userID) {
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, fmt.Sprintf("You don't own the dataset %s.", datasetID))
|
||||
if err := h.datasetService.CheckAccess(ctx, permission.Subject{UserID: userID}, datasetID, permission.OperationRead); err != nil {
|
||||
respondPermissionError(c, err, false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1000,8 +1004,8 @@ func (h *DocumentHandler) UploadDocuments(c *gin.Context) {
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, fmt.Sprintf("Can't find the dataset with ID %s!", datasetID))
|
||||
return
|
||||
}
|
||||
if !h.datasetService.CheckKBTeamPermission(ctx, kb, tenantID) {
|
||||
common.ResponseWithCodeData(c, common.CodeAuthenticationError, nil, "no authorization")
|
||||
if err := service.CheckDatasetAccess(ctx, permission.Subject{UserID: user.ID}, kb.ID, permission.OperationUpdate); err != nil {
|
||||
respondPermissionError(c, err, false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1194,22 +1198,22 @@ func (h *DocumentHandler) DownloadDocument(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
// Authorize the caller before serving file bytes. The sibling routes on
|
||||
// this dataset (PATCH/DELETE documents, chunks, metadata) all gate on
|
||||
// datasetService.Accessible, and the Python reference
|
||||
// (document_api.py download) checks KnowledgebaseService.accessible and
|
||||
// DocumentService.accessible; without this, any logged-in user could
|
||||
// download any tenant's document by supplying its dataset and document
|
||||
// ids. Answer exactly like the missing-document case so existence is
|
||||
// not leaked either.
|
||||
if !h.datasetService.Accessible(ctx, datasetID, user.ID) {
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, "document not found")
|
||||
// Authorize the caller before serving file bytes. Answer exactly like the
|
||||
// missing-document case so dataset existence is not leaked.
|
||||
if err := h.datasetService.CheckAccess(ctx, permission.Subject{UserID: user.ID}, datasetID, permission.OperationRead); err != nil {
|
||||
respondPermissionError(c, err, true)
|
||||
return
|
||||
}
|
||||
res, err := h.documentService.DownloadDocument(ctx, datasetID, docID)
|
||||
|
||||
if err != nil {
|
||||
common.ErrorWithCode(c, common.CodeDataError, err.Error())
|
||||
if errors.Is(err, document.ErrDocumentNotFound) {
|
||||
common.ErrorWithCode(c, common.CodeNotFound, "Resource not found")
|
||||
return
|
||||
}
|
||||
common.Error("DownloadDocument failed", err,
|
||||
zap.String("dataset_id", datasetID), zap.String("document_id", docID), zap.String("user_id", user.ID))
|
||||
common.ErrorWithCode(c, common.CodeServerError, "Failed to download document")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1332,8 +1336,8 @@ func (h *DocumentHandler) MetadataSummary(c *gin.Context) {
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
if !h.datasetService.Accessible(ctx, kbID, user.ID) {
|
||||
common.ResponseWithHttpCodeData(c, http.StatusForbidden, common.CodeForbidden, nil, "no permission to access this dataset")
|
||||
if err := h.datasetService.CheckAccess(ctx, permission.Subject{UserID: user.ID}, kbID, permission.OperationRead); err != nil {
|
||||
respondHTTPPermissionError(c, err, false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1419,8 +1423,8 @@ func (h *DocumentHandler) SetMeta(c *gin.Context) {
|
||||
common.ResponseWithHttpCodeData(c, http.StatusBadRequest, 1, nil, "document not found")
|
||||
return
|
||||
}
|
||||
if !h.datasetService.Accessible(ctx, doc.KbID, user.ID) {
|
||||
common.ResponseWithCodeData(c, common.CodeAuthenticationError, nil, "no authorization")
|
||||
if err := h.datasetService.CheckAccess(ctx, permission.Subject{UserID: user.ID}, doc.KbID, permission.OperationUpdate); err != nil {
|
||||
respondPermissionError(c, err, false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1514,8 +1518,8 @@ func (h *DocumentHandler) DeleteMeta(c *gin.Context) {
|
||||
common.ResponseWithHttpCodeData(c, http.StatusBadRequest, 1, nil, "document not found")
|
||||
return
|
||||
}
|
||||
if !h.datasetService.Accessible(ctx, doc.KbID, user.ID) {
|
||||
common.ResponseWithCodeData(c, common.CodeAuthenticationError, nil, "no authorization")
|
||||
if err := h.datasetService.CheckAccess(ctx, permission.Subject{UserID: user.ID}, doc.KbID, permission.OperationUpdate); err != nil {
|
||||
respondPermissionError(c, err, false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1577,8 +1581,8 @@ func (h *DocumentHandler) ListIngestionTasks(c *gin.Context) {
|
||||
var err error
|
||||
ctx := c.Request.Context()
|
||||
if req.DatasetID != nil {
|
||||
if !h.datasetService.Accessible(ctx, *req.DatasetID, userID) {
|
||||
common.ResponseWithCodeData(c, common.CodeAuthenticationError, nil, "No authorization to access the dataset.")
|
||||
if err := h.datasetService.CheckAccess(ctx, permission.Subject{UserID: userID}, *req.DatasetID, permission.OperationRead); err != nil {
|
||||
respondPermissionError(c, err, false)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1620,8 +1624,8 @@ func (h *DocumentHandler) StartIngestionTask(c *gin.Context) {
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
if !h.datasetService.Accessible(ctx, datasetID, userID) {
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, fmt.Sprintf("You don't own the dataset %s.", datasetID))
|
||||
if err := h.datasetService.CheckAccess(ctx, permission.Subject{UserID: userID}, datasetID, permission.OperationRun); err != nil {
|
||||
respondPermissionError(c, err, false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1703,8 +1707,8 @@ func (h *DocumentHandler) ParseDocuments(c *gin.Context) {
|
||||
|
||||
userID := c.GetString("user_id")
|
||||
ctx := c.Request.Context()
|
||||
if !h.datasetService.Accessible(ctx, datasetID, userID) {
|
||||
common.ResponseWithCodeData(c, common.CodeAuthenticationError, nil, "No authorization to access the dataset.")
|
||||
if err := h.datasetService.CheckAccess(ctx, permission.Subject{UserID: userID}, datasetID, permission.OperationRun); err != nil {
|
||||
respondPermissionError(c, err, false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1745,8 +1749,8 @@ func (h *DocumentHandler) StopParseDocuments(c *gin.Context) {
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
if !h.datasetService.Accessible(ctx, datasetID, userID) {
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, fmt.Sprintf("You don't own the dataset %s.", datasetID))
|
||||
if err := h.datasetService.CheckAccess(ctx, permission.Subject{UserID: userID}, datasetID, permission.OperationRun); err != nil {
|
||||
respondPermissionError(c, err, false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1771,8 +1775,8 @@ func (h *DocumentHandler) MetadataSummaryByDataset(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
if !h.datasetService.Accessible(ctx, datasetID, user.ID) {
|
||||
common.ErrorWithCode(c, common.CodeServerError, "You don't own the dataset "+datasetID)
|
||||
if err := h.datasetService.CheckAccess(ctx, permission.Subject{UserID: user.ID}, datasetID, permission.OperationRead); err != nil {
|
||||
respondPermissionFailure(c, err, false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1952,8 +1956,8 @@ func (h *DocumentHandler) handleBatchUpdateDocumentMetadatas(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
if !h.datasetService.Accessible(ctx, datasetID, user.ID) {
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, "You don't own the dataset "+datasetID+".")
|
||||
if err := h.datasetService.CheckAccess(ctx, permission.Subject{UserID: user.ID}, datasetID, permission.OperationUpdate); err != nil {
|
||||
respondPermissionError(c, err, false)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -123,6 +123,7 @@ func TestDocumentMetadataBatchRejectsNonStringIDs(t *testing.T) {
|
||||
type fakeDocumentService struct {
|
||||
deleted int
|
||||
err error
|
||||
downloadErr error
|
||||
doc *document.DocumentResponse
|
||||
docErr error
|
||||
updateCalled bool
|
||||
@@ -232,8 +233,11 @@ func (f *fakeDocumentService) GetDocumentPreview(ctx context.Context, userID, do
|
||||
}, nil
|
||||
}
|
||||
func (f *fakeDocumentService) DownloadDocument(ctx context.Context, datasetID, docID string) (*document.DownloadDocumentResp, error) {
|
||||
if f.downloadErr != nil {
|
||||
return nil, f.downloadErr
|
||||
}
|
||||
if docID == "not-found" {
|
||||
return nil, fmt.Errorf("not found")
|
||||
return nil, document.ErrDocumentNotFound
|
||||
}
|
||||
return &document.DownloadDocumentResp{
|
||||
Data: []byte("document data"),
|
||||
@@ -411,6 +415,15 @@ func setupDocumentPermissionDB(t *testing.T, accessible bool) {
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("insert knowledgebase: %v", err)
|
||||
}
|
||||
if err := db.Create(&entity.UserTenant{
|
||||
ID: "ut-owner",
|
||||
UserID: "owner-user",
|
||||
TenantID: "tenant-owner",
|
||||
Role: "owner",
|
||||
Status: sptr(string(entity.StatusValid)),
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("insert tenant owner: %v", err)
|
||||
}
|
||||
if accessible {
|
||||
if err := db.Create(&entity.UserTenant{
|
||||
ID: "ut-user-1",
|
||||
@@ -464,7 +477,7 @@ func TestDocumentReadHandlersForbidden(t *testing.T) {
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Code != common.CodeForbidden || resp.Message != "no permission to access this dataset" || resp.Data != nil {
|
||||
if resp.Code != common.CodeForbidden || resp.Message != "Permission denied" || resp.Data != nil {
|
||||
t.Fatalf("unexpected response: %s", w.Body.String())
|
||||
}
|
||||
if fake.metadataKBID != "" {
|
||||
@@ -495,10 +508,10 @@ func TestSetMetaHandler_NotAccessible(t *testing.T) {
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp["code"] != float64(common.CodeAuthenticationError) {
|
||||
if resp["code"] != float64(common.CodeForbidden) {
|
||||
t.Fatalf("expected auth error, got %v", resp)
|
||||
}
|
||||
if resp["message"] != "no authorization" {
|
||||
if resp["message"] != "Permission denied" {
|
||||
t.Fatalf("unexpected message: %v", resp["message"])
|
||||
}
|
||||
if fake.setMetaCalled {
|
||||
@@ -563,10 +576,10 @@ func TestDeleteDocumentHandler_NotAccessible(t *testing.T) {
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp["code"] != float64(common.CodeAuthenticationError) {
|
||||
if resp["code"] != float64(common.CodeForbidden) {
|
||||
t.Fatalf("expected auth error, got %v", resp)
|
||||
}
|
||||
if resp["message"] != "no authorization" {
|
||||
if resp["message"] != "Permission denied" {
|
||||
t.Fatalf("unexpected message: %v", resp["message"])
|
||||
}
|
||||
if fake.deleteCalled {
|
||||
@@ -629,10 +642,10 @@ func TestUpdateDocumentHandler_NotAccessible(t *testing.T) {
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp["code"] != float64(common.CodeAuthenticationError) {
|
||||
if resp["code"] != float64(common.CodeForbidden) {
|
||||
t.Fatalf("expected auth error, got %v", resp)
|
||||
}
|
||||
if resp["message"] != "no authorization" {
|
||||
if resp["message"] != "Permission denied" {
|
||||
t.Fatalf("unexpected message: %v", resp["message"])
|
||||
}
|
||||
if fake.updateCalled {
|
||||
@@ -763,6 +776,9 @@ func setupUploadHandlerDB(t *testing.T, role string) *gorm.DB {
|
||||
if err := db.Create(&entity.UserTenant{ID: "ut-1", UserID: "user-1", TenantID: "tenant-1", Role: role, Status: sptr(string(entity.StatusValid))}).Error; err != nil {
|
||||
t.Fatalf("insert user_tenant: %v", err)
|
||||
}
|
||||
if err := db.Create(&entity.UserTenant{ID: "ut-owner", UserID: "owner-1", TenantID: "tenant-1", Role: "owner", Status: sptr(string(entity.StatusValid))}).Error; err != nil {
|
||||
t.Fatalf("insert tenant owner: %v", err)
|
||||
}
|
||||
pipelineID := "pipe-1"
|
||||
if err := db.Create(&entity.Knowledgebase{
|
||||
ID: "123e4567e89b12d3a456426614174000",
|
||||
@@ -1277,8 +1293,7 @@ func TestStopParseDocumentsHandler_BadJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// setupHandlerAccessDB sets up SQLite in-memory DB for handler tests that need
|
||||
// datasetService.Accessible to work.
|
||||
// setupHandlerAccessDB sets up SQLite in-memory DB for handler dataset access tests.
|
||||
func setupHandlerAccessDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
@@ -1303,7 +1318,8 @@ func setupHandlerAccessDB(t *testing.T) *gorm.DB {
|
||||
// Insert tenant
|
||||
db.Create(&entity.Tenant{ID: "tenant-1", LLMID: "llm-1", EmbdID: "embd-1", ASRID: "asr-1"})
|
||||
// Insert user_tenant mapping
|
||||
db.Create(&entity.UserTenant{ID: "ut-1", UserID: "user-1", TenantID: "tenant-1", Role: "admin"})
|
||||
db.Create(&entity.UserTenant{ID: "ut-owner", UserID: "owner-1", TenantID: "tenant-1", Role: "owner", Status: sptr(string(entity.StatusValid))})
|
||||
db.Create(&entity.UserTenant{ID: "ut-1", UserID: "user-1", TenantID: "tenant-1", Role: "normal", Status: sptr(string(entity.StatusValid))})
|
||||
// Insert knowledgebase
|
||||
db.Create(&entity.Knowledgebase{
|
||||
ID: "ds-1", TenantID: "tenant-1", Name: "test-kb", EmbdID: "embd-1",
|
||||
@@ -2239,8 +2255,36 @@ func TestDownloadDocument_NotFound(t *testing.T) {
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["code"] != float64(common.CodeDataError) {
|
||||
t.Fatalf("expected code %d, got %v", common.CodeDataError, resp["code"])
|
||||
if resp["code"] != float64(common.CodeNotFound) {
|
||||
t.Fatalf("expected code %d, got %v", common.CodeNotFound, resp["code"])
|
||||
}
|
||||
if resp["message"] != "Resource not found" {
|
||||
t.Fatalf("unexpected hidden-resource message: %v", resp["message"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadDocument_ServiceErrorReturnsServerError(t *testing.T) {
|
||||
h := downloadHandlerWithAccessDB(t)
|
||||
h.documentService = &fakeDocumentService{downloadErr: errors.New("database connection failed")}
|
||||
c, w := downloadContextAs("user-1", "ds-1", "doc-1")
|
||||
|
||||
h.DownloadDocument(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 response envelope, got %d", w.Code)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp["code"] != float64(common.CodeServerError) {
|
||||
t.Fatalf("expected code %d, got %v", common.CodeServerError, resp["code"])
|
||||
}
|
||||
if resp["message"] != "Failed to download document" {
|
||||
t.Fatalf("unexpected client-visible error: %v", resp["message"])
|
||||
}
|
||||
if strings.Contains(w.Body.String(), "database connection failed") {
|
||||
t.Fatal("database error detail leaked to the client")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2260,10 +2304,10 @@ func TestDownloadDocument_ForeignUserRejected(t *testing.T) {
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["code"] != float64(common.CodeDataError) {
|
||||
t.Fatalf("expected code %d, got %v", common.CodeDataError, resp["code"])
|
||||
if resp["code"] != float64(common.CodeNotFound) {
|
||||
t.Fatalf("expected code %d, got %v", common.CodeNotFound, resp["code"])
|
||||
}
|
||||
if resp["message"] != "document not found" {
|
||||
t.Fatalf("foreign user must get the same message as a missing document, got %v", resp["message"])
|
||||
if resp["message"] != "Resource not found" {
|
||||
t.Fatalf("unexpected hidden-resource message: %v", resp["message"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"ragflow/internal/common"
|
||||
permissionresponse "ragflow/internal/permission/response"
|
||||
"ragflow/internal/storage"
|
||||
"ragflow/internal/utility"
|
||||
"strconv"
|
||||
@@ -28,6 +29,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"ragflow/internal/permission"
|
||||
"ragflow/internal/service"
|
||||
"ragflow/internal/service/document"
|
||||
"ragflow/internal/service/file"
|
||||
@@ -598,9 +600,11 @@ func (h *FileHandler) LinkToDatasets(c *gin.Context) {
|
||||
// any other (internal) error is reported as a server error.
|
||||
func linkToDatasetsErrorCode(err error) common.ErrorCode {
|
||||
switch {
|
||||
case errors.Is(err, document.ErrLinkFileNotFound),
|
||||
errors.Is(err, document.ErrLinkDatasetNotFound),
|
||||
errors.Is(err, document.ErrLinkNoAuthorization):
|
||||
case errors.Is(err, permission.ErrUnauthenticated), errors.Is(err, permission.ErrMembershipNotFound),
|
||||
errors.Is(err, permission.ErrPermissionDenied), errors.Is(err, permission.ErrResourceNotFound):
|
||||
code, _ := permissionresponse.Normalize(err)
|
||||
return code
|
||||
case errors.Is(err, document.ErrLinkFileNotFound), errors.Is(err, document.ErrLinkDatasetNotFound):
|
||||
return common.CodeDataError
|
||||
default:
|
||||
return common.CodeServerError
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/permission"
|
||||
"ragflow/internal/service"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -95,10 +96,16 @@ func CommitFolderResolver(h *FileCommitHandler, entityType, urlParam string) gin
|
||||
// Authorize, not just existence: the commit surface exposes artifact
|
||||
// contents and history, so it needs the same access check the dataset
|
||||
// routes apply.
|
||||
if entityType == "datasets" && !h.kbDAO.Accessible(ctx, dao.DB, id, user.ID) {
|
||||
common.ResponseWithCodeData(c, common.CodeNotFound, nil, fmt.Sprintf("%s not found", entityType))
|
||||
c.Abort()
|
||||
return
|
||||
if entityType == "datasets" {
|
||||
operation := permission.OperationUpdate
|
||||
if c.Request.Method == "GET" || c.Request.Method == "HEAD" {
|
||||
operation = permission.OperationRead
|
||||
}
|
||||
if err := service.CheckDatasetAccess(ctx, permission.Subject{UserID: user.ID}, id, operation); err != nil {
|
||||
respondHTTPPermissionError(c, err, true)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
folderID, err := h.ResolveFolderID(ctx, entityType, id)
|
||||
if err != nil {
|
||||
|
||||
@@ -123,6 +123,15 @@ func setupIngestionMessagesHandlerDB(t *testing.T) *gorm.DB {
|
||||
func insertIngestionMessagesHandlerKB(t *testing.T, db *gorm.DB, id, userID string) {
|
||||
t.Helper()
|
||||
status := string(entity.StatusValid)
|
||||
if err := db.Create(&entity.UserTenant{
|
||||
ID: "ut-owner-" + id,
|
||||
UserID: userID,
|
||||
TenantID: userID,
|
||||
Role: "owner",
|
||||
Status: &status,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("insert tenant owner: %v", err)
|
||||
}
|
||||
if err := db.Create(&entity.Knowledgebase{
|
||||
ID: id,
|
||||
TenantID: userID,
|
||||
|
||||
@@ -122,9 +122,6 @@ func mcpRetrieval(ctx context.Context, ds *dataset.DatasetService, userID string
|
||||
}
|
||||
name, _ := info["name"].(string)
|
||||
datasetNames[id] = name
|
||||
if !ds.Accessible(ctx, id, userID) {
|
||||
continue
|
||||
}
|
||||
// Read only documents returned by retrieval, through the existing listing
|
||||
// and metadata services. This avoids loading entire document collections.
|
||||
for _, chunk := range resp.Chunks {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/permission"
|
||||
)
|
||||
|
||||
func TestPermissionErrorResult(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
hide bool
|
||||
code common.ErrorCode
|
||||
message string
|
||||
}{
|
||||
{name: "denial", err: permission.ErrPermissionDenied, code: common.CodeForbidden, message: "Permission denied"},
|
||||
{name: "hidden denial", err: permission.ErrPermissionDenied, hide: true, code: common.CodeNotFound, message: "Resource not found"},
|
||||
{name: "hidden missing resource", err: permission.ErrResourceNotFound, hide: true, code: common.CodeNotFound, message: "Resource not found"},
|
||||
{name: "internal error is not hidden", err: errors.New("database error"), hide: true, code: common.CodeServerError, message: "Permission check failed"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
code, message := permissionErrorResult(test.err, test.hide)
|
||||
if code != test.code || message != test.message {
|
||||
t.Fatalf("permissionErrorResult() = (%d, %q), want (%d, %q)", code, message, test.code, test.message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/permission"
|
||||
"ragflow/internal/service"
|
||||
dataset "ragflow/internal/service/dataset"
|
||||
|
||||
@@ -265,8 +266,8 @@ func (h *TenantHandler) CreateChunkStore(c *gin.Context) {
|
||||
|
||||
ctx := c.Request.Context()
|
||||
// Check authorization - user must have access to this kb
|
||||
if !h.datasetService.Accessible(ctx, req.KBID, user.ID) {
|
||||
common.ResponseWithCodeData(c, common.CodeAuthenticationError, nil, "no authorization")
|
||||
if err := h.datasetService.CheckAccess(ctx, permission.Subject{UserID: user.ID}, req.KBID, permission.OperationUpdate); err != nil {
|
||||
respondPermissionError(c, err, false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -313,8 +314,8 @@ func (h *TenantHandler) DeleteChunkStore(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Check authorization
|
||||
if !h.datasetService.Accessible(ctx, req.KBID, user.ID) {
|
||||
common.ResponseWithCodeData(c, common.CodeAuthenticationError, nil, "no authorization")
|
||||
if err := h.datasetService.CheckAccess(ctx, permission.Subject{UserID: user.ID}, req.KBID, permission.OperationUpdate); err != nil {
|
||||
respondPermissionError(c, err, false)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
//
|
||||
// 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"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"ragflow/internal/entity"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type databaseSource struct {
|
||||
db *gorm.DB
|
||||
lockRows bool
|
||||
}
|
||||
|
||||
// NewDatabaseChecker creates a Checker backed by the application's SQL database.
|
||||
// It currently loads dataset authorization facts.
|
||||
func NewDatabaseChecker(db *gorm.DB) *Checker {
|
||||
return NewChecker(&databaseSource{db: db})
|
||||
}
|
||||
|
||||
// NewLockingDatabaseChecker creates a database-backed Checker that locks loaded
|
||||
// resource and membership rows. Use it only with a short-lived transaction.
|
||||
func NewLockingDatabaseChecker(tx *gorm.DB) *Checker {
|
||||
return NewChecker(&databaseSource{db: tx, lockRows: true})
|
||||
}
|
||||
|
||||
func (s *databaseSource) GetMembership(ctx context.Context, userID, tenantID string) (*Membership, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, ErrSourceUnavailable
|
||||
}
|
||||
query := s.db.WithContext(ctx).Where("user_id = ? AND tenant_id = ? AND status = ?", userID, tenantID, string(entity.StatusValid))
|
||||
if s.lockRows {
|
||||
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
}
|
||||
var relation entity.UserTenant
|
||||
if err := query.First(&relation).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &Membership{
|
||||
ID: relation.ID,
|
||||
UserID: relation.UserID,
|
||||
TenantID: relation.TenantID,
|
||||
Role: TenantRole(relation.Role),
|
||||
Active: relation.Status != nil && *relation.Status == string(entity.StatusValid),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *databaseSource) GetResources(ctx context.Context, refs []ResourceRef) ([]Resource, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, ErrSourceUnavailable
|
||||
}
|
||||
refs = uniqueRefs(refs)
|
||||
datasetIDs := make([]string, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
if ref.Kind != ResourceKindDataset {
|
||||
return nil, fmt.Errorf("%w: unsupported resource kind %q", ErrInvalidPermission, ref.Kind)
|
||||
}
|
||||
datasetIDs = append(datasetIDs, ref.ID)
|
||||
}
|
||||
datasetsByID, ownerIDsByTenant, err := s.loadDatasets(ctx, datasetIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resources := make([]Resource, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
if dataset, ok := datasetsByID[ref.ID]; ok {
|
||||
resources = append(resources, datasetResource(dataset, ownerIDsByTenant))
|
||||
}
|
||||
}
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
func (s *databaseSource) ListResourceRefs(ctx context.Context, subject Subject, kind ResourceKind, parent ResourceRef) ([]ResourceRef, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, ErrSourceUnavailable
|
||||
}
|
||||
var ids []string
|
||||
switch kind {
|
||||
case ResourceKindDataset:
|
||||
if parent.ID != "" || parent.Kind != "" {
|
||||
return nil, fmt.Errorf("%w: datasets 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 = ? AND role IN ?", subject.UserID, string(entity.StatusValid), []string{
|
||||
string(RoleOwner), string(RoleAdmin), string(RoleNormal),
|
||||
}).
|
||||
Pluck("tenant_id", &tenantIDs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(tenantIDs) == 0 {
|
||||
return []ResourceRef{}, nil
|
||||
}
|
||||
err := s.db.WithContext(ctx).Model(&entity.Knowledgebase{}).
|
||||
Where("tenant_id IN ? AND status = ?", tenantIDs, string(entity.StatusValid)).
|
||||
Pluck("id", &ids).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: unsupported scope resource kind %q", ErrInvalidPermission, kind)
|
||||
}
|
||||
|
||||
refs := make([]ResourceRef, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
refs = append(refs, ResourceRef{Kind: kind, ID: id})
|
||||
}
|
||||
return refs, 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 {
|
||||
return datasetsByID, nil, nil
|
||||
}
|
||||
query := s.db.WithContext(ctx).Model(&entity.Knowledgebase{}).Where("id IN ? AND status = ?", ids, string(entity.StatusValid))
|
||||
if s.lockRows {
|
||||
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
}
|
||||
var datasets []*entity.Knowledgebase
|
||||
if err := query.Find(&datasets).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
tenantIDs := make([]string, 0, len(datasets))
|
||||
seenTenantIDs := make(map[string]struct{}, len(datasets))
|
||||
for _, dataset := range datasets {
|
||||
if _, seen := seenTenantIDs[dataset.TenantID]; seen {
|
||||
continue
|
||||
}
|
||||
seenTenantIDs[dataset.TenantID] = struct{}{}
|
||||
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 {
|
||||
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 == "" {
|
||||
return nil, nil, fmt.Errorf("%w: tenant %q has no active owner", ErrInvalidPermission, dataset.TenantID)
|
||||
}
|
||||
datasetsByID[dataset.ID] = dataset
|
||||
}
|
||||
return datasetsByID, ownerIDsByTenant, nil
|
||||
}
|
||||
|
||||
func datasetResource(dataset *entity.Knowledgebase, ownerIDsByTenant map[string]string) Resource {
|
||||
ownerUserID := ownerIDsByTenant[dataset.TenantID]
|
||||
visibility := VisibilityPrivate
|
||||
switch dataset.Permission {
|
||||
case string(entity.TenantPermissionMe):
|
||||
visibility = VisibilityPrivate
|
||||
case string(entity.TenantPermissionTeam):
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
//go:build !enterprise
|
||||
|
||||
package permission
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"ragflow/internal/entity"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestDatabaseCheckerAuthorizesDatasetFromPersistedRows(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.Knowledgebase{}, &entity.UserTenant{}); err != nil {
|
||||
t.Fatalf("migrate permission tables: %v", err)
|
||||
}
|
||||
|
||||
valid := string(entity.StatusValid)
|
||||
dataset := entity.Knowledgebase{
|
||||
ID: "dataset-1", TenantID: "tenant-1", CreatedBy: "creator-1",
|
||||
Permission: string(entity.TenantPermissionTeam), Status: &valid,
|
||||
}
|
||||
ownerMembership := entity.UserTenant{
|
||||
ID: "owner-membership", UserID: "tenant-owner", TenantID: dataset.TenantID,
|
||||
Role: string(RoleOwner), Status: &valid,
|
||||
}
|
||||
membership := entity.UserTenant{
|
||||
ID: "membership-1", UserID: "member-1", TenantID: dataset.TenantID,
|
||||
Role: string(RoleNormal), Status: &valid,
|
||||
}
|
||||
if err := db.Create(&dataset).Error; err != nil {
|
||||
t.Fatalf("create dataset: %v", err)
|
||||
}
|
||||
if err := db.Create(&membership).Error; err != nil {
|
||||
t.Fatalf("create membership: %v", err)
|
||||
}
|
||||
if err := db.Create(&ownerMembership).Error; err != nil {
|
||||
t.Fatalf("create owner membership: %v", err)
|
||||
}
|
||||
|
||||
checker := NewDatabaseChecker(db)
|
||||
subject := Subject{UserID: membership.UserID}
|
||||
resources, err := (&databaseSource{db: db}).GetResources(context.Background(), []ResourceRef{{
|
||||
Kind: ResourceKindDataset,
|
||||
ID: dataset.ID,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("load dataset authorization facts: %v", err)
|
||||
}
|
||||
if len(resources) != 1 || resources[0].OwnerUserID != ownerMembership.UserID || resources[0].CreatedBy != dataset.CreatedBy {
|
||||
t.Fatalf("dataset resource = %+v, want tenant owner %q and creator %q", resources, ownerMembership.UserID, dataset.CreatedBy)
|
||||
}
|
||||
|
||||
ref := ResourceRef{Kind: ResourceKindDataset, ID: dataset.ID}
|
||||
if err := checker.CheckResource(context.Background(), subject, ref, OperationRead); err != nil {
|
||||
t.Errorf("CheckResource(%+v) error = %v, want nil", ref, err)
|
||||
}
|
||||
adminMembership := entity.UserTenant{
|
||||
ID: "admin-membership", UserID: "admin-1", TenantID: dataset.TenantID,
|
||||
Role: string(RoleAdmin), Status: &valid,
|
||||
}
|
||||
if err := db.Create(&adminMembership).Error; err != nil {
|
||||
t.Fatalf("create admin membership: %v", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
access, err := checker.ResolveAccess(context.Background(), subject, ref)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccess(dataset) error = %v", err)
|
||||
}
|
||||
if access.TenantID != dataset.TenantID || access.Source != AccessSourceTenant {
|
||||
t.Fatalf("dataset access = %+v, want tenant access", access)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseCheckerRejectsPrivateDatasetForOtherTenantMember(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.Knowledgebase{}, &entity.UserTenant{}); err != nil {
|
||||
t.Fatalf("migrate permission tables: %v", err)
|
||||
}
|
||||
|
||||
valid := string(entity.StatusValid)
|
||||
dataset := entity.Knowledgebase{
|
||||
ID: "dataset-private", TenantID: "tenant-1", CreatedBy: "creator-1",
|
||||
Permission: string(entity.TenantPermissionMe), Status: &valid,
|
||||
}
|
||||
membership := entity.UserTenant{
|
||||
ID: "membership-1", UserID: "member-1", TenantID: "tenant-1",
|
||||
Role: string(RoleNormal), Status: &valid,
|
||||
}
|
||||
ownerMembership := entity.UserTenant{
|
||||
ID: "owner-membership", UserID: "tenant-owner", TenantID: "tenant-1",
|
||||
Role: string(RoleOwner), Status: &valid,
|
||||
}
|
||||
if err := db.Create(&dataset).Error; err != nil {
|
||||
t.Fatalf("create dataset: %v", err)
|
||||
}
|
||||
if err := db.Create(&membership).Error; err != nil {
|
||||
t.Fatalf("create membership: %v", err)
|
||||
}
|
||||
if err := db.Create(&ownerMembership).Error; err != nil {
|
||||
t.Fatalf("create owner membership: %v", err)
|
||||
}
|
||||
|
||||
err = NewDatabaseChecker(db).CheckResource(context.Background(), Subject{UserID: membership.UserID}, ResourceRef{
|
||||
Kind: ResourceKindDataset,
|
||||
ID: dataset.ID,
|
||||
}, OperationRead)
|
||||
if !errors.Is(err, ErrPermissionDenied) {
|
||||
t.Fatalf("CheckResource(private dataset) error = %v, want %v", err, ErrPermissionDenied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseCheckerScopesDatasetsToMembershipAndVisibility(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.Knowledgebase{}, &entity.UserTenant{}); err != nil {
|
||||
t.Fatalf("migrate permission tables: %v", err)
|
||||
}
|
||||
|
||||
valid := string(entity.StatusValid)
|
||||
datasets := []entity.Knowledgebase{
|
||||
{ID: "team-visible", TenantID: "member-tenant", CreatedBy: "tenant-owner", Permission: string(entity.TenantPermissionTeam), Status: &valid},
|
||||
{ID: "private-hidden", TenantID: "member-tenant", CreatedBy: "tenant-owner", Permission: string(entity.TenantPermissionMe), Status: &valid},
|
||||
{ID: "outside-tenant", TenantID: "other-tenant", CreatedBy: "other-owner", Permission: string(entity.TenantPermissionTeam), 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)
|
||||
}
|
||||
}
|
||||
memberships := []entity.UserTenant{
|
||||
{ID: "member", UserID: "member-user", TenantID: "member-tenant", Role: string(RoleNormal), 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},
|
||||
}
|
||||
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-user"}, ScopeQuery{
|
||||
Kind: ResourceKindDataset, Operation: OperationRead,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Scope(datasets) error = %v", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
//
|
||||
// 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.
|
||||
|
||||
//go:build !enterprise
|
||||
|
||||
package permission
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type communityPolicy struct {
|
||||
source Source
|
||||
}
|
||||
|
||||
type membershipResult struct {
|
||||
membership *Membership
|
||||
err error
|
||||
}
|
||||
|
||||
type evaluation struct {
|
||||
source Source
|
||||
memberships map[string]membershipResult
|
||||
resources map[ResourceRef]Resource
|
||||
resourceErr map[ResourceRef]error
|
||||
}
|
||||
|
||||
func newPolicy(source Source) policy {
|
||||
return &communityPolicy{source: source}
|
||||
}
|
||||
|
||||
func (p *communityPolicy) CheckFeature(ctx context.Context, subject Subject, feature Feature, action Action) error {
|
||||
if err := validateSubject(subject); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(string(feature)) == "" || !validAction(action) {
|
||||
return ErrInvalidPermission
|
||||
}
|
||||
// Community edition has no role-based feature restrictions.
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *communityPolicy) CheckTenant(ctx context.Context, subject Subject, tenantID string, requirement TenantRequirement) error {
|
||||
return newEvaluation(p.source).checkTenant(ctx, subject, tenantID, requirement)
|
||||
}
|
||||
|
||||
func (p *communityPolicy) CheckResource(ctx context.Context, subject Subject, ref ResourceRef, operation Operation) error {
|
||||
if !validOperation(operation) {
|
||||
return ErrInvalidPermission
|
||||
}
|
||||
access, err := newEvaluation(p.source).resolveAccess(ctx, subject, ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !accessAllows(access, operation) {
|
||||
return ErrPermissionDenied
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *communityPolicy) CheckDependency(ctx context.Context, subject Subject, entryRef, dependencyRef ResourceRef, entryOperation, dependencyOperation Operation) error {
|
||||
if !validOperation(entryOperation) || !validOperation(dependencyOperation) {
|
||||
return ErrInvalidPermission
|
||||
}
|
||||
evaluation := newEvaluation(p.source)
|
||||
return evaluation.checkDependency(ctx, subject, entryRef, dependencyRef, entryOperation, dependencyOperation)
|
||||
}
|
||||
|
||||
func (p *communityPolicy) ResolveAccess(ctx context.Context, subject Subject, ref ResourceRef) (Access, error) {
|
||||
return newEvaluation(p.source).resolveAccess(ctx, subject, ref)
|
||||
}
|
||||
|
||||
func (p *communityPolicy) Scope(ctx context.Context, subject Subject, query ScopeQuery) (Scope, error) {
|
||||
if err := validateSubject(subject); err != nil {
|
||||
return Scope{}, err
|
||||
}
|
||||
if strings.TrimSpace(string(query.Kind)) == "" || !validOperation(query.Operation) {
|
||||
return Scope{}, ErrInvalidPermission
|
||||
}
|
||||
if (query.Parent.ID == "") != (query.Parent.Kind == "") {
|
||||
return Scope{}, ErrInvalidPermission
|
||||
}
|
||||
if query.Entry != nil && (!validResourceRef(*query.Entry) || !validOperation(query.EntryOp)) {
|
||||
return Scope{}, ErrInvalidPermission
|
||||
}
|
||||
|
||||
evaluation := newEvaluation(p.source)
|
||||
candidateRefs, err := p.source.ListResourceRefs(ctx, subject, query.Kind, query.Parent)
|
||||
if err != nil {
|
||||
return Scope{}, fmt.Errorf("permission: list resource candidates: %w", err)
|
||||
}
|
||||
candidateRefs = uniqueRefs(candidateRefs)
|
||||
resourcesToLoad := append([]ResourceRef(nil), candidateRefs...)
|
||||
if query.Parent.ID != "" {
|
||||
resourcesToLoad = append(resourcesToLoad, query.Parent)
|
||||
}
|
||||
if query.Entry != nil {
|
||||
resourcesToLoad = append(resourcesToLoad, *query.Entry)
|
||||
}
|
||||
if err := evaluation.loadResources(ctx, resourcesToLoad); err != nil {
|
||||
return Scope{}, err
|
||||
}
|
||||
|
||||
var entryRef ResourceRef
|
||||
if query.Entry != nil {
|
||||
entryRef = *query.Entry
|
||||
}
|
||||
accessible := make([]ResourceRef, 0, len(candidateRefs))
|
||||
for _, ref := range candidateRefs {
|
||||
var checkErr error
|
||||
if query.Entry == nil {
|
||||
checkErr = evaluation.checkResource(ctx, subject, ref, query.Operation)
|
||||
} else {
|
||||
checkErr = evaluation.checkDependency(ctx, subject, entryRef, ref, query.EntryOp, query.Operation)
|
||||
}
|
||||
if checkErr == nil {
|
||||
accessible = append(accessible, ref)
|
||||
continue
|
||||
}
|
||||
if errorsIsPermissionDenial(checkErr) || errors.Is(checkErr, ErrResourceNotFound) {
|
||||
continue
|
||||
}
|
||||
return Scope{}, checkErr
|
||||
}
|
||||
|
||||
// In the community edition the effective tenant comes from the persisted
|
||||
// resource boundary, not from a caller-selected tenant. Scopes without a
|
||||
// single parent or entry can contain resources from multiple tenants, so
|
||||
// their explicit authorized IDs are the boundary and TenantID stays empty.
|
||||
var tenantID string
|
||||
if query.Parent.ID != "" {
|
||||
if parent, parentErr := evaluation.getResource(query.Parent); parentErr == nil {
|
||||
tenantID = parent.TenantID
|
||||
}
|
||||
}
|
||||
if query.Entry != nil {
|
||||
if entry, entryErr := evaluation.getResource(*query.Entry); entryErr == nil {
|
||||
tenantID = entry.TenantID
|
||||
}
|
||||
}
|
||||
scope := Scope{TenantID: tenantID, Kind: query.Kind, Parent: query.Parent, Operation: query.Operation}
|
||||
if len(accessible) == 0 {
|
||||
scope.Mode = ScopeNone
|
||||
return scope, nil
|
||||
}
|
||||
scope.Mode = ScopeIDs
|
||||
scope.ResourceIDs = make([]string, 0, len(accessible))
|
||||
for _, ref := range accessible {
|
||||
scope.ResourceIDs = append(scope.ResourceIDs, ref.ID)
|
||||
}
|
||||
return scope, nil
|
||||
}
|
||||
|
||||
func (p *communityPolicy) FilterResources(ctx context.Context, subject Subject, refs []ResourceRef, operation Operation) ([]ResourceRef, error) {
|
||||
if err := validateSubject(subject); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !validOperation(operation) {
|
||||
return nil, ErrInvalidPermission
|
||||
}
|
||||
evaluation := newEvaluation(p.source)
|
||||
refs = uniqueRefs(refs)
|
||||
if err := evaluation.loadResources(ctx, refs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accessible := make([]ResourceRef, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
err := evaluation.checkResource(ctx, subject, ref, operation)
|
||||
if err == nil {
|
||||
accessible = append(accessible, ref)
|
||||
continue
|
||||
}
|
||||
if errorsIsPermissionDenial(err) || errors.Is(err, ErrResourceNotFound) {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return accessible, nil
|
||||
}
|
||||
|
||||
func newEvaluation(source Source) *evaluation {
|
||||
return &evaluation{
|
||||
source: source,
|
||||
memberships: make(map[string]membershipResult),
|
||||
resources: make(map[ResourceRef]Resource),
|
||||
resourceErr: make(map[ResourceRef]error),
|
||||
}
|
||||
}
|
||||
|
||||
func (e *evaluation) checkTenant(ctx context.Context, subject Subject, tenantID string, requirement TenantRequirement) error {
|
||||
if err := validateSubject(subject); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(tenantID) == "" || !validTenantRequirement(requirement) {
|
||||
return ErrInvalidPermission
|
||||
}
|
||||
membership, err := e.getMembership(ctx, subject.UserID, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !membershipSatisfies(membership, requirement) {
|
||||
return ErrMembershipNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *evaluation) checkResource(ctx context.Context, subject Subject, ref ResourceRef, operation Operation) error {
|
||||
if !validOperation(operation) {
|
||||
return ErrInvalidPermission
|
||||
}
|
||||
access, err := e.resolveAccess(ctx, subject, ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !accessAllows(access, operation) {
|
||||
return ErrPermissionDenied
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *evaluation) checkDependency(ctx context.Context, subject Subject, entryRef, dependencyRef ResourceRef, entryOperation, dependencyOperation Operation) error {
|
||||
if err := validateSubject(subject); err != nil {
|
||||
return err
|
||||
}
|
||||
if !validOperation(entryOperation) || !validOperation(dependencyOperation) {
|
||||
return ErrInvalidPermission
|
||||
}
|
||||
if err := e.loadResources(ctx, []ResourceRef{entryRef, dependencyRef}); err != nil {
|
||||
return err
|
||||
}
|
||||
entry, err := e.getResource(entryRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entryAccess, err := e.resolveResource(ctx, subject, entry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !accessAllows(entryAccess, entryOperation) {
|
||||
return ErrPermissionDenied
|
||||
}
|
||||
dependency, err := e.getResource(dependencyRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
userAccess, err := e.resolveResource(ctx, subject, dependency)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !accessAllows(userAccess, dependencyOperation) {
|
||||
return ErrPermissionDenied
|
||||
}
|
||||
ownerSubject := Subject{UserID: entry.OwnerUserID, TenantID: entry.TenantID}
|
||||
ownerAccess, err := e.resolveResource(ctx, ownerSubject, dependency)
|
||||
if err != nil {
|
||||
if errorsIsPermissionDenial(err) {
|
||||
return ErrPermissionDenied
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !accessAllows(ownerAccess, dependencyOperation) {
|
||||
return ErrPermissionDenied
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *evaluation) resolveAccess(ctx context.Context, subject Subject, ref ResourceRef) (Access, error) {
|
||||
if err := validateSubject(subject); err != nil {
|
||||
return Access{}, err
|
||||
}
|
||||
if err := e.loadResources(ctx, []ResourceRef{ref}); err != nil {
|
||||
return Access{}, err
|
||||
}
|
||||
resource, err := e.getResource(ref)
|
||||
if err != nil {
|
||||
return Access{}, err
|
||||
}
|
||||
return e.resolveResource(ctx, subject, resource)
|
||||
}
|
||||
|
||||
func (e *evaluation) resolveResource(ctx context.Context, subject Subject, resource Resource) (Access, error) {
|
||||
if resource.TenantID == "" || resource.OwnerUserID == "" || !validResourceRef(resource.Ref) {
|
||||
return Access{}, fmt.Errorf("%w: incomplete resource authorization facts", ErrInvalidPermission)
|
||||
}
|
||||
if !resource.Active {
|
||||
return Access{}, ErrResourceNotFound
|
||||
}
|
||||
if !validOperations(resource.OwnerOperations) {
|
||||
return Access{}, fmt.Errorf("%w: invalid owner operations", ErrInvalidPermission)
|
||||
}
|
||||
switch resource.Visibility {
|
||||
case VisibilityPrivate, VisibilityTenant, VisibilityShared:
|
||||
default:
|
||||
return Access{}, fmt.Errorf("%w: unknown resource visibility %q", ErrInvalidPermission, resource.Visibility)
|
||||
}
|
||||
if subject.UserID == resource.OwnerUserID {
|
||||
return resourceAccess(resource, AccessSourceOwner), nil
|
||||
}
|
||||
|
||||
switch resource.Visibility {
|
||||
case VisibilityPrivate:
|
||||
return Access{}, ErrPermissionDenied
|
||||
case VisibilityTenant:
|
||||
if err := e.checkTenant(ctx, subject, resource.TenantID, TenantNormalMember); err != nil {
|
||||
if errors.Is(err, ErrMembershipNotFound) {
|
||||
return Access{}, ErrPermissionDenied
|
||||
}
|
||||
return Access{}, err
|
||||
}
|
||||
return resourceAccess(resource, AccessSourceTenant), nil
|
||||
case VisibilityShared:
|
||||
if contains(resource.SharedWithUserIDs, subject.UserID) {
|
||||
return resourceAccess(resource, AccessSourceShared), nil
|
||||
}
|
||||
for _, tenantID := range resource.SharedWithTenantIDs {
|
||||
if err := e.checkTenant(ctx, subject, tenantID, TenantNormalMember); err != nil {
|
||||
if errors.Is(err, ErrMembershipNotFound) {
|
||||
continue
|
||||
}
|
||||
return Access{}, err
|
||||
}
|
||||
return resourceAccess(resource, AccessSourceShared), nil
|
||||
}
|
||||
return Access{}, ErrPermissionDenied
|
||||
default:
|
||||
return Access{}, fmt.Errorf("%w: unknown resource visibility %q", ErrInvalidPermission, resource.Visibility)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *evaluation) loadResources(ctx context.Context, refs []ResourceRef) error {
|
||||
if e.source == nil {
|
||||
return ErrSourceUnavailable
|
||||
}
|
||||
var missing []ResourceRef
|
||||
for _, ref := range uniqueRefs(refs) {
|
||||
if _, ok := e.resources[ref]; ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := e.resourceErr[ref]; ok {
|
||||
continue
|
||||
}
|
||||
if !validResourceRef(ref) {
|
||||
return ErrInvalidPermission
|
||||
}
|
||||
missing = append(missing, ref)
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
resources, err := e.source.GetResources(ctx, missing)
|
||||
if err != nil {
|
||||
return fmt.Errorf("permission: load resources: %w", err)
|
||||
}
|
||||
for _, ref := range missing {
|
||||
e.resourceErr[ref] = ErrResourceNotFound
|
||||
}
|
||||
for _, resource := range resources {
|
||||
if _, requested := e.resourceErr[resource.Ref]; requested {
|
||||
e.resources[resource.Ref] = resource
|
||||
delete(e.resourceErr, resource.Ref)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *evaluation) getResource(ref ResourceRef) (Resource, error) {
|
||||
if !validResourceRef(ref) {
|
||||
return Resource{}, ErrInvalidPermission
|
||||
}
|
||||
if resource, ok := e.resources[ref]; ok {
|
||||
return resource, nil
|
||||
}
|
||||
if err, ok := e.resourceErr[ref]; ok {
|
||||
return Resource{}, err
|
||||
}
|
||||
return Resource{}, ErrResourceNotFound
|
||||
}
|
||||
|
||||
func (e *evaluation) getMembership(ctx context.Context, userID, tenantID string) (*Membership, error) {
|
||||
if e.source == nil {
|
||||
return nil, ErrSourceUnavailable
|
||||
}
|
||||
key := userID + "\x00" + tenantID
|
||||
if result, ok := e.memberships[key]; ok {
|
||||
return result.membership, result.err
|
||||
}
|
||||
membership, err := e.source.GetMembership(ctx, userID, tenantID)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("permission: load tenant membership: %w", err)
|
||||
} else if membership == nil || !membership.Active || membership.UserID != userID || membership.TenantID != tenantID || membership.Role == RoleInvite {
|
||||
membership = nil
|
||||
err = ErrMembershipNotFound
|
||||
}
|
||||
e.memberships[key] = membershipResult{membership: membership, err: err}
|
||||
return membership, err
|
||||
}
|
||||
|
||||
func errorsIsPermissionDenial(err error) bool {
|
||||
return errors.Is(err, ErrPermissionDenied) || errors.Is(err, ErrMembershipNotFound)
|
||||
}
|
||||
|
||||
func validateSubject(subject Subject) error {
|
||||
if strings.TrimSpace(subject.UserID) == "" {
|
||||
return ErrUnauthenticated
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validResourceRef(ref ResourceRef) bool {
|
||||
return strings.TrimSpace(string(ref.Kind)) != "" && strings.TrimSpace(ref.ID) != ""
|
||||
}
|
||||
|
||||
func validAction(action Action) bool {
|
||||
switch action {
|
||||
case ActionEnable, ActionRead, ActionWrite, ActionShare:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validOperation(operation Operation) bool {
|
||||
switch operation {
|
||||
case OperationRead, OperationCreate, OperationUpdate, OperationDelete, OperationShare, OperationRun, OperationUse:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validTenantRequirement(requirement TenantRequirement) bool {
|
||||
switch requirement {
|
||||
case TenantMember, TenantNormalMember, TenantAdmin, TenantOwner:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func membershipSatisfies(membership *Membership, requirement TenantRequirement) bool {
|
||||
if membership == nil || !membership.Active || membership.Role == RoleInvite {
|
||||
return false
|
||||
}
|
||||
switch requirement {
|
||||
case TenantMember:
|
||||
return membership.Role == RoleOwner || membership.Role == RoleAdmin || membership.Role == RoleNormal
|
||||
case TenantNormalMember:
|
||||
return membership.Role == RoleNormal
|
||||
case TenantAdmin:
|
||||
return membership.Role == RoleOwner || membership.Role == RoleAdmin
|
||||
case TenantOwner:
|
||||
return membership.Role == RoleOwner
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validOperations(operations []Operation) bool {
|
||||
for _, operation := range operations {
|
||||
if !validOperation(operation) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func resourceAccess(resource Resource, source AccessSource) Access {
|
||||
return Access{
|
||||
Resource: resource.Ref,
|
||||
TenantID: resource.TenantID,
|
||||
Source: source,
|
||||
Operations: append([]Operation(nil), resource.OwnerOperations...),
|
||||
}
|
||||
}
|
||||
|
||||
func accessAllows(access Access, operation Operation) bool {
|
||||
if !access.Allows(operation) {
|
||||
return false
|
||||
}
|
||||
return operation != OperationShare || access.Source == AccessSourceOwner
|
||||
}
|
||||
|
||||
func contains(values []string, target string) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func uniqueRefs(refs []ResourceRef) []ResourceRef {
|
||||
seen := make(map[ResourceRef]struct{}, len(refs))
|
||||
unique := make([]ResourceRef, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
if !validResourceRef(ref) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[ref]; ok {
|
||||
continue
|
||||
}
|
||||
seen[ref] = struct{}{}
|
||||
unique = append(unique, ref)
|
||||
}
|
||||
return unique
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
//go:build !enterprise
|
||||
|
||||
package permission
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type permissionTestSource struct {
|
||||
memberships map[string]*Membership
|
||||
resources map[ResourceRef]Resource
|
||||
candidates []ResourceRef
|
||||
}
|
||||
|
||||
func (s *permissionTestSource) GetMembership(_ context.Context, userID, tenantID string) (*Membership, error) {
|
||||
return s.memberships[userID+"\x00"+tenantID], nil
|
||||
}
|
||||
|
||||
func (s *permissionTestSource) GetResources(_ context.Context, refs []ResourceRef) ([]Resource, error) {
|
||||
resources := make([]Resource, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
if resource, ok := s.resources[ref]; ok {
|
||||
resources = append(resources, resource)
|
||||
}
|
||||
}
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
func (s *permissionTestSource) ListResourceRefs(_ context.Context, _ Subject, kind ResourceKind, parent ResourceRef) ([]ResourceRef, error) {
|
||||
refs := make([]ResourceRef, 0, len(s.candidates))
|
||||
for _, ref := range s.candidates {
|
||||
if ref.Kind == kind {
|
||||
refs = append(refs, ref)
|
||||
}
|
||||
}
|
||||
return refs, nil
|
||||
}
|
||||
|
||||
func TestCommunityPolicyResolvesResourceTenant(t *testing.T) {
|
||||
resourceRef := ResourceRef{Kind: "dataset", ID: "dataset-1"}
|
||||
source := &permissionTestSource{
|
||||
memberships: map[string]*Membership{
|
||||
"user-1\x00tenant-resource": {
|
||||
UserID: "user-1", TenantID: "tenant-resource", Role: RoleNormal, Active: true,
|
||||
},
|
||||
},
|
||||
resources: map[ResourceRef]Resource{
|
||||
resourceRef: {
|
||||
Ref: resourceRef, TenantID: "tenant-resource", OwnerUserID: "tenant-owner",
|
||||
Visibility: VisibilityTenant, OwnerOperations: []Operation{OperationRead}, Active: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
access, err := NewChecker(source).ResolveAccess(context.Background(), Subject{UserID: "user-1"}, resourceRef)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccess() error = %v", err)
|
||||
}
|
||||
if access.TenantID != "tenant-resource" {
|
||||
t.Fatalf("ResolveAccess() tenant = %q, want resource tenant", access.TenantID)
|
||||
}
|
||||
if access.Source != AccessSourceTenant || !access.Allows(OperationRead) {
|
||||
t.Fatalf("ResolveAccess() = %#v, want tenant read access", access)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommunityPolicyUsesResourceTenantInsteadOfSubjectTenant(t *testing.T) {
|
||||
resourceRef := ResourceRef{Kind: "dataset", ID: "dataset-1"}
|
||||
source := &permissionTestSource{
|
||||
memberships: map[string]*Membership{
|
||||
"user-1\x00tenant-resource": {
|
||||
UserID: "user-1", TenantID: "tenant-resource", Role: RoleNormal, Active: true,
|
||||
},
|
||||
},
|
||||
resources: map[ResourceRef]Resource{
|
||||
resourceRef: {
|
||||
Ref: resourceRef, TenantID: "tenant-resource", OwnerUserID: "tenant-owner",
|
||||
Visibility: VisibilityTenant, OwnerOperations: []Operation{OperationRead}, Active: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
access, err := NewChecker(source).ResolveAccess(context.Background(), Subject{UserID: "user-1", TenantID: "another-tenant"}, resourceRef)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccess() error = %v", err)
|
||||
}
|
||||
if access.TenantID != "tenant-resource" {
|
||||
t.Fatalf("ResolveAccess() tenant = %q, want resource tenant", access.TenantID)
|
||||
}
|
||||
|
||||
_, err = NewChecker(source).ResolveAccess(context.Background(), Subject{UserID: "unrelated-user", TenantID: "tenant-resource"}, resourceRef)
|
||||
if !errors.Is(err, ErrPermissionDenied) {
|
||||
t.Fatalf("ResolveAccess() error = %v, want %v for a non-member", err, ErrPermissionDenied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommunityPolicySharedResourceNeedsNoSubjectTenant(t *testing.T) {
|
||||
resourceRef := ResourceRef{Kind: "agent", ID: "agent-1"}
|
||||
source := &permissionTestSource{
|
||||
memberships: map[string]*Membership{
|
||||
"user-1\x00shared-tenant": {
|
||||
UserID: "user-1", TenantID: "shared-tenant", Role: RoleNormal, Active: true,
|
||||
},
|
||||
},
|
||||
resources: map[ResourceRef]Resource{
|
||||
resourceRef: {
|
||||
Ref: resourceRef, TenantID: "owner-tenant", OwnerUserID: "owner-user",
|
||||
Visibility: VisibilityShared, SharedWithTenantIDs: []string{"shared-tenant"},
|
||||
OwnerOperations: []Operation{OperationRun}, Active: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
access, err := NewChecker(source).ResolveAccess(context.Background(), Subject{UserID: "user-1"}, resourceRef)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccess() error = %v", err)
|
||||
}
|
||||
if access.Source != AccessSourceShared || !access.Allows(OperationRun) {
|
||||
t.Fatalf("ResolveAccess() = %#v, want shared run access", access)
|
||||
}
|
||||
if err := NewChecker(source).CheckResource(context.Background(), Subject{UserID: "user-1"}, resourceRef, OperationShare); !errors.Is(err, ErrPermissionDenied) {
|
||||
t.Fatalf("CheckResource(share) error = %v, want %v for a shared resource", err, ErrPermissionDenied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommunityPolicyChecksDependencyForBothUserAndEntryOwner(t *testing.T) {
|
||||
entryRef := ResourceRef{Kind: "agent", ID: "agent-1"}
|
||||
dependencyRef := ResourceRef{Kind: "dataset", ID: "dataset-1"}
|
||||
source := &permissionTestSource{
|
||||
resources: map[ResourceRef]Resource{
|
||||
entryRef: {
|
||||
Ref: entryRef, TenantID: "entry-tenant", OwnerUserID: "entry-owner",
|
||||
Visibility: VisibilityShared, SharedWithUserIDs: []string{"caller"},
|
||||
OwnerOperations: []Operation{OperationRun}, Active: true,
|
||||
},
|
||||
dependencyRef: {
|
||||
Ref: dependencyRef, TenantID: "dataset-tenant", OwnerUserID: "dataset-owner",
|
||||
Visibility: VisibilityShared, SharedWithUserIDs: []string{"caller"},
|
||||
OwnerOperations: []Operation{OperationRead}, Active: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := NewChecker(source).CheckDependency(
|
||||
context.Background(), Subject{UserID: "caller"}, entryRef, dependencyRef, OperationRun, OperationRead,
|
||||
)
|
||||
if !errors.Is(err, ErrPermissionDenied) {
|
||||
t.Fatalf("CheckDependency() error = %v, want %v", err, ErrPermissionDenied)
|
||||
}
|
||||
|
||||
dependency := source.resources[dependencyRef]
|
||||
dependency.SharedWithUserIDs = []string{"caller", "entry-owner"}
|
||||
source.resources[dependencyRef] = dependency
|
||||
err = NewChecker(source).CheckDependency(
|
||||
context.Background(), Subject{UserID: "caller"}, entryRef, dependencyRef, OperationRun, OperationRead,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckDependency() error = %v, want nil when both caller and entry owner can read dependency", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommunityPolicyFeatureDoesNotRequireTenantContext(t *testing.T) {
|
||||
checker := NewChecker(&permissionTestSource{})
|
||||
if err := checker.CheckFeature(context.Background(), Subject{UserID: "user-1"}, "dataset", ActionRead); err != nil {
|
||||
t.Fatalf("CheckFeature() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommunityPolicyScopeUsesParentResourceTenant(t *testing.T) {
|
||||
parentRef := ResourceRef{Kind: "dataset", ID: "dataset-1"}
|
||||
childRef := ResourceRef{Kind: "document", ID: "document-1"}
|
||||
source := &permissionTestSource{
|
||||
memberships: map[string]*Membership{
|
||||
"user-1\x00dataset-tenant": {
|
||||
UserID: "user-1", TenantID: "dataset-tenant", Role: RoleNormal, Active: true,
|
||||
},
|
||||
},
|
||||
resources: map[ResourceRef]Resource{
|
||||
parentRef: {
|
||||
Ref: parentRef, TenantID: "dataset-tenant", OwnerUserID: "dataset-owner",
|
||||
Visibility: VisibilityTenant, OwnerOperations: []Operation{OperationRead}, Active: true,
|
||||
},
|
||||
childRef: {
|
||||
Ref: childRef, TenantID: "dataset-tenant", OwnerUserID: "dataset-owner",
|
||||
Visibility: VisibilityTenant, OwnerOperations: []Operation{OperationRead}, Active: true,
|
||||
},
|
||||
},
|
||||
candidates: []ResourceRef{childRef},
|
||||
}
|
||||
|
||||
scope, err := NewChecker(source).Scope(context.Background(), Subject{UserID: "user-1"}, ScopeQuery{
|
||||
Kind: "document", Parent: parentRef, Operation: OperationRead,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Scope() error = %v", err)
|
||||
}
|
||||
if scope.TenantID != "dataset-tenant" {
|
||||
t.Fatalf("Scope() tenant = %q, want parent tenant", scope.TenantID)
|
||||
}
|
||||
if scope.Mode != ScopeIDs || len(scope.ResourceIDs) != 1 || scope.ResourceIDs[0] != childRef.ID {
|
||||
t.Fatalf("Scope() = %#v, want the accessible child ID", scope)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommunityPolicyRequiresUserIdentity(t *testing.T) {
|
||||
resourceRef := ResourceRef{Kind: "dataset", ID: "dataset-1"}
|
||||
_, err := NewChecker(&permissionTestSource{}).ResolveAccess(context.Background(), Subject{}, resourceRef)
|
||||
if !errors.Is(err, ErrUnauthenticated) {
|
||||
t.Fatalf("ResolveAccess() error = %v, want %v", err, ErrUnauthenticated)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//
|
||||
// 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 permissionresponse
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/permission"
|
||||
)
|
||||
|
||||
// Normalize maps permission decisions to stable API codes and safe messages
|
||||
// while retaining the original error for errors.Is/errors.As.
|
||||
func Normalize(err error) (common.ErrorCode, error) {
|
||||
return normalize(err, false)
|
||||
}
|
||||
|
||||
// NormalizeHidden hides whether a resource exists when the caller is not
|
||||
// allowed to access it.
|
||||
func NormalizeHidden(err error) (common.ErrorCode, error) {
|
||||
return normalize(err, true)
|
||||
}
|
||||
|
||||
// IsPermissionError reports whether err carries a typed permission decision.
|
||||
func IsPermissionError(err error) bool {
|
||||
return errors.Is(err, permission.ErrUnauthenticated) ||
|
||||
errors.Is(err, permission.ErrResourceNotFound) ||
|
||||
errors.Is(err, permission.ErrMembershipNotFound) ||
|
||||
errors.Is(err, permission.ErrPermissionDenied)
|
||||
}
|
||||
|
||||
func normalize(err error, hideResource bool) (common.ErrorCode, error) {
|
||||
if err == nil {
|
||||
return common.CodeSuccess, nil
|
||||
}
|
||||
|
||||
code := common.CodeServerError
|
||||
message := "Permission check failed"
|
||||
switch {
|
||||
case errors.Is(err, permission.ErrUnauthenticated):
|
||||
code = common.CodeUnauthorized
|
||||
message = "Authentication required"
|
||||
case errors.Is(err, permission.ErrResourceNotFound):
|
||||
code = common.CodeNotFound
|
||||
message = "Resource not found"
|
||||
case errors.Is(err, permission.ErrPermissionDenied), errors.Is(err, permission.ErrMembershipNotFound):
|
||||
code = common.CodeForbidden
|
||||
message = "Permission denied"
|
||||
}
|
||||
if hideResource && (code == common.CodeForbidden || code == common.CodeNotFound) {
|
||||
code = common.CodeNotFound
|
||||
message = "Resource not found"
|
||||
}
|
||||
|
||||
return code, &normalizedError{cause: err, message: message}
|
||||
}
|
||||
|
||||
type normalizedError struct {
|
||||
cause error
|
||||
message string
|
||||
}
|
||||
|
||||
func (e *normalizedError) Error() string { return e.message }
|
||||
|
||||
func (e *normalizedError) Unwrap() error { return e.cause }
|
||||
@@ -0,0 +1,74 @@
|
||||
package permissionresponse
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/permission"
|
||||
)
|
||||
|
||||
func TestNormalize(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
code common.ErrorCode
|
||||
message string
|
||||
}{
|
||||
{name: "unauthenticated", err: permission.ErrUnauthenticated, code: common.CodeUnauthorized, message: "Authentication required"},
|
||||
{name: "missing resource", err: permission.ErrResourceNotFound, code: common.CodeNotFound, message: "Resource not found"},
|
||||
{name: "denied", err: permission.ErrPermissionDenied, code: common.CodeForbidden, message: "Permission denied"},
|
||||
{name: "missing membership", err: permission.ErrMembershipNotFound, code: common.CodeForbidden, message: "Permission denied"},
|
||||
{name: "internal permission failure", err: errors.New("database credentials must not leak"), code: common.CodeServerError, message: "Permission check failed"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
code, normalizedErr := Normalize(test.err)
|
||||
if code != test.code {
|
||||
t.Fatalf("code = %d, want %d", code, test.code)
|
||||
}
|
||||
if normalizedErr == nil || normalizedErr.Error() != test.message {
|
||||
t.Fatalf("error = %v, want message %q", normalizedErr, test.message)
|
||||
}
|
||||
if !errors.Is(normalizedErr, test.err) {
|
||||
t.Fatalf("normalized error %v does not wrap %v", normalizedErr, test.err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeHidden(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
code common.ErrorCode
|
||||
message string
|
||||
}{
|
||||
{name: "denied resource", err: permission.ErrPermissionDenied, code: common.CodeNotFound, message: "Resource not found"},
|
||||
{name: "missing resource", err: permission.ErrResourceNotFound, code: common.CodeNotFound, message: "Resource not found"},
|
||||
{name: "internal failure remains visible", err: errors.New("db error"), code: common.CodeServerError, message: "Permission check failed"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
code, normalizedErr := NormalizeHidden(test.err)
|
||||
if code != test.code || normalizedErr.Error() != test.message {
|
||||
t.Fatalf("NormalizeHidden() = (%d, %q), want (%d, %q)", code, normalizedErr, test.code, test.message)
|
||||
}
|
||||
if !errors.Is(normalizedErr, test.err) {
|
||||
t.Fatalf("normalized error %v does not wrap %v", normalizedErr, test.err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPermissionError(t *testing.T) {
|
||||
if !IsPermissionError(fmt.Errorf("wrapped: %w", permission.ErrPermissionDenied)) {
|
||||
t.Fatal("IsPermissionError() = false for wrapped permission denial")
|
||||
}
|
||||
if IsPermissionError(errors.New("ordinary failure")) {
|
||||
t.Fatal("IsPermissionError() = true for ordinary failure")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
//
|
||||
// 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 provides shared permission contracts and edition policies.
|
||||
package permission
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnauthenticated = errors.New("permission: user is required")
|
||||
ErrInvalidSubject = errors.New("permission: tenant is required")
|
||||
ErrResourceNotFound = errors.New("permission: resource not found")
|
||||
ErrMembershipNotFound = errors.New("permission: active tenant membership not found")
|
||||
ErrPermissionDenied = errors.New("permission: access denied")
|
||||
ErrInvalidPermission = errors.New("permission: invalid permission request")
|
||||
ErrSourceUnavailable = errors.New("permission: source is not configured")
|
||||
)
|
||||
|
||||
// Subject identifies the authenticated user and, when the policy requires it,
|
||||
// the active tenant context. Community-edition resource checks derive the
|
||||
// effective tenant from the persisted resource, so TenantID may be empty.
|
||||
type Subject struct {
|
||||
UserID string
|
||||
TenantID string
|
||||
}
|
||||
|
||||
// ResourceKind identifies a resource family without coupling this package to business entities.
|
||||
type ResourceKind string
|
||||
|
||||
const (
|
||||
ResourceKindDataset ResourceKind = "dataset"
|
||||
)
|
||||
|
||||
// ResourceRef identifies a persisted resource.
|
||||
type ResourceRef struct {
|
||||
Kind ResourceKind
|
||||
ID string
|
||||
}
|
||||
|
||||
// Feature identifies a feature whose availability is edition-dependent.
|
||||
type Feature string
|
||||
|
||||
// Action identifies a feature-level action.
|
||||
type Action string
|
||||
|
||||
const (
|
||||
ActionEnable Action = "enable"
|
||||
ActionRead Action = "read"
|
||||
ActionWrite Action = "write"
|
||||
ActionShare Action = "share"
|
||||
)
|
||||
|
||||
// Operation identifies an operation on a concrete resource.
|
||||
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"
|
||||
)
|
||||
|
||||
// TenantRequirement describes the tenant role required by a check.
|
||||
type TenantRequirement string
|
||||
|
||||
const (
|
||||
TenantMember TenantRequirement = "member"
|
||||
TenantNormalMember TenantRequirement = "normal_member"
|
||||
TenantAdmin TenantRequirement = "admin"
|
||||
TenantOwner TenantRequirement = "owner"
|
||||
)
|
||||
|
||||
// TenantRole is the normalized role in a user-tenant membership.
|
||||
type TenantRole string
|
||||
|
||||
const (
|
||||
RoleOwner TenantRole = "owner"
|
||||
RoleAdmin TenantRole = "admin"
|
||||
RoleNormal TenantRole = "normal"
|
||||
RoleInvite TenantRole = "invite"
|
||||
)
|
||||
|
||||
// Membership is the normalized active membership fact returned by Source.
|
||||
type Membership struct {
|
||||
ID string
|
||||
UserID string
|
||||
TenantID string
|
||||
Role TenantRole
|
||||
Active bool
|
||||
}
|
||||
|
||||
// Visibility describes how a resource is exposed in the community edition.
|
||||
type Visibility string
|
||||
|
||||
const (
|
||||
VisibilityPrivate Visibility = "private"
|
||||
VisibilityTenant Visibility = "tenant"
|
||||
VisibilityShared Visibility = "shared"
|
||||
)
|
||||
|
||||
// Resource contains normalized authorization and attribution facts loaded from
|
||||
// the persisted business resource. TenantID, CreatedBy, and OwnerUserID are
|
||||
// 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
|
||||
OwnerOperations []Operation
|
||||
SharedWithUserIDs []string
|
||||
SharedWithTenantIDs []string
|
||||
Active bool
|
||||
}
|
||||
|
||||
// AccessSource identifies the rule that granted access.
|
||||
type AccessSource string
|
||||
|
||||
const (
|
||||
AccessSourceOwner AccessSource = "owner"
|
||||
AccessSourceTenant AccessSource = "tenant"
|
||||
AccessSourceShared AccessSource = "shared"
|
||||
)
|
||||
|
||||
// Access describes the operations a subject can perform on a resource.
|
||||
type Access struct {
|
||||
Resource ResourceRef
|
||||
TenantID string
|
||||
Source AccessSource
|
||||
Operations []Operation
|
||||
}
|
||||
|
||||
func (a Access) Allows(operation Operation) bool {
|
||||
for _, allowed := range a.Operations {
|
||||
if allowed == operation {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ScopeMode distinguishes an empty scope from an explicit resource-ID scope.
|
||||
type ScopeMode string
|
||||
|
||||
const (
|
||||
ScopeNone ScopeMode = "none"
|
||||
ScopeIDs ScopeMode = "ids"
|
||||
)
|
||||
|
||||
// Scope is a permission-filtered candidate set for list and retrieval queries.
|
||||
type Scope struct {
|
||||
Mode ScopeMode
|
||||
TenantID string
|
||||
Kind ResourceKind
|
||||
Parent ResourceRef
|
||||
Operation Operation
|
||||
ResourceIDs []string
|
||||
}
|
||||
|
||||
// ScopeQuery describes the candidate resource set and optional execution entry.
|
||||
type ScopeQuery struct {
|
||||
Kind ResourceKind
|
||||
Parent ResourceRef
|
||||
Operation Operation
|
||||
Entry *ResourceRef
|
||||
EntryOp Operation
|
||||
}
|
||||
|
||||
// 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
|
||||
// returns all candidate IDs within the query boundary, including candidates
|
||||
// that policy will reject; Checker authorizes them before returning a scope.
|
||||
type Source interface {
|
||||
GetMembership(ctx context.Context, userID, tenantID string) (*Membership, error)
|
||||
GetResources(ctx context.Context, refs []ResourceRef) ([]Resource, error)
|
||||
ListResourceRefs(ctx context.Context, subject Subject, kind ResourceKind, parent ResourceRef) ([]ResourceRef, error)
|
||||
}
|
||||
|
||||
type policy interface {
|
||||
CheckFeature(context.Context, Subject, Feature, Action) error
|
||||
CheckTenant(context.Context, Subject, string, TenantRequirement) error
|
||||
CheckResource(context.Context, Subject, ResourceRef, Operation) error
|
||||
CheckDependency(context.Context, Subject, ResourceRef, ResourceRef, Operation, Operation) error
|
||||
ResolveAccess(context.Context, Subject, ResourceRef) (Access, error)
|
||||
Scope(context.Context, Subject, ScopeQuery) (Scope, error)
|
||||
FilterResources(context.Context, Subject, []ResourceRef, Operation) ([]ResourceRef, error)
|
||||
}
|
||||
|
||||
// Checker exposes edition-independent permission methods.
|
||||
type Checker struct {
|
||||
policy policy
|
||||
}
|
||||
|
||||
// NewChecker creates a checker backed by normalized membership and resource facts.
|
||||
func NewChecker(source Source) *Checker {
|
||||
if source == nil {
|
||||
return &Checker{}
|
||||
}
|
||||
return &Checker{policy: newPolicy(source)}
|
||||
}
|
||||
|
||||
// CheckFeature checks feature access under the active edition's policy.
|
||||
func (c *Checker) CheckFeature(ctx context.Context, subject Subject, feature Feature, action Action) error {
|
||||
if c == nil || c.policy == nil {
|
||||
return ErrSourceUnavailable
|
||||
}
|
||||
return c.policy.CheckFeature(ctx, subject, feature, action)
|
||||
}
|
||||
|
||||
// CheckTenant checks the user's membership and role in tenantID.
|
||||
func (c *Checker) CheckTenant(ctx context.Context, subject Subject, tenantID string, requirement TenantRequirement) error {
|
||||
if c == nil || c.policy == nil {
|
||||
return ErrSourceUnavailable
|
||||
}
|
||||
return c.policy.CheckTenant(ctx, subject, tenantID, requirement)
|
||||
}
|
||||
|
||||
// CheckResource checks a direct operation on a resource.
|
||||
func (c *Checker) CheckResource(ctx context.Context, subject Subject, resource ResourceRef, operation Operation) error {
|
||||
if c == nil || c.policy == nil {
|
||||
return ErrSourceUnavailable
|
||||
}
|
||||
return c.policy.CheckResource(ctx, subject, resource, operation)
|
||||
}
|
||||
|
||||
// CheckDependency checks entry access and the user's and entry owner's access to a dependency.
|
||||
func (c *Checker) CheckDependency(ctx context.Context, subject Subject, entry, dependency ResourceRef, entryOperation, dependencyOperation Operation) error {
|
||||
if c == nil || c.policy == nil {
|
||||
return ErrSourceUnavailable
|
||||
}
|
||||
return c.policy.CheckDependency(ctx, subject, entry, dependency, entryOperation, dependencyOperation)
|
||||
}
|
||||
|
||||
// ResolveAccess resolves the effective operations and grant source for a resource.
|
||||
func (c *Checker) ResolveAccess(ctx context.Context, subject Subject, resource ResourceRef) (Access, error) {
|
||||
if c == nil || c.policy == nil {
|
||||
return Access{}, ErrSourceUnavailable
|
||||
}
|
||||
return c.policy.ResolveAccess(ctx, subject, resource)
|
||||
}
|
||||
|
||||
// Scope returns the explicitly authorized IDs for a resource query.
|
||||
func (c *Checker) Scope(ctx context.Context, subject Subject, query ScopeQuery) (Scope, error) {
|
||||
if c == nil || c.policy == nil {
|
||||
return Scope{}, ErrSourceUnavailable
|
||||
}
|
||||
return c.policy.Scope(ctx, subject, query)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return nil, ErrSourceUnavailable
|
||||
}
|
||||
return c.policy.FilterResources(ctx, subject, resources, operation)
|
||||
}
|
||||
@@ -23,6 +23,8 @@ import (
|
||||
"fmt"
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/permission"
|
||||
permissionresponse "ragflow/internal/permission/response"
|
||||
"ragflow/internal/utility"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
@@ -381,7 +383,7 @@ func validateCreateChatName(value interface{}) (string, error) {
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func (s *ChatService) validateCreateDatasetIDs(ctx context.Context, value interface{}, tenantID string) ([]string, error) {
|
||||
func (s *ChatService) validateCreateDatasetIDs(ctx context.Context, value interface{}, userID string) ([]string, error) {
|
||||
if value == nil {
|
||||
return []string{}, nil
|
||||
}
|
||||
@@ -401,8 +403,9 @@ func (s *ChatService) validateCreateDatasetIDs(ctx context.Context, value interf
|
||||
}
|
||||
|
||||
for _, datasetID := range normalizedIDs {
|
||||
if !s.kbDAO.Accessible(ctx, dao.DB, datasetID, tenantID) {
|
||||
return nil, fmt.Errorf("you don't own the dataset %s", datasetID)
|
||||
if err := CheckDatasetAccess(ctx, permission.Subject{UserID: userID}, datasetID, permission.OperationUse); err != nil {
|
||||
_, permissionErr := permissionresponse.Normalize(err)
|
||||
return nil, permissionErr
|
||||
}
|
||||
kb, err := s.kbDAO.GetByID(ctx, dao.DB, datasetID)
|
||||
if err != nil {
|
||||
@@ -1055,8 +1058,9 @@ func (s *ChatService) validateRESTDatasetIDs(ctx context.Context, value interfac
|
||||
continue
|
||||
}
|
||||
datasetID := fmt.Sprint(item)
|
||||
if !s.kbDAO.Accessible(ctx, dao.DB, datasetID, userID) {
|
||||
return nil, fmt.Errorf("you don't own the dataset %s", datasetID)
|
||||
if err := CheckDatasetAccess(ctx, permission.Subject{UserID: userID}, datasetID, permission.OperationUse); err != nil {
|
||||
_, permissionErr := permissionresponse.Normalize(err)
|
||||
return nil, permissionErr
|
||||
}
|
||||
kb, err := s.kbDAO.GetByID(ctx, dao.DB, datasetID)
|
||||
if err != nil || kb == nil {
|
||||
|
||||
@@ -29,6 +29,8 @@ import (
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/entity/models"
|
||||
"ragflow/internal/permission"
|
||||
permissionresponse "ragflow/internal/permission/response"
|
||||
"ragflow/internal/service"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -83,7 +85,7 @@ type ChunkService struct {
|
||||
ingestionTaskDAO *dao.IngestionTaskDAO
|
||||
searchService *service.SearchService
|
||||
|
||||
accessibleFunc func(string, string) bool
|
||||
checkDatasetAccessFunc func(string, string) error
|
||||
getKnowledgebaseByIDFunc func(string) (*entity.Knowledgebase, error)
|
||||
getDocumentsByIDsFunc func([]string) ([]*entity.Document, error)
|
||||
// startParseDocumentsFunc overrides the DSL start-parse flow. Production
|
||||
@@ -517,7 +519,7 @@ func (s *ChunkService) Get(ctx context.Context, req *service.GetChunkRequest, us
|
||||
return nil, fmt.Errorf("chunk_id is required")
|
||||
}
|
||||
|
||||
kb, err := s.getAccessibleKnowledgebase(ctx, req.DatasetID, userID)
|
||||
kb, err := s.getAccessibleKnowledgebase(ctx, req.DatasetID, userID, permission.OperationRead)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -590,8 +592,9 @@ func (s *ChunkService) cancelAllTasksOfDoc(ctx context.Context, doc *entity.Docu
|
||||
}
|
||||
|
||||
func (s *ChunkService) StopParsing(ctx context.Context, userID, datasetID string, req service.StopParsingRequest) (*service.StopParsingResponse, common.ErrorCode, error) {
|
||||
if !s.kbDAO.Accessible(ctx, dao.DB, datasetID, userID) {
|
||||
return nil, common.CodeDataError, fmt.Errorf("you don't own the dataset %s", datasetID)
|
||||
if err := service.CheckDatasetAccess(ctx, permission.Subject{UserID: userID}, datasetID, permission.OperationRun); err != nil {
|
||||
code, permissionErr := permissionresponse.Normalize(err)
|
||||
return nil, code, permissionErr
|
||||
}
|
||||
|
||||
if len(req.DocumentIDs) == 0 {
|
||||
@@ -666,11 +669,11 @@ func checkDuplicateIDs(documentIDs []string, idTypes string) ([]string, []string
|
||||
return uniqueDocIDs, duplicateMessages
|
||||
}
|
||||
|
||||
func (s *ChunkService) accessible(ctx context.Context, datasetID, userID string) bool {
|
||||
if s.accessibleFunc != nil {
|
||||
return s.accessibleFunc(datasetID, userID)
|
||||
func (s *ChunkService) checkDatasetAccess(ctx context.Context, datasetID, userID string, operation permission.Operation) error {
|
||||
if s.checkDatasetAccessFunc != nil {
|
||||
return s.checkDatasetAccessFunc(datasetID, userID)
|
||||
}
|
||||
return s.kbDAO.Accessible(ctx, dao.DB, datasetID, userID)
|
||||
return service.CheckDatasetAccess(ctx, permission.Subject{UserID: userID}, datasetID, operation)
|
||||
}
|
||||
|
||||
func (s *ChunkService) getKnowledgebaseByID(ctx context.Context, datasetID string) (*entity.Knowledgebase, error) {
|
||||
@@ -680,9 +683,9 @@ func (s *ChunkService) getKnowledgebaseByID(ctx context.Context, datasetID strin
|
||||
return s.kbDAO.GetByID(ctx, dao.DB, datasetID)
|
||||
}
|
||||
|
||||
func (s *ChunkService) getAccessibleKnowledgebase(ctx context.Context, datasetID, userID string) (*entity.Knowledgebase, error) {
|
||||
if !s.accessible(ctx, datasetID, userID) {
|
||||
return nil, fmt.Errorf("user does not have access to this dataset")
|
||||
func (s *ChunkService) getAccessibleKnowledgebase(ctx context.Context, datasetID, userID string, operation permission.Operation) (*entity.Knowledgebase, error) {
|
||||
if err := s.checkDatasetAccess(ctx, datasetID, userID, operation); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kb, err := s.getKnowledgebaseByID(ctx, datasetID)
|
||||
if err != nil || kb == nil {
|
||||
@@ -699,8 +702,9 @@ func (s *ChunkService) getDocumentsByIDs(ctx context.Context, docIDs []string) (
|
||||
}
|
||||
|
||||
func (s *ChunkService) Parse(ctx context.Context, userID, datasetID string, req *service.ParseFileRequest) (map[string]interface{}, common.ErrorCode, error) {
|
||||
if !s.accessible(ctx, datasetID, userID) {
|
||||
return nil, common.CodeOperatingError, fmt.Errorf("you don't own the dataset %s", datasetID)
|
||||
if err := s.checkDatasetAccess(ctx, datasetID, userID, permission.OperationRun); err != nil {
|
||||
code, permissionErr := permissionresponse.Normalize(err)
|
||||
return nil, code, permissionErr
|
||||
}
|
||||
if req == nil || len(req.DocumentIDs) == 0 {
|
||||
return nil, common.CodeDataError, fmt.Errorf("`document_ids` is required")
|
||||
@@ -786,7 +790,7 @@ func (s *ChunkService) List(ctx context.Context, req *service.ListChunksRequest,
|
||||
return nil, fmt.Errorf("document not found")
|
||||
}
|
||||
|
||||
kb, err := s.getAccessibleKnowledgebase(ctx, doc.KbID, userID)
|
||||
kb, err := s.getAccessibleKnowledgebase(ctx, doc.KbID, userID, permission.OperationRead)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1002,7 +1006,7 @@ func (s *ChunkService) SwitchChunks(ctx context.Context, userID, datasetID, docu
|
||||
return fmt.Errorf("req is null")
|
||||
}
|
||||
|
||||
kb, err := s.getAccessibleKnowledgebase(ctx, datasetID, userID)
|
||||
kb, err := s.getAccessibleKnowledgebase(ctx, datasetID, userID, permission.OperationUpdate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1044,7 +1048,7 @@ func (s *ChunkService) UpdateChunk(ctx context.Context, req *service.UpdateChunk
|
||||
return fmt.Errorf("chunk_id is required")
|
||||
}
|
||||
|
||||
kb, err := s.getAccessibleKnowledgebase(ctx, req.DatasetID, userID)
|
||||
kb, err := s.getAccessibleKnowledgebase(ctx, req.DatasetID, userID, permission.OperationUpdate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1226,7 +1230,7 @@ func (s *ChunkService) RemoveChunks(ctx context.Context, req *service.RemoveChun
|
||||
return 0, fmt.Errorf("document not found")
|
||||
}
|
||||
|
||||
kb, err := s.getAccessibleKnowledgebase(ctx, doc.KbID, userID)
|
||||
kb, err := s.getAccessibleKnowledgebase(ctx, doc.KbID, userID, permission.OperationUpdate)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -1276,8 +1280,9 @@ func (s *ChunkService) AddChunk(ctx context.Context, req *service.AddChunkReques
|
||||
if req == nil {
|
||||
return nil, addChunkError{code: common.CodeDataError, message: "invalid request payload"}
|
||||
}
|
||||
if !s.accessible(ctx, req.DatasetID, userID) {
|
||||
return nil, addChunkError{code: common.CodeDataError, message: fmt.Sprintf("You don't own the dataset %s.", req.DatasetID)}
|
||||
if err := s.checkDatasetAccess(ctx, req.DatasetID, userID, permission.OperationUpdate); err != nil {
|
||||
code, permissionErr := permissionresponse.Normalize(err)
|
||||
return nil, addChunkError{code: code, message: permissionErr.Error(), cause: permissionErr}
|
||||
}
|
||||
|
||||
kb, err := s.getKnowledgebaseByID(ctx, req.DatasetID)
|
||||
@@ -1448,6 +1453,7 @@ func (s *ChunkService) markWikiDirty(ctx context.Context, tenantID, datasetID, d
|
||||
type addChunkError struct {
|
||||
code common.ErrorCode
|
||||
message string
|
||||
cause error
|
||||
}
|
||||
|
||||
type updateChunkError struct {
|
||||
@@ -1467,6 +1473,10 @@ func (e addChunkError) Error() string {
|
||||
return e.message
|
||||
}
|
||||
|
||||
func (e addChunkError) Unwrap() error {
|
||||
return e.cause
|
||||
}
|
||||
|
||||
func (e addChunkError) Code() common.ErrorCode {
|
||||
return e.code
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
permissionresponse "ragflow/internal/permission/response"
|
||||
"ragflow/internal/service"
|
||||
)
|
||||
|
||||
@@ -79,7 +80,7 @@ func TestChunkManagementDatasetPermissions(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("authorized operation failed: %v", err)
|
||||
}
|
||||
} else if err == nil || err.Error() != "user does not have access to this dataset" {
|
||||
} else if !permissionresponse.IsPermissionError(err) {
|
||||
t.Fatalf("unauthorized operation error = %v, want access denied", err)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"ragflow/internal/engine/types"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/entity/models"
|
||||
"ragflow/internal/permission"
|
||||
"ragflow/internal/service"
|
||||
"ragflow/internal/service/document"
|
||||
"ragflow/internal/storage"
|
||||
@@ -208,23 +209,23 @@ func TestParsePrevalidatesDocumentsBeforeMutating(t *testing.T) {
|
||||
|
||||
func TestParseRejectsInaccessibleDataset(t *testing.T) {
|
||||
svc := newParseTestService(t)
|
||||
svc.accessibleFunc = func(string, string) bool { return false }
|
||||
svc.checkDatasetAccessFunc = func(string, string) error { return permission.ErrPermissionDenied }
|
||||
ctx := t.Context()
|
||||
_, code, err := svc.Parse(ctx, "user-1", "kb-1", &service.ParseFileRequest{DocumentIDs: []string{"doc-1"}})
|
||||
if err == nil {
|
||||
t.Fatal("expected parse to fail")
|
||||
}
|
||||
if code != common.CodeOperatingError {
|
||||
t.Fatalf("expected CodeOperatingError, got %v", code)
|
||||
if code != common.CodeForbidden {
|
||||
t.Fatalf("expected CodeForbidden, got %v", code)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "don't own the dataset") {
|
||||
if err.Error() != "Permission denied" {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRequiresDocumentIDs(t *testing.T) {
|
||||
svc := newParseTestService(t)
|
||||
svc.accessibleFunc = func(string, string) bool { return true }
|
||||
svc.checkDatasetAccessFunc = func(string, string) error { return nil }
|
||||
ctx := t.Context()
|
||||
for _, req := range []*service.ParseFileRequest{nil, {DocumentIDs: nil}, {DocumentIDs: []string{}}} {
|
||||
_, code, err := svc.Parse(ctx, "user-1", "kb-1", req)
|
||||
@@ -242,7 +243,7 @@ func TestParseRequiresDocumentIDs(t *testing.T) {
|
||||
|
||||
func TestParseReturnsDataErrorWhenDatasetMissing(t *testing.T) {
|
||||
svc := newParseTestService(t)
|
||||
svc.accessibleFunc = func(string, string) bool { return true }
|
||||
svc.checkDatasetAccessFunc = func(string, string) error { return nil }
|
||||
svc.getKnowledgebaseByIDFunc = func(string) (*entity.Knowledgebase, error) { return nil, nil }
|
||||
ctx := t.Context()
|
||||
_, code, err := svc.Parse(ctx, "user-1", "kb-1", &service.ParseFileRequest{DocumentIDs: []string{"doc-1"}})
|
||||
@@ -260,7 +261,7 @@ func TestParseReturnsDataErrorWhenDatasetMissing(t *testing.T) {
|
||||
func TestParseReturnsServerErrorWhenDocumentsQueryFails(t *testing.T) {
|
||||
svc := newParseTestService(t)
|
||||
queryErr := errors.New("documents query failed")
|
||||
svc.accessibleFunc = func(string, string) bool { return true }
|
||||
svc.checkDatasetAccessFunc = func(string, string) error { return nil }
|
||||
svc.getKnowledgebaseByIDFunc = func(string) (*entity.Knowledgebase, error) {
|
||||
return &entity.Knowledgebase{ID: "kb-1", TenantID: "user-1"}, nil
|
||||
}
|
||||
@@ -1070,8 +1071,11 @@ func TestAddChunkSuccess(t *testing.T) {
|
||||
docEngine: engine,
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
documentDAO: dao.NewDocumentDAO(),
|
||||
accessibleFunc: func(datasetIDArg, userIDArg string) bool {
|
||||
return datasetIDArg == datasetID && userIDArg == userID
|
||||
checkDatasetAccessFunc: func(datasetIDArg, userIDArg string) error {
|
||||
if datasetIDArg == datasetID && userIDArg == userID {
|
||||
return nil
|
||||
}
|
||||
return permission.ErrPermissionDenied
|
||||
},
|
||||
getKnowledgebaseByIDFunc: func(id string) (*entity.Knowledgebase, error) {
|
||||
return &entity.Knowledgebase{ID: id, TenantID: userID, EmbdID: "embed-1"}, nil
|
||||
@@ -1166,8 +1170,11 @@ func TestAddChunkPersistsTagKwdOnlyWhenProvided(t *testing.T) {
|
||||
docEngine: engine,
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
documentDAO: dao.NewDocumentDAO(),
|
||||
accessibleFunc: func(datasetIDArg, userIDArg string) bool {
|
||||
return datasetIDArg == datasetID && userIDArg == userID
|
||||
checkDatasetAccessFunc: func(datasetIDArg, userIDArg string) error {
|
||||
if datasetIDArg != datasetID || userIDArg != userID {
|
||||
return permission.ErrPermissionDenied
|
||||
}
|
||||
return nil
|
||||
},
|
||||
getKnowledgebaseByIDFunc: func(id string) (*entity.Knowledgebase, error) {
|
||||
return &entity.Knowledgebase{ID: id, TenantID: userID, EmbdID: "embed-1"}, nil
|
||||
@@ -1291,7 +1298,7 @@ func TestAddChunkValidationErrors(t *testing.T) {
|
||||
name: "empty content",
|
||||
req: &service.AddChunkRequest{DatasetID: "kb-1", DocumentID: "doc-1", Content: " "},
|
||||
setup: func(svc *ChunkService) {
|
||||
svc.accessibleFunc = func(string, string) bool { return true }
|
||||
svc.checkDatasetAccessFunc = func(string, string) error { return nil }
|
||||
svc.getKnowledgebaseByIDFunc = func(string) (*entity.Knowledgebase, error) {
|
||||
return &entity.Knowledgebase{ID: "kb-1", TenantID: "user-1", EmbdID: "embed-1"}, nil
|
||||
}
|
||||
@@ -1323,10 +1330,10 @@ func TestAddChunkImageAndTagFeatureValidation(t *testing.T) {
|
||||
|
||||
storeCalls := 0
|
||||
svc := &ChunkService{
|
||||
docEngine: &addChunkTestEngine{},
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
documentDAO: dao.NewDocumentDAO(),
|
||||
accessibleFunc: func(string, string) bool { return true },
|
||||
docEngine: &addChunkTestEngine{},
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
documentDAO: dao.NewDocumentDAO(),
|
||||
checkDatasetAccessFunc: func(string, string) error { return nil },
|
||||
getKnowledgebaseByIDFunc: func(id string) (*entity.Knowledgebase, error) {
|
||||
return &entity.Knowledgebase{ID: id, TenantID: userID, EmbdID: "embed-1"}, nil
|
||||
},
|
||||
@@ -1407,10 +1414,10 @@ func TestAddChunkIncrementsStatsAfterInsert(t *testing.T) {
|
||||
var incrementCalls int
|
||||
engine := &addChunkTestEngine{}
|
||||
svc := &ChunkService{
|
||||
docEngine: engine,
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
documentDAO: dao.NewDocumentDAO(),
|
||||
accessibleFunc: func(string, string) bool { return true },
|
||||
docEngine: engine,
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
documentDAO: dao.NewDocumentDAO(),
|
||||
checkDatasetAccessFunc: func(string, string) error { return nil },
|
||||
getKnowledgebaseByIDFunc: func(id string) (*entity.Knowledgebase, error) {
|
||||
return &entity.Knowledgebase{ID: id, TenantID: userID, EmbdID: "embed-1"}, nil
|
||||
},
|
||||
@@ -1852,6 +1859,14 @@ func insertChunkTestUserTenant(t *testing.T, userID, tenantID string) {
|
||||
func insertChunkTestKB(t *testing.T, id, tenantID string) {
|
||||
t.Helper()
|
||||
|
||||
var owner entity.UserTenant
|
||||
err := dao.DB.Where("tenant_id = ? AND role = ? AND status = ?", tenantID, "owner", "1").First(&owner).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
insertChunkTestUserTenant(t, tenantID, tenantID)
|
||||
} else if err != nil {
|
||||
t.Fatalf("find tenant owner: %v", err)
|
||||
}
|
||||
|
||||
status := string(entity.StatusValid)
|
||||
kb := &entity.Knowledgebase{
|
||||
ID: id,
|
||||
@@ -2463,7 +2478,7 @@ func TestChunkServiceParse_RejectsBatchWithRunningIngestionTask(t *testing.T) {
|
||||
insertChunkTestIngestionTask(t, "task-2", "user-1", "doc-2", "kb-1", common.RUNNING)
|
||||
|
||||
svc := newParseTestService(t)
|
||||
svc.accessibleFunc = func(string, string) bool { return true }
|
||||
svc.checkDatasetAccessFunc = func(string, string) error { return nil }
|
||||
ctx := t.Context()
|
||||
_, _, err := svc.Parse(ctx, "user-1", "kb-1", &service.ParseFileRequest{DocumentIDs: []string{"doc-1", "doc-2"}})
|
||||
if err == nil {
|
||||
@@ -2479,7 +2494,7 @@ func TestChunkServiceParse_CallsStartParseDocumentsWithRerunWithDelete(t *testin
|
||||
insertChunkTestDoc(t, "doc-1", "kb-1")
|
||||
|
||||
svc := newParseTestService(t)
|
||||
svc.accessibleFunc = func(string, string) bool { return true }
|
||||
svc.checkDatasetAccessFunc = func(string, string) error { return nil }
|
||||
var calledOpts document.StartParseOptions
|
||||
var calledDocID string
|
||||
svc.startParseDocumentsFunc = func(ctx context.Context, doc *entity.Document, kb *entity.Knowledgebase, userID string, opts document.StartParseOptions) error {
|
||||
|
||||
@@ -41,6 +41,8 @@ import (
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/engine"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/permission"
|
||||
permissionresponse "ragflow/internal/permission/response"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -1088,10 +1090,11 @@ func (s *ConnectorService) RebuildConnector(ctx context.Context, connectorID, us
|
||||
|
||||
// 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 !s.knowledgebaseDAO.Accessible(ctx, dao.DB, kbID, userID) {
|
||||
if err := CheckDatasetAccess(ctx, permission.Subject{UserID: userID}, kbID, permission.OperationRun); err != nil {
|
||||
common.Warn("rebuild denied: kb not accessible",
|
||||
zap.String("connector_id", connectorID), zap.String("kb_id", kbID), zap.String("user_id", userID))
|
||||
return false, common.CodeAuthenticationError, ErrConnectorNoAuth
|
||||
code, permissionErr := permissionresponse.Normalize(err)
|
||||
return false, code, permissionErr
|
||||
}
|
||||
bound, err := s.connectorDAO.Connector2KBExists(ctx, dao.DB, connectorID, kbID)
|
||||
if err != nil {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/permission"
|
||||
syncerconnector "ragflow/internal/syncer/connector"
|
||||
)
|
||||
|
||||
@@ -216,14 +217,22 @@ func TestRebuildConnectorDeletesOldSyncCheckpointsBeforePublishing(t *testing.T)
|
||||
t.Fatalf("insert connector: %v", err)
|
||||
}
|
||||
if err := db.Create(&entity.Knowledgebase{
|
||||
ID: "kb-1",
|
||||
TenantID: "user-1",
|
||||
Name: "kb-1",
|
||||
CreatedBy: "user-1",
|
||||
EmbdID: "embd",
|
||||
ID: "kb-1",
|
||||
TenantID: "user-1",
|
||||
Name: "kb-1",
|
||||
CreatedBy: "user-1",
|
||||
EmbdID: "embd",
|
||||
Permission: string(entity.TenantPermissionMe),
|
||||
Status: sptr(string(entity.StatusValid)),
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("insert kb: %v", err)
|
||||
}
|
||||
if err := db.Create(&entity.UserTenant{
|
||||
ID: "ut-owner", UserID: "user-1", TenantID: "user-1",
|
||||
Role: "owner", Status: sptr(string(entity.StatusValid)),
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("insert tenant owner: %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 mapping: %v", err)
|
||||
}
|
||||
@@ -286,13 +295,19 @@ func TestRebuildConnectorRejectsCrossTenantAndUnboundKB(t *testing.T) {
|
||||
t.Fatalf("insert connector: %v", err)
|
||||
}
|
||||
// kb-1 is owned by the caller but is never bound to conn-1.
|
||||
if err := db.Create(&entity.Knowledgebase{ID: "kb-1", TenantID: "user-1", Name: "kb-1", CreatedBy: "user-1", EmbdID: "embd"}).Error; err != nil {
|
||||
if err := db.Create(&entity.Knowledgebase{ID: "kb-1", TenantID: "user-1", Name: "kb-1", CreatedBy: "user-1", EmbdID: "embd", Permission: string(entity.TenantPermissionMe), Status: sptr(string(entity.StatusValid))}).Error; err != nil {
|
||||
t.Fatalf("insert kb-1: %v", err)
|
||||
}
|
||||
// kb-foreign belongs to another tenant.
|
||||
if err := db.Create(&entity.Knowledgebase{ID: "kb-foreign", TenantID: "user-2", Name: "kb-foreign", CreatedBy: "user-2", EmbdID: "embd"}).Error; err != nil {
|
||||
if err := db.Create(&entity.Knowledgebase{ID: "kb-foreign", TenantID: "user-2", Name: "kb-foreign", CreatedBy: "user-2", EmbdID: "embd", Permission: string(entity.TenantPermissionMe), Status: sptr(string(entity.StatusValid))}).Error; err != nil {
|
||||
t.Fatalf("insert kb-foreign: %v", err)
|
||||
}
|
||||
if err := db.Create([]entity.UserTenant{
|
||||
{ID: "ut-owner-1", UserID: "user-1", TenantID: "user-1", Role: "owner", Status: sptr(string(entity.StatusValid))},
|
||||
{ID: "ut-owner-2", UserID: "user-2", TenantID: "user-2", Role: "owner", Status: sptr(string(entity.StatusValid))},
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("insert tenant owners: %v", err)
|
||||
}
|
||||
|
||||
svc := NewConnectorService()
|
||||
|
||||
@@ -308,11 +323,11 @@ func TestRebuildConnectorRejectsCrossTenantAndUnboundKB(t *testing.T) {
|
||||
|
||||
// The caller cannot access a cross-tenant kb at all: denied up front.
|
||||
ok, code, err = svc.RebuildConnector(t.Context(), "conn-1", "user-1", "kb-foreign")
|
||||
if err == nil || !errors.Is(err, ErrConnectorNoAuth) {
|
||||
t.Fatalf("err = %v, want ErrConnectorNoAuth", err)
|
||||
if err == nil || !errors.Is(err, permission.ErrPermissionDenied) {
|
||||
t.Fatalf("err = %v, want permission denial", err)
|
||||
}
|
||||
if ok || code != common.CodeAuthenticationError {
|
||||
t.Fatalf("ok/code = %v/%v, want false/authentication error", ok, code)
|
||||
if ok || code != common.CodeForbidden {
|
||||
t.Fatalf("ok/code = %v/%v, want false/forbidden", ok, code)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/permission"
|
||||
permissionresponse "ragflow/internal/permission/response"
|
||||
)
|
||||
|
||||
// CompilationStatus is the dataset-level knowledge-compile lifecycle state
|
||||
@@ -41,8 +43,9 @@ func (d *DatasetService) GetDatasetCompilationStatus(ctx context.Context, userID
|
||||
if datasetID == "" {
|
||||
return CompilationStatus{}, common.CodeDataError, errors.New("dataset_id is required")
|
||||
}
|
||||
if !d.kbDAO.Accessible(ctx, dao.DB, datasetID, userID) {
|
||||
return CompilationStatus{}, common.CodeDataError, errors.New("no authorization")
|
||||
if err := d.CheckAccess(ctx, permission.Subject{UserID: userID}, datasetID, permission.OperationRead); err != nil {
|
||||
code, permissionErr := permissionresponse.Normalize(err)
|
||||
return CompilationStatus{}, code, permissionErr
|
||||
}
|
||||
requestedKind := strings.ToLower(strings.TrimSpace(kind))
|
||||
normalizedKind := normalizeCompilationKind(requestedKind)
|
||||
|
||||
@@ -80,6 +80,7 @@ func insertCompilationOwnerKB(t *testing.T, kbID, userID string) {
|
||||
if err := dao.DB.Create(kb).Error; err != nil {
|
||||
t.Fatalf("insert kb: %v", err)
|
||||
}
|
||||
ensureDatasetTestMembership(t, userID, userID, "owner")
|
||||
}
|
||||
|
||||
func testCompilationStatusService() *DatasetService {
|
||||
@@ -189,8 +190,8 @@ func TestGetDatasetCompilationStatus_Unauthorized(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected authorization error, got nil (status=%+v)", st)
|
||||
}
|
||||
if code != common.CodeDataError {
|
||||
t.Fatalf("code=%d want %d", code, common.CodeDataError)
|
||||
if code != common.CodeForbidden {
|
||||
t.Fatalf("code=%d want %d", code, common.CodeForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,9 +9,14 @@ import (
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/permission"
|
||||
"ragflow/internal/service"
|
||||
)
|
||||
|
||||
func testDatasetSubject(id string) permission.Subject {
|
||||
return permission.Subject{UserID: id, TenantID: id}
|
||||
}
|
||||
|
||||
func insertCreateDatasetTenant(t *testing.T, tenantID string) {
|
||||
t.Helper()
|
||||
var existing entity.Tenant
|
||||
@@ -28,6 +33,7 @@ func insertCreateDatasetTenant(t *testing.T, tenantID string) {
|
||||
t.Fatalf("insert test tenant: %v", err)
|
||||
}
|
||||
}
|
||||
ensureDatasetTestMembership(t, tenantID, tenantID, "owner")
|
||||
}
|
||||
|
||||
func testDatasetCreateService(t *testing.T) *DatasetService {
|
||||
@@ -54,7 +60,7 @@ func TestCreateDataset_NoComponentParams(t *testing.T) {
|
||||
Name: "ds-no-cp",
|
||||
ParserID: &chunkMethod,
|
||||
ParseType: &parseType,
|
||||
}, "tenant-1")
|
||||
}, testDatasetSubject("tenant-1"))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDataset failed: %v", err)
|
||||
}
|
||||
@@ -82,7 +88,7 @@ func TestCreateDataset_DefaultsParentChildConfig(t *testing.T) {
|
||||
ParserID: sptr("general"),
|
||||
ParseType: iptr(1),
|
||||
ParserConfig: map[string]interface{}{},
|
||||
}, "tenant-1")
|
||||
}, testDatasetSubject("tenant-1"))
|
||||
if err != nil || code != common.CodeSuccess {
|
||||
t.Fatalf("CreateDataset err=%v code=%d", err, code)
|
||||
}
|
||||
@@ -117,6 +123,20 @@ func findChunkerNodeInTest(t *testing.T, config map[string]interface{}) map[stri
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCreateDataset_RequiresParseTypeWhenParserIDIsProvided(t *testing.T) {
|
||||
db := setupServiceTestDB(t)
|
||||
pushServiceDB(t, db)
|
||||
insertCreateDatasetTenant(t, "tenant-1")
|
||||
|
||||
parserID := "qa"
|
||||
_, code, err := testDatasetCreateService(t).CreateDataset(t.Context(), &service.CreateDatasetRequest{
|
||||
Name: "ds-parser-only",
|
||||
ParserID: &parserID,
|
||||
}, testDatasetSubject("tenant-1"))
|
||||
if err == nil || code != common.CodeDataError || err.Error() != "parse_type is required" {
|
||||
t.Fatalf("CreateDataset = (code=%d, err=%v), want data error for missing parse_type", code, err)
|
||||
}
|
||||
}
|
||||
func TestCreateDataset_ComponentParamsPopulated(t *testing.T) {
|
||||
db := setupServiceTestDB(t)
|
||||
pushServiceDB(t, db)
|
||||
@@ -130,7 +150,7 @@ func TestCreateDataset_ComponentParamsPopulated(t *testing.T) {
|
||||
ParserID: &chunkMethod,
|
||||
ParserConfig: map[string]interface{}{},
|
||||
ParseType: &parseType,
|
||||
}, "tenant-1")
|
||||
}, testDatasetSubject("tenant-1"))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDataset failed: %v", err)
|
||||
}
|
||||
@@ -172,7 +192,7 @@ func TestCreateDataset_ParentChildConfigReachesGeneralChunker(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
}, "tenant-1")
|
||||
}, testDatasetSubject("tenant-1"))
|
||||
if err != nil || code != common.CodeSuccess {
|
||||
t.Fatalf("CreateDataset err=%v code=%d", err, code)
|
||||
}
|
||||
@@ -205,7 +225,7 @@ func TestCreateDataset_PreservesChunkerComponentOverrides(t *testing.T) {
|
||||
"chunk_token_size": float64(256),
|
||||
},
|
||||
},
|
||||
}, "tenant-1")
|
||||
}, testDatasetSubject("tenant-1"))
|
||||
if err != nil || code != common.CodeSuccess {
|
||||
t.Fatalf("CreateDataset err=%v code=%d", err, code)
|
||||
}
|
||||
@@ -235,7 +255,7 @@ func TestCreateDataset_ParseTypeBuiltinHasNoPipelineID(t *testing.T) {
|
||||
Name: "ds-parse-builtin",
|
||||
ParserID: &chunkMethod,
|
||||
ParseType: &parseTypeBuiltin,
|
||||
}, "tenant-1")
|
||||
}, testDatasetSubject("tenant-1"))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDataset failed: %v", err)
|
||||
}
|
||||
@@ -263,7 +283,7 @@ func TestCreateDataset_ParseTypePipelineIgnoresParserID(t *testing.T) {
|
||||
Name: "ds-parse-pipeline",
|
||||
PipelineID: &pipelineID,
|
||||
ParseType: &parseTypePipeline,
|
||||
}, "tenant-1")
|
||||
}, testDatasetSubject("tenant-1"))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDataset failed: %v", err)
|
||||
}
|
||||
@@ -282,7 +302,7 @@ func TestCreateDataset_ValidatesName(t *testing.T) {
|
||||
insertCreateDatasetTenant(t, "tenant-1")
|
||||
ctx := t.Context()
|
||||
|
||||
_, code, err := testDatasetCreateService(t).CreateDataset(ctx, &service.CreateDatasetRequest{Name: " "}, "tenant-1")
|
||||
_, code, err := testDatasetCreateService(t).CreateDataset(ctx, &service.CreateDatasetRequest{Name: " "}, testDatasetSubject("tenant-1"))
|
||||
if err == nil {
|
||||
t.Fatal("expected name validation error")
|
||||
}
|
||||
@@ -316,7 +336,7 @@ func TestCreateDataset_DedupesDuplicateName(t *testing.T) {
|
||||
Name: "Existing",
|
||||
ParserID: sptr("general"),
|
||||
ParseType: iptr(1),
|
||||
}, "tenant-1")
|
||||
}, testDatasetSubject("tenant-1"))
|
||||
if err != nil {
|
||||
t.Fatalf("expected success, got %v", err)
|
||||
}
|
||||
@@ -328,6 +348,48 @@ func TestCreateDataset_DedupesDuplicateName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDataset_UsesActorAsCreatorAndTenantAsOwner(t *testing.T) {
|
||||
db := setupServiceTestDB(t)
|
||||
pushServiceDB(t, db)
|
||||
insertCreateDatasetTenant(t, "tenant-1")
|
||||
ensureDatasetTestMembership(t, "member-1", "tenant-1", "normal")
|
||||
|
||||
parserID := "general"
|
||||
parseType := 1
|
||||
result, code, err := testDatasetCreateService(t).CreateDataset(t.Context(), &service.CreateDatasetRequest{
|
||||
Name: "created-by-member",
|
||||
ParserID: &parserID,
|
||||
ParseType: &parseType,
|
||||
}, permission.Subject{UserID: "member-1", TenantID: "tenant-1"})
|
||||
if err != nil || code != common.CodeSuccess {
|
||||
t.Fatalf("CreateDataset err=%v code=%d", err, code)
|
||||
}
|
||||
|
||||
var dataset entity.Knowledgebase
|
||||
if err := db.Where("id = ?", result["id"]).Take(&dataset).Error; err != nil {
|
||||
t.Fatalf("load created dataset: %v", err)
|
||||
}
|
||||
if dataset.TenantID != "tenant-1" {
|
||||
t.Fatalf("tenant_id = %q, want tenant-1", dataset.TenantID)
|
||||
}
|
||||
if dataset.CreatedBy != "member-1" {
|
||||
t.Fatalf("created_by = %q, want member-1", dataset.CreatedBy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDatasetRejectsUserWithoutTenantMembership(t *testing.T) {
|
||||
db := setupServiceTestDB(t)
|
||||
pushServiceDB(t, db)
|
||||
insertCreateDatasetTenant(t, "tenant-1")
|
||||
|
||||
_, code, err := testDatasetCreateService(t).CreateDataset(t.Context(), &service.CreateDatasetRequest{
|
||||
Name: "unauthorized-dataset",
|
||||
}, permission.Subject{UserID: "outsider", TenantID: "tenant-1"})
|
||||
if err == nil || code != common.CodeForbidden || err.Error() != "Permission denied" {
|
||||
t.Fatalf("CreateDataset = (code=%d, err=%v), want forbidden for missing tenant membership", code, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDataset_RejectsInvalidEmbeddingModel(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -355,7 +417,7 @@ func TestCreateDataset_RejectsInvalidEmbeddingModel(t *testing.T) {
|
||||
ParserID: sptr("general"),
|
||||
ParseType: iptr(1),
|
||||
EmbeddingModel: &tc.embeddingModel,
|
||||
}, "tenant-1")
|
||||
}, testDatasetSubject("tenant-1"))
|
||||
if err == nil {
|
||||
t.Fatal("expected embedding model validation error")
|
||||
}
|
||||
@@ -381,7 +443,7 @@ func TestCreateDataset_SetsExplicitLanguage(t *testing.T) {
|
||||
Language: &language,
|
||||
ParserID: sptr("general"),
|
||||
ParseType: iptr(1),
|
||||
}, "tenant-1")
|
||||
}, testDatasetSubject("tenant-1"))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDataset failed: %v", err)
|
||||
}
|
||||
@@ -405,7 +467,7 @@ func TestCreateDataset_OmittedLanguageKeepsDefault(t *testing.T) {
|
||||
Name: "ds-no-language",
|
||||
ParserID: sptr("general"),
|
||||
ParseType: iptr(1),
|
||||
}, "tenant-1")
|
||||
}, testDatasetSubject("tenant-1"))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDataset failed: %v", err)
|
||||
}
|
||||
@@ -437,7 +499,7 @@ func TestCreateDataset_RejectsBlankLanguage(t *testing.T) {
|
||||
Language: &language,
|
||||
ParserID: sptr("general"),
|
||||
ParseType: iptr(1),
|
||||
}, "tenant-1")
|
||||
}, testDatasetSubject("tenant-1"))
|
||||
if err == nil {
|
||||
t.Fatal("expected language validation error")
|
||||
}
|
||||
@@ -467,7 +529,7 @@ func TestCreateDataset_LanguageLimitCountsCharacters(t *testing.T) {
|
||||
Language: &atLimit,
|
||||
ParserID: sptr("general"),
|
||||
ParseType: iptr(1),
|
||||
}, "tenant-1")
|
||||
}, testDatasetSubject("tenant-1"))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDataset failed for a %d-character language: %v", datasetLanguageLimit, err)
|
||||
}
|
||||
@@ -483,7 +545,7 @@ func TestCreateDataset_LanguageLimitCountsCharacters(t *testing.T) {
|
||||
Language: &overLimit,
|
||||
ParserID: sptr("general"),
|
||||
ParseType: iptr(1),
|
||||
}, "tenant-1")
|
||||
}, testDatasetSubject("tenant-1"))
|
||||
if err == nil {
|
||||
t.Fatal("expected language length validation error")
|
||||
}
|
||||
|
||||
+156
-121
@@ -11,6 +11,8 @@ import (
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
pipelinepkg "ragflow/internal/ingestion/pipeline"
|
||||
"ragflow/internal/permission"
|
||||
permissionresponse "ragflow/internal/permission/response"
|
||||
"ragflow/internal/service"
|
||||
"ragflow/internal/storage"
|
||||
"ragflow/internal/utility"
|
||||
@@ -19,7 +21,12 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (d *DatasetService) CreateDataset(ctx context.Context, req *service.CreateDatasetRequest, tenantID string) (map[string]interface{}, common.ErrorCode, error) {
|
||||
func (d *DatasetService) CreateDataset(ctx context.Context, req *service.CreateDatasetRequest, subject permission.Subject) (map[string]interface{}, common.ErrorCode, error) {
|
||||
tenantID := strings.TrimSpace(subject.TenantID)
|
||||
userID := strings.TrimSpace(subject.UserID)
|
||||
if tenantID == "" || userID == "" {
|
||||
return nil, common.CodeUnauthorized, errors.New("user id and tenant id are required")
|
||||
}
|
||||
if !common.IsValidString(req.Name) {
|
||||
return nil, common.CodeDataError, errors.New("dataset name must be string")
|
||||
}
|
||||
@@ -36,6 +43,10 @@ func (d *DatasetService) CreateDataset(ctx context.Context, req *service.CreateD
|
||||
if err != nil || tenant == nil {
|
||||
return nil, common.CodeDataError, errors.New("tenant not found")
|
||||
}
|
||||
if err := permission.NewDatabaseChecker(dao.DB).CheckTenant(ctx, subject, tenantID, permission.TenantMember); err != nil {
|
||||
code, permissionErr := permissionresponse.Normalize(err)
|
||||
return nil, code, permissionErr
|
||||
}
|
||||
|
||||
// parse_type is required on dataset creation: it explicitly selects BuiltIn
|
||||
// (1) or Pipeline (2) mode. This replaces the previous silent default to
|
||||
@@ -101,7 +112,7 @@ func (d *DatasetService) CreateDataset(ctx context.Context, req *service.CreateD
|
||||
}
|
||||
|
||||
if pipelineID != nil && strings.TrimSpace(*pipelineID) != "" {
|
||||
if ok, err := canvasAccessibleForUser(ctx, tenantID, strings.TrimSpace(*pipelineID)); err != nil {
|
||||
if ok, err := canvasAccessibleForUser(ctx, userID, strings.TrimSpace(*pipelineID)); err != nil {
|
||||
return nil, common.CodeServerError, err
|
||||
} else if !ok {
|
||||
return nil, common.CodeDataError, errors.New("canvas is not accessible")
|
||||
@@ -171,7 +182,7 @@ func (d *DatasetService) CreateDataset(ctx context.Context, req *service.CreateD
|
||||
embdID := tenant.EmbdID
|
||||
tenantEmbdID := ptrStringValue(tenant.TenantEmbdID)
|
||||
if embeddingModel != "" {
|
||||
ok, message := d.verifyEmbeddingAvailability(ctx, embeddingModel, tenantID)
|
||||
ok, message := d.verifyEmbeddingAvailability(ctx, embeddingModel, service.ModelAccess{UserID: userID, TenantID: tenantID})
|
||||
if !ok {
|
||||
return nil, common.CodeDataError, errors.New(message)
|
||||
}
|
||||
@@ -179,7 +190,7 @@ func (d *DatasetService) CreateDataset(ctx context.Context, req *service.CreateD
|
||||
tenantEmbdID = ""
|
||||
}
|
||||
if embdID != "" && tenantEmbdID == "" {
|
||||
target, err := service.NewModelFactory().ResolveInfo(ctx, service.ModelAccess{TenantID: tenantID}, entity.ModelTypeEmbedding, embdID)
|
||||
target, err := service.NewModelFactory().ResolveInfo(ctx, service.ModelAccess{UserID: userID, TenantID: tenantID}, entity.ModelTypeEmbedding, embdID)
|
||||
if err == nil {
|
||||
tenantEmbdID = target.ID
|
||||
} else {
|
||||
@@ -205,7 +216,7 @@ func (d *DatasetService) CreateDataset(ctx context.Context, req *service.CreateD
|
||||
ID: kbID,
|
||||
Name: name,
|
||||
TenantID: tenantID,
|
||||
CreatedBy: tenantID,
|
||||
CreatedBy: userID,
|
||||
ParserID: parserID,
|
||||
PipelineID: pipelineID,
|
||||
ParserConfig: entity.JSONMap(parserConfigMap),
|
||||
@@ -253,17 +264,20 @@ func (d *DatasetService) GetDataset(ctx context.Context, datasetID, userID strin
|
||||
// or malformed id simply fails the permission check.
|
||||
normalizedID, err := normalizeDatasetID(datasetID)
|
||||
if err != nil {
|
||||
return nil, common.CodeDataError, fmt.Errorf("user '%s' lacks permission for dataset '%s'", userID, datasetID)
|
||||
code, permissionErr := permissionresponse.NormalizeHidden(permission.ErrResourceNotFound)
|
||||
return nil, code, permissionErr
|
||||
}
|
||||
datasetID = normalizedID
|
||||
|
||||
if !d.kbDAO.Accessible(ctx, dao.DB, datasetID, userID) {
|
||||
return nil, common.CodeDataError, fmt.Errorf("user '%s' lacks permission for dataset '%s'", userID, datasetID)
|
||||
if err := d.CheckAccess(ctx, permission.Subject{UserID: userID}, datasetID, permission.OperationRead); err != nil {
|
||||
code, permissionErr := permissionresponse.NormalizeHidden(err)
|
||||
return nil, code, permissionErr
|
||||
}
|
||||
|
||||
kb, err := d.kbDAO.GetByID(ctx, dao.DB, datasetID)
|
||||
if err != nil || kb == nil {
|
||||
return nil, common.CodeDataError, errors.New("invalid Dataset ID")
|
||||
code, permissionErr := permissionresponse.NormalizeHidden(permission.ErrResourceNotFound)
|
||||
return nil, code, permissionErr
|
||||
}
|
||||
|
||||
data := datasetToMap(kb)
|
||||
@@ -283,7 +297,13 @@ func (d *DatasetService) GetDataset(ctx context.Context, datasetID, userID strin
|
||||
return data, common.CodeSuccess, nil
|
||||
}
|
||||
|
||||
func (d *DatasetService) DeleteDatasets(ctx context.Context, ids []string, deleteAll bool, tenantID string) (map[string]interface{}, common.ErrorCode, error) {
|
||||
func (d *DatasetService) DeleteDatasets(ctx context.Context, ids []string, deleteAll bool, subject permission.Subject) (map[string]interface{}, common.ErrorCode, error) {
|
||||
subject.UserID = strings.TrimSpace(subject.UserID)
|
||||
subject.TenantID = strings.TrimSpace(subject.TenantID)
|
||||
if subject.UserID == "" {
|
||||
return nil, common.CodeUnauthorized, errors.New("user id is required")
|
||||
}
|
||||
|
||||
normalizedIDs := make([]string, 0, len(ids))
|
||||
seenIDs := make(map[string]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
@@ -298,12 +318,20 @@ func (d *DatasetService) DeleteDatasets(ctx context.Context, ids []string, delet
|
||||
normalizedIDs = append(normalizedIDs, normalizedID)
|
||||
}
|
||||
|
||||
// If no explicit ids and deleteAll is set, resolve all datasets for this tenant.
|
||||
checker := permission.NewDatabaseChecker(dao.DB)
|
||||
// Delete-all is tenant-scoped and remains an owner-only operation.
|
||||
if len(normalizedIDs) == 0 {
|
||||
if !deleteAll {
|
||||
return map[string]interface{}{"deleted": []string{}}, common.CodeSuccess, nil
|
||||
}
|
||||
kbs, err := d.kbDAO.Query(ctx, dao.DB, map[string]interface{}{"tenant_id": tenantID})
|
||||
if subject.TenantID == "" {
|
||||
return nil, common.CodeUnauthorized, errors.New("tenant id is required to delete all datasets")
|
||||
}
|
||||
if err := checker.CheckTenant(ctx, subject, subject.TenantID, permission.TenantOwner); err != nil {
|
||||
code, permissionErr := permissionresponse.Normalize(err)
|
||||
return nil, code, permissionErr
|
||||
}
|
||||
kbs, err := d.kbDAO.Query(ctx, dao.DB, map[string]interface{}{"tenant_id": subject.TenantID})
|
||||
if err != nil {
|
||||
return nil, common.CodeServerError, errors.New("database operation failed")
|
||||
}
|
||||
@@ -312,11 +340,28 @@ func (d *DatasetService) DeleteDatasets(ctx context.Context, ids []string, delet
|
||||
}
|
||||
}
|
||||
|
||||
// Validate ownership: collect KBs that exist and belong to this tenant.
|
||||
refs := make([]permission.ResourceRef, 0, len(normalizedIDs))
|
||||
for _, id := range normalizedIDs {
|
||||
refs = append(refs, permission.ResourceRef{Kind: permission.ResourceKindDataset, ID: id})
|
||||
}
|
||||
accessible, err := checker.FilterResources(ctx, subject, refs, permission.OperationDelete)
|
||||
if err != nil {
|
||||
code, permissionErr := permissionresponse.Normalize(err)
|
||||
return nil, code, permissionErr
|
||||
}
|
||||
accessibleIDs := make(map[string]struct{}, len(accessible))
|
||||
for _, ref := range accessible {
|
||||
accessibleIDs[ref.ID] = struct{}{}
|
||||
}
|
||||
|
||||
kbs := make([]*entity.Knowledgebase, 0, len(normalizedIDs))
|
||||
unauthorizedIDs := make([]string, 0)
|
||||
for _, id := range normalizedIDs {
|
||||
kb, err := d.kbDAO.GetByIDAndTenantID(ctx, dao.DB, id, tenantID)
|
||||
if _, ok := accessibleIDs[id]; !ok {
|
||||
unauthorizedIDs = append(unauthorizedIDs, id)
|
||||
continue
|
||||
}
|
||||
kb, err := d.kbDAO.GetByID(ctx, dao.DB, id)
|
||||
if err != nil || kb == nil {
|
||||
unauthorizedIDs = append(unauthorizedIDs, id)
|
||||
continue
|
||||
@@ -324,13 +369,13 @@ func (d *DatasetService) DeleteDatasets(ctx context.Context, ids []string, delet
|
||||
kbs = append(kbs, kb)
|
||||
}
|
||||
if len(unauthorizedIDs) > 0 {
|
||||
return nil, common.CodeDataError,
|
||||
fmt.Errorf("user '%s' lacks permission for datasets: '%s'", tenantID, strings.Join(unauthorizedIDs, ", "))
|
||||
_, permissionErr := permissionresponse.Normalize(permission.ErrPermissionDenied)
|
||||
return nil, common.CodeForbidden, permissionErr
|
||||
}
|
||||
|
||||
successCount := 0
|
||||
for _, kb := range kbs {
|
||||
if err := d.deleteDataset(ctx, tenantID, kb); err != nil {
|
||||
if err := d.deleteDataset(ctx, kb); err != nil {
|
||||
common.Warn("deleteDataset failed", zap.String("dataset", datasetNameAndID(kb)), zap.String("kb_id", kb.ID), zap.Error(err))
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
@@ -343,7 +388,7 @@ func (d *DatasetService) DeleteDatasets(ctx context.Context, ids []string, delet
|
||||
}, common.CodeSuccess, nil
|
||||
}
|
||||
|
||||
func (d *DatasetService) deleteDataset(ctx context.Context, tenantID string, kb *entity.Knowledgebase) error {
|
||||
func (d *DatasetService) deleteDataset(ctx context.Context, kb *entity.Knowledgebase) error {
|
||||
storageImpl := storage.GetStorageFactory().GetStorage()
|
||||
if storageImpl == nil {
|
||||
return fmt.Errorf("storage not initialized")
|
||||
@@ -427,7 +472,7 @@ func (d *DatasetService) deleteDataset(ctx context.Context, tenantID string, kb
|
||||
// Delete the KB folder file record.
|
||||
if err := tx.Unscoped().
|
||||
Where("source_type = ? AND type = ? AND name = ? AND tenant_id = ?",
|
||||
string(entity.FileSourceKnowledgebase), "folder", kb.Name, tenantID).
|
||||
string(entity.FileSourceKnowledgebase), "folder", kb.Name, kb.TenantID).
|
||||
Delete(&entity.File{}).Error; err != nil {
|
||||
return fmt.Errorf("delete dataset error for %s", kb.ID)
|
||||
}
|
||||
@@ -468,26 +513,9 @@ func (d *DatasetService) ListDatasets(ctx context.Context, id, name string, page
|
||||
return nil, 0, common.CodeArgumentError, err
|
||||
}
|
||||
id = normalizedID
|
||||
|
||||
kbs, err := d.kbDAO.GetKBByIDAndUserID(ctx, dao.DB, id, userID)
|
||||
if err != nil {
|
||||
return nil, 0, common.CodeServerError, errors.New("database operation failed")
|
||||
}
|
||||
if len(kbs) == 0 {
|
||||
return nil, 0, common.CodeDataError, fmt.Errorf("user '%s' lacks permission for dataset '%s'", userID, id)
|
||||
}
|
||||
}
|
||||
|
||||
name = strings.TrimSpace(name)
|
||||
if name != "" {
|
||||
kbs, err := d.kbDAO.GetKBByNameAndUserID(ctx, dao.DB, name, userID)
|
||||
if err != nil {
|
||||
return nil, 0, common.CodeServerError, errors.New("database operation failed")
|
||||
}
|
||||
if len(kbs) == 0 {
|
||||
return nil, 0, common.CodeDataError, fmt.Errorf("user '%s' lacks permission for dataset '%s'", userID, name)
|
||||
}
|
||||
}
|
||||
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
@@ -501,90 +529,92 @@ func (d *DatasetService) ListDatasets(ctx context.Context, id, name string, page
|
||||
keywords = strings.TrimSpace(keywords)
|
||||
parserID = strings.TrimSpace(parserID)
|
||||
|
||||
tenantIDs := make([]string, 0, len(ownerIDs))
|
||||
filteredOwnerIDs := make([]string, 0, len(ownerIDs))
|
||||
seenOwnerIDs := make(map[string]struct{}, len(ownerIDs))
|
||||
for _, ownerID := range ownerIDs {
|
||||
ownerID = strings.TrimSpace(ownerID)
|
||||
if ownerID != "" {
|
||||
tenantIDs = append(tenantIDs, ownerID)
|
||||
if ownerID == "" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
queryUserID := userID
|
||||
var joinedTenantIDs []string
|
||||
if len(tenantIDs) > 0 {
|
||||
joinedTenants, err := d.tenantDAO.GetJoinedTenantsByUserID(ctx, dao.DB, userID)
|
||||
if err != nil {
|
||||
return nil, 0, common.CodeServerError, errors.New("database operation failed")
|
||||
}
|
||||
allowedTenantIDs := map[string]struct{}{userID: {}}
|
||||
for _, joinedTenant := range joinedTenants {
|
||||
if joinedTenant == nil || joinedTenant.TenantID == "" {
|
||||
continue
|
||||
}
|
||||
allowedTenantIDs[joinedTenant.TenantID] = struct{}{}
|
||||
joinedTenantIDs = append(joinedTenantIDs, joinedTenant.TenantID)
|
||||
}
|
||||
filteredTenantIDs := tenantIDs[:0]
|
||||
queryUserID = ""
|
||||
for _, tenantID := range tenantIDs {
|
||||
if _, ok := allowedTenantIDs[tenantID]; !ok {
|
||||
continue
|
||||
}
|
||||
filteredTenantIDs = append(filteredTenantIDs, tenantID)
|
||||
if tenantID == userID {
|
||||
queryUserID = userID
|
||||
}
|
||||
}
|
||||
tenantIDs = filteredTenantIDs
|
||||
} else {
|
||||
joinedTenants, err := d.tenantDAO.GetJoinedTenantsByUserID(ctx, dao.DB, userID)
|
||||
if err != nil {
|
||||
return nil, 0, common.CodeServerError, errors.New("database operation failed")
|
||||
}
|
||||
for _, joinedTenant := range joinedTenants {
|
||||
if joinedTenant == nil || joinedTenant.TenantID == "" {
|
||||
continue
|
||||
}
|
||||
tenantIDs = append(tenantIDs, joinedTenant.TenantID)
|
||||
joinedTenantIDs = append(joinedTenantIDs, joinedTenant.TenantID)
|
||||
if _, exists := seenOwnerIDs[ownerID]; exists {
|
||||
continue
|
||||
}
|
||||
seenOwnerIDs[ownerID] = struct{}{}
|
||||
filteredOwnerIDs = append(filteredOwnerIDs, ownerID)
|
||||
}
|
||||
|
||||
// Mirror Python: ids are checked for accessibility against the joined
|
||||
// tenants (not the owner-filtered tenant list) before filtering.
|
||||
if len(ids) > 0 {
|
||||
accessibleIDs, err := d.kbDAO.GetAccessibleIDs(ctx, dao.DB, joinedTenantIDs, userID, ids)
|
||||
if err != nil {
|
||||
return nil, 0, common.CodeServerError, errors.New("database operation failed")
|
||||
}
|
||||
accessible := make(map[string]struct{}, len(accessibleIDs))
|
||||
for _, accessibleID := range accessibleIDs {
|
||||
accessible[accessibleID] = struct{}{}
|
||||
}
|
||||
filteredIDs := make([]string, 0, len(ids))
|
||||
deniedIDs := make([]string, 0, len(ids))
|
||||
for _, datasetID := range ids {
|
||||
if _, ok := accessible[datasetID]; ok {
|
||||
filteredIDs = append(filteredIDs, datasetID)
|
||||
} else {
|
||||
deniedIDs = append(deniedIDs, datasetID)
|
||||
var kbs []*entity.KnowledgebaseListItem
|
||||
var total int64
|
||||
var deniedIDs []string
|
||||
err := dao.DB.Transaction(func(tx *gorm.DB) error {
|
||||
checker := permission.NewDatabaseChecker(tx)
|
||||
subject := permission.Subject{UserID: userID}
|
||||
var resourceIDs []string
|
||||
|
||||
switch {
|
||||
case id != "":
|
||||
checkErr := checker.CheckResource(ctx, subject, permission.ResourceRef{Kind: permission.ResourceKindDataset, ID: id}, permission.OperationRead)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
resourceIDs = []string{id}
|
||||
case len(ids) > 0:
|
||||
refs := make([]permission.ResourceRef, 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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
default:
|
||||
scope, checkErr := checker.Scope(ctx, subject, permission.ScopeQuery{
|
||||
Kind: permission.ResourceKindDataset, Operation: permission.OperationRead,
|
||||
})
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
if scope.Mode == permission.ScopeIDs {
|
||||
resourceIDs = scope.ResourceIDs
|
||||
}
|
||||
}
|
||||
if len(deniedIDs) > 0 {
|
||||
common.Warn("User lacks permission for datasets",
|
||||
zap.String("user_id", userID),
|
||||
zap.Strings("dataset_ids", deniedIDs),
|
||||
)
|
||||
}
|
||||
ids = filteredIDs
|
||||
if len(ids) == 0 {
|
||||
return []map[string]interface{}{}, 0, common.CodeSuccess, nil
|
||||
}
|
||||
}
|
||||
|
||||
kbs, total, err := d.kbDAO.GetByTenantIDs(ctx, dao.DB, tenantIDs, queryUserID, page, pageSize, terms, keywords, parserID, id, name, ids)
|
||||
if len(resourceIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
listed, count, err := d.kbDAO.ListByResourceIDs(ctx, tx, resourceIDs, filteredOwnerIDs, page, pageSize, terms, keywords, parserID, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
kbs, total = listed, count
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, permission.ErrUnauthenticated) || errors.Is(err, permission.ErrPermissionDenied) ||
|
||||
errors.Is(err, permission.ErrMembershipNotFound) || errors.Is(err, permission.ErrResourceNotFound) {
|
||||
code, permissionErr := permissionresponse.Normalize(err)
|
||||
return nil, 0, code, permissionErr
|
||||
}
|
||||
return nil, 0, common.CodeServerError, errors.New("database operation failed")
|
||||
}
|
||||
if len(deniedIDs) > 0 {
|
||||
common.Warn("User lacks permission for datasets",
|
||||
zap.String("user_id", userID),
|
||||
zap.Strings("dataset_ids", common.Deduplicate(deniedIDs)),
|
||||
)
|
||||
}
|
||||
|
||||
data := make([]map[string]interface{}, 0, len(kbs))
|
||||
modelNameCache := make(map[string]string)
|
||||
@@ -607,21 +637,26 @@ func (d *DatasetService) ListDatasets(ctx context.Context, id, name string, page
|
||||
}
|
||||
|
||||
func (d *DatasetService) ListDatasetFilters(ctx context.Context, userID string) (map[string]interface{}, common.ErrorCode, error) {
|
||||
joinedTenants, err := d.tenantDAO.GetJoinedTenantsByUserID(ctx, dao.DB, userID)
|
||||
if err != nil {
|
||||
return nil, common.CodeServerError, errors.New("database operation failed")
|
||||
}
|
||||
|
||||
tenantIDs := make([]string, 0, len(joinedTenants))
|
||||
for _, joinedTenant := range joinedTenants {
|
||||
if joinedTenant == nil || joinedTenant.TenantID == "" {
|
||||
continue
|
||||
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,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tenantIDs = append(tenantIDs, joinedTenant.TenantID)
|
||||
}
|
||||
|
||||
owners, err := d.kbDAO.GetOwnerFilter(ctx, dao.DB, tenantIDs, userID)
|
||||
if scope.Mode == permission.ScopeNone {
|
||||
return nil
|
||||
}
|
||||
owners, err = d.kbDAO.GetOwnerFilterByResourceIDs(ctx, tx, scope.ResourceIDs)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, permission.ErrUnauthenticated) || errors.Is(err, permission.ErrPermissionDenied) ||
|
||||
errors.Is(err, permission.ErrMembershipNotFound) || errors.Is(err, permission.ErrResourceNotFound) {
|
||||
code, permissionErr := permissionresponse.Normalize(err)
|
||||
return nil, code, permissionErr
|
||||
}
|
||||
return nil, common.CodeServerError, errors.New("database operation failed")
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ func TestDeleteDatasetRemovesTemporaryFiles(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := testDatasetUpdateService(t)
|
||||
if err := service.deleteDataset(t.Context(), "tenant-1", kb); err != nil {
|
||||
if err := service.deleteDataset(t.Context(), kb); err != nil {
|
||||
t.Fatalf("delete dataset with temporary file: %v", err)
|
||||
}
|
||||
if store.ObjExist(t.Context(), "kb-1", "temporary") {
|
||||
@@ -96,7 +96,7 @@ func TestDeleteDatasetContinuesWhenStorageIsMissing(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := testDatasetUpdateService(t).deleteDataset(t.Context(), "tenant-1", kb); err != nil {
|
||||
if err := testDatasetUpdateService(t).deleteDataset(t.Context(), kb); err != nil {
|
||||
t.Fatalf("delete dataset with missing storage: %v", err)
|
||||
}
|
||||
var count int64
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/permission"
|
||||
permissionresponse "ragflow/internal/permission/response"
|
||||
"ragflow/internal/service"
|
||||
|
||||
enginetypes "ragflow/internal/engine/types"
|
||||
@@ -39,8 +41,9 @@ func (d *DatasetService) CheckEmbedding(ctx context.Context, userID, datasetID s
|
||||
if datasetID == "" {
|
||||
return nil, common.CodeDataError, errors.New(`lack of "Dataset ID"`)
|
||||
}
|
||||
if !d.kbDAO.Accessible(ctx, dao.DB, datasetID, userID) {
|
||||
return nil, common.CodeDataError, errors.New("no authorization")
|
||||
if err := service.CheckDatasetAccess(ctx, permission.Subject{UserID: userID}, datasetID, permission.OperationUse); err != nil {
|
||||
code, permissionErr := permissionresponse.Normalize(err)
|
||||
return nil, code, permissionErr
|
||||
}
|
||||
|
||||
kb, err := d.kbDAO.GetByID(ctx, dao.DB, datasetID)
|
||||
@@ -278,8 +281,8 @@ func (d *DatasetService) sampleRandomChunksWithVectors(ctx context.Context, tena
|
||||
return samples, nil
|
||||
}
|
||||
|
||||
func (d *DatasetService) verifyEmbeddingAvailability(ctx context.Context, embdID string, tenantID string) (bool, string) {
|
||||
_, err := service.NewModelFactory().ResolveInfo(ctx, service.ModelAccess{TenantID: tenantID}, entity.ModelTypeEmbedding, embdID)
|
||||
func (d *DatasetService) verifyEmbeddingAvailability(ctx context.Context, embdID string, access service.ModelAccess) (bool, string) {
|
||||
_, err := service.NewModelFactory().ResolveInfo(ctx, access, entity.ModelTypeEmbedding, embdID)
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/permission"
|
||||
permissionresponse "ragflow/internal/permission/response"
|
||||
"ragflow/internal/service"
|
||||
syncerconnector "ragflow/internal/syncer/connector"
|
||||
|
||||
@@ -46,8 +48,9 @@ func (d *DatasetService) GetIngestionSummary(ctx context.Context, datasetID, use
|
||||
if datasetID == "" {
|
||||
return nil, common.CodeDataError, errors.New(`lack of "Dataset ID"`)
|
||||
}
|
||||
if !d.kbDAO.Accessible(ctx, dao.DB, datasetID, userID) {
|
||||
return nil, common.CodeDataError, errors.New("no authorization")
|
||||
if err := d.CheckAccess(ctx, permission.Subject{UserID: userID}, datasetID, permission.OperationRead); err != nil {
|
||||
code, permissionErr := permissionresponse.Normalize(err)
|
||||
return nil, code, permissionErr
|
||||
}
|
||||
|
||||
kb, err := d.kbDAO.GetByID(ctx, dao.DB, datasetID)
|
||||
@@ -168,8 +171,9 @@ func (d *DatasetService) ListIngestionMessages(ctx context.Context, datasetID, u
|
||||
if limit < 0 || limit > maxIngestionMessagesLimit {
|
||||
return nil, common.CodeArgumentError, fmt.Errorf("limit must be between 1 and %d", maxIngestionMessagesLimit)
|
||||
}
|
||||
if !d.kbDAO.Accessible(ctx, dao.DB, datasetID, userID) {
|
||||
return nil, common.CodeDataError, errors.New("no authorization")
|
||||
if err := d.CheckAccess(ctx, permission.Subject{UserID: userID}, datasetID, permission.OperationRead); err != nil {
|
||||
code, permissionErr := permissionresponse.Normalize(err)
|
||||
return nil, code, permissionErr
|
||||
}
|
||||
|
||||
run, err := d.pipelineLogDAO.GetByIDAndKBID(ctx, dao.DB, logID, datasetID)
|
||||
@@ -228,8 +232,9 @@ func (d *DatasetService) ListIngestionLogs(ctx context.Context, datasetID, userI
|
||||
if datasetID == "" {
|
||||
return nil, common.CodeDataError, errors.New(`lack of "Dataset ID"`)
|
||||
}
|
||||
if !d.kbDAO.Accessible(ctx, dao.DB, datasetID, userID) {
|
||||
return nil, common.CodeDataError, errors.New("no authorization")
|
||||
if err := d.CheckAccess(ctx, permission.Subject{UserID: userID}, datasetID, permission.OperationRead); err != nil {
|
||||
code, permissionErr := permissionresponse.Normalize(err)
|
||||
return nil, code, permissionErr
|
||||
}
|
||||
|
||||
if page <= 0 {
|
||||
@@ -289,8 +294,9 @@ func (d *DatasetService) GetIngestionLog(ctx context.Context, datasetID, userID,
|
||||
if logID == "" {
|
||||
return nil, common.CodeDataError, errors.New(`lack of "Log ID"`)
|
||||
}
|
||||
if !d.kbDAO.Accessible(ctx, dao.DB, datasetID, userID) {
|
||||
return nil, common.CodeDataError, errors.New("no authorization")
|
||||
if err := d.CheckAccess(ctx, permission.Subject{UserID: userID}, datasetID, permission.OperationRead); err != nil {
|
||||
code, permissionErr := permissionresponse.Normalize(err)
|
||||
return nil, code, permissionErr
|
||||
}
|
||||
|
||||
log, err := d.pipelineLogDAO.GetByIDAndKBID(ctx, dao.DB, logID, datasetID)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package dataset
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
@@ -80,6 +81,75 @@ func TestDatasetServiceListDatasetsIDsAccessibleViaTeamTenant(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetServiceListDatasetsAndFiltersUseAuthorizedScope(t *testing.T) {
|
||||
db := setupDatasetUpdateTestDB(t)
|
||||
pushServiceDB(t, db)
|
||||
insertDatasetUpdateKB(t, "kb-own", "user-1", "Own")
|
||||
insertDatasetUpdateKB(t, "kb-team", "owner-1", "Team")
|
||||
insertDatasetUpdateKB(t, "kb-private", "owner-1", "Private")
|
||||
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)
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
if err != nil || code != common.CodeSuccess {
|
||||
t.Fatalf("ListDatasets = (%#v, %d, %v), want success", data, code, err)
|
||||
}
|
||||
if total != 1 || len(data) != 1 || data[0]["id"] != "kb-team" {
|
||||
t.Fatalf("ListDatasets returned %#v (total=%d), want only the shared dataset", data, total)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if filters["total"] != int64(2) {
|
||||
t.Fatalf("ListDatasetFilters total = %#v, want 2 visible datasets", filters["total"])
|
||||
}
|
||||
owners, ok := filters["filter"].(map[string]interface{})["owner"].([]*entity.DatasetOwnerFilter)
|
||||
if !ok || len(owners) != 2 {
|
||||
t.Fatalf("ListDatasetFilters owners = %#v, want own and shared tenant groups", filters["filter"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetServiceListDatasetFiltersReturnsEmptyOwnerList(t *testing.T) {
|
||||
db := setupDatasetUpdateTestDB(t)
|
||||
pushServiceDB(t, db)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
owners, ok := filters["filter"].(map[string]interface{})["owner"].([]*entity.DatasetOwnerFilter)
|
||||
if !ok || owners == nil || len(owners) != 0 {
|
||||
t.Fatalf("ListDatasetFilters owners = %#v, want a non-nil empty slice", filters["filter"])
|
||||
}
|
||||
|
||||
encoded, err := json.Marshal(filters)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal filters: %v", err)
|
||||
}
|
||||
var response map[string]interface{}
|
||||
if err := json.Unmarshal(encoded, &response); err != nil {
|
||||
t.Fatalf("unmarshal filters: %v", err)
|
||||
}
|
||||
ownerJSON := response["filter"].(map[string]interface{})["owner"]
|
||||
if ownerJSON == nil {
|
||||
t.Fatal(`serialized owner is null; want []`)
|
||||
}
|
||||
if ownerList, ok := ownerJSON.([]interface{}); !ok || len(ownerList) != 0 {
|
||||
t.Fatalf("serialized owner = %#v, want []", ownerJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetServiceListDatasetsRejectsIDAndIDsTogether(t *testing.T) {
|
||||
db := setupDatasetUpdateTestDB(t)
|
||||
pushServiceDB(t, db)
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/permission"
|
||||
permissionresponse "ragflow/internal/permission/response"
|
||||
"ragflow/internal/service"
|
||||
)
|
||||
|
||||
@@ -16,8 +18,9 @@ import (
|
||||
func (d *DatasetService) UpdateDocumentMetadataConfig(ctx context.Context, userID, datasetID, documentID string, req map[string]interface{}) (*entity.Document, common.ErrorCode, error) {
|
||||
userID = strings.TrimSpace(userID)
|
||||
datasetID = strings.TrimSpace(datasetID)
|
||||
if !d.Accessible(ctx, datasetID, userID) {
|
||||
return nil, common.CodeDataError, errors.New("you don't own the dataset")
|
||||
if err := service.CheckDatasetAccess(ctx, permission.Subject{UserID: userID}, datasetID, permission.OperationUpdate); err != nil {
|
||||
code, permissionErr := permissionresponse.Normalize(err)
|
||||
return nil, code, permissionErr
|
||||
}
|
||||
|
||||
doc, err := d.documentDAO.GetByDocumentIDAndDatasetID(ctx, dao.DB, documentID, datasetID)
|
||||
@@ -67,22 +70,25 @@ func (d *DatasetService) UpdateDocumentMetadataConfig(ctx context.Context, userI
|
||||
}
|
||||
|
||||
// GetMetadataConfig gets the auto-metadata configuration for a dataset.
|
||||
func (d *DatasetService) GetMetadataConfig(ctx context.Context, datasetID, tenantID string) (map[string]interface{}, common.ErrorCode, error) {
|
||||
func (d *DatasetService) GetMetadataConfig(ctx context.Context, datasetID, userID string) (map[string]interface{}, common.ErrorCode, error) {
|
||||
datasetID = strings.TrimSpace(datasetID)
|
||||
tenantID = strings.TrimSpace(tenantID)
|
||||
if !d.Accessible(ctx, datasetID, tenantID) {
|
||||
return nil, common.CodeDataError, fmt.Errorf("user '%s' lacks permission for dataset '%s'", tenantID, datasetID)
|
||||
userID = strings.TrimSpace(userID)
|
||||
if err := d.CheckAccess(ctx, permission.Subject{UserID: userID}, datasetID, permission.OperationRead); err != nil {
|
||||
code, permissionErr := permissionresponse.Normalize(err)
|
||||
return nil, code, permissionErr
|
||||
}
|
||||
|
||||
kb, err := d.kbDAO.GetByID(ctx, dao.DB, datasetID)
|
||||
if err != nil {
|
||||
if dao.IsNotFoundErr(err) {
|
||||
return nil, common.CodeDataError, errors.New("dataset not found")
|
||||
code, permissionErr := permissionresponse.Normalize(permission.ErrResourceNotFound)
|
||||
return nil, code, permissionErr
|
||||
}
|
||||
return nil, common.CodeServerError, errors.New("database operation failed")
|
||||
}
|
||||
if kb == nil {
|
||||
return nil, common.CodeDataError, fmt.Errorf("user '%s' lacks permission for dataset '%s'", tenantID, datasetID)
|
||||
code, permissionErr := permissionresponse.Normalize(permission.ErrResourceNotFound)
|
||||
return nil, code, permissionErr
|
||||
}
|
||||
|
||||
_, enabled, metadata, builtInMetadata := modularMetadataConfig(kb.ParserConfig)
|
||||
@@ -95,23 +101,26 @@ func (d *DatasetService) GetMetadataConfig(ctx context.Context, datasetID, tenan
|
||||
}
|
||||
|
||||
// UpdateMetadataConfig updates the auto-metadata configuration for a dataset.
|
||||
func (d *DatasetService) UpdateMetadataConfig(ctx context.Context, datasetID, tenantID string, req *service.MetadataConfigRequest) (map[string]interface{}, common.ErrorCode, error) {
|
||||
func (d *DatasetService) UpdateMetadataConfig(ctx context.Context, datasetID, userID string, req *service.MetadataConfigRequest) (map[string]interface{}, common.ErrorCode, error) {
|
||||
datasetID = strings.TrimSpace(datasetID)
|
||||
tenantID = strings.TrimSpace(tenantID)
|
||||
userID = strings.TrimSpace(userID)
|
||||
|
||||
if !d.Accessible(ctx, datasetID, tenantID) {
|
||||
return nil, common.CodeDataError, fmt.Errorf("user '%s' lacks permission for dataset '%s'", tenantID, datasetID)
|
||||
if err := service.CheckDatasetAccess(ctx, permission.Subject{UserID: userID}, datasetID, permission.OperationUpdate); err != nil {
|
||||
code, permissionErr := permissionresponse.Normalize(err)
|
||||
return nil, code, permissionErr
|
||||
}
|
||||
|
||||
kb, err := d.kbDAO.GetByID(ctx, dao.DB, datasetID)
|
||||
if err != nil {
|
||||
if dao.IsNotFoundErr(err) {
|
||||
return nil, common.CodeDataError, errors.New("dataset not found")
|
||||
code, permissionErr := permissionresponse.Normalize(permission.ErrResourceNotFound)
|
||||
return nil, code, permissionErr
|
||||
}
|
||||
return nil, common.CodeServerError, errors.New("database operation failed")
|
||||
}
|
||||
if kb == nil {
|
||||
return nil, common.CodeDataError, fmt.Errorf("user '%s' lacks permission for dataset '%s'", tenantID, datasetID)
|
||||
code, permissionErr := permissionresponse.Normalize(permission.ErrResourceNotFound)
|
||||
return nil, code, permissionErr
|
||||
}
|
||||
|
||||
if req == nil {
|
||||
|
||||
@@ -65,6 +65,7 @@ func insertDatasetMetadataConfigKB(t *testing.T, datasetID, tenantID string) {
|
||||
if err := dao.DB.Create(kb).Error; err != nil {
|
||||
t.Fatalf("insert test kb: %v", err)
|
||||
}
|
||||
ensureDatasetTestMembership(t, tenantID, tenantID, "owner")
|
||||
}
|
||||
|
||||
func insertDatasetMetadataConfigTeamMember(t *testing.T, userID, tenantID string) {
|
||||
@@ -203,10 +204,10 @@ func TestDatasetServiceUpdateDocumentMetadataConfigRejectsNonOwner(t *testing.T)
|
||||
if err == nil {
|
||||
t.Fatal("expected ownership error")
|
||||
}
|
||||
if code != common.CodeDataError {
|
||||
t.Fatalf("expected data error code, got %d", code)
|
||||
if code != common.CodeForbidden {
|
||||
t.Fatalf("expected forbidden code, got %d", code)
|
||||
}
|
||||
if err.Error() != "you don't own the dataset" {
|
||||
if err.Error() != "Permission denied" {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestCreateDataset_RequiresParseType(t *testing.T) {
|
||||
// defaulted to BuiltIn; now this must be rejected.
|
||||
_, code, err := testDatasetCreateService(t).CreateDataset(t.Context(), &service.CreateDatasetRequest{
|
||||
Name: "ds-no-parse-type",
|
||||
}, "tenant-1")
|
||||
}, testDatasetSubject("tenant-1"))
|
||||
if err == nil {
|
||||
t.Fatal("expected parse_type required error")
|
||||
}
|
||||
@@ -41,7 +41,7 @@ func TestCreateDataset_BuiltinRequiresParserID(t *testing.T) {
|
||||
_, code, err := testDatasetCreateService(t).CreateDataset(t.Context(), &service.CreateDatasetRequest{
|
||||
Name: "ds-builtin-no-parser",
|
||||
ParseType: &parseType,
|
||||
}, "tenant-1")
|
||||
}, testDatasetSubject("tenant-1"))
|
||||
if err == nil {
|
||||
t.Fatal("expected parser_id required error for BuiltIn mode")
|
||||
}
|
||||
@@ -62,7 +62,7 @@ func TestCreateDataset_PipelineRequiresPipelineID(t *testing.T) {
|
||||
_, code, err := testDatasetCreateService(t).CreateDataset(t.Context(), &service.CreateDatasetRequest{
|
||||
Name: "ds-pipeline-no-id",
|
||||
ParseType: &parseType,
|
||||
}, "tenant-1")
|
||||
}, testDatasetSubject("tenant-1"))
|
||||
if err == nil {
|
||||
t.Fatal("expected pipeline_id required error for Pipeline mode")
|
||||
}
|
||||
@@ -83,7 +83,7 @@ func TestCreateDataset_RejectsInvalidParseType(t *testing.T) {
|
||||
_, code, err := testDatasetCreateService(t).CreateDataset(t.Context(), &service.CreateDatasetRequest{
|
||||
Name: "ds-bad-parse-type",
|
||||
ParseType: &parseType,
|
||||
}, "tenant-1")
|
||||
}, testDatasetSubject("tenant-1"))
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid parse_type error")
|
||||
}
|
||||
@@ -106,7 +106,7 @@ func TestCreateDataset_BuiltinSucceedsWithParseType(t *testing.T) {
|
||||
Name: "ds-builtin-ok",
|
||||
ParseType: &parseType,
|
||||
ParserID: &parserID,
|
||||
}, "tenant-1")
|
||||
}, testDatasetSubject("tenant-1"))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDataset failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -3,20 +3,17 @@ package dataset
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"ragflow/internal/dao"
|
||||
"strings"
|
||||
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/permission"
|
||||
"ragflow/internal/service"
|
||||
)
|
||||
|
||||
// Accessible checks if a user has access to a dataset.
|
||||
func (d *DatasetService) Accessible(ctx context.Context, kbID, userID string) bool {
|
||||
return d.kbDAO.Accessible(ctx, dao.DB, kbID, userID)
|
||||
}
|
||||
|
||||
// GetByID retrieves a knowledge base by ID.
|
||||
func (d *DatasetService) GetByID(ctx context.Context, kbID string) (*entity.Knowledgebase, error) {
|
||||
return d.kbDAO.GetByID(ctx, dao.DB, kbID)
|
||||
// CheckAccess authorizes an operation on a dataset under the active edition's policy.
|
||||
func (d *DatasetService) CheckAccess(ctx context.Context, subject permission.Subject, datasetID string, operation permission.Operation) error {
|
||||
return service.CheckDatasetAccess(ctx, subject, datasetID, operation)
|
||||
}
|
||||
|
||||
// GetKnowledgebaseByID resolves a dataset entity without applying permission
|
||||
@@ -33,29 +30,6 @@ func (d *DatasetService) GetKnowledgebaseByID(ctx context.Context, datasetID str
|
||||
return d.kbDAO.GetByID(ctx, dao.DB, normalizedID)
|
||||
}
|
||||
|
||||
// CheckKBTeamPermission checks if a user has team-level permission for the KB.
|
||||
func (d *DatasetService) CheckKBTeamPermission(ctx context.Context, kb *entity.Knowledgebase, userID string) bool {
|
||||
if kb == nil {
|
||||
return false
|
||||
}
|
||||
if kb.TenantID == userID {
|
||||
return true
|
||||
}
|
||||
if kb.Permission != string(entity.TenantPermissionTeam) {
|
||||
return false
|
||||
}
|
||||
joinedTenants, err := d.tenantDAO.GetJoinedTenantsByUserID(ctx, dao.DB, userID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, jt := range joinedTenants {
|
||||
if jt != nil && jt.TenantID == kb.TenantID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetFieldMap returns the field map for the given knowledge base IDs.
|
||||
func (d *DatasetService) GetFieldMap(ctx context.Context, ids []string) (map[string]interface{}, error) {
|
||||
return d.kbDAO.GetFieldMap(ctx, dao.DB, ids)
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/entity"
|
||||
modelModule "ragflow/internal/entity/models"
|
||||
"ragflow/internal/permission"
|
||||
permissionresponse "ragflow/internal/permission/response"
|
||||
"ragflow/internal/service"
|
||||
"ragflow/internal/service/nlp"
|
||||
)
|
||||
@@ -112,9 +114,10 @@ func (d *DatasetService) SearchDatasets(ctx context.Context, req *service.Search
|
||||
var kbRecords []*entity.Knowledgebase
|
||||
seenTenants := make(map[string]bool)
|
||||
for _, datasetID := range datasetIDs {
|
||||
if !d.kbDAO.Accessible(ctx, dao.DB, datasetID, userID) {
|
||||
if err := service.CheckDatasetAccess(ctx, permission.Subject{UserID: userID}, datasetID, permission.OperationUse); err != nil {
|
||||
common.Warn("SearchDatasets access denied", zap.String("datasetID", datasetID), zap.String("userID", userID))
|
||||
return nil, fmt.Errorf("only owner of dataset %s is authorized for this operation", datasetID)
|
||||
_, permissionErr := permissionresponse.Normalize(err)
|
||||
return nil, permissionErr
|
||||
}
|
||||
|
||||
kb, err := d.kbDAO.GetByID(ctx, dao.DB, datasetID)
|
||||
|
||||
@@ -60,4 +60,27 @@ func pushServiceDB(t *testing.T, testDB *gorm.DB) {
|
||||
t.Cleanup(func() { dao.DB = oldDB })
|
||||
}
|
||||
|
||||
func ensureDatasetTestMembership(t *testing.T, userID, tenantID, role string) {
|
||||
t.Helper()
|
||||
var count int64
|
||||
if err := dao.DB.Model(&entity.UserTenant{}).
|
||||
Where("user_id = ? AND tenant_id = ? AND status = ?", userID, tenantID, string(entity.StatusValid)).
|
||||
Count(&count).Error; err != nil {
|
||||
t.Fatalf("count test tenant memberships: %v", err)
|
||||
}
|
||||
if count > 0 {
|
||||
return
|
||||
}
|
||||
if err := dao.DB.Create(&entity.UserTenant{
|
||||
ID: userID + "-" + tenantID,
|
||||
UserID: userID,
|
||||
TenantID: tenantID,
|
||||
Role: role,
|
||||
InvitedBy: tenantID,
|
||||
Status: sptr(string(entity.StatusValid)),
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("insert test tenant membership: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func sptr(s string) *string { return &s }
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/ingestion/component"
|
||||
"ragflow/internal/permission"
|
||||
permissionresponse "ragflow/internal/permission/response"
|
||||
)
|
||||
|
||||
func (d *DatasetService) AggregateTags(ctx context.Context, datasetIDs []string, userID string) ([]map[string]interface{}, common.ErrorCode, error) {
|
||||
@@ -66,8 +68,9 @@ func (d *DatasetService) AggregateTags(ctx context.Context, datasetIDs []string,
|
||||
}
|
||||
authorized := make([]authorizedDataset, 0, len(orderedIDs))
|
||||
for _, datasetID := range orderedIDs {
|
||||
if !d.kbDAO.Accessible(ctx, dao.DB, datasetID, userID) {
|
||||
return nil, common.CodeDataError, fmt.Errorf("No authorization for dataset '%s'", datasetID)
|
||||
if err := d.CheckAccess(ctx, permission.Subject{UserID: userID}, datasetID, permission.OperationRead); err != nil {
|
||||
code, permissionErr := permissionresponse.Normalize(err)
|
||||
return nil, code, permissionErr
|
||||
}
|
||||
kb, err := d.kbDAO.GetByID(ctx, dao.DB, datasetID)
|
||||
if err != nil {
|
||||
|
||||
@@ -65,6 +65,7 @@ func insertAggregateTagsKB(t *testing.T, datasetID, tenantID, permission, tagFil
|
||||
if err := dao.DB.Create(kb).Error; err != nil {
|
||||
t.Fatalf("insert test kb: %v", err)
|
||||
}
|
||||
ensureDatasetTestMembership(t, tenantID, tenantID, "owner")
|
||||
}
|
||||
|
||||
func insertAggregateTagsMembership(t *testing.T, tenantID, userID string) {
|
||||
@@ -73,7 +74,7 @@ func insertAggregateTagsMembership(t *testing.T, tenantID, userID string) {
|
||||
ID: tenantID + "-" + userID,
|
||||
UserID: userID,
|
||||
TenantID: tenantID,
|
||||
Role: "member",
|
||||
Role: "normal",
|
||||
InvitedBy: tenantID,
|
||||
Status: sptr(string(entity.StatusValid)),
|
||||
}
|
||||
@@ -222,10 +223,10 @@ func TestDatasetServiceAggregateTagsRejectsUnauthorizedDataset(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected authorization error")
|
||||
}
|
||||
if code != common.CodeDataError {
|
||||
t.Fatalf("code=%d want=%d", code, common.CodeDataError)
|
||||
if code != common.CodeForbidden {
|
||||
t.Fatalf("code=%d want=%d", code, common.CodeForbidden)
|
||||
}
|
||||
if err.Error() != "No authorization for dataset '"+kbID+"'" {
|
||||
if err.Error() != "Permission denied" {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"ragflow/internal/engine/types"
|
||||
"ragflow/internal/entity"
|
||||
pipelinepkg "ragflow/internal/ingestion/pipeline"
|
||||
"ragflow/internal/permission"
|
||||
permissionresponse "ragflow/internal/permission/response"
|
||||
"ragflow/internal/service"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -28,14 +30,13 @@ type datasetPagerankUpdate struct {
|
||||
datasetID string
|
||||
}
|
||||
|
||||
func (d *DatasetService) UpdateDataset(ctx context.Context, datasetID, tenantID string, req service.UpdateDatasetRequest) (map[string]interface{}, common.ErrorCode, error) {
|
||||
func (d *DatasetService) UpdateDataset(ctx context.Context, datasetID, userID string, req service.UpdateDatasetRequest) (map[string]interface{}, common.ErrorCode, error) {
|
||||
datasetID = strings.TrimSpace(datasetID)
|
||||
tenantID = strings.TrimSpace(tenantID)
|
||||
userID = strings.TrimSpace(userID)
|
||||
if _, err := d.kbDAO.GetByID(ctx, dao.DB, datasetID); err != nil {
|
||||
if dao.IsNotFoundErr(err) {
|
||||
// Match Python: nonexistent and not-owned datasets share the
|
||||
// "lacks permission" error so existence is not revealed (IDOR).
|
||||
return nil, common.CodeDataError, fmt.Errorf("user '%s' lacks permission for dataset '%s'", tenantID, datasetID)
|
||||
code, permissionErr := permissionresponse.NormalizeHidden(permission.ErrResourceNotFound)
|
||||
return nil, code, permissionErr
|
||||
}
|
||||
return nil, common.CodeServerError, errors.New("database operation failed")
|
||||
}
|
||||
@@ -144,7 +145,7 @@ func (d *DatasetService) UpdateDataset(ctx context.Context, datasetID, tenantID
|
||||
common.Warn("dropping unscoped (flat) parser_config keys; keys must be component-scoped (contain ':')",
|
||||
zap.Strings("keys", dropped),
|
||||
zap.String("dataset_id", datasetID),
|
||||
zap.String("tenant_id", tenantID),
|
||||
zap.String("user_id", userID),
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
@@ -179,26 +180,38 @@ func (d *DatasetService) UpdateDataset(ctx context.Context, datasetID, tenantID
|
||||
var scheduledTaskIDs []string
|
||||
var pagerankUpdate *datasetPagerankUpdate
|
||||
err = dao.DB.Transaction(func(tx *gorm.DB) error {
|
||||
lockedKB, code, authErr := d.lockAccessibleDatasetForUpdate(tx, datasetID, tenantID)
|
||||
lockedKB, code, authErr := d.lockAccessibleDatasetForUpdate(ctx, tx, datasetID, userID)
|
||||
if authErr != nil {
|
||||
txCode = code
|
||||
return authErr
|
||||
}
|
||||
|
||||
if req.Permission != nil && lockedKB.TenantID != tenantID {
|
||||
txCode = common.CodeDataError
|
||||
return errors.New("only dataset owner can change permission")
|
||||
}
|
||||
|
||||
updates := make(map[string]interface{}, len(simpleUpdates)+6)
|
||||
for key, value := range simpleUpdates {
|
||||
updates[key] = value
|
||||
}
|
||||
if permissionValue, ok := updates["permission"].(string); ok {
|
||||
if permissionValue != lockedKB.Permission {
|
||||
accessErr := permission.NewLockingDatabaseChecker(tx).CheckResource(
|
||||
ctx,
|
||||
permission.Subject{UserID: userID},
|
||||
permission.ResourceRef{Kind: permission.ResourceKindDataset, ID: datasetID},
|
||||
permission.OperationShare,
|
||||
)
|
||||
if accessErr != nil {
|
||||
txCode, accessErr = permissionresponse.Normalize(accessErr)
|
||||
return accessErr
|
||||
}
|
||||
}
|
||||
if permissionValue == lockedKB.Permission {
|
||||
delete(updates, "permission")
|
||||
}
|
||||
}
|
||||
|
||||
if nameValue, ok := updates["name"].(string); ok {
|
||||
available, nameErr := common.NameAvailable(lockedKB.Name, nameValue, func(candidate string) (bool, error) {
|
||||
var existing entity.Knowledgebase
|
||||
lookupErr := tx.Where("LOWER(name) = LOWER(?) AND tenant_id = ? AND status = ?", candidate, tenantID, string(entity.StatusValid)).First(&existing).Error
|
||||
lookupErr := tx.Where("LOWER(name) = LOWER(?) AND tenant_id = ? AND status = ?", candidate, lockedKB.TenantID, string(entity.StatusValid)).First(&existing).Error
|
||||
if lookupErr != nil {
|
||||
if dao.IsNotFoundErr(lookupErr) {
|
||||
return false, nil
|
||||
@@ -231,13 +244,14 @@ func (d *DatasetService) UpdateDataset(ctx context.Context, datasetID, tenantID
|
||||
} else {
|
||||
tenantEmbdID = ""
|
||||
}
|
||||
ok, message := d.verifyEmbeddingAvailability(ctx, effectiveEmbdID, tenantID)
|
||||
modelAccess := service.ModelAccess{UserID: userID, TenantID: lockedKB.TenantID}
|
||||
ok, message := d.verifyEmbeddingAvailability(ctx, effectiveEmbdID, modelAccess)
|
||||
if !ok {
|
||||
txCode = common.CodeDataError
|
||||
return errors.New(message)
|
||||
}
|
||||
if effectiveEmbdID != "" && tenantEmbdID == "" {
|
||||
target, err := service.NewModelFactory().ResolveInfo(ctx, service.ModelAccess{TenantID: tenantID}, entity.ModelTypeEmbedding, effectiveEmbdID)
|
||||
target, err := service.NewModelFactory().ResolveInfo(ctx, modelAccess, entity.ModelTypeEmbedding, effectiveEmbdID)
|
||||
if err == nil {
|
||||
tenantEmbdID = target.ID
|
||||
}
|
||||
@@ -426,34 +440,28 @@ func publishDatasetSyncerTasks(taskIDs []string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DatasetService) lockAccessibleDatasetForUpdate(tx *gorm.DB, datasetID, userID string) (*entity.Knowledgebase, common.ErrorCode, error) {
|
||||
func (d *DatasetService) lockAccessibleDatasetForUpdate(ctx context.Context, tx *gorm.DB, datasetID, userID string) (*entity.Knowledgebase, common.ErrorCode, error) {
|
||||
var kb entity.Knowledgebase
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("id = ? AND status = ?", datasetID, string(entity.StatusValid)).
|
||||
First(&kb).Error
|
||||
if err != nil {
|
||||
if dao.IsNotFoundErr(err) {
|
||||
return nil, common.CodeDataError, errors.New("dataset not found")
|
||||
code, permissionErr := permissionresponse.NormalizeHidden(permission.ErrResourceNotFound)
|
||||
return nil, code, permissionErr
|
||||
}
|
||||
return nil, common.CodeServerError, errors.New("database operation failed")
|
||||
}
|
||||
|
||||
if kb.TenantID == userID {
|
||||
return &kb, common.CodeSuccess, nil
|
||||
}
|
||||
if kb.Permission != string(entity.TenantPermissionTeam) {
|
||||
return nil, common.CodeDataError, fmt.Errorf("user '%s' lacks permission for dataset '%s'", userID, datasetID)
|
||||
}
|
||||
|
||||
var relation entity.UserTenant
|
||||
err = tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("tenant_id = ? AND user_id = ? AND status = ?", kb.TenantID, userID, "1").
|
||||
First(&relation).Error
|
||||
err = permission.NewLockingDatabaseChecker(tx).CheckResource(
|
||||
ctx,
|
||||
permission.Subject{UserID: userID},
|
||||
permission.ResourceRef{Kind: permission.ResourceKindDataset, ID: datasetID},
|
||||
permission.OperationUpdate,
|
||||
)
|
||||
if err != nil {
|
||||
if dao.IsNotFoundErr(err) {
|
||||
return nil, common.CodeDataError, fmt.Errorf("user '%s' lacks permission for dataset '%s'", userID, datasetID)
|
||||
}
|
||||
return nil, common.CodeServerError, errors.New("database operation failed")
|
||||
code, permissionErr := permissionresponse.NormalizeHidden(err)
|
||||
return nil, code, permissionErr
|
||||
}
|
||||
|
||||
return &kb, common.CodeSuccess, nil
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/permission"
|
||||
"ragflow/internal/service"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -440,13 +441,10 @@ func TestDatasetServiceUpdateDatasetRejectsMissingDataset(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected missing dataset error")
|
||||
}
|
||||
if code != common.CodeDataError {
|
||||
t.Fatalf("expected data error code, got %d", code)
|
||||
if code != common.CodeNotFound {
|
||||
t.Fatalf("expected not found code, got %d", code)
|
||||
}
|
||||
// Nonexistent and not-owned datasets share the "lacks permission"
|
||||
// error so existence is not revealed (IDOR), matching Python.
|
||||
expected := "user 'tenant-1' lacks permission for dataset 'missing-kb'"
|
||||
if err.Error() != expected {
|
||||
if err.Error() != "Resource not found" {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -462,10 +460,10 @@ func TestDatasetServiceUpdateDatasetRejectsNonOwner(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected permission error")
|
||||
}
|
||||
if code != common.CodeDataError {
|
||||
t.Fatalf("expected data error code, got %d", code)
|
||||
if code != common.CodeNotFound {
|
||||
t.Fatalf("expected not found code, got %d", code)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "lacks permission") {
|
||||
if err.Error() != "Resource not found" {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -489,10 +487,10 @@ func TestDatasetServiceUpdateDatasetRejectsTeamMemberPermissionChange(t *testing
|
||||
if err == nil {
|
||||
t.Fatal("expected permission change error")
|
||||
}
|
||||
if code != common.CodeDataError {
|
||||
t.Fatalf("expected data error code, got %d", code)
|
||||
if code != common.CodeForbidden {
|
||||
t.Fatalf("expected forbidden code, got %d", code)
|
||||
}
|
||||
if err.Error() != "only dataset owner can change permission" {
|
||||
if err.Error() != "Permission denied" {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
@@ -505,6 +503,81 @@ func TestDatasetServiceUpdateDatasetRejectsTeamMemberPermissionChange(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetServiceUpdateDatasetAllowsTeamMemberToResubmitUnchangedPermission(t *testing.T) {
|
||||
db := setupDatasetUpdateTestDB(t)
|
||||
pushServiceDB(t, db)
|
||||
insertDatasetUpdateKB(t, "kb-1", "owner-1", "Original")
|
||||
if err := dao.DB.Model(&entity.Knowledgebase{}).
|
||||
Where("id = ?", "kb-1").
|
||||
Update("permission", string(entity.TenantPermissionTeam)).Error; err != nil {
|
||||
t.Fatalf("update kb permission: %v", err)
|
||||
}
|
||||
insertDatasetUpdateTeamMember(t, "user-1", "owner-1")
|
||||
|
||||
name := "Renamed by team member"
|
||||
permission := string(entity.TenantPermissionTeam)
|
||||
result, code, err := testDatasetUpdateService(t).UpdateDataset(t.Context(), "kb-1", "user-1", service.UpdateDatasetRequest{
|
||||
Name: &name,
|
||||
Permission: &permission,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected unchanged permission to be accepted, got: %v", err)
|
||||
}
|
||||
if code != common.CodeSuccess {
|
||||
t.Fatalf("expected success code, got %d", code)
|
||||
}
|
||||
if result["name"] != name {
|
||||
t.Fatalf("expected updated name %q, got %#v", name, result["name"])
|
||||
}
|
||||
|
||||
persisted, err := dao.NewKnowledgebaseDAO().GetByID(t.Context(), db, "kb-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get dataset: %v", err)
|
||||
}
|
||||
if persisted.Name != name {
|
||||
t.Fatalf("expected persisted name %q, got %q", name, persisted.Name)
|
||||
}
|
||||
if persisted.Permission != permission {
|
||||
t.Fatalf("expected permission to remain %q, got %q", permission, persisted.Permission)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetServiceUpdateDatasetResolvesEmbeddingInDatasetTenant(t *testing.T) {
|
||||
db := setupDatasetUpdateTestDB(t)
|
||||
pushServiceDB(t, db)
|
||||
insertDatasetUpdateKB(t, "kb-1", "tenant-1", "Original")
|
||||
if err := dao.DB.Model(&entity.Knowledgebase{}).
|
||||
Where("id = ?", "kb-1").
|
||||
Update("permission", string(entity.TenantPermissionTeam)).Error; err != nil {
|
||||
t.Fatalf("update kb permission: %v", err)
|
||||
}
|
||||
insertDatasetUpdateTeamMember(t, "user-1", "tenant-1")
|
||||
insertDatasetUpdateModelProvider(t, "provider-1", "tenant-1", "ZHIPU-AI")
|
||||
insertDatasetUpdateModelInstance(t, "instance-1", "provider-1", "test")
|
||||
embeddingModelID := "aabbccdd11223344aabbccdd11223344"
|
||||
insertDatasetUpdateTenantModel(t, embeddingModelID, "provider-1", "instance-1", "embedding-2", int(entity.ModelTypeEmbedding))
|
||||
|
||||
permission := string(entity.TenantPermissionTeam)
|
||||
_, code, err := testDatasetUpdateService(t).UpdateDataset(t.Context(), "kb-1", "user-1", service.UpdateDatasetRequest{
|
||||
Permission: &permission,
|
||||
EmbeddingModel: &embeddingModelID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected shared dataset embedding model to resolve in its owning tenant, got: %v", err)
|
||||
}
|
||||
if code != common.CodeSuccess {
|
||||
t.Fatalf("expected success code, got %d", code)
|
||||
}
|
||||
|
||||
persisted, err := dao.NewKnowledgebaseDAO().GetByID(t.Context(), db, "kb-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get dataset: %v", err)
|
||||
}
|
||||
if persisted.TenantEmbdID == nil || *persisted.TenantEmbdID != embeddingModelID {
|
||||
t.Fatalf("expected tenant_embd_id %q, got %#v", embeddingModelID, persisted.TenantEmbdID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetServiceUpdateDatasetValidatesName(t *testing.T) {
|
||||
db := setupDatasetUpdateTestDB(t)
|
||||
pushServiceDB(t, db)
|
||||
@@ -868,14 +941,14 @@ func TestDatasetServiceDeleteDatasetsRejectsUnauthorizedID(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
svc := NewDatasetService()
|
||||
normalizedID := "11111111111141118111111111111111"
|
||||
_, code, err := svc.DeleteDatasets(ctx, []string{normalizedID}, false, "tenant-2")
|
||||
_, code, err := svc.DeleteDatasets(ctx, []string{normalizedID}, false, permission.Subject{UserID: "tenant-2"})
|
||||
if err == nil {
|
||||
t.Fatal("expected unauthorized error")
|
||||
}
|
||||
if code != common.CodeDataError {
|
||||
t.Fatalf("expected data error code, got %d", code)
|
||||
if code != common.CodeForbidden {
|
||||
t.Fatalf("expected forbidden code, got %d", code)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "lacks permission") {
|
||||
if err.Error() != "Permission denied" {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -886,14 +959,34 @@ func TestDatasetServiceDeleteDatasetsRejectsAllUnauthorized(t *testing.T) {
|
||||
|
||||
ctx := t.Context()
|
||||
svc := NewDatasetService()
|
||||
_, code, err := svc.DeleteDatasets(ctx, []string{"d94a8dc02c9711f0930f7fbc369eab6d"}, false, "tenant-1")
|
||||
_, code, err := svc.DeleteDatasets(ctx, []string{"d94a8dc02c9711f0930f7fbc369eab6d"}, false, testDatasetSubject("tenant-1"))
|
||||
if err == nil {
|
||||
t.Fatal("expected unauthorized error")
|
||||
}
|
||||
if code != common.CodeDataError {
|
||||
t.Fatalf("expected data error code, got %d", code)
|
||||
if code != common.CodeForbidden {
|
||||
t.Fatalf("expected forbidden code, got %d", code)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "lacks permission") {
|
||||
if err.Error() != "Permission denied" {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetServiceDeleteAllDatasetsRequiresTenantOwner(t *testing.T) {
|
||||
db := setupDatasetUpdateTestDB(t)
|
||||
pushServiceDB(t, db)
|
||||
insertDatasetUpdateKB(t, "kb-1", "tenant-1", "Shared dataset")
|
||||
insertDatasetUpdateTeamMember(t, "member-1", "tenant-1")
|
||||
|
||||
_, code, err := NewDatasetService().DeleteDatasets(
|
||||
t.Context(), nil, true, permission.Subject{UserID: "member-1", TenantID: "tenant-1"},
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected delete-all to be denied for a non-owner tenant member")
|
||||
}
|
||||
if code != common.CodeForbidden {
|
||||
t.Fatalf("expected forbidden code, got %d", code)
|
||||
}
|
||||
if err.Error() != "Permission denied" {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -949,6 +1042,7 @@ func insertDatasetUpdateKB(t *testing.T, id, tenantID, name string) {
|
||||
if err := dao.DB.Create(kb).Error; err != nil {
|
||||
t.Fatalf("insert test kb: %v", err)
|
||||
}
|
||||
ensureDatasetTestMembership(t, tenantID, tenantID, "owner")
|
||||
}
|
||||
|
||||
func insertDatasetUpdateCanvas(t *testing.T, id, userID string) {
|
||||
@@ -965,16 +1059,7 @@ func insertDatasetUpdateCanvas(t *testing.T, id, userID string) {
|
||||
|
||||
func insertDatasetUpdateTeamMember(t *testing.T, userID, tenantID string) {
|
||||
t.Helper()
|
||||
if err := dao.DB.Create(&entity.UserTenant{
|
||||
ID: userID + "-" + tenantID,
|
||||
UserID: userID,
|
||||
TenantID: tenantID,
|
||||
Role: "normal",
|
||||
InvitedBy: tenantID,
|
||||
Status: sptr("1"),
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("insert user tenant: %v", err)
|
||||
}
|
||||
ensureDatasetTestMembership(t, userID, tenantID, "normal")
|
||||
}
|
||||
|
||||
func insertDatasetUpdateConnector(t *testing.T, id, tenantID string) {
|
||||
@@ -1101,6 +1186,7 @@ func insertDatasetUpdateCanvasKB(t *testing.T, id, tenantID, name, pipelineID st
|
||||
if err := dao.DB.Create(kb).Error; err != nil {
|
||||
t.Fatalf("insert test canvas kb: %v", err)
|
||||
}
|
||||
ensureDatasetTestMembership(t, tenantID, tenantID, "owner")
|
||||
}
|
||||
|
||||
func TestUpdateDataset_StripsUnknownParam_Builtin(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/permission"
|
||||
)
|
||||
|
||||
// CheckDatasetAccess checks a dataset operation using the active edition's permission policy.
|
||||
func CheckDatasetAccess(ctx context.Context, subject permission.Subject, datasetID string, operation permission.Operation) error {
|
||||
return permission.NewDatabaseChecker(dao.DB).CheckResource(
|
||||
ctx,
|
||||
subject,
|
||||
permission.ResourceRef{Kind: permission.ResourceKindDataset, ID: datasetID},
|
||||
operation,
|
||||
)
|
||||
}
|
||||
@@ -170,6 +170,7 @@ type UpdateDatasetDocumentResponse struct {
|
||||
}
|
||||
|
||||
var (
|
||||
ErrDocumentNotFound = errors.New("document not found")
|
||||
ErrArtifactInvalidFilename = errors.New("invalid filename")
|
||||
ErrArtifactInvalidFileType = errors.New("invalid file type")
|
||||
ErrArtifactNotFound = errors.New("artifact not found")
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"ragflow/internal/dao"
|
||||
enginetypes "ragflow/internal/engine/types"
|
||||
"ragflow/internal/parser/parser"
|
||||
"ragflow/internal/permission"
|
||||
"ragflow/internal/service"
|
||||
"ragflow/internal/storage"
|
||||
"ragflow/internal/utility"
|
||||
@@ -40,7 +41,7 @@ func (s *DocumentService) GetDocumentImage(ctx context.Context, userID, imageID
|
||||
// composite image ID belongs to an indexed chunk of an accessible document.
|
||||
func (s *DocumentService) GetDocumentImageForDocument(ctx context.Context, userID, docID, imageID string) ([]byte, error) {
|
||||
doc, err := s.documentDAO.GetByID(ctx, dao.DB, docID)
|
||||
if err != nil || doc == nil || !s.kbDAO.Accessible(ctx, dao.DB, doc.KbID, userID) {
|
||||
if err != nil || doc == nil || service.CheckDatasetAccess(ctx, permission.Subject{UserID: userID}, doc.KbID, permission.OperationRead) != nil {
|
||||
return nil, ErrDocumentImageNotFound
|
||||
}
|
||||
bucket, objectKey, ok := strings.Cut(imageID, "-")
|
||||
@@ -68,7 +69,7 @@ func (s *DocumentService) GetDocumentImageForDocument(ctx context.Context, userI
|
||||
// document metadata.
|
||||
func (s *DocumentService) GetDocumentThumbnail(ctx context.Context, userID, docID string) ([]byte, error) {
|
||||
doc, err := s.documentDAO.GetByID(ctx, dao.DB, docID)
|
||||
if err != nil || doc == nil || !s.kbDAO.Accessible(ctx, dao.DB, doc.KbID, userID) || doc.Thumbnail == nil || *doc.Thumbnail == "" || strings.HasPrefix(*doc.Thumbnail, imgBase64Prefix) {
|
||||
if err != nil || doc == nil || service.CheckDatasetAccess(ctx, permission.Subject{UserID: userID}, doc.KbID, permission.OperationRead) != nil || doc.Thumbnail == nil || *doc.Thumbnail == "" || strings.HasPrefix(*doc.Thumbnail, imgBase64Prefix) {
|
||||
return nil, ErrDocumentImageNotFound
|
||||
}
|
||||
storageImpl := storage.GetStorageFactory().GetStorage()
|
||||
@@ -279,13 +280,9 @@ func (s *DocumentService) GetDocumentPreview(ctx context.Context, userID, docID
|
||||
return nil, ErrPreviewDocumentNotFound
|
||||
}
|
||||
|
||||
// Reuse KnowledgebaseDAO.Accessible — the exact rule the chunk list on
|
||||
// the same page uses — so the two panels can never disagree: the owning
|
||||
// tenant always, and tenant members only when the dataset's permission
|
||||
// is TEAM. A denial stays indistinguishable from a missing document so
|
||||
// an unauthorized caller cannot probe document IDs (mirrors Python
|
||||
// DocumentService.accessible in the preview path).
|
||||
if !s.kbDAO.Accessible(ctx, dao.DB, doc.KbID, userID) {
|
||||
// Keep access denial indistinguishable from a missing document so callers
|
||||
// cannot probe document IDs.
|
||||
if service.CheckDatasetAccess(ctx, permission.Subject{UserID: userID}, doc.KbID, permission.OperationRead) != nil {
|
||||
return nil, ErrPreviewDocumentNotFound
|
||||
}
|
||||
|
||||
|
||||
@@ -10,28 +10,15 @@ import (
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/ingestion/knowledge_compile"
|
||||
"ragflow/internal/permission"
|
||||
permissionresponse "ragflow/internal/permission/response"
|
||||
"ragflow/internal/service"
|
||||
"ragflow/internal/storage"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// Accessible reports whether docID belongs to a knowledge base
|
||||
// reachable by userID. Used to gate actions on a document the caller
|
||||
// has access to. Returns false on any lookup failure or empty inputs
|
||||
// so callers can treat a denial as a 404-equivalent and avoid leaking
|
||||
// whether the document exists at all.
|
||||
func (s *DocumentService) Accessible(ctx context.Context, docID, userID string) bool {
|
||||
if docID == "" || userID == "" {
|
||||
return false
|
||||
}
|
||||
doc, err := s.documentDAO.GetByID(ctx, dao.DB, docID)
|
||||
if err != nil || doc == nil {
|
||||
return false
|
||||
}
|
||||
return s.kbDAO.Accessible(ctx, dao.DB, doc.KbID, userID)
|
||||
}
|
||||
|
||||
func (s *DocumentService) GetDocumentStorageAddress(ctx context.Context, doc *entity.Document) (string, string, error) {
|
||||
if doc == nil {
|
||||
return "", "", fmt.Errorf("document is nil")
|
||||
@@ -70,8 +57,14 @@ func (s *DocumentService) DownloadDocument(ctx context.Context, datasetID, docID
|
||||
return nil, fmt.Errorf("specify document_id please")
|
||||
}
|
||||
doc, err := s.documentDAO.GetByID(ctx, dao.DB, docID)
|
||||
if err != nil || doc.KbID != datasetID {
|
||||
return nil, fmt.Errorf("document not found")
|
||||
if err != nil {
|
||||
if dao.IsNotFoundErr(err) {
|
||||
return nil, ErrDocumentNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get document %s: %w", docID, err)
|
||||
}
|
||||
if doc == nil || doc.KbID != datasetID {
|
||||
return nil, ErrDocumentNotFound
|
||||
}
|
||||
bucket, name, err := s.GetDocumentStorageAddress(ctx, doc)
|
||||
if err != nil {
|
||||
@@ -227,8 +220,9 @@ 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 !s.kbDAO.Accessible(ctx, dao.DB, datasetID, userID) {
|
||||
return 0, fmt.Errorf("You don't own the dataset %s.", datasetID)
|
||||
if err := service.CheckDatasetAccess(ctx, permission.Subject{UserID: userID}, datasetID, permission.OperationDelete); err != nil {
|
||||
_, permissionErr := permissionresponse.Normalize(err)
|
||||
return 0, permissionErr
|
||||
}
|
||||
|
||||
// 2. Resolve document IDs
|
||||
|
||||
@@ -14,13 +14,19 @@ import (
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
pipelinepkg "ragflow/internal/ingestion/pipeline"
|
||||
"ragflow/internal/permission"
|
||||
permissionresponse "ragflow/internal/permission/response"
|
||||
"ragflow/internal/tokenizer"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (s *DocumentService) BatchUpdateDocumentStatus(ctx context.Context, userID, datasetID, status string, documentIDs []string) (map[string]interface{}, common.ErrorCode, error) {
|
||||
kb, err := s.kbDAO.GetByIDAndTenantID(ctx, dao.DB, datasetID, userID)
|
||||
if err := service.CheckDatasetAccess(ctx, permission.Subject{UserID: userID}, datasetID, permission.OperationUpdate); err != nil {
|
||||
code, permissionErr := permissionresponse.Normalize(err)
|
||||
return nil, code, permissionErr
|
||||
}
|
||||
kb, err := s.kbDAO.GetByID(ctx, dao.DB, datasetID)
|
||||
if err != nil {
|
||||
return nil, common.CodeDataError, fmt.Errorf("you don't own the dataset")
|
||||
}
|
||||
@@ -108,8 +114,11 @@ func (s *DocumentService) BatchUpdateDocumentStatus(ctx context.Context, userID,
|
||||
}
|
||||
|
||||
func (s *DocumentService) UpdateDatasetDocument(ctx context.Context, userID, datasetID, documentID string, req *UpdateDatasetDocumentRequest, present map[string]bool) (*UpdateDatasetDocumentResponse, common.ErrorCode, error) {
|
||||
tenantID := userID
|
||||
kb, err := s.kbDAO.GetByIDAndTenantID(ctx, dao.DB, datasetID, tenantID)
|
||||
if err := service.CheckDatasetAccess(ctx, permission.Subject{UserID: userID}, datasetID, permission.OperationUpdate); err != nil {
|
||||
code, permissionErr := permissionresponse.Normalize(err)
|
||||
return nil, code, permissionErr
|
||||
}
|
||||
kb, err := s.kbDAO.GetByID(ctx, dao.DB, datasetID)
|
||||
if err != nil {
|
||||
if dao.IsNotFoundErr(err) {
|
||||
return nil, common.CodeDataError, errors.New("you don't own the dataset")
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/permission"
|
||||
permissionresponse "ragflow/internal/permission/response"
|
||||
)
|
||||
|
||||
func (s *DocumentService) ListIngestionTasks(ctx context.Context, userID string, datasetID *string, page, pageSize int) ([]*entity.IngestionTask, error) {
|
||||
@@ -64,8 +66,9 @@ func (s *DocumentService) Ingest(ctx context.Context, userID string, req *Ingest
|
||||
if err != nil {
|
||||
return common.CodeDataError, fmt.Errorf("dataset not found")
|
||||
}
|
||||
if !s.kbDAO.Accessible(ctx, dao.DB, kb.ID, userID) {
|
||||
return common.CodeAuthenticationError, fmt.Errorf("no authorization")
|
||||
if err := service.CheckDatasetAccess(ctx, permission.Subject{UserID: userID}, kb.ID, permission.OperationRun); err != nil {
|
||||
code, permissionErr := permissionresponse.Normalize(err)
|
||||
return code, permissionErr
|
||||
}
|
||||
validated = append(validated, validatedDoc{doc, kb})
|
||||
validatedIDs = append(validatedIDs, docID)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/permission"
|
||||
"ragflow/internal/service"
|
||||
)
|
||||
|
||||
@@ -77,7 +78,7 @@ func (s *DocumentService) GetThumbnails(ctx context.Context, userID string, docI
|
||||
|
||||
result := make(map[string]string, len(documents))
|
||||
for _, document := range documents {
|
||||
if document == nil || !s.kbDAO.Accessible(ctx, dao.DB, document.KbID, userID) {
|
||||
if document == nil || service.CheckDatasetAccess(ctx, permission.Subject{UserID: userID}, document.KbID, permission.OperationRead) != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ func updateDatasetDocumentRejected(t *testing.T, status string, req *UpdateDatas
|
||||
|
||||
svc := testDocumentService(t)
|
||||
ctx := t.Context()
|
||||
_, code, err := svc.UpdateDatasetDocument(ctx, "tenant-1", "kb-1", "doc-1", req, present)
|
||||
_, code, err := svc.UpdateDatasetDocument(ctx, "user-1", "kb-1", "doc-1", req, present)
|
||||
if err == nil {
|
||||
t.Fatalf("expected task-state rejection, got nil error (code=%v)", code)
|
||||
}
|
||||
@@ -118,7 +118,7 @@ func updateDatasetDocumentAllowed(t *testing.T, status string, req *UpdateDatase
|
||||
|
||||
svc := testDocumentService(t)
|
||||
ctx := t.Context()
|
||||
_, code, err := svc.UpdateDatasetDocument(ctx, "tenant-1", "kb-1", "doc-1", req, present)
|
||||
_, code, err := svc.UpdateDatasetDocument(ctx, "user-1", "kb-1", "doc-1", req, present)
|
||||
if err != nil && strings.Contains(err.Error(), "cannot be modified") {
|
||||
t.Fatalf("editable doc was wrongly rejected: %v", err)
|
||||
}
|
||||
@@ -201,7 +201,7 @@ func TestUpdateDatasetDocumentRunningEmptyPresentNotRejected(t *testing.T) {
|
||||
|
||||
svc := testDocumentService(t)
|
||||
ctx := t.Context()
|
||||
_, code, err := svc.UpdateDatasetDocument(ctx, "tenant-1", "kb-1", "doc-1",
|
||||
_, code, err := svc.UpdateDatasetDocument(ctx, "user-1", "kb-1", "doc-1",
|
||||
&UpdateDatasetDocumentRequest{}, map[string]bool{})
|
||||
if err != nil {
|
||||
t.Fatalf("empty PATCH on RUNNING doc should not be rejected: code=%v err=%v", code, err)
|
||||
@@ -231,7 +231,7 @@ func TestUpdateDatasetDocumentAllowsDoneRename(t *testing.T) {
|
||||
name := "renamed.txt"
|
||||
svc := testDocumentService(t)
|
||||
ctx := t.Context()
|
||||
_, code, err := svc.UpdateDatasetDocument(ctx, "tenant-1", "kb-1", "doc-1",
|
||||
_, code, err := svc.UpdateDatasetDocument(ctx, "user-1", "kb-1", "doc-1",
|
||||
&UpdateDatasetDocumentRequest{Name: &name},
|
||||
map[string]bool{"name": true})
|
||||
if err != nil && strings.Contains(err.Error(), "cannot be modified") {
|
||||
|
||||
@@ -600,6 +600,7 @@ func sptr(s string) *string { return &s }
|
||||
|
||||
func insertTestKB(t *testing.T, id, tenantID string, docNum, tokenNum, chunkNum int64) {
|
||||
t.Helper()
|
||||
ensureTestTenantOwner(t, "user-1", tenantID)
|
||||
kb := &entity.Knowledgebase{
|
||||
ID: id,
|
||||
TenantID: tenantID,
|
||||
@@ -617,6 +618,30 @@ func insertTestKB(t *testing.T, id, tenantID string, docNum, tokenNum, chunkNum
|
||||
}
|
||||
}
|
||||
|
||||
func ensureTestTenantOwner(t *testing.T, userID, tenantID string) {
|
||||
t.Helper()
|
||||
var owner entity.UserTenant
|
||||
err := dao.DB.Where("tenant_id = ? AND role = ? AND status = ?", tenantID, "owner", string(entity.StatusValid)).First(&owner).Error
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
if !dao.IsNotFoundErr(err) {
|
||||
t.Fatalf("find test tenant owner: %v", err)
|
||||
}
|
||||
status := string(entity.StatusValid)
|
||||
owner = entity.UserTenant{
|
||||
ID: userID + "_" + tenantID,
|
||||
UserID: userID,
|
||||
TenantID: tenantID,
|
||||
Role: "owner",
|
||||
InvitedBy: userID,
|
||||
Status: &status,
|
||||
}
|
||||
if err := dao.DB.Create(&owner).Error; err != nil {
|
||||
t.Fatalf("insert test tenant owner: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertKBDocNum(t *testing.T, kbID string, want int64) {
|
||||
t.Helper()
|
||||
var got int64
|
||||
@@ -1251,7 +1276,7 @@ func insertUserTenantForAccessCheck(t *testing.T, userID, tenantID string) {
|
||||
ID: userID + "_" + tenantID,
|
||||
UserID: userID,
|
||||
TenantID: tenantID,
|
||||
Role: "admin",
|
||||
Role: "owner",
|
||||
}
|
||||
if err = dao.DB.Create(ut).Error; err != nil {
|
||||
t.Fatalf("insert test user_tenant: %v", err)
|
||||
@@ -2048,7 +2073,7 @@ func TestGetDocumentPreview_DocumentNotFound(t *testing.T) {
|
||||
svc := testDocumentService(t)
|
||||
|
||||
ctx := t.Context()
|
||||
_, err := svc.GetDocumentPreview(ctx, "tenant-1", "nonexistent")
|
||||
_, err := svc.GetDocumentPreview(ctx, "user-1", "nonexistent")
|
||||
if !errors.Is(err, ErrPreviewDocumentNotFound) {
|
||||
t.Errorf("expected ErrPreviewDocumentNotFound, got %v", err)
|
||||
}
|
||||
@@ -2098,7 +2123,7 @@ func TestGetDocumentPreview_AccessControl(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
// Dataset owner reads the original file.
|
||||
p, err := svc.GetDocumentPreview(ctx, "tenant-1", "doc-prev")
|
||||
p, err := svc.GetDocumentPreview(ctx, "user-1", "doc-prev")
|
||||
if err != nil {
|
||||
t.Fatalf("owner preview: %v", err)
|
||||
}
|
||||
@@ -2118,7 +2143,7 @@ func TestGetDocumentPreview_AccessControl(t *testing.T) {
|
||||
}
|
||||
|
||||
// An unrelated user gets the same answer as for a missing document.
|
||||
_, err = svc.GetDocumentPreview(ctx, "tenant-2", "doc-prev")
|
||||
_, err = svc.GetDocumentPreview(ctx, "user-3", "doc-prev")
|
||||
if !errors.Is(err, ErrPreviewDocumentNotFound) {
|
||||
t.Fatalf("stranger preview: expected ErrPreviewDocumentNotFound, got %v", err)
|
||||
}
|
||||
@@ -2137,7 +2162,7 @@ func TestGetDocumentPreview_AccessControl(t *testing.T) {
|
||||
if _, err = svc.GetDocumentPreview(ctx, "user-2", "doc-prev-me"); !errors.Is(err, ErrPreviewDocumentNotFound) {
|
||||
t.Fatalf("team member on private dataset: expected ErrPreviewDocumentNotFound, got %v", err)
|
||||
}
|
||||
p, err = svc.GetDocumentPreview(ctx, "tenant-1", "doc-prev-me")
|
||||
p, err = svc.GetDocumentPreview(ctx, "user-1", "doc-prev-me")
|
||||
if err != nil {
|
||||
t.Fatalf("owner preview private dataset: %v", err)
|
||||
}
|
||||
@@ -2154,7 +2179,7 @@ func TestGetDocumentPreview_EmptyObject(t *testing.T) {
|
||||
insertTestPreviewDoc(t, db, mockStorage, "doc-prev-empty", "kb-prev-empty", "")
|
||||
|
||||
svc := testDocumentService(t)
|
||||
_, err := svc.GetDocumentPreview(t.Context(), "tenant-1", "doc-prev-empty")
|
||||
_, err := svc.GetDocumentPreview(t.Context(), "user-1", "doc-prev-empty")
|
||||
if !errors.Is(err, ErrPreviewFileEmpty) {
|
||||
t.Fatalf("expected ErrPreviewFileEmpty, got %v", err)
|
||||
}
|
||||
@@ -2177,7 +2202,7 @@ func TestGetDocumentPreview_MissingObjectSurfacesStorageError(t *testing.T) {
|
||||
}
|
||||
|
||||
svc := testDocumentService(t)
|
||||
_, err := svc.GetDocumentPreview(t.Context(), "tenant-1", "doc-prev-miss")
|
||||
_, err := svc.GetDocumentPreview(t.Context(), "user-1", "doc-prev-miss")
|
||||
if err == nil {
|
||||
t.Fatal("expected storage read error")
|
||||
}
|
||||
@@ -2212,6 +2237,26 @@ func TestDownloadDocument_WrongDataset(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadDocument_DatabaseErrorIsNotNotFound(t *testing.T) {
|
||||
db := setupServiceTestDB(t)
|
||||
pushServiceDB(t, db)
|
||||
if err := db.Migrator().DropTable(&entity.Document{}); err != nil {
|
||||
t.Fatalf("drop document table: %v", err)
|
||||
}
|
||||
|
||||
svc := testDocumentService(t)
|
||||
_, err := svc.DownloadDocument(t.Context(), "kb-1", "doc-1")
|
||||
if err == nil {
|
||||
t.Fatal("expected database error")
|
||||
}
|
||||
if errors.Is(err, ErrDocumentNotFound) {
|
||||
t.Fatalf("database error was masked as document not found: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to get document doc-1") {
|
||||
t.Fatalf("unexpected database error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDatasetDocumentRejectsNonOwner(t *testing.T) {
|
||||
db := setupServiceTestDB(t)
|
||||
pushServiceDB(t, db)
|
||||
@@ -2220,14 +2265,14 @@ func TestUpdateDatasetDocumentRejectsNonOwner(t *testing.T) {
|
||||
|
||||
svc := testDocumentService(t)
|
||||
ctx := t.Context()
|
||||
_, code, err := svc.UpdateDatasetDocument(ctx, "tenant-2", "kb-1", "doc-1", &UpdateDatasetDocumentRequest{}, map[string]bool{})
|
||||
_, code, err := svc.UpdateDatasetDocument(ctx, "user-2", "kb-1", "doc-1", &UpdateDatasetDocumentRequest{}, map[string]bool{})
|
||||
if err == nil {
|
||||
t.Fatal("expected ownership error")
|
||||
}
|
||||
if code != common.CodeDataError {
|
||||
t.Fatalf("code = %v, want %v", code, common.CodeDataError)
|
||||
if code != common.CodeForbidden {
|
||||
t.Fatalf("code = %v, want %v", code, common.CodeForbidden)
|
||||
}
|
||||
if err.Error() != "you don't own the dataset" {
|
||||
if err.Error() != "Permission denied" {
|
||||
t.Fatalf("err = %q", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -2241,7 +2286,7 @@ func TestUpdateDatasetDocumentRejectsCounterMutation(t *testing.T) {
|
||||
chunkCount := int64(6)
|
||||
svc := testDocumentService(t)
|
||||
ctx := t.Context()
|
||||
_, code, err := svc.UpdateDatasetDocument(ctx, "tenant-1", "kb-1", "doc-1", &UpdateDatasetDocumentRequest{
|
||||
_, code, err := svc.UpdateDatasetDocument(ctx, "user-1", "kb-1", "doc-1", &UpdateDatasetDocumentRequest{
|
||||
ChunkCount: &chunkCount,
|
||||
}, map[string]bool{"chunk_count": true})
|
||||
if err == nil {
|
||||
@@ -2279,7 +2324,7 @@ func TestUpdateDatasetDocumentRejectsZeroImmutableFields(t *testing.T) {
|
||||
t.Fatalf("prepare document: %v", err)
|
||||
}
|
||||
|
||||
_, code, err := testDocumentService(t).UpdateDatasetDocument(t.Context(), "tenant-1", "kb-1", "doc-1", &tt.request, tt.present)
|
||||
_, code, err := testDocumentService(t).UpdateDatasetDocument(t.Context(), "user-1", "kb-1", "doc-1", &tt.request, tt.present)
|
||||
if err == nil {
|
||||
t.Fatalf("expected %s mutation error", tt.name)
|
||||
}
|
||||
@@ -2399,7 +2444,7 @@ func TestUpdateDatasetDocumentRejectsUnsupportedParserIDForVisualDoc(t *testing.
|
||||
parseType := 1
|
||||
svc := testDocumentService(t)
|
||||
ctx := t.Context()
|
||||
_, code, err := svc.UpdateDatasetDocument(ctx, "tenant-1", "kb-1", "doc-1", &UpdateDatasetDocumentRequest{
|
||||
_, code, err := svc.UpdateDatasetDocument(ctx, "user-1", "kb-1", "doc-1", &UpdateDatasetDocumentRequest{
|
||||
ParserID: &parserID,
|
||||
ParseType: &parseType,
|
||||
}, map[string]bool{"parser_id": true, "parse_type": true})
|
||||
@@ -2425,7 +2470,7 @@ func TestUpdateDatasetDocumentRenameUpdatesDocumentAndFile(t *testing.T) {
|
||||
newName := "new.pdf"
|
||||
svc := testDocumentService(t)
|
||||
ctx := t.Context()
|
||||
resp, code, err := svc.UpdateDatasetDocument(ctx, "tenant-1", "kb-1", "doc-1", &UpdateDatasetDocumentRequest{
|
||||
resp, code, err := svc.UpdateDatasetDocument(ctx, "user-1", "kb-1", "doc-1", &UpdateDatasetDocumentRequest{
|
||||
Name: &newName,
|
||||
}, map[string]bool{"name": true})
|
||||
if err != nil {
|
||||
@@ -2455,7 +2500,7 @@ func TestUpdateDatasetDocumentParserIDResetsForReparse(t *testing.T) {
|
||||
chunkMethod := "manual"
|
||||
svc := testDocumentService(t)
|
||||
ctx := t.Context()
|
||||
resp, code, err := svc.UpdateDatasetDocument(ctx, "tenant-1", "kb-1", "doc-1", &UpdateDatasetDocumentRequest{
|
||||
resp, code, err := svc.UpdateDatasetDocument(ctx, "user-1", "kb-1", "doc-1", &UpdateDatasetDocumentRequest{
|
||||
ParserID: &chunkMethod,
|
||||
ParseType: &parseType,
|
||||
}, map[string]bool{"parser_id": true, "parse_type": true})
|
||||
@@ -3036,7 +3081,7 @@ func TestUpdateDatasetDocumentPropagatesMetadataDeleteFailure(t *testing.T) {
|
||||
svc.docEngine = engine
|
||||
svc.metadataSvc = service.NewMetadataServiceForTest(nil, nil)
|
||||
ctx := t.Context()
|
||||
_, code, err := svc.UpdateDatasetDocument(ctx, "tenant-1", "kb-1", "doc-1", &UpdateDatasetDocumentRequest{
|
||||
_, code, err := svc.UpdateDatasetDocument(ctx, "user-1", "kb-1", "doc-1", &UpdateDatasetDocumentRequest{
|
||||
MetaFields: map[string]any{"new": "value"},
|
||||
}, map[string]bool{"meta_fields": true})
|
||||
if err == nil {
|
||||
@@ -3375,7 +3420,7 @@ func TestUpdateDatasetDocumentPipelineIDTakesPrecedenceOverParserID(t *testing.T
|
||||
parseType := 2
|
||||
svc := testDocumentService(t)
|
||||
ctx := t.Context()
|
||||
resp, code, err := svc.UpdateDatasetDocument(ctx, "tenant-1", "kb-1", "doc-1", &UpdateDatasetDocumentRequest{
|
||||
resp, code, err := svc.UpdateDatasetDocument(ctx, "user-1", "kb-1", "doc-1", &UpdateDatasetDocumentRequest{
|
||||
PipelineID: &pipelineID,
|
||||
ParseType: &parseType,
|
||||
}, map[string]bool{"pipeline_id": true, "parse_type": true})
|
||||
@@ -3410,7 +3455,7 @@ func TestUpdateDatasetDocumentParseTypeBuiltin(t *testing.T) {
|
||||
pipelineIDEmpty := ""
|
||||
svc := testDocumentService(t)
|
||||
ctx := t.Context()
|
||||
resp, code, err := svc.UpdateDatasetDocument(ctx, "tenant-1", "kb-1", "doc-1", &UpdateDatasetDocumentRequest{
|
||||
resp, code, err := svc.UpdateDatasetDocument(ctx, "user-1", "kb-1", "doc-1", &UpdateDatasetDocumentRequest{
|
||||
ParseType: &parseType,
|
||||
ParserID: &parserID,
|
||||
PipelineID: &pipelineIDEmpty,
|
||||
@@ -3445,7 +3490,7 @@ func TestUpdateDatasetDocumentParseTypePipeline(t *testing.T) {
|
||||
pipelineID := "1234567890abcdef1234567890abcdef"
|
||||
svc := testDocumentService(t)
|
||||
ctx := t.Context()
|
||||
resp, code, err := svc.UpdateDatasetDocument(ctx, "tenant-1", "kb-1", "doc-1", &UpdateDatasetDocumentRequest{
|
||||
resp, code, err := svc.UpdateDatasetDocument(ctx, "user-1", "kb-1", "doc-1", &UpdateDatasetDocumentRequest{
|
||||
ParseType: &parseType,
|
||||
PipelineID: &pipelineID,
|
||||
}, map[string]bool{"parser_id": false, "pipeline_id": true, "parse_type": true})
|
||||
@@ -3475,7 +3520,7 @@ func TestUpdateDatasetDocumentParseTypePipelineRejectsParserID(t *testing.T) {
|
||||
pipelineID := "1234567890abcdef1234567890abcdef"
|
||||
svc := testDocumentService(t)
|
||||
ctx := t.Context()
|
||||
_, code, err := svc.UpdateDatasetDocument(ctx, "tenant-1", "kb-1", "doc-1", &UpdateDatasetDocumentRequest{
|
||||
_, code, err := svc.UpdateDatasetDocument(ctx, "user-1", "kb-1", "doc-1", &UpdateDatasetDocumentRequest{
|
||||
ParseType: &parseType,
|
||||
ParserID: &parserID,
|
||||
PipelineID: &pipelineID,
|
||||
@@ -3506,7 +3551,7 @@ func TestUpdateDatasetDocumentParentChildConfigSurvivesDSLFailure(t *testing.T)
|
||||
|
||||
parseType := 2
|
||||
pipelineID := "1234567890abcdef1234567890abcdef"
|
||||
resp, code, err := testDocumentService(t).UpdateDatasetDocument(t.Context(), "tenant-1", "kb-1", "doc-1", &UpdateDatasetDocumentRequest{
|
||||
resp, code, err := testDocumentService(t).UpdateDatasetDocument(t.Context(), "user-1", "kb-1", "doc-1", &UpdateDatasetDocumentRequest{
|
||||
ParseType: &parseType,
|
||||
PipelineID: &pipelineID,
|
||||
ParserConfig: map[string]any{
|
||||
@@ -3543,7 +3588,7 @@ func TestUpdateDatasetDocumentRejectsInvalidPages(t *testing.T) {
|
||||
parserID := "manual"
|
||||
svc := testDocumentService(t)
|
||||
ctx := t.Context()
|
||||
_, code, err := svc.UpdateDatasetDocument(ctx, "tenant-1", "kb-1", "doc-1", &UpdateDatasetDocumentRequest{
|
||||
_, code, err := svc.UpdateDatasetDocument(ctx, "user-1", "kb-1", "doc-1", &UpdateDatasetDocumentRequest{
|
||||
ParseType: &parseType,
|
||||
ParserID: &parserID,
|
||||
ParserConfig: map[string]any{
|
||||
@@ -3568,7 +3613,7 @@ func TestUpdateDatasetDocumentParentChildConfigReachesGeneralChunker(t *testing.
|
||||
insertTestKB(t, "kb-1", "tenant-1", 1, 10, 5)
|
||||
insertNamedTestDoc(t, "doc-1", "kb-1", "doc.txt", 10, 5)
|
||||
|
||||
resp, code, err := testDocumentService(t).UpdateDatasetDocument(t.Context(), "tenant-1", "kb-1", "doc-1", &UpdateDatasetDocumentRequest{
|
||||
resp, code, err := testDocumentService(t).UpdateDatasetDocument(t.Context(), "user-1", "kb-1", "doc-1", &UpdateDatasetDocumentRequest{
|
||||
ParserConfig: map[string]any{
|
||||
"GeneralChunker:SixApplesFall": map[string]any{
|
||||
"parent_child": map[string]any{
|
||||
@@ -3606,7 +3651,7 @@ func TestUpdateDatasetDocumentEnabledUpdatesStatus(t *testing.T) {
|
||||
enabled := 0
|
||||
svc := testDocumentService(t)
|
||||
ctx := t.Context()
|
||||
resp, code, err := svc.UpdateDatasetDocument(ctx, "tenant-1", "kb-1", "doc-1", &UpdateDatasetDocumentRequest{
|
||||
resp, code, err := svc.UpdateDatasetDocument(ctx, "user-1", "kb-1", "doc-1", &UpdateDatasetDocumentRequest{
|
||||
Enabled: &enabled,
|
||||
}, map[string]bool{"enabled": true})
|
||||
if err != nil {
|
||||
@@ -3826,17 +3871,15 @@ func TestGetThumbnails_AlignsWithPythonFormatting(t *testing.T) {
|
||||
insertTestKB(t, "kb-1", "tenant-1", 0, 0, 0)
|
||||
insertTestKB(t, "kb-2", "tenant-1", 0, 0, 0)
|
||||
insertTestKB(t, "kb-other", "tenant-other", 0, 0, 0)
|
||||
if err := db.Where("tenant_id = ? AND role = ?", "tenant-other", "owner").Delete(&entity.UserTenant{}).Error; err != nil {
|
||||
t.Fatalf("remove fixture owner: %v", err)
|
||||
}
|
||||
if err := db.Create(&entity.UserTenant{
|
||||
ID: "user-1_tenant-1",
|
||||
UserID: "user-1",
|
||||
TenantID: "tenant-1",
|
||||
Role: "owner",
|
||||
InvitedBy: "user-1",
|
||||
Status: sptr("1"),
|
||||
ID: "other-owner_tenant-other", UserID: "other-owner", TenantID: "tenant-other",
|
||||
Role: "owner", InvitedBy: "other-owner", Status: sptr(string(entity.StatusValid)),
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed user tenant: %v", err)
|
||||
t.Fatalf("seed other tenant owner: %v", err)
|
||||
}
|
||||
|
||||
base64Thumb := "data:image/png;base64,AAAA"
|
||||
fileThumb := "thumb.png"
|
||||
otherThumb := "secret.png"
|
||||
@@ -3938,6 +3981,7 @@ func TestGetDocumentImageForDocumentAllowsIndexedSharedBucketImage(t *testing.T)
|
||||
|
||||
kbID := strings.Repeat("b", 32)
|
||||
status := string(entity.StatusValid)
|
||||
ensureTestTenantOwner(t, "user-1", "user-1")
|
||||
if err := db.Create(&entity.Knowledgebase{ID: kbID, TenantID: "user-1", Name: "kb", EmbdID: "embd", Status: &status}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -3991,7 +4035,7 @@ func TestGetDocumentThumbnailAuthorizesBeforeStorage(t *testing.T) {
|
||||
if store.getCalls != 0 {
|
||||
t.Fatalf("storage Get called %d times after authorization denial", store.getCalls)
|
||||
}
|
||||
got, err := svc.GetDocumentThumbnail(ctx, "owner-1", "doc-thumb")
|
||||
got, err := svc.GetDocumentThumbnail(ctx, "user-1", "doc-thumb")
|
||||
if err != nil || !bytes.Equal(got, image) {
|
||||
t.Fatalf("authorized GetDocumentThumbnail() = %q, %v", got, err)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ import (
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/permission"
|
||||
permissionresponse "ragflow/internal/permission/response"
|
||||
"ragflow/internal/utility"
|
||||
)
|
||||
|
||||
@@ -40,8 +42,6 @@ var (
|
||||
ErrLinkFileNotFound = errors.New("File not found!")
|
||||
// ErrLinkDatasetNotFound mirrors Python "Can't find this dataset!".
|
||||
ErrLinkDatasetNotFound = errors.New("Can't find this dataset!")
|
||||
// ErrLinkNoAuthorization mirrors Python "no authorization".
|
||||
ErrLinkNoAuthorization = errors.New("no authorization")
|
||||
// ErrLinkInternal is a generic, safe-to-expose internal failure.
|
||||
ErrLinkInternal = errors.New("Internal server error.")
|
||||
)
|
||||
@@ -133,14 +133,16 @@ func (s *File2DocumentService) LinkToDatasets(ctx context.Context, userID string
|
||||
return ErrLinkFileNotFound
|
||||
}
|
||||
if !service.CheckFileTeamPermission(ctx, s.fileDAO, file, userID) {
|
||||
return ErrLinkNoAuthorization
|
||||
_, permissionErr := permissionresponse.Normalize(permission.ErrPermissionDenied)
|
||||
return permissionErr
|
||||
}
|
||||
}
|
||||
|
||||
// ── 5. Validate KB permissions ────────────────────────────────────────────
|
||||
for _, kb := range kbMap {
|
||||
if !service.HasKBTeamPermission(ctx, kb, userID, dao.NewTenantDAO()) {
|
||||
return ErrLinkNoAuthorization
|
||||
if err := service.CheckDatasetAccess(ctx, permission.Subject{UserID: userID}, kb.ID, permission.OperationUpdate); err != nil {
|
||||
_, permissionErr := permissionresponse.Normalize(err)
|
||||
return permissionErr
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/permission"
|
||||
)
|
||||
|
||||
// CheckFileTeamPermission reports whether userID may access the given file: either
|
||||
@@ -21,13 +22,8 @@ func CheckFileTeamPermission(ctx context.Context, fileDAO *dao.FileDAO, file *en
|
||||
return false
|
||||
}
|
||||
|
||||
kbDAO := dao.NewKnowledgebaseDAO()
|
||||
for _, datasetID := range datasetIDs {
|
||||
kb, err := kbDAO.GetByID(ctx, dao.DB, datasetID)
|
||||
if err != nil || kb == nil {
|
||||
continue
|
||||
}
|
||||
if HasKBTeamPermission(ctx, kb, userID, dao.NewTenantDAO()) {
|
||||
if CheckDatasetAccess(ctx, permission.Subject{UserID: userID}, datasetID, permission.OperationRead) == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ import (
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/permission"
|
||||
permissionresponse "ragflow/internal/permission/response"
|
||||
"ragflow/internal/utility"
|
||||
)
|
||||
|
||||
@@ -389,10 +391,9 @@ func (s *SearchService) PrepareCompletion(ctx context.Context, userID, searchID
|
||||
}
|
||||
|
||||
for _, datasetID := range datasetIDs {
|
||||
accessible = s.datasetDAO.Accessible(ctx, dao.DB, datasetID, userID)
|
||||
if !accessible {
|
||||
// Mirror Python's search completion endpoint message.
|
||||
return nil, common.CodeDataError, fmt.Errorf("You don't own the dataset %s", datasetID)
|
||||
if err := CheckDatasetAccess(ctx, permission.Subject{UserID: userID}, datasetID, permission.OperationUse); err != nil {
|
||||
code, permissionErr := permissionresponse.Normalize(err)
|
||||
return nil, code, permissionErr
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
)
|
||||
|
||||
// HasKBTeamPermission mirrors Python check_kb_team_permission:
|
||||
// direct owner access is always allowed; otherwise the KB must be team-shared
|
||||
// and the caller must be a joined normal member of the owner tenant.
|
||||
func HasKBTeamPermission(ctx context.Context, kb *entity.Knowledgebase, userID string, tenantDAO *dao.TenantDAO) bool {
|
||||
if kb == nil {
|
||||
return false
|
||||
}
|
||||
if kb.TenantID == userID {
|
||||
return true
|
||||
}
|
||||
if kb.Permission != string(entity.TenantPermissionTeam) {
|
||||
return false
|
||||
}
|
||||
joinedTenants, err := tenantDAO.GetJoinedTenantsByUserID(ctx, dao.DB, userID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, tenant := range joinedTenants {
|
||||
if tenant.TenantID == kb.TenantID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -271,8 +271,8 @@ def test_chunk_add_invalid_dataset_and_document_contract(rest_client, create_doc
|
||||
)
|
||||
assert invalid_dataset_res.status_code == 200
|
||||
invalid_dataset_payload = invalid_dataset_res.json()
|
||||
assert invalid_dataset_payload["code"] == 102, invalid_dataset_payload
|
||||
assert invalid_dataset_payload["message"] == f"You don't own the dataset {INVALID_ID_32}.", invalid_dataset_payload
|
||||
assert invalid_dataset_payload["code"] == 404, invalid_dataset_payload
|
||||
assert invalid_dataset_payload["message"] == "Resource not found", invalid_dataset_payload
|
||||
|
||||
invalid_document_res = rest_client.post(
|
||||
f"/datasets/{dataset_id}/documents/{INVALID_ID_32}/chunks",
|
||||
|
||||
@@ -560,14 +560,14 @@ def test_dataset_update_identifier_validation_contract(rest_client):
|
||||
not_uuid1_res = rest_client.put(f"/datasets/{uuid.uuid4().hex}", json=payload)
|
||||
assert not_uuid1_res.status_code == 200
|
||||
not_uuid1_payload = not_uuid1_res.json()
|
||||
assert not_uuid1_payload["code"] == 102, not_uuid1_payload
|
||||
assert "lacks permission for dataset" in not_uuid1_payload["message"], not_uuid1_payload
|
||||
assert not_uuid1_payload["code"] == 404, not_uuid1_payload
|
||||
assert not_uuid1_payload["message"] == "Resource not found", not_uuid1_payload
|
||||
|
||||
wrong_uuid_res = rest_client.put("/datasets/d94a8dc02c9711f0930f7fbc369eab6d", json=payload)
|
||||
assert wrong_uuid_res.status_code == 200
|
||||
wrong_uuid_payload = wrong_uuid_res.json()
|
||||
assert wrong_uuid_payload["code"] == 102, wrong_uuid_payload
|
||||
assert "lacks permission for dataset" in wrong_uuid_payload["message"], wrong_uuid_payload
|
||||
assert wrong_uuid_payload["code"] == 404, wrong_uuid_payload
|
||||
assert wrong_uuid_payload["message"] == "Resource not found", wrong_uuid_payload
|
||||
|
||||
|
||||
@pytest.mark.p2
|
||||
@@ -1312,14 +1312,14 @@ def test_dataset_delete_contract_matrix(rest_client, clear_datasets):
|
||||
id_not_uuid1_res = rest_client.delete("/datasets", json={"ids": [uuid.uuid4().hex]})
|
||||
assert id_not_uuid1_res.status_code == 200
|
||||
id_not_uuid1_payload = id_not_uuid1_res.json()
|
||||
assert id_not_uuid1_payload["code"] == 102, id_not_uuid1_payload
|
||||
assert "lacks permission for dataset" in id_not_uuid1_payload["message"], id_not_uuid1_payload
|
||||
assert id_not_uuid1_payload["code"] == 403, id_not_uuid1_payload
|
||||
assert id_not_uuid1_payload["message"] == "Permission denied", id_not_uuid1_payload
|
||||
|
||||
id_wrong_uuid_res = rest_client.delete("/datasets", json={"ids": ["d94a8dc02c9711f0930f7fbc369eab6d"]})
|
||||
assert id_wrong_uuid_res.status_code == 200
|
||||
id_wrong_uuid_payload = id_wrong_uuid_res.json()
|
||||
assert id_wrong_uuid_payload["code"] == 102, id_wrong_uuid_payload
|
||||
assert "lacks permission for dataset" in id_wrong_uuid_payload["message"], id_wrong_uuid_payload
|
||||
assert id_wrong_uuid_payload["code"] == 403, id_wrong_uuid_payload
|
||||
assert id_wrong_uuid_payload["message"] == "Permission denied", id_wrong_uuid_payload
|
||||
|
||||
list_res = rest_client.get("/datasets")
|
||||
assert list_res.status_code == 200
|
||||
@@ -1335,8 +1335,8 @@ def test_dataset_delete_contract_matrix(rest_client, clear_datasets):
|
||||
partial_invalid_res = rest_client.delete("/datasets", json={"ids": invalid_ids})
|
||||
assert partial_invalid_res.status_code == 200
|
||||
partial_invalid_payload = partial_invalid_res.json()
|
||||
assert partial_invalid_payload["code"] == 102, partial_invalid_payload
|
||||
assert "lacks permission for dataset" in partial_invalid_payload["message"], partial_invalid_payload
|
||||
assert partial_invalid_payload["code"] == 403, partial_invalid_payload
|
||||
assert partial_invalid_payload["message"] == "Permission denied", partial_invalid_payload
|
||||
|
||||
duplicate_ids_res = rest_client.delete("/datasets", json={"ids": remaining_ids + remaining_ids})
|
||||
assert duplicate_ids_res.status_code == 200
|
||||
@@ -1353,8 +1353,8 @@ def test_dataset_delete_contract_matrix(rest_client, clear_datasets):
|
||||
second_delete_res = rest_client.delete("/datasets", json=repeated_delete_payload)
|
||||
assert second_delete_res.status_code == 200
|
||||
second_delete_payload = second_delete_res.json()
|
||||
assert second_delete_payload["code"] == 102, second_delete_payload
|
||||
assert "lacks permission for dataset" in second_delete_payload["message"], second_delete_payload
|
||||
assert second_delete_payload["code"] == 403, second_delete_payload
|
||||
assert second_delete_payload["message"] == "Permission denied", second_delete_payload
|
||||
|
||||
unsupported_field_res = rest_client.delete("/datasets", json={"unknown_field": "unknown_field"})
|
||||
assert unsupported_field_res.status_code == 200
|
||||
@@ -1558,8 +1558,8 @@ def test_dataset_list_query_contract_matrix(rest_client, clear_datasets):
|
||||
name_wrong_res = rest_client.get("/datasets", params={"name": "wrong name"})
|
||||
assert name_wrong_res.status_code == 200
|
||||
name_wrong_payload = name_wrong_res.json()
|
||||
assert name_wrong_payload["code"] == 102, name_wrong_payload
|
||||
assert "lacks permission for dataset" in name_wrong_payload["message"], name_wrong_payload
|
||||
assert name_wrong_payload["code"] == 0, name_wrong_payload
|
||||
assert name_wrong_payload["data"] == [], name_wrong_payload
|
||||
|
||||
name_empty_res = rest_client.get("/datasets", params={"name": ""})
|
||||
assert name_empty_res.status_code == 200
|
||||
@@ -1589,14 +1589,14 @@ def test_dataset_list_query_contract_matrix(rest_client, clear_datasets):
|
||||
id_not_uuid1_res = rest_client.get("/datasets", params={"id": uuid.uuid4().hex})
|
||||
assert id_not_uuid1_res.status_code == 200
|
||||
id_not_uuid1_payload = id_not_uuid1_res.json()
|
||||
assert id_not_uuid1_payload["code"] == 102, id_not_uuid1_payload
|
||||
assert "lacks permission for dataset" in id_not_uuid1_payload["message"], id_not_uuid1_payload
|
||||
assert id_not_uuid1_payload["code"] == 404, id_not_uuid1_payload
|
||||
assert id_not_uuid1_payload["message"] == "Resource not found", id_not_uuid1_payload
|
||||
|
||||
id_wrong_uuid_res = rest_client.get("/datasets", params={"id": "d94a8dc02c9711f0930f7fbc369eab6d"})
|
||||
assert id_wrong_uuid_res.status_code == 200
|
||||
id_wrong_uuid_payload = id_wrong_uuid_res.json()
|
||||
assert id_wrong_uuid_payload["code"] == 102, id_wrong_uuid_payload
|
||||
assert "lacks permission for dataset" in id_wrong_uuid_payload["message"], id_wrong_uuid_payload
|
||||
assert id_wrong_uuid_payload["code"] == 404, id_wrong_uuid_payload
|
||||
assert id_wrong_uuid_payload["message"] == "Resource not found", id_wrong_uuid_payload
|
||||
|
||||
id_empty_res = rest_client.get("/datasets", params={"id": ""})
|
||||
assert id_empty_res.status_code == 200
|
||||
@@ -1622,12 +1622,17 @@ def test_dataset_list_query_contract_matrix(rest_client, clear_datasets):
|
||||
assert name_id_mismatch_payload["code"] == 0, name_id_mismatch_payload
|
||||
assert len(name_id_mismatch_payload["data"]) == 0, name_id_mismatch_payload
|
||||
|
||||
for dataset_id, name in ((dataset_ids[0], "wrong_name"), (uuid.uuid1().hex, "dataset_0")):
|
||||
name_id_wrong_res = rest_client.get("/datasets", params={"id": dataset_id, "name": name})
|
||||
assert name_id_wrong_res.status_code == 200
|
||||
name_id_wrong_payload = name_id_wrong_res.json()
|
||||
assert name_id_wrong_payload["code"] == 102, name_id_wrong_payload
|
||||
assert "lacks permission for dataset" in name_id_wrong_payload["message"], name_id_wrong_payload
|
||||
name_id_wrong_res = rest_client.get("/datasets", params={"id": dataset_ids[0], "name": "wrong_name"})
|
||||
assert name_id_wrong_res.status_code == 200
|
||||
name_id_wrong_payload = name_id_wrong_res.json()
|
||||
assert name_id_wrong_payload["code"] == 0, name_id_wrong_payload
|
||||
assert name_id_wrong_payload["data"] == [], name_id_wrong_payload
|
||||
|
||||
missing_id_name_res = rest_client.get("/datasets", params={"id": uuid.uuid1().hex, "name": "dataset_0"})
|
||||
assert missing_id_name_res.status_code == 200
|
||||
missing_id_name_payload = missing_id_name_res.json()
|
||||
assert missing_id_name_payload["code"] == 404, missing_id_name_payload
|
||||
assert missing_id_name_payload["message"] == "Resource not found", missing_id_name_payload
|
||||
|
||||
unsupported_field_res = rest_client.get("/datasets", params={"unknown_field": "unknown_field"})
|
||||
assert unsupported_field_res.status_code == 200
|
||||
@@ -1709,8 +1714,8 @@ def test_dataset_metadata_config_get_and_update_contract(rest_client, create_dat
|
||||
invalid_dataset_res = rest_client.get("/datasets/invalid_dataset_id/metadata/config")
|
||||
assert invalid_dataset_res.status_code == 200
|
||||
invalid_dataset_payload = invalid_dataset_res.json()
|
||||
assert invalid_dataset_payload["code"] == 102, invalid_dataset_payload
|
||||
assert "lacks permission for dataset 'invalid_dataset_id'" in invalid_dataset_payload["message"], invalid_dataset_payload
|
||||
assert invalid_dataset_payload["code"] == 404, invalid_dataset_payload
|
||||
assert invalid_dataset_payload["message"] == "Resource not found", invalid_dataset_payload
|
||||
|
||||
update_payload = {
|
||||
"metadata": [
|
||||
@@ -1758,8 +1763,8 @@ def test_dataset_metadata_config_get_and_update_contract(rest_client, create_dat
|
||||
)
|
||||
assert invalid_update_dataset_res.status_code == 200
|
||||
invalid_update_dataset_payload = invalid_update_dataset_res.json()
|
||||
assert invalid_update_dataset_payload["code"] == 102, invalid_update_dataset_payload
|
||||
assert "lacks permission for dataset 'invalid_dataset_id'" in invalid_update_dataset_payload["message"], invalid_update_dataset_payload
|
||||
assert invalid_update_dataset_payload["code"] == 404, invalid_update_dataset_payload
|
||||
assert invalid_update_dataset_payload["message"] == "Resource not found", invalid_update_dataset_payload
|
||||
|
||||
|
||||
def test_dataset_metadata_summary_contract(rest_client, create_dataset, tmp_path):
|
||||
|
||||
@@ -584,8 +584,8 @@ def test_documents_update_invalid_dataset_and_document_contract(rest_client, cre
|
||||
)
|
||||
assert invalid_dataset_res.status_code == 200
|
||||
invalid_dataset_body = invalid_dataset_res.json()
|
||||
assert invalid_dataset_body["code"] == 102, invalid_dataset_body
|
||||
assert "you don't own the dataset" in invalid_dataset_body["message"], invalid_dataset_body
|
||||
assert invalid_dataset_body["code"] == 404, invalid_dataset_body
|
||||
assert invalid_dataset_body["message"] == "Resource not found", invalid_dataset_body
|
||||
|
||||
invalid_document_res = rest_client.patch(
|
||||
f"/datasets/{dataset_id}/documents/{INVALID_ID_32}",
|
||||
@@ -1007,8 +1007,8 @@ def test_document_metadata_config_contract(rest_client, create_document):
|
||||
)
|
||||
assert invalid_dataset_res.status_code == 200
|
||||
invalid_dataset_payload = invalid_dataset_res.json()
|
||||
assert invalid_dataset_payload["code"] == 102, invalid_dataset_payload
|
||||
assert invalid_dataset_payload["message"] == "you don't own the dataset", invalid_dataset_payload
|
||||
assert invalid_dataset_payload["code"] == 404, invalid_dataset_payload
|
||||
assert invalid_dataset_payload["message"] == "Resource not found", invalid_dataset_payload
|
||||
|
||||
invalid_document_res = rest_client.put(
|
||||
f"/datasets/{dataset_id}/documents/{INVALID_ID_32}/metadata/config",
|
||||
@@ -1487,15 +1487,15 @@ def test_documents_download_requires_auth_and_invalid_id_contract(rest_client, c
|
||||
invalid_doc_res = _download_document_to_file(rest_client, dataset_id, "invalid_document_id", invalid_doc_path)
|
||||
assert invalid_doc_res.status_code == 200
|
||||
invalid_doc_payload = invalid_doc_res.json()
|
||||
assert invalid_doc_payload["code"] == 102, invalid_doc_payload
|
||||
assert invalid_doc_payload["message"] == "document not found", invalid_doc_payload
|
||||
assert invalid_doc_payload["code"] == 404, invalid_doc_payload
|
||||
assert invalid_doc_payload["message"] == "Resource not found", invalid_doc_payload
|
||||
|
||||
invalid_dataset_path = tmp_path / "invalid_dataset_download.txt"
|
||||
invalid_dataset_res = _download_document_to_file(rest_client, "invalid_dataset_id", document_id, invalid_dataset_path)
|
||||
assert invalid_dataset_res.status_code == 200
|
||||
invalid_dataset_payload = invalid_dataset_res.json()
|
||||
assert invalid_dataset_payload["code"] == 102, invalid_dataset_payload
|
||||
assert invalid_dataset_payload["message"] == "document not found", invalid_dataset_payload
|
||||
assert invalid_dataset_payload["code"] == 404, invalid_dataset_payload
|
||||
assert invalid_dataset_payload["message"] == "Resource not found", invalid_dataset_payload
|
||||
|
||||
|
||||
@pytest.mark.p3
|
||||
|
||||
@@ -146,5 +146,5 @@ def test_search_completion_sse_shape_when_kb_ids_provided(rest_client, search_re
|
||||
)
|
||||
assert res.status_code == 200
|
||||
payload = res.json()
|
||||
assert payload["code"] == 102, payload
|
||||
assert "You don't own the dataset nonexistent_dataset" in payload["message"], payload
|
||||
assert payload["code"] == 404, payload
|
||||
assert payload["message"] == "Resource not found", payload
|
||||
|
||||
Reference in New Issue
Block a user