From a23080c27ae015c71ade58d9288ef7d69066a248 Mon Sep 17 00:00:00 2001 From: CaIon Date: Fri, 26 Jun 2026 16:07:13 +0800 Subject: [PATCH 1/6] feat: add casbin admin permissions --- controller/channel.go | 67 ++- controller/channel_authz_test.go | 63 +++ controller/user.go | 36 ++ go.mod | 3 + go.sum | 12 +- main.go | 5 + middleware/auth.go | 17 + model/authz_role.go | 17 + model/casbin_rule.go | 16 + model/main.go | 4 + model/user.go | 63 +-- router/api-router.go | 43 +- router/channel-router.go | 77 +++ service/authz/adapter.go | 120 +++++ service/authz/authz.go | 463 ++++++++++++++++++ service/authz/authz_test.go | 138 ++++++ .../components/channels-primary-buttons.tsx | 36 +- .../components/data-table-row-actions.tsx | 26 +- .../users/components/users-mutate-drawer.tsx | 95 ++++ .../src/features/users/lib/user-form.ts | 18 + web/default/src/features/users/types.ts | 3 + web/default/src/i18n/locales/en.json | 20 +- web/default/src/i18n/locales/fr.json | 20 +- web/default/src/i18n/locales/ja.json | 20 +- web/default/src/i18n/locales/ru.json | 20 +- web/default/src/i18n/locales/vi.json | 20 +- web/default/src/i18n/locales/zh.json | 20 +- web/default/src/lib/admin-permissions.ts | 75 +++ web/default/src/stores/auth-store.ts | 2 + 29 files changed, 1408 insertions(+), 111 deletions(-) create mode 100644 controller/channel_authz_test.go create mode 100644 model/authz_role.go create mode 100644 model/casbin_rule.go create mode 100644 router/channel-router.go create mode 100644 service/authz/adapter.go create mode 100644 service/authz/authz.go create mode 100644 service/authz/authz_test.go create mode 100644 web/default/src/lib/admin-permissions.ts diff --git a/controller/channel.go b/controller/channel.go index 5b1cefef53ba..d3d5a3169e6c 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)) { @@ -898,11 +905,20 @@ type PatchChannel struct { 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 err := validateChannel(&channel.Channel, false); err != nil { @@ -925,6 +941,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) @@ -1052,6 +1074,40 @@ func UpdateChannel(c *gin.Context) { return } +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 + } + return false +} + // equalStringPtr 比较两个 *string 是否相等(均为 nil 视为相等)。 func equalStringPtr(a, b *string) bool { if a == nil && b == nil { @@ -1364,6 +1420,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 +1869,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_test.go b/controller/channel_authz_test.go new file mode 100644 index 000000000000..f891f3aa48ab --- /dev/null +++ b/controller/channel_authz_test.go @@ -0,0 +1,63 @@ +package controller + +import ( + "testing" + + "github.com/QuantumNous/new-api/model" + "github.com/stretchr/testify/assert" +) + +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})) + }) +} diff --git a/controller/user.go b/controller/user.go index 33c7b1dff76c..e8249ee782f2 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" @@ -334,6 +335,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 +445,7 @@ func GetSelf(c *gin.Context) { // 计算用户权限信息 permissions := calculateUserPermissions(userRole) + permissions["admin_permissions"] = authz.Capabilities(id, userRole) // 获取用户设置并提取sidebar_modules userSetting := user.GetSetting() @@ -620,6 +623,9 @@ func UpdateUser(c *gin.Context) { common.ApiError(c, err) return } + if updatedUser.Role == 0 { + updatedUser.Role = originUser.Role + } myRole := c.GetInt("role") if !canManageTargetRole(myRole, originUser.Role) { common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel) @@ -637,6 +643,10 @@ func UpdateUser(c *gin.Context) { common.ApiError(c, err) return } + if err := updateAdminPermissionsForUser(c, updatedUser.Id, updatedUser.Role, updatedUser.AdminPermissions); err != nil { + common.ApiError(c, err) + return + } recordManageAuditFor(c, updatedUser.Id, "user.update", map[string]interface{}{ "username": originUser.Username, "id": updatedUser.Id, @@ -905,6 +915,10 @@ func CreateUser(c *gin.Context) { common.ApiError(c, err) return } + if err := updateAdminPermissionsForUser(c, cleanUser.Id, cleanUser.Role, user.AdminPermissions); err != nil { + common.ApiError(c, err) + return + } recordManageAuditFor(c, cleanUser.Id, "user.create", map[string]interface{}{ "username": cleanUser.Username, @@ -917,6 +931,22 @@ func CreateUser(c *gin.Context) { return } +func updateAdminPermissionsForUser(c *gin.Context, userID int, userRole int, permissions map[string]map[string]bool) error { + if permissions == nil { + if userRole < common.RoleAdminUser && c.GetInt("role") == common.RoleRootUser { + return authz.ClearUserAuthorization(userID) + } + return nil + } + if c.GetInt("role") != common.RoleRootUser { + return fmt.Errorf("only root can update admin permissions") + } + if userRole < common.RoleAdminUser { + return authz.ClearUserAuthorization(userID) + } + return authz.SetUserPermissions(userID, permissions) +} + type ManageRequest struct { Id int `json:"id"` Action string `json:"action"` @@ -1044,6 +1074,12 @@ func ManageUser(c *gin.Context) { common.ApiError(c, err) return } + if req.Action == "demote" { + if err := authz.ClearUserAuthorization(user.Id); err != nil { + common.ApiError(c, err) + return + } + } // 禁用 / 角色调整后,强制失效用户缓存与其全部令牌缓存, // 避免在 Redis TTL 过期前仍使用旧状态(尤其是禁用后仍可发起请求的问题)。 // InvalidateUserCache 会让下一次 GetUserCache 从数据库重新加载, diff --git a/go.mod b/go.mod index cdd342fd3191..c1ee8cd1bd5a 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 10dd579e07b5..ee0ec67736a9 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 976e01d73fd6..4a0ca15487c2 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" @@ -284,6 +285,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 5f2ed4899d4c..69c06e9672b8 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 000000000000..329eda92aebc --- /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 000000000000..07e3082e3b90 --- /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"` + V0 string `gorm:"size:100;index:idx_casbin_rule,priority:2"` + V1 string `gorm:"size:100;index:idx_casbin_rule,priority:3"` + V2 string `gorm:"size:100;index:idx_casbin_rule,priority:4"` + V3 string `gorm:"size:100;index:idx_casbin_rule,priority:5"` + V4 string `gorm:"size:100;index:idx_casbin_rule,priority:6"` + V5 string `gorm:"size:100;index:idx_casbin_rule,priority:7"` +} + +func (CasbinRule) TableName() string { + return "casbin_rule" +} diff --git a/model/main.go b/model/main.go index ec1485632707..dc2e22e8513e 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 @@ -349,6 +351,8 @@ func migrateDBFast() error { {&SystemInstance{}, "SystemInstance"}, {&SystemTask{}, "SystemTask"}, {&SystemTaskLock{}, "SystemTaskLock"}, + {&CasbinRule{}, "CasbinRule"}, + {&AuthzRole{}, "AuthzRole"}, } // 动态计算migration数量,确保errChan缓冲区足够大 errChan := make(chan error, len(migrations)) diff --git a/model/user.go b/model/user.go index 53f93b02b10b..0c2f263eb4c4 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 { diff --git a/router/api-router.go b/router/api-router.go index 47bdc7c1a2c6..c5a437676204 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -225,48 +225,7 @@ 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) tokenRoute := apiRouter.Group("/token") tokenRoute.Use(middleware.UserAuth()) { diff --git a/router/channel-router.go b/router/channel-router.go new file mode 100644 index 000000000000..cb9afac33e3e --- /dev/null +++ b/router/channel-router.go @@ -0,0 +1,77 @@ +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.MethodDelete, path: "/disabled", permission: authz.ChannelWrite, 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.ChannelWrite, handler: controller.DeleteChannel}, + {method: http.MethodPost, path: "/batch", permission: authz.ChannelWrite, 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.ChannelRead, 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/service/authz/adapter.go b/service/authz/adapter.go new file mode 100644 index 000000000000..27ddff27662c --- /dev/null +++ b/service/authz/adapter.go @@ -0,0 +1,120 @@ +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" +) + +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.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/authz.go b/service/authz/authz.go new file mode 100644 index 000000000000..ac8d3ec2b25c --- /dev/null +++ b/service/authz/authz.go @@ -0,0 +1,463 @@ +package authz + +import ( + "fmt" + "sort" + "strconv" + "sync" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/casbin/casbin/v2" + casbinmodel "github.com/casbin/casbin/v2/model" + "gorm.io/gorm" +) + +type Permission struct { + Resource string + Action string +} + +type ActionDefinition struct { + Action string + LabelKey string + DescriptionKey string + DefaultAdmin bool +} + +type ResourceDefinition struct { + Resource string + LabelKey string + Actions []ActionDefinition +} + +type PermissionsMap map[string]map[string]bool + +const ( + ResourceChannel = "channel" + + ActionRead = "read" + ActionOperate = "operate" + ActionWrite = "write" + ActionSensitiveWrite = "sensitive_write" + ActionSecretView = "secret_view" + + EffectAllow = "allow" + EffectDeny = "deny" + + BuiltInRoleRoot = "root" + BuiltInRoleAdmin = "admin" +) + +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} + + enforcerMu sync.RWMutex + enforcer *casbin.Enforcer + + catalog = []ResourceDefinition{ + { + Resource: ResourceChannel, + LabelKey: "Channel Management", + Actions: []ActionDefinition{ + { + Action: ActionRead, + LabelKey: "Read channels", + DescriptionKey: "View channel lists and details without secrets.", + DefaultAdmin: true, + }, + { + Action: ActionOperate, + LabelKey: "Operate channels", + DescriptionKey: "Test channels, update balances, and toggle availability.", + DefaultAdmin: true, + }, + { + Action: ActionWrite, + LabelKey: "Edit channel routing", + DescriptionKey: "Edit non-sensitive routing fields such as models and groups.", + DefaultAdmin: true, + }, + { + 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.", + }, + }, + }, + } +) + +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 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.NewEnforcer(m, newGormAdapter(db)) + if err != nil { + return err + } + e.EnableAutoSave(true) + + enforcerMu.Lock() + enforcer = e + enforcerMu.Unlock() + + return seedDefaultPolicies() +} + +func Catalog() []ResourceDefinition { + result := make([]ResourceDefinition, 0, len(catalog)) + for _, resource := range catalog { + item := ResourceDefinition{ + Resource: resource.Resource, + LabelKey: resource.LabelKey, + Actions: append([]ActionDefinition(nil), resource.Actions...), + } + result = append(result, item) + } + return result +} + +func Can(userID int, systemRole int, permission Permission) bool { + if systemRole >= common.RoleRootUser { + return true + } + if systemRole < common.RoleAdminUser || !isKnownPermission(permission) { + return false + } + + e := currentEnforcer() + if e == nil { + return false + } + + if effect, ok := explicitSubjectEffect(e, UserSubject(userID), permission); ok { + return effect == EffectAllow + } + return roleBaselineAllows(e, permission) +} + +func Capabilities(userID int, systemRole int) PermissionsMap { + result := make(PermissionsMap, len(catalog)) + for _, resource := range catalog { + 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 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 ClearUserPermissions(userID int) error { + e := currentEnforcer() + if e == nil { + return fmt.Errorf("authz enforcer is not initialized") + } + + for _, resource := range catalog { + if _, err := e.RemoveFilteredPolicy(0, UserSubject(userID), resource.Resource); err != nil { + return err + } + } + return nil +} + +func ClearUserAuthorization(userID int) error { + return ClearUserPermissions(userID) +} + +func ExplicitUserPermissions(userID int) PermissionsMap { + return Capabilities(userID, common.RoleAdminUser) +} + +func ExplicitUserOverrides(userID int) PermissionsMap { + e := currentEnforcer() + if e == nil { + return PermissionsMap{} + } + + result := PermissionsMap{} + for _, resource := range catalog { + 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 +} + +func AllPermissions() []Permission { + permissions := make([]Permission, 0) + for _, resource := range catalog { + for _, action := range resource.Actions { + permissions = append(permissions, Permission{ + Resource: resource.Resource, + Action: action.Action, + }) + } + } + return permissions +} + +func DefaultAdminPermissions() []Permission { + permissions := make([]Permission, 0) + for _, resource := range catalog { + for _, action := range resource.Actions { + if !action.DefaultAdmin { + continue + } + permissions = append(permissions, Permission{ + Resource: resource.Resource, + Action: action.Action, + }) + } + } + return permissions +} + +func UserSubject(userID int) string { + return "user:" + strconv.Itoa(userID) +} + +func RoleSubject(roleKey string) string { + return "role:" + roleKey +} + +func seedBuiltInRoles(db *gorm.DB) error { + roles := []model.AuthzRole{ + { + Key: BuiltInRoleRoot, + Name: "Root", + Description: "Built-in root authorization role", + BuiltIn: true, + Enabled: true, + Sort: 0, + }, + { + Key: BuiltInRoleAdmin, + Name: "Admin", + Description: "Built-in admin authorization role", + BuiltIn: true, + Enabled: true, + Sort: 10, + }, + } + for _, role := range roles { + var existing model.AuthzRole + err := db.Where("key = ?", role.Key).First(&existing).Error + if err == nil { + existing.Name = role.Name + existing.Description = role.Description + existing.BuiltIn = role.BuiltIn + existing.Enabled = role.Enabled + existing.Sort = role.Sort + if err := db.Save(&existing).Error; err != nil { + return err + } + continue + } + if err != gorm.ErrRecordNotFound { + return err + } + if err := db.Create(&role).Error; err != nil { + return err + } + } + return nil +} + +func resetBuiltInRolePolicies(db *gorm.DB) error { + subjects := []string{RoleSubject(BuiltInRoleRoot), RoleSubject(BuiltInRoleAdmin)} + 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 _, permission := range AllPermissions() { + if _, err := e.AddPolicy(RoleSubject(BuiltInRoleRoot), permission.Resource, permission.Action, EffectAllow); err != nil { + return err + } + } + for _, permission := range DefaultAdminPermissions() { + if _, err := e.AddPolicy(RoleSubject(BuiltInRoleAdmin), permission.Resource, permission.Action, EffectAllow); err != nil { + return err + } + } + return nil +} + +func currentEnforcer() *casbin.Enforcer { + enforcerMu.RLock() + defer enforcerMu.RUnlock() + return enforcer +} + +func roleBaselineAllows(e *casbin.Enforcer, permission Permission) bool { + effect, ok := explicitSubjectEffect(e, RoleSubject(BuiltInRoleAdmin), permission) + return ok && effect == EffectAllow +} + +func isKnownResource(resource string) bool { + for _, known := range catalog { + if known.Resource == resource { + return true + } + } + return false +} + +type overridePolicy struct { + Resource string + Action string + Effect string +} + +func userOverridePolicies(e *casbin.Enforcer, 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, 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 +} + +func explicitSubjectEffect(e *casbin.Enforcer, 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] +} + +func catalogActions(resource string) []ActionDefinition { + for _, known := range catalog { + if known.Resource == resource { + return known.Actions + } + } + return nil +} + +func isKnownPermission(permission Permission) bool { + for _, resource := range catalog { + 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/authz_test.go b/service/authz/authz_test.go new file mode 100644 index 000000000000..cb170922fc93 --- /dev/null +++ b/service/authz/authz_test.go @@ -0,0 +1,138 @@ +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() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + 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)) + + var count int64 + require.NoError(t, db.Model(&model.CasbinRule{}).Count(&count).Error) + assert.Equal(t, int64(len(AllPermissions())+len(DefaultAdminPermissions())), 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 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 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/web/default/src/features/channels/components/channels-primary-buttons.tsx b/web/default/src/features/channels/components/channels-primary-buttons.tsx index 4fc68e6a9568..2d38c732ba7e 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, @@ -65,6 +71,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 +117,19 @@ export function ChannelsPrimaryButtons() { {/* Create Channel */} - + {canEditSensitive && ( + + )} {/* More Actions */} 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 32a49fa48850..57780f5220ee 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 @@ -37,6 +37,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, @@ -75,12 +81,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) @@ -304,12 +316,14 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { {/* Copy Channel */} - - {t('Copy Channel')} - - - - + {canEditSensitive && ( + + {t('Copy Channel')} + + + + + )} {/* Manage Keys (only for multi-key channels) */} {isMultiKey && ( diff --git a/web/default/src/features/users/components/users-mutate-drawer.tsx b/web/default/src/features/users/components/users-mutate-drawer.tsx index 9ebd5039ca8d..01f02a104424 100644 --- a/web/default/src/features/users/components/users-mutate-drawer.tsx +++ b/web/default/src/features/users/components/users-mutate-drawer.tsx @@ -23,9 +23,19 @@ import { useQuery } from '@tanstack/react-query' import { Pencil } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import { + ADMIN_PERMISSION_ACTIONS, + ADMIN_PERMISSION_CATALOG, + ADMIN_PERMISSION_RESOURCES, + hasPermission, + normalizeAdminPermissions, +} from '@/lib/admin-permissions' import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency' import { formatQuota, parseQuotaFromDollars } from '@/lib/format' +import { ROLE } from '@/lib/roles' +import { useAuthStore } from '@/stores/auth-store' import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' import { Form, FormControl, @@ -89,6 +99,7 @@ export function UsersMutateDrawer({ const { t } = useTranslation() const isUpdate = !!currentRow const { triggerRefresh } = useUsers() + const currentUser = useAuthStore((s) => s.auth.user) const [isSubmitting, setIsSubmitting] = useState(false) const [quotaDialogOpen, setQuotaDialogOpen] = useState(false) @@ -126,6 +137,9 @@ export function UsersMutateDrawer({ const tokensOnly = currencyMeta.kind === 'tokens' const currentQuotaRaw = form.watch('quota_dollars') || 0 + const selectedRole = form.watch('role') + const canEditAdminPermissions = currentUser?.role === ROLE.SUPER_ADMIN + const targetIsAdmin = (selectedRole ?? currentRow?.role ?? 0) >= ROLE.ADMIN const onSubmit = async (data: UserFormValues) => { if (!isUpdate) { @@ -417,6 +431,87 @@ export function UsersMutateDrawer({ )} + {canEditAdminPermissions && targetIsAdmin && ( + +

+ {t('Admin Permissions')} +

+

+ {t( + 'Default administrator permissions can be overridden for this user.' + )} +

+ { + const selected = normalizeAdminPermissions(field.value) + return ( + +
+ {ADMIN_PERMISSION_CATALOG.map((resource) => ( +
+
+ {t(resource.labelKey)} +
+
+ {resource.actions.map((option) => ( + + ))} +
+
+ ))} +
+ +
+ ) + }} + /> + {currentUser && ( +

+ {hasPermission( + currentUser, + ADMIN_PERMISSION_RESOURCES.CHANNEL, + ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE + ) + ? t('Your account can edit sensitive channel settings.') + : t('Your account cannot edit sensitive channel settings.')} +

+ )} +
+ )} + {/* Binding Information (Read-only) */} {isUpdate && ( diff --git a/web/default/src/features/users/lib/user-form.ts b/web/default/src/features/users/lib/user-form.ts index bfd03f7b839d..ba78c5190440 100644 --- a/web/default/src/features/users/lib/user-form.ts +++ b/web/default/src/features/users/lib/user-form.ts @@ -18,6 +18,11 @@ For commercial licensing, please contact support@quantumnous.com */ import { z } from 'zod' import { quotaUnitsToDollars } from '@/lib/format' +import { + type AdminPermissionMatrix, + normalizeAdminPermissions, +} from '@/lib/admin-permissions' +import { ROLE } from '@/lib/roles' import { DEFAULT_GROUP } from '../constants' import { type UserFormData, type User } from '../types' @@ -33,6 +38,7 @@ export const userFormSchema = z.object({ quota_dollars: z.number().min(0).optional(), group: z.string().optional(), remark: z.string().optional(), + admin_permissions: z.record(z.string(), z.record(z.string(), z.boolean())).optional(), }) export type UserFormValues = z.infer @@ -49,6 +55,7 @@ export const USER_FORM_DEFAULT_VALUES: UserFormValues = { quota_dollars: 0, group: DEFAULT_GROUP, remark: '', + admin_permissions: normalizeAdminPermissions(undefined), } // ============================================================================ @@ -71,11 +78,21 @@ export function transformFormDataToPayload( // For create: only send required fields if (userId === undefined) { payload.role = data.role || 1 // Default to common user + if (payload.role >= ROLE.ADMIN) { + payload.admin_permissions = normalizeAdminPermissions( + data.admin_permissions as AdminPermissionMatrix | undefined + ) + } } else { // For update: quota is adjusted atomically via /api/user/manage, not sent here payload.group = data.group payload.remark = data.remark || undefined payload.id = userId + if ((data.role ?? 0) >= ROLE.ADMIN) { + payload.admin_permissions = normalizeAdminPermissions( + data.admin_permissions as AdminPermissionMatrix | undefined + ) + } } return payload @@ -93,5 +110,6 @@ export function transformUserToFormDefaults(user: User): UserFormValues { quota_dollars: quotaUnitsToDollars(user.quota), group: user.group || DEFAULT_GROUP, remark: user.remark || '', + admin_permissions: normalizeAdminPermissions(user.admin_permissions), } } diff --git a/web/default/src/features/users/types.ts b/web/default/src/features/users/types.ts index 3b699d8fcb19..1cbd20969507 100644 --- a/web/default/src/features/users/types.ts +++ b/web/default/src/features/users/types.ts @@ -17,6 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { z } from 'zod' +import type { AdminPermissionMatrix } from '@/lib/admin-permissions' // ============================================================================ // User Schema & Types @@ -57,6 +58,7 @@ export const userSchema = z.object({ last_login_at: z.number().optional(), DeletedAt: z.any().nullable().optional(), remark: z.string().optional(), + admin_permissions: z.record(z.string(), z.record(z.string(), z.boolean())).optional(), }) export type User = z.infer @@ -106,6 +108,7 @@ export interface UserFormData { quota?: number // Only used when updating user group?: string // Only used when updating user remark?: string // Only used when updating user + admin_permissions?: AdminPermissionMatrix } export type ManageUserAction = diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 6566822f607b..689c0012f667 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -219,6 +219,8 @@ "Admin": "Admin", "Admin access required": "Admin access required", "Admin area": "Admin area", + "Admin Channel Permissions": "Admin Channel Permissions", + "Admin Permissions": "Admin Permissions", "Admin notes (only visible to admins)": "Admin notes (only visible to admins)", "Admin Only": "Admin Only", "Administer user accounts and roles.": "Administer user accounts and roles.", @@ -708,6 +710,7 @@ "Channel ID": "Channel ID", "Channel ID is required": "Channel ID is required", "Channel key": "Channel key", + "Channel Management": "Channel Management", "Channel key unlocked": "Channel key unlocked", "Channel models": "Channel models", "Channel name is required": "Channel name is required", @@ -1068,6 +1071,7 @@ "Create cache": "Create cache", "Create cache ratio": "Create cache ratio", "Create Channel": "Create Channel", + "Create channels or edit keys, base URLs, and overrides.": "Create channels or edit keys, base URLs, and overrides.", "Create Code": "Create Code", "Create credentials for the root user": "Create credentials for the root user", "Create deployment": "Create deployment", @@ -1186,6 +1190,7 @@ "Default": "Default", "Default (New Frontend)": "Default (New Frontend)", "Default / range": "Default / range", + "Default administrator permissions can be overridden for this user.": "Default administrator permissions can be overridden for this user.", "Default API Version *": "Default API Version *", "Default API version for this channel": "Default API version for this channel", "Default Bearer": "Default Bearer", @@ -1372,8 +1377,8 @@ "Drawing": "Drawing", "Drawing logs": "Drawing logs", "Drawing Logs": "Drawing Logs", - "Drawing task records": "Drawing task records", "Drawing task polling": "Drawing task polling", + "Drawing task records": "Drawing task records", "Duplicate": "Duplicate", "Duplicate group names: {{names}}": "Duplicate group names: {{names}}", "Duplicate source model mappings are not allowed": "Duplicate source model mappings are not allowed", @@ -1440,6 +1445,7 @@ "Edit API Shortcut": "Edit API Shortcut", "Edit billing ratios and user-selectable groups in one table.": "Edit billing ratios and user-selectable groups in one table.", "Edit Channel": "Edit Channel", + "Edit channel routing": "Edit channel routing", "Edit chat preset": "Edit chat preset", "Edit discount tier": "Edit discount tier", "Edit FAQ": "Edit FAQ", @@ -1449,6 +1455,7 @@ "Edit model": "Edit model", "Edit Model": "Edit Model", "Edit model pricing": "Edit model pricing", + "Edit non-sensitive routing fields such as models and groups.": "Edit non-sensitive routing fields such as models and groups.", "Edit OAuth Provider": "Edit OAuth Provider", "Edit payment method": "Edit payment method", "Edit Prefill Group": "Edit Prefill Group", @@ -1456,6 +1463,7 @@ "Edit ratio override": "Edit ratio override", "Edit Rule": "Edit Rule", "Edit selectable group": "Edit selectable group", + "Edit sensitive channel settings": "Edit sensitive channel settings", "Edit Tag": "Edit Tag", "Edit Tag:": "Edit Tag:", "Edit Uptime Kuma Group": "Edit Uptime Kuma Group", @@ -2964,6 +2972,7 @@ "OpenAIMax": "OpenAIMax", "OpenRouter": "OpenRouter", "opens in an external client. Trigger it from the sidebar or API key actions to launch the configured application.": "opens in an external client. Trigger it from the sidebar or API key actions to launch the configured application.", + "Operate channels": "Operate channels", "Operation": "Operation", "operation and charging behavior": "operation and charging behavior", "Operation Audit Info": "Operation Audit Info", @@ -3422,6 +3431,7 @@ "Raw Quota": "Raw Quota", "Re-enable on success": "Re-enable on success", "Re-login": "Re-login", + "Read channels": "Read channels", "Ready": "Ready", "Ready to initialize": "Ready to initialize", "Ready to simplify": "Ready to simplify", @@ -3727,7 +3737,6 @@ "Save Preferences": "Save Preferences", "Save preview": "Save preview", "Save rate limits": "Save rate limits", - "Save token limits": "Save token limits", "Save sensitive words": "Save sensitive words", "Save Settings": "Save Settings", "Save sidebar modules": "Save sidebar modules", @@ -3736,6 +3745,7 @@ "Save Stripe settings": "Save Stripe settings", "Save these backup codes in a safe place. Each code can only be used once.": "Save these backup codes in a safe place. Each code can only be used once.", "Save these codes in a safe place. Each code can only be used once.": "Save these codes in a safe place. Each code can only be used once.", + "Save token limits": "Save token limits", "Save tool prices": "Save tool prices", "Save Waffo Pancake settings": "Save Waffo Pancake settings", "Save Worker settings": "Save Worker settings", @@ -3964,11 +3974,11 @@ "Simple mode only returns message; status code and error type use system defaults.": "Simple mode only returns message; status code and error type use system defaults.", "Simple mode: prune objects by type, e.g. redacted_thinking.": "Simple mode: prune objects by type, e.g. redacted_thinking.", "Single Key": "Single Key", - "Skip async task polling delay": "Skip async task polling delay", "Site & Branding": "Site & Branding", "Site Key": "Site Key", "Size:": "Size:", "sk_xxx or rk_xxx": "sk_xxx or rk_xxx", + "Skip async task polling delay": "Skip async task polling delay", "Skip retry on failure": "Skip retry on failure", "Skip SMTP TLS certificate verification": "Skip SMTP TLS certificate verification", "Skip to Main": "Skip to Main", @@ -4194,6 +4204,7 @@ "Test all {{count}} models": "Test all {{count}} models", "Test All Channels": "Test All Channels", "Test Channel Connection": "Test Channel Connection", + "Test channels, update balances, and toggle availability.": "Test channels, update balances, and toggle availability.", "Test Connection": "Test Connection", "Test connectivity for:": "Test connectivity for:", "Test failed": "Test failed", @@ -4715,6 +4726,7 @@ "Vidu": "Vidu", "View": "View", "View all currently available models": "View all currently available models", + "View channel lists and details without secrets.": "View channel lists and details without secrets.", "View detailed information about this user including balance, usage statistics, and invitation details.": "View detailed information about this user including balance, usage statistics, and invitation details.", "View details": "View details", "View document": "View document", @@ -4869,6 +4881,8 @@ "You understand this compliance reminder is only for risk notice and does not constitute legal advice, a compliance review conclusion, or a guarantee of the legality of your use of this system; you should consult professional legal or compliance advisors based on your actual business scenario.": "You understand this compliance reminder is only for risk notice and does not constitute legal advice, a compliance review conclusion, or a guarantee of the legality of your use of this system; you should consult professional legal or compliance advisors based on your actual business scenario.", "You will be redirected to Telegram to complete the binding process.": "You will be redirected to Telegram to complete the binding process.", "You'll be redirected automatically. You can return to the previous page if nothing happens after a few seconds.": "You'll be redirected automatically. You can return to the previous page if nothing happens after a few seconds.", + "Your account can edit sensitive channel settings.": "Your account can edit sensitive channel settings.", + "Your account cannot edit sensitive channel settings.": "Your account cannot edit sensitive channel settings.", "your AI integration?": "your AI integration?", "Your Azure OpenAI endpoint URL": "Your Azure OpenAI endpoint URL", "Your Bot Name": "Your Bot Name", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index 348e2f29a69c..89b55a045c55 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -219,6 +219,8 @@ "Admin": "Administrateur", "Admin access required": "Accès administrateur requis", "Admin area": "Espace administrateur", + "Admin Channel Permissions": "Autorisations des canaux administrateur", + "Admin Permissions": "Autorisations administrateur", "Admin notes (only visible to admins)": "Notes d'administration (visibles uniquement par les administrateurs)", "Admin Only": "Administrateur uniquement", "Administer user accounts and roles.": "Gérer les comptes d'utilisateurs et les rôles.", @@ -708,6 +710,7 @@ "Channel ID": "ID du Canal", "Channel ID is required": "L'ID du canal est requis", "Channel key": "Clé du canal", + "Channel Management": "Gestion des canaux", "Channel key unlocked": "Clé de canal déverrouillée", "Channel models": "Modèles de canaux", "Channel name is required": "Le nom du canal est requis", @@ -1068,6 +1071,7 @@ "Create cache": "Créer le cache", "Create cache ratio": "Créer un ratio de cache", "Create Channel": "Créer un canal", + "Create channels or edit keys, base URLs, and overrides.": "Créer des canaux ou modifier les clés, URL de base et règles de remplacement.", "Create Code": "Créer un code", "Create credentials for the root user": "Créer les identifiants pour le compte administrateur", "Create deployment": "Créer un déploiement", @@ -1186,6 +1190,7 @@ "Default": "Par défaut", "Default (New Frontend)": "Par défaut (Nouveau frontend)", "Default / range": "Défaut / plage", + "Default administrator permissions can be overridden for this user.": "Les autorisations administrateur par défaut peuvent être remplacées pour cet utilisateur.", "Default API Version *": "Version API par défaut *", "Default API version for this channel": "Version API par défaut pour ce canal", "Default Bearer": "Bearer par defaut", @@ -1372,8 +1377,8 @@ "Drawing": "Dessin", "Drawing logs": "Journaux de dessin", "Drawing Logs": "Journaux de dessin", - "Drawing task records": "Historique des tâches de dessin", "Drawing task polling": "Interrogation des tâches de dessin", + "Drawing task records": "Historique des tâches de dessin", "Duplicate": "Dupliquer", "Duplicate group names: {{names}}": "Noms de groupe en double : {{names}}", "Duplicate source model mappings are not allowed": "Les mappages de modèles source en double ne sont pas autorisés", @@ -1440,6 +1445,7 @@ "Edit API Shortcut": "Modifier le raccourci API", "Edit billing ratios and user-selectable groups in one table.": "Modifiez les ratios de facturation et les groupes sélectionnables par les utilisateurs dans un seul tableau.", "Edit Channel": "Modifier le canal", + "Edit channel routing": "Modifier le routage des canaux", "Edit chat preset": "Modifier le préréglage de chat", "Edit discount tier": "Modifier le palier de remise", "Edit FAQ": "Modifier la FAQ", @@ -1449,6 +1455,7 @@ "Edit model": "Modifier le modèle", "Edit Model": "Modifier le modèle", "Edit model pricing": "Modifier la tarification du modèle", + "Edit non-sensitive routing fields such as models and groups.": "Modifier les champs de routage non sensibles comme les modèles et les groupes.", "Edit OAuth Provider": "Modifier le fournisseur OAuth", "Edit payment method": "Modifier le mode de paiement", "Edit Prefill Group": "Modifier le groupe de préremplissage", @@ -1456,6 +1463,7 @@ "Edit ratio override": "Modifier le remplacement de ratio", "Edit Rule": "Modifier la règle", "Edit selectable group": "Modifier le groupe sélectionnable", + "Edit sensitive channel settings": "Modifier les paramètres sensibles des canaux", "Edit Tag": "Modifier l'étiquette", "Edit Tag:": "Modifier l'étiquette :", "Edit Uptime Kuma Group": "Modifier le groupe Uptime Kuma", @@ -2964,6 +2972,7 @@ "OpenAIMax": "OpenAIMax", "OpenRouter": "OpenRouter", "opens in an external client. Trigger it from the sidebar or API key actions to launch the configured application.": "s'ouvre dans un client externe. Déclenchez-le depuis la barre latérale ou les actions de clé API pour lancer l'application configurée.", + "Operate channels": "Exploiter les canaux", "Operation": "Opération", "operation and charging behavior": "à l’exploitation et à la facturation", "Operation Audit Info": "Informations d'audit d'opération", @@ -3422,6 +3431,7 @@ "Raw Quota": "Quota brut", "Re-enable on success": "Réactiver en cas de succès", "Re-login": "Se reconnecter", + "Read channels": "Lire les canaux", "Ready": "Prêt", "Ready to initialize": "Prêt à initialiser", "Ready to simplify": "Prêt à simplifier", @@ -3727,7 +3737,6 @@ "Save Preferences": "Enregistrer les préférences", "Save preview": "Aperçu de l’enregistrement", "Save rate limits": "Enregistrer les limites de débit", - "Save token limits": "Enregistrer les limites de jetons", "Save sensitive words": "Enregistrer les mots sensibles", "Save Settings": "Enregistrer les paramètres", "Save sidebar modules": "Enregistrer les modules de la barre latérale", @@ -3736,6 +3745,7 @@ "Save Stripe settings": "Enregistrer les paramètres Stripe", "Save these backup codes in a safe place. Each code can only be used once.": "Enregistrez ces codes de secours dans un endroit sûr. Chaque code ne peut être utilisé qu'une seule fois.", "Save these codes in a safe place. Each code can only be used once.": "Enregistrez ces codes dans un endroit sûr. Chaque code ne peut être utilisé qu'une seule fois.", + "Save token limits": "Enregistrer les limites de jetons", "Save tool prices": "Enregistrer les prix des outils", "Save Waffo Pancake settings": "Enregistrer les paramètres Waffo Pancake", "Save Worker settings": "Enregistrer les paramètres Worker", @@ -3964,11 +3974,11 @@ "Simple mode only returns message; status code and error type use system defaults.": "Le mode simple ne retourne que le message ; le code de statut et le type d'erreur utilisent les valeurs par défaut.", "Simple mode: prune objects by type, e.g. redacted_thinking.": "Mode simple : nettoyer les objets par type, ex. redacted_thinking.", "Single Key": "Clé unique", - "Skip async task polling delay": "Ignorer le délai de polling des tâches asynchrones", "Site & Branding": "Site et marque", "Site Key": "Clé du site", "Size:": "Taille :", "sk_xxx or rk_xxx": "sk_xxx ou rk_xxx", + "Skip async task polling delay": "Ignorer le délai de polling des tâches asynchrones", "Skip retry on failure": "Ne pas réessayer en cas d'échec", "Skip SMTP TLS certificate verification": "Ignorer la vérification du certificat TLS SMTP", "Skip to Main": "Aller au contenu principal", @@ -4194,6 +4204,7 @@ "Test all {{count}} models": "Tester les {{count}} modèles", "Test All Channels": "Tester tous les canaux", "Test Channel Connection": "Tester la connexion du canal", + "Test channels, update balances, and toggle availability.": "Tester les canaux, mettre à jour les soldes et basculer la disponibilité.", "Test Connection": "Tester la connexion", "Test connectivity for:": "Tester la connectivité pour :", "Test failed": "Échec du test", @@ -4715,6 +4726,7 @@ "Vidu": "Vidu", "View": "Afficher", "View all currently available models": "Voir tous les modèles actuellement disponibles", + "View channel lists and details without secrets.": "Afficher les listes et détails des canaux sans secrets.", "View detailed information about this user including balance, usage statistics, and invitation details.": "Afficher des informations détaillées sur cet utilisateur, y compris le solde, les statistiques d'utilisation et les détails d'invitation.", "View details": "Voir les détails", "View document": "Afficher le document", @@ -4869,6 +4881,8 @@ "You understand this compliance reminder is only for risk notice and does not constitute legal advice, a compliance review conclusion, or a guarantee of the legality of your use of this system; you should consult professional legal or compliance advisors based on your actual business scenario.": "Vous comprenez que ce rappel de conformité est uniquement un avis de risque et ne constitue ni un conseil juridique, ni une conclusion d’examen de conformité, ni une garantie de la légalité de votre utilisation de ce système ; vous devez consulter des conseillers juridiques ou conformité professionnels selon votre situation réelle.", "You will be redirected to Telegram to complete the binding process.": "Vous serez redirigé vers Telegram pour terminer le processus de liaison.", "You'll be redirected automatically. You can return to the previous page if nothing happens after a few seconds.": "Vous serez redirigé automatiquement. Vous pouvez revenir à la page précédente si rien ne se passe après quelques secondes.", + "Your account can edit sensitive channel settings.": "Votre compte peut modifier les paramètres sensibles des canaux.", + "Your account cannot edit sensitive channel settings.": "Votre compte ne peut pas modifier les paramètres sensibles des canaux.", "your AI integration?": "votre intégration IA ?", "Your Azure OpenAI endpoint URL": "Votre URL de point de terminaison Azure OpenAI", "Your Bot Name": "Nom de votre Bot", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index 379d3e6fb4c9..b0fe90748c3c 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -219,6 +219,8 @@ "Admin": "管理者", "Admin access required": "管理者アクセスが必要です", "Admin area": "管理者エリア", + "Admin Channel Permissions": "管理者のチャネル権限", + "Admin Permissions": "管理者権限", "Admin notes (only visible to admins)": "管理者メモ (管理者のみに表示)", "Admin Only": "管理者のみ", "Administer user accounts and roles.": "ユーザーアカウントとロールを管理します。", @@ -708,6 +710,7 @@ "Channel ID": "チャネルID", "Channel ID is required": "チャネル ID が必要です", "Channel key": "チャネルキー", + "Channel Management": "チャネル管理", "Channel key unlocked": "チャネルキーが解除されました", "Channel models": "チャネルモデル", "Channel name is required": "チャネル名が必要です", @@ -1068,6 +1071,7 @@ "Create cache": "キャッシュを作成", "Create cache ratio": "キャッシュ倍率を作成", "Create Channel": "チャネルを作成", + "Create channels or edit keys, base URLs, and overrides.": "チャネルの作成、キー、ベース URL、上書き設定の編集を許可します。", "Create Code": "コードを作成", "Create credentials for the root user": "管理者アカウントの認証情報を作成", "Create deployment": "デプロイを作成", @@ -1186,6 +1190,7 @@ "Default": "デフォルト", "Default (New Frontend)": "デフォルト(新フロントエンド)", "Default / range": "デフォルト / 範囲", + "Default administrator permissions can be overridden for this user.": "このユーザーには既定の管理者権限を上書きできます。", "Default API Version *": "デフォルトのAPIバージョン *", "Default API version for this channel": "このチャネルのデフォルトのAPIバージョン", "Default Bearer": "既定の Bearer", @@ -1372,8 +1377,8 @@ "Drawing": "画像生成", "Drawing logs": "描画ログ", "Drawing Logs": "画像生成履歴", - "Drawing task records": "描画タスク記録", "Drawing task polling": "描画タスクのポーリング", + "Drawing task records": "描画タスク記録", "Duplicate": "複製", "Duplicate group names: {{names}}": "重複するグループ名: {{names}}", "Duplicate source model mappings are not allowed": "重複したソースモデルのマッピングは許可されていません", @@ -1440,6 +1445,7 @@ "Edit API Shortcut": "API ショートカットを編集", "Edit billing ratios and user-selectable groups in one table.": "課金倍率とユーザーが選択できるグループを1つの表で編集します。", "Edit Channel": "チャネルを編集", + "Edit channel routing": "チャネルルーティングを編集", "Edit chat preset": "チャットプリセットを編集", "Edit discount tier": "割引ティアを編集", "Edit FAQ": "FAQ を編集", @@ -1449,6 +1455,7 @@ "Edit model": "モデルを編集", "Edit Model": "モデルを編集", "Edit model pricing": "モデル料金を編集", + "Edit non-sensitive routing fields such as models and groups.": "モデルやグループなどの非機密ルーティング項目を編集します。", "Edit OAuth Provider": "OAuthプロバイダーを編集", "Edit payment method": "決済方法を編集", "Edit Prefill Group": "プリフィルグループを編集", @@ -1456,6 +1463,7 @@ "Edit ratio override": "倍率オーバーライドを編集", "Edit Rule": "ルール編集", "Edit selectable group": "選択可能なグループを編集", + "Edit sensitive channel settings": "機密チャネル設定を編集", "Edit Tag": "タグ編集", "Edit Tag:": "タグを編集:", "Edit Uptime Kuma Group": "Uptime Kuma グループを編集", @@ -2964,6 +2972,7 @@ "OpenAIMax": "OpenAIMax", "OpenRouter": "OpenRouter", "opens in an external client. Trigger it from the sidebar or API key actions to launch the configured application.": "外部クライアントで開きます。サイドバーまたはAPIキーアクションからトリガーして、設定されたアプリケーションを起動します。", + "Operate channels": "チャネルを運用", "Operation": "操作", "operation and charging behavior": "運用および課金行為に起因する法的責任を負うことを確認します", "Operation Audit Info": "操作監査情報", @@ -3422,6 +3431,7 @@ "Raw Quota": "元のクォータ", "Re-enable on success": "成功時に再有効化", "Re-login": "再ログイン", + "Read channels": "チャネルを読み取り", "Ready": "準備完了", "Ready to initialize": "初期化準備完了", "Ready to simplify": "シンプルにする準備は", @@ -3727,7 +3737,6 @@ "Save Preferences": "設定を保存", "Save preview": "保存プレビュー", "Save rate limits": "レート制限を保存", - "Save token limits": "トークン制限を保存", "Save sensitive words": "敏感な言葉を保存", "Save Settings": "設定を保存", "Save sidebar modules": "サイドバーモジュールを保存", @@ -3736,6 +3745,7 @@ "Save Stripe settings": "Stripe設定を保存", "Save these backup codes in a safe place. Each code can only be used once.": "これらのバックアップコードを安全な場所に保存してください。各コードは一度だけ使用できます。", "Save these codes in a safe place. Each code can only be used once.": "これらのコードを安全な場所に保存してください。各コードは一度だけ使用できます。", + "Save token limits": "トークン制限を保存", "Save tool prices": "ツール価格を保存", "Save Waffo Pancake settings": "Waffo Pancake 設定を保存", "Save Worker settings": "Worker設定を保存", @@ -3964,11 +3974,11 @@ "Simple mode only returns message; status code and error type use system defaults.": "シンプルモードはメッセージのみ返します。ステータスコードとエラータイプはシステムデフォルトを使用します。", "Simple mode: prune objects by type, e.g. redacted_thinking.": "シンプルモード:typeでオブジェクトを削除(例:redacted_thinking)。", "Single Key": "単一キー", - "Skip async task polling delay": "非同期タスクのポーリング遅延をスキップ", "Site & Branding": "サイトとブランド", "Site Key": "サイトキー", "Size:": "サイズ:", "sk_xxx or rk_xxx": "sk_xxx または rk_xxx", + "Skip async task polling delay": "非同期タスクのポーリング遅延をスキップ", "Skip retry on failure": "失敗時にリトライしない", "Skip SMTP TLS certificate verification": "SMTP TLS証明書の検証をスキップ", "Skip to Main": "メインコンテンツへスキップ", @@ -4194,6 +4204,7 @@ "Test all {{count}} models": "{{count}} 件すべてのモデルをテスト", "Test All Channels": "すべてのチャネルをテスト", "Test Channel Connection": "チャネル接続をテスト", + "Test channels, update balances, and toggle availability.": "チャネルのテスト、残高更新、有効状態の切り替えを行います。", "Test Connection": "接続をテスト", "Test connectivity for:": "接続性をテスト:", "Test failed": "テストに失敗しました", @@ -4715,6 +4726,7 @@ "Vidu": "Vidu", "View": "表示", "View all currently available models": "現在利用可能なすべてのモデルを表示", + "View channel lists and details without secrets.": "シークレットを含まないチャネル一覧と詳細を表示します。", "View detailed information about this user including balance, usage statistics, and invitation details.": "残高、使用統計、招待の詳細など、このユーザーに関する詳細情報を表示します。", "View details": "詳細を表示", "View document": "ドキュメントを表示", @@ -4869,6 +4881,8 @@ "You understand this compliance reminder is only for risk notice and does not constitute legal advice, a compliance review conclusion, or a guarantee of the legality of your use of this system; you should consult professional legal or compliance advisors based on your actual business scenario.": "このコンプライアンス注意事項はリスク通知にすぎず、法的助言、コンプライアンス審査の結論、または本システム利用の合法性の保証ではないことを理解しています。実際の事業状況に応じて、専門の法律またはコンプライアンス担当者に相談してください。", "You will be redirected to Telegram to complete the binding process.": "バインドプロセスを完了するためにTelegramにリダイレクトされます。", "You'll be redirected automatically. You can return to the previous page if nothing happens after a few seconds.": "自動的にリダイレクトされます。数秒経っても何も起こらない場合は、前のページに戻ることができます。", + "Your account can edit sensitive channel settings.": "あなたのアカウントは機密チャネル設定を編集できます。", + "Your account cannot edit sensitive channel settings.": "あなたのアカウントは機密チャネル設定を編集できません。", "your AI integration?": "AIインテグレーションを?", "Your Azure OpenAI endpoint URL": "あなたのAzure OpenAIエンドポイント URL", "Your Bot Name": "あなたのボット名", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index 3d90855c1718..926ae9fc83d9 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -219,6 +219,8 @@ "Admin": "Администратор", "Admin access required": "Требуется доступ администратора", "Admin area": "Область администратора", + "Admin Channel Permissions": "Права администратора для каналов", + "Admin Permissions": "Права администратора", "Admin notes (only visible to admins)": "Заметки администратора (видны только администраторам)", "Admin Only": "Только для администраторов", "Administer user accounts and roles.": "Управление учетными записями пользователей и ролями.", @@ -708,6 +710,7 @@ "Channel ID": "ID канала", "Channel ID is required": "Требуется ID канала", "Channel key": "Ключ канала", + "Channel Management": "Управление каналами", "Channel key unlocked": "Ключ канала разблокирован", "Channel models": "Модели каналов", "Channel name is required": "Имя канала обязательно", @@ -1068,6 +1071,7 @@ "Create cache": "Создать кеш", "Create cache ratio": "Создать коэффициент кэширования", "Create Channel": "Создать канал", + "Create channels or edit keys, base URLs, and overrides.": "Создание каналов или изменение ключей, базовых URL и переопределений.", "Create Code": "Создать код", "Create credentials for the root user": "Создайте учётные данные для администратора", "Create deployment": "Создать развертывание", @@ -1186,6 +1190,7 @@ "Default": "По умолчанию", "Default (New Frontend)": "По умолчанию (Новый интерфейс)", "Default / range": "По умолчанию / диапазон", + "Default administrator permissions can be overridden for this user.": "Для этого пользователя можно переопределить стандартные права администратора.", "Default API Version *": "Версия API по умолчанию *", "Default API version for this channel": "Версия API по умолчанию для этого канала", "Default Bearer": "Bearer по умолчанию", @@ -1372,8 +1377,8 @@ "Drawing": "Рисование", "Drawing logs": "Журналы рисования", "Drawing Logs": "Журнал рисования", - "Drawing task records": "Записи задач рисования", "Drawing task polling": "Опрос задач рисования", + "Drawing task records": "Записи задач рисования", "Duplicate": "Дублировать", "Duplicate group names: {{names}}": "Повторяющиеся имена групп: {{names}}", "Duplicate source model mappings are not allowed": "Повторяющиеся сопоставления исходных моделей не допускаются", @@ -1440,6 +1445,7 @@ "Edit API Shortcut": "Редактировать ярлык API", "Edit billing ratios and user-selectable groups in one table.": "Редактируйте коэффициенты тарификации и доступные пользователю группы в одной таблице.", "Edit Channel": "Редактировать канал", + "Edit channel routing": "Изменение маршрутизации каналов", "Edit chat preset": "Редактировать пресет чата", "Edit discount tier": "Редактировать уровень скидки", "Edit FAQ": "Редактировать FAQ", @@ -1449,6 +1455,7 @@ "Edit model": "Редактировать модель", "Edit Model": "Редактировать модель", "Edit model pricing": "Изменить тариф модели", + "Edit non-sensitive routing fields such as models and groups.": "Изменение нечувствительных полей маршрутизации, таких как модели и группы.", "Edit OAuth Provider": "Редактировать поставщика OAuth", "Edit payment method": "Редактировать способ оплаты", "Edit Prefill Group": "Редактировать группу предзаполнения", @@ -1456,6 +1463,7 @@ "Edit ratio override": "Редактировать переопределение коэффициента", "Edit Rule": "Редактировать правило", "Edit selectable group": "Редактировать выбираемую группу", + "Edit sensitive channel settings": "Изменение чувствительных настроек каналов", "Edit Tag": "Редактировать тег", "Edit Tag:": "Редактировать тег:", "Edit Uptime Kuma Group": "Редактировать группу Uptime Kuma", @@ -2964,6 +2972,7 @@ "OpenAIMax": "OpenAIMax", "OpenRouter": "OpenRouter", "opens in an external client. Trigger it from the sidebar or API key actions to launch the configured application.": "открывается во внешнем клиенте. Запустите его из боковой панели или действий с ключом API, чтобы запустить настроенное приложение.", + "Operate channels": "Обслуживание каналов", "Operation": "Операция", "operation and charging behavior": "эксплуатацию и взимание платы", "Operation Audit Info": "Информация об аудите операций", @@ -3422,6 +3431,7 @@ "Raw Quota": "Исходная квота", "Re-enable on success": "Повторно включить при успехе", "Re-login": "Повторный вход", + "Read channels": "Чтение каналов", "Ready": "Готово", "Ready to initialize": "Готов к инициализации", "Ready to simplify": "Готовы упростить", @@ -3727,7 +3737,6 @@ "Save Preferences": "Сохранить настройки", "Save preview": "Предпросмотр сохранения", "Save rate limits": "Сохранить лимиты скорости", - "Save token limits": "Сохранить лимиты токенов", "Save sensitive words": "Сохранить чувствительные слова", "Save Settings": "Сохранить настройки", "Save sidebar modules": "Сохранить модули боковой панели", @@ -3736,6 +3745,7 @@ "Save Stripe settings": "Сохранить настройки Stripe", "Save these backup codes in a safe place. Each code can only be used once.": "Сохраните эти резервные коды в безопасном месте. Каждый код может быть использован только один раз.", "Save these codes in a safe place. Each code can only be used once.": "Сохраните эти коды в безопасном месте. Каждый код может быть использован только один раз.", + "Save token limits": "Сохранить лимиты токенов", "Save tool prices": "Сохранить цены инструментов", "Save Waffo Pancake settings": "Сохранить настройки Waffo Pancake", "Save Worker settings": "Сохранить настройки Worker", @@ -3964,11 +3974,11 @@ "Simple mode only returns message; status code and error type use system defaults.": "Простой режим возвращает только сообщение; код статуса и тип ошибки используют системные значения по умолчанию.", "Simple mode: prune objects by type, e.g. redacted_thinking.": "Простой режим: очистка объектов по типу, например redacted_thinking.", "Single Key": "Одиночный ключ", - "Skip async task polling delay": "Пропускать задержку опроса асинхронных задач", "Site & Branding": "Сайт и брендинг", "Site Key": "Ключ сайта", "Size:": "Размер:", "sk_xxx or rk_xxx": "sk_xxx или rk_xxx", + "Skip async task polling delay": "Пропускать задержку опроса асинхронных задач", "Skip retry on failure": "Не повторять при ошибке", "Skip SMTP TLS certificate verification": "Пропустить проверку TLS-сертификата SMTP", "Skip to Main": "Перейти к основному содержимому", @@ -4194,6 +4204,7 @@ "Test all {{count}} models": "Проверить все модели: {{count}}", "Test All Channels": "Проверить все каналы", "Test Channel Connection": "Проверить подключение канала", + "Test channels, update balances, and toggle availability.": "Тестирование каналов, обновление балансов и переключение доступности.", "Test Connection": "Проверить подключение", "Test connectivity for:": "Проверить подключение для:", "Test failed": "Тест не выполнен", @@ -4715,6 +4726,7 @@ "Vidu": "Vidu", "View": "Просмотр", "View all currently available models": "Просмотреть все доступные модели", + "View channel lists and details without secrets.": "Просмотр списков и сведений о каналах без секретов.", "View detailed information about this user including balance, usage statistics, and invitation details.": "Просмотр подробной информации об этом пользователе, включая баланс, статистику использования и данные приглашения.", "View details": "Просмотреть детали", "View document": "Просмотреть документ", @@ -4869,6 +4881,8 @@ "You understand this compliance reminder is only for risk notice and does not constitute legal advice, a compliance review conclusion, or a guarantee of the legality of your use of this system; you should consult professional legal or compliance advisors based on your actual business scenario.": "Вы понимаете, что это напоминание о соответствии является только уведомлением о рисках и не является юридической консультацией, заключением проверки соответствия или гарантией законности использования этой системы; вам следует обратиться к профессиональным юридическим или комплаенс-консультантам с учетом вашей реальной бизнес-ситуации.", "You will be redirected to Telegram to complete the binding process.": "Вы будете перенаправлены в Telegram для завершения процесса привязки.", "You'll be redirected automatically. You can return to the previous page if nothing happens after a few seconds.": "Вы будете автоматически перенаправлены. Если через несколько секунд ничего не происходит, вы можете вернуться на предыдущую страницу.", + "Your account can edit sensitive channel settings.": "Ваша учетная запись может изменять чувствительные настройки каналов.", + "Your account cannot edit sensitive channel settings.": "Ваша учетная запись не может изменять чувствительные настройки каналов.", "your AI integration?": "вашу интеграцию с ИИ?", "Your Azure OpenAI endpoint URL": "Ваш URL конечной точки Azure OpenAI", "Your Bot Name": "Имя бота", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index 278b9d747fc4..084d452d7cc2 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -219,6 +219,8 @@ "Admin": "Quản trị viên", "Admin access required": "Yêu cầu quyền truy cập Admin", "Admin area": "Khu vực quản trị", + "Admin Channel Permissions": "Quyền kênh của quản trị viên", + "Admin Permissions": "Quyền quản trị viên", "Admin notes (only visible to admins)": "Ghi chú của quản trị viên (chỉ hiển thị với quản trị viên)", "Admin Only": "Chỉ dành cho quản trị viên", "Administer user accounts and roles.": "Quản lý tài khoản người dùng và vai trò.", @@ -708,6 +710,7 @@ "Channel ID": "Mã kênh", "Channel ID is required": "Cần có ID kênh", "Channel key": "Khóa kênh", + "Channel Management": "Quản lý kênh", "Channel key unlocked": "Khóa kênh đã được mở khóa", "Channel models": "Mô hình kênh", "Channel name is required": "Tên kênh là bắt buộc", @@ -1068,6 +1071,7 @@ "Create cache": "Tạo bộ nhớ đệm", "Create cache ratio": "Tạo tỷ lệ bộ nhớ đệm", "Create Channel": "Tạo Kênh", + "Create channels or edit keys, base URLs, and overrides.": "Tạo kênh hoặc chỉnh sửa khóa, URL cơ sở và quy tắc ghi đè.", "Create Code": "Tạo Mã", "Create credentials for the root user": "Tạo thông tin đăng nhập cho tài khoản quản trị", "Create deployment": "Tạo triển khai", @@ -1186,6 +1190,7 @@ "Default": "Mặc định", "Default (New Frontend)": "Mặc định (Frontend mới)", "Default / range": "Mặc định / khoảng", + "Default administrator permissions can be overridden for this user.": "Có thể ghi đè quyền quản trị viên mặc định cho người dùng này.", "Default API Version *": "Phiên bản API mặc định *", "Default API version for this channel": "Phiên bản API mặc định cho kênh này", "Default Bearer": "Bearer mặc định", @@ -1372,8 +1377,8 @@ "Drawing": "Vẽ", "Drawing logs": "Nhật ký vẽ", "Drawing Logs": "Nhật ký bản vẽ", - "Drawing task records": "Lịch sử tác vụ vẽ", "Drawing task polling": "Thăm dò tác vụ vẽ", + "Drawing task records": "Lịch sử tác vụ vẽ", "Duplicate": "Nhân bản", "Duplicate group names: {{names}}": "Tên nhóm bị trùng: {{names}}", "Duplicate source model mappings are not allowed": "Không cho phép ánh xạ mô hình nguồn trùng lặp", @@ -1440,6 +1445,7 @@ "Edit API Shortcut": "Chỉnh sửa lối tắt API", "Edit billing ratios and user-selectable groups in one table.": "Chỉnh sửa tỷ lệ tính phí và nhóm người dùng có thể chọn trong một bảng.", "Edit Channel": "Chỉnh sửa Kênh", + "Edit channel routing": "Chỉnh sửa định tuyến kênh", "Edit chat preset": "Chỉnh sửa cài đặt trước trò chuyện", "Edit discount tier": "Chỉnh sửa bậc giảm giá", "Edit FAQ": "Chỉnh sửa câu hỏi thường gặp", @@ -1449,6 +1455,7 @@ "Edit model": "Chỉnh sửa mô hình", "Edit Model": "Chỉnh sửa Mô hình", "Edit model pricing": "Chỉnh sửa giá mô hình", + "Edit non-sensitive routing fields such as models and groups.": "Chỉnh sửa các trường định tuyến không nhạy cảm như mô hình và nhóm.", "Edit OAuth Provider": "Chỉnh Sửa Nhà Cung Cấp OAuth", "Edit payment method": "Sửa phương thức thanh toán", "Edit Prefill Group": "Chỉnh sửa Nhóm Điền sẵn", @@ -1456,6 +1463,7 @@ "Edit ratio override": "Chỉnh sửa ghi đè tỷ lệ", "Edit Rule": "Sửa quy tắc", "Edit selectable group": "Chỉnh sửa nhóm có thể chọn", + "Edit sensitive channel settings": "Chỉnh sửa cài đặt kênh nhạy cảm", "Edit Tag": "Chỉnh sửa Thẻ", "Edit Tag:": "Chỉnh sửa thẻ:", "Edit Uptime Kuma Group": "Chỉnh sửa Nhóm Uptime Kuma", @@ -2964,6 +2972,7 @@ "OpenAIMax": "OpenAIMax", "OpenRouter": "OpenRouter", "opens in an external client. Trigger it from the sidebar or API key actions to launch the configured application.": "mở trong một ứng dụng bên ngoài. Kích hoạt nó từ thanh bên hoặc các hành động khóa API để khởi chạy ứng dụng đã cấu hình.", + "Operate channels": "Vận hành kênh", "Operation": "Thao tác", "operation and charging behavior": "vận hành và thu phí", "Operation Audit Info": "Thông tin kiểm toán thao tác", @@ -3422,6 +3431,7 @@ "Raw Quota": "Hạn mức gốc", "Re-enable on success": "Kích hoạt lại khi thành công", "Re-login": "Đăng nhập lại", + "Read channels": "Đọc kênh", "Ready": "Sẵn sàng", "Ready to initialize": "Sẵn sàng khởi tạo", "Ready to simplify": "Sẵn sàng đơn giản hóa", @@ -3727,7 +3737,6 @@ "Save Preferences": "Lưu tùy chọn", "Save preview": "Xem trước lưu", "Save rate limits": "Lưu giới hạn tốc độ", - "Save token limits": "Lưu giới hạn token", "Save sensitive words": "Lưu từ nhạy cảm", "Save Settings": "Lưu Cài đặt", "Save sidebar modules": "Lưu các mô-đun thanh bên", @@ -3736,6 +3745,7 @@ "Save Stripe settings": "Lưu cài đặt Stripe", "Save these backup codes in a safe place. Each code can only be used once.": "Lưu các mã dự phòng này ở nơi an toàn. Mỗi mã chỉ được sử dụng một lần.", "Save these codes in a safe place. Each code can only be used once.": "Hãy lưu các mã này ở nơi an toàn. Mỗi mã chỉ có thể được sử dụng một lần.", + "Save token limits": "Lưu giới hạn token", "Save tool prices": "Lưu giá công cụ", "Save Waffo Pancake settings": "Lưu cài đặt Waffo Pancake", "Save Worker settings": "Lưu cài đặt Worker", @@ -3964,11 +3974,11 @@ "Simple mode only returns message; status code and error type use system defaults.": "Chế độ đơn giản chỉ trả về message; mã trạng thái và loại lỗi sử dụng giá trị mặc định.", "Simple mode: prune objects by type, e.g. redacted_thinking.": "Chế độ đơn giản: dọn dẹp đối tượng theo type, ví dụ redacted_thinking.", "Single Key": "Khóa đơn", - "Skip async task polling delay": "Bỏ qua độ trễ thăm dò tác vụ bất đồng bộ", "Site & Branding": "Trang web & thương hiệu", "Site Key": "Khóa trang web", "Size:": "Kích thước:", "sk_xxx or rk_xxx": "sk_xxx hoặc rk_xxx", + "Skip async task polling delay": "Bỏ qua độ trễ thăm dò tác vụ bất đồng bộ", "Skip retry on failure": "Không thử lại khi thất bại", "Skip SMTP TLS certificate verification": "Bỏ qua xác minh chứng chỉ TLS SMTP", "Skip to Main": "Bỏ qua đến nội dung chính", @@ -4194,6 +4204,7 @@ "Test all {{count}} models": "Kiểm thử tất cả {{count}} mô hình", "Test All Channels": "Kiểm tra tất cả các kênh", "Test Channel Connection": "Kiểm tra kết nối kênh", + "Test channels, update balances, and toggle availability.": "Kiểm thử kênh, cập nhật số dư và bật tắt trạng thái khả dụng.", "Test Connection": "Kiểm tra kết nối", "Test connectivity for:": "Kiểm tra kết nối cho:", "Test failed": "Kiểm tra thất bại", @@ -4715,6 +4726,7 @@ "Vidu": "Vidu", "View": "Xem", "View all currently available models": "Xem tất cả mô hình hiện có", + "View channel lists and details without secrets.": "Xem danh sách và chi tiết kênh không chứa bí mật.", "View detailed information about this user including balance, usage statistics, and invitation details.": "Xem thông tin chi tiết về người dùng này bao gồm số dư, thống kê sử dụng và chi tiết lời mời.", "View details": "Xem chi tiết", "View document": "Xem tài liệu", @@ -4869,6 +4881,8 @@ "You understand this compliance reminder is only for risk notice and does not constitute legal advice, a compliance review conclusion, or a guarantee of the legality of your use of this system; you should consult professional legal or compliance advisors based on your actual business scenario.": "Bạn hiểu rằng nhắc nhở tuân thủ này chỉ là thông báo rủi ro, không cấu thành tư vấn pháp lý, kết luận rà soát tuân thủ hoặc bảo đảm tính hợp pháp của việc sử dụng hệ thống; bạn nên tham khảo cố vấn pháp lý hoặc tuân thủ chuyên nghiệp dựa trên tình huống kinh doanh thực tế.", "You will be redirected to Telegram to complete the binding process.": "Bạn sẽ được chuyển hướng đến Telegram để hoàn tất quá trình liên kết.", "You'll be redirected automatically. You can return to the previous page if nothing happens after a few seconds.": "Bạn sẽ được chuyển hướng tự động. Bạn có thể quay lại trang trước nếu không có gì xảy ra sau vài giây.", + "Your account can edit sensitive channel settings.": "Tài khoản của bạn có thể chỉnh sửa cài đặt kênh nhạy cảm.", + "Your account cannot edit sensitive channel settings.": "Tài khoản của bạn không thể chỉnh sửa cài đặt kênh nhạy cảm.", "your AI integration?": "tích hợp AI của bạn?", "Your Azure OpenAI endpoint URL": "URL điểm cuối Azure OpenAI của bạn", "Your Bot Name": "Tên Bot của bạn", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 23927dbdfbea..3f55f3468b87 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -219,6 +219,8 @@ "Admin": "管理员", "Admin access required": "需要管理员权限", "Admin area": "管理员区域", + "Admin Channel Permissions": "管理员渠道权限", + "Admin Permissions": "管理员权限", "Admin notes (only visible to admins)": "管理员备注(仅管理员可见)", "Admin Only": "仅限管理员", "Administer user accounts and roles.": "管理用户账户和角色。", @@ -708,6 +710,7 @@ "Channel ID": "渠道 ID", "Channel ID is required": "缺少渠道 ID", "Channel key": "渠道密钥", + "Channel Management": "渠道管理", "Channel key unlocked": "渠道密钥已解锁", "Channel models": "渠道模型", "Channel name is required": "渠道名称是必填的", @@ -1068,6 +1071,7 @@ "Create cache": "创建缓存", "Create cache ratio": "创建缓存倍率", "Create Channel": "创建渠道", + "Create channels or edit keys, base URLs, and overrides.": "创建渠道或编辑密钥、基础 URL 和覆盖规则。", "Create Code": "创建代码", "Create credentials for the root user": "为管理员创建登录凭据", "Create deployment": "创建部署", @@ -1186,6 +1190,7 @@ "Default": "默认", "Default (New Frontend)": "新版前端(默认)", "Default / range": "默认值 / 范围", + "Default administrator permissions can be overridden for this user.": "可以为此用户覆盖默认管理员权限。", "Default API Version *": "默认 API 版本 *", "Default API version for this channel": "此渠道的默认 API 版本", "Default Bearer": "默认 Bearer", @@ -1372,8 +1377,8 @@ "Drawing": "绘图", "Drawing logs": "绘制日志", "Drawing Logs": "绘图日志", - "Drawing task records": "绘图任务记录", "Drawing task polling": "绘图任务轮询", + "Drawing task records": "绘图任务记录", "Duplicate": "重复", "Duplicate group names: {{names}}": "存在重复的分组名称:{{names}}", "Duplicate source model mappings are not allowed": "不允许重复的源模型映射", @@ -1440,6 +1445,7 @@ "Edit API Shortcut": "编辑 API 快捷方式", "Edit billing ratios and user-selectable groups in one table.": "在一个表格中编辑计费倍率和用户可选分组。", "Edit Channel": "编辑渠道", + "Edit channel routing": "编辑渠道路由", "Edit chat preset": "编辑聊天预设", "Edit discount tier": "编辑折扣档位", "Edit FAQ": "编辑常见问题", @@ -1449,6 +1455,7 @@ "Edit model": "编辑模型", "Edit Model": "编辑模型", "Edit model pricing": "编辑模型定价", + "Edit non-sensitive routing fields such as models and groups.": "编辑模型和分组等非敏感路由字段。", "Edit OAuth Provider": "编辑 OAuth 提供商", "Edit payment method": "编辑支付方式", "Edit Prefill Group": "编辑预填充组", @@ -1456,6 +1463,7 @@ "Edit ratio override": "编辑倍率覆盖", "Edit Rule": "编辑规则", "Edit selectable group": "编辑可选分组", + "Edit sensitive channel settings": "编辑敏感渠道设置", "Edit Tag": "编辑标签", "Edit Tag:": "编辑标签:", "Edit Uptime Kuma Group": "编辑 Uptime Kuma 分组", @@ -2964,6 +2972,7 @@ "OpenAIMax": "OpenAIMax", "OpenRouter": "OpenRouter", "opens in an external client. Trigger it from the sidebar or API key actions to launch the configured application.": "在外部客户端中打开。从侧边栏或 API 密钥操作中触发,以启动配置的应用。", + "Operate channels": "运维渠道", "Operation": "操作", "operation and charging behavior": "运营和收费行为产生的法律责任", "Operation Audit Info": "操作审计信息", @@ -3422,6 +3431,7 @@ "Raw Quota": "原生额度", "Re-enable on success": "成功后重新启用", "Re-login": "重新登录", + "Read channels": "读取渠道", "Ready": "就绪", "Ready to initialize": "准备初始化", "Ready to simplify": "准备好简化", @@ -3727,7 +3737,6 @@ "Save Preferences": "保存偏好设置", "Save preview": "保存预览", "Save rate limits": "保存速率限制", - "Save token limits": "保存令牌限制", "Save sensitive words": "保存敏感词", "Save Settings": "保存设置", "Save sidebar modules": "保存侧边栏模块", @@ -3736,6 +3745,7 @@ "Save Stripe settings": "保存 Stripe 设置", "Save these backup codes in a safe place. Each code can only be used once.": "将这些备份代码保存在安全的地方。每个代码只能使用一次。", "Save these codes in a safe place. Each code can only be used once.": "将这些代码保存在安全的地方。每个代码只能使用一次。", + "Save token limits": "保存令牌限制", "Save tool prices": "保存工具价格", "Save Waffo Pancake settings": "保存 Waffo Pancake 设置", "Save Worker settings": "保存 Worker 设置", @@ -3964,11 +3974,11 @@ "Simple mode only returns message; status code and error type use system defaults.": "简洁模式仅返回 message;状态码和错误类型将使用系统默认值。", "Simple mode: prune objects by type, e.g. redacted_thinking.": "简洁模式:按 type 全量清理对象,例如 redacted_thinking。", "Single Key": "单密钥", - "Skip async task polling delay": "跳过异步任务轮询延迟", "Site & Branding": "站点与品牌", "Site Key": "站点密钥", "Size:": "大小:", "sk_xxx or rk_xxx": "sk_xxx 或 rk_xxx", + "Skip async task polling delay": "跳过异步任务轮询延迟", "Skip retry on failure": "失败后不重试", "Skip SMTP TLS certificate verification": "跳过 SMTP TLS 证书验证", "Skip to Main": "跳到主内容", @@ -4194,6 +4204,7 @@ "Test all {{count}} models": "测试全部 {{count}} 个模型", "Test All Channels": "测试所有渠道", "Test Channel Connection": "测试渠道连接", + "Test channels, update balances, and toggle availability.": "测试渠道、更新余额并切换可用状态。", "Test Connection": "测试连接", "Test connectivity for:": "测试连接性:", "Test failed": "测试失败", @@ -4715,6 +4726,7 @@ "Vidu": "Vidu", "View": "查看", "View all currently available models": "查看当前可用的所有模型", + "View channel lists and details without secrets.": "查看不含密钥的渠道列表和详情。", "View detailed information about this user including balance, usage statistics, and invitation details.": "查看此用户的详细信息,包括余额、使用统计和邀请详情。", "View details": "查看详情", "View document": "查看文档", @@ -4869,6 +4881,8 @@ "You understand this compliance reminder is only for risk notice and does not constitute legal advice, a compliance review conclusion, or a guarantee of the legality of your use of this system; you should consult professional legal or compliance advisors based on your actual business scenario.": "你理解此合规提醒仅用于风险提示,不构成法律意见、合规审查结论或对你使用本系统合法性的保证;你应结合实际业务场景咨询专业法律或合规顾问。", "You will be redirected to Telegram to complete the binding process.": "您将被重定向到 Telegram 以完成绑定过程。", "You'll be redirected automatically. You can return to the previous page if nothing happens after a few seconds.": "您将被自动重定向。如果几秒钟后无反应,您可以返回上一页。", + "Your account can edit sensitive channel settings.": "你的账号可以编辑敏感渠道设置。", + "Your account cannot edit sensitive channel settings.": "你的账号不能编辑敏感渠道设置。", "your AI integration?": "你的 AI 集成了吗?", "Your Azure OpenAI endpoint URL": "您的 Azure OpenAI 端点 URL", "Your Bot Name": "您的机器人名称", diff --git a/web/default/src/lib/admin-permissions.ts b/web/default/src/lib/admin-permissions.ts new file mode 100644 index 000000000000..79c3e3a5c190 --- /dev/null +++ b/web/default/src/lib/admin-permissions.ts @@ -0,0 +1,75 @@ +import { ROLE } from './roles' +import type { AuthUser } from '@/stores/auth-store' + +export type AdminPermissionMatrix = Record> +export type AdminCapabilities = AdminPermissionMatrix + +export const ADMIN_PERMISSION_RESOURCES = { + CHANNEL: 'channel', +} as const + +export const ADMIN_PERMISSION_ACTIONS = { + READ: 'read', + OPERATE: 'operate', + WRITE: 'write', + SENSITIVE_WRITE: 'sensitive_write', + SECRET_VIEW: 'secret_view', +} as const + +export const ADMIN_PERMISSION_CATALOG = [ + { + resource: ADMIN_PERMISSION_RESOURCES.CHANNEL, + labelKey: 'Channel Management', + actions: [ + { + value: ADMIN_PERMISSION_ACTIONS.READ, + labelKey: 'Read channels', + descriptionKey: 'View channel lists and details without secrets.', + defaultAdmin: true, + }, + { + value: ADMIN_PERMISSION_ACTIONS.OPERATE, + labelKey: 'Operate channels', + descriptionKey: 'Test channels, update balances, and toggle availability.', + defaultAdmin: true, + }, + { + value: ADMIN_PERMISSION_ACTIONS.WRITE, + labelKey: 'Edit channel routing', + descriptionKey: 'Edit non-sensitive routing fields such as models and groups.', + defaultAdmin: true, + }, + { + value: ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE, + labelKey: 'Edit sensitive channel settings', + descriptionKey: 'Create channels or edit keys, base URLs, and overrides.', + defaultAdmin: false, + }, + ], + }, +] as const + +export function hasPermission( + user: AuthUser | null | undefined, + resource: string, + action: string +): boolean { + if (!user) return false + if (user.role === ROLE.SUPER_ADMIN) return true + return user.permissions?.admin_permissions?.[resource]?.[action] === true +} + +export function normalizeAdminPermissions( + value: AdminPermissionMatrix | null | undefined +): AdminPermissionMatrix { + const normalized: AdminPermissionMatrix = {} + for (const resource of ADMIN_PERMISSION_CATALOG) { + const actions: Record = {} + for (const action of resource.actions) { + actions[action.value] = + value?.[resource.resource]?.[action.value] ?? action.defaultAdmin + } + normalized[resource.resource] = actions + } + return normalized +} diff --git a/web/default/src/stores/auth-store.ts b/web/default/src/stores/auth-store.ts index 95a14083f6de..20981a7673b8 100644 --- a/web/default/src/stores/auth-store.ts +++ b/web/default/src/stores/auth-store.ts @@ -17,10 +17,12 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { create } from 'zustand' +import type { AdminCapabilities } from '@/lib/admin-permissions' export type UserPermissions = { sidebar_settings?: boolean sidebar_modules?: Record + admin_permissions?: AdminCapabilities } export interface AuthUser { From 2f5565e7844f92bdb8bee730157d92e29b496c02 Mon Sep 17 00:00:00 2001 From: CaIon Date: Fri, 26 Jun 2026 16:20:52 +0800 Subject: [PATCH 2/6] feat: improve audit logging to associate logs with actual operators and target users --- controller/audit.go | 15 +++++++++++---- model/log.go | 4 ++-- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/controller/audit.go b/controller/audit.go index 2e54db4e9dff..cbc231841235 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/model/log.go b/model/log.go index 544638c870bf..d1db2deee101 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,普通用户查询时剥离)。 From a6de6c7aa241b390fa0731b8215e987558c8aa7e Mon Sep 17 00:00:00 2001 From: CaIon Date: Fri, 26 Jun 2026 18:28:15 +0800 Subject: [PATCH 3/6] feat: enhance admin permissions and UI interactions for sensitive actions --- controller/user.go | 87 ++++-- model/casbin_rule.go | 14 +- model/main.go | 2 - model/user.go | 38 ++- router/channel-router.go | 2 +- service/authz/adapter.go | 3 +- service/authz/authz.go | 93 ++++-- service/authz/authz_test.go | 89 ++++++ .../components/channels-primary-buttons.tsx | 40 ++- .../components/data-table-row-actions.tsx | 18 +- .../dialogs/multi-key-manage-dialog.tsx | 37 ++- .../dialogs/multi-key-table-row-actions.tsx | 13 +- .../drawers/channel-mutate-drawer.tsx | 291 ++++++++++++++---- .../channels/hooks/use-channel-mutate-form.ts | 37 ++- web/default/src/i18n/locales/en.json | 6 + web/default/src/i18n/locales/fr.json | 6 + web/default/src/i18n/locales/ja.json | 6 + web/default/src/i18n/locales/ru.json | 6 + web/default/src/i18n/locales/vi.json | 6 + web/default/src/i18n/locales/zh.json | 6 + web/default/src/lib/admin-permissions.ts | 7 + 21 files changed, 651 insertions(+), 156 deletions(-) diff --git a/controller/user.go b/controller/user.go index e8249ee782f2..190c2b1d3593 100644 --- a/controller/user.go +++ b/controller/user.go @@ -24,6 +24,7 @@ import ( "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" + "gorm.io/gorm" ) type LoginRequest struct { @@ -623,29 +624,40 @@ func UpdateUser(c *gin.Context) { common.ApiError(c, err) return } - if updatedUser.Role == 0 { - updatedUser.Role = originUser.Role + 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 err := updateAdminPermissionsForUser(c, updatedUser.Id, updatedUser.Role, updatedUser.AdminPermissions); 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, @@ -911,14 +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 err := updateAdminPermissionsForUser(c, cleanUser.Id, cleanUser.Role, user.AdminPermissions); 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, @@ -931,20 +954,20 @@ func CreateUser(c *gin.Context) { return } -func updateAdminPermissionsForUser(c *gin.Context, userID int, userRole int, permissions map[string]map[string]bool) error { +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 authz.ClearUserAuthorization(userID) + return true, authz.ClearUserAuthorizationInTx(tx, userID) } - return nil + return false, nil } if c.GetInt("role") != common.RoleRootUser { - return fmt.Errorf("only root can update admin permissions") + return false, fmt.Errorf("only root can update admin permissions") } if userRole < common.RoleAdminUser { - return authz.ClearUserAuthorization(userID) + return true, authz.ClearUserAuthorizationInTx(tx, userID) } - return authz.SetUserPermissions(userID, permissions) + return true, authz.SetUserPermissionsInTx(tx, userID, permissions) } type ManageRequest struct { @@ -1070,12 +1093,26 @@ 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 := authz.ClearUserAuthorization(user.Id); err != nil { + 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 } diff --git a/model/casbin_rule.go b/model/casbin_rule.go index 07e3082e3b90..e5f07eeddfdc 100644 --- a/model/casbin_rule.go +++ b/model/casbin_rule.go @@ -2,13 +2,13 @@ package model type CasbinRule struct { Id uint `gorm:"primaryKey;autoIncrement"` - Ptype string `gorm:"size:100;index:idx_casbin_rule,priority:1"` - V0 string `gorm:"size:100;index:idx_casbin_rule,priority:2"` - V1 string `gorm:"size:100;index:idx_casbin_rule,priority:3"` - V2 string `gorm:"size:100;index:idx_casbin_rule,priority:4"` - V3 string `gorm:"size:100;index:idx_casbin_rule,priority:5"` - V4 string `gorm:"size:100;index:idx_casbin_rule,priority:6"` - V5 string `gorm:"size:100;index:idx_casbin_rule,priority:7"` + 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 { diff --git a/model/main.go b/model/main.go index dc2e22e8513e..76f98a59c307 100644 --- a/model/main.go +++ b/model/main.go @@ -351,8 +351,6 @@ func migrateDBFast() error { {&SystemInstance{}, "SystemInstance"}, {&SystemTask{}, "SystemTask"}, {&SystemTaskLock{}, "SystemTaskLock"}, - {&CasbinRule{}, "CasbinRule"}, - {&AuthzRole{}, "AuthzRole"}, } // 动态计算migration数量,确保errChan缓冲区足够大 errChan := make(chan error, len(migrations)) diff --git a/model/user.go b/model/user.go index 0c2f263eb4c4..85bbc7c4515c 100644 --- a/model/user.go +++ b/model/user.go @@ -409,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 @@ -438,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. @@ -501,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) @@ -509,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 } + return nil +} - // Update cache +func (user *User) Edit(updatePassword bool) error { + if err := user.EditWithTx(DB, updatePassword); err != nil { + return err + } return updateUserCache(*user) } -func (user *User) Edit(updatePassword bool) error { +func (user *User) EditWithTx(tx *gorm.DB, updatePassword bool) error { var err error if updatePassword { user.Password, err = common.Password2Hash(user.Password) @@ -538,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/channel-router.go b/router/channel-router.go index cb9afac33e3e..de1c10017260 100644 --- a/router/channel-router.go +++ b/router/channel-router.go @@ -65,7 +65,7 @@ var channelPermissionRoutes = []permissionRoute{ {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.ChannelRead, handler: controller.OllamaVersion}, + {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}, diff --git a/service/authz/adapter.go b/service/authz/adapter.go index 27ddff27662c..6c971a8aada8 100644 --- a/service/authz/adapter.go +++ b/service/authz/adapter.go @@ -7,6 +7,7 @@ import ( casbinmodel "github.com/casbin/casbin/v2/model" "github.com/casbin/casbin/v2/persist" "gorm.io/gorm" + "gorm.io/gorm/clause" ) type gormAdapter struct { @@ -62,7 +63,7 @@ func (a *gormAdapter) AddPolicy(_ string, ptype string, rule []string) error { if count > 0 { return nil } - return a.db.Create(&casbinRule).Error + return a.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&casbinRule).Error } func (a *gormAdapter) RemovePolicy(_ string, ptype string, rule []string) error { diff --git a/service/authz/authz.go b/service/authz/authz.go index ac8d3ec2b25c..4e543f8acab7 100644 --- a/service/authz/authz.go +++ b/service/authz/authz.go @@ -11,6 +11,7 @@ import ( "github.com/casbin/casbin/v2" casbinmodel "github.com/casbin/casbin/v2/model" "gorm.io/gorm" + "gorm.io/gorm/clause" ) type Permission struct { @@ -112,11 +113,13 @@ m = r.sub == p.sub && r.obj == p.obj && r.act == p.act && p.eft == "allow" ` func Init(db *gorm.DB) error { - if err := seedBuiltInRoles(db); err != nil { - return err - } - if err := resetBuiltInRolePolicies(db); err != nil { - return err + if common.IsMasterNode { + if err := seedBuiltInRoles(db); err != nil { + return err + } + if err := resetBuiltInRolePolicies(db); err != nil { + return err + } } m, err := casbinmodel.NewModelFromString(modelText) @@ -133,6 +136,9 @@ func Init(db *gorm.DB) error { enforcer = e enforcerMu.Unlock() + if !common.IsMasterNode { + return nil + } return seedDefaultPolicies() } @@ -205,6 +211,34 @@ func SetUserPermissions(userID int, permissions PermissionsMap) error { 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 { @@ -219,10 +253,32 @@ func ClearUserPermissions(userID int) error { return nil } +func ClearUserPermissionsInTx(tx *gorm.DB, userID int) error { + for _, resource := range catalog { + 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) +} + +func ReloadPolicy() error { + enforcerMu.Lock() + defer enforcerMu.Unlock() + if enforcer == nil { + return fmt.Errorf("authz enforcer is not initialized") + } + return enforcer.LoadPolicy() +} + func ExplicitUserPermissions(userID int) PermissionsMap { return Capabilities(userID, common.RoleAdminUser) } @@ -312,23 +368,16 @@ func seedBuiltInRoles(db *gorm.DB) error { }, } for _, role := range roles { - var existing model.AuthzRole - err := db.Where("key = ?", role.Key).First(&existing).Error - if err == nil { - existing.Name = role.Name - existing.Description = role.Description - existing.BuiltIn = role.BuiltIn - existing.Enabled = role.Enabled - existing.Sort = role.Sort - if err := db.Save(&existing).Error; err != nil { - return err - } - continue - } - if err != gorm.ErrRecordNotFound { - return err - } - if err := db.Create(&role).Error; err != nil { + 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 } } diff --git a/service/authz/authz_test.go b/service/authz/authz_test.go index cb170922fc93..dfd13fc1a47d 100644 --- a/service/authz/authz_test.go +++ b/service/authz/authz_test.go @@ -13,8 +13,16 @@ import ( 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 } @@ -43,6 +51,30 @@ func TestInitSeedsBuiltInRolesAndPoliciesOnce(t *testing.T) { 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)) @@ -124,6 +156,63 @@ func TestClearUserAuthorizationRemovesOverrides(t *testing.T) { 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)) 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 2d38c732ba7e..0b8ab0661a1b 100644 --- a/web/default/src/features/channels/components/channels-primary-buttons.tsx +++ b/web/default/src/features/channels/components/channels-primary-buttons.tsx @@ -49,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, @@ -117,19 +122,28 @@ export function ChannelsPrimaryButtons() { {/* Create Channel */} - {canEditSensitive && ( - - )} + + }> + + + {!canEditSensitive && ( + + {t('No permission to perform this action')} + + )} + {/* More Actions */} 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 57780f5220ee..0adc0213cd37 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 @@ -316,12 +316,18 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { {/* Copy Channel */} - {canEditSensitive && ( - - {t('Copy Channel')} - - - + + {t('Copy Channel')} + + + + + {!canEditSensitive && ( + + {t('No permission to perform this action')} )} 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 e0daa930da9b..06f8e5b3992f 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 cab16261e7ea..3c4109aff6d5 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, weight, and status.' + )} + + + )} +
- ( - - {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' + )} + + )} + + + )} + /> +
{currentType === 1 && ( - ( - - {t('OpenAI Organization')} - - - - - {t(FIELD_DESCRIPTIONS.OPENAI_ORG)} - - - - )} - /> +
+ ( + + {t('OpenAI Organization')} + + + + + {sensitiveLocked + ? t( + 'No permission to perform this action' + ) + : t(FIELD_DESCRIPTIONS.OPENAI_ORG)} + + + + )} + /> +
)} @@ -1219,6 +1326,20 @@ export function ChannelMutateDrawer({ )} + {sensitiveLocked && ( + + + {t( + 'No permission to perform this action' + )} + + + )} + +
{/* Azure (type 3) */} {currentType === 3 && ( <> @@ -2004,7 +2125,7 @@ export function ChannelMutateDrawer({ )} - {isEditing && ( + {isEditing && canRevealChannelKey && (
@@ -2081,7 +2202,10 @@ export function ChannelMutateDrawer({ variant='outline' size='sm' onClick={handleRefreshCodexCredential} - disabled={isCodexCredentialRefreshing} + disabled={ + sensitiveLocked || + isCodexCredentialRefreshing + } > {isCodexCredentialRefreshing ? ( @@ -2207,6 +2331,7 @@ export function ChannelMutateDrawer({ /> )} +
{/* ── Models & Groups ── */} @@ -2324,18 +2449,28 @@ export function ChannelMutateDrawer({ {t('Fill All Models')} {MODEL_FETCHABLE_TYPES.has(currentType) && ( - + <> + + {!isEditing && !canEditSensitive && ( + + {t( + 'No permission to perform this action' + )} + + )} + )} - 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 0adc0213cd37..be7623b827b3 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 @@ -345,8 +345,10 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { {/* Delete */} { e.preventDefault() + if (!canEditSensitive) return setDeleteConfirmOpen(true) }} className='text-destructive focus:text-destructive' @@ -367,6 +369,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { confirmText='Delete' destructive handleConfirm={() => { + if (!canEditSensitive) return handleDeleteChannel(channel.id, queryClient) setDeleteConfirmOpen(false) }} 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 3c4109aff6d5..380d10e54083 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 @@ -1183,7 +1183,7 @@ export function ChannelMutateDrawer({ {t('Sensitive channel settings are read-only for your account.')}{' '} {t( - 'You can still edit non-sensitive operations fields such as models, groups, priority, weight, and status.' + 'You can still edit non-sensitive operations fields such as models, groups, priority, and weight.' )} @@ -1264,28 +1264,30 @@ export function ChannelMutateDrawer({ - ( - -
- {t('Enabled')} - - {t('Enable or disable this channel')} - -
- - - field.onChange(checked ? 1 : 2) - } - /> - -
- )} - /> + {!isEditing && ( + ( + +
+ {t('Enabled')} + + {t('Enable or disable this channel')} + +
+ + + field.onChange(checked ? 1 : 2) + } + /> + +
+ )} + /> + )} {currentType === 1 && (
void ): Promise { try { - const response = await updateChannel(id, { status: CHANNEL_STATUS.ENABLED }) + const response = await updateChannelStatus(id, CHANNEL_STATUS.ENABLED) if (response.success) { toast.success(i18next.t(SUCCESS_MESSAGES.ENABLED)) queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() }) @@ -141,9 +143,10 @@ export async function handleDisableChannel( onSuccess?: () => void ): Promise { try { - const response = await updateChannel(id, { - status: CHANNEL_STATUS.MANUAL_DISABLED, - }) + const response = await updateChannelStatus( + id, + CHANNEL_STATUS.MANUAL_DISABLED + ) if (response.success) { toast.success(i18next.t(SUCCESS_MESSAGES.DISABLED)) queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() }) @@ -441,16 +444,12 @@ export async function handleBatchEnable( } try { - // Update each channel individually - const promises = ids.map((id) => - updateChannel(id, { status: CHANNEL_STATUS.ENABLED }) + const response = await batchUpdateChannelStatus( + ids, + CHANNEL_STATUS.ENABLED ) - const results = await Promise.allSettled(promises) - - const successCount = results.filter( - (r) => r.status === 'fulfilled' && r.value.success - ).length - const failCount = results.length - successCount + const successCount = response.success ? response.data || 0 : 0 + const failCount = ids.length - successCount if (successCount > 0) { toast.success( @@ -460,7 +459,9 @@ export async function handleBatchEnable( onSuccess?.() } - if (failCount > 0) { + if (!response.success) { + toast.error(response.message || i18next.t('Failed to enable channels')) + } else if (failCount > 0) { toast.error( i18next.t('{{count}} channel(s) failed to enable', { count: failCount }) ) @@ -484,16 +485,12 @@ export async function handleBatchDisable( } try { - // Update each channel individually - const promises = ids.map((id) => - updateChannel(id, { status: CHANNEL_STATUS.MANUAL_DISABLED }) + const response = await batchUpdateChannelStatus( + ids, + CHANNEL_STATUS.MANUAL_DISABLED ) - const results = await Promise.allSettled(promises) - - const successCount = results.filter( - (r) => r.status === 'fulfilled' && r.value.success - ).length - const failCount = results.length - successCount + const successCount = response.success ? response.data || 0 : 0 + const failCount = ids.length - successCount if (successCount > 0) { toast.success( @@ -503,7 +500,9 @@ export async function handleBatchDisable( onSuccess?.() } - if (failCount > 0) { + if (!response.success) { + toast.error(response.message || i18next.t('Failed to disable channels')) + } else if (failCount > 0) { toast.error( i18next.t('{{count}} channel(s) failed to disable', { count: failCount, diff --git a/web/default/src/features/channels/lib/channel-form.ts b/web/default/src/features/channels/lib/channel-form.ts index 02c8fc770efb..57ab8c5f8564 100644 --- a/web/default/src/features/channels/lib/channel-form.ts +++ b/web/default/src/features/channels/lib/channel-form.ts @@ -702,7 +702,6 @@ export function transformFormDataToUpdatePayload( weight: formData.weight ?? 0, test_model: formData.test_model || null, auto_ban: formData.auto_ban ?? 1, - status: formData.status, status_code_mapping: formData.status_code_mapping || null, tag: formData.tag || null, remark: formData.remark || '', diff --git a/web/default/src/features/users/api.ts b/web/default/src/features/users/api.ts index 14710cfcb21b..bceaf14d68f7 100644 --- a/web/default/src/features/users/api.ts +++ b/web/default/src/features/users/api.ts @@ -17,6 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { api } from '@/lib/api' +import type { PermissionCatalog } from '@/lib/admin-permissions' import type { User, GetUsersParams, @@ -149,6 +150,18 @@ export async function getGroups(): Promise> { return res.data } +/** + * Get the permission catalog (resources, actions, and role baselines). + * Source of truth lives in the backend authz package. + */ +export async function getPermissionCatalog(): Promise { + const res = await api.get('/api/authz/catalog') + return { + resources: res.data?.data?.resources ?? [], + roles: res.data?.data?.roles ?? [], + } +} + // ============================================================================ // Admin Binding Management APIs // ============================================================================ diff --git a/web/default/src/features/users/components/users-mutate-drawer.tsx b/web/default/src/features/users/components/users-mutate-drawer.tsx index 01f02a104424..1717ca03d955 100644 --- a/web/default/src/features/users/components/users-mutate-drawer.tsx +++ b/web/default/src/features/users/components/users-mutate-drawer.tsx @@ -25,8 +25,8 @@ import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { ADMIN_PERMISSION_ACTIONS, - ADMIN_PERMISSION_CATALOG, ADMIN_PERMISSION_RESOURCES, + EMPTY_PERMISSION_CATALOG, hasPermission, normalizeAdminPermissions, } from '@/lib/admin-permissions' @@ -72,7 +72,13 @@ import { sideDrawerFormClassName, sideDrawerHeaderClassName, } from '@/components/drawer-layout' -import { createUser, updateUser, getUser, getGroups } from '../api' +import { + createUser, + updateUser, + getUser, + getGroups, + getPermissionCatalog, +} from '../api' import { BINDING_FIELDS, ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants' import { userFormSchema, @@ -112,6 +118,13 @@ export function UsersMutateDrawer({ const groups = groupsData?.data || [] + // Permission catalog is owned by the backend; fetched once and reused. + const { data: permissionCatalog = EMPTY_PERMISSION_CATALOG } = useQuery({ + queryKey: ['admin-permission-catalog'], + queryFn: getPermissionCatalog, + staleTime: 5 * 60 * 1000, + }) + const form = useForm({ resolver: zodResolver(userFormSchema), defaultValues: USER_FORM_DEFAULT_VALUES, @@ -155,7 +168,11 @@ export function UsersMutateDrawer({ setIsSubmitting(true) try { - const payload = transformFormDataToPayload(data, currentRow?.id) + const payload = transformFormDataToPayload( + data, + currentRow?.id, + permissionCatalog + ) const result = isUpdate ? await updateUser(payload as typeof payload & { id: number }) : await createUser(payload) @@ -431,7 +448,9 @@ export function UsersMutateDrawer({ )} - {canEditAdminPermissions && targetIsAdmin && ( + {canEditAdminPermissions && + targetIsAdmin && + permissionCatalog.resources.length > 0 && (

{t('Admin Permissions')} @@ -445,28 +464,31 @@ export function UsersMutateDrawer({ control={form.control} name='admin_permissions' render={({ field }) => { - const selected = normalizeAdminPermissions(field.value) + const selected = normalizeAdminPermissions( + field.value, + permissionCatalog + ) return (
- {ADMIN_PERMISSION_CATALOG.map((resource) => ( + {permissionCatalog.resources.map((resource) => (
- {t(resource.labelKey)} + {t(resource.label_key)}
{resource.actions.map((option) => ( diff --git a/web/default/src/features/users/lib/user-form.ts b/web/default/src/features/users/lib/user-form.ts index ba78c5190440..916bff76f363 100644 --- a/web/default/src/features/users/lib/user-form.ts +++ b/web/default/src/features/users/lib/user-form.ts @@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com import { z } from 'zod' import { quotaUnitsToDollars } from '@/lib/format' import { + type PermissionCatalog, type AdminPermissionMatrix, normalizeAdminPermissions, } from '@/lib/admin-permissions' @@ -55,7 +56,8 @@ export const USER_FORM_DEFAULT_VALUES: UserFormValues = { quota_dollars: 0, group: DEFAULT_GROUP, remark: '', - admin_permissions: normalizeAdminPermissions(undefined), + // Filled against the backend catalog at render time; see UsersMutateDrawer. + admin_permissions: {}, } // ============================================================================ @@ -67,7 +69,8 @@ export const USER_FORM_DEFAULT_VALUES: UserFormValues = { */ export function transformFormDataToPayload( data: UserFormValues, - userId?: number + userId?: number, + catalog?: PermissionCatalog ): UserFormData & { id?: number } { const payload: UserFormData & { id?: number } = { username: data.username, @@ -75,31 +78,35 @@ export function transformFormDataToPayload( password: data.password || undefined, } + const role = userId === undefined ? data.role || 1 : (data.role ?? 0) + + // Only send the permission matrix when the target is an admin and the catalog + // is available; without the catalog we cannot build a full matrix, so we omit + // the field (the backend then leaves existing permissions untouched). + if (role >= ROLE.ADMIN && catalog) { + payload.admin_permissions = normalizeAdminPermissions( + data.admin_permissions as AdminPermissionMatrix | undefined, + catalog + ) + } + // For create: only send required fields if (userId === undefined) { - payload.role = data.role || 1 // Default to common user - if (payload.role >= ROLE.ADMIN) { - payload.admin_permissions = normalizeAdminPermissions( - data.admin_permissions as AdminPermissionMatrix | undefined - ) - } + payload.role = role } else { // For update: quota is adjusted atomically via /api/user/manage, not sent here payload.group = data.group payload.remark = data.remark || undefined payload.id = userId - if ((data.role ?? 0) >= ROLE.ADMIN) { - payload.admin_permissions = normalizeAdminPermissions( - data.admin_permissions as AdminPermissionMatrix | undefined - ) - } } return payload } /** - * Transform user data to form defaults + * Transform user data to form defaults. The admin permission matrix is passed + * through as-is (the backend already returns a full matrix); it is filled against + * the catalog at render time in UsersMutateDrawer. */ export function transformUserToFormDefaults(user: User): UserFormValues { return { @@ -110,6 +117,6 @@ export function transformUserToFormDefaults(user: User): UserFormValues { quota_dollars: quotaUnitsToDollars(user.quota), group: user.group || DEFAULT_GROUP, remark: user.remark || '', - admin_permissions: normalizeAdminPermissions(user.admin_permissions), + admin_permissions: user.admin_permissions ?? {}, } } diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 295c8d6cf808..015a92e2cecf 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -220,9 +220,9 @@ "Admin access required": "Admin access required", "Admin area": "Admin area", "Admin Channel Permissions": "Admin Channel Permissions", - "Admin Permissions": "Admin Permissions", "Admin notes (only visible to admins)": "Admin notes (only visible to admins)", "Admin Only": "Admin Only", + "Admin Permissions": "Admin Permissions", "Administer user accounts and roles.": "Administer user accounts and roles.", "Administrator account": "Administrator account", "Administrator username": "Administrator username", @@ -710,8 +710,8 @@ "Channel ID": "Channel ID", "Channel ID is required": "Channel ID is required", "Channel key": "Channel key", - "Channel Management": "Channel Management", "Channel key unlocked": "Channel key unlocked", + "Channel Management": "Channel Management", "Channel models": "Channel models", "Channel name is required": "Channel name is required", "Channel test completed": "Channel test completed", @@ -1455,7 +1455,7 @@ "Edit model": "Edit model", "Edit Model": "Edit Model", "Edit model pricing": "Edit model pricing", - "Edit non-sensitive routing fields such as models and groups.": "Edit non-sensitive routing fields such as models and groups.", + "Edit non-sensitive settings such as models, groups, and routing rules.": "Edit non-sensitive settings such as models, groups, and routing rules.", "Edit OAuth Provider": "Edit OAuth Provider", "Edit payment method": "Edit payment method", "Edit Prefill Group": "Edit Prefill Group", @@ -2797,6 +2797,7 @@ "No payment methods configured. Click \"Add method\" or use templates to get started.": "No payment methods configured. Click \"Add method\" or use templates to get started.", "No payment methods match your search": "No payment methods match your search", "No performance data available": "No performance data available", + "No permission to perform this action": "No permission to perform this action", "No plans available": "No plans available", "No preference": "No preference", "No prefill groups yet": "No prefill groups yet", @@ -3599,11 +3600,11 @@ "Required events:": "Required events:", "Required provider, authentication, model, and group settings": "Required provider, authentication, model, and group settings", "Required to expose MjProxy-style image generation to end users.": "Required to expose MjProxy-style image generation to end users.", - "No permission to perform this action": "No permission to perform this action", "Rerank": "Rerank", "Reroll": "Reroll", "Research, analysis, scientific reasoning": "Research, analysis, scientific reasoning", "Resend ({{seconds}}s)": "Resend ({{seconds}}s)", + "Reserved for viewing complete channel keys after secure verification.": "Reserved for viewing complete channel keys after secure verification.", "Reset": "Reset", "Reset 2FA": "Reset 2FA", "Reset all model prices?": "Reset all model prices?", @@ -3637,7 +3638,6 @@ "Resolve Conflicts": "Resolve Conflicts", "Resource Configuration": "Resource Configuration", "Resources": "Resources", - "Reserved for viewing complete channel keys after secure verification.": "Reserved for viewing complete channel keys after secure verification.", "Response": "Response", "Response Time": "Response Time", "Response time: {{duration}}": "Response time: {{duration}}", @@ -4207,7 +4207,7 @@ "Test all {{count}} models": "Test all {{count}} models", "Test All Channels": "Test All Channels", "Test Channel Connection": "Test Channel Connection", - "Test channels, update balances, and toggle availability.": "Test channels, update balances, and toggle availability.", + "Test channels, refresh balances, and enable/disable individual, batch, or tagged channels.": "Test channels, refresh balances, and enable/disable individual, batch, or tagged channels.", "Test Connection": "Test Connection", "Test connectivity for:": "Test connectivity for:", "Test failed": "Test failed", @@ -4729,8 +4729,8 @@ "Vidu": "Vidu", "View": "View", "View all currently available models": "View all currently available models", - "View channel secrets": "View channel secrets", "View channel lists and details without secrets.": "View channel lists and details without secrets.", + "View channel secrets": "View channel secrets", "View detailed information about this user including balance, usage statistics, and invitation details.": "View detailed information about this user including balance, usage statistics, and invitation details.", "View details": "View details", "View document": "View document", @@ -4872,12 +4872,12 @@ "You are running the latest version ({{version}}).": "You are running the latest version ({{version}}).", "You can close this tab once the binding completes or a success message appears in the original window.": "You can close this tab once the binding completes or a success message appears in the original window.", "You can manually add them in \"Custom Model Names\", click \"Fill\" and then submit, or use the operations below to handle automatically.": "You can manually add them in \"Custom Model Names\", click \"Fill\" and then submit, or use the operations below to handle automatically.", - "You can still edit non-sensitive operations fields such as models, groups, priority, weight, and status.": "You can still edit non-sensitive operations fields such as models, groups, priority, weight, and status.", "You can only check in once per day": "You can only check in once per day", + "You can still edit non-sensitive operations fields such as models, groups, priority, and weight.": "You can still edit non-sensitive operations fields such as models, groups, priority, and weight.", "You commit not to use this system to implement, assist with, or indirectly implement acts that violate applicable laws and regulations, regulatory requirements, platform rules, public interests, or the lawful rights and interests of third parties.": "You commit not to use this system to implement, assist with, or indirectly implement acts that violate applicable laws and regulations, regulatory requirements, platform rules, public interests, or the lawful rights and interests of third parties.", "You commit to using upstream APIs, accounts, keys, quotas, and service capabilities only within the scope of lawful authorization obtained from upstream service providers, model service providers, or relevant rights holders, and will not conduct unauthorized resale, trafficking, distribution, or other non-compliant commercialization.": "You commit to using upstream APIs, accounts, keys, quotas, and service capabilities only within the scope of lawful authorization obtained from upstream service providers, model service providers, or relevant rights holders, and will not conduct unauthorized resale, trafficking, distribution, or other non-compliant commercialization.", - "You don't have necessary permission": "You don't have necessary permission", "You do not have permission to edit sensitive channel settings.": "You do not have permission to edit sensitive channel settings.", + "You don't have necessary permission": "You don't have necessary permission", "You have legally obtained authorization for the connected model APIs, accounts, keys, and quotas.": "You have legally obtained authorization for the connected model APIs, accounts, keys, and quotas.", "You have unsaved changes": "You have unsaved changes", "You have unsaved changes. Are you sure you want to leave?": "You have unsaved changes. Are you sure you want to leave?", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index 427741e947bf..5f771b3ff91e 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -220,9 +220,9 @@ "Admin access required": "Accès administrateur requis", "Admin area": "Espace administrateur", "Admin Channel Permissions": "Autorisations des canaux administrateur", - "Admin Permissions": "Autorisations administrateur", "Admin notes (only visible to admins)": "Notes d'administration (visibles uniquement par les administrateurs)", "Admin Only": "Administrateur uniquement", + "Admin Permissions": "Autorisations administrateur", "Administer user accounts and roles.": "Gérer les comptes d'utilisateurs et les rôles.", "Administrator account": "Compte administrateur", "Administrator username": "Nom d'utilisateur administrateur", @@ -710,8 +710,8 @@ "Channel ID": "ID du Canal", "Channel ID is required": "L'ID du canal est requis", "Channel key": "Clé du canal", - "Channel Management": "Gestion des canaux", "Channel key unlocked": "Clé de canal déverrouillée", + "Channel Management": "Gestion des canaux", "Channel models": "Modèles de canaux", "Channel name is required": "Le nom du canal est requis", "Channel test completed": "Test du canal terminé", @@ -1455,7 +1455,7 @@ "Edit model": "Modifier le modèle", "Edit Model": "Modifier le modèle", "Edit model pricing": "Modifier la tarification du modèle", - "Edit non-sensitive routing fields such as models and groups.": "Modifier les champs de routage non sensibles comme les modèles et les groupes.", + "Edit non-sensitive settings such as models, groups, and routing rules.": "Modifier les paramètres non sensibles comme les modèles, les groupes et les règles de routage.", "Edit OAuth Provider": "Modifier le fournisseur OAuth", "Edit payment method": "Modifier le mode de paiement", "Edit Prefill Group": "Modifier le groupe de préremplissage", @@ -2797,6 +2797,7 @@ "No payment methods configured. Click \"Add method\" or use templates to get started.": "Aucune méthode de paiement configurée. Cliquez sur \"Ajouter une méthode\" ou utilisez des modèles pour commencer.", "No payment methods match your search": "Aucune méthode de paiement ne correspond à votre recherche", "No performance data available": "Aucune donnée de performance disponible", + "No permission to perform this action": "Vous n’avez pas l’autorisation d’effectuer cette action", "No plans available": "Aucun plan disponible", "No preference": "Aucune préférence", "No prefill groups yet": "Aucun groupe de préremplissage pour l'instant", @@ -3599,11 +3600,11 @@ "Required events:": "Événements requis :", "Required provider, authentication, model, and group settings": "Paramètres requis de fournisseur, authentification, modèles et groupes", "Required to expose MjProxy-style image generation to end users.": "Requis pour exposer la génération d'images style MjProxy aux utilisateurs finaux.", - "No permission to perform this action": "Vous n’avez pas l’autorisation d’effectuer cette action", "Rerank": "Reclasser", "Reroll": "Relancer", "Research, analysis, scientific reasoning": "Recherche, analyse, raisonnement scientifique", "Resend ({{seconds}}s)": "Renvoyer ({{seconds}}s)", + "Reserved for viewing complete channel keys after secure verification.": "Réservé à l'affichage des clés complètes des canaux après une vérification sécurisée.", "Reset": "Réinitialiser", "Reset 2FA": "Réinitialiser la 2FA", "Reset all model prices?": "Réinitialiser tous les prix des modèles ?", @@ -3637,7 +3638,6 @@ "Resolve Conflicts": "Résoudre les conflits", "Resource Configuration": "Configuration des ressources", "Resources": "Ressources", - "Reserved for viewing complete channel keys after secure verification.": "Réservé à l'affichage des clés complètes des canaux après une vérification sécurisée.", "Response": "Réponse", "Response Time": "Temps de réponse", "Response time: {{duration}}": "Temps de réponse : {{duration}}", @@ -4207,7 +4207,7 @@ "Test all {{count}} models": "Tester les {{count}} modèles", "Test All Channels": "Tester tous les canaux", "Test Channel Connection": "Tester la connexion du canal", - "Test channels, update balances, and toggle availability.": "Tester les canaux, mettre à jour les soldes et basculer la disponibilité.", + "Test channels, refresh balances, and enable/disable individual, batch, or tagged channels.": "Tester les canaux, actualiser les soldes et activer/désactiver des canaux individuellement, par lot ou par tag.", "Test Connection": "Tester la connexion", "Test connectivity for:": "Tester la connectivité pour :", "Test failed": "Échec du test", @@ -4729,8 +4729,8 @@ "Vidu": "Vidu", "View": "Afficher", "View all currently available models": "Voir tous les modèles actuellement disponibles", - "View channel secrets": "Voir les secrets des canaux", "View channel lists and details without secrets.": "Afficher les listes et détails des canaux sans secrets.", + "View channel secrets": "Voir les secrets des canaux", "View detailed information about this user including balance, usage statistics, and invitation details.": "Afficher des informations détaillées sur cet utilisateur, y compris le solde, les statistiques d'utilisation et les détails d'invitation.", "View details": "Voir les détails", "View document": "Afficher le document", @@ -4872,12 +4872,12 @@ "You are running the latest version ({{version}}).": "Vous utilisez la dernière version ({{version}}).", "You can close this tab once the binding completes or a success message appears in the original window.": "Vous pouvez fermer cet onglet une fois la liaison terminée ou qu'un message de succès apparaît dans la fenêtre d'origine.", "You can manually add them in \"Custom Model Names\", click \"Fill\" and then submit, or use the operations below to handle automatically.": "Vous pouvez les ajouter manuellement dans \"Noms de modèles personnalisés\", cliquer sur \"Remplir\" puis soumettre, ou utiliser les opérations ci-dessous pour les gérer automatiquement.", - "You can still edit non-sensitive operations fields such as models, groups, priority, weight, and status.": "Vous pouvez toujours modifier les champs opérationnels non sensibles, comme les modèles, les groupes, la priorité, le poids et le statut.", "You can only check in once per day": "Vous ne pouvez vous connecter qu'une fois par jour", + "You can still edit non-sensitive operations fields such as models, groups, priority, and weight.": "Vous pouvez toujours modifier les champs opérationnels non sensibles, comme les modèles, les groupes, la priorité et le poids.", "You commit not to use this system to implement, assist with, or indirectly implement acts that violate applicable laws and regulations, regulatory requirements, platform rules, public interests, or the lawful rights and interests of third parties.": "Vous vous engagez à ne pas utiliser ce système pour mettre en œuvre, faciliter ou indirectement réaliser des actes violant les lois et règlements applicables, les exigences réglementaires, les règles des plateformes, l’intérêt public ou les droits et intérêts légitimes de tiers.", "You commit to using upstream APIs, accounts, keys, quotas, and service capabilities only within the scope of lawful authorization obtained from upstream service providers, model service providers, or relevant rights holders, and will not conduct unauthorized resale, trafficking, distribution, or other non-compliant commercialization.": "Vous vous engagez à utiliser les API, comptes, clés, quotas et capacités de service en amont uniquement dans le cadre d’une autorisation légale obtenue auprès des fournisseurs de services en amont, fournisseurs de modèles ou ayants droit concernés, et à ne pas effectuer de revente, trafic, distribution ou autre commercialisation non conforme sans autorisation.", - "You don't have necessary permission": "Vous n'avez pas la permission nécessaire", "You do not have permission to edit sensitive channel settings.": "Vous n’avez pas l’autorisation de modifier les paramètres sensibles des canaux.", + "You don't have necessary permission": "Vous n'avez pas la permission nécessaire", "You have legally obtained authorization for the connected model APIs, accounts, keys, and quotas.": "Vous avez légalement obtenu l’autorisation pour les API de modèles, comptes, clés et quotas connectés.", "You have unsaved changes": "Vous avez des modifications non enregistrées", "You have unsaved changes. Are you sure you want to leave?": "Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir quitter ?", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index 270d0405a25f..3b901ca1780a 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -220,9 +220,9 @@ "Admin access required": "管理者アクセスが必要です", "Admin area": "管理者エリア", "Admin Channel Permissions": "管理者のチャネル権限", - "Admin Permissions": "管理者権限", "Admin notes (only visible to admins)": "管理者メモ (管理者のみに表示)", "Admin Only": "管理者のみ", + "Admin Permissions": "管理者権限", "Administer user accounts and roles.": "ユーザーアカウントとロールを管理します。", "Administrator account": "管理者アカウント", "Administrator username": "管理者ユーザー名", @@ -710,8 +710,8 @@ "Channel ID": "チャネルID", "Channel ID is required": "チャネル ID が必要です", "Channel key": "チャネルキー", - "Channel Management": "チャネル管理", "Channel key unlocked": "チャネルキーが解除されました", + "Channel Management": "チャネル管理", "Channel models": "チャネルモデル", "Channel name is required": "チャネル名が必要です", "Channel test completed": "チャネルテストが完了しました", @@ -1455,7 +1455,7 @@ "Edit model": "モデルを編集", "Edit Model": "モデルを編集", "Edit model pricing": "モデル料金を編集", - "Edit non-sensitive routing fields such as models and groups.": "モデルやグループなどの非機密ルーティング項目を編集します。", + "Edit non-sensitive settings such as models, groups, and routing rules.": "モデル、グループ、ルーティングルールなどの非機密設定を編集します。", "Edit OAuth Provider": "OAuthプロバイダーを編集", "Edit payment method": "決済方法を編集", "Edit Prefill Group": "プリフィルグループを編集", @@ -2797,6 +2797,7 @@ "No payment methods configured. Click \"Add method\" or use templates to get started.": "支払い方法が設定されていません。「メソッドを追加」をクリックするか、テンプレートを使用して開始してください。", "No payment methods match your search": "検索に一致する支払い方法がありません", "No performance data available": "利用可能なパフォーマンスデータはありません", + "No permission to perform this action": "この操作を実行する権限がありません", "No plans available": "利用可能なプランがありません", "No preference": "設定なし", "No prefill groups yet": "まだ事前入力グループはありません", @@ -3599,11 +3600,11 @@ "Required events:": "必須イベント:", "Required provider, authentication, model, and group settings": "必須のプロバイダー、認証、モデル、グループ設定", "Required to expose MjProxy-style image generation to end users.": "エンドユーザーに MjProxy スタイルの画像生成を公開するために必要です。", - "No permission to perform this action": "この操作を実行する権限がありません", "Rerank": "再ランク付け", "Reroll": "やり直し", "Research, analysis, scientific reasoning": "リサーチ・分析・科学的推論", "Resend ({{seconds}}s)": "再送信 ({{seconds}}秒)", + "Reserved for viewing complete channel keys after secure verification.": "安全な検証後に完全なチャンネルキーを表示するために予約されています。", "Reset": "リセット", "Reset 2FA": "2FAをリセット", "Reset all model prices?": "すべてのモデル価格をリセットしますか?", @@ -3637,7 +3638,6 @@ "Resolve Conflicts": "競合を解決", "Resource Configuration": "リソース設定", "Resources": "リソース", - "Reserved for viewing complete channel keys after secure verification.": "安全な検証後に完全なチャンネルキーを表示するために予約されています。", "Response": "レスポンス", "Response Time": "応答時間", "Response time: {{duration}}": "応答時間: {{duration}}", @@ -4207,7 +4207,7 @@ "Test all {{count}} models": "{{count}} 件すべてのモデルをテスト", "Test All Channels": "すべてのチャネルをテスト", "Test Channel Connection": "チャネル接続をテスト", - "Test channels, update balances, and toggle availability.": "チャネルのテスト、残高更新、有効状態の切り替えを行います。", + "Test channels, refresh balances, and enable/disable individual, batch, or tagged channels.": "チャネルのテスト、残高の更新、個別・一括・タグ指定でのチャネル有効化/無効化を行います。", "Test Connection": "接続をテスト", "Test connectivity for:": "接続性をテスト:", "Test failed": "テストに失敗しました", @@ -4729,8 +4729,8 @@ "Vidu": "Vidu", "View": "表示", "View all currently available models": "現在利用可能なすべてのモデルを表示", - "View channel secrets": "チャンネルシークレットを表示", "View channel lists and details without secrets.": "シークレットを含まないチャネル一覧と詳細を表示します。", + "View channel secrets": "チャンネルシークレットを表示", "View detailed information about this user including balance, usage statistics, and invitation details.": "残高、使用統計、招待の詳細など、このユーザーに関する詳細情報を表示します。", "View details": "詳細を表示", "View document": "ドキュメントを表示", @@ -4872,12 +4872,12 @@ "You are running the latest version ({{version}}).": "最新バージョン ({{version}}) を使用中です。", "You can close this tab once the binding completes or a success message appears in the original window.": "バインディングが完了するか、元のウィンドウに成功メッセージが表示されたら、このタブを閉じることができます。", "You can manually add them in \"Custom Model Names\", click \"Fill\" and then submit, or use the operations below to handle automatically.": "\"カスタムモデル名\"で手動で追加し、\"入力\"をクリックしてから送信するか、以下の操作を使用して自動的に処理できます。", - "You can still edit non-sensitive operations fields such as models, groups, priority, weight, and status.": "モデル、グループ、優先度、重み、ステータスなどの非機密の運用項目は引き続き編集できます。", "You can only check in once per day": "チェックインできるのは1日1回のみです", + "You can still edit non-sensitive operations fields such as models, groups, priority, and weight.": "モデル、グループ、優先度、重みなどの非機密の運用項目は引き続き編集できます。", "You commit not to use this system to implement, assist with, or indirectly implement acts that violate applicable laws and regulations, regulatory requirements, platform rules, public interests, or the lawful rights and interests of third parties.": "適用される法令、規制要件、プラットフォーム規則、公共の利益、または第三者の正当な権利利益に違反する行為を、このシステムを用いて実施、支援、または間接的に実施しないことを約束します。", "You commit to using upstream APIs, accounts, keys, quotas, and service capabilities only within the scope of lawful authorization obtained from upstream service providers, model service providers, or relevant rights holders, and will not conduct unauthorized resale, trafficking, distribution, or other non-compliant commercialization.": "上流 API、アカウント、キー、クォータ、サービス機能を、上流サービス提供者、モデルサービス提供者、または関連する権利者から取得した合法的な許可の範囲内でのみ使用し、無許可の再販売、転売、配布、その他の不適切な商業利用を行わないことを約束します。", - "You don't have necessary permission": "必要な権限がありません", "You do not have permission to edit sensitive channel settings.": "機密チャネル設定を編集する権限がありません。", + "You don't have necessary permission": "必要な権限がありません", "You have legally obtained authorization for the connected model APIs, accounts, keys, and quotas.": "接続されたモデル API、アカウント、キー、クォータについて合法的な許可を取得しています。", "You have unsaved changes": "未保存の変更があります", "You have unsaved changes. Are you sure you want to leave?": "未保存の変更があります。離れてもよろしいですか?", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index d8d9479a0646..d2c40a74dbcc 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -220,9 +220,9 @@ "Admin access required": "Требуется доступ администратора", "Admin area": "Область администратора", "Admin Channel Permissions": "Права администратора для каналов", - "Admin Permissions": "Права администратора", "Admin notes (only visible to admins)": "Заметки администратора (видны только администраторам)", "Admin Only": "Только для администраторов", + "Admin Permissions": "Права администратора", "Administer user accounts and roles.": "Управление учетными записями пользователей и ролями.", "Administrator account": "Учетная запись администратора", "Administrator username": "Имя пользователя администратора", @@ -710,8 +710,8 @@ "Channel ID": "ID канала", "Channel ID is required": "Требуется ID канала", "Channel key": "Ключ канала", - "Channel Management": "Управление каналами", "Channel key unlocked": "Ключ канала разблокирован", + "Channel Management": "Управление каналами", "Channel models": "Модели каналов", "Channel name is required": "Имя канала обязательно", "Channel test completed": "Тест канала завершён", @@ -1455,7 +1455,7 @@ "Edit model": "Редактировать модель", "Edit Model": "Редактировать модель", "Edit model pricing": "Изменить тариф модели", - "Edit non-sensitive routing fields such as models and groups.": "Изменение нечувствительных полей маршрутизации, таких как модели и группы.", + "Edit non-sensitive settings such as models, groups, and routing rules.": "Изменение нечувствительных настроек, таких как модели, группы и правила маршрутизации.", "Edit OAuth Provider": "Редактировать поставщика OAuth", "Edit payment method": "Редактировать способ оплаты", "Edit Prefill Group": "Редактировать группу предзаполнения", @@ -2797,6 +2797,7 @@ "No payment methods configured. Click \"Add method\" or use templates to get started.": "Способы оплаты не настроены. Нажмите \"Добавить способ\" или используйте шаблоны, чтобы начать.", "No payment methods match your search": "Нет способов оплаты, соответствующих вашему поиску", "No performance data available": "Нет доступных данных о производительности", + "No permission to perform this action": "Нет прав для выполнения этого действия", "No plans available": "Нет доступных планов", "No preference": "Без предпочтений", "No prefill groups yet": "Пока нет групп предзаполнения", @@ -3599,11 +3600,11 @@ "Required events:": "Обязательные события:", "Required provider, authentication, model, and group settings": "Обязательные настройки провайдера, аутентификации, моделей и групп", "Required to expose MjProxy-style image generation to end users.": "Необходимо для предоставления генерации изображений в стиле MjProxy конечным пользователям.", - "No permission to perform this action": "Нет прав для выполнения этого действия", "Rerank": "Переранжировать", "Reroll": "Повторить", "Research, analysis, scientific reasoning": "Исследования, анализ, научные рассуждения", "Resend ({{seconds}}s)": "Отправить повторно ({{seconds}}с)", + "Reserved for viewing complete channel keys after secure verification.": "Зарезервировано для просмотра полных ключей каналов после безопасной проверки.", "Reset": "Сброс", "Reset 2FA": "Сбросить 2FA", "Reset all model prices?": "Сбросить все цены моделей?", @@ -3637,7 +3638,6 @@ "Resolve Conflicts": "Разрешить конфликты", "Resource Configuration": "Конфигурация ресурсов", "Resources": "Ресурсы", - "Reserved for viewing complete channel keys after secure verification.": "Зарезервировано для просмотра полных ключей каналов после безопасной проверки.", "Response": "Ответ", "Response Time": "Время ответа", "Response time: {{duration}}": "Время ответа: {{duration}}", @@ -4207,7 +4207,7 @@ "Test all {{count}} models": "Проверить все модели: {{count}}", "Test All Channels": "Проверить все каналы", "Test Channel Connection": "Проверить подключение канала", - "Test channels, update balances, and toggle availability.": "Тестирование каналов, обновление балансов и переключение доступности.", + "Test channels, refresh balances, and enable/disable individual, batch, or tagged channels.": "Тестирование каналов, обновление балансов и включение/отключение отдельных, пакетных или помеченных каналов.", "Test Connection": "Проверить подключение", "Test connectivity for:": "Проверить подключение для:", "Test failed": "Тест не выполнен", @@ -4729,8 +4729,8 @@ "Vidu": "Vidu", "View": "Просмотр", "View all currently available models": "Просмотреть все доступные модели", - "View channel secrets": "Просматривать секреты каналов", "View channel lists and details without secrets.": "Просмотр списков и сведений о каналах без секретов.", + "View channel secrets": "Просматривать секреты каналов", "View detailed information about this user including balance, usage statistics, and invitation details.": "Просмотр подробной информации об этом пользователе, включая баланс, статистику использования и данные приглашения.", "View details": "Просмотреть детали", "View document": "Просмотреть документ", @@ -4872,12 +4872,12 @@ "You are running the latest version ({{version}}).": "Вы используете последнюю версию ({{version}}).", "You can close this tab once the binding completes or a success message appears in the original window.": "Вы можете закрыть эту вкладку, как только привязка завершится или в исходном окне появится сообщение об успехе.", "You can manually add them in \"Custom Model Names\", click \"Fill\" and then submit, or use the operations below to handle automatically.": "Вы можете вручную добавить их в \"Пользовательские имена моделей\", нажать \"Заполнить\", а затем отправить, или использовать операции ниже для автоматической обработки.", - "You can still edit non-sensitive operations fields such as models, groups, priority, weight, and status.": "Вы по-прежнему можете изменять нечувствительные операционные поля, такие как модели, группы, приоритет, вес и статус.", "You can only check in once per day": "Вы можете заселяться только один раз в день", + "You can still edit non-sensitive operations fields such as models, groups, priority, and weight.": "Вы по-прежнему можете изменять нечувствительные операционные поля, такие как модели, группы, приоритет и вес.", "You commit not to use this system to implement, assist with, or indirectly implement acts that violate applicable laws and regulations, regulatory requirements, platform rules, public interests, or the lawful rights and interests of third parties.": "Вы обязуетесь не использовать эту систему для совершения, содействия или косвенного совершения действий, нарушающих применимые законы и нормы, регуляторные требования, правила платформ, общественные интересы либо законные права и интересы третьих лиц.", "You commit to using upstream APIs, accounts, keys, quotas, and service capabilities only within the scope of lawful authorization obtained from upstream service providers, model service providers, or relevant rights holders, and will not conduct unauthorized resale, trafficking, distribution, or other non-compliant commercialization.": "Вы обязуетесь использовать вышестоящие API, аккаунты, ключи, квоты и сервисные возможности только в пределах законного разрешения, полученного от вышестоящих поставщиков услуг, поставщиков моделей или соответствующих правообладателей, и не осуществлять несанкционированную перепродажу, оборот, распространение или иную несоответствующую коммерциализацию.", - "You don't have necessary permission": "У вас нет необходимых разрешений", "You do not have permission to edit sensitive channel settings.": "У вас нет права изменять чувствительные настройки каналов.", + "You don't have necessary permission": "У вас нет необходимых разрешений", "You have legally obtained authorization for the connected model APIs, accounts, keys, and quotas.": "Вы законно получили разрешение на подключенные API моделей, аккаунты, ключи и квоты.", "You have unsaved changes": "У вас есть несохранённые изменения", "You have unsaved changes. Are you sure you want to leave?": "У вас есть несохранённые изменения. Вы уверены, что хотите уйти?", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index 2583e94ef2af..7d05d31911c1 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -220,9 +220,9 @@ "Admin access required": "Yêu cầu quyền truy cập Admin", "Admin area": "Khu vực quản trị", "Admin Channel Permissions": "Quyền kênh của quản trị viên", - "Admin Permissions": "Quyền quản trị viên", "Admin notes (only visible to admins)": "Ghi chú của quản trị viên (chỉ hiển thị với quản trị viên)", "Admin Only": "Chỉ dành cho quản trị viên", + "Admin Permissions": "Quyền quản trị viên", "Administer user accounts and roles.": "Quản lý tài khoản người dùng và vai trò.", "Administrator account": "Tài khoản quản trị viên", "Administrator username": "Tên người dùng quản trị viên", @@ -710,8 +710,8 @@ "Channel ID": "Mã kênh", "Channel ID is required": "Cần có ID kênh", "Channel key": "Khóa kênh", - "Channel Management": "Quản lý kênh", "Channel key unlocked": "Khóa kênh đã được mở khóa", + "Channel Management": "Quản lý kênh", "Channel models": "Mô hình kênh", "Channel name is required": "Tên kênh là bắt buộc", "Channel test completed": "Kiểm tra kênh hoàn tất", @@ -1455,7 +1455,7 @@ "Edit model": "Chỉnh sửa mô hình", "Edit Model": "Chỉnh sửa Mô hình", "Edit model pricing": "Chỉnh sửa giá mô hình", - "Edit non-sensitive routing fields such as models and groups.": "Chỉnh sửa các trường định tuyến không nhạy cảm như mô hình và nhóm.", + "Edit non-sensitive settings such as models, groups, and routing rules.": "Chỉnh sửa các thiết lập không nhạy cảm như mô hình, nhóm và quy tắc định tuyến.", "Edit OAuth Provider": "Chỉnh Sửa Nhà Cung Cấp OAuth", "Edit payment method": "Sửa phương thức thanh toán", "Edit Prefill Group": "Chỉnh sửa Nhóm Điền sẵn", @@ -2797,6 +2797,7 @@ "No payment methods configured. Click \"Add method\" or use templates to get started.": "Chưa cấu hình phương thức thanh toán. Nhấp vào \"Thêm phương thức\" hoặc sử dụng mẫu để bắt đầu.", "No payment methods match your search": "Không có phương thức thanh toán nào khớp với tìm kiếm của bạn", "No performance data available": "Không có dữ liệu hiệu năng", + "No permission to perform this action": "Không có quyền thực hiện thao tác này", "No plans available": "Không có gói nào khả dụng", "No preference": "Không có ưu tiên", "No prefill groups yet": "Chưa có nhóm điền sẵn nào", @@ -3599,11 +3600,11 @@ "Required events:": "Sự kiện bắt buộc:", "Required provider, authentication, model, and group settings": "Thiết lập bắt buộc về nhà cung cấp, xác thực, mô hình và nhóm", "Required to expose MjProxy-style image generation to end users.": "Cần thiết để cung cấp tính năng tạo hình ảnh kiểu MjProxy cho người dùng cuối.", - "No permission to perform this action": "Không có quyền thực hiện thao tác này", "Rerank": "Re-rank", "Reroll": "Quay lại", "Research, analysis, scientific reasoning": "Nghiên cứu, phân tích, suy luận khoa học", "Resend ({{seconds}}s)": "Gửi lại ({{seconds}}s)", + "Reserved for viewing complete channel keys after secure verification.": "Dành riêng để xem khóa kênh đầy đủ sau khi xác minh bảo mật.", "Reset": "Đặt lại", "Reset 2FA": "Đặt lại 2FA", "Reset all model prices?": "Đặt lại tất cả giá mô hình?", @@ -3637,7 +3638,6 @@ "Resolve Conflicts": "Giải quyết Xung đột", "Resource Configuration": "Cấu hình tài nguyên", "Resources": "Tài nguyên", - "Reserved for viewing complete channel keys after secure verification.": "Dành riêng để xem khóa kênh đầy đủ sau khi xác minh bảo mật.", "Response": "Phản hồi", "Response Time": "Thời gian phản hồi", "Response time: {{duration}}": "Thời gian phản hồi: {{duration}}", @@ -4207,7 +4207,7 @@ "Test all {{count}} models": "Kiểm thử tất cả {{count}} mô hình", "Test All Channels": "Kiểm tra tất cả các kênh", "Test Channel Connection": "Kiểm tra kết nối kênh", - "Test channels, update balances, and toggle availability.": "Kiểm thử kênh, cập nhật số dư và bật tắt trạng thái khả dụng.", + "Test channels, refresh balances, and enable/disable individual, batch, or tagged channels.": "Kiểm thử kênh, làm mới số dư và bật/tắt từng kênh, hàng loạt hoặc theo thẻ.", "Test Connection": "Kiểm tra kết nối", "Test connectivity for:": "Kiểm tra kết nối cho:", "Test failed": "Kiểm tra thất bại", @@ -4729,8 +4729,8 @@ "Vidu": "Vidu", "View": "Xem", "View all currently available models": "Xem tất cả mô hình hiện có", - "View channel secrets": "Xem bí mật kênh", "View channel lists and details without secrets.": "Xem danh sách và chi tiết kênh không chứa bí mật.", + "View channel secrets": "Xem bí mật kênh", "View detailed information about this user including balance, usage statistics, and invitation details.": "Xem thông tin chi tiết về người dùng này bao gồm số dư, thống kê sử dụng và chi tiết lời mời.", "View details": "Xem chi tiết", "View document": "Xem tài liệu", @@ -4872,12 +4872,12 @@ "You are running the latest version ({{version}}).": "Bạn đang sử dụng phiên bản mới nhất ({{version}}).", "You can close this tab once the binding completes or a success message appears in the original window.": "Bạn có thể đóng tab này sau khi quá trình liên kết hoàn tất hoặc thông báo thành công xuất hiện trong cửa sổ gốc.", "You can manually add them in \"Custom Model Names\", click \"Fill\" and then submit, or use the operations below to handle automatically.": "Bạn có thể thêm chúng theo cách thủ công trong \"Tên mô hình tùy chỉnh\", nhấp vào \"Điền\" rồi gửi, hoặc sử dụng các thao tác bên dưới để xử lý tự động.", - "You can still edit non-sensitive operations fields such as models, groups, priority, weight, and status.": "Bạn vẫn có thể chỉnh sửa các trường vận hành không nhạy cảm như mô hình, nhóm, độ ưu tiên, trọng số và trạng thái.", "You can only check in once per day": "Bạn chỉ có thể điểm danh một lần mỗi ngày", + "You can still edit non-sensitive operations fields such as models, groups, priority, and weight.": "Bạn vẫn có thể chỉnh sửa các trường vận hành không nhạy cảm như mô hình, nhóm, độ ưu tiên và trọng số.", "You commit not to use this system to implement, assist with, or indirectly implement acts that violate applicable laws and regulations, regulatory requirements, platform rules, public interests, or the lawful rights and interests of third parties.": "Bạn cam kết không sử dụng hệ thống này để thực hiện, hỗ trợ hoặc gián tiếp thực hiện các hành vi vi phạm luật và quy định hiện hành, yêu cầu quản lý, quy tắc nền tảng, lợi ích công cộng hoặc quyền và lợi ích hợp pháp của bên thứ ba.", "You commit to using upstream APIs, accounts, keys, quotas, and service capabilities only within the scope of lawful authorization obtained from upstream service providers, model service providers, or relevant rights holders, and will not conduct unauthorized resale, trafficking, distribution, or other non-compliant commercialization.": "Bạn cam kết chỉ sử dụng API upstream, tài khoản, khóa, hạn mức và năng lực dịch vụ trong phạm vi ủy quyền hợp pháp nhận được từ nhà cung cấp dịch vụ upstream, nhà cung cấp mô hình hoặc chủ thể quyền liên quan, và sẽ không thực hiện bán lại, giao dịch, phân phối trái phép hoặc thương mại hóa không tuân thủ khác.", - "You don't have necessary permission": "Bạn không có quyền cần thiết", "You do not have permission to edit sensitive channel settings.": "Bạn không có quyền chỉnh sửa cài đặt kênh nhạy cảm.", + "You don't have necessary permission": "Bạn không có quyền cần thiết", "You have legally obtained authorization for the connected model APIs, accounts, keys, and quotas.": "Bạn đã nhận được ủy quyền hợp pháp cho API mô hình, tài khoản, khóa và hạn mức được kết nối.", "You have unsaved changes": "Bạn có thay đổi chưa được lưu", "You have unsaved changes. Are you sure you want to leave?": "Bạn có thay đổi chưa được lưu. Bạn có chắc chắn muốn rời đi không?", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index ff03129a824c..7195d384148e 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -220,9 +220,9 @@ "Admin access required": "需要管理员权限", "Admin area": "管理员区域", "Admin Channel Permissions": "管理员渠道权限", - "Admin Permissions": "管理员权限", "Admin notes (only visible to admins)": "管理员备注(仅管理员可见)", "Admin Only": "仅限管理员", + "Admin Permissions": "管理员权限", "Administer user accounts and roles.": "管理用户账户和角色。", "Administrator account": "管理员账户", "Administrator username": "管理员用户名", @@ -710,8 +710,8 @@ "Channel ID": "渠道 ID", "Channel ID is required": "缺少渠道 ID", "Channel key": "渠道密钥", - "Channel Management": "渠道管理", "Channel key unlocked": "渠道密钥已解锁", + "Channel Management": "渠道管理", "Channel models": "渠道模型", "Channel name is required": "渠道名称是必填的", "Channel test completed": "渠道测试完成", @@ -1455,7 +1455,7 @@ "Edit model": "编辑模型", "Edit Model": "编辑模型", "Edit model pricing": "编辑模型定价", - "Edit non-sensitive routing fields such as models and groups.": "编辑模型和分组等非敏感路由字段。", + "Edit non-sensitive settings such as models, groups, and routing rules.": "编辑模型、分组和路由规则等非敏感设置。", "Edit OAuth Provider": "编辑 OAuth 提供商", "Edit payment method": "编辑支付方式", "Edit Prefill Group": "编辑预填充组", @@ -2797,6 +2797,7 @@ "No payment methods configured. Click \"Add method\" or use templates to get started.": "未配置支付方式。点击\"添加方式\"或使用模板开始。", "No payment methods match your search": "没有匹配的支付方式", "No performance data available": "暂无性能数据", + "No permission to perform this action": "无权进行此操作", "No plans available": "暂无可购买套餐", "No preference": "无偏好", "No prefill groups yet": "暂无预填充分组", @@ -3599,11 +3600,11 @@ "Required events:": "必需事件:", "Required provider, authentication, model, and group settings": "必填的供应商、鉴权、模型和分组设置", "Required to expose MjProxy-style image generation to end users.": "需要向终端用户开放 MjProxy 风格的图像生成。", - "No permission to perform this action": "无权进行此操作", "Rerank": "重新排序", "Reroll": "重绘", "Research, analysis, scientific reasoning": "研究、分析与科学推理", "Resend ({{seconds}}s)": "重新发送 ({{seconds}}s)", + "Reserved for viewing complete channel keys after secure verification.": "预留用于在安全验证后查看完整渠道密钥。", "Reset": "重置", "Reset 2FA": "重置 2FA", "Reset all model prices?": "重置所有模型价格吗?", @@ -3637,7 +3638,6 @@ "Resolve Conflicts": "解决冲突", "Resource Configuration": "资源配置", "Resources": "资源", - "Reserved for viewing complete channel keys after secure verification.": "预留用于在安全验证后查看完整渠道密钥。", "Response": "响应", "Response Time": "响应时间", "Response time: {{duration}}": "响应时间:{{duration}}", @@ -4207,7 +4207,7 @@ "Test all {{count}} models": "测试全部 {{count}} 个模型", "Test All Channels": "测试所有渠道", "Test Channel Connection": "测试渠道连接", - "Test channels, update balances, and toggle availability.": "测试渠道、更新余额并切换可用状态。", + "Test channels, refresh balances, and enable/disable individual, batch, or tagged channels.": "测试渠道、刷新余额,并启用/禁用单个、批量或带标签的渠道。", "Test Connection": "测试连接", "Test connectivity for:": "测试连接性:", "Test failed": "测试失败", @@ -4729,8 +4729,8 @@ "Vidu": "Vidu", "View": "查看", "View all currently available models": "查看当前可用的所有模型", - "View channel secrets": "查看渠道密钥", "View channel lists and details without secrets.": "查看不含密钥的渠道列表和详情。", + "View channel secrets": "查看渠道密钥", "View detailed information about this user including balance, usage statistics, and invitation details.": "查看此用户的详细信息,包括余额、使用统计和邀请详情。", "View details": "查看详情", "View document": "查看文档", @@ -4872,12 +4872,12 @@ "You are running the latest version ({{version}}).": "您正在运行最新版本 ({{version}})。", "You can close this tab once the binding completes or a success message appears in the original window.": "绑定完成后或原窗口出现成功消息后,您可以关闭此标签页。", "You can manually add them in \"Custom Model Names\", click \"Fill\" and then submit, or use the operations below to handle automatically.": "你可以在\"自定义模型名称\"处手动添加它们,然后点击\"填入\"后再提交,或者直接使用下方操作自动处理。", - "You can still edit non-sensitive operations fields such as models, groups, priority, weight, and status.": "你仍可编辑模型、分组、优先级、权重和状态等非敏感运维字段。", "You can only check in once per day": "每日仅可签到一次,请勿重复签到", + "You can still edit non-sensitive operations fields such as models, groups, priority, and weight.": "你仍可编辑模型、分组、优先级和权重等非敏感运维字段。", "You commit not to use this system to implement, assist with, or indirectly implement acts that violate applicable laws and regulations, regulatory requirements, platform rules, public interests, or the lawful rights and interests of third parties.": "你承诺不会使用本系统实施、协助实施或间接实施违反适用法律法规、监管要求、平台规则、公共利益或第三方合法权益的行为。", "You commit to using upstream APIs, accounts, keys, quotas, and service capabilities only within the scope of lawful authorization obtained from upstream service providers, model service providers, or relevant rights holders, and will not conduct unauthorized resale, trafficking, distribution, or other non-compliant commercialization.": "你承诺仅在从上游服务提供商、模型服务提供商或相关权利人处获得合法授权的范围内使用上游 API、账户、密钥、额度和服务能力,并不会进行未经授权的转售、倒卖、分发或其他不合规商业化行为。", - "You don't have necessary permission": "您没有必要的权限", "You do not have permission to edit sensitive channel settings.": "你没有权限编辑敏感渠道设置。", + "You don't have necessary permission": "您没有必要的权限", "You have legally obtained authorization for the connected model APIs, accounts, keys, and quotas.": "你已合法取得所连接模型 API、账户、密钥和额度的授权。", "You have unsaved changes": "您有未保存的更改", "You have unsaved changes. Are you sure you want to leave?": "您有未保存的更改。确定要离开吗?", diff --git a/web/default/src/lib/admin-permissions.ts b/web/default/src/lib/admin-permissions.ts index 387c9f316557..424a80d42579 100644 --- a/web/default/src/lib/admin-permissions.ts +++ b/web/default/src/lib/admin-permissions.ts @@ -16,45 +16,42 @@ export const ADMIN_PERMISSION_ACTIONS = { SECRET_VIEW: 'secret_view', } as const -export const ADMIN_PERMISSION_CATALOG = [ - { - resource: ADMIN_PERMISSION_RESOURCES.CHANNEL, - labelKey: 'Channel Management', - actions: [ - { - value: ADMIN_PERMISSION_ACTIONS.READ, - labelKey: 'Read channels', - descriptionKey: 'View channel lists and details without secrets.', - defaultAdmin: true, - }, - { - value: ADMIN_PERMISSION_ACTIONS.OPERATE, - labelKey: 'Operate channels', - descriptionKey: 'Test channels, update balances, and toggle availability.', - defaultAdmin: true, - }, - { - value: ADMIN_PERMISSION_ACTIONS.WRITE, - labelKey: 'Edit channel routing', - descriptionKey: 'Edit non-sensitive routing fields such as models and groups.', - defaultAdmin: true, - }, - { - value: ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE, - labelKey: 'Edit sensitive channel settings', - descriptionKey: 'Create channels or edit keys, base URLs, and overrides.', - defaultAdmin: false, - }, - { - value: ADMIN_PERMISSION_ACTIONS.SECRET_VIEW, - labelKey: 'View channel secrets', - descriptionKey: - 'Reserved for viewing complete channel keys after secure verification.', - defaultAdmin: false, - }, - ], - }, -] as const +// The role whose baseline grants are used as defaults in the permission editor. +export const ADMIN_ROLE_KEY = 'admin' + +// The permission catalog (resources, actions, labels and role baselines) is owned +// by the backend authz package and fetched from GET /api/authz/catalog. It is +// intentionally NOT duplicated here so the schema stays defined in one place. +// These types mirror the backend JSON shape. +export interface PermissionActionDef { + action: string + label_key: string + description_key: string +} + +export interface PermissionResourceDef { + resource: string + label_key: string + actions: PermissionActionDef[] +} + +export interface PermissionRoleDef { + key: string + name: string + built_in: boolean + superuser: boolean + grants: AdminPermissionMatrix +} + +export interface PermissionCatalog { + resources: PermissionResourceDef[] + roles: PermissionRoleDef[] +} + +export const EMPTY_PERMISSION_CATALOG: PermissionCatalog = { + resources: [], + roles: [], +} export function hasPermission( user: AuthUser | null | undefined, @@ -66,15 +63,29 @@ export function hasPermission( return user.permissions?.admin_permissions?.[resource]?.[action] === true } +// roleGrants returns the baseline grant matrix for the given role key. +export function roleGrants( + catalog: PermissionCatalog, + roleKey: string +): AdminPermissionMatrix { + return catalog.roles.find((role) => role.key === roleKey)?.grants ?? {} +} + +// normalizeAdminPermissions produces a full matrix for the catalog, filling any +// value missing from `value` with the admin role's baseline grant. export function normalizeAdminPermissions( - value: AdminPermissionMatrix | null | undefined + value: AdminPermissionMatrix | null | undefined, + catalog: PermissionCatalog ): AdminPermissionMatrix { + const baseline = roleGrants(catalog, ADMIN_ROLE_KEY) const normalized: AdminPermissionMatrix = {} - for (const resource of ADMIN_PERMISSION_CATALOG) { + for (const resource of catalog.resources) { const actions: Record = {} for (const action of resource.actions) { - actions[action.value] = - value?.[resource.resource]?.[action.value] ?? action.defaultAdmin + actions[action.action] = + value?.[resource.resource]?.[action.action] ?? + baseline[resource.resource]?.[action.action] ?? + false } normalized[resource.resource] = actions } From a23b7e426cf97d0bca0efc1e4e88a7ee6db5276b Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 27 Jun 2026 14:33:24 +0800 Subject: [PATCH 5/6] Split channel authz field policy --- controller/channel.go | 104 ------------------------------ controller/channel_authz.go | 107 +++++++++++++++++++++++++++++++ controller/channel_authz_test.go | 2 +- 3 files changed, 108 insertions(+), 105 deletions(-) create mode 100644 controller/channel_authz.go diff --git a/controller/channel.go b/controller/channel.go index 0ea3f22a6a79..903c50b4339c 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -1144,110 +1144,6 @@ func isManageableChannelStatus(status int) bool { return status == common.ChannelStatusEnabled || status == common.ChannelStatusManuallyDisabled } -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 - } - 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": {}, -} - -// 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": {}, - "created_time": {}, - "test_time": {}, - "response_time": {}, - "balance": {}, - "balance_updated_time": {}, - "models": {}, - "group": {}, - "used_quota": {}, - "model_mapping": {}, - "status_code_mapping": {}, - "priority": {}, - "auto_ban": {}, - "other_info": {}, - "tag": {}, - "remark": {}, - "channel_info": {}, - "multi_key_mode": {}, -} - // equalStringPtr 比较两个 *string 是否相等(均为 nil 视为相等)。 func equalStringPtr(a, b *string) bool { if a == nil && b == nil { diff --git a/controller/channel_authz.go b/controller/channel_authz.go new file mode 100644 index 000000000000..6d6139bb1cb1 --- /dev/null +++ b/controller/channel_authz.go @@ -0,0 +1,107 @@ +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 + } + 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": {}, +} + +// 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": {}, + "created_time": {}, + "test_time": {}, + "response_time": {}, + "balance": {}, + "balance_updated_time": {}, + "models": {}, + "group": {}, + "used_quota": {}, + "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 index 35c5ce114975..988ce3649d19 100644 --- a/controller/channel_authz_test.go +++ b/controller/channel_authz_test.go @@ -150,6 +150,6 @@ func TestChannelFieldsAreClassified(t *testing.T) { for _, name := range collect(reflect.TypeOf(PatchChannel{})) { assert.Truef(t, classified(name), - "channel field %q is not classified; add it to channelSensitiveFields, channelNonSensitiveFields, or channelOperationalFields in channel.go", name) + "channel field %q is not classified; add it to channelSensitiveFields, channelNonSensitiveFields, or channelOperationalFields in channel_authz.go", name) } } From 468923003b798e91fcbc056749b603f761350f57 Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 27 Jun 2026 16:50:26 +0800 Subject: [PATCH 6/6] Address channel authz review findings --- controller/channel.go | 1 + controller/channel_authz.go | 71 +++++++++++++------ controller/channel_authz_test.go | 53 +++++++++++++- service/authz/enforcer.go | 6 +- service/authz/override.go | 2 +- service/authz/resolver.go | 4 +- .../components/data-table-bulk-actions.tsx | 14 +++- 7 files changed, 119 insertions(+), 32 deletions(-) diff --git a/controller/channel.go b/controller/channel.go index 903c50b4339c..a2b5687ab319 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -932,6 +932,7 @@ func UpdateChannel(c *gin.Context) { common.ApiErrorI18n(c, i18n.MsgInvalidParams) return } + clearChannelReadOnlyFields(&channel, requestData) // 使用统一的校验函数 if err := validateChannel(&channel.Channel, false); err != nil { diff --git a/controller/channel_authz.go b/controller/channel_authz.go index 6d6139bb1cb1..f85ffef92769 100644 --- a/controller/channel_authz.go +++ b/controller/channel_authz.go @@ -48,6 +48,9 @@ func channelHasSensitiveChanges(channel *PatchChannel, origin *model.Channel, re if _, ok := channelOperationalFields[field]; ok { continue } + if _, ok := channelReadOnlyFields[field]; ok { + continue + } return true } return false @@ -76,6 +79,38 @@ 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 @@ -83,25 +118,19 @@ var channelOperationalFields = map[string]struct{}{ // 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": {}, - "created_time": {}, - "test_time": {}, - "response_time": {}, - "balance": {}, - "balance_updated_time": {}, - "models": {}, - "group": {}, - "used_quota": {}, - "model_mapping": {}, - "status_code_mapping": {}, - "priority": {}, - "auto_ban": {}, - "other_info": {}, - "tag": {}, - "remark": {}, - "channel_info": {}, - "multi_key_mode": {}, + "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 index 988ce3649d19..0a57eac50dd7 100644 --- a/controller/channel_authz_test.go +++ b/controller/channel_authz_test.go @@ -81,6 +81,52 @@ func TestChannelHasSensitiveChanges(t *testing.T) { 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) { @@ -126,7 +172,10 @@ func TestChannelFieldsAreClassified(t *testing.T) { if _, ok := channelNonSensitiveFields[name]; ok { return true } - _, ok := channelOperationalFields[name] + if _, ok := channelOperationalFields[name]; ok { + return true + } + _, ok := channelReadOnlyFields[name] return ok } @@ -150,6 +199,6 @@ func TestChannelFieldsAreClassified(t *testing.T) { for _, name := range collect(reflect.TypeOf(PatchChannel{})) { assert.Truef(t, classified(name), - "channel field %q is not classified; add it to channelSensitiveFields, channelNonSensitiveFields, or channelOperationalFields in channel_authz.go", name) + "channel field %q is not classified; add it to channelSensitiveFields, channelNonSensitiveFields, channelOperationalFields, or channelReadOnlyFields in channel_authz.go", name) } } diff --git a/service/authz/enforcer.go b/service/authz/enforcer.go index d165d15c373a..b64bf762e2c5 100644 --- a/service/authz/enforcer.go +++ b/service/authz/enforcer.go @@ -13,7 +13,7 @@ import ( var ( enforcerMu sync.RWMutex - enforcer *casbin.Enforcer + enforcer *casbin.SyncedEnforcer ) const modelText = ` @@ -44,7 +44,7 @@ func Init(db *gorm.DB) error { if err != nil { return err } - e, err := casbin.NewEnforcer(m, newGormAdapter(db)) + e, err := casbin.NewSyncedEnforcer(m, newGormAdapter(db)) if err != nil { return err } @@ -60,7 +60,7 @@ func Init(db *gorm.DB) error { return seedDefaultPolicies() } -func currentEnforcer() *casbin.Enforcer { +func currentEnforcer() *casbin.SyncedEnforcer { enforcerMu.RLock() defer enforcerMu.RUnlock() return enforcer diff --git a/service/authz/override.go b/service/authz/override.go index 7550252d3232..e2e9987ed160 100644 --- a/service/authz/override.go +++ b/service/authz/override.go @@ -135,7 +135,7 @@ func ExplicitUserOverrides(userID int) PermissionsMap { // userOverridePolicies returns the override entries that differ from the managed // role baseline; entries matching the baseline are omitted. -func userOverridePolicies(e *casbin.Enforcer, resource string, actions map[string]bool) []overridePolicy { +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] diff --git a/service/authz/resolver.go b/service/authz/resolver.go index 9a933304f545..888c5fb08d02 100644 --- a/service/authz/resolver.go +++ b/service/authz/resolver.go @@ -50,12 +50,12 @@ func Capabilities(userID int, systemRole int) PermissionsMap { return result } -func roleBaselineAllows(e *casbin.Enforcer, roleKey string, permission Permission) bool { +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.Enforcer, subject string, permission Permission) (string, bool) { +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 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 e53ddcb3543a..389bfe3f0803 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 @@ -30,6 +30,7 @@ import { ADMIN_PERMISSION_RESOURCES, hasPermission, } from '@/lib/admin-permissions' +import { cn } from '@/lib/utils' import { Tooltip, TooltipContent, @@ -181,10 +182,17 @@ export function DataTableBulkActions({ if (!canEditSensitive) return setShowDeleteConfirm(true) }} - disabled={!canEditSensitive} - className='size-8' + aria-disabled={!canEditSensitive} + className={cn( + 'size-8', + !canEditSensitive && 'cursor-not-allowed opacity-50' + )} aria-label={t('Delete selected channels')} - title={t('Delete selected channels')} + title={ + canEditSensitive + ? t('Delete selected channels') + : t('No permission to perform this action') + } /> } >