Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions controller/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
24 changes: 24 additions & 0 deletions controller/authz.go
Original file line number Diff line number Diff line change
@@ -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(),
},
})
}
110 changes: 106 additions & 4 deletions controller/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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)
Comment thread
Calcium-Ion marked this conversation as resolved.
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 {
Expand Down Expand Up @@ -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" {
Expand Down Expand Up @@ -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 {
Expand Down
136 changes: 136 additions & 0 deletions controller/channel_authz.go
Original file line number Diff line number Diff line change
@@ -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": {},
}
Loading