* 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
205 lines
6.3 KiB
Go
205 lines
6.3 KiB
Go
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)
|
|
}
|
|
}
|