diff --git a/controller/audit.go b/controller/audit.go index 2e54db4e..cbc23184 100644 --- a/controller/audit.go +++ b/controller/audit.go @@ -91,10 +91,17 @@ func recordManageAudit(c *gin.Context, action string, params map[string]interfac recordManageAuditFor(c, c.GetInt("id"), action, params) } -// recordManageAuditFor 记录一条归属于 logUserId 的管理审计日志(面向用户的操作: -// 对目标用户的额度调整 / 解绑 / 2FA 等,使该用户也能在自己的日志中看到)。 -func recordManageAuditFor(c *gin.Context, logUserId int, action string, params map[string]interface{}) { - model.RecordOperationAuditLog(logUserId, auditContentEN(action, params), c.ClientIP(), action, params, auditOperatorInfo(c), nil) +// recordManageAuditFor 记录一条管理审计日志,日志归属于操作者;targetUserId +// 只表示被操作用户,用于在结构化参数中保留目标上下文。 +func recordManageAuditFor(c *gin.Context, targetUserId int, action string, params map[string]interface{}) { + if params == nil { + params = map[string]interface{}{} + } + operatorUserId := c.GetInt("id") + if _, ok := params["target_user_id"]; !ok && targetUserId > 0 && targetUserId != operatorUserId { + params["target_user_id"] = targetUserId + } + model.RecordOperationAuditLog(operatorUserId, auditContentEN(action, params), c.ClientIP(), action, params, auditOperatorInfo(c), nil) markAuditLogged(c) } diff --git a/controller/authz.go b/controller/authz.go new file mode 100644 index 00000000..801de769 --- /dev/null +++ b/controller/authz.go @@ -0,0 +1,24 @@ +package controller + +import ( + "net/http" + + "github.com/QuantumNous/new-api/service/authz" + + "github.com/gin-gonic/gin" +) + +// GetPermissionCatalog returns the permission schema used by the client to +// render the permission editor: the registry of resources with their actions +// and display label keys, plus the roles with their baseline grant matrices. +// Defining it in the authz package keeps the schema in a single place. +func GetPermissionCatalog(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "resources": authz.Catalog(), + "roles": authz.Roles(), + }, + }) +} diff --git a/controller/channel.go b/controller/channel.go index 5b1cefef..a2b5687a 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -12,11 +12,13 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/i18n" "github.com/QuantumNous/new-api/model" relaychannel "github.com/QuantumNous/new-api/relay/channel" "github.com/QuantumNous/new-api/relay/channel/gemini" "github.com/QuantumNous/new-api/relay/channel/ollama" "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/service/authz" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -820,6 +822,11 @@ func EditTagChannels(c *gin.Context) { }) return } + if (channelTag.ParamOverride != nil || channelTag.HeaderOverride != nil) && + !authz.Can(c.GetInt("id"), c.GetInt("role"), authz.ChannelSensitiveWrite) { + common.ApiErrorI18n(c, i18n.MsgAuthInsufficientPrivilege) + return + } if channelTag.ParamOverride != nil { trimmed := strings.TrimSpace(*channelTag.ParamOverride) if trimmed != "" && !json.Valid([]byte(trimmed)) { @@ -896,13 +903,36 @@ type PatchChannel struct { KeyMode *string `json:"key_mode"` // 多key模式下密钥覆盖或者追加 } +type ChannelStatusRequest struct { + Status int `json:"status"` +} + +type ChannelStatusBatchRequest struct { + Ids []int `json:"ids"` + Status int `json:"status"` +} + func UpdateChannel(c *gin.Context) { channel := PatchChannel{} - err := c.ShouldBindJSON(&channel) + rawBody, err := c.GetRawData() if err != nil { common.ApiError(c, err) return } + if err := common.Unmarshal(rawBody, &channel); err != nil { + common.ApiError(c, err) + return + } + var requestData map[string]any + if err := common.Unmarshal(rawBody, &requestData); err != nil { + common.ApiError(c, err) + return + } + if _, ok := requestData["status"]; ok { + common.ApiErrorI18n(c, i18n.MsgInvalidParams) + return + } + clearChannelReadOnlyFields(&channel, requestData) // 使用统一的校验函数 if err := validateChannel(&channel.Channel, false); err != nil { @@ -925,6 +955,12 @@ func UpdateChannel(c *gin.Context) { // Always copy the original ChannelInfo so that fields like IsMultiKey and MultiKeySize are retained. channel.ChannelInfo = originChannel.ChannelInfo + if channelHasSensitiveChanges(&channel, originChannel, requestData) && + !authz.Can(c.GetInt("id"), c.GetInt("role"), authz.ChannelSensitiveWrite) { + common.ApiErrorI18n(c, i18n.MsgAuthInsufficientPrivilege) + return + } + // If the request explicitly specifies a new MultiKeyMode, apply it on top of the original info. if channel.MultiKeyMode != nil && *channel.MultiKeyMode != "" { channel.ChannelInfo.MultiKeyMode = constant.MultiKeyMode(*channel.MultiKeyMode) @@ -1019,9 +1055,6 @@ func UpdateChannel(c *gin.Context) { service.ResetProxyClientCache() // 记录变更的字段名(语言无关的字段标识),密钥仅记录"已更换"绝不记录内容。 changedFields := make([]string, 0) - if channel.Status != originChannel.Status { - changedFields = append(changedFields, "status") - } if channel.Models != originChannel.Models { changedFields = append(changedFields, "models") } @@ -1052,6 +1085,66 @@ func UpdateChannel(c *gin.Context) { return } +func UpdateChannelStatus(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiErrorI18n(c, i18n.MsgInvalidParams) + return + } + req := ChannelStatusRequest{} + if err := c.ShouldBindJSON(&req); err != nil || !isManageableChannelStatus(req.Status) { + common.ApiErrorI18n(c, i18n.MsgInvalidParams) + return + } + changed := model.UpdateChannelStatus(id, "", req.Status, "manual operation") + if changed { + model.InitChannelCache() + service.ResetProxyClientCache() + } + recordManageAudit(c, "channel.status_update", map[string]interface{}{ + "id": id, + "status": req.Status, + "changed": changed, + }) + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": changed, + }) +} + +func BatchUpdateChannelStatus(c *gin.Context) { + req := ChannelStatusBatchRequest{} + if err := c.ShouldBindJSON(&req); err != nil || len(req.Ids) == 0 || !isManageableChannelStatus(req.Status) { + common.ApiErrorI18n(c, i18n.MsgInvalidParams) + return + } + changedCount := 0 + for _, id := range req.Ids { + if model.UpdateChannelStatus(id, "", req.Status, "manual batch operation") { + changedCount++ + } + } + if changedCount > 0 { + model.InitChannelCache() + service.ResetProxyClientCache() + } + recordManageAudit(c, "channel.status_update_batch", map[string]interface{}{ + "count": changedCount, + "total": len(req.Ids), + "status": req.Status, + }) + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": changedCount, + }) +} + +func isManageableChannelStatus(status int) bool { + return status == common.ChannelStatusEnabled || status == common.ChannelStatusManuallyDisabled +} + // equalStringPtr 比较两个 *string 是否相等(均为 nil 视为相等)。 func equalStringPtr(a, b *string) bool { if a == nil && b == nil { @@ -1364,6 +1457,11 @@ func ManageMultiKeys(c *gin.Context) { }) return } + if multiKeyActionRequiresSensitiveWrite(request.Action) && + !authz.Can(c.GetInt("id"), c.GetInt("role"), authz.ChannelSensitiveWrite) { + common.ApiErrorI18n(c, i18n.MsgAuthInsufficientPrivilege) + return + } // get_key_status 为只读查询,不记录审计;其余为修改操作,记录审计并跳过中间件兜底。 if request.Action == "get_key_status" { @@ -1808,6 +1906,10 @@ func ManageMultiKeys(c *gin.Context) { } } +func multiKeyActionRequiresSensitiveWrite(action string) bool { + return action == "delete_key" || action == "delete_disabled_keys" +} + // OllamaPullModel 拉取 Ollama 模型 func OllamaPullModel(c *gin.Context) { var req struct { diff --git a/controller/channel_authz.go b/controller/channel_authz.go new file mode 100644 index 00000000..f85ffef9 --- /dev/null +++ b/controller/channel_authz.go @@ -0,0 +1,136 @@ +package controller + +import "github.com/QuantumNous/new-api/model" + +func channelHasSensitiveChanges(channel *PatchChannel, origin *model.Channel, requestData map[string]any) bool { + if _, ok := requestData["type"]; ok && channel.Type != origin.Type { + return true + } + if _, ok := requestData["key"]; ok && channel.Key != "" && channel.Key != origin.Key { + return true + } + if _, ok := requestData["base_url"]; ok && !equalStringPtr(channel.BaseURL, origin.BaseURL) { + return true + } + if _, ok := requestData["openai_organization"]; ok && !equalStringPtr(channel.OpenAIOrganization, origin.OpenAIOrganization) { + return true + } + if _, ok := requestData["header_override"]; ok && !equalStringPtr(channel.HeaderOverride, origin.HeaderOverride) { + return true + } + if _, ok := requestData["param_override"]; ok && !equalStringPtr(channel.ParamOverride, origin.ParamOverride) { + return true + } + if _, ok := requestData["setting"]; ok && !equalStringPtr(channel.Setting, origin.Setting) { + return true + } + if _, ok := requestData["other"]; ok && channel.Other != origin.Other { + return true + } + if _, ok := requestData["settings"]; ok && channel.OtherSettings != origin.OtherSettings { + return true + } + if _, ok := requestData["key_mode"]; ok && channel.KeyMode != nil { + return true + } + // Fail closed: any field present in the request that is neither a known + // sensitive field (gated above) nor an explicitly classified non-sensitive + // field must be treated as sensitive. This keeps a newly added channel field + // from silently becoming editable by ChannelWrite-only admins until it is + // consciously classified in channelNonSensitiveFields. + for field := range requestData { + if _, ok := channelSensitiveFields[field]; ok { + continue + } + if _, ok := channelNonSensitiveFields[field]; ok { + continue + } + if _, ok := channelOperationalFields[field]; ok { + continue + } + if _, ok := channelReadOnlyFields[field]; ok { + continue + } + return true + } + return false +} + +// channelSensitiveFields lists the channel fields whose modification requires +// ChannelSensitiveWrite. They are each checked individually in +// channelHasSensitiveChanges with a precise old-vs-new comparison; this set is +// used to exclude them from the fail-closed scan for unknown fields. +var channelSensitiveFields = map[string]struct{}{ + "type": {}, + "key": {}, + "base_url": {}, + "openai_organization": {}, + "header_override": {}, + "param_override": {}, + "setting": {}, + "other": {}, + "settings": {}, + "key_mode": {}, +} + +// channelOperationalFields lists fields managed by operation endpoints instead +// of the general channel edit endpoint. +var channelOperationalFields = map[string]struct{}{ + "status": {}, +} + +// channelReadOnlyFields lists server-managed/accounting fields that the general +// channel edit endpoint must ignore even if a client sends them. +var channelReadOnlyFields = map[string]struct{}{ + "created_time": {}, + "test_time": {}, + "response_time": {}, + "balance": {}, + "balance_updated_time": {}, + "used_quota": {}, +} + +func clearChannelReadOnlyFields(channel *PatchChannel, requestData map[string]any) { + if _, ok := requestData["created_time"]; ok { + channel.CreatedTime = 0 + } + if _, ok := requestData["test_time"]; ok { + channel.TestTime = 0 + } + if _, ok := requestData["response_time"]; ok { + channel.ResponseTime = 0 + } + if _, ok := requestData["balance"]; ok { + channel.Balance = 0 + } + if _, ok := requestData["balance_updated_time"]; ok { + channel.BalanceUpdatedTime = 0 + } + if _, ok := requestData["used_quota"]; ok { + channel.UsedQuota = 0 + } +} + +// channelNonSensitiveFields lists routing / server-managed channel +// fields a ChannelWrite admin may edit without ChannelSensitiveWrite. When a new +// field is added to model.Channel it must be added to either this set or +// channelSensitiveFields or channelOperationalFields; otherwise it falls through +// to the fail-closed branch and is treated as sensitive. The +// TestChannelFieldsAreClassified guard test enforces this. +var channelNonSensitiveFields = map[string]struct{}{ + "id": {}, + "test_model": {}, + "name": {}, + "weight": {}, + "models": {}, + "group": {}, + "model_mapping": {}, + "status_code_mapping": {}, + "priority": {}, + "auto_ban": {}, + "other_info": {}, + "tag": {}, + "remark": {}, + "channel_info": {}, + "multi_key_mode": {}, +} diff --git a/controller/channel_authz_test.go b/controller/channel_authz_test.go new file mode 100644 index 00000000..0a57eac5 --- /dev/null +++ b/controller/channel_authz_test.go @@ -0,0 +1,204 @@ +package controller + +import ( + "bytes" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestChannelHasSensitiveChanges(t *testing.T) { + baseURL := "https://api.example.com" + headerOverride := `{"Authorization":"Bearer {api_key}"}` + origin := &model.Channel{ + Type: 1, + Key: "old-key", + BaseURL: &baseURL, + HeaderOverride: &headerOverride, + Models: "gpt-4o", + Group: "default", + } + + t.Run("non-sensitive routing fields", func(t *testing.T) { + updated := PatchChannel{Channel: *origin} + updated.Models = "gpt-4o,gpt-4o-mini" + updated.Group = "vip" + + assert.False(t, channelHasSensitiveChanges(&updated, origin, map[string]any{ + "models": updated.Models, + "group": updated.Group, + })) + }) + + t.Run("key change", func(t *testing.T) { + updated := PatchChannel{Channel: *origin} + updated.Key = "new-key" + + assert.True(t, channelHasSensitiveChanges(&updated, origin, map[string]any{"key": updated.Key})) + }) + + t.Run("base url change", func(t *testing.T) { + updated := PatchChannel{Channel: *origin} + newBaseURL := "https://leak.example.com" + updated.BaseURL = &newBaseURL + + assert.True(t, channelHasSensitiveChanges(&updated, origin, map[string]any{"base_url": newBaseURL})) + }) + + t.Run("header override change", func(t *testing.T) { + updated := PatchChannel{Channel: *origin} + newHeaderOverride := `{"X-Key":"{api_key}"}` + updated.HeaderOverride = &newHeaderOverride + + assert.True(t, channelHasSensitiveChanges(&updated, origin, map[string]any{"header_override": newHeaderOverride})) + }) + + t.Run("omitted sensitive fields do not use zero values", func(t *testing.T) { + updated := PatchChannel{} + updated.Id = origin.Id + updated.Priority = origin.Priority + + assert.False(t, channelHasSensitiveChanges(&updated, origin, map[string]any{"priority": 10})) + }) + + t.Run("unknown field fails closed", func(t *testing.T) { + updated := PatchChannel{Channel: *origin} + + assert.True(t, channelHasSensitiveChanges(&updated, origin, map[string]any{"future_secret_field": "x"})) + }) + + t.Run("status is operational", func(t *testing.T) { + updated := PatchChannel{Channel: *origin} + updated.Status = common.ChannelStatusManuallyDisabled + + assert.False(t, channelHasSensitiveChanges(&updated, origin, map[string]any{"status": updated.Status})) + }) + + t.Run("read-only fields are ignored by sensitivity check", func(t *testing.T) { + updated := PatchChannel{Channel: *origin} + updated.Balance = 99 + updated.UsedQuota = 100 + updated.ResponseTime = 200 + + assert.False(t, channelHasSensitiveChanges(&updated, origin, map[string]any{ + "balance": updated.Balance, + "used_quota": updated.UsedQuota, + "response_time": updated.ResponseTime, + })) + }) +} + +func TestClearChannelReadOnlyFields(t *testing.T) { + channel := PatchChannel{Channel: model.Channel{ + CreatedTime: 11, + TestTime: 22, + ResponseTime: 33, + Balance: 44.5, + BalanceUpdatedTime: 55, + UsedQuota: 66, + Models: "gpt-4o", + Group: "default", + }} + + clearChannelReadOnlyFields(&channel, map[string]any{ + "created_time": channel.CreatedTime, + "test_time": channel.TestTime, + "response_time": channel.ResponseTime, + "balance": channel.Balance, + "balance_updated_time": channel.BalanceUpdatedTime, + "used_quota": channel.UsedQuota, + "models": channel.Models, + "group": channel.Group, + }) + + assert.Zero(t, channel.CreatedTime) + assert.Zero(t, channel.TestTime) + assert.Zero(t, channel.ResponseTime) + assert.Zero(t, channel.Balance) + assert.Zero(t, channel.BalanceUpdatedTime) + assert.Zero(t, channel.UsedQuota) + assert.Equal(t, "gpt-4o", channel.Models) + assert.Equal(t, "default", channel.Group) +} + +func TestUpdateChannelRejectsStatusField(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest( + http.MethodPut, + "/api/channel/", + bytes.NewBufferString(`{"id":1,"status":2}`), + ) + ctx.Request.Header.Set("Content-Type", "application/json") + + UpdateChannel(ctx) + + require.Equal(t, http.StatusOK, recorder.Code) + var response struct { + Success bool `json:"success"` + Message string `json:"message"` + } + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response)) + assert.False(t, response.Success) +} + +func TestChannelStatusValidation(t *testing.T) { + assert.True(t, isManageableChannelStatus(common.ChannelStatusEnabled)) + assert.True(t, isManageableChannelStatus(common.ChannelStatusManuallyDisabled)) + assert.False(t, isManageableChannelStatus(common.ChannelStatusAutoDisabled)) + assert.False(t, isManageableChannelStatus(0)) +} + +// TestChannelFieldsAreClassified guards the fail-closed sensitivity check: every +// JSON field of PatchChannel (including the embedded model.Channel) must be listed +// in channelSensitiveFields, channelNonSensitiveFields, or +// channelOperationalFields. A newly added field that is left unclassified will +// fail this test, forcing a conscious permission decision instead of silently +// defaulting either way. +func TestChannelFieldsAreClassified(t *testing.T) { + classified := func(name string) bool { + if _, ok := channelSensitiveFields[name]; ok { + return true + } + if _, ok := channelNonSensitiveFields[name]; ok { + return true + } + if _, ok := channelOperationalFields[name]; ok { + return true + } + _, ok := channelReadOnlyFields[name] + return ok + } + + var collect func(rt reflect.Type) []string + collect = func(rt reflect.Type) []string { + var names []string + for i := 0; i < rt.NumField(); i++ { + field := rt.Field(i) + if field.Anonymous && field.Type.Kind() == reflect.Struct { + names = append(names, collect(field.Type)...) + continue + } + name := strings.Split(field.Tag.Get("json"), ",")[0] + if name == "" || name == "-" { + continue + } + names = append(names, name) + } + return names + } + + for _, name := range collect(reflect.TypeOf(PatchChannel{})) { + assert.Truef(t, classified(name), + "channel field %q is not classified; add it to channelSensitiveFields, channelNonSensitiveFields, channelOperationalFields, or channelReadOnlyFields in channel_authz.go", name) + } +} diff --git a/controller/user.go b/controller/user.go index 33c7b1df..190c2b1d 100644 --- a/controller/user.go +++ b/controller/user.go @@ -16,6 +16,7 @@ import ( "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/service/authz" "github.com/QuantumNous/new-api/setting" "github.com/QuantumNous/new-api/setting/operation_setting" @@ -23,6 +24,7 @@ import ( "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" + "gorm.io/gorm" ) type LoginRequest struct { @@ -334,6 +336,7 @@ func GetUser(c *gin.Context) { common.ApiErrorI18n(c, i18n.MsgUserNoPermissionSameLevel) return } + user.AdminPermissions = authz.Capabilities(user.Id, user.Role) c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", @@ -443,6 +446,7 @@ func GetSelf(c *gin.Context) { // 计算用户权限信息 permissions := calculateUserPermissions(userRole) + permissions["admin_permissions"] = authz.Capabilities(id, userRole) // 获取用户设置并提取sidebar_modules userSetting := user.GetSetting() @@ -620,23 +624,41 @@ func UpdateUser(c *gin.Context) { common.ApiError(c, err) return } + if updatedUser.Role != common.RoleGuestUser && updatedUser.Role != originUser.Role { + common.ApiErrorI18n(c, i18n.MsgInvalidParams) + return + } + updatedUser.Role = originUser.Role myRole := c.GetInt("role") if !canManageTargetRole(myRole, originUser.Role) { common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel) return } - if !canManageTargetRole(myRole, updatedUser.Role) { - common.ApiErrorI18n(c, i18n.MsgUserCannotCreateHigherLevel) - return - } if updatedUser.Password == "$I_LOVE_U" { updatedUser.Password = "" // rollback to what it should be } updatePassword := updatedUser.Password != "" - if err := updatedUser.Edit(updatePassword); err != nil { + authzTouched := false + if err := model.DB.Transaction(func(tx *gorm.DB) error { + if err := updatedUser.EditWithTx(tx, updatePassword); err != nil { + return err + } + touched, err := updateAdminPermissionsForUserInTx(c, tx, updatedUser.Id, originUser.Role, updatedUser.AdminPermissions) + authzTouched = touched + return err + }); err != nil { common.ApiError(c, err) return } + if authzTouched { + if err := authz.ReloadPolicy(); err != nil { + common.ApiError(c, err) + return + } + } + if err := model.InvalidateUserCache(updatedUser.Id); err != nil { + common.SysLog(fmt.Sprintf("failed to invalidate user cache for user %d: %s", updatedUser.Id, err.Error())) + } recordManageAuditFor(c, updatedUser.Id, "user.update", map[string]interface{}{ "username": originUser.Username, "id": updatedUser.Id, @@ -901,10 +923,25 @@ func CreateUser(c *gin.Context) { DisplayName: user.DisplayName, Role: user.Role, // 保持管理员设置的角色 } - if err := cleanUser.Insert(0); err != nil { + authzTouched := false + if err := model.DB.Transaction(func(tx *gorm.DB) error { + if err := cleanUser.InsertWithTx(tx, 0); err != nil { + return err + } + touched, err := updateAdminPermissionsForUserInTx(c, tx, cleanUser.Id, cleanUser.Role, user.AdminPermissions) + authzTouched = touched + return err + }); err != nil { common.ApiError(c, err) return } + if authzTouched { + if err := authz.ReloadPolicy(); err != nil { + common.ApiError(c, err) + return + } + } + cleanUser.FinishInsert(0) recordManageAuditFor(c, cleanUser.Id, "user.create", map[string]interface{}{ "username": cleanUser.Username, @@ -917,6 +954,22 @@ func CreateUser(c *gin.Context) { return } +func updateAdminPermissionsForUserInTx(c *gin.Context, tx *gorm.DB, userID int, userRole int, permissions map[string]map[string]bool) (bool, error) { + if permissions == nil { + if userRole < common.RoleAdminUser && c.GetInt("role") == common.RoleRootUser { + return true, authz.ClearUserAuthorizationInTx(tx, userID) + } + return false, nil + } + if c.GetInt("role") != common.RoleRootUser { + return false, fmt.Errorf("only root can update admin permissions") + } + if userRole < common.RoleAdminUser { + return true, authz.ClearUserAuthorizationInTx(tx, userID) + } + return true, authz.SetUserPermissionsInTx(tx, userID, permissions) +} + type ManageRequest struct { Id int `json:"id"` Action string `json:"action"` @@ -1040,9 +1093,29 @@ func ManageUser(c *gin.Context) { return } - if err := user.Update(false); err != nil { - common.ApiError(c, err) - return + authzTouched := false + if req.Action == "demote" { + if err := model.DB.Transaction(func(tx *gorm.DB) error { + if err := user.UpdateWithTx(tx, false); err != nil { + return err + } + authzTouched = true + return authz.ClearUserAuthorizationInTx(tx, user.Id) + }); err != nil { + common.ApiError(c, err) + return + } + if authzTouched { + if err := authz.ReloadPolicy(); err != nil { + common.ApiError(c, err) + return + } + } + } else { + if err := user.Update(false); err != nil { + common.ApiError(c, err) + return + } } // 禁用 / 角色调整后,强制失效用户缓存与其全部令牌缓存, // 避免在 Redis TTL 过期前仍使用旧状态(尤其是禁用后仍可发起请求的问题)。 diff --git a/go.mod b/go.mod index 93914b85..510383dc 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4 github.com/aws/smithy-go v1.24.2 github.com/bytedance/gopkg v0.1.3 + github.com/casbin/casbin/v2 v2.135.0 github.com/gin-contrib/cors v1.7.2 github.com/gin-contrib/gzip v0.0.6 github.com/gin-contrib/sessions v0.0.5 @@ -68,6 +69,8 @@ require ( require ( github.com/ClickHouse/ch-go v0.65.0 // indirect github.com/ClickHouse/clickhouse-go/v2 v2.32.0 // indirect + github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect + github.com/casbin/govaluate v1.10.0 // indirect github.com/go-faster/city v1.0.1 // indirect github.com/go-faster/errors v0.7.1 // indirect github.com/hashicorp/go-version v1.7.0 // indirect diff --git a/go.sum b/go.sum index 2b55853a..836fe463 100644 --- a/go.sum +++ b/go.sum @@ -608,8 +608,6 @@ github.com/Azure/azure-sdk-for-go v56.3.0+incompatible/go.mod h1:9XXNKU+eRnpl9mo github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= github.com/Azure/go-ansiterm v0.0.0-20210608223527-2377c96fe795/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw= -github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= github.com/Azure/go-autorest v10.8.1+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= github.com/Azure/go-autorest/autorest v0.11.1/go.mod h1:JFgpikqFJ/MleTTxwepExTKnFUKKszPS8UavbQYUMuw= @@ -626,6 +624,8 @@ github.com/Azure/go-autorest/autorest/to v0.4.0/go.mod h1:fE8iZBn7LQR7zH/9XU2NcP github.com/Azure/go-autorest/logger v0.2.0/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= +github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw= +github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= @@ -750,6 +750,8 @@ github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJm github.com/blang/semver v3.1.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I= +github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= @@ -770,6 +772,11 @@ github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7 github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk= +github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18= +github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= +github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0= +github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= github.com/cenkalti/backoff/v4 v4.1.2/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= github.com/cenkalti/backoff/v4 v4.1.3/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= @@ -1243,6 +1250,7 @@ github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= diff --git a/main.go b/main.go index 976e01d7..c157bc0a 100644 --- a/main.go +++ b/main.go @@ -23,6 +23,7 @@ import ( "github.com/QuantumNous/new-api/relay" "github.com/QuantumNous/new-api/router" "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/service/authz" _ "github.com/QuantumNous/new-api/setting/performance_setting" "github.com/QuantumNous/new-api/setting/ratio_setting" @@ -100,6 +101,9 @@ func main() { // 热更新配置 go model.SyncOptions(common.SyncFrequency) + // 周期性重载授权策略,保证多节点/多 master 部署下权限变更能传播到每个实例 + go authz.StartPolicySync(common.SyncFrequency) + // 数据看板 go model.UpdateQuotaData() @@ -284,6 +288,10 @@ func InitResources() error { common.FatalLog("failed to initialize database: " + err.Error()) return err } + if err = authz.Init(model.DB); err != nil { + common.FatalLog("failed to initialize authorization: " + err.Error()) + return err + } model.CheckSetup() diff --git a/middleware/auth.go b/middleware/auth.go index 5f2ed489..69c06e96 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -14,6 +14,7 @@ import ( "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/service/authz" "github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/QuantumNous/new-api/types" @@ -195,6 +196,22 @@ func RootAuth() func(c *gin.Context) { } } +func RequirePermission(permission authz.Permission) func(c *gin.Context) { + return func(c *gin.Context) { + role := c.GetInt("role") + userID := c.GetInt("id") + if authz.Can(userID, role, permission) { + c.Next() + return + } + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": common.TranslateMessage(c, i18n.MsgAuthInsufficientPrivilege), + }) + c.Abort() + } +} + func WssAuth(c *gin.Context) { } diff --git a/model/authz_role.go b/model/authz_role.go new file mode 100644 index 00000000..329eda92 --- /dev/null +++ b/model/authz_role.go @@ -0,0 +1,17 @@ +package model + +type AuthzRole struct { + Id uint `json:"id" gorm:"primaryKey;autoIncrement"` + Key string `json:"key" gorm:"size:64;uniqueIndex;not null"` + Name string `json:"name" gorm:"size:100;not null"` + Description string `json:"description" gorm:"type:text"` + BuiltIn bool `json:"built_in"` + Enabled bool `json:"enabled"` + Sort int `json:"sort"` + CreatedAt int64 `json:"created_at" gorm:"autoCreateTime;column:created_at"` + UpdatedAt int64 `json:"updated_at" gorm:"autoUpdateTime;column:updated_at"` +} + +func (AuthzRole) TableName() string { + return "authz_roles" +} diff --git a/model/casbin_rule.go b/model/casbin_rule.go new file mode 100644 index 00000000..e5f07eed --- /dev/null +++ b/model/casbin_rule.go @@ -0,0 +1,16 @@ +package model + +type CasbinRule struct { + Id uint `gorm:"primaryKey;autoIncrement"` + Ptype string `gorm:"size:100;index:idx_casbin_rule,priority:1;uniqueIndex:idx_casbin_rule_unique,priority:1"` + V0 string `gorm:"size:100;index:idx_casbin_rule,priority:2;uniqueIndex:idx_casbin_rule_unique,priority:2"` + V1 string `gorm:"size:100;index:idx_casbin_rule,priority:3;uniqueIndex:idx_casbin_rule_unique,priority:3"` + V2 string `gorm:"size:100;index:idx_casbin_rule,priority:4;uniqueIndex:idx_casbin_rule_unique,priority:4"` + V3 string `gorm:"size:100;index:idx_casbin_rule,priority:5;uniqueIndex:idx_casbin_rule_unique,priority:5"` + V4 string `gorm:"size:100;index:idx_casbin_rule,priority:6;uniqueIndex:idx_casbin_rule_unique,priority:6"` + V5 string `gorm:"size:100;index:idx_casbin_rule,priority:7;uniqueIndex:idx_casbin_rule_unique,priority:7"` +} + +func (CasbinRule) TableName() string { + return "casbin_rule" +} diff --git a/model/log.go b/model/log.go index 7247184f..1f54a8b3 100644 --- a/model/log.go +++ b/model/log.go @@ -196,8 +196,8 @@ func RecordLoginLog(userId int, username string, content string, ip string, acti } // RecordOperationAuditLog 记录管理/高危操作审计日志(type=LogTypeManage)。 -// logUserId 为日志归属者(面向用户的操作如额度调整归属目标用户,资源类操作如渠道/系统设置归属操作者), -// username 内部按 logUserId 查询。content 为英文兜底文本(导出/经典前端用)。 +// logUserId 为日志归属者,管理审计日志应归属实际操作者;目标资源/用户放入 +// action params。username 内部按 logUserId 查询。content 为英文兜底文本(导出/经典前端用)。 // action+params 写入 Other.op,供前端本地化渲染(普通用户可见,不含敏感信息)。 // adminInfo 存放操作者身份(写入 Other.admin_info,普通用户查询时剥离); // auditInfo 存放路由/方法/结果等中间件兜底信息(写入 Other.audit_info,普通用户查询时剥离)。 diff --git a/model/main.go b/model/main.go index ec148563..76f98a59 100644 --- a/model/main.go +++ b/model/main.go @@ -297,6 +297,8 @@ func migrateDB() error { &SystemInstance{}, &SystemTask{}, &SystemTaskLock{}, + &CasbinRule{}, + &AuthzRole{}, ) if err != nil { return err diff --git a/model/user.go b/model/user.go index 53f93b02..85bbc7c4 100644 --- a/model/user.go +++ b/model/user.go @@ -22,37 +22,38 @@ const UserNameMaxLength = 20 // User if you add sensitive fields, don't forget to clean them in setupLogin function. // Otherwise, the sensitive information will be saved on local storage in plain text! type User struct { - Id int `json:"id"` - Username string `json:"username" gorm:"unique;index" validate:"max=20"` - Password string `json:"password" gorm:"not null;" validate:"min=8,max=20"` - OriginalPassword string `json:"original_password" gorm:"-:all"` // this field is only for Password change verification, don't save it to database! - DisplayName string `json:"display_name" gorm:"index" validate:"max=20"` - Role int `json:"role" gorm:"type:int;default:1"` // admin, common - Status int `json:"status" gorm:"type:int;default:1"` // enabled, disabled - Email string `json:"email" gorm:"index" validate:"max=50"` - GitHubId string `json:"github_id" gorm:"column:github_id;index"` - DiscordId string `json:"discord_id" gorm:"column:discord_id;index"` - OidcId string `json:"oidc_id" gorm:"column:oidc_id;index"` - WeChatId string `json:"wechat_id" gorm:"column:wechat_id;index"` - TelegramId string `json:"telegram_id" gorm:"column:telegram_id;index"` - VerificationCode string `json:"verification_code" gorm:"-:all"` // this field is only for Email verification, don't save it to database! - AccessToken *string `json:"-" gorm:"type:char(32);column:access_token;uniqueIndex"` // this token is for system management - Quota int `json:"quota" gorm:"type:int;default:0"` - UsedQuota int `json:"used_quota" gorm:"type:int;default:0;column:used_quota"` // used quota - RequestCount int `json:"request_count" gorm:"type:int;default:0;"` // request number - Group string `json:"group" gorm:"type:varchar(64);default:'default'"` - AffCode string `json:"aff_code" gorm:"type:varchar(32);column:aff_code;uniqueIndex"` - AffCount int `json:"aff_count" gorm:"type:int;default:0;column:aff_count"` - AffQuota int `json:"aff_quota" gorm:"type:int;default:0;column:aff_quota"` // 邀请剩余额度 - AffHistoryQuota int `json:"aff_history_quota" gorm:"type:int;default:0;column:aff_history"` // 邀请历史额度 - InviterId int `json:"inviter_id" gorm:"type:int;column:inviter_id;index"` - DeletedAt gorm.DeletedAt `gorm:"index"` - LinuxDOId string `json:"linux_do_id" gorm:"column:linux_do_id;index"` - Setting string `json:"setting" gorm:"type:text;column:setting"` - Remark string `json:"remark,omitempty" gorm:"type:varchar(255)" validate:"max=255"` - StripeCustomer string `json:"stripe_customer" gorm:"type:varchar(64);column:stripe_customer;index"` - CreatedAt int64 `json:"created_at" gorm:"autoCreateTime;column:created_at"` - LastLoginAt int64 `json:"last_login_at" gorm:"default:0;column:last_login_at"` + Id int `json:"id"` + Username string `json:"username" gorm:"unique;index" validate:"max=20"` + Password string `json:"password" gorm:"not null;" validate:"min=8,max=20"` + OriginalPassword string `json:"original_password" gorm:"-:all"` // this field is only for Password change verification, don't save it to database! + DisplayName string `json:"display_name" gorm:"index" validate:"max=20"` + Role int `json:"role" gorm:"type:int;default:1"` // admin, common + Status int `json:"status" gorm:"type:int;default:1"` // enabled, disabled + Email string `json:"email" gorm:"index" validate:"max=50"` + GitHubId string `json:"github_id" gorm:"column:github_id;index"` + DiscordId string `json:"discord_id" gorm:"column:discord_id;index"` + OidcId string `json:"oidc_id" gorm:"column:oidc_id;index"` + WeChatId string `json:"wechat_id" gorm:"column:wechat_id;index"` + TelegramId string `json:"telegram_id" gorm:"column:telegram_id;index"` + VerificationCode string `json:"verification_code" gorm:"-:all"` // this field is only for Email verification, don't save it to database! + AccessToken *string `json:"-" gorm:"type:char(32);column:access_token;uniqueIndex"` // this token is for system management + Quota int `json:"quota" gorm:"type:int;default:0"` + UsedQuota int `json:"used_quota" gorm:"type:int;default:0;column:used_quota"` // used quota + RequestCount int `json:"request_count" gorm:"type:int;default:0;"` // request number + Group string `json:"group" gorm:"type:varchar(64);default:'default'"` + AffCode string `json:"aff_code" gorm:"type:varchar(32);column:aff_code;uniqueIndex"` + AffCount int `json:"aff_count" gorm:"type:int;default:0;column:aff_count"` + AffQuota int `json:"aff_quota" gorm:"type:int;default:0;column:aff_quota"` // 邀请剩余额度 + AffHistoryQuota int `json:"aff_history_quota" gorm:"type:int;default:0;column:aff_history"` // 邀请历史额度 + InviterId int `json:"inviter_id" gorm:"type:int;column:inviter_id;index"` + DeletedAt gorm.DeletedAt `gorm:"index"` + LinuxDOId string `json:"linux_do_id" gorm:"column:linux_do_id;index"` + Setting string `json:"setting" gorm:"type:text;column:setting"` + Remark string `json:"remark,omitempty" gorm:"type:varchar(255)" validate:"max=255"` + StripeCustomer string `json:"stripe_customer" gorm:"type:varchar(64);column:stripe_customer;index"` + CreatedAt int64 `json:"created_at" gorm:"autoCreateTime;column:created_at"` + LastLoginAt int64 `json:"last_login_at" gorm:"default:0;column:last_login_at"` + AdminPermissions map[string]map[string]bool `json:"admin_permissions,omitempty" gorm:"-:all"` } func (user *User) ToBaseUser() *UserBase { @@ -408,6 +409,11 @@ func (user *User) Insert(inviterId int) error { return result.Error } + user.finishInsert(inviterId) + return nil +} + +func (user *User) finishInsert(inviterId int) { // 用户创建成功后,根据角色初始化边栏配置 // 需要重新获取用户以确保有正确的ID和Role var createdUser User @@ -437,7 +443,10 @@ func (user *User) Insert(inviterId int) error { _ = inviteUser(inviterId) } } - return nil +} + +func (user *User) FinishInsert(inviterId int) { + user.finishInsert(inviterId) } // InsertWithTx inserts a new user within an existing transaction. @@ -500,6 +509,13 @@ func (user *User) FinalizeOAuthUserCreation(inviterId int) { } func (user *User) Update(updatePassword bool) error { + if err := user.UpdateWithTx(DB, updatePassword); err != nil { + return err + } + return updateUserCache(*user) +} + +func (user *User) UpdateWithTx(tx *gorm.DB, updatePassword bool) error { var err error if updatePassword { user.Password, err = common.Password2Hash(user.Password) @@ -508,16 +524,21 @@ func (user *User) Update(updatePassword bool) error { } } newUser := *user - DB.First(&user, user.Id) - if err = DB.Model(user).Updates(newUser).Error; err != nil { + tx.First(&user, user.Id) + if err = tx.Model(user).Updates(newUser).Error; err != nil { return err } - - // Update cache - return updateUserCache(*user) + return nil } func (user *User) Edit(updatePassword bool) error { + if err := user.EditWithTx(DB, updatePassword); err != nil { + return err + } + return updateUserCache(*user) +} + +func (user *User) EditWithTx(tx *gorm.DB, updatePassword bool) error { var err error if updatePassword { user.Password, err = common.Password2Hash(user.Password) @@ -537,13 +558,11 @@ func (user *User) Edit(updatePassword bool) error { updates["password"] = newUser.Password } - DB.First(&user, user.Id) - if err = DB.Model(user).Updates(updates).Error; err != nil { + tx.First(&user, user.Id) + if err = tx.Model(user).Updates(updates).Error; err != nil { return err } - - // Update cache - return updateUserCache(*user) + return nil } func (user *User) ClearBinding(bindingType string) error { diff --git a/router/api-router.go b/router/api-router.go index 47bdc7c1..efe2131d 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -225,48 +225,8 @@ func SetApiRouter(router *gin.Engine) { ratioSyncRoute.GET("/channels", controller.GetSyncableChannels) ratioSyncRoute.POST("/fetch", controller.FetchUpstreamRatios) } - channelRoute := apiRouter.Group("/channel") - channelRoute.Use(middleware.AdminAuth()) - { - channelRoute.GET("/", controller.GetAllChannels) - channelRoute.GET("/search", controller.SearchChannels) - channelRoute.GET("/models", controller.ChannelListModels) - channelRoute.GET("/models_enabled", controller.EnabledListModels) - channelRoute.GET("/ops", controller.GetChannelOps) - channelRoute.GET("/:id", controller.GetChannel) - channelRoute.POST("/:id/key", middleware.RootAuth(), middleware.CriticalRateLimit(), middleware.DisableCache(), middleware.SecureVerificationRequired(), controller.GetChannelKey) - channelRoute.GET("/test", controller.TestAllChannels) - channelRoute.GET("/test/:id", controller.TestChannel) - channelRoute.GET("/update_balance", controller.UpdateAllChannelsBalance) - channelRoute.GET("/update_balance/:id", controller.UpdateChannelBalance) - channelRoute.POST("/", controller.AddChannel) - channelRoute.PUT("/", controller.UpdateChannel) - channelRoute.DELETE("/disabled", controller.DeleteDisabledChannel) - channelRoute.POST("/tag/disabled", controller.DisableTagChannels) - channelRoute.POST("/tag/enabled", controller.EnableTagChannels) - channelRoute.PUT("/tag", controller.EditTagChannels) - channelRoute.DELETE("/:id", controller.DeleteChannel) - channelRoute.POST("/batch", controller.DeleteChannelBatch) - channelRoute.POST("/fix", controller.FixChannelsAbilities) - channelRoute.GET("/fetch_models/:id", controller.FetchUpstreamModels) - channelRoute.POST("/fetch_models", middleware.RootAuth(), controller.FetchModels) - channelRoute.POST("/:id/codex/refresh", controller.RefreshCodexChannelCredential) - channelRoute.GET("/:id/codex/usage", controller.GetCodexChannelUsage) - channelRoute.GET("/:id/codex/usage/reset-credits", controller.GetCodexChannelRateLimitResetCredits) - channelRoute.POST("/:id/codex/usage/reset", controller.ResetCodexChannelUsage) - channelRoute.POST("/ollama/pull", controller.OllamaPullModel) - channelRoute.POST("/ollama/pull/stream", controller.OllamaPullModelStream) - channelRoute.DELETE("/ollama/delete", controller.OllamaDeleteModel) - channelRoute.GET("/ollama/version/:id", controller.OllamaVersion) - channelRoute.POST("/batch/tag", controller.BatchSetChannelTag) - channelRoute.GET("/tag/models", controller.GetTagModels) - channelRoute.POST("/copy/:id", controller.CopyChannel) - channelRoute.POST("/multi_key/manage", controller.ManageMultiKeys) - channelRoute.POST("/upstream_updates/apply", controller.ApplyChannelUpstreamModelUpdates) - channelRoute.POST("/upstream_updates/apply_all", controller.ApplyAllChannelUpstreamModelUpdates) - channelRoute.POST("/upstream_updates/detect", controller.DetectChannelUpstreamModelUpdates) - channelRoute.POST("/upstream_updates/detect_all", controller.DetectAllChannelUpstreamModelUpdates) - } + registerChannelRoutes(apiRouter) + registerAuthzRoutes(apiRouter) tokenRoute := apiRouter.Group("/token") tokenRoute.Use(middleware.UserAuth()) { diff --git a/router/authz-router.go b/router/authz-router.go new file mode 100644 index 00000000..df88d35b --- /dev/null +++ b/router/authz-router.go @@ -0,0 +1,19 @@ +package router + +import ( + "github.com/QuantumNous/new-api/controller" + "github.com/QuantumNous/new-api/middleware" + + "github.com/gin-gonic/gin" +) + +// registerAuthzRoutes mounts the authorization API under its own /authz +// namespace. GET /authz/catalog returns the permission schema (resources, +// actions, and role baselines) used by the client permission editor. +func registerAuthzRoutes(apiRouter *gin.RouterGroup) { + authzRoute := apiRouter.Group("/authz") + authzRoute.Use(middleware.AdminAuth()) + { + authzRoute.GET("/catalog", controller.GetPermissionCatalog) + } +} diff --git a/router/channel-router.go b/router/channel-router.go new file mode 100644 index 00000000..b85cbd88 --- /dev/null +++ b/router/channel-router.go @@ -0,0 +1,79 @@ +package router + +import ( + "net/http" + + "github.com/QuantumNous/new-api/controller" + "github.com/QuantumNous/new-api/middleware" + "github.com/QuantumNous/new-api/service/authz" + "github.com/gin-gonic/gin" +) + +type permissionRoute struct { + method string + path string + permission authz.Permission + handler gin.HandlerFunc +} + +func registerChannelRoutes(apiRouter *gin.RouterGroup) { + channelRoute := apiRouter.Group("/channel") + channelRoute.Use(middleware.AdminAuth()) + + channelRoute.POST("/:id/key", + middleware.RootAuth(), + middleware.CriticalRateLimit(), + middleware.DisableCache(), + middleware.SecureVerificationRequired(), + controller.GetChannelKey, + ) + + for _, route := range channelPermissionRoutes { + channelRoute.Handle(route.method, route.path, + middleware.RequirePermission(route.permission), + route.handler, + ) + } +} + +var channelPermissionRoutes = []permissionRoute{ + {method: http.MethodGet, path: "/", permission: authz.ChannelRead, handler: controller.GetAllChannels}, + {method: http.MethodGet, path: "/search", permission: authz.ChannelRead, handler: controller.SearchChannels}, + {method: http.MethodGet, path: "/models", permission: authz.ChannelRead, handler: controller.ChannelListModels}, + {method: http.MethodGet, path: "/models_enabled", permission: authz.ChannelRead, handler: controller.EnabledListModels}, + {method: http.MethodGet, path: "/ops", permission: authz.ChannelRead, handler: controller.GetChannelOps}, + {method: http.MethodGet, path: "/:id", permission: authz.ChannelRead, handler: controller.GetChannel}, + {method: http.MethodGet, path: "/test", permission: authz.ChannelOperate, handler: controller.TestAllChannels}, + {method: http.MethodGet, path: "/test/:id", permission: authz.ChannelOperate, handler: controller.TestChannel}, + {method: http.MethodGet, path: "/update_balance", permission: authz.ChannelOperate, handler: controller.UpdateAllChannelsBalance}, + {method: http.MethodGet, path: "/update_balance/:id", permission: authz.ChannelOperate, handler: controller.UpdateChannelBalance}, + {method: http.MethodPost, path: "/", permission: authz.ChannelSensitiveWrite, handler: controller.AddChannel}, + {method: http.MethodPut, path: "/", permission: authz.ChannelWrite, handler: controller.UpdateChannel}, + {method: http.MethodPost, path: "/status/batch", permission: authz.ChannelOperate, handler: controller.BatchUpdateChannelStatus}, + {method: http.MethodPost, path: "/:id/status", permission: authz.ChannelOperate, handler: controller.UpdateChannelStatus}, + {method: http.MethodDelete, path: "/disabled", permission: authz.ChannelSensitiveWrite, handler: controller.DeleteDisabledChannel}, + {method: http.MethodPost, path: "/tag/disabled", permission: authz.ChannelOperate, handler: controller.DisableTagChannels}, + {method: http.MethodPost, path: "/tag/enabled", permission: authz.ChannelOperate, handler: controller.EnableTagChannels}, + {method: http.MethodPut, path: "/tag", permission: authz.ChannelWrite, handler: controller.EditTagChannels}, + {method: http.MethodDelete, path: "/:id", permission: authz.ChannelSensitiveWrite, handler: controller.DeleteChannel}, + {method: http.MethodPost, path: "/batch", permission: authz.ChannelSensitiveWrite, handler: controller.DeleteChannelBatch}, + {method: http.MethodPost, path: "/fix", permission: authz.ChannelOperate, handler: controller.FixChannelsAbilities}, + {method: http.MethodGet, path: "/fetch_models/:id", permission: authz.ChannelOperate, handler: controller.FetchUpstreamModels}, + {method: http.MethodPost, path: "/fetch_models", permission: authz.ChannelSensitiveWrite, handler: controller.FetchModels}, + {method: http.MethodPost, path: "/:id/codex/refresh", permission: authz.ChannelSensitiveWrite, handler: controller.RefreshCodexChannelCredential}, + {method: http.MethodGet, path: "/:id/codex/usage", permission: authz.ChannelRead, handler: controller.GetCodexChannelUsage}, + {method: http.MethodGet, path: "/:id/codex/usage/reset-credits", permission: authz.ChannelRead, handler: controller.GetCodexChannelRateLimitResetCredits}, + {method: http.MethodPost, path: "/:id/codex/usage/reset", permission: authz.ChannelOperate, handler: controller.ResetCodexChannelUsage}, + {method: http.MethodPost, path: "/ollama/pull", permission: authz.ChannelSensitiveWrite, handler: controller.OllamaPullModel}, + {method: http.MethodPost, path: "/ollama/pull/stream", permission: authz.ChannelSensitiveWrite, handler: controller.OllamaPullModelStream}, + {method: http.MethodDelete, path: "/ollama/delete", permission: authz.ChannelSensitiveWrite, handler: controller.OllamaDeleteModel}, + {method: http.MethodGet, path: "/ollama/version/:id", permission: authz.ChannelSensitiveWrite, handler: controller.OllamaVersion}, + {method: http.MethodPost, path: "/batch/tag", permission: authz.ChannelWrite, handler: controller.BatchSetChannelTag}, + {method: http.MethodGet, path: "/tag/models", permission: authz.ChannelRead, handler: controller.GetTagModels}, + {method: http.MethodPost, path: "/copy/:id", permission: authz.ChannelSensitiveWrite, handler: controller.CopyChannel}, + {method: http.MethodPost, path: "/multi_key/manage", permission: authz.ChannelOperate, handler: controller.ManageMultiKeys}, + {method: http.MethodPost, path: "/upstream_updates/apply", permission: authz.ChannelWrite, handler: controller.ApplyChannelUpstreamModelUpdates}, + {method: http.MethodPost, path: "/upstream_updates/apply_all", permission: authz.ChannelWrite, handler: controller.ApplyAllChannelUpstreamModelUpdates}, + {method: http.MethodPost, path: "/upstream_updates/detect", permission: authz.ChannelOperate, handler: controller.DetectChannelUpstreamModelUpdates}, + {method: http.MethodPost, path: "/upstream_updates/detect_all", permission: authz.ChannelOperate, handler: controller.DetectAllChannelUpstreamModelUpdates}, +} diff --git a/router/channel_router_test.go b/router/channel_router_test.go new file mode 100644 index 00000000..8ec1f9b1 --- /dev/null +++ b/router/channel_router_test.go @@ -0,0 +1,50 @@ +package router + +import ( + "net/http" + "reflect" + "testing" + + "github.com/QuantumNous/new-api/controller" + "github.com/QuantumNous/new-api/service/authz" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestChannelStatusRoutesUseOperatePermission(t *testing.T) { + assertChannelRoutePermission(t, http.MethodPost, "/:id/status", authz.ChannelOperate, controller.UpdateChannelStatus) + assertChannelRoutePermission(t, http.MethodPost, "/status/batch", authz.ChannelOperate, controller.BatchUpdateChannelStatus) + assertChannelRoutePermission(t, http.MethodPut, "/", authz.ChannelWrite, controller.UpdateChannel) +} + +func TestChannelDeleteRoutesUseSensitiveWritePermission(t *testing.T) { + assertChannelRoutePermission(t, http.MethodDelete, "/:id", authz.ChannelSensitiveWrite, controller.DeleteChannel) + assertChannelRoutePermission(t, http.MethodPost, "/batch", authz.ChannelSensitiveWrite, controller.DeleteChannelBatch) + assertChannelRoutePermission(t, http.MethodDelete, "/disabled", authz.ChannelSensitiveWrite, controller.DeleteDisabledChannel) + assertChannelRoutePermission(t, http.MethodPut, "/", authz.ChannelWrite, controller.UpdateChannel) + assertChannelRoutePermission(t, http.MethodPut, "/tag", authz.ChannelWrite, controller.EditTagChannels) + assertChannelRoutePermission(t, http.MethodPost, "/batch/tag", authz.ChannelWrite, controller.BatchSetChannelTag) +} + +func TestChannelStatusRoutesRegisterWithoutConflict(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := gin.New() + api := engine.Group("/api") + + require.NotPanics(t, func() { + registerChannelRoutes(api) + }) +} + +func assertChannelRoutePermission(t *testing.T, method string, path string, permission authz.Permission, handler any) { + t.Helper() + for _, route := range channelPermissionRoutes { + if route.method == method && route.path == path { + assert.Equal(t, permission, route.permission) + assert.Equal(t, reflect.ValueOf(handler).Pointer(), reflect.ValueOf(route.handler).Pointer()) + return + } + } + t.Fatalf("route %s %s not found", method, path) +} diff --git a/service/authz/adapter.go b/service/authz/adapter.go new file mode 100644 index 00000000..6c971a8a --- /dev/null +++ b/service/authz/adapter.go @@ -0,0 +1,121 @@ +package authz + +import ( + "strings" + + "github.com/QuantumNous/new-api/model" + casbinmodel "github.com/casbin/casbin/v2/model" + "github.com/casbin/casbin/v2/persist" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type gormAdapter struct { + db *gorm.DB +} + +func newGormAdapter(db *gorm.DB) *gormAdapter { + return &gormAdapter{db: db} +} + +func (a *gormAdapter) LoadPolicy(m casbinmodel.Model) error { + var rules []model.CasbinRule + if err := a.db.Order("id asc").Find(&rules).Error; err != nil { + return err + } + for _, rule := range rules { + if err := persist.LoadPolicyLine(ruleToLine(rule), m); err != nil { + return err + } + } + return nil +} + +func (a *gormAdapter) SavePolicy(m casbinmodel.Model) error { + return a.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("1 = 1").Delete(&model.CasbinRule{}).Error; err != nil { + return err + } + rules := make([]model.CasbinRule, 0) + for ptype, ast := range m["p"] { + for _, policy := range ast.Policy { + rules = append(rules, newRule(ptype, policy)) + } + } + for ptype, ast := range m["g"] { + for _, policy := range ast.Policy { + rules = append(rules, newRule(ptype, policy)) + } + } + if len(rules) == 0 { + return nil + } + return tx.Create(&rules).Error + }) +} + +func (a *gormAdapter) AddPolicy(_ string, ptype string, rule []string) error { + casbinRule := newRule(ptype, rule) + var count int64 + if err := a.ruleQuery(a.db.Model(&model.CasbinRule{}), ptype, rule).Count(&count).Error; err != nil { + return err + } + if count > 0 { + return nil + } + return a.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&casbinRule).Error +} + +func (a *gormAdapter) RemovePolicy(_ string, ptype string, rule []string) error { + return a.ruleQuery(a.db, ptype, rule).Delete(&model.CasbinRule{}).Error +} + +func (a *gormAdapter) RemoveFilteredPolicy(_ string, ptype string, fieldIndex int, fieldValues ...string) error { + query := a.db.Where("ptype = ?", ptype) + for i, value := range fieldValues { + if value == "" { + continue + } + query = query.Where("v"+string(rune('0'+fieldIndex+i))+" = ?", value) + } + return query.Delete(&model.CasbinRule{}).Error +} + +func (a *gormAdapter) ruleQuery(query *gorm.DB, ptype string, rule []string) *gorm.DB { + query = query.Where("ptype = ?", ptype) + for idx := 0; idx < 6; idx++ { + value := "" + if idx < len(rule) { + value = rule[idx] + } + query = query.Where("v"+string(rune('0'+idx))+" = ?", value) + } + return query +} + +func newRule(ptype string, policy []string) model.CasbinRule { + rule := model.CasbinRule{Ptype: ptype} + values := []*string{&rule.V0, &rule.V1, &rule.V2, &rule.V3, &rule.V4, &rule.V5} + for idx, value := range policy { + if idx >= len(values) { + break + } + *values[idx] = value + } + return rule +} + +func ruleToLine(rule model.CasbinRule) string { + parts := []string{rule.Ptype} + values := []string{rule.V0, rule.V1, rule.V2, rule.V3, rule.V4, rule.V5} + if rule.Ptype == "p" && rule.V0 != "" && rule.V1 != "" && rule.V2 != "" && rule.V3 == "" { + values[3] = EffectAllow + } + for _, value := range values { + if value == "" { + continue + } + parts = append(parts, value) + } + return strings.Join(parts, ", ") +} diff --git a/service/authz/assignment.go b/service/authz/assignment.go new file mode 100644 index 00000000..8024f51f --- /dev/null +++ b/service/authz/assignment.go @@ -0,0 +1,20 @@ +package authz + +import "github.com/QuantumNous/new-api/common" + +// resolveSubjectRoles returns the role keys assigned to a subject. The mapping +// is derived from the caller's system role. +var resolveSubjectRoles = func(userID int, systemRole int) []string { + switch { + case systemRole >= common.RoleRootUser: + return []string{BuiltInRoleRoot} + case systemRole >= common.RoleAdminUser: + return []string{BuiltInRoleAdmin} + default: + return nil + } +} + +// managedRoleKey is the role whose baseline per-user overrides are expressed +// relative to. +const managedRoleKey = BuiltInRoleAdmin diff --git a/service/authz/authz_test.go b/service/authz/authz_test.go new file mode 100644 index 00000000..eda3f4ad --- /dev/null +++ b/service/authz/authz_test.go @@ -0,0 +1,229 @@ +package authz + +import ( + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func newAuthzTestDB(t *testing.T) *gorm.DB { + t.Helper() + wasMaster := common.IsMasterNode + common.IsMasterNode = true + t.Cleanup(func() { + common.IsMasterNode = wasMaster + }) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + sqlDB, err := db.DB() + require.NoError(t, err) + sqlDB.SetMaxOpenConns(1) + require.NoError(t, db.AutoMigrate(&model.CasbinRule{}, &model.AuthzRole{})) + return db +} + +func TestInitSeedsBuiltInRolesAndPoliciesOnce(t *testing.T) { + db := newAuthzTestDB(t) + + require.NoError(t, Init(db)) + require.NoError(t, Init(db)) + + // root is a superuser role and is granted everything implicitly, so only the + // admin baseline is written as explicit policy rows. + var count int64 + require.NoError(t, db.Model(&model.CasbinRule{}).Count(&count).Error) + assert.Equal(t, int64(len(PermissionsForRole(BuiltInRoleAdmin))), count) + + var roles []model.AuthzRole + require.NoError(t, db.Order("sort asc").Find(&roles).Error) + require.Len(t, roles, 2) + assert.Equal(t, BuiltInRoleRoot, roles[0].Key) + assert.Equal(t, BuiltInRoleAdmin, roles[1].Key) + + assert.True(t, Can(1, common.RoleRootUser, ChannelSensitiveWrite)) + assert.True(t, Can(2, common.RoleAdminUser, ChannelRead)) + assert.True(t, Can(2, common.RoleAdminUser, ChannelOperate)) + assert.True(t, Can(2, common.RoleAdminUser, ChannelWrite)) + assert.False(t, Can(2, common.RoleAdminUser, ChannelSensitiveWrite)) + assert.False(t, Can(3, common.RoleCommonUser, ChannelRead)) +} + +func TestInitOnSlaveOnlyLoadsPolicies(t *testing.T) { + wasMaster := common.IsMasterNode + common.IsMasterNode = false + t.Cleanup(func() { + common.IsMasterNode = wasMaster + }) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + sqlDB, err := db.DB() + require.NoError(t, err) + sqlDB.SetMaxOpenConns(1) + require.NoError(t, db.AutoMigrate(&model.CasbinRule{}, &model.AuthzRole{})) + + require.NoError(t, Init(db)) + + var roleCount int64 + require.NoError(t, db.Model(&model.AuthzRole{}).Count(&roleCount).Error) + assert.Equal(t, int64(0), roleCount) + var policyCount int64 + require.NoError(t, db.Model(&model.CasbinRule{}).Count(&policyCount).Error) + assert.Equal(t, int64(0), policyCount) + assert.False(t, Can(2, common.RoleAdminUser, ChannelRead)) +} + +func TestSetUserPermissionsStoresOnlyOverrides(t *testing.T) { + db := newAuthzTestDB(t) + require.NoError(t, Init(db)) + + require.NoError(t, SetUserPermissions(42, PermissionsMap{ + ResourceChannel: { + ActionRead: true, + ActionOperate: true, + ActionWrite: false, + ActionSensitiveWrite: true, + ActionSecretView: false, + "unknown": true, + }, + "unknown": { + ActionRead: true, + }, + })) + + assert.True(t, Can(42, common.RoleAdminUser, ChannelSensitiveWrite)) + assert.False(t, Can(42, common.RoleAdminUser, ChannelWrite)) + assert.Equal(t, PermissionsMap{ + ResourceChannel: { + ActionRead: true, + ActionOperate: true, + ActionWrite: false, + ActionSensitiveWrite: true, + ActionSecretView: false, + }, + }, ExplicitUserPermissions(42)) + assert.Equal(t, PermissionsMap{ + ResourceChannel: { + ActionSensitiveWrite: true, + ActionWrite: false, + }, + }, ExplicitUserOverrides(42)) + + var userPolicyCount int64 + require.NoError(t, db.Model(&model.CasbinRule{}).Where("v0 = ?", UserSubject(42)).Count(&userPolicyCount).Error) + assert.Equal(t, int64(2), userPolicyCount) + + require.NoError(t, SetUserPermissions(42, PermissionsMap{ResourceChannel: { + ActionRead: true, + ActionOperate: true, + ActionWrite: true, + ActionSensitiveWrite: false, + ActionSecretView: false, + }})) + assert.False(t, Can(42, common.RoleAdminUser, ChannelSensitiveWrite)) + assert.Equal(t, PermissionsMap{ + ResourceChannel: { + ActionRead: true, + ActionOperate: true, + ActionWrite: true, + ActionSensitiveWrite: false, + ActionSecretView: false, + }, + }, ExplicitUserPermissions(42)) + assert.Empty(t, ExplicitUserOverrides(42)) +} + +func TestClearUserAuthorizationRemovesOverrides(t *testing.T) { + db := newAuthzTestDB(t) + require.NoError(t, Init(db)) + + require.NoError(t, SetUserPermissions(90, PermissionsMap{ResourceChannel: { + ActionWrite: false, + ActionSensitiveWrite: true, + }})) + + assert.True(t, Can(90, common.RoleAdminUser, ChannelSensitiveWrite)) + assert.False(t, Can(90, common.RoleAdminUser, ChannelWrite)) + + require.NoError(t, ClearUserAuthorization(90)) + + assert.Empty(t, ExplicitUserOverrides(90)) + assert.True(t, Can(90, common.RoleAdminUser, ChannelRead)) + assert.True(t, Can(90, common.RoleAdminUser, ChannelWrite)) + assert.False(t, Can(90, common.RoleAdminUser, ChannelSensitiveWrite)) + assert.False(t, Can(90, common.RoleCommonUser, ChannelRead)) +} + +func TestSetUserPermissionsInTxDoesNotMutateEnforcerBeforeReload(t *testing.T) { + db := newAuthzTestDB(t) + require.NoError(t, Init(db)) + + require.NoError(t, db.Transaction(func(tx *gorm.DB) error { + return SetUserPermissionsInTx(tx, 42, PermissionsMap{ResourceChannel: { + ActionRead: true, + ActionOperate: true, + ActionWrite: true, + ActionSensitiveWrite: true, + ActionSecretView: false, + }}) + })) + + assert.False(t, Can(42, common.RoleAdminUser, ChannelSensitiveWrite)) + require.NoError(t, ReloadPolicy()) + assert.True(t, Can(42, common.RoleAdminUser, ChannelSensitiveWrite)) +} + +func TestSetUserPermissionsInTxRollbackLeavesNoPolicy(t *testing.T) { + db := newAuthzTestDB(t) + require.NoError(t, Init(db)) + + tx := db.Begin() + require.NoError(t, tx.Error) + require.NoError(t, SetUserPermissionsInTx(tx, 43, PermissionsMap{ResourceChannel: { + ActionSensitiveWrite: true, + }})) + require.NoError(t, tx.Rollback().Error) + require.NoError(t, ReloadPolicy()) + + assert.False(t, Can(43, common.RoleAdminUser, ChannelSensitiveWrite)) + var count int64 + require.NoError(t, db.Model(&model.CasbinRule{}).Where("v0 = ?", UserSubject(43)).Count(&count).Error) + assert.Equal(t, int64(0), count) +} + +func TestAdapterAddPolicyIsIdempotent(t *testing.T) { + db := newAuthzTestDB(t) + adapter := newGormAdapter(db) + rule := []string{UserSubject(55), ResourceChannel, ActionSensitiveWrite, EffectAllow} + + require.NoError(t, adapter.AddPolicy("p", "p", rule)) + require.NoError(t, adapter.AddPolicy("p", "p", rule)) + + var count int64 + require.NoError(t, db.Model(&model.CasbinRule{}).Where( + "ptype = ? AND v0 = ? AND v1 = ? AND v2 = ? AND v3 = ?", + "p", + UserSubject(55), + ResourceChannel, + ActionSensitiveWrite, + EffectAllow, + ).Count(&count).Error) + assert.Equal(t, int64(1), count) +} + +func TestCapabilitiesUseCatalogShape(t *testing.T) { + db := newAuthzTestDB(t) + require.NoError(t, Init(db)) + + capabilities := Capabilities(7, common.RoleAdminUser) + + assert.True(t, capabilities[ResourceChannel][ActionRead]) + assert.True(t, capabilities[ResourceChannel][ActionOperate]) + assert.True(t, capabilities[ResourceChannel][ActionWrite]) + assert.False(t, capabilities[ResourceChannel][ActionSensitiveWrite]) + assert.False(t, capabilities[ResourceChannel][ActionSecretView]) +} diff --git a/service/authz/enforcer.go b/service/authz/enforcer.go new file mode 100644 index 00000000..b64bf762 --- /dev/null +++ b/service/authz/enforcer.go @@ -0,0 +1,94 @@ +package authz + +import ( + "fmt" + "sync" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/casbin/casbin/v2" + casbinmodel "github.com/casbin/casbin/v2/model" + "gorm.io/gorm" +) + +var ( + enforcerMu sync.RWMutex + enforcer *casbin.SyncedEnforcer +) + +const modelText = ` +[request_definition] +r = sub, obj, act + +[policy_definition] +p = sub, obj, act, eft + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = r.sub == p.sub && r.obj == p.obj && r.act == p.act && p.eft == "allow" +` + +func Init(db *gorm.DB) error { + if common.IsMasterNode { + if err := seedBuiltInRoles(db); err != nil { + return err + } + if err := resetBuiltInRolePolicies(db); err != nil { + return err + } + } + + m, err := casbinmodel.NewModelFromString(modelText) + if err != nil { + return err + } + e, err := casbin.NewSyncedEnforcer(m, newGormAdapter(db)) + if err != nil { + return err + } + e.EnableAutoSave(true) + + enforcerMu.Lock() + enforcer = e + enforcerMu.Unlock() + + if !common.IsMasterNode { + return nil + } + return seedDefaultPolicies() +} + +func currentEnforcer() *casbin.SyncedEnforcer { + enforcerMu.RLock() + defer enforcerMu.RUnlock() + return enforcer +} + +func ReloadPolicy() error { + enforcerMu.Lock() + defer enforcerMu.Unlock() + if enforcer == nil { + return fmt.Errorf("authz enforcer is not initialized") + } + return enforcer.LoadPolicy() +} + +// StartPolicySync periodically reloads the authorization policy from the database. +// The enforcer keeps an in-memory snapshot, and permission changes are written +// straight to the DB (see SetUserPermissionsInTx) with only the local node's +// snapshot refreshed afterwards. Without this loop other instances in a +// multi-node deployment would keep serving stale permissions (including not +// honoring a revoked grant) until restart. Mirrors model.SyncOptions polling. +func StartPolicySync(frequency int) { + if frequency <= 0 { + return + } + for { + time.Sleep(time.Duration(frequency) * time.Second) + if err := ReloadPolicy(); err != nil { + common.SysError("failed to reload authz policy: " + err.Error()) + } + } +} diff --git a/service/authz/override.go b/service/authz/override.go new file mode 100644 index 00000000..e2e9987e --- /dev/null +++ b/service/authz/override.go @@ -0,0 +1,163 @@ +package authz + +import ( + "fmt" + "sort" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/casbin/casbin/v2" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type overridePolicy struct { + Resource string + Action string + Effect string +} + +func SetUserPermissions(userID int, permissions PermissionsMap) error { + e := currentEnforcer() + if e == nil { + return fmt.Errorf("authz enforcer is not initialized") + } + + for resource, actions := range permissions { + if !isKnownResource(resource) { + continue + } + if _, err := e.RemoveFilteredPolicy(0, UserSubject(userID), resource); err != nil { + return err + } + for _, policy := range userOverridePolicies(e, resource, actions) { + if _, err := e.AddPolicy(UserSubject(userID), policy.Resource, policy.Action, policy.Effect); err != nil { + return err + } + } + } + return nil +} + +func SetUserPermissionsInTx(tx *gorm.DB, userID int, permissions PermissionsMap) error { + e := currentEnforcer() + if e == nil { + return fmt.Errorf("authz enforcer is not initialized") + } + + for resource, actions := range permissions { + if !isKnownResource(resource) { + continue + } + if err := tx.Where("ptype = ? AND v0 = ? AND v1 = ?", "p", UserSubject(userID), resource).Delete(&model.CasbinRule{}).Error; err != nil { + return err + } + policies := userOverridePolicies(e, resource, actions) + if len(policies) == 0 { + continue + } + rules := make([]model.CasbinRule, 0, len(policies)) + for _, policy := range policies { + rules = append(rules, newRule("p", []string{UserSubject(userID), policy.Resource, policy.Action, policy.Effect})) + } + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&rules).Error; err != nil { + return err + } + } + return nil +} + +func ClearUserPermissions(userID int) error { + e := currentEnforcer() + if e == nil { + return fmt.Errorf("authz enforcer is not initialized") + } + + for _, resource := range registry { + if _, err := e.RemoveFilteredPolicy(0, UserSubject(userID), resource.Resource); err != nil { + return err + } + } + return nil +} + +func ClearUserPermissionsInTx(tx *gorm.DB, userID int) error { + for _, resource := range registry { + if err := tx.Where("ptype = ? AND v0 = ? AND v1 = ?", "p", UserSubject(userID), resource.Resource).Delete(&model.CasbinRule{}).Error; err != nil { + return err + } + } + return nil +} + +func ClearUserAuthorization(userID int) error { + return ClearUserPermissions(userID) +} + +func ClearUserAuthorizationInTx(tx *gorm.DB, userID int) error { + return ClearUserPermissionsInTx(tx, userID) +} + +// ExplicitUserPermissions returns the effective permission matrix for the +// managed role plus any per-user overrides. +func ExplicitUserPermissions(userID int) PermissionsMap { + return Capabilities(userID, common.RoleAdminUser) +} + +// ExplicitUserOverrides returns only the per-user override entries. +func ExplicitUserOverrides(userID int) PermissionsMap { + e := currentEnforcer() + if e == nil { + return PermissionsMap{} + } + + result := PermissionsMap{} + for _, resource := range registry { + policies, err := e.GetFilteredPolicy(0, UserSubject(userID), resource.Resource) + if err != nil { + return PermissionsMap{} + } + actions := make(map[string]bool, len(policies)) + for _, policy := range policies { + if len(policy) >= 3 && isKnownPermission(Permission{Resource: policy[1], Action: policy[2]}) { + effect := policyEffect(policy) + if effect == EffectAllow || effect == EffectDeny { + actions[policy[2]] = effect == EffectAllow + } + } + } + if len(actions) > 0 { + result[resource.Resource] = actions + } + } + return result +} + +// userOverridePolicies returns the override entries that differ from the managed +// role baseline; entries matching the baseline are omitted. +func userOverridePolicies(e *casbin.SyncedEnforcer, resource string, actions map[string]bool) []overridePolicy { + overrides := make([]overridePolicy, 0, len(actions)) + for _, action := range catalogActions(resource) { + desired, ok := actions[action.Action] + if !ok { + continue + } + permission := Permission{Resource: resource, Action: action.Action} + if desired == roleBaselineAllows(e, managedRoleKey, permission) { + continue + } + effect := EffectDeny + if desired { + effect = EffectAllow + } + overrides = append(overrides, overridePolicy{ + Resource: resource, + Action: action.Action, + Effect: effect, + }) + } + sort.Slice(overrides, func(i, j int) bool { + return overrides[i].Action < overrides[j].Action + }) + return overrides +} diff --git a/service/authz/permission.go b/service/authz/permission.go new file mode 100644 index 00000000..994474b8 --- /dev/null +++ b/service/authz/permission.go @@ -0,0 +1,27 @@ +package authz + +import "strconv" + +// Permission identifies a single action on a resource. +type Permission struct { + Resource string + Action string +} + +// PermissionsMap is a resource -> action -> allowed lookup. +type PermissionsMap map[string]map[string]bool + +const ( + EffectAllow = "allow" + EffectDeny = "deny" +) + +// UserSubject is the casbin subject string for a single user. +func UserSubject(userID int) string { + return "user:" + strconv.Itoa(userID) +} + +// RoleSubject is the casbin subject string for a role. +func RoleSubject(roleKey string) string { + return "role:" + roleKey +} diff --git a/service/authz/registry.go b/service/authz/registry.go new file mode 100644 index 00000000..2b158d05 --- /dev/null +++ b/service/authz/registry.go @@ -0,0 +1,108 @@ +package authz + +// ActionDefinition describes a single action exposed by a resource. DefaultRoles +// lists the role keys that receive this action as part of their baseline grants. +type ActionDefinition struct { + Action string `json:"action"` + LabelKey string `json:"label_key"` + DescriptionKey string `json:"description_key"` + DefaultRoles []string `json:"-"` +} + +// ResourceDefinition describes a resource and the actions it exposes. +type ResourceDefinition struct { + Resource string `json:"resource"` + LabelKey string `json:"label_key"` + Actions []ActionDefinition `json:"actions"` +} + +var registry []ResourceDefinition + +// RegisterResource adds a resource definition to the permission registry. +func RegisterResource(resource ResourceDefinition) { + registry = append(registry, resource) +} + +// Catalog returns a copy of the registered resource definitions. +func Catalog() []ResourceDefinition { + result := make([]ResourceDefinition, 0, len(registry)) + for _, resource := range registry { + result = append(result, ResourceDefinition{ + Resource: resource.Resource, + LabelKey: resource.LabelKey, + Actions: append([]ActionDefinition(nil), resource.Actions...), + }) + } + return result +} + +// AllPermissions returns every registered permission. +func AllPermissions() []Permission { + permissions := make([]Permission, 0) + for _, resource := range registry { + for _, action := range resource.Actions { + permissions = append(permissions, Permission{ + Resource: resource.Resource, + Action: action.Action, + }) + } + } + return permissions +} + +// PermissionsForRole returns the permissions whose DefaultRoles include roleKey. +func PermissionsForRole(roleKey string) []Permission { + permissions := make([]Permission, 0) + for _, resource := range registry { + for _, action := range resource.Actions { + if actionHasRole(action, roleKey) { + permissions = append(permissions, Permission{ + Resource: resource.Resource, + Action: action.Action, + }) + } + } + } + return permissions +} + +func actionHasRole(action ActionDefinition, roleKey string) bool { + for _, r := range action.DefaultRoles { + if r == roleKey { + return true + } + } + return false +} + +func isKnownResource(resource string) bool { + for _, known := range registry { + if known.Resource == resource { + return true + } + } + return false +} + +func catalogActions(resource string) []ActionDefinition { + for _, known := range registry { + if known.Resource == resource { + return known.Actions + } + } + return nil +} + +func isKnownPermission(permission Permission) bool { + for _, resource := range registry { + if resource.Resource != permission.Resource { + continue + } + for _, action := range resource.Actions { + if action.Action == permission.Action { + return true + } + } + } + return false +} diff --git a/service/authz/resolver.go b/service/authz/resolver.go new file mode 100644 index 00000000..888c5fb0 --- /dev/null +++ b/service/authz/resolver.go @@ -0,0 +1,83 @@ +package authz + +import "github.com/casbin/casbin/v2" + +// Can reports whether the subject may perform the permission. A superuser role +// short-circuits to allow. Otherwise a per-user override wins, then the union of +// the subject's role baselines applies. +func Can(userID int, systemRole int, permission Permission) bool { + roles := resolveSubjectRoles(userID, systemRole) + if len(roles) == 0 { + return false + } + for _, role := range roles { + if isSuperuserRole(role) { + return true + } + } + if !isKnownPermission(permission) { + return false + } + + e := currentEnforcer() + if e == nil { + return false + } + if effect, ok := explicitSubjectEffect(e, UserSubject(userID), permission); ok { + return effect == EffectAllow + } + for _, role := range roles { + if roleBaselineAllows(e, role, permission) { + return true + } + } + return false +} + +// Capabilities returns the full resource/action matrix the subject is allowed. +func Capabilities(userID int, systemRole int) PermissionsMap { + result := make(PermissionsMap, len(registry)) + for _, resource := range registry { + actions := make(map[string]bool, len(resource.Actions)) + for _, action := range resource.Actions { + actions[action.Action] = Can(userID, systemRole, Permission{ + Resource: resource.Resource, + Action: action.Action, + }) + } + result[resource.Resource] = actions + } + return result +} + +func roleBaselineAllows(e *casbin.SyncedEnforcer, roleKey string, permission Permission) bool { + effect, ok := explicitSubjectEffect(e, RoleSubject(roleKey), permission) + return ok && effect == EffectAllow +} + +func explicitSubjectEffect(e *casbin.SyncedEnforcer, subject string, permission Permission) (string, bool) { + policies, err := e.GetFilteredPolicy(0, subject, permission.Resource, permission.Action) + if err != nil { + return "", false + } + hasAllow := false + for _, policy := range policies { + switch policyEffect(policy) { + case EffectDeny: + return EffectDeny, true + case EffectAllow: + hasAllow = true + } + } + if hasAllow { + return EffectAllow, true + } + return "", false +} + +func policyEffect(policy []string) string { + if len(policy) < 4 || policy[3] == "" { + return EffectAllow + } + return policy[3] +} diff --git a/service/authz/resources_channel.go b/service/authz/resources_channel.go new file mode 100644 index 00000000..f7883830 --- /dev/null +++ b/service/authz/resources_channel.go @@ -0,0 +1,56 @@ +package authz + +const ( + ResourceChannel = "channel" + + ActionRead = "read" + ActionOperate = "operate" + ActionWrite = "write" + ActionSensitiveWrite = "sensitive_write" + ActionSecretView = "secret_view" +) + +var ( + ChannelRead = Permission{Resource: ResourceChannel, Action: ActionRead} + ChannelOperate = Permission{Resource: ResourceChannel, Action: ActionOperate} + ChannelWrite = Permission{Resource: ResourceChannel, Action: ActionWrite} + ChannelSensitiveWrite = Permission{Resource: ResourceChannel, Action: ActionSensitiveWrite} + ChannelSecretView = Permission{Resource: ResourceChannel, Action: ActionSecretView} +) + +func init() { + RegisterResource(ResourceDefinition{ + Resource: ResourceChannel, + LabelKey: "Channel Management", + Actions: []ActionDefinition{ + { + Action: ActionRead, + LabelKey: "Read channels", + DescriptionKey: "View channel lists and details without secrets.", + DefaultRoles: []string{BuiltInRoleAdmin}, + }, + { + Action: ActionOperate, + LabelKey: "Operate channels", + DescriptionKey: "Test channels, refresh balances, and enable/disable individual, batch, or tagged channels.", + DefaultRoles: []string{BuiltInRoleAdmin}, + }, + { + Action: ActionWrite, + LabelKey: "Edit channel routing", + DescriptionKey: "Edit non-sensitive settings such as models, groups, and routing rules.", + DefaultRoles: []string{BuiltInRoleAdmin}, + }, + { + Action: ActionSensitiveWrite, + LabelKey: "Edit sensitive channel settings", + DescriptionKey: "Create channels or edit keys, base URLs, and overrides.", + }, + { + Action: ActionSecretView, + LabelKey: "View channel secrets", + DescriptionKey: "Reserved for viewing complete channel keys after secure verification.", + }, + }, + }) +} diff --git a/service/authz/role.go b/service/authz/role.go new file mode 100644 index 00000000..d3af237e --- /dev/null +++ b/service/authz/role.go @@ -0,0 +1,86 @@ +package authz + +const ( + BuiltInRoleRoot = "root" + BuiltInRoleAdmin = "admin" +) + +// RoleSpec describes a role. A superuser role is allowed every permission +// without an explicit policy entry. +type RoleSpec struct { + Key string + Name string + Description string + BuiltIn bool + Superuser bool + Sort int +} + +var builtInRoles = []RoleSpec{ + { + Key: BuiltInRoleRoot, + Name: "Root", + Description: "Built-in root authorization role", + BuiltIn: true, + Superuser: true, + Sort: 0, + }, + { + Key: BuiltInRoleAdmin, + Name: "Admin", + Description: "Built-in admin authorization role", + BuiltIn: true, + Superuser: false, + Sort: 10, + }, +} + +// RoleDescriptor exposes a role together with its baseline grant matrix. +type RoleDescriptor struct { + Key string `json:"key"` + Name string `json:"name"` + BuiltIn bool `json:"built_in"` + Superuser bool `json:"superuser"` + Grants PermissionsMap `json:"grants"` +} + +// Roles returns the role descriptors with their baseline grants. +func Roles() []RoleDescriptor { + result := make([]RoleDescriptor, 0, len(builtInRoles)) + for _, spec := range builtInRoles { + result = append(result, RoleDescriptor{ + Key: spec.Key, + Name: spec.Name, + BuiltIn: spec.BuiltIn, + Superuser: spec.Superuser, + Grants: roleGrants(spec), + }) + } + return result +} + +func roleGrants(spec RoleSpec) PermissionsMap { + grants := make(PermissionsMap, len(registry)) + for _, resource := range registry { + actions := make(map[string]bool, len(resource.Actions)) + for _, action := range resource.Actions { + actions[action.Action] = spec.Superuser || actionHasRole(action, spec.Key) + } + grants[resource.Resource] = actions + } + return grants +} + +func roleSpec(roleKey string) (RoleSpec, bool) { + for _, spec := range builtInRoles { + if spec.Key == roleKey { + return spec, true + } + } + return RoleSpec{}, false +} + +func isSuperuserRole(roleKey string) bool { + spec, ok := roleSpec(roleKey) + return ok && spec.Superuser +} diff --git a/service/authz/seed.go b/service/authz/seed.go new file mode 100644 index 00000000..78536fcf --- /dev/null +++ b/service/authz/seed.go @@ -0,0 +1,62 @@ +package authz + +import ( + "fmt" + + "github.com/QuantumNous/new-api/model" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +func seedBuiltInRoles(db *gorm.DB) error { + for _, spec := range builtInRoles { + role := model.AuthzRole{ + Key: spec.Key, + Name: spec.Name, + Description: spec.Description, + BuiltIn: spec.BuiltIn, + Enabled: true, + Sort: spec.Sort, + } + if err := db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "key"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "name", + "description", + "built_in", + "enabled", + "sort", + }), + }).Create(&role).Error; err != nil { + return err + } + } + return nil +} + +func resetBuiltInRolePolicies(db *gorm.DB) error { + subjects := make([]string, 0, len(builtInRoles)) + for _, spec := range builtInRoles { + subjects = append(subjects, RoleSubject(spec.Key)) + } + return db.Where("ptype = ? AND v0 IN ?", "p", subjects).Delete(&model.CasbinRule{}).Error +} + +func seedDefaultPolicies() error { + e := currentEnforcer() + if e == nil { + return fmt.Errorf("authz enforcer is not initialized") + } + + for _, spec := range builtInRoles { + if spec.Superuser { + continue + } + for _, permission := range PermissionsForRole(spec.Key) { + if _, err := e.AddPolicy(RoleSubject(spec.Key), permission.Resource, permission.Action, EffectAllow); err != nil { + return err + } + } + } + return nil +} diff --git a/web/default/src/features/channels/api.ts b/web/default/src/features/channels/api.ts index 303d97cd..9e80801d 100644 --- a/web/default/src/features/channels/api.ts +++ b/web/default/src/features/channels/api.ts @@ -138,6 +138,36 @@ export async function updateChannel( return res.data } +/** + * Update channel enabled/disabled status. + */ +export async function updateChannelStatus( + id: number, + status: number +): Promise<{ success: boolean; message?: string; data?: boolean }> { + const res = await api.post( + `/api/channel/${id}/status`, + { status }, + channelActionConfig() + ) + return res.data +} + +/** + * Batch update channel enabled/disabled status. + */ +export async function batchUpdateChannelStatus( + ids: number[], + status: number +): Promise<{ success: boolean; message?: string; data?: number }> { + const res = await api.post( + '/api/channel/status/batch', + { ids, status }, + channelActionConfig() + ) + return res.data +} + /** * Delete single channel */ diff --git a/web/default/src/features/channels/components/channels-primary-buttons.tsx b/web/default/src/features/channels/components/channels-primary-buttons.tsx index 4aa34098..3e9d7c43 100644 --- a/web/default/src/features/channels/components/channels-primary-buttons.tsx +++ b/web/default/src/features/channels/components/channels-primary-buttons.tsx @@ -32,6 +32,12 @@ import { } 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 { DropdownMenu, DropdownMenuContent, @@ -43,6 +49,11 @@ import { } from '@/components/ui/dropdown-menu' import { Label } from '@/components/ui/label' import { Switch } from '@/components/ui/switch' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip' import { ConfirmDialog } from '@/components/confirm-dialog' import { handleDeleteAllDisabled, @@ -65,6 +76,12 @@ export function ChannelsPrimaryButtons() { } = useChannels() const queryClient = useQueryClient() const [showDeleteDialog, setShowDeleteDialog] = useState(false) + const currentUser = useAuthStore((s) => s.auth.user) + const canEditSensitive = hasPermission( + currentUser, + ADMIN_PERMISSION_RESOURCES.CHANNEL, + ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE + ) const handleTagModeToggle = (checked: boolean) => { localStorage.setItem('enable-tag-mode', String(checked)) @@ -105,17 +122,28 @@ export function ChannelsPrimaryButtons() { {/* Create Channel */} - + + }> + + + {!canEditSensitive && ( + + {t('No permission to perform this action')} + + )} + {/* More Actions */} @@ -209,8 +237,10 @@ export function ChannelsPrimaryButtons() { { e.preventDefault() + if (!canEditSensitive) return setShowDeleteDialog(true) }} + disabled={!canEditSensitive} className='text-destructive focus:text-destructive' > {t('Delete All Disabled')} @@ -231,6 +261,7 @@ export function ChannelsPrimaryButtons() { )} destructive handleConfirm={() => { + if (!canEditSensitive) return handleDeleteAllDisabled(queryClient, (_count) => { // eslint-disable-next-line no-console console.log(`Deleted ${_count} channels`) diff --git a/web/default/src/features/channels/components/data-table-bulk-actions.tsx b/web/default/src/features/channels/components/data-table-bulk-actions.tsx index 31a30b28..389bfe3f 100644 --- a/web/default/src/features/channels/components/data-table-bulk-actions.tsx +++ b/web/default/src/features/channels/components/data-table-bulk-actions.tsx @@ -24,6 +24,13 @@ import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { useAuthStore } from '@/stores/auth-store' +import { + ADMIN_PERMISSION_ACTIONS, + ADMIN_PERMISSION_RESOURCES, + hasPermission, +} from '@/lib/admin-permissions' +import { cn } from '@/lib/utils' import { Tooltip, TooltipContent, @@ -51,6 +58,12 @@ export function DataTableBulkActions({ const [showTagDialog, setShowTagDialog] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const [tagValue, setTagValue] = useState('') + const currentUser = useAuthStore((s) => s.auth.user) + const canEditSensitive = hasPermission( + currentUser, + ADMIN_PERMISSION_RESOURCES.CHANNEL, + ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE + ) const selectedRows = table.getFilteredSelectedRowModel().rows const selectedIds = selectedRows.reduce((ids, row) => { @@ -76,6 +89,7 @@ export function DataTableBulkActions({ } const handleDeleteAll = () => { + if (!canEditSensitive) return handleBatchDelete(selectedIds, queryClient, () => { setShowDeleteConfirm(false) handleClearSelection() @@ -164,10 +178,21 @@ export function DataTableBulkActions({ - diff --git a/web/default/src/features/channels/components/data-table-row-actions.tsx b/web/default/src/features/channels/components/data-table-row-actions.tsx index 99c31ca0..8a56756d 100644 --- a/web/default/src/features/channels/components/data-table-row-actions.tsx +++ b/web/default/src/features/channels/components/data-table-row-actions.tsx @@ -39,6 +39,12 @@ import { useTranslation } from 'react-i18next' import { ConfirmDialog } from '@/components/confirm-dialog' 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 { DropdownMenu, DropdownMenuContent, @@ -77,12 +83,18 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { const channel = row.original const { setOpen, setCurrentRow, upstream } = useChannels() const queryClient = useQueryClient() + const currentUser = useAuthStore((s) => s.auth.user) const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false) const [isTesting, setIsTesting] = useState(false) const [isTogglingStatus, setIsTogglingStatus] = useState(false) const isEnabled = isChannelEnabled(channel) const isMultiKey = isMultiKeyChannel(channel) + const canEditSensitive = hasPermission( + currentUser, + ADMIN_PERMISSION_RESOURCES.CHANNEL, + ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE + ) const handleEdit = () => { setCurrentRow(channel) @@ -314,12 +326,20 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { {/* Copy Channel */} - + {t('Copy Channel')} + {!canEditSensitive && ( + + {t('No permission to perform this action')} + + )} {/* Manage Keys (only for multi-key channels) */} {isMultiKey && ( @@ -335,8 +355,10 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { {/* Delete */} { e.preventDefault() + if (!canEditSensitive) return setDeleteConfirmOpen(true) }} className='text-destructive focus:text-destructive' @@ -360,6 +382,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { confirmText={t('Delete')} destructive handleConfirm={() => { + if (!canEditSensitive) return handleDeleteChannel(channel.id, queryClient) setDeleteConfirmOpen(false) }} diff --git a/web/default/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx b/web/default/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx index bf744d9b..a00809aa 100644 --- a/web/default/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx @@ -21,6 +21,12 @@ import { useQueryClient } from '@tanstack/react-query' import { Loader2, RefreshCw, Trash2, Power, PowerOff } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import { + ADMIN_PERMISSION_ACTIONS, + ADMIN_PERMISSION_RESOURCES, + hasPermission, +} from '@/lib/admin-permissions' +import { useAuthStore } from '@/stores/auth-store' import { Button } from '@/components/ui/button' import { Select, @@ -69,6 +75,12 @@ export function MultiKeyManageDialog({ const { t } = useTranslation() const { currentRow } = useChannels() const queryClient = useQueryClient() + const currentUser = useAuthStore((s) => s.auth.user) + const canEditSensitive = hasPermission( + currentUser, + ADMIN_PERMISSION_RESOURCES.CHANNEL, + ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE + ) // Data state const [isLoading, setIsLoading] = useState(false) @@ -148,6 +160,14 @@ export function MultiKeyManageDialog({ const performAction = async () => { if (!confirmAction || !currentRow) return + if ( + !canEditSensitive && + (confirmAction.type === 'delete' || + confirmAction.type === 'delete-disabled') + ) { + setConfirmAction(null) + return + } setIsPerformingAction(true) try { @@ -331,7 +351,16 @@ export function MultiKeyManageDialog({ diff --git a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx index cab16261..380d10e5 100644 --- a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx +++ b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx @@ -47,7 +47,13 @@ import { } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import { + ADMIN_PERMISSION_ACTIONS, + ADMIN_PERMISSION_RESOURCES, + hasPermission, +} from '@/lib/admin-permissions' import { getLobeIcon } from '@/lib/lobe-icon' +import { ROLE } from '@/lib/roles' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { useHiddenClickUnlock } from '@/hooks/use-hidden-click-unlock' import { Alert, AlertDescription } from '@/components/ui/alert' @@ -104,6 +110,7 @@ import { SecureVerificationDialog, useSecureVerification, } from '@/features/auth/secure-verification' +import { useAuthStore } from '@/stores/auth-store' import { fetchModels, getAllModels, @@ -198,6 +205,40 @@ const MODEL_MAPPING_PREVIEW_FALLBACK: Array<{ const ADVANCED_SETTINGS_EXPANDED_KEY = 'channel-advanced-settings-expanded' const UPSTREAM_DETECTED_MODEL_PREVIEW_LIMIT = 8 +const SENSITIVE_FORM_FIELDS = [ + 'type', + 'base_url', + 'key', + 'openai_organization', + 'other', + 'key_mode', + 'param_override', + 'header_override', + 'settings', + 'setting', + 'advanced_custom', + 'is_enterprise_account', + 'vertex_key_type', + 'aws_key_type', + 'azure_responses_version', + 'force_format', + 'thinking_to_content', + 'proxy', + 'pass_through_body_enabled', + 'system_prompt', + 'system_prompt_override', + 'allow_service_tier', + 'disable_store', + 'allow_safety_identifier', + 'allow_include_obfuscation', + 'allow_inference_geo', + 'allow_speed', + 'claude_beta_query', + 'disable_task_polling_sleep', + 'upstream_model_update_check_enabled', + 'upstream_model_update_auto_sync_enabled', + 'upstream_model_update_ignored_models', +] satisfies (keyof ChannelFormValues)[] function readAdvancedSettingsPreference(): boolean { if (typeof window === 'undefined') return false @@ -280,6 +321,13 @@ export function ChannelMutateDrawer({ const { t } = useTranslation() const queryClient = useQueryClient() const { setOpen } = useChannels() + const currentUser = useAuthStore((s) => s.auth.user) + const canEditSensitive = hasPermission( + currentUser, + ADMIN_PERMISSION_RESOURCES.CHANNEL, + ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE + ) + const canRevealChannelKey = currentUser?.role === ROLE.SUPER_ADMIN const [fetchModelsDialogOpen, setFetchModelsDialogOpen] = useState(false) const [channelKey, setChannelKey] = useState(null) const [isChannelKeyLoading, setIsChannelKeyLoading] = useState(false) @@ -307,6 +355,7 @@ export function ChannelMutateDrawer({ const isEditing = Boolean(currentRow) const channelId = currentRow?.id ?? null + const sensitiveLocked = isEditing && !canEditSensitive // Fetch channel details if editing const { data: channelData, isLoading: isChannelLoading } = useQuery({ @@ -388,7 +437,7 @@ export function ChannelMutateDrawer({ reset: resetDoubaoApiUnlock, } = useHiddenClickUnlock({ requiredClicks: 10, - disabled: currentType !== 45, + disabled: currentType !== 45 || sensitiveLocked, onUnlock: () => { toast.info(t('Doubao custom API address editing unlocked')) }, @@ -783,6 +832,11 @@ export function ChannelMutateDrawer({ return } + if (!isEditing && !canEditSensitive) { + toast.error(t("You don't have necessary permission")) + return + } + // For creation mode, validate key before opening dialog if (!isEditing) { const key = form.getValues('key') @@ -793,9 +847,12 @@ export function ChannelMutateDrawer({ } setFetchModelsDialogOpen(true) - }, [isEditing, form, t]) + }, [isEditing, canEditSensitive, form, t]) const createModeFetcher = useCallback(async (): Promise => { + if (!canEditSensitive) { + throw new Error(t("You don't have necessary permission")) + } const response = await fetchModels({ type: form.getValues('type'), key: form.getValues('key'), @@ -805,7 +862,7 @@ export function ChannelMutateDrawer({ return response.data } throw new Error(response.message || 'No models fetched from upstream') - }, [form]) + }, [canEditSensitive, form, t]) // Handle model operations const handleFillRelatedModels = useCallback(() => { @@ -963,6 +1020,21 @@ export function ChannelMutateDrawer({ return } + if (sensitiveLocked) { + const dirtyFields = form.formState.dirtyFields as Partial< + Record + > + const hasSensitiveChanges = SENSITIVE_FORM_FIELDS.some((field) => + Boolean(dirtyFields[field]) + ) + if (hasSensitiveChanges) { + toast.error( + t('You do not have permission to edit sensitive channel settings.') + ) + return + } + } + // Validate status_code_mapping entries if (data.status_code_mapping?.trim()) { const invalidEntries = collectInvalidStatusCodeEntries( @@ -1038,6 +1110,7 @@ export function ChannelMutateDrawer({ }, [ isEditing, + sensitiveLocked, form, confirmMissingModelMappings, confirmStatusCodeRisk, @@ -1105,6 +1178,17 @@ export function ChannelMutateDrawer({ + {sensitiveLocked && ( + + + {t('Sensitive channel settings are read-only for your account.')}{' '} + {t( + 'You can still edit non-sensitive operations fields such as models, groups, priority, and weight.' + )} + + + )} +
- ( - - {t('Type *')} - - { - const nextType = Number(value) - if ( - Number.isInteger(nextType) && - nextType > 0 - ) { - field.onChange(nextType) - } - }} - placeholder={t('Select channel type')} - searchPlaceholder={t('Search channel type...')} - emptyText={t('No channel type found.')} - allowCustomValue - /> - - - - )} - /> +
+ ( + + {t('Type *')} + + { + const nextType = Number(value) + if ( + Number.isInteger(nextType) && + nextType > 0 + ) { + field.onChange(nextType) + } + }} + placeholder={t('Select channel type')} + searchPlaceholder={t( + 'Search channel type...' + )} + emptyText={t('No channel type found.')} + allowCustomValue + /> + + {sensitiveLocked && ( + + {t( + 'No permission to perform this action' + )} + + )} + + + )} + /> +
- ( - -
- {t('Enabled')} - - {t('Enable or disable this channel')} - -
- - - field.onChange(checked ? 1 : 2) - } - /> - -
- )} - /> - - {currentType === 1 && ( + {!isEditing && ( ( - - {t('OpenAI Organization')} + +
+ {t('Enabled')} + + {t('Enable or disable this channel')} + +
- + + field.onChange(checked ? 1 : 2) + } + /> - - {t(FIELD_DESCRIPTIONS.OPENAI_ORG)} - -
)} /> )} + + {currentType === 1 && ( +
+ ( + + {t('OpenAI Organization')} + + + + + {sensitiveLocked + ? t( + 'No permission to perform this action' + ) + : t(FIELD_DESCRIPTIONS.OPENAI_ORG)} + + + + )} + /> +
+ )} {/* ── API Access ── */} @@ -1219,6 +1328,20 @@ export function ChannelMutateDrawer({ )} + {sensitiveLocked && ( + + + {t( + 'No permission to perform this action' + )} + + + )} + +
{/* Azure (type 3) */} {currentType === 3 && ( <> @@ -2004,7 +2127,7 @@ export function ChannelMutateDrawer({ )} - {isEditing && ( + {isEditing && canRevealChannelKey && (
@@ -2081,7 +2204,10 @@ export function ChannelMutateDrawer({ variant='outline' size='sm' onClick={handleRefreshCodexCredential} - disabled={isCodexCredentialRefreshing} + disabled={ + sensitiveLocked || + isCodexCredentialRefreshing + } > {isCodexCredentialRefreshing ? ( @@ -2207,6 +2333,7 @@ export function ChannelMutateDrawer({ /> )} +
{/* ── Models & Groups ── */} @@ -2324,18 +2451,28 @@ export function ChannelMutateDrawer({ {t('Fill All Models')} {MODEL_FETCHABLE_TYPES.has(currentType) && ( - + <> + + {!isEditing && !canEditSensitive && ( + + {t( + 'No permission to perform this action' + )} + + )} + )}