feat: better admin permissions (#5755)
* feat: add casbin admin permissions * feat: improve audit logging to associate logs with actual operators and target users * feat: enhance admin permissions and UI interactions for sensitive actions * Refactor authz RBAC and tighten channel permissions * Split channel authz field policy * Address channel authz review findings
This commit is contained in:
+11
-4
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/QuantumNous/new-api/service/authz"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetPermissionCatalog returns the permission schema used by the client to
|
||||
// render the permission editor: the registry of resources with their actions
|
||||
// and display label keys, plus the roles with their baseline grant matrices.
|
||||
// Defining it in the authz package keeps the schema in a single place.
|
||||
func GetPermissionCatalog(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": gin.H{
|
||||
"resources": authz.Catalog(),
|
||||
"roles": authz.Roles(),
|
||||
},
|
||||
})
|
||||
}
|
||||
+106
-4
@@ -12,11 +12,13 @@ import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
relaychannel "github.com/QuantumNous/new-api/relay/channel"
|
||||
"github.com/QuantumNous/new-api/relay/channel/gemini"
|
||||
"github.com/QuantumNous/new-api/relay/channel/ollama"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/service/authz"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
@@ -820,6 +822,11 @@ func EditTagChannels(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
if (channelTag.ParamOverride != nil || channelTag.HeaderOverride != nil) &&
|
||||
!authz.Can(c.GetInt("id"), c.GetInt("role"), authz.ChannelSensitiveWrite) {
|
||||
common.ApiErrorI18n(c, i18n.MsgAuthInsufficientPrivilege)
|
||||
return
|
||||
}
|
||||
if channelTag.ParamOverride != nil {
|
||||
trimmed := strings.TrimSpace(*channelTag.ParamOverride)
|
||||
if trimmed != "" && !json.Valid([]byte(trimmed)) {
|
||||
@@ -896,13 +903,36 @@ type PatchChannel struct {
|
||||
KeyMode *string `json:"key_mode"` // 多key模式下密钥覆盖或者追加
|
||||
}
|
||||
|
||||
type ChannelStatusRequest struct {
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
type ChannelStatusBatchRequest struct {
|
||||
Ids []int `json:"ids"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
func UpdateChannel(c *gin.Context) {
|
||||
channel := PatchChannel{}
|
||||
err := c.ShouldBindJSON(&channel)
|
||||
rawBody, err := c.GetRawData()
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if err := common.Unmarshal(rawBody, &channel); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
var requestData map[string]any
|
||||
if err := common.Unmarshal(rawBody, &requestData); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if _, ok := requestData["status"]; ok {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
return
|
||||
}
|
||||
clearChannelReadOnlyFields(&channel, requestData)
|
||||
|
||||
// 使用统一的校验函数
|
||||
if err := validateChannel(&channel.Channel, false); err != nil {
|
||||
@@ -925,6 +955,12 @@ func UpdateChannel(c *gin.Context) {
|
||||
// Always copy the original ChannelInfo so that fields like IsMultiKey and MultiKeySize are retained.
|
||||
channel.ChannelInfo = originChannel.ChannelInfo
|
||||
|
||||
if channelHasSensitiveChanges(&channel, originChannel, requestData) &&
|
||||
!authz.Can(c.GetInt("id"), c.GetInt("role"), authz.ChannelSensitiveWrite) {
|
||||
common.ApiErrorI18n(c, i18n.MsgAuthInsufficientPrivilege)
|
||||
return
|
||||
}
|
||||
|
||||
// If the request explicitly specifies a new MultiKeyMode, apply it on top of the original info.
|
||||
if channel.MultiKeyMode != nil && *channel.MultiKeyMode != "" {
|
||||
channel.ChannelInfo.MultiKeyMode = constant.MultiKeyMode(*channel.MultiKeyMode)
|
||||
@@ -1019,9 +1055,6 @@ func UpdateChannel(c *gin.Context) {
|
||||
service.ResetProxyClientCache()
|
||||
// 记录变更的字段名(语言无关的字段标识),密钥仅记录"已更换"绝不记录内容。
|
||||
changedFields := make([]string, 0)
|
||||
if channel.Status != originChannel.Status {
|
||||
changedFields = append(changedFields, "status")
|
||||
}
|
||||
if channel.Models != originChannel.Models {
|
||||
changedFields = append(changedFields, "models")
|
||||
}
|
||||
@@ -1052,6 +1085,66 @@ func UpdateChannel(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
func UpdateChannelStatus(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
return
|
||||
}
|
||||
req := ChannelStatusRequest{}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || !isManageableChannelStatus(req.Status) {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
return
|
||||
}
|
||||
changed := model.UpdateChannelStatus(id, "", req.Status, "manual operation")
|
||||
if changed {
|
||||
model.InitChannelCache()
|
||||
service.ResetProxyClientCache()
|
||||
}
|
||||
recordManageAudit(c, "channel.status_update", map[string]interface{}{
|
||||
"id": id,
|
||||
"status": req.Status,
|
||||
"changed": changed,
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": changed,
|
||||
})
|
||||
}
|
||||
|
||||
func BatchUpdateChannelStatus(c *gin.Context) {
|
||||
req := ChannelStatusBatchRequest{}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || len(req.Ids) == 0 || !isManageableChannelStatus(req.Status) {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
return
|
||||
}
|
||||
changedCount := 0
|
||||
for _, id := range req.Ids {
|
||||
if model.UpdateChannelStatus(id, "", req.Status, "manual batch operation") {
|
||||
changedCount++
|
||||
}
|
||||
}
|
||||
if changedCount > 0 {
|
||||
model.InitChannelCache()
|
||||
service.ResetProxyClientCache()
|
||||
}
|
||||
recordManageAudit(c, "channel.status_update_batch", map[string]interface{}{
|
||||
"count": changedCount,
|
||||
"total": len(req.Ids),
|
||||
"status": req.Status,
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": changedCount,
|
||||
})
|
||||
}
|
||||
|
||||
func isManageableChannelStatus(status int) bool {
|
||||
return status == common.ChannelStatusEnabled || status == common.ChannelStatusManuallyDisabled
|
||||
}
|
||||
|
||||
// equalStringPtr 比较两个 *string 是否相等(均为 nil 视为相等)。
|
||||
func equalStringPtr(a, b *string) bool {
|
||||
if a == nil && b == nil {
|
||||
@@ -1364,6 +1457,11 @@ func ManageMultiKeys(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
if multiKeyActionRequiresSensitiveWrite(request.Action) &&
|
||||
!authz.Can(c.GetInt("id"), c.GetInt("role"), authz.ChannelSensitiveWrite) {
|
||||
common.ApiErrorI18n(c, i18n.MsgAuthInsufficientPrivilege)
|
||||
return
|
||||
}
|
||||
|
||||
// get_key_status 为只读查询,不记录审计;其余为修改操作,记录审计并跳过中间件兜底。
|
||||
if request.Action == "get_key_status" {
|
||||
@@ -1808,6 +1906,10 @@ func ManageMultiKeys(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func multiKeyActionRequiresSensitiveWrite(action string) bool {
|
||||
return action == "delete_key" || action == "delete_disabled_keys"
|
||||
}
|
||||
|
||||
// OllamaPullModel 拉取 Ollama 模型
|
||||
func OllamaPullModel(c *gin.Context) {
|
||||
var req struct {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package controller
|
||||
|
||||
import "github.com/QuantumNous/new-api/model"
|
||||
|
||||
func channelHasSensitiveChanges(channel *PatchChannel, origin *model.Channel, requestData map[string]any) bool {
|
||||
if _, ok := requestData["type"]; ok && channel.Type != origin.Type {
|
||||
return true
|
||||
}
|
||||
if _, ok := requestData["key"]; ok && channel.Key != "" && channel.Key != origin.Key {
|
||||
return true
|
||||
}
|
||||
if _, ok := requestData["base_url"]; ok && !equalStringPtr(channel.BaseURL, origin.BaseURL) {
|
||||
return true
|
||||
}
|
||||
if _, ok := requestData["openai_organization"]; ok && !equalStringPtr(channel.OpenAIOrganization, origin.OpenAIOrganization) {
|
||||
return true
|
||||
}
|
||||
if _, ok := requestData["header_override"]; ok && !equalStringPtr(channel.HeaderOverride, origin.HeaderOverride) {
|
||||
return true
|
||||
}
|
||||
if _, ok := requestData["param_override"]; ok && !equalStringPtr(channel.ParamOverride, origin.ParamOverride) {
|
||||
return true
|
||||
}
|
||||
if _, ok := requestData["setting"]; ok && !equalStringPtr(channel.Setting, origin.Setting) {
|
||||
return true
|
||||
}
|
||||
if _, ok := requestData["other"]; ok && channel.Other != origin.Other {
|
||||
return true
|
||||
}
|
||||
if _, ok := requestData["settings"]; ok && channel.OtherSettings != origin.OtherSettings {
|
||||
return true
|
||||
}
|
||||
if _, ok := requestData["key_mode"]; ok && channel.KeyMode != nil {
|
||||
return true
|
||||
}
|
||||
// Fail closed: any field present in the request that is neither a known
|
||||
// sensitive field (gated above) nor an explicitly classified non-sensitive
|
||||
// field must be treated as sensitive. This keeps a newly added channel field
|
||||
// from silently becoming editable by ChannelWrite-only admins until it is
|
||||
// consciously classified in channelNonSensitiveFields.
|
||||
for field := range requestData {
|
||||
if _, ok := channelSensitiveFields[field]; ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := channelNonSensitiveFields[field]; ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := channelOperationalFields[field]; ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := channelReadOnlyFields[field]; ok {
|
||||
continue
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// channelSensitiveFields lists the channel fields whose modification requires
|
||||
// ChannelSensitiveWrite. They are each checked individually in
|
||||
// channelHasSensitiveChanges with a precise old-vs-new comparison; this set is
|
||||
// used to exclude them from the fail-closed scan for unknown fields.
|
||||
var channelSensitiveFields = map[string]struct{}{
|
||||
"type": {},
|
||||
"key": {},
|
||||
"base_url": {},
|
||||
"openai_organization": {},
|
||||
"header_override": {},
|
||||
"param_override": {},
|
||||
"setting": {},
|
||||
"other": {},
|
||||
"settings": {},
|
||||
"key_mode": {},
|
||||
}
|
||||
|
||||
// channelOperationalFields lists fields managed by operation endpoints instead
|
||||
// of the general channel edit endpoint.
|
||||
var channelOperationalFields = map[string]struct{}{
|
||||
"status": {},
|
||||
}
|
||||
|
||||
// channelReadOnlyFields lists server-managed/accounting fields that the general
|
||||
// channel edit endpoint must ignore even if a client sends them.
|
||||
var channelReadOnlyFields = map[string]struct{}{
|
||||
"created_time": {},
|
||||
"test_time": {},
|
||||
"response_time": {},
|
||||
"balance": {},
|
||||
"balance_updated_time": {},
|
||||
"used_quota": {},
|
||||
}
|
||||
|
||||
func clearChannelReadOnlyFields(channel *PatchChannel, requestData map[string]any) {
|
||||
if _, ok := requestData["created_time"]; ok {
|
||||
channel.CreatedTime = 0
|
||||
}
|
||||
if _, ok := requestData["test_time"]; ok {
|
||||
channel.TestTime = 0
|
||||
}
|
||||
if _, ok := requestData["response_time"]; ok {
|
||||
channel.ResponseTime = 0
|
||||
}
|
||||
if _, ok := requestData["balance"]; ok {
|
||||
channel.Balance = 0
|
||||
}
|
||||
if _, ok := requestData["balance_updated_time"]; ok {
|
||||
channel.BalanceUpdatedTime = 0
|
||||
}
|
||||
if _, ok := requestData["used_quota"]; ok {
|
||||
channel.UsedQuota = 0
|
||||
}
|
||||
}
|
||||
|
||||
// channelNonSensitiveFields lists routing / server-managed channel
|
||||
// fields a ChannelWrite admin may edit without ChannelSensitiveWrite. When a new
|
||||
// field is added to model.Channel it must be added to either this set or
|
||||
// channelSensitiveFields or channelOperationalFields; otherwise it falls through
|
||||
// to the fail-closed branch and is treated as sensitive. The
|
||||
// TestChannelFieldsAreClassified guard test enforces this.
|
||||
var channelNonSensitiveFields = map[string]struct{}{
|
||||
"id": {},
|
||||
"test_model": {},
|
||||
"name": {},
|
||||
"weight": {},
|
||||
"models": {},
|
||||
"group": {},
|
||||
"model_mapping": {},
|
||||
"status_code_mapping": {},
|
||||
"priority": {},
|
||||
"auto_ban": {},
|
||||
"other_info": {},
|
||||
"tag": {},
|
||||
"remark": {},
|
||||
"channel_info": {},
|
||||
"multi_key_mode": {},
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestChannelHasSensitiveChanges(t *testing.T) {
|
||||
baseURL := "https://api.example.com"
|
||||
headerOverride := `{"Authorization":"Bearer {api_key}"}`
|
||||
origin := &model.Channel{
|
||||
Type: 1,
|
||||
Key: "old-key",
|
||||
BaseURL: &baseURL,
|
||||
HeaderOverride: &headerOverride,
|
||||
Models: "gpt-4o",
|
||||
Group: "default",
|
||||
}
|
||||
|
||||
t.Run("non-sensitive routing fields", func(t *testing.T) {
|
||||
updated := PatchChannel{Channel: *origin}
|
||||
updated.Models = "gpt-4o,gpt-4o-mini"
|
||||
updated.Group = "vip"
|
||||
|
||||
assert.False(t, channelHasSensitiveChanges(&updated, origin, map[string]any{
|
||||
"models": updated.Models,
|
||||
"group": updated.Group,
|
||||
}))
|
||||
})
|
||||
|
||||
t.Run("key change", func(t *testing.T) {
|
||||
updated := PatchChannel{Channel: *origin}
|
||||
updated.Key = "new-key"
|
||||
|
||||
assert.True(t, channelHasSensitiveChanges(&updated, origin, map[string]any{"key": updated.Key}))
|
||||
})
|
||||
|
||||
t.Run("base url change", func(t *testing.T) {
|
||||
updated := PatchChannel{Channel: *origin}
|
||||
newBaseURL := "https://leak.example.com"
|
||||
updated.BaseURL = &newBaseURL
|
||||
|
||||
assert.True(t, channelHasSensitiveChanges(&updated, origin, map[string]any{"base_url": newBaseURL}))
|
||||
})
|
||||
|
||||
t.Run("header override change", func(t *testing.T) {
|
||||
updated := PatchChannel{Channel: *origin}
|
||||
newHeaderOverride := `{"X-Key":"{api_key}"}`
|
||||
updated.HeaderOverride = &newHeaderOverride
|
||||
|
||||
assert.True(t, channelHasSensitiveChanges(&updated, origin, map[string]any{"header_override": newHeaderOverride}))
|
||||
})
|
||||
|
||||
t.Run("omitted sensitive fields do not use zero values", func(t *testing.T) {
|
||||
updated := PatchChannel{}
|
||||
updated.Id = origin.Id
|
||||
updated.Priority = origin.Priority
|
||||
|
||||
assert.False(t, channelHasSensitiveChanges(&updated, origin, map[string]any{"priority": 10}))
|
||||
})
|
||||
|
||||
t.Run("unknown field fails closed", func(t *testing.T) {
|
||||
updated := PatchChannel{Channel: *origin}
|
||||
|
||||
assert.True(t, channelHasSensitiveChanges(&updated, origin, map[string]any{"future_secret_field": "x"}))
|
||||
})
|
||||
|
||||
t.Run("status is operational", func(t *testing.T) {
|
||||
updated := PatchChannel{Channel: *origin}
|
||||
updated.Status = common.ChannelStatusManuallyDisabled
|
||||
|
||||
assert.False(t, channelHasSensitiveChanges(&updated, origin, map[string]any{"status": updated.Status}))
|
||||
})
|
||||
|
||||
t.Run("read-only fields are ignored by sensitivity check", func(t *testing.T) {
|
||||
updated := PatchChannel{Channel: *origin}
|
||||
updated.Balance = 99
|
||||
updated.UsedQuota = 100
|
||||
updated.ResponseTime = 200
|
||||
|
||||
assert.False(t, channelHasSensitiveChanges(&updated, origin, map[string]any{
|
||||
"balance": updated.Balance,
|
||||
"used_quota": updated.UsedQuota,
|
||||
"response_time": updated.ResponseTime,
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
func TestClearChannelReadOnlyFields(t *testing.T) {
|
||||
channel := PatchChannel{Channel: model.Channel{
|
||||
CreatedTime: 11,
|
||||
TestTime: 22,
|
||||
ResponseTime: 33,
|
||||
Balance: 44.5,
|
||||
BalanceUpdatedTime: 55,
|
||||
UsedQuota: 66,
|
||||
Models: "gpt-4o",
|
||||
Group: "default",
|
||||
}}
|
||||
|
||||
clearChannelReadOnlyFields(&channel, map[string]any{
|
||||
"created_time": channel.CreatedTime,
|
||||
"test_time": channel.TestTime,
|
||||
"response_time": channel.ResponseTime,
|
||||
"balance": channel.Balance,
|
||||
"balance_updated_time": channel.BalanceUpdatedTime,
|
||||
"used_quota": channel.UsedQuota,
|
||||
"models": channel.Models,
|
||||
"group": channel.Group,
|
||||
})
|
||||
|
||||
assert.Zero(t, channel.CreatedTime)
|
||||
assert.Zero(t, channel.TestTime)
|
||||
assert.Zero(t, channel.ResponseTime)
|
||||
assert.Zero(t, channel.Balance)
|
||||
assert.Zero(t, channel.BalanceUpdatedTime)
|
||||
assert.Zero(t, channel.UsedQuota)
|
||||
assert.Equal(t, "gpt-4o", channel.Models)
|
||||
assert.Equal(t, "default", channel.Group)
|
||||
}
|
||||
|
||||
func TestUpdateChannelRejectsStatusField(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/api/channel/",
|
||||
bytes.NewBufferString(`{"id":1,"status":2}`),
|
||||
)
|
||||
ctx.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
UpdateChannel(ctx)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
|
||||
assert.False(t, response.Success)
|
||||
}
|
||||
|
||||
func TestChannelStatusValidation(t *testing.T) {
|
||||
assert.True(t, isManageableChannelStatus(common.ChannelStatusEnabled))
|
||||
assert.True(t, isManageableChannelStatus(common.ChannelStatusManuallyDisabled))
|
||||
assert.False(t, isManageableChannelStatus(common.ChannelStatusAutoDisabled))
|
||||
assert.False(t, isManageableChannelStatus(0))
|
||||
}
|
||||
|
||||
// TestChannelFieldsAreClassified guards the fail-closed sensitivity check: every
|
||||
// JSON field of PatchChannel (including the embedded model.Channel) must be listed
|
||||
// in channelSensitiveFields, channelNonSensitiveFields, or
|
||||
// channelOperationalFields. A newly added field that is left unclassified will
|
||||
// fail this test, forcing a conscious permission decision instead of silently
|
||||
// defaulting either way.
|
||||
func TestChannelFieldsAreClassified(t *testing.T) {
|
||||
classified := func(name string) bool {
|
||||
if _, ok := channelSensitiveFields[name]; ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := channelNonSensitiveFields[name]; ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := channelOperationalFields[name]; ok {
|
||||
return true
|
||||
}
|
||||
_, ok := channelReadOnlyFields[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
var collect func(rt reflect.Type) []string
|
||||
collect = func(rt reflect.Type) []string {
|
||||
var names []string
|
||||
for i := 0; i < rt.NumField(); i++ {
|
||||
field := rt.Field(i)
|
||||
if field.Anonymous && field.Type.Kind() == reflect.Struct {
|
||||
names = append(names, collect(field.Type)...)
|
||||
continue
|
||||
}
|
||||
name := strings.Split(field.Tag.Get("json"), ",")[0]
|
||||
if name == "" || name == "-" {
|
||||
continue
|
||||
}
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
for _, name := range collect(reflect.TypeOf(PatchChannel{})) {
|
||||
assert.Truef(t, classified(name),
|
||||
"channel field %q is not classified; add it to channelSensitiveFields, channelNonSensitiveFields, channelOperationalFields, or channelReadOnlyFields in channel_authz.go", name)
|
||||
}
|
||||
}
|
||||
+82
-9
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/service/authz"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
|
||||
@@ -23,6 +24,7 @@ import (
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type LoginRequest struct {
|
||||
@@ -334,6 +336,7 @@ func GetUser(c *gin.Context) {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionSameLevel)
|
||||
return
|
||||
}
|
||||
user.AdminPermissions = authz.Capabilities(user.Id, user.Role)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
@@ -443,6 +446,7 @@ func GetSelf(c *gin.Context) {
|
||||
|
||||
// 计算用户权限信息
|
||||
permissions := calculateUserPermissions(userRole)
|
||||
permissions["admin_permissions"] = authz.Capabilities(id, userRole)
|
||||
|
||||
// 获取用户设置并提取sidebar_modules
|
||||
userSetting := user.GetSetting()
|
||||
@@ -620,23 +624,41 @@ func UpdateUser(c *gin.Context) {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if updatedUser.Role != common.RoleGuestUser && updatedUser.Role != originUser.Role {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
return
|
||||
}
|
||||
updatedUser.Role = originUser.Role
|
||||
myRole := c.GetInt("role")
|
||||
if !canManageTargetRole(myRole, originUser.Role) {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel)
|
||||
return
|
||||
}
|
||||
if !canManageTargetRole(myRole, updatedUser.Role) {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserCannotCreateHigherLevel)
|
||||
return
|
||||
}
|
||||
if updatedUser.Password == "$I_LOVE_U" {
|
||||
updatedUser.Password = "" // rollback to what it should be
|
||||
}
|
||||
updatePassword := updatedUser.Password != ""
|
||||
if err := updatedUser.Edit(updatePassword); err != nil {
|
||||
authzTouched := false
|
||||
if err := model.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := updatedUser.EditWithTx(tx, updatePassword); err != nil {
|
||||
return err
|
||||
}
|
||||
touched, err := updateAdminPermissionsForUserInTx(c, tx, updatedUser.Id, originUser.Role, updatedUser.AdminPermissions)
|
||||
authzTouched = touched
|
||||
return err
|
||||
}); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if authzTouched {
|
||||
if err := authz.ReloadPolicy(); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := model.InvalidateUserCache(updatedUser.Id); err != nil {
|
||||
common.SysLog(fmt.Sprintf("failed to invalidate user cache for user %d: %s", updatedUser.Id, err.Error()))
|
||||
}
|
||||
recordManageAuditFor(c, updatedUser.Id, "user.update", map[string]interface{}{
|
||||
"username": originUser.Username,
|
||||
"id": updatedUser.Id,
|
||||
@@ -901,10 +923,25 @@ func CreateUser(c *gin.Context) {
|
||||
DisplayName: user.DisplayName,
|
||||
Role: user.Role, // 保持管理员设置的角色
|
||||
}
|
||||
if err := cleanUser.Insert(0); err != nil {
|
||||
authzTouched := false
|
||||
if err := model.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := cleanUser.InsertWithTx(tx, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
touched, err := updateAdminPermissionsForUserInTx(c, tx, cleanUser.Id, cleanUser.Role, user.AdminPermissions)
|
||||
authzTouched = touched
|
||||
return err
|
||||
}); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if authzTouched {
|
||||
if err := authz.ReloadPolicy(); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
cleanUser.FinishInsert(0)
|
||||
|
||||
recordManageAuditFor(c, cleanUser.Id, "user.create", map[string]interface{}{
|
||||
"username": cleanUser.Username,
|
||||
@@ -917,6 +954,22 @@ func CreateUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
func updateAdminPermissionsForUserInTx(c *gin.Context, tx *gorm.DB, userID int, userRole int, permissions map[string]map[string]bool) (bool, error) {
|
||||
if permissions == nil {
|
||||
if userRole < common.RoleAdminUser && c.GetInt("role") == common.RoleRootUser {
|
||||
return true, authz.ClearUserAuthorizationInTx(tx, userID)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
if c.GetInt("role") != common.RoleRootUser {
|
||||
return false, fmt.Errorf("only root can update admin permissions")
|
||||
}
|
||||
if userRole < common.RoleAdminUser {
|
||||
return true, authz.ClearUserAuthorizationInTx(tx, userID)
|
||||
}
|
||||
return true, authz.SetUserPermissionsInTx(tx, userID, permissions)
|
||||
}
|
||||
|
||||
type ManageRequest struct {
|
||||
Id int `json:"id"`
|
||||
Action string `json:"action"`
|
||||
@@ -1040,9 +1093,29 @@ func ManageUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := user.Update(false); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
authzTouched := false
|
||||
if req.Action == "demote" {
|
||||
if err := model.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := user.UpdateWithTx(tx, false); err != nil {
|
||||
return err
|
||||
}
|
||||
authzTouched = true
|
||||
return authz.ClearUserAuthorizationInTx(tx, user.Id)
|
||||
}); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if authzTouched {
|
||||
if err := authz.ReloadPolicy(); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err := user.Update(false); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
// 禁用 / 角色调整后,强制失效用户缓存与其全部令牌缓存,
|
||||
// 避免在 Redis TTL 过期前仍使用旧状态(尤其是禁用后仍可发起请求的问题)。
|
||||
|
||||
Reference in New Issue
Block a user