From 9503030366ca8226d44ca038eada7759be4b4565 Mon Sep 17 00:00:00 2001 From: cyf1124906008-ai Date: Fri, 12 Jun 2026 03:14:41 +0800 Subject: [PATCH] add read-only admin role --- common/constants.go | 11 ++--- controller/user.go | 12 +++++- middleware/auth.go | 42 +++++++++++++++++- middleware/readonly_admin_test.go | 43 +++++++++++++++++++ model/user.go | 2 +- .../src/components/layout/SiderBar.jsx | 20 ++++----- .../personal/components/UserInfoHeader.jsx | 9 ++++ .../table/users/UsersColumnDefs.jsx | 6 +++ .../table/users/modals/AddUserModal.jsx | 14 ++++++ .../table/users/modals/EditUserModal.jsx | 15 +++++++ .../topup/modals/TopupHistoryModal.jsx | 9 ++-- web/classic/src/helpers/auth.jsx | 5 ++- web/classic/src/helpers/utils.jsx | 28 +++++++++--- .../src/hooks/dashboard/useDashboardData.js | 4 +- .../src/hooks/mj-logs/useMjLogsData.js | 4 +- .../src/hooks/task-logs/useTaskLogsData.js | 4 +- .../src/hooks/usage-logs/useUsageLogsData.jsx | 4 +- .../components/models/log-stat-cards.tsx | 3 +- .../models/models-filter-dialog.tsx | 3 +- .../overview/overview-dashboard.tsx | 2 +- web/default/src/features/dashboard/index.tsx | 2 +- .../playground/components/message-error.tsx | 3 +- .../components/tabs/notification-tab.tsx | 2 +- .../components/common-logs-filter-bar.tsx | 4 +- .../components/common-logs-stats.tsx | 4 +- .../components/task-logs-filter-bar.tsx | 4 +- .../components/usage-logs-table.tsx | 4 +- .../users/components/users-mutate-drawer.tsx | 32 +++++++++++--- web/default/src/features/users/constants.ts | 11 +++++ web/default/src/features/users/types.ts | 2 +- .../wallet/hooks/use-billing-history.ts | 7 +-- web/default/src/hooks/use-admin.ts | 9 +++- web/default/src/hooks/use-sidebar-view.ts | 4 +- web/default/src/i18n/locales/en.json | 1 + web/default/src/i18n/locales/zh.json | 1 + web/default/src/lib/roles.ts | 12 +++++- .../routes/_authenticated/channels/index.tsx | 4 +- .../routes/_authenticated/models/$section.tsx | 4 +- .../routes/_authenticated/models/index.tsx | 4 +- .../_authenticated/redemption-codes/index.tsx | 4 +- .../_authenticated/subscriptions/index.tsx | 4 +- .../src/routes/_authenticated/users/index.tsx | 6 +-- 42 files changed, 286 insertions(+), 82 deletions(-) create mode 100644 middleware/readonly_admin_test.go diff --git a/common/constants.go b/common/constants.go index c7d5637c8e9a..af2b0dd45955 100644 --- a/common/constants.go +++ b/common/constants.go @@ -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 ( diff --git a/controller/user.go b/controller/user.go index afebc6d49627..33fb0a89e46e 100644 --- a/controller/user.go +++ b/controller/user.go @@ -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{}{ @@ -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, @@ -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": "", diff --git a/middleware/auth.go b/middleware/auth.go index 23d933fbe0c1..7b0e35e9dab1 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -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") @@ -128,7 +161,8 @@ 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), @@ -136,7 +170,7 @@ func authHelper(c *gin.Context, minRole int) { 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), @@ -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) diff --git a/middleware/readonly_admin_test.go b/middleware/readonly_admin_test.go new file mode 100644 index 000000000000..03b97f24feac --- /dev/null +++ b/middleware/readonly_admin_test.go @@ -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}, + } + + 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) + } + }) + } +} diff --git a/model/user.go b/model/user.go index e40ac3d21444..bfbcef5afe03 100644 --- a/model/user.go +++ b/model/user.go @@ -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, diff --git a/web/classic/src/components/layout/SiderBar.jsx b/web/classic/src/components/layout/SiderBar.jsx index a4375cf856cc..e17df6f4aff0 100644 --- a/web/classic/src/components/layout/SiderBar.jsx +++ b/web/classic/src/components/layout/SiderBar.jsx @@ -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'; @@ -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('系统设置'), @@ -198,7 +198,7 @@ const SiderBar = ({ onNavigate = () => {} }) => { }); return filteredItems; - }, [isAdmin(), isRoot(), t, isModuleVisible]); + }, [canViewAdmin(), isRoot(), t, isModuleVisible]); const chatMenuItems = useMemo(() => { const items = [ @@ -402,7 +402,7 @@ const SiderBar = ({ onNavigate = () => {} }) => { type='sidebar' className='' collapsed={collapsed} - showAdmin={isAdmin()} + showAdmin={canViewAdmin()} >