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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions common/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,14 +184,15 @@ const (
)

const (
RoleGuestUser = 0
RoleCommonUser = 1
RoleAdminUser = 10
RoleRootUser = 100
RoleGuestUser = 0
RoleCommonUser = 1
RoleReadOnlyAdminUser = 9
RoleAdminUser = 10
RoleRootUser = 100
)

func IsValidateRole(role int) bool {
return role == RoleGuestUser || role == RoleCommonUser || role == RoleAdminUser || role == RoleRootUser
return role == RoleGuestUser || role == RoleCommonUser || role == RoleReadOnlyAdminUser || role == RoleAdminUser || role == RoleRootUser
}

var (
Expand Down
12 changes: 10 additions & 2 deletions controller/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -455,7 +455,7 @@ func calculateUserPermissions(userRole int) map[string]interface{} {
// 超级管理员不需要边栏设置功能
permissions["sidebar_settings"] = false
permissions["sidebar_modules"] = map[string]interface{}{}
} else if userRole == common.RoleAdminUser {
} else if userRole == common.RoleAdminUser || userRole == common.RoleReadOnlyAdminUser {
// 管理员可以设置边栏,但不包含系统设置功能
permissions["sidebar_settings"] = true
permissions["sidebar_modules"] = map[string]interface{}{
Expand Down Expand Up @@ -503,7 +503,7 @@ func generateDefaultSidebarConfig(userRole int) string {
}

// 管理员区域 - 根据角色决定
if userRole == common.RoleAdminUser {
if userRole == common.RoleAdminUser || userRole == common.RoleReadOnlyAdminUser {
// 管理员可以访问管理员区域,但不能访问系统设置
defaultConfig["admin"] = map[string]interface{}{
"enabled": true,
Expand Down Expand Up @@ -599,6 +599,14 @@ func UpdateUser(c *gin.Context) {
common.ApiError(c, err)
return
}
if originUser.Role != updatedUser.Role {
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()))
}
if err := model.InvalidateUserTokensCache(updatedUser.Id); err != nil {
common.SysLog(fmt.Sprintf("failed to invalidate tokens cache for user %d: %s", updatedUser.Id, err.Error()))
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
Expand Down
42 changes: 40 additions & 2 deletions middleware/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,39 @@ func validUserInfo(username string, role int) bool {
return true
}

func isReadOnlyAdminAllowed(c *gin.Context) bool {
switch c.Request.Method {
case http.MethodHead, http.MethodOptions:
return true
case http.MethodGet:
path := c.Request.URL.Path
blockedGetPaths := []string{
"/api/status/test",
"/api/user/token",
"/api/user/aff",
"/api/channel/test",
"/api/channel/update_balance",
"/api/channel/fetch_models",
}
for _, blockedPath := range blockedGetPaths {
if strings.HasPrefix(path, blockedPath) {
return false
}
}
return true
default:
return false
}
}

func rejectReadOnlyAdminMutation(c *gin.Context) {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "read-only administrator can only view data",
})
c.Abort()
}

func authHelper(c *gin.Context, minRole int) {
session := sessions.Default(c)
username := session.Get("username")
Expand Down Expand Up @@ -128,15 +161,16 @@ func authHelper(c *gin.Context, minRole int) {
c.Abort()
return
}
if role.(int) < minRole {
userRole := role.(int)
if userRole < minRole && !(minRole == common.RoleAdminUser && userRole == common.RoleReadOnlyAdminUser) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgAuthInsufficientPrivilege),
})
c.Abort()
return
}
if !validUserInfo(username.(string), role.(int)) {
if !validUserInfo(username.(string), userRole) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgAuthUserInfoInvalid),
Expand All @@ -145,6 +179,10 @@ func authHelper(c *gin.Context, minRole int) {
return
}
// 防止不同newapi版本冲突,导致数据不通用
if userRole == common.RoleReadOnlyAdminUser && !isReadOnlyAdminAllowed(c) {
rejectReadOnlyAdminMutation(c)
return
}
c.Header("Auth-Version", "864b7076dbcd0a3c01b5520316720ebf")
c.Set("username", username)
c.Set("role", role)
Expand Down
43 changes: 43 additions & 0 deletions middleware/readonly_admin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package middleware

import (
"net/http"
"net/http/httptest"
"testing"

"github.com/gin-gonic/gin"
)

func TestIsReadOnlyAdminAllowed(t *testing.T) {
gin.SetMode(gin.TestMode)

tests := []struct {
name string
method string
path string
allowed bool
}{
{name: "allows ordinary get", method: http.MethodGet, path: "/api/user/self", allowed: true},
{name: "allows admin list get", method: http.MethodGet, path: "/api/user/?p=0", allowed: true},
{name: "allows head", method: http.MethodHead, path: "/api/user/self", allowed: true},
{name: "allows options", method: http.MethodOptions, path: "/api/user/self", allowed: true},
{name: "blocks post", method: http.MethodPost, path: "/api/user/", allowed: false},
{name: "blocks put", method: http.MethodPut, path: "/api/user/", allowed: false},
{name: "blocks delete", method: http.MethodDelete, path: "/api/user/1", allowed: false},
{name: "blocks status test get", method: http.MethodGet, path: "/api/status/test", allowed: false},
{name: "blocks channel test get", method: http.MethodGet, path: "/api/channel/test/1", allowed: false},
{name: "blocks fetch models get", method: http.MethodGet, path: "/api/channel/fetch_models/1", allowed: false},
{name: "blocks update balance get", method: http.MethodGet, path: "/api/channel/update_balance/1", allowed: false},
}
Comment on lines +20 to +31

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add the two blocked /api/user/* GET cases to this table.

isReadOnlyAdminAllowed() now rejects /api/user/token and /api/user/aff, but this test never asserts either path. A regression there would weaken the new read-only-admin restriction without failing CI.

Suggested change
 		{name: "blocks status test get", method: http.MethodGet, path: "/api/status/test", allowed: false},
+		{name: "blocks token generate get", method: http.MethodGet, path: "/api/user/token", allowed: false},
+		{name: "blocks aff generate get", method: http.MethodGet, path: "/api/user/aff", allowed: false},
 		{name: "blocks channel test get", method: http.MethodGet, path: "/api/channel/test/1", allowed: false},
 		{name: "blocks fetch models get", method: http.MethodGet, path: "/api/channel/fetch_models/1", allowed: false},
 		{name: "blocks update balance get", method: http.MethodGet, path: "/api/channel/update_balance/1", allowed: false},

Based on PR objectives, token generation and aff-code generation are part of the blocked side-effect GET contract for read-only admins.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{name: "allows ordinary get", method: http.MethodGet, path: "/api/user/self", allowed: true},
{name: "allows admin list get", method: http.MethodGet, path: "/api/user/?p=0", allowed: true},
{name: "allows head", method: http.MethodHead, path: "/api/user/self", allowed: true},
{name: "allows options", method: http.MethodOptions, path: "/api/user/self", allowed: true},
{name: "blocks post", method: http.MethodPost, path: "/api/user/", allowed: false},
{name: "blocks put", method: http.MethodPut, path: "/api/user/", allowed: false},
{name: "blocks delete", method: http.MethodDelete, path: "/api/user/1", allowed: false},
{name: "blocks status test get", method: http.MethodGet, path: "/api/status/test", allowed: false},
{name: "blocks channel test get", method: http.MethodGet, path: "/api/channel/test/1", allowed: false},
{name: "blocks fetch models get", method: http.MethodGet, path: "/api/channel/fetch_models/1", allowed: false},
{name: "blocks update balance get", method: http.MethodGet, path: "/api/channel/update_balance/1", allowed: false},
}
{name: "allows ordinary get", method: http.MethodGet, path: "/api/user/self", allowed: true},
{name: "allows admin list get", method: http.MethodGet, path: "/api/user/?p=0", allowed: true},
{name: "allows head", method: http.MethodHead, path: "/api/user/self", allowed: true},
{name: "allows options", method: http.MethodOptions, path: "/api/user/self", allowed: true},
{name: "blocks post", method: http.MethodPost, path: "/api/user/", allowed: false},
{name: "blocks put", method: http.MethodPut, path: "/api/user/", allowed: false},
{name: "blocks delete", method: http.MethodDelete, path: "/api/user/1", allowed: false},
{name: "blocks status test get", method: http.MethodGet, path: "/api/status/test", allowed: false},
{name: "blocks token generate get", method: http.MethodGet, path: "/api/user/token", allowed: false},
{name: "blocks aff generate get", method: http.MethodGet, path: "/api/user/aff", allowed: false},
{name: "blocks channel test get", method: http.MethodGet, path: "/api/channel/test/1", allowed: false},
{name: "blocks fetch models get", method: http.MethodGet, path: "/api/channel/fetch_models/1", allowed: false},
{name: "blocks update balance get", method: http.MethodGet, path: "/api/channel/update_balance/1", allowed: false},
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@middleware/readonly_admin_test.go` around lines 20 - 31, The test table in
readonly_admin_test.go is missing assertions for the two GET paths that
isReadOnlyAdminAllowed() now rejects; add two entries to the existing cases
table with method http.MethodGet, paths "/api/user/token" and "/api/user/aff",
and allowed: false so the test explicitly fails on regressions — update the same
test cases slice used by the existing loop that exercises
isReadOnlyAdminAllowed().


for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(tt.method, tt.path, nil)

if got := isReadOnlyAdminAllowed(c); got != tt.allowed {
t.Fatalf("isReadOnlyAdminAllowed() = %v, want %v", got, tt.allowed)
}
})
}
}
2 changes: 1 addition & 1 deletion model/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ func generateDefaultSidebarConfigForRole(userRole int) string {
}

// 管理员区域 - 根据角色决定
if userRole == common.RoleAdminUser {
if userRole == common.RoleAdminUser || userRole == common.RoleReadOnlyAdminUser {
// 管理员可以访问管理员区域,但不能访问系统设置
defaultConfig["admin"] = map[string]interface{}{
"enabled": true,
Expand Down
20 changes: 10 additions & 10 deletions web/classic/src/components/layout/SiderBar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { ChevronLeft } from 'lucide-react';
import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed';
import { useSidebar } from '../../hooks/common/useSidebar';
import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime';
import { isAdmin, isRoot, showError } from '../../helpers';
import { canViewAdmin, isRoot, showError } from '../../helpers';
import SkeletonWrapper from './components/SkeletonWrapper';

import { Nav, Divider, Button } from '@douyinfe/semi-ui';
Expand Down Expand Up @@ -151,37 +151,37 @@ const SiderBar = ({ onNavigate = () => {} }) => {
text: t('渠道管理'),
itemKey: 'channel',
to: '/channel',
className: isAdmin() ? '' : 'tableHiddle',
className: canViewAdmin() ? '' : 'tableHiddle',
},
{
text: t('订阅管理'),
itemKey: 'subscription',
to: '/subscription',
className: isAdmin() ? '' : 'tableHiddle',
className: canViewAdmin() ? '' : 'tableHiddle',
},
{
text: t('模型管理'),
itemKey: 'models',
to: '/console/models',
className: isAdmin() ? '' : 'tableHiddle',
className: canViewAdmin() ? '' : 'tableHiddle',
},
{
text: t('模型部署'),
itemKey: 'deployment',
to: '/deployment',
className: isAdmin() ? '' : 'tableHiddle',
className: canViewAdmin() ? '' : 'tableHiddle',
},
{
text: t('兑换码管理'),
itemKey: 'redemption',
to: '/redemption',
className: isAdmin() ? '' : 'tableHiddle',
className: canViewAdmin() ? '' : 'tableHiddle',
},
{
text: t('用户管理'),
itemKey: 'user',
to: '/user',
className: isAdmin() ? '' : 'tableHiddle',
className: canViewAdmin() ? '' : 'tableHiddle',
},
{
text: t('系统设置'),
Expand All @@ -198,7 +198,7 @@ const SiderBar = ({ onNavigate = () => {} }) => {
});

return filteredItems;
}, [isAdmin(), isRoot(), t, isModuleVisible]);
}, [canViewAdmin(), isRoot(), t, isModuleVisible]);

const chatMenuItems = useMemo(() => {
const items = [
Expand Down Expand Up @@ -402,7 +402,7 @@ const SiderBar = ({ onNavigate = () => {} }) => {
type='sidebar'
className=''
collapsed={collapsed}
showAdmin={isAdmin()}
showAdmin={canViewAdmin()}
>
<Nav
className='sidebar-nav'
Expand Down Expand Up @@ -480,7 +480,7 @@ const SiderBar = ({ onNavigate = () => {} }) => {
)}

{/* 管理员区域 - 只在管理员时显示且配置允许时显示 */}
{isAdmin() && hasSectionVisibleModules('admin') && (
{canViewAdmin() && hasSectionVisibleModules('admin') && (
<>
<Divider className='sidebar-divider' />
<div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
import {
isRoot,
isAdmin,
isReadOnlyAdmin,
renderQuota,
stringToColor,
} from '../../../../helpers';
Expand Down Expand Up @@ -96,6 +97,14 @@ const UserInfoHeader = ({ t, userState }) => {
>
{t('管理员')}
</Tag>
) : isReadOnlyAdmin() ? (
<Tag
size='large'
shape='circle'
style={{ color: 'white' }}
>
{t('只读管理员')}
</Tag>
) : (
<Tag
size='large'
Expand Down
6 changes: 6 additions & 0 deletions web/classic/src/components/table/users/UsersColumnDefs.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ const renderRole = (role, t) => {
{t('普通用户')}
</Tag>
);
case 9:
return (
<Tag color='green' shape='circle'>
{t('只读管理员')}
</Tag>
);
case 10:
return (
<Tag color='yellow' shape='circle'>
Expand Down
14 changes: 14 additions & 0 deletions web/classic/src/components/table/users/modals/AddUserModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const AddUserModal = (props) => {
username: '',
display_name: '',
password: '',
role: 1,
remark: '',
});

Expand Down Expand Up @@ -165,6 +166,19 @@ const AddUserModal = (props) => {
showClear
/>
</Col>
<Col span={24}>
<Form.Select
field='role'
label={t('角色')}
placeholder={t('请选择角色')}
optionList={[
{ label: t('普通用户'), value: 1 },
{ label: t('只读管理员'), value: 9 },
{ label: t('管理员'), value: 10 },
]}
rules={[{ required: true, message: t('请选择角色') }]}
/>
</Col>
<Col span={24}>
<Form.Input
field='remark'
Expand Down
15 changes: 15 additions & 0 deletions web/classic/src/components/table/users/modals/EditUserModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ const EditUserModal = (props) => {
quota: 0,
quota_amount: 0,
group: 'default',
role: 1,
remark: '',
});

Expand Down Expand Up @@ -368,6 +369,20 @@ const EditUserModal = (props) => {
/>
</Col>

<Col span={24}>
<Form.Select
field='role'
label={t('角色')}
placeholder={t('请选择角色')}
optionList={[
{ label: t('普通用户'), value: 1 },
{ label: t('只读管理员'), value: 9 },
{ label: t('管理员'), value: 10 },
]}
rules={[{ required: true, message: t('请选择角色') }]}
/>
</Col>

<Col span={10}>
<Form.InputNumber
field='quota_amount'
Expand Down
9 changes: 5 additions & 4 deletions web/classic/src/components/topup/modals/TopupHistoryModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import {
import { Coins } from 'lucide-react';
import { IconSearch } from '@douyinfe/semi-icons';
import { API, timestamp2string } from '../../../helpers';
import { isAdmin } from '../../../helpers/utils';
import { canViewAdmin, isAdmin } from '../../../helpers/utils';
import { useIsMobile } from '../../../hooks/common/useIsMobile';
const { Text } = Typography;

Expand Down Expand Up @@ -68,7 +68,7 @@ const TopupHistoryModal = ({ visible, onCancel, t }) => {
const loadTopups = async (currentPage, currentPageSize) => {
setLoading(true);
try {
const base = isAdmin() ? '/api/user/topup' : '/api/user/topup/self';
const base = canViewAdmin() ? '/api/user/topup' : '/api/user/topup/self';
const qs =
`p=${currentPage}&page_size=${currentPageSize}` +
(keyword ? `&keyword=${encodeURIComponent(keyword)}` : '');
Expand Down Expand Up @@ -157,11 +157,12 @@ const TopupHistoryModal = ({ visible, onCancel, t }) => {
};

// 检查是否为管理员
const userCanViewAdmin = useMemo(() => canViewAdmin(), []);
const userIsAdmin = useMemo(() => isAdmin(), []);

const columns = useMemo(() => {
const baseColumns = [
...(userIsAdmin
...(userCanViewAdmin
? [
{
title: t('用户ID'),
Expand Down Expand Up @@ -250,7 +251,7 @@ const TopupHistoryModal = ({ visible, onCancel, t }) => {
});

return baseColumns;
}, [t, userIsAdmin]);
}, [t, userCanViewAdmin, userIsAdmin]);

return (
<Modal
Expand Down
Loading