diff --git a/controller/admin_scope.go b/controller/admin_scope.go new file mode 100644 index 000000000000..c7a92a1efab4 --- /dev/null +++ b/controller/admin_scope.go @@ -0,0 +1,84 @@ +package controller + +import ( + "net/http" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/i18n" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service/authz" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +func canViewAllChannels(c *gin.Context) bool { + return authz.Can(c.GetInt("id"), c.GetInt("role"), authz.ChannelReadAll) +} + +func canReadUsers(c *gin.Context) bool { + return authz.Can(c.GetInt("id"), c.GetInt("role"), authz.UserRead) +} + +func applyChannelScope(c *gin.Context, query *gorm.DB) *gorm.DB { + if canViewAllChannels(c) { + return query + } + return query.Where("creator_id = ?", c.GetInt("id")) +} + +func visibleChannelIDs(c *gin.Context) (ids []int, unrestricted bool, err error) { + if canViewAllChannels(c) { + return nil, true, nil + } + err = model.DB.Model(&model.Channel{}). + Where("creator_id = ?", c.GetInt("id")). + Pluck("id", &ids).Error + return ids, false, err +} + +func ensureChannelVisible(c *gin.Context, channelID int) bool { + if canViewAllChannels(c) { + return true + } + var count int64 + err := model.DB.Model(&model.Channel{}). + Where("id = ? AND creator_id = ?", channelID, c.GetInt("id")). + Count(&count).Error + if err == nil && count > 0 { + return true + } + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": common.TranslateMessage(c, i18n.MsgAuthInsufficientPrivilege), + }) + return false +} + +func ensureChannelsVisible(c *gin.Context, channelIDs []int) bool { + if len(channelIDs) == 0 || canViewAllChannels(c) { + return true + } + var count int64 + err := model.DB.Model(&model.Channel{}). + Where("id IN ? AND creator_id = ?", channelIDs, c.GetInt("id")). + Count(&count).Error + if err == nil && count == int64(len(channelIDs)) { + return true + } + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": common.TranslateMessage(c, i18n.MsgAuthInsufficientPrivilege), + }) + return false +} + +func requireAllChannelScope(c *gin.Context) bool { + if canViewAllChannels(c) { + return true + } + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": common.TranslateMessage(c, i18n.MsgAuthInsufficientPrivilege), + }) + return false +} diff --git a/controller/channel-billing.go b/controller/channel-billing.go index 751ee3600ac9..d04bc476b46d 100644 --- a/controller/channel-billing.go +++ b/controller/channel-billing.go @@ -427,6 +427,9 @@ func UpdateChannelBalance(c *gin.Context) { common.ApiError(c, err) return } + if !ensureChannelVisible(c, id) { + return + } channel, err := model.CacheGetChannel(id) if err != nil { common.ApiError(c, err) @@ -482,6 +485,9 @@ func updateAllChannelsBalance() error { } func UpdateAllChannelsBalance(c *gin.Context) { + if !requireAllChannelScope(c) { + return + } // TODO: make it async err := updateAllChannelsBalance() if err != nil { diff --git a/controller/channel-test.go b/controller/channel-test.go index 4ba3698bd54c..d31c7b991110 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -831,6 +831,9 @@ func TestChannel(c *gin.Context) { common.ApiError(c, err) return } + if !ensureChannelVisible(c, channelId) { + return + } channel, err := model.CacheGetChannel(channelId) if err != nil { channel, err = model.GetChannelById(channelId, true) @@ -1033,6 +1036,9 @@ func selectChannelsForAutomaticTest(channels []*model.Channel, mode string) []*m // test loop inline. If any channel_test task is already active, the manual run is // rejected so the caller does not mistake a scheduled run for this manual one. func TestAllChannels(c *gin.Context) { + if !requireAllChannelScope(c) { + return + } task, created, err := service.EnqueueSystemTask(model.SystemTaskTypeChannelTest, channelTestTaskPayload{ Mode: operation_setting.ChannelTestModeScheduledAll, Notify: true, diff --git a/controller/channel.go b/controller/channel.go index a00011a9f9c1..57c9800421e2 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -81,8 +81,9 @@ func applyChannelStatusFilter(query *gorm.DB, statusFilter int) *gorm.DB { return query } -func buildChannelListQuery(group string, statusFilter int, typeFilter int) *gorm.DB { +func buildChannelListQuery(c *gin.Context, group string, statusFilter int, typeFilter int) *gorm.DB { query := model.DB.Model(&model.Channel{}) + query = applyChannelScope(c, query) query = model.ApplyChannelGroupFilter(query, group) query = applyChannelStatusFilter(query, statusFilter) if typeFilter >= 0 { @@ -119,13 +120,13 @@ func GetAllChannels(c *gin.Context) { var total int64 if enableTagMode { - tags, err := model.GetPaginatedChannelTags(buildChannelListQuery(groupFilter, statusFilter, typeFilter), pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + tags, err := model.GetPaginatedChannelTags(buildChannelListQuery(c, groupFilter, statusFilter, typeFilter), pageInfo.GetStartIdx(), pageInfo.GetPageSize()) if err != nil { common.SysError("failed to get paginated tags: " + err.Error()) c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取标签失败,请稍后重试"}) return } - total, err = model.CountChannelTags(buildChannelListQuery(groupFilter, statusFilter, typeFilter)) + total, err = model.CountChannelTags(buildChannelListQuery(c, groupFilter, statusFilter, typeFilter)) if err != nil { common.SysError("failed to count tags: " + err.Error()) c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取标签数量失败,请稍后重试"}) @@ -136,7 +137,7 @@ func GetAllChannels(c *gin.Context) { continue } var tagChannels []*model.Channel - err := sortOptions.Apply(buildChannelListQuery(groupFilter, statusFilter, typeFilter).Where("tag = ?", *tag)). + err := sortOptions.Apply(buildChannelListQuery(c, groupFilter, statusFilter, typeFilter).Where("tag = ?", *tag)). Omit("key"). Find(&tagChannels).Error if err != nil { @@ -147,13 +148,13 @@ func GetAllChannels(c *gin.Context) { channelData = append(channelData, tagChannels...) } } else { - if err := buildChannelListQuery(groupFilter, statusFilter, typeFilter).Count(&total).Error; err != nil { + if err := buildChannelListQuery(c, groupFilter, statusFilter, typeFilter).Count(&total).Error; err != nil { common.SysError("failed to count channels: " + err.Error()) c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取渠道数量失败,请稍后重试"}) return } - err := sortOptions.Apply(buildChannelListQuery(groupFilter, statusFilter, typeFilter)). + err := sortOptions.Apply(buildChannelListQuery(c, groupFilter, statusFilter, typeFilter)). Limit(pageInfo.GetPageSize()). Offset(pageInfo.GetStartIdx()). Omit("key"). @@ -169,7 +170,7 @@ func GetAllChannels(c *gin.Context) { clearChannelInfo(datum) } - countQuery := buildChannelListQuery(groupFilter, statusFilter, -1) + countQuery := buildChannelListQuery(c, groupFilter, statusFilter, -1) var results []struct { Type int64 Count int64 @@ -233,6 +234,9 @@ func FetchUpstreamModels(c *gin.Context) { common.ApiError(c, err) return } + if !ensureChannelVisible(c, id) { + return + } channel, err := model.GetChannelById(id, true) if err != nil { @@ -294,7 +298,7 @@ func SearchChannels(c *gin.Context) { for _, tag := range tags { if tag != nil && *tag != "" { var tagChannels []*model.Channel - err := sortOptions.Apply(buildChannelListQuery(group, -1, -1).Where("tag = ?", *tag)). + err := sortOptions.Apply(buildChannelListQuery(c, group, -1, -1).Where("tag = ?", *tag)). Omit("key"). Find(&tagChannels).Error if err != nil { @@ -308,7 +312,9 @@ func SearchChannels(c *gin.Context) { } } } else { - channels, err := model.SearchChannels(keyword, group, modelKeyword, idSort, sortOptions) + channels, err := model.SearchChannelsScoped(keyword, group, modelKeyword, idSort, func(query *gorm.DB) *gorm.DB { + return applyChannelScope(c, query) + }, sortOptions) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, @@ -400,6 +406,9 @@ func GetChannel(c *gin.Context) { common.ApiError(c, err) return } + if !ensureChannelVisible(c, id) { + return + } channel, err := model.GetChannelById(id, false) if err != nil { common.ApiError(c, err) @@ -424,6 +433,9 @@ func GetChannelKey(c *gin.Context) { common.ApiError(c, fmt.Errorf("渠道ID格式错误: %v", err)) return } + if !ensureChannelVisible(c, channelId) { + return + } // 获取渠道信息(包含密钥) channel, err := model.GetChannelById(channelId, true) @@ -540,6 +552,9 @@ func RefreshCodexChannelCredential(c *gin.Context) { common.ApiError(c, fmt.Errorf("invalid channel id: %w", err)) return } + if !ensureChannelVisible(c, channelId) { + return + } ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() @@ -623,6 +638,7 @@ func AddChannel(c *gin.Context) { } addChannelRequest.Channel.CreatedTime = common.GetTimestamp() + addChannelRequest.Channel.CreatorId = c.GetInt("id") keys := make([]string, 0) switch addChannelRequest.Mode { case "multi_to_single": @@ -712,6 +728,9 @@ func AddChannel(c *gin.Context) { func DeleteChannel(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) + if !ensureChannelVisible(c, id) { + return + } channelName := "" if existing, err := model.GetChannelById(id, false); err == nil && existing != nil { channelName = existing.Name @@ -735,6 +754,9 @@ func DeleteChannel(c *gin.Context) { } func DeleteDisabledChannel(c *gin.Context) { + if !requireAllChannelScope(c) { + return + } rows, err := model.DeleteDisabledChannel() if err != nil { common.ApiError(c, err) @@ -774,6 +796,9 @@ func DisableTagChannels(c *gin.Context) { }) return } + if !requireAllChannelScope(c) { + return + } err = model.DisableChannelByTag(channelTag.Tag) if err != nil { common.ApiError(c, err) @@ -800,6 +825,9 @@ func EnableTagChannels(c *gin.Context) { }) return } + if !requireAllChannelScope(c) { + return + } err = model.EnableChannelByTag(channelTag.Tag) if err != nil { common.ApiError(c, err) @@ -833,6 +861,9 @@ func EditTagChannels(c *gin.Context) { }) return } + if !requireAllChannelScope(c) { + return + } if (channelTag.ParamOverride != nil || channelTag.HeaderOverride != nil) && !authz.Can(c.GetInt("id"), c.GetInt("role"), authz.ChannelSensitiveWrite) { common.ApiErrorI18n(c, i18n.MsgAuthInsufficientPrivilege) @@ -891,6 +922,9 @@ func DeleteChannelBatch(c *gin.Context) { }) return } + if !ensureChannelsVisible(c, channelBatch.Ids) { + return + } err = model.BatchDeleteChannels(channelBatch.Ids) if err != nil { common.ApiError(c, err) @@ -962,6 +996,9 @@ func UpdateChannel(c *gin.Context) { }) return } + if !ensureChannelVisible(c, channel.Id) { + return + } // Always copy the original ChannelInfo so that fields like IsMultiKey and MultiKeySize are retained. channel.ChannelInfo = originChannel.ChannelInfo @@ -1107,6 +1144,9 @@ func UpdateChannelStatus(c *gin.Context) { common.ApiErrorI18n(c, i18n.MsgInvalidParams) return } + if !ensureChannelVisible(c, id) { + return + } changed := model.UpdateChannelStatus(id, "", req.Status, "manual operation") if changed { model.InitChannelCache() @@ -1130,6 +1170,9 @@ func BatchUpdateChannelStatus(c *gin.Context) { common.ApiErrorI18n(c, i18n.MsgInvalidParams) return } + if !ensureChannelsVisible(c, req.Ids) { + return + } changedCount := 0 for _, id := range req.Ids { if model.UpdateChannelStatus(id, "", req.Status, "manual batch operation") { @@ -1312,6 +1355,9 @@ func BatchSetChannelTag(c *gin.Context) { }) return } + if !ensureChannelsVisible(c, channelBatch.Ids) { + return + } err = model.BatchSetChannelTag(channelBatch.Ids, channelBatch.Tag) if err != nil { common.ApiError(c, err) @@ -1339,7 +1385,9 @@ func GetTagModels(c *gin.Context) { return } - channels, err := model.GetChannelsByTag(tag, false, false) // idSort=false, selectAll=false + query := applyChannelScope(c, model.DB.Where("tag = ?", tag)).Omit("key") + var channels []*model.Channel + err := query.Find(&channels).Error if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "success": false, @@ -1382,6 +1430,9 @@ func CopyChannel(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"success": false, "message": "invalid id"}) return } + if !ensureChannelVisible(c, id) { + return + } suffix := c.DefaultQuery("suffix", "_复制") resetBalance := true @@ -1403,6 +1454,7 @@ func CopyChannel(c *gin.Context) { clone := *origin // shallow copy is sufficient as we will overwrite primitives clone.Id = 0 // let DB auto-generate clone.CreatedTime = common.GetTimestamp() + clone.CreatorId = c.GetInt("id") clone.Name = origin.Name + suffix clone.TestTime = 0 clone.ResponseTime = 0 @@ -1466,6 +1518,9 @@ func ManageMultiKeys(c *gin.Context) { common.ApiError(c, err) return } + if !ensureChannelVisible(c, request.ChannelId) { + return + } channel, err := model.GetChannelById(request.ChannelId, true) if err != nil { @@ -1960,6 +2015,9 @@ func OllamaPullModel(c *gin.Context) { } // 获取渠道信息 + if !ensureChannelVisible(c, req.ChannelID) { + return + } channel, err := model.GetChannelById(req.ChannelID, true) if err != nil { c.JSON(http.StatusNotFound, gin.H{ @@ -2023,6 +2081,9 @@ func OllamaPullModelStream(c *gin.Context) { } // 获取渠道信息 + if !ensureChannelVisible(c, req.ChannelID) { + return + } channel, err := model.GetChannelById(req.ChannelID, true) if err != nil { c.JSON(http.StatusNotFound, gin.H{ @@ -2105,6 +2166,9 @@ func OllamaDeleteModel(c *gin.Context) { } // 获取渠道信息 + if !ensureChannelVisible(c, req.ChannelID) { + return + } channel, err := model.GetChannelById(req.ChannelID, true) if err != nil { c.JSON(http.StatusNotFound, gin.H{ @@ -2154,6 +2218,9 @@ func OllamaVersion(c *gin.Context) { }) return } + if !ensureChannelVisible(c, id) { + return + } channel, err := model.GetChannelById(id, true) if err != nil { diff --git a/controller/channel_authz.go b/controller/channel_authz.go index f85ffef92769..8e9c8fe100a0 100644 --- a/controller/channel_authz.go +++ b/controller/channel_authz.go @@ -83,6 +83,7 @@ var channelOperationalFields = map[string]struct{}{ // channel edit endpoint must ignore even if a client sends them. var channelReadOnlyFields = map[string]struct{}{ "created_time": {}, + "creator_id": {}, "test_time": {}, "response_time": {}, "balance": {}, @@ -94,6 +95,9 @@ func clearChannelReadOnlyFields(channel *PatchChannel, requestData map[string]an if _, ok := requestData["created_time"]; ok { channel.CreatedTime = 0 } + if _, ok := requestData["creator_id"]; ok { + channel.CreatorId = 0 + } if _, ok := requestData["test_time"]; ok { channel.TestTime = 0 } diff --git a/controller/channel_authz_test.go b/controller/channel_authz_test.go index 0a57eac50dd7..bb65d62242b2 100644 --- a/controller/channel_authz_test.go +++ b/controller/channel_authz_test.go @@ -99,6 +99,7 @@ func TestChannelHasSensitiveChanges(t *testing.T) { func TestClearChannelReadOnlyFields(t *testing.T) { channel := PatchChannel{Channel: model.Channel{ CreatedTime: 11, + CreatorId: 7, TestTime: 22, ResponseTime: 33, Balance: 44.5, @@ -110,6 +111,7 @@ func TestClearChannelReadOnlyFields(t *testing.T) { clearChannelReadOnlyFields(&channel, map[string]any{ "created_time": channel.CreatedTime, + "creator_id": channel.CreatorId, "test_time": channel.TestTime, "response_time": channel.ResponseTime, "balance": channel.Balance, @@ -120,6 +122,7 @@ func TestClearChannelReadOnlyFields(t *testing.T) { }) assert.Zero(t, channel.CreatedTime) + assert.Zero(t, channel.CreatorId) assert.Zero(t, channel.TestTime) assert.Zero(t, channel.ResponseTime) assert.Zero(t, channel.Balance) diff --git a/controller/channel_test_internal_test.go b/controller/channel_test_internal_test.go index aa98ab830585..5783f15f2c42 100644 --- a/controller/channel_test_internal_test.go +++ b/controller/channel_test_internal_test.go @@ -120,6 +120,7 @@ func TestTestAllChannelsRejectsExistingActiveTask(t *testing.T) { recorder := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(recorder) + ctx.Set("role", common.RoleRootUser) ctx.Request = httptest.NewRequest(http.MethodPost, "/api/channel/test", nil) TestAllChannels(ctx) diff --git a/controller/channel_upstream_update.go b/controller/channel_upstream_update.go index 2c63a691b63d..5daabb9d4dd6 100644 --- a/controller/channel_upstream_update.go +++ b/controller/channel_upstream_update.go @@ -830,6 +830,9 @@ func ApplyChannelUpstreamModelUpdates(c *gin.Context) { }) return } + if !ensureChannelVisible(c, req.ID) { + return + } channel, err := model.GetChannelById(req.ID, true) if err != nil { @@ -886,6 +889,9 @@ func DetectChannelUpstreamModelUpdates(c *gin.Context) { }) return } + if !ensureChannelVisible(c, req.ID) { + return + } channel, err := model.GetChannelById(req.ID, true) if err != nil { @@ -985,6 +991,9 @@ func findEnabledChannelsAfterID(lastID int, batchSize int) ([]*model.Channel, er } func ApplyAllChannelUpstreamModelUpdates(c *gin.Context) { + if !requireAllChannelScope(c) { + return + } results := make([]applyAllChannelUpstreamModelUpdatesResult, 0) failed := make([]int, 0) refreshNeeded := false @@ -1075,6 +1084,9 @@ func ApplyAllChannelUpstreamModelUpdates(c *gin.Context) { // manual run is rejected so the caller does not mistake a scheduled run for this // manual one. func DetectAllChannelUpstreamModelUpdates(c *gin.Context) { + if !requireAllChannelScope(c) { + return + } task, created, err := service.EnqueueSystemTask(model.SystemTaskTypeModelUpdate, modelUpdateTaskPayload{Manual: true}) if err != nil { common.ApiError(c, err) diff --git a/controller/channel_upstream_update_test.go b/controller/channel_upstream_update_test.go index 9265c93b9c02..881b3ef35bb4 100644 --- a/controller/channel_upstream_update_test.go +++ b/controller/channel_upstream_update_test.go @@ -9,7 +9,6 @@ import ( "testing" "github.com/QuantumNous/new-api/common" - "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/model" "github.com/gin-gonic/gin" @@ -563,6 +562,7 @@ func TestDetectAllChannelUpstreamModelUpdatesRejectsExistingActiveTask(t *testin recorder := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(recorder) + ctx.Set("role", common.RoleRootUser) ctx.Request = httptest.NewRequest(http.MethodPost, "/api/channel/upstream-models/detect-all", nil) DetectAllChannelUpstreamModelUpdates(ctx) diff --git a/controller/codex_usage.go b/controller/codex_usage.go index 10e5abe2057a..338cebfb0656 100644 --- a/controller/codex_usage.go +++ b/controller/codex_usage.go @@ -63,6 +63,9 @@ func fetchCodexChannelWhamData( common.ApiError(c, fmt.Errorf("invalid channel id: %w", err)) return } + if !ensureChannelVisible(c, channelId) { + return + } ch, err := model.GetChannelById(channelId, true) if err != nil { diff --git a/controller/log.go b/controller/log.go index ce9b4666fa5e..b879cab8c07e 100644 --- a/controller/log.go +++ b/controller/log.go @@ -10,6 +10,19 @@ import ( "github.com/gin-gonic/gin" ) +func adminLogVisibilityScope(c *gin.Context) (*model.LogVisibilityScope, error) { + channelIDs, unrestricted, err := visibleChannelIDs(c) + if err != nil { + return nil, err + } + return &model.LogVisibilityScope{ + UserID: c.GetInt("id"), + ChannelIDs: channelIDs, + AllChannels: unrestricted, + IncludeOtherUsersNonChannel: canReadUsers(c), + }, nil +} + func GetAllLogs(c *gin.Context) { pageInfo := common.GetPageQuery(c) logType, _ := strconv.Atoi(c.Query("type")) @@ -22,7 +35,12 @@ func GetAllLogs(c *gin.Context) { group := c.Query("group") requestId := c.Query("request_id") upstreamRequestId := c.Query("upstream_request_id") - logs, total, err := model.GetAllLogs(logType, startTimestamp, endTimestamp, modelName, username, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), channel, group, requestId, upstreamRequestId) + scope, err := adminLogVisibilityScope(c) + if err != nil { + common.ApiError(c, err) + return + } + logs, total, err := model.GetAllLogsScoped(logType, startTimestamp, endTimestamp, modelName, username, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), channel, group, requestId, upstreamRequestId, scope) if err != nil { common.ApiError(c, err) return @@ -104,7 +122,12 @@ func GetLogsStat(c *gin.Context) { modelName := c.Query("model_name") channel, _ := strconv.Atoi(c.Query("channel")) group := c.Query("group") - stat, err := model.SumUsedQuota(logType, startTimestamp, endTimestamp, modelName, username, tokenName, channel, group) + scope, err := adminLogVisibilityScope(c) + if err != nil { + common.ApiError(c, err) + return + } + stat, err := model.SumUsedQuotaScoped(logType, startTimestamp, endTimestamp, modelName, username, tokenName, channel, group, scope) if err != nil { common.ApiError(c, err) return diff --git a/controller/midjourney.go b/controller/midjourney.go index bf52314a7581..8f0ee6cc101c 100644 --- a/controller/midjourney.go +++ b/controller/midjourney.go @@ -304,8 +304,13 @@ func GetAllMidjourney(c *gin.Context) { EndTimestamp: c.Query("end_timestamp"), } - items := model.GetAllTasks(pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams) - total := model.CountAllTasks(queryParams) + scope, err := adminTaskVisibilityScope(c) + if err != nil { + common.ApiError(c, err) + return + } + items := model.GetAllTasksScoped(pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams, scope) + total := model.CountAllTasksScoped(queryParams, scope) if setting.MjForwardUrlEnabled { for i, midjourney := range items { diff --git a/controller/task.go b/controller/task.go index a80f1a687aab..f5a43e9360bc 100644 --- a/controller/task.go +++ b/controller/task.go @@ -13,6 +13,18 @@ import ( "github.com/gin-gonic/gin" ) +func adminTaskVisibilityScope(c *gin.Context) (*model.TaskVisibilityScope, error) { + channelIDs, unrestricted, err := visibleChannelIDs(c) + if err != nil { + return nil, err + } + return &model.TaskVisibilityScope{ + UserID: c.GetInt("id"), + ChannelIDs: channelIDs, + AllChannels: unrestricted, + }, nil +} + func GetAllTask(c *gin.Context) { pageInfo := common.GetPageQuery(c) @@ -29,8 +41,13 @@ func GetAllTask(c *gin.Context) { ChannelID: c.Query("channel_id"), } - items := model.TaskGetAllTasks(pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams) - total := model.TaskCountAllTasks(queryParams) + scope, err := adminTaskVisibilityScope(c) + if err != nil { + common.ApiError(c, err) + return + } + items := model.TaskGetAllTasksScoped(pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams, scope) + total := model.TaskCountAllTasksScoped(queryParams, scope) pageInfo.SetTotal(int(total)) pageInfo.SetItems(tasksToDto(items, true)) common.ApiSuccess(c, pageInfo) diff --git a/model/channel.go b/model/channel.go index 7326f28c6196..8045b0cdb97a 100644 --- a/model/channel.go +++ b/model/channel.go @@ -30,6 +30,7 @@ type Channel struct { Name string `json:"name" gorm:"index"` Weight *uint `json:"weight" gorm:"default:0"` CreatedTime int64 `json:"created_time" gorm:"bigint"` + CreatorId int `json:"creator_id" gorm:"index;default:0"` TestTime int64 `json:"test_time" gorm:"bigint"` ResponseTime int `json:"response_time"` // in milliseconds BaseURL *string `json:"base_url" gorm:"column:base_url;default:''"` @@ -377,6 +378,10 @@ func GetChannelsByTag(tag string, idSort bool, selectAll bool, sortOptions ...Ch } func SearchChannels(keyword string, group string, model string, idSort bool, sortOptions ...ChannelSortOptions) ([]*Channel, error) { + return SearchChannelsScoped(keyword, group, model, idSort, nil, sortOptions...) +} + +func SearchChannelsScoped(keyword string, group string, model string, idSort bool, scope func(*gorm.DB) *gorm.DB, sortOptions ...ChannelSortOptions) ([]*Channel, error) { var channels []*Channel modelsCol := "`models`" @@ -395,6 +400,9 @@ func SearchChannels(keyword string, group string, model string, idSort bool, sor // 构造基础查询 baseQuery := DB.Model(&Channel{}).Omit("key") + if scope != nil { + baseQuery = scope(baseQuery) + } // 构造WHERE子句 whereClause := "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + " LIKE ?" diff --git a/model/log.go b/model/log.go index 506bd504b686..6075310c6b73 100644 --- a/model/log.go +++ b/model/log.go @@ -80,6 +80,37 @@ type Log struct { Other string `json:"other"` } +type LogVisibilityScope struct { + UserID int + ChannelIDs []int + AllChannels bool + IncludeOtherUsersNonChannel bool +} + +func (scope LogVisibilityScope) Apply(tx *gorm.DB, prefix string) *gorm.DB { + conditions := make([]string, 0, 3) + args := make([]any, 0, 3) + userCol := prefix + "user_id" + channelCol := prefix + "channel_id" + if scope.UserID > 0 { + conditions = append(conditions, userCol+" = ?") + args = append(args, scope.UserID) + } + if scope.AllChannels { + conditions = append(conditions, channelCol+" <> 0") + } else if len(scope.ChannelIDs) > 0 { + conditions = append(conditions, channelCol+" IN ?") + args = append(args, scope.ChannelIDs) + } + if scope.IncludeOtherUsersNonChannel { + conditions = append(conditions, channelCol+" = 0") + } + if len(conditions) == 0 { + return tx.Where("1 = 0") + } + return tx.Where("("+strings.Join(conditions, " OR ")+")", args...) +} + // don't use iota, avoid change log type value const ( LogTypeUnknown = 0 @@ -466,12 +497,19 @@ func RecordTaskBillingLog(params RecordTaskBillingLogParams) { } func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, startIdx int, num int, channel int, group string, requestId string, upstreamRequestId string) (logs []*Log, total int64, err error) { + return GetAllLogsScoped(logType, startTimestamp, endTimestamp, modelName, username, tokenName, startIdx, num, channel, group, requestId, upstreamRequestId, nil) +} + +func GetAllLogsScoped(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, startIdx int, num int, channel int, group string, requestId string, upstreamRequestId string, scope *LogVisibilityScope) (logs []*Log, total int64, err error) { var tx *gorm.DB if logType == LogTypeUnknown { tx = LOG_DB } else { tx = LOG_DB.Where("logs.type = ?", logType) } + if scope != nil { + tx = scope.Apply(tx, "logs.") + } if tx, err = applyExplicitLogTextFilter(tx, "logs.model_name", modelName); err != nil { return nil, 0, err @@ -616,11 +654,19 @@ type Stat struct { } func SumUsedQuota(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, channel int, group string) (stat Stat, err error) { + return SumUsedQuotaScoped(logType, startTimestamp, endTimestamp, modelName, username, tokenName, channel, group, nil) +} + +func SumUsedQuotaScoped(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, channel int, group string, scope *LogVisibilityScope) (stat Stat, err error) { tx := LOG_DB.Table("logs").Select("COALESCE(sum(quota), 0) quota") // 为rpm和tpm创建单独的查询 rpmTpmQuery := LOG_DB.Table("logs").Select("count(*) rpm, COALESCE(sum(prompt_tokens), 0) + COALESCE(sum(completion_tokens), 0) tpm") + if scope != nil { + tx = scope.Apply(tx, "") + rpmTpmQuery = scope.Apply(rpmTpmQuery, "") + } if tx, err = applyExplicitLogTextFilter(tx, "username", username); err != nil { return stat, err } diff --git a/model/log_visibility_scope_test.go b/model/log_visibility_scope_test.go new file mode 100644 index 000000000000..f81dc9cbc90f --- /dev/null +++ b/model/log_visibility_scope_test.go @@ -0,0 +1,60 @@ +package model + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "gorm.io/gorm/utils/tests" +) + +// visibilitySQL runs the scope through a DryRun query and returns the generated +// WHERE clause. The scope only builds predicates; DryRun keeps this deterministic +// and table-schema-free. +func visibilitySQL(t *testing.T, apply func(*gorm.DB) *gorm.DB) string { + t.Helper() + db, err := gorm.Open(tests.DummyDialector{}, &gorm.Config{DryRun: true}) + require.NoError(t, err) + query := apply(db.Table("logs")) + var rows []Log + require.NoError(t, query.Find(&rows).Error) + return query.Statement.SQL.String() +} + +func TestLogVisibilityScopeEmptyMatchesNothing(t *testing.T) { + scope := LogVisibilityScope{} + sql := visibilitySQL(t, func(tx *gorm.DB) *gorm.DB { + return scope.Apply(tx, "logs.") + }) + assert.Contains(t, sql, "1 = 0") +} + +func TestLogVisibilityScopeOwnedChannelsAndSelf(t *testing.T) { + // No channel.read_all, no user.read: own logs + own channels only. + scope := LogVisibilityScope{ + UserID: 5, + ChannelIDs: []int{10, 11}, + } + sql := visibilitySQL(t, func(tx *gorm.DB) *gorm.DB { + return scope.Apply(tx, "logs.") + }) + assert.Contains(t, sql, "logs.user_id = ?") + assert.Contains(t, sql, "logs.channel_id IN (?,?)") + assert.Contains(t, sql, " OR ") +} + +func TestLogVisibilityScopeAllChannelsExcludesZeroChannel(t *testing.T) { + // channel.read_all uses <> 0 so historical channel_id=0 logs are channel-related + // and only surfaced when user.read also applies. + scope := LogVisibilityScope{ + UserID: 5, + AllChannels: true, + IncludeOtherUsersNonChannel: true, + } + sql := visibilitySQL(t, func(tx *gorm.DB) *gorm.DB { + return scope.Apply(tx, "logs.") + }) + assert.Contains(t, sql, "logs.channel_id <> 0") + assert.Contains(t, sql, "logs.channel_id = 0") +} diff --git a/model/midjourney.go b/model/midjourney.go index 201f774ca38e..e00eccdf3a6a 100644 --- a/model/midjourney.go +++ b/model/midjourney.go @@ -61,11 +61,18 @@ func GetAllUserTask(userId int, startIdx int, num int, queryParams TaskQueryPara } func GetAllTasks(startIdx int, num int, queryParams TaskQueryParams) []*Midjourney { + return GetAllTasksScoped(startIdx, num, queryParams, nil) +} + +func GetAllTasksScoped(startIdx int, num int, queryParams TaskQueryParams, scope *TaskVisibilityScope) []*Midjourney { var tasks []*Midjourney var err error // 初始化查询构建器 query := DB + if scope != nil { + query = scope.Apply(query) + } // 添加过滤条件 if queryParams.ChannelID != "" { @@ -197,8 +204,15 @@ func MjBulkUpdateByTaskIds(taskIDs []int, params map[string]any) error { // CountAllTasks returns total midjourney tasks for admin query func CountAllTasks(queryParams TaskQueryParams) int64 { + return CountAllTasksScoped(queryParams, nil) +} + +func CountAllTasksScoped(queryParams TaskQueryParams, scope *TaskVisibilityScope) int64 { var total int64 query := DB.Model(&Midjourney{}) + if scope != nil { + query = scope.Apply(query) + } if queryParams.ChannelID != "" { query = query.Where("channel_id = ?", queryParams.ChannelID) } diff --git a/model/task.go b/model/task.go index cf936ed1f967..3c3108da3095 100644 --- a/model/task.go +++ b/model/task.go @@ -4,12 +4,14 @@ import ( "bytes" "database/sql/driver" "encoding/json" + "strings" "time" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" commonRelay "github.com/QuantumNous/new-api/relay/common" + "gorm.io/gorm" ) type TaskStatus string @@ -170,6 +172,31 @@ type SyncTaskQueryParams struct { UserIDs []int } +type TaskVisibilityScope struct { + UserID int + ChannelIDs []int + AllChannels bool +} + +func (scope TaskVisibilityScope) Apply(query *gorm.DB) *gorm.DB { + conditions := make([]string, 0, 2) + args := make([]any, 0, 2) + if scope.UserID > 0 { + conditions = append(conditions, "user_id = ?") + args = append(args, scope.UserID) + } + if scope.AllChannels { + conditions = append(conditions, "channel_id <> 0") + } else if len(scope.ChannelIDs) > 0 { + conditions = append(conditions, "channel_id IN ?") + args = append(args, scope.ChannelIDs) + } + if len(conditions) == 0 { + return query.Where("1 = 0") + } + return query.Where("("+strings.Join(conditions, " OR ")+")", args...) +} + func InitTask(platform constant.TaskPlatform, relayInfo *commonRelay.RelayInfo) *Task { properties := Properties{} privateData := TaskPrivateData{} @@ -246,11 +273,18 @@ func TaskGetAllUserTask(userId int, startIdx int, num int, queryParams SyncTaskQ } func TaskGetAllTasks(startIdx int, num int, queryParams SyncTaskQueryParams) []*Task { + return TaskGetAllTasksScoped(startIdx, num, queryParams, nil) +} + +func TaskGetAllTasksScoped(startIdx int, num int, queryParams SyncTaskQueryParams, scope *TaskVisibilityScope) []*Task { var tasks []*Task var err error // 初始化查询构建器 query := DB + if scope != nil { + query = scope.Apply(query) + } // 添加过滤条件 if queryParams.ChannelID != "" { @@ -468,8 +502,15 @@ type TaskQuotaUsage struct { // TaskCountAllTasks returns total tasks that match the given query params (admin usage) func TaskCountAllTasks(queryParams SyncTaskQueryParams) int64 { + return TaskCountAllTasksScoped(queryParams, nil) +} + +func TaskCountAllTasksScoped(queryParams SyncTaskQueryParams, scope *TaskVisibilityScope) int64 { var total int64 query := DB.Model(&Task{}) + if scope != nil { + query = scope.Apply(query) + } if queryParams.ChannelID != "" { query = query.Where("channel_id = ?", queryParams.ChannelID) } diff --git a/router/api-router.go b/router/api-router.go index 83f9259b2132..552536abae60 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -3,6 +3,7 @@ package router import ( "github.com/QuantumNous/new-api/controller" "github.com/QuantumNous/new-api/middleware" + "github.com/QuantumNous/new-api/service/authz" // Import oauth package to register providers via init() _ "github.com/QuantumNous/new-api/oauth" @@ -127,23 +128,23 @@ func SetApiRouter(router *gin.Engine) { adminRoute := userRoute.Group("/") adminRoute.Use(middleware.AdminAuth()) { - adminRoute.GET("/", controller.GetAllUsers) - adminRoute.GET("/topup", controller.GetAllTopUps) - adminRoute.POST("/topup/complete", controller.AdminCompleteTopUp) - adminRoute.GET("/search", controller.SearchUsers) - adminRoute.GET("/:id/oauth/bindings", controller.GetUserOAuthBindingsByAdmin) - adminRoute.DELETE("/:id/oauth/bindings/:provider_id", controller.UnbindCustomOAuthByAdmin) - adminRoute.DELETE("/:id/bindings/:binding_type", controller.AdminClearUserBinding) - adminRoute.GET("/:id", controller.GetUser) - adminRoute.POST("/", controller.CreateUser) - adminRoute.POST("/manage", controller.ManageUser) - adminRoute.PUT("/", controller.UpdateUser) - adminRoute.DELETE("/:id", controller.DeleteUser) - adminRoute.DELETE("/:id/reset_passkey", controller.AdminResetPasskey) + adminRoute.GET("/", middleware.RequirePermission(authz.UserRead), controller.GetAllUsers) + adminRoute.GET("/topup", middleware.RequirePermission(authz.UserRead), controller.GetAllTopUps) + adminRoute.POST("/topup/complete", middleware.RequirePermission(authz.UserWrite), controller.AdminCompleteTopUp) + adminRoute.GET("/search", middleware.RequirePermission(authz.UserRead), controller.SearchUsers) + adminRoute.GET("/:id/oauth/bindings", middleware.RequirePermission(authz.UserRead), controller.GetUserOAuthBindingsByAdmin) + adminRoute.DELETE("/:id/oauth/bindings/:provider_id", middleware.RequirePermission(authz.UserWrite), controller.UnbindCustomOAuthByAdmin) + adminRoute.DELETE("/:id/bindings/:binding_type", middleware.RequirePermission(authz.UserWrite), controller.AdminClearUserBinding) + adminRoute.GET("/:id", middleware.RequirePermission(authz.UserRead), controller.GetUser) + adminRoute.POST("/", middleware.RequirePermission(authz.UserWrite), controller.CreateUser) + adminRoute.POST("/manage", middleware.RequirePermission(authz.UserWrite), controller.ManageUser) + adminRoute.PUT("/", middleware.RequirePermission(authz.UserWrite), controller.UpdateUser) + adminRoute.DELETE("/:id", middleware.RequirePermission(authz.UserWrite), controller.DeleteUser) + adminRoute.DELETE("/:id/reset_passkey", middleware.RequirePermission(authz.UserWrite), controller.AdminResetPasskey) // Admin 2FA routes - adminRoute.GET("/2fa/stats", controller.Admin2FAStats) - adminRoute.DELETE("/:id/2fa", controller.AdminDisable2FA) + adminRoute.GET("/2fa/stats", middleware.RequirePermission(authz.UserRead), controller.Admin2FAStats) + adminRoute.DELETE("/:id/2fa", middleware.RequirePermission(authz.UserWrite), controller.AdminDisable2FA) } } diff --git a/service/authz/authz_test.go b/service/authz/authz_test.go index eda3f4add2e9..242bcc6fef5a 100644 --- a/service/authz/authz_test.go +++ b/service/authz/authz_test.go @@ -100,11 +100,16 @@ func TestSetUserPermissionsStoresOnlyOverrides(t *testing.T) { assert.Equal(t, PermissionsMap{ ResourceChannel: { ActionRead: true, + ActionReadAll: true, ActionOperate: true, ActionWrite: false, ActionSensitiveWrite: true, ActionSecretView: false, }, + ResourceUser: { + ActionRead: true, + ActionWrite: true, + }, }, ExplicitUserPermissions(42)) assert.Equal(t, PermissionsMap{ ResourceChannel: { @@ -128,11 +133,16 @@ func TestSetUserPermissionsStoresOnlyOverrides(t *testing.T) { assert.Equal(t, PermissionsMap{ ResourceChannel: { ActionRead: true, + ActionReadAll: true, ActionOperate: true, ActionWrite: true, ActionSensitiveWrite: false, ActionSecretView: false, }, + ResourceUser: { + ActionRead: true, + ActionWrite: true, + }, }, ExplicitUserPermissions(42)) assert.Empty(t, ExplicitUserOverrides(42)) } @@ -222,8 +232,60 @@ func TestCapabilitiesUseCatalogShape(t *testing.T) { capabilities := Capabilities(7, common.RoleAdminUser) assert.True(t, capabilities[ResourceChannel][ActionRead]) + assert.True(t, capabilities[ResourceChannel][ActionReadAll]) assert.True(t, capabilities[ResourceChannel][ActionOperate]) assert.True(t, capabilities[ResourceChannel][ActionWrite]) assert.False(t, capabilities[ResourceChannel][ActionSensitiveWrite]) assert.False(t, capabilities[ResourceChannel][ActionSecretView]) } + +// TestGranularPermissions covers the admin-permission-granularity additions: +// the catalog exposes channel.read_all, user.read, and user.write; root is +// granted everything; the built-in admin role keeps the compatibility grants; +// and an explicit deny overrides the role baseline. +func TestGranularPermissions(t *testing.T) { + db := newAuthzTestDB(t) + require.NoError(t, Init(db)) + + // Catalog surface: the new actions must be registered on their resources. + assert.Contains(t, actionsFor(ResourceChannel), ActionReadAll) + assert.Contains(t, actionsFor(ResourceUser), ActionRead) + assert.Contains(t, actionsFor(ResourceUser), ActionWrite) + + // root is a superuser and short-circuits to allow every permission. + assert.True(t, Can(1, common.RoleRootUser, ChannelReadAll)) + assert.True(t, Can(1, common.RoleRootUser, UserRead)) + assert.True(t, Can(1, common.RoleRootUser, UserWrite)) + + // Built-in admin baseline keeps the compatibility grants. + assert.True(t, Can(2, common.RoleAdminUser, ChannelReadAll)) + assert.True(t, Can(2, common.RoleAdminUser, UserRead)) + assert.True(t, Can(2, common.RoleAdminUser, UserWrite)) + + // A common user has no baseline grants for these resources. + assert.False(t, Can(3, common.RoleCommonUser, ChannelReadAll)) + assert.False(t, Can(3, common.RoleCommonUser, UserRead)) + + // An explicit deny on channel.read_all overrides the admin baseline. + require.NoError(t, SetUserPermissions(2, PermissionsMap{ + ResourceChannel: {ActionReadAll: false}, + })) + assert.False(t, Can(2, common.RoleAdminUser, ChannelReadAll)) + // Other channel grants remain unaffected. + assert.True(t, Can(2, common.RoleAdminUser, ChannelRead)) + assert.True(t, Can(2, common.RoleAdminUser, UserRead)) +} + +func actionsFor(resource string) []string { + for _, def := range registry { + if def.Resource != resource { + continue + } + actions := make([]string, 0, len(def.Actions)) + for _, action := range def.Actions { + actions = append(actions, action.Action) + } + return actions + } + return nil +} diff --git a/service/authz/resources_channel.go b/service/authz/resources_channel.go index f78838306cda..d01c83e03db6 100644 --- a/service/authz/resources_channel.go +++ b/service/authz/resources_channel.go @@ -4,6 +4,7 @@ const ( ResourceChannel = "channel" ActionRead = "read" + ActionReadAll = "read_all" ActionOperate = "operate" ActionWrite = "write" ActionSensitiveWrite = "sensitive_write" @@ -12,6 +13,7 @@ const ( var ( ChannelRead = Permission{Resource: ResourceChannel, Action: ActionRead} + ChannelReadAll = Permission{Resource: ResourceChannel, Action: ActionReadAll} ChannelOperate = Permission{Resource: ResourceChannel, Action: ActionOperate} ChannelWrite = Permission{Resource: ResourceChannel, Action: ActionWrite} ChannelSensitiveWrite = Permission{Resource: ResourceChannel, Action: ActionSensitiveWrite} @@ -29,6 +31,12 @@ func init() { DescriptionKey: "View channel lists and details without secrets.", DefaultRoles: []string{BuiltInRoleAdmin}, }, + { + Action: ActionReadAll, + LabelKey: "View all channels", + DescriptionKey: "View every channel, including channels created by other administrators.", + DefaultRoles: []string{BuiltInRoleAdmin}, + }, { Action: ActionOperate, LabelKey: "Operate channels", diff --git a/service/authz/resources_user.go b/service/authz/resources_user.go new file mode 100644 index 000000000000..ee1f8fd63067 --- /dev/null +++ b/service/authz/resources_user.go @@ -0,0 +1,31 @@ +package authz + +const ( + ResourceUser = "user" +) + +var ( + UserRead = Permission{Resource: ResourceUser, Action: ActionRead} + UserWrite = Permission{Resource: ResourceUser, Action: ActionWrite} +) + +func init() { + RegisterResource(ResourceDefinition{ + Resource: ResourceUser, + LabelKey: "User Management", + Actions: []ActionDefinition{ + { + Action: ActionRead, + LabelKey: "Read users", + DescriptionKey: "View user lists, user details, and non-channel usage logs for other users.", + DefaultRoles: []string{BuiltInRoleAdmin}, + }, + { + Action: ActionWrite, + LabelKey: "Manage users", + DescriptionKey: "Create, update, delete, and manage user accounts.", + DefaultRoles: []string{BuiltInRoleAdmin}, + }, + }, + }) +} diff --git a/web/default/src/components/layout/types.ts b/web/default/src/components/layout/types.ts index 6a2830edc983..80a36675cb58 100644 --- a/web/default/src/components/layout/types.ts +++ b/web/default/src/components/layout/types.ts @@ -34,6 +34,10 @@ type BaseNavItem = { * `useSidebarView`). Route-level guards still enforce access independently. */ requiredRole?: number + requiredPermission?: { + resource: string + action: string + } } /** diff --git a/web/default/src/features/channels/types.ts b/web/default/src/features/channels/types.ts index 96bd5b2cfd12..6bb73c64ee81 100644 --- a/web/default/src/features/channels/types.ts +++ b/web/default/src/features/channels/types.ts @@ -44,6 +44,7 @@ export const channelSchema = z.object({ name: z.string(), weight: z.number().nullish(), created_time: z.number(), + creator_id: z.number().default(0), test_time: z.number(), response_time: z.number(), // in milliseconds base_url: z.string().nullish(), diff --git a/web/default/src/features/usage-logs/components/common-logs-filter-bar.tsx b/web/default/src/features/usage-logs/components/common-logs-filter-bar.tsx index b774aa1cf8b4..6420a2ce5842 100644 --- a/web/default/src/features/usage-logs/components/common-logs-filter-bar.tsx +++ b/web/default/src/features/usage-logs/components/common-logs-filter-bar.tsx @@ -116,7 +116,11 @@ export function CommonLogsFilterBar( const navigate = useNavigate() const queryClient = useQueryClient() const searchParams = route.useSearch() - const { isAdminView: isAdmin } = useLogsViewScope() + const { + isAdminView: isAdmin, + canReadUsers, + canViewChannelLogs, + } = useLogsViewScope() const { sensitiveVisible, setSensitiveVisible } = useUsageLogsContext() const fetchingLogs = useIsFetching({ queryKey: ['logs'] }) @@ -235,8 +239,8 @@ export function CommonLogsFilterBar( const hasExpandedFilters = !!filters.token || - !!filters.username || - !!filters.channel || + (isAdmin && canReadUsers && !!filters.username) || + (isAdmin && canViewChannelLogs && !!filters.channel) || !!filters.requestId || !!filters.upstreamRequestId @@ -246,8 +250,8 @@ export function CommonLogsFilterBar( const expandedFilterCount = [ filters.token, - isAdmin ? filters.username : undefined, - isAdmin ? filters.channel : undefined, + isAdmin && canReadUsers ? filters.username : undefined, + isAdmin && canViewChannelLogs ? filters.channel : undefined, filters.requestId, filters.upstreamRequestId, ].filter(Boolean).length @@ -369,7 +373,7 @@ export function CommonLogsFilterBar( onKeyDown={handleKeyDown} /> - {isAdmin && ( + {isAdmin && canReadUsers && ( ( /> )} - {isAdmin && ( + {isAdmin && canViewChannelLogs && ( (props: TaskLogsFilterBarProps) { const navigate = useNavigate() const queryClient = useQueryClient() const searchParams = route.useSearch() - const { isAdminView: isAdmin } = useLogsViewScope() + const { isAdminView: isAdmin, canViewChannelLogs } = useLogsViewScope() const fetchingLogs = useIsFetching({ queryKey: ['logs'] }) const [filters, setFilters] = useState(() => { @@ -164,7 +164,8 @@ export function TaskLogsFilterBar(props: TaskLogsFilterBarProps) { props.logCategory === 'drawing' ? t('Filter by MjProxy task ID') : t('Filter by task ID') - const hasAdditionalFilters = !!filterValue || !!filters.channel + const hasAdditionalFilters = + !!filterValue || (isAdmin && canViewChannelLogs && !!filters.channel) const dateRangeFilter = ( (props: TaskLogsFilterBarProps) { /> ) - const channelFilter = isAdmin ? ( + const channelFilter = isAdmin && canViewChannelLogs ? ( (props: TaskLogsFilterBarProps) { {channelFilter} } - mobileFilterCount={[filterValue, filters.channel].filter(Boolean).length} + mobileFilterCount={[ + filterValue, + isAdmin && canViewChannelLogs ? filters.channel : undefined, + ].filter(Boolean).length} hasActiveFilters={hasAdditionalFilters} onSearch={handleApply} searchLoading={fetchingLogs > 0} diff --git a/web/default/src/features/usage-logs/components/usage-logs-provider.tsx b/web/default/src/features/usage-logs/components/usage-logs-provider.tsx index 50fa0ed30f85..64914ff05836 100644 --- a/web/default/src/features/usage-logs/components/usage-logs-provider.tsx +++ b/web/default/src/features/usage-logs/components/usage-logs-provider.tsx @@ -19,7 +19,12 @@ For commercial licensing, please contact support@quantumnous.com /* eslint-disable react-refresh/only-export-components */ import { createContext, useContext, useState, type ReactNode } from 'react' -import { useIsAdmin } from '@/hooks/use-admin' +import { + ADMIN_PERMISSION_ACTIONS, + ADMIN_PERMISSION_RESOURCES, + hasPermission, +} from '@/lib/admin-permissions' +import { useAuthStore } from '@/stores/auth-store' import type { ChannelAffinityInfo } from '../types' @@ -92,11 +97,24 @@ export function useUsageLogsContext() { * mine" is treated exactly like a regular user for that view. */ export function useLogsViewScope() { - const canManageScope = useIsAdmin() + const user = useAuthStore((state) => state.auth.user) + const canViewChannelLogs = hasPermission( + user, + ADMIN_PERMISSION_RESOURCES.CHANNEL, + ADMIN_PERMISSION_ACTIONS.READ + ) + const canReadUsers = hasPermission( + user, + ADMIN_PERMISSION_RESOURCES.USER, + ADMIN_PERMISSION_ACTIONS.READ + ) + const canManageScope = canViewChannelLogs || canReadUsers const { viewScope, setViewScope } = useUsageLogsContext() return { canManageScope, + canViewChannelLogs, + canReadUsers, viewScope, setViewScope, isAdminView: canManageScope && viewScope === 'all', diff --git a/web/default/src/features/usage-logs/components/usage-logs-table.tsx b/web/default/src/features/usage-logs/components/usage-logs-table.tsx index bb961239a946..d099ef260bd7 100644 --- a/web/default/src/features/usage-logs/components/usage-logs-table.tsx +++ b/web/default/src/features/usage-logs/components/usage-logs-table.tsx @@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com */ import { useQuery } from '@tanstack/react-query' import { getRouteApi } from '@tanstack/react-router' -import { type ColumnDef } from '@tanstack/react-table' +import type { ColumnDef } from '@tanstack/react-table' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -64,7 +64,12 @@ function getColumnVisibilityStorageKey( } function deserializeLogTypeFilter(value: unknown): unknown[] { - const values = Array.isArray(value) ? value : value ? [value] : [] + let values: unknown[] = [] + if (Array.isArray(value)) { + values = value + } else if (value) { + values = [value] + } return values.filter((item) => String(item) !== LOG_TYPE_ALL_VALUE) } @@ -74,7 +79,11 @@ interface UsageLogsTableProps { export function UsageLogsTable({ logCategory }: UsageLogsTableProps) { const { t } = useTranslation() - const { isAdminView: isAdmin } = useLogsViewScope() + const { + isAdminView: isAdmin, + canReadUsers, + canViewChannelLogs, + } = useLogsViewScope() const isMobile = useMediaQuery('(max-width: 640px)') const searchParams = route.useSearch() @@ -99,13 +108,17 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) { { columnId: 'model_name', searchKey: 'model', type: 'string' as const }, { columnId: 'token_name', searchKey: 'token', type: 'string' as const }, { columnId: 'group', searchKey: 'group', type: 'string' as const }, - ...(isAdmin + ...(isAdmin && canViewChannelLogs ? [ { columnId: 'channel', searchKey: 'channel', type: 'string' as const, }, + ] + : []), + ...(isAdmin && canReadUsers + ? [ { columnId: 'username', searchKey: 'username', @@ -131,6 +144,8 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) { const result = await fetchLogsByCategory({ logCategory, isAdmin, + canReadUsers, + canViewChannelLogs, page: pagination.pageIndex + 1, pageSize: pagination.pageSize, searchParams, diff --git a/web/default/src/features/usage-logs/lib/utils.ts b/web/default/src/features/usage-logs/lib/utils.ts index 22a648f87f1b..b911a1b807b4 100644 --- a/web/default/src/features/usage-logs/lib/utils.ts +++ b/web/default/src/features/usage-logs/lib/utils.ts @@ -176,8 +176,18 @@ export function buildApiParams(config: { searchParams: Record columnFilters?: Array<{ id: string; value: unknown }> isAdmin: boolean + canReadUsers?: boolean + canViewChannelLogs?: boolean }): GetLogsParams { - const { page, pageSize, searchParams, columnFilters = [], isAdmin } = config + const { + page, + pageSize, + searchParams, + columnFilters = [], + isAdmin, + canReadUsers = isAdmin, + canViewChannelLogs = isAdmin, + } = config // Helper to process type parameter (single value from array) const processType = (value: unknown): number | undefined => { @@ -203,10 +213,10 @@ export function buildApiParams(config: { ...(searchParams.model ? { model_name: String(searchParams.model) } : {}), ...(searchParams.token ? { token_name: String(searchParams.token) } : {}), ...(searchParams.group ? { group: String(searchParams.group) } : {}), - ...(isAdmin && searchParams.channel + ...(isAdmin && canViewChannelLogs && searchParams.channel ? { channel: Number(searchParams.channel) || 0 } : {}), - ...(isAdmin && searchParams.username + ...(isAdmin && canReadUsers && searchParams.username ? { username: String(searchParams.username) } : {}), ...(searchParams.requestId @@ -237,10 +247,10 @@ export function buildApiParams(config: { params.group = String(value) break case 'channel': - if (isAdmin) params.channel = Number(value) || 0 + if (isAdmin && canViewChannelLogs) params.channel = Number(value) || 0 break case 'username': - if (isAdmin) params.username = String(value) + if (isAdmin && canReadUsers) params.username = String(value) break } }) @@ -259,8 +269,16 @@ export function buildApiParams(config: { export async function fetchLogsByCategory( config: FetchLogsConfig ): Promise { - const { logCategory, isAdmin, page, pageSize, searchParams, columnFilters } = - config + const { + logCategory, + isAdmin, + canReadUsers, + canViewChannelLogs, + page, + pageSize, + searchParams, + columnFilters, + } = config if (logCategory === 'common') { const params = buildApiParams({ @@ -269,6 +287,8 @@ export async function fetchLogsByCategory( searchParams, columnFilters, isAdmin, + canReadUsers, + canViewChannelLogs, }) return isAdmin ? await getAllLogs(params) : await getUserLogs(params) } @@ -277,7 +297,10 @@ export async function fetchLogsByCategory( const baseParams = buildBaseParams({ page, pageSize, - searchParams, + searchParams: { + ...searchParams, + ...(!isAdmin || !canViewChannelLogs ? { channel: undefined } : {}), + }, useMilliseconds: logCategory === 'drawing', }) diff --git a/web/default/src/features/usage-logs/types.ts b/web/default/src/features/usage-logs/types.ts index a03d393546a3..cc07d5abdb3d 100644 --- a/web/default/src/features/usage-logs/types.ts +++ b/web/default/src/features/usage-logs/types.ts @@ -381,6 +381,8 @@ export interface GetTaskLogsParams { export interface FetchLogsConfig { logCategory: LogCategory isAdmin: boolean + canReadUsers?: boolean + canViewChannelLogs?: boolean page: number pageSize: number searchParams: Record diff --git a/web/default/src/features/users/components/data-table-row-actions.tsx b/web/default/src/features/users/components/data-table-row-actions.tsx index adf8b5cd21ac..2b741d241508 100644 --- a/web/default/src/features/users/components/data-table-row-actions.tsx +++ b/web/default/src/features/users/components/data-table-row-actions.tsx @@ -47,6 +47,12 @@ import { TooltipTrigger, } from '@/components/ui/tooltip' import { UserSubscriptionsDialog } from '@/features/subscriptions/components/dialogs/user-subscriptions-dialog' +import { + ADMIN_PERMISSION_ACTIONS, + ADMIN_PERMISSION_RESOURCES, + hasPermission, +} from '@/lib/admin-permissions' +import { useAuthStore } from '@/stores/auth-store' import { manageUser, resetUserPasskey, resetUserTwoFA } from '../api' import { @@ -67,6 +73,7 @@ interface DataTableRowActionsProps { export function DataTableRowActions({ row }: DataTableRowActionsProps) { const { t } = useTranslation() const user = row.original + const authUser = useAuthStore((state) => state.auth.user) const { setOpen, setCurrentRow, triggerRefresh } = useUsers() const [resetPasskeyOpen, setResetPasskeyOpen] = useState(false) const [resetTwoFAOpen, setResetTwoFAOpen] = useState(false) @@ -134,8 +141,13 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { const isDisabled = user.status === USER_STATUS.DISABLED const isAdmin = user.role >= USER_ROLE.ADMIN const isRoot = user.role === USER_ROLE.ROOT + const canWriteUsers = hasPermission( + authUser, + ADMIN_PERMISSION_RESOURCES.USER, + ADMIN_PERMISSION_ACTIONS.WRITE + ) - if (isUserDeleted(user)) { + if (isUserDeleted(user) || !canWriteUsers) { return null } diff --git a/web/default/src/features/users/components/users-primary-buttons.tsx b/web/default/src/features/users/components/users-primary-buttons.tsx index bf80a3c97dbd..9e191e4c55fe 100644 --- a/web/default/src/features/users/components/users-primary-buttons.tsx +++ b/web/default/src/features/users/components/users-primary-buttons.tsx @@ -20,18 +20,34 @@ import { Plus } from 'lucide-react' import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' +import { + ADMIN_PERMISSION_ACTIONS, + ADMIN_PERMISSION_RESOURCES, + hasPermission, +} from '@/lib/admin-permissions' +import { useAuthStore } from '@/stores/auth-store' import { useUsers } from './users-provider' export function UsersPrimaryButtons() { const { t } = useTranslation() const { setOpen, setCurrentRow } = useUsers() + const user = useAuthStore((state) => state.auth.user) + const canWriteUsers = hasPermission( + user, + ADMIN_PERMISSION_RESOURCES.USER, + ADMIN_PERMISSION_ACTIONS.WRITE + ) const handleCreate = () => { setCurrentRow(null) setOpen('create') } + if (!canWriteUsers) { + return null + } + return (