Commit 4aee5f7d by Calcium-Ion Committed by GitHub

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
parent 6c35e1ef
......@@ -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)
}
......
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(),
},
})
}
......@@ -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 {
......
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": {},
}
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)
}
}
......@@ -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,10 +1093,30 @@ func ManageUser(c *gin.Context) {
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 过期前仍使用旧状态(尤其是禁用后仍可发起请求的问题)。
// InvalidateUserCache 会让下一次 GetUserCache 从数据库重新加载,
......
......@@ -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
......
......@@ -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=
......
......@@ -23,6 +23,7 @@ import (
"github.com/QuantumNous/new-api/relay"
"github.com/QuantumNous/new-api/router"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/service/authz"
_ "github.com/QuantumNous/new-api/setting/performance_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
......@@ -100,6 +101,9 @@ func main() {
// 热更新配置
go model.SyncOptions(common.SyncFrequency)
// 周期性重载授权策略,保证多节点/多 master 部署下权限变更能传播到每个实例
go authz.StartPolicySync(common.SyncFrequency)
// 数据看板
go model.UpdateQuotaData()
......@@ -284,6 +288,10 @@ func InitResources() error {
common.FatalLog("failed to initialize database: " + err.Error())
return err
}
if err = authz.Init(model.DB); err != nil {
common.FatalLog("failed to initialize authorization: " + err.Error())
return err
}
model.CheckSetup()
......
......@@ -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) {
}
......
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"
}
package model
type CasbinRule struct {
Id uint `gorm:"primaryKey;autoIncrement"`
Ptype string `gorm:"size:100;index:idx_casbin_rule,priority:1;uniqueIndex:idx_casbin_rule_unique,priority:1"`
V0 string `gorm:"size:100;index:idx_casbin_rule,priority:2;uniqueIndex:idx_casbin_rule_unique,priority:2"`
V1 string `gorm:"size:100;index:idx_casbin_rule,priority:3;uniqueIndex:idx_casbin_rule_unique,priority:3"`
V2 string `gorm:"size:100;index:idx_casbin_rule,priority:4;uniqueIndex:idx_casbin_rule_unique,priority:4"`
V3 string `gorm:"size:100;index:idx_casbin_rule,priority:5;uniqueIndex:idx_casbin_rule_unique,priority:5"`
V4 string `gorm:"size:100;index:idx_casbin_rule,priority:6;uniqueIndex:idx_casbin_rule_unique,priority:6"`
V5 string `gorm:"size:100;index:idx_casbin_rule,priority:7;uniqueIndex:idx_casbin_rule_unique,priority:7"`
}
func (CasbinRule) TableName() string {
return "casbin_rule"
}
......@@ -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,普通用户查询时剥离)。
......
......@@ -297,6 +297,8 @@ func migrateDB() error {
&SystemInstance{},
&SystemTask{},
&SystemTaskLock{},
&CasbinRule{},
&AuthzRole{},
)
if err != nil {
return err
......
......@@ -53,6 +53,7 @@ type User struct {
StripeCustomer string `json:"stripe_customer" gorm:"type:varchar(64);column:stripe_customer;index"`
CreatedAt int64 `json:"created_at" gorm:"autoCreateTime;column:created_at"`
LastLoginAt int64 `json:"last_login_at" gorm:"default:0;column:last_login_at"`
AdminPermissions map[string]map[string]bool `json:"admin_permissions,omitempty" gorm:"-:all"`
}
func (user *User) ToBaseUser() *UserBase {
......@@ -408,6 +409,11 @@ func (user *User) Insert(inviterId int) error {
return result.Error
}
user.finishInsert(inviterId)
return nil
}
func (user *User) finishInsert(inviterId int) {
// 用户创建成功后,根据角色初始化边栏配置
// 需要重新获取用户以确保有正确的ID和Role
var createdUser User
......@@ -437,7 +443,10 @@ func (user *User) Insert(inviterId int) error {
_ = inviteUser(inviterId)
}
}
return nil
}
func (user *User) FinishInsert(inviterId int) {
user.finishInsert(inviterId)
}
// InsertWithTx inserts a new user within an existing transaction.
......@@ -500,6 +509,13 @@ func (user *User) FinalizeOAuthUserCreation(inviterId int) {
}
func (user *User) Update(updatePassword bool) error {
if err := user.UpdateWithTx(DB, updatePassword); err != nil {
return err
}
return updateUserCache(*user)
}
func (user *User) UpdateWithTx(tx *gorm.DB, updatePassword bool) error {
var err error
if updatePassword {
user.Password, err = common.Password2Hash(user.Password)
......@@ -508,16 +524,21 @@ func (user *User) Update(updatePassword bool) error {
}
}
newUser := *user
DB.First(&user, user.Id)
if err = DB.Model(user).Updates(newUser).Error; err != nil {
tx.First(&user, user.Id)
if err = tx.Model(user).Updates(newUser).Error; err != nil {
return err
}
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)
......@@ -537,13 +558,11 @@ func (user *User) Edit(updatePassword bool) error {
updates["password"] = newUser.Password
}
DB.First(&user, user.Id)
if err = DB.Model(user).Updates(updates).Error; err != nil {
tx.First(&user, user.Id)
if err = tx.Model(user).Updates(updates).Error; err != nil {
return err
}
// Update cache
return updateUserCache(*user)
return nil
}
func (user *User) ClearBinding(bindingType string) error {
......
......@@ -225,48 +225,8 @@ func SetApiRouter(router *gin.Engine) {
ratioSyncRoute.GET("/channels", controller.GetSyncableChannels)
ratioSyncRoute.POST("/fetch", controller.FetchUpstreamRatios)
}
channelRoute := apiRouter.Group("/channel")
channelRoute.Use(middleware.AdminAuth())
{
channelRoute.GET("/", controller.GetAllChannels)
channelRoute.GET("/search", controller.SearchChannels)
channelRoute.GET("/models", controller.ChannelListModels)
channelRoute.GET("/models_enabled", controller.EnabledListModels)
channelRoute.GET("/ops", controller.GetChannelOps)
channelRoute.GET("/:id", controller.GetChannel)
channelRoute.POST("/:id/key", middleware.RootAuth(), middleware.CriticalRateLimit(), middleware.DisableCache(), middleware.SecureVerificationRequired(), controller.GetChannelKey)
channelRoute.GET("/test", controller.TestAllChannels)
channelRoute.GET("/test/:id", controller.TestChannel)
channelRoute.GET("/update_balance", controller.UpdateAllChannelsBalance)
channelRoute.GET("/update_balance/:id", controller.UpdateChannelBalance)
channelRoute.POST("/", controller.AddChannel)
channelRoute.PUT("/", controller.UpdateChannel)
channelRoute.DELETE("/disabled", controller.DeleteDisabledChannel)
channelRoute.POST("/tag/disabled", controller.DisableTagChannels)
channelRoute.POST("/tag/enabled", controller.EnableTagChannels)
channelRoute.PUT("/tag", controller.EditTagChannels)
channelRoute.DELETE("/:id", controller.DeleteChannel)
channelRoute.POST("/batch", controller.DeleteChannelBatch)
channelRoute.POST("/fix", controller.FixChannelsAbilities)
channelRoute.GET("/fetch_models/:id", controller.FetchUpstreamModels)
channelRoute.POST("/fetch_models", middleware.RootAuth(), controller.FetchModels)
channelRoute.POST("/:id/codex/refresh", controller.RefreshCodexChannelCredential)
channelRoute.GET("/:id/codex/usage", controller.GetCodexChannelUsage)
channelRoute.GET("/:id/codex/usage/reset-credits", controller.GetCodexChannelRateLimitResetCredits)
channelRoute.POST("/:id/codex/usage/reset", controller.ResetCodexChannelUsage)
channelRoute.POST("/ollama/pull", controller.OllamaPullModel)
channelRoute.POST("/ollama/pull/stream", controller.OllamaPullModelStream)
channelRoute.DELETE("/ollama/delete", controller.OllamaDeleteModel)
channelRoute.GET("/ollama/version/:id", controller.OllamaVersion)
channelRoute.POST("/batch/tag", controller.BatchSetChannelTag)
channelRoute.GET("/tag/models", controller.GetTagModels)
channelRoute.POST("/copy/:id", controller.CopyChannel)
channelRoute.POST("/multi_key/manage", controller.ManageMultiKeys)
channelRoute.POST("/upstream_updates/apply", controller.ApplyChannelUpstreamModelUpdates)
channelRoute.POST("/upstream_updates/apply_all", controller.ApplyAllChannelUpstreamModelUpdates)
channelRoute.POST("/upstream_updates/detect", controller.DetectChannelUpstreamModelUpdates)
channelRoute.POST("/upstream_updates/detect_all", controller.DetectAllChannelUpstreamModelUpdates)
}
registerChannelRoutes(apiRouter)
registerAuthzRoutes(apiRouter)
tokenRoute := apiRouter.Group("/token")
tokenRoute.Use(middleware.UserAuth())
{
......
package router
import (
"github.com/QuantumNous/new-api/controller"
"github.com/QuantumNous/new-api/middleware"
"github.com/gin-gonic/gin"
)
// registerAuthzRoutes mounts the authorization API under its own /authz
// namespace. GET /authz/catalog returns the permission schema (resources,
// actions, and role baselines) used by the client permission editor.
func registerAuthzRoutes(apiRouter *gin.RouterGroup) {
authzRoute := apiRouter.Group("/authz")
authzRoute.Use(middleware.AdminAuth())
{
authzRoute.GET("/catalog", controller.GetPermissionCatalog)
}
}
package router
import (
"net/http"
"github.com/QuantumNous/new-api/controller"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/service/authz"
"github.com/gin-gonic/gin"
)
type permissionRoute struct {
method string
path string
permission authz.Permission
handler gin.HandlerFunc
}
func registerChannelRoutes(apiRouter *gin.RouterGroup) {
channelRoute := apiRouter.Group("/channel")
channelRoute.Use(middleware.AdminAuth())
channelRoute.POST("/:id/key",
middleware.RootAuth(),
middleware.CriticalRateLimit(),
middleware.DisableCache(),
middleware.SecureVerificationRequired(),
controller.GetChannelKey,
)
for _, route := range channelPermissionRoutes {
channelRoute.Handle(route.method, route.path,
middleware.RequirePermission(route.permission),
route.handler,
)
}
}
var channelPermissionRoutes = []permissionRoute{
{method: http.MethodGet, path: "/", permission: authz.ChannelRead, handler: controller.GetAllChannels},
{method: http.MethodGet, path: "/search", permission: authz.ChannelRead, handler: controller.SearchChannels},
{method: http.MethodGet, path: "/models", permission: authz.ChannelRead, handler: controller.ChannelListModels},
{method: http.MethodGet, path: "/models_enabled", permission: authz.ChannelRead, handler: controller.EnabledListModels},
{method: http.MethodGet, path: "/ops", permission: authz.ChannelRead, handler: controller.GetChannelOps},
{method: http.MethodGet, path: "/:id", permission: authz.ChannelRead, handler: controller.GetChannel},
{method: http.MethodGet, path: "/test", permission: authz.ChannelOperate, handler: controller.TestAllChannels},
{method: http.MethodGet, path: "/test/:id", permission: authz.ChannelOperate, handler: controller.TestChannel},
{method: http.MethodGet, path: "/update_balance", permission: authz.ChannelOperate, handler: controller.UpdateAllChannelsBalance},
{method: http.MethodGet, path: "/update_balance/:id", permission: authz.ChannelOperate, handler: controller.UpdateChannelBalance},
{method: http.MethodPost, path: "/", permission: authz.ChannelSensitiveWrite, handler: controller.AddChannel},
{method: http.MethodPut, path: "/", permission: authz.ChannelWrite, handler: controller.UpdateChannel},
{method: http.MethodPost, path: "/status/batch", permission: authz.ChannelOperate, handler: controller.BatchUpdateChannelStatus},
{method: http.MethodPost, path: "/:id/status", permission: authz.ChannelOperate, handler: controller.UpdateChannelStatus},
{method: http.MethodDelete, path: "/disabled", permission: authz.ChannelSensitiveWrite, handler: controller.DeleteDisabledChannel},
{method: http.MethodPost, path: "/tag/disabled", permission: authz.ChannelOperate, handler: controller.DisableTagChannels},
{method: http.MethodPost, path: "/tag/enabled", permission: authz.ChannelOperate, handler: controller.EnableTagChannels},
{method: http.MethodPut, path: "/tag", permission: authz.ChannelWrite, handler: controller.EditTagChannels},
{method: http.MethodDelete, path: "/:id", permission: authz.ChannelSensitiveWrite, handler: controller.DeleteChannel},
{method: http.MethodPost, path: "/batch", permission: authz.ChannelSensitiveWrite, handler: controller.DeleteChannelBatch},
{method: http.MethodPost, path: "/fix", permission: authz.ChannelOperate, handler: controller.FixChannelsAbilities},
{method: http.MethodGet, path: "/fetch_models/:id", permission: authz.ChannelOperate, handler: controller.FetchUpstreamModels},
{method: http.MethodPost, path: "/fetch_models", permission: authz.ChannelSensitiveWrite, handler: controller.FetchModels},
{method: http.MethodPost, path: "/:id/codex/refresh", permission: authz.ChannelSensitiveWrite, handler: controller.RefreshCodexChannelCredential},
{method: http.MethodGet, path: "/:id/codex/usage", permission: authz.ChannelRead, handler: controller.GetCodexChannelUsage},
{method: http.MethodGet, path: "/:id/codex/usage/reset-credits", permission: authz.ChannelRead, handler: controller.GetCodexChannelRateLimitResetCredits},
{method: http.MethodPost, path: "/:id/codex/usage/reset", permission: authz.ChannelOperate, handler: controller.ResetCodexChannelUsage},
{method: http.MethodPost, path: "/ollama/pull", permission: authz.ChannelSensitiveWrite, handler: controller.OllamaPullModel},
{method: http.MethodPost, path: "/ollama/pull/stream", permission: authz.ChannelSensitiveWrite, handler: controller.OllamaPullModelStream},
{method: http.MethodDelete, path: "/ollama/delete", permission: authz.ChannelSensitiveWrite, handler: controller.OllamaDeleteModel},
{method: http.MethodGet, path: "/ollama/version/:id", permission: authz.ChannelSensitiveWrite, handler: controller.OllamaVersion},
{method: http.MethodPost, path: "/batch/tag", permission: authz.ChannelWrite, handler: controller.BatchSetChannelTag},
{method: http.MethodGet, path: "/tag/models", permission: authz.ChannelRead, handler: controller.GetTagModels},
{method: http.MethodPost, path: "/copy/:id", permission: authz.ChannelSensitiveWrite, handler: controller.CopyChannel},
{method: http.MethodPost, path: "/multi_key/manage", permission: authz.ChannelOperate, handler: controller.ManageMultiKeys},
{method: http.MethodPost, path: "/upstream_updates/apply", permission: authz.ChannelWrite, handler: controller.ApplyChannelUpstreamModelUpdates},
{method: http.MethodPost, path: "/upstream_updates/apply_all", permission: authz.ChannelWrite, handler: controller.ApplyAllChannelUpstreamModelUpdates},
{method: http.MethodPost, path: "/upstream_updates/detect", permission: authz.ChannelOperate, handler: controller.DetectChannelUpstreamModelUpdates},
{method: http.MethodPost, path: "/upstream_updates/detect_all", permission: authz.ChannelOperate, handler: controller.DetectAllChannelUpstreamModelUpdates},
}
package router
import (
"net/http"
"reflect"
"testing"
"github.com/QuantumNous/new-api/controller"
"github.com/QuantumNous/new-api/service/authz"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestChannelStatusRoutesUseOperatePermission(t *testing.T) {
assertChannelRoutePermission(t, http.MethodPost, "/:id/status", authz.ChannelOperate, controller.UpdateChannelStatus)
assertChannelRoutePermission(t, http.MethodPost, "/status/batch", authz.ChannelOperate, controller.BatchUpdateChannelStatus)
assertChannelRoutePermission(t, http.MethodPut, "/", authz.ChannelWrite, controller.UpdateChannel)
}
func TestChannelDeleteRoutesUseSensitiveWritePermission(t *testing.T) {
assertChannelRoutePermission(t, http.MethodDelete, "/:id", authz.ChannelSensitiveWrite, controller.DeleteChannel)
assertChannelRoutePermission(t, http.MethodPost, "/batch", authz.ChannelSensitiveWrite, controller.DeleteChannelBatch)
assertChannelRoutePermission(t, http.MethodDelete, "/disabled", authz.ChannelSensitiveWrite, controller.DeleteDisabledChannel)
assertChannelRoutePermission(t, http.MethodPut, "/", authz.ChannelWrite, controller.UpdateChannel)
assertChannelRoutePermission(t, http.MethodPut, "/tag", authz.ChannelWrite, controller.EditTagChannels)
assertChannelRoutePermission(t, http.MethodPost, "/batch/tag", authz.ChannelWrite, controller.BatchSetChannelTag)
}
func TestChannelStatusRoutesRegisterWithoutConflict(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
api := engine.Group("/api")
require.NotPanics(t, func() {
registerChannelRoutes(api)
})
}
func assertChannelRoutePermission(t *testing.T, method string, path string, permission authz.Permission, handler any) {
t.Helper()
for _, route := range channelPermissionRoutes {
if route.method == method && route.path == path {
assert.Equal(t, permission, route.permission)
assert.Equal(t, reflect.ValueOf(handler).Pointer(), reflect.ValueOf(route.handler).Pointer())
return
}
}
t.Fatalf("route %s %s not found", method, path)
}
package authz
import (
"strings"
"github.com/QuantumNous/new-api/model"
casbinmodel "github.com/casbin/casbin/v2/model"
"github.com/casbin/casbin/v2/persist"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type gormAdapter struct {
db *gorm.DB
}
func newGormAdapter(db *gorm.DB) *gormAdapter {
return &gormAdapter{db: db}
}
func (a *gormAdapter) LoadPolicy(m casbinmodel.Model) error {
var rules []model.CasbinRule
if err := a.db.Order("id asc").Find(&rules).Error; err != nil {
return err
}
for _, rule := range rules {
if err := persist.LoadPolicyLine(ruleToLine(rule), m); err != nil {
return err
}
}
return nil
}
func (a *gormAdapter) SavePolicy(m casbinmodel.Model) error {
return a.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("1 = 1").Delete(&model.CasbinRule{}).Error; err != nil {
return err
}
rules := make([]model.CasbinRule, 0)
for ptype, ast := range m["p"] {
for _, policy := range ast.Policy {
rules = append(rules, newRule(ptype, policy))
}
}
for ptype, ast := range m["g"] {
for _, policy := range ast.Policy {
rules = append(rules, newRule(ptype, policy))
}
}
if len(rules) == 0 {
return nil
}
return tx.Create(&rules).Error
})
}
func (a *gormAdapter) AddPolicy(_ string, ptype string, rule []string) error {
casbinRule := newRule(ptype, rule)
var count int64
if err := a.ruleQuery(a.db.Model(&model.CasbinRule{}), ptype, rule).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
return a.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&casbinRule).Error
}
func (a *gormAdapter) RemovePolicy(_ string, ptype string, rule []string) error {
return a.ruleQuery(a.db, ptype, rule).Delete(&model.CasbinRule{}).Error
}
func (a *gormAdapter) RemoveFilteredPolicy(_ string, ptype string, fieldIndex int, fieldValues ...string) error {
query := a.db.Where("ptype = ?", ptype)
for i, value := range fieldValues {
if value == "" {
continue
}
query = query.Where("v"+string(rune('0'+fieldIndex+i))+" = ?", value)
}
return query.Delete(&model.CasbinRule{}).Error
}
func (a *gormAdapter) ruleQuery(query *gorm.DB, ptype string, rule []string) *gorm.DB {
query = query.Where("ptype = ?", ptype)
for idx := 0; idx < 6; idx++ {
value := ""
if idx < len(rule) {
value = rule[idx]
}
query = query.Where("v"+string(rune('0'+idx))+" = ?", value)
}
return query
}
func newRule(ptype string, policy []string) model.CasbinRule {
rule := model.CasbinRule{Ptype: ptype}
values := []*string{&rule.V0, &rule.V1, &rule.V2, &rule.V3, &rule.V4, &rule.V5}
for idx, value := range policy {
if idx >= len(values) {
break
}
*values[idx] = value
}
return rule
}
func ruleToLine(rule model.CasbinRule) string {
parts := []string{rule.Ptype}
values := []string{rule.V0, rule.V1, rule.V2, rule.V3, rule.V4, rule.V5}
if rule.Ptype == "p" && rule.V0 != "" && rule.V1 != "" && rule.V2 != "" && rule.V3 == "" {
values[3] = EffectAllow
}
for _, value := range values {
if value == "" {
continue
}
parts = append(parts, value)
}
return strings.Join(parts, ", ")
}
package authz
import "github.com/QuantumNous/new-api/common"
// resolveSubjectRoles returns the role keys assigned to a subject. The mapping
// is derived from the caller's system role.
var resolveSubjectRoles = func(userID int, systemRole int) []string {
switch {
case systemRole >= common.RoleRootUser:
return []string{BuiltInRoleRoot}
case systemRole >= common.RoleAdminUser:
return []string{BuiltInRoleAdmin}
default:
return nil
}
}
// managedRoleKey is the role whose baseline per-user overrides are expressed
// relative to.
const managedRoleKey = BuiltInRoleAdmin
package authz
import (
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func newAuthzTestDB(t *testing.T) *gorm.DB {
t.Helper()
wasMaster := common.IsMasterNode
common.IsMasterNode = true
t.Cleanup(func() {
common.IsMasterNode = wasMaster
})
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
sqlDB, err := db.DB()
require.NoError(t, err)
sqlDB.SetMaxOpenConns(1)
require.NoError(t, db.AutoMigrate(&model.CasbinRule{}, &model.AuthzRole{}))
return db
}
func TestInitSeedsBuiltInRolesAndPoliciesOnce(t *testing.T) {
db := newAuthzTestDB(t)
require.NoError(t, Init(db))
require.NoError(t, Init(db))
// root is a superuser role and is granted everything implicitly, so only the
// admin baseline is written as explicit policy rows.
var count int64
require.NoError(t, db.Model(&model.CasbinRule{}).Count(&count).Error)
assert.Equal(t, int64(len(PermissionsForRole(BuiltInRoleAdmin))), count)
var roles []model.AuthzRole
require.NoError(t, db.Order("sort asc").Find(&roles).Error)
require.Len(t, roles, 2)
assert.Equal(t, BuiltInRoleRoot, roles[0].Key)
assert.Equal(t, BuiltInRoleAdmin, roles[1].Key)
assert.True(t, Can(1, common.RoleRootUser, ChannelSensitiveWrite))
assert.True(t, Can(2, common.RoleAdminUser, ChannelRead))
assert.True(t, Can(2, common.RoleAdminUser, ChannelOperate))
assert.True(t, Can(2, common.RoleAdminUser, ChannelWrite))
assert.False(t, Can(2, common.RoleAdminUser, ChannelSensitiveWrite))
assert.False(t, Can(3, common.RoleCommonUser, ChannelRead))
}
func TestInitOnSlaveOnlyLoadsPolicies(t *testing.T) {
wasMaster := common.IsMasterNode
common.IsMasterNode = false
t.Cleanup(func() {
common.IsMasterNode = wasMaster
})
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
sqlDB, err := db.DB()
require.NoError(t, err)
sqlDB.SetMaxOpenConns(1)
require.NoError(t, db.AutoMigrate(&model.CasbinRule{}, &model.AuthzRole{}))
require.NoError(t, Init(db))
var roleCount int64
require.NoError(t, db.Model(&model.AuthzRole{}).Count(&roleCount).Error)
assert.Equal(t, int64(0), roleCount)
var policyCount int64
require.NoError(t, db.Model(&model.CasbinRule{}).Count(&policyCount).Error)
assert.Equal(t, int64(0), policyCount)
assert.False(t, Can(2, common.RoleAdminUser, ChannelRead))
}
func TestSetUserPermissionsStoresOnlyOverrides(t *testing.T) {
db := newAuthzTestDB(t)
require.NoError(t, Init(db))
require.NoError(t, SetUserPermissions(42, PermissionsMap{
ResourceChannel: {
ActionRead: true,
ActionOperate: true,
ActionWrite: false,
ActionSensitiveWrite: true,
ActionSecretView: false,
"unknown": true,
},
"unknown": {
ActionRead: true,
},
}))
assert.True(t, Can(42, common.RoleAdminUser, ChannelSensitiveWrite))
assert.False(t, Can(42, common.RoleAdminUser, ChannelWrite))
assert.Equal(t, PermissionsMap{
ResourceChannel: {
ActionRead: true,
ActionOperate: true,
ActionWrite: false,
ActionSensitiveWrite: true,
ActionSecretView: false,
},
}, ExplicitUserPermissions(42))
assert.Equal(t, PermissionsMap{
ResourceChannel: {
ActionSensitiveWrite: true,
ActionWrite: false,
},
}, ExplicitUserOverrides(42))
var userPolicyCount int64
require.NoError(t, db.Model(&model.CasbinRule{}).Where("v0 = ?", UserSubject(42)).Count(&userPolicyCount).Error)
assert.Equal(t, int64(2), userPolicyCount)
require.NoError(t, SetUserPermissions(42, PermissionsMap{ResourceChannel: {
ActionRead: true,
ActionOperate: true,
ActionWrite: true,
ActionSensitiveWrite: false,
ActionSecretView: false,
}}))
assert.False(t, Can(42, common.RoleAdminUser, ChannelSensitiveWrite))
assert.Equal(t, PermissionsMap{
ResourceChannel: {
ActionRead: true,
ActionOperate: true,
ActionWrite: true,
ActionSensitiveWrite: false,
ActionSecretView: false,
},
}, ExplicitUserPermissions(42))
assert.Empty(t, ExplicitUserOverrides(42))
}
func TestClearUserAuthorizationRemovesOverrides(t *testing.T) {
db := newAuthzTestDB(t)
require.NoError(t, Init(db))
require.NoError(t, SetUserPermissions(90, PermissionsMap{ResourceChannel: {
ActionWrite: false,
ActionSensitiveWrite: true,
}}))
assert.True(t, Can(90, common.RoleAdminUser, ChannelSensitiveWrite))
assert.False(t, Can(90, common.RoleAdminUser, ChannelWrite))
require.NoError(t, ClearUserAuthorization(90))
assert.Empty(t, ExplicitUserOverrides(90))
assert.True(t, Can(90, common.RoleAdminUser, ChannelRead))
assert.True(t, Can(90, common.RoleAdminUser, ChannelWrite))
assert.False(t, Can(90, common.RoleAdminUser, ChannelSensitiveWrite))
assert.False(t, Can(90, common.RoleCommonUser, ChannelRead))
}
func TestSetUserPermissionsInTxDoesNotMutateEnforcerBeforeReload(t *testing.T) {
db := newAuthzTestDB(t)
require.NoError(t, Init(db))
require.NoError(t, db.Transaction(func(tx *gorm.DB) error {
return SetUserPermissionsInTx(tx, 42, PermissionsMap{ResourceChannel: {
ActionRead: true,
ActionOperate: true,
ActionWrite: true,
ActionSensitiveWrite: true,
ActionSecretView: false,
}})
}))
assert.False(t, Can(42, common.RoleAdminUser, ChannelSensitiveWrite))
require.NoError(t, ReloadPolicy())
assert.True(t, Can(42, common.RoleAdminUser, ChannelSensitiveWrite))
}
func TestSetUserPermissionsInTxRollbackLeavesNoPolicy(t *testing.T) {
db := newAuthzTestDB(t)
require.NoError(t, Init(db))
tx := db.Begin()
require.NoError(t, tx.Error)
require.NoError(t, SetUserPermissionsInTx(tx, 43, PermissionsMap{ResourceChannel: {
ActionSensitiveWrite: true,
}}))
require.NoError(t, tx.Rollback().Error)
require.NoError(t, ReloadPolicy())
assert.False(t, Can(43, common.RoleAdminUser, ChannelSensitiveWrite))
var count int64
require.NoError(t, db.Model(&model.CasbinRule{}).Where("v0 = ?", UserSubject(43)).Count(&count).Error)
assert.Equal(t, int64(0), count)
}
func TestAdapterAddPolicyIsIdempotent(t *testing.T) {
db := newAuthzTestDB(t)
adapter := newGormAdapter(db)
rule := []string{UserSubject(55), ResourceChannel, ActionSensitiveWrite, EffectAllow}
require.NoError(t, adapter.AddPolicy("p", "p", rule))
require.NoError(t, adapter.AddPolicy("p", "p", rule))
var count int64
require.NoError(t, db.Model(&model.CasbinRule{}).Where(
"ptype = ? AND v0 = ? AND v1 = ? AND v2 = ? AND v3 = ?",
"p",
UserSubject(55),
ResourceChannel,
ActionSensitiveWrite,
EffectAllow,
).Count(&count).Error)
assert.Equal(t, int64(1), count)
}
func TestCapabilitiesUseCatalogShape(t *testing.T) {
db := newAuthzTestDB(t)
require.NoError(t, Init(db))
capabilities := Capabilities(7, common.RoleAdminUser)
assert.True(t, capabilities[ResourceChannel][ActionRead])
assert.True(t, capabilities[ResourceChannel][ActionOperate])
assert.True(t, capabilities[ResourceChannel][ActionWrite])
assert.False(t, capabilities[ResourceChannel][ActionSensitiveWrite])
assert.False(t, capabilities[ResourceChannel][ActionSecretView])
}
package authz
import (
"fmt"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/casbin/casbin/v2"
casbinmodel "github.com/casbin/casbin/v2/model"
"gorm.io/gorm"
)
var (
enforcerMu sync.RWMutex
enforcer *casbin.SyncedEnforcer
)
const modelText = `
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act, eft
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = r.sub == p.sub && r.obj == p.obj && r.act == p.act && p.eft == "allow"
`
func Init(db *gorm.DB) error {
if common.IsMasterNode {
if err := seedBuiltInRoles(db); err != nil {
return err
}
if err := resetBuiltInRolePolicies(db); err != nil {
return err
}
}
m, err := casbinmodel.NewModelFromString(modelText)
if err != nil {
return err
}
e, err := casbin.NewSyncedEnforcer(m, newGormAdapter(db))
if err != nil {
return err
}
e.EnableAutoSave(true)
enforcerMu.Lock()
enforcer = e
enforcerMu.Unlock()
if !common.IsMasterNode {
return nil
}
return seedDefaultPolicies()
}
func currentEnforcer() *casbin.SyncedEnforcer {
enforcerMu.RLock()
defer enforcerMu.RUnlock()
return enforcer
}
func ReloadPolicy() error {
enforcerMu.Lock()
defer enforcerMu.Unlock()
if enforcer == nil {
return fmt.Errorf("authz enforcer is not initialized")
}
return enforcer.LoadPolicy()
}
// StartPolicySync periodically reloads the authorization policy from the database.
// The enforcer keeps an in-memory snapshot, and permission changes are written
// straight to the DB (see SetUserPermissionsInTx) with only the local node's
// snapshot refreshed afterwards. Without this loop other instances in a
// multi-node deployment would keep serving stale permissions (including not
// honoring a revoked grant) until restart. Mirrors model.SyncOptions polling.
func StartPolicySync(frequency int) {
if frequency <= 0 {
return
}
for {
time.Sleep(time.Duration(frequency) * time.Second)
if err := ReloadPolicy(); err != nil {
common.SysError("failed to reload authz policy: " + err.Error())
}
}
}
package authz
import (
"fmt"
"sort"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/casbin/casbin/v2"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type overridePolicy struct {
Resource string
Action string
Effect string
}
func SetUserPermissions(userID int, permissions PermissionsMap) error {
e := currentEnforcer()
if e == nil {
return fmt.Errorf("authz enforcer is not initialized")
}
for resource, actions := range permissions {
if !isKnownResource(resource) {
continue
}
if _, err := e.RemoveFilteredPolicy(0, UserSubject(userID), resource); err != nil {
return err
}
for _, policy := range userOverridePolicies(e, resource, actions) {
if _, err := e.AddPolicy(UserSubject(userID), policy.Resource, policy.Action, policy.Effect); err != nil {
return err
}
}
}
return nil
}
func SetUserPermissionsInTx(tx *gorm.DB, userID int, permissions PermissionsMap) error {
e := currentEnforcer()
if e == nil {
return fmt.Errorf("authz enforcer is not initialized")
}
for resource, actions := range permissions {
if !isKnownResource(resource) {
continue
}
if err := tx.Where("ptype = ? AND v0 = ? AND v1 = ?", "p", UserSubject(userID), resource).Delete(&model.CasbinRule{}).Error; err != nil {
return err
}
policies := userOverridePolicies(e, resource, actions)
if len(policies) == 0 {
continue
}
rules := make([]model.CasbinRule, 0, len(policies))
for _, policy := range policies {
rules = append(rules, newRule("p", []string{UserSubject(userID), policy.Resource, policy.Action, policy.Effect}))
}
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&rules).Error; err != nil {
return err
}
}
return nil
}
func ClearUserPermissions(userID int) error {
e := currentEnforcer()
if e == nil {
return fmt.Errorf("authz enforcer is not initialized")
}
for _, resource := range registry {
if _, err := e.RemoveFilteredPolicy(0, UserSubject(userID), resource.Resource); err != nil {
return err
}
}
return nil
}
func ClearUserPermissionsInTx(tx *gorm.DB, userID int) error {
for _, resource := range registry {
if err := tx.Where("ptype = ? AND v0 = ? AND v1 = ?", "p", UserSubject(userID), resource.Resource).Delete(&model.CasbinRule{}).Error; err != nil {
return err
}
}
return nil
}
func ClearUserAuthorization(userID int) error {
return ClearUserPermissions(userID)
}
func ClearUserAuthorizationInTx(tx *gorm.DB, userID int) error {
return ClearUserPermissionsInTx(tx, userID)
}
// ExplicitUserPermissions returns the effective permission matrix for the
// managed role plus any per-user overrides.
func ExplicitUserPermissions(userID int) PermissionsMap {
return Capabilities(userID, common.RoleAdminUser)
}
// ExplicitUserOverrides returns only the per-user override entries.
func ExplicitUserOverrides(userID int) PermissionsMap {
e := currentEnforcer()
if e == nil {
return PermissionsMap{}
}
result := PermissionsMap{}
for _, resource := range registry {
policies, err := e.GetFilteredPolicy(0, UserSubject(userID), resource.Resource)
if err != nil {
return PermissionsMap{}
}
actions := make(map[string]bool, len(policies))
for _, policy := range policies {
if len(policy) >= 3 && isKnownPermission(Permission{Resource: policy[1], Action: policy[2]}) {
effect := policyEffect(policy)
if effect == EffectAllow || effect == EffectDeny {
actions[policy[2]] = effect == EffectAllow
}
}
}
if len(actions) > 0 {
result[resource.Resource] = actions
}
}
return result
}
// userOverridePolicies returns the override entries that differ from the managed
// role baseline; entries matching the baseline are omitted.
func userOverridePolicies(e *casbin.SyncedEnforcer, resource string, actions map[string]bool) []overridePolicy {
overrides := make([]overridePolicy, 0, len(actions))
for _, action := range catalogActions(resource) {
desired, ok := actions[action.Action]
if !ok {
continue
}
permission := Permission{Resource: resource, Action: action.Action}
if desired == roleBaselineAllows(e, managedRoleKey, permission) {
continue
}
effect := EffectDeny
if desired {
effect = EffectAllow
}
overrides = append(overrides, overridePolicy{
Resource: resource,
Action: action.Action,
Effect: effect,
})
}
sort.Slice(overrides, func(i, j int) bool {
return overrides[i].Action < overrides[j].Action
})
return overrides
}
package authz
import "strconv"
// Permission identifies a single action on a resource.
type Permission struct {
Resource string
Action string
}
// PermissionsMap is a resource -> action -> allowed lookup.
type PermissionsMap map[string]map[string]bool
const (
EffectAllow = "allow"
EffectDeny = "deny"
)
// UserSubject is the casbin subject string for a single user.
func UserSubject(userID int) string {
return "user:" + strconv.Itoa(userID)
}
// RoleSubject is the casbin subject string for a role.
func RoleSubject(roleKey string) string {
return "role:" + roleKey
}
package authz
// ActionDefinition describes a single action exposed by a resource. DefaultRoles
// lists the role keys that receive this action as part of their baseline grants.
type ActionDefinition struct {
Action string `json:"action"`
LabelKey string `json:"label_key"`
DescriptionKey string `json:"description_key"`
DefaultRoles []string `json:"-"`
}
// ResourceDefinition describes a resource and the actions it exposes.
type ResourceDefinition struct {
Resource string `json:"resource"`
LabelKey string `json:"label_key"`
Actions []ActionDefinition `json:"actions"`
}
var registry []ResourceDefinition
// RegisterResource adds a resource definition to the permission registry.
func RegisterResource(resource ResourceDefinition) {
registry = append(registry, resource)
}
// Catalog returns a copy of the registered resource definitions.
func Catalog() []ResourceDefinition {
result := make([]ResourceDefinition, 0, len(registry))
for _, resource := range registry {
result = append(result, ResourceDefinition{
Resource: resource.Resource,
LabelKey: resource.LabelKey,
Actions: append([]ActionDefinition(nil), resource.Actions...),
})
}
return result
}
// AllPermissions returns every registered permission.
func AllPermissions() []Permission {
permissions := make([]Permission, 0)
for _, resource := range registry {
for _, action := range resource.Actions {
permissions = append(permissions, Permission{
Resource: resource.Resource,
Action: action.Action,
})
}
}
return permissions
}
// PermissionsForRole returns the permissions whose DefaultRoles include roleKey.
func PermissionsForRole(roleKey string) []Permission {
permissions := make([]Permission, 0)
for _, resource := range registry {
for _, action := range resource.Actions {
if actionHasRole(action, roleKey) {
permissions = append(permissions, Permission{
Resource: resource.Resource,
Action: action.Action,
})
}
}
}
return permissions
}
func actionHasRole(action ActionDefinition, roleKey string) bool {
for _, r := range action.DefaultRoles {
if r == roleKey {
return true
}
}
return false
}
func isKnownResource(resource string) bool {
for _, known := range registry {
if known.Resource == resource {
return true
}
}
return false
}
func catalogActions(resource string) []ActionDefinition {
for _, known := range registry {
if known.Resource == resource {
return known.Actions
}
}
return nil
}
func isKnownPermission(permission Permission) bool {
for _, resource := range registry {
if resource.Resource != permission.Resource {
continue
}
for _, action := range resource.Actions {
if action.Action == permission.Action {
return true
}
}
}
return false
}
package authz
import "github.com/casbin/casbin/v2"
// Can reports whether the subject may perform the permission. A superuser role
// short-circuits to allow. Otherwise a per-user override wins, then the union of
// the subject's role baselines applies.
func Can(userID int, systemRole int, permission Permission) bool {
roles := resolveSubjectRoles(userID, systemRole)
if len(roles) == 0 {
return false
}
for _, role := range roles {
if isSuperuserRole(role) {
return true
}
}
if !isKnownPermission(permission) {
return false
}
e := currentEnforcer()
if e == nil {
return false
}
if effect, ok := explicitSubjectEffect(e, UserSubject(userID), permission); ok {
return effect == EffectAllow
}
for _, role := range roles {
if roleBaselineAllows(e, role, permission) {
return true
}
}
return false
}
// Capabilities returns the full resource/action matrix the subject is allowed.
func Capabilities(userID int, systemRole int) PermissionsMap {
result := make(PermissionsMap, len(registry))
for _, resource := range registry {
actions := make(map[string]bool, len(resource.Actions))
for _, action := range resource.Actions {
actions[action.Action] = Can(userID, systemRole, Permission{
Resource: resource.Resource,
Action: action.Action,
})
}
result[resource.Resource] = actions
}
return result
}
func roleBaselineAllows(e *casbin.SyncedEnforcer, roleKey string, permission Permission) bool {
effect, ok := explicitSubjectEffect(e, RoleSubject(roleKey), permission)
return ok && effect == EffectAllow
}
func explicitSubjectEffect(e *casbin.SyncedEnforcer, subject string, permission Permission) (string, bool) {
policies, err := e.GetFilteredPolicy(0, subject, permission.Resource, permission.Action)
if err != nil {
return "", false
}
hasAllow := false
for _, policy := range policies {
switch policyEffect(policy) {
case EffectDeny:
return EffectDeny, true
case EffectAllow:
hasAllow = true
}
}
if hasAllow {
return EffectAllow, true
}
return "", false
}
func policyEffect(policy []string) string {
if len(policy) < 4 || policy[3] == "" {
return EffectAllow
}
return policy[3]
}
package authz
const (
ResourceChannel = "channel"
ActionRead = "read"
ActionOperate = "operate"
ActionWrite = "write"
ActionSensitiveWrite = "sensitive_write"
ActionSecretView = "secret_view"
)
var (
ChannelRead = Permission{Resource: ResourceChannel, Action: ActionRead}
ChannelOperate = Permission{Resource: ResourceChannel, Action: ActionOperate}
ChannelWrite = Permission{Resource: ResourceChannel, Action: ActionWrite}
ChannelSensitiveWrite = Permission{Resource: ResourceChannel, Action: ActionSensitiveWrite}
ChannelSecretView = Permission{Resource: ResourceChannel, Action: ActionSecretView}
)
func init() {
RegisterResource(ResourceDefinition{
Resource: ResourceChannel,
LabelKey: "Channel Management",
Actions: []ActionDefinition{
{
Action: ActionRead,
LabelKey: "Read channels",
DescriptionKey: "View channel lists and details without secrets.",
DefaultRoles: []string{BuiltInRoleAdmin},
},
{
Action: ActionOperate,
LabelKey: "Operate channels",
DescriptionKey: "Test channels, refresh balances, and enable/disable individual, batch, or tagged channels.",
DefaultRoles: []string{BuiltInRoleAdmin},
},
{
Action: ActionWrite,
LabelKey: "Edit channel routing",
DescriptionKey: "Edit non-sensitive settings such as models, groups, and routing rules.",
DefaultRoles: []string{BuiltInRoleAdmin},
},
{
Action: ActionSensitiveWrite,
LabelKey: "Edit sensitive channel settings",
DescriptionKey: "Create channels or edit keys, base URLs, and overrides.",
},
{
Action: ActionSecretView,
LabelKey: "View channel secrets",
DescriptionKey: "Reserved for viewing complete channel keys after secure verification.",
},
},
})
}
package authz
const (
BuiltInRoleRoot = "root"
BuiltInRoleAdmin = "admin"
)
// RoleSpec describes a role. A superuser role is allowed every permission
// without an explicit policy entry.
type RoleSpec struct {
Key string
Name string
Description string
BuiltIn bool
Superuser bool
Sort int
}
var builtInRoles = []RoleSpec{
{
Key: BuiltInRoleRoot,
Name: "Root",
Description: "Built-in root authorization role",
BuiltIn: true,
Superuser: true,
Sort: 0,
},
{
Key: BuiltInRoleAdmin,
Name: "Admin",
Description: "Built-in admin authorization role",
BuiltIn: true,
Superuser: false,
Sort: 10,
},
}
// RoleDescriptor exposes a role together with its baseline grant matrix.
type RoleDescriptor struct {
Key string `json:"key"`
Name string `json:"name"`
BuiltIn bool `json:"built_in"`
Superuser bool `json:"superuser"`
Grants PermissionsMap `json:"grants"`
}
// Roles returns the role descriptors with their baseline grants.
func Roles() []RoleDescriptor {
result := make([]RoleDescriptor, 0, len(builtInRoles))
for _, spec := range builtInRoles {
result = append(result, RoleDescriptor{
Key: spec.Key,
Name: spec.Name,
BuiltIn: spec.BuiltIn,
Superuser: spec.Superuser,
Grants: roleGrants(spec),
})
}
return result
}
func roleGrants(spec RoleSpec) PermissionsMap {
grants := make(PermissionsMap, len(registry))
for _, resource := range registry {
actions := make(map[string]bool, len(resource.Actions))
for _, action := range resource.Actions {
actions[action.Action] = spec.Superuser || actionHasRole(action, spec.Key)
}
grants[resource.Resource] = actions
}
return grants
}
func roleSpec(roleKey string) (RoleSpec, bool) {
for _, spec := range builtInRoles {
if spec.Key == roleKey {
return spec, true
}
}
return RoleSpec{}, false
}
func isSuperuserRole(roleKey string) bool {
spec, ok := roleSpec(roleKey)
return ok && spec.Superuser
}
package authz
import (
"fmt"
"github.com/QuantumNous/new-api/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func seedBuiltInRoles(db *gorm.DB) error {
for _, spec := range builtInRoles {
role := model.AuthzRole{
Key: spec.Key,
Name: spec.Name,
Description: spec.Description,
BuiltIn: spec.BuiltIn,
Enabled: true,
Sort: spec.Sort,
}
if err := db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{
"name",
"description",
"built_in",
"enabled",
"sort",
}),
}).Create(&role).Error; err != nil {
return err
}
}
return nil
}
func resetBuiltInRolePolicies(db *gorm.DB) error {
subjects := make([]string, 0, len(builtInRoles))
for _, spec := range builtInRoles {
subjects = append(subjects, RoleSubject(spec.Key))
}
return db.Where("ptype = ? AND v0 IN ?", "p", subjects).Delete(&model.CasbinRule{}).Error
}
func seedDefaultPolicies() error {
e := currentEnforcer()
if e == nil {
return fmt.Errorf("authz enforcer is not initialized")
}
for _, spec := range builtInRoles {
if spec.Superuser {
continue
}
for _, permission := range PermissionsForRole(spec.Key) {
if _, err := e.AddPolicy(RoleSubject(spec.Key), permission.Resource, permission.Action, EffectAllow); err != nil {
return err
}
}
}
return nil
}
......@@ -139,6 +139,36 @@ export async function updateChannel(
}
/**
* Update channel enabled/disabled status.
*/
export async function updateChannelStatus(
id: number,
status: number
): Promise<{ success: boolean; message?: string; data?: boolean }> {
const res = await api.post(
`/api/channel/${id}/status`,
{ status },
channelActionConfig()
)
return res.data
}
/**
* Batch update channel enabled/disabled status.
*/
export async function batchUpdateChannelStatus(
ids: number[],
status: number
): Promise<{ success: boolean; message?: string; data?: number }> {
const res = await api.post(
'/api/channel/status/batch',
{ ids, status },
channelActionConfig()
)
return res.data
}
/**
* Delete single channel
*/
export async function deleteChannel(
......
......@@ -33,6 +33,12 @@ import {
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,
DropdownMenuCheckboxItem,
......@@ -43,6 +49,11 @@ import {
} from '@/components/ui/dropdown-menu'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { ConfirmDialog } from '@/components/confirm-dialog'
import {
handleDeleteAllDisabled,
......@@ -65,6 +76,12 @@ export function ChannelsPrimaryButtons() {
} = useChannels()
const queryClient = useQueryClient()
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const currentUser = useAuthStore((s) => s.auth.user)
const canEditSensitive = hasPermission(
currentUser,
ADMIN_PERMISSION_RESOURCES.CHANNEL,
ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE
)
const handleTagModeToggle = (checked: boolean) => {
localStorage.setItem('enable-tag-mode', String(checked))
......@@ -105,17 +122,28 @@ export function ChannelsPrimaryButtons() {
</div>
{/* Create Channel */}
<Tooltip>
<TooltipTrigger render={<span className='inline-flex' />}>
<Button
onClick={() => {
if (!canEditSensitive) return
setCurrentRow(null)
setOpen('create-channel')
}}
size='sm'
disabled={!canEditSensitive}
>
<Plus className='h-4 w-4' />
<span className='max-sm:hidden'>{t('Create Channel')}</span>
<span className='sm:hidden'>{t('Create')}</span>
</Button>
</TooltipTrigger>
{!canEditSensitive && (
<TooltipContent>
{t('No permission to perform this action')}
</TooltipContent>
)}
</Tooltip>
{/* More Actions */}
<DropdownMenu>
......@@ -209,8 +237,10 @@ export function ChannelsPrimaryButtons() {
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
if (!canEditSensitive) return
setShowDeleteDialog(true)
}}
disabled={!canEditSensitive}
className='text-destructive focus:text-destructive'
>
{t('Delete All Disabled')}
......@@ -231,6 +261,7 @@ export function ChannelsPrimaryButtons() {
)}
destructive
handleConfirm={() => {
if (!canEditSensitive) return
handleDeleteAllDisabled(queryClient, (_count) => {
// eslint-disable-next-line no-console
console.log(`Deleted ${_count} channels`)
......
......@@ -24,6 +24,13 @@ import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useAuthStore } from '@/stores/auth-store'
import {
ADMIN_PERMISSION_ACTIONS,
ADMIN_PERMISSION_RESOURCES,
hasPermission,
} from '@/lib/admin-permissions'
import { cn } from '@/lib/utils'
import {
Tooltip,
TooltipContent,
......@@ -51,6 +58,12 @@ export function DataTableBulkActions<TData>({
const [showTagDialog, setShowTagDialog] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [tagValue, setTagValue] = useState('')
const currentUser = useAuthStore((s) => s.auth.user)
const canEditSensitive = hasPermission(
currentUser,
ADMIN_PERMISSION_RESOURCES.CHANNEL,
ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE
)
const selectedRows = table.getFilteredSelectedRowModel().rows
const selectedIds = selectedRows.reduce<number[]>((ids, row) => {
......@@ -76,6 +89,7 @@ export function DataTableBulkActions<TData>({
}
const handleDeleteAll = () => {
if (!canEditSensitive) return
handleBatchDelete(selectedIds, queryClient, () => {
setShowDeleteConfirm(false)
handleClearSelection()
......@@ -164,10 +178,21 @@ export function DataTableBulkActions<TData>({
<Button
variant='destructive'
size='icon'
onClick={() => setShowDeleteConfirm(true)}
className='size-8'
onClick={() => {
if (!canEditSensitive) return
setShowDeleteConfirm(true)
}}
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')
}
/>
}
>
......@@ -175,7 +200,11 @@ export function DataTableBulkActions<TData>({
<span className='sr-only'>{t('Delete selected channels')}</span>
</TooltipTrigger>
<TooltipContent>
<p>{t('Delete selected channels')}</p>
<p>
{canEditSensitive
? t('Delete selected channels')
: t('No permission to perform this action')}
</p>
</TooltipContent>
</Tooltip>
</BulkActionsToolbar>
......@@ -243,7 +272,11 @@ export function DataTableBulkActions<TData>({
>
{t('Cancel')}
</Button>
<Button variant='destructive' onClick={handleDeleteAll}>
<Button
variant='destructive'
onClick={handleDeleteAll}
disabled={!canEditSensitive}
>
{t('Delete')}
</Button>
</>
......
......@@ -40,6 +40,12 @@ import { useTranslation } from 'react-i18next'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Button } from '@/components/ui/button'
import {
ADMIN_PERMISSION_ACTIONS,
ADMIN_PERMISSION_RESOURCES,
hasPermission,
} from '@/lib/admin-permissions'
import { useAuthStore } from '@/stores/auth-store'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
......@@ -77,12 +83,18 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
const channel = row.original
const { setOpen, setCurrentRow, upstream } = useChannels()
const queryClient = useQueryClient()
const currentUser = useAuthStore((s) => s.auth.user)
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [isTesting, setIsTesting] = useState(false)
const [isTogglingStatus, setIsTogglingStatus] = useState(false)
const isEnabled = isChannelEnabled(channel)
const isMultiKey = isMultiKeyChannel(channel)
const canEditSensitive = hasPermission(
currentUser,
ADMIN_PERMISSION_RESOURCES.CHANNEL,
ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE
)
const handleEdit = () => {
setCurrentRow(channel)
......@@ -314,12 +326,20 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
<DropdownMenuSeparator />
{/* Copy Channel */}
<DropdownMenuItem onClick={handleCopy}>
<DropdownMenuItem
disabled={!canEditSensitive}
onClick={canEditSensitive ? handleCopy : undefined}
>
{t('Copy Channel')}
<DropdownMenuShortcut>
<Copy size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
{!canEditSensitive && (
<DropdownMenuItem disabled className='text-xs normal-case'>
{t('No permission to perform this action')}
</DropdownMenuItem>
)}
{/* Manage Keys (only for multi-key channels) */}
{isMultiKey && (
......@@ -335,8 +355,10 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
{/* Delete */}
<DropdownMenuItem
disabled={!canEditSensitive}
onSelect={(e) => {
e.preventDefault()
if (!canEditSensitive) return
setDeleteConfirmOpen(true)
}}
className='text-destructive focus:text-destructive'
......@@ -360,6 +382,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
confirmText={t('Delete')}
destructive
handleConfirm={() => {
if (!canEditSensitive) return
handleDeleteChannel(channel.id, queryClient)
setDeleteConfirmOpen(false)
}}
......
......@@ -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({
<Button
variant='destructive'
size='sm'
onClick={() => setConfirmAction({ type: 'delete-disabled' })}
onClick={() => {
if (!canEditSensitive) return
setConfirmAction({ type: 'delete-disabled' })
}}
disabled={!canEditSensitive}
title={
canEditSensitive
? undefined
: t('No permission to perform this action')
}
>
<Trash2 className='mr-2 h-4 w-4' />
{t('Delete Auto-Disabled')}
......@@ -339,6 +368,11 @@ export function MultiKeyManageDialog({
)}
</div>
</div>
{!canEditSensitive && (
<p className='text-muted-foreground text-xs'>
{t('No permission to perform this action')}
</p>
)}
{/* Table */}
<div className='min-h-0 flex-1 overflow-auto rounded-md border'>
......@@ -392,6 +426,7 @@ export function MultiKeyManageDialog({
<MultiKeyTableRowActions
keyIndex={key.index}
status={key.status}
canDelete={canEditSensitive}
onAction={setConfirmAction}
/>
),
......
......@@ -23,12 +23,14 @@ import type { MultiKeyConfirmAction } from '../../types'
type MultiKeyTableRowActionsProps = {
keyIndex: number
status: number
canDelete: boolean
onAction: (action: MultiKeyConfirmAction) => void
}
export function MultiKeyTableRowActions({
keyIndex,
status,
canDelete,
onAction,
}: MultiKeyTableRowActionsProps) {
const { t } = useTranslation()
......@@ -56,7 +58,16 @@ export function MultiKeyTableRowActions({
<Button
variant='destructive'
size='sm'
onClick={() => onAction({ type: 'delete', keyIndex })}
onClick={() => {
if (!canDelete) return
onAction({ type: 'delete', keyIndex })
}}
disabled={!canDelete}
title={
canDelete
? undefined
: t('No permission to perform this action')
}
>
{t('Delete')}
</Button>
......
......@@ -19,6 +19,12 @@ For commercial licensing, please contact support@quantumnous.com
import { useMutation } from '@tanstack/react-query'
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 { createChannel, updateChannel } from '../api'
import { ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
import {
......@@ -35,6 +41,18 @@ type UseChannelMutateFormParams = {
onSuccess: () => void
}
const SENSITIVE_UPDATE_FIELDS = [
'type',
'key',
'base_url',
'openai_organization',
'param_override',
'header_override',
'setting',
'settings',
'other',
] satisfies (keyof Channel)[]
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
......@@ -62,6 +80,12 @@ function getErrorMessage(error: unknown): string | undefined {
export function useChannelMutateForm(props: UseChannelMutateFormParams) {
const { t } = useTranslation()
const currentUser = useAuthStore((s) => s.auth.user)
const canEditSensitive = hasPermission(
currentUser,
ADMIN_PERMISSION_RESOURCES.CHANNEL,
ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE
)
return useMutation({
mutationFn: async (data: ChannelFormValues): Promise<string> => {
......@@ -70,8 +94,19 @@ export function useChannelMutateForm(props: UseChannelMutateFormParams) {
data,
props.currentRow.id
)
if (!data.key?.trim()) {
delete payload.key
}
if (!canEditSensitive) {
for (const field of SENSITIVE_UPDATE_FIELDS) {
delete payload[field]
}
}
const payloadWithKeyMode =
props.isMultiKeyChannel && data.key_mode
canEditSensitive &&
props.isMultiKeyChannel &&
data.key?.trim() &&
data.key_mode
? {
...payload,
key_mode: data.key_mode,
......
......@@ -25,6 +25,8 @@ import {
deleteChannel,
testChannel,
updateChannel,
updateChannelStatus,
batchUpdateChannelStatus,
batchDeleteChannels,
batchSetChannelTag,
enableTagChannels,
......@@ -119,7 +121,7 @@ export async function handleEnableChannel(
onSuccess?: () => void
): Promise<void> {
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<void> {
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,
......
......@@ -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 || '',
......
......@@ -17,6 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
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<ApiResponse<string[]>> {
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<PermissionCatalog> {
const res = await api.get('/api/authz/catalog')
return {
resources: res.data?.data?.resources ?? [],
roles: res.data?.data?.roles ?? [],
}
}
// ============================================================================
// Admin Binding Management APIs
// ============================================================================
......
......@@ -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_RESOURCES,
EMPTY_PERMISSION_CATALOG,
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,
......@@ -62,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,
......@@ -89,6 +105,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)
......@@ -101,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<UserFormValues>({
resolver: zodResolver(userFormSchema),
defaultValues: USER_FORM_DEFAULT_VALUES,
......@@ -126,6 +150,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) {
......@@ -141,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)
......@@ -417,6 +448,92 @@ export function UsersMutateDrawer({
</SideDrawerSection>
)}
{canEditAdminPermissions &&
targetIsAdmin &&
permissionCatalog.resources.length > 0 && (
<SideDrawerSection>
<h3 className='text-sm font-medium'>
{t('Admin Permissions')}
</h3>
<p className='text-muted-foreground text-xs'>
{t(
'Default administrator permissions can be overridden for this user.'
)}
</p>
<FormField
control={form.control}
name='admin_permissions'
render={({ field }) => {
const selected = normalizeAdminPermissions(
field.value,
permissionCatalog
)
return (
<FormItem>
<div className='space-y-3'>
{permissionCatalog.resources.map((resource) => (
<div
key={resource.resource}
className='space-y-2 rounded-md border p-3'
>
<div className='text-sm font-medium'>
{t(resource.label_key)}
</div>
<div className='space-y-2'>
{resource.actions.map((option) => (
<label
key={option.action}
className='flex items-start gap-3'
>
<Checkbox
checked={
selected[resource.resource]?.[
option.action
] === true
}
onCheckedChange={(checked) => {
field.onChange({
...selected,
[resource.resource]: {
...selected[resource.resource],
[option.action]: checked === true,
},
})
}}
/>
<span className='flex flex-col gap-1'>
<span className='text-sm font-medium'>
{t(option.label_key)}
</span>
<span className='text-muted-foreground text-xs'>
{t(option.description_key)}
</span>
</span>
</label>
))}
</div>
</div>
))}
</div>
<FormMessage />
</FormItem>
)
}}
/>
{currentUser && (
<p className='text-muted-foreground text-xs'>
{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.')}
</p>
)}
</SideDrawerSection>
)}
{/* Binding Information (Read-only) */}
{isUpdate && (
<SideDrawerSection>
......
......@@ -18,6 +18,12 @@ 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'
import { ROLE } from '@/lib/roles'
import { DEFAULT_GROUP } from '../constants'
import { type UserFormData, type User } from '../types'
......@@ -33,6 +39,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<typeof userFormSchema>
......@@ -49,6 +56,8 @@ export const USER_FORM_DEFAULT_VALUES: UserFormValues = {
quota_dollars: 0,
group: DEFAULT_GROUP,
remark: '',
// Filled against the backend catalog at render time; see UsersMutateDrawer.
admin_permissions: {},
}
// ============================================================================
......@@ -60,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,
......@@ -68,9 +78,21 @@ 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
payload.role = role
} else {
// For update: quota is adjusted atomically via /api/user/manage, not sent here
payload.group = data.group
......@@ -82,7 +104,9 @@ export function transformFormDataToPayload(
}
/**
* 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 {
......@@ -93,5 +117,6 @@ export function transformUserToFormDefaults(user: User): UserFormValues {
quota_dollars: quotaUnitsToDollars(user.quota),
group: user.group || DEFAULT_GROUP,
remark: user.remark || '',
admin_permissions: user.admin_permissions ?? {},
}
}
......@@ -17,6 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
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<typeof userSchema>
......@@ -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 =
......
import { ROLE } from './roles'
import type { AuthUser } from '@/stores/auth-store'
export type AdminPermissionMatrix = Record<string, Record<string, boolean>>
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
// 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,
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
}
// 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,
catalog: PermissionCatalog
): AdminPermissionMatrix {
const baseline = roleGrants(catalog, ADMIN_ROLE_KEY)
const normalized: AdminPermissionMatrix = {}
for (const resource of catalog.resources) {
const actions: Record<string, boolean> = {}
for (const action of resource.actions) {
actions[action.action] =
value?.[resource.resource]?.[action.action] ??
baseline[resource.resource]?.[action.action] ??
false
}
normalized[resource.resource] = actions
}
return normalized
}
......@@ -17,10 +17,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
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<string, unknown>
admin_permissions?: AdminCapabilities
}
export interface AuthUser {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment