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:
Calcium-Ion
2026-06-27 17:01:59 +08:00
committed by GitHub
parent 6c35e1ef26
commit 4aee5f7d5a
52 changed files with 2778 additions and 255 deletions
+11 -4
View File
@@ -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)
}
+24
View File
@@ -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
View File
@@ -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 {
+136
View File
@@ -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": {},
}
+204
View File
@@ -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
View File
@@ -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 过期前仍使用旧状态(尤其是禁用后仍可发起请求的问题)。